agnes-the-ai-analyst/app/web/templates/marketplace.html
minasarustamyan 6fe67d5279
Curated marketplace enrichment via agnes-metadata.json + curator metadata (#234)
* Curated marketplace enrichment via agnes-metadata.json + curator metadata

Adds a second well-defined metadata file `.claude-plugin/agnes-metadata.json`
that upstream marketplace repos can opt into, providing per-plugin (and
per-skill / per-agent) cover photo, demo video URL, doc links, and
category override. The Claude Code marketplace contract is untouched —
agnes-metadata.json + the convention `.agnes/` directory are stripped
from the synthetic Claude Code marketplace served via /marketplace.zip
and /marketplace.git/*, so user instances see a clean Claude Code repo
with no Agnes-only metadata.

Highlights:
- DB schema v32 — adds curator_name + curator_email on marketplace_registry,
  cover_photo_url + video_url + doc_links on marketplace_plugins.
- Mandatory curator at marketplace registration, editable later through
  the admin UI; surfaces on cards + detail pages in place of owner_todo.
- External-asset mirror cache at ${DATA_DIR}/marketplace-cache/<slug>/
  with conditional GET, 60s timeout, 10 MB body cap, SSRF guards, and
  Wikipedia-policy-compliant User-Agent.
- Strict drop semantics — anything Agnes can't deliver as a real PDF /
  Markdown / plain text doc, or a real PNG / JPEG / WebP cover, is
  dropped from the served metadata; UI looks identical to no-entry case
  (gradient placeholder for missing covers, no row in the doc list).
- Doc allowlist + image allowlist enforced on both the curated mirror
  flow and the Flea upload flow (/store/new); shared module
  src/marketplace_assets.py.
- New /api/marketplace/curated/{mp}/{plugin}/{asset,doc,mirrored}/...
  endpoints with path-traversal guards + RBAC + Content-Disposition
  attachment for docs.
- Curator-focused format guide at /marketplace/format-guide; canonical
  source is docs/curated-marketplace-format.md, also linked from the
  admin /admin/marketplaces page next to + Add Marketplace.

See CHANGELOG.md under [Unreleased] for the full breakdown.

* Fix format-guide test assertion to match shortened disclaimer

The 'Flea Market' phrase was trimmed out of the disclaimer in
docs/curated-marketplace-format.md after the curator-focused rewrite.
Update the rendered-HTML test to assert the channel-scoping phrase
that's actually present ('Curated Marketplace channel only') rather
than the 'Flea Market' contrast that's no longer in the doc.

* Drop unused 'version' field from agnes-metadata.json schema

The parser never read it; it was a YAGNI placeholder for future
schema evolution. Curators don't need to wonder what to put there
when adding the file for the first time. Will be re-added if and
when we actually introduce a backwards-incompatible schema change.

* Harden asset mirror against SSRF via redirect + DNS rebinding

The pre-flight _is_safe_url check validated only the initial URL;
urllib.request.urlopen then followed redirects and re-resolved DNS for
the actual connection — both bypassable. Attacker-controlled origin
could 302 to http://169.254.169.254/... and exfil cloud metadata;
attacker-controlled DNS could return public IP first / 127.0.0.1 second.

Replace urlopen call with a shared OpenerDirector wired through three
custom handlers: _SafeRedirectHandler re-runs SSRF allowlist on every
redirect Location (max 5 hops, down from urllib's 10), and
_PinnedHTTPHandler / _PinnedHTTPSHandler connect to the IP that passed
validation rather than re-resolving the hostname. TLS SNI + cert verify
stay bound to the original hostname.

_resolve_safe returns the validated IP (the existing _is_safe_url
2-tuple wrapper stays for backwards compatibility) and rejects round-
robin DNS that mixes a public + private record. _UnsafeRedirectError
is a typed exception so _fetch_url can map redirect blocks to terminal
'rejected' status (not transient 'failed'). _http_open is the single
call site so tests can mock at one well-defined seam.

Tests cover redirect blocking (link-local, loopback), redirect-error
unwrapping inside URLError, pinned-IP connection target, and the
end-to-end DNS-rebinding scenario. Existing tests that mocked
urllib.request.urlopen are migrated to mock _http_open.

* Harden /asset/ endpoint against stored XSS

The endpoint served any file in the cloned marketplace repo with
stdlib-detected Content-Type, so a curator who landed evil.html (or a
renamed evil.png carrying HTML bytes) in the working tree got a
same-origin XSS — the response shares cookie scope with /admin and
/api/me/*.

The asset endpoint is image-only by contract (cover photos referenced
from agnes-metadata.json + inner skill / agent cards), so applying the
same allowlist + magic-bytes pattern that /doc/ already uses closes
the gap without breaking any legitimate use case. Three layered
checks: extension in IMAGE_EXTENSIONS (.png/.jpg/.jpeg/.webp; SVG
excluded — <script> inside SVG executes), validate_image_file magic
bytes (defeats rename-extension attack), Content-Type pinned from the
validated extension (never stdlib mimetypes).

Defense-in-depth: X-Content-Type-Options: nosniff stops browser MIME
sniffing; Content-Security-Policy: default-src 'none' blocks script /
iframe execution even if a future regression let HTML through.

Tests cover the .html extension reject, the renamed-HTML-as-PNG magic-
bytes reject, the .svg reject, and the happy-path PNG with security
headers attached. The pre-existing path-traversal test seeds a real
PNG instead of ok.txt now that the endpoint is image-only.

* Enforce mandatory curator on marketplace PATCH

The POST handler enforced curator_name + curator_email at create time,
but PATCH treated empty / missing curator inputs as 'no change'. Legacy
rows that pre-date v32 (curator_name=NULL) could be edited indefinitely
without ever filling the curator gap, and OWNER_TODO_PLACEHOLDER lingered
on every /marketplace card.

Reject the PATCH with 400 when the post-merge row would persist with
empty curator. The check fires after the existing field-merge logic, so
once-filled rows that don't touch curator still pass through (their
existing values fall through from the DB row). DB column stays nullable
so untouched legacy rows continue to coexist — the gate fires only the
moment an admin opens the edit modal.

Existing PATCH semantics preserved: empty-string input still means 'leave
existing value alone', and once-filled curator can't be cleared (those
test cases pass unchanged). New test seeds a legacy row directly via the
repository, then exercises url-only PATCH (rejected), partial-fill PATCH
(rejected), and full-fill PATCH (succeeds); a follow-up no-curator PATCH
on the now-formed row also passes.

* Drop unused curated-marketplace helpers (PR #234 review)

* build_db_payload — imported by src/marketplace.py but never called.
  The strict-drop semantics it would have implemented were re-written
  inline in _refresh_plugin_cache (see the comment block there). The
  standalone helper still carried the old fall-back-to-original-external-
  URL-on-mirror-failure behaviour, which contradicts the documented
  drop-when-can't-deliver contract — a future contributor who re-wired
  it would have introduced a silent regression. Delete with the helper
  + the import + the comment that referenced it.

* _resolve_marketplace_name — one-line shim with no remaining call
  sites. Callers use _resolve_marketplace_meta which returns name +
  curator together, avoiding the double DB hit the shim exists to
  hide.

* '# noqa: F401  Optional kept for forward-compat' was wrong — Optional
  IS used in src/marketplace.py (line 70 and line 238). Drop the noqa
  comment so a future ruff run doesn't try to remove a real import.

Removing build_db_payload also drops the only remaining use of Optional
in src/marketplace_metadata.py, so the import comes out there too.

* Cap agnes-metadata.json size + catch RecursionError on parse

The reader is invoked once per marketplace per sync and the file is
curator-controlled. Two failure modes were unguarded:

* Multi-GB JSON: path.read_text() pulled the whole file into memory
  before json.loads even ran. A curator with commit access to an
  upstream repo could OOM the sync worker.

* Deeply-nested JSON under any size cap: cpython's recursive object /
  array parser raises RecursionError at ~1000 levels of depth.
  RecursionError is a RuntimeError, not ValueError, so the existing
  catch let it propagate up and abort the entire sync — every other
  marketplace in the same pass got skipped.

Add AGNES_METADATA_MAX_BYTES = 1 MiB (a real metadata file with covers,
docs, categories for ~50 plugins fits in <100 KB so the cap is
generous) and gate the size check on path.stat().st_size before the
body read. Broaden the parse except to (ValueError, RecursionError)
with a unified log line. Both failure modes degrade to the same
empty-dict fall-back the malformed-JSON path already used, so one bad
upstream never aborts the rest of the sync.

Tests cover the size cap firing before json.loads (whitespace-padded
valid JSON exceeding the cap) and the recursion path (5000 nested
arrays — past cpython's default recursion limit but well under the
size cap).

* Persist asset-mirror manifest per body write, before unlink

sync_assets wrote each body atomically (tmp + rename) but persisted
the manifest only at the end of the batch. A kill -9 mid-Phase 2 left
on-disk files the manifest never referenced. Once a curator dropped
that URL from agnes-metadata.json, Phase 3's cleanup had no record of
the file and the orphan stayed forever — there's no GC pass walking
the cache dir today, so disk would slowly bloat.

Phase 2 (body-write iteration): after the in-memory manifest mutation,
persist BEFORE unlinking the previous body. The crash window narrows
from 'all of Phase 2' to 'between persist and unlink' (microseconds).
A persist failure mid-batch keeps the previous body on disk — the on-
disk manifest still references it, and a stale-but-existing file beats
a 404. Cost: one extra tmp+rename per body write; manifest is a few KB
so the overhead is negligible vs. the HTTP fetches.

Phase 3 (curator-removed URLs): same discipline. Collect the to-delete
relpaths, persist the manifest with the entries already gone, THEN
unlink. A crash mid-cleanup leaves at most a microsecond window where
files exist despite the manifest no longer naming them. The next sync
reads the (correct) manifest and the orphan stays orphaned, but the
served state is consistent.

Tests cover per-body persist call count, the post-update on-disk
manifest content, and Phase 3 ordering verified by reading the on-disk
manifest from inside Path.unlink.

* Consolidate marketplace video embeds + format-guide CSS

The YouTube nocookie / Vimeo / <video> / link-fallback detection logic
was duplicated verbatim in marketplace_plugin_detail.html and
marketplace_item_detail.html (~40 JS lines each, with subtly-different
inline styles). Both templates now {% include %} a single
_marketplace_video_embed.html partial inside their IIFE so the regex,
the nocookie attribute set, and the unknown-host link fallback live in
ONE place — future tweaks (new host, new attribute, fixed sandbox flag)
no longer need to be applied twice in lockstep.

The .video-wrap selectors (one inline <style> rule in plugin_detail,
one inline style='...' attribute in item_detail) are replaced by the
existing .video-embed 16:9 wrapper in style-custom.css, with new
.video-embed video / .video-embed a child rules added so the wrapper
handles all four embed shapes uniformly without per-template
positioning.

The 60-line inline <style> block in marketplace_format_guide.html
moves verbatim to style-custom.css under a new 'Marketplace format
guide page' section, scoped to .format-guide so other pages aren't
affected.

No user-visible behaviour change: the rendered HTML for valid
YouTube / Vimeo / mp4 / external links is byte-identical to before,
and the format-guide page renders the same.

* Maintainability cleanup batch (PR #234 review)

#10: drop _path_under from app/api/marketplace.py — it was a byte-
equivalent clone of _safe_join (same Path.resolve(strict=True) +
relative_to() containment check). The three v32 endpoint handlers
(/asset, /doc, /mirrored) now share the existing helper.

#14: rename src/marketplace_assets.py → src/marketplace_asset_validation.py
so the file's purpose is obvious from the name and the previous
overlap with src/marketplace_asset_mirror.py is gone. Six call-site
imports updated in lockstep; CHANGELOG references under [Unreleased]
updated to track the new path.

#11: consolidate the URL builders that resolve
/api/marketplace/curated/<slug>/<plugin>/{asset,doc,mirrored}/...
paths. _internal_asset_url / _internal_doc_url / _mirrored_asset_url
lived in src/marketplace.py, while a copy named _mirrored_url lived
in app/api/marketplace.py with a 'must stay aligned' comment. New
module src/marketplace_urls.py is the single source of truth — both
call sites import from it and a future URL-format tweak only needs
to change one file. The _ROUTE_PREFIX constant collapses the per-
function f-string repetition. The route-handler endpoints themselves
still own the path string literals (keeping the builders identical
to the route declarations remains a checklist item, not a runtime
guarantee).

* Re-key asset-mirror manifest by (plugin, url) + dedup HTTP fetches

The manifest used to be keyed by URL alone, so two plugins in the
same marketplace referencing the same external image (a shared CDN
icon, a common cover) collided on entry.plugin_name — last writer
won. The DB row for the losing plugin then stored a served URL
pointing under the winning plugin's tree, and require_resource_access
denied legitimate access on one side and let the other plugin's user
reach the wrong asset.

In-memory: Dict[Tuple[str, str], MirrorEntry] keyed (plugin_name, url).
On disk: format flips from {url: entry} dict to [entry, ...] list of
self-describing entries (each carries plugin_name + url + the
previous fields). JSON keys can't be tuples; encoding 'plugin::url'
would just shift the parsing burden.

Phase 1 of sync_assets deduplicates fetches by URL — three plugins
sharing one URL share one HTTP request. The conditional-GET prior is
picked from any owning plugin's prior entry; if their etags diverge
(rare) we miss one 304 and pay for a full re-download instead.
Phase 2 still creates a per-(plugin, url) manifest entry pointing
under the plugin's own subdir, and Phase 3 cleanup is keyed the same
way so dropping a URL from one plugin's metadata doesn't disturb
another plugin still referencing it.

Body files stay per plugin (RBAC-clean isolation: deleting plugin A's
cache can't strand plugin B). Bandwidth saved by fetch dedup.

Consumer code re-keyed: src.marketplace._refresh_plugin_cache rebuilt
served_url_for / mirror_status as composite-keyed maps;
app.api.marketplace._resolve_external_via_mirror /
_curated_inner_cover / _curated_inner_enrichment look up by
(plugin_name, url).

Tests cover per-plugin manifest entries with shared URL, the single
HTTP fetch for N plugins, and Phase 3 drop-one-keep-other. All
existing tests migrated to composite key access; v2 list format
assertions verify on-disk shape.

* Migrate asset mirror from urllib.request to httpx

The asset mirror was the only HTTP call site in Agnes still using
urllib.request; every other module (CLI, Jira / OpenMetadata / OpenAI
connectors, scheduler, Telegram bot) already used httpx. The asset
mirror was added in this PR's base commit, so this is the only chance
to bring it into convention before someone copies it as 'the pattern
for HTTP fetches in Agnes'.

Three concrete benefits beyond consistency:

* SSRF defence collapses from five urllib classes
  (_PinnedHTTPConnection, _PinnedHTTPSConnection, _PinnedHTTPHandler,
  _PinnedHTTPSHandler, _SafeRedirectHandler) into one
  _SSRFGuardTransport. httpx invokes handle_request() on every redirect
  hop, so re-validation is free — we don't need a custom redirect
  handler at all.

* DNS-rebinding defence: the transport rewrites request.url.host to the
  SSRF-validated IP before delegating to super().handle_request().
  httpcore connects to whatever URL.host says, so this pins the
  connection without subclassing HTTPSConnection. The original hostname
  goes into the Host header + the sni_hostname extension so TLS / vhost
  routing still bind to the curator-supplied hostname.

* Error handling: one httpx.HTTPError catch-all for transport errors,
  plus specific httpx.TimeoutException / httpx.TooManyRedirects branches
  for clearer diagnostics. Matches the _translate_transport_error shape
  in cli/client.py.

The shared httpx.Client is built lazily at module load (same pattern as
cli/client.py:_get_shared_client) with follow_redirects=True,
max_redirects=5, timeout=HTTP_TIMEOUT_SEC, and our custom transport.

Externally observable behaviour is unchanged: same FetchOutcome
statuses, same manifest format, same conditional GET semantics, same
body-size cap.

Tests migrated from urllib-shaped fakes to httpx-shaped (status_code,
iter_bytes, context manager). Five urllib-specific tests replaced with
httpx equivalents — three transport unit tests + one DNS-rebinding
integration test that verifies host rewrite via monkey-patched
super().handle_request. One test deleted without replacement
(unwrap-URLError-wrapping-an-_UnsafeRedirectError — urllib-specific,
not applicable to httpx).

* Surface curated agnes-metadata enrichment on My Stack tab

GET /api/marketplace/items?tab=my built each curated row from the
on-disk marketplace.json by way of resolve_allowed_plugins, which
doesn't carry the agnes-metadata enrichment columns
(cover_photo_url, video_url, category override, doc_links). The
handler then hard-coded cover_photo_url=None on the synthetic row.
Result: once a user clicked '+ Add to my stack' on a curated card,
the same plugin in tab=my rendered with the gradient placeholder
instead of its cover photo — confusing parity break vs. the curated
tab where the same row goes through MarketplacePluginsRepository
and gets the enriched columns.

Pre-load the enriched marketplace_plugins rows for every marketplace
the user is subscribed to, then look each granted+subscribed plugin
up by (marketplace_id, plugin_name). Fall back to the on-disk
synthetic shape only when the DB row is missing — happens during
the rare race where RBAC is granted before the first sync cycle
ingests the plugin. RBAC gating (granted set from
resolve_allowed_plugins) is unchanged so this fix can't widen
visibility; it just upgrades the data shape behind cards the user
was already going to see.

Per-marketplace list_for_marketplace beats N gets — typical user is
subscribed to <5 marketplaces, so this is at most a handful of
queries vs. one per subscribed plugin.

Regression test seeds a plugin with cover_photo_url + category
override, subscribes the user, hits /api/marketplace/items?tab=my,
and asserts photo_url + category come through. The misleading
'fall through to gradient until the user re-visits the curated tab'
comment is gone.

---------

Co-authored-by: Minas Arustamyan <arustamyan.minas@gmail.com>
2026-05-09 17:01:37 +02:00

909 lines
39 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 %}Marketplace — {{ config.INSTANCE_NAME }}{% endblock %}
{% block content %}
<style>
.mp-page {
--surface: #ffffff;
--primary-light: rgba(0, 115, 209, 0.12);
--border-light: #eceff1;
--text-primary: #202124;
--text-secondary: #5f6368;
--success-color: #10b77f;
--warn-color: #b45309;
--font-medium: 500;
--font-semibold: 600;
--font-bold: 700;
--font-primary: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
/* ── Hero with integrated search ─────────────────────────────────── */
/* Layout split: cover image is anchored absolutely to the right edge
of the hero (z-index 1) so it doesn't claim layout space; the text
content sits on top (z-index 2) and uses the hero's full width.
`overflow: hidden` clips the cover to the hero's rounded corners. */
.mp-hero {
position: relative;
overflow: hidden;
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;
}
.mp-hero-content {
position: relative;
z-index: 2;
}
.mp-hero-cover {
position: absolute;
right: 0;
top: 18px;
bottom: 0;
width: 46%;
object-fit: cover;
z-index: 1;
}
@media (max-width: 900px) {
.mp-hero-cover { display: none; }
}
.mp-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;
}
.mp-hero h1 { margin: 0; font-size: 28px; font-weight: 700; letter-spacing: -0.4px; }
.mp-hero .sub { margin: 6px 0 0; font-size: 14px; color: rgba(255,255,255,0.85); }
.mp-hero .search-row {
display: flex; align-items: stretch;
margin-top: 18px;
background: #fff;
border-radius: 10px;
padding: 4px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
max-width: 760px;
}
.mp-hero .search-wrap { flex: 1; position: relative; min-width: 0; }
.mp-hero input[type="search"] {
width: 100%;
padding: 11px 14px 11px 40px;
border: none; border-radius: 8px;
font-size: 14px; font-family: var(--font-primary);
background: transparent; color: var(--text-primary); outline: none;
}
.mp-hero input[type="search"]::placeholder { color: var(--text-secondary); opacity: 0.75; }
.mp-hero .search-icon {
position: absolute; left: 14px; top: 50%; transform: translateY(-50%);
width: 16px; height: 16px; color: var(--text-secondary); pointer-events: none;
}
.mp-hero .search-btn {
appearance: none; border: none; background: var(--primary); color: #fff;
padding: 0 24px; border-radius: 8px;
font-size: 13px; font-weight: 600; font-family: var(--font-primary);
cursor: pointer; flex-shrink: 0;
transition: background 0.15s ease;
}
.mp-hero .search-btn:hover { background: var(--primary-dark); }
.mp-hero .search-btn:active { transform: scale(0.98); }
.mp-hero .scope {
display: flex; gap: 6px; align-items: center;
margin-top: 10px; font-size: 12px;
}
.mp-hero .scope-label {
font-weight: 600; text-transform: uppercase; letter-spacing: 0.6px;
font-size: 11px; color: rgba(255,255,255,0.7); margin-right: 4px;
}
.mp-hero .scope label {
display: inline-flex; align-items: center; gap: 6px; cursor: pointer;
user-select: none; padding: 4px 10px; border-radius: 6px; color: #fff;
}
.mp-hero .scope label:hover { background: rgba(255,255,255,0.12); }
.mp-hero .scope input[type="checkbox"] { accent-color: #fff; }
/* ── Tabs row + actions ──────────────────────────────────────────── */
.mp-tabs-row {
display: flex; align-items: center; justify-content: space-between;
gap: 16px; margin-bottom: 16px; flex-wrap: wrap;
}
.mp-tabs {
display: flex; gap: 4px; align-items: center;
background: var(--surface); border: 1px solid var(--border);
border-radius: 10px; padding: 4px;
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
}
.mp-tabs button {
appearance: none; border: none; background: transparent;
color: var(--text-secondary);
padding: 8px 16px; border-radius: 7px;
font-size: 13px; font-weight: 500; cursor: pointer;
font-family: var(--font-primary);
display: inline-flex; align-items: center; gap: 8px;
transition: all 0.15s ease;
}
.mp-tabs button .tab-icon {
width: 16px; height: 16px; flex-shrink: 0;
}
/* Per-tab icon tint reuses the established palette: blue = curated/
vetted, purple = flea/community, amber = "in your stack" (mirrors
the In Stack badge on installed cards). The active-tab override
flips icons to white so they read against the blue active fill. */
.mp-tabs button[data-tab="curated"] .tab-icon { color: #0073D1; }
.mp-tabs button[data-tab="flea"] .tab-icon { color: #6D28D9; }
.mp-tabs button[data-tab="my"] .tab-icon { color: #F59F0A; }
.mp-tabs button.is-active .tab-icon { color: #fff; }
.mp-tabs button:hover { color: var(--text-primary); background: var(--bg); }
.mp-tabs button.is-active { background: var(--primary); color: #fff; }
.mp-tabs button .count {
display: inline-flex; align-items: center; justify-content: center;
min-width: 22px; height: 18px; padding: 0 6px; border-radius: 9px;
background: rgba(0,0,0,0.08); color: inherit;
font-size: 11px; font-weight: 600;
}
.mp-tabs button.is-active .count { background: rgba(255,255,255,0.2); }
.mp-actions { display: flex; gap: 8px; align-items: center; }
.mp-actions .btn {
appearance: none;
display: inline-flex; align-items: center; gap: 6px;
padding: 8px 14px;
border: 1px solid var(--border); background: var(--surface);
color: var(--text-primary); border-radius: 8px;
font-size: 13px; font-weight: 500; cursor: pointer;
font-family: var(--font-primary); text-decoration: none;
transition: all 0.15s ease;
}
.mp-actions .btn:hover { border-color: var(--primary); color: var(--primary); }
.mp-actions .btn.primary {
background: var(--primary); color: #fff; border-color: var(--primary);
}
.mp-actions .btn.primary:hover {
background: var(--primary-dark); border-color: var(--primary-dark); color: #fff;
}
/* ── Info block (curated trust badge + flea open-shelf signal) ───── */
.mp-curator-block {
background: var(--surface);
border: 1px solid var(--border);
border-left: 3px solid var(--primary);
border-radius: 10px;
padding: 14px 18px;
margin-bottom: 16px;
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
display: flex; gap: 16px; align-items: flex-start; flex-wrap: wrap;
}
/* Flea variant — purple accent matches the flea badge color used on
/marketplace cards and detail pages so the two info blocks read as
visually distinct (blue = curated/vetted, purple = flea/community).
The same purple is applied to the trailing link so it reads as part
of the flea block, not a stray curated-blue accent. */
.mp-curator-block.is-flea { border-left-color: #6D28D9; }
.mp-curator-block.is-flea .link { color: #6D28D9; }
/* My Stack variant — neutral slate. Not a trust/community signal like
the other two; it's the user's personal shelf, so the accent stays
intentionally non-branded so the user's own content is the focus. */
.mp-curator-block.is-mystack { border-left-color: #64748B; }
.mp-curator-block .text { flex: 1; min-width: 240px; }
.mp-curator-block .title {
font-size: 13px; font-weight: 600; color: var(--text-primary); margin-bottom: 4px;
}
.mp-curator-block .body { font-size: 12px; color: var(--text-secondary); line-height: 1.5; }
.mp-curator-block .link {
font-size: 12px; color: var(--primary); font-weight: 500;
white-space: nowrap; align-self: center;
}
/* ── Filter row ──────────────────────────────────────────────────── */
.mp-filter-row {
display: flex; gap: 8px; align-items: center; flex-wrap: wrap;
margin-bottom: 12px;
}
.mp-filter-row .pill {
appearance: none; border: 1px solid var(--border);
background: var(--surface); color: var(--text-primary);
padding: 7px 12px; border-radius: 999px;
font-size: 13px; font-weight: 500; cursor: pointer;
font-family: var(--font-primary);
display: inline-flex; align-items: center; gap: 6px;
transition: all 0.15s ease;
}
.mp-filter-row .pill svg { width: 14px; height: 14px; flex-shrink: 0; }
.mp-filter-row .pill:hover { border-color: var(--primary); color: var(--primary); }
.mp-filter-row .pill.is-active {
background: var(--primary-light); color: var(--primary); border-color: var(--primary);
}
.mp-filter-row .pill .count { color: var(--text-secondary); font-weight: 500; margin-left: 2px; }
.mp-filter-row .pill.is-active .count { color: var(--primary); }
.mp-type-row {
display: flex; gap: 6px; align-items: center; margin-bottom: 24px;
}
.mp-type-row .pill {
appearance: none; border: 1px solid var(--border);
background: var(--surface); color: var(--text-primary);
padding: 6px 12px; border-radius: 8px;
font-size: 12px; font-weight: 500; cursor: pointer;
font-family: var(--font-primary);
transition: all 0.15s ease;
}
.mp-type-row .pill:hover { border-color: var(--primary); color: var(--primary); }
.mp-type-row .pill.is-active {
background: var(--primary); color: #fff; border-color: var(--primary);
}
/* ── Card grid ───────────────────────────────────────────────────── */
.mp-grid {
display: grid; gap: 16px;
grid-template-columns: repeat(4, minmax(0, 1fr));
}
@media (max-width: 1100px) { .mp-grid { grid-template-columns: repeat(3, minmax(0,1fr)); } }
@media (max-width: 820px) { .mp-grid { grid-template-columns: repeat(2, minmax(0,1fr)); } }
@media (max-width: 540px) { .mp-grid { grid-template-columns: 1fr; } }
.mp-card {
position: relative;
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; cursor: pointer;
transition: all 0.15s ease;
/* Card is an <a> so middle-click / ctrl-click open in a new tab; reset
the link defaults so the styling matches the surrounding panel. */
text-decoration: none;
color: inherit;
}
.mp-card:hover {
border-color: var(--primary);
box-shadow: 0 6px 20px rgba(0, 115, 209, 0.12);
transform: translateY(-2px);
}
.mp-card .photo {
width: 100%; height: 120px;
display: flex; align-items: center; justify-content: center;
background: linear-gradient(135deg, var(--primary-light) 0%, #fce7f3 100%);
color: var(--primary);
font-size: 26px; font-weight: var(--font-bold); letter-spacing: 0.5px;
}
.mp-card .photo img {
width: 100%; height: 100%; object-fit: cover;
}
.mp-card.is-installed { border-color: rgba(245, 159, 10, 0.55); }
/* Solid filled pill in amber — signals "selected / in your stack"
while staying readable on both the white card photo placeholder
and any uploaded cover image. Card border matches at low opacity
so the two installed-state cues read as a pair. */
.mp-card .installed-badge {
position: absolute; top: 10px; right: 10px;
display: inline-flex; align-items: center; gap: 4px;
padding: 4px 10px; border-radius: 999px;
background: #f59f0a; color: #fff;
font-size: 11px; font-weight: var(--font-semibold);
letter-spacing: 0.2px;
border: 1px solid rgba(255, 255, 255, 0.55);
}
.mp-card .body {
padding: 14px 16px 12px; flex: 1;
display: flex; flex-direction: column; gap: 6px;
}
.mp-card .badges { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
.mp-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;
}
.mp-card .type-badge[data-type="plugin"] { background: rgba(0, 115, 209, 0.12); color: #0056A3; }
.mp-card .type-badge[data-type="skill"] { background: rgba(16, 183, 127, 0.14); color: #0e9b6a; }
.mp-card .type-badge[data-type="agent"] { background: rgba(124, 58, 237, 0.14); color: #6d28d9; }
.mp-card .cat-badge {
font-size: 10px; color: var(--text-secondary);
border: 1px solid var(--border); border-radius: 4px;
padding: 2px 7px;
text-transform: uppercase; letter-spacing: 0.4px; font-weight: 500;
}
.mp-card .name {
font-weight: var(--font-semibold); color: var(--text-primary);
font-size: 15px; line-height: 1.3;
}
.mp-card .by {
font-size: 11px; color: var(--text-secondary); margin-top: -2px;
}
.mp-card .by .todo { color: var(--warn-color); font-style: italic; }
.mp-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;
}
.mp-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);
}
.mp-card .ver { font-variant-numeric: tabular-nums; }
/* ── Pager + empty state ─────────────────────────────────────────── */
.mp-pager { display: flex; gap: 6px; justify-content: center; margin: 32px 0 0; }
.mp-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;
}
.mp-pager button:hover { border-color: var(--primary); color: var(--primary); }
.mp-pager button.is-active {
background: var(--primary); color: #fff; border-color: var(--primary);
}
.mp-pager .ellipsis {
display: inline-flex; align-items: center; padding: 0 4px;
color: var(--text-secondary); font-size: 13px;
}
.mp-empty {
text-align: center; padding: 56px 24px;
color: var(--text-secondary); font-size: 14px;
background: var(--surface);
border: 1px dashed var(--border); border-radius: 12px;
}
.mp-empty h3 {
margin: 0 0 8px; color: var(--text-primary); font-size: 16px; font-weight: 600;
}
.mp-empty .cta { margin-top: 16px; }
.mp-empty .cta a {
display: inline-block; margin: 0 6px;
color: var(--primary); font-weight: 500; text-decoration: none;
}
.mp-empty .cta a:hover { text-decoration: underline; }
[hidden] { display: none !important; }
</style>
<script id="category-icons-data" type="application/json">{{ category_icons_json | safe }}</script>
<div class="mp-page page-shell">
<!-- Hero with integrated search -->
<div class="mp-hero">
<div class="mp-hero-content">
<div class="eyebrow">Marketplace</div>
<h1>Plugin Marketplace</h1>
<p class="sub">Discover AI tools — from curated catalogs and the community.</p>
<div class="search-row">
<div class="search-wrap">
<svg class="search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/>
</svg>
<input id="mp-search" type="search"
placeholder="Search plugins, skills, agents — by name, description, author…">
</div>
<button class="search-btn" id="mp-search-btn" type="button">Search</button>
</div>
<div class="scope">
<span class="scope-label">Search in:</span>
<label><input type="checkbox" id="mp-scope-curated"> Curated</label>
<label><input type="checkbox" id="mp-scope-flea"> Flea Market</label>
</div>
</div>
<img class="mp-hero-cover" src="/static/marketplace-cover.png"
alt="" aria-hidden="true">
</div>
<!-- Tabs + actions -->
<div class="mp-tabs-row">
<div class="mp-tabs" role="tablist">
{# Tab icons (Heroicons outline 24×24): shield-check signals trust/vetting
for curated, building-storefront signals an open shelf for flea, and
rectangle-stack signals the user's personal collection for My Stack. #}
<button class="is-active" data-tab="curated" role="tab" aria-selected="true">
<svg class="tab-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M9 12.75 11.25 15 15 9.75m-3-7.036A11.959 11.959 0 0 1 3.598 6 11.99 11.99 0 0 0 3 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285Z"/>
</svg>
Curated Marketplace <span class="count" data-count-curated>0</span>
</button>
<button data-tab="flea" role="tab" aria-selected="false">
<svg class="tab-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M13.5 21v-7.5a.75.75 0 0 1 .75-.75h3a.75.75 0 0 1 .75.75V21m-4.5 0H2.36m11.14 0H18m0 0h3.64m-1.39 0V9.349M3.75 21V9.349m0 0a3.001 3.001 0 0 0 3.75-.615A2.993 2.993 0 0 0 9.75 9.75c.896 0 1.7-.393 2.25-1.016a2.993 2.993 0 0 0 2.25 1.016c.896 0 1.7-.393 2.25-1.016a3.001 3.001 0 0 0 3.75.614m-16.5 0a3.004 3.004 0 0 1-.621-4.72L4.318 3.44A1.5 1.5 0 0 1 5.378 3h13.243a1.5 1.5 0 0 1 1.06.44l1.19 1.189a3 3 0 0 1-.621 4.72M6.75 18h3.75a.75.75 0 0 0 .75-.75V13.5a.75.75 0 0 0-.75-.75H6.75a.75.75 0 0 0-.75.75v3.75c0 .414.336.75.75.75Z"/>
</svg>
Flea Market <span class="count" data-count-flea>0</span>
</button>
<button data-tab="my" role="tab" aria-selected="false">
<svg class="tab-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M6.429 9.75 2.25 12l4.179 2.25m0-4.5 5.571 3 5.571-3m-11.142 0L2.25 7.5 12 2.25l9.75 5.25-4.179 2.25m0 0L21.75 12l-4.179 2.25m0 0 4.179 2.25L12 21.75 2.25 16.5l4.179-2.25m11.142 0-5.571 3-5.571-3"/>
</svg>
My Stack <span class="count" data-count-my>0</span>
</button>
</div>
<div class="mp-actions">
<a class="btn" data-actions-for="curated" href="/marketplace/guide/curated">Submit a plugin</a>
<!-- Flea has a self-service +Upload button below — no second
"how to" CTA needed. Anyone uploads via the form directly. -->
<a class="btn primary" data-actions-for="flea" href="/store/new" hidden>+ Upload</a>
</div>
</div>
<!-- Curator info block (Curated tab only) — trust badge, not onboarding.
Goal: signal "someone is accountable for what's in here", without
prescribing the process (teams may or may not have a curator; we
don't want the copy to imply a structure the org doesn't follow). -->
<div class="mp-curator-block" data-show-on="curated">
<div class="text">
<div class="title">Each plugin here has a named curator accountable for it.</div>
<div class="body">Each plugin in this marketplace has a named curator and meets a baseline review bar (security, telemetry hygiene, documentation).</div>
</div>
<a class="link" href="#">See all curators →</a>
</div>
<!-- Flea Market info block — open-shelf signal, mirror structure of
the curated block. The trailing "Tips for sharing" link points at
the publication guide; the +Upload button in the actions row above
handles the direct CTA, so this link is informational, not a
second action. Purple left-border + link colour distinguishes the
block from curated. -->
<div class="mp-curator-block is-flea" data-show-on="flea">
<div class="text">
<div class="title">Anyone in the company can upload here.</div>
<div class="body">The Flea Market is the community shelf — anyone can share a skill, agent, or plugin. Browse what colleagues have built, or upload something new.</div>
</div>
<a class="link" href="/marketplace/guide/flea">Tips for sharing →</a>
</div>
<!-- My Stack info block — personal-shelf orientation. Answers two
questions a non-technical user lands here with: "what is this?"
(their own collected items from Curated + Flea) and "how does
this connect to Claude Code?" (auto-sync at next session). No
trailing link — the user is on their own tab, navigating away
would be a distraction; the cards themselves are the action. -->
<div class="mp-curator-block is-mystack" data-show-on="my">
<div class="text">
<div class="title">Your AI stack — everything youve added.</div>
<div class="body">Plugins, skills, and agents youve picked from Curated or the Flea Market all live here. They sync to Claude Code on your laptop automatically the next time you start a session.</div>
</div>
</div>
<!-- Filter row: categories -->
<div class="mp-filter-row" id="mp-cat-row"></div>
<!-- Type filter (Flea-only) -->
<div class="mp-type-row" id="mp-type-row" hidden>
<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>
</div>
<!-- Most Popular section: hidden until telemetry exists.
<div class="mp-section-header"><h2>Most Popular</h2><div class="meta">Last 30 days</div></div>
<div class="mp-grid">…8 cards…</div>
-->
<!-- Main grid -->
<div class="mp-grid" id="mp-grid"></div>
<div class="mp-empty" id="mp-empty" hidden>
<h3 id="mp-empty-title">Nothing here yet</h3>
<p id="mp-empty-body"></p>
<div class="cta" id="mp-empty-cta"></div>
</div>
<div class="mp-pager" id="mp-pager"></div>
</div>
<script>
'use strict';
const ICON_PATHS = JSON.parse(document.getElementById('category-icons-data').textContent);
const PAGE_SIZE = 24;
const state = {
tab: 'curated',
q: '',
category: null,
type: null,
page: 1,
scope: { curated: true, flea: true },
};
// ── URL state ────────────────────────────────────────────────────────────
function loadFromURL() {
const p = new URLSearchParams(window.location.search);
const tab = p.get('tab');
state.tab = (tab === 'flea' || tab === 'my') ? tab : 'curated';
state.q = p.get('q') || '';
state.category = p.get('category') || null;
state.type = p.get('type') || null;
state.page = Math.max(1, parseInt(p.get('page') || '1', 10));
}
function syncURL() {
const u = new URL(window.location.href);
u.searchParams.set('tab', state.tab);
['q','category','type'].forEach(k => {
if (state[k]) u.searchParams.set(k, state[k]);
else u.searchParams.delete(k);
});
if (state.page > 1) u.searchParams.set('page', String(state.page));
else u.searchParams.delete('page');
window.history.replaceState({}, '', u);
}
// ── DOM helpers ──────────────────────────────────────────────────────────
function esc(s) {
return String(s ?? '').replace(/[&<>"']/g, ch => (
{'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[ch]
));
}
function typeIcon(t) {
return t === 'skill' ? 'SK' : t === 'agent' ? 'AG' : t === 'plugin' ? 'PL' : '?';
}
function categorySVG(category) {
const path = ICON_PATHS[category || 'Other'] || ICON_PATHS['Other'] || '';
return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">${path}</svg>`;
}
// ── Category filter ──────────────────────────────────────────────────────
function renderCategories(items) {
const row = document.getElementById('mp-cat-row');
row.innerHTML = '';
const allBtn = document.createElement('button');
allBtn.className = 'pill' + (!state.category ? ' is-active' : '');
allBtn.dataset.cat = '';
allBtn.textContent = 'All';
allBtn.addEventListener('click', () => {
state.category = null; state.page = 1; syncURL();
renderCategories(items); loadItems();
});
row.appendChild(allBtn);
for (const c of items) {
const btn = document.createElement('button');
btn.className = 'pill' + (state.category === c.name ? ' is-active' : '');
btn.dataset.cat = c.name;
btn.innerHTML = `${categorySVG(c.name)} ${esc(c.name)} <span class="count">${c.count}</span>`;
btn.addEventListener('click', () => {
state.category = c.name; state.page = 1; syncURL();
renderCategories(items); loadItems();
});
row.appendChild(btn);
}
}
async function loadCategories() {
const params = new URLSearchParams({ tab: state.tab });
if (state.tab === 'flea' && state.type) params.set('type', state.type);
try {
const res = await fetch('/api/marketplace/categories?' + params);
if (!res.ok) { renderCategories([]); return; }
const data = await res.json();
renderCategories(data.items || []);
} catch (e) {
renderCategories([]);
}
}
// ── Items grid ───────────────────────────────────────────────────────────
function renderGrid(items) {
const grid = document.getElementById('mp-grid');
const empty = document.getElementById('mp-empty');
grid.innerHTML = '';
if (!items || !items.length) {
grid.hidden = true;
empty.hidden = false;
fillEmptyState();
return;
}
grid.hidden = false; empty.hidden = true;
for (const it of items) {
const card = document.createElement('a');
card.className = 'mp-card' + (it.installed ? ' is-installed' : '');
card.href = it.detail_url;
// Cover photo with broken-image fallback. When the <img> 404s (e.g. an
// agnes-metadata.json `cover_photo` references a file the curator forgot
// to commit, or an external mirror failed), browsers paint their default
// broken-image icon — which looks worse than the gradient placeholder we
// render when no cover_photo_url is set at all. The `onerror` swaps the
// parent's content for the same fallback initials (PL/SK/AG) so the card
// looks identical to the no-photo case.
const fallbackInitials = esc(typeIcon(it.type));
const photoMarkup = it.photo_url
? `<div class="photo"><img src="${esc(it.photo_url)}" alt=""
onerror="this.parentElement.classList.add('photo-failed');
this.parentElement.textContent=this.dataset.fallback;"
data-fallback="${fallbackInitials}"></div>`
: `<div class="photo">${fallbackInitials}</div>`;
const installedBadge = it.installed
? `<div class="installed-badge" title="In your stack">
<svg width="11" height="11" 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>
In stack
</div>` : '';
// v32+ quarantine: subtle corner badge on the submitter's own
// non-approved cards. Approved cards + non-owner views omit it.
let quarantineBadge = '';
if (it.is_viewer_owner && it.visibility_status && it.visibility_status !== 'approved') {
const isPending = it.visibility_status === 'pending'
|| it.visibility_status === 'pending_inline'
|| it.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 class="quarantine-badge"
title="Visible only to you. ${isPending ? 'Checks in progress.' : 'Failed automated/security checks — open the detail page to see why.'}"
style="position:absolute;top:8px;left:8px;padding:3px 9px;border-radius:999px;font-size:11px;font-weight:600;letter-spacing:0.2px;${palette}z-index:2;">${label}</div>`;
}
let ownerLine;
if (it.source === 'curated') {
const ownerVal = it.owner === 'owner_todo'
? `<span class="todo">owner_todo</span>`
: `<span>${esc(it.owner || '')}</span>`;
const mp = it.marketplace_name ? `via <strong>${esc(it.marketplace_name)}</strong> · ` : '';
ownerLine = `${mp}${ownerVal}`;
} else {
ownerLine = `by <strong>${esc(it.owner || '')}</strong>`;
}
const addedFmt = it.added ? new Date(it.added).toISOString().slice(0,10) : '';
const verFmt = it.version ? `v${esc(it.version)}` : '';
const catBadge = it.category ? `<span class="cat-badge">${esc(it.category)}</span>` : '';
card.innerHTML = `
${quarantineBadge}
${photoMarkup}
${installedBadge}
<div class="body">
<div class="badges">
<span class="type-badge" data-type="${esc(it.type)}">${esc(it.type)}</span>
${catBadge}
</div>
<div class="name">${esc(it.name)}</div>
<div class="by">${ownerLine}</div>
<div class="desc">${esc(it.description || '')}</div>
</div>
<div class="footer">
<span class="added">${addedFmt ? 'Added ' + addedFmt : ''}</span>
<span class="ver">${verFmt}</span>
</div>`;
grid.appendChild(card);
}
}
function fillEmptyState() {
const t = document.getElementById('mp-empty-title');
const b = document.getElementById('mp-empty-body');
const c = document.getElementById('mp-empty-cta');
if (state.q) {
t.textContent = `Nothing matches "${state.q}"`;
b.textContent = '';
c.innerHTML = '';
return;
}
if (state.tab === 'curated') {
t.textContent = 'No curated plugins available.';
b.textContent = 'Ask your admin to grant your group access.';
c.innerHTML = `<a href="/marketplace/guide/curated">Submit a plugin →</a>`;
} else if (state.tab === 'flea') {
t.textContent = 'No community entities yet.';
b.textContent = 'Be the first to share something.';
c.innerHTML = `<a href="/store/new">+ Upload</a>`;
} else {
t.textContent = 'My Stack';
b.textContent = "Plugins you've added to your stack will show up here. Browse the Curated Marketplace or Flea Market and click “Add to my stack”.";
c.innerHTML = `<a href="/marketplace?tab=curated">Browse Curated →</a><a href="/marketplace?tab=flea">Browse Flea Market →</a>`;
}
}
// ── Pager ────────────────────────────────────────────────────────────────
function renderPager(total) {
const pager = document.getElementById('mp-pager');
pager.innerHTML = '';
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
if (totalPages <= 1) return;
const mk = (label, page, active, isEll) => {
if (isEll) {
const s = document.createElement('span');
s.className = 'ellipsis'; s.textContent = '…'; return s;
}
const b = document.createElement('button');
b.textContent = label;
if (active) b.classList.add('is-active');
b.addEventListener('click', () => {
state.page = page; syncURL(); loadItems();
window.scrollTo({ top: 0, behavior: 'smooth' });
});
return b;
};
if (state.page > 1) pager.appendChild(mk('', state.page - 1, false));
// Compact pagination: 1 … (cur-1) cur (cur+1) … last
const pages = new Set([1, totalPages, state.page, state.page-1, state.page+1, 2, totalPages-1]);
const sortedPages = [...pages].filter(p => p >= 1 && p <= totalPages).sort((a,b) => a-b);
let prev = 0;
for (const p of sortedPages) {
if (p - prev > 1) pager.appendChild(mk('', 0, false, true));
pager.appendChild(mk(String(p), p, p === state.page));
prev = p;
}
if (state.page < totalPages) pager.appendChild(mk('', state.page + 1, false));
}
// ── Tab counts (separate cheap fetches) ──────────────────────────────────
async function loadTabCounts() {
const fetchCount = async (tab) => {
try {
const res = await fetch('/api/marketplace/items?tab=' + tab + '&page_size=1');
if (!res.ok) return 0;
const data = await res.json();
return data.total || 0;
} catch { return 0; }
};
const [c, f, m] = await Promise.all([
fetchCount('curated'), fetchCount('flea'), fetchCount('my'),
]);
document.querySelector('[data-count-curated]').textContent = c;
document.querySelector('[data-count-flea]').textContent = f;
document.querySelector('[data-count-my]').textContent = m;
}
// ── Items loader ─────────────────────────────────────────────────────────
async function loadItems() {
// Search-results mode: when user is searching with both scopes checked,
// we fan out two requests (one per scope) and merge client-side. With
// exactly one scope checked we just hit the matching tab.
if (state.q) {
const wantC = document.getElementById('mp-scope-curated').checked;
const wantF = document.getElementById('mp-scope-flea').checked;
if (!wantC && !wantF) {
document.getElementById('mp-scope-curated').checked = true;
state.scope.curated = true;
}
const fetches = [];
if (wantC) fetches.push(fetchOne('curated'));
if (wantF) fetches.push(fetchOne('flea'));
const results = await Promise.all(fetches);
const merged = [];
let total = 0;
for (const r of results) {
merged.push(...(r.items || []));
total += r.total || 0;
}
// Client-side paginate the merged set: backend already paginated each
// half so this is an approximate combined view, sufficient for search.
renderGrid(merged);
renderPager(total);
return;
}
const r = await fetchOne(state.tab);
renderGrid(r.items || []);
renderPager(r.total || 0);
}
async function fetchOne(tab) {
const params = new URLSearchParams({
tab,
page: String(state.page),
page_size: String(PAGE_SIZE),
});
if (state.q) params.set('q', state.q);
if (state.category) params.set('category', state.category);
if (tab === 'flea' && state.type) params.set('type', state.type);
try {
const res = await fetch('/api/marketplace/items?' + params);
if (!res.ok) return { items: [], total: 0 };
return await res.json();
} catch { return { items: [], total: 0 }; }
}
// ── Tab switching + filter pills ─────────────────────────────────────────
function setTab(tab) {
state.tab = tab;
state.category = null;
state.type = null;
state.page = 1;
// Sync scope checkboxes with the active tab (user can manually flip back).
document.getElementById('mp-scope-curated').checked = (tab === 'curated');
document.getElementById('mp-scope-flea').checked = (tab === 'flea');
document.querySelectorAll('.mp-tabs button').forEach(b => {
const active = b.dataset.tab === tab;
b.classList.toggle('is-active', active);
b.setAttribute('aria-selected', active ? 'true' : 'false');
});
document.querySelectorAll('[data-actions-for]').forEach(a => {
a.hidden = a.dataset.actionsFor !== tab;
});
document.querySelectorAll('[data-show-on]').forEach(el => {
el.hidden = el.dataset.showOn !== tab;
});
document.getElementById('mp-type-row').hidden = (tab !== 'flea');
document.getElementById('mp-cat-row').hidden = (tab === 'my');
// Type filter pills always reset to 'All' on tab switch.
document.querySelectorAll('#mp-type-row .pill').forEach(p => {
p.classList.toggle('is-active', !p.dataset.type);
});
syncURL();
Promise.all([loadCategories(), loadItems()]);
}
// ── Boot ─────────────────────────────────────────────────────────────────
document.querySelectorAll('.mp-tabs button').forEach(b => {
b.addEventListener('click', () => setTab(b.dataset.tab));
});
document.querySelectorAll('#mp-type-row .pill').forEach(p => {
p.addEventListener('click', () => {
document.querySelectorAll('#mp-type-row .pill').forEach(x => x.classList.remove('is-active'));
p.classList.add('is-active');
state.type = p.dataset.type || null;
state.page = 1; syncURL();
Promise.all([loadCategories(), loadItems()]);
});
});
const searchInput = document.getElementById('mp-search');
const searchBtn = document.getElementById('mp-search-btn');
let searchTimer;
function runSearch() {
state.q = searchInput.value.trim();
state.page = 1;
syncURL();
loadItems();
}
searchInput.addEventListener('input', () => {
clearTimeout(searchTimer);
searchTimer = setTimeout(runSearch, 250);
});
searchInput.addEventListener('keydown', e => {
if (e.key === 'Enter') { clearTimeout(searchTimer); runSearch(); }
});
searchBtn.addEventListener('click', () => {
clearTimeout(searchTimer); runSearch();
});
document.getElementById('mp-scope-curated').addEventListener('change', () => {
if (state.q) loadItems();
});
document.getElementById('mp-scope-flea').addEventListener('change', () => {
if (state.q) loadItems();
});
(async function init() {
loadFromURL();
// Restore search input from URL state
if (state.q) document.getElementById('mp-search').value = state.q;
// Sync scope checkboxes with the active tab on first load — only the
// active tab's source is searched by default. User can flip the other
// checkbox manually to widen the scope.
document.getElementById('mp-scope-curated').checked = (state.tab === 'curated');
document.getElementById('mp-scope-flea').checked = (state.tab === 'flea');
// For tab='my' fall back to Curated checked so a search query still has
// a non-empty scope to match against.
if (state.tab === 'my') {
document.getElementById('mp-scope-curated').checked = true;
}
// Apply tab to UI
document.querySelectorAll('.mp-tabs button').forEach(b => {
const active = b.dataset.tab === state.tab;
b.classList.toggle('is-active', active);
b.setAttribute('aria-selected', active ? 'true' : 'false');
});
document.querySelectorAll('[data-actions-for]').forEach(a => {
a.hidden = a.dataset.actionsFor !== state.tab;
});
document.querySelectorAll('[data-show-on]').forEach(el => {
el.hidden = el.dataset.showOn !== state.tab;
});
document.getElementById('mp-type-row').hidden = (state.tab !== 'flea');
document.getElementById('mp-cat-row').hidden = (state.tab === 'my');
document.querySelectorAll('#mp-type-row .pill').forEach(p => {
p.classList.toggle('is-active', (p.dataset.type || null) === state.type);
});
await Promise.all([
loadTabCounts(),
loadCategories(),
loadItems(),
]);
})();
</script>
{% endblock %}