Settings overhaul + UI polish pass

Two months of iteration on the Settings panel, integration forms, and
small visual nudges across the app. Highlights:

Settings restructure
- Add Models: split into separate Local + API cards (no more in-card
  tabs); each fuses Type/Provider with the URL input.
- Added Models: new dedicated sidebar tab, with Probe + Clear-offline
  pulled into its header; Local/API sub-section icons accent-tinted.
- Search: Web Search and a new Deep Research card (Model + tuning),
  with a cross-link to AI Defaults. Provider hints use real clickable
  anchors; Web Search Test button shows a whirlpool spinner.
- AI Defaults: Image Generation card returns; Research Model card
  carries only Endpoint+Model with a cross-link to Search; Vision /
  Default / Utility fallbacks unified under one numbered-row design
  matching Search's chain.
- API Permissions (was 'API Tokens'): per-row rename, inline
  Permissions toggle that expands the scope-edit panel, in-field
  copy icons (icon→check on success). Empty state accent-tinted.
- Integrations: + Add Integration drops a type-picker menu directly
  under the button (drop-up on tight viewports); each integration
  form (API, CalDAV, CardDAV, Email, Codex/Claude, Vault, MCP) uses
  the same accent-outlined Save/Test/Cancel buttons right-aligned.
- Danger Zone: Wipe→Delete with trash icons; new 'Delete everything'
  row at the bottom that loops every category.

AI Synthesis (Reminders)
- Persona dropdown sourced from PROMPT_TEMPLATES + custom preset.
- src/reminder_personas.py mirrors the five built-ins for the
  server-side synthesis path.
- dispatch_reminder() reads reminder_llm_persona and uses the
  persona's system prompt; empty/unknown falls back to warm-neutral.

Esc handling
- Kebab menus and the provider picker intercept Esc in capture phase
  so dismissing a popup no longer closes the whole Settings modal.

Accent tinting
- Scoped CSS rule across data-settings-panel=ai/services/added-models/
  search/integrations/reminders for card h2 icons + the Added Models
  sub-section icons.

Codex/Claude integration form
- No more auto-creation on form open — explicit Create token button.
- New tokens start with every scope granted; existing tokens move out
  of the integration form into the API Permissions card.
- Setup reveal: copy buttons inline inside the token + setup code
  blocks; shorter subtitle wording.

Misc visual polish
- Save/Test/Cancel uniformly accent-outlined and right-aligned on
  every integration form.
- Provider logos render inline next to the search fallback selects
  and the Deep Research Search dropdown.
- Trash icons in fallback rows bumped to 20x20 so they fill the 32px
  button.
