mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-06 06:52:20 +02:00
Merge lab into refactor/email-library-package
This commit is contained in:
+1
-1
@@ -37,7 +37,7 @@ import { UI_VIS_DEFAULT_OFF, resolveVisibility } from './js/ui_visibility.js?v=2
|
||||
import tasksModule from './js/tasks.js?v=20260914taskmodel1';
|
||||
import calendarModule from './js/calendar.js?v=20260914emailsource11';
|
||||
import notesModule from './js/notes.js?v=20260911notesselectioncancel1';
|
||||
import adminModule from './js/admin.js?v=20260921chatgptusage2';
|
||||
import adminModule from './js/admin.js?v=20260929csssplit';
|
||||
import settingsModule from './js/settings.js?v=20260912writingstyle3';
|
||||
// Eagerly bind unified minimize/restore behavior across all tool modals.
|
||||
import './js/modalManager.js';
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
/* 00-tokens - part of the former static/style.css.
|
||||
*
|
||||
* The stylesheet is split by area, but the split is mechanical: rules were
|
||||
* moved verbatim and NOT regrouped. The numeric prefix is the load order, and
|
||||
* that order is what preserves the cascade. A rule sits in the file its
|
||||
* original position fell into, so an area's rules are not all in its file and
|
||||
* a file is not only that area's rules.
|
||||
*
|
||||
* Do not reorder the <link> tags in static/index.html, and do not move a rule
|
||||
* between files: with 1,151 redeclarations and 1,886 !important in this
|
||||
* cascade, either can change which declaration wins.
|
||||
*
|
||||
* static/index.html and the PRECACHE list in static/sw.js must agree on this
|
||||
* order. tests/test_static_stylesheet_manifest.py and
|
||||
* tests/test_css_computed_style_snapshot.py fail if that drifts.
|
||||
*/
|
||||
/* ============================================ */
|
||||
/* Odysseus UI — Consolidated Stylesheet */
|
||||
/* ============================================ */
|
||||
|
||||
|
||||
/* ── Variables ──
|
||||
*
|
||||
* Theme-public variables (override via theme.js or custom themes):
|
||||
* Core: --bg, --fg, --panel, --border, --red
|
||||
* Syntax: --hl-keyword, --hl-string, --hl-comment, --hl-function,
|
||||
* --hl-number, --hl-builtin, --hl-variable, --hl-params,
|
||||
* --hl-bg, --hl-fg
|
||||
* Accents: --accent-primary, --accent-error (set by theme.js)
|
||||
* Semantic: --color-error, --color-success, --color-warning, --color-danger,
|
||||
* --color-accent, --color-muted, --color-muted-alt
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* Core palette */
|
||||
--bg: #282c34;
|
||||
--fg: #9cdef2;
|
||||
--panel: #111;
|
||||
--border: #355a66;
|
||||
--red: #e06c75;
|
||||
/* Were `var(--green)` / `var(--warn)` — self-referential, so they
|
||||
resolved to invalid and every site fell back to its own literal
|
||||
(or, for sites with no fallback, painted as transparent/inherit).
|
||||
Anchor them to real hex so the token layer actually works. */
|
||||
--green: #50fa7b;
|
||||
--warn: #f0ad4e;
|
||||
|
||||
/* Syntax highlighting */
|
||||
--hl-bg: #1e2228;
|
||||
--hl-fg: #9cdef2;
|
||||
--hl-keyword: #c678dd;
|
||||
--hl-string: #e5c07b;
|
||||
--hl-comment: #828997;
|
||||
--hl-function: #61afef;
|
||||
--hl-number: #d19a66;
|
||||
--hl-builtin: #56b6c2;
|
||||
--hl-variable: #abb2bf;
|
||||
--hl-params: #a8c0d4;
|
||||
|
||||
/* Semantic colors */
|
||||
--color-error: #ff4444;
|
||||
--color-error-light: #ff6666;
|
||||
--color-success: #4caf50;
|
||||
--color-warning: #f0ad4e;
|
||||
--color-danger: #c0392b;
|
||||
--color-recording: #ff3b30;
|
||||
--color-recording-hover: #d63031;
|
||||
--color-muted: #888;
|
||||
--color-muted-alt: #6b7280;
|
||||
--color-accent: #00aaff;
|
||||
--color-agent-active: #00ff00;
|
||||
--color-brand-blue: #3b82f6;
|
||||
--color-blind-orange: #ff9800;
|
||||
--color-save-green: var(--color-success);
|
||||
--color-link-hover: #66c7ff;
|
||||
--color-subheader: #6b8a94;
|
||||
--select-bg: var(--bg);
|
||||
--select-fg: var(--fg);
|
||||
--select-option-bg: color-mix(in srgb, var(--panel) 74%, var(--bg));
|
||||
--select-option-fg: var(--fg);
|
||||
--select-option-active-bg: color-mix(in srgb, var(--accent, var(--red)) 24%, var(--panel));
|
||||
/* Warm accent — used by the Goals/Today UI in Notes. Lives as a token so
|
||||
themes can override without touching the goal CSS. */
|
||||
--accent-warm: #d19a66;
|
||||
}
|
||||
|
||||
:root.light {
|
||||
--bg: #f5f5f5;
|
||||
--fg: #2b2b2b;
|
||||
--panel: #fff;
|
||||
--border: #bbb;
|
||||
--hl-bg: #f9f9f9;
|
||||
--hl-fg: #2b2b2b;
|
||||
--hl-keyword: #7928a1;
|
||||
--hl-string: #986801;
|
||||
--hl-comment: #6a737d;
|
||||
--hl-function: #005cc5;
|
||||
--hl-number: #986801;
|
||||
--hl-builtin: #0070a0;
|
||||
--hl-variable: #383a42;
|
||||
--hl-params: #4a4f5c;
|
||||
--select-bg: #eaeaea;
|
||||
--select-fg: var(--fg);
|
||||
--select-option-bg: var(--panel);
|
||||
--select-option-fg: var(--fg);
|
||||
--select-option-active-bg: color-mix(in srgb, var(--red) 16%, var(--panel));
|
||||
}
|
||||
|
||||
/* ── Reset & Base ── */
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { overflow-x: hidden; height: 100%; margin: 0; overscroll-behavior: none; }
|
||||
body {
|
||||
background-color: var(--bg);
|
||||
color: var(--fg);
|
||||
/* Keep the base padding transition for older layout paths that still adjust
|
||||
the body directly. Edge docks reserve workspace room on the flex panes
|
||||
below so left + right docks can coexist without skewing the whole body. */
|
||||
transition: padding-left 160ms cubic-bezier(0.22, 0.61, 0.36, 1),
|
||||
padding-right 160ms cubic-bezier(0.22, 0.61, 0.36, 1);
|
||||
font-family: var(--font-family, 'Fira Code', monospace);
|
||||
display: flex;
|
||||
height: 100%;
|
||||
height: 100dvh; /* dynamic viewport height — adapts when mobile keyboard opens */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Self-hosted Fira Code font */
|
||||
@font-face { font-family: 'Fira Code'; font-weight: 300; font-style: normal; font-display: swap; src: url('/static/fonts/FiraCode-Light.woff2') format('woff2'); }
|
||||
@font-face { font-family: 'Fira Code'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/FiraCode-Regular.woff2') format('woff2'); }
|
||||
@font-face { font-family: 'Fira Code'; font-weight: 600; font-style: normal; font-display: swap; src: url('/static/fonts/FiraCode-SemiBold.woff2') format('woff2'); }
|
||||
|
||||
/* GohuFont is the default UI face across themes; keep the shipped font local
|
||||
so the default is available before custom-font discovery completes. */
|
||||
@font-face { font-family: 'GohuFont'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/custom/GohuFont.ttf') format('truetype'); }
|
||||
|
||||
/* Self-hosted OpenDyslexic — dyslexia-friendly accessibility font option (SIL OFL 1.1) */
|
||||
@font-face { font-family: 'OpenDyslexic'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/OpenDyslexic-Regular.woff2') format('woff2'); }
|
||||
@font-face { font-family: 'OpenDyslexic'; font-weight: 700; font-style: normal; font-display: swap; src: url('/static/fonts/OpenDyslexic-Bold.woff2') format('woff2'); }
|
||||
|
||||
/* Code block baseline */
|
||||
pre, code, .hljs {
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
@supports selector(::-webkit-scrollbar) {
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--panel);
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: var(--red);
|
||||
border-radius: 4px;
|
||||
border: 2px solid var(--panel);
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background-color: color-mix(in srgb, var(--red) 80%, white);
|
||||
}
|
||||
}
|
||||
|
||||
html {
|
||||
scrollbar-color: var(--red) var(--panel);
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
/* Utility */
|
||||
.red-text { color: var(--red); }
|
||||
|
||||
/* ── Density Overrides ── */
|
||||
|
||||
:root.density-compact { font-size: 13px; }
|
||||
:root.density-compact .msg { padding: 6px 10px; margin-bottom: 4px; }
|
||||
:root.density-compact .list-item { padding: 4px 8px; }
|
||||
:root.density-compact .sidebar .section { padding: 0; }
|
||||
:root.density-spacious { font-size: 16px; }
|
||||
:root.density-spacious .msg { padding: 14px 18px; margin-bottom: 12px; }
|
||||
:root.density-spacious .list-item { padding: 8px 12px; }
|
||||
:root.density-spacious .sidebar .section { padding: 0; }
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,839 @@
|
||||
/* 12-gallery - part of the former static/style.css.
|
||||
*
|
||||
* The stylesheet is split by area, but the split is mechanical: rules were
|
||||
* moved verbatim and NOT regrouped. The numeric prefix is the load order, and
|
||||
* that order is what preserves the cascade. A rule sits in the file its
|
||||
* original position fell into, so an area's rules are not all in its file and
|
||||
* a file is not only that area's rules.
|
||||
*
|
||||
* Do not reorder the <link> tags in static/index.html, and do not move a rule
|
||||
* between files: with 1,151 redeclarations and 1,886 !important in this
|
||||
* cascade, either can change which declaration wins.
|
||||
*
|
||||
* static/index.html and the PRECACHE list in static/sw.js must agree on this
|
||||
* order. tests/test_static_stylesheet_manifest.py and
|
||||
* tests/test_css_computed_style_snapshot.py fail if that drifts.
|
||||
*/
|
||||
.research-synapse.rs-phase-reading .rs-phase-chip,
|
||||
.research-synapse.rs-phase-analyzing .rs-phase-chip { color: var(--gold, #d4a72c); }
|
||||
.research-synapse.rs-phase-writing .rs-phase-chip { color: var(--color-success, #4caf50); }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.research-synapse .rs-live-dot,
|
||||
.research-synapse .rs-pulse,
|
||||
.research-synapse .rs-node-new,
|
||||
.research-synapse .rs-node-expanded,
|
||||
.research-synapse .rs-edge-firing { animation: none !important; }
|
||||
}
|
||||
|
||||
/* ── Raw findings items (inside sources-style box) ── */
|
||||
.finding-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.finding-item .source-link {
|
||||
border-radius: 6px 6px 0 0;
|
||||
}
|
||||
.finding-summary {
|
||||
padding: 6px 8px 8px;
|
||||
font-size: 0.82em;
|
||||
line-height: 1.5;
|
||||
color: var(--fg-dim, var(--fg));
|
||||
opacity: 0.85;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--fg) 6%, transparent);
|
||||
margin-bottom: 4px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.finding-item:last-child .finding-summary {
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════
|
||||
Gallery Editor
|
||||
═══════════════════════════════════════════════════════════ */
|
||||
|
||||
/* Tab bar */
|
||||
.gallery-tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 0 16px;
|
||||
background: var(--panel);
|
||||
}
|
||||
.gallery-tab {
|
||||
padding: 8px 18px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--fg);
|
||||
opacity: 0.6;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
transition: opacity 0.15s, border-color 0.15s;
|
||||
}
|
||||
.gallery-tab:hover { opacity: 0.85; }
|
||||
.gallery-tab.active {
|
||||
opacity: 1;
|
||||
border-bottom-color: var(--red);
|
||||
}
|
||||
|
||||
/* Narrow modal tab strips should stay on one row. Resized docked windows can
|
||||
be much narrower than the viewport, so this cannot live only in mobile media
|
||||
queries. */
|
||||
.cookbook-tabs,
|
||||
.memory-tabs,
|
||||
.admin-tabs,
|
||||
.lib-tabs,
|
||||
.gallery-tabs,
|
||||
.preset-tabs {
|
||||
flex-wrap: nowrap !important;
|
||||
overflow-x: auto !important;
|
||||
overflow-y: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overscroll-behavior-x: contain;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.cookbook-tabs::-webkit-scrollbar,
|
||||
.memory-tabs::-webkit-scrollbar,
|
||||
.admin-tabs::-webkit-scrollbar,
|
||||
.lib-tabs::-webkit-scrollbar,
|
||||
.gallery-tabs::-webkit-scrollbar,
|
||||
.preset-tabs::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.cookbook-tabs > *,
|
||||
.memory-tabs > *,
|
||||
.admin-tabs > *,
|
||||
.lib-tabs > *,
|
||||
.gallery-tabs > *,
|
||||
.preset-tabs > * {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.cookbook-tab,
|
||||
.memory-tab,
|
||||
.admin-tab,
|
||||
.lib-tab,
|
||||
.gallery-tab,
|
||||
.preset-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
white-space: nowrap;
|
||||
line-height: 1;
|
||||
}
|
||||
.gallery-tab {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
@container (max-width: 360px) {
|
||||
.cookbook-tab:has(svg),
|
||||
.memory-tab:has(svg),
|
||||
.admin-tab:has(svg),
|
||||
.lib-tab:has(svg),
|
||||
.gallery-tab:has(svg),
|
||||
.preset-tab:has(svg) {
|
||||
width: 34px;
|
||||
min-width: 34px;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
font-size: 0;
|
||||
}
|
||||
|
||||
.cookbook-tab:has(svg) svg,
|
||||
.memory-tab:has(svg) svg,
|
||||
.admin-tab:has(svg) svg,
|
||||
.lib-tab:has(svg) svg,
|
||||
.gallery-tab:has(svg) svg,
|
||||
.preset-tab:has(svg) svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin-right: 0 !important;
|
||||
vertical-align: middle !important;
|
||||
}
|
||||
|
||||
.memory-tab:has(svg) .memory-count,
|
||||
.gallery-tab:has(svg) .gallery-tab-label,
|
||||
.gallery-tab:has(svg) .gallery-tab-close,
|
||||
.cookbook-tab:has(svg) .cookbook-tab-count,
|
||||
.preset-tab:has(svg) .preset-count {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
/* Icon + label layout inside each tab. */
|
||||
.gallery-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.gallery-tab-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.gallery-tab.active .gallery-tab-icon { opacity: 1; }
|
||||
/* Close × on the Edit tab — appears on hover. */
|
||||
.gallery-tab-close {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-left: 2px;
|
||||
border-radius: 4px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.12s, background 0.12s;
|
||||
/* Use a slightly larger glyph at a larger line-height so the × sits
|
||||
visually centered — the U+00D7 character has uneven em-box metrics. */
|
||||
font-size: 16px;
|
||||
line-height: 16px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
padding-bottom: 2px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.gallery-tab:hover .gallery-tab-close,
|
||||
.gallery-tab.active .gallery-tab-close { opacity: 0.65; }
|
||||
/* Hide the close × entirely on the Edit tab when there's no edit open. */
|
||||
.gallery-tab[data-tab="editor"]:not(.has-edit) .gallery-tab-close { display: none !important; }
|
||||
.gallery-tab-close:hover { opacity: 1 !important; background: color-mix(in srgb, var(--red) 25%, transparent); color: var(--red); }
|
||||
/* Inline rename input shown when the Edit tab is double-clicked. */
|
||||
.gallery-tab-rename-input {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--red);
|
||||
border-radius: 3px;
|
||||
color: var(--fg);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
padding: 1px 4px;
|
||||
width: 140px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Albums tab — grid of album cards with cover thumbnails. */
|
||||
.gallery-albums-container {
|
||||
padding: 12px 4px;
|
||||
max-height: calc(92vh - 120px);
|
||||
overflow-y: auto;
|
||||
border: 2px dashed transparent;
|
||||
border-radius: 8px;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.gallery-settings-container {
|
||||
padding: 8px 4px;
|
||||
max-height: calc(92vh - 120px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
.gallery-albums-container.gallery-dragover {
|
||||
border-color: var(--red);
|
||||
background: color-mix(in srgb, var(--red) 5%, transparent);
|
||||
}
|
||||
.gallery-albums-empty {
|
||||
display: flex; flex-direction: column; align-items: center; gap: 12px;
|
||||
padding: 48px 16px;
|
||||
opacity: 0.7;
|
||||
font-size: 13px;
|
||||
}
|
||||
.gallery-albums-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.gallery-album-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: transform 0.12s, border-color 0.12s, box-shadow 0.12s;
|
||||
}
|
||||
.gallery-album-card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--red);
|
||||
box-shadow: 0 4px 14px color-mix(in srgb, var(--fg) 12%, transparent);
|
||||
}
|
||||
.gallery-album-menu-btn {
|
||||
position: absolute;
|
||||
top: 6px; right: 6px;
|
||||
width: 24px; height: 24px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: color-mix(in srgb, var(--bg) 70%, transparent);
|
||||
color: var(--fg);
|
||||
border: 1px solid color-mix(in srgb, var(--border) 60%, transparent);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.12s, background 0.12s;
|
||||
z-index: 2;
|
||||
backdrop-filter: blur(4px);
|
||||
/* Plain "…" text — the previous SVG was rendering as a flat black
|
||||
block on some themes when its currentColor didn't contrast. */
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
font-weight: 600;
|
||||
padding: 0 0 4px;
|
||||
}
|
||||
.gallery-album-card:hover .gallery-album-menu-btn,
|
||||
.gallery-album-menu-btn:focus-visible { opacity: 1; }
|
||||
.gallery-album-menu-btn:hover { background: var(--bg); }
|
||||
.gallery-album-menu-pop {
|
||||
position: absolute;
|
||||
top: 34px; right: 6px;
|
||||
min-width: 140px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 14px color-mix(in srgb, var(--fg) 18%, transparent);
|
||||
display: flex; flex-direction: column;
|
||||
padding: 4px;
|
||||
z-index: 3;
|
||||
}
|
||||
.gallery-album-menu-pop[hidden] { display: none; }
|
||||
.gallery-album-cover {
|
||||
aspect-ratio: 4 / 3;
|
||||
background: color-mix(in srgb, var(--fg) 5%, var(--bg));
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
.gallery-album-placeholder {
|
||||
opacity: 0.4;
|
||||
font-size: 40px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 100%; height: 100%;
|
||||
}
|
||||
.gallery-album-info {
|
||||
padding: 8px 10px;
|
||||
display: flex; flex-direction: column; gap: 2px;
|
||||
}
|
||||
.gallery-album-name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.gallery-album-count {
|
||||
font-size: 11px;
|
||||
opacity: 0.55;
|
||||
}
|
||||
.gallery-album-card-add {
|
||||
border-style: dashed;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.gallery-album-card-add:hover { opacity: 1; }
|
||||
.gallery-album-action-tile {
|
||||
background: color-mix(in srgb, var(--fg) 3%, var(--bg));
|
||||
}
|
||||
|
||||
/* Edit-tab empty state — shown before any image is loaded. */
|
||||
.gallery-editor-landing {
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 28px 16px;
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
color: var(--fg);
|
||||
}
|
||||
.gallery-editor-landing h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.gallery-editor-heading {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
.gallery-editor-heading > svg { opacity: 0.6; flex: 0 0 auto; }
|
||||
/* "vision model" link in the AI-tagging description → opens Settings. */
|
||||
.ge-vision-link {
|
||||
color: var(--accent, var(--red));
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ge-vision-link:hover { opacity: 0.8; }
|
||||
.ge-alpha-tag {
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
vertical-align: middle;
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
color: var(--accent, var(--red));
|
||||
background: color-mix(in srgb, var(--accent, var(--red)) 15%, transparent);
|
||||
position: relative;
|
||||
top: -1px;
|
||||
}
|
||||
.gallery-editor-landing-actions {
|
||||
display: flex; gap: 10px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
/* Bigger primary action buttons specifically inside the editor landing —
|
||||
the gallery-wide .gallery-select-btn is a compact toolbar style which is
|
||||
too small for the empty-state hero. */
|
||||
.gallery-editor-landing-actions .gallery-select-btn {
|
||||
padding: 7px 22px 17px;
|
||||
font-size: 13px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
/* Visible template picker: every canvas ratio is inspectable without opening
|
||||
a native menu, and its SVG outline previews the resulting canvas shape. */
|
||||
.gallery-editor-template-picker {
|
||||
width: min(760px, 96%);
|
||||
margin-top: 10px;
|
||||
}
|
||||
.gallery-editor-template-picker h4 {
|
||||
margin: 0 0 7px;
|
||||
font-size: 11px;
|
||||
opacity: 0.55;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
.gallery-editor-template-options {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(105px, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
.gallery-editor-template-option {
|
||||
height: 72px;
|
||||
min-width: 0;
|
||||
padding: 6px 7px;
|
||||
display: grid;
|
||||
grid-template-rows: 28px auto auto;
|
||||
justify-items: center;
|
||||
align-items: center;
|
||||
background: var(--panel);
|
||||
color: var(--fg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
}
|
||||
.gallery-editor-template-option svg { color: var(--accent, var(--red)); }
|
||||
.gallery-editor-template-option:hover,
|
||||
.gallery-editor-template-option:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--accent, var(--red));
|
||||
color: var(--accent, var(--red));
|
||||
background: color-mix(in srgb, var(--accent, var(--red)) 7%, var(--panel));
|
||||
}
|
||||
.gallery-editor-template-option span {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 600;
|
||||
}
|
||||
.gallery-editor-template-option small {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 9px;
|
||||
opacity: 0.52;
|
||||
}
|
||||
@media (max-width: 620px) {
|
||||
.gallery-editor-landing {
|
||||
justify-content: flex-start;
|
||||
gap: 7px;
|
||||
padding: 18px 12px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.gallery-editor-landing-actions {
|
||||
width: 100%;
|
||||
gap: 6px;
|
||||
justify-content: center;
|
||||
}
|
||||
.gallery-editor-landing-actions .gallery-select-btn {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
max-width: 180px;
|
||||
padding: 6px 8px 10px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.gallery-editor-template-picker {
|
||||
width: 100%;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.gallery-editor-template-options {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
/* The common square, widescreen, portrait, and Instagram presets are
|
||||
enough to start on a phone. Keep the print and 4K presets on desktop. */
|
||||
.gallery-editor-template-option:nth-child(n + 5) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
/* Saved-drafts grid on the editor landing. Surfaces every persisted
|
||||
in-progress project so the user can resume without re-opening the
|
||||
original photo. Each card: thumbnail + name + last-saved hint + ×. */
|
||||
.gallery-editor-drafts {
|
||||
width: min(720px, 92%);
|
||||
margin: 28px auto 0; /* centered */
|
||||
padding-top: 18px;
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.gallery-editor-drafts {
|
||||
position: relative;
|
||||
}
|
||||
.gallery-editor-drafts-loading {
|
||||
position: absolute;
|
||||
inset: 30px 0 0 0;
|
||||
z-index: 4;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--panel);
|
||||
border-radius: 6px;
|
||||
min-height: 80px;
|
||||
}
|
||||
.gallery-editor-drafts-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center; /* center the title / search / select */
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.gallery-editor-drafts-title {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
opacity: 0.55;
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.gallery-editor-drafts-search {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
height: 26px; /* same thickness as the Select button */
|
||||
box-sizing: border-box;
|
||||
padding: 0 8px;
|
||||
font-size: 12px;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.gallery-editor-drafts-search:focus { outline: none; border-color: var(--red); }
|
||||
/* The shared .gallery-select-btn has a -4px top margin + tall asymmetric padding
|
||||
meant for the main gallery toolbar; in this header it makes the button a
|
||||
different height / offset from the search box. Normalize so it lines up. */
|
||||
.gallery-editor-drafts-header .gallery-select-btn {
|
||||
margin-top: 0 !important;
|
||||
height: 26px; /* match the search bar */
|
||||
box-sizing: border-box;
|
||||
padding: 0 10px;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cookbook-official-filter {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
color: var(--fg);
|
||||
font-size: 10px;
|
||||
opacity: 0.72;
|
||||
white-space: nowrap;
|
||||
position: relative;
|
||||
top: 1px;
|
||||
}
|
||||
.cookbook-official-filter > span:first-child {
|
||||
position: relative;
|
||||
top: 1px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 9px;
|
||||
}
|
||||
.cookbook-official-filter .toggle {
|
||||
width: 26px;
|
||||
height: 14px;
|
||||
}
|
||||
.cookbook-official-filter .toggle .slider:before {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
left: 2px;
|
||||
bottom: 2px;
|
||||
}
|
||||
.cookbook-official-filter .toggle input:checked + .slider:before {
|
||||
transform: translateX(12px);
|
||||
}
|
||||
#gallery-editor-drafts-select { margin-left: auto; flex-shrink: 0; }
|
||||
#gallery-editor-drafts-select.active {
|
||||
background: color-mix(in srgb, var(--accent, var(--red)) 18%, transparent);
|
||||
border-color: var(--accent, var(--red));
|
||||
color: var(--accent, var(--red));
|
||||
}
|
||||
/* Graceful exit when a project card is deleted — fade + shrink before re-render. */
|
||||
.gallery-editor-draft-card.gallery-draft-removing {
|
||||
opacity: 0;
|
||||
transform: scale(0.92);
|
||||
transition: opacity 0.24s ease, transform 0.24s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
/* Draft select dot uses the standard small .gallery-select-dot look (same as the
|
||||
photo grid) — no oversized white-ring checkbox. */
|
||||
.gallery-editor-drafts-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.gallery-drafts-just-opened > .gallery-editor-draft-card {
|
||||
animation: section-domino-in 0.36s cubic-bezier(0.22, 1.61, 0.36, 1) backwards;
|
||||
}
|
||||
.gallery-drafts-just-opened > :nth-child(1) { animation-delay: 0.02s; }
|
||||
.gallery-drafts-just-opened > :nth-child(2) { animation-delay: 0.04s; }
|
||||
.gallery-drafts-just-opened > :nth-child(3) { animation-delay: 0.06s; }
|
||||
.gallery-drafts-just-opened > :nth-child(4) { animation-delay: 0.08s; }
|
||||
.gallery-drafts-just-opened > :nth-child(5) { animation-delay: 0.10s; }
|
||||
.gallery-drafts-just-opened > :nth-child(6) { animation-delay: 0.12s; }
|
||||
.gallery-drafts-just-opened > :nth-child(7) { animation-delay: 0.14s; }
|
||||
.gallery-drafts-just-opened > :nth-child(8) { animation-delay: 0.16s; }
|
||||
.gallery-drafts-just-opened > :nth-child(9) { animation-delay: 0.18s; }
|
||||
.gallery-drafts-just-opened > :nth-child(10) { animation-delay: 0.20s; }
|
||||
.gallery-drafts-just-opened > :nth-child(11) { animation-delay: 0.22s; }
|
||||
.gallery-drafts-just-opened > :nth-child(12) { animation-delay: 0.24s; }
|
||||
.gallery-drafts-just-opened > :nth-child(13) { animation-delay: 0.26s; }
|
||||
.gallery-drafts-just-opened > :nth-child(14) { animation-delay: 0.28s; }
|
||||
.gallery-drafts-just-opened > :nth-child(15) { animation-delay: 0.30s; }
|
||||
.gallery-drafts-just-opened > :nth-child(16) { animation-delay: 0.32s; }
|
||||
.gallery-drafts-just-opened > :nth-child(17) { animation-delay: 0.34s; }
|
||||
.gallery-drafts-just-opened > :nth-child(18) { animation-delay: 0.36s; }
|
||||
.gallery-drafts-just-opened > :nth-child(19) { animation-delay: 0.38s; }
|
||||
.gallery-drafts-just-opened > :nth-child(20) { animation-delay: 0.40s; }
|
||||
.gallery-drafts-just-opened > :nth-child(n+21) { animation-delay: 0.42s; }
|
||||
.gallery-drafts-view-toggle {
|
||||
display: inline-flex;
|
||||
position: relative;
|
||||
top: 0;
|
||||
height: 24px;
|
||||
flex: 0 0 auto;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
background: var(--bg);
|
||||
}
|
||||
.gallery-drafts-view-toggle button {
|
||||
width: 25px;
|
||||
height: 22px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 0;
|
||||
border-right: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: color-mix(in srgb, var(--fg) 55%, transparent);
|
||||
cursor: pointer;
|
||||
line-height: 0;
|
||||
}
|
||||
.gallery-drafts-view-toggle button:last-child { border-right: 0; }
|
||||
.gallery-drafts-view-toggle button:hover { color: var(--fg); }
|
||||
.gallery-drafts-view-toggle button.active {
|
||||
height: 22px;
|
||||
color: var(--accent, var(--red));
|
||||
background: color-mix(in srgb, var(--accent, var(--red)) 12%, transparent);
|
||||
}
|
||||
.gallery-editor-drafts-grid.list-view .gallery-editor-draft-thumb {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
aspect-ratio: auto;
|
||||
}
|
||||
.gallery-editor-drafts-grid.list-view .gallery-editor-draft-delete {
|
||||
position: static;
|
||||
opacity: 0.55;
|
||||
background: transparent;
|
||||
color: var(--fg);
|
||||
}
|
||||
.gallery-editor-draft-delete-trash { display: none; }
|
||||
.gallery-editor-drafts-grid.list-view .gallery-editor-draft-delete-trash {
|
||||
display: block;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
position: relative;
|
||||
top: -2px;
|
||||
}
|
||||
.gallery-editor-drafts-grid.list-view .gallery-editor-draft-delete:hover {
|
||||
opacity: 1;
|
||||
color: var(--color-error, #f44);
|
||||
background: color-mix(in srgb, var(--color-error, #f44) 10%, transparent);
|
||||
}
|
||||
.gallery-editor-draft-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 6px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--panel) 70%, transparent);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, background 0.15s, transform 0.15s;
|
||||
text-align: left;
|
||||
}
|
||||
.gallery-editor-draft-card:hover {
|
||||
border-color: var(--accent, var(--red));
|
||||
background: color-mix(in srgb, var(--panel) 90%, transparent);
|
||||
}
|
||||
.gallery-editor-draft-thumb {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
background: var(--bg);
|
||||
display: block;
|
||||
}
|
||||
.gallery-editor-draft-thumb-empty {
|
||||
background: repeating-linear-gradient(
|
||||
45deg,
|
||||
color-mix(in srgb, var(--fg) 4%, transparent),
|
||||
color-mix(in srgb, var(--fg) 4%, transparent) 6px,
|
||||
transparent 6px,
|
||||
transparent 12px
|
||||
);
|
||||
}
|
||||
.gallery-editor-draft-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
.gallery-editor-draft-name {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.gallery-editor-draft-meta {
|
||||
font-size: 10px;
|
||||
opacity: 0.55;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.gallery-editor-draft-delete {
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
right: 8px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.12s, background 0.12s;
|
||||
}
|
||||
.gallery-editor-draft-delete {
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
.gallery-editor-draft-delete:hover { background: var(--red); }
|
||||
/* On very narrow phones, give project search its own row instead of
|
||||
compressing the title and view controls into an overflowing header. */
|
||||
@media (max-width: 360px) {
|
||||
.gallery-editor-drafts-header {
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-start;
|
||||
gap: 6px;
|
||||
}
|
||||
.gallery-editor-drafts-title {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
.gallery-editor-drafts-search {
|
||||
order: 10;
|
||||
flex: 1 1 100%;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
}
|
||||
#gallery-editor-drafts-select {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
/* Editor layout */
|
||||
.gallery-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
/* Prevent the editor's own scroll/touch from chaining out to the
|
||||
gallery modal or the page body — critical on mobile so touching
|
||||
the canvas doesn't slide the whole page. */
|
||||
overscroll-behavior: contain;
|
||||
touch-action: pan-y;
|
||||
}
|
||||
/* Pin the topbar to the top of the editor so its action buttons remain
|
||||
visible if the gallery modal body itself ends up scrolling. */
|
||||
.ge-topbar { position: sticky; top: 0; z-index: 5; }
|
||||
|
||||
/* Top bar */
|
||||
.ge-topbar {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: thin;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 10px;
|
||||
background: var(--panel);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
gap: 8px;
|
||||
}
|
||||
.ge-topbar-left, .ge-topbar-right {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.ge-topbar-right > * {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ge-topbar .dropdown[popover] {
|
||||
position: fixed;
|
||||
margin: 0;
|
||||
bottom: auto;
|
||||
max-height: calc(100vh - 80px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
.ge-draft-status {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
max-width: 92px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: color-mix(in srgb, var(--fg) 52%, transparent);
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
transition: color 0.15s, opacity 0.15s;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,22 +1,20 @@
|
||||
/* Cookbook (including What Fits?), deep research, memory, settings and admin.
|
||||
*
|
||||
* Split out of style.css for maintainability, not for performance: this file
|
||||
* is loaded eagerly from index.html immediately after style.css, so the rules
|
||||
* land in the cascade in exactly the order they had inside the single file.
|
||||
* Nothing here is lazy-loaded.
|
||||
* This is an eagerly loaded tail segment of the app stylesheet cascade. The
|
||||
* former static/style.css now lives in the numbered 00-17 fragments; these
|
||||
* three pre-existing panel files follow those fragments without changing
|
||||
* their relative order. Nothing here is lazy-loaded.
|
||||
*
|
||||
* LOAD ORDER IS PART OF THE CONTRACT. The <link> tags in static/index.html and
|
||||
* the PRECACHE list in static/sw.js must both list the stylesheets in this
|
||||
* order:
|
||||
* 1. static/style.css
|
||||
* LOAD ORDER IS PART OF THE CONTRACT. static/index.html and the PRECACHE list
|
||||
* in static/sw.js must agree:
|
||||
* 1. static/css/00-tokens.css through static/css/17-research.css,
|
||||
* in numeric order
|
||||
* 2. static/css/documents-gallery-editor.css
|
||||
* 3. static/css/email-calendar-notes-tasks.css
|
||||
* 4. static/css/cookbook-research-memory-settings.css
|
||||
*
|
||||
* Rules were moved verbatim. A rule stays in style.css when its selector group
|
||||
* covers more than one panel, when the class is shared design language used by
|
||||
* modules outside this panel, or when moving it would change which of two
|
||||
* equally specific rules wins.
|
||||
* Rules in this file retain their existing cascade position. Shared rules
|
||||
* formerly kept in static/style.css now live in the numbered fragments.
|
||||
*/
|
||||
|
||||
/* Cookbook already shows its own running/served-status dot
|
||||
@@ -28,10 +26,6 @@
|
||||
0%, 100% { transform: scale(1); opacity: 1; }
|
||||
50% { transform: scale(1.5); opacity: 0.7; }
|
||||
}
|
||||
@keyframes research-pulse {
|
||||
0%, 100% { background: color-mix(in srgb, var(--red) 12%, transparent); }
|
||||
50% { background: color-mix(in srgb, var(--red) 22%, transparent); }
|
||||
}
|
||||
#cookbook-gguf-delete-overlay {
|
||||
background:rgba(0,0,0,0.5);
|
||||
backdrop-filter:blur(4px);
|
||||
|
||||
@@ -1,22 +1,20 @@
|
||||
/* Document library and editor, image gallery, image editor.
|
||||
*
|
||||
* Split out of style.css for maintainability, not for performance: this file
|
||||
* is loaded eagerly from index.html immediately after style.css, so the rules
|
||||
* land in the cascade in exactly the order they had inside the single file.
|
||||
* Nothing here is lazy-loaded.
|
||||
* This is an eagerly loaded tail segment of the app stylesheet cascade. The
|
||||
* former static/style.css now lives in the numbered 00-17 fragments; these
|
||||
* three pre-existing panel files follow those fragments without changing
|
||||
* their relative order. Nothing here is lazy-loaded.
|
||||
*
|
||||
* LOAD ORDER IS PART OF THE CONTRACT. The <link> tags in static/index.html and
|
||||
* the PRECACHE list in static/sw.js must both list the stylesheets in this
|
||||
* order:
|
||||
* 1. static/style.css
|
||||
* LOAD ORDER IS PART OF THE CONTRACT. static/index.html and the PRECACHE list
|
||||
* in static/sw.js must agree:
|
||||
* 1. static/css/00-tokens.css through static/css/17-research.css,
|
||||
* in numeric order
|
||||
* 2. static/css/documents-gallery-editor.css
|
||||
* 3. static/css/email-calendar-notes-tasks.css
|
||||
* 4. static/css/cookbook-research-memory-settings.css
|
||||
*
|
||||
* Rules were moved verbatim. A rule stays in style.css when its selector group
|
||||
* covers more than one panel, when the class is shared design language used by
|
||||
* modules outside this panel, or when moving it would change which of two
|
||||
* equally specific rules wins.
|
||||
* Rules in this file retain their existing cascade position. Shared rules
|
||||
* formerly kept in static/style.css now live in the numbered fragments.
|
||||
*/
|
||||
|
||||
/* Document library search-term highlight — clear, high-contrast. */
|
||||
|
||||
@@ -1,22 +1,20 @@
|
||||
/* Email, calendar, notes and tasks panels.
|
||||
*
|
||||
* Split out of style.css for maintainability, not for performance: this file
|
||||
* is loaded eagerly from index.html immediately after style.css, so the rules
|
||||
* land in the cascade in exactly the order they had inside the single file.
|
||||
* Nothing here is lazy-loaded.
|
||||
* This is an eagerly loaded tail segment of the app stylesheet cascade. The
|
||||
* former static/style.css now lives in the numbered 00-17 fragments; these
|
||||
* three pre-existing panel files follow those fragments without changing
|
||||
* their relative order. Nothing here is lazy-loaded.
|
||||
*
|
||||
* LOAD ORDER IS PART OF THE CONTRACT. The <link> tags in static/index.html and
|
||||
* the PRECACHE list in static/sw.js must both list the stylesheets in this
|
||||
* order:
|
||||
* 1. static/style.css
|
||||
* LOAD ORDER IS PART OF THE CONTRACT. static/index.html and the PRECACHE list
|
||||
* in static/sw.js must agree:
|
||||
* 1. static/css/00-tokens.css through static/css/17-research.css,
|
||||
* in numeric order
|
||||
* 2. static/css/documents-gallery-editor.css
|
||||
* 3. static/css/email-calendar-notes-tasks.css
|
||||
* 4. static/css/cookbook-research-memory-settings.css
|
||||
*
|
||||
* Rules were moved verbatim. A rule stays in style.css when its selector group
|
||||
* covers more than one panel, when the class is shared design language used by
|
||||
* modules outside this panel, or when moving it would change which of two
|
||||
* equally specific rules wins.
|
||||
* Rules in this file retain their existing cascade position. Shared rules
|
||||
* formerly kept in static/style.css now live in the numbered fragments.
|
||||
*/
|
||||
|
||||
@keyframes email-notif-breathe {
|
||||
|
||||
+23
-6
@@ -246,15 +246,32 @@
|
||||
real request is discarded and the font fetched a second time. -->
|
||||
<link rel="preload" as="font" type="font/woff2" crossorigin href="/static/fonts/FiraCode-Regular.woff2">
|
||||
<link rel="preload" as="font" type="font/woff2" crossorigin href="/static/fonts/FiraCode-SemiBold.woff2">
|
||||
<link rel="stylesheet" href="/static/style.css?v=20260921chatgptusage2">
|
||||
<link rel="stylesheet" href="/static/css/00-tokens.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/01-agent-chat.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/02-compare.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/03-agent-chat.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/04-memory.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/05-documents.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/06-admin-settings.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/07-documents.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/08-skills.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/09-gallery.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/10-cookbook.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/11-tasks.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/12-gallery.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/13-image-editor.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/14-email.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/15-notes.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/16-calendar.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/17-research.css?v=20260929csssplit">
|
||||
<!-- Panel stylesheets split out of style.css for maintainability. They are
|
||||
eager and their order is load-bearing: the cascade is the concatenation of
|
||||
these files in this order, so they must stay in this order here and in the
|
||||
PRECACHE list in sw.js. None of them is lazy-loaded. -->
|
||||
<link rel="stylesheet" href="/static/css/documents-gallery-editor.css?v=20260923csssplit1">
|
||||
<link rel="stylesheet" href="/static/css/email-calendar-notes-tasks.css?v=20260923csssplit2">
|
||||
<link rel="stylesheet" href="/static/css/cookbook-research-memory-settings.css?v=20260923csssplit3">
|
||||
<link rel="modulepreload" href="/static/app.js?v=20260921chatgptusage2">
|
||||
<link rel="stylesheet" href="/static/css/documents-gallery-editor.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/email-calendar-notes-tasks.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/cookbook-research-memory-settings.css?v=20260929csssplit">
|
||||
<link rel="modulepreload" href="/static/app.js?v=20260929csssplit">
|
||||
<link rel="modulepreload" href="/static/js/chat.js?v=20260917toolttft1">
|
||||
<link rel="modulepreload" href="/static/js/ui.js?v=20260916largetoolscroll1">
|
||||
<link rel="modulepreload" href="/static/js/sessions.js">
|
||||
@@ -2639,7 +2656,7 @@
|
||||
<script type="module" src="/static/js/censor.js"></script>
|
||||
<script type="module" src="/static/js/settings.js?v=20260912writingstyle3"></script>
|
||||
<script type="module" src="/static/js/assistant.js?v=20260912firefoxjscleanup1"></script>
|
||||
<script type="module" src="/static/app.js?v=20260921chatgptusage2"></script> <!-- app.js must be LAST -->
|
||||
<script type="module" src="/static/app.js?v=20260929csssplit"></script> <!-- app.js must be LAST -->
|
||||
<script type="module" src="/static/js/init.js?v=20260829chatstyle12"></script>
|
||||
<script type="module" src="/static/js/a11y.js"></script>
|
||||
<script nonce="{{CSP_NONCE}}">if('serviceWorker' in navigator){navigator.serviceWorker.register('/static/sw.js?v=20260916autoscroll1').catch(()=>{});}</script>
|
||||
|
||||
@@ -3142,6 +3142,7 @@ function initMcpForm() {
|
||||
if (transport === 'stdio' && !command) { msg.textContent = 'Command is required for stdio'; msg.className = 'admin-error'; return; }
|
||||
if (transport === 'sse' && !url) { msg.textContent = 'URL is required for SSE'; msg.className = 'admin-error'; return; }
|
||||
try { JSON.parse(env); } catch { msg.textContent = 'Env must be valid JSON'; msg.className = 'admin-error'; return; }
|
||||
try { JSON.parse(args); } catch { msg.textContent = 'Args must be valid JSON, e.g. ["-y", "pkg"]'; msg.className = 'admin-error'; return; }
|
||||
const fd = new FormData();
|
||||
fd.append('name', name); fd.append('transport', transport); fd.append('command', command); fd.append('args', args); fd.append('env', env); fd.append('url', url);
|
||||
// If preset has oauthFile config, send credentials for file generation
|
||||
@@ -3162,6 +3163,10 @@ function initMcpForm() {
|
||||
try {
|
||||
const res = await fetch('/api/mcp/servers', { method: 'POST', body: fd, credentials: 'same-origin' });
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
msg.textContent = data.detail || `Failed (${res.status})`; msg.className = 'admin-error';
|
||||
return;
|
||||
}
|
||||
if (data.needs_oauth) {
|
||||
msg.innerHTML = `Added ${esc(name)} — <a href="/api/mcp/oauth/authorize/${data.id}" target="_blank" style="color:var(--red);font-weight:600;">Authorize with Google</a> to connect`;
|
||||
msg.className = 'admin-success';
|
||||
|
||||
+40
-577
@@ -4,24 +4,23 @@
|
||||
import uiModule from './ui.js?v=20260916largetoolscroll1';
|
||||
import searchModule from './search.js';
|
||||
import { byId } from './settings/dom.js';
|
||||
import {
|
||||
getSettingsRegistryIssues,
|
||||
isAdminManagedSettingsTab,
|
||||
} from './settings/registry.js';
|
||||
import { postSettings as _postSettings } from './settings/api.js';
|
||||
import { initTtsSettings, initSttSettings } from './settings/speech.js';
|
||||
import { initDocumentWritingStyle } from './settings/writingStyle.js';
|
||||
import { initImageSettings } from './settings/imageModels.js';
|
||||
import { initAgentSettings } from './settings/agent.js';
|
||||
import { getSettingsRegistryIssues } from './settings/registry.js';
|
||||
import { bindSettingsSearch } from './settings/search.js';
|
||||
import { bindSettingsSidebar } from './settings/sidebar.js';
|
||||
import {
|
||||
activateSettingsPanel,
|
||||
getActiveSettingsTab,
|
||||
bindSettingsNavigation,
|
||||
} from './settings/navigation.js';
|
||||
import { bindSettingsNavigation } from './settings/navigation.js';
|
||||
import {
|
||||
bindSettingsDrag,
|
||||
bindSettingsClose,
|
||||
bindOpenPromptModalLink,
|
||||
showSettingsModal,
|
||||
hideSettingsModal,
|
||||
} from './settings/lifecycle.js';
|
||||
import { bindSettingsPeekToggle } from './settings/peek.js';
|
||||
import { createSettingsShell } from './settings/shell.js';
|
||||
import { handleSettingsOauthReturn } from './settings/oauthReturn.js';
|
||||
import { sortModelIds } from './modelSort.js';
|
||||
import { providerLogo } from './providers.js';
|
||||
import { isAltGrEvent } from './platform.js';
|
||||
@@ -45,18 +44,6 @@ let _authPolicy = { password_min_length: 8 };
|
||||
* Reads in this file deliberately stay direct fetches: this panel is the writer
|
||||
* and edits what it reads, so it must see the authoritative state, not a cache.
|
||||
*/
|
||||
async function _postSettings(body) {
|
||||
try {
|
||||
return await fetch('/api/auth/settings', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
} finally {
|
||||
invalidateSettings();
|
||||
}
|
||||
}
|
||||
|
||||
const el = byId;
|
||||
function esc(s) { return uiModule.esc(s); }
|
||||
@@ -65,79 +52,18 @@ function safeRasterDataUrl(raw) {
|
||||
return /^data:image\/(?:png|jpe?g|gif|webp);base64,[a-z0-9+/=\s]+$/i.test(value) ? value : '';
|
||||
}
|
||||
|
||||
/* ── Settings shell coordination ── */
|
||||
function onSettingsPanelActivated(tab) {
|
||||
// Appearance keeps its existing transparent preview behavior.
|
||||
document.body.classList.toggle('settings-appearance-open', tab === 'appearance');
|
||||
syncAppearanceOpacity(tab === 'appearance');
|
||||
|
||||
// AI endpoints are intentionally refreshed only when entering the AI panel.
|
||||
if (tab === 'ai') refreshAiModelEndpoints();
|
||||
}
|
||||
|
||||
function openAdminSettingsTab(tab) {
|
||||
if (window.adminModule && typeof window.adminModule.open === 'function') {
|
||||
window.adminModule.open(tab);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/* ── Appearance-tab opacity slider ──
|
||||
Mirrors the Theme customizer's slider: fades the settings modal's
|
||||
background (and inner cards) via color-mix so the user can watch the
|
||||
rest of the UI react to toggles, while keeping text/controls crisp
|
||||
(no element opacity). Only shown/active on the Appearance tab. */
|
||||
const _SETTINGS_PEEK = 55; // % opacity when the Peek toggle is on
|
||||
function _applySettingsOpacity(on) {
|
||||
const content = modalEl && modalEl.querySelector('.settings-modal-content, .modal-content');
|
||||
if (!content) return;
|
||||
const cards = content.querySelectorAll('.admin-card');
|
||||
if (on) {
|
||||
const bgMix = `color-mix(in srgb, var(--bg) ${_SETTINGS_PEEK}%, transparent)`;
|
||||
const panelMix = `color-mix(in srgb, var(--panel) ${_SETTINGS_PEEK}%, transparent)`;
|
||||
content.style.setProperty('background', bgMix, 'important');
|
||||
content.style.setProperty('backdrop-filter', 'none', 'important');
|
||||
content.style.setProperty('-webkit-backdrop-filter', 'none', 'important');
|
||||
cards.forEach(c => {
|
||||
c.style.setProperty('background', panelMix, 'important');
|
||||
c.style.setProperty('backdrop-filter', 'none', 'important');
|
||||
c.style.setProperty('-webkit-backdrop-filter', 'none', 'important');
|
||||
});
|
||||
} else {
|
||||
content.style.removeProperty('background');
|
||||
content.style.removeProperty('backdrop-filter');
|
||||
content.style.removeProperty('-webkit-backdrop-filter');
|
||||
cards.forEach(c => {
|
||||
c.style.removeProperty('background');
|
||||
c.style.removeProperty('backdrop-filter');
|
||||
c.style.removeProperty('-webkit-backdrop-filter');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Show/hide the Peek toggle for the Appearance tab and apply or clear the fade.
|
||||
function syncAppearanceOpacity(active) {
|
||||
const toggle = el('settings-opacity-wrap');
|
||||
if (toggle) toggle.classList.toggle('hidden', !active);
|
||||
if (active) {
|
||||
_applySettingsOpacity(toggle ? toggle.classList.contains('active') : false);
|
||||
} else {
|
||||
_applySettingsOpacity(false); // clear the fade off the Appearance tab
|
||||
}
|
||||
}
|
||||
|
||||
function initOpacityToggle() {
|
||||
const toggle = el('settings-opacity-wrap');
|
||||
if (!toggle || toggle.dataset.bound === '1') return;
|
||||
toggle.dataset.bound = '1';
|
||||
toggle.addEventListener('click', () => {
|
||||
const on = !toggle.classList.contains('active');
|
||||
toggle.classList.toggle('active', on);
|
||||
toggle.setAttribute('aria-pressed', on ? 'true' : 'false');
|
||||
_applySettingsOpacity(on);
|
||||
});
|
||||
}
|
||||
/* ── Settings shell coordination ──
|
||||
Panel activation side effects, the admin handoff, admin-only visibility and
|
||||
the public open/close API live in ./settings/shell.js. This coordinator only
|
||||
supplies the panel-owned pieces the shell needs. */
|
||||
const _shell = createSettingsShell({
|
||||
getModal: () => modalEl,
|
||||
ensureInitialized: () => { if (!initialized) initAll(); },
|
||||
syncAppearanceCheckboxes: () => syncAppearanceCheckboxes(),
|
||||
refreshAiModelEndpoints: () => refreshAiModelEndpoints(),
|
||||
isAdmin: () => !!window._isAdmin,
|
||||
getAdminModule: () => window.adminModule,
|
||||
});
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
AI TAB
|
||||
@@ -602,56 +528,6 @@ async function initTeacherModel() {
|
||||
}
|
||||
|
||||
/* ── Image Generation ── */
|
||||
async function initImageSettings() {
|
||||
const modelSel = el('set-imgModelSelect');
|
||||
const qualSel = el('set-imgQualitySelect');
|
||||
const msg = el('set-imgSettingsMsg');
|
||||
const enabledToggle = el('set-imgEnabledToggle');
|
||||
const configWrap = modelSel ? modelSel.closest('div[style*="flex-direction"]') : null;
|
||||
try {
|
||||
const endpointsRes = await fetch('/api/model-endpoints', { credentials: 'same-origin' });
|
||||
const endpoints = await endpointsRes.json();
|
||||
const imageModels = new Set();
|
||||
(Array.isArray(endpoints) ? endpoints : []).forEach(endpoint => {
|
||||
if (!endpoint.is_enabled || !endpoint.online || String(endpoint.model_type || '').toLowerCase() !== 'image') return;
|
||||
(Array.isArray(endpoint.models) ? endpoint.models : []).forEach(modelId => {
|
||||
if (modelId) imageModels.add(String(modelId));
|
||||
});
|
||||
});
|
||||
sortModelIds(Array.from(imageModels)).forEach(mid => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = mid;
|
||||
opt.textContent = mid;
|
||||
modelSel.appendChild(opt);
|
||||
});
|
||||
} catch (e) { console.warn('Failed to load models for image settings', e); }
|
||||
try {
|
||||
const settingsRes = await fetch('/api/auth/settings', { credentials: 'same-origin' });
|
||||
const settings = await settingsRes.json();
|
||||
if (settings.image_model) modelSel.value = settings.image_model;
|
||||
if (settings.image_quality) qualSel.value = settings.image_quality;
|
||||
if (enabledToggle) enabledToggle.checked = settings.image_gen_enabled === true;
|
||||
} catch (e) { console.warn('Failed to load settings', e); }
|
||||
|
||||
function syncImgDisabled() {
|
||||
var off = enabledToggle && !enabledToggle.checked;
|
||||
var card = enabledToggle ? enabledToggle.closest('.admin-card') : null;
|
||||
if (card) card.style.opacity = off ? '0.45' : '';
|
||||
if (configWrap) configWrap.style.pointerEvents = off ? 'none' : '';
|
||||
}
|
||||
syncImgDisabled();
|
||||
|
||||
async function saveSettings() {
|
||||
try {
|
||||
const res = await _postSettings({ image_gen_enabled: enabledToggle ? enabledToggle.checked : false, image_model: modelSel.value, image_quality: qualSel.value });
|
||||
if (!res.ok) throw new Error(await res.text().catch(() => `HTTP ${res.status}`));
|
||||
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)'; setTimeout(() => { msg.textContent = ''; }, 2000);
|
||||
} catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
|
||||
}
|
||||
modelSel.addEventListener('change', saveSettings);
|
||||
qualSel.addEventListener('change', saveSettings);
|
||||
if (enabledToggle) enabledToggle.addEventListener('change', function() { syncImgDisabled(); saveSettings(); });
|
||||
}
|
||||
|
||||
/* ── Vision ── */
|
||||
async function initVisionSettings() {
|
||||
@@ -733,246 +609,8 @@ async function initVisionSettings() {
|
||||
/* ── Face Recognition ── */
|
||||
|
||||
/* ── Text to Speech ── */
|
||||
async function initTtsSettings() {
|
||||
var provSel = el('set-ttsProviderSelect');
|
||||
var modelSelect = el('set-ttsModelSelect');
|
||||
var modelInput = el('set-ttsModelInput');
|
||||
var voiceSelect = el('set-ttsVoiceSelect');
|
||||
var voiceInput = el('set-ttsVoiceInput');
|
||||
var modelRow = el('set-ttsModelRow');
|
||||
var voiceRow = el('set-ttsVoiceRow');
|
||||
var speedSelect = el('set-ttsSpeedSelect');
|
||||
var speedRow = el('set-ttsSpeedRow');
|
||||
var ttsMsg = el('set-ttsSettingsMsg');
|
||||
var ttsEnabledToggle = el('set-ttsEnabledToggle');
|
||||
var ttsConfigWrap = provSel ? provSel.closest('div[style*="flex-direction"]') : null;
|
||||
|
||||
function isEndpoint() { return provSel.value.startsWith('endpoint:'); }
|
||||
function getModel() { return isEndpoint() ? modelSelect.value : modelInput.value; }
|
||||
function getVoice() { return isEndpoint() ? voiceSelect.value : voiceInput.value; }
|
||||
|
||||
function updateVisibility() {
|
||||
var prov = provSel.value;
|
||||
modelRow.style.display = prov.startsWith('endpoint:') ? 'flex' : 'none';
|
||||
voiceRow.style.display = prov === 'disabled' ? 'none' : 'flex';
|
||||
speedRow.style.display = prov === 'disabled' ? 'none' : 'flex';
|
||||
if (isEndpoint()) {
|
||||
modelSelect.style.display = ''; modelInput.style.display = 'none';
|
||||
voiceSelect.style.display = ''; voiceInput.style.display = 'none';
|
||||
} else {
|
||||
modelSelect.style.display = 'none'; modelInput.style.display = '';
|
||||
voiceSelect.style.display = 'none'; voiceInput.style.display = prov === 'disabled' ? 'none' : '';
|
||||
}
|
||||
}
|
||||
|
||||
var ttsKeywords = ['tts', 'audio'];
|
||||
try {
|
||||
var epRes = await fetch('/api/model-endpoints', { credentials: 'same-origin' });
|
||||
var endpoints = await epRes.json();
|
||||
endpoints.forEach(function(ep) {
|
||||
if (!ep.is_enabled) return;
|
||||
var hasTTS = (ep.models || []).some(m => ttsKeywords.some(kw => m.toLowerCase().includes(kw)));
|
||||
if (!hasTTS) return;
|
||||
var opt = document.createElement('option'); opt.value = 'endpoint:' + ep.id; opt.textContent = ep.name + ' (API)'; provSel.appendChild(opt);
|
||||
});
|
||||
} catch (e) { console.warn('Failed to load endpoints for TTS', e); }
|
||||
|
||||
try {
|
||||
var settingsRes = await fetch('/api/auth/settings', { credentials: 'same-origin' });
|
||||
var settings = await settingsRes.json();
|
||||
if (settings.tts_provider) provSel.value = settings.tts_provider;
|
||||
if (settings.tts_model) { modelSelect.value = settings.tts_model; modelInput.value = settings.tts_model; }
|
||||
if (settings.tts_voice) { voiceSelect.value = settings.tts_voice; voiceInput.value = settings.tts_voice; }
|
||||
if (settings.tts_speed) { speedSelect.value = settings.tts_speed; }
|
||||
if (ttsEnabledToggle) ttsEnabledToggle.checked = settings.tts_enabled !== false;
|
||||
} catch (e) { console.warn('Failed to load TTS settings', e); }
|
||||
|
||||
function syncTtsDisabled() {
|
||||
var off = ttsEnabledToggle && !ttsEnabledToggle.checked;
|
||||
var card = ttsEnabledToggle ? ttsEnabledToggle.closest('.admin-card') : null;
|
||||
if (card) card.style.opacity = off ? '0.45' : '';
|
||||
if (ttsConfigWrap) ttsConfigWrap.style.pointerEvents = off ? 'none' : '';
|
||||
}
|
||||
syncTtsDisabled();
|
||||
updateVisibility();
|
||||
|
||||
async function saveTTS() {
|
||||
try {
|
||||
await _postSettings({ tts_enabled: ttsEnabledToggle ? ttsEnabledToggle.checked : true, tts_provider: provSel.value, tts_model: getModel() || 'tts-1', tts_voice: getVoice() || 'alloy', tts_speed: speedSelect.value || '1' });
|
||||
ttsMsg.textContent = 'Saved'; ttsMsg.style.color = 'var(--fg)'; setTimeout(() => { ttsMsg.textContent = ''; }, 2000);
|
||||
if (window.aiTTSManager) window.aiTTSManager.checkAvailability();
|
||||
} catch (e) { ttsMsg.textContent = 'Failed to save'; ttsMsg.style.color = 'var(--red)'; }
|
||||
}
|
||||
|
||||
async function saveAndClearCache() {
|
||||
await saveTTS();
|
||||
fetch('/api/tts/clear-cache', { method: 'POST', credentials: 'same-origin' }).catch(function(){});
|
||||
}
|
||||
|
||||
provSel.addEventListener('change', function() {
|
||||
var prov = provSel.value;
|
||||
if (prov === 'local') voiceInput.value = 'af_heart';
|
||||
else if (isEndpoint()) { voiceSelect.value = 'alloy'; modelSelect.value = 'tts-1'; }
|
||||
else if (prov === 'browser') { voiceInput.value = ''; voiceInput.placeholder = 'OS default voice'; }
|
||||
updateVisibility();
|
||||
saveTTS();
|
||||
});
|
||||
modelSelect.addEventListener('change', saveAndClearCache);
|
||||
modelInput.addEventListener('change', saveTTS);
|
||||
voiceSelect.addEventListener('change', saveAndClearCache);
|
||||
voiceInput.addEventListener('change', saveTTS);
|
||||
speedSelect.addEventListener('change', saveAndClearCache);
|
||||
if (ttsEnabledToggle) ttsEnabledToggle.addEventListener('change', function() { syncTtsDisabled(); saveTTS(); });
|
||||
|
||||
// Preview / test button
|
||||
var previewBtn = el('set-ttsPreviewBtn');
|
||||
if (previewBtn) {
|
||||
var previewAudio = null;
|
||||
var previewPlaying = false;
|
||||
function resetPreview() { previewPlaying = false; previewBtn.textContent = 'Preview'; previewBtn.style.borderColor = ''; }
|
||||
|
||||
previewBtn.addEventListener('click', async function() {
|
||||
if (previewPlaying) {
|
||||
if (previewAudio) { previewAudio.pause(); previewAudio = null; }
|
||||
window.speechSynthesis.cancel();
|
||||
resetPreview(); return;
|
||||
}
|
||||
var prov = provSel.value;
|
||||
if (prov === 'disabled') {
|
||||
ttsMsg.textContent = 'Select a provider first'; ttsMsg.style.color = 'var(--red, #e55)';
|
||||
setTimeout(function() { ttsMsg.textContent = ''; }, 2000); return;
|
||||
}
|
||||
var testText = 'Hello, this is a test of text to speech.';
|
||||
previewPlaying = true; previewBtn.textContent = 'Loading...';
|
||||
try {
|
||||
if (prov === 'browser') {
|
||||
if (!('speechSynthesis' in window)) throw new Error('Browser TTS not supported');
|
||||
var utt = new SpeechSynthesisUtterance(testText);
|
||||
var voiceVal = getVoice();
|
||||
if (voiceVal) {
|
||||
var voices = window.speechSynthesis.getVoices();
|
||||
var target = voiceVal.toLowerCase();
|
||||
var match = voices.find(function(v) { return v.name.toLowerCase() === target; }) ||
|
||||
voices.find(function(v) { return v.name.toLowerCase().includes(target); });
|
||||
if (match) utt.voice = match;
|
||||
}
|
||||
utt.rate = parseFloat(speedSelect.value) || 1;
|
||||
previewBtn.textContent = 'Stop'; previewBtn.style.borderColor = 'var(--red, #e55)';
|
||||
await new Promise(function(resolve, reject) {
|
||||
utt.onend = resolve;
|
||||
utt.onerror = function(e) { reject(new Error('Browser TTS: ' + e.error)); };
|
||||
window.speechSynthesis.speak(utt);
|
||||
});
|
||||
} else {
|
||||
var res = await fetch('/api/tts/synthesize', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: testText, format: 'audio' })
|
||||
});
|
||||
if (!res.ok) { var err = await res.json().catch(function() { return {}; }); throw new Error(err.detail?.message || 'Synthesis failed'); }
|
||||
var blob = await res.blob();
|
||||
var url = URL.createObjectURL(blob);
|
||||
previewAudio = new Audio(url);
|
||||
previewBtn.textContent = 'Stop'; previewBtn.style.borderColor = 'var(--red, #e55)';
|
||||
await new Promise(function(resolve, reject) {
|
||||
previewAudio.onended = function() { URL.revokeObjectURL(url); previewAudio = null; resolve(); };
|
||||
previewAudio.onerror = function() { URL.revokeObjectURL(url); previewAudio = null; reject(new Error('Playback failed')); };
|
||||
previewAudio.play().catch(reject);
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
ttsMsg.textContent = 'Preview failed: ' + e.message; ttsMsg.style.color = 'var(--red, #e55)';
|
||||
setTimeout(function() { ttsMsg.textContent = ''; }, 3000);
|
||||
} finally {
|
||||
resetPreview();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Speech to Text ── */
|
||||
async function initSttSettings() {
|
||||
var provSel = el('set-sttProviderSelect');
|
||||
var modelSelect = el('set-sttModelSelect');
|
||||
var modelInput = el('set-sttModelInput');
|
||||
var modelRow = el('set-sttModelRow');
|
||||
var langRow = el('set-sttLangRow');
|
||||
var langInput = el('set-sttLangInput');
|
||||
var sttMsg = el('set-sttSettingsMsg');
|
||||
var sttEnabledToggle = el('set-sttEnabledToggle');
|
||||
var sttConfigWrap = el('set-sttConfigWrap');
|
||||
// STT was removed from AI Defaults — bail if the UI isn't present.
|
||||
if (!provSel) return;
|
||||
|
||||
function isEndpoint() { return provSel.value.startsWith('endpoint:'); }
|
||||
function getModel() { return isEndpoint() ? modelInput.value : modelSelect.value; }
|
||||
|
||||
function updateVisibility() {
|
||||
var prov = provSel.value;
|
||||
var showModel = prov === 'local' || prov.startsWith('endpoint:');
|
||||
var showLang = prov !== 'disabled';
|
||||
modelRow.style.display = showModel ? 'flex' : 'none';
|
||||
langRow.style.display = showLang ? 'flex' : 'none';
|
||||
if (isEndpoint()) {
|
||||
modelSelect.style.display = 'none'; modelInput.style.display = '';
|
||||
} else {
|
||||
modelSelect.style.display = ''; modelInput.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function syncSttDisabled() {
|
||||
var off = sttEnabledToggle && !sttEnabledToggle.checked;
|
||||
var card = sttEnabledToggle ? sttEnabledToggle.closest('.admin-card') : null;
|
||||
if (card) card.style.opacity = off ? '0.45' : '';
|
||||
if (sttConfigWrap) sttConfigWrap.style.pointerEvents = off ? 'none' : '';
|
||||
}
|
||||
|
||||
// Effective provider: if toggle is off, treat as disabled regardless of provider select
|
||||
function effectiveProvider() {
|
||||
if (sttEnabledToggle && !sttEnabledToggle.checked) return 'disabled';
|
||||
return provSel.value;
|
||||
}
|
||||
|
||||
// Add API endpoints that might support STT
|
||||
try {
|
||||
var epRes = await fetch('/api/model-endpoints', { credentials: 'same-origin' });
|
||||
var endpoints = await epRes.json();
|
||||
endpoints.forEach(function(ep) {
|
||||
if (!ep.is_enabled) return;
|
||||
var opt = document.createElement('option'); opt.value = 'endpoint:' + ep.id; opt.textContent = ep.name + ' (API)'; provSel.appendChild(opt);
|
||||
});
|
||||
} catch (e) { console.warn('Failed to load endpoints for STT', e); }
|
||||
|
||||
// Load saved settings
|
||||
try {
|
||||
var settingsRes = await fetch('/api/auth/settings', { credentials: 'same-origin' });
|
||||
var settings = await settingsRes.json();
|
||||
if (settings.stt_provider) provSel.value = settings.stt_provider;
|
||||
if (settings.stt_model) { modelSelect.value = settings.stt_model; modelInput.value = settings.stt_model; }
|
||||
if (settings.stt_language) langInput.value = settings.stt_language;
|
||||
if (sttEnabledToggle) sttEnabledToggle.checked = settings.stt_enabled !== false;
|
||||
} catch (e) { console.warn('Failed to load STT settings', e); }
|
||||
|
||||
syncSttDisabled();
|
||||
updateVisibility();
|
||||
|
||||
async function saveSTT() {
|
||||
try {
|
||||
var enabled = sttEnabledToggle ? sttEnabledToggle.checked : false;
|
||||
await _postSettings({ stt_enabled: enabled, stt_provider: provSel.value, stt_model: getModel() || 'base', stt_language: langInput.value.trim() });
|
||||
sttMsg.textContent = 'Saved'; sttMsg.style.color = 'var(--fg)'; setTimeout(() => { sttMsg.textContent = ''; }, 2000);
|
||||
// Notify voiceRecorder of effective provider and update send button icon
|
||||
if (window.voiceRecorderModule) window.voiceRecorderModule._sttProvider = effectiveProvider();
|
||||
if (window._updateSendBtnIcon) window._updateSendBtnIcon();
|
||||
} catch (e) { sttMsg.textContent = 'Failed to save'; sttMsg.style.color = 'var(--red)'; }
|
||||
}
|
||||
|
||||
provSel.addEventListener('change', function() { updateVisibility(); saveSTT(); });
|
||||
modelSelect.addEventListener('change', saveSTT);
|
||||
modelInput.addEventListener('change', saveSTT);
|
||||
langInput.addEventListener('change', saveSTT);
|
||||
if (sttEnabledToggle) sttEnabledToggle.addEventListener('change', function() { syncSttDisabled(); saveSTT(); });
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
SEARCH TAB
|
||||
@@ -1494,56 +1132,6 @@ async function initResearchSearchSettings() {
|
||||
}
|
||||
|
||||
/* ── Agent Settings (AI tab) ── */
|
||||
async function initAgentSettings() {
|
||||
var toolsInput = el('set-agentMaxTools');
|
||||
var roundsInput = el('set-agentMaxRounds');
|
||||
var supInput = el('set-agentSupervisorLadder');
|
||||
var msg = el('set-agentMsg');
|
||||
if (!toolsInput) return;
|
||||
|
||||
try {
|
||||
var res = await fetch('/api/auth/settings', { credentials: 'same-origin' });
|
||||
var settings = await res.json();
|
||||
if (settings.agent_max_tool_calls) toolsInput.value = settings.agent_max_tool_calls;
|
||||
if (roundsInput && settings.agent_max_rounds) roundsInput.value = settings.agent_max_rounds;
|
||||
if (supInput) supInput.checked = !!settings.agent_supervisor_ladder;
|
||||
} catch (e) {}
|
||||
|
||||
// Clamp + coerce a raw input to an int in [lo, hi]; falls back to `dflt`
|
||||
// when blank/non-numeric. Mirrors the server-side validation.
|
||||
function clampInt(raw, lo, hi, dflt) {
|
||||
var n = parseInt(raw, 10);
|
||||
if (isNaN(n)) return dflt;
|
||||
return Math.max(lo, Math.min(n, hi));
|
||||
}
|
||||
|
||||
async function save() {
|
||||
var tools = clampInt(toolsInput.value, 0, 1000, 0);
|
||||
var rounds = roundsInput ? clampInt(roundsInput.value, 1, 200, 20) : null;
|
||||
toolsInput.value = tools; // reflect the clamped value
|
||||
if (roundsInput) roundsInput.value = rounds;
|
||||
var payload = { agent_max_tool_calls: tools };
|
||||
if (rounds != null) payload.agent_max_rounds = rounds;
|
||||
if (supInput) payload.agent_supervisor_ladder = !!supInput.checked;
|
||||
try {
|
||||
await _postSettings(payload);
|
||||
msg.textContent = (tools > 0 ? 'Limit: ' + tools + ' tool calls' : 'Unlimited tool calls') +
|
||||
(rounds != null ? ' · ' + rounds + ' steps/message' : '') +
|
||||
(supInput && supInput.checked ? ' · supervisor on' : '');
|
||||
msg.style.color = 'var(--fg)';
|
||||
} catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
|
||||
}
|
||||
|
||||
toolsInput.addEventListener('change', save);
|
||||
if (roundsInput) roundsInput.addEventListener('change', save);
|
||||
if (supInput) supInput.addEventListener('change', save);
|
||||
var cur = parseInt(toolsInput.value, 10) || 0;
|
||||
var curR = roundsInput ? (parseInt(roundsInput.value, 10) || 20) : null;
|
||||
msg.textContent = (cur > 0 ? 'Limit: ' + cur + ' tool calls' : 'Unlimited tool calls') +
|
||||
(curR != null ? ' · ' + curR + ' steps/message' : '') +
|
||||
(supInput && supInput.checked ? ' · supervisor on' : '');
|
||||
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
APPEARANCE TAB
|
||||
@@ -2141,8 +1729,8 @@ function initAll() {
|
||||
modalEl = el('settings-modal');
|
||||
|
||||
bindSettingsNavigation(modalEl, {
|
||||
openAdminTab: openAdminSettingsTab,
|
||||
onPanelActivated: onSettingsPanelActivated,
|
||||
openAdminTab: _shell.openAdminTab,
|
||||
onPanelActivated: _shell.onPanelActivated,
|
||||
});
|
||||
|
||||
bindSettingsSearch(modalEl, {
|
||||
@@ -2172,7 +1760,7 @@ function initAll() {
|
||||
closeSettings: close,
|
||||
});
|
||||
|
||||
initOpacityToggle();
|
||||
bindSettingsPeekToggle(modalEl);
|
||||
initialized = true;
|
||||
initDefaultChat();
|
||||
initTeacherModel();
|
||||
@@ -2196,69 +1784,6 @@ function initAll() {
|
||||
initDocumentWritingStyle();
|
||||
}
|
||||
|
||||
async function initDocumentWritingStyle() {
|
||||
const styleEl = el('set-document-style');
|
||||
const saveBtn = el('set-document-style-save');
|
||||
const extractBtn = el('set-document-style-extract');
|
||||
const fileEl = el('set-document-style-file');
|
||||
const msg = el('set-document-style-msg');
|
||||
if (!styleEl || !saveBtn) return;
|
||||
try {
|
||||
const res = await fetch('/api/auth/settings', { credentials: 'same-origin' });
|
||||
const data = await res.json();
|
||||
styleEl.value = String(data.document_writing_style || '');
|
||||
} catch (_) {
|
||||
if (msg) msg.textContent = 'Failed to load';
|
||||
}
|
||||
saveBtn.addEventListener('click', async () => {
|
||||
if (msg) msg.textContent = 'Saving...';
|
||||
try {
|
||||
const res = await _postSettings({ document_writing_style: styleEl.value });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
if (msg) msg.textContent = '✓ Saved';
|
||||
} catch (_) {
|
||||
if (msg) msg.textContent = 'Failed to save';
|
||||
}
|
||||
setTimeout(() => { if (msg) msg.textContent = ''; }, 3000);
|
||||
});
|
||||
extractBtn?.addEventListener('click', () => fileEl?.click());
|
||||
fileEl?.addEventListener('change', async () => {
|
||||
const file = fileEl.files?.[0];
|
||||
if (!file) return;
|
||||
extractBtn.disabled = true;
|
||||
let whirlpool = null;
|
||||
if (msg) {
|
||||
msg.replaceChildren();
|
||||
try {
|
||||
const spinner = window.spinnerModule || (await import('./spinner.js')).default;
|
||||
whirlpool = spinner.createWhirlpool(14);
|
||||
whirlpool.element.style.cssText = 'display:inline-flex;width:14px;height:14px;margin-right:7px;';
|
||||
const label = document.createElement('span');
|
||||
label.textContent = 'Analyzing...';
|
||||
msg.append(whirlpool.element, label);
|
||||
} catch (_) {
|
||||
msg.textContent = 'Analyzing...';
|
||||
}
|
||||
}
|
||||
try {
|
||||
const body = new FormData();
|
||||
body.append('file', file);
|
||||
const res = await fetch('/api/auth/settings/document-style/extract', {
|
||||
method: 'POST', credentials: 'same-origin', body,
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || !data.style) throw new Error(data.detail || data.error || 'Extraction failed');
|
||||
styleEl.value = String(data.style);
|
||||
if (msg) msg.textContent = '✓ Extracted — review and save';
|
||||
} catch (error) {
|
||||
if (msg) msg.textContent = error.message || 'Extraction failed';
|
||||
} finally {
|
||||
whirlpool?.destroy?.();
|
||||
extractBtn.disabled = false;
|
||||
fileEl.value = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function notifyIntegrationsChanged() {
|
||||
try {
|
||||
@@ -5102,7 +4627,11 @@ async function initUnifiedIntegrations() {
|
||||
fd.append('transport', transport);
|
||||
if (transport === 'stdio') {
|
||||
fd.append('command', el('uf-mcp-cmd').value);
|
||||
let args = '[]'; try { args = JSON.stringify(JSON.parse(el('uf-mcp-args').value || '[]')); } catch (_) {}
|
||||
// Unlike env below, an unparseable args value is not silently
|
||||
// defaulted: it would spawn the subprocess with an empty argv.
|
||||
let args;
|
||||
try { args = JSON.stringify(JSON.parse(el('uf-mcp-args').value || '[]')); }
|
||||
catch (_) { el('uf-mcp-msg').textContent = 'Args must be valid JSON, e.g. ["-y", "pkg"]'; return; }
|
||||
let env = '{}'; try { env = JSON.stringify(JSON.parse(el('uf-mcp-env').value || '{}')); } catch (_) {}
|
||||
fd.append('args', args);
|
||||
fd.append('env', env);
|
||||
@@ -5124,7 +4653,12 @@ async function initUnifiedIntegrations() {
|
||||
} else if (r.ok) {
|
||||
el('uf-mcp-msg').textContent = 'Saved'; formEl.style.display = 'none'; await renderList();
|
||||
} else {
|
||||
el('uf-mcp-msg').textContent = `Failed (${r.status})`;
|
||||
// Surface the server's reason. The Args validation above rejects
|
||||
// unparseable JSON, but `"x"` and `{}` parse and are refused by
|
||||
// routes/mcp/mcp_routes.py with a message naming the expected
|
||||
// shape; a bare status code sends the user looking in the wrong
|
||||
// place. Matches what admin.js shows for the same endpoint.
|
||||
el('uf-mcp-msg').textContent = data.detail || `Failed (${r.status})`;
|
||||
}
|
||||
} catch (_) { el('uf-mcp-msg').textContent = 'Failed'; }
|
||||
finally { _setBtnLoading(saveBtn, false, _origLabel); if (cancelBtn) cancelBtn.disabled = false; }
|
||||
@@ -5635,85 +5169,14 @@ async function initUnifiedIntegrations() {
|
||||
await renderList();
|
||||
}
|
||||
|
||||
/* ── Admin visibility sync ── */
|
||||
function syncAdminVisibility() {
|
||||
if (!modalEl) return;
|
||||
const isAdmin = !!window._isAdmin;
|
||||
modalEl.querySelectorAll('.admin-only').forEach(el => {
|
||||
el.style.display = isAdmin ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
PUBLIC API
|
||||
═══════════════════════════════════════════ */
|
||||
export function open(tab) {
|
||||
if (!initialized) initAll();
|
||||
export function open(tab) { _shell.open(tab); }
|
||||
export function close() { _shell.close(); }
|
||||
function syncAdminVisibility() { _shell.syncAdminVisibility(); }
|
||||
|
||||
syncAppearanceCheckboxes();
|
||||
showSettingsModal(modalEl);
|
||||
syncAdminVisibility();
|
||||
|
||||
if (tab) {
|
||||
activateSettingsPanel(modalEl, tab);
|
||||
}
|
||||
|
||||
// Preserve existing panel-specific side effects when Settings is opened
|
||||
// directly to a tab as well as when the user navigates there.
|
||||
const activeTab = tab || getActiveSettingsTab(modalEl);
|
||||
onSettingsPanelActivated(activeTab);
|
||||
|
||||
// Auto-init admin data if showing an admin tab.
|
||||
if (isAdminManagedSettingsTab(activeTab) && window.adminModule && !window.adminModule._initialized) {
|
||||
window.adminModule._initData();
|
||||
}
|
||||
}
|
||||
|
||||
export function close() {
|
||||
if (!modalEl) return;
|
||||
|
||||
// Always clear the Appearance state so the rest of the app does not remain
|
||||
// dimmed if Settings is closed while that panel is active.
|
||||
document.body.classList.remove('settings-appearance-open');
|
||||
syncAppearanceOpacity(false);
|
||||
|
||||
hideSettingsModal(modalEl);
|
||||
}
|
||||
|
||||
// Handle redirect back from Google OAuth2 — open settings to integrations and show status.
|
||||
(function _handleOauthRedirect() {
|
||||
const sp = new URLSearchParams(window.location.search);
|
||||
if (!sp.has('email_oauth_success') && !sp.has('email_oauth_error')) return;
|
||||
// Strip params from URL without a page reload.
|
||||
const clean = window.location.pathname + window.location.hash;
|
||||
window.history.replaceState(null, '', clean);
|
||||
const success = sp.has('email_oauth_success');
|
||||
const errMsg = sp.get('email_oauth_error') || '';
|
||||
// Open settings → integrations once the document is ready. This module owns
|
||||
// the open() API, so it does not need to wait for a window-level alias.
|
||||
function _showResult() {
|
||||
open('integrations');
|
||||
// Brief toast-style banner.
|
||||
const banner = document.createElement('div');
|
||||
banner.textContent = success
|
||||
? 'Google account connected — email is ready'
|
||||
: `Google OAuth failed: ${errMsg || 'unknown error'}`;
|
||||
Object.assign(banner.style, {
|
||||
position: 'fixed', bottom: '24px', left: '50%', transform: 'translateX(-50%)',
|
||||
background: success ? 'var(--accent, #50fa7b)' : 'var(--red, #ff5555)',
|
||||
color: '#000', padding: '8px 18px', borderRadius: '6px', fontSize: '12px',
|
||||
fontWeight: '600', zIndex: '99999', pointerEvents: 'none',
|
||||
boxShadow: '0 2px 12px rgba(0,0,0,0.3)',
|
||||
});
|
||||
document.body.appendChild(banner);
|
||||
setTimeout(() => banner.remove(), 4000);
|
||||
}
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', _showResult, { once: true });
|
||||
} else {
|
||||
_showResult();
|
||||
}
|
||||
})();
|
||||
handleSettingsOauthReturn({ openSettings: open });
|
||||
|
||||
const settingsModule = { open, close, initIntegrations, initUnifiedIntegrations, syncAdminVisibility, refreshAiModelEndpoints };
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// Agent settings panel.
|
||||
// Extracted verbatim from static/js/settings.js. Behaviour is unchanged: the
|
||||
// functions moved, their bodies did not. settings.js imports them and calls
|
||||
// them from the same places, so load order and init sequence are untouched.
|
||||
|
||||
import { byId as el } from './dom.js';
|
||||
import { postSettings as _postSettings } from './api.js';
|
||||
|
||||
export async function initAgentSettings() {
|
||||
var toolsInput = el('set-agentMaxTools');
|
||||
var roundsInput = el('set-agentMaxRounds');
|
||||
var supInput = el('set-agentSupervisorLadder');
|
||||
var msg = el('set-agentMsg');
|
||||
if (!toolsInput) return;
|
||||
|
||||
try {
|
||||
var res = await fetch('/api/auth/settings', { credentials: 'same-origin' });
|
||||
var settings = await res.json();
|
||||
if (settings.agent_max_tool_calls) toolsInput.value = settings.agent_max_tool_calls;
|
||||
if (roundsInput && settings.agent_max_rounds) roundsInput.value = settings.agent_max_rounds;
|
||||
if (supInput) supInput.checked = !!settings.agent_supervisor_ladder;
|
||||
} catch (e) {}
|
||||
|
||||
// Clamp + coerce a raw input to an int in [lo, hi]; falls back to `dflt`
|
||||
// when blank/non-numeric. Mirrors the server-side validation.
|
||||
function clampInt(raw, lo, hi, dflt) {
|
||||
var n = parseInt(raw, 10);
|
||||
if (isNaN(n)) return dflt;
|
||||
return Math.max(lo, Math.min(n, hi));
|
||||
}
|
||||
|
||||
async function save() {
|
||||
var tools = clampInt(toolsInput.value, 0, 1000, 0);
|
||||
var rounds = roundsInput ? clampInt(roundsInput.value, 1, 200, 20) : null;
|
||||
toolsInput.value = tools; // reflect the clamped value
|
||||
if (roundsInput) roundsInput.value = rounds;
|
||||
var payload = { agent_max_tool_calls: tools };
|
||||
if (rounds != null) payload.agent_max_rounds = rounds;
|
||||
if (supInput) payload.agent_supervisor_ladder = !!supInput.checked;
|
||||
try {
|
||||
await _postSettings(payload);
|
||||
msg.textContent = (tools > 0 ? 'Limit: ' + tools + ' tool calls' : 'Unlimited tool calls') +
|
||||
(rounds != null ? ' · ' + rounds + ' steps/message' : '') +
|
||||
(supInput && supInput.checked ? ' · supervisor on' : '');
|
||||
msg.style.color = 'var(--fg)';
|
||||
} catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
|
||||
}
|
||||
|
||||
toolsInput.addEventListener('change', save);
|
||||
if (roundsInput) roundsInput.addEventListener('change', save);
|
||||
if (supInput) supInput.addEventListener('change', save);
|
||||
var cur = parseInt(toolsInput.value, 10) || 0;
|
||||
var curR = roundsInput ? (parseInt(roundsInput.value, 10) || 20) : null;
|
||||
msg.textContent = (cur > 0 ? 'Limit: ' + cur + ' tool calls' : 'Unlimited tool calls') +
|
||||
(curR != null ? ' · ' + curR + ' steps/message' : '') +
|
||||
(supInput && supInput.checked ? ' · supervisor on' : '');
|
||||
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// Lifted verbatim from static/js/settings.js so extracted panels can post
|
||||
// settings without importing the module they were extracted from.
|
||||
import { invalidateSettings } from '../appConfig.js';
|
||||
|
||||
export async function postSettings(body) {
|
||||
try {
|
||||
return await fetch('/api/auth/settings', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
} finally {
|
||||
invalidateSettings();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// Image-model settings panel.
|
||||
// Extracted verbatim from static/js/settings.js. Behaviour is unchanged: the
|
||||
// functions moved, their bodies did not. settings.js imports them and calls
|
||||
// them from the same places, so load order and init sequence are untouched.
|
||||
|
||||
import { byId as el } from './dom.js';
|
||||
import { postSettings as _postSettings } from './api.js';
|
||||
import { sortModelIds } from '../modelSort.js';
|
||||
|
||||
export async function initImageSettings() {
|
||||
const modelSel = el('set-imgModelSelect');
|
||||
const qualSel = el('set-imgQualitySelect');
|
||||
const msg = el('set-imgSettingsMsg');
|
||||
const enabledToggle = el('set-imgEnabledToggle');
|
||||
const configWrap = modelSel ? modelSel.closest('div[style*="flex-direction"]') : null;
|
||||
try {
|
||||
const endpointsRes = await fetch('/api/model-endpoints', { credentials: 'same-origin' });
|
||||
const endpoints = await endpointsRes.json();
|
||||
const imageModels = new Set();
|
||||
(Array.isArray(endpoints) ? endpoints : []).forEach(endpoint => {
|
||||
if (!endpoint.is_enabled || !endpoint.online || String(endpoint.model_type || '').toLowerCase() !== 'image') return;
|
||||
(Array.isArray(endpoint.models) ? endpoint.models : []).forEach(modelId => {
|
||||
if (modelId) imageModels.add(String(modelId));
|
||||
});
|
||||
});
|
||||
sortModelIds(Array.from(imageModels)).forEach(mid => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = mid;
|
||||
opt.textContent = mid;
|
||||
modelSel.appendChild(opt);
|
||||
});
|
||||
} catch (e) { console.warn('Failed to load models for image settings', e); }
|
||||
try {
|
||||
const settingsRes = await fetch('/api/auth/settings', { credentials: 'same-origin' });
|
||||
const settings = await settingsRes.json();
|
||||
if (settings.image_model) modelSel.value = settings.image_model;
|
||||
if (settings.image_quality) qualSel.value = settings.image_quality;
|
||||
if (enabledToggle) enabledToggle.checked = settings.image_gen_enabled === true;
|
||||
} catch (e) { console.warn('Failed to load settings', e); }
|
||||
|
||||
function syncImgDisabled() {
|
||||
var off = enabledToggle && !enabledToggle.checked;
|
||||
var card = enabledToggle ? enabledToggle.closest('.admin-card') : null;
|
||||
if (card) card.style.opacity = off ? '0.45' : '';
|
||||
if (configWrap) configWrap.style.pointerEvents = off ? 'none' : '';
|
||||
}
|
||||
syncImgDisabled();
|
||||
|
||||
async function saveSettings() {
|
||||
try {
|
||||
const res = await _postSettings({ image_gen_enabled: enabledToggle ? enabledToggle.checked : false, image_model: modelSel.value, image_quality: qualSel.value });
|
||||
if (!res.ok) throw new Error(await res.text().catch(() => `HTTP ${res.status}`));
|
||||
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)'; setTimeout(() => { msg.textContent = ''; }, 2000);
|
||||
} catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
|
||||
}
|
||||
modelSel.addEventListener('change', saveSettings);
|
||||
qualSel.addEventListener('change', saveSettings);
|
||||
if (enabledToggle) enabledToggle.addEventListener('change', function() { syncImgDisabled(); saveSettings(); });
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// Return path from the Google OAuth2 redirect.
|
||||
//
|
||||
// The provider sends the browser back to the app root with a result in the
|
||||
// query string, so something has to notice on load and put the user back where
|
||||
// they were — the Integrations panel — with the outcome visible. That is
|
||||
// navigation, not an Integrations concern, and it runs once per page load
|
||||
// rather than once per panel open.
|
||||
|
||||
/**
|
||||
* @param {object} options
|
||||
* @param {(tab: string) => void} options.openSettings the Settings open() entry point
|
||||
*/
|
||||
export function handleSettingsOauthReturn({ openSettings } = {}) {
|
||||
const sp = new URLSearchParams(window.location.search);
|
||||
if (!sp.has('email_oauth_success') && !sp.has('email_oauth_error')) return;
|
||||
// Strip params from URL without a page reload.
|
||||
const clean = window.location.pathname + window.location.hash;
|
||||
window.history.replaceState(null, '', clean);
|
||||
const success = sp.has('email_oauth_success');
|
||||
const errMsg = sp.get('email_oauth_error') || '';
|
||||
|
||||
// The caller passes its own open() in, so this never has to wait for a
|
||||
// window-level alias to exist.
|
||||
function _showResult() {
|
||||
openSettings('integrations');
|
||||
// Brief toast-style banner.
|
||||
const banner = document.createElement('div');
|
||||
banner.textContent = success
|
||||
? 'Google account connected — email is ready'
|
||||
: `Google OAuth failed: ${errMsg || 'unknown error'}`;
|
||||
Object.assign(banner.style, {
|
||||
position: 'fixed', bottom: '24px', left: '50%', transform: 'translateX(-50%)',
|
||||
background: success ? 'var(--accent, #50fa7b)' : 'var(--red, #ff5555)',
|
||||
color: '#000', padding: '8px 18px', borderRadius: '6px', fontSize: '12px',
|
||||
fontWeight: '600', zIndex: '99999', pointerEvents: 'none',
|
||||
boxShadow: '0 2px 12px rgba(0,0,0,0.3)',
|
||||
});
|
||||
document.body.appendChild(banner);
|
||||
setTimeout(() => banner.remove(), 4000);
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', _showResult, { once: true });
|
||||
} else {
|
||||
_showResult();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// Settings window peek (transparency) chrome.
|
||||
//
|
||||
// The Appearance panel lets the user watch the rest of the UI react to a toggle
|
||||
// without closing Settings. That is window chrome, not panel data, so it lives
|
||||
// here rather than with the Appearance panel's checkbox handling: the fade is
|
||||
// applied to the Settings window's own background via color-mix — never element
|
||||
// opacity, so controls and text stay crisp — and it has to be cleared the
|
||||
// moment the user leaves Appearance or closes the window. Mirrors the Theme
|
||||
// customizer's slider.
|
||||
|
||||
import { byId } from './dom.js';
|
||||
|
||||
const PEEK_OPACITY = 55; // % opacity when the Peek toggle is on
|
||||
const TOGGLE_ID = 'settings-opacity-wrap';
|
||||
|
||||
function _toggleEl() {
|
||||
return byId(TOGGLE_ID);
|
||||
}
|
||||
|
||||
export function applySettingsPeek(modalEl, on) {
|
||||
const content = modalEl && modalEl.querySelector('.settings-modal-content, .modal-content');
|
||||
if (!content) return;
|
||||
const cards = content.querySelectorAll('.admin-card');
|
||||
if (on) {
|
||||
const bgMix = `color-mix(in srgb, var(--bg) ${PEEK_OPACITY}%, transparent)`;
|
||||
const panelMix = `color-mix(in srgb, var(--panel) ${PEEK_OPACITY}%, transparent)`;
|
||||
content.style.setProperty('background', bgMix, 'important');
|
||||
content.style.setProperty('backdrop-filter', 'none', 'important');
|
||||
content.style.setProperty('-webkit-backdrop-filter', 'none', 'important');
|
||||
cards.forEach(c => {
|
||||
c.style.setProperty('background', panelMix, 'important');
|
||||
c.style.setProperty('backdrop-filter', 'none', 'important');
|
||||
c.style.setProperty('-webkit-backdrop-filter', 'none', 'important');
|
||||
});
|
||||
} else {
|
||||
content.style.removeProperty('background');
|
||||
content.style.removeProperty('backdrop-filter');
|
||||
content.style.removeProperty('-webkit-backdrop-filter');
|
||||
cards.forEach(c => {
|
||||
c.style.removeProperty('background');
|
||||
c.style.removeProperty('backdrop-filter');
|
||||
c.style.removeProperty('-webkit-backdrop-filter');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Show/hide the Peek toggle for the Appearance tab and apply or clear the fade.
|
||||
export function syncSettingsPeek(modalEl, active) {
|
||||
const toggle = _toggleEl();
|
||||
if (toggle) toggle.classList.toggle('hidden', !active);
|
||||
if (active) {
|
||||
applySettingsPeek(modalEl, toggle ? toggle.classList.contains('active') : false);
|
||||
} else {
|
||||
applySettingsPeek(modalEl, false); // clear the fade off the Appearance tab
|
||||
}
|
||||
}
|
||||
|
||||
export function bindSettingsPeekToggle(modalEl) {
|
||||
const toggle = _toggleEl();
|
||||
if (!toggle || toggle.dataset.bound === '1') return;
|
||||
toggle.dataset.bound = '1';
|
||||
toggle.addEventListener('click', () => {
|
||||
const on = !toggle.classList.contains('active');
|
||||
toggle.classList.toggle('active', on);
|
||||
toggle.setAttribute('aria-pressed', on ? 'true' : 'false');
|
||||
applySettingsPeek(modalEl, on);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// Settings shell coordination.
|
||||
//
|
||||
// settings.js still owns every panel's data loading. This module owns the shell
|
||||
// around them: what happens when a panel becomes active, where an
|
||||
// admin-managed tab is handed off to admin.js, which elements are admin-only,
|
||||
// and the public open/close entry points.
|
||||
//
|
||||
// It keeps no panel state. Everything it needs from the panels arrives through
|
||||
// the options below, so panels can move out of settings.js one at a time
|
||||
// without this file changing — and so the shell's behavior is testable without
|
||||
// booting a panel.
|
||||
|
||||
import { activateSettingsPanel, getActiveSettingsTab } from './navigation.js';
|
||||
import { showSettingsModal, hideSettingsModal } from './lifecycle.js';
|
||||
import { isAdminManagedSettingsTab } from './registry.js';
|
||||
import { syncSettingsPeek } from './peek.js';
|
||||
|
||||
/**
|
||||
* @param {object} options
|
||||
* @param {() => Element|null} options.getModal the Settings modal, once initialized
|
||||
* @param {() => void} options.ensureInitialized first-open panel initialization
|
||||
* @param {() => void} options.syncAppearanceCheckboxes Appearance panel state refresh
|
||||
* @param {() => void} options.refreshAiModelEndpoints AI panel endpoint refresh
|
||||
* @param {() => boolean} options.isAdmin current admin status
|
||||
* @param {() => object|null} options.getAdminModule the lazily loaded admin module
|
||||
*/
|
||||
export function createSettingsShell(options = {}) {
|
||||
const getModal = options.getModal;
|
||||
const ensureInitialized = options.ensureInitialized;
|
||||
const syncAppearanceCheckboxes = options.syncAppearanceCheckboxes;
|
||||
const refreshAiModelEndpoints = options.refreshAiModelEndpoints;
|
||||
const isAdmin = options.isAdmin;
|
||||
const getAdminModule = options.getAdminModule;
|
||||
|
||||
const _modal = () => (typeof getModal === 'function' ? getModal() : null);
|
||||
const _admin = () => (typeof getAdminModule === 'function' ? getAdminModule() : null);
|
||||
|
||||
function onPanelActivated(tab) {
|
||||
// Appearance keeps its existing transparent preview behavior.
|
||||
document.body.classList.toggle('settings-appearance-open', tab === 'appearance');
|
||||
syncSettingsPeek(_modal(), tab === 'appearance');
|
||||
|
||||
// AI endpoints are intentionally refreshed only when entering the AI panel.
|
||||
if (tab === 'ai' && typeof refreshAiModelEndpoints === 'function') {
|
||||
refreshAiModelEndpoints();
|
||||
}
|
||||
}
|
||||
|
||||
function openAdminTab(tab) {
|
||||
const admin = _admin();
|
||||
if (admin && typeof admin.open === 'function') {
|
||||
admin.open(tab);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function syncAdminVisibility() {
|
||||
const modalEl = _modal();
|
||||
if (!modalEl) return;
|
||||
const admin = typeof isAdmin === 'function' ? !!isAdmin() : false;
|
||||
modalEl.querySelectorAll('.admin-only').forEach(el => {
|
||||
el.style.display = admin ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
function open(tab) {
|
||||
if (typeof ensureInitialized === 'function') ensureInitialized();
|
||||
|
||||
if (typeof syncAppearanceCheckboxes === 'function') syncAppearanceCheckboxes();
|
||||
|
||||
const modalEl = _modal();
|
||||
showSettingsModal(modalEl);
|
||||
syncAdminVisibility();
|
||||
|
||||
if (tab) {
|
||||
activateSettingsPanel(modalEl, tab);
|
||||
}
|
||||
|
||||
// Preserve existing panel-specific side effects when Settings is opened
|
||||
// directly to a tab as well as when the user navigates there.
|
||||
const activeTab = tab || getActiveSettingsTab(modalEl);
|
||||
onPanelActivated(activeTab);
|
||||
|
||||
// Auto-init admin data if showing an admin tab.
|
||||
const admin = _admin();
|
||||
if (isAdminManagedSettingsTab(activeTab) && admin && !admin._initialized) {
|
||||
admin._initData();
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
const modalEl = _modal();
|
||||
if (!modalEl) return;
|
||||
|
||||
// Always clear the Appearance state so the rest of the app does not remain
|
||||
// dimmed if Settings is closed while that panel is active.
|
||||
document.body.classList.remove('settings-appearance-open');
|
||||
syncSettingsPeek(modalEl, false);
|
||||
|
||||
hideSettingsModal(modalEl);
|
||||
}
|
||||
|
||||
return { open, close, onPanelActivated, openAdminTab, syncAdminVisibility };
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
// Text-to-speech and speech-to-text settings panels.
|
||||
// Extracted verbatim from static/js/settings.js. Behaviour is unchanged: the
|
||||
// functions moved, their bodies did not. settings.js imports them and calls
|
||||
// them from the same places, so load order and init sequence are untouched.
|
||||
|
||||
import { byId as el } from './dom.js';
|
||||
import { postSettings as _postSettings } from './api.js';
|
||||
|
||||
export async function initTtsSettings() {
|
||||
var provSel = el('set-ttsProviderSelect');
|
||||
var modelSelect = el('set-ttsModelSelect');
|
||||
var modelInput = el('set-ttsModelInput');
|
||||
var voiceSelect = el('set-ttsVoiceSelect');
|
||||
var voiceInput = el('set-ttsVoiceInput');
|
||||
var modelRow = el('set-ttsModelRow');
|
||||
var voiceRow = el('set-ttsVoiceRow');
|
||||
var speedSelect = el('set-ttsSpeedSelect');
|
||||
var speedRow = el('set-ttsSpeedRow');
|
||||
var ttsMsg = el('set-ttsSettingsMsg');
|
||||
var ttsEnabledToggle = el('set-ttsEnabledToggle');
|
||||
var ttsConfigWrap = provSel ? provSel.closest('div[style*="flex-direction"]') : null;
|
||||
|
||||
function isEndpoint() { return provSel.value.startsWith('endpoint:'); }
|
||||
function getModel() { return isEndpoint() ? modelSelect.value : modelInput.value; }
|
||||
function getVoice() { return isEndpoint() ? voiceSelect.value : voiceInput.value; }
|
||||
|
||||
function updateVisibility() {
|
||||
var prov = provSel.value;
|
||||
modelRow.style.display = prov.startsWith('endpoint:') ? 'flex' : 'none';
|
||||
voiceRow.style.display = prov === 'disabled' ? 'none' : 'flex';
|
||||
speedRow.style.display = prov === 'disabled' ? 'none' : 'flex';
|
||||
if (isEndpoint()) {
|
||||
modelSelect.style.display = ''; modelInput.style.display = 'none';
|
||||
voiceSelect.style.display = ''; voiceInput.style.display = 'none';
|
||||
} else {
|
||||
modelSelect.style.display = 'none'; modelInput.style.display = '';
|
||||
voiceSelect.style.display = 'none'; voiceInput.style.display = prov === 'disabled' ? 'none' : '';
|
||||
}
|
||||
}
|
||||
|
||||
var ttsKeywords = ['tts', 'audio'];
|
||||
try {
|
||||
var epRes = await fetch('/api/model-endpoints', { credentials: 'same-origin' });
|
||||
var endpoints = await epRes.json();
|
||||
endpoints.forEach(function(ep) {
|
||||
if (!ep.is_enabled) return;
|
||||
var hasTTS = (ep.models || []).some(m => ttsKeywords.some(kw => m.toLowerCase().includes(kw)));
|
||||
if (!hasTTS) return;
|
||||
var opt = document.createElement('option'); opt.value = 'endpoint:' + ep.id; opt.textContent = ep.name + ' (API)'; provSel.appendChild(opt);
|
||||
});
|
||||
} catch (e) { console.warn('Failed to load endpoints for TTS', e); }
|
||||
|
||||
try {
|
||||
var settingsRes = await fetch('/api/auth/settings', { credentials: 'same-origin' });
|
||||
var settings = await settingsRes.json();
|
||||
if (settings.tts_provider) provSel.value = settings.tts_provider;
|
||||
if (settings.tts_model) { modelSelect.value = settings.tts_model; modelInput.value = settings.tts_model; }
|
||||
if (settings.tts_voice) { voiceSelect.value = settings.tts_voice; voiceInput.value = settings.tts_voice; }
|
||||
if (settings.tts_speed) { speedSelect.value = settings.tts_speed; }
|
||||
if (ttsEnabledToggle) ttsEnabledToggle.checked = settings.tts_enabled !== false;
|
||||
} catch (e) { console.warn('Failed to load TTS settings', e); }
|
||||
|
||||
function syncTtsDisabled() {
|
||||
var off = ttsEnabledToggle && !ttsEnabledToggle.checked;
|
||||
var card = ttsEnabledToggle ? ttsEnabledToggle.closest('.admin-card') : null;
|
||||
if (card) card.style.opacity = off ? '0.45' : '';
|
||||
if (ttsConfigWrap) ttsConfigWrap.style.pointerEvents = off ? 'none' : '';
|
||||
}
|
||||
syncTtsDisabled();
|
||||
updateVisibility();
|
||||
|
||||
async function saveTTS() {
|
||||
try {
|
||||
await _postSettings({ tts_enabled: ttsEnabledToggle ? ttsEnabledToggle.checked : true, tts_provider: provSel.value, tts_model: getModel() || 'tts-1', tts_voice: getVoice() || 'alloy', tts_speed: speedSelect.value || '1' });
|
||||
ttsMsg.textContent = 'Saved'; ttsMsg.style.color = 'var(--fg)'; setTimeout(() => { ttsMsg.textContent = ''; }, 2000);
|
||||
if (window.aiTTSManager) window.aiTTSManager.checkAvailability();
|
||||
} catch (e) { ttsMsg.textContent = 'Failed to save'; ttsMsg.style.color = 'var(--red)'; }
|
||||
}
|
||||
|
||||
async function saveAndClearCache() {
|
||||
await saveTTS();
|
||||
fetch('/api/tts/clear-cache', { method: 'POST', credentials: 'same-origin' }).catch(function(){});
|
||||
}
|
||||
|
||||
provSel.addEventListener('change', function() {
|
||||
var prov = provSel.value;
|
||||
if (prov === 'local') voiceInput.value = 'af_heart';
|
||||
else if (isEndpoint()) { voiceSelect.value = 'alloy'; modelSelect.value = 'tts-1'; }
|
||||
else if (prov === 'browser') { voiceInput.value = ''; voiceInput.placeholder = 'OS default voice'; }
|
||||
updateVisibility();
|
||||
saveTTS();
|
||||
});
|
||||
modelSelect.addEventListener('change', saveAndClearCache);
|
||||
modelInput.addEventListener('change', saveTTS);
|
||||
voiceSelect.addEventListener('change', saveAndClearCache);
|
||||
voiceInput.addEventListener('change', saveTTS);
|
||||
speedSelect.addEventListener('change', saveAndClearCache);
|
||||
if (ttsEnabledToggle) ttsEnabledToggle.addEventListener('change', function() { syncTtsDisabled(); saveTTS(); });
|
||||
|
||||
// Preview / test button
|
||||
var previewBtn = el('set-ttsPreviewBtn');
|
||||
if (previewBtn) {
|
||||
var previewAudio = null;
|
||||
var previewPlaying = false;
|
||||
function resetPreview() { previewPlaying = false; previewBtn.textContent = 'Preview'; previewBtn.style.borderColor = ''; }
|
||||
|
||||
previewBtn.addEventListener('click', async function() {
|
||||
if (previewPlaying) {
|
||||
if (previewAudio) { previewAudio.pause(); previewAudio = null; }
|
||||
window.speechSynthesis.cancel();
|
||||
resetPreview(); return;
|
||||
}
|
||||
var prov = provSel.value;
|
||||
if (prov === 'disabled') {
|
||||
ttsMsg.textContent = 'Select a provider first'; ttsMsg.style.color = 'var(--red, #e55)';
|
||||
setTimeout(function() { ttsMsg.textContent = ''; }, 2000); return;
|
||||
}
|
||||
var testText = 'Hello, this is a test of text to speech.';
|
||||
previewPlaying = true; previewBtn.textContent = 'Loading...';
|
||||
try {
|
||||
if (prov === 'browser') {
|
||||
if (!('speechSynthesis' in window)) throw new Error('Browser TTS not supported');
|
||||
var utt = new SpeechSynthesisUtterance(testText);
|
||||
var voiceVal = getVoice();
|
||||
if (voiceVal) {
|
||||
var voices = window.speechSynthesis.getVoices();
|
||||
var target = voiceVal.toLowerCase();
|
||||
var match = voices.find(function(v) { return v.name.toLowerCase() === target; }) ||
|
||||
voices.find(function(v) { return v.name.toLowerCase().includes(target); });
|
||||
if (match) utt.voice = match;
|
||||
}
|
||||
utt.rate = parseFloat(speedSelect.value) || 1;
|
||||
previewBtn.textContent = 'Stop'; previewBtn.style.borderColor = 'var(--red, #e55)';
|
||||
await new Promise(function(resolve, reject) {
|
||||
utt.onend = resolve;
|
||||
utt.onerror = function(e) { reject(new Error('Browser TTS: ' + e.error)); };
|
||||
window.speechSynthesis.speak(utt);
|
||||
});
|
||||
} else {
|
||||
var res = await fetch('/api/tts/synthesize', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: testText, format: 'audio' })
|
||||
});
|
||||
if (!res.ok) { var err = await res.json().catch(function() { return {}; }); throw new Error(err.detail?.message || 'Synthesis failed'); }
|
||||
var blob = await res.blob();
|
||||
var url = URL.createObjectURL(blob);
|
||||
previewAudio = new Audio(url);
|
||||
previewBtn.textContent = 'Stop'; previewBtn.style.borderColor = 'var(--red, #e55)';
|
||||
await new Promise(function(resolve, reject) {
|
||||
previewAudio.onended = function() { URL.revokeObjectURL(url); previewAudio = null; resolve(); };
|
||||
previewAudio.onerror = function() { URL.revokeObjectURL(url); previewAudio = null; reject(new Error('Playback failed')); };
|
||||
previewAudio.play().catch(reject);
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
ttsMsg.textContent = 'Preview failed: ' + e.message; ttsMsg.style.color = 'var(--red, #e55)';
|
||||
setTimeout(function() { ttsMsg.textContent = ''; }, 3000);
|
||||
} finally {
|
||||
resetPreview();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function initSttSettings() {
|
||||
var provSel = el('set-sttProviderSelect');
|
||||
var modelSelect = el('set-sttModelSelect');
|
||||
var modelInput = el('set-sttModelInput');
|
||||
var modelRow = el('set-sttModelRow');
|
||||
var langRow = el('set-sttLangRow');
|
||||
var langInput = el('set-sttLangInput');
|
||||
var sttMsg = el('set-sttSettingsMsg');
|
||||
var sttEnabledToggle = el('set-sttEnabledToggle');
|
||||
var sttConfigWrap = el('set-sttConfigWrap');
|
||||
// STT was removed from AI Defaults — bail if the UI isn't present.
|
||||
if (!provSel) return;
|
||||
|
||||
function isEndpoint() { return provSel.value.startsWith('endpoint:'); }
|
||||
function getModel() { return isEndpoint() ? modelInput.value : modelSelect.value; }
|
||||
|
||||
function updateVisibility() {
|
||||
var prov = provSel.value;
|
||||
var showModel = prov === 'local' || prov.startsWith('endpoint:');
|
||||
var showLang = prov !== 'disabled';
|
||||
modelRow.style.display = showModel ? 'flex' : 'none';
|
||||
langRow.style.display = showLang ? 'flex' : 'none';
|
||||
if (isEndpoint()) {
|
||||
modelSelect.style.display = 'none'; modelInput.style.display = '';
|
||||
} else {
|
||||
modelSelect.style.display = ''; modelInput.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function syncSttDisabled() {
|
||||
var off = sttEnabledToggle && !sttEnabledToggle.checked;
|
||||
var card = sttEnabledToggle ? sttEnabledToggle.closest('.admin-card') : null;
|
||||
if (card) card.style.opacity = off ? '0.45' : '';
|
||||
if (sttConfigWrap) sttConfigWrap.style.pointerEvents = off ? 'none' : '';
|
||||
}
|
||||
|
||||
// Effective provider: if toggle is off, treat as disabled regardless of provider select
|
||||
function effectiveProvider() {
|
||||
if (sttEnabledToggle && !sttEnabledToggle.checked) return 'disabled';
|
||||
return provSel.value;
|
||||
}
|
||||
|
||||
// Add API endpoints that might support STT
|
||||
try {
|
||||
var epRes = await fetch('/api/model-endpoints', { credentials: 'same-origin' });
|
||||
var endpoints = await epRes.json();
|
||||
endpoints.forEach(function(ep) {
|
||||
if (!ep.is_enabled) return;
|
||||
var opt = document.createElement('option'); opt.value = 'endpoint:' + ep.id; opt.textContent = ep.name + ' (API)'; provSel.appendChild(opt);
|
||||
});
|
||||
} catch (e) { console.warn('Failed to load endpoints for STT', e); }
|
||||
|
||||
// Load saved settings
|
||||
try {
|
||||
var settingsRes = await fetch('/api/auth/settings', { credentials: 'same-origin' });
|
||||
var settings = await settingsRes.json();
|
||||
if (settings.stt_provider) provSel.value = settings.stt_provider;
|
||||
if (settings.stt_model) { modelSelect.value = settings.stt_model; modelInput.value = settings.stt_model; }
|
||||
if (settings.stt_language) langInput.value = settings.stt_language;
|
||||
if (sttEnabledToggle) sttEnabledToggle.checked = settings.stt_enabled !== false;
|
||||
} catch (e) { console.warn('Failed to load STT settings', e); }
|
||||
|
||||
syncSttDisabled();
|
||||
updateVisibility();
|
||||
|
||||
async function saveSTT() {
|
||||
try {
|
||||
var enabled = sttEnabledToggle ? sttEnabledToggle.checked : false;
|
||||
await _postSettings({ stt_enabled: enabled, stt_provider: provSel.value, stt_model: getModel() || 'base', stt_language: langInput.value.trim() });
|
||||
sttMsg.textContent = 'Saved'; sttMsg.style.color = 'var(--fg)'; setTimeout(() => { sttMsg.textContent = ''; }, 2000);
|
||||
// Notify voiceRecorder of effective provider and update send button icon
|
||||
if (window.voiceRecorderModule) window.voiceRecorderModule._sttProvider = effectiveProvider();
|
||||
if (window._updateSendBtnIcon) window._updateSendBtnIcon();
|
||||
} catch (e) { sttMsg.textContent = 'Failed to save'; sttMsg.style.color = 'var(--red)'; }
|
||||
}
|
||||
|
||||
provSel.addEventListener('change', function() { updateVisibility(); saveSTT(); });
|
||||
modelSelect.addEventListener('change', saveSTT);
|
||||
modelInput.addEventListener('change', saveSTT);
|
||||
langInput.addEventListener('change', saveSTT);
|
||||
if (sttEnabledToggle) sttEnabledToggle.addEventListener('change', function() { syncSttDisabled(); saveSTT(); });
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// Document writing-style settings panel.
|
||||
// Extracted verbatim from static/js/settings.js. Behaviour is unchanged: the
|
||||
// functions moved, their bodies did not. settings.js imports them and calls
|
||||
// them from the same places, so load order and init sequence are untouched.
|
||||
|
||||
import { byId as el } from './dom.js';
|
||||
import { postSettings as _postSettings } from './api.js';
|
||||
|
||||
export async function initDocumentWritingStyle() {
|
||||
const styleEl = el('set-document-style');
|
||||
const saveBtn = el('set-document-style-save');
|
||||
const extractBtn = el('set-document-style-extract');
|
||||
const fileEl = el('set-document-style-file');
|
||||
const msg = el('set-document-style-msg');
|
||||
if (!styleEl || !saveBtn) return;
|
||||
try {
|
||||
const res = await fetch('/api/auth/settings', { credentials: 'same-origin' });
|
||||
const data = await res.json();
|
||||
styleEl.value = String(data.document_writing_style || '');
|
||||
} catch (_) {
|
||||
if (msg) msg.textContent = 'Failed to load';
|
||||
}
|
||||
saveBtn.addEventListener('click', async () => {
|
||||
if (msg) msg.textContent = 'Saving...';
|
||||
try {
|
||||
const res = await _postSettings({ document_writing_style: styleEl.value });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
if (msg) msg.textContent = '✓ Saved';
|
||||
} catch (_) {
|
||||
if (msg) msg.textContent = 'Failed to save';
|
||||
}
|
||||
setTimeout(() => { if (msg) msg.textContent = ''; }, 3000);
|
||||
});
|
||||
extractBtn?.addEventListener('click', () => fileEl?.click());
|
||||
fileEl?.addEventListener('change', async () => {
|
||||
const file = fileEl.files?.[0];
|
||||
if (!file) return;
|
||||
extractBtn.disabled = true;
|
||||
let whirlpool = null;
|
||||
if (msg) {
|
||||
msg.replaceChildren();
|
||||
try {
|
||||
const spinner = window.spinnerModule || (await import('../spinner.js')).default;
|
||||
whirlpool = spinner.createWhirlpool(14);
|
||||
whirlpool.element.style.cssText = 'display:inline-flex;width:14px;height:14px;margin-right:7px;';
|
||||
const label = document.createElement('span');
|
||||
label.textContent = 'Analyzing...';
|
||||
msg.append(whirlpool.element, label);
|
||||
} catch (_) {
|
||||
msg.textContent = 'Analyzing...';
|
||||
}
|
||||
}
|
||||
try {
|
||||
const body = new FormData();
|
||||
body.append('file', file);
|
||||
const res = await fetch('/api/auth/settings/document-style/extract', {
|
||||
method: 'POST', credentials: 'same-origin', body,
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || !data.style) throw new Error(data.detail || data.error || 'Extraction failed');
|
||||
styleEl.value = String(data.style);
|
||||
if (msg) msg.textContent = '✓ Extracted — review and save';
|
||||
} catch (error) {
|
||||
if (msg) msg.textContent = error.message || 'Extraction failed';
|
||||
} finally {
|
||||
whirlpool?.destroy?.();
|
||||
extractBtn.disabled = false;
|
||||
fileEl.value = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -316,7 +316,7 @@ class SmoothPad {
|
||||
}
|
||||
|
||||
function _modal(innerHtml) {
|
||||
// Match the app's standard .modal pattern (defined in static/style.css).
|
||||
// Match the app's standard .modal pattern from the main stylesheet cascade.
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'modal sig-modal-overlay';
|
||||
overlay.style.cssText = 'pointer-events:auto;background:rgba(0,0,0,0.45);z-index:10100;';
|
||||
|
||||
+4
-4
@@ -106,7 +106,7 @@
|
||||
<style>
|
||||
@font-face { font-family: 'Fira Code'; font-weight: 400; font-style: normal; font-display: swap; src: url('static/fonts/FiraCode-Regular.woff2') format('woff2'); }
|
||||
@font-face { font-family: 'Fira Code'; font-weight: 600; font-style: normal; font-display: swap; src: url('static/fonts/FiraCode-SemiBold.woff2') format('woff2'); }
|
||||
/* Mirror the main app's :root defaults (static/style.css ~line 18) so an
|
||||
/* Mirror the main app's :root defaults from the stylesheet cascade so an
|
||||
uncustomized theme — or a fresh browser with no `odysseus-theme` in
|
||||
localStorage — renders the login page in the same palette as the rest
|
||||
of the app instead of a different greys+offwhite scheme. */
|
||||
@@ -115,7 +115,7 @@
|
||||
--red: #e06c75; --green: #50fa7b;
|
||||
--bg-effect-intensity: 1;
|
||||
}
|
||||
/* Background patterns — mirrors static/style.css "Background Patterns"
|
||||
/* Background patterns — mirrors the main app "Background Patterns" styles
|
||||
block so the theme's chosen bgPattern renders on login too. The
|
||||
canvas-based effects (rain, constellations, perlin-flow, petals,
|
||||
sparkles, embers) are spawned by the deferred theme.js import below;
|
||||
@@ -157,7 +157,7 @@
|
||||
font-size: 2rem; font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
/* Same gradient as the main app's welcome-screen "Odysseus" title
|
||||
(static/style.css #welcome-screen .welcome-name) so the brand
|
||||
(the main cascade's #welcome-screen .welcome-name) so the brand
|
||||
wordmark is consistent across login + first-launch screens. */
|
||||
background: linear-gradient(135deg, var(--brand-color, var(--red)), color-mix(in srgb, var(--brand-color, var(--red)) 60%, var(--fg)));
|
||||
-webkit-background-clip: text;
|
||||
@@ -175,7 +175,7 @@
|
||||
/* On touch devices keep inputs at >=16px so iOS Safari doesn't zoom the whole
|
||||
page when a field is focused (it auto-zooms any focused input under 16px).
|
||||
This page has its own inline styles, so it doesn't inherit the main app's
|
||||
equivalent rule in static/style.css; mirror it here. !important also lifts
|
||||
equivalent rule in the main stylesheet cascade; mirror it here. !important also lifts
|
||||
the dynamically-inserted 2FA input, which pins font-size:14px inline. */
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
input:not(.remember-check) { font-size: 16px !important; }
|
||||
|
||||
-47530
File diff suppressed because it is too large
Load Diff
+23
-6
@@ -39,11 +39,28 @@ const KATEX_FONTS = [
|
||||
// exact URL the browser requests, query string included.
|
||||
const PRECACHE = [
|
||||
'/',
|
||||
'/static/style.css?v=20260921chatgptusage2',
|
||||
'/static/css/documents-gallery-editor.css?v=20260923csssplit1',
|
||||
'/static/css/email-calendar-notes-tasks.css?v=20260923csssplit2',
|
||||
'/static/css/cookbook-research-memory-settings.css?v=20260923csssplit3',
|
||||
'/static/app.js?v=20260921chatgptusage2',
|
||||
'/static/css/00-tokens.css?v=20260929csssplit',
|
||||
'/static/css/01-agent-chat.css?v=20260929csssplit',
|
||||
'/static/css/02-compare.css?v=20260929csssplit',
|
||||
'/static/css/03-agent-chat.css?v=20260929csssplit',
|
||||
'/static/css/04-memory.css?v=20260929csssplit',
|
||||
'/static/css/05-documents.css?v=20260929csssplit',
|
||||
'/static/css/06-admin-settings.css?v=20260929csssplit',
|
||||
'/static/css/07-documents.css?v=20260929csssplit',
|
||||
'/static/css/08-skills.css?v=20260929csssplit',
|
||||
'/static/css/09-gallery.css?v=20260929csssplit',
|
||||
'/static/css/10-cookbook.css?v=20260929csssplit',
|
||||
'/static/css/11-tasks.css?v=20260929csssplit',
|
||||
'/static/css/12-gallery.css?v=20260929csssplit',
|
||||
'/static/css/13-image-editor.css?v=20260929csssplit',
|
||||
'/static/css/14-email.css?v=20260929csssplit',
|
||||
'/static/css/15-notes.css?v=20260929csssplit',
|
||||
'/static/css/16-calendar.css?v=20260929csssplit',
|
||||
'/static/css/17-research.css?v=20260929csssplit',
|
||||
'/static/css/documents-gallery-editor.css?v=20260929csssplit',
|
||||
'/static/css/email-calendar-notes-tasks.css?v=20260929csssplit',
|
||||
'/static/css/cookbook-research-memory-settings.css?v=20260929csssplit',
|
||||
'/static/app.js?v=20260929csssplit',
|
||||
'/static/js/storage.js',
|
||||
'/static/js/appConfig.js',
|
||||
'/static/js/ui.js?v=20260916largetoolscroll1',
|
||||
@@ -78,7 +95,7 @@ const PRECACHE = [
|
||||
'/static/js/theme.js?v=20260911organsrain1',
|
||||
'/static/js/censor.js',
|
||||
'/static/js/settings.js?v=20260912writingstyle3',
|
||||
'/static/js/admin.js?v=20260921chatgptusage2',
|
||||
'/static/js/admin.js?v=20260929csssplit',
|
||||
'/static/js/chatgptSubscriptionUsage.js',
|
||||
'/static/js/init.js?v=20260829chatstyle12',
|
||||
'/static/js/slashCommands.js?v=20260921chatgptusage1',
|
||||
|
||||
Reference in New Issue
Block a user