Changeset 0.25.1 (#161)

This commit is contained in:
2026-03-08 18:54:53 +00:00
parent 2b01d540d6
commit b3f8b747dd
48 changed files with 531 additions and 888 deletions

View File

@@ -1 +1 @@
0.25.0
0.25.1

View File

@@ -393,50 +393,101 @@ Vanilla JavaScript, no framework. The frontend ships as static files served by n
```
src/
├── index.html # Single-page app shell
├── sw.js # Service worker (offline cache)
├── index.html # Redirect stub → server-rendered surfaces
├── sw.js # Service worker (offline cache, BUILD_HASH)
├── manifest.json # PWA manifest
├── css/
│ ├── styles.css # Core styles (CSS variables, light/dark themes)
── memory.css # Memory settings + admin review panel styles
│ ├── variables.css # Theme vars, reset, base styles
── layout.css # App shell, workspace, sidebar, responsive
│ ├── primitives.css # Buttons, forms, toasts, toggles, badges, icon-btn
│ ├── modals.css # Modal system, debug, command palette, confirm
│ ├── chat.css # Chat area, markdown, input, files, lightbox
│ ├── panels.css # Side panel, preview, notes, graph
│ ├── surfaces.css # Admin, settings, editor, projects, notes layouts
│ ├── splash.css # Auth splash, PWA banner
│ ├── chat-pane.css # ChatPane component styles
│ ├── pane-container.css # PaneContainer resize/tab styles
│ ├── user-menu.css # UserMenu flyout styles
│ ├── editor-surface.css # Editor surface 3-pane layout
│ ├── channel-models.css # @mention autocomplete, model roster
│ ├── memory.css # Memory settings + admin review
│ ├── notifications.css # Notification dropdown + list
│ ├── notification-prefs.css # SMTP config, notification pref table
│ ├── persona-kb.css # Persona KB picker panel
│ ├── admin-surfaces.css # Admin surface management
│ └── tool-grants.css # Persona tool grants checkbox list
├── js/
│ ├── ── Primitives (base.html, all surfaces) ──
│ ├── app-state.js # Global state (App, API refs)
│ ├── api.js # HTTP client with token refresh
│ ├── app.js # Application state machine, startup, routing
│ ├── chat.js # Chat input abstraction, message send/receive
│ ├── channel-models.js # @mention autocomplete, channel model roster
│ ├── events.js # Labeled event bus + WebSocket bridge
│ ├── debug.js # Debug panel, diagnostics, state export
├── ui-core.js # DOM rendering, sidebar, modals, panels
├── ui-format.js # Markdown rendering, @mention pills, code blocks
├── ui-settings.js # Settings tabs, theme, appearance, teams
├── ui-admin.js # Admin panel rendering
│ ├── admin-handlers.js # Admin CRUD operations + extension editors
│ ├── ui-primitives.js # esc(), componentMixin, Providers, Roles,
# renderCapBadges, renderProviderForm/List,
│ # renderRoleConfig, renderUsageDashboard,
│ # showConfirm, showPrompt, openModal/closeModal,
│ # Theme, mountAvatarUpload
│ ├── ui-format.js # Markdown rendering, code blocks, time formatting
│ ├── ui-core.js # DOM rendering, sidebar, navigation, toast
│ ├── pages.js # Page routing, splash, login
│ │
│ ├── ── Components (base.html, all surfaces) ──
│ ├── user-menu.js # UserMenu template+factory component
│ ├── model-selector.js # ModelSelector template+factory component
│ ├── file-tree.js # FileTree template+factory component
│ ├── code-editor.js # CodeEditor template+factory component (CM6)
│ ├── note-editor.js # NoteEditor template+factory component
│ ├── chat-pane.js # ChatPane template+factory component
│ ├── pane-container.js # PaneContainer (resizable multi-pane layout)
│ ├── debug.js # Debug panel + diagnostics
│ ├── repl.js # Browser REPL console
│ │
│ ├── ── Surface: Chat ──
│ ├── app.js # Chat surface boot, auth, WebSocket
│ ├── chat.js # Chat message send/receive, stream
│ ├── extensions.js # Extension framework + sandbox
│ ├── panels.js # Side panel registry
│ ├── ui-settings.js # Settings modal tabs (chat surface)
│ ├── ui-admin.js # Admin navigation + section routing
│ ├── admin-handlers.js # Admin CRUD + extension editors
│ ├── admin-scaffold.js # Admin section HTML scaffolding
│ ├── settings-handlers.js # User settings CRUD, command palette
│ ├── tokens.js # Input token counting + context budget
│ ├── attachments.js # File upload, paste-to-file, lightbox
│ ├── files.js # File upload, paste-to-file, lightbox
│ ├── notes.js # Notes panel CRUD + graph + daily notes
│ ├── note-graph.js # Canvas force-directed graph visualization
│ ├── projects-ui.js # Project sidebar, context menu, drag-and-drop
│ ├── knowledge.js # Knowledge base UI
│ ├── memory-ui.js # Memory settings tab + admin review panel
│ ├── projects-ui.js # Project sidebar, context menu, DnD
│ ├── channel-models.js # Channel model roster + @mention AC
│ ├── tools-toggle.js # Tool enable/disable popup
│ ├── knowledge-ui.js # Knowledge base picker + management
│ ├── persona-kb.js # Persona KB assignment panel
│ ├── memory-ui.js # Memory settings + admin review
│ ├── notifications.js # Notification bell + dropdown
│ ├── notification-prefs.js # SMTP config + notification prefs
│ │
│ ├── ── Surface: Editor ──
│ ├── editor-surface.js # Editor 3-pane boot, workspace management
│ │
│ ├── ── Surface: Admin ──
│ ├── admin-surfaces.js # Surface management (admin only)
│ │
│ ├── ── Surface: Login ──
│ ├── pages-splash.js # Login/register splash page
│ │
│ └── __tests__/ # Node.js test suite (node --test)
├── editor/ # CM6 source (compiled at build time)
│ ├── package.json # CM6 + language mode dependencies
│ ├── package-lock.json # Lockfile for npm ci
│ ├── build.mjs # esbuild script → IIFE bundle
│ ├── index.mjs # Bundle entrypoint (window.CM)
│ ├── chat-input.mjs # Chat input factory (markdown, code blocks)
│ ├── code-editor.mjs # Code editor factory (syntax, keybindings)
│ ├── note-editor.mjs # Note editor factory (markdown + wikilinks)
│ ├── wikilink.mjs # CM6 wikilink extension (parse, decorate, autocomplete)
│ └── theme.mjs # Switchboard + chat input + note editor themes
└── vendor/ # Vendored libraries (local + CDN fallback)
├── marked/ # Markdown renderer
├── purify/ # DOMPurify (XSS protection)
── mermaid/ # Diagram renderer
├── katex/ # Math renderer
└── codemirror/ # CM6 bundle (built by Docker)
└── codemirror.bundle.js # ~295KB min, ~90KB gzip
│ ├── wikilink.mjs # CM6 wikilink extension
│ └── theme.mjs # CM6 themes (dark/light)
└── vendor/ # Vendored libraries (loaded in base.html)
├── marked.min.js # Markdown renderer
├── purify.min.js # DOMPurify (XSS protection)
── codemirror/
└── codemirror.bundle.js # CM6 bundle (~295KB min, ~90KB gzip)
```
### CM6 Integration

View File

@@ -1,8 +1,8 @@
# DESIGN — Surface & Extension Architecture
**Status:** Accepted
**Status:** Accepted (Phases 12 implemented in v0.25.1)
**Scope:** Primitives, Components, Surfaces, Extension hooks, Themes
**Depends on:** v0.22.5 (Go template engine), v0.22.7 (ChatPane)
**Depends on:** v0.22.5 (Go template engine), v0.22.7 (ChatPane), v0.25.1 (audit)
**Informs:** v0.22.8+, EXTENSIONS.md rewrite, ARCHITECTURE.md update, ICD-API, ICD-SURFACE
---
@@ -159,7 +159,9 @@ Primitives.menu({ anchor: el, items: [...], onSelect: fn })
Primitives.dialog({ title: 'Confirm', body: el, onConfirm: fn })
```
Current locations: `ui-primitives.js`, `ui-primitives-additions.js`,
Current locations: `ui-primitives.js` (factory functions, `esc()`,
`createComponentRegistry()`, `componentMixin()`, `showConfirm()`,
`showPrompt()`, `Theme`, `mountAvatarUpload()`)
and Go template components (`model-select.html`, `team-select.html`,
`file-upload.html`). These converge into one coherent set.
@@ -202,23 +204,42 @@ to interact with.
### Instance Pattern
Components use the factory pattern established by ChatPane:
Components use `createComponentRegistry()` and `componentMixin()` from
`ui-primitives.js` for shared lifecycle (instance tracking, event
listener cleanup, destroy):
```js
const pane = ChatPane.create({
messagesEl: document.getElementById('editorChatMessages'),
inputEl: document.getElementById('editorChatInput'),
sendBtnEl: document.getElementById('editorSendBtn'),
channelId: workspaceChatId,
standalone: true,
});
const ChatPane = {
...createComponentRegistry('ChatPane'),
// Lifecycle
pane.renderMessages(msgs);
pane.streamResponse(resp, msgs);
pane.destroy();
create(opts) {
const instance = componentMixin({
id: opts.id || 'pane-' + Date.now(),
messagesEl: opts.messagesEl,
inputEl: opts.inputEl,
channelId: opts.channelId || null,
// ... domain-specific state
// Component-specific teardown (called by mixin's destroy())
_cleanup() {
if (this._cmView) { this._cmView.destroy(); this._cmView = null; }
},
}, ChatPane);
ChatPane._register(instance.id, instance);
return instance;
},
// Custom queries beyond .get(id)
forChannel(channelId) { /* ... */ },
};
```
The mixin provides `_on(el, event, handler)` for tracked event binding
and `destroy()` which calls `_cleanup()` then removes all listeners and
unregisters from the registry. Components that need extra teardown define
`_cleanup()`.
Components can be instantiated multiple times on the same page (editor
has a ChatPane, notes has a ChatPane — independent instances).
@@ -567,7 +588,7 @@ accommodates it without redesign.
b. Register hooks (pre-completion, post-render, etc.)
c. Register surface injections for current surface
d. Initialize tool bridges
5. Surface-specific JS runs (app.js for chat, editor-mode.js, etc.)
5. Surface-specific JS runs (app.js for chat, editor-surface.js, etc.)
```
`Extensions.boot()` replaces the current chat-specific
@@ -672,8 +693,8 @@ post-render hooks use to discover display content from any extension.
| Current State | Target State |
|---------------|-------------|
| `ui-primitives.js` + `ui-primitives-additions.js` | Primitive catalog with stable factory API |
| `ChatPane.create()` (v0.22.7) | Component catalog with shared instance pattern |
| `ui-primitives.js` (merged, with `esc()`, `createComponentRegistry()`, `componentMixin()`) | ✅ Done (v0.25.1) |
| `ChatPane.create()` + 5 other components using shared mixin | ✅ Done (v0.25.1) |
| 5 Go template routes | Surface registry (hardcoded core, manifest-driven extensions) |
| `ctx.ui.replace()` / `ctx.ui.inject()` | Hook 6: Surface Injection with declared mount points |
| `ctx.surfaces.register()` | Manifest `surfaces` field → page engine route registration |
@@ -688,17 +709,22 @@ post-render hooks use to discover display content from any extension.
This does not prescribe version numbers. It describes dependency order.
**Phase 1: Primitive consolidation**
- Audit `ui-primitives.js`, `ui-primitives-additions.js`, and Go
template components
- Define the primitive catalog and factory API
- Define the CSS custom property contract (theme API)
- Migrate existing callers to the consolidated API
**Phase 1: Primitive consolidation** ✅ (v0.25.1)
- `ui-primitives-additions.js` deleted, survivors merged into
`ui-primitives.js`
- `esc()` centralized (13 private copies eliminated)
- Badge system unified (24 scattered classes → grouped in primitives.css)
- CSS variable names normalized, duplicate selectors resolved
- `showConfirm()` is the sole confirm pattern (bare `confirm()` purged)
**Phase 2: Component formalization**
- ChatPane already exists (v0.22.7). Formalize the instance pattern.
- Extract NoteEditor, FileTree, ModelSelector as components
- Each component gets a Go template partial + JS hydration
**Phase 2: Component formalization** ✅ (v0.25.1)
- `createComponentRegistry()` + `componentMixin()` extracted
- 6 components (ChatPane, ModelSelector, UserMenu, CodeEditor, FileTree,
NoteEditor) use the shared mixin. ~100 lines of boilerplate eliminated.
- Each component has a Go template partial + JS hydration
- `surface-nav.js` deleted; navigation is native in `ui-core.js`
- Vendor scripts (`marked.min.js`, `purify.min.js`) and `ui-format.js`
moved to `base.html` (loaded once, available to all surfaces)
**Phase 3: Extension hooks**
- `Extensions.boot()` on every surface (v0.22.8)

View File

@@ -1,6 +1,6 @@
# Surface Development Guide
**Version:** 0.25.0
**Version:** 0.25.1
**Audience:** Anyone with admin access to a Chat Switchboard instance
---

View File

@@ -90,7 +90,9 @@
</script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/events.js?v={{.Version}}"></script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/ui-primitives.js?v={{.Version}}"></script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/ui-primitives-additions.js?v={{.Version}}"></script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/vendor/marked.min.js"></script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/vendor/purify.min.js"></script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/ui-format.js?v={{.Version}}"></script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/ui-core.js?v={{.Version}}"></script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/pages.js?v={{.Version}}"></script>
{{/* v0.25.0: Component scripts — available on all surfaces */}}
@@ -156,6 +158,7 @@
</div>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/debug.js?v={{.Version}}"></script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/repl.js?v={{.Version}}"></script>
<div id="toastContainer" class="toast-container"></div>
</body>
</html>
{{end}}

View File

@@ -77,25 +77,6 @@
</div>
</div>
<div id="toastContainer" class="toast-container"></div>
{{/* Confirm dialog */}}
<div id="confirmModal" class="modal-overlay">
<div class="modal" style="max-width:380px;">
<div class="modal-header">
<h2 id="confirmTitle">Confirm</h2>
<button class="modal-close" onclick="closeModal('confirmModal')">&#10005;</button>
</div>
<div class="modal-body">
<p id="confirmMessage" style="color:var(--text-2);font-size:14px;line-height:1.65;"></p>
</div>
<div class="modal-footer">
<button class="btn-md btn-ghost" onclick="closeModal('confirmModal')">Cancel</button>
<button id="confirmOkBtn" class="btn-md btn-danger">Confirm</button>
</div>
</div>
</div>
{{/* Create User modal */}}
<div id="createUserModal" class="modal-overlay">
<div class="modal" style="max-width:440px;">
@@ -229,9 +210,6 @@
{{/* Scripts */}}
{{define "scripts-admin"}}
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/vendor/marked.min.js"></script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/vendor/purify.min.js"></script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/ui-format.js?v={{.Version}}"></script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/ui-settings.js?v={{.Version}}"></script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/ui-admin.js?v={{.Version}}"></script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/admin-handlers.js?v={{.Version}}"></script>

View File

@@ -334,9 +334,6 @@ window.addEventListener('unhandledrejection', function(e) {
{{/* Side panel overlay (mobile) */}}
<div id="sidePanelOverlay" class="side-panel-overlay" style="display:none;"></div>
{{/* ── Toast Container ─────────────────────── */}}
<div id="toastContainer" class="toast-container"></div>
{{/* ── Lightbox ────────────────────────────── */}}
<div id="lightbox" class="lightbox" onclick="closeLightbox()">
<img id="lightboxImg" src="" alt="">
@@ -353,23 +350,7 @@ window.addEventListener('unhandledrejection', function(e) {
{{/* ── Hidden File Input ─────── */}}
<input type="file" id="fileInput" multiple style="display:none;">
{{/* ── Settings Modal ──────────────────────── */}}
<div id="settingsModal" class="modal-overlay">
<div class="modal-content modal-lg">
<div class="modal-header">
<h2>Settings</h2>
<button id="settingsCloseBtn" class="modal-close" onclick="closeModal('settingsModal')"></button>
</div>
<div class="modal-tabs">
<button class="settings-tab active" data-stab="general" onclick="UI.switchSettingsTab('general')">General</button>
<button class="settings-tab" data-stab="appearance" onclick="UI.switchSettingsTab('appearance')">Appearance</button>
<button id="settingsProvidersTabBtn" class="settings-tab" data-stab="providers" onclick="UI.switchSettingsTab('providers')">My Providers</button>
<button id="settingsPersonasTabBtn" class="settings-tab" data-stab="personas" onclick="UI.switchSettingsTab('personas')">My Personas</button>
<button id="settingsUsageTabBtn" class="settings-tab" data-stab="usage" onclick="UI.switchSettingsTab('usage')">Usage</button>
<button class="settings-tab" data-stab="profile" onclick="UI.switchSettingsTab('profile')">Profile</button>
<button id="settingsRolesTabBtn" class="settings-tab" data-stab="roles" onclick="UI.switchSettingsTab('roles')" style="display:none;">Roles</button>
<button id="settingsKBTabBtn" class="settings-tab" data-stab="kb" onclick="UI.switchSettingsTab('kb')" style="display:none;">Knowledge</button>
<button id="settingsTeamsSection" class="settings-tab" data-stab="teams" onclick="UI.switchSettingsTab('teams')">Teams</button>
{{/* ── Team Admin Modal ────────────────────── */}}
</div>
<div class="modal-body">
{{/* General tab */}}
@@ -730,7 +711,6 @@ window.addEventListener('unhandledrejection', function(e) {
{{end}}
{{define "css-chat"}}
<link rel="stylesheet" href="{{.BasePath}}/css/editor-mode.css?v={{.Version}}">
<link rel="stylesheet" href="{{.BasePath}}/css/persona-kb.css?v={{.Version}}">
<link rel="stylesheet" href="{{.BasePath}}/css/memory.css?v={{.Version}}">
<link rel="stylesheet" href="{{.BasePath}}/css/notifications.css?v={{.Version}}">
@@ -740,15 +720,11 @@ window.addEventListener('unhandledrejection', function(e) {
{{end}}
{{define "scripts-chat"}}
{{/* Vendor libs */}}
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/vendor/marked.min.js" onerror="this.onerror=null;this.src='https://cdnjs.cloudflare.com/ajax/libs/marked/16.3.0/lib/marked.umd.min.js'"></script>
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/vendor/purify.min.js" onerror="this.onerror=null;this.src='https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.2.4/purify.min.js'"></script>
{{/* CM6 bundle — chat-specific (code input, extension editor) */}}
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/vendor/codemirror/codemirror.bundle.js?v={{$.Version}}" onerror="console.warn('[CM6] Bundle not available — falling back to textarea')"></script>
{{/* App JS — order matches index.html (minus api/events/ui-primitives already in base) */}}
{{/* debug.js and repl.js now loaded in base.html for all surfaces */}}
{{/* App JS — order matches index.html (minus api/events/ui-primitives/ui-format already in base) */}}
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/extensions.js?v={{$.Version}}"></script>
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/panels.js?v={{$.Version}}"></script>
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/ui-format.js?v={{$.Version}}"></script>
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/ui-settings.js?v={{$.Version}}"></script>
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/ui-admin.js?v={{$.Version}}"></script>
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/tokens.js?v={{$.Version}}"></script>
@@ -766,7 +742,5 @@ window.addEventListener('unhandledrejection', function(e) {
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/chat.js?v={{$.Version}}"></script>
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/settings-handlers.js?v={{$.Version}}"></script>
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/admin-handlers.js?v={{$.Version}}"></script>
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/surface-nav.js?v={{$.Version}}"></script>
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/editor-mode.js?v={{$.Version}}"></script>
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/app.js?v={{$.Version}}"></script>
{{end}}

View File

@@ -83,8 +83,6 @@
{{template "note-editor" dict "ID" "edNotes"}}
</div>
</div>
<div id="toastContainer" class="toast-container"></div>
{{end}}
{{/* ── CSS ─────────────────────────────────── */}}
@@ -94,10 +92,7 @@
{{/* ── Scripts ─────────────────────────────── */}}
{{define "scripts-editor"}}
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/vendor/marked.min.js"></script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/vendor/purify.min.js"></script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/vendor/codemirror/codemirror.bundle.js?v={{.Version}}" onerror="console.warn('[CM6] Bundle not available')"></script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/ui-format.js?v={{.Version}}"></script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/chat-pane.js?v={{.Version}}"></script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/editor-surface.js?v={{.Version}}"></script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/app.js?v={{.Version}}"></script>

View File

@@ -16,49 +16,14 @@
{{end}}
{{define "css-notes"}}
<link rel="stylesheet" href="{{.BasePath}}/css/editor-mode.css?v={{.Version}}">
<link rel="stylesheet" href="{{.BasePath}}/css/persona-kb.css?v={{.Version}}">
<link rel="stylesheet" href="{{.BasePath}}/css/memory.css?v={{.Version}}">
<link rel="stylesheet" href="{{.BasePath}}/css/notifications.css?v={{.Version}}">
<link rel="stylesheet" href="{{.BasePath}}/css/channel-models.css?v={{.Version}}">
<link rel="stylesheet" href="{{.BasePath}}/branding/custom.css" onerror="this.remove()">
<style>
.surface-notes {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.surface-notes-body {
flex: 1;
display: flex;
min-height: 0;
overflow: hidden;
}
.surface-notes-main {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
overflow: hidden;
}
.surface-notes-assist {
width: 400px;
border-left: 1px solid var(--border, #2a2a2a);
display: flex;
flex-direction: column;
overflow: hidden;
}
@media (max-width: 768px) {
.surface-notes-assist { display: none; }
}
</style>
{{end}}
{{define "scripts-notes"}}
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/vendor/marked.min.js"></script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/vendor/purify.min.js"></script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/ui-format.js?v={{.Version}}"></script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/chat.js?v={{.Version}}"></script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/files.js?v={{.Version}}"></script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/channel-models.js?v={{.Version}}"></script>

View File

@@ -89,9 +89,9 @@
<div class="settings-section">
<h3>Theme</h3>
<div id="themeToggle" class="toggle-group">
<button class="theme-btn" data-theme="light">&#9728; Light</button>
<button class="theme-btn" data-theme="dark">&#9790; Dark</button>
<button class="theme-btn" data-theme="system">&#8862; System</button>
<button class="toggle-btn" data-theme="light">&#9728; Light</button>
<button class="toggle-btn" data-theme="dark">&#9790; Dark</button>
<button class="toggle-btn" data-theme="system">&#8862; System</button>
</div>
</div>
<div class="settings-section">
@@ -150,8 +150,6 @@
</div>
</div>
</div>
<div id="toastContainer" class="toast-container"></div>
{{end}}
{{/* CSS: loaded via base.html (variables, layout, primitives, modals, chat, panels, surfaces, splash) */}}
@@ -159,9 +157,6 @@
{{/* Scripts */}}
{{define "scripts-settings"}}
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/vendor/marked.min.js"></script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/vendor/purify.min.js"></script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/ui-format.js?v={{.Version}}"></script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/ui-settings.js?v={{.Version}}"></script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/settings-handlers.js?v={{.Version}}"></script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/knowledge-ui.js?v={{.Version}}"></script>

View File

@@ -24,7 +24,7 @@
}
.admin-surface-row:hover {
background: var(--bg-tertiary, #1e1e22);
background: var(--bg-hover);
}
.admin-surface-info {

View File

@@ -53,7 +53,7 @@
}
.ch-model-pill-remove:hover {
opacity: 1;
color: var(--error-color, #d32f2f);
color: var(--danger);
}
.ch-model-add-btn {
@@ -151,7 +151,7 @@
.mention-ac-handle {
font-size: 0.72rem;
color: var(--accent, #6c9fff);
font-family: var(--font-mono, monospace);
font-family: var(--mono);
opacity: 0.8;
}
.mention-ac-model {

View File

@@ -164,13 +164,6 @@
color: var(--text-3);
}
.icon-btn {
background: none; border: none; color: var(--text-3); cursor: pointer;
padding: 4px; border-radius: 4px;
transition: color var(--transition);
}
.icon-btn:hover { color: var(--text); }
/* Messages — Bubble Layout (matching prototype) */
.messages { flex: 1; overflow-y: auto; scroll-behavior: smooth; }
@@ -650,9 +643,9 @@
/* ── Chat type indicators (v0.23.0) ────────── */
.chat-type-icon { font-size: 11px; opacity: 0.7; }
.mono-input { font-family: var(--font-mono, 'JetBrains Mono', monospace) !important; font-size: 12px !important; }
.mono-input { font-family: var(--mono) !important; font-size: 12px !important; }
/* Handle hints in dropdowns and pickers */
.item-handle { font-size: 10px; color: var(--accent); font-family: var(--font-mono, monospace); margin-left: 6px; opacity: 0.75; }
.group-persona-handle { font-size: 10px; color: var(--accent); font-family: var(--font-mono, monospace); margin-left: 4px; opacity: 0.75; }
.item-handle { font-size: 10px; color: var(--accent); font-family: var(--mono); margin-left: 6px; opacity: 0.75; }
.group-persona-handle { font-size: 10px; color: var(--accent); font-family: var(--mono); margin-left: 4px; opacity: 0.75; }

View File

@@ -47,7 +47,7 @@
.editor-topbar-back:hover {
color: var(--text, #eee);
background: var(--bg-tertiary, #1e1e22);
background: var(--bg-hover);
}
.editor-topbar-sep {
@@ -86,7 +86,7 @@
.editor-ws-selector-btn:hover {
border-color: var(--border, #2a2a2e);
background: var(--bg-tertiary, #1e1e22);
background: var(--bg-hover);
}
.editor-ws-dropdown {
@@ -128,7 +128,7 @@
}
.editor-ws-dropdown-item:hover {
background: var(--bg-tertiary, #1e1e22);
background: var(--bg-hover);
}
.editor-ws-dropdown-item.active {
@@ -247,7 +247,7 @@
.editor-bootstrap-ws-item:hover {
border-color: var(--accent, #b38a4e);
background: var(--bg-tertiary, #1e1e22);
background: var(--bg-hover);
}
.editor-bootstrap-ws-name {
@@ -307,7 +307,7 @@
}
.surface-editor .file-tree-row:hover {
background: var(--bg-tertiary, #1e1e22);
background: var(--bg-hover);
}
.surface-editor .file-tree-row.active {
@@ -361,7 +361,7 @@
}
.file-tree-ctx-item:hover {
background: var(--bg-tertiary, #1e1e22);
background: var(--bg-hover);
}
/* ── CodeEditor overrides ──────────────────── */
@@ -399,7 +399,7 @@
white-space: nowrap;
}
.surface-editor .code-editor-tab:hover { background: var(--bg-tertiary, #1e1e22); }
.surface-editor .code-editor-tab:hover { background: var(--bg-hover); }
.surface-editor .code-editor-tab.active { color: var(--text, #eee); background: var(--bg, #0e0e10); }
.surface-editor .code-editor-tab.modified .code-editor-tab-modified { color: var(--warning, #e5a842); }

View File

@@ -16,7 +16,7 @@
flex-direction: column;
align-items: center;
padding: 10px 20px;
background: var(--bg-2);
background: var(--bg-raised);
border-radius: 8px;
min-width: 80px;
}
@@ -93,7 +93,7 @@
/* ── Memory Card ───────────────────────── */
.memory-card {
background: var(--bg-2);
background: var(--bg-raised);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 12px;

View File

@@ -110,7 +110,6 @@
.provider-name { font-weight: 500; font-size: 13px; }
.provider-meta { font-size: 11px; color: var(--text-3); margin-left: 8px; }
.provider-actions { display: flex; align-items: center; gap: 8px; }
.badge-global { font-size: 10px; color: var(--text-3); background: var(--bg-raised); padding: 2px 8px; border-radius: 4px; }
/* Admin panel (fullscreen) */
.admin-panel {
@@ -232,7 +231,6 @@
.admin-persona-row .persona-actions { display: flex; gap: 6px; align-items: center; flex-shrink: 0; }
.admin-persona-row .btn-delete { background: none; border: none; color: var(--text-3); cursor: pointer; font-size: 16px; padding: 2px 6px; }
.admin-persona-row .btn-delete:hover { color: var(--danger); }
.badge-user { font-size: 10px; padding: 1px 6px; border-radius: 3px; background: var(--accent-dim); color: var(--accent); }
/* Stats cards */
.stats-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 12px; }
@@ -247,12 +245,12 @@
.audit-row { padding: 6px 0; border-bottom: 1px solid var(--border); font-size: 13px; }
.audit-row:last-child { border-bottom: none; }
.audit-main { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.audit-action { font-weight: 600; color: var(--accent); font-family: var(--font-mono, monospace); font-size: 12px; }
.audit-action { font-weight: 600; color: var(--accent); font-family: var(--mono); font-size: 12px; }
.audit-actor { color: var(--text); }
.audit-resource { color: var(--text-3); font-family: var(--font-mono, monospace); font-size: 11px; }
.audit-resource { color: var(--text-3); font-family: var(--mono); font-size: 11px; }
.audit-meta { display: flex; gap: 10px; margin-top: 2px; font-size: 11px; color: var(--text-3); flex-wrap: wrap; }
.audit-details { color: var(--text-2); }
.audit-ip { font-family: var(--font-mono, monospace); }
.audit-ip { font-family: var(--mono); }
.pagination-row { display: flex; align-items: center; justify-content: center; gap: 12px; }
/* Banner preview */
@@ -285,11 +283,6 @@
.model-list-item .model-provider { font-size: 12px; color: var(--text-3); }
.model-list-item .model-caps-inline { display: flex; gap: 3px; }
.badge-admin { background: var(--accent); color: var(--text-on-color); font-size: 10px; padding: 1px 8px; border-radius: 4px; }
.badge-user { background: var(--bg-raised); color: var(--text-3); font-size: 10px; padding: 1px 8px; border-radius: 4px; }
.badge-inactive { background: var(--danger); color: var(--text-on-color); font-size: 10px; padding: 1px 8px; border-radius: 4px; }
.badge-pending { background: var(--warning); color: var(--text-on-warning); font-size: 10px; padding: 1px 8px; border-radius: 4px; }
.badge-team { background: var(--accent-dim); color: var(--accent-light); font-size: 10px; padding: 1px 8px; border-radius: 4px; margin-left: 2px; }
.admin-approve-form { padding: 10px 12px; margin: -4px 0 8px; background: var(--glass); border-radius: 0 0 8px 8px; border-top: 1px solid var(--border); }
.admin-approve-form .checkbox-label { display: block; margin: 2px 0; }
.btn-approve { background: var(--success-dim); color: var(--success-light); border: 1px solid rgba(34,197,94,0.3); border-radius: 4px; padding: 3px 10px; cursor: pointer; font-size: 12px; }
@@ -301,22 +294,12 @@
}
.team-admin-back:hover { opacity: 1; }
.team-tab-content { padding: 0; }
.badge-private { background: var(--purple); color: var(--text-on-color); font-size: 10px; padding: 1px 8px; border-radius: 4px; }
.badge-success { background: var(--success-dim); color: var(--success-light); font-size: 10px; padding: 1px 8px; border-radius: 4px; }
.badge-warning { background: var(--warning-dim); color: var(--warning-light); font-size: 10px; padding: 1px 8px; border-radius: 4px; }
.badge-danger { background: var(--danger-dim); color: var(--danger-light); font-size: 10px; padding: 1px 8px; border-radius: 4px; }
/* Health + Routing admin (v0.22.3) */
.admin-health-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 12px; }
.admin-health-card { background: var(--bg-2); border: 1px solid var(--border); border-radius: 8px; padding: 12px; }
.list-card { background: var(--bg-2); border: 1px solid var(--border); border-radius: 8px; padding: 12px; margin-bottom: 8px; }
.admin-table { border-collapse: collapse; }
.admin-table th, .admin-table td { padding: 6px 10px; text-align: left; border-bottom: 1px solid var(--border); }
.admin-table th { font-weight: 600; color: var(--text-2); font-size: 11px; text-transform: uppercase; }
.admin-health-card { background: var(--bg-raised); border: 1px solid var(--border); border-radius: 8px; padding: 12px; }
.list-card { background: var(--bg-raised); border: 1px solid var(--border); border-radius: 8px; padding: 12px; margin-bottom: 8px; }
.admin-user-row.user-inactive { opacity: 0.7; }
.loading { color: var(--text-3); font-size: 13px; padding: 0.5rem; }
.error-hint { color: var(--danger); font-size: 13px; padding: 0.5rem; }
.empty-hint { color: var(--text-3); font-size: 13px; text-align: center; padding: 1rem; }
/* ── Debug Modal ─────────────────────────── */
@@ -455,7 +438,7 @@
.group-persona-item:hover { background: var(--bg-hover); }
.group-persona-item input[type="checkbox"] { accent-color: var(--accent); flex-shrink: 0; }
.group-persona-name { flex: 1; color: var(--text); font-weight: 500; }
.group-persona-model { font-size: 11px; color: var(--text-3); font-family: var(--font-mono); }
.group-persona-model { font-size: 11px; color: var(--text-3); font-family: var(--mono); }
.chain-typing-name { font-size: 11px; font-weight: 600; color: var(--accent); margin-right: 6px; }
/* ── Creation Dialog (project / channel / folder — v0.23.1) ── */
@@ -486,11 +469,3 @@
.sb-dialog-color-label { font-size: 12px; color: var(--text-2); }
.sb-dialog-color-input { width: 32px; height: 26px; border: 1px solid var(--border); border-radius: var(--radius); cursor: pointer; padding: 1px; background: none; }
.sb-dialog-actions { display: flex; justify-content: flex-end; gap: 8px; }
.sb-dialog-btn {
padding: 6px 14px; border-radius: var(--radius); font-size: 12px; font-family: inherit;
cursor: pointer; border: 1px solid var(--border);
background: var(--bg-hover); color: var(--text-2);
}
.sb-dialog-btn:hover { background: var(--bg-raised); }
.sb-dialog-btn-primary { background: var(--accent); color: #fff; border-color: var(--accent); }
.sb-dialog-btn-primary:hover { opacity: 0.88; }

View File

@@ -10,9 +10,9 @@
.notif-pref-table th {
text-align: left;
padding: 6px 8px;
border-bottom: 2px solid var(--border-color, #ddd);
border-bottom: 2px solid var(--border);
font-size: 0.8rem;
color: var(--text-secondary, #888);
color: var(--text-2);
font-weight: 500;
}
.notif-pref-table th:nth-child(2),
@@ -22,7 +22,7 @@
}
.notif-pref-table td {
padding: 8px;
border-bottom: 1px solid var(--border-color, #eee);
border-bottom: 1px solid var(--border);
}
.notif-pref-toggle {
text-align: center;
@@ -52,10 +52,10 @@
}
.smtp-test-status {
font-size: 0.85rem;
color: var(--text-secondary, #888);
color: var(--text-2);
}
.smtp-test-status.success { color: var(--success-color, #2e7d32); }
.smtp-test-status.error { color: var(--error-color, #d32f2f); }
.smtp-test-status.success { color: var(--success); }
.smtp-test-status.error { color: var(--danger); }
@media (max-width: 600px) {
.smtp-config-grid {

View File

@@ -314,10 +314,6 @@
.notes-select-all input { cursor: pointer; }
.notes-list { padding: 8px; }
.notes-empty, .notes-loading {
text-align: center; color: var(--text-3); padding: 2rem;
font-size: 13px;
}
.note-item {
display: flex; align-items: flex-start; gap: 8px;
@@ -475,7 +471,7 @@
background: var(--bg-surface);
}
.transclusion-header {
padding: 6px 10px; background: var(--bg-2);
padding: 6px 10px; background: var(--bg-raised);
border-bottom: 1px solid var(--border); font-size: 12px;
}
.transclusion-content {

View File

@@ -13,7 +13,7 @@
.persona-kb-list {
max-height: 200px;
overflow-y: auto;
border: 1px solid var(--border-color, #333);
border: 1px solid var(--border);
border-radius: var(--radius, 6px);
padding: 0.25rem;
}

View File

@@ -41,14 +41,6 @@ input[type="password"]:focus, textarea:focus {
}
select option { background: var(--bg-surface); color: var(--text); }
.form-group { margin-bottom: 0.75rem; }
.form-group label { display: block; font-size: 13px; color: var(--text-2); margin-bottom: 4px; font-weight: 500; }
.form-group input, .form-group select, .form-group textarea {
width: 100%;
}
.form-group input:focus, .form-group select:focus, .form-group textarea:focus {
outline: none; border-color: var(--accent);
}
.form-row { display: flex; gap: 0.75rem; }
.form-row .form-group { flex: 1; }
.form-hint { font-weight: 400; color: var(--text-3); margin-left: 4px; }
@@ -68,25 +60,6 @@ select option { background: var(--bg-surface); color: var(--text); }
.range-labels { display: flex; justify-content: space-between; font-size: 11px; color: var(--text-3); }
.checkbox-label { display: flex; align-items: center; gap: 8px; font-size: 14px; cursor: pointer; margin: 0.5rem 0; color: var(--text-2); }
/* ── Theme Toggle ────────────────────────── */
.theme-toggle {
display: flex; gap: 4px; padding: 3px;
background: var(--bg); border: 1px solid var(--border);
border-radius: var(--radius); width: fit-content;
}
.theme-btn {
display: flex; align-items: center; gap: 6px;
padding: 6px 14px; border-radius: calc(var(--radius) - 2px);
border: none; background: none; color: var(--text-3);
font-family: var(--font); font-size: 13px; font-weight: 500;
cursor: pointer; transition: all var(--transition);
}
.theme-btn:hover { color: var(--text); background: var(--bg-hover); }
.theme-btn.active {
color: var(--text); background: var(--bg-raised);
box-shadow: 0 1px 3px rgba(0,0,0,0.15);
}
/* ── Toast ────────────────────────────────── */
@@ -320,9 +293,6 @@ select option { background: var(--bg-surface); color: var(--text); }
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.kb-popup-meta { font-size: 11px; color: var(--text-3); }
.kb-popup-empty, .kb-popup-loading {
padding: 16px; text-align: center; font-size: 13px; color: var(--text-3);
}
/* KB management panel (settings tab) */
.kb-manage-header {
@@ -335,7 +305,7 @@ select option { background: var(--bg-surface); color: var(--text); }
.kb-manage-item {
display: flex; align-items: center; gap: 8px;
padding: 8px 10px; border-radius: var(--radius);
background: var(--bg-2); transition: background var(--transition);
background: var(--bg-raised); transition: background var(--transition);
}
.kb-manage-item:hover { background: var(--bg-hover); }
.kb-manage-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
@@ -344,9 +314,6 @@ select option { background: var(--bg-surface); color: var(--text); }
.kb-manage-stats { font-size: 11px; color: var(--text-3); }
.kb-manage-desc-block { font-size: 12px; color: var(--text-3); margin-bottom: 12px; }
.kb-manage-actions { display: flex; gap: 4px; flex-shrink: 0; }
.kb-manage-empty, .kb-manage-loading {
padding: 20px; text-align: center; font-size: 13px; color: var(--text-3);
}
.kb-doc-item[data-doc-status="pending"] .kb-manage-name,
.kb-doc-item[data-doc-status="extracting"] .kb-manage-name,
.kb-doc-item[data-doc-status="chunking"] .kb-manage-name,
@@ -363,20 +330,15 @@ select option { background: var(--bg-surface); color: var(--text); }
color: var(--text-2); margin-bottom: 5px;
}
.form-group input, .form-group select, .form-group textarea {
padding: 8px 12px; border-radius: 8px;
border: 1px solid var(--border);
background: var(--input-bg, var(--bg)); color: var(--text);
font-size: 13px; font-family: inherit;
width: 100%; max-width: 360px; outline: none;
transition: border-color 0.15s;
width: 100%; outline: none;
}
.form-group input:focus, .form-group select:focus, .form-group textarea:focus {
border-color: var(--accent);
}
.form-group input:disabled { opacity: 0.6; }
.form-group input[type="range"] { max-width: 100%; border: none; background: none; }
.form-group input[type="range"] { border: none; background: none; }
.form-group input[type="range"]:focus { box-shadow: none; }
.form-group input[type="checkbox"] { width: auto; max-width: none; accent-color: var(--accent); }
.form-group input[type="checkbox"] { width: auto; accent-color: var(--accent); }
.toggle-group {
display: flex; gap: 1px; padding: 3px;
@@ -392,26 +354,32 @@ select option { background: var(--bg-surface); color: var(--text); }
.toggle-btn.active { background: var(--bg-raised); color: var(--text); }
.toggle-btn:hover { color: var(--text-2); }
.btn-primary { background: var(--accent); color: var(--text-on-color); border: none; }
.btn-primary:hover { background: var(--accent-hover); }
.btn-danger { background: var(--danger); color: var(--text-on-color); border: none; }
.btn-ghost { background: transparent; color: var(--text-2); border: 1px solid var(--border); }
.btn-ghost:hover { background: var(--bg-hover); }
.btn-subtle { background: var(--accent-dim); color: var(--accent); border: none; }
.btn-sm { padding: 5px 11px; font-size: 12px; border-radius: 6px; cursor: pointer; font-weight: 500; font-family: inherit; }
.btn-md { padding: 7px 15px; font-size: 13px; border-radius: 8px; cursor: pointer; font-weight: 500; font-family: inherit; }
.btn-md { padding: 7px 15px; font-size: 13px; border-radius: var(--radius); cursor: pointer; font-weight: 500; font-family: inherit; }
/* ── Badge ───────────────────────────────── */
.badge {
font-size: 10px; padding: 2px 7px; border-radius: 4px;
font-size: 10px; padding: 2px 8px; border-radius: 4px;
font-weight: 600; letter-spacing: 0.3px; white-space: nowrap;
display: inline-flex; align-items: center;
}
.badge-accent { background: var(--accent-dim); color: var(--accent); }
.badge-success { background: var(--success-dim); color: var(--success); }
.badge-warning { background: var(--warning-dim); color: var(--warning); }
.badge-danger { background: var(--danger-dim); color: var(--danger); }
.badge-muted { background: var(--bg-raised); color: var(--text-3); }
/* Dim variants (colored text on tinted background) */
.badge-accent, .badge-models, .badge-team { background: var(--accent-dim); color: var(--accent-light); }
.badge-success, .badge-active, .badge-healthy { background: var(--success-dim); color: var(--success-light); }
.badge-warning, .badge-degraded { background: var(--warning-dim); color: var(--warning-light); }
.badge-danger, .badge-disabled, .badge-unhealthy { background: var(--danger-dim); color: var(--danger-light); }
.badge-muted, .badge-unknown, .badge-provider-type, .badge-global, .badge-user { background: var(--bg-raised); color: var(--text-3); }
.badge-purple, .badge-service { background: var(--purple-dim); color: var(--purple); }
/* Solid variants (white text on colored background) */
.badge-admin { background: var(--accent); color: var(--text-on-color); }
.badge-inactive { background: var(--danger); color: var(--text-on-color); }
.badge-pending { background: var(--warning); color: var(--text-on-warning); }
.badge-private { background: var(--purple); color: var(--text-on-color); }
/* ── Status Dot ──────────────────────────── */
.status-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
@@ -429,4 +397,10 @@ select option { background: var(--bg-surface); color: var(--text); }
}
.icon-btn:hover { background: var(--bg-hover); color: var(--text-2); }
.icon-btn.active { color: var(--accent); }
.icon-btn-danger:hover { color: var(--danger); }
/* ── Utility States ─────────────────────── */
.loading { color: var(--text-3); font-size: 13px; padding: 0.5rem; }
.error-hint { color: var(--danger); font-size: 13px; padding: 0.5rem; }
.empty-hint { color: var(--text-3); font-size: 13px; text-align: center; padding: 1rem; }

View File

@@ -122,7 +122,7 @@
.project-panel-label { font-size: 12px; font-weight: 600; color: var(--text-2); text-transform: uppercase; letter-spacing: 0.5px; }
.project-panel-textarea {
width: 100%; resize: vertical; min-height: 80px; padding: 8px;
font-family: var(--font-mono, monospace); font-size: 13px;
font-family: var(--mono); font-size: 13px;
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius);
color: var(--text);
}
@@ -429,7 +429,7 @@
/* ── Bar Chart ───────────────────────────── */
.bar-chart { display: flex; align-items: flex-end; gap: 8px; height: 140px; }
.bar-chart-col { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 6px; }
.bar-chart-val { font-size: 10px; color: var(--text-3); font-family: var(--font-mono, monospace); }
.bar-chart-val { font-size: 10px; color: var(--text-3); font-family: var(--mono); }
.bar-chart-track { width: 100%; background: var(--accent-dim); border-radius: 4px; position: relative; height: 100px; }
.bar-chart-fill { position: absolute; bottom: 0; left: 0; right: 0; background: var(--accent); border-radius: 4px; transition: height 0.3s ease; }
.bar-chart-label { font-size: 10px; color: var(--text-3); }
@@ -484,18 +484,8 @@
}
.admin-user-name { font-weight: 600; font-size: 13px; }
.admin-user-handle { font-size: 11px; color: var(--text-3); margin-top: 1px; }
.admin-user-time { color: var(--text-3); font-size: 12px; font-family: var(--font-mono, monospace); }
.admin-user-time { color: var(--text-3); font-size: 12px; font-family: var(--mono); }
.admin-actions-cell { white-space: nowrap; text-align: right; }
.btn-icon {
background: none; border: none; color: var(--text-3); cursor: pointer;
padding: 4px; font-size: 14px; border-radius: 4px;
transition: color var(--transition), background var(--transition);
}
.btn-icon:hover { color: var(--text); background: var(--bg-hover); }
.btn-icon-danger:hover { color: var(--danger); }
.badge-service { background: var(--purple-dim); color: var(--purple); font-size: 10px; padding: 1px 8px; border-radius: 4px; }
.badge-disabled { background: var(--danger-dim); color: var(--danger-light); font-size: 10px; padding: 1px 8px; border-radius: 4px; }
.badge-active { background: var(--success-dim); color: var(--success-light); font-size: 10px; padding: 1px 8px; border-radius: 4px; }
/* ── Provider Cards ──────────────────────── */
.provider-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; }
@@ -507,12 +497,6 @@
.provider-card-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 10px; }
.provider-card-name { font-weight: 600; font-size: 14px; }
.provider-card-meta { display: flex; gap: 6px; align-items: center; margin-top: 4px; flex-wrap: wrap; }
.badge-healthy { background: var(--success-dim); color: var(--success-light); font-size: 10px; padding: 2px 8px; border-radius: 4px; font-weight: 500; }
.badge-degraded { background: var(--warning-dim); color: var(--warning-light); font-size: 10px; padding: 2px 8px; border-radius: 4px; font-weight: 500; }
.badge-unhealthy { background: var(--danger-dim); color: var(--danger-light); font-size: 10px; padding: 2px 8px; border-radius: 4px; font-weight: 500; }
.badge-unknown { background: var(--bg-raised); color: var(--text-3); font-size: 10px; padding: 2px 8px; border-radius: 4px; }
.badge-models { background: var(--accent-dim); color: var(--accent-light); font-size: 10px; padding: 2px 8px; border-radius: 4px; }
.badge-provider-type { background: var(--bg-raised); color: var(--text-3); font-size: 10px; padding: 2px 8px; border-radius: 4px; }
@media (max-width: 768px) {
@@ -775,3 +759,13 @@
/* Indented chat items inside folders */
.chat-item.indented { padding-left: 28px; }
/* ── Notes Surface ─────────────────────── */
.surface-notes { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
.surface-notes-body { flex: 1; display: flex; min-height: 0; overflow: hidden; }
.surface-notes-main { flex: 1; display: flex; flex-direction: column; min-width: 0; overflow: hidden; }
.surface-notes-assist {
width: 400px; border-left: 1px solid var(--border);
display: flex; flex-direction: column; overflow: hidden;
}
@media (max-width: 768px) { .surface-notes-assist { display: none; } }

View File

@@ -33,7 +33,7 @@
}
.tool-grants-item:hover {
background: var(--bg-tertiary, #1e1e22);
background: var(--bg-hover);
}
.tool-grants-item input[type="checkbox"] {

View File

@@ -474,13 +474,6 @@ function _initAdminListeners() {
document.querySelectorAll('.admin-cat').forEach(btn => {
btn.addEventListener('click', () => UI.switchAdminCategory(btn.dataset.cat));
});
// Esc closes admin panel
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && document.getElementById('adminPanel')?.style.display === 'flex') {
UI.closeAdmin();
e.stopPropagation();
}
});
document.getElementById('adminSaveSettings')?.addEventListener('click', handleSaveAdminSettings);
// Admin — users

View File

@@ -120,22 +120,22 @@ async function _loadSurfaceList() {
row.innerHTML =
'<div class="admin-surface-info">' +
'<div class="admin-surface-title">' +
'<span class="admin-surface-name">' + _surfEsc(s.title) + '</span>' +
'<span class="admin-surface-name">' + esc(s.title) + '</span>' +
'<span class="admin-surface-badge admin-surface-badge--' + s.source + '">' + sourceLabel + '</span>' +
(isProtected ? '<span class="admin-surface-badge admin-surface-badge--required">Required</span>' : '') +
'</div>' +
'<div class="admin-surface-meta">' +
'<span class="admin-surface-id">' + _surfEsc(s.id) + '</span>' +
(route ? '<span class="admin-surface-route">' + _surfEsc(route) + '</span>' : '') +
'<span class="admin-surface-id">' + esc(s.id) + '</span>' +
(route ? '<span class="admin-surface-route">' + esc(route) + '</span>' : '') +
'</div>' +
'</div>' +
'<div class="admin-surface-actions">' +
'<label class="admin-surface-toggle' + (isProtected ? ' admin-surface-toggle--locked' : '') + '">' +
'<input type="checkbox"' + (s.enabled ? ' checked' : '') + (isProtected ? ' disabled' : '') +
' data-surface-id="' + _surfEsc(s.id) + '">' +
' data-surface-id="' + esc(s.id) + '">' +
'<span class="admin-surface-toggle-label">' + (s.enabled ? 'Enabled' : 'Disabled') + '</span>' +
'</label>' +
(s.source === 'extension' ? '<button class="btn-small btn-danger admin-surface-uninstall" data-surface-id="' + _surfEsc(s.id) + '">Uninstall</button>' : '') +
(s.source === 'extension' ? '<button class="btn-small btn-danger admin-surface-uninstall" data-surface-id="' + esc(s.id) + '">Uninstall</button>' : '') +
'</div>';
// Toggle handler
@@ -187,12 +187,7 @@ async function _loadSurfaceList() {
listEl.appendChild(row);
});
} catch (e) {
listEl.innerHTML = '<div style="padding:12px;font-size:12px;color:var(--danger);">Failed to load: ' + _surfEsc(e.message) + '</div>';
listEl.innerHTML = '<div style="padding:12px;font-size:12px;color:var(--danger);">Failed to load: ' + esc(e.message) + '</div>';
}
}
function _surfEsc(s) {
var d = document.createElement('div');
d.textContent = s || '';
return d.innerHTML;
}

View File

@@ -6,12 +6,11 @@
// ChatPane.primary is the backward-compat bridge.
const ChatPane = {
primary: null,
_instances: new Map(),
...createComponentRegistry('ChatPane'),
create(opts) {
const id = opts.id || 'pane-' + Date.now() + '-' + Math.random().toString(36).slice(2, 6);
const instance = {
const instance = componentMixin({
id,
messagesEl: opts.messagesEl,
inputEl: opts.inputEl,
@@ -22,7 +21,6 @@ const ChatPane = {
standalone: opts.standalone || false,
_cmView: null,
_typing: null,
_listeners: [],
renderMessages(messages) {
if (!this.messagesEl) return;
@@ -34,7 +32,7 @@ const ChatPane = {
const div = document.createElement('div');
div.className = 'chat-msg chat-msg--' + m.role;
div.innerHTML = typeof formatMessage === 'function'
? formatMessage(m) : _cpEsc(m.content || '');
? formatMessage(m) : esc(m.content || '');
this.messagesEl.appendChild(div);
});
}
@@ -127,28 +125,16 @@ const ChatPane = {
else { const ta = this.inputEl && this.inputEl.querySelector('textarea'); if (ta) ta.focus(); }
},
on(el, event, handler) {
if (!el) return;
el.addEventListener(event, handler);
this._listeners.push({ el, event, handler });
},
destroy() {
this._listeners.forEach(({ el, event, handler }) => el.removeEventListener(event, handler));
this._listeners = [];
_cleanup() {
if (this._cmView) { this._cmView.destroy(); this._cmView = null; }
this.removeTyping();
ChatPane._instances.delete(this.id);
if (ChatPane.primary === this) ChatPane.primary = null;
},
};
}, ChatPane);
ChatPane._instances.set(id, instance);
ChatPane._register(id, instance);
return instance;
},
get(id) { return this._instances.get(id) || null; },
forChannel(channelId) {
for (const [, inst] of this._instances) {
if (inst.channelId === channelId) return inst;
@@ -159,4 +145,3 @@ const ChatPane = {
active() { return this.primary; },
};
function _cpEsc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }

View File

@@ -19,11 +19,11 @@
// editor.destroy();
const CodeEditor = {
_instances: new Map(),
...createComponentRegistry('CodeEditor'),
create(opts) {
const pfx = opts.id || 'editor';
const instance = {
const instance = componentMixin({
id: pfx,
tabsEl: document.getElementById(pfx + 'EditorTabs'),
contentEl: document.getElementById(pfx + 'EditorContent'),
@@ -35,9 +35,8 @@ const CodeEditor = {
workspaceId: opts.workspaceId || null,
onSave: opts.onSave || null,
onActivate: opts.onActivate || null,
_openFiles: new Map(), // path → { tab, editorWrap, editor, content, modified, language }
_openFiles: new Map(),
_activeFile: null,
_listeners: [],
// ── Public API ──────────────────────
@@ -69,7 +68,7 @@ const CodeEditor = {
const fileName = path.split('/').pop();
tab.innerHTML =
'<span class="code-editor-tab-icon">' + _ceFileIcon(fileName) + '</span>' +
'<span class="code-editor-tab-name">' + _ceEsc(fileName) + '</span>' +
'<span class="code-editor-tab-name">' + esc(fileName) + '</span>' +
'<span class="code-editor-tab-modified" style="display:none">●</span>' +
'<button class="code-editor-tab-close" title="Close">✕</button>';
@@ -235,10 +234,7 @@ const CodeEditor = {
});
},
// ── Lifecycle ───────────────────────
destroy() {
// Destroy all CM6 instances
_cleanup() {
for (const [, f] of this._openFiles) {
if (f.editor?.destroy) f.editor.destroy();
f.tab.remove();
@@ -246,9 +242,6 @@ const CodeEditor = {
}
this._openFiles.clear();
this._activeFile = null;
this._listeners.forEach(({ el, event, handler }) => el.removeEventListener(event, handler));
this._listeners = [];
CodeEditor._instances.delete(this.id);
},
// ── Internal ────────────────────────
@@ -288,23 +281,15 @@ const CodeEditor = {
return Promise.resolve(true);
},
_on(el, event, handler) {
if (!el) return;
el.addEventListener(event, handler);
this._listeners.push({ el, event, handler });
},
};
}, CodeEditor);
CodeEditor._instances.set(pfx, instance);
CodeEditor._register(pfx, instance);
return instance;
},
get(id) { return this._instances.get(id) || null; },
};
// ── Helpers ─────────────────────────────────
function _ceEsc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
function _ceFileIcon(name) {
const ext = name.split('.').pop()?.toLowerCase();

View File

@@ -603,7 +603,7 @@ function copyDebugLog() {
const text = DebugLog.exportText();
navigator.clipboard.writeText(text)
.then(() => { if (typeof showToast === 'function') showToast('📋 Debug log copied', 'success'); })
.catch(() => { alert('Failed to copy'); });
.catch(() => { UI.toast('Copy failed', 'error'); });
}
function exportDebugLog() {

View File

@@ -162,8 +162,8 @@
const item = document.createElement('button');
item.className = 'editor-bootstrap-ws-item';
item.innerHTML =
'<span class="editor-bootstrap-ws-name">' + _edEsc(ws.name || ws.id?.slice(0, 8)) + '</span>' +
'<span class="editor-bootstrap-ws-date">' + _edEsc(ws.created_at ? new Date(ws.created_at).toLocaleDateString() : '') + '</span>';
'<span class="editor-bootstrap-ws-name">' + esc(ws.name || ws.id?.slice(0, 8)) + '</span>' +
'<span class="editor-bootstrap-ws-date">' + esc(ws.created_at ? new Date(ws.created_at).toLocaleDateString() : '') + '</span>';
item.addEventListener('click', () => {
window.location.href = (window.__BASE__ || '') + '/editor/' + ws.id;
});
@@ -336,7 +336,6 @@
// ── Helpers ──────────────────────────────
function _edEsc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
// ── Assist Chat (channel-based, standalone) ──
// Full chat pane for the editor: model selector, chat history,
@@ -616,7 +615,7 @@
function _appendSystemMsg(text) {
const div = document.createElement('div');
div.className = 'chat-msg chat-msg--system';
div.innerHTML = '<div class="chat-msg__content" style="color:var(--danger,#f44336);font-size:12px;">' + _edEsc(text) + '</div>';
div.innerHTML = '<div class="chat-msg__content" style="color:var(--danger,#f44336);font-size:12px;">' + esc(text) + '</div>';
chatPane.messagesEl?.appendChild(div);
chatPane.scrollToBottom();
}

View File

@@ -18,11 +18,11 @@
// tree.destroy();
const FileTree = {
_instances: new Map(),
...createComponentRegistry('FileTree'),
create(opts) {
const pfx = opts.id || 'editor';
const instance = {
const instance = componentMixin({
id: pfx,
itemsEl: document.getElementById(pfx + 'TreeItems'),
newFileEl: document.getElementById(pfx + 'TreeNewFile'),
@@ -34,7 +34,6 @@ const FileTree = {
_expandedDirs: new Set(['']),
_gitStatusMap: new Map(),
_activeFile: null,
_listeners: [],
// ── Public API ──────────────────────
@@ -166,7 +165,7 @@ const FileTree = {
row.innerHTML =
'<span class="file-tree-arrow ' + (expanded ? 'expanded' : '') + '">' + (expanded ? '▾' : '▸') + '</span>' +
'<span class="file-tree-icon">📁</span>' +
'<span class="file-tree-name">' + _ftEsc(node.name) + '</span>';
'<span class="file-tree-name">' + esc(node.name) + '</span>';
row.addEventListener('click', () => {
if (self._expandedDirs.has(node.path)) {
self._expandedDirs.delete(node.path);
@@ -182,7 +181,7 @@ const FileTree = {
} else {
row.innerHTML =
'<span class="file-tree-icon">' + _ftFileIcon(node.name) + '</span>' +
'<span class="file-tree-name">' + _ftEsc(node.name) + '</span>';
'<span class="file-tree-name">' + esc(node.name) + '</span>';
row.addEventListener('click', () => {
if (self.onSelect) self.onSelect(node.path);
});
@@ -233,34 +232,20 @@ const FileTree = {
});
},
// ── Lifecycle ───────────────────────
destroy() {
this._listeners.forEach(({ el, event, handler }) => el.removeEventListener(event, handler));
this._listeners = [];
_cleanup() {
this._treeData = [];
this._expandedDirs = new Set(['']);
this._gitStatusMap.clear();
FileTree._instances.delete(this.id);
},
}, FileTree);
_on(el, event, handler) {
if (!el) return;
el.addEventListener(event, handler);
this._listeners.push({ el, event, handler });
},
};
FileTree._instances.set(pfx, instance);
FileTree._register(pfx, instance);
return instance;
},
get(id) { return this._instances.get(id) || null; },
};
// ── Helpers ─────────────────────────────────
function _ftEsc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
function _ftFileIcon(name) {
const ext = name.split('.').pop()?.toLowerCase();

View File

@@ -344,8 +344,8 @@ function _renderStrip() {
return `<div class="att-chip ${statusClass}" data-local-id="${a.localId}">
${a.previewUrl ? `<img class="att-thumb" src="${a.previewUrl}" alt="">` : `<span class="att-icon">${icon}</span>`}
<span class="att-info">
<span class="att-name" title="${_esc(a.filename)}">${_esc(name)}</span>
<span class="att-meta">${_esc(size)}${label ? ' · ' + label : ''}</span>
<span class="att-name" title="${esc(a.filename)}">${esc(name)}</span>
<span class="att-meta">${esc(size)}${label ? ' · ' + label : ''}</span>
</span>
<button class="att-remove" onclick="unstageFile('${a.localId}')" title="Remove">✕</button>
</div>`;
@@ -363,12 +363,6 @@ function _truncFilename(name, max) {
return name.slice(0, max - 1) + '…';
}
function _esc(s) {
if (!s) return '';
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
// ── Channel File Loading ──────────────
@@ -426,8 +420,8 @@ function renderMessageFiles(msgId) {
const visionHint = !hasVision
? '<span class="att-vision-hint" title="Current model does not support images">👁️‍🗨️</span>'
: '';
return `<div class="msg-att msg-att-image" onclick="openLightbox('${_esc(att.id)}')">
<img class="msg-att-img" data-att-id="${_esc(att.id)}" alt="${_esc(att.filename)}"
return `<div class="msg-att msg-att-image" onclick="openLightbox('${esc(att.id)}')">
<img class="msg-att-img" data-att-id="${esc(att.id)}" alt="${esc(att.filename)}"
style="max-width:${w}px" loading="lazy">
${visionHint}
</div>`;
@@ -436,10 +430,10 @@ function renderMessageFiles(msgId) {
// Document chip
const icon = _docIcon(att.content_type);
const size = formatFileSize(att.size_bytes);
return `<a class="msg-att msg-att-doc" href="#" onclick="downloadFile('${_esc(att.id)}','${_esc(att.filename)}');return false">
return `<a class="msg-att msg-att-doc" href="#" onclick="downloadFile('${esc(att.id)}','${esc(att.filename)}');return false">
<span class="att-doc-icon">${icon}</span>
<span class="att-doc-info">
<span class="att-doc-name">${_esc(att.filename)}</span>
<span class="att-doc-name">${esc(att.filename)}</span>
<span class="att-doc-size">${size}</span>
</span>
<span class="att-doc-dl">⬇</span>
@@ -565,7 +559,7 @@ async function loadAdminStorage() {
<div class="admin-storage-grid">
<div class="admin-storage-item">
<span class="admin-storage-label">Backend</span>
<span class="admin-storage-value">${_esc(status.backend || 'none')}</span>
<span class="admin-storage-value">${esc(status.backend || 'none')}</span>
</div>
<div class="admin-storage-item">
<span class="admin-storage-label">Status</span>
@@ -581,15 +575,15 @@ async function loadAdminStorage() {
</div>
${status.path ? `<div class="admin-storage-item">
<span class="admin-storage-label">Path</span>
<span class="admin-storage-value" style="font-family:monospace;font-size:12px">${_esc(status.path)}</span>
<span class="admin-storage-value" style="font-family:monospace;font-size:12px">${esc(status.path)}</span>
</div>` : ''}
${status.endpoint ? `<div class="admin-storage-item">
<span class="admin-storage-label">Endpoint</span>
<span class="admin-storage-value" style="font-family:monospace;font-size:12px">${_esc(status.endpoint)}</span>
<span class="admin-storage-value" style="font-family:monospace;font-size:12px">${esc(status.endpoint)}</span>
</div>` : ''}
${status.bucket ? `<div class="admin-storage-item">
<span class="admin-storage-label">Bucket</span>
<span class="admin-storage-value" style="font-family:monospace;font-size:12px">${_esc(status.bucket)}</span>
<span class="admin-storage-value" style="font-family:monospace;font-size:12px">${esc(status.bucket)}</span>
</div>` : ''}
</div>
</div>`;
@@ -640,7 +634,7 @@ async function loadAdminStorage() {
});
} catch (e) {
el.innerHTML = `<div class="empty-hint">Failed to load storage status: ${_esc(e.message)}</div>`;
el.innerHTML = `<div class="empty-hint">Failed to load storage status: ${esc(e.message)}</div>`;
}
}

View File

@@ -29,12 +29,12 @@ const KnowledgeUI = (() => {
if (!popup) return;
if (!App.activeId) {
popup.innerHTML = '<div class="kb-popup-empty">Start a conversation first</div>';
popup.innerHTML = '<div class="empty-hint">Start a conversation first</div>';
popup.classList.add('open');
return;
}
popup.innerHTML = '<div class="kb-popup-loading">Loading…</div>';
popup.innerHTML = '<div class="loading">Loading…</div>';
popup.classList.add('open');
try {
@@ -46,7 +46,7 @@ const KnowledgeUI = (() => {
_channelKBs = chResp.data || [];
_renderChannelPopup(popup);
} catch (e) {
popup.innerHTML = '<div class="kb-popup-empty">Failed to load knowledge bases</div>';
popup.innerHTML = '<div class="empty-hint">Failed to load knowledge bases</div>';
console.warn('KB popup load failed:', e);
}
}
@@ -54,7 +54,7 @@ const KnowledgeUI = (() => {
function _renderChannelPopup(popup) {
if (_allKBs.length === 0) {
popup.innerHTML = `
<div class="kb-popup-empty">
<div class="empty-hint">
No knowledge bases yet.
<br><small>Create one in Settings → Knowledge Bases</small>
</div>`;
@@ -73,7 +73,7 @@ const KnowledgeUI = (() => {
<div class="kb-popup-item ${linked ? 'enabled' : ''}" data-kb-id="${kb.id}">
<span class="kb-popup-check"></span>
<span class="kb-popup-label">
<span class="kb-popup-name">${_esc(kb.name)}</span>
<span class="kb-popup-name">${esc(kb.name)}</span>
<span class="kb-popup-meta">${docs}${scope}${status}</span>
</span>
</div>`;
@@ -130,7 +130,7 @@ const KnowledgeUI = (() => {
}
_panelCtx = { container: containerId, scope, teamId };
panel.innerHTML = '<div class="kb-manage-loading">Loading…</div>';
panel.innerHTML = '<div class="loading">Loading…</div>';
panel.style.display = '';
try {
@@ -146,7 +146,7 @@ const KnowledgeUI = (() => {
_renderManagePanel(panel);
} catch (e) {
console.warn('[KB] openPanel error:', e);
panel.innerHTML = '<div class="kb-manage-empty">Failed to load knowledge bases</div>';
panel.innerHTML = '<div class="empty-hint">Failed to load knowledge bases</div>';
}
}
@@ -166,7 +166,7 @@ const KnowledgeUI = (() => {
</div>`;
if (_allKBs.length === 0) {
html += '<div class="kb-manage-empty">No knowledge bases yet. Create one to get started.</div>';
html += '<div class="empty-hint">No knowledge bases yet. Create one to get started.</div>';
} else {
// Summary stats
const totalDocs = _allKBs.reduce((s, kb) => s + (kb.document_count || 0), 0);
@@ -184,12 +184,12 @@ const KnowledgeUI = (() => {
html += `
<div class="kb-manage-item" data-kb-id="${kb.id}" data-grant-kb="${kb.id}">
<div class="kb-manage-info">
<span class="kb-manage-name">${scope} ${_esc(kb.name)}</span>
<span class="kb-manage-desc">${_esc(kb.description || '')}</span>
<span class="kb-manage-name">${scope} ${esc(kb.name)}</span>
<span class="kb-manage-desc">${esc(kb.description || '')}</span>
<span class="kb-manage-stats">${docs} · ${chunks}${size}${status}</span>
</div>
<div class="kb-manage-actions">
${kb.scope !== 'personal' ? `<button class="btn-small" data-kb-grant="${kb.id}" data-kb-name="${_esc(kb.name)}" title="Access">🔒</button>` : ''}
${kb.scope !== 'personal' ? `<button class="btn-small" data-kb-grant="${kb.id}" data-kb-name="${esc(kb.name)}" title="Access">🔒</button>` : ''}
<button class="btn-small" data-kb-docs="${kb.id}" title="Documents">📄</button>
<button class="btn-small btn-danger" data-kb-del="${kb.id}" title="Delete">✕</button>
</div>
@@ -273,7 +273,7 @@ const KnowledgeUI = (() => {
close();
_refreshCurrentPanel();
} catch (e) {
alert('Failed to create: ' + (e.message || e));
UI.toast('Failed to create: ' + (e.message || e), 'error');
}
});
@@ -292,12 +292,12 @@ const KnowledgeUI = (() => {
async function _deleteKB(kbId) {
const kb = _allKBs.find(k => k.id === kbId);
if (!confirm(`Delete "${kb?.name || kbId}" and all its documents?`)) return;
if (!await showConfirm(`Delete "${kb?.name || kbId}" and all its documents?`)) return;
try {
await API.deleteKnowledgeBase(kbId);
_refreshCurrentPanel();
} catch (e) {
alert('Delete failed: ' + (e.message || e));
UI.toast('Delete failed: ' + (e.message || e), 'error');
}
}
@@ -322,7 +322,7 @@ const KnowledgeUI = (() => {
const panel = document.getElementById(containerId);
if (!panel) return;
panel.innerHTML = '<div class="kb-manage-loading">Loading documents…</div>';
panel.innerHTML = '<div class="loading">Loading documents…</div>';
try {
const [kbResp, docsResp] = await Promise.all([
@@ -333,7 +333,7 @@ const KnowledgeUI = (() => {
const docs = docsResp.data || docsResp || [];
_renderDocumentsPanel(panel, kb, Array.isArray(docs) ? docs : []);
} catch (e) {
panel.innerHTML = `<div class="kb-manage-empty">Failed to load documents: ${_esc(e.message || '')}</div>`;
panel.innerHTML = `<div class="empty-hint">Failed to load documents: ${esc(e.message || '')}</div>`;
}
}
@@ -341,7 +341,7 @@ const KnowledgeUI = (() => {
let html = `
<div class="kb-manage-header">
<button class="btn-small" id="kbDocsBack">← Back</button>
<h3>${_esc(kb.name)}</h3>
<h3>${esc(kb.name)}</h3>
<label class="btn-small btn-primary" id="kbUploadLabel" title="Upload document">
+ Upload
<input type="file" id="kbFileInput" accept=".txt,.md,.csv,.html,.htm" style="display:none" multiple>
@@ -349,7 +349,7 @@ const KnowledgeUI = (() => {
</div>`;
if (kb.description) {
html += `<div class="kb-manage-desc-block">${_esc(kb.description)}</div>`;
html += `<div class="kb-manage-desc-block">${esc(kb.description)}</div>`;
}
// Stats line
@@ -360,18 +360,18 @@ const KnowledgeUI = (() => {
}
if (docs.length === 0) {
html += '<div class="kb-manage-empty">No documents yet. Upload text, markdown, CSV, or HTML files.</div>';
html += '<div class="empty-hint">No documents yet. Upload text, markdown, CSV, or HTML files.</div>';
} else {
html += '<div class="kb-manage-list">';
for (const doc of docs) {
const statusIcon = _docStatusIcon(doc.status);
const size = _formatSize(doc.size_bytes);
const chunks = doc.chunk_count > 0 ? ` · ${doc.chunk_count} chunks` : '';
const errText = doc.error ? ` · ⚠ ${_esc(doc.error)}` : '';
const errText = doc.error ? ` · ⚠ ${esc(doc.error)}` : '';
html += `
<div class="kb-manage-item kb-doc-item" data-doc-id="${doc.id}" data-doc-status="${doc.status}">
<div class="kb-manage-info">
<span class="kb-manage-name">${statusIcon} ${_esc(doc.filename)}</span>
<span class="kb-manage-name">${statusIcon} ${esc(doc.filename)}</span>
<span class="kb-manage-stats">${size}${chunks}${errText}</span>
</div>
<div class="kb-manage-actions">
@@ -417,18 +417,18 @@ const KnowledgeUI = (() => {
console.log(`📄 Uploaded ${file.name}, status: ${resp.status}`);
if (resp.id) _startPolling(kbId, resp.id);
} catch (e) {
alert(`Upload failed for ${file.name}: ${e.message || e}`);
UI.toast(`Upload failed for ${file.name}: ${e.message || e}`, 'error');
}
}
async function _deleteDocument(kbId, docId) {
if (!confirm('Delete this document and all its chunks?')) return;
if (!await showConfirm('Delete this document and all its chunks?')) return;
try {
_stopPolling(docId);
await API.deleteKBDocument(kbId, docId);
openDocumentsPanel(kbId);
} catch (e) {
alert('Delete failed: ' + (e.message || e));
UI.toast('Delete failed: ' + (e.message || e), 'error');
}
}
@@ -467,7 +467,7 @@ const KnowledgeUI = (() => {
if (nameEl) {
const icon = _docStatusIcon(status.status);
const filename = nameEl.textContent.replace(/^[^\s]+ /, '');
nameEl.innerHTML = `${icon} ${_esc(status.filename || filename)}`;
nameEl.innerHTML = `${icon} ${esc(status.filename || filename)}`;
}
const statsEl = item.querySelector('.kb-manage-stats');
if (statsEl && status.chunk_count > 0) {
@@ -538,12 +538,6 @@ const KnowledgeUI = (() => {
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
function _esc(s) {
if (!s) return '';
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
// ═════════════════════════════════════════
// Public API

View File

@@ -160,7 +160,7 @@ const MemoryUI = {
},
async deleteMemory(id) {
if (!confirm('Delete this memory permanently?')) return;
if (!await showConfirm('Delete this memory permanently?')) return;
try {
await API.deleteMemory(id);
UI.toast('Memory deleted', 'success');
@@ -213,7 +213,7 @@ const MemoryUI = {
const data = await API.listMemories('pending_review', '');
const ids = (data.memories || []).map(m => m.id);
if (!ids.length) return UI.toast('No pending memories', 'info');
if (!confirm(`Approve all ${ids.length} pending memories?`)) return;
if (!await showConfirm(`Approve all ${ids.length} pending memories?`)) return;
await API.bulkApproveMemories(ids);
UI.toast(`${ids.length} memories approved`, 'success');
this.openSettingsPanel();
@@ -250,7 +250,7 @@ const MemoryUI = {
// Wire events
document.getElementById('adminBulkApproveBtn')?.addEventListener('click', async () => {
const ids = pending.map(m => m.id);
if (!confirm(`Approve all ${ids.length} pending memories?`)) return;
if (!await showConfirm(`Approve all ${ids.length} pending memories?`)) return;
try {
await API.bulkApproveMemories(ids);
UI.toast(`${ids.length} memories approved`, 'success');

View File

@@ -13,12 +13,11 @@
// sel.destroy();
const ModelSelector = {
primary: null,
_instances: new Map(),
...createComponentRegistry('ModelSelector'),
create(opts) {
const pfx = opts.id || '';
const instance = {
const instance = componentMixin({
id: pfx,
dropdownEl: document.getElementById(pfx + 'modelDropdown'),
btnEl: document.getElementById(pfx + 'modelDropdownBtn'),
@@ -28,7 +27,6 @@ const ModelSelector = {
onChange: opts.onChange || null,
_value: '',
_models: [],
_listeners: [],
// ── Selection ───────────────────────
@@ -100,11 +98,11 @@ const ModelSelector = {
const div = document.createElement('div');
div.className = 'model-dropdown-item';
div.dataset.value = m.id;
const avatar = m.personaAvatar ? '<img src="' + _msEsc(m.personaAvatar) + '" class="dropdown-avatar" alt="">' : '';
const handle = m.isPersona && m.personaHandle ? '<span class="item-handle">@' + _msEsc(m.personaHandle) + '</span>' : '';
const teamBadge = m.source === 'team' && m.teamName ? '<span class="badge-team" style="font-size:9px;padding:0 4px">\uD83D\uDC65 ' + _msEsc(m.teamName) + '</span>' : '';
const provider = m.provider ? '<span class="item-provider">' + _msEsc(m.provider) + '</span>' : '';
div.innerHTML = avatar + '<span class="item-label">' + _msEsc(m.name || m.id) + handle + '</span>' + teamBadge + provider;
const avatar = m.personaAvatar ? '<img src="' + esc(m.personaAvatar) + '" class="dropdown-avatar" alt="">' : '';
const handle = m.isPersona && m.personaHandle ? '<span class="item-handle">@' + esc(m.personaHandle) + '</span>' : '';
const teamBadge = m.source === 'team' && m.teamName ? '<span class="badge-team" style="font-size:9px;padding:0 4px">\uD83D\uDC65 ' + esc(m.teamName) + '</span>' : '';
const provider = m.provider ? '<span class="item-provider">' + esc(m.provider) + '</span>' : '';
div.innerHTML = avatar + '<span class="item-label">' + esc(m.name || m.id) + handle + '</span>' + teamBadge + provider;
div.addEventListener('click', () => {
self.select(m.id, (m.name || m.id) + (m.provider ? ' (' + m.provider + ')' : ''));
self.close();
@@ -163,29 +161,9 @@ const ModelSelector = {
if (!e.target.closest('#' + pfx + 'modelDropdown')) this.close();
});
},
}, ModelSelector);
// ── Lifecycle ───────────────────────
destroy() {
this._listeners.forEach(({ el, event, handler }) => el.removeEventListener(event, handler));
this._listeners = [];
ModelSelector._instances.delete(this.id);
if (ModelSelector.primary === this) ModelSelector.primary = null;
},
_on(el, event, handler) {
if (!el) return;
el.addEventListener(event, handler);
this._listeners.push({ el, event, handler });
},
};
ModelSelector._instances.set(pfx, instance);
ModelSelector._register(pfx, instance);
return instance;
},
get(id) { return this._instances.get(id) || null; },
};
// HTML-escape for model selector content
function _msEsc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }

View File

@@ -20,11 +20,11 @@
// (editor pane, future notes-studio layout).
const NoteEditor = {
_instances: new Map(),
...createComponentRegistry('NoteEditor'),
create(opts) {
const pfx = opts.id || 'main';
const instance = {
const instance = componentMixin({
id: pfx,
rootEl: document.getElementById(pfx + 'NoteEditor'),
listViewEl: document.getElementById(pfx + 'NotesListView'),
@@ -51,7 +51,6 @@ const NoteEditor = {
_currentNote: null,
_sort: 'updated_desc',
_cmEditor: null,
_listeners: [],
// ── View Switching ───────────────────
@@ -78,7 +77,7 @@ const NoteEditor = {
async loadNotes(folder, searchQuery) {
if (!this.listEl) return;
this.listEl.innerHTML = '<div class="notes-loading">Loading…</div>';
this.listEl.innerHTML = '<div class="loading">Loading…</div>';
try {
let notes;
@@ -92,7 +91,7 @@ const NoteEditor = {
}
if (notes.length === 0) {
this.listEl.innerHTML = '<div class="notes-empty">' +
this.listEl.innerHTML = '<div class="empty-hint">' +
(searchQuery ? 'No results found' : 'No notes yet') + '</div>';
return;
}
@@ -108,15 +107,15 @@ const NoteEditor = {
const date = note.updated_at ? new Date(note.updated_at).toLocaleDateString() : '';
item.innerHTML =
'<div class="note-content-col">' +
'<div class="note-title">' + _neEsc(title) + '</div>' +
'<div class="note-preview">' + _neEsc(preview) + '</div>' +
'<div class="note-date">' + _neEsc(date) + '</div>' +
'<div class="note-title">' + esc(title) + '</div>' +
'<div class="note-preview">' + esc(preview) + '</div>' +
'<div class="note-date">' + esc(date) + '</div>' +
'</div>';
item.addEventListener('click', () => self.openNote(note.id));
self.listEl.appendChild(item);
});
} catch (e) {
this.listEl.innerHTML = '<div class="notes-empty">Failed to load: ' + _neEsc(e.message) + '</div>';
this.listEl.innerHTML = '<div class="empty-hint">Failed to load: ' + esc(e.message) + '</div>';
}
},
@@ -422,29 +421,15 @@ const NoteEditor = {
}
},
// ── Lifecycle ───────────────────────
destroy() {
_cleanup() {
this._destroyCmEditor();
this._listeners.forEach(({ el, event, handler }) => el.removeEventListener(event, handler));
this._listeners = [];
this._editingNoteId = null;
this._currentNote = null;
NoteEditor._instances.delete(this.id);
},
}, NoteEditor);
_on(el, event, handler) {
if (!el) return;
el.addEventListener(event, handler);
this._listeners.push({ el, event, handler });
},
};
NoteEditor._instances.set(pfx, instance);
NoteEditor._register(pfx, instance);
return instance;
},
get(id) { return this._instances.get(id) || null; },
};
function _neEsc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }

View File

@@ -25,7 +25,7 @@ async function openNotes() {
async function loadNotesList(folder, searchQuery) {
const list = document.getElementById('notesList');
list.innerHTML = '<div class="notes-loading">Loading…</div>';
list.innerHTML = '<div class="loading">Loading…</div>';
try {
let data;
@@ -33,7 +33,7 @@ async function loadNotesList(folder, searchQuery) {
data = await API.searchNotes(searchQuery);
const results = data.data || [];
if (results.length === 0) {
list.innerHTML = '<div class="notes-empty">No results found</div>';
list.innerHTML = '<div class="empty-hint">No results found</div>';
return;
}
list.innerHTML = results.map(n => _noteListItem(n, true)).join('');
@@ -42,13 +42,13 @@ async function loadNotesList(folder, searchQuery) {
data = await API.listNotes(100, 0, folderVal, '', _notesSort);
const notes = data.data || [];
if (notes.length === 0) {
list.innerHTML = `<div class="notes-empty">${folderVal ? 'No notes in this folder' : 'No notes yet. Create one or ask the AI to save a note.'}</div>`;
list.innerHTML = `<div class="empty-hint">${folderVal ? 'No notes in this folder' : 'No notes yet. Create one or ask the AI to save a note.'}</div>`;
return;
}
list.innerHTML = notes.map(n => _noteListItem(n, false)).join('');
}
} catch (e) {
list.innerHTML = `<div class="notes-empty">Failed to load: ${esc(e.message)}</div>`;
list.innerHTML = `<div class="empty-hint">Failed to load: ${esc(e.message)}</div>`;
}
}
@@ -177,7 +177,7 @@ function showNotesList() {
_destroyNoteEditor();
// Reset list to loading state to prevent stale content flash
const list = document.getElementById('notesList');
if (list) list.innerHTML = '<div class="notes-loading">Loading…</div>';
if (list) list.innerHTML = '<div class="loading">Loading…</div>';
}
var _currentNote = null; // cached note for read mode

View File

@@ -126,8 +126,8 @@ const Notifications = {
html += `<div class="${cls}" data-id="${n.id}" data-resource-type="${n.resource_type || ''}" data-resource-id="${n.resource_id || ''}" onclick="Notifications._onItemClick(this)">
<span class="notif-dot">${dot}</span>
<div class="notif-body">
<div class="notif-title">${_esc(n.title)}</div>
${n.body ? `<div class="notif-detail">${_esc(n.body)}</div>` : ''}
<div class="notif-title">${esc(n.title)}</div>
${n.body ? `<div class="notif-detail">${esc(n.body)}</div>` : ''}
<div class="notif-time">${ago}</div>
</div>
</div>`;
@@ -277,8 +277,8 @@ const Notifications = {
<div class="notif-panel-row">
<span class="notif-dot">${dot}</span>
<div class="notif-body">
<div class="notif-title">${_esc(n.title)}</div>
${n.body ? `<div class="notif-detail">${_esc(n.body)}</div>` : ''}
<div class="notif-title">${esc(n.title)}</div>
${n.body ? `<div class="notif-detail">${esc(n.body)}</div>` : ''}
<div class="notif-time">${ago}</div>
</div>
<button class="notif-delete" onclick="event.stopPropagation(); Notifications._deleteItem('${n.id}')" title="Delete">✕</button>
@@ -411,12 +411,6 @@ const Notifications = {
// ── Helpers ──────────────────────────────────
function _esc(s) {
if (!s) return '';
const el = document.createElement('span');
el.textContent = s;
return el.innerHTML;
}
function _timeAgo(isoStr) {
if (!isoStr) return '';

View File

@@ -29,7 +29,7 @@ const Pages = {
);
modelSelect.innerHTML = '<option value="">— select model —</option>' +
filtered.map(m =>
`<option value="${m.model_id}">${_esc(m.display_name || m.model_id)}</option>`
`<option value="${m.model_id}">${esc(m.display_name || m.model_id)}</option>`
).join('');
},
@@ -302,11 +302,6 @@ function _val(id, setVal) {
function _show(id) { const el = document.getElementById(id); if (el) el.style.display = ''; }
function _hide(id) { const el = document.getElementById(id); if (el) el.style.display = 'none'; }
function _esc(s) {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
function _toast(msg, type) {
if (typeof UI !== 'undefined' && UI.toast) {

View File

@@ -271,7 +271,7 @@ const PanelRegistry = {
const panel = this._panels[this._active];
slot.innerHTML = (panel.actions || [])
.filter(a => !a.visible || a.visible())
.map(a => `<button class="side-panel-action-btn" id="${a.id || ''}" onclick="${a.onClickName || ''}" title="${_escPanel(a.title || '')}">${a.icon || ''}</button>`)
.map(a => `<button class="side-panel-action-btn" id="${a.id || ''}" onclick="${a.onClickName || ''}" title="${esc(a.title || '')}">${a.icon || ''}</button>`)
.join('');
},
@@ -282,10 +282,6 @@ const PanelRegistry = {
};
/** Minimal HTML escaping for panel labels/titles. */
function _escPanel(s) {
if (!s) return '';
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// ── Secondary Pane Fullscreen ──────────────

View File

@@ -68,8 +68,8 @@ function _showCreationDialog({ title, placeholder, withColor = false, defaultCol
<input id="sbDialogColor" type="color" value="${defaultColor}" class="sb-dialog-color-input">
</div>` : ''}
<div class="sb-dialog-actions">
<button id="sbDialogCancel" class="sb-dialog-btn">Cancel</button>
<button id="sbDialogOK" class="sb-dialog-btn sb-dialog-btn-primary">${btnLabel}</button>
<button id="sbDialogCancel" class="btn-small">Cancel</button>
<button id="sbDialogOK" class="btn-small btn-primary">${btnLabel}</button>
</div>
</div>`;
@@ -142,7 +142,7 @@ async function renameProject(projectId) {
async function deleteProject(projectId) {
const proj = App.projects.find(p => p.id === projectId);
if (!proj) return;
if (!confirm(`Delete project "${proj.name}"?\nChats inside will be kept but unassigned.`)) return;
if (!await showConfirm(`Delete project "${proj.name}"?\nChats inside will be kept but unassigned.`)) return;
try {
await API.deleteProject(projectId);
App.projects = App.projects.filter(p => p.id !== projectId);

View File

@@ -465,13 +465,17 @@ function _renderCmdResults(query) {
// ── Settings Listeners (extracted from initListeners) ──
function _initSettingsListeners() {
// Settings modal
document.getElementById('settingsCloseBtn').addEventListener('click', UI.closeSettings);
document.getElementById('settingsSaveBtn').addEventListener('click', handleSaveSettings);
// Settings modal elements only exist on the settings surface.
// On chat surface, openSettings() navigates to /settings/ instead.
const settingsModel = document.getElementById('settingsModel');
if (!settingsModel) return;
document.getElementById('settingsCloseBtn')?.addEventListener('click', UI.closeSettings);
document.getElementById('settingsSaveBtn')?.addEventListener('click', handleSaveSettings);
document.querySelectorAll('.settings-tab').forEach(tab => {
tab.addEventListener('click', () => UI.switchSettingsTab(tab.dataset.stab));
});
document.getElementById('settingsModel').addEventListener('change', function() {
settingsModel.addEventListener('change', function() {
const model = App.findModel(this.value);
const caps = model?.capabilities || {};
const hint = document.getElementById('settingsMaxHint');

View File

@@ -1,43 +0,0 @@
/**
* surface-nav.js — Navigation overrides for server-rendered surfaces.
*
* Replaces SPA modal-based openSettings/openAdmin with page navigation
* to the dedicated /settings and /admin surface routes.
*
* Load AFTER ui-core.js and ui-admin.js so it overrides their methods.
*
* v0.22.8 — FE refactor: surfaces replace modals
*/
(function() {
'use strict';
const base = window.__BASE__ || '';
// ── Settings: navigate to /settings surface ──────────
if (typeof UI !== 'undefined') {
UI.openSettings = function(section) {
const sec = section || 'general';
window.location.href = base + '/settings/' + sec;
};
UI.closeSettings = function() {
// Navigate back to chat if called from settings surface
window.location.href = base + '/';
};
}
// ── Admin: navigate to /admin surface ────────────────
if (typeof UI !== 'undefined') {
UI.openAdmin = function(cat, section) {
const sec = section || 'users';
window.location.href = base + '/admin/' + sec;
};
UI.closeAdmin = function() {
window.location.href = base + '/';
};
UI.openAdminSection = function(section) {
window.location.href = base + '/admin/' + (section || 'users');
};
}
console.log('[surface-nav] Navigation overrides active — settings/admin use server surfaces');
})();

View File

@@ -66,26 +66,19 @@ Object.assign(UI, {
// ── Open / Close ──────────────────────────
openAdmin(cat, section) {
UI._adminCat = cat || 'people';
UI._adminSection = section || ADMIN_SECTIONS[UI._adminCat][0];
const panel = document.getElementById('adminPanel');
panel.style.display = 'flex';
// Show current user email
const userEl = document.getElementById('adminCurrentUser');
if (userEl && API.user) userEl.textContent = API.user.email || API.user.username || '';
UI._renderAdminNav();
UI._switchAdminSection(UI._adminSection);
const base = window.__BASE__ || '';
window.location.href = base + '/admin/' + (section || 'users');
},
closeAdmin() {
document.getElementById('adminPanel').style.display = 'none';
const base = window.__BASE__ || '';
window.location.href = base + '/';
},
// ── Navigate to specific section ──────────
// Called from command palette, deep links, etc.
openAdminSection(section) {
const cat = _adminCatForSection(section);
UI.openAdmin(cat, section);
const base = window.__BASE__ || '';
window.location.href = base + '/admin/' + (section || 'users');
},
// ── Category switch ───────────────────────
@@ -128,21 +121,6 @@ Object.assign(UI, {
await ADMIN_LOADERS[section]?.();
},
// ── Backward compat: switchAdminTab still works ──
// (called by some inline onclick handlers and admin-handlers.js)
async switchAdminTab(tab) {
const cat = _adminCatForSection(tab);
if (document.getElementById('adminPanel').style.display !== 'flex') {
UI.openAdmin(cat, tab);
} else {
if (cat !== UI._adminCat) {
UI._adminCat = cat;
UI._renderAdminNav();
}
await UI._switchAdminSection(tab);
}
},
openTeamAdmin() {
const adminTeams = (UI._myTeams || []).filter(t => t.my_role === 'admin');
if (adminTeams.length === 0) {
@@ -233,12 +211,12 @@ Object.assign(UI, {
<td class="admin-user-time">${lastSeen}</td>
<td class="admin-actions-cell">
${isPending
? `<button class="btn-icon" onclick="showApproveForm('${u.id}','${esc(u.username)}')" title="Approve">✓</button>`
: `<button class="btn-icon" onclick="toggleUserActive('${u.id}',false)" title="Disable">⏸</button>`
? `<button class="icon-btn" onclick="showApproveForm('${u.id}','${esc(u.username)}')" title="Approve">✓</button>`
: `<button class="icon-btn" onclick="toggleUserActive('${u.id}',false)" title="Disable">⏸</button>`
}
<button class="btn-icon" onclick="toggleUserRole('${u.id}','${u.role === 'admin' ? 'user' : 'admin'}')" title="${u.role === 'admin' ? 'Demote' : 'Promote'}">⇅</button>
<button class="btn-icon" onclick="adminResetUserPassword('${u.id}','${esc(u.username)}')" title="Reset PW">🔑</button>
<button class="btn-icon btn-icon-danger" onclick="deleteUser('${u.id}','${esc(u.username)}')" title="Delete">🗑</button>
<button class="icon-btn" onclick="toggleUserRole('${u.id}','${u.role === 'admin' ? 'user' : 'admin'}')" title="${u.role === 'admin' ? 'Demote' : 'Promote'}">⇅</button>
<button class="icon-btn" onclick="adminResetUserPassword('${u.id}','${esc(u.username)}')" title="Reset PW">🔑</button>
<button class="icon-btn icon-btn-danger" onclick="deleteUser('${u.id}','${esc(u.username)}')" title="Delete">🗑</button>
</td>
</tr>`;
}).join('');
@@ -439,9 +417,9 @@ Object.assign(UI, {
<td><span class="badge-provider-type">${esc(ext.tier)}</span></td>
<td style="font-size:12px;color:var(--text-3);font-family:var(--font-mono,monospace)">v${esc(ext.version)}</td>
<td class="admin-actions-cell">
<button class="btn-icon" onclick="editAdminExtension('${ext.id}')" title="Edit">✎</button>
<button class="btn-icon" onclick="toggleAdminExtension('${ext.id}',${!ext.is_enabled})" title="${ext.is_enabled ? 'Disable' : 'Enable'}">${ext.is_enabled ? '⏸' : '▶'}</button>
<button class="btn-icon btn-icon-danger" onclick="deleteAdminExtension('${ext.id}','${esc(ext.name)}')" title="Uninstall">🗑</button>
<button class="icon-btn" onclick="editAdminExtension('${ext.id}')" title="Edit">✎</button>
<button class="icon-btn" onclick="toggleAdminExtension('${ext.id}',${!ext.is_enabled})" title="${ext.is_enabled ? 'Disable' : 'Enable'}">${ext.is_enabled ? '⏸' : '▶'}</button>
<button class="icon-btn icon-btn-danger" onclick="deleteAdminExtension('${ext.id}','${esc(ext.name)}')" title="Uninstall">🗑</button>
</td>
</tr>`;
}).join('')}</tbody>
@@ -746,10 +724,10 @@ Object.assign(UI, {
<td style="font-size:12px;color:var(--text-3);font-family:var(--font-mono,monospace)">${esc(p.base_model_id)}</td>
<td>${p.is_active ? '<span class="badge-active">active</span>' : '<span class="badge-disabled">inactive</span>'}</td>
<td class="admin-actions-cell">
<button class="btn-icon" onclick="UI.openGrantPicker('persona','${p.id}','${esc(p.name)}','[data-grant-persona=&quot;${p.id}&quot;]')" title="Access">🔒</button>
<button class="btn-icon" onclick="editAdminPersona('${p.id}')" title="Edit">✎</button>
<button class="btn-icon" onclick="toggleAdminPersona('${p.id}',${!p.is_active})" title="${p.is_active ? 'Disable' : 'Enable'}">${p.is_active ? '⏸' : '▶'}</button>
<button class="btn-icon btn-icon-danger" onclick="deleteAdminPersona('${p.id}','${esc(p.name)}')" title="Delete">🗑</button>
<button class="icon-btn" onclick="UI.openGrantPicker('persona','${p.id}','${esc(p.name)}','[data-grant-persona=&quot;${p.id}&quot;]')" title="Access">🔒</button>
<button class="icon-btn" onclick="editAdminPersona('${p.id}')" title="Edit">✎</button>
<button class="icon-btn" onclick="toggleAdminPersona('${p.id}',${!p.is_active})" title="${p.is_active ? 'Disable' : 'Enable'}">${p.is_active ? '⏸' : '▶'}</button>
<button class="icon-btn icon-btn-danger" onclick="deleteAdminPersona('${p.id}','${esc(p.name)}')" title="Delete">🗑</button>
</td>
</tr>`;
}).join('')}</tbody>
@@ -788,9 +766,9 @@ Object.assign(UI, {
</td>
<td style="font-size:12px;color:var(--text-2)">${t.member_count} member${t.member_count !== 1 ? 's' : ''}</td>
<td class="admin-actions-cell">
<button class="btn-icon" onclick="UI.openTeamDetail('${t.id}','${esc(t.name)}')" title="Members">👥</button>
<button class="btn-icon" onclick="toggleTeamActive('${t.id}',${!t.is_active})" title="${t.is_active ? 'Disable' : 'Enable'}">${t.is_active ? '⏸' : '▶'}</button>
<button class="btn-icon btn-icon-danger" onclick="deleteTeam('${t.id}','${esc(t.name)}')" title="Delete">🗑</button>
<button class="icon-btn" onclick="UI.openTeamDetail('${t.id}','${esc(t.name)}')" title="Members">👥</button>
<button class="icon-btn" onclick="toggleTeamActive('${t.id}',${!t.is_active})" title="${t.is_active ? 'Disable' : 'Enable'}">${t.is_active ? '⏸' : '▶'}</button>
<button class="icon-btn icon-btn-danger" onclick="deleteTeam('${t.id}','${esc(t.name)}')" title="Delete">🗑</button>
</td>
</tr>`).join('')}</tbody>
</table>
@@ -843,7 +821,7 @@ Object.assign(UI, {
${m.user_role === 'admin' ? '<span class="badge-admin" style="margin-left:4px;font-size:9px">sys-admin</span>' : ''}
</td>
<td class="admin-actions-cell">
<button class="btn-icon btn-icon-danger" onclick="removeTeamMember('${teamId}','${m.id}','${esc(m.email)}')" title="Remove">🗑</button>
<button class="icon-btn icon-btn-danger" onclick="removeTeamMember('${teamId}','${m.id}','${esc(m.email)}')" title="Remove">🗑</button>
</td>
</tr>`).join('')}</tbody>
</table>
@@ -898,8 +876,8 @@ Object.assign(UI, {
<td>${scopeBadge}</td>
<td style="font-size:12px;color:var(--text-2)">${g.member_count} member${g.member_count !== 1 ? 's' : ''}</td>
<td class="admin-actions-cell">
<button class="btn-icon" onclick="UI.openGroupDetail('${g.id}','${esc(g.name)}')" title="Members">👥</button>
<button class="btn-icon btn-icon-danger" onclick="deleteGroup('${g.id}','${esc(g.name)}')" title="Delete">🗑</button>
<button class="icon-btn" onclick="UI.openGroupDetail('${g.id}','${esc(g.name)}')" title="Members">👥</button>
<button class="icon-btn icon-btn-danger" onclick="deleteGroup('${g.id}','${esc(g.name)}')" title="Delete">🗑</button>
</td>
</tr>`;
}).join('')}</tbody>
@@ -1022,7 +1000,7 @@ Object.assign(UI, {
</td>
<td class="admin-user-time">${new Date(m.added_at).toLocaleDateString()}</td>
<td class="admin-actions-cell">
<button class="btn-icon btn-icon-danger" onclick="removeGroupMember('${groupId}','${m.user_id}','${esc(m.username || m.email)}')" title="Remove">🗑</button>
<button class="icon-btn icon-btn-danger" onclick="removeGroupMember('${groupId}','${m.user_id}','${esc(m.username || m.email)}')" title="Remove">🗑</button>
</td>
</tr>`).join('')}</tbody>
</table>
@@ -1196,9 +1174,9 @@ Object.assign(UI, {
<td class="admin-user-time">${p.priority}</td>
<td>${p.is_active ? '<span class="badge-active">active</span>' : '<span class="badge-disabled">inactive</span>'}</td>
<td class="admin-actions-cell">
<button class="btn-icon" onclick="UI._showRoutingForm('${esc(p.id)}')" title="Edit">✎</button>
<button class="btn-icon" onclick="UI._toggleRoutingPolicy('${esc(p.id)}',${!p.is_active})" title="${p.is_active ? 'Disable' : 'Enable'}">${p.is_active ? '⏸' : '▶'}</button>
<button class="btn-icon btn-icon-danger" onclick="UI._deleteRoutingPolicy('${esc(p.id)}','${esc(p.name)}')" title="Delete">🗑</button>
<button class="icon-btn" onclick="UI._showRoutingForm('${esc(p.id)}')" title="Edit">✎</button>
<button class="icon-btn" onclick="UI._toggleRoutingPolicy('${esc(p.id)}',${!p.is_active})" title="${p.is_active ? 'Disable' : 'Enable'}">${p.is_active ? '⏸' : '▶'}</button>
<button class="icon-btn icon-btn-danger" onclick="UI._deleteRoutingPolicy('${esc(p.id)}','${esc(p.name)}')" title="Delete">🗑</button>
</td>
</tr>`).join('')}</tbody>
</table>

View File

@@ -1377,34 +1377,14 @@ const UI = {
// ── Settings Modal ───────────────────────
openSettings() {
document.getElementById('settingsModel').value = App.settings.model;
document.getElementById('settingsSystemPrompt').value = App.settings.systemPrompt;
document.getElementById('settingsMaxTokens').value = App.settings.maxTokens || '';
document.getElementById('settingsTemp').value = App.settings.temperature;
document.getElementById('settingsThinking').checked = App.settings.showThinking;
document.getElementById('profileChangePwForm').style.display = 'none';
// Show model's max output in the hint
const caps = this.getSelectedModelCaps();
const hint = document.getElementById('settingsMaxHint');
if (hint) {
if (caps.max_output_tokens > 0) {
const k = (caps.max_output_tokens / 1000).toFixed(0) + 'K';
hint.textContent = `(model max: ${k})`;
} else {
hint.textContent = '';
}
}
UI.loadProfileIntoSettings();
UI.loadMyTeams();
UI.checkUserProvidersAllowed();
UI.checkUserPersonasAllowed();
UI.switchSettingsTab('general');
openModal('settingsModal');
openSettings(section) {
const base = window.__BASE__ || '';
window.location.href = base + '/settings/' + (section || 'general');
},
closeSettings() {
const base = window.__BASE__ || '';
window.location.href = base + '/';
},
closeSettings() { closeModal('settingsModal'); },
switchSettingsTab(tab) {
document.querySelectorAll('.settings-tab').forEach(t => t.classList.toggle('active', t.dataset.stab === tab));

View File

@@ -1,17 +1,8 @@
// ==========================================
// Chat Switchboard UI Formatting & Helpers
// ==========================================
// Pure utility layer: esc(), markdown rendering, code blocks,
// preview panel, time formatting. Loaded after panels.js.
// ── HTML Escaping ───────────────────────────
function esc(s) {
if (!s) return '';
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
// Markdown rendering, code blocks, preview panel, time formatting.
// esc() is in ui-primitives.js (loaded globally in base.html).
// ── Message Formatting ──────────────────────

View File

@@ -1,199 +0,0 @@
// ==========================================
// Chat Switchboard - UI Primitives Additions
// ==========================================
// v0.22.7: Toast, Badge, IconBtn, Theme toggle, confirm dialog, avatar upload.
// Load AFTER ui-primitives.js in base.html.
// -- Toast ------------------------------------------------
var Toast = {
_container: null,
_init: function() {
if (this._container) return;
this._container = document.getElementById('toastContainer');
if (!this._container) {
this._container = document.createElement('div');
this._container.id = 'toastContainer';
this._container.className = 'toast-stack';
document.body.appendChild(this._container);
}
},
show: function(msg, type) {
type = type || 'info';
this._init();
var el = document.createElement('div');
el.className = 'toast toast-' + type;
el.innerHTML = '<span class="toast-icon"></span><span class="toast-msg">' + (typeof esc === 'function' ? esc(msg) : msg) + '</span>';
this._container.appendChild(el);
setTimeout(function() {
el.classList.add('toast-exit');
setTimeout(function() { el.remove(); }, 300);
}, 3000);
},
success: function(msg) { this.show(msg, 'success'); },
error: function(msg) { this.show(msg, 'error'); },
warning: function(msg) { this.show(msg, 'warning'); },
info: function(msg) { this.show(msg, 'info'); },
};
// -- Badge ------------------------------------------------
function renderBadge(text, color) {
color = color || 'muted';
var e = typeof esc === 'function' ? esc : function(s) { return s; };
return '<span class="badge badge-' + e(color) + '">' + e(text) + '</span>';
}
// -- Icon SVG ---------------------------------------------
var _ICONS = {
x: '<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>',
back: '<line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/>',
plus: '<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>',
edit: '<path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/>',
trash: '<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/>',
search: '<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>',
send: '<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>',
user: '<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/>',
users: '<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 00-3-3.87"/><path d="M16 3.13a4 4 0 010 7.75"/>',
key: '<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 11-7.778 7.778 5.5 5.5 0 010-7.777L12 4l3.5 3.5L18 5l3-3"/>',
settings:'<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9c.2.65.7 1.15 1.33 1.34H21a2 2 0 010 4h-.09c-.63.2-1.13.7-1.33 1.34z"/>',
cpu: '<rect x="4" y="4" width="16" height="16" rx="2"/><rect x="9" y="9" width="6" height="6"/><line x1="9" y1="1" x2="9" y2="4"/><line x1="15" y1="1" x2="15" y2="4"/><line x1="9" y1="20" x2="9" y2="23"/><line x1="15" y1="20" x2="15" y2="23"/><line x1="20" y1="9" x2="23" y2="9"/><line x1="20" y1="14" x2="23" y2="14"/><line x1="1" y1="9" x2="4" y2="9"/><line x1="1" y1="14" x2="4" y2="14"/>',
eye: '<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>',
chart: '<line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/>',
shield: '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>',
msg: '<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/>',
globe: '<circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z"/>',
};
function renderIcon(name, size) {
size = size || 16;
var d = _ICONS[name];
if (!d) return '';
return '<svg width="' + size + '" height="' + size + '" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' + d + '</svg>';
}
function renderIconBtn(icon, opts) {
opts = opts || {};
var title = opts.title || '';
var onclick = opts.onclick || '';
var cls = opts.cls || '';
var size = opts.size || 16;
var activeClass = opts.active ? ' active' : '';
var e = typeof esc === 'function' ? esc : function(s) { return s; };
return '<button class="icon-btn' + activeClass + ' ' + cls + '" title="' + e(title) + '"' +
(onclick ? ' onclick="' + onclick + '"' : '') + '>' + renderIcon(icon, size) + '</button>';
}
// -- Theme ------------------------------------------------
var Theme = {
_key: 'switchboard_theme',
init: function() {
var saved = localStorage.getItem(this._key) || 'system';
this.set(saved);
},
set: function(mode) {
localStorage.setItem(this._key, mode);
if (mode === 'system') document.documentElement.removeAttribute('data-theme');
else document.documentElement.setAttribute('data-theme', mode);
},
get: function() { return localStorage.getItem(this._key) || 'system'; },
resolved: function() {
var mode = this.get();
if (mode !== 'system') return mode;
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
},
renderToggle: function(containerId) {
var el = document.getElementById(containerId);
if (!el) return;
var current = this.get();
var options = [
{ id: 'light', label: 'Light', icon: '\u2600' },
{ id: 'dark', label: 'Dark', icon: '\u263E' },
{ id: 'system',label: 'System',icon: '\u229E' },
];
var self = this;
el.innerHTML = '<div class="theme-toggle">' + options.map(function(o) {
return '<button class="theme-toggle__btn' + (current === o.id ? ' active' : '') + '" data-theme="' + o.id + '" title="' + o.label + '">' + o.icon + '</button>';
}).join('') + '</div>';
el.querySelectorAll('.theme-toggle__btn').forEach(function(btn) {
btn.addEventListener('click', function() {
self.set(btn.dataset.theme);
self.renderToggle(containerId);
});
});
},
};
// -- Confirm Dialog (enhanced) ----------------------------
function showConfirmDialog(opts) {
opts = opts || {};
var title = opts.title || 'Confirm';
var message = opts.message || 'Are you sure?';
var confirmLabel = opts.confirmLabel || 'Confirm';
var cancelLabel = opts.cancelLabel || 'Cancel';
var variant = opts.variant || 'primary';
var onConfirm = opts.onConfirm;
var onCancel = opts.onCancel;
var e = typeof esc === 'function' ? esc : function(s) { return s; };
var overlay = document.getElementById('confirmOverlay');
if (!overlay) {
overlay = document.createElement('div');
overlay.id = 'confirmOverlay';
overlay.className = 'confirm-overlay';
overlay.style.display = 'none';
document.body.appendChild(overlay);
}
overlay.innerHTML =
'<div class="confirm-dialog">' +
'<div class="confirm-dialog__title">' + e(title) + '</div>' +
'<div class="confirm-dialog__msg">' + e(message) + '</div>' +
'<div class="confirm-dialog__actions">' +
'<button class="btn btn-ghost" id="confirmCancelBtn">' + e(cancelLabel) + '</button>' +
'<button class="btn btn-' + variant + '" id="confirmOkBtn">' + e(confirmLabel) + '</button>' +
'</div>' +
'</div>';
overlay.style.display = 'flex';
function close() { overlay.style.display = 'none'; overlay.innerHTML = ''; }
document.getElementById('confirmCancelBtn').addEventListener('click', function() { close(); if (onCancel) onCancel(); });
document.getElementById('confirmOkBtn').addEventListener('click', function() { close(); if (onConfirm) onConfirm(); });
overlay.addEventListener('click', function(ev) { if (ev.target === overlay) { close(); if (onCancel) onCancel(); } });
}
// -- Avatar Upload Component ------------------------------
function mountAvatarUpload(containerEl, opts) {
opts = opts || {};
var current = opts.current;
var size = opts.size || 80;
var onUpload = opts.onUpload;
var input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.style.display = 'none';
containerEl.className = 'avatar-upload';
containerEl.style.width = size + 'px';
containerEl.style.height = size + 'px';
function render(src) {
var e = typeof esc === 'function' ? esc : function(s) { return s; };
if (src) containerEl.innerHTML = '<img src="' + e(src) + '" alt="Avatar">';
else containerEl.innerHTML = '<div class="avatar-upload__placeholder">Upload<br>Photo</div>';
containerEl.appendChild(input);
}
render(current || null);
containerEl.addEventListener('click', function() { input.click(); });
input.addEventListener('change', function() {
var file = input.files[0];
if (!file) return;
var reader = new FileReader();
reader.onload = function(ev) {
render(ev.target.result);
if (onUpload) onUpload(file, ev.target.result);
};
reader.readAsDataURL(file);
});
return { setImage: render };
}

View File

@@ -1,6 +1,6 @@
// ── Chat Switchboard — UI Primitives ────────────────────────────────────────
// Single source of truth for constants + reusable rendering functions.
// Loaded after ui-format.js, before ui-core.js.
// Loaded in base.html before all components and surfaces.
// Follows the renderPersonaForm() pattern: (container, options) → control object.
// Designed for extension hooks: registries are open for .add(), primitives
// accept options for customization, and return handles for programmatic control.
@@ -8,6 +8,67 @@
'use strict';
// ══════════════════════════════════════════════════════════════════════════════
// §0 CORE UTILITIES
// ══════════════════════════════════════════════════════════════════════════════
/** HTML-escape a string for safe insertion into innerHTML and attributes. */
function esc(s) {
if (!s) return '';
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML.replace(/"/g, '&quot;');
}
// ══════════════════════════════════════════════════════════════════════════════
// §0b COMPONENT LIFECYCLE
// ══════════════════════════════════════════════════════════════════════════════
/**
* Create a component registry (factory namespace with instance tracking).
*
* @param {string} name - Component name (for debug)
* @returns {{ primary, _instances, _register, _unregister, get }}
*/
function createComponentRegistry(name) {
return {
primary: null,
_instances: new Map(),
_register(id, instance) { this._instances.set(id, instance); },
_unregister(id) {
this._instances.delete(id);
if (this.primary && this.primary.id === id) this.primary = null;
},
get(id) { return this._instances.get(id) || null; },
};
}
/**
* Mix lifecycle methods into a component instance.
* Adds _listeners array, _on() for tracked event binding, and destroy()
* for automatic cleanup + registry removal.
* Components needing extra teardown define _cleanup() which is called first.
*
* @param {Object} instance - The instance object (must have .id)
* @param {Object} registry - The component registry (from createComponentRegistry)
* @returns {Object} The instance, mutated with lifecycle methods
*/
function componentMixin(instance, registry) {
instance._listeners = [];
instance._on = function(el, event, handler) {
if (!el) return;
el.addEventListener(event, handler);
this._listeners.push({ el, event, handler });
};
instance.destroy = function() {
if (typeof this._cleanup === 'function') this._cleanup();
this._listeners.forEach(({ el, event, handler }) => el.removeEventListener(event, handler));
this._listeners = [];
registry._unregister(this.id);
};
return instance;
}
// ══════════════════════════════════════════════════════════════════════════════
// §1 PROVIDER REGISTRY
// ══════════════════════════════════════════════════════════════════════════════
@@ -933,3 +994,98 @@ function updateTabArrows(tabs) {
left.classList.toggle('visible', !atStart);
right.classList.toggle('visible', !atEnd);
}
// ══════════════════════════════════════════════════════════════════════════════
// §9 THEME
// ══════════════════════════════════════════════════════════════════════════════
const Theme = {
_key: 'switchboard_theme',
init() {
this.set(localStorage.getItem(this._key) || 'system');
},
set(mode) {
localStorage.setItem(this._key, mode);
if (mode === 'system') document.documentElement.removeAttribute('data-theme');
else document.documentElement.setAttribute('data-theme', mode);
},
get() {
return localStorage.getItem(this._key) || 'system';
},
resolved() {
const mode = this.get();
if (mode !== 'system') return mode;
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
},
renderToggle(containerId) {
const el = document.getElementById(containerId);
if (!el) return;
const current = this.get();
const options = [
{ id: 'light', label: 'Light', icon: '☀' },
{ id: 'dark', label: 'Dark', icon: '☾' },
{ id: 'system',label: 'System',icon: '⊞' },
];
el.innerHTML = '<div class="toggle-group">' + options.map(o =>
`<button class="toggle-btn${current === o.id ? ' active' : ''}" data-theme="${o.id}" title="${o.label}">${o.icon}</button>`
).join('') + '</div>';
el.querySelectorAll('.toggle-btn').forEach(btn => {
btn.addEventListener('click', () => {
this.set(btn.dataset.theme);
this.renderToggle(containerId);
});
});
},
};
// ══════════════════════════════════════════════════════════════════════════════
// §10 AVATAR UPLOAD
// ══════════════════════════════════════════════════════════════════════════════
/**
* Mount an avatar upload component into a container.
*
* @param {HTMLElement} containerEl
* @param {Object} opts
* @param {string} [opts.current] - Current avatar URL
* @param {number} [opts.size=80] - Avatar size in px
* @param {Function} [opts.onUpload] - Called with (file, dataURL)
* @returns {{ setImage }}
*/
function mountAvatarUpload(containerEl, opts = {}) {
const size = opts.size || 80;
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.style.display = 'none';
containerEl.className = 'avatar-upload';
containerEl.style.width = size + 'px';
containerEl.style.height = size + 'px';
function render(src) {
if (src) containerEl.innerHTML = `<img src="${esc(src)}" alt="Avatar">`;
else containerEl.innerHTML = '<div class="avatar-upload__placeholder">Upload<br>Photo</div>';
containerEl.appendChild(input);
}
render(opts.current || null);
containerEl.addEventListener('click', () => input.click());
input.addEventListener('change', () => {
const file = input.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
render(ev.target.result);
if (opts.onUpload) opts.onUpload(file, ev.target.result);
};
reader.readAsDataURL(file);
});
return { setImage: render };
}

View File

@@ -107,7 +107,7 @@ Object.assign(UI, {
if (scaleEl) prefs.scale = parseInt(scaleEl.value);
if (msgFontEl) prefs.msgFont = parseInt(msgFontEl.value);
// v0.23.2: Theme saved via Theme API (localStorage key: switchboard_theme)
const activeThemeBtn = document.querySelector('#themeToggle .theme-btn.active');
const activeThemeBtn = document.querySelector('#themeToggle .toggle-btn.active');
if (activeThemeBtn && typeof Theme !== 'undefined') {
Theme.set(activeThemeBtn.dataset.theme);
}
@@ -156,17 +156,17 @@ Object.assign(UI, {
if (msgFontEl) { msgFontEl.value = msgFont; document.getElementById('msgFontValue').textContent = msgFont + 'px'; }
// Highlight active theme button and wire click
document.querySelectorAll('#themeToggle .theme-btn').forEach(btn => {
document.querySelectorAll('#themeToggle .toggle-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.theme === theme);
btn.onclick = () => {
document.querySelectorAll('#themeToggle .theme-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('#themeToggle .toggle-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
if (typeof Theme !== 'undefined') Theme.set(btn.dataset.theme);
};
});
// Highlight active keymap button
document.querySelectorAll('#keymapToggle .theme-btn').forEach(btn => {
document.querySelectorAll('#keymapToggle .toggle-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.keymap === keymap);
});
},
@@ -192,11 +192,11 @@ Object.assign(UI, {
});
// Theme toggle buttons
document.querySelectorAll('#themeToggle .theme-btn').forEach(btn => {
document.querySelectorAll('#themeToggle .toggle-btn').forEach(btn => {
btn.addEventListener('click', () => {
const theme = btn.dataset.theme;
UI.applyTheme(theme);
document.querySelectorAll('#themeToggle .theme-btn').forEach(b =>
document.querySelectorAll('#themeToggle .toggle-btn').forEach(b =>
b.classList.toggle('active', b === btn)
);
const p = JSON.parse(localStorage.getItem('cs-appearance') || '{}');
@@ -206,10 +206,10 @@ Object.assign(UI, {
});
// Editor keymap toggle buttons
document.querySelectorAll('#keymapToggle .theme-btn').forEach(btn => {
document.querySelectorAll('#keymapToggle .toggle-btn').forEach(btn => {
btn.addEventListener('click', () => {
const mode = btn.dataset.keymap;
document.querySelectorAll('#keymapToggle .theme-btn').forEach(b =>
document.querySelectorAll('#keymapToggle .toggle-btn').forEach(b =>
b.classList.toggle('active', b === btn)
);
// Persist
@@ -395,7 +395,7 @@ Object.assign(UI, {
</select>
</td>
<td class="admin-actions-cell">
<button class="btn-icon btn-icon-danger" onclick="settingsRemoveTeamMember('${teamId}','${m.id}','${esc(m.email)}')" title="Remove">🗑</button>
<button class="icon-btn icon-btn-danger" onclick="settingsRemoveTeamMember('${teamId}','${m.id}','${esc(m.email)}')" title="Remove">🗑</button>
</td>
</tr>`).join('')}</tbody>
</table>

View File

@@ -12,12 +12,11 @@
// menu.destroy();
const UserMenu = {
primary: null,
_instances: new Map(),
...createComponentRegistry('UserMenu'),
create(opts) {
const pfx = opts.id || '';
const instance = {
const instance = componentMixin({
id: pfx,
btnEl: document.getElementById(pfx + 'userMenuBtn'),
avatarEl: document.getElementById(pfx + 'userAvatar'),
@@ -30,7 +29,6 @@ const UserMenu = {
debugEl: document.getElementById(pfx + 'menuDebug'),
signoutEl: document.getElementById(pfx + 'menuSignout'),
_open: false,
_listeners: [],
// ── User display ────────────────────
@@ -127,28 +125,9 @@ const UserMenu = {
this._on(this.debugEl, 'click', () => { this.close(); if (handlers.onDebug) handlers.onDebug(); });
this._on(this.signoutEl, 'click', () => { this.close(); if (handlers.onSignout) handlers.onSignout(); });
},
}, UserMenu);
// ── Lifecycle ───────────────────────
destroy() {
this._listeners.forEach(({ el, event, handler }) => el.removeEventListener(event, handler));
this._listeners = [];
UserMenu._instances.delete(this.id);
if (UserMenu.primary === this) UserMenu.primary = null;
},
// ── Internal ────────────────────────
_on(el, event, handler) {
if (!el) return;
el.addEventListener(event, handler);
this._listeners.push({ el, event, handler });
},
};
UserMenu._instances.set(pfx, instance);
UserMenu._register(pfx, instance);
return instance;
},
get(id) { return this._instances.get(id) || null; },
};