diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4e23f9d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +* +!Caddyfile +!frontend +frontend/node_modules +frontend/dist diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2908141 --- /dev/null +++ b/.env.example @@ -0,0 +1,30 @@ +# Copy to .env.local (local dev) and .env.prod (production), then edit. +# `podium deploy` copies the right one to .env before running `sm deploy`: +# docker compose reads .env for interpolation, and sm rsyncs it to the server. + +# Public URL of the HappyView instance (OAuth callbacks, dashboard links). +# Local: the loopback port published by compose, so atproto OAuth loopback +# login works without a publicly reachable URL. +PODIUM_PUBLIC_URL=http://127.0.0.1:3300/hv +# Prod: PODIUM_PUBLIC_URL=https://podium.mycopunk.it/hv + +# Session cookie signing secret, 64+ chars: `openssl rand -hex 48` +PODIUM_SESSION_SECRET= + +# Where `podium push` sends lexicons and Lua scripts. +PODIUM_HV_URL=http://127.0.0.1:3300/hv +# Prod: PODIUM_HV_URL=https://podium.mycopunk.it/hv + +# Admin API key for `podium push`. Locally, `podium bootstrap --local` +# fills this in; in prod create one in the HappyView dashboard (Settings > +# API Keys β€” needs lexicons + scripts + settings + backfill permissions). +PODIUM_HV_KEY= + +# Public origins HappyView should accept (it 421s unknown Hosts; the +# PUBLIC_URL host is always accepted). Comma-separated. +PODIUM_DOMAINS=https://podium.bast +# Prod: PODIUM_DOMAINS=https://podium.mycopunk.it + +# Local only: host directory for service data (SQLite lives here). Must be +# somewhere Docker Desktop can mount. The server sets this itself. +SM_DATA_ROOT=/Users/robin/.supramundane/data diff --git a/Caddyfile b/Caddyfile new file mode 100644 index 0000000..0dfbba5 --- /dev/null +++ b/Caddyfile @@ -0,0 +1,23 @@ +# Internal Caddy for the podium container β€” TLS terminates at the front proxy. +:80 { + encode zstd gzip + + # HappyView (dashboard, admin API, XRPC) mounted under /hv + @hv path /hv /hv/* + handle @hv { + reverse_proxy podium-hv:3000 + } + + # Standard XRPC path for protocol-native consumers + handle /xrpc/* { + rewrite * /hv{uri} + reverse_proxy podium-hv:3000 + } + + # The Podium frontend + handle { + root * /srv + try_files {path} /index.html + file_server + } +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..974ad72 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM node:24-alpine AS build +WORKDIR /app +COPY frontend/package.json frontend/package-lock.json ./ +RUN npm ci +COPY frontend/ ./ +RUN npm run build + +FROM caddy:2-alpine +COPY Caddyfile /etc/caddy/Caddyfile +COPY --from=build /app/dist /srv diff --git a/README.md b/README.md index 16f1b7c..2c95ef6 100644 --- a/README.md +++ b/README.md @@ -1 +1,129 @@ -# podium + +# πŸͺ© Podium + +A search engine for open data published on the Atmosphere: [Matadisco](https://matadisco.org/) +dataset records, [Astrosky](https://astrosky.eco/)/[Nebra](https://github.com/the-astrosky-ecosystem/nebra) +transient astronomy events, and whatever else gets added over time. + +Live at `https://podium.mycopunk.it`. + +## How it works + +``` +podium.mycopunk.it + β”‚ + caddy-front ──────────── reverse_proxy http://podium:80 (supramundane network) + β”‚ + β”Œβ”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ podium (caddy) β”‚ + β”‚ / β†’ the frontend β”‚ + β”‚ /xrpc/* β†’ rewritten to /hv/xrpc/* β”‚ + β”‚ /hv/* β†’ podium-hv β”‚ + β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ + β”‚ podium-hv (HappyView) β”‚ + β”‚ indexes cx.vmx.matadisco + eco.astrosky.transient β”‚ + β”‚ .gcn from Jetstream + backfill, SQLite on /data, β”‚ + β”‚ serves XRPC, Lua query scripts, admin dashboard β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +- **Backend** is [HappyView](https://happyview.dev/): a lexicon-driven AppView. + The collections to index, Podium's own query lexicons + (`it.mycopunk.podium.*`), and the Lua scripts that implement them all live in + this repo (`backend/`, wired up by `podium.config.json`) and are pushed to + the instance through its admin API β€” no pasting into dashboards. +- **Frontend** is Lit 3 + [refrakt](https://github.com/gordonbrander/refrakt). +- **Deployment** is a Supramundane service: `service.json` + + a hand-authored multi-container `compose.yaml` on the `supramundane` network. + HappyView's SQLite lives in `${SM_DATA_ROOT}/podium`, so it's backed up + in prod. + +## XRPC endpoints + +| Query | What it does | +| --- | --- | +| `it.mycopunk.podium.search?q=&collection=&limit=` | full-text search across collections (per-collection field lists in `backend/scripts/search.lua`) | +| `it.mycopunk.podium.listRecords?collection=&limit=&cursor=` | freshest records, merged or per collection | +| `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`) | +| `it.mycopunk.podium.stats` | per-collection record counts | + +All available on both `/xrpc/…` and `/hv/xrpc/…`. + +## The `podium` CLI + +`npm link` once (or call `node bin/podium.js`). Like `sm`, commands hit prod by +default; add `--local` for the local stack. + +```sh +podium deploy [--local] [--no-push] # env file β†’ .env, sm deploy, then push +podium push [--local] [--watch] # sync domains, lexicons + Lua scripts to HappyView +podium backfill [collection] [--local] # (re)start historical backfill +podium bootstrap --local # first-boot: seed super user + API key (local only) +podium status [--local] # what the instance currently has +``` + +`push --watch` re-pushes on every save under `backend/` β€” that's the dev loop +for Lua/lexicon work. Frontend dev is `npm run dev` (Vite on :5173, proxying +XRPC to the local HappyView on 127.0.0.1:3300). + +## First boot + +**Local** (needs Docker, the `supramundane` network from a running +caddy-front, and `.bast` DNS): + +```sh +cp .env.example .env.local # fill in PODIUM_SESSION_SECRET, check SM_DATA_ROOT +podium deploy --local # brings the stack up (no push yet β€” no key) +podium bootstrap robin.berjon.com --local # seeds super user + API key into .env.local +podium push --local # domains, lexicons, scripts, kicks off backfill +``` + +The atproto OAuth dance can't run against a `.bast` domain (the PDS must fetch +the client metadata), which is why local uses `bootstrap` (a direct SQLite +seed, done with the container stopped) and `PODIUM_PUBLIC_URL` points at the +loopback port. The dashboard is at `http://127.0.0.1:3300/hv/dashboard/`, and +logging in there with the bootstrapped handle works because the seeded user +*is* that identity. + +**Prod**: + +```sh +cp .env.example .env.prod # prod URLs, fresh PODIUM_SESSION_SECRET, no key yet +podium deploy # rsyncs + builds on $SUPRAMUNDANE, wires the front +# then in a browser: https://podium.mycopunk.it/hv β†’ log in with your handle +# (first login becomes super user) β†’ Settings > API Keys β†’ create a key with +# lexicons/network-lexicons/scripts/settings/backfill permissions +# put it in .env.prod as PODIUM_HV_KEY, then: +podium push +``` + +## Things learned the hard way + +- **HappyView 421s any Host it doesn't know.** Its primary domain comes from + `PUBLIC_URL`; every other public origin (e.g. `https://podium.bast`) must be + registered, which `podium push` does from `PODIUM_DOMAINS`. +- **Don't touch the SQLite file while the container runs.** It's on a Docker + bind mount; host-side writes against the live WAL corrupt it (this is why + `bootstrap` stops the container first). If it happens anyway: stop the + stack, delete `happyview.db*`, start, re-bootstrap, re-push. +- **Re-POSTing a network lexicon is not idempotent** β€” it re-fetches and + re-kicks sync machinery. `podium push` therefore only creates missing ones, + and only auto-backfills on creation. +- **The records in the wild don't all match the published lexicons.** + Matadisco has (at least) three shapes: `resource`/`publishedAt`(/`tags`), + and `metadata`/`created` with a text or image `preview`. Search fields and + the result cards handle all of them; keep that in mind when adding sources. +- GCN `data` payloads can be tens of KB, so list/search responses strip them + server-side (`slim()` in the Lua scripts) and keep a human `summary` + extracted from the event JSON. + +## Adding a data source + +1. Add its NSID to `networkLexicons` in `podium.config.json` (or a local + lexicon file under `backend/lexicons/` if it isn't resolvable on-network). +2. Add a source entry (collection + searchable fields) to + `backend/scripts/search.lua`, and to the `COLLECTIONS` lists in + `listRecords.lua` / `stats.lua`. +3. Teach `frontend/src/components/podium-result-card.ts` how to render it + (falls back to a generic card otherwise). +4. `podium push --local`, check, `podium push`. diff --git a/backend/lexicons/it.mycopunk.podium.getRecord.json b/backend/lexicons/it.mycopunk.podium.getRecord.json new file mode 100644 index 0000000..9046b11 --- /dev/null +++ b/backend/lexicons/it.mycopunk.podium.getRecord.json @@ -0,0 +1,30 @@ +{ + "lexicon": 1, + "id": "it.mycopunk.podium.getRecord", + "defs": { + "main": { + "type": "query", + "description": "Fetch a single indexed record (full, unslimmed) by AT URI.", + "parameters": { + "type": "params", + "required": ["uri"], + "properties": { + "uri": { + "type": "string", + "format": "at-uri" + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["record"], + "properties": { + "record": { "type": "unknown" } + } + } + } + } + } +} diff --git a/backend/lexicons/it.mycopunk.podium.listRecords.json b/backend/lexicons/it.mycopunk.podium.listRecords.json new file mode 100644 index 0000000..7bd924c --- /dev/null +++ b/backend/lexicons/it.mycopunk.podium.listRecords.json @@ -0,0 +1,41 @@ +{ + "lexicon": 1, + "id": "it.mycopunk.podium.listRecords", + "defs": { + "main": { + "type": "query", + "description": "Most recently indexed records, merged across collections or scoped to one (with cursor pagination).", + "parameters": { + "type": "params", + "properties": { + "collection": { + "type": "string", + "format": "nsid", + "description": "Restrict to a single collection; enables cursor pagination" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 25 + }, + "cursor": { "type": "string" } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["results"], + "properties": { + "results": { + "type": "array", + "items": { "type": "unknown" } + }, + "cursor": { "type": "string" } + } + } + } + } + } +} diff --git a/backend/lexicons/it.mycopunk.podium.search.json b/backend/lexicons/it.mycopunk.podium.search.json new file mode 100644 index 0000000..0417bdc --- /dev/null +++ b/backend/lexicons/it.mycopunk.podium.search.json @@ -0,0 +1,45 @@ +{ + "lexicon": 1, + "id": "it.mycopunk.podium.search", + "defs": { + "main": { + "type": "query", + "description": "Full-text search across the collections Podium indexes.", + "parameters": { + "type": "params", + "required": ["q"], + "properties": { + "q": { + "type": "string", + "description": "Search terms" + }, + "collection": { + "type": "string", + "format": "nsid", + "description": "Restrict results to a single collection" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 25 + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["results"], + "properties": { + "q": { "type": "string" }, + "results": { + "type": "array", + "items": { "type": "unknown" } + } + } + } + } + } + } +} diff --git a/backend/lexicons/it.mycopunk.podium.stats.json b/backend/lexicons/it.mycopunk.podium.stats.json new file mode 100644 index 0000000..73bae01 --- /dev/null +++ b/backend/lexicons/it.mycopunk.podium.stats.json @@ -0,0 +1,24 @@ +{ + "lexicon": 1, + "id": "it.mycopunk.podium.stats", + "defs": { + "main": { + "type": "query", + "description": "Record counts for every collection Podium indexes.", + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["total", "collections"], + "properties": { + "total": { "type": "integer" }, + "collections": { + "type": "array", + "items": { "type": "unknown" } + } + } + } + } + } + } +} diff --git a/backend/scripts/getRecord.lua b/backend/scripts/getRecord.lua new file mode 100644 index 0000000..8895709 --- /dev/null +++ b/backend/scripts/getRecord.lua @@ -0,0 +1,6 @@ +function handle() + if not params.uri then error("missing required parameter: uri") end + local record = db.get(params.uri) + if not record then error("record not found") end + return { record = record } +end diff --git a/backend/scripts/listRecords.lua b/backend/scripts/listRecords.lua new file mode 100644 index 0000000..14462c1 --- /dev/null +++ b/backend/scripts/listRecords.lua @@ -0,0 +1,55 @@ +-- Recent records: per collection (with cursor) or merged across all of them. + +local COLLECTIONS = { "cx.vmx.matadisco", "eco.astrosky.transient.gcn" } + +-- GCN records carry the full raw event JSON, which can be huge; replace it +-- with a short display summary (getRecord serves the full record). +local function slim(collection, r) + if collection == "eco.astrosky.transient.gcn" and r.data ~= nil then + local ok, d = pcall(json.decode, r.data) + if ok and type(d) == "table" then + r.summary = d.subject + or (d.superevent_id and ((d.alert_type and (d.alert_type .. " ") or "") .. d.superevent_id)) + or d.id + end + r.data = nil + r.hasData = true + end + return r +end + +local function wrap(collection, res, out) + for _, r in ipairs(res.records or {}) do + out[#out + 1] = { + uri = r.uri, + collection = collection, + indexedAt = r.indexedAt or r.indexed_at, + record = slim(collection, r), + } + end +end + +function handle() + local limit = math.min(tonumber(params.limit) or 25, 100) + + if params.collection then + local res = db.query({ collection = params.collection, limit = limit, cursor = params.cursor }) + local out = {} + wrap(params.collection, res, out) + return { results = toarray(out), cursor = res.cursor } + end + + -- Merged view: no cursor, just the freshest records across collections. + local out = {} + for _, c in ipairs(COLLECTIONS) do + local ok, res = pcall(db.query, { collection = c, limit = limit }) + if ok then + wrap(c, res, out) + else + log("query failed on " .. c .. ": " .. tostring(res)) + end + end + table.sort(out, function(a, b) return (a.indexedAt or "") > (b.indexedAt or "") end) + while #out > limit do table.remove(out) end + return { results = toarray(out) } +end diff --git a/backend/scripts/search.lua b/backend/scripts/search.lua new file mode 100644 index 0000000..77fbb97 --- /dev/null +++ b/backend/scripts/search.lua @@ -0,0 +1,66 @@ +-- Cross-collection search. Each source lists the record fields worth +-- searching (dot notation reaches into nested objects); hits are deduped +-- by URI and slimmed for transport. +-- +-- Field choices reflect the records actually on the network, which have +-- drifted from the published lexicons: most Matadisco producers use +-- `metadata` + `preview.data` rather than `resource` + `tags`. + +local SOURCES = { + { collection = "cx.vmx.matadisco", fields = { "tags", "resource", "metadata", "preview.data" } }, + { collection = "eco.astrosky.transient.gcn", fields = { "topic", "data" } }, +} + +-- GCN records carry the full raw event JSON, which can be huge; replace it +-- with a short display summary (getRecord serves the full record). +local function slim(collection, r) + if collection == "eco.astrosky.transient.gcn" and r.data ~= nil then + local ok, d = pcall(json.decode, r.data) + if ok and type(d) == "table" then + r.summary = d.subject + or (d.superevent_id and ((d.alert_type and (d.alert_type .. " ") or "") .. d.superevent_id)) + or d.id + end + r.data = nil + r.hasData = true + end + return r +end + +local function collect(out, seen, collection, res) + if not res or not res.records then return end + for _, r in ipairs(res.records) do + if r.uri and not seen[r.uri] then + seen[r.uri] = true + out[#out + 1] = { uri = r.uri, collection = collection, record = slim(collection, r) } + end + end +end + +function handle() + local q = params.q + if not q or #q == 0 then error("missing required parameter: q") end + local limit = math.min(tonumber(params.limit) or 25, 100) + + local out, seen = {}, {} + for _, src in ipairs(SOURCES) do + if not params.collection or params.collection == src.collection then + for _, field in ipairs(src.fields) do + local ok, res = pcall(db.search, { + collection = src.collection, + field = field, + query = q, + limit = limit, + }) + if ok then + collect(out, seen, src.collection, res) + else + log("search failed on " .. src.collection .. "." .. field .. ": " .. tostring(res)) + end + end + end + end + + while #out > limit do table.remove(out) end + return { q = q, results = toarray(out) } +end diff --git a/backend/scripts/stats.lua b/backend/scripts/stats.lua new file mode 100644 index 0000000..af4b0c6 --- /dev/null +++ b/backend/scripts/stats.lua @@ -0,0 +1,12 @@ +local COLLECTIONS = { "cx.vmx.matadisco", "eco.astrosky.transient.gcn" } + +function handle() + local out, total = {}, 0 + for _, c in ipairs(COLLECTIONS) do + local ok, n = pcall(db.count, c) + n = ok and n or 0 + out[#out + 1] = { collection = c, count = n } + total = total + n + end + return { total = total, collections = toarray(out) } +end diff --git a/bin/podium.js b/bin/podium.js new file mode 100755 index 0000000..ff5b523 --- /dev/null +++ b/bin/podium.js @@ -0,0 +1,320 @@ +#!/usr/bin/env node +// Podium ops CLI. +// +// podium deploy [--local] [--no-push] env file β†’ .env, sm deploy, then push +// podium push [--local] [--watch] sync lexicons + Lua scripts to HappyView +// podium status [--local] list what the instance currently has +// +// Like sm, commands target production by default; pass --local for the +// local stack. Backend code lives in backend/ and podium.config.json is +// the manifest that maps it onto the HappyView admin API. + +import { readFile, copyFile } from 'node:fs/promises'; +import { watch } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import process from 'node:process'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const args = process.argv.slice(2); +const command = args.find((a) => !a.startsWith('-')); +const flags = new Set(args.filter((a) => a.startsWith('-'))); +const env = flags.has('--local') ? 'local' : 'prod'; + +const log = (msg) => console.log(msg); +const fail = (msg) => { + console.error(`podium: ${msg}`); + process.exit(1); +}; + +async function loadEnvFile() { + const file = resolve(root, `.env.${env}`); + let text; + try { + text = await readFile(file, 'utf8'); + } catch { + fail(`missing .env.${env} β€” copy .env.example and fill it in`); + } + const vars = {}; + for (const line of text.split('\n')) { + const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/); + if (m) vars[m[1]] = m[2].replace(/^(["'])(.*)\1$/, '$2'); + } + return { file, vars }; +} + +async function loadConfig() { + const text = await readFile(resolve(root, 'podium.config.json'), 'utf8'); + return JSON.parse(text); +} + +function makeApi({ vars }) { + const base = vars.PODIUM_HV_URL?.replace(/\/$/, ''); + if (!base) fail(`PODIUM_HV_URL is not set in .env.${env}`); + if (!vars.PODIUM_HV_KEY) { + fail( + `PODIUM_HV_KEY is not set in .env.${env}\n` + + ` Log into the HappyView dashboard (${base}/), create an API key under\n` + + ` Settings > API Keys with lexicons:create/read + scripts:manage/read,\n` + + ` and put it in .env.${env}.` + ); + } + return async (method, path, body) => { + const res = await fetch(`${base}${path}`, { + method, + headers: { + authorization: `Bearer ${vars.PODIUM_HV_KEY}`, + ...(body ? { 'content-type': 'application/json' } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + }); + const text = await res.text(); + let data; + try { + data = text ? JSON.parse(text) : null; + } catch { + data = text; + } + return { status: res.status, ok: res.ok, data }; + }; +} + +async function waitForInstance(api) { + for (let i = 0; i < 15; i++) { + try { + const res = await api('GET', '/admin/lexicons'); + if (res.status === 401 || res.status === 403) { + fail('the instance rejected PODIUM_HV_KEY (401/403) β€” check the key and its permissions'); + } + if (res.ok) return; + } catch { + // not up yet + } + if (i === 0) log('… waiting for HappyView to answer'); + await new Promise((r) => setTimeout(r, 2000)); + } + fail('HappyView did not answer on /admin/lexicons β€” is the stack up?'); +} + +async function push(envFile, config) { + const api = makeApi(envFile); + await waitForInstance(api); + + // HappyView 421s any Host it doesn't know (its primary domain comes from + // PUBLIC_URL); make sure the public-facing domains are registered. + const wanted = (envFile.vars.PODIUM_DOMAINS ?? '') + .split(',') + .map((d) => d.trim()) + .filter(Boolean); + if (wanted.length) { + const existing = await api('GET', '/admin/domains'); + const have = new Set( + (Array.isArray(existing.data) ? existing.data : []).map((d) => d.url?.replace(/\/$/, '')) + ); + for (const url of wanted) { + if (have.has(url.replace(/\/$/, ''))) continue; + const res = await api('POST', '/admin/domains', { url }); + if (res.ok) log(`βœ“ domain ${url}`); + else log(`βœ— domain ${url}: ${res.status} ${JSON.stringify(res.data)}`); + } + } + + // Re-POSTing an existing network lexicon re-fetches it and re-kicks + // jetstream/backfill machinery, so only create the missing ones. + const netExisting = await api('GET', '/admin/network-lexicons'); + const netHave = new Set( + (Array.isArray(netExisting.data) ? netExisting.data : []).map((l) => l.nsid) + ); + for (const { nsid, targetCollection } of config.networkLexicons ?? []) { + if (netHave.has(nsid)) { + log(`= network lexicon ${nsid}`); + continue; + } + const res = await api('POST', '/admin/network-lexicons', { + nsid, + target_collection: targetCollection ?? null, + }); + if (res.ok) log(`βœ“ network lexicon ${nsid}`); + else log(`βœ— network lexicon ${nsid}: ${res.status} ${JSON.stringify(res.data)}`); + // A fresh record collection has no history yet β€” kick off a backfill. + if (res.ok) await backfill(api, nsid); + } + + for (const { file, targetCollection, backfill } of config.lexicons ?? []) { + const lexicon = JSON.parse(await readFile(resolve(root, file), 'utf8')); + const res = await api('POST', '/admin/lexicons', { + lexicon_json: lexicon, + backfill: backfill ?? false, + target_collection: targetCollection ?? null, + }); + if (res.ok) log(`βœ“ lexicon ${lexicon.id}`); + else log(`βœ— lexicon ${lexicon.id}: ${res.status} ${JSON.stringify(res.data)}`); + } + + for (const { trigger, file, description } of config.scripts ?? []) { + const body = await readFile(resolve(root, file), 'utf8'); + const res = await api('POST', '/admin/scripts', { + id: trigger, + script_type: 'lua', + body, + description, + }); + if (res.ok) log(`βœ“ script ${trigger}`); + else log(`βœ— script ${trigger}: ${res.status} ${JSON.stringify(res.data)}`); + } +} + +async function backfill(api, collection) { + const res = await api('POST', '/admin/backfill', collection ? { collection } : {}); + if (res.ok) log(`⟳ backfill started${collection ? ` for ${collection}` : ''} (job ${res.data?.id ?? '?'})`); + else log(`βœ— backfill${collection ? ` for ${collection}` : ''}: ${res.status} ${JSON.stringify(res.data)}`); +} + +async function cmdBackfill() { + const envFile = await loadEnvFile(); + const api = makeApi(envFile); + await waitForInstance(api); + const collection = args.filter((a) => !a.startsWith('-'))[1]; + if (collection) return backfill(api, collection); + const config = await loadConfig(); + for (const { nsid } of config.networkLexicons ?? []) await backfill(api, nsid); +} + +async function cmdPush() { + const envFile = await loadEnvFile(); + const config = await loadConfig(); + await push(envFile, config); + + if (!flags.has('--watch')) return; + log(`\nwatching backend/ and podium.config.json (${env}) β€” ^C to stop`); + let timer = null; + const kick = (what) => { + clearTimeout(timer); + timer = setTimeout(async () => { + log(`\n↻ ${what} changed`); + try { + await push(await loadEnvFile(), await loadConfig()); + } catch (err) { + log(`βœ— push failed: ${err.message}`); + } + }, 250); + }; + watch(resolve(root, 'backend'), { recursive: true }, (_e, f) => kick(f ?? 'backend')); + watch(resolve(root, 'podium.config.json'), () => kick('podium.config.json')); + await new Promise(() => {}); +} + +async function cmdDeploy() { + const envFile = await loadEnvFile(); + await copyFile(envFile.file, resolve(root, '.env')); + log(`.env ← .env.${env}`); + + const smArgs = ['deploy']; + if (env === 'local') smArgs.push('--local'); + const res = spawnSync('sm', smArgs, { cwd: root, stdio: 'inherit' }); + if (res.error?.code === 'ENOENT') fail('sm not found on PATH (npm link it from caddy-front)'); + if (res.status !== 0) fail(`sm deploy exited with ${res.status}`); + + if (flags.has('--no-push')) return; + if (!envFile.vars.PODIUM_HV_KEY) { + log('\nNo PODIUM_HV_KEY yet, skipping backend push. First-boot bootstrap:'); + log(` 1. open ${envFile.vars.PODIUM_HV_URL ?? 'the HappyView dashboard'}/`); + log(' 2. log in with your atproto handle (first login becomes super user)'); + log(' 3. Settings > API Keys β†’ create a key (lexicons:create/read, scripts:manage/read)'); + log(` 4. put it in .env.${env} as PODIUM_HV_KEY and run: podium push${env === 'local' ? ' --local' : ''}`); + return; + } + await push(envFile, await loadConfig()); +} + +// First-boot helper for environments where the dashboard's atproto OAuth +// can't run: seeds a super user + API key straight into SQLite. Only safe +// with HappyView stopped (host + container sharing a live WAL db over a +// Docker bind mount corrupts it), so this stops and restarts the container. +async function cmdBootstrap() { + if (env !== 'local') fail('bootstrap only works on the local stack (use the dashboard in prod)'); + const { execSync } = await import('node:child_process'); + const crypto = await import('node:crypto'); + const envFile = await loadEnvFile(); + if (envFile.vars.PODIUM_HV_KEY) fail('.env.local already has PODIUM_HV_KEY β€” nothing to do'); + + const handleArg = args.filter((a) => !a.startsWith('-'))[1]; + if (!handleArg) fail('usage: podium bootstrap --local'); + let did = handleArg; + if (!did.startsWith('did:')) { + const res = await fetch( + `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${handleArg}` + ); + if (!res.ok) fail(`could not resolve handle ${handleArg}`); + did = (await res.json()).did; + log(`${handleArg} β†’ ${did}`); + } + + const dataRoot = envFile.vars.SM_DATA_ROOT; + if (!dataRoot) fail('SM_DATA_ROOT is not set in .env.local'); + const db = `${dataRoot}/podium/happyview.db`; + + const rawKey = `hv_${crypto.randomBytes(16).toString('hex')}`; + const hash = crypto.createHash('sha256').update(rawKey).digest('hex'); + const run = (cmd, input) => + execSync(cmd, { cwd: root, input, stdio: [input ? 'pipe' : 'ignore', 'pipe', 'inherit'] }); + + run('docker compose stop podium-hv'); + try { + const sql = ` +INSERT OR IGNORE INTO happyview_users (id, did, is_super, created_at) + VALUES ('${crypto.randomUUID()}', '${did}', 1, datetime('now')); +INSERT INTO happyview_api_keys (id, user_id, name, key_hash, key_prefix, permissions, created_at) + SELECT '${crypto.randomUUID()}', id, 'podium bootstrap', '${hash}', '${rawKey.slice(0, 11)}', '[]', datetime('now') + FROM happyview_users WHERE did = '${did}';`; + run(`sqlite3 ${JSON.stringify(db)}`, sql); + } finally { + run('docker compose start podium-hv'); + } + + const envText = await readFile(envFile.file, 'utf8'); + const updated = envText.replace(/^PODIUM_HV_KEY=.*$/m, `PODIUM_HV_KEY=${rawKey}`); + const { writeFile } = await import('node:fs/promises'); + await writeFile(envFile.file, updated.includes(rawKey) ? updated : `${envText}\nPODIUM_HV_KEY=${rawKey}\n`); + log(`βœ“ super user ${did} seeded, API key written to .env.local`); + log(' run: podium push --local'); +} + +async function cmdStatus() { + const envFile = await loadEnvFile(); + const api = makeApi(envFile); + const [lex, scripts] = await Promise.all([ + api('GET', '/admin/lexicons'), + api('GET', '/admin/scripts'), + ]); + if (!lex.ok || !scripts.ok) { + fail(`instance answered ${lex.status}/${scripts.status} β€” check PODIUM_HV_URL and PODIUM_HV_KEY`); + } + const lexicons = Array.isArray(lex.data) ? lex.data : lex.data?.lexicons ?? []; + const scr = Array.isArray(scripts.data) ? scripts.data : scripts.data?.scripts ?? []; + log(`lexicons (${lexicons.length}):`); + for (const l of lexicons) log(` ${l.id ?? l.nsid ?? JSON.stringify(l)}`); + log(`scripts (${scr.length}):`); + for (const s of scr) log(` ${s.id ?? JSON.stringify(s)}`); +} + +async function main() { + switch (command) { + case 'deploy': + return cmdDeploy(); + case 'push': + return cmdPush(); + case 'backfill': + return cmdBackfill(); + case 'bootstrap': + return cmdBootstrap(); + case 'status': + return cmdStatus(); + default: + log('usage: podium [--local] [--watch] [--no-push]'); + process.exit(command ? 1 : 0); + } +} + +await main(); diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..7afac0e --- /dev/null +++ b/compose.yaml @@ -0,0 +1,38 @@ +services: + # Public-facing container: serves the built frontend and proxies + # /hv/* (and /xrpc/* rewritten) to HappyView. The caddy-front proxy + # reaches it as http://podium:80 on the supramundane network. + podium: + build: . + restart: unless-stopped + container_name: podium + depends_on: + - podium-hv + networks: + - default + + # HappyView AppView: indexes the collections, serves XRPC + dashboard. + podium-hv: + image: ghcr.io/gamesgamesgamesgamesgames/happyview:2.10.2 + restart: unless-stopped + container_name: podium-hv + environment: + DATABASE_URL: "sqlite:///data/happyview.db?mode=rwc" + BASE_PATH: /hv + PUBLIC_URL: ${PODIUM_PUBLIC_URL:?PODIUM_PUBLIC_URL must be set β€” see .env.example} + SESSION_SECRET: ${PODIUM_SESSION_SECRET:?PODIUM_SESSION_SECRET must be set β€” see .env.example} + PORT: "3000" + ports: + # Loopback-only admin/dashboard access that bypasses the front proxy: + # locally this is what makes atproto OAuth loopback login work; on the + # server it stays reachable via an SSH tunnel. + - "127.0.0.1:3300:3000" + volumes: + - ${SM_DATA_ROOT:-/srv/supramundane/data}/podium:/data + networks: + - default + +networks: + default: + name: supramundane + external: true diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..90eb2f3 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,58 @@ + + + + + + Podium β€” open data search + + + + + + + + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..a20aa4d --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,999 @@ +{ + "name": "podium-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "podium-frontend", + "version": "0.1.0", + "dependencies": { + "@lit-labs/signals": "^0.3.0", + "lit": "^3.3.3", + "refrakt": "^1.0.0" + }, + "devDependencies": { + "typescript": "^6.0.3", + "vite": "^8.1.3" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@lit-labs/signals": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@lit-labs/signals/-/signals-0.3.0.tgz", + "integrity": "sha512-PfB5FLtorVfkhwTWXOPNT/rvrVdSDY8siwPyR17ztrViRbMqxy1TQBcHqJoA4303QT8yXPGxYtGKJGj7fEfEwg==", + "license": "BSD-3-Clause", + "dependencies": { + "lit": "^2.0.0 || ^3.0.0", + "signal-polyfill": "^0.2.2" + } + }, + "node_modules/@lit-labs/ssr-dom-shim": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.6.0.tgz", + "integrity": "sha512-VHb0ALPMTlgKjM6yIxxoQNnpKyUKLD04VzeQdsiXkMqkvYlAHxq9glGLmgbb889/1GsohSOAjvQYoiBppXFqrQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@lit/reactive-element": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-2.1.2.tgz", + "integrity": "sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A==", + "license": "BSD-3-Clause", + "dependencies": { + "@lit-labs/ssr-dom-shim": "^1.5.0" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.138.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz", + "integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz", + "integrity": "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.4.tgz", + "integrity": "sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.4.tgz", + "integrity": "sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.4.tgz", + "integrity": "sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.4.tgz", + "integrity": "sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.4.tgz", + "integrity": "sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.4.tgz", + "integrity": "sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.4.tgz", + "integrity": "sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.4.tgz", + "integrity": "sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.4.tgz", + "integrity": "sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.4.tgz", + "integrity": "sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.4.tgz", + "integrity": "sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.4.tgz", + "integrity": "sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz", + "integrity": "sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.4.tgz", + "integrity": "sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lit": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/lit/-/lit-3.3.3.tgz", + "integrity": "sha512-fycuvZg/hkpozL00lm1pEJH5nN/lr9ZXd6mJI2HSN4+Bzc+LDNdEApJ6HFbPkdFNHLvOplIIuJvxkS4XUxqirw==", + "license": "BSD-3-Clause", + "dependencies": { + "@lit/reactive-element": "^2.1.0", + "lit-element": "^4.2.0", + "lit-html": "^3.3.0" + } + }, + "node_modules/lit-element": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/lit-element/-/lit-element-4.2.2.tgz", + "integrity": "sha512-aFKhNToWxoyhkNDmWZwEva2SlQia+jfG0fjIWV//YeTaWrVnOxD89dPKfigCUspXFmjzOEUQpOkejH5Ly6sG0w==", + "license": "BSD-3-Clause", + "dependencies": { + "@lit-labs/ssr-dom-shim": "^1.5.0", + "@lit/reactive-element": "^2.1.0", + "lit-html": "^3.3.0" + } + }, + "node_modules/lit-html": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/lit-html/-/lit-html-3.3.3.tgz", + "integrity": "sha512-el8M6jK2o3RXBnrSHX3ZKrsN8zEV63pSExTO1wYJz7QndGYZ8353e2a5PPX+qHe2aGayfnchQmkAojaWAREOIA==", + "license": "BSD-3-Clause", + "dependencies": { + "@types/trusted-types": "^2.0.2" + } + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/refrakt": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/refrakt/-/refrakt-1.0.0.tgz", + "integrity": "sha512-Ru0J+jimi7ee0331ERoCkE3kVtWM7lXzbtKbQQNqaGOAd2rOwzAhv7leWaDi38H8LCkz4WlRCr6wiLJFFjn70g==", + "license": "MIT", + "dependencies": { + "signal-polyfill": "^0.2.2" + } + }, + "node_modules/rolldown": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz", + "integrity": "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.138.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.4", + "@rolldown/binding-darwin-arm64": "1.1.4", + "@rolldown/binding-darwin-x64": "1.1.4", + "@rolldown/binding-freebsd-x64": "1.1.4", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.4", + "@rolldown/binding-linux-arm64-gnu": "1.1.4", + "@rolldown/binding-linux-arm64-musl": "1.1.4", + "@rolldown/binding-linux-ppc64-gnu": "1.1.4", + "@rolldown/binding-linux-s390x-gnu": "1.1.4", + "@rolldown/binding-linux-x64-gnu": "1.1.4", + "@rolldown/binding-linux-x64-musl": "1.1.4", + "@rolldown/binding-openharmony-arm64": "1.1.4", + "@rolldown/binding-wasm32-wasi": "1.1.4", + "@rolldown/binding-win32-arm64-msvc": "1.1.4", + "@rolldown/binding-win32-x64-msvc": "1.1.4" + } + }, + "node_modules/signal-polyfill": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/signal-polyfill/-/signal-polyfill-0.2.2.tgz", + "integrity": "sha512-p63Y4Er5/eMQ9RHg0M0Y64NlsQKpiu6MDdhBXpyywRuWiPywhJTpKJ1iB5K2hJEbFZ0BnDS7ZkJ+0AfTuL37Rg==", + "license": "Apache-2.0" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "8.1.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz", + "integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.16", + "rolldown": "~1.1.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..e3a9512 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,20 @@ +{ + "name": "podium-frontend", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@lit-labs/signals": "^0.3.0", + "lit": "^3.3.3", + "refrakt": "^1.0.0" + }, + "devDependencies": { + "typescript": "^6.0.3", + "vite": "^8.1.3" + } +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..3d7f2b9 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,3 @@ + + πŸͺ© + diff --git a/frontend/src/api.ts b/frontend/src/api.ts new file mode 100644 index 0000000..dba0fed --- /dev/null +++ b/frontend/src/api.ts @@ -0,0 +1,33 @@ +import type { Result, Stats } from './types.js'; + +const NS = 'it.mycopunk.podium'; + +async function query( + method: string, + params: Record = {} +): Promise { + const url = new URL(`/xrpc/${NS}.${method}`, location.origin); + for (const [k, v] of Object.entries(params)) { + if (v !== undefined && v !== '') url.searchParams.set(k, String(v)); + } + const res = await fetch(url); + if (!res.ok) { + let msg = `request failed (${res.status})`; + try { + const err = await res.json(); + msg = err.message ?? err.error ?? msg; + } catch { + // keep the status message + } + throw new Error(msg); + } + return res.json() as Promise; +} + +export const search = (q: string, collection?: string, limit = 30) => + query<{ results: Result[] }>('search', { q, collection, limit }); + +export const listRecords = (collection?: string, limit = 30, cursor?: string) => + query<{ results: Result[]; cursor?: string }>('listRecords', { collection, limit, cursor }); + +export const stats = () => query('stats'); diff --git a/frontend/src/components/podium-app.ts b/frontend/src/components/podium-app.ts new file mode 100644 index 0000000..db48a48 --- /dev/null +++ b/frontend/src/components/podium-app.ts @@ -0,0 +1,223 @@ +import { LitElement, html, css, nothing } from 'lit'; +import { SignalWatcher } from '@lit-labs/signals'; +import { app } from '../store.js'; +import { GCN, MATADISCO } from '../types.js'; +import { collectionLabel, collectionShortLabel } from '../format.js'; +import './podium-search-box.js'; +import './podium-result-card.js'; + +const nf = new Intl.NumberFormat('en'); + +const FILTERS: (string | null)[] = [null, MATADISCO, GCN]; + +export class PodiumApp extends SignalWatcher(LitElement) { + static styles = css` + :host { + display: block; + position: relative; + z-index: 1; + } + .shell { + max-width: 44rem; + margin: 0 auto; + padding: 4rem 1.25rem 5rem; + } + header { + text-align: center; + margin-bottom: 2.2rem; + } + .wordmark { + margin: 0; + font-size: 3rem; + font-weight: 800; + letter-spacing: -0.03em; + } + .wordmark .ball { + -webkit-text-stroke: 0; + filter: drop-shadow(0 0 12px rgba(1, 205, 254, 0.4)); + margin-right: 0.3rem; + } + .wordmark .text { + background: var(--grad); + background-size: 300% 100%; + background-clip: text; + -webkit-background-clip: text; + color: transparent; + animation: shimmer 9s linear infinite; + } + @keyframes shimmer { + to { + background-position: 300% 0; + } + } + @media (prefers-reduced-motion: reduce) { + .wordmark .text { + animation: none; + } + } + .strap { + margin: 0.4rem 0 0; + color: var(--muted); + font-size: 0.95rem; + } + .stats { + margin-top: 0.6rem; + color: var(--faint); + font: 0.78rem var(--mono); + } + .filters { + display: flex; + justify-content: center; + gap: 0.4rem; + margin: 1.1rem 0 1.8rem; + } + .filters button { + font: 0.8rem var(--mono); + color: var(--muted); + background: none; + border: 1px solid var(--border); + border-radius: 99px; + padding: 0.3rem 0.85rem; + cursor: pointer; + transition: border-color 0.15s ease; + } + .filters button:hover { + color: var(--text); + } + .filters button.on { + color: var(--text); + border-color: transparent; + background: + linear-gradient(var(--bg), var(--bg)) padding-box, + var(--grad) border-box; + } + .heading { + display: flex; + align-items: baseline; + gap: 0.6rem; + color: var(--muted); + font: 0.8rem var(--mono); + text-transform: uppercase; + letter-spacing: 0.08em; + margin: 0 0 0.8rem 0.2rem; + } + .results { + display: flex; + flex-direction: column; + gap: 0.65rem; + } + .note { + color: var(--muted); + text-align: center; + padding: 2.5rem 0; + font-size: 0.95rem; + } + .note .big { + font-size: 2rem; + display: block; + margin-bottom: 0.6rem; + } + .error { + color: var(--pink); + border: 1px solid color-mix(in srgb, var(--pink) 35%, transparent); + border-radius: 10px; + background: color-mix(in srgb, var(--pink) 7%, transparent); + padding: 0.7rem 1rem; + font: 0.85rem var(--mono); + margin-bottom: 1rem; + } + footer { + margin-top: 3.5rem; + text-align: center; + color: var(--faint); + font-size: 0.82rem; + } + footer a { + color: var(--muted); + text-decoration: none; + } + footer a:hover { + color: var(--green); + } + `; + + override connectedCallback() { + super.connectedCallback(); + const params = new URLSearchParams(location.search); + app.send({ + type: 'boot', + query: params.get('q') ?? undefined, + collection: params.get('in'), + }); + } + + private statsLine() { + const s = app.get().stats; + if (!s) return nothing; + const parts = s.collections.map( + (c) => `${collectionShortLabel(c.collection)} ${nf.format(c.count)}` + ); + return html`
${nf.format(s.total)} records indexed Β· ${parts.join(' Β· ')}
`; + } + + private filters() { + const current = app.get().collection; + return html` + + `; + } + + override render() { + const s = app.get(); + const searching = s.searching; + const hasQuery = s.query.length > 0; + return html` +
+
+

