Changeset 0.25.1 (#161)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 '';
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// ── Secondary Pane Fullscreen ──────────────
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
})();
|
||||
@@ -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="${p.id}"]')" 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="${p.id}"]')" 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>
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 ──────────────────────
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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, '"');
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// §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 };
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; },
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user