* feat(home+news): state-aware /home + /news + admin-edited news section
Squash of the vr/home-page feature work for clean rebase onto main.
Original 18-commit history preserved in branch backup/vr-home-page-pre-rebase.
What's in this PR:
**State-aware /home page**
- New `/home` route with hero + auto-mode + connectors (Asana / GWS /
Atlassian) + lookarounds. Onboarded vs not-onboarded state-machine
branches a single template (`home_not_onboarded.html`); the install
steps, "Setup a new Claude Code" CTA (90-day PAT mint), and per-
connector setup prompts hide once `users.onboarded=TRUE`. A
completion badge replaces them.
- "Mark me as offboarded" button reverses the flag without an SQL UPDATE.
- `users.onboarded BOOLEAN` column added; default FALSE; flipped by the
CLI's `agnes init` post-success POST and the `/admin/users` API.
- Connector setup prompts pre-check whether the tool is already
installed/connected before re-running setup.
- GWS scope set widened to include Google Chat (`chat.spaces`,
`chat.messages`).
**Single template + design tokens**
- `dashboard.html` now extends `base.html` via the new
`{% block layout %}` opt-out (full-width pages skip the 800px
`.container`). Net: every page shares one shell.
- `style-custom.css` `:root` extended with `--space-{7,9,10,12}`,
`--radius-2xl`, `--shadow-{card,elevated}`, `--text-{muted,disabled}`,
`--focus-ring`, `--transition-*`, `--width-{narrow,app,wide}` so
inline page styles can migrate incrementally.
**Auth redirects honor AGNES_HOME_ROUTE**
- `safe_next_path` resolves the configured home route when no `default=`
is passed; OAuth callbacks, magic-link clicks, password form, and
LOCAL_DEV_MODE shortcuts now land on `/home` (or whatever the operator
picked) instead of always /dashboard.
**News section + /news permalink + /admin/news editor**
- Schema-bumped `news_template` table (single versioned entity, draft +
publish gate). `published BOOLEAN` distinguishes draft from public;
monotonically-increasing `version` per save; rows >30d pruned on
save except the currently-displayed published version.
- `/home` bottom-of-page renders the latest published intro with a
"Read more →" link to `/news` (which renders the full body).
- `/admin/news` editor with sandboxed live preview, versions table,
per-row Unpublish, Format-help cheatsheet.
- `agnes admin news show / draft / edit / publish / unpublish /
versions / export` (CLI). Talks to the live server via the
`/api/admin/news/*` endpoints (PAT-authed) — no direct DB access
so it coexists with a running uvicorn.
- **Optimistic-lock guard**: `agnes admin news publish --version N` and
PUT/PATCH endpoints accept `expected_version` and 409 with structured
`{error: "version_conflict", expected, actual, actual_by}` when a
concurrent admin replaced the draft. Edit refuses to overwrite a
draft authored by someone else without `--force` or
`--expect-version`.
- nh3 (Rust-backed ammonia) HTML sanitizer; iframe pre-pass strips
any iframe whose src is not on the YouTube/Vimeo/Loom allowlist;
javascript:/data: schemes blocked everywhere.
- Author CSS vocabulary: `.news-hero` (blue gradient hero block),
`.callout`/`.callout-{info,warn,success,danger}`,
`.video-embed`, `.news-section`, `.news-grid-{2,3}`, `.news-cta` —
all consolidated in `style-custom.css` under "News content
vocabulary (shared)" so /home perex, /news body, and /admin/news
preview share one source of styling.
- Code-inside-`<pre>` contrast fix (was unreadable amber-on-silver).
- `.news-content` table styling (border, header band, row-hover).
**`scripts/dev/run-local.sh`** — local uvicorn launcher. Pulls Google
OAuth client id/secret from GCP Secret Manager
(`AGNES_OAUTH_GCP_PROJECT`-driven, no vendor defaults), points
`AGNES_CLI_DIST_DIR` at `./dist` so the wheel endpoint resolves, and
`--dev` flips `LOCAL_DEV_MODE=1` + `AGNES_HOME_ROUTE=/home` for one-
command iteration. `LOCAL_DEV_MODE=1` also enables the FastAPI debug
toolbar.
**CLAUDE.md "Run tests before every push" section** codifies
`pytest tests/ -n auto -q` as non-negotiable before each push.
**Tests**: 51 + 14 + 8 = 73 new tests across news-template repo,
sanitizer, API, web, CLI; plus updated home/auth/template tests for
the new shared-shell architecture.
Origin docs (gitignored, customer-fork content):
docs/brainstorms/home-page-requirements.md,
docs/plans/2026-05-07-001-feat-home-page-plan.md.
* feat(cli): agnes onboarded {on,off,status} — self-scoped flag toggle
User-facing equivalent of the in-page "Mark me as (off)boarded" button
on /home. POSTs /api/me/onboarded with {onboarded, source}; --source
overrides the audit-log marker so flips made from the CLI vs the web
button vs agnes init automation stay distinguishable.
`status` reads via /api/me/profile (when present); falls back to a
quick body-marker scan of /home so the read path doesn't write an
audit_log row. PAT-authed via cli.client.api_post — same convention
as agnes admin news / agnes admin add-user etc.
Tests: 5 covering on/off/status round-trip, idempotency, and
audit-log source recording. Full suite holds at 12 pre-existing
failures (same set as before).
* ui(nav+home): primary nav reorg + green What's new band + /marketplace link fix
Primary nav (post-rebase audit + per-user feedback):
- Items: Home → Marketplace → Data Packages → Memory. Admin dropdown
for admins only. The "Dashboard" label was renamed Home — point still
resolves through `home_route` so customer instances on /dashboard
still land there.
- Activity Center moved into the Admin dropdown. Per-team adoption
analytics is admin-consumed in practice; the route still allows
any authed user for direct deep-links so existing /home tile +
bookmarks keep working.
- Memory link added (→ /corporate-memory) — was previously buried in
the /home "Look around" tiles.
- Setup local agent + My Stack dropped from main nav. Setup is the
/home install flow's home now; My Stack lives as a tab inside
/marketplace.
/home tweaks:
- Plugin marketplace tile now points at /marketplace (was /store —
legacy from before the marketplace rebrand landed in #230).
- "What's new" section header gets a green band (success-flavored
D1FAE5 background, A7F3D0 border, darker green title) so the
bottom-of-page news block visibly distinguishes from the blue
install-hero at the top. Header strip only — body stays white.
Test fix: test_home_route_resolution renamed `dashboard_link_uses_home_route`
→ `home_link_uses_home_route` and asserts `href="/home">Home` instead
of `href="/home">Dashboard` after the label change.
* fix(home): decouple Step 3 + Connect-tools collapse from server onboarded flag
The server-side `users.onboarded` flip happens through two paths:
1. Explicit user click on "Mark me as onboarded" or `agnes onboarded on`.
2. Implicit `agnes init` POST → /api/me/onboarded on success.
Path 2 produced a UX surprise: an analyst running `agnes init` mid-flow
reloaded /home and saw Step 3 (auto-mode) + Connect-your-tools auto-
collapse to summary bars. They were actively working through those
sections — the install POST never signalled "I'm done with the rest
of setup", just "Agnes itself is installed".
Decouple the section-collapse decision from the server flag:
- Step 1 + Step 2 install blocks: still hidden on `onboarded=TRUE`
(their completion is a hard server signal — Agnes IS installed).
- Step 3 + Connect-your-tools: render flat by default in BOTH states.
Wrapped in `<details class="setup-collapsible" open>` so the
browser's native disclosure handles per-section toggle without JS,
but the `<summary>` is CSS-hidden until the page-level
`data-setup-minimized="1"` attribute is set on `.home-mock`.
- New "Minimize setup view" toggle inside the blue install-hero,
rendered only when onboarded. Click flips the data-attr on
`.home-mock` AND removes the `open` attribute from each
`<details>`. State persists in `localStorage["agnes_home_setup_minimized"]`
so the choice survives reloads but is per-device.
- "Show full setup view" (the same button when minimized) re-opens
both `<details>` and clears localStorage.
When minimized, each `<details>` still has its own native expand/
collapse — click the gray summary bar to peek at one section without
toggling the page-level minimize off.
Tests:
- test_step3_and_connectors_render_flat_when_onboarded_by_default —
asserts `<details class="setup-collapsible" ... open>` for both
sections post-onboarding and the absence of any server-rendered
`data-setup-minimized` attribute on the `.home-mock` root.
- test_minimize_toggle_visible_only_when_onboarded — toggle button
rendered only when onboarded.
Full pytest holds at 12 pre-existing failures (same set).
400 lines
15 KiB
HTML
400 lines
15 KiB
HTML
{% extends "base.html" %}
|
||
{% block title %}Store — {{ config.INSTANCE_NAME }}{% endblock %}
|
||
|
||
{% block content %}
|
||
{% include "_page_chrome.html" %}
|
||
<style>
|
||
|
||
/* ── Hero (mirrors /setup) ─────────────────────────────────────── */
|
||
.store-hero {
|
||
background: linear-gradient(135deg, #0073D1 0%, #0056A3 100%);
|
||
border-radius: 12px;
|
||
padding: 28px 32px;
|
||
margin-bottom: 24px;
|
||
box-shadow: 0 4px 16px rgba(0, 115, 209, 0.2);
|
||
color: #fff;
|
||
display: flex; gap: 24px; align-items: center; justify-content: space-between;
|
||
flex-wrap: wrap;
|
||
}
|
||
.store-hero .titles { min-width: 0; }
|
||
.store-hero .eyebrow {
|
||
font-size: 11px; font-weight: 600; text-transform: uppercase;
|
||
letter-spacing: 0.8px; color: rgba(255,255,255,0.75); margin-bottom: 8px;
|
||
}
|
||
.store-hero h1 { margin: 0; font-size: 28px; font-weight: 700; letter-spacing: -0.4px; }
|
||
.store-hero .sub {
|
||
margin: 6px 0 0; font-size: 14px;
|
||
color: rgba(255,255,255,0.85); line-height: 1.6;
|
||
}
|
||
.store-hero .upload-btn {
|
||
display: inline-flex; align-items: center; gap: 8px;
|
||
background: #fff; color: var(--primary);
|
||
border: none; border-radius: 8px;
|
||
padding: 11px 20px; font-size: 14px; font-weight: 600;
|
||
text-decoration: none; cursor: pointer;
|
||
transition: all 0.15s ease;
|
||
font-family: var(--font-primary); flex-shrink: 0;
|
||
}
|
||
.store-hero .upload-btn:hover {
|
||
transform: translateY(-1px);
|
||
box-shadow: 0 4px 14px rgba(0,0,0,0.15);
|
||
}
|
||
|
||
/* ── Filters card ──────────────────────────────────────────────── */
|
||
.filters-card {
|
||
background: var(--surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: 12px;
|
||
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
|
||
padding: 16px 18px;
|
||
margin-bottom: 20px;
|
||
display: flex; gap: 10px; align-items: center; flex-wrap: wrap;
|
||
}
|
||
.filters-card .pill {
|
||
appearance: none; border: 1px solid var(--border);
|
||
background: var(--surface); color: var(--text-primary);
|
||
padding: 7px 14px; border-radius: 999px;
|
||
font-size: 13px; font-weight: 500; cursor: pointer;
|
||
font-family: var(--font-primary);
|
||
transition: all 0.15s ease;
|
||
}
|
||
.filters-card .pill:hover { border-color: var(--primary); color: var(--primary); }
|
||
.filters-card .pill.is-active {
|
||
background: var(--primary-light); color: var(--primary);
|
||
border-color: var(--primary);
|
||
}
|
||
.filters-card select, .filters-card input {
|
||
padding: 8px 12px; border: 1px solid var(--border);
|
||
border-radius: 8px; font-size: 13px; font-family: var(--font-primary);
|
||
background: var(--surface); color: var(--text-primary);
|
||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||
}
|
||
.filters-card select:focus, .filters-card input:focus {
|
||
outline: none; border-color: var(--primary);
|
||
box-shadow: 0 0 0 3px rgba(0, 115, 209, 0.12);
|
||
}
|
||
.filters-card input { flex: 1; min-width: 220px; max-width: 360px; }
|
||
|
||
/* ── Card grid ─────────────────────────────────────────────────── */
|
||
.store-grid {
|
||
display: grid; gap: 16px;
|
||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||
}
|
||
.store-card {
|
||
display: flex; flex-direction: column;
|
||
background: var(--surface);
|
||
border: 1px solid var(--border); border-radius: 12px;
|
||
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
|
||
overflow: hidden; transition: all 0.15s ease;
|
||
cursor: pointer;
|
||
}
|
||
.store-card:hover {
|
||
border-color: var(--primary);
|
||
box-shadow: 0 6px 20px rgba(0, 115, 209, 0.12);
|
||
transform: translateY(-2px);
|
||
}
|
||
.store-card .photo {
|
||
width: 100%; height: 140px; object-fit: cover;
|
||
background: linear-gradient(135deg, var(--primary-light) 0%, #fce7f3 100%);
|
||
display: flex; align-items: center; justify-content: center;
|
||
color: var(--primary);
|
||
font-size: 28px; font-weight: var(--font-bold);
|
||
letter-spacing: 0.5px;
|
||
}
|
||
.store-card .body {
|
||
padding: 14px 16px; flex: 1;
|
||
display: flex; flex-direction: column; gap: 6px;
|
||
}
|
||
.store-card .type-badge {
|
||
display: inline-block; padding: 2px 8px; border-radius: 4px;
|
||
background: var(--primary-light); color: var(--primary);
|
||
font-size: 10px; font-weight: var(--font-semibold);
|
||
text-transform: uppercase; letter-spacing: 0.5px;
|
||
align-self: flex-start;
|
||
}
|
||
.store-card .name {
|
||
font-weight: var(--font-semibold); color: var(--text-primary);
|
||
font-size: var(--text-base);
|
||
}
|
||
.store-card .by {
|
||
font-size: 11px; color: var(--text-secondary);
|
||
margin-top: -2px;
|
||
}
|
||
.store-card .desc {
|
||
font-size: 12px; color: var(--text-secondary);
|
||
line-height: 1.5;
|
||
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
|
||
overflow: hidden;
|
||
}
|
||
.store-card .footer {
|
||
display: flex; align-items: center; justify-content: space-between;
|
||
padding: 10px 16px; border-top: 1px solid var(--border-light);
|
||
font-size: 11px; color: var(--text-secondary);
|
||
}
|
||
.store-card .footer button {
|
||
appearance: none; border: 1px solid var(--border);
|
||
background: var(--surface); color: var(--primary);
|
||
padding: 6px 12px; border-radius: 6px;
|
||
font-size: 12px; font-weight: var(--font-medium); cursor: pointer;
|
||
font-family: var(--font-primary);
|
||
transition: all 0.15s ease;
|
||
}
|
||
.store-card .footer button:hover {
|
||
background: var(--primary-light); border-color: var(--primary);
|
||
}
|
||
.store-card .footer button.installed {
|
||
color: var(--success); background: rgba(16, 183, 127, 0.1);
|
||
border-color: rgba(16, 183, 127, 0.3);
|
||
}
|
||
|
||
.empty-state {
|
||
text-align: center; padding: 60px 20px;
|
||
color: var(--text-secondary); font-size: 14px;
|
||
background: var(--surface);
|
||
border: 1px dashed var(--border); border-radius: 12px;
|
||
}
|
||
|
||
/* ── Pager ─────────────────────────────────────────────────────── */
|
||
.pager { display: flex; gap: 6px; justify-content: center; margin: 28px 0 0; }
|
||
.pager button {
|
||
padding: 7px 14px; border: 1px solid var(--border);
|
||
background: var(--surface); color: var(--text-primary);
|
||
border-radius: 8px; cursor: pointer; font-size: 13px;
|
||
font-family: var(--font-primary);
|
||
transition: all 0.15s ease;
|
||
}
|
||
.pager button:hover { border-color: var(--primary); color: var(--primary); }
|
||
.pager button.is-active {
|
||
background: var(--primary); color: #fff; border-color: var(--primary);
|
||
}
|
||
</style>
|
||
|
||
<div class="store-page">
|
||
<div class="store-hero">
|
||
<div class="titles">
|
||
<div class="eyebrow">Store</div>
|
||
<h1>Community AI marketplace</h1>
|
||
<p class="sub">Skills, agents, and plugins shared by everyone on this instance.
|
||
Install what you need into your <a href="/my-ai-stack" style="color:#fff;text-decoration:underline;">AI stack</a>.</p>
|
||
</div>
|
||
<a class="upload-btn" href="/store/new">+ Upload</a>
|
||
</div>
|
||
|
||
<div class="filters-card">
|
||
<button class="pill is-active" data-type="">All</button>
|
||
<button class="pill" data-type="skill">Skills</button>
|
||
<button class="pill" data-type="agent">Agents</button>
|
||
<button class="pill" data-type="plugin">Plugins</button>
|
||
<select id="cat-filter">
|
||
<option value="">All categories</option>
|
||
</select>
|
||
<select id="owner-filter">
|
||
<option value="">All owners</option>
|
||
</select>
|
||
<input id="search-input" placeholder="Search by name or description...">
|
||
</div>
|
||
|
||
<div id="store-grid" class="store-grid"></div>
|
||
<div id="store-empty" class="empty-state" hidden>Nothing matches.</div>
|
||
<div id="pager" class="pager"></div>
|
||
</div>
|
||
|
||
<script>
|
||
const state = {
|
||
page: 1,
|
||
pageSize: 24,
|
||
type: '',
|
||
category: '',
|
||
owner: '',
|
||
search: '',
|
||
total: 0,
|
||
};
|
||
|
||
function syncURL() {
|
||
const u = new URL(window.location.href);
|
||
u.searchParams.set('page', state.page);
|
||
if (state.type) u.searchParams.set('type', state.type); else u.searchParams.delete('type');
|
||
if (state.category) u.searchParams.set('category', state.category); else u.searchParams.delete('category');
|
||
if (state.owner) u.searchParams.set('owner', state.owner); else u.searchParams.delete('owner');
|
||
if (state.search) u.searchParams.set('q', state.search); else u.searchParams.delete('q');
|
||
window.history.replaceState({}, '', u);
|
||
}
|
||
|
||
function loadFromURL() {
|
||
const p = new URLSearchParams(window.location.search);
|
||
state.page = parseInt(p.get('page') || '1', 10);
|
||
state.type = p.get('type') || '';
|
||
state.category = p.get('category') || '';
|
||
state.owner = p.get('owner') || '';
|
||
state.search = p.get('q') || '';
|
||
document.getElementById('search-input').value = state.search;
|
||
document.querySelectorAll('.filters-card .pill').forEach(b => {
|
||
b.classList.toggle('is-active', (b.dataset.type || '') === state.type);
|
||
});
|
||
}
|
||
|
||
async function loadCategories() {
|
||
const res = await fetch('/api/store/categories');
|
||
if (!res.ok) return;
|
||
const cats = await res.json();
|
||
const sel = document.getElementById('cat-filter');
|
||
for (const c of cats) {
|
||
const opt = document.createElement('option');
|
||
opt.value = c; opt.textContent = c;
|
||
if (c === state.category) opt.selected = true;
|
||
sel.appendChild(opt);
|
||
}
|
||
}
|
||
|
||
async function loadOwners() {
|
||
const res = await fetch('/api/store/owners');
|
||
if (!res.ok) return;
|
||
const owners = await res.json();
|
||
const sel = document.getElementById('owner-filter');
|
||
for (const o of owners) {
|
||
const opt = document.createElement('option');
|
||
opt.value = o.user_id;
|
||
opt.textContent = `${o.display_name} (${o.entity_count})`;
|
||
if (o.user_id === state.owner) opt.selected = true;
|
||
sel.appendChild(opt);
|
||
}
|
||
}
|
||
|
||
async function loadEntities() {
|
||
const params = new URLSearchParams({
|
||
skip: String((state.page - 1) * state.pageSize),
|
||
limit: String(state.pageSize),
|
||
});
|
||
if (state.type) params.set('type', state.type);
|
||
if (state.category) params.set('category', state.category);
|
||
if (state.owner) params.set('owner', state.owner);
|
||
if (state.search) params.set('search', state.search);
|
||
const res = await fetch('/api/store/entities?' + params);
|
||
if (!res.ok) { alert('Failed: ' + res.status); return; }
|
||
const data = await res.json();
|
||
state.total = data.total;
|
||
// also fetch caller's installs for button state
|
||
const myStackRes = await fetch('/api/my-stack');
|
||
const myStack = myStackRes.ok ? await myStackRes.json() : {store: []};
|
||
const installed = new Set(myStack.store.map(e => e.entity_id));
|
||
renderGrid(data.items, installed);
|
||
renderPager();
|
||
}
|
||
|
||
function renderGrid(items, installed) {
|
||
const grid = document.getElementById('store-grid');
|
||
const empty = document.getElementById('store-empty');
|
||
grid.innerHTML = '';
|
||
if (!items.length) { empty.hidden = false; return; }
|
||
empty.hidden = true;
|
||
for (const e of items) {
|
||
const card = document.createElement('div');
|
||
card.className = 'store-card';
|
||
card.dataset.href = `/store/${encodeURIComponent(e.id)}`;
|
||
const photoMarkup = e.photo_url
|
||
? `<img class="photo" src="${escapeAttr(e.photo_url)}" alt="">`
|
||
: `<div class="photo">${escapeHtml(typeIcon(e.type))}</div>`;
|
||
const isInstalled = installed.has(e.id);
|
||
const btn = isInstalled
|
||
? `<button class="installed" data-id="${escapeAttr(e.id)}" data-action="uninstall">Installed ✓</button>`
|
||
: `<button data-id="${escapeAttr(e.id)}" data-action="install">Install</button>`;
|
||
const ownerLabel = e.owner_display_name || e.owner_username;
|
||
card.innerHTML = `
|
||
${photoMarkup}
|
||
<div class="body">
|
||
<span class="type-badge">${escapeHtml(e.type)}</span>
|
||
<div class="name">${escapeHtml(e.name)}</div>
|
||
<div class="by">by ${escapeHtml(ownerLabel)}</div>
|
||
<div class="desc">${escapeHtml(e.description || '')}</div>
|
||
</div>
|
||
<div class="footer">
|
||
<span>${e.install_count} installed${e.category ? ' · ' + escapeHtml(e.category) : ''}</span>
|
||
${btn}
|
||
</div>`;
|
||
card.addEventListener('click', () => {
|
||
window.location = card.dataset.href;
|
||
});
|
||
grid.appendChild(card);
|
||
}
|
||
grid.querySelectorAll('button[data-action]').forEach(btn => {
|
||
btn.addEventListener('click', async (e) => {
|
||
e.stopPropagation();
|
||
const id = btn.dataset.id;
|
||
const action = btn.dataset.action;
|
||
if (action === 'install') {
|
||
const r = await fetch(`/api/store/entities/${encodeURIComponent(id)}/install`, {method: 'POST'});
|
||
if (!r.ok) { alert('Install failed'); return; }
|
||
} else if (action === 'uninstall') {
|
||
const r = await fetch(`/api/store/entities/${encodeURIComponent(id)}/install`, {method: 'DELETE'});
|
||
if (!r.ok) { alert('Uninstall failed'); return; }
|
||
}
|
||
loadEntities();
|
||
});
|
||
});
|
||
}
|
||
|
||
function renderPager() {
|
||
const pager = document.getElementById('pager');
|
||
pager.innerHTML = '';
|
||
const totalPages = Math.max(1, Math.ceil(state.total / state.pageSize));
|
||
if (totalPages <= 1) return;
|
||
const mk = (label, page, active) => {
|
||
const b = document.createElement('button');
|
||
b.textContent = label;
|
||
if (active) b.classList.add('is-active');
|
||
b.addEventListener('click', () => {
|
||
state.page = page; syncURL(); loadEntities();
|
||
});
|
||
return b;
|
||
};
|
||
if (state.page > 1) pager.appendChild(mk('‹', state.page - 1, false));
|
||
const start = Math.max(1, state.page - 3);
|
||
const end = Math.min(totalPages, start + 6);
|
||
for (let i = start; i <= end; i++) pager.appendChild(mk(String(i), i, i === state.page));
|
||
if (state.page < totalPages) pager.appendChild(mk('›', state.page + 1, false));
|
||
}
|
||
|
||
function typeIcon(t) {
|
||
if (t === 'skill') return 'SK';
|
||
if (t === 'agent') return 'AG';
|
||
if (t === 'plugin') return 'PL';
|
||
return '?';
|
||
}
|
||
|
||
function escapeHtml(s) {
|
||
return String(s).replace(/[&<>"']/g, ch => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[ch]));
|
||
}
|
||
function escapeAttr(s) { return escapeHtml(s); }
|
||
|
||
document.querySelectorAll('.filters-card .pill').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
document.querySelectorAll('.filters-card .pill').forEach(b => b.classList.remove('is-active'));
|
||
btn.classList.add('is-active');
|
||
state.type = btn.dataset.type || '';
|
||
state.page = 1; syncURL(); loadEntities();
|
||
});
|
||
});
|
||
document.getElementById('cat-filter').addEventListener('change', (e) => {
|
||
state.category = e.target.value;
|
||
state.page = 1; syncURL(); loadEntities();
|
||
});
|
||
document.getElementById('owner-filter').addEventListener('change', (e) => {
|
||
state.owner = e.target.value;
|
||
state.page = 1; syncURL(); loadEntities();
|
||
});
|
||
let _searchTimer;
|
||
document.getElementById('search-input').addEventListener('input', (e) => {
|
||
clearTimeout(_searchTimer);
|
||
_searchTimer = setTimeout(() => {
|
||
state.search = e.target.value.trim();
|
||
state.page = 1; syncURL(); loadEntities();
|
||
}, 250);
|
||
});
|
||
|
||
(async function init() {
|
||
loadFromURL();
|
||
await Promise.all([loadCategories(), loadOwners()]);
|
||
await loadEntities();
|
||
})();
|
||
</script>
|
||
{% endblock %}
|