πŸͺ©podium

+

a search engine for open data on the AT Protocol

+ ${this.statsLine()} +
+ + + ${this.filters()} + ${s.error ? html`
${s.error}
` : nothing} + +
+ ${hasQuery ? `results for β€œ${s.query}”` : 'freshly indexed'} + ${searching ? '…' : hasQuery ? `${s.results.length}` : ''} +
+ +
+ ${s.results.map((r) => html``)} + ${!searching && s.results.length === 0 + ? html`
+ πŸͺ© + ${hasQuery + ? html`nothing under the glitter ball for β€œ${s.query}”` + : html`nothing indexed yet β€” backfill may still be running`} +
` + : nothing} +
+ + +
+ `; + } +} + +customElements.define('podium-app', PodiumApp); diff --git a/frontend/src/components/podium-result-card.ts b/frontend/src/components/podium-result-card.ts new file mode 100644 index 0000000..04781bd --- /dev/null +++ b/frontend/src/components/podium-result-card.ts @@ -0,0 +1,237 @@ +import { LitElement, html, css, nothing } from 'lit'; +import { app } from '../store.js'; +import { GCN, MATADISCO } from '../types.js'; +import type { GcnRecord, MatadiscoRecord, Result } from '../types.js'; +import { + collectionLabel, + pdsls, + prettyDate, + prettyResource, + prettyTopic, + splitPreviewText, +} from '../format.js'; + +export class PodiumResultCard extends LitElement { + static properties = { + result: { attribute: false }, + }; + + declare result: Result; + + static styles = css` + :host { + display: block; + } + .card { + display: flex; + gap: 1rem; + justify-content: space-between; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--panel); + padding: 0.9rem 1.1rem; + transition: border-color 0.15s ease; + } + .card:hover { + border-color: transparent; + background: + linear-gradient(var(--panel), var(--panel)) padding-box, + var(--grad) border-box; + } + .body { + min-width: 0; + flex: 1; + } + .top { + display: flex; + align-items: baseline; + gap: 0.7rem; + font: 0.72rem var(--mono); + letter-spacing: 0.06em; + text-transform: uppercase; + } + .badge { + padding: 0.1rem 0.5rem; + border-radius: 99px; + border: 1px solid; + } + .badge.matadisco { + color: var(--pink); + border-color: color-mix(in srgb, var(--pink) 45%, transparent); + } + .badge.gcn { + color: var(--cyan); + border-color: color-mix(in srgb, var(--cyan) 45%, transparent); + } + .badge.other { + color: var(--muted); + border-color: var(--border); + text-transform: none; + letter-spacing: 0; + } + .date { + color: var(--faint); + } + .spacer { + flex: 1; + } + .record-link { + color: var(--faint); + text-decoration: none; + } + .record-link:hover { + color: var(--green); + } + h3 { + margin: 0.45rem 0 0.2rem; + font-size: 1rem; + font-weight: 600; + overflow-wrap: anywhere; + } + h3 a { + color: var(--text); + text-decoration: none; + } + h3 a:hover { + background: var(--grad); + background-clip: text; + -webkit-background-clip: text; + color: transparent; + } + .meta { + color: var(--muted); + font: 0.8rem var(--mono); + overflow-wrap: anywhere; + } + .desc { + margin: 0.35rem 0 0; + color: var(--muted); + font-size: 0.88rem; + line-height: 1.45; + } + .tags { + margin-top: 0.55rem; + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + } + .tags button { + font: 0.75rem var(--mono); + color: var(--muted); + background: var(--panel-2); + border: 1px solid var(--border); + border-radius: 99px; + padding: 0.15rem 0.6rem; + cursor: pointer; + } + .tags button:hover { + color: var(--green); + border-color: color-mix(in srgb, var(--green) 45%, transparent); + } + .preview { + flex-shrink: 0; + align-self: center; + } + .preview img { + width: 84px; + height: 84px; + object-fit: cover; + border-radius: 8px; + border: 1px solid var(--border); + display: block; + } + `; + + private top(kind: string, date: string | undefined) { + return html` +
+ ${collectionLabel(this.result.collection)} + ${date ? html`${date}` : nothing} + + record β†— +
+ `; + } + + private matadisco(r: MatadiscoRecord) { + const link = r.resource ?? r.metadata; + const date = prettyDate(r.publishedAt ?? r.created); + const img = + r.preview?.url && r.preview.mimeType?.startsWith('image/') ? r.preview.url : undefined; + const text = + r.preview?.data && r.preview.mimeType?.startsWith('text/') + ? splitPreviewText(r.preview.data) + : undefined; + const title = text?.title || (link ? prettyResource(link) : this.result.uri); + return html` +
+
+ ${this.top('matadisco', date)} +

