* feat(store): flea-market upload guardrails + soft delete + JOIN-based admin queue
Adds an end-to-end guardrails pipeline for store uploads (manifest +
static-security + LLM review), persists blocked bundles for forensics,
introduces soft-delete (Archive) semantics, consolidates the legacy
/store/{id} surface into /marketplace/flea/{id}, and reworks the admin
queue so lifecycle filters read live entity visibility via LEFT JOIN
rather than a denormalized submission column.
Schema v29 → v35:
* v29 store_submissions table + store_entities.visibility_status
* v30 file_size, bundle_sha256, bundle_purged_at on submissions
* v31 reshape store_submissions (drop legacy unique on entity_id)
* v32 store_entities.archived_at/by + 'archived' visibility value
* v33 drop store_submissions.retry_count (unused)
* v34 ensure idx_store_submissions_entity exists post column-drop
* v35 broaden visibility_status enum + JOIN architecture cutover
Pipeline (src/store_guardrails/):
* Inline checks: manifest_check, static_scan, quality_check
* LLM review configurable haiku|sonnet|opus (default haiku)
* BackgroundTasks-driven async path with structured-output JSON
* Per-submitter daily quota (default 50)
* 30-day TTL purge job (POST /api/admin/run-blocked-purge)
* Bundle SHA256 + size persisted; sha256 survives purge for forensics
Visibility model:
* pending | approved | hidden | archived
* _enforce_visibility returns 404 (no leak) for non-owner non-admin
* Owner sees own non-approved entries via include_owner_id widening
* Install refused with 409 entity_not_approved when not approved
Soft-delete (DELETE /api/store/entities/{id}):
* Default = soft (visibility_status='archived'); existing installs
keep getting served the bundle so users don't lose the plugin
* ?hard=true admin-only: drops bundle + cascades user_store_installs
* Hard-delete preserves entity_id on submission as tombstone so
audit_log linkage survives for the activity timeline
Admin queue lifecycle (the JOIN refactor):
* Verdict (store_submissions.status) is immutable forensic record
* Lifecycle (store_entities.visibility_status) is live state
* /admin/store/submissions Archived chip translates to
`e.visibility_status='archived'` via LEFT JOIN — any path that
flips visibility surfaces in the queue immediately
* Detail page renders Status (verdict) and Entity lifecycle side by
side so admins see "approved at review, now archived" at a glance
URL consolidation:
* /store/{id} deleted (no redirect, stale bookmarks 404)
* /marketplace/flea/{id} is the canonical detail surface
* Three in-tree callers (upload-success, my-stack card, store
listing card) updated to point at the new URL
* Quarantine banner extracted to _quarantine_banner.html partial,
self-guarded, included from both flea detail templates
* Banner JS auto-refreshes when the verdict lands by polling
/api/marketplace/flea/{id}/detail (visibility_status +
submission_status — the latter is needed because blocked_llm
keeps the entity at visibility_status='pending')
Audit log resource format:
* runner.py emits prefixed `store_submission:{id}` (post-fix)
* Detail-page timeline query handles three patterns: prefixed
submission, helper-emitted `store_entity:{sub_id}`, and bare-id
legacy rows — all surface in the activity timeline
UX fixes:
* Owner sees Under review / Quarantined / Hidden banner with status
* Install button gray-disabled (not blue) when non-approved
* Owner cannot delete quarantined entries (403); admin can
* Admin queue: filter chips, sortable columns, paging, page-size
* Auto-refresh queue every 5s while pending rows are visible
* Store upload page file picker no longer opens twice (label →
input default action collided with explicit JS handler)
Tests: 168 passed across the guardrails suites (admin submissions,
store API, inline / LLM / purge guardrails, store repositories,
marketplace filter, schema version). New regression coverage
includes: archive surfaces via JOIN even when API path is bypassed;
deleted submission renders activity timeline (tombstone); flea
detail surfaces submission_status only for owner/admin; detail page
renders Entity lifecycle row; audit log resource format covers both
helper and runner paths.
* fix(store-guardrails): PR #233 follow-up — prompt injection, atomic PUT, BG race, schema, reaper, sort whitelist
Addresses 9 of the 23 findings from the PR #233 review (spec at
docs/superpowers/specs/2026-05-09-pr233-guardrails-fixes-spec.md).
Merge-gate items #1-#6 plus high-value mediums #7, #9-#12, #23.
Architectural items (#8 enum split, #14 factory) and pure
maintainability (#15-#22) deferred to follow-ups.
Security:
* #1 prompt injection — SYSTEM_PROMPT now passed via the SDK's
dedicated system= parameter; bundle wrapped in <bundle>...</bundle>
sentinels declared data-only by the system prompt; literal
sentinel strings in user content are escaped so an adversarial
README can't forge a close tag.
* #6 static scan honesty — module docstring + admin copy + docs
declare static scan as signal not gate; .md/.txt/.rst/.html/.json/
.yaml/.yml/.toml skipped to avoid false positives on prose.
AST mode for Python deferred (separate flag, FP comparison work).
Correctness:
* #2 PUT atomicity — bundles bake into plugin.staging-<rand>/
alongside live, atomic-rename on success; failed checks leave
live tree byte-for-byte intact.
* #3 BG-task race — set_visibility_if_pending guards verdict flips
to the (pending, hidden) review window; admin archives during
review survive; skipped flips audit-logged.
* #4 v35 NOT NULL/DEFAULT — schema v35→v36 re-applies them on
store_entities.visibility_status. CHECK constraint enforced
application-side (DuckDB ADD CHECK on existing column unsupported).
* #7 stuck-review reaper — reap_stuck_llm_reviews flips pending_llm
rows older than guardrails.stuck_review_grace_seconds (default
1800) to review_error. Scheduler runs every 15 min via new
/api/admin/run-reap-stuck-reviews. Set knob to 0 to disable.
* #9 quota counter — count_blocked_for_submitter_since now counts
blocked_inline + blocked_llm + review_error so a submitter
triggering only LLM-blocked verdicts is bounded.
* #10 missing risk_level — surfaces as review_error with
error='missing_risk_level' instead of silently defaulting to
'medium' (which looked like a model-decided block).
* #11 archived_at clear — set_visibility nulls archived_at +
archived_by when transitioning out of 'archived' so a future
read doesn't show stale archive forensics on an approved row.
Maintainability:
* #12 FSM doc comment — accurate insert/transition/lifecycle
description in src/db.py near store_submissions schema.
* #23 sort-key whitelist — admin queue rejects unknown sort keys
with 400 invalid_sort_key; substring-replace footgun removed.
Deferred (separate PRs):
* #5 quota race — proper fix requires asyncio.Lock spanning the
full pipeline; threading.Lock blocks event loop, DuckDB MVCC
doesn't help. API-level slowapi bounds worst case for now.
* #6 part 3 (AST static scan), #8 (enum split), #13 (import
bundle docs), #14 (factory consolidation), #15-#22 (maint).
Tests:
* New: tests/test_store_guardrails_prompt_injection.py (corpus +
trust-boundary invariants), tests/test_store_put_atomic.py,
tests/test_store_guardrails_reaper.py.
* Extended: test_store_guardrails_llm.py (system param, missing
risk_level, BG race), test_admin_store_submissions.py (quota
counter widening, sort whitelist 400), test_store_repositories.py
(un-archive metadata clear), test_db_schema_version.py (v36).
* Full suite: 3738 passed; 17 pre-existing baseline failures
unchanged (db migration tests, cli binary rename, catalog export,
user mgmt v5 backfill — confirmed by stash + rerun on clean tree).
427 lines
16 KiB
HTML
427 lines
16 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 page-shell">
|
||
<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>
|
||
// Server-injected viewer id — drives the v32+ owner-only quarantine
|
||
// corner badge on cards (so a submitter sees their own non-approved
|
||
// uploads marked, but other users browsing the same grid don't).
|
||
const currentUserId = {{ session.user.id | tojson }};
|
||
|
||
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 = `/marketplace/flea/${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;
|
||
// v32+ quarantine corner badge — shown only to the submitter on
|
||
// their own non-approved cards. Approved cards + non-owner views
|
||
// omit it. The visibility filter on /api/store/entities already
|
||
// limits non-approved rows to the viewer's own; we still
|
||
// double-check `e.owner_user_id === currentUserId` here so the
|
||
// badge never paints on someone else's card if the API contract
|
||
// ever loosens.
|
||
let quarantineBadge = '';
|
||
if (
|
||
e.visibility_status && e.visibility_status !== 'approved'
|
||
&& currentUserId && e.owner_user_id === currentUserId
|
||
) {
|
||
const isPending = e.visibility_status === 'pending'
|
||
|| e.visibility_status === 'pending_inline'
|
||
|| e.visibility_status === 'pending_llm';
|
||
const label = isPending ? '⟳ Under review' : '⚠ Quarantined';
|
||
const palette = isPending
|
||
? 'background:#fef3c7;color:#92400e;border:1px solid #fde68a;'
|
||
: 'background:#fee2e2;color:#991b1b;border:1px solid #fecaca;';
|
||
quarantineBadge = `<div title="Visible only to you. ${isPending ? 'Checks in progress.' : 'Failed automated/security checks — open detail to see why.'}" style="position:absolute;top:8px;left:8px;padding:3px 9px;border-radius:999px;font-size:11px;font-weight:600;${palette}z-index:2;">${label}</div>`;
|
||
}
|
||
card.innerHTML = `
|
||
${quarantineBadge}
|
||
${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 %}
|