1111 lines
53 KiB
JavaScript
1111 lines
53 KiB
JavaScript
// ── Chat Switchboard — UI Primitives ────────────────────────────────────────
|
|
// Single source of truth for constants + reusable rendering functions.
|
|
// 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.
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
'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, '"');
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
// §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
|
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
|
|
const Providers = (() => {
|
|
const _types = [
|
|
{ id: 'openai', label: 'OpenAI-compatible', endpoint: 'https://api.openai.com/v1' },
|
|
{ id: 'anthropic', label: 'Anthropic', endpoint: 'https://api.anthropic.com' },
|
|
{ id: 'venice', label: 'Venice.ai', endpoint: 'https://api.venice.ai/api/v1' },
|
|
{ id: 'openrouter', label: 'OpenRouter', endpoint: 'https://openrouter.ai/api/v1' },
|
|
];
|
|
|
|
return {
|
|
/** Register a new provider type (extension hook). */
|
|
add(entry) {
|
|
if (!entry?.id) throw new Error('Provider entry requires id');
|
|
if (_types.some(t => t.id === entry.id)) return; // no dupes
|
|
_types.push(entry);
|
|
},
|
|
/** All registered types. */
|
|
all() { return _types; },
|
|
/** Lookup by id. */
|
|
get(id) { return _types.find(t => t.id === id) || null; },
|
|
/** Map of id → label. */
|
|
labels() { return Object.fromEntries(_types.map(t => [t.id, t.label])); },
|
|
/** Map of id → default endpoint. */
|
|
endpoints() { return Object.fromEntries(_types.filter(t => t.endpoint).map(t => [t.id, t.endpoint])); },
|
|
/** Build <option> tags for a <select>. */
|
|
optionsHTML(selected) {
|
|
return _types.map(t =>
|
|
`<option value="${t.id}"${t.id === selected ? ' selected' : ''}>${esc(t.label)}</option>`
|
|
).join('');
|
|
},
|
|
};
|
|
})();
|
|
|
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
// §2 ROLE REGISTRY
|
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
|
|
const Roles = (() => {
|
|
const _roles = [
|
|
{ id: 'utility', label: 'Utility', typeFilter: 'chat', hint: 'summarization, title gen' },
|
|
{ id: 'embedding', label: 'Embedding', typeFilter: 'embedding', hint: 'future: KB search, note search' },
|
|
];
|
|
|
|
return {
|
|
/** Register a new role (extension hook). */
|
|
add(entry) {
|
|
if (!entry?.id) throw new Error('Role entry requires id');
|
|
if (_roles.some(r => r.id === entry.id)) return;
|
|
_roles.push(entry);
|
|
},
|
|
all() { return _roles; },
|
|
get(id) { return _roles.find(r => r.id === id) || null; },
|
|
/** Model type filter string for a given role. */
|
|
typeFor(roleId) {
|
|
const r = _roles.find(r => r.id === roleId);
|
|
return r ? r.typeFilter : 'chat';
|
|
},
|
|
};
|
|
})();
|
|
|
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
// §3 CAPABILITY BADGES
|
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
|
|
/**
|
|
* Render capability badge HTML from a capabilities object.
|
|
*
|
|
* @param {Object} caps - Capabilities object (max_output_tokens, vision, etc.)
|
|
* @param {Object} [opts]
|
|
* @param {boolean} [opts.compact=false] - Compact mode (icons only, no labels/titles)
|
|
* @param {Array} [opts.extra] - Additional badge entries [{cap, icon, label, title, cssClass}]
|
|
* @returns {string} HTML string of badge spans
|
|
*/
|
|
function renderCapBadges(caps, opts = {}) {
|
|
if (!caps) return '';
|
|
const compact = opts.compact === true;
|
|
const badges = [];
|
|
|
|
// ── Token counts ──
|
|
if (caps.max_output_tokens > 0) {
|
|
const k = caps.max_output_tokens >= 1000
|
|
? (caps.max_output_tokens / 1000).toFixed(0) + 'K'
|
|
: String(caps.max_output_tokens);
|
|
const label = compact ? k : k + ' out';
|
|
const title = compact ? '' : ` title="Max output: ${caps.max_output_tokens.toLocaleString()} tokens"`;
|
|
badges.push(`<span class="cap-badge cap-context"${title}>${label}</span>`);
|
|
}
|
|
if (!compact && caps.max_context > 0) {
|
|
const k = (caps.max_context / 1000).toFixed(0) + 'K';
|
|
badges.push(`<span class="cap-badge cap-context" title="Context window: ${caps.max_context.toLocaleString()} tokens">${k} ctx</span>`);
|
|
}
|
|
|
|
// ── Capability flags (registry-driven for extensibility) ──
|
|
const flags = [
|
|
{ cap: 'tool_calling', icon: '🔧', label: 'tools', title: 'Supports tool/function calling', css: 'cap-accent' },
|
|
{ cap: 'vision', icon: '👁', label: 'vision', title: 'Supports image/vision input', css: 'cap-accent' },
|
|
{ cap: 'thinking', icon: '💭', label: 'thinking', title: 'Supports extended thinking', css: 'cap-accent' },
|
|
{ cap: 'reasoning', icon: '🧠', label: 'reasoning', title: 'Reasoning model (chain-of-thought)', css: 'cap-accent' },
|
|
{ cap: 'code_optimized', icon: '⟨/⟩', label: 'code', title: 'Optimized for code generation', css: '' },
|
|
{ cap: 'web_search', icon: '🔍', label: 'search', title: 'Supports web search', css: '' },
|
|
];
|
|
|
|
for (const f of flags) {
|
|
if (!caps[f.cap]) continue;
|
|
const text = compact ? f.icon : `${f.icon} ${f.label}`;
|
|
const cls = f.css ? `cap-badge ${f.css}` : 'cap-badge';
|
|
const title = compact ? '' : ` title="${f.title}"`;
|
|
badges.push(`<span class="${cls}"${title}>${text}</span>`);
|
|
}
|
|
|
|
// Extension-provided badges
|
|
if (opts.extra) {
|
|
for (const e of opts.extra) {
|
|
if (e.cap && !caps[e.cap]) continue;
|
|
const cls = e.cssClass || 'cap-badge';
|
|
const title = e.title && !compact ? ` title="${esc(e.title)}"` : '';
|
|
const text = compact ? (e.icon || '') : `${e.icon || ''} ${e.label || ''}`.trim();
|
|
badges.push(`<span class="${cls}"${title}>${text}</span>`);
|
|
}
|
|
}
|
|
|
|
return badges.join('');
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
// §4 PROVIDER FORM
|
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
|
|
/**
|
|
* Render a provider create/edit form into a container.
|
|
*
|
|
* @param {HTMLElement} containerEl
|
|
* @param {Object} opts
|
|
* @param {string} [opts.prefix='prov'] - ID prefix for form elements
|
|
* @param {boolean} [opts.showPrivate=false] - Show "private provider" checkbox
|
|
* @param {boolean} [opts.showDefaultModel=true] - Show default model field
|
|
* @param {Function} [opts.onSubmit] - Called with (values, isEdit) on save
|
|
* @param {Function} [opts.onCancel] - Called on cancel
|
|
* @returns {{ getValues, setValues, clear, setEditMode, container }}
|
|
*/
|
|
function renderProviderForm(containerEl, opts = {}) {
|
|
const pfx = opts.prefix || 'prov';
|
|
const showPrivate = opts.showPrivate === true;
|
|
const showModel = opts.showDefaultModel !== false;
|
|
|
|
containerEl.innerHTML = `
|
|
<div class="form-group"><label>Name</label><input type="text" id="${pfx}_name" placeholder="My Provider"></div>
|
|
<div class="form-group"><label>Provider</label><select id="${pfx}_type">${Providers.optionsHTML()}</select></div>
|
|
<div class="form-group"><label>Endpoint</label><input type="text" id="${pfx}_endpoint" placeholder="https://api.openai.com/v1"></div>
|
|
<div class="form-group"><label>API Key</label><input type="password" id="${pfx}_key" placeholder="sk-..."></div>
|
|
${showModel ? `<div class="form-group"><label>Default Model</label><input type="text" id="${pfx}_model" placeholder="gpt-4o"></div>` : ''}
|
|
${showPrivate ? `<div style="margin:6px 0"><label class="checkbox-label"><input type="checkbox" id="${pfx}_private"> Private provider (data stays on-prem)</label></div>` : ''}
|
|
<div class="form-row">
|
|
<button class="btn-small btn-primary" id="${pfx}_submitBtn">Save</button>
|
|
<button class="btn-small" id="${pfx}_cancelBtn">Cancel</button>
|
|
</div>
|
|
`;
|
|
|
|
// ── Endpoint auto-fill on type change ──
|
|
const typeSelect = document.getElementById(`${pfx}_type`);
|
|
const endpointInput = document.getElementById(`${pfx}_endpoint`);
|
|
const endpoints = Providers.endpoints();
|
|
typeSelect?.addEventListener('change', function () {
|
|
const epVal = endpointInput?.value || '';
|
|
if (!epVal || Object.values(endpoints).includes(epVal)) {
|
|
endpointInput.value = endpoints[this.value] || '';
|
|
}
|
|
});
|
|
|
|
// ── Submit / Cancel wiring ──
|
|
const submitBtn = document.getElementById(`${pfx}_submitBtn`);
|
|
const cancelBtn = document.getElementById(`${pfx}_cancelBtn`);
|
|
submitBtn?.addEventListener('click', () => {
|
|
if (opts.onSubmit) opts.onSubmit(form.getValues(), !!form._editId);
|
|
});
|
|
cancelBtn?.addEventListener('click', () => {
|
|
if (opts.onCancel) opts.onCancel();
|
|
});
|
|
|
|
const form = {
|
|
_editId: null,
|
|
container: containerEl,
|
|
|
|
getValues() {
|
|
const v = {
|
|
name: document.getElementById(`${pfx}_name`)?.value.trim() || '',
|
|
provider: document.getElementById(`${pfx}_type`)?.value || 'openai',
|
|
endpoint: document.getElementById(`${pfx}_endpoint`)?.value.trim() || '',
|
|
api_key: document.getElementById(`${pfx}_key`)?.value || '',
|
|
};
|
|
if (showModel) {
|
|
v.model_default = document.getElementById(`${pfx}_model`)?.value.trim() || '';
|
|
}
|
|
if (showPrivate) {
|
|
v.is_private = document.getElementById(`${pfx}_private`)?.checked || false;
|
|
}
|
|
if (form._editId) v._editId = form._editId;
|
|
return v;
|
|
},
|
|
|
|
setValues(cfg) {
|
|
const el = id => document.getElementById(`${pfx}_${id}`);
|
|
if (el('name')) el('name').value = cfg.name || '';
|
|
if (el('type')) el('type').value = cfg.provider || 'openai';
|
|
if (el('endpoint')) el('endpoint').value = cfg.endpoint || '';
|
|
if (el('key')) {
|
|
el('key').value = '';
|
|
el('key').placeholder = cfg.has_key ? '(unchanged — leave blank to keep)' : 'sk-...';
|
|
}
|
|
if (showModel && el('model')) el('model').value = cfg.model_default || '';
|
|
if (showPrivate && el('private')) el('private').checked = !!cfg.is_private;
|
|
},
|
|
|
|
clear() {
|
|
['name', 'endpoint', 'key'].forEach(f => {
|
|
const el = document.getElementById(`${pfx}_${f}`);
|
|
if (el) el.value = '';
|
|
});
|
|
if (showModel) {
|
|
const m = document.getElementById(`${pfx}_model`);
|
|
if (m) m.value = '';
|
|
}
|
|
if (showPrivate) {
|
|
const p = document.getElementById(`${pfx}_private`);
|
|
if (p) p.checked = false;
|
|
}
|
|
const typeSel = document.getElementById(`${pfx}_type`);
|
|
if (typeSel) typeSel.selectedIndex = 0;
|
|
const keyEl = document.getElementById(`${pfx}_key`);
|
|
if (keyEl) keyEl.placeholder = 'sk-...';
|
|
form._editId = null;
|
|
if (submitBtn) submitBtn.textContent = 'Save';
|
|
},
|
|
|
|
/** Switch form into edit mode for a given provider id. */
|
|
setEditMode(id, cfg) {
|
|
form._editId = id;
|
|
form.setValues(cfg);
|
|
if (submitBtn) submitBtn.textContent = 'Update';
|
|
},
|
|
|
|
/** Back to create mode. */
|
|
setCreateMode() {
|
|
form.clear();
|
|
},
|
|
|
|
/** Get the type select element (for extensions to hook into). */
|
|
getTypeSelect() { return typeSelect; },
|
|
};
|
|
|
|
return form;
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
// §5 PROVIDER LIST
|
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
|
|
/**
|
|
* Render a provider list into a container.
|
|
*
|
|
* @param {HTMLElement} containerEl
|
|
* @param {Object} opts
|
|
* @param {Function} opts.apiFetch - async () → provider array
|
|
* @param {Function} [opts.onEdit] - (provider) → void
|
|
* @param {Function} [opts.onDelete] - (provider) → void
|
|
* @param {Function} [opts.onRefresh] - (provider) → void (refresh models btn)
|
|
* @param {Function} [opts.onToggleActive] - (provider) → void (active/inactive toggle)
|
|
* @param {boolean} [opts.showEndpoint=false]
|
|
* @param {boolean} [opts.showPrivate=false]
|
|
* @param {boolean} [opts.showActive=false]
|
|
* @param {boolean} [opts.showRefresh=false]
|
|
* @param {string} [opts.emptyMsg]
|
|
* @returns {{ refresh, getCache }}
|
|
*/
|
|
function renderProviderList(containerEl, opts = {}) {
|
|
const cache = {};
|
|
|
|
async function refresh(quiet) {
|
|
if (!quiet) containerEl.innerHTML = '<div class="loading">Loading...</div>';
|
|
try {
|
|
const raw = await opts.apiFetch();
|
|
// Normalize — APIs return different shapes
|
|
const list = Array.isArray(raw) ? raw : (raw.configs || raw.providers || raw.data || []);
|
|
|
|
// Clear and rebuild cache
|
|
Object.keys(cache).forEach(k => delete cache[k]);
|
|
list.forEach(c => { cache[c.id] = c; });
|
|
|
|
if (list.length === 0) {
|
|
containerEl.innerHTML = `<div class="empty-hint">${esc(opts.emptyMsg || 'No providers configured.')}</div>`;
|
|
return;
|
|
}
|
|
|
|
containerEl.innerHTML = list.map(c => {
|
|
const meta = [];
|
|
meta.push(esc(c.provider || ''));
|
|
if (c.has_key != null) meta.push(c.has_key ? '🔑' : '⚠️ no key');
|
|
if (opts.showEndpoint && c.endpoint) meta.push(esc(c.endpoint));
|
|
if (!opts.showEndpoint && c.model_default) meta.push(esc(c.model_default));
|
|
|
|
const badges = [];
|
|
if (opts.showPrivate && c.is_private)
|
|
badges.push('<span class="badge-private">private</span>');
|
|
if (opts.showActive) {
|
|
const active = c.is_active !== false;
|
|
badges.push(`<span class="${active ? 'badge-admin' : 'badge-user'}" style="font-size:10px">${active ? 'active' : 'inactive'}</span>`);
|
|
}
|
|
|
|
const actions = [];
|
|
if (opts.onRefresh)
|
|
actions.push(`<button class="btn-small" data-action="refresh" data-id="${c.id}">Refresh Models</button>`);
|
|
if (opts.onEdit)
|
|
actions.push(`<button class="btn-edit" data-action="edit" data-id="${c.id}" title="Edit">✎</button>`);
|
|
if (opts.onToggleActive) {
|
|
const active = c.is_active !== false;
|
|
actions.push(`<button class="admin-model-toggle ${active ? 'enabled' : ''}" data-action="toggle" data-id="${c.id}">${active ? '✓ Active' : 'Inactive'}</button>`);
|
|
}
|
|
if (opts.onDelete)
|
|
actions.push(`<button class="btn-delete" data-action="delete" data-id="${c.id}" title="Delete">✕</button>`);
|
|
|
|
return `<div class="provider-row" data-provider-id="${c.id}">
|
|
<div>
|
|
<span class="provider-name">${esc(c.name)}</span>
|
|
<span class="provider-meta">${meta.join(' · ')}</span>
|
|
${badges.join(' ')}
|
|
</div>
|
|
<div class="provider-actions">${actions.join('')}</div>
|
|
</div>`;
|
|
}).join('');
|
|
|
|
} catch (e) {
|
|
containerEl.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`;
|
|
}
|
|
}
|
|
|
|
// ── Delegate clicks ──
|
|
containerEl.addEventListener('click', (e) => {
|
|
const btn = e.target.closest('[data-action]');
|
|
if (!btn) return;
|
|
const id = btn.dataset.id;
|
|
const provider = cache[id];
|
|
if (!provider) return;
|
|
switch (btn.dataset.action) {
|
|
case 'edit': opts.onEdit?.(provider); break;
|
|
case 'delete': opts.onDelete?.(provider); break;
|
|
case 'refresh': opts.onRefresh?.(provider); break;
|
|
case 'toggle': opts.onToggleActive?.(provider); break;
|
|
}
|
|
});
|
|
|
|
return { refresh, getCache: () => ({ ...cache }) };
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
// §6 ROLE CONFIG
|
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
|
|
/**
|
|
* Render role configuration (provider + model dropdowns per role).
|
|
*
|
|
* @param {HTMLElement} containerEl
|
|
* @param {Object} opts
|
|
* @param {string} opts.scope - 'admin' | 'personal'
|
|
* @param {Function} opts.fetchProviders - async () → provider array
|
|
* @param {Function} opts.fetchModels - async () → model array
|
|
* @param {Function} opts.fetchRoles - async () → { utility: {primary, fallback}, ... }
|
|
* @param {Function} opts.onSave - async (roleId, config) → void
|
|
* @param {Function} [opts.onClear] - async (roleId) → void (personal only)
|
|
* @param {Function} [opts.onTest] - async (roleId) → result (admin only)
|
|
* @param {boolean} [opts.showFallback=false]
|
|
* @param {string} [opts.modelIdField='model_id'] - field name for model ID on model objects
|
|
* @param {string} [opts.modelNameField='display_name'] - field for model display name
|
|
* @param {string} [opts.providerIdField='provider_config_id'] - field on models for their provider
|
|
* @param {string} [opts.noneLabel='— none —']
|
|
* @returns {{ refresh }}
|
|
*/
|
|
function renderRoleConfig(containerEl, opts = {}) {
|
|
const scope = opts.scope || 'admin';
|
|
const showFallback = opts.showFallback === true;
|
|
const midField = opts.modelIdField || 'model_id';
|
|
const mnameField = opts.modelNameField || 'display_name';
|
|
const pidField = opts.providerIdField || 'provider_config_id';
|
|
const noneLabel = opts.noneLabel || '— none —';
|
|
|
|
// Cached data for provider-change handler
|
|
let _providers = [];
|
|
let _models = [];
|
|
let _roles = {};
|
|
|
|
function filterModels(providerId, roleId) {
|
|
const typeFilter = Roles.typeFor(roleId);
|
|
return _models.filter(m => {
|
|
if (m[pidField] !== providerId) return false;
|
|
const mt = m.model_type || '';
|
|
// Embedding role: include models typed as 'embedding' OR untyped.
|
|
// Many providers don't report type for embedding models.
|
|
if (typeFilter === 'embedding') return !mt || mt === 'embedding';
|
|
// Other roles: default untyped to 'chat' (existing behavior).
|
|
return (mt || 'chat') === typeFilter;
|
|
});
|
|
}
|
|
|
|
function onProviderChange(roleId, slot) {
|
|
const provSel = containerEl.querySelector(`[data-role-provider="${roleId}-${slot}"]`);
|
|
const modelSel = containerEl.querySelector(`[data-role-model="${roleId}-${slot}"]`);
|
|
const modelInput = containerEl.querySelector(`[data-role-model-input="${roleId}-${slot}"]`);
|
|
const toggleBtn = containerEl.querySelector(`[data-role-model-toggle="${roleId}-${slot}"]`);
|
|
if (!provSel || !modelSel) return;
|
|
|
|
modelSel.innerHTML = '<option value="">— select model —</option>';
|
|
const provId = provSel.value;
|
|
if (!provId) return;
|
|
|
|
const models = filterModels(provId, roleId);
|
|
models.forEach(m => {
|
|
const opt = document.createElement('option');
|
|
opt.value = m[midField];
|
|
opt.textContent = m[mnameField] || m[midField];
|
|
modelSel.appendChild(opt);
|
|
});
|
|
|
|
// Auto-switch to manual entry if dropdown has no models.
|
|
if (models.length === 0 && modelInput && toggleBtn) {
|
|
modelSel.style.display = 'none';
|
|
modelInput.style.display = '';
|
|
toggleBtn.title = 'Switch to dropdown';
|
|
toggleBtn.textContent = '▾';
|
|
}
|
|
}
|
|
|
|
function toggleManualEntry(roleId, slot) {
|
|
const modelSel = containerEl.querySelector(`[data-role-model="${roleId}-${slot}"]`);
|
|
const modelInput = containerEl.querySelector(`[data-role-model-input="${roleId}-${slot}"]`);
|
|
const toggleBtn = containerEl.querySelector(`[data-role-model-toggle="${roleId}-${slot}"]`);
|
|
if (!modelSel || !modelInput || !toggleBtn) return;
|
|
|
|
const showingDropdown = modelSel.style.display !== 'none';
|
|
if (showingDropdown) {
|
|
// Switch to manual: copy dropdown value into input.
|
|
modelInput.value = modelSel.value || modelInput.value;
|
|
modelSel.style.display = 'none';
|
|
modelInput.style.display = '';
|
|
toggleBtn.title = 'Switch to dropdown';
|
|
toggleBtn.textContent = '▾';
|
|
} else {
|
|
// Switch to dropdown: try to select the typed value.
|
|
modelSel.style.display = '';
|
|
modelInput.style.display = 'none';
|
|
toggleBtn.title = 'Type model ID manually';
|
|
toggleBtn.textContent = '✎';
|
|
if (modelInput.value) {
|
|
modelSel.value = modelInput.value;
|
|
}
|
|
}
|
|
}
|
|
|
|
function getBinding(roleId, slot) {
|
|
const prov = containerEl.querySelector(`[data-role-provider="${roleId}-${slot}"]`)?.value;
|
|
const modelSel = containerEl.querySelector(`[data-role-model="${roleId}-${slot}"]`);
|
|
const modelInput = containerEl.querySelector(`[data-role-model-input="${roleId}-${slot}"]`);
|
|
// Use manual input if it's visible, otherwise use dropdown.
|
|
const model = (modelInput && modelInput.style.display !== 'none')
|
|
? modelInput.value.trim()
|
|
: (modelSel?.value || '');
|
|
return (prov && model) ? { provider_config_id: prov, model_id: model } : null;
|
|
}
|
|
|
|
async function handleSave(roleId) {
|
|
const statusEl = containerEl.querySelector(`[data-role-status="${roleId}"]`);
|
|
try {
|
|
const config = { primary: getBinding(roleId, 'primary') };
|
|
if (showFallback) config.fallback = getBinding(roleId, 'fallback');
|
|
await opts.onSave(roleId, config);
|
|
if (statusEl) { statusEl.textContent = '✓ Saved'; statusEl.style.color = 'var(--success)'; }
|
|
// Reload after short delay so dropdowns reflect saved state
|
|
setTimeout(() => refresh(), 500);
|
|
} catch (e) {
|
|
if (statusEl) { statusEl.textContent = '✗ ' + e.message; statusEl.style.color = 'var(--danger)'; }
|
|
}
|
|
}
|
|
|
|
async function handleTest(roleId) {
|
|
if (!opts.onTest) return;
|
|
const statusEl = containerEl.querySelector(`[data-role-status="${roleId}"]`);
|
|
if (statusEl) { statusEl.textContent = 'Testing...'; statusEl.style.color = 'var(--text-3)'; }
|
|
try {
|
|
const result = await opts.onTest(roleId);
|
|
if (statusEl) {
|
|
const fb = result.used_fallback ? ' (fallback)' : '';
|
|
statusEl.textContent = `✓ ${result.model}${fb}`;
|
|
statusEl.style.color = 'var(--success)';
|
|
}
|
|
} catch (e) {
|
|
if (statusEl) { statusEl.textContent = '✗ ' + e.message; statusEl.style.color = 'var(--danger)'; }
|
|
}
|
|
}
|
|
|
|
async function handleClear(roleId) {
|
|
if (!opts.onClear) return;
|
|
try {
|
|
await opts.onClear(roleId);
|
|
refresh();
|
|
} catch (e) { UI.toast(e.message, 'error'); }
|
|
}
|
|
|
|
function renderSlot(roleId, slotName, cfg, slotLabel) {
|
|
const binding = cfg?.[slotName];
|
|
const selectedProv = binding?.provider_config_id || '';
|
|
const selectedModel = binding?.model_id || '';
|
|
|
|
const provOptions = _providers.map(p =>
|
|
`<option value="${p.id}"${p.id === selectedProv ? ' selected' : ''}>${esc(p.name)}</option>`
|
|
).join('');
|
|
|
|
const dropdownModels = selectedProv ? filterModels(selectedProv, roleId) : [];
|
|
const modelOptions = dropdownModels.map(m =>
|
|
`<option value="${m[midField]}"${m[midField] === selectedModel ? ' selected' : ''}>${esc(m[mnameField] || m[midField])}</option>`
|
|
).join('');
|
|
|
|
// Show manual entry if: saved model ID not in dropdown, or dropdown is empty with a provider selected.
|
|
const savedNotInDropdown = selectedModel && selectedProv &&
|
|
!dropdownModels.some(m => m[midField] === selectedModel);
|
|
const showManual = savedNotInDropdown || (selectedProv && dropdownModels.length === 0);
|
|
|
|
return `
|
|
<div class="form-row" style="gap:8px;align-items:center${slotName === 'fallback' ? ';margin-top:6px' : ''}">
|
|
<label style="min-width:60px;font-size:12px">${slotLabel}</label>
|
|
<select data-role-provider="${roleId}-${slotName}" style="min-width:140px">
|
|
<option value="">${noneLabel}</option>
|
|
${provOptions}
|
|
</select>
|
|
<select data-role-model="${roleId}-${slotName}" style="min-width:200px${showManual ? ';display:none' : ''}">
|
|
<option value="">— select model —</option>
|
|
${modelOptions}
|
|
</select>
|
|
<input type="text" data-role-model-input="${roleId}-${slotName}"
|
|
placeholder="Model ID (e.g. text-embedding-3-small)"
|
|
value="${esc(showManual ? selectedModel : '')}"
|
|
style="min-width:200px;font-size:12px${showManual ? '' : ';display:none'}">
|
|
<button type="button" class="btn-small" data-role-model-toggle="${roleId}-${slotName}"
|
|
title="${showManual ? 'Switch to dropdown' : 'Type model ID manually'}"
|
|
style="padding:2px 6px;font-size:12px;line-height:1">${showManual ? '▾' : '✎'}</button>
|
|
</div>`;
|
|
}
|
|
|
|
async function refresh() {
|
|
containerEl.innerHTML = '<div class="loading">Loading...</div>';
|
|
try {
|
|
const [provRaw, modelRaw, roleData] = await Promise.all([
|
|
opts.fetchProviders(),
|
|
opts.fetchModels(),
|
|
opts.fetchRoles(),
|
|
]);
|
|
|
|
// Normalize
|
|
_providers = Array.isArray(provRaw) ? provRaw : (provRaw.configs || provRaw.providers || provRaw.data || []);
|
|
_models = Array.isArray(modelRaw) ? modelRaw : (modelRaw.models || modelRaw.data || []);
|
|
_roles = roleData || {};
|
|
|
|
const roles = Roles.all();
|
|
containerEl.innerHTML = roles.map(role => {
|
|
const cfg = _roles[role.id] || { primary: null, fallback: null };
|
|
const hasClear = scope === 'personal' && opts.onClear && cfg.primary;
|
|
return `
|
|
<div class="settings-section" style="margin-bottom:${scope === 'admin' ? 16 : 12}px">
|
|
<${scope === 'admin' ? 'h3' : 'h4'} style="font-size:${scope === 'admin' ? 14 : 13}px;margin:0 0 ${scope === 'admin' ? 8 : 6}px;text-transform:capitalize">
|
|
${esc(role.label || role.id)}
|
|
${scope === 'personal' && role.hint ? `<span class="form-hint">(${esc(role.hint)})</span>` : ''}
|
|
</${scope === 'admin' ? 'h3' : 'h4'}>
|
|
${renderSlot(role.id, 'primary', cfg, scope === 'personal' ? 'Provider' : 'Primary')}
|
|
${showFallback ? renderSlot(role.id, 'fallback', cfg, 'Fallback') : ''}
|
|
<div class="form-row" style="gap:8px;margin-top:8px">
|
|
<button class="btn-primary btn-small" data-role-save="${role.id}">Save</button>
|
|
${opts.onTest ? `<button class="btn-secondary btn-small" data-role-test="${role.id}">Test</button>` : ''}
|
|
${hasClear ? `<button class="btn-small btn-danger" data-role-clear="${role.id}" title="Remove override, use org default">Clear</button>` : ''}
|
|
<span data-role-status="${role.id}" style="font-size:12px"></span>
|
|
</div>
|
|
</div>`;
|
|
}).join('');
|
|
|
|
} catch (e) {
|
|
containerEl.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`;
|
|
}
|
|
}
|
|
|
|
// ── Delegate events ──
|
|
containerEl.addEventListener('change', (e) => {
|
|
const provSel = e.target.closest('[data-role-provider]');
|
|
if (!provSel) return;
|
|
const [roleId, slot] = provSel.dataset.roleProvider.split('-');
|
|
onProviderChange(roleId, slot);
|
|
});
|
|
|
|
containerEl.addEventListener('click', (e) => {
|
|
const saveBtn = e.target.closest('[data-role-save]');
|
|
if (saveBtn) { handleSave(saveBtn.dataset.roleSave); return; }
|
|
const testBtn = e.target.closest('[data-role-test]');
|
|
if (testBtn) { handleTest(testBtn.dataset.roleTest); return; }
|
|
const clearBtn = e.target.closest('[data-role-clear]');
|
|
if (clearBtn) { handleClear(clearBtn.dataset.roleClear); return; }
|
|
const toggleBtn = e.target.closest('[data-role-model-toggle]');
|
|
if (toggleBtn) {
|
|
const [roleId, slot] = toggleBtn.dataset.roleModelToggle.split('-');
|
|
toggleManualEntry(roleId, slot);
|
|
return;
|
|
}
|
|
});
|
|
|
|
return { refresh };
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
// §7 USAGE DASHBOARD
|
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
|
|
/**
|
|
* Render usage stats + table into a container.
|
|
*
|
|
* @param {HTMLElement} containerEl
|
|
* @param {Object} opts
|
|
* @param {Function} opts.apiFetch - async ({period, group_by}) → { totals, results }
|
|
* @param {string} [opts.periodElId] - ID of period <select>
|
|
* @param {string} [opts.groupByElId] - ID of groupBy <select>
|
|
* @param {boolean} [opts.compact=false] - Use compact sizing (user/team)
|
|
* @param {boolean} [opts.showUserColumn=false] - Include "User" in group_by options
|
|
* @param {Array} [opts.extraColumns] - [{header, render: (row) → html}]
|
|
* @returns {{ refresh }}
|
|
*/
|
|
function renderUsageDashboard(containerEl, opts = {}) {
|
|
const compact = opts.compact === true;
|
|
const cardStyle = compact ? ' style="padding:8px"' : '';
|
|
const valueStyle = compact ? ' style="font-size:16px"' : '';
|
|
const labelStyle = compact ? ' style="font-size:10px"' : '';
|
|
const gridStyle = compact ? ' style="grid-template-columns:repeat(4,1fr);gap:6px;margin-bottom:8px"' : '';
|
|
const tableStyle = compact ? ' style="font-size:12px"' : ' style="margin-top:12px"';
|
|
|
|
// We render into two sub-zones
|
|
let totalsEl, resultsEl;
|
|
|
|
function ensureStructure() {
|
|
if (!containerEl.querySelector('[data-usage-totals]')) {
|
|
containerEl.innerHTML = `
|
|
<div data-usage-totals></div>
|
|
<div data-usage-results></div>
|
|
`;
|
|
}
|
|
totalsEl = containerEl.querySelector('[data-usage-totals]');
|
|
resultsEl = containerEl.querySelector('[data-usage-results]');
|
|
}
|
|
|
|
async function refresh() {
|
|
ensureStructure();
|
|
const period = opts.periodElId ? (document.getElementById(opts.periodElId)?.value || '30d') : '30d';
|
|
const groupBy = opts.groupByElId ? (document.getElementById(opts.groupByElId)?.value || 'model') : 'model';
|
|
|
|
totalsEl.innerHTML = compact
|
|
? '<div class="loading" style="font-size:12px">Loading...</div>'
|
|
: '<div class="loading">Loading...</div>';
|
|
resultsEl.innerHTML = '';
|
|
|
|
try {
|
|
const data = await opts.apiFetch({ period, group_by: groupBy });
|
|
const t = data.totals || {};
|
|
|
|
if (!t.requests && compact) {
|
|
totalsEl.innerHTML = '<div class="empty-hint">No usage recorded in this period</div>';
|
|
return;
|
|
}
|
|
|
|
totalsEl.innerHTML = `
|
|
<div class="stats-grid"${gridStyle}>
|
|
<div class="stat-card"${cardStyle}><div class="stat-value"${valueStyle}>${(t.requests || 0).toLocaleString()}</div><div class="stat-label"${labelStyle}>Requests</div></div>
|
|
<div class="stat-card"${cardStyle}><div class="stat-value"${valueStyle}>${(t.input_tokens || 0).toLocaleString()}</div><div class="stat-label"${labelStyle}>Input${compact ? '' : ' Tokens'}</div></div>
|
|
<div class="stat-card"${cardStyle}><div class="stat-value"${valueStyle}>${(t.output_tokens || 0).toLocaleString()}</div><div class="stat-label"${labelStyle}>Output${compact ? '' : ' Tokens'}</div></div>
|
|
<div class="stat-card"${cardStyle}><div class="stat-value"${valueStyle}>$${(t.total_cost || 0).toFixed(4)}</div><div class="stat-label"${labelStyle}>Est. Cost</div></div>
|
|
</div>`;
|
|
|
|
const results = data.results || [];
|
|
if (results.length === 0) {
|
|
resultsEl.innerHTML = '<div class="empty-hint">No usage data for this period</div>';
|
|
return;
|
|
}
|
|
|
|
// Column header depends on groupBy
|
|
let firstCol;
|
|
if (groupBy === 'day') firstCol = 'Date';
|
|
else if (groupBy === 'user') firstCol = 'User';
|
|
else firstCol = 'Model';
|
|
|
|
const extraHeaders = (opts.extraColumns || []).map(c =>
|
|
`<th style="text-align:right">${esc(c.header)}</th>`
|
|
).join('');
|
|
|
|
resultsEl.innerHTML = `
|
|
<table class="admin-table"${tableStyle}>
|
|
<thead><tr>
|
|
<th>${firstCol}</th>
|
|
<th style="text-align:right">${compact ? 'Reqs' : 'Requests'}</th>
|
|
<th style="text-align:right">Input</th>
|
|
<th style="text-align:right">Output</th>
|
|
<th style="text-align:right">Cost</th>
|
|
${extraHeaders}
|
|
</tr></thead>
|
|
<tbody>${results.map(r => {
|
|
const extraCells = (opts.extraColumns || []).map(c =>
|
|
`<td style="text-align:right">${c.render(r)}</td>`
|
|
).join('');
|
|
return `<tr>
|
|
<td>${esc(r.label || r.group_key)}</td>
|
|
<td style="text-align:right">${(r.requests || 0).toLocaleString()}</td>
|
|
<td style="text-align:right">${(r.input_tokens || 0).toLocaleString()}</td>
|
|
<td style="text-align:right">${(r.output_tokens || 0).toLocaleString()}</td>
|
|
<td style="text-align:right">$${(r.total_cost || 0).toFixed(4)}</td>
|
|
${extraCells}
|
|
</tr>`;
|
|
}).join('')}</tbody>
|
|
</table>`;
|
|
} catch (e) {
|
|
totalsEl.innerHTML = `<div class="error-hint"${compact ? ' style="font-size:12px"' : ''}>${esc(e.message)}</div>`;
|
|
}
|
|
}
|
|
|
|
return { refresh };
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
// §8 CONFIRM DIALOG
|
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
|
|
/**
|
|
* Custom confirm dialog replacing native confirm().
|
|
* Returns a Promise<boolean>.
|
|
*
|
|
* @param {string} message
|
|
* @param {Object} [opts]
|
|
* @param {string} [opts.title='Confirm']
|
|
* @param {string} [opts.ok='Confirm']
|
|
* @param {string} [opts.cancel='Cancel']
|
|
* @param {boolean} [opts.danger=false] - Styles the confirm button as destructive
|
|
* @returns {Promise<boolean>}
|
|
*/
|
|
function showConfirm(message, opts = {}) {
|
|
return new Promise(resolve => {
|
|
const title = opts.title || 'Confirm';
|
|
const okText = opts.ok || 'Confirm';
|
|
const caText = opts.cancel || 'Cancel';
|
|
const danger = opts.danger !== false; // default true for destructive actions
|
|
|
|
const overlay = document.createElement('div');
|
|
overlay.className = 'confirm-overlay';
|
|
overlay.innerHTML = `
|
|
<div class="confirm-dialog">
|
|
<div class="confirm-header">${esc(title)}</div>
|
|
<div class="confirm-body">${esc(message)}</div>
|
|
<div class="confirm-footer">
|
|
<button class="btn-small" data-action="cancel">${esc(caText)}</button>
|
|
<button class="btn-small ${danger ? 'btn-danger' : 'btn-primary'}" data-action="ok">${esc(okText)}</button>
|
|
</div>
|
|
</div>`;
|
|
|
|
function close(result) {
|
|
overlay.remove();
|
|
document.removeEventListener('keydown', onKey);
|
|
resolve(result);
|
|
}
|
|
|
|
function onKey(e) {
|
|
if (e.key === 'Escape') close(false);
|
|
if (e.key === 'Enter') close(true);
|
|
}
|
|
|
|
overlay.querySelector('[data-action="ok"]').addEventListener('click', () => close(true));
|
|
overlay.querySelector('[data-action="cancel"]').addEventListener('click', () => close(false));
|
|
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(false); });
|
|
document.addEventListener('keydown', onKey);
|
|
|
|
document.body.appendChild(overlay);
|
|
overlay.querySelector('[data-action="cancel"]').focus();
|
|
});
|
|
}
|
|
|
|
// §8b PROMPT DIALOG
|
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
|
|
/**
|
|
* Custom prompt dialog replacing native prompt().
|
|
* Returns a Promise<string|null> — null means cancelled or empty.
|
|
*
|
|
* @param {Object} opts
|
|
* @param {string} [opts.title='Enter value']
|
|
* @param {string} [opts.label] - Optional helper text below title
|
|
* @param {string} [opts.value=''] - Pre-filled value
|
|
* @param {string} [opts.placeholder='']
|
|
* @param {string} [opts.ok='OK']
|
|
* @param {string} [opts.cancel='Cancel']
|
|
* @param {string} [opts.inputType='text']
|
|
* @returns {Promise<string|null>}
|
|
*/
|
|
function showPrompt(opts = {}) {
|
|
return new Promise(resolve => {
|
|
const title = opts.title || 'Enter value';
|
|
const label = opts.label || '';
|
|
const defVal = opts.value || '';
|
|
const ph = opts.placeholder || '';
|
|
const okText = opts.ok || 'OK';
|
|
const caText = opts.cancel || 'Cancel';
|
|
const iType = opts.inputType || 'text';
|
|
|
|
const overlay = document.createElement('div');
|
|
overlay.className = 'confirm-overlay';
|
|
overlay.innerHTML = `
|
|
<div class="confirm-dialog">
|
|
<div class="confirm-header">${esc(title)}</div>
|
|
${label ? `<div class="confirm-body">${esc(label)}</div>` : ''}
|
|
<div class="prompt-input-wrap">
|
|
<input class="prompt-input" type="${esc(iType)}"
|
|
value="${esc(defVal)}" placeholder="${esc(ph)}" autocomplete="off">
|
|
</div>
|
|
<div class="confirm-footer">
|
|
<button class="btn-small" data-action="cancel">${esc(caText)}</button>
|
|
<button class="btn-small btn-primary" data-action="ok">${esc(okText)}</button>
|
|
</div>
|
|
</div>`;
|
|
|
|
const input = overlay.querySelector('.prompt-input');
|
|
|
|
function close(result) {
|
|
overlay.remove();
|
|
document.removeEventListener('keydown', onKey);
|
|
resolve(result);
|
|
}
|
|
|
|
function onKey(e) {
|
|
if (e.key === 'Escape') { close(null); }
|
|
if (e.key === 'Enter') { const v = input.value.trim(); close(v || null); }
|
|
}
|
|
|
|
overlay.querySelector('[data-action="ok"]').addEventListener('click', () => {
|
|
close(input.value.trim() || null);
|
|
});
|
|
overlay.querySelector('[data-action="cancel"]').addEventListener('click', () => close(null));
|
|
overlay.addEventListener('click', e => { if (e.target === overlay) close(null); });
|
|
document.addEventListener('keydown', onKey);
|
|
|
|
document.body.appendChild(overlay);
|
|
requestAnimationFrame(() => { input.focus(); if (defVal) input.select(); });
|
|
});
|
|
}
|
|
|
|
// ── Modal open/close ────────────────────────
|
|
// Generic modal show/hide used by settings, admin, debug, team-admin modals.
|
|
// Moved here from app.js so all surfaces (not just chat) can use modals.
|
|
|
|
function openModal(id) {
|
|
const el = document.getElementById(id);
|
|
if (!el) return;
|
|
el.classList.add('active');
|
|
// Defer overflow check — browser needs a frame to lay out the modal
|
|
requestAnimationFrame(() => {
|
|
el.querySelectorAll('.modal-tabs').forEach(checkTabsOverflow);
|
|
});
|
|
}
|
|
|
|
function closeModal(id) {
|
|
const el = document.getElementById(id);
|
|
if (el) el.classList.remove('active');
|
|
}
|
|
|
|
// Toggle .is-scrollable on tab bars that overflow horizontally
|
|
// and inject arrow navigation buttons when needed.
|
|
function checkTabsOverflow(tabs) {
|
|
if (!tabs) return;
|
|
const scrollable = tabs.scrollWidth > tabs.clientWidth + 1;
|
|
|
|
if (!scrollable) {
|
|
const wrap = tabs.parentElement;
|
|
if (wrap && wrap.classList.contains('modal-tabs-wrap')) {
|
|
wrap.parentElement.insertBefore(tabs, wrap);
|
|
wrap.remove();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!tabs.parentElement.classList.contains('modal-tabs-wrap')) {
|
|
const wrap = document.createElement('div');
|
|
wrap.className = 'modal-tabs-wrap';
|
|
tabs.parentElement.insertBefore(wrap, tabs);
|
|
wrap.appendChild(tabs);
|
|
|
|
const left = document.createElement('button');
|
|
left.className = 'tab-arrow tab-arrow-left';
|
|
left.innerHTML = '‹';
|
|
left.addEventListener('click', () => { tabs.scrollLeft -= 120; });
|
|
|
|
const right = document.createElement('button');
|
|
right.className = 'tab-arrow tab-arrow-right';
|
|
right.innerHTML = '›';
|
|
right.addEventListener('click', () => { tabs.scrollLeft += 120; });
|
|
|
|
wrap.appendChild(left);
|
|
wrap.appendChild(right);
|
|
|
|
tabs.addEventListener('scroll', () => updateTabArrows(tabs), { passive: true });
|
|
}
|
|
|
|
updateTabArrows(tabs);
|
|
}
|
|
|
|
function updateTabArrows(tabs) {
|
|
const wrap = tabs.parentElement;
|
|
if (!wrap || !wrap.classList.contains('modal-tabs-wrap')) return;
|
|
|
|
const left = wrap.querySelector('.tab-arrow-left');
|
|
const right = wrap.querySelector('.tab-arrow-right');
|
|
if (!left || !right) return;
|
|
|
|
const atStart = tabs.scrollLeft <= 2;
|
|
const atEnd = tabs.scrollLeft + tabs.clientWidth >= tabs.scrollWidth - 2;
|
|
|
|
left.classList.toggle('visible', !atStart);
|
|
right.classList.toggle('visible', !atEnd);
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
// §9 THEME
|
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
|
|
const Theme = {
|
|
_key: 'switchboard_theme',
|
|
_mqListener: null,
|
|
|
|
init() {
|
|
this.set(localStorage.getItem(this._key) || 'system');
|
|
},
|
|
|
|
set(mode) {
|
|
localStorage.setItem(this._key, mode);
|
|
// Remove any prior system-preference listener
|
|
if (this._mqListener) {
|
|
window.matchMedia('(prefers-color-scheme: dark)').removeEventListener('change', this._mqListener);
|
|
this._mqListener = null;
|
|
}
|
|
if (mode === 'system') {
|
|
// Resolve current OS preference and apply it
|
|
const resolved = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
|
document.documentElement.setAttribute('data-theme', resolved);
|
|
// Listen for OS preference changes while in system mode
|
|
this._mqListener = (e) => {
|
|
// Only react if still in system mode
|
|
if (this.get() === 'system') {
|
|
document.documentElement.setAttribute('data-theme', e.matches ? 'dark' : 'light');
|
|
}
|
|
};
|
|
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', this._mqListener);
|
|
} 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 };
|
|
}
|