- Image generation default flipped to off.
This commit is contained in:
pewdiepie-archdaemon
2026-06-10 15:15:13 +09:00
parent 7690860ab1
commit 4f7061fd61
18 changed files with 1512 additions and 552 deletions
+323 -43
View File
@@ -3,7 +3,7 @@
import uiModule from './ui.js';
import settingsModule from './settings.js';
import { providerLogo } from './providers.js';
import { providerLogo, providerLogoFromUrl } from './providers.js';
import { sortModelObjects } from './modelSort.js';
import { PROVIDER_DEVICE_FLOWS, formatDeviceFlowError, runProviderDeviceFlow } from './providerDeviceFlow.js';
@@ -449,13 +449,14 @@ async function loadEndpoints() {
return `
<div class="admin-user-row${ep.is_enabled ? '' : ' admin-ep-disabled'}${justAddedClass}" data-adm-ep-id="${ep.id}">
<div style="display:flex;align-items:center;justify-content:space-between;${hasModels ? 'cursor:pointer;' : ''}padding:4px 0;" data-adm-ep-header="${ep.id}">
<div class="admin-user-info" style="flex:1;flex-wrap:wrap;gap:0.3rem;">
<div class="admin-user-info" style="flex:1;flex-wrap:wrap;gap:0.3rem;align-items:center;">
<span class="adm-ep-row-logo" style="display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;flex-shrink:0;opacity:0.9;">${providerLogoFromUrl(ep.base_url) || ''}</span>
<span class="admin-user-name">${esc(ep.name)}</span>
${ep.model_type === 'image' ? '<span class="admin-badge" style="background:color-mix(in srgb, var(--accent) 20%, transparent);color:var(--accent);">Image</span>' : ''}
${kindLabel ? `<span class="admin-badge">${esc(kindLabel)}</span>` : ''}
${statusBadge}
${ep.is_enabled ? '' : '<span class="admin-badge admin-badge-off">disabled</span>'}
${hasModels ? '<span style="font-size:10px;opacity:0.4;">Click to manage models</span>' : ''}
${hasModels ? `<span style="font-size:10px;opacity:0.4;${category === 'api' ? 'flex-basis:100%;' : ''}">Click to manage models</span>` : ''}
</div>
<div style="display:flex;gap:4px;align-items:center;">
<button class="admin-btn-sm" data-adm-toggle-ep="${ep.id}">${ep.is_enabled ? 'Disable' : 'Enable'}</button>
@@ -828,6 +829,14 @@ function initEndpointForm() {
document.addEventListener('click', (e) => {
if (!picker.contains(e.target)) pickerMenu.classList.add('hidden');
});
// Capture-phase Esc: dismiss the picker menu without bubbling to the
// settings-modal handler that would otherwise close the whole modal.
document.addEventListener('keydown', (e) => {
if (e.key !== 'Escape') return;
if (pickerMenu.classList.contains('hidden')) return;
e.stopPropagation();
pickerMenu.classList.add('hidden');
}, { capture: true });
}
provider.addEventListener('change', () => {
@@ -1022,14 +1031,15 @@ function initEndpointForm() {
if (d.id) _recentlyAddedEpId = String(d.id);
await loadEndpoints();
await _selectAddedModelInChat(d);
const goLink = ' <a href="#" data-go-added-models style="margin-left:6px;text-decoration:underline;color:inherit;font-weight:600;">Added Models →</a>';
if (!d.online) {
msg.textContent = 'Added (endpoint offline — will retry on next load)';
msg.innerHTML = 'Added (endpoint offline — will retry on next load)' + goLink;
msg.className = 'admin-error';
} else if (d.status === 'empty') {
msg.textContent = 'Added — endpoint reachable, no models found';
msg.innerHTML = 'Added — endpoint reachable, no models found' + goLink;
msg.className = 'admin-success';
} else {
msg.textContent = `Added — found ${count} model${count !== 1 ? 's' : ''}`;
msg.innerHTML = `Added — found ${count} model${count !== 1 ? 's' : ''}` + goLink;
msg.className = 'admin-success';
}
} else { msg.textContent = d.detail || 'Failed'; msg.className = 'admin-error'; }
@@ -1169,6 +1179,125 @@ function initEndpointForm() {
};
_wireKeyToggle('adm-epLocalKeyBtn', 'adm-epLocalApiKey-row');
// Delegated link handler for jumping between settings tabs.
// [data-go-added-models] → quick shortcut for the Added Models tab
// [data-go-settings-tab="X"] → any tab whose nav button has data-settings-tab="X"
// [data-go-scroll-to="#elementId"] → after switching, scroll the element into view
document.addEventListener('click', (e) => {
const explicit = e.target.closest('[data-go-settings-tab]');
if (explicit) {
e.preventDefault();
const tab = explicit.getAttribute('data-go-settings-tab');
const scrollTo = explicit.getAttribute('data-go-scroll-to');
const btn = document.querySelector(`[data-settings-tab="${tab}"]`);
if (btn) btn.click();
if (scrollTo) {
// Defer to the next frame so the panel has actually become visible
// before we try to scroll into it.
requestAnimationFrame(() => {
const target = document.querySelector(scrollTo);
if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
}
return;
}
const link = e.target.closest('[data-go-added-models]');
if (!link) return;
e.preventDefault();
const btn = document.querySelector('[data-settings-tab="added-models"]');
if (btn) btn.click();
});
// Generic open/close helper for the kebab dropdowns in this card.
// Both the Local and API cards use the same shape: an h2-anchored button
// with id "<prefix>MoreBtn" toggles a sibling menu with id "<prefix>MoreMenu".
// Global Esc handler: close any currently-open kebab menu in the admin
// panel regardless of which _wireKebab instance owns it. Belt-and-braces
// backup for the per-instance handler below — registered once.
if (!document._admKebabEscWired) {
document._admKebabEscWired = true;
document.addEventListener('keydown', (e) => {
if (e.key !== 'Escape') return;
// Any visible kebab dropdown in the admin panel — match by id pattern
// so adding a new kebab elsewhere automatically benefits.
const menus = document.querySelectorAll(
'#adm-epLocalMoreMenu, #adm-epApiMoreMenu'
);
let closed = false;
menus.forEach((m) => {
if (m && m.style.display !== 'none') {
m.style.display = 'none';
// Sync the associated button's aria-expanded when we can find it.
const btn = document.getElementById(m.id.replace('Menu', 'Btn'));
if (btn) btn.setAttribute('aria-expanded', 'false');
closed = true;
}
});
if (closed) e.stopPropagation();
}, { capture: true });
}
const _wireKebab = (btnId, menuId, onItem) => {
const btn = el(btnId);
const menu = el(menuId);
if (!btn || !menu) return;
const isOpen = () => menu.style.display !== 'none';
const close = () => { menu.style.display = 'none'; btn.setAttribute('aria-expanded', 'false'); };
const open = () => { menu.style.display = 'flex'; btn.setAttribute('aria-expanded', 'true'); };
btn.addEventListener('click', (e) => {
e.stopPropagation();
if (isOpen()) close(); else open();
});
menu.addEventListener('click', (e) => {
const item = e.target.closest('.adm-more-item');
if (!item) return;
if (onItem) onItem(item, e);
close();
});
document.addEventListener('click', (e) => {
if (!isOpen()) return;
if (e.target.closest('#' + menuId + ', #' + btnId)) return;
close();
});
// Use capture phase so this fires before the settings-modal Esc handler
// (which is in bubble phase). stopPropagation prevents the modal from
// closing when the user only meant to dismiss this menu.
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && isOpen()) {
e.stopPropagation();
close();
}
}, { capture: true });
};
// API card "..." menu: contains the Proxy/API connection-mode toggle.
// Sync the visible checkmarks with the hidden #adm-epKind select so
// downstream code (which reads kindSel.value) keeps working.
(function wireApiKindMenu() {
const kind = el('adm-epKind');
if (!kind) return;
const opts = document.querySelectorAll('#adm-epApiMoreMenu .adm-kind-opt');
const sync = () => {
opts.forEach((o) => {
const check = o.querySelector('.adm-kind-check');
if (check) check.style.visibility = (o.dataset.kind === kind.value) ? 'visible' : 'hidden';
});
};
sync();
kind.addEventListener('change', sync);
_wireKebab('adm-epApiMoreBtn', 'adm-epApiMoreMenu', (item) => {
const k = item.dataset.kind;
if (!k) return;
kind.value = k;
kind.dispatchEvent(new Event('change'));
});
})();
// Local card "..." kebab: holds Scan network / Ollama / API key reveal.
// Item buttons keep their own click handlers; the helper just handles
// open/close + outside-click + Esc.
_wireKebab('adm-epLocalMoreBtn', 'adm-epLocalMoreMenu');
// ── Added Models toolbar: Probe + Clear offline ────────────────────
// Both buttons act over the currently-rendered endpoint list. The
// online/offline marker is stamped on each row's [data-adm-ep-online]
@@ -1179,10 +1308,10 @@ function initEndpointForm() {
if (!lbl) return;
const n = document.querySelectorAll('[data-adm-ep-id] [data-adm-ep-online="0"]').length;
lbl.textContent = n > 0 ? `(${n})` : '';
// Keep the button enabled even when there are no offline rows — a
// click on the empty case fires a toast instead of feeling dead.
// Hide the button entirely when there's nothing offline — no point
// showing an action that has nothing to act on.
const btn = el('adm-epClearOfflineBtn');
if (btn) btn.style.opacity = n === 0 ? '0.55' : '0.85';
if (btn) btn.style.display = n === 0 ? 'none' : '';
};
// Wire after every loadEndpoints() run by patching the render hook —
// simplest path: MutationObserver on the two list containers.
@@ -1199,7 +1328,17 @@ function initEndpointForm() {
probeAllBtn.addEventListener('click', async () => {
probeAllBtn.disabled = true;
const origHTML = probeAllBtn.innerHTML;
probeAllBtn.innerHTML = '<span style="opacity:0.7;">Probing…</span>';
let _wp = null;
try {
const sp = window.spinnerModule || (await import('./spinner.js')).default;
_wp = sp.createWhirlpool(11);
_wp.element.style.cssText = 'display:inline-flex;width:11px;height:11px;margin:0 4px 0 0;';
probeAllBtn.innerHTML = '';
probeAllBtn.appendChild(_wp.element);
probeAllBtn.appendChild(document.createTextNode('Probing'));
} catch (_) {
probeAllBtn.innerHTML = '<span style="opacity:0.7;">Probing…</span>';
}
try {
// Hit the bulk local probe (same one the model picker uses).
await fetch('/api/model-endpoints/probe-local', { credentials: 'same-origin' }).catch(() => {});
@@ -1221,6 +1360,7 @@ function initEndpointForm() {
await loadEndpoints();
if (uiModule && uiModule.showToast) uiModule.showToast('Endpoint status refreshed', 1800);
} finally {
if (_wp) { try { _wp.destroy(); } catch (_) {} }
probeAllBtn.innerHTML = origHTML;
probeAllBtn.disabled = false;
}
@@ -1290,16 +1430,17 @@ function initEndpointForm() {
const localAddBtn = el('adm-epLocalAddBtn');
const localTestBtn = el('adm-epLocalTestBtn');
if (localTestBtn) {
const testOriginalHtml = localTestBtn.innerHTML;
localTestBtn.addEventListener('click', async () => {
const msg = _endpointMsg('local');
msg.textContent = ''; msg.className = '';
msg.textContent = ''; msg.className = 'adm-ep-inline-msg';
const raw = (el('adm-epLocalUrl').value || '').trim();
if (!raw) { msg.textContent = 'Enter a base URL to test'; msg.className = 'admin-error'; return; }
const url = _normalizeBaseUrl(raw);
const keyEl = el('adm-epLocalApiKey');
const apiKey = keyEl ? keyEl.value.trim() : '';
localTestBtn.disabled = true;
localTestBtn.textContent = 'Testing...';
localTestBtn.innerHTML = testOriginalHtml.replace(/>Test\s*$/, '>Testing...');
try {
const fd = new FormData();
fd.append('base_url', url);
@@ -1312,19 +1453,21 @@ function initEndpointForm() {
msg.className = 'admin-error';
}
localTestBtn.disabled = false;
localTestBtn.textContent = 'Test';
localTestBtn.innerHTML = testOriginalHtml;
});
}
if (localAddBtn) {
const addOriginalHtml = localAddBtn.innerHTML;
localAddBtn.addEventListener('click', async () => {
const msg = _endpointMsg('local');
msg.textContent = ''; msg.className = '';
msg.textContent = ''; msg.className = 'adm-ep-inline-msg';
const raw = (el('adm-epLocalUrl').value || '').trim();
if (!raw) { msg.textContent = 'Enter a base URL (e.g. http://localhost:8002/v1)'; msg.className = 'admin-error'; return; }
const url = _normalizeBaseUrl(raw);
const keyEl = el('adm-epLocalApiKey');
const apiKey = keyEl ? keyEl.value.trim() : '';
localAddBtn.disabled = true; localAddBtn.textContent = 'Adding...';
localAddBtn.disabled = true;
localAddBtn.innerHTML = addOriginalHtml.replace(/>Add\s*$/, '>Adding...');
try {
const fd = new FormData();
fd.append('base_url', url);
@@ -1344,15 +1487,17 @@ function initEndpointForm() {
await loadEndpoints();
await _selectAddedModelInChat(d);
const count = (d.models || []).length;
msg.textContent = d.status === 'empty'
const baseText = d.status === 'empty'
? 'Added — Ollama is running, no models pulled yet'
: d.online
? `Added — found ${count} model${count !== 1 ? 's' : ''}`
: 'Added (offline — will retry on next load)';
msg.innerHTML = `${baseText} <a href="#" data-go-added-models style="margin-left:6px;text-decoration:underline;color:inherit;font-weight:600;">Added Models →</a>`;
msg.className = d.online ? 'admin-success' : 'admin-error';
} else { msg.textContent = d.detail || 'Failed'; msg.className = 'admin-error'; }
} catch (e) { msg.textContent = 'Request failed'; msg.className = 'admin-error'; }
localAddBtn.disabled = false; localAddBtn.textContent = 'Add';
localAddBtn.disabled = false;
localAddBtn.innerHTML = addOriginalHtml;
});
}
@@ -1378,10 +1523,7 @@ function initEndpointForm() {
discoverBtn.addEventListener('click', async () => {
const msg = _endpointMsg('local');
discoverBtn.disabled = true;
// Keep the button's icon as-is while scanning; the whirlpool +
// status text below is enough feedback. (Two spinning indicators
// at once looks busy.)
msg.className = '';
msg.className = 'adm-ep-inline-msg';
msg.innerHTML = '';
try {
const sp = window.spinnerModule || (await import('./spinner.js')).default;
@@ -1392,7 +1534,7 @@ function initEndpointForm() {
wrap.appendChild(wp.element);
const txt = document.createElement('span');
txt.textContent = 'Scanning ports 8000-8020 and 11434 for model servers...';
txt.style.cssText = 'font-size:12px;opacity:0.7;';
txt.style.cssText = 'opacity:0.7;';
wrap.appendChild(txt);
msg.appendChild(wrap);
discoverBtn._wp = wp;
@@ -2158,28 +2300,126 @@ function initRag() {
/* ═══════════════════════════════════════════
SYSTEM TAB — Tokens
═══════════════════════════════════════════ */
// Catalog mirrors the one in settings.js integration form. Keep keys in
// sync with the backend scope allowlist.
const _TOKEN_SCOPES = [
{ key: 'todos:read', label: 'Todos read', detail: 'Read notes and checklists' },
{ key: 'todos:write', label: 'Todos write', detail: 'Create, update, delete, and toggle todo items' },
{ key: 'documents:read', label: 'Documents read', detail: 'Read documents when a document API is enabled' },
{ key: 'documents:write', label: 'Documents write', detail: 'Create and update draft documents' },
{ key: 'email:read', label: 'Email read', detail: 'Read email when an email API is enabled' },
{ key: 'email:draft', label: 'Email draft', detail: 'Create email reply drafts without sending' },
{ key: 'email:send', label: 'Email send', detail: 'Send email directly' },
{ key: 'calendar:read', label: 'Calendar read', detail: 'Read calendar events when enabled' },
{ key: 'calendar:write', label: 'Calendar write', detail: 'Create and update calendar events' },
{ key: 'memory:read', label: 'Memory read', detail: 'Read memory when enabled' },
{ key: 'memory:write', label: 'Memory write', detail: 'Write memory when enabled' },
{ key: 'cookbook:read', label: 'Cookbook read', detail: 'List cookbook tasks + tail their tmux output' },
{ key: 'cookbook:launch', label: 'Cookbook launch', detail: 'Launch and stop cookbook serve tasks' },
];
function _renderTokenScopeRows(t) {
const have = new Set(t.scopes || []);
return _TOKEN_SCOPES.map(s => {
const action = (s.key.split(':')[1] || '').toLowerCase();
const pill = action === 'read'
? 'background:rgba(150,150,150,0.18);color:var(--fg-muted,#888);'
: 'background:color-mix(in srgb, var(--accent, var(--red)) 18%, transparent);color:var(--accent, var(--red));';
const tool = s.label.replace(/\s+(read|write|draft|send|launch)$/i, '');
return `
<label style="display:flex;align-items:center;gap:8px;min-height:28px;padding:1px 0;">
<span class="settings-label" style="width:90px;flex-shrink:0;padding:0;font-size:12px;">${esc(tool)}</span>
<span style="font-size:9px;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;padding:1px 7px;border-radius:999px;flex-shrink:0;min-width:44px;text-align:center;box-sizing:border-box;${pill}">${esc(action)}</span>
<span style="font-size:11px;line-height:1.35;opacity:0.62;flex:1;min-width:0;">${esc(s.detail)}</span>
<label class="admin-switch" style="margin-left:auto;flex-shrink:0;"><input type="checkbox" class="adm-tok-scope" data-token-id="${esc(t.id)}" data-scope="${esc(s.key)}" ${have.has(s.key) ? 'checked' : ''}><span class="admin-slider"></span></label>
</label>`;
}).join('');
}
async function loadTokens() {
const list = el('adm-tokenList');
if (!list) return;
try {
const res = await fetch('/api/tokens', { credentials: 'same-origin' });
const tokens = await res.json();
if (!tokens.length) { list.innerHTML = '<div class="admin-empty">No API tokens</div>'; return; }
if (!tokens.length) { list.innerHTML = '<div class="admin-empty" style="color:var(--accent, var(--red));opacity:0.7;font-size:10px;">No API tokens</div>'; return; }
list.innerHTML = tokens.map(t => `
<div class="admin-user-row">
<div class="admin-user-info" style="flex:1;flex-wrap:wrap;gap:0.3rem;">
<span class="admin-user-name">${esc(t.name)}</span>
<span class="admin-badge">${esc(t.token_prefix)}...</span>
<span class="admin-badge" title="Allowed API scopes">${esc((t.scopes || ['chat']).join(', '))}</span>
${t.owner ? `<span style="font-size:0.75rem;opacity:0.5;">Owner: ${esc(t.owner)}</span>` : ''}
${t.last_used_at ? `<span style="font-size:0.75rem;opacity:0.5;">Last used: ${new Date(t.last_used_at).toLocaleDateString()}</span>` : '<span style="font-size:0.75rem;opacity:0.4;">Never used</span>'}
<div class="admin-user-row" data-adm-tok-row="${esc(t.id)}" style="display:block;">
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
<div class="admin-user-info" style="flex:1;min-width:0;flex-wrap:wrap;gap:0.3rem;">
<input type="text" class="adm-tok-rename" data-token-id="${esc(t.id)}" value="${esc(t.name || '')}" placeholder="Token name" style="font-size:13px;font-weight:600;padding:3px 6px;background:transparent;border:1px solid transparent;border-radius:4px;min-width:160px;" title="Click to rename">
<span class="admin-badge">${esc(t.token_prefix)}...</span>
${t.owner ? `<span style="font-size:0.75rem;opacity:0.5;">Owner: ${esc(t.owner)}</span>` : ''}
${t.last_used_at ? `<span style="font-size:0.75rem;opacity:0.5;">Last used: ${new Date(t.last_used_at).toLocaleDateString()}</span>` : '<span style="font-size:0.75rem;opacity:0.4;">Never used</span>'}
</div>
<button class="admin-btn-sm" data-adm-tok-toggle="${esc(t.id)}" style="opacity:0.75;">Permissions</button>
<button class="admin-btn-delete" data-adm-del-token="${esc(t.id)}">Revoke</button>
</div>
<div data-adm-tok-perm="${esc(t.id)}" style="display:none;margin-top:8px;padding:8px 4px 0;border-top:1px solid var(--border);">
${_renderTokenScopeRows(t)}
<div class="adm-tok-scope-msg" data-token-id="${esc(t.id)}" style="font-size:11px;min-height:14px;margin-top:4px;"></div>
</div>
<button class="admin-btn-delete" data-adm-del-token="${t.id}">Revoke</button>
</div>`).join('');
// Revoke
list.querySelectorAll('[data-adm-del-token]').forEach(btn => {
btn.addEventListener('click', async () => {
if (!await uiModule.styledConfirm('Revoke this API token? External integrations using it will stop working.', { confirmText: 'Revoke', danger: true })) return;
await fetch(`/api/tokens/${btn.dataset.admDelToken}`, { method: 'DELETE', credentials: 'same-origin' });
loadTokens();
// Codex / Claude integration cards on the Integrations panel are
// backed by these tokens — let them re-render so the deleted token
// disappears there too.
try { window.dispatchEvent(new CustomEvent('odysseus-integrations-changed')); } catch (_) {}
});
});
// Toggle permissions panel
list.querySelectorAll('[data-adm-tok-toggle]').forEach(btn => {
btn.addEventListener('click', () => {
const panel = list.querySelector(`[data-adm-tok-perm="${btn.dataset.admTokToggle}"]`);
if (!panel) return;
panel.style.display = panel.style.display === 'none' ? '' : 'none';
});
});
// Rename
list.querySelectorAll('.adm-tok-rename').forEach(input => {
const original = input.value;
const commit = async () => {
const name = (input.value || '').trim();
if (!name || name === original) return;
try {
const r = await fetch(`/api/tokens/${input.dataset.tokenId}`, {
method: 'PATCH', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
});
if (!r.ok) throw new Error('Save failed');
loadTokens();
} catch (_) { input.value = original; }
};
input.addEventListener('blur', commit);
input.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); input.blur(); } });
});
// Scope toggle change → PATCH the whole scopes array for this token.
list.querySelectorAll('.adm-tok-scope').forEach(cb => {
cb.addEventListener('change', async () => {
const tokenId = cb.dataset.tokenId;
const panel = list.querySelector(`[data-adm-tok-perm="${tokenId}"]`);
const msg = list.querySelector(`.adm-tok-scope-msg[data-token-id="${tokenId}"]`);
const scopes = Array.from(panel.querySelectorAll('.adm-tok-scope:checked')).map(input => input.dataset.scope);
try {
const r = await fetch(`/api/tokens/${tokenId}`, {
method: 'PATCH', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ scopes }),
});
const d = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(d.detail || 'Failed');
if (msg) { msg.textContent = 'Saved'; msg.style.color = 'var(--green, #50fa7b)'; setTimeout(() => { msg.textContent = ''; }, 1200); }
} catch (err) {
cb.checked = !cb.checked;
if (msg) { msg.textContent = (err && err.message) || 'Failed'; msg.style.color = 'var(--red)'; }
}
});
});
} catch (e) { list.innerHTML = '<div class="admin-error">Failed to load tokens</div>'; }
@@ -2211,11 +2451,20 @@ function initTokenForm() {
else { msg.textContent = data.detail || 'Failed'; msg.className = 'admin-error'; }
} catch (e) { msg.textContent = 'Request failed'; msg.className = 'admin-error'; }
});
const TOKEN_COPY_ICON = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
const TOKEN_CHECK_ICON = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
el('adm-tokenCopyBtn').addEventListener('click', () => {
const val = el('adm-tokenValue').textContent;
const btn = el('adm-tokenCopyBtn');
navigator.clipboard.writeText(val).then(() => {
el('adm-tokenCopyBtn').textContent = 'Copied!';
setTimeout(() => { el('adm-tokenCopyBtn').textContent = 'Copy'; }, 2000);
btn.innerHTML = TOKEN_CHECK_ICON;
btn.style.color = 'var(--accent, var(--red))';
btn.style.opacity = '1';
setTimeout(() => {
btn.innerHTML = TOKEN_COPY_ICON;
btn.style.color = '';
btn.style.opacity = '0.7';
}, 1600);
});
});
}
@@ -2442,23 +2691,54 @@ function initDangerZone() {
modalEl.querySelectorAll('[data-wipe-kind]').forEach(btn => {
btn.addEventListener('click', async () => {
const kind = btn.dataset.wipeKind;
const label = _LABELS[kind] || kind;
if (!await uiModule.styledConfirm(`Wipe ALL ${label}? This cannot be undone.`, { confirmText: 'Wipe', danger: true })) return;
if (!await uiModule.styledConfirm(`Really wipe every one of your ${label}?`, { confirmText: 'Yes, wipe everything', danger: true })) return;
btn.disabled = true; const prev = btn.textContent; btn.textContent = 'Wiping';
const isAll = kind === '__all__';
const label = isAll ? 'data across every category' : (_LABELS[kind] || kind);
if (!await uiModule.styledConfirm(`Delete ALL ${label}? This cannot be undone.`, { confirmText: 'Delete', danger: true })) return;
if (!await uiModule.styledConfirm(`Really delete every one of your ${label}?`, { confirmText: isAll ? 'Yes, delete everything' : 'Yes, delete everything', danger: true })) return;
btn.disabled = true;
const prevHtml = btn.innerHTML;
btn.innerHTML = isAll ? 'Deleting all…' : 'Deleting…';
if (_wipeMsg) { _wipeMsg.textContent = ''; _wipeMsg.className = ''; }
try {
const res = await fetch(`/api/admin/wipe/${kind}`, { method: 'DELETE', credentials: 'same-origin' });
const data = await res.json().catch(() => ({}));
if (res.ok) {
if (_wipeMsg) { _wipeMsg.textContent = `Wiped ${data.count ?? 0} ${label}.`; _wipeMsg.className = 'admin-success'; }
if (isAll) {
// Iterate every known category. Failures in one shouldn't stop
// the rest — record per-category counts and surface a summary.
const kinds = Object.keys(_LABELS);
const results = [];
for (const k of kinds) {
try {
const r = await fetch(`/api/admin/wipe/${k}`, { method: 'DELETE', credentials: 'same-origin' });
const d = await r.json().catch(() => ({}));
results.push({ k, ok: r.ok, count: d.count ?? 0, error: r.ok ? null : (d.detail || 'failed') });
} catch (e) {
results.push({ k, ok: false, count: 0, error: e.message });
}
}
const okCount = results.filter(r => r.ok).length;
const total = results.reduce((n, r) => n + (r.ok ? r.count : 0), 0);
const fails = results.filter(r => !r.ok).map(r => r.k);
if (_wipeMsg) {
if (!fails.length) {
_wipeMsg.textContent = `Deleted ${total} items across all ${okCount} categories.`;
_wipeMsg.className = 'admin-success';
} else {
_wipeMsg.textContent = `Deleted ${total} items; failed: ${fails.join(', ')}.`;
_wipeMsg.className = 'admin-error';
}
}
} else {
if (_wipeMsg) { _wipeMsg.textContent = data.detail || 'Failed'; _wipeMsg.className = 'admin-error'; }
const res = await fetch(`/api/admin/wipe/${kind}`, { method: 'DELETE', credentials: 'same-origin' });
const data = await res.json().catch(() => ({}));
if (res.ok) {
if (_wipeMsg) { _wipeMsg.textContent = `Deleted ${data.count ?? 0} ${label}.`; _wipeMsg.className = 'admin-success'; }
} else {
if (_wipeMsg) { _wipeMsg.textContent = data.detail || 'Failed'; _wipeMsg.className = 'admin-error'; }
}
}
} catch (e) {
if (_wipeMsg) { _wipeMsg.textContent = 'Request failed: ' + e.message; _wipeMsg.className = 'admin-error'; }
}
btn.disabled = false; btn.textContent = prev;
btn.disabled = false; btn.innerHTML = prevHtml;
});
});
}