- Updated the default `instance.theme` to `blue`, making it the new out-of-the-box look. The previous default `navy` can still be used by explicitly setting `AGNES_INSTANCE_THEME`. - Pre-login pages now respect the configured `instance.theme`, eliminating the abrupt color change after sign-in for navy-configured instances. - Adjusted documentation and code comments to reflect the new theme settings and their implications for existing instances. - Version bump to 0.55.6 to reflect these changes.
638 lines
29 KiB
HTML
638 lines
29 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en" data-theme="{{ instance_theme | default('blue') }}">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>{% block title %}Data Analyst Portal{% endblock %}</title>
|
||
<link rel="stylesheet" href="{{ static_url('style-custom.css') }}">
|
||
{# Design-system tokens (`--ds-*`) — opt-in green/navy palette used
|
||
by pages that scope themselves with `.home-mock` /
|
||
`.advanced-mock`. Loaded globally so the tokens are available
|
||
anywhere; pages that haven't opted in just continue using the
|
||
legacy `--primary` family from style-custom.css. #}
|
||
<link rel="stylesheet" href="{{ static_url('css/design-tokens.css') }}">
|
||
{# Shared design-system components — `.callout-rec`, `.callout-hint`,
|
||
`.code-output`, `.lightbox`, `.setup-section-header`. Pages
|
||
opt in by adding the class to markup; no scope wrapper needed. #}
|
||
<link rel="stylesheet" href="{{ static_url('css/components.css') }}">
|
||
{# Shared stack card styles — drives /catalog + /memory Browse/My Stack
|
||
cards (matches marketplace.html .mp-card visual language). #}
|
||
<link rel="stylesheet" href="{{ static_url('css/stack_card.css') }}">
|
||
{# app.js loaded by _app_header.html itself so pages that include
|
||
the header directly without extending base.html (e.g. admin_tables)
|
||
still get the nav-dropdown wiring. #}
|
||
{% block head_extra %}{% endblock %}
|
||
{% include '_theme.html' %}
|
||
</head>
|
||
<body {% block body_attrs %}{% endblock %}>
|
||
{% include '_app_header.html' %}
|
||
|
||
{# `layout` block: pages opt out of the narrow .container wrap by overriding
|
||
this entire block (e.g. dashboard.html renders its own full-width <main
|
||
class="main">). Pages that want the standard chrome leave it alone and
|
||
fill `content` instead. #}
|
||
{% block layout %}
|
||
<div class="container">
|
||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||
{% if messages %}
|
||
<div class="flash-messages">
|
||
{% for category, message in messages %}
|
||
<div class="flash flash-{{ category }}">
|
||
{{ message }}
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
{% endif %}
|
||
{% endwith %}
|
||
|
||
<main>
|
||
{% block content %}{% endblock %}
|
||
</main>
|
||
|
||
<footer>
|
||
<p>© {{ now().year if now is defined else 2024 }} {{ config.INSTANCE_COPYRIGHT or 'AI Data Analyst' }}</p>
|
||
</footer>
|
||
</div>
|
||
{% endblock %}
|
||
{% include "_version_badge.html" %}
|
||
{# Pages that mount a chip-input opt in via the {% raw %}{{ super() }}{% endraw %} +
|
||
{% raw %}{% block extra_scripts %}<script type="module" src="..."></script>{% endblock %}{% endraw %}
|
||
pattern. Was previously loaded globally here even though only
|
||
/admin/corporate-memory actually used it — pure waste on every
|
||
other admin/user page (#L85). #}
|
||
{% block extra_scripts %}{% endblock %}
|
||
<script>
|
||
// v54 global undo-toast — surfaced by admin delete handlers. The
|
||
// toast carries an Undo button that POSTs to a restore URL, so
|
||
// soft-deleted resources can be brought back inside the dwell
|
||
// window (default 10s). Click Undo → POST + onSuccess() (typically
|
||
// a list refresh) — then the toast closes.
|
||
window.showUndoToast = function (message, restoreUrl, onSuccess, dwellMs) {
|
||
const dwell = dwellMs || 10000;
|
||
let el = document.getElementById('agnes-undo-toast');
|
||
if (!el) {
|
||
el = document.createElement('div');
|
||
el.id = 'agnes-undo-toast';
|
||
el.style.cssText =
|
||
'position:fixed; bottom:24px; right:24px; padding:12px 16px; ' +
|
||
'background:#202124; color:#fff; border-radius:8px; ' +
|
||
'box-shadow:0 6px 20px rgba(0,0,0,.25); font-size:13px; ' +
|
||
'z-index:3000; display:flex; align-items:center; gap:12px;';
|
||
document.body.appendChild(el);
|
||
}
|
||
el.innerHTML = '';
|
||
const msg = document.createElement('span');
|
||
msg.textContent = message;
|
||
const btn = document.createElement('button');
|
||
btn.type = 'button';
|
||
btn.textContent = 'Undo';
|
||
btn.style.cssText =
|
||
'background:transparent; color:#7dd3fc; border:1px solid #38bdf8; ' +
|
||
'border-radius:6px; padding:4px 10px; cursor:pointer; ' +
|
||
'font-weight:600; font-size:12px;';
|
||
const close = document.createElement('button');
|
||
close.type = 'button';
|
||
close.textContent = '×';
|
||
close.setAttribute('aria-label', 'Dismiss');
|
||
close.style.cssText =
|
||
'background:transparent; color:#9ca3af; border:none; cursor:pointer; ' +
|
||
'font-size:16px; padding:0 4px;';
|
||
el.appendChild(msg);
|
||
el.appendChild(btn);
|
||
el.appendChild(close);
|
||
el.style.display = 'flex';
|
||
const dismiss = () => { el.style.display = 'none'; };
|
||
const timer = setTimeout(dismiss, dwell);
|
||
close.addEventListener('click', () => { clearTimeout(timer); dismiss(); });
|
||
btn.addEventListener('click', async () => {
|
||
clearTimeout(timer);
|
||
btn.disabled = true;
|
||
btn.textContent = 'Restoring…';
|
||
try {
|
||
const r = await fetch(restoreUrl, {
|
||
method: 'POST', credentials: 'same-origin',
|
||
});
|
||
if (!r.ok) {
|
||
const d = await r.json().catch(() => ({}));
|
||
msg.textContent = 'Undo failed: ' + (d.detail || r.statusText);
|
||
btn.textContent = 'Undo';
|
||
btn.disabled = false;
|
||
return;
|
||
}
|
||
dismiss();
|
||
if (typeof onSuccess === 'function') {
|
||
try { onSuccess(); } catch (_) { /* swallow */ }
|
||
}
|
||
} catch (e) {
|
||
msg.textContent = 'Network error: ' + e.message;
|
||
btn.textContent = 'Undo';
|
||
btn.disabled = false;
|
||
}
|
||
});
|
||
};
|
||
|
||
// Global Escape → close topmost visible modal. Inspects three
|
||
// common modal patterns used across the app:
|
||
// * `.modal-overlay` (admin_tables)
|
||
// * `#xyzModal` flex/block (admin_corporate_memory inline divs)
|
||
// * `.modal` open via class (catalog, marketplace)
|
||
// Picks the one with the highest computed z-index so nested
|
||
// modal-on-modal flows close the inner one first. opt-out per
|
||
// element via `data-no-esc-close="1"`.
|
||
(function () {
|
||
function _visibleModals() {
|
||
const out = [];
|
||
document.querySelectorAll(
|
||
'.modal-overlay, [id$="Modal"], .modal.is-open'
|
||
).forEach((el) => {
|
||
if (el.dataset.noEscClose === '1') return;
|
||
const cs = window.getComputedStyle(el);
|
||
if (cs.display === 'none' || cs.visibility === 'hidden') return;
|
||
// Ignore elements that don't actually paint (no width/height).
|
||
const r = el.getBoundingClientRect();
|
||
if (!r.width || !r.height) return;
|
||
out.push({ el, z: parseInt(cs.zIndex, 10) || 0 });
|
||
});
|
||
return out;
|
||
}
|
||
function _closeTopmost() {
|
||
const modals = _visibleModals();
|
||
if (!modals.length) return false;
|
||
modals.sort((a, b) => b.z - a.z);
|
||
const target = modals[0].el;
|
||
// Preferred path: a `data-close-handler` attribute names the
|
||
// page-specific close function (e.g. data-close-handler=
|
||
// "closeEditDataPackageModal"). Falls back to flipping the
|
||
// inline display style — works for both flex+block conventions.
|
||
const fn = target.dataset.closeHandler;
|
||
if (fn && typeof window[fn] === 'function') {
|
||
try { window[fn](); return true; } catch (_) { /* fall through */ }
|
||
}
|
||
target.style.display = 'none';
|
||
target.classList.remove('is-open', 'active');
|
||
return true;
|
||
}
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.key !== 'Escape') return;
|
||
// Skip when typing in an input/textarea/contenteditable so Escape
|
||
// can blur native inputs without closing the surrounding modal.
|
||
const t = e.target;
|
||
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' ||
|
||
t.isContentEditable)) {
|
||
t.blur();
|
||
return;
|
||
}
|
||
if (_closeTopmost()) e.preventDefault();
|
||
});
|
||
})();
|
||
|
||
// v51 palette swatches — hydrate every `.cf-palette-row` element
|
||
// with 8 design-system color buttons; clicking a swatch sets the
|
||
// adjacent native `<input type="color">` (id = .data-target) and
|
||
// marks the swatch active. Free-form picker still works as before.
|
||
(function () {
|
||
const PALETTE = [
|
||
'#0EA5B5', // teal
|
||
'#2563EB', // blue
|
||
'#7C3AED', // violet
|
||
'#DB2777', // pink
|
||
'#DC2626', // red
|
||
'#D97706', // amber
|
||
'#059669', // emerald
|
||
'#475569', // slate
|
||
];
|
||
function _hydrate(row) {
|
||
if (row.dataset.hydrated === '1') return;
|
||
row.dataset.hydrated = '1';
|
||
const targetId = row.dataset.target;
|
||
if (!targetId) return;
|
||
PALETTE.forEach((hex) => {
|
||
const btn = document.createElement('button');
|
||
btn.type = 'button';
|
||
btn.className = 'cf-swatch';
|
||
btn.style.background = hex;
|
||
btn.dataset.hex = hex;
|
||
btn.title = hex;
|
||
btn.addEventListener('click', () => {
|
||
const input = document.getElementById(targetId);
|
||
if (input) {
|
||
input.value = hex;
|
||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||
}
|
||
row.querySelectorAll('.cf-swatch').forEach((s) =>
|
||
s.classList.toggle('is-active', s === btn)
|
||
);
|
||
});
|
||
row.appendChild(btn);
|
||
});
|
||
// Reflect any pre-set color value on the input back onto the
|
||
// swatch row so the user sees which palette entry matches.
|
||
const input = document.getElementById(targetId);
|
||
if (input) {
|
||
// v54: visible hex chip beside the picker — Safari + Firefox
|
||
// hide the value entirely inside the native swatch; this
|
||
// gives admins a copy-able read of what's actually stored.
|
||
// Sits in-flow after the input so it doesn't fight the
|
||
// modal layout.
|
||
let hexChip = input.parentElement
|
||
? input.parentElement.querySelector('.cf-hex-chip[data-for="' + targetId + '"]')
|
||
: null;
|
||
if (!hexChip && input.parentElement) {
|
||
hexChip = document.createElement('code');
|
||
hexChip.className = 'cf-hex-chip';
|
||
hexChip.dataset.for = targetId;
|
||
hexChip.style.cssText =
|
||
'display:inline-block; margin-top:4px; padding:1px 8px; ' +
|
||
'font-size:11px; font-family:ui-monospace, Menlo, monospace; ' +
|
||
'color:var(--text-secondary, #5f6368); background:rgba(0,0,0,0.04); ' +
|
||
'border-radius:4px;';
|
||
input.insertAdjacentElement('afterend', hexChip);
|
||
}
|
||
const sync = () => {
|
||
const cur = (input.value || '').toLowerCase();
|
||
row.querySelectorAll('.cf-swatch').forEach((s) =>
|
||
s.classList.toggle('is-active', s.dataset.hex.toLowerCase() === cur)
|
||
);
|
||
if (hexChip) hexChip.textContent = cur;
|
||
};
|
||
input.addEventListener('change', sync);
|
||
input.addEventListener('input', sync);
|
||
sync();
|
||
}
|
||
}
|
||
function _hydrateAll() {
|
||
document.querySelectorAll('.cf-palette-row').forEach(_hydrate);
|
||
}
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', _hydrateAll);
|
||
} else {
|
||
_hydrateAll();
|
||
}
|
||
// Re-hydrate on demand — modal templates already exist at page
|
||
// load (display:none), so this catches any future dynamically
|
||
// injected modal too.
|
||
const obs = new MutationObserver(_hydrateAll);
|
||
obs.observe(document.body, { childList: true, subtree: true });
|
||
})();
|
||
|
||
// v54 admin-nav keyboard shortcuts — Vim-style two-key ``g <x>``
|
||
// sequences for the admin landing pages. Only fires when:
|
||
// * caller is admin (the gated nav is rendered)
|
||
// * user isn't typing into an input/textarea/contenteditable
|
||
// * no modal is currently visible (Esc handler would conflict)
|
||
// First ``g`` starts a 1s window for the second key; outside the
|
||
// window the prefix resets. Hint chip surfaces briefly on the
|
||
// first key to teach the shortcut on the fly.
|
||
(function () {
|
||
// Admin-only — short-circuit if the nav isn't there.
|
||
if (!document.getElementById('adminNavMenu')) return;
|
||
const MAP = {
|
||
't': '/admin/tables', // (data packages)
|
||
'a': '/admin/access', // resource access
|
||
'm': '/admin/corporate-memory', // (memory)
|
||
'u': '/admin/users',
|
||
'g': '/admin/groups',
|
||
's': '/admin/server-config',
|
||
'l': '/admin/activity', // (log)
|
||
'c': '/admin/marketplaces', // (curated)
|
||
'k': '/admin/tokens', // (keys/tokens)
|
||
'r': '/admin/store/submissions', // (review queue)
|
||
};
|
||
let pending = false;
|
||
let pendingTimer = null;
|
||
let hintEl = null;
|
||
function _showHint() {
|
||
if (!hintEl) {
|
||
hintEl = document.createElement('div');
|
||
hintEl.style.cssText =
|
||
'position:fixed; bottom:24px; left:24px; padding:6px 12px; ' +
|
||
'background:#202124; color:#fff; border-radius:6px; ' +
|
||
'font-family:ui-monospace, Menlo, monospace; font-size:12px; ' +
|
||
'box-shadow:0 4px 12px rgba(0,0,0,.2); z-index:3000;';
|
||
document.body.appendChild(hintEl);
|
||
}
|
||
hintEl.textContent = 'g … (t a m u g s l c k r)';
|
||
hintEl.style.display = 'block';
|
||
}
|
||
function _hideHint() {
|
||
if (hintEl) hintEl.style.display = 'none';
|
||
}
|
||
function _resetPending() {
|
||
pending = false;
|
||
if (pendingTimer) { clearTimeout(pendingTimer); pendingTimer = null; }
|
||
_hideHint();
|
||
}
|
||
document.addEventListener('keydown', (e) => {
|
||
// Ignore modifier-key shortcuts (Cmd-T, Ctrl-T etc.) so we don't
|
||
// hijack browser-native bindings.
|
||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||
const t = e.target;
|
||
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' ||
|
||
t.isContentEditable)) return;
|
||
// Skip when any modal is visible — the Esc handler should win there.
|
||
const anyModalOpen = Array.from(document.querySelectorAll(
|
||
'.modal-overlay, [id$="Modal"], .modal.is-open'
|
||
)).some((el) => {
|
||
const cs = window.getComputedStyle(el);
|
||
return cs.display !== 'none' && cs.visibility !== 'hidden';
|
||
});
|
||
if (anyModalOpen) return;
|
||
const k = e.key.toLowerCase();
|
||
if (!pending && k === 'g') {
|
||
pending = true;
|
||
_showHint();
|
||
pendingTimer = setTimeout(_resetPending, 1000);
|
||
return;
|
||
}
|
||
if (pending) {
|
||
const target = MAP[k];
|
||
_resetPending();
|
||
if (target) {
|
||
e.preventDefault();
|
||
window.location.href = target;
|
||
}
|
||
}
|
||
});
|
||
})();
|
||
|
||
// v55 admin command palette — Cmd/Ctrl-K opens a fuzzy-search
|
||
// overlay over the same set of routes the admin nav exposes,
|
||
// plus a few common actions. Admin-only (gated on adminNavMenu
|
||
// presence). Esc to close; arrows + Enter to navigate; the same
|
||
// typing-into-input safeguards as the g+letter handler.
|
||
(function () {
|
||
if (!document.getElementById('adminNavMenu')) return;
|
||
const ITEMS = [
|
||
{ label: 'Tables / Data packages', hint: 'admin · g t', href: '/admin/tables' },
|
||
{ label: 'Resource access', hint: 'admin · g a', href: '/admin/access' },
|
||
{ label: 'Corporate Memory', hint: 'admin · g m', href: '/admin/corporate-memory' },
|
||
{ label: 'Memory · Review queue', hint: 'admin', href: '/admin/corporate-memory#review' },
|
||
{ label: 'Memory · All items', hint: 'admin', href: '/admin/corporate-memory#all' },
|
||
{ label: 'Memory · Domains', hint: 'admin', href: '/admin/corporate-memory#domains' },
|
||
{ label: 'Memory · Audit log', hint: 'admin', href: '/admin/corporate-memory#audit' },
|
||
{ label: 'Users', hint: 'admin · g u', href: '/admin/users' },
|
||
{ label: 'Groups', hint: 'admin · g g', href: '/admin/groups' },
|
||
{ label: 'Server config', hint: 'admin · g s', href: '/admin/server-config' },
|
||
{ label: 'Activity / Audit log', hint: 'admin · g l', href: '/admin/activity' },
|
||
{ label: 'Telemetry', hint: 'admin', href: '/admin/telemetry' },
|
||
{ label: 'Sessions', hint: 'admin', href: '/admin/sessions' },
|
||
{ label: 'Curated Marketplaces', hint: 'admin · g c', href: '/admin/marketplaces' },
|
||
{ label: 'Tokens', hint: 'admin · g k', href: '/admin/tokens' },
|
||
{ label: 'Flea Submissions', hint: 'admin · g r', href: '/admin/store/submissions' },
|
||
{ label: 'Init prompt', hint: 'admin', href: '/admin/agent-prompt' },
|
||
{ label: 'Workspace prompt', hint: 'admin', href: '/admin/workspace-prompt' },
|
||
{ label: '— User-facing —', hint: 'separator', href: null },
|
||
{ label: 'Catalog', hint: 'user', href: '/catalog' },
|
||
{ label: 'Catalog · Browse', hint: 'user', href: '/catalog#browse' },
|
||
{ label: 'Catalog · My Stack', hint: 'user', href: '/catalog#my' },
|
||
{ label: 'Catalog · Recipes', hint: 'user', href: '/catalog#recipes' },
|
||
{ label: 'Corporate memory', hint: 'user', href: '/corporate-memory' },
|
||
{ label: 'Marketplace', hint: 'user', href: '/marketplace' },
|
||
{ label: 'Activity (mine)', hint: 'user', href: '/me/activity' },
|
||
];
|
||
|
||
let overlay = null;
|
||
let input = null;
|
||
let list = null;
|
||
let activeIdx = 0;
|
||
let filtered = ITEMS.filter((it) => it.href); // skip separators initially
|
||
|
||
function _build() {
|
||
overlay = document.createElement('div');
|
||
overlay.id = 'adminCmdkOverlay';
|
||
overlay.setAttribute('role', 'dialog');
|
||
overlay.setAttribute('aria-label', 'Admin command palette');
|
||
overlay.style.cssText = (
|
||
'position:fixed; inset:0; background:rgba(0,0,0,.4); ' +
|
||
'z-index:5000; display:none; align-items:flex-start; ' +
|
||
'justify-content:center; padding-top:14vh;'
|
||
);
|
||
overlay.innerHTML = (
|
||
'<div style="background:var(--surface,#fff); width:min(640px,90vw); ' +
|
||
' border-radius:12px; border:1px solid var(--border,#e5e7eb); ' +
|
||
' box-shadow:0 24px 48px rgba(0,0,0,.18); overflow:hidden;">' +
|
||
' <input type="search" id="adminCmdkInput" ' +
|
||
' placeholder="Search admin pages and actions…" ' +
|
||
' autocomplete="off" spellcheck="false" ' +
|
||
' style="width:100%; padding:14px 16px; border:0; ' +
|
||
' border-bottom:1px solid var(--border,#e5e7eb); ' +
|
||
' font: 14px ui-sans-serif, system-ui; outline:none;">' +
|
||
' <ul id="adminCmdkList" role="listbox" ' +
|
||
' style="margin:0; padding:6px 0; max-height:50vh; overflow-y:auto; ' +
|
||
' list-style:none; font:13px ui-sans-serif, system-ui;"></ul>' +
|
||
' <div style="padding:8px 14px; border-top:1px solid var(--border,#e5e7eb); ' +
|
||
' color:var(--text-secondary,#6b7280); font:11px ui-monospace, monospace; ' +
|
||
' display:flex; justify-content:space-between;">' +
|
||
' <span>↑↓ navigate · Enter open · Esc close</span>' +
|
||
' <span>Cmd/Ctrl-K to toggle</span>' +
|
||
' </div>' +
|
||
'</div>'
|
||
);
|
||
document.body.appendChild(overlay);
|
||
input = overlay.querySelector('#adminCmdkInput');
|
||
list = overlay.querySelector('#adminCmdkList');
|
||
|
||
input.addEventListener('input', () => { _render(input.value); });
|
||
input.addEventListener('keydown', _onKey);
|
||
// Click on backdrop closes; clicks inside the panel don't bubble up here.
|
||
overlay.addEventListener('click', (e) => {
|
||
if (e.target === overlay) _close();
|
||
});
|
||
}
|
||
|
||
function _matchScore(q, label) {
|
||
// Tiny scoring: every query char must appear in order. Earlier
|
||
// matches score higher; consecutive-match streaks score higher.
|
||
// No fancy library — keeps this dependency-free.
|
||
if (!q) return 1;
|
||
const ql = q.toLowerCase();
|
||
const ll = label.toLowerCase();
|
||
let qi = 0, score = 0, streak = 0;
|
||
for (let li = 0; li < ll.length && qi < ql.length; li++) {
|
||
if (ll[li] === ql[qi]) {
|
||
qi++;
|
||
streak++;
|
||
score += 1 + streak;
|
||
} else {
|
||
streak = 0;
|
||
}
|
||
}
|
||
return qi === ql.length ? score / (1 + ll.length / 20) : 0;
|
||
}
|
||
|
||
function _render(query) {
|
||
const q = (query || '').trim();
|
||
const scored = ITEMS
|
||
.filter((it) => it.href)
|
||
.map((it) => ({ it, s: _matchScore(q, it.label + ' ' + (it.hint || '')) }))
|
||
.filter((x) => x.s > 0)
|
||
.sort((a, b) => b.s - a.s)
|
||
.slice(0, 30);
|
||
filtered = scored.map((x) => x.it);
|
||
activeIdx = 0;
|
||
if (!filtered.length) {
|
||
list.innerHTML = (
|
||
'<li style="padding:14px 16px; color:var(--text-secondary,#6b7280);">' +
|
||
'No matches.</li>'
|
||
);
|
||
return;
|
||
}
|
||
list.innerHTML = filtered.map((it, i) => (
|
||
'<li role="option" data-idx="' + i + '" ' +
|
||
' style="padding:8px 16px; cursor:pointer; display:flex; ' +
|
||
' justify-content:space-between; align-items:baseline; ' +
|
||
' gap:12px;' +
|
||
(i === activeIdx ? ' background:var(--primary-light,#e0f2fe);' : '') +
|
||
'">' +
|
||
' <span>' + _esc(it.label) + '</span>' +
|
||
' <span style="color:var(--text-secondary,#6b7280); font:11px ui-monospace, monospace;">' +
|
||
_esc(it.hint || '') + '</span>' +
|
||
'</li>'
|
||
)).join('');
|
||
list.querySelectorAll('li[data-idx]').forEach((li) => {
|
||
li.addEventListener('mousemove', () => {
|
||
const i = parseInt(li.dataset.idx, 10);
|
||
if (i !== activeIdx) { activeIdx = i; _highlight(); }
|
||
});
|
||
li.addEventListener('click', () => {
|
||
const i = parseInt(li.dataset.idx, 10);
|
||
_goto(filtered[i]);
|
||
});
|
||
});
|
||
}
|
||
|
||
function _highlight() {
|
||
list.querySelectorAll('li[data-idx]').forEach((li, i) => {
|
||
li.style.background = (i === activeIdx)
|
||
? 'var(--primary-light,#e0f2fe)' : '';
|
||
});
|
||
const active = list.querySelector('li[data-idx="' + activeIdx + '"]');
|
||
if (active && active.scrollIntoView) {
|
||
active.scrollIntoView({ block: 'nearest' });
|
||
}
|
||
}
|
||
|
||
function _onKey(e) {
|
||
if (e.key === 'Escape') { e.preventDefault(); _close(); return; }
|
||
if (e.key === 'ArrowDown') {
|
||
e.preventDefault();
|
||
if (!filtered.length) return;
|
||
activeIdx = (activeIdx + 1) % filtered.length;
|
||
_highlight();
|
||
return;
|
||
}
|
||
if (e.key === 'ArrowUp') {
|
||
e.preventDefault();
|
||
if (!filtered.length) return;
|
||
activeIdx = (activeIdx - 1 + filtered.length) % filtered.length;
|
||
_highlight();
|
||
return;
|
||
}
|
||
if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
if (!filtered.length) return;
|
||
_goto(filtered[activeIdx]);
|
||
}
|
||
}
|
||
|
||
function _goto(item) {
|
||
if (!item || !item.href) return;
|
||
_close();
|
||
window.location.href = item.href;
|
||
}
|
||
|
||
function _open() {
|
||
if (!overlay) _build();
|
||
overlay.style.display = 'flex';
|
||
input.value = '';
|
||
_render('');
|
||
// Focus on next frame so Cmd/Ctrl-K's own keyup doesn't blur it.
|
||
requestAnimationFrame(() => input.focus());
|
||
}
|
||
|
||
function _close() {
|
||
if (overlay) overlay.style.display = 'none';
|
||
}
|
||
|
||
function _esc(s) {
|
||
return String(s == null ? '' : s).replace(/[&<>"']/g,
|
||
(c) => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||
}
|
||
|
||
document.addEventListener('keydown', (e) => {
|
||
if (!(e.metaKey || e.ctrlKey)) return;
|
||
if (e.key !== 'k' && e.key !== 'K') return;
|
||
// Don't fight typing in an input *unless* the palette is the
|
||
// input — then Cmd-K acts as toggle-off.
|
||
const t = e.target;
|
||
const insidePalette = overlay && overlay.contains(t);
|
||
if (!insidePalette && t && (
|
||
t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' ||
|
||
t.isContentEditable)) return;
|
||
e.preventDefault();
|
||
if (overlay && overlay.style.display === 'flex') _close();
|
||
else _open();
|
||
});
|
||
})();
|
||
|
||
// v55 stack-tabs digit shortcuts — `1` = first tab, `2` = second,
|
||
// etc. Hot-wired on any page that renders a `.stack-tabs` strip
|
||
// (currently /catalog + /corporate-memory). No-op when typing in
|
||
// inputs, when modifiers are held, or when a modal is open.
|
||
(function () {
|
||
const tabs = document.querySelectorAll('.stack-tabs button[data-tab]');
|
||
if (!tabs.length) return;
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return;
|
||
const t = e.target;
|
||
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' ||
|
||
t.isContentEditable)) return;
|
||
const anyModalOpen = Array.from(document.querySelectorAll(
|
||
'.modal-overlay, [id$="Modal"], .modal.is-open'
|
||
)).some((el) => {
|
||
const cs = window.getComputedStyle(el);
|
||
return cs.display !== 'none' && cs.visibility !== 'hidden';
|
||
});
|
||
if (anyModalOpen) return;
|
||
const k = e.key;
|
||
if (!/^[1-9]$/.test(k)) return;
|
||
const idx = parseInt(k, 10) - 1;
|
||
if (idx >= tabs.length) return;
|
||
e.preventDefault();
|
||
tabs[idx].click();
|
||
tabs[idx].focus();
|
||
});
|
||
})();
|
||
|
||
// Persist each admin-nav <details data-section=...> open/closed
|
||
// state in localStorage so reopening the dropdown restores the
|
||
// sections the admin last had collapsed. Sections default open
|
||
// when no entry exists (matches Jinja's `open` attribute).
|
||
(function () {
|
||
const KEY = 'agnes-admin-nav-collapsed-sections';
|
||
let collapsed = new Set();
|
||
try {
|
||
const raw = localStorage.getItem(KEY);
|
||
if (raw) collapsed = new Set(JSON.parse(raw));
|
||
} catch (_) { /* corrupt JSON or storage disabled — fall back to defaults */ }
|
||
|
||
function _persist() {
|
||
try { localStorage.setItem(KEY, JSON.stringify(Array.from(collapsed))); }
|
||
catch (_) { /* storage full / private mode — silently no-op */ }
|
||
}
|
||
|
||
document.querySelectorAll('details.app-nav-menu-group[data-section]')
|
||
.forEach((det) => {
|
||
const name = det.dataset.section;
|
||
if (collapsed.has(name)) det.open = false;
|
||
det.addEventListener('toggle', () => {
|
||
if (det.open) collapsed.delete(name);
|
||
else collapsed.add(name);
|
||
_persist();
|
||
});
|
||
});
|
||
})();
|
||
</script>
|
||
{% block scripts %}{% endblock %}
|
||
</body>
|
||
</html>
|