This repository has no description
2

Configure Feed

Select the types of activity you want to include in your feed.

ready for prod test

Robin Berjon (Jul 3, 2026, 9:37 AM +0200) f0eb5de9 b4b23969

+2824 -1
+5
.dockerignore
··· 1 + * 2 + !Caddyfile 3 + !frontend 4 + frontend/node_modules 5 + frontend/dist
+30
.env.example
··· 1 + # Copy to .env.local (local dev) and .env.prod (production), then edit. 2 + # `podium deploy` copies the right one to .env before running `sm deploy`: 3 + # docker compose reads .env for interpolation, and sm rsyncs it to the server. 4 + 5 + # Public URL of the HappyView instance (OAuth callbacks, dashboard links). 6 + # Local: the loopback port published by compose, so atproto OAuth loopback 7 + # login works without a publicly reachable URL. 8 + PODIUM_PUBLIC_URL=http://127.0.0.1:3300/hv 9 + # Prod: PODIUM_PUBLIC_URL=https://podium.mycopunk.it/hv 10 + 11 + # Session cookie signing secret, 64+ chars: `openssl rand -hex 48` 12 + PODIUM_SESSION_SECRET= 13 + 14 + # Where `podium push` sends lexicons and Lua scripts. 15 + PODIUM_HV_URL=http://127.0.0.1:3300/hv 16 + # Prod: PODIUM_HV_URL=https://podium.mycopunk.it/hv 17 + 18 + # Admin API key for `podium push`. Locally, `podium bootstrap <handle> --local` 19 + # fills this in; in prod create one in the HappyView dashboard (Settings > 20 + # API Keys — needs lexicons + scripts + settings + backfill permissions). 21 + PODIUM_HV_KEY= 22 + 23 + # Public origins HappyView should accept (it 421s unknown Hosts; the 24 + # PUBLIC_URL host is always accepted). Comma-separated. 25 + PODIUM_DOMAINS=https://podium.bast 26 + # Prod: PODIUM_DOMAINS=https://podium.mycopunk.it 27 + 28 + # Local only: host directory for service data (SQLite lives here). Must be 29 + # somewhere Docker Desktop can mount. The server sets this itself. 30 + SM_DATA_ROOT=/Users/robin/.supramundane/data
+23
Caddyfile
··· 1 + # Internal Caddy for the podium container — TLS terminates at the front proxy. 2 + :80 { 3 + encode zstd gzip 4 + 5 + # HappyView (dashboard, admin API, XRPC) mounted under /hv 6 + @hv path /hv /hv/* 7 + handle @hv { 8 + reverse_proxy podium-hv:3000 9 + } 10 + 11 + # Standard XRPC path for protocol-native consumers 12 + handle /xrpc/* { 13 + rewrite * /hv{uri} 14 + reverse_proxy podium-hv:3000 15 + } 16 + 17 + # The Podium frontend 18 + handle { 19 + root * /srv 20 + try_files {path} /index.html 21 + file_server 22 + } 23 + }
+10
Dockerfile
··· 1 + FROM node:24-alpine AS build 2 + WORKDIR /app 3 + COPY frontend/package.json frontend/package-lock.json ./ 4 + RUN npm ci 5 + COPY frontend/ ./ 6 + RUN npm run build 7 + 8 + FROM caddy:2-alpine 9 + COPY Caddyfile /etc/caddy/Caddyfile 10 + COPY --from=build /app/dist /srv
+129 -1
README.md
··· 1 - # podium 1 + 2 + # 🪩 Podium 3 + 4 + A search engine for open data published on the Atmosphere: [Matadisco](https://matadisco.org/) 5 + dataset records, [Astrosky](https://astrosky.eco/)/[Nebra](https://github.com/the-astrosky-ecosystem/nebra) 6 + transient astronomy events, and whatever else gets added over time. 7 + 8 + Live at `https://podium.mycopunk.it`. 9 + 10 + ## How it works 11 + 12 + ``` 13 + podium.mycopunk.it 14 + 15 + caddy-front ──────────── reverse_proxy http://podium:80 (supramundane network) 16 + 17 + ┌──────┴─────────────────────────────────────────────┐ 18 + │ podium (caddy) │ 19 + │ / → the frontend │ 20 + │ /xrpc/* → rewritten to /hv/xrpc/* │ 21 + │ /hv/* → podium-hv │ 22 + ├────────────────────────────────────────────────────┤ 23 + │ podium-hv (HappyView) │ 24 + │ indexes cx.vmx.matadisco + eco.astrosky.transient │ 25 + │ .gcn from Jetstream + backfill, SQLite on /data, │ 26 + │ serves XRPC, Lua query scripts, admin dashboard │ 27 + └────────────────────────────────────────────────────┘ 28 + ``` 29 + 30 + - **Backend** is [HappyView](https://happyview.dev/): a lexicon-driven AppView. 31 + The collections to index, Podium's own query lexicons 32 + (`it.mycopunk.podium.*`), and the Lua scripts that implement them all live in 33 + this repo (`backend/`, wired up by `podium.config.json`) and are pushed to 34 + the instance through its admin API — no pasting into dashboards. 35 + - **Frontend** is Lit 3 + [refrakt](https://github.com/gordonbrander/refrakt). 36 + - **Deployment** is a Supramundane service: `service.json` + 37 + a hand-authored multi-container `compose.yaml` on the `supramundane` network. 38 + HappyView's SQLite lives in `${SM_DATA_ROOT}/podium`, so it's backed up 39 + in prod. 40 + 41 + ## XRPC endpoints 42 + 43 + | Query | What it does | 44 + | --- | --- | 45 + | `it.mycopunk.podium.search?q=&collection=&limit=` | full-text search across collections (per-collection field lists in `backend/scripts/search.lua`) | 46 + | `it.mycopunk.podium.listRecords?collection=&limit=&cursor=` | freshest records, merged or per collection | 47 + | `it.mycopunk.podium.getRecord?uri=` | one full record by AT URI (GCN records keep their raw `data` here; list/search replace it with a `summary`) | 48 + | `it.mycopunk.podium.stats` | per-collection record counts | 49 + 50 + All available on both `/xrpc/…` and `/hv/xrpc/…`. 51 + 52 + ## The `podium` CLI 53 + 54 + `npm link` once (or call `node bin/podium.js`). Like `sm`, commands hit prod by 55 + default; add `--local` for the local stack. 56 + 57 + ```sh 58 + podium deploy [--local] [--no-push] # env file → .env, sm deploy, then push 59 + podium push [--local] [--watch] # sync domains, lexicons + Lua scripts to HappyView 60 + podium backfill [collection] [--local] # (re)start historical backfill 61 + podium bootstrap <handle> --local # first-boot: seed super user + API key (local only) 62 + podium status [--local] # what the instance currently has 63 + ``` 64 + 65 + `push --watch` re-pushes on every save under `backend/` — that's the dev loop 66 + for Lua/lexicon work. Frontend dev is `npm run dev` (Vite on :5173, proxying 67 + XRPC to the local HappyView on 127.0.0.1:3300). 68 + 69 + ## First boot 70 + 71 + **Local** (needs Docker, the `supramundane` network from a running 72 + caddy-front, and `.bast` DNS): 73 + 74 + ```sh 75 + cp .env.example .env.local # fill in PODIUM_SESSION_SECRET, check SM_DATA_ROOT 76 + podium deploy --local # brings the stack up (no push yet — no key) 77 + podium bootstrap robin.berjon.com --local # seeds super user + API key into .env.local 78 + podium push --local # domains, lexicons, scripts, kicks off backfill 79 + ``` 80 + 81 + The atproto OAuth dance can't run against a `.bast` domain (the PDS must fetch 82 + the client metadata), which is why local uses `bootstrap` (a direct SQLite 83 + seed, done with the container stopped) and `PODIUM_PUBLIC_URL` points at the 84 + loopback port. The dashboard is at `http://127.0.0.1:3300/hv/dashboard/`, and 85 + logging in there with the bootstrapped handle works because the seeded user 86 + *is* that identity. 87 + 88 + **Prod**: 89 + 90 + ```sh 91 + cp .env.example .env.prod # prod URLs, fresh PODIUM_SESSION_SECRET, no key yet 92 + podium deploy # rsyncs + builds on $SUPRAMUNDANE, wires the front 93 + # then in a browser: https://podium.mycopunk.it/hv → log in with your handle 94 + # (first login becomes super user) → Settings > API Keys → create a key with 95 + # lexicons/network-lexicons/scripts/settings/backfill permissions 96 + # put it in .env.prod as PODIUM_HV_KEY, then: 97 + podium push 98 + ``` 99 + 100 + ## Things learned the hard way 101 + 102 + - **HappyView 421s any Host it doesn't know.** Its primary domain comes from 103 + `PUBLIC_URL`; every other public origin (e.g. `https://podium.bast`) must be 104 + registered, which `podium push` does from `PODIUM_DOMAINS`. 105 + - **Don't touch the SQLite file while the container runs.** It's on a Docker 106 + bind mount; host-side writes against the live WAL corrupt it (this is why 107 + `bootstrap` stops the container first). If it happens anyway: stop the 108 + stack, delete `happyview.db*`, start, re-bootstrap, re-push. 109 + - **Re-POSTing a network lexicon is not idempotent** — it re-fetches and 110 + re-kicks sync machinery. `podium push` therefore only creates missing ones, 111 + and only auto-backfills on creation. 112 + - **The records in the wild don't all match the published lexicons.** 113 + Matadisco has (at least) three shapes: `resource`/`publishedAt`(/`tags`), 114 + and `metadata`/`created` with a text or image `preview`. Search fields and 115 + the result cards handle all of them; keep that in mind when adding sources. 116 + - GCN `data` payloads can be tens of KB, so list/search responses strip them 117 + server-side (`slim()` in the Lua scripts) and keep a human `summary` 118 + extracted from the event JSON. 119 + 120 + ## Adding a data source 121 + 122 + 1. Add its NSID to `networkLexicons` in `podium.config.json` (or a local 123 + lexicon file under `backend/lexicons/` if it isn't resolvable on-network). 124 + 2. Add a source entry (collection + searchable fields) to 125 + `backend/scripts/search.lua`, and to the `COLLECTIONS` lists in 126 + `listRecords.lua` / `stats.lua`. 127 + 3. Teach `frontend/src/components/podium-result-card.ts` how to render it 128 + (falls back to a generic card otherwise). 129 + 4. `podium push --local`, check, `podium push`.
+30
backend/lexicons/it.mycopunk.podium.getRecord.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "it.mycopunk.podium.getRecord", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Fetch a single indexed record (full, unslimmed) by AT URI.", 8 + "parameters": { 9 + "type": "params", 10 + "required": ["uri"], 11 + "properties": { 12 + "uri": { 13 + "type": "string", 14 + "format": "at-uri" 15 + } 16 + } 17 + }, 18 + "output": { 19 + "encoding": "application/json", 20 + "schema": { 21 + "type": "object", 22 + "required": ["record"], 23 + "properties": { 24 + "record": { "type": "unknown" } 25 + } 26 + } 27 + } 28 + } 29 + } 30 + }
+41
backend/lexicons/it.mycopunk.podium.listRecords.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "it.mycopunk.podium.listRecords", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Most recently indexed records, merged across collections or scoped to one (with cursor pagination).", 8 + "parameters": { 9 + "type": "params", 10 + "properties": { 11 + "collection": { 12 + "type": "string", 13 + "format": "nsid", 14 + "description": "Restrict to a single collection; enables cursor pagination" 15 + }, 16 + "limit": { 17 + "type": "integer", 18 + "minimum": 1, 19 + "maximum": 100, 20 + "default": 25 21 + }, 22 + "cursor": { "type": "string" } 23 + } 24 + }, 25 + "output": { 26 + "encoding": "application/json", 27 + "schema": { 28 + "type": "object", 29 + "required": ["results"], 30 + "properties": { 31 + "results": { 32 + "type": "array", 33 + "items": { "type": "unknown" } 34 + }, 35 + "cursor": { "type": "string" } 36 + } 37 + } 38 + } 39 + } 40 + } 41 + }
+45
backend/lexicons/it.mycopunk.podium.search.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "it.mycopunk.podium.search", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Full-text search across the collections Podium indexes.", 8 + "parameters": { 9 + "type": "params", 10 + "required": ["q"], 11 + "properties": { 12 + "q": { 13 + "type": "string", 14 + "description": "Search terms" 15 + }, 16 + "collection": { 17 + "type": "string", 18 + "format": "nsid", 19 + "description": "Restrict results to a single collection" 20 + }, 21 + "limit": { 22 + "type": "integer", 23 + "minimum": 1, 24 + "maximum": 100, 25 + "default": 25 26 + } 27 + } 28 + }, 29 + "output": { 30 + "encoding": "application/json", 31 + "schema": { 32 + "type": "object", 33 + "required": ["results"], 34 + "properties": { 35 + "q": { "type": "string" }, 36 + "results": { 37 + "type": "array", 38 + "items": { "type": "unknown" } 39 + } 40 + } 41 + } 42 + } 43 + } 44 + } 45 + }
+24
backend/lexicons/it.mycopunk.podium.stats.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "it.mycopunk.podium.stats", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Record counts for every collection Podium indexes.", 8 + "output": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["total", "collections"], 13 + "properties": { 14 + "total": { "type": "integer" }, 15 + "collections": { 16 + "type": "array", 17 + "items": { "type": "unknown" } 18 + } 19 + } 20 + } 21 + } 22 + } 23 + } 24 + }
+6
backend/scripts/getRecord.lua
··· 1 + function handle() 2 + if not params.uri then error("missing required parameter: uri") end 3 + local record = db.get(params.uri) 4 + if not record then error("record not found") end 5 + return { record = record } 6 + end
+55
backend/scripts/listRecords.lua
··· 1 + -- Recent records: per collection (with cursor) or merged across all of them. 2 + 3 + local COLLECTIONS = { "cx.vmx.matadisco", "eco.astrosky.transient.gcn" } 4 + 5 + -- GCN records carry the full raw event JSON, which can be huge; replace it 6 + -- with a short display summary (getRecord serves the full record). 7 + local function slim(collection, r) 8 + if collection == "eco.astrosky.transient.gcn" and r.data ~= nil then 9 + local ok, d = pcall(json.decode, r.data) 10 + if ok and type(d) == "table" then 11 + r.summary = d.subject 12 + or (d.superevent_id and ((d.alert_type and (d.alert_type .. " ") or "") .. d.superevent_id)) 13 + or d.id 14 + end 15 + r.data = nil 16 + r.hasData = true 17 + end 18 + return r 19 + end 20 + 21 + local function wrap(collection, res, out) 22 + for _, r in ipairs(res.records or {}) do 23 + out[#out + 1] = { 24 + uri = r.uri, 25 + collection = collection, 26 + indexedAt = r.indexedAt or r.indexed_at, 27 + record = slim(collection, r), 28 + } 29 + end 30 + end 31 + 32 + function handle() 33 + local limit = math.min(tonumber(params.limit) or 25, 100) 34 + 35 + if params.collection then 36 + local res = db.query({ collection = params.collection, limit = limit, cursor = params.cursor }) 37 + local out = {} 38 + wrap(params.collection, res, out) 39 + return { results = toarray(out), cursor = res.cursor } 40 + end 41 + 42 + -- Merged view: no cursor, just the freshest records across collections. 43 + local out = {} 44 + for _, c in ipairs(COLLECTIONS) do 45 + local ok, res = pcall(db.query, { collection = c, limit = limit }) 46 + if ok then 47 + wrap(c, res, out) 48 + else 49 + log("query failed on " .. c .. ": " .. tostring(res)) 50 + end 51 + end 52 + table.sort(out, function(a, b) return (a.indexedAt or "") > (b.indexedAt or "") end) 53 + while #out > limit do table.remove(out) end 54 + return { results = toarray(out) } 55 + end
+66
backend/scripts/search.lua
··· 1 + -- Cross-collection search. Each source lists the record fields worth 2 + -- searching (dot notation reaches into nested objects); hits are deduped 3 + -- by URI and slimmed for transport. 4 + -- 5 + -- Field choices reflect the records actually on the network, which have 6 + -- drifted from the published lexicons: most Matadisco producers use 7 + -- `metadata` + `preview.data` rather than `resource` + `tags`. 8 + 9 + local SOURCES = { 10 + { collection = "cx.vmx.matadisco", fields = { "tags", "resource", "metadata", "preview.data" } }, 11 + { collection = "eco.astrosky.transient.gcn", fields = { "topic", "data" } }, 12 + } 13 + 14 + -- GCN records carry the full raw event JSON, which can be huge; replace it 15 + -- with a short display summary (getRecord serves the full record). 16 + local function slim(collection, r) 17 + if collection == "eco.astrosky.transient.gcn" and r.data ~= nil then 18 + local ok, d = pcall(json.decode, r.data) 19 + if ok and type(d) == "table" then 20 + r.summary = d.subject 21 + or (d.superevent_id and ((d.alert_type and (d.alert_type .. " ") or "") .. d.superevent_id)) 22 + or d.id 23 + end 24 + r.data = nil 25 + r.hasData = true 26 + end 27 + return r 28 + end 29 + 30 + local function collect(out, seen, collection, res) 31 + if not res or not res.records then return end 32 + for _, r in ipairs(res.records) do 33 + if r.uri and not seen[r.uri] then 34 + seen[r.uri] = true 35 + out[#out + 1] = { uri = r.uri, collection = collection, record = slim(collection, r) } 36 + end 37 + end 38 + end 39 + 40 + function handle() 41 + local q = params.q 42 + if not q or #q == 0 then error("missing required parameter: q") end 43 + local limit = math.min(tonumber(params.limit) or 25, 100) 44 + 45 + local out, seen = {}, {} 46 + for _, src in ipairs(SOURCES) do 47 + if not params.collection or params.collection == src.collection then 48 + for _, field in ipairs(src.fields) do 49 + local ok, res = pcall(db.search, { 50 + collection = src.collection, 51 + field = field, 52 + query = q, 53 + limit = limit, 54 + }) 55 + if ok then 56 + collect(out, seen, src.collection, res) 57 + else 58 + log("search failed on " .. src.collection .. "." .. field .. ": " .. tostring(res)) 59 + end 60 + end 61 + end 62 + end 63 + 64 + while #out > limit do table.remove(out) end 65 + return { q = q, results = toarray(out) } 66 + end
+12
backend/scripts/stats.lua
··· 1 + local COLLECTIONS = { "cx.vmx.matadisco", "eco.astrosky.transient.gcn" } 2 + 3 + function handle() 4 + local out, total = {}, 0 5 + for _, c in ipairs(COLLECTIONS) do 6 + local ok, n = pcall(db.count, c) 7 + n = ok and n or 0 8 + out[#out + 1] = { collection = c, count = n } 9 + total = total + n 10 + end 11 + return { total = total, collections = toarray(out) } 12 + end
+320
bin/podium.js
··· 1 + #!/usr/bin/env node 2 + // Podium ops CLI. 3 + // 4 + // podium deploy [--local] [--no-push] env file → .env, sm deploy, then push 5 + // podium push [--local] [--watch] sync lexicons + Lua scripts to HappyView 6 + // podium status [--local] list what the instance currently has 7 + // 8 + // Like sm, commands target production by default; pass --local for the 9 + // local stack. Backend code lives in backend/ and podium.config.json is 10 + // the manifest that maps it onto the HappyView admin API. 11 + 12 + import { readFile, copyFile } from 'node:fs/promises'; 13 + import { watch } from 'node:fs'; 14 + import { spawnSync } from 'node:child_process'; 15 + import { resolve, dirname } from 'node:path'; 16 + import { fileURLToPath } from 'node:url'; 17 + import process from 'node:process'; 18 + 19 + const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); 20 + const args = process.argv.slice(2); 21 + const command = args.find((a) => !a.startsWith('-')); 22 + const flags = new Set(args.filter((a) => a.startsWith('-'))); 23 + const env = flags.has('--local') ? 'local' : 'prod'; 24 + 25 + const log = (msg) => console.log(msg); 26 + const fail = (msg) => { 27 + console.error(`podium: ${msg}`); 28 + process.exit(1); 29 + }; 30 + 31 + async function loadEnvFile() { 32 + const file = resolve(root, `.env.${env}`); 33 + let text; 34 + try { 35 + text = await readFile(file, 'utf8'); 36 + } catch { 37 + fail(`missing .env.${env} — copy .env.example and fill it in`); 38 + } 39 + const vars = {}; 40 + for (const line of text.split('\n')) { 41 + const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/); 42 + if (m) vars[m[1]] = m[2].replace(/^(["'])(.*)\1$/, '$2'); 43 + } 44 + return { file, vars }; 45 + } 46 + 47 + async function loadConfig() { 48 + const text = await readFile(resolve(root, 'podium.config.json'), 'utf8'); 49 + return JSON.parse(text); 50 + } 51 + 52 + function makeApi({ vars }) { 53 + const base = vars.PODIUM_HV_URL?.replace(/\/$/, ''); 54 + if (!base) fail(`PODIUM_HV_URL is not set in .env.${env}`); 55 + if (!vars.PODIUM_HV_KEY) { 56 + fail( 57 + `PODIUM_HV_KEY is not set in .env.${env}\n` + 58 + ` Log into the HappyView dashboard (${base}/), create an API key under\n` + 59 + ` Settings > API Keys with lexicons:create/read + scripts:manage/read,\n` + 60 + ` and put it in .env.${env}.` 61 + ); 62 + } 63 + return async (method, path, body) => { 64 + const res = await fetch(`${base}${path}`, { 65 + method, 66 + headers: { 67 + authorization: `Bearer ${vars.PODIUM_HV_KEY}`, 68 + ...(body ? { 'content-type': 'application/json' } : {}), 69 + }, 70 + body: body ? JSON.stringify(body) : undefined, 71 + }); 72 + const text = await res.text(); 73 + let data; 74 + try { 75 + data = text ? JSON.parse(text) : null; 76 + } catch { 77 + data = text; 78 + } 79 + return { status: res.status, ok: res.ok, data }; 80 + }; 81 + } 82 + 83 + async function waitForInstance(api) { 84 + for (let i = 0; i < 15; i++) { 85 + try { 86 + const res = await api('GET', '/admin/lexicons'); 87 + if (res.status === 401 || res.status === 403) { 88 + fail('the instance rejected PODIUM_HV_KEY (401/403) — check the key and its permissions'); 89 + } 90 + if (res.ok) return; 91 + } catch { 92 + // not up yet 93 + } 94 + if (i === 0) log('… waiting for HappyView to answer'); 95 + await new Promise((r) => setTimeout(r, 2000)); 96 + } 97 + fail('HappyView did not answer on /admin/lexicons — is the stack up?'); 98 + } 99 + 100 + async function push(envFile, config) { 101 + const api = makeApi(envFile); 102 + await waitForInstance(api); 103 + 104 + // HappyView 421s any Host it doesn't know (its primary domain comes from 105 + // PUBLIC_URL); make sure the public-facing domains are registered. 106 + const wanted = (envFile.vars.PODIUM_DOMAINS ?? '') 107 + .split(',') 108 + .map((d) => d.trim()) 109 + .filter(Boolean); 110 + if (wanted.length) { 111 + const existing = await api('GET', '/admin/domains'); 112 + const have = new Set( 113 + (Array.isArray(existing.data) ? existing.data : []).map((d) => d.url?.replace(/\/$/, '')) 114 + ); 115 + for (const url of wanted) { 116 + if (have.has(url.replace(/\/$/, ''))) continue; 117 + const res = await api('POST', '/admin/domains', { url }); 118 + if (res.ok) log(`✓ domain ${url}`); 119 + else log(`✗ domain ${url}: ${res.status} ${JSON.stringify(res.data)}`); 120 + } 121 + } 122 + 123 + // Re-POSTing an existing network lexicon re-fetches it and re-kicks 124 + // jetstream/backfill machinery, so only create the missing ones. 125 + const netExisting = await api('GET', '/admin/network-lexicons'); 126 + const netHave = new Set( 127 + (Array.isArray(netExisting.data) ? netExisting.data : []).map((l) => l.nsid) 128 + ); 129 + for (const { nsid, targetCollection } of config.networkLexicons ?? []) { 130 + if (netHave.has(nsid)) { 131 + log(`= network lexicon ${nsid}`); 132 + continue; 133 + } 134 + const res = await api('POST', '/admin/network-lexicons', { 135 + nsid, 136 + target_collection: targetCollection ?? null, 137 + }); 138 + if (res.ok) log(`✓ network lexicon ${nsid}`); 139 + else log(`✗ network lexicon ${nsid}: ${res.status} ${JSON.stringify(res.data)}`); 140 + // A fresh record collection has no history yet — kick off a backfill. 141 + if (res.ok) await backfill(api, nsid); 142 + } 143 + 144 + for (const { file, targetCollection, backfill } of config.lexicons ?? []) { 145 + const lexicon = JSON.parse(await readFile(resolve(root, file), 'utf8')); 146 + const res = await api('POST', '/admin/lexicons', { 147 + lexicon_json: lexicon, 148 + backfill: backfill ?? false, 149 + target_collection: targetCollection ?? null, 150 + }); 151 + if (res.ok) log(`✓ lexicon ${lexicon.id}`); 152 + else log(`✗ lexicon ${lexicon.id}: ${res.status} ${JSON.stringify(res.data)}`); 153 + } 154 + 155 + for (const { trigger, file, description } of config.scripts ?? []) { 156 + const body = await readFile(resolve(root, file), 'utf8'); 157 + const res = await api('POST', '/admin/scripts', { 158 + id: trigger, 159 + script_type: 'lua', 160 + body, 161 + description, 162 + }); 163 + if (res.ok) log(`✓ script ${trigger}`); 164 + else log(`✗ script ${trigger}: ${res.status} ${JSON.stringify(res.data)}`); 165 + } 166 + } 167 + 168 + async function backfill(api, collection) { 169 + const res = await api('POST', '/admin/backfill', collection ? { collection } : {}); 170 + if (res.ok) log(`⟳ backfill started${collection ? ` for ${collection}` : ''} (job ${res.data?.id ?? '?'})`); 171 + else log(`✗ backfill${collection ? ` for ${collection}` : ''}: ${res.status} ${JSON.stringify(res.data)}`); 172 + } 173 + 174 + async function cmdBackfill() { 175 + const envFile = await loadEnvFile(); 176 + const api = makeApi(envFile); 177 + await waitForInstance(api); 178 + const collection = args.filter((a) => !a.startsWith('-'))[1]; 179 + if (collection) return backfill(api, collection); 180 + const config = await loadConfig(); 181 + for (const { nsid } of config.networkLexicons ?? []) await backfill(api, nsid); 182 + } 183 + 184 + async function cmdPush() { 185 + const envFile = await loadEnvFile(); 186 + const config = await loadConfig(); 187 + await push(envFile, config); 188 + 189 + if (!flags.has('--watch')) return; 190 + log(`\nwatching backend/ and podium.config.json (${env}) — ^C to stop`); 191 + let timer = null; 192 + const kick = (what) => { 193 + clearTimeout(timer); 194 + timer = setTimeout(async () => { 195 + log(`\n↻ ${what} changed`); 196 + try { 197 + await push(await loadEnvFile(), await loadConfig()); 198 + } catch (err) { 199 + log(`✗ push failed: ${err.message}`); 200 + } 201 + }, 250); 202 + }; 203 + watch(resolve(root, 'backend'), { recursive: true }, (_e, f) => kick(f ?? 'backend')); 204 + watch(resolve(root, 'podium.config.json'), () => kick('podium.config.json')); 205 + await new Promise(() => {}); 206 + } 207 + 208 + async function cmdDeploy() { 209 + const envFile = await loadEnvFile(); 210 + await copyFile(envFile.file, resolve(root, '.env')); 211 + log(`.env ← .env.${env}`); 212 + 213 + const smArgs = ['deploy']; 214 + if (env === 'local') smArgs.push('--local'); 215 + const res = spawnSync('sm', smArgs, { cwd: root, stdio: 'inherit' }); 216 + if (res.error?.code === 'ENOENT') fail('sm not found on PATH (npm link it from caddy-front)'); 217 + if (res.status !== 0) fail(`sm deploy exited with ${res.status}`); 218 + 219 + if (flags.has('--no-push')) return; 220 + if (!envFile.vars.PODIUM_HV_KEY) { 221 + log('\nNo PODIUM_HV_KEY yet, skipping backend push. First-boot bootstrap:'); 222 + log(` 1. open ${envFile.vars.PODIUM_HV_URL ?? 'the HappyView dashboard'}/`); 223 + log(' 2. log in with your atproto handle (first login becomes super user)'); 224 + log(' 3. Settings > API Keys → create a key (lexicons:create/read, scripts:manage/read)'); 225 + log(` 4. put it in .env.${env} as PODIUM_HV_KEY and run: podium push${env === 'local' ? ' --local' : ''}`); 226 + return; 227 + } 228 + await push(envFile, await loadConfig()); 229 + } 230 + 231 + // First-boot helper for environments where the dashboard's atproto OAuth 232 + // can't run: seeds a super user + API key straight into SQLite. Only safe 233 + // with HappyView stopped (host + container sharing a live WAL db over a 234 + // Docker bind mount corrupts it), so this stops and restarts the container. 235 + async function cmdBootstrap() { 236 + if (env !== 'local') fail('bootstrap only works on the local stack (use the dashboard in prod)'); 237 + const { execSync } = await import('node:child_process'); 238 + const crypto = await import('node:crypto'); 239 + const envFile = await loadEnvFile(); 240 + if (envFile.vars.PODIUM_HV_KEY) fail('.env.local already has PODIUM_HV_KEY — nothing to do'); 241 + 242 + const handleArg = args.filter((a) => !a.startsWith('-'))[1]; 243 + if (!handleArg) fail('usage: podium bootstrap <atproto-handle-or-did> --local'); 244 + let did = handleArg; 245 + if (!did.startsWith('did:')) { 246 + const res = await fetch( 247 + `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${handleArg}` 248 + ); 249 + if (!res.ok) fail(`could not resolve handle ${handleArg}`); 250 + did = (await res.json()).did; 251 + log(`${handleArg} → ${did}`); 252 + } 253 + 254 + const dataRoot = envFile.vars.SM_DATA_ROOT; 255 + if (!dataRoot) fail('SM_DATA_ROOT is not set in .env.local'); 256 + const db = `${dataRoot}/podium/happyview.db`; 257 + 258 + const rawKey = `hv_${crypto.randomBytes(16).toString('hex')}`; 259 + const hash = crypto.createHash('sha256').update(rawKey).digest('hex'); 260 + const run = (cmd, input) => 261 + execSync(cmd, { cwd: root, input, stdio: [input ? 'pipe' : 'ignore', 'pipe', 'inherit'] }); 262 + 263 + run('docker compose stop podium-hv'); 264 + try { 265 + const sql = ` 266 + INSERT OR IGNORE INTO happyview_users (id, did, is_super, created_at) 267 + VALUES ('${crypto.randomUUID()}', '${did}', 1, datetime('now')); 268 + INSERT INTO happyview_api_keys (id, user_id, name, key_hash, key_prefix, permissions, created_at) 269 + SELECT '${crypto.randomUUID()}', id, 'podium bootstrap', '${hash}', '${rawKey.slice(0, 11)}', '[]', datetime('now') 270 + FROM happyview_users WHERE did = '${did}';`; 271 + run(`sqlite3 ${JSON.stringify(db)}`, sql); 272 + } finally { 273 + run('docker compose start podium-hv'); 274 + } 275 + 276 + const envText = await readFile(envFile.file, 'utf8'); 277 + const updated = envText.replace(/^PODIUM_HV_KEY=.*$/m, `PODIUM_HV_KEY=${rawKey}`); 278 + const { writeFile } = await import('node:fs/promises'); 279 + await writeFile(envFile.file, updated.includes(rawKey) ? updated : `${envText}\nPODIUM_HV_KEY=${rawKey}\n`); 280 + log(`✓ super user ${did} seeded, API key written to .env.local`); 281 + log(' run: podium push --local'); 282 + } 283 + 284 + async function cmdStatus() { 285 + const envFile = await loadEnvFile(); 286 + const api = makeApi(envFile); 287 + const [lex, scripts] = await Promise.all([ 288 + api('GET', '/admin/lexicons'), 289 + api('GET', '/admin/scripts'), 290 + ]); 291 + if (!lex.ok || !scripts.ok) { 292 + fail(`instance answered ${lex.status}/${scripts.status} — check PODIUM_HV_URL and PODIUM_HV_KEY`); 293 + } 294 + const lexicons = Array.isArray(lex.data) ? lex.data : lex.data?.lexicons ?? []; 295 + const scr = Array.isArray(scripts.data) ? scripts.data : scripts.data?.scripts ?? []; 296 + log(`lexicons (${lexicons.length}):`); 297 + for (const l of lexicons) log(` ${l.id ?? l.nsid ?? JSON.stringify(l)}`); 298 + log(`scripts (${scr.length}):`); 299 + for (const s of scr) log(` ${s.id ?? JSON.stringify(s)}`); 300 + } 301 + 302 + async function main() { 303 + switch (command) { 304 + case 'deploy': 305 + return cmdDeploy(); 306 + case 'push': 307 + return cmdPush(); 308 + case 'backfill': 309 + return cmdBackfill(); 310 + case 'bootstrap': 311 + return cmdBootstrap(); 312 + case 'status': 313 + return cmdStatus(); 314 + default: 315 + log('usage: podium <deploy|push|backfill|bootstrap|status> [--local] [--watch] [--no-push]'); 316 + process.exit(command ? 1 : 0); 317 + } 318 + } 319 + 320 + await main();
+38
compose.yaml
··· 1 + services: 2 + # Public-facing container: serves the built frontend and proxies 3 + # /hv/* (and /xrpc/* rewritten) to HappyView. The caddy-front proxy 4 + # reaches it as http://podium:80 on the supramundane network. 5 + podium: 6 + build: . 7 + restart: unless-stopped 8 + container_name: podium 9 + depends_on: 10 + - podium-hv 11 + networks: 12 + - default 13 + 14 + # HappyView AppView: indexes the collections, serves XRPC + dashboard. 15 + podium-hv: 16 + image: ghcr.io/gamesgamesgamesgamesgames/happyview:2.10.2 17 + restart: unless-stopped 18 + container_name: podium-hv 19 + environment: 20 + DATABASE_URL: "sqlite:///data/happyview.db?mode=rwc" 21 + BASE_PATH: /hv 22 + PUBLIC_URL: ${PODIUM_PUBLIC_URL:?PODIUM_PUBLIC_URL must be set — see .env.example} 23 + SESSION_SECRET: ${PODIUM_SESSION_SECRET:?PODIUM_SESSION_SECRET must be set — see .env.example} 24 + PORT: "3000" 25 + ports: 26 + # Loopback-only admin/dashboard access that bypasses the front proxy: 27 + # locally this is what makes atproto OAuth loopback login work; on the 28 + # server it stays reachable via an SSH tunnel. 29 + - "127.0.0.1:3300:3000" 30 + volumes: 31 + - ${SM_DATA_ROOT:-/srv/supramundane/data}/podium:/data 32 + networks: 33 + - default 34 + 35 + networks: 36 + default: 37 + name: supramundane 38 + external: true
+58
frontend/index.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="utf-8" /> 5 + <meta name="viewport" content="width=device-width, initial-scale=1" /> 6 + <title>Podium — open data search</title> 7 + <meta 8 + name="description" 9 + content="Podium: a search engine for open data published on the AT Protocol — Matadisco datasets, Astrosky transient events, and more." 10 + /> 11 + <link rel="icon" href="/favicon.svg" type="image/svg+xml" /> 12 + <style> 13 + :root { 14 + --bg: #0a0a0e; 15 + --panel: #121218; 16 + --panel-2: #17171f; 17 + --border: #26262f; 18 + --text: #ececf1; 19 + --muted: #8f8f9b; 20 + --faint: #5c5c68; 21 + --pink: #ff71ce; 22 + --cyan: #01cdfe; 23 + --green: #05ffa1; 24 + --purple: #b967ff; 25 + --grad: linear-gradient(100deg, var(--pink), var(--cyan) 35%, var(--green) 70%, var(--purple)); 26 + --mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace; 27 + --sans: system-ui, -apple-system, "Segoe UI", sans-serif; 28 + } 29 + html { 30 + color-scheme: dark; 31 + } 32 + body { 33 + margin: 0; 34 + background: var(--bg); 35 + color: var(--text); 36 + font-family: var(--sans); 37 + -webkit-font-smoothing: antialiased; 38 + } 39 + /* faint disco glow up top */ 40 + body::before { 41 + content: ""; 42 + position: fixed; 43 + inset: -40vh -20vw auto; 44 + height: 70vh; 45 + background: 46 + radial-gradient(40% 55% at 30% 30%, rgba(255, 113, 206, 0.1), transparent 70%), 47 + radial-gradient(35% 50% at 70% 25%, rgba(1, 205, 254, 0.1), transparent 70%), 48 + radial-gradient(30% 45% at 50% 60%, rgba(185, 103, 255, 0.08), transparent 70%); 49 + pointer-events: none; 50 + z-index: 0; 51 + } 52 + </style> 53 + </head> 54 + <body> 55 + <podium-app></podium-app> 56 + <script type="module" src="/src/main.ts"></script> 57 + </body> 58 + </html>
+999
frontend/package-lock.json
··· 1 + { 2 + "name": "podium-frontend", 3 + "version": "0.1.0", 4 + "lockfileVersion": 3, 5 + "requires": true, 6 + "packages": { 7 + "": { 8 + "name": "podium-frontend", 9 + "version": "0.1.0", 10 + "dependencies": { 11 + "@lit-labs/signals": "^0.3.0", 12 + "lit": "^3.3.3", 13 + "refrakt": "^1.0.0" 14 + }, 15 + "devDependencies": { 16 + "typescript": "^6.0.3", 17 + "vite": "^8.1.3" 18 + } 19 + }, 20 + "node_modules/@emnapi/core": { 21 + "version": "1.11.1", 22 + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", 23 + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", 24 + "dev": true, 25 + "license": "MIT", 26 + "optional": true, 27 + "dependencies": { 28 + "@emnapi/wasi-threads": "1.2.2", 29 + "tslib": "^2.4.0" 30 + } 31 + }, 32 + "node_modules/@emnapi/runtime": { 33 + "version": "1.11.1", 34 + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", 35 + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", 36 + "dev": true, 37 + "license": "MIT", 38 + "optional": true, 39 + "dependencies": { 40 + "tslib": "^2.4.0" 41 + } 42 + }, 43 + "node_modules/@emnapi/wasi-threads": { 44 + "version": "1.2.2", 45 + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", 46 + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", 47 + "dev": true, 48 + "license": "MIT", 49 + "optional": true, 50 + "dependencies": { 51 + "tslib": "^2.4.0" 52 + } 53 + }, 54 + "node_modules/@lit-labs/signals": { 55 + "version": "0.3.0", 56 + "resolved": "https://registry.npmjs.org/@lit-labs/signals/-/signals-0.3.0.tgz", 57 + "integrity": "sha512-PfB5FLtorVfkhwTWXOPNT/rvrVdSDY8siwPyR17ztrViRbMqxy1TQBcHqJoA4303QT8yXPGxYtGKJGj7fEfEwg==", 58 + "license": "BSD-3-Clause", 59 + "dependencies": { 60 + "lit": "^2.0.0 || ^3.0.0", 61 + "signal-polyfill": "^0.2.2" 62 + } 63 + }, 64 + "node_modules/@lit-labs/ssr-dom-shim": { 65 + "version": "1.6.0", 66 + "resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.6.0.tgz", 67 + "integrity": "sha512-VHb0ALPMTlgKjM6yIxxoQNnpKyUKLD04VzeQdsiXkMqkvYlAHxq9glGLmgbb889/1GsohSOAjvQYoiBppXFqrQ==", 68 + "license": "BSD-3-Clause" 69 + }, 70 + "node_modules/@lit/reactive-element": { 71 + "version": "2.1.2", 72 + "resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-2.1.2.tgz", 73 + "integrity": "sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A==", 74 + "license": "BSD-3-Clause", 75 + "dependencies": { 76 + "@lit-labs/ssr-dom-shim": "^1.5.0" 77 + } 78 + }, 79 + "node_modules/@napi-rs/wasm-runtime": { 80 + "version": "1.1.6", 81 + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", 82 + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", 83 + "dev": true, 84 + "license": "MIT", 85 + "optional": true, 86 + "dependencies": { 87 + "@tybys/wasm-util": "^0.10.3" 88 + }, 89 + "funding": { 90 + "type": "github", 91 + "url": "https://github.com/sponsors/Brooooooklyn" 92 + }, 93 + "peerDependencies": { 94 + "@emnapi/core": "^1.7.1", 95 + "@emnapi/runtime": "^1.7.1" 96 + } 97 + }, 98 + "node_modules/@oxc-project/types": { 99 + "version": "0.138.0", 100 + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz", 101 + "integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==", 102 + "dev": true, 103 + "license": "MIT", 104 + "funding": { 105 + "url": "https://github.com/sponsors/Boshen" 106 + } 107 + }, 108 + "node_modules/@rolldown/binding-android-arm64": { 109 + "version": "1.1.4", 110 + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz", 111 + "integrity": "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==", 112 + "cpu": [ 113 + "arm64" 114 + ], 115 + "dev": true, 116 + "license": "MIT", 117 + "optional": true, 118 + "os": [ 119 + "android" 120 + ], 121 + "engines": { 122 + "node": "^20.19.0 || >=22.12.0" 123 + } 124 + }, 125 + "node_modules/@rolldown/binding-darwin-arm64": { 126 + "version": "1.1.4", 127 + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.4.tgz", 128 + "integrity": "sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==", 129 + "cpu": [ 130 + "arm64" 131 + ], 132 + "dev": true, 133 + "license": "MIT", 134 + "optional": true, 135 + "os": [ 136 + "darwin" 137 + ], 138 + "engines": { 139 + "node": "^20.19.0 || >=22.12.0" 140 + } 141 + }, 142 + "node_modules/@rolldown/binding-darwin-x64": { 143 + "version": "1.1.4", 144 + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.4.tgz", 145 + "integrity": "sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==", 146 + "cpu": [ 147 + "x64" 148 + ], 149 + "dev": true, 150 + "license": "MIT", 151 + "optional": true, 152 + "os": [ 153 + "darwin" 154 + ], 155 + "engines": { 156 + "node": "^20.19.0 || >=22.12.0" 157 + } 158 + }, 159 + "node_modules/@rolldown/binding-freebsd-x64": { 160 + "version": "1.1.4", 161 + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.4.tgz", 162 + "integrity": "sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==", 163 + "cpu": [ 164 + "x64" 165 + ], 166 + "dev": true, 167 + "license": "MIT", 168 + "optional": true, 169 + "os": [ 170 + "freebsd" 171 + ], 172 + "engines": { 173 + "node": "^20.19.0 || >=22.12.0" 174 + } 175 + }, 176 + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { 177 + "version": "1.1.4", 178 + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.4.tgz", 179 + "integrity": "sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==", 180 + "cpu": [ 181 + "arm" 182 + ], 183 + "dev": true, 184 + "license": "MIT", 185 + "optional": true, 186 + "os": [ 187 + "linux" 188 + ], 189 + "engines": { 190 + "node": "^20.19.0 || >=22.12.0" 191 + } 192 + }, 193 + "node_modules/@rolldown/binding-linux-arm64-gnu": { 194 + "version": "1.1.4", 195 + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.4.tgz", 196 + "integrity": "sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==", 197 + "cpu": [ 198 + "arm64" 199 + ], 200 + "dev": true, 201 + "libc": [ 202 + "glibc" 203 + ], 204 + "license": "MIT", 205 + "optional": true, 206 + "os": [ 207 + "linux" 208 + ], 209 + "engines": { 210 + "node": "^20.19.0 || >=22.12.0" 211 + } 212 + }, 213 + "node_modules/@rolldown/binding-linux-arm64-musl": { 214 + "version": "1.1.4", 215 + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.4.tgz", 216 + "integrity": "sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==", 217 + "cpu": [ 218 + "arm64" 219 + ], 220 + "dev": true, 221 + "libc": [ 222 + "musl" 223 + ], 224 + "license": "MIT", 225 + "optional": true, 226 + "os": [ 227 + "linux" 228 + ], 229 + "engines": { 230 + "node": "^20.19.0 || >=22.12.0" 231 + } 232 + }, 233 + "node_modules/@rolldown/binding-linux-ppc64-gnu": { 234 + "version": "1.1.4", 235 + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.4.tgz", 236 + "integrity": "sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==", 237 + "cpu": [ 238 + "ppc64" 239 + ], 240 + "dev": true, 241 + "libc": [ 242 + "glibc" 243 + ], 244 + "license": "MIT", 245 + "optional": true, 246 + "os": [ 247 + "linux" 248 + ], 249 + "engines": { 250 + "node": "^20.19.0 || >=22.12.0" 251 + } 252 + }, 253 + "node_modules/@rolldown/binding-linux-s390x-gnu": { 254 + "version": "1.1.4", 255 + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.4.tgz", 256 + "integrity": "sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==", 257 + "cpu": [ 258 + "s390x" 259 + ], 260 + "dev": true, 261 + "libc": [ 262 + "glibc" 263 + ], 264 + "license": "MIT", 265 + "optional": true, 266 + "os": [ 267 + "linux" 268 + ], 269 + "engines": { 270 + "node": "^20.19.0 || >=22.12.0" 271 + } 272 + }, 273 + "node_modules/@rolldown/binding-linux-x64-gnu": { 274 + "version": "1.1.4", 275 + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.4.tgz", 276 + "integrity": "sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==", 277 + "cpu": [ 278 + "x64" 279 + ], 280 + "dev": true, 281 + "libc": [ 282 + "glibc" 283 + ], 284 + "license": "MIT", 285 + "optional": true, 286 + "os": [ 287 + "linux" 288 + ], 289 + "engines": { 290 + "node": "^20.19.0 || >=22.12.0" 291 + } 292 + }, 293 + "node_modules/@rolldown/binding-linux-x64-musl": { 294 + "version": "1.1.4", 295 + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.4.tgz", 296 + "integrity": "sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==", 297 + "cpu": [ 298 + "x64" 299 + ], 300 + "dev": true, 301 + "libc": [ 302 + "musl" 303 + ], 304 + "license": "MIT", 305 + "optional": true, 306 + "os": [ 307 + "linux" 308 + ], 309 + "engines": { 310 + "node": "^20.19.0 || >=22.12.0" 311 + } 312 + }, 313 + "node_modules/@rolldown/binding-openharmony-arm64": { 314 + "version": "1.1.4", 315 + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.4.tgz", 316 + "integrity": "sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==", 317 + "cpu": [ 318 + "arm64" 319 + ], 320 + "dev": true, 321 + "license": "MIT", 322 + "optional": true, 323 + "os": [ 324 + "openharmony" 325 + ], 326 + "engines": { 327 + "node": "^20.19.0 || >=22.12.0" 328 + } 329 + }, 330 + "node_modules/@rolldown/binding-wasm32-wasi": { 331 + "version": "1.1.4", 332 + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.4.tgz", 333 + "integrity": "sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==", 334 + "cpu": [ 335 + "wasm32" 336 + ], 337 + "dev": true, 338 + "license": "MIT", 339 + "optional": true, 340 + "dependencies": { 341 + "@emnapi/core": "1.11.1", 342 + "@emnapi/runtime": "1.11.1", 343 + "@napi-rs/wasm-runtime": "^1.1.6" 344 + }, 345 + "engines": { 346 + "node": "^20.19.0 || >=22.12.0" 347 + } 348 + }, 349 + "node_modules/@rolldown/binding-win32-arm64-msvc": { 350 + "version": "1.1.4", 351 + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz", 352 + "integrity": "sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==", 353 + "cpu": [ 354 + "arm64" 355 + ], 356 + "dev": true, 357 + "license": "MIT", 358 + "optional": true, 359 + "os": [ 360 + "win32" 361 + ], 362 + "engines": { 363 + "node": "^20.19.0 || >=22.12.0" 364 + } 365 + }, 366 + "node_modules/@rolldown/binding-win32-x64-msvc": { 367 + "version": "1.1.4", 368 + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.4.tgz", 369 + "integrity": "sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==", 370 + "cpu": [ 371 + "x64" 372 + ], 373 + "dev": true, 374 + "license": "MIT", 375 + "optional": true, 376 + "os": [ 377 + "win32" 378 + ], 379 + "engines": { 380 + "node": "^20.19.0 || >=22.12.0" 381 + } 382 + }, 383 + "node_modules/@rolldown/pluginutils": { 384 + "version": "1.0.1", 385 + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", 386 + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", 387 + "dev": true, 388 + "license": "MIT" 389 + }, 390 + "node_modules/@tybys/wasm-util": { 391 + "version": "0.10.3", 392 + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", 393 + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", 394 + "dev": true, 395 + "license": "MIT", 396 + "optional": true, 397 + "dependencies": { 398 + "tslib": "^2.4.0" 399 + } 400 + }, 401 + "node_modules/@types/trusted-types": { 402 + "version": "2.0.7", 403 + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", 404 + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", 405 + "license": "MIT" 406 + }, 407 + "node_modules/detect-libc": { 408 + "version": "2.1.2", 409 + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", 410 + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", 411 + "dev": true, 412 + "license": "Apache-2.0", 413 + "engines": { 414 + "node": ">=8" 415 + } 416 + }, 417 + "node_modules/fdir": { 418 + "version": "6.5.0", 419 + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", 420 + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", 421 + "dev": true, 422 + "license": "MIT", 423 + "engines": { 424 + "node": ">=12.0.0" 425 + }, 426 + "peerDependencies": { 427 + "picomatch": "^3 || ^4" 428 + }, 429 + "peerDependenciesMeta": { 430 + "picomatch": { 431 + "optional": true 432 + } 433 + } 434 + }, 435 + "node_modules/fsevents": { 436 + "version": "2.3.3", 437 + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", 438 + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", 439 + "dev": true, 440 + "hasInstallScript": true, 441 + "license": "MIT", 442 + "optional": true, 443 + "os": [ 444 + "darwin" 445 + ], 446 + "engines": { 447 + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" 448 + } 449 + }, 450 + "node_modules/lightningcss": { 451 + "version": "1.32.0", 452 + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", 453 + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", 454 + "dev": true, 455 + "license": "MPL-2.0", 456 + "dependencies": { 457 + "detect-libc": "^2.0.3" 458 + }, 459 + "engines": { 460 + "node": ">= 12.0.0" 461 + }, 462 + "funding": { 463 + "type": "opencollective", 464 + "url": "https://opencollective.com/parcel" 465 + }, 466 + "optionalDependencies": { 467 + "lightningcss-android-arm64": "1.32.0", 468 + "lightningcss-darwin-arm64": "1.32.0", 469 + "lightningcss-darwin-x64": "1.32.0", 470 + "lightningcss-freebsd-x64": "1.32.0", 471 + "lightningcss-linux-arm-gnueabihf": "1.32.0", 472 + "lightningcss-linux-arm64-gnu": "1.32.0", 473 + "lightningcss-linux-arm64-musl": "1.32.0", 474 + "lightningcss-linux-x64-gnu": "1.32.0", 475 + "lightningcss-linux-x64-musl": "1.32.0", 476 + "lightningcss-win32-arm64-msvc": "1.32.0", 477 + "lightningcss-win32-x64-msvc": "1.32.0" 478 + } 479 + }, 480 + "node_modules/lightningcss-android-arm64": { 481 + "version": "1.32.0", 482 + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", 483 + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", 484 + "cpu": [ 485 + "arm64" 486 + ], 487 + "dev": true, 488 + "license": "MPL-2.0", 489 + "optional": true, 490 + "os": [ 491 + "android" 492 + ], 493 + "engines": { 494 + "node": ">= 12.0.0" 495 + }, 496 + "funding": { 497 + "type": "opencollective", 498 + "url": "https://opencollective.com/parcel" 499 + } 500 + }, 501 + "node_modules/lightningcss-darwin-arm64": { 502 + "version": "1.32.0", 503 + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", 504 + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", 505 + "cpu": [ 506 + "arm64" 507 + ], 508 + "dev": true, 509 + "license": "MPL-2.0", 510 + "optional": true, 511 + "os": [ 512 + "darwin" 513 + ], 514 + "engines": { 515 + "node": ">= 12.0.0" 516 + }, 517 + "funding": { 518 + "type": "opencollective", 519 + "url": "https://opencollective.com/parcel" 520 + } 521 + }, 522 + "node_modules/lightningcss-darwin-x64": { 523 + "version": "1.32.0", 524 + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", 525 + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", 526 + "cpu": [ 527 + "x64" 528 + ], 529 + "dev": true, 530 + "license": "MPL-2.0", 531 + "optional": true, 532 + "os": [ 533 + "darwin" 534 + ], 535 + "engines": { 536 + "node": ">= 12.0.0" 537 + }, 538 + "funding": { 539 + "type": "opencollective", 540 + "url": "https://opencollective.com/parcel" 541 + } 542 + }, 543 + "node_modules/lightningcss-freebsd-x64": { 544 + "version": "1.32.0", 545 + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", 546 + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", 547 + "cpu": [ 548 + "x64" 549 + ], 550 + "dev": true, 551 + "license": "MPL-2.0", 552 + "optional": true, 553 + "os": [ 554 + "freebsd" 555 + ], 556 + "engines": { 557 + "node": ">= 12.0.0" 558 + }, 559 + "funding": { 560 + "type": "opencollective", 561 + "url": "https://opencollective.com/parcel" 562 + } 563 + }, 564 + "node_modules/lightningcss-linux-arm-gnueabihf": { 565 + "version": "1.32.0", 566 + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", 567 + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", 568 + "cpu": [ 569 + "arm" 570 + ], 571 + "dev": true, 572 + "license": "MPL-2.0", 573 + "optional": true, 574 + "os": [ 575 + "linux" 576 + ], 577 + "engines": { 578 + "node": ">= 12.0.0" 579 + }, 580 + "funding": { 581 + "type": "opencollective", 582 + "url": "https://opencollective.com/parcel" 583 + } 584 + }, 585 + "node_modules/lightningcss-linux-arm64-gnu": { 586 + "version": "1.32.0", 587 + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", 588 + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", 589 + "cpu": [ 590 + "arm64" 591 + ], 592 + "dev": true, 593 + "libc": [ 594 + "glibc" 595 + ], 596 + "license": "MPL-2.0", 597 + "optional": true, 598 + "os": [ 599 + "linux" 600 + ], 601 + "engines": { 602 + "node": ">= 12.0.0" 603 + }, 604 + "funding": { 605 + "type": "opencollective", 606 + "url": "https://opencollective.com/parcel" 607 + } 608 + }, 609 + "node_modules/lightningcss-linux-arm64-musl": { 610 + "version": "1.32.0", 611 + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", 612 + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", 613 + "cpu": [ 614 + "arm64" 615 + ], 616 + "dev": true, 617 + "libc": [ 618 + "musl" 619 + ], 620 + "license": "MPL-2.0", 621 + "optional": true, 622 + "os": [ 623 + "linux" 624 + ], 625 + "engines": { 626 + "node": ">= 12.0.0" 627 + }, 628 + "funding": { 629 + "type": "opencollective", 630 + "url": "https://opencollective.com/parcel" 631 + } 632 + }, 633 + "node_modules/lightningcss-linux-x64-gnu": { 634 + "version": "1.32.0", 635 + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", 636 + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", 637 + "cpu": [ 638 + "x64" 639 + ], 640 + "dev": true, 641 + "libc": [ 642 + "glibc" 643 + ], 644 + "license": "MPL-2.0", 645 + "optional": true, 646 + "os": [ 647 + "linux" 648 + ], 649 + "engines": { 650 + "node": ">= 12.0.0" 651 + }, 652 + "funding": { 653 + "type": "opencollective", 654 + "url": "https://opencollective.com/parcel" 655 + } 656 + }, 657 + "node_modules/lightningcss-linux-x64-musl": { 658 + "version": "1.32.0", 659 + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", 660 + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", 661 + "cpu": [ 662 + "x64" 663 + ], 664 + "dev": true, 665 + "libc": [ 666 + "musl" 667 + ], 668 + "license": "MPL-2.0", 669 + "optional": true, 670 + "os": [ 671 + "linux" 672 + ], 673 + "engines": { 674 + "node": ">= 12.0.0" 675 + }, 676 + "funding": { 677 + "type": "opencollective", 678 + "url": "https://opencollective.com/parcel" 679 + } 680 + }, 681 + "node_modules/lightningcss-win32-arm64-msvc": { 682 + "version": "1.32.0", 683 + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", 684 + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", 685 + "cpu": [ 686 + "arm64" 687 + ], 688 + "dev": true, 689 + "license": "MPL-2.0", 690 + "optional": true, 691 + "os": [ 692 + "win32" 693 + ], 694 + "engines": { 695 + "node": ">= 12.0.0" 696 + }, 697 + "funding": { 698 + "type": "opencollective", 699 + "url": "https://opencollective.com/parcel" 700 + } 701 + }, 702 + "node_modules/lightningcss-win32-x64-msvc": { 703 + "version": "1.32.0", 704 + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", 705 + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", 706 + "cpu": [ 707 + "x64" 708 + ], 709 + "dev": true, 710 + "license": "MPL-2.0", 711 + "optional": true, 712 + "os": [ 713 + "win32" 714 + ], 715 + "engines": { 716 + "node": ">= 12.0.0" 717 + }, 718 + "funding": { 719 + "type": "opencollective", 720 + "url": "https://opencollective.com/parcel" 721 + } 722 + }, 723 + "node_modules/lit": { 724 + "version": "3.3.3", 725 + "resolved": "https://registry.npmjs.org/lit/-/lit-3.3.3.tgz", 726 + "integrity": "sha512-fycuvZg/hkpozL00lm1pEJH5nN/lr9ZXd6mJI2HSN4+Bzc+LDNdEApJ6HFbPkdFNHLvOplIIuJvxkS4XUxqirw==", 727 + "license": "BSD-3-Clause", 728 + "dependencies": { 729 + "@lit/reactive-element": "^2.1.0", 730 + "lit-element": "^4.2.0", 731 + "lit-html": "^3.3.0" 732 + } 733 + }, 734 + "node_modules/lit-element": { 735 + "version": "4.2.2", 736 + "resolved": "https://registry.npmjs.org/lit-element/-/lit-element-4.2.2.tgz", 737 + "integrity": "sha512-aFKhNToWxoyhkNDmWZwEva2SlQia+jfG0fjIWV//YeTaWrVnOxD89dPKfigCUspXFmjzOEUQpOkejH5Ly6sG0w==", 738 + "license": "BSD-3-Clause", 739 + "dependencies": { 740 + "@lit-labs/ssr-dom-shim": "^1.5.0", 741 + "@lit/reactive-element": "^2.1.0", 742 + "lit-html": "^3.3.0" 743 + } 744 + }, 745 + "node_modules/lit-html": { 746 + "version": "3.3.3", 747 + "resolved": "https://registry.npmjs.org/lit-html/-/lit-html-3.3.3.tgz", 748 + "integrity": "sha512-el8M6jK2o3RXBnrSHX3ZKrsN8zEV63pSExTO1wYJz7QndGYZ8353e2a5PPX+qHe2aGayfnchQmkAojaWAREOIA==", 749 + "license": "BSD-3-Clause", 750 + "dependencies": { 751 + "@types/trusted-types": "^2.0.2" 752 + } 753 + }, 754 + "node_modules/nanoid": { 755 + "version": "3.3.15", 756 + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", 757 + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", 758 + "dev": true, 759 + "funding": [ 760 + { 761 + "type": "github", 762 + "url": "https://github.com/sponsors/ai" 763 + } 764 + ], 765 + "license": "MIT", 766 + "bin": { 767 + "nanoid": "bin/nanoid.cjs" 768 + }, 769 + "engines": { 770 + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" 771 + } 772 + }, 773 + "node_modules/picocolors": { 774 + "version": "1.1.1", 775 + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", 776 + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", 777 + "dev": true, 778 + "license": "ISC" 779 + }, 780 + "node_modules/picomatch": { 781 + "version": "4.0.5", 782 + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", 783 + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", 784 + "dev": true, 785 + "license": "MIT", 786 + "engines": { 787 + "node": ">=12" 788 + }, 789 + "funding": { 790 + "url": "https://github.com/sponsors/jonschlinkert" 791 + } 792 + }, 793 + "node_modules/postcss": { 794 + "version": "8.5.16", 795 + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", 796 + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", 797 + "dev": true, 798 + "funding": [ 799 + { 800 + "type": "opencollective", 801 + "url": "https://opencollective.com/postcss/" 802 + }, 803 + { 804 + "type": "tidelift", 805 + "url": "https://tidelift.com/funding/github/npm/postcss" 806 + }, 807 + { 808 + "type": "github", 809 + "url": "https://github.com/sponsors/ai" 810 + } 811 + ], 812 + "license": "MIT", 813 + "dependencies": { 814 + "nanoid": "^3.3.12", 815 + "picocolors": "^1.1.1", 816 + "source-map-js": "^1.2.1" 817 + }, 818 + "engines": { 819 + "node": "^10 || ^12 || >=14" 820 + } 821 + }, 822 + "node_modules/refrakt": { 823 + "version": "1.0.0", 824 + "resolved": "https://registry.npmjs.org/refrakt/-/refrakt-1.0.0.tgz", 825 + "integrity": "sha512-Ru0J+jimi7ee0331ERoCkE3kVtWM7lXzbtKbQQNqaGOAd2rOwzAhv7leWaDi38H8LCkz4WlRCr6wiLJFFjn70g==", 826 + "license": "MIT", 827 + "dependencies": { 828 + "signal-polyfill": "^0.2.2" 829 + } 830 + }, 831 + "node_modules/rolldown": { 832 + "version": "1.1.4", 833 + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz", 834 + "integrity": "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==", 835 + "dev": true, 836 + "license": "MIT", 837 + "dependencies": { 838 + "@oxc-project/types": "=0.138.0", 839 + "@rolldown/pluginutils": "^1.0.0" 840 + }, 841 + "bin": { 842 + "rolldown": "bin/cli.mjs" 843 + }, 844 + "engines": { 845 + "node": "^20.19.0 || >=22.12.0" 846 + }, 847 + "optionalDependencies": { 848 + "@rolldown/binding-android-arm64": "1.1.4", 849 + "@rolldown/binding-darwin-arm64": "1.1.4", 850 + "@rolldown/binding-darwin-x64": "1.1.4", 851 + "@rolldown/binding-freebsd-x64": "1.1.4", 852 + "@rolldown/binding-linux-arm-gnueabihf": "1.1.4", 853 + "@rolldown/binding-linux-arm64-gnu": "1.1.4", 854 + "@rolldown/binding-linux-arm64-musl": "1.1.4", 855 + "@rolldown/binding-linux-ppc64-gnu": "1.1.4", 856 + "@rolldown/binding-linux-s390x-gnu": "1.1.4", 857 + "@rolldown/binding-linux-x64-gnu": "1.1.4", 858 + "@rolldown/binding-linux-x64-musl": "1.1.4", 859 + "@rolldown/binding-openharmony-arm64": "1.1.4", 860 + "@rolldown/binding-wasm32-wasi": "1.1.4", 861 + "@rolldown/binding-win32-arm64-msvc": "1.1.4", 862 + "@rolldown/binding-win32-x64-msvc": "1.1.4" 863 + } 864 + }, 865 + "node_modules/signal-polyfill": { 866 + "version": "0.2.2", 867 + "resolved": "https://registry.npmjs.org/signal-polyfill/-/signal-polyfill-0.2.2.tgz", 868 + "integrity": "sha512-p63Y4Er5/eMQ9RHg0M0Y64NlsQKpiu6MDdhBXpyywRuWiPywhJTpKJ1iB5K2hJEbFZ0BnDS7ZkJ+0AfTuL37Rg==", 869 + "license": "Apache-2.0" 870 + }, 871 + "node_modules/source-map-js": { 872 + "version": "1.2.1", 873 + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", 874 + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", 875 + "dev": true, 876 + "license": "BSD-3-Clause", 877 + "engines": { 878 + "node": ">=0.10.0" 879 + } 880 + }, 881 + "node_modules/tinyglobby": { 882 + "version": "0.2.17", 883 + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", 884 + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", 885 + "dev": true, 886 + "license": "MIT", 887 + "dependencies": { 888 + "fdir": "^6.5.0", 889 + "picomatch": "^4.0.4" 890 + }, 891 + "engines": { 892 + "node": ">=12.0.0" 893 + }, 894 + "funding": { 895 + "url": "https://github.com/sponsors/SuperchupuDev" 896 + } 897 + }, 898 + "node_modules/tslib": { 899 + "version": "2.8.1", 900 + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", 901 + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", 902 + "dev": true, 903 + "license": "0BSD", 904 + "optional": true 905 + }, 906 + "node_modules/typescript": { 907 + "version": "6.0.3", 908 + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", 909 + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", 910 + "dev": true, 911 + "license": "Apache-2.0", 912 + "bin": { 913 + "tsc": "bin/tsc", 914 + "tsserver": "bin/tsserver" 915 + }, 916 + "engines": { 917 + "node": ">=14.17" 918 + } 919 + }, 920 + "node_modules/vite": { 921 + "version": "8.1.3", 922 + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz", 923 + "integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==", 924 + "dev": true, 925 + "license": "MIT", 926 + "dependencies": { 927 + "lightningcss": "^1.32.0", 928 + "picomatch": "^4.0.4", 929 + "postcss": "^8.5.16", 930 + "rolldown": "~1.1.3", 931 + "tinyglobby": "^0.2.17" 932 + }, 933 + "bin": { 934 + "vite": "bin/vite.js" 935 + }, 936 + "engines": { 937 + "node": "^20.19.0 || >=22.12.0" 938 + }, 939 + "funding": { 940 + "url": "https://github.com/vitejs/vite?sponsor=1" 941 + }, 942 + "optionalDependencies": { 943 + "fsevents": "~2.3.3" 944 + }, 945 + "peerDependencies": { 946 + "@types/node": "^20.19.0 || >=22.12.0", 947 + "@vitejs/devtools": "^0.3.0", 948 + "esbuild": "^0.27.0 || ^0.28.0", 949 + "jiti": ">=1.21.0", 950 + "less": "^4.0.0", 951 + "sass": "^1.70.0", 952 + "sass-embedded": "^1.70.0", 953 + "stylus": ">=0.54.8", 954 + "sugarss": "^5.0.0", 955 + "terser": "^5.16.0", 956 + "tsx": "^4.8.1", 957 + "yaml": "^2.4.2" 958 + }, 959 + "peerDependenciesMeta": { 960 + "@types/node": { 961 + "optional": true 962 + }, 963 + "@vitejs/devtools": { 964 + "optional": true 965 + }, 966 + "esbuild": { 967 + "optional": true 968 + }, 969 + "jiti": { 970 + "optional": true 971 + }, 972 + "less": { 973 + "optional": true 974 + }, 975 + "sass": { 976 + "optional": true 977 + }, 978 + "sass-embedded": { 979 + "optional": true 980 + }, 981 + "stylus": { 982 + "optional": true 983 + }, 984 + "sugarss": { 985 + "optional": true 986 + }, 987 + "terser": { 988 + "optional": true 989 + }, 990 + "tsx": { 991 + "optional": true 992 + }, 993 + "yaml": { 994 + "optional": true 995 + } 996 + } 997 + } 998 + } 999 + }
+20
frontend/package.json
··· 1 + { 2 + "name": "podium-frontend", 3 + "version": "0.1.0", 4 + "private": true, 5 + "type": "module", 6 + "scripts": { 7 + "dev": "vite", 8 + "build": "tsc --noEmit && vite build", 9 + "preview": "vite preview" 10 + }, 11 + "dependencies": { 12 + "@lit-labs/signals": "^0.3.0", 13 + "lit": "^3.3.3", 14 + "refrakt": "^1.0.0" 15 + }, 16 + "devDependencies": { 17 + "typescript": "^6.0.3", 18 + "vite": "^8.1.3" 19 + } 20 + }
+3
frontend/public/favicon.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"> 2 + <text y="82" font-size="82">🪩</text> 3 + </svg>
+33
frontend/src/api.ts
··· 1 + import type { Result, Stats } from './types.js'; 2 + 3 + const NS = 'it.mycopunk.podium'; 4 + 5 + async function query<T>( 6 + method: string, 7 + params: Record<string, string | number | undefined> = {} 8 + ): Promise<T> { 9 + const url = new URL(`/xrpc/${NS}.${method}`, location.origin); 10 + for (const [k, v] of Object.entries(params)) { 11 + if (v !== undefined && v !== '') url.searchParams.set(k, String(v)); 12 + } 13 + const res = await fetch(url); 14 + if (!res.ok) { 15 + let msg = `request failed (${res.status})`; 16 + try { 17 + const err = await res.json(); 18 + msg = err.message ?? err.error ?? msg; 19 + } catch { 20 + // keep the status message 21 + } 22 + throw new Error(msg); 23 + } 24 + return res.json() as Promise<T>; 25 + } 26 + 27 + export const search = (q: string, collection?: string, limit = 30) => 28 + query<{ results: Result[] }>('search', { q, collection, limit }); 29 + 30 + export const listRecords = (collection?: string, limit = 30, cursor?: string) => 31 + query<{ results: Result[]; cursor?: string }>('listRecords', { collection, limit, cursor }); 32 + 33 + export const stats = () => query<Stats>('stats');
+223
frontend/src/components/podium-app.ts
··· 1 + import { LitElement, html, css, nothing } from 'lit'; 2 + import { SignalWatcher } from '@lit-labs/signals'; 3 + import { app } from '../store.js'; 4 + import { GCN, MATADISCO } from '../types.js'; 5 + import { collectionLabel, collectionShortLabel } from '../format.js'; 6 + import './podium-search-box.js'; 7 + import './podium-result-card.js'; 8 + 9 + const nf = new Intl.NumberFormat('en'); 10 + 11 + const FILTERS: (string | null)[] = [null, MATADISCO, GCN]; 12 + 13 + export class PodiumApp extends SignalWatcher(LitElement) { 14 + static styles = css` 15 + :host { 16 + display: block; 17 + position: relative; 18 + z-index: 1; 19 + } 20 + .shell { 21 + max-width: 44rem; 22 + margin: 0 auto; 23 + padding: 4rem 1.25rem 5rem; 24 + } 25 + header { 26 + text-align: center; 27 + margin-bottom: 2.2rem; 28 + } 29 + .wordmark { 30 + margin: 0; 31 + font-size: 3rem; 32 + font-weight: 800; 33 + letter-spacing: -0.03em; 34 + } 35 + .wordmark .ball { 36 + -webkit-text-stroke: 0; 37 + filter: drop-shadow(0 0 12px rgba(1, 205, 254, 0.4)); 38 + margin-right: 0.3rem; 39 + } 40 + .wordmark .text { 41 + background: var(--grad); 42 + background-size: 300% 100%; 43 + background-clip: text; 44 + -webkit-background-clip: text; 45 + color: transparent; 46 + animation: shimmer 9s linear infinite; 47 + } 48 + @keyframes shimmer { 49 + to { 50 + background-position: 300% 0; 51 + } 52 + } 53 + @media (prefers-reduced-motion: reduce) { 54 + .wordmark .text { 55 + animation: none; 56 + } 57 + } 58 + .strap { 59 + margin: 0.4rem 0 0; 60 + color: var(--muted); 61 + font-size: 0.95rem; 62 + } 63 + .stats { 64 + margin-top: 0.6rem; 65 + color: var(--faint); 66 + font: 0.78rem var(--mono); 67 + } 68 + .filters { 69 + display: flex; 70 + justify-content: center; 71 + gap: 0.4rem; 72 + margin: 1.1rem 0 1.8rem; 73 + } 74 + .filters button { 75 + font: 0.8rem var(--mono); 76 + color: var(--muted); 77 + background: none; 78 + border: 1px solid var(--border); 79 + border-radius: 99px; 80 + padding: 0.3rem 0.85rem; 81 + cursor: pointer; 82 + transition: border-color 0.15s ease; 83 + } 84 + .filters button:hover { 85 + color: var(--text); 86 + } 87 + .filters button.on { 88 + color: var(--text); 89 + border-color: transparent; 90 + background: 91 + linear-gradient(var(--bg), var(--bg)) padding-box, 92 + var(--grad) border-box; 93 + } 94 + .heading { 95 + display: flex; 96 + align-items: baseline; 97 + gap: 0.6rem; 98 + color: var(--muted); 99 + font: 0.8rem var(--mono); 100 + text-transform: uppercase; 101 + letter-spacing: 0.08em; 102 + margin: 0 0 0.8rem 0.2rem; 103 + } 104 + .results { 105 + display: flex; 106 + flex-direction: column; 107 + gap: 0.65rem; 108 + } 109 + .note { 110 + color: var(--muted); 111 + text-align: center; 112 + padding: 2.5rem 0; 113 + font-size: 0.95rem; 114 + } 115 + .note .big { 116 + font-size: 2rem; 117 + display: block; 118 + margin-bottom: 0.6rem; 119 + } 120 + .error { 121 + color: var(--pink); 122 + border: 1px solid color-mix(in srgb, var(--pink) 35%, transparent); 123 + border-radius: 10px; 124 + background: color-mix(in srgb, var(--pink) 7%, transparent); 125 + padding: 0.7rem 1rem; 126 + font: 0.85rem var(--mono); 127 + margin-bottom: 1rem; 128 + } 129 + footer { 130 + margin-top: 3.5rem; 131 + text-align: center; 132 + color: var(--faint); 133 + font-size: 0.82rem; 134 + } 135 + footer a { 136 + color: var(--muted); 137 + text-decoration: none; 138 + } 139 + footer a:hover { 140 + color: var(--green); 141 + } 142 + `; 143 + 144 + override connectedCallback() { 145 + super.connectedCallback(); 146 + const params = new URLSearchParams(location.search); 147 + app.send({ 148 + type: 'boot', 149 + query: params.get('q') ?? undefined, 150 + collection: params.get('in'), 151 + }); 152 + } 153 + 154 + private statsLine() { 155 + const s = app.get().stats; 156 + if (!s) return nothing; 157 + const parts = s.collections.map( 158 + (c) => `${collectionShortLabel(c.collection)} ${nf.format(c.count)}` 159 + ); 160 + return html`<div class="stats">${nf.format(s.total)} records indexed · ${parts.join(' · ')}</div>`; 161 + } 162 + 163 + private filters() { 164 + const current = app.get().collection; 165 + return html` 166 + <nav class="filters"> 167 + ${FILTERS.map( 168 + (f) => html` 169 + <button class=${f === current ? 'on' : ''} @click=${() => 170 + app.send({ type: 'set-collection', collection: f })}> 171 + ${f === null ? 'everything' : collectionLabel(f)} 172 + </button> 173 + ` 174 + )} 175 + </nav> 176 + `; 177 + } 178 + 179 + override render() { 180 + const s = app.get(); 181 + const searching = s.searching; 182 + const hasQuery = s.query.length > 0; 183 + return html` 184 + <div class="shell"> 185 + <header> 186 + <h1 class="wordmark"><span class="ball">🪩</span><span class="text">podium</span></h1> 187 + <p class="strap">a search engine for open data on the AT Protocol</p> 188 + ${this.statsLine()} 189 + </header> 190 + 191 + <podium-search-box></podium-search-box> 192 + ${this.filters()} 193 + ${s.error ? html`<div class="error">${s.error}</div>` : nothing} 194 + 195 + <div class="heading"> 196 + <span>${hasQuery ? `results for “${s.query}”` : 'freshly indexed'}</span> 197 + <span>${searching ? '…' : hasQuery ? `${s.results.length}` : ''}</span> 198 + </div> 199 + 200 + <section class="results"> 201 + ${s.results.map((r) => html`<podium-result-card .result=${r}></podium-result-card>`)} 202 + ${!searching && s.results.length === 0 203 + ? html`<div class="note"> 204 + <span class="big">🪩</span> 205 + ${hasQuery 206 + ? html`nothing under the glitter ball for “${s.query}”` 207 + : html`nothing indexed yet — backfill may still be running`} 208 + </div>` 209 + : nothing} 210 + </section> 211 + 212 + <footer> 213 + an AppView for open data · 214 + <a href="https://matadisco.org/" target="_blank" rel="noopener">Matadisco</a> · 215 + <a href="https://astrosky.eco/" target="_blank" rel="noopener">Astrosky</a> · 216 + built on <a href="https://happyview.dev/" target="_blank" rel="noopener">HappyView</a> 217 + </footer> 218 + </div> 219 + `; 220 + } 221 + } 222 + 223 + customElements.define('podium-app', PodiumApp);
+237
frontend/src/components/podium-result-card.ts
··· 1 + import { LitElement, html, css, nothing } from 'lit'; 2 + import { app } from '../store.js'; 3 + import { GCN, MATADISCO } from '../types.js'; 4 + import type { GcnRecord, MatadiscoRecord, Result } from '../types.js'; 5 + import { 6 + collectionLabel, 7 + pdsls, 8 + prettyDate, 9 + prettyResource, 10 + prettyTopic, 11 + splitPreviewText, 12 + } from '../format.js'; 13 + 14 + export class PodiumResultCard extends LitElement { 15 + static properties = { 16 + result: { attribute: false }, 17 + }; 18 + 19 + declare result: Result; 20 + 21 + static styles = css` 22 + :host { 23 + display: block; 24 + } 25 + .card { 26 + display: flex; 27 + gap: 1rem; 28 + justify-content: space-between; 29 + border: 1px solid var(--border); 30 + border-radius: 12px; 31 + background: var(--panel); 32 + padding: 0.9rem 1.1rem; 33 + transition: border-color 0.15s ease; 34 + } 35 + .card:hover { 36 + border-color: transparent; 37 + background: 38 + linear-gradient(var(--panel), var(--panel)) padding-box, 39 + var(--grad) border-box; 40 + } 41 + .body { 42 + min-width: 0; 43 + flex: 1; 44 + } 45 + .top { 46 + display: flex; 47 + align-items: baseline; 48 + gap: 0.7rem; 49 + font: 0.72rem var(--mono); 50 + letter-spacing: 0.06em; 51 + text-transform: uppercase; 52 + } 53 + .badge { 54 + padding: 0.1rem 0.5rem; 55 + border-radius: 99px; 56 + border: 1px solid; 57 + } 58 + .badge.matadisco { 59 + color: var(--pink); 60 + border-color: color-mix(in srgb, var(--pink) 45%, transparent); 61 + } 62 + .badge.gcn { 63 + color: var(--cyan); 64 + border-color: color-mix(in srgb, var(--cyan) 45%, transparent); 65 + } 66 + .badge.other { 67 + color: var(--muted); 68 + border-color: var(--border); 69 + text-transform: none; 70 + letter-spacing: 0; 71 + } 72 + .date { 73 + color: var(--faint); 74 + } 75 + .spacer { 76 + flex: 1; 77 + } 78 + .record-link { 79 + color: var(--faint); 80 + text-decoration: none; 81 + } 82 + .record-link:hover { 83 + color: var(--green); 84 + } 85 + h3 { 86 + margin: 0.45rem 0 0.2rem; 87 + font-size: 1rem; 88 + font-weight: 600; 89 + overflow-wrap: anywhere; 90 + } 91 + h3 a { 92 + color: var(--text); 93 + text-decoration: none; 94 + } 95 + h3 a:hover { 96 + background: var(--grad); 97 + background-clip: text; 98 + -webkit-background-clip: text; 99 + color: transparent; 100 + } 101 + .meta { 102 + color: var(--muted); 103 + font: 0.8rem var(--mono); 104 + overflow-wrap: anywhere; 105 + } 106 + .desc { 107 + margin: 0.35rem 0 0; 108 + color: var(--muted); 109 + font-size: 0.88rem; 110 + line-height: 1.45; 111 + } 112 + .tags { 113 + margin-top: 0.55rem; 114 + display: flex; 115 + flex-wrap: wrap; 116 + gap: 0.35rem; 117 + } 118 + .tags button { 119 + font: 0.75rem var(--mono); 120 + color: var(--muted); 121 + background: var(--panel-2); 122 + border: 1px solid var(--border); 123 + border-radius: 99px; 124 + padding: 0.15rem 0.6rem; 125 + cursor: pointer; 126 + } 127 + .tags button:hover { 128 + color: var(--green); 129 + border-color: color-mix(in srgb, var(--green) 45%, transparent); 130 + } 131 + .preview { 132 + flex-shrink: 0; 133 + align-self: center; 134 + } 135 + .preview img { 136 + width: 84px; 137 + height: 84px; 138 + object-fit: cover; 139 + border-radius: 8px; 140 + border: 1px solid var(--border); 141 + display: block; 142 + } 143 + `; 144 + 145 + private top(kind: string, date: string | undefined) { 146 + return html` 147 + <div class="top"> 148 + <span class="badge ${kind}">${collectionLabel(this.result.collection)}</span> 149 + ${date ? html`<span class="date">${date}</span>` : nothing} 150 + <span class="spacer"></span> 151 + <a class="record-link" href=${pdsls(this.result.uri)} target="_blank" rel="noopener" 152 + >record ↗</a 153 + > 154 + </div> 155 + `; 156 + } 157 + 158 + private matadisco(r: MatadiscoRecord) { 159 + const link = r.resource ?? r.metadata; 160 + const date = prettyDate(r.publishedAt ?? r.created); 161 + const img = 162 + r.preview?.url && r.preview.mimeType?.startsWith('image/') ? r.preview.url : undefined; 163 + const text = 164 + r.preview?.data && r.preview.mimeType?.startsWith('text/') 165 + ? splitPreviewText(r.preview.data) 166 + : undefined; 167 + const title = text?.title || (link ? prettyResource(link) : this.result.uri); 168 + return html` 169 + <div class="card"> 170 + <div class="body"> 171 + ${this.top('matadisco', date)} 172 + <h3> 173 + ${link 174 + ? html`<a href=${link} target="_blank" rel="noopener">${title}</a>` 175 + : title} 176 + </h3> 177 + ${text?.title && link ? html`<div class="meta">${prettyResource(link)}</div>` : nothing} 178 + ${text?.body ? html`<p class="desc">${text.body}</p>` : nothing} 179 + ${r.tags?.length 180 + ? html`<div class="tags"> 181 + ${r.tags.map( 182 + (t) => 183 + html`<button @click=${() => app.send({ type: 'query-input', value: t })}> 184 + ${t} 185 + </button>` 186 + )} 187 + </div>` 188 + : nothing} 189 + </div> 190 + ${img 191 + ? html`<div class="preview"><img src=${img} alt="" loading="lazy" /></div>` 192 + : nothing} 193 + </div> 194 + `; 195 + } 196 + 197 + private gcn(r: GcnRecord) { 198 + return html` 199 + <div class="card"> 200 + <div class="body"> 201 + ${this.top('gcn', prettyDate(r.createdAt))} 202 + <h3>${r.summary ?? prettyTopic(r.topic ?? 'transient event')}</h3> 203 + <div class="meta"> 204 + ${r.summary ? html`${prettyTopic(r.topic)} · ` : nothing}${r.topic}${r.eventID !== 205 + undefined 206 + ? html` · event #${r.eventID}` 207 + : nothing} 208 + </div> 209 + </div> 210 + </div> 211 + `; 212 + } 213 + 214 + private other() { 215 + return html` 216 + <div class="card"> 217 + <div class="body"> 218 + ${this.top('other', prettyDate(this.result.indexedAt))} 219 + <h3> 220 + <a href=${pdsls(this.result.uri)} target="_blank" rel="noopener" 221 + >${this.result.uri}</a 222 + > 223 + </h3> 224 + </div> 225 + </div> 226 + `; 227 + } 228 + 229 + override render() { 230 + const { collection, record } = this.result; 231 + if (collection === MATADISCO) return this.matadisco(record as MatadiscoRecord); 232 + if (collection === GCN) return this.gcn(record as GcnRecord); 233 + return this.other(); 234 + } 235 + } 236 + 237 + customElements.define('podium-result-card', PodiumResultCard);
+114
frontend/src/components/podium-search-box.ts
··· 1 + import { LitElement, html, css } from 'lit'; 2 + import { SignalWatcher } from '@lit-labs/signals'; 3 + import { app } from '../store.js'; 4 + 5 + export class PodiumSearchBox extends SignalWatcher(LitElement) { 6 + static styles = css` 7 + :host { 8 + display: block; 9 + } 10 + .box { 11 + position: relative; 12 + border-radius: 12px; 13 + border: 1px solid var(--border); 14 + background: var(--panel); 15 + transition: border-color 0.15s ease; 16 + } 17 + .box:focus-within { 18 + border-color: transparent; 19 + background: 20 + linear-gradient(var(--panel), var(--panel)) padding-box, 21 + var(--grad) border-box; 22 + box-shadow: 0 0 24px rgba(185, 103, 255, 0.15); 23 + } 24 + input { 25 + width: 100%; 26 + box-sizing: border-box; 27 + padding: 1rem 3rem 1rem 1.1rem; 28 + font: 1.05rem var(--mono); 29 + color: var(--text); 30 + background: none; 31 + border: none; 32 + outline: none; 33 + } 34 + input::placeholder { 35 + color: var(--faint); 36 + } 37 + .hint { 38 + position: absolute; 39 + right: 0.9rem; 40 + top: 50%; 41 + translate: 0 -50%; 42 + color: var(--faint); 43 + font: 0.8rem var(--mono); 44 + border: 1px solid var(--border); 45 + border-radius: 5px; 46 + padding: 0.1rem 0.45rem; 47 + pointer-events: none; 48 + } 49 + .spin { 50 + position: absolute; 51 + right: 0.9rem; 52 + top: 50%; 53 + translate: 0 -50%; 54 + font-size: 0.9rem; 55 + animation: turn 1.2s linear infinite; 56 + } 57 + @keyframes turn { 58 + to { 59 + rotate: 360deg; 60 + } 61 + } 62 + @media (prefers-reduced-motion: reduce) { 63 + .spin { 64 + animation: none; 65 + } 66 + } 67 + `; 68 + 69 + private onKey = (e: KeyboardEvent) => { 70 + if (e.key === '/' && !e.metaKey && !e.ctrlKey && !e.altKey) { 71 + const active = document.activeElement; 72 + if (active instanceof HTMLElement && active.closest('input, textarea')) return; 73 + e.preventDefault(); 74 + this.focusInput(); 75 + } 76 + }; 77 + 78 + override connectedCallback() { 79 + super.connectedCallback(); 80 + window.addEventListener('keydown', this.onKey); 81 + } 82 + 83 + override disconnectedCallback() { 84 + super.disconnectedCallback(); 85 + window.removeEventListener('keydown', this.onKey); 86 + } 87 + 88 + focusInput() { 89 + this.renderRoot.querySelector('input')?.focus(); 90 + } 91 + 92 + override render() { 93 + const s = app.get(); 94 + return html` 95 + <div class="box"> 96 + <input 97 + type="search" 98 + .value=${s.query} 99 + placeholder="search open data — tags, resources, event topics…" 100 + autocomplete="off" 101 + autofocus 102 + @input=${(e: InputEvent) => 103 + app.send({ type: 'query-input', value: (e.target as HTMLInputElement).value })} 104 + @keydown=${(e: KeyboardEvent) => { 105 + if (e.key === 'Escape') app.send({ type: 'query-input', value: '' }); 106 + }} 107 + /> 108 + ${s.searching ? html`<span class="spin">🪩</span>` : html`<span class="hint">/</span>`} 109 + </div> 110 + `; 111 + } 112 + } 113 + 114 + customElements.define('podium-search-box', PodiumSearchBox);
+52
frontend/src/format.ts
··· 1 + import { GCN, MATADISCO } from './types.js'; 2 + 3 + export const pdsls = (uri: string) => `https://pdsls.dev/${uri}`; 4 + 5 + export const collectionLabel = (c: string) => 6 + c === MATADISCO ? 'Matadisco' : c === GCN ? 'Astrosky · GCN' : c; 7 + 8 + // Compact variant for tight spots like the stats line. 9 + export const collectionShortLabel = (c: string) => 10 + c === MATADISCO ? 'Matadisco' : c === GCN ? 'GCN' : c; 11 + 12 + const TOPIC_LABELS: Record<string, string> = { 13 + 'igwn.gwalert': 'gravitational wave alert', 14 + 'gcn.circulars': 'GCN circular', 15 + 'gcn.heartbeat': 'GCN heartbeat', 16 + 'gcn.notices.chime.frb': 'CHIME fast radio burst', 17 + 'gcn.notices.dsa110.frb': 'DSA-110 fast radio burst', 18 + 'gcn.notices.einstein_probe.wxt.alert': 'Einstein Probe WXT alert', 19 + 'gcn.notices.superk.sn_alert': 'Super-K supernova alert', 20 + 'gcn.notices.icecube.gold_bronze_track_alerts': 'IceCube track alert', 21 + 'gcn.notices.icecube.lvk_nu_track_search': 'IceCube LVK neutrino search', 22 + 'gcn.notices.swift.bat.guano': 'Swift BAT GUANO', 23 + }; 24 + 25 + // Known topics get a friendly name; the rest get de-dotted. 26 + export const prettyTopic = (topic: string) => 27 + TOPIC_LABELS[topic] ?? 28 + topic.replace(/^(gcn\.notices\.|gcn\.|igwn\.)/, '').replace(/[._]/g, ' '); 29 + 30 + export const prettyDate = (iso?: string) => { 31 + if (!iso) return ''; 32 + const d = new Date(iso); 33 + if (Number.isNaN(d.getTime())) return iso.slice(0, 10); 34 + return d.toISOString().slice(0, 10); 35 + }; 36 + 37 + // Show a resource URL compactly: drop the scheme, trim to something scannable. 38 + export const prettyResource = (url: string) => { 39 + const bare = url.replace(/^https?:\/\//, '').replace(/\/$/, ''); 40 + return bare.length > 90 ? `${bare.slice(0, 87)}…` : bare; 41 + }; 42 + 43 + // Text previews are "title\n\nbody-ish" (sometimes markdown) — split for display. 44 + export const splitPreviewText = (data: string) => { 45 + const [first = '', ...rest] = data.split('\n'); 46 + const title = first.replace(/^#+\s*/, '').trim(); 47 + const body = rest.join(' ').trim(); 48 + return { 49 + title, 50 + body: body && body !== title ? (body.length > 180 ? `${body.slice(0, 177)}…` : body) : '', 51 + }; 52 + };
+1
frontend/src/main.ts
··· 1 + import './components/podium-app.js';
+111
frontend/src/store.ts
··· 1 + import { store, tx, type Tx } from 'refrakt/store.js'; 2 + import { assertNever } from 'refrakt/never.js'; 3 + import * as api from './api.js'; 4 + import type { Result, Stats } from './types.js'; 5 + 6 + export type Model = { 7 + query: string; 8 + collection: string | null; 9 + results: Result[]; 10 + searching: boolean; 11 + stats: Stats | null; 12 + error: string | null; 13 + }; 14 + 15 + export type Action = 16 + | { type: 'boot'; query?: string; collection?: string | null } 17 + | { type: 'stats-loaded'; stats: Stats } 18 + | { type: 'query-input'; value: string } 19 + | { type: 'set-collection'; collection: string | null } 20 + | { type: 'results'; forQuery: string; forCollection: string | null; results: Result[] } 21 + | { type: 'fetch-failed'; forQuery: string; forCollection: string | null; message: string }; 22 + 23 + const DEBOUNCE_MS = 250; 24 + 25 + const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); 26 + 27 + // Keep the URL shareable: reflect the current search into ?q= and ?in=. 28 + const syncUrl = (query: string, collection: string | null) => { 29 + const url = new URL(location.href); 30 + if (query) url.searchParams.set('q', query); 31 + else url.searchParams.delete('q'); 32 + if (collection) url.searchParams.set('in', collection); 33 + else url.searchParams.delete('in'); 34 + history.replaceState(null, '', url); 35 + }; 36 + 37 + // Fetch matching the current query/filter; empty query means "recent". 38 + // Stale responses are dropped both here and in the reducer. 39 + const fetchFx = (query: string, collection: string | null) => 40 + async function* (state: () => Model): AsyncGenerator<Action> { 41 + syncUrl(query, collection); 42 + if (query) await sleep(DEBOUNCE_MS); 43 + const s = state(); 44 + if (s.query !== query || s.collection !== collection) return; 45 + try { 46 + const res = query 47 + ? await api.search(query, collection ?? undefined) 48 + : await api.listRecords(collection ?? undefined); 49 + yield { type: 'results', forQuery: query, forCollection: collection, results: res.results }; 50 + } catch (err) { 51 + yield { 52 + type: 'fetch-failed', 53 + forQuery: query, 54 + forCollection: collection, 55 + message: err instanceof Error ? err.message : String(err), 56 + }; 57 + } 58 + }; 59 + 60 + const bootFx = (query: string, collection: string | null) => 61 + async function* (state: () => Model): AsyncGenerator<Action> { 62 + const statsP = api.stats().catch(() => null); 63 + yield* fetchFx(query, collection)(state); 64 + const stats = await statsP; 65 + if (stats) yield { type: 'stats-loaded', stats }; 66 + }; 67 + 68 + const isStale = (state: Model, forQuery: string, forCollection: string | null) => 69 + state.query !== forQuery || state.collection !== forCollection; 70 + 71 + const update = (state: Model, action: Action): Tx<Model, Action> => { 72 + switch (action.type) { 73 + case 'boot': { 74 + const query = action.query ?? state.query; 75 + const collection = action.collection ?? state.collection; 76 + return tx<Model, Action>( 77 + { ...state, query, collection, searching: true }, 78 + bootFx(query, collection) 79 + ); 80 + } 81 + case 'stats-loaded': 82 + return tx<Model, Action>({ ...state, stats: action.stats }); 83 + case 'query-input': 84 + return tx<Model, Action>( 85 + { ...state, query: action.value, searching: true, error: null }, 86 + fetchFx(action.value, state.collection) 87 + ); 88 + case 'set-collection': 89 + return tx<Model, Action>( 90 + { ...state, collection: action.collection, searching: true, error: null }, 91 + fetchFx(state.query, action.collection) 92 + ); 93 + case 'results': 94 + if (isStale(state, action.forQuery, action.forCollection)) return tx<Model, Action>(state); 95 + return tx<Model, Action>({ ...state, results: action.results, searching: false, error: null }); 96 + case 'fetch-failed': 97 + if (isStale(state, action.forQuery, action.forCollection)) return tx<Model, Action>(state); 98 + return tx<Model, Action>({ ...state, searching: false, error: action.message }); 99 + default: 100 + return assertNever(action); 101 + } 102 + }; 103 + 104 + export const app = store(update, { 105 + query: '', 106 + collection: null, 107 + results: [], 108 + searching: false, 109 + stats: null, 110 + error: null, 111 + });
+38
frontend/src/types.ts
··· 1 + export const MATADISCO = 'cx.vmx.matadisco'; 2 + export const GCN = 'eco.astrosky.transient.gcn'; 3 + 4 + // Records on the network have drifted from the published lexicon: some 5 + // producers use `resource` + `publishedAt` (+ `tags`, image `preview`), 6 + // others `metadata` + `created` with a text `preview.data` snippet. 7 + export type MatadiscoRecord = { 8 + $type?: string; 9 + resource?: string; 10 + metadata?: string; 11 + publishedAt?: string; 12 + created?: string; 13 + preview?: { mimeType?: string; url?: string; data?: string }; 14 + tags?: string[] | null; 15 + [k: string]: unknown; 16 + }; 17 + 18 + // `data` (the raw GCN event JSON) is stripped server-side and replaced 19 + // with a short `summary`. 20 + export type GcnRecord = { 21 + $type?: string; 22 + topic: string; 23 + eventID?: number; 24 + createdAt?: string; 25 + summary?: string; 26 + hasData?: boolean; 27 + [k: string]: unknown; 28 + }; 29 + 30 + export type Result = { 31 + uri: string; 32 + collection: string; 33 + indexedAt?: string; 34 + record: MatadiscoRecord | GcnRecord | Record<string, unknown>; 35 + }; 36 + 37 + export type CollectionStat = { collection: string; count: number }; 38 + export type Stats = { total: number; collections: CollectionStat[] };
+16
frontend/tsconfig.json
··· 1 + { 2 + "compilerOptions": { 3 + "target": "ES2022", 4 + "lib": ["ES2022", "DOM", "DOM.Iterable"], 5 + "module": "ESNext", 6 + "moduleResolution": "bundler", 7 + "strict": true, 8 + "noUncheckedIndexedAccess": true, 9 + "noEmit": true, 10 + "skipLibCheck": true, 11 + "isolatedModules": true, 12 + "verbatimModuleSyntax": true, 13 + "useDefineForClassFields": true 14 + }, 15 + "include": ["src", "vite.config.ts"] 16 + }
+17
frontend/vite.config.ts
··· 1 + import { defineConfig } from 'vite'; 2 + 3 + // In dev, XRPC goes straight to the local HappyView container (published on 4 + // 127.0.0.1:3300 by compose.yaml), mirroring what the podium Caddy does in 5 + // the deployed container. 6 + export default defineConfig({ 7 + server: { 8 + proxy: { 9 + '/hv': { target: 'http://127.0.0.1:3300', changeOrigin: true }, 10 + '/xrpc': { 11 + target: 'http://127.0.0.1:3300', 12 + changeOrigin: true, 13 + rewrite: (path) => `/hv${path}`, 14 + }, 15 + }, 16 + }, 17 + });
+23
package.json
··· 1 + { 2 + "name": "podium", 3 + "version": "0.1.0", 4 + "description": "A search engine for open data on ATProto (Matadisco, Astrosky/Nebra, …)", 5 + "private": true, 6 + "type": "module", 7 + "bin": { 8 + "podium": "bin/podium.js" 9 + }, 10 + "scripts": { 11 + "deploy": "node bin/podium.js deploy", 12 + "deploy:local": "node bin/podium.js deploy --local", 13 + "push": "node bin/podium.js push", 14 + "push:local": "node bin/podium.js push --local", 15 + "watch": "node bin/podium.js push --local --watch", 16 + "dev": "npm --prefix frontend run dev" 17 + }, 18 + "engines": { 19 + "node": ">=20" 20 + }, 21 + "author": "Robin Berjon <robin@berjon.com>", 22 + "license": "MIT" 23 + }
+34
podium.config.json
··· 1 + { 2 + "networkLexicons": [ 3 + { "nsid": "cx.vmx.matadisco" }, 4 + { "nsid": "eco.astrosky.transient.gcn" } 5 + ], 6 + "lexicons": [ 7 + { "file": "backend/lexicons/it.mycopunk.podium.search.json" }, 8 + { "file": "backend/lexicons/it.mycopunk.podium.listRecords.json" }, 9 + { "file": "backend/lexicons/it.mycopunk.podium.getRecord.json" }, 10 + { "file": "backend/lexicons/it.mycopunk.podium.stats.json" } 11 + ], 12 + "scripts": [ 13 + { 14 + "trigger": "xrpc.query:it.mycopunk.podium.search", 15 + "file": "backend/scripts/search.lua", 16 + "description": "Cross-collection full-text search for Podium" 17 + }, 18 + { 19 + "trigger": "xrpc.query:it.mycopunk.podium.listRecords", 20 + "file": "backend/scripts/listRecords.lua", 21 + "description": "Recent records, merged or per collection" 22 + }, 23 + { 24 + "trigger": "xrpc.query:it.mycopunk.podium.getRecord", 25 + "file": "backend/scripts/getRecord.lua", 26 + "description": "Fetch a single indexed record by AT URI" 27 + }, 28 + { 29 + "trigger": "xrpc.query:it.mycopunk.podium.stats", 30 + "file": "backend/scripts/stats.lua", 31 + "description": "Per-collection record counts" 32 + } 33 + ] 34 + }
+11
service.json
··· 1 + { 2 + "name": "podium", 3 + "type": "node", 4 + "domains": { 5 + "local": ["podium.bast"], 6 + "prod": ["podium.mycopunk.it"] 7 + }, 8 + "headers": true, 9 + "upstreamPort": 80, 10 + "data": true 11 + }