agnes-the-ai-analyst/app/web/templates/store_listing.html
Minas Arustamyan d5a7c9ad79 feat(store): /store + /my-ai-stack — community marketplace + per-user composition
Adds a community-driven Store where any authenticated user uploads
skills/agents/plugins as ZIPs, plus /my-ai-stack as the per-user
composition view. The served Claude Code marketplace is now:

    (admin_granted ∖ opt_outs) ∪ store_installs

Skill + agent installs are merged into a single `agnes-store-bundle`
plugin in the served marketplace; type=plugin uploads stay standalone.
Names are suffixed with `-by-<owner-username>` at upload time so two
owners can use the same display name without colliding in Claude Code's
flat skill/agent namespace.

Schema v23 → v24 adds three tables:
  - store_entities       — community-uploaded skills/agents/plugins
  - user_store_installs  — what each user has chosen to install
  - user_plugin_optouts  — opt-out overlay on top of admin grants

Admin grant-delete drops every user's opt-out for that plugin so
re-grant resets cleanly to enabled (no sticky personal preference).

UI:
  - /store      — e-commerce-style listing with type/category/owner
                  filters, search, pagination, owner-aware [Install]
                  buttons, clickable cards
  - /store/new  — 2-step upload wizard with drag & drop, preview
                  validation (POST /api/store/entities/preview), docs
                  multi-upload, photo + video URL
  - /store/{id} — detail page with hero, file list, docs, owner
                  actions (Edit/Delete) for the uploader
  - /my-ai-stack — Granted plugins (toggle opt-out) + From the Store
                  (uninstall) sections
  - Admin nav: Marketplaces moved into Admin dropdown, renamed to
                "Curated Marketplaces"

Validation hardening: type-mismatch guards reject skill ZIP uploaded as
agent (or vice versa), and plugin ZIPs masquerading as skills/agents.
Human-readable error messages mapped client-side from machine codes.

Cross-source naming: Store entity-id-prefixed dirs (`plugins/store-<id>/`)
plus the bundle (`plugins/store-bundle/`) avoid collisions with admin
marketplaces (whose `store` slug is reserved by `is_valid_slug`).

Bundle composition is content-hashed at serve time — install/uninstall
or owner re-upload bumps the bundle's plugin.json `version`, so Claude
Code's auto-update toggle picks up changes.

Tests: 50+ new tests across naming, repositories, filter (admin ∪ store
∪ bundle), API (upload/install/uninstall/delete/preview/docs), end-to-end
marketplace.zip with bundle merging.
2026-05-05 02:53:49 +02:00

401 lines
15 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% extends "base.html" %}
{% block title %}Store — {{ config.INSTANCE_NAME }}{% endblock %}
{% block content %}
<style>
.container:has(.store-page) { max-width: 1280px; margin: 0 auto; padding: 16px 32px 48px; }
.container:has(.store-page) > main { margin: 0; padding: 0; }
/* ── 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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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 %}