+ ${link + ? html`${title}` + : title} +

+ ${text?.title && link ? html`
${prettyResource(link)}
` : nothing} + ${text?.body ? html`

${text.body}

` : nothing} + ${r.tags?.length + ? html`
+ ${r.tags.map( + (t) => + html`` + )} +
` + : nothing} +
+ ${img + ? html`
` + : nothing} +
+ `; + } + + private gcn(r: GcnRecord) { + return html` +
+
+ ${this.top('gcn', prettyDate(r.createdAt))} +

${r.summary ?? prettyTopic(r.topic ?? 'transient event')}

+
+ ${r.summary ? html`${prettyTopic(r.topic)} Β· ` : nothing}${r.topic}${r.eventID !== + undefined + ? html` Β· event #${r.eventID}` + : nothing} +
+
+
+ `; + } + + private other() { + return html` +
+
+ ${this.top('other', prettyDate(this.result.indexedAt))} +

+ ${this.result.uri} +

+
+
+ `; + } + + override render() { + const { collection, record } = this.result; + if (collection === MATADISCO) return this.matadisco(record as MatadiscoRecord); + if (collection === GCN) return this.gcn(record as GcnRecord); + return this.other(); + } +} + +customElements.define('podium-result-card', PodiumResultCard); diff --git a/frontend/src/components/podium-search-box.ts b/frontend/src/components/podium-search-box.ts new file mode 100644 index 0000000..3e5cf80 --- /dev/null +++ b/frontend/src/components/podium-search-box.ts @@ -0,0 +1,114 @@ +import { LitElement, html, css } from 'lit'; +import { SignalWatcher } from '@lit-labs/signals'; +import { app } from '../store.js'; + +export class PodiumSearchBox extends SignalWatcher(LitElement) { + static styles = css` + :host { + display: block; + } + .box { + position: relative; + border-radius: 12px; + border: 1px solid var(--border); + background: var(--panel); + transition: border-color 0.15s ease; + } + .box:focus-within { + border-color: transparent; + background: + linear-gradient(var(--panel), var(--panel)) padding-box, + var(--grad) border-box; + box-shadow: 0 0 24px rgba(185, 103, 255, 0.15); + } + input { + width: 100%; + box-sizing: border-box; + padding: 1rem 3rem 1rem 1.1rem; + font: 1.05rem var(--mono); + color: var(--text); + background: none; + border: none; + outline: none; + } + input::placeholder { + color: var(--faint); + } + .hint { + position: absolute; + right: 0.9rem; + top: 50%; + translate: 0 -50%; + color: var(--faint); + font: 0.8rem var(--mono); + border: 1px solid var(--border); + border-radius: 5px; + padding: 0.1rem 0.45rem; + pointer-events: none; + } + .spin { + position: absolute; + right: 0.9rem; + top: 50%; + translate: 0 -50%; + font-size: 0.9rem; + animation: turn 1.2s linear infinite; + } + @keyframes turn { + to { + rotate: 360deg; + } + } + @media (prefers-reduced-motion: reduce) { + .spin { + animation: none; + } + } + `; + + private onKey = (e: KeyboardEvent) => { + if (e.key === '/' && !e.metaKey && !e.ctrlKey && !e.altKey) { + const active = document.activeElement; + if (active instanceof HTMLElement && active.closest('input, textarea')) return; + e.preventDefault(); + this.focusInput(); + } + }; + + override connectedCallback() { + super.connectedCallback(); + window.addEventListener('keydown', this.onKey); + } + + override disconnectedCallback() { + super.disconnectedCallback(); + window.removeEventListener('keydown', this.onKey); + } + + focusInput() { + this.renderRoot.querySelector('input')?.focus(); + } + + override render() { + const s = app.get(); + return html` +
+ + app.send({ type: 'query-input', value: (e.target as HTMLInputElement).value })} + @keydown=${(e: KeyboardEvent) => { + if (e.key === 'Escape') app.send({ type: 'query-input', value: '' }); + }} + /> + ${s.searching ? html`πŸͺ©` : html`/`} +
+ `; + } +} + +customElements.define('podium-search-box', PodiumSearchBox); diff --git a/frontend/src/format.ts b/frontend/src/format.ts new file mode 100644 index 0000000..0b8825d --- /dev/null +++ b/frontend/src/format.ts @@ -0,0 +1,52 @@ +import { GCN, MATADISCO } from './types.js'; + +export const pdsls = (uri: string) => `https://pdsls.dev/${uri}`; + +export const collectionLabel = (c: string) => + c === MATADISCO ? 'Matadisco' : c === GCN ? 'Astrosky Β· GCN' : c; + +// Compact variant for tight spots like the stats line. +export const collectionShortLabel = (c: string) => + c === MATADISCO ? 'Matadisco' : c === GCN ? 'GCN' : c; + +const TOPIC_LABELS: Record = { + 'igwn.gwalert': 'gravitational wave alert', + 'gcn.circulars': 'GCN circular', + 'gcn.heartbeat': 'GCN heartbeat', + 'gcn.notices.chime.frb': 'CHIME fast radio burst', + 'gcn.notices.dsa110.frb': 'DSA-110 fast radio burst', + 'gcn.notices.einstein_probe.wxt.alert': 'Einstein Probe WXT alert', + 'gcn.notices.superk.sn_alert': 'Super-K supernova alert', + 'gcn.notices.icecube.gold_bronze_track_alerts': 'IceCube track alert', + 'gcn.notices.icecube.lvk_nu_track_search': 'IceCube LVK neutrino search', + 'gcn.notices.swift.bat.guano': 'Swift BAT GUANO', +}; + +// Known topics get a friendly name; the rest get de-dotted. +export const prettyTopic = (topic: string) => + TOPIC_LABELS[topic] ?? + topic.replace(/^(gcn\.notices\.|gcn\.|igwn\.)/, '').replace(/[._]/g, ' '); + +export const prettyDate = (iso?: string) => { + if (!iso) return ''; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return iso.slice(0, 10); + return d.toISOString().slice(0, 10); +}; + +// Show a resource URL compactly: drop the scheme, trim to something scannable. +export const prettyResource = (url: string) => { + const bare = url.replace(/^https?:\/\//, '').replace(/\/$/, ''); + return bare.length > 90 ? `${bare.slice(0, 87)}…` : bare; +}; + +// Text previews are "title\n\nbody-ish" (sometimes markdown) β€” split for display. +export const splitPreviewText = (data: string) => { + const [first = '', ...rest] = data.split('\n'); + const title = first.replace(/^#+\s*/, '').trim(); + const body = rest.join(' ').trim(); + return { + title, + body: body && body !== title ? (body.length > 180 ? `${body.slice(0, 177)}…` : body) : '', + }; +}; diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..8a627b2 --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1 @@ +import './components/podium-app.js'; diff --git a/frontend/src/store.ts b/frontend/src/store.ts new file mode 100644 index 0000000..17aa5e3 --- /dev/null +++ b/frontend/src/store.ts @@ -0,0 +1,111 @@ +import { store, tx, type Tx } from 'refrakt/store.js'; +import { assertNever } from 'refrakt/never.js'; +import * as api from './api.js'; +import type { Result, Stats } from './types.js'; + +export type Model = { + query: string; + collection: string | null; + results: Result[]; + searching: boolean; + stats: Stats | null; + error: string | null; +}; + +export type Action = + | { type: 'boot'; query?: string; collection?: string | null } + | { type: 'stats-loaded'; stats: Stats } + | { type: 'query-input'; value: string } + | { type: 'set-collection'; collection: string | null } + | { type: 'results'; forQuery: string; forCollection: string | null; results: Result[] } + | { type: 'fetch-failed'; forQuery: string; forCollection: string | null; message: string }; + +const DEBOUNCE_MS = 250; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +// Keep the URL shareable: reflect the current search into ?q= and ?in=. +const syncUrl = (query: string, collection: string | null) => { + const url = new URL(location.href); + if (query) url.searchParams.set('q', query); + else url.searchParams.delete('q'); + if (collection) url.searchParams.set('in', collection); + else url.searchParams.delete('in'); + history.replaceState(null, '', url); +}; + +// Fetch matching the current query/filter; empty query means "recent". +// Stale responses are dropped both here and in the reducer. +const fetchFx = (query: string, collection: string | null) => + async function* (state: () => Model): AsyncGenerator { + syncUrl(query, collection); + if (query) await sleep(DEBOUNCE_MS); + const s = state(); + if (s.query !== query || s.collection !== collection) return; + try { + const res = query + ? await api.search(query, collection ?? undefined) + : await api.listRecords(collection ?? undefined); + yield { type: 'results', forQuery: query, forCollection: collection, results: res.results }; + } catch (err) { + yield { + type: 'fetch-failed', + forQuery: query, + forCollection: collection, + message: err instanceof Error ? err.message : String(err), + }; + } + }; + +const bootFx = (query: string, collection: string | null) => + async function* (state: () => Model): AsyncGenerator { + const statsP = api.stats().catch(() => null); + yield* fetchFx(query, collection)(state); + const stats = await statsP; + if (stats) yield { type: 'stats-loaded', stats }; + }; + +const isStale = (state: Model, forQuery: string, forCollection: string | null) => + state.query !== forQuery || state.collection !== forCollection; + +const update = (state: Model, action: Action): Tx => { + switch (action.type) { + case 'boot': { + const query = action.query ?? state.query; + const collection = action.collection ?? state.collection; + return tx( + { ...state, query, collection, searching: true }, + bootFx(query, collection) + ); + } + case 'stats-loaded': + return tx({ ...state, stats: action.stats }); + case 'query-input': + return tx( + { ...state, query: action.value, searching: true, error: null }, + fetchFx(action.value, state.collection) + ); + case 'set-collection': + return tx( + { ...state, collection: action.collection, searching: true, error: null }, + fetchFx(state.query, action.collection) + ); + case 'results': + if (isStale(state, action.forQuery, action.forCollection)) return tx(state); + return tx({ ...state, results: action.results, searching: false, error: null }); + case 'fetch-failed': + if (isStale(state, action.forQuery, action.forCollection)) return tx(state); + return tx({ ...state, searching: false, error: action.message }); + default: + return assertNever(action); + } +}; + +export const app = store(update, { + query: '', + collection: null, + results: [], + searching: false, + stats: null, + error: null, +}); diff --git a/frontend/src/types.ts b/frontend/src/types.ts new file mode 100644 index 0000000..e02b579 --- /dev/null +++ b/frontend/src/types.ts @@ -0,0 +1,38 @@ +export const MATADISCO = 'cx.vmx.matadisco'; +export const GCN = 'eco.astrosky.transient.gcn'; + +// Records on the network have drifted from the published lexicon: some +// producers use `resource` + `publishedAt` (+ `tags`, image `preview`), +// others `metadata` + `created` with a text `preview.data` snippet. +export type MatadiscoRecord = { + $type?: string; + resource?: string; + metadata?: string; + publishedAt?: string; + created?: string; + preview?: { mimeType?: string; url?: string; data?: string }; + tags?: string[] | null; + [k: string]: unknown; +}; + +// `data` (the raw GCN event JSON) is stripped server-side and replaced +// with a short `summary`. +export type GcnRecord = { + $type?: string; + topic: string; + eventID?: number; + createdAt?: string; + summary?: string; + hasData?: boolean; + [k: string]: unknown; +}; + +export type Result = { + uri: string; + collection: string; + indexedAt?: string; + record: MatadiscoRecord | GcnRecord | Record; +}; + +export type CollectionStat = { collection: string; count: number }; +export type Stats = { total: number; collections: CollectionStat[] }; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..88f10e6 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noUncheckedIndexedAccess": true, + "noEmit": true, + "skipLibCheck": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "useDefineForClassFields": true + }, + "include": ["src", "vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..5a6623d --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from 'vite'; + +// In dev, XRPC goes straight to the local HappyView container (published on +// 127.0.0.1:3300 by compose.yaml), mirroring what the podium Caddy does in +// the deployed container. +export default defineConfig({ + server: { + proxy: { + '/hv': { target: 'http://127.0.0.1:3300', changeOrigin: true }, + '/xrpc': { + target: 'http://127.0.0.1:3300', + changeOrigin: true, + rewrite: (path) => `/hv${path}`, + }, + }, + }, +}); diff --git a/package.json b/package.json new file mode 100644 index 0000000..dd5a265 --- /dev/null +++ b/package.json @@ -0,0 +1,23 @@ +{ + "name": "podium", + "version": "0.1.0", + "description": "A search engine for open data on ATProto (Matadisco, Astrosky/Nebra, …)", + "private": true, + "type": "module", + "bin": { + "podium": "bin/podium.js" + }, + "scripts": { + "deploy": "node bin/podium.js deploy", + "deploy:local": "node bin/podium.js deploy --local", + "push": "node bin/podium.js push", + "push:local": "node bin/podium.js push --local", + "watch": "node bin/podium.js push --local --watch", + "dev": "npm --prefix frontend run dev" + }, + "engines": { + "node": ">=20" + }, + "author": "Robin Berjon ", + "license": "MIT" +} diff --git a/podium.config.json b/podium.config.json new file mode 100644 index 0000000..75fbd7e --- /dev/null +++ b/podium.config.json @@ -0,0 +1,34 @@ +{ + "networkLexicons": [ + { "nsid": "cx.vmx.matadisco" }, + { "nsid": "eco.astrosky.transient.gcn" } + ], + "lexicons": [ + { "file": "backend/lexicons/it.mycopunk.podium.search.json" }, + { "file": "backend/lexicons/it.mycopunk.podium.listRecords.json" }, + { "file": "backend/lexicons/it.mycopunk.podium.getRecord.json" }, + { "file": "backend/lexicons/it.mycopunk.podium.stats.json" } + ], + "scripts": [ + { + "trigger": "xrpc.query:it.mycopunk.podium.search", + "file": "backend/scripts/search.lua", + "description": "Cross-collection full-text search for Podium" + }, + { + "trigger": "xrpc.query:it.mycopunk.podium.listRecords", + "file": "backend/scripts/listRecords.lua", + "description": "Recent records, merged or per collection" + }, + { + "trigger": "xrpc.query:it.mycopunk.podium.getRecord", + "file": "backend/scripts/getRecord.lua", + "description": "Fetch a single indexed record by AT URI" + }, + { + "trigger": "xrpc.query:it.mycopunk.podium.stats", + "file": "backend/scripts/stats.lua", + "description": "Per-collection record counts" + } + ] +} diff --git a/service.json b/service.json new file mode 100644 index 0000000..5bf0159 --- /dev/null +++ b/service.json @@ -0,0 +1,11 @@ +{ + "name": "podium", + "type": "node", + "domains": { + "local": ["podium.bast"], + "prod": ["podium.mycopunk.it"] + }, + "headers": true, + "upstreamPort": 80, + "data": true +}