Add shareable multi-board links with paper UI and voice notes.
CI / secret-scan (pull_request) Successful in 32s
CI / python-ci (pull_request) Successful in 50s

Boards use /b/<uid> with no invite password; restore renamable columns,
recordings, and seeded locale notes, with first column locked.
This commit is contained in:
2026-08-07 14:47:28 -04:00
parent 33c9793fe1
commit b91ba3490c
25 changed files with 2881 additions and 576 deletions
+3 -1
View File
@@ -1,5 +1,7 @@
# Copy to .env on the deploy host (never commit real tokens). # Copy to .env for local run / deploy host (never commit real tokens).
# Optional: bootstraps a "Family" board that legacy ?invite= links can resolve.
STORK_INVITE_TOKEN=change-me-long-random STORK_INVITE_TOKEN=change-me-long-random
STORK_ADMIN_TOKEN=change-me-admin-random STORK_ADMIN_TOKEN=change-me-admin-random
STORK_COOKIE_SECURE=false STORK_COOKIE_SECURE=false
STORK_DATA=./data STORK_DATA=./data
# STORK_PUBLIC_URL=https://stork.levkin.ca
+4 -4
View File
@@ -4,13 +4,13 @@ Short orientation for Cursor agents.
## Defaults ## Defaults
- Family baby-name board (votes + en/ru/he notes). Run/install: `README.md`. - Multi-board baby-name app (`/b/<uid>` share links). Run/install: `README.md`.
- Lint/tests are CI hard gates: `make lint` / `make test` — never `|| true`. - Lint/tests are CI hard gates: `make lint` / `make test` — never `|| true`.
- Secrets: Infisical `/apps/stork` or gitignored `.env`. Never commit tokens. - Secrets: gitignored `.env` or deploy host `.env`. Never commit tokens.
- Deploy/DNS/Caddy/Kuma live in `~/Documents/code/ansible` (`make deploy-stork`). - Deploy/DNS/Caddy/Kuma live in `~/Documents/code/ansible` (`make deploy-stork`).
## Close-out ## Close-out
1. `make test` and `make lint` before claiming done. 1. `make test` and `make lint` before claiming done.
2. Do not put LAN IPs or real invites in this repo. 2. Do not put LAN IPs or real invites/board ids in this repo.
3. Merge only when the user asks. 3. Merge only when the user asks (or when they explicitly say merge when green).
+11 -3
View File
@@ -19,10 +19,18 @@ run: ## Local uvicorn on :8094 (needs .env)
@test -f .env || (echo "Copy .env.example to .env first" && exit 1) @test -f .env || (echo "Copy .env.example to .env first" && exit 1)
set -a && . ./.env && set +a && uvicorn stork.app:app --reload --host 127.0.0.1 --port 8094 set -a && . ./.env && set +a && uvicorn stork.app:app --reload --host 127.0.0.1 --port 8094
seed: ## Seed example first names (usage: make seed STORK_INVITE=... [STORK_URL=...]) seed: ## Seed example names (usage: make seed STORK_BOARD_ID=b_… OR STORK_INVITE=…)
@test -n "$(STORK_INVITE)" || (echo "Set STORK_INVITE=..." && exit 1) @if [ -z "$(STORK_BOARD_ID)" ] && [ -z "$(STORK_INVITE)" ]; then \
STORK_URL="$(or $(STORK_URL),http://127.0.0.1:8094)" STORK_INVITE="$(STORK_INVITE)" \ echo "Set STORK_BOARD_ID=b_… or STORK_INVITE=…"; exit 1; \
fi
STORK_URL="$(or $(STORK_URL),http://127.0.0.1:8094)" \
STORK_BOARD_ID="$(STORK_BOARD_ID)" STORK_INVITE="$(STORK_INVITE)" \
.venv/bin/python scripts/seed_names.py .venv/bin/python scripts/seed_names.py
create-board: ## Create empty board + print share URL (usage: make create-board [STORK_BOARD_TITLE='…'])
STORK_URL="$(or $(STORK_URL),http://127.0.0.1:8094)" \
STORK_BOARD_TITLE="$(or $(STORK_BOARD_TITLE),Name board)" \
.venv/bin/python scripts/create_board.py
docker-build: ## Build local image docker-build: ## Build local image
docker build -t homelab-stork:latest . docker build -t homelab-stork:latest .
+44 -15
View File
@@ -1,42 +1,71 @@
# Stork # Stork
Private family board for baby **first** and **middle** name ideas: suggest Private family baby-name boards: suggest names, vote, keep origin / meaning /
names, vote, and keep origin / meaning / pronunciation in English, Russian, pronunciation in English, Russian, and Hebrew, and optionally record how a
and Hebrew. name sounds.
Homelab deploy lives in the ansible repo (`make deploy-stork`). This repo is Each board has a unique share link (`/b/<uid>`). Start a board with no password,
the app only — no LAN IPs or production secrets here. add names, share the link. Homelab deploy lives in the ansible repo
(`make deploy-stork`). This repo is the app only — no LAN IPs or production
secrets here.
## Quick start (local) ## Quick start (local)
```bash ```bash
cp .env.example .env # set STORK_INVITE_TOKEN (and optional STORK_ADMIN_TOKEN) cp .env.example .env # optional STORK_INVITE_TOKEN bootstraps a "Family" board
make install make install
make test make test
make run make run
``` ```
Open http://127.0.0.1:8094 — enter the invite code and your display name. Open http://127.0.0.1:8094 → **Start new board** → share `/b/…` with family.
Share `http://127.0.0.1:8094/?invite=YOUR_TOKEN` with family for one-tap entry. They open the link and enter a display name.
## Features
- Renamable columns (First is fixed; `+` adds up to 4; ✕ removes extras)
- Votes / ranking per board
- Locale notes (en / ru / he) + voice recordings (re-record overwrites)
- Paper UI at `/` (classic layout at `/v1`)
## API sketch ## API sketch
| Method | Path | Notes | | Method | Path | Notes |
|--------|------|-------| |--------|------|-------|
| GET | `/api/health` | Liveness | | GET | `/api/health` | Liveness |
| POST | `/api/session` | `{invite, display_name}` → cookies | | POST | `/api/boards` | Start board `{title}` → `{id, url}` |
| GET | `/api/names?kind=first\|middle` | Ranked list | | GET | `/api/boards/{id}` | Public board title + url |
| POST | `/api/names` | Add name + optional locales | | GET | `/api/resolve?invite=` | Legacy invite → board url |
| PATCH | `/api/names/{id}` | Update locale notes | | POST | `/api/session` | `{board_id, display_name}` → cookies |
| GET | `/api/columns` | Columns for session board |
| POST/PATCH/DELETE | `/api/columns…` | Add / rename / remove (not first) |
| GET/POST | `/api/names` | List / add |
| PATCH | `/api/names/{id}` | Locale notes |
| POST | `/api/names/{id}/vote` | `{value: 1\|-1\|0}` | | POST | `/api/names/{id}/vote` | `{value: 1\|-1\|0}` |
| POST/GET/DELETE | `/api/names/{id}/recording/{lang}` | Voice |
| DELETE | `/api/names/{id}` | Admin header `X-Stork-Admin` | | DELETE | `/api/names/{id}` | Admin header `X-Stork-Admin` |
## Scripts
```bash
# Seed Shai/Roze/Odet/Rivka onto a board
make seed STORK_BOARD_ID=b_… # or STORK_INVITE=… for legacy
# Create an empty board and print share URL
.venv/bin/python scripts/create_board.py
```
## Secrets ## Secrets
- Local: gitignored `.env` - Local: gitignored `.env`
- Prod: Infisical `/apps/stork` (or host `.env` written by deploy) — never git - Prod: host `/opt/stork/.env` from deploy (never git)
## CI gates
Gitea Actions (`.gitea/workflows/ci.yml`): **ruff** and **pytest** are hard gates
(no `|| true`). Gitleaks on PRs. Bandit is advisory.
## Production shape ## Production shape
Same host pattern as Compare: Docker on automationlab LXC **225**, public Docker on automationlab; public HTTPS via Caddy (`stork.levkin.ca`). See ansible
HTTPS via Caddy (`stork.levkin.ca`). See ansible `docs/guides/stork-deploy.md`. `docs/guides/stork-deploy.md`.
+16 -64
View File
@@ -1,87 +1,39 @@
{ {
"names": [ "names": [
{ {
"kind": "first", "kind": "c1",
"spelling": "Shai", "spelling": "Shai",
"locales": { "locales": {
"en": { "en": {"pronunciation": "Shai", "origin": "Hebrew", "meaning": "Gift. Variants: Shay."},
"pronunciation": "SHY (rhymes with sky)", "ru": {"pronunciation": "Шай", "origin": "иврит", "meaning": "Дар, подарок."},
"origin": "Hebrew (שי)", "he": {"pronunciation": "שי", "origin": "עברית", "meaning": "מתנה."}
"meaning": "Gift / present; sometimes a short form of Isaiah (Yeshayahu). Variants: Shay, Shai."
},
"ru": {
"pronunciation": "Шай",
"origin": "иврит",
"meaning": "«дар», «подарок»; иногда уменьшительное от Исайи. Варианты: Shay, Shai."
},
"he": {
"pronunciation": "שַׁי — shai",
"origin": "עברית",
"meaning": "שי — מתנה / מנחה. לפעמים קיצור של ישעיהו. כתיבים: שי, Shay."
}
} }
}, },
{ {
"kind": "first", "kind": "c1",
"spelling": "Roze", "spelling": "Roze",
"locales": { "locales": {
"en": { "en": {"pronunciation": "Roze", "origin": "Latin rosa", "meaning": "Rose. Related: Rose, Rosa, Roza."},
"pronunciation": "ROHZ", "ru": {"pronunciation": "Роуз", "origin": "латинское rosa", "meaning": "Роза. Также: Rose, Rosa, Roza."},
"origin": "Latin rosa / flower name (also Germanic Rose lineage)", "he": {"pronunciation": "שושנה", "origin": "עברית", "meaning": "מקבילה ל־Roze/Rose. גם ורד."}
"meaning": "Rose (the flower); love and beauty. Spelling variant of Rose. Related: Rose, Rosa, Roza (RU/PL), Rosie, Rožė (LT), Roze (LV)."
},
"ru": {
"pronunciation": "Ро́уз / Ро́за",
"origin": "латинское rosa; славянская форма Роза",
"meaning": "роза (цветок). Варианты: Rose, Rosa, Roza, Rosie."
},
"he": {
"pronunciation": "רוֹז",
"origin": "לטינית / שם פרח",
"meaning": "ורד / רוזה. כתיבים קרובים: Rose, Rosa, Roza; בעברית גם ורד (Vered)."
}
} }
}, },
{ {
"kind": "first", "kind": "c1",
"spelling": "Odet", "spelling": "Odet",
"locales": { "locales": {
"en": { "en": {"pronunciation": "Odet", "origin": "French (Odette)", "meaning": "Wealth / prosperity."},
"pronunciation": "oh-DET", "ru": {"pronunciation": "Одет", "origin": "французское Odette", "meaning": "Богатство."},
"origin": "French diminutive (Odette) from Germanic od-/ot- “wealth”", "he": {"pronunciation": "אודט", "origin": "צרפתית", "meaning": "אין מקבילה עברית נפוצה."}
"meaning": "Wealth / prosperity. Short form of Odette (Swan Lake). Variants: Odette, Odetta, Ode, Oda."
},
"ru": {
"pronunciation": "Оде́т",
"origin": "французское Odette ← германское «богатство»",
"meaning": "богатство / достаток. Варианты: Odette, Одетта, Odetta."
},
"he": {
"pronunciation": "אוֹדֶט",
"origin": "צרפתית (Odette) משורש גרמאני",
"meaning": "עושר / שפע. צורות: Odette, Odetta, Odet."
}
} }
}, },
{ {
"kind": "first", "kind": "c1",
"spelling": "Rivka", "spelling": "Rivka",
"locales": { "locales": {
"en": { "en": {"pronunciation": "Rivka", "origin": "Hebrew", "meaning": "To bind / join. Short form: Riva. English: Rebecca."},
"pronunciation": "riv-KAH / REEV-kah (short form Riva: REE-vah)", "ru": {"pronunciation": "Ривка", "origin": "иврит", "meaning": "Связывать. Кратко: Рива. Ревекка."},
"origin": "Hebrew biblical (רִבְקָה) — wife of Isaac", "he": {"pronunciation": "רבקה", "origin": "תנ״ך", "meaning": "קיצור: ריבה. באנגלית Rebecca."}
"meaning": "From root “to bind / tie / join”; often glossed captivating. One entry for Rivka and Riva (short). English: Rebecca, Rebekah. Also Rifka, Becky."
},
"ru": {
"pronunciation": "Ри́вка (кратко: Ри́ва)",
"origin": "библейское еврейское имя (Ревекка)",
"meaning": "«связывать», «соединять»; библейская Ревекка. Одна запись для Ривка и Рива. Варианты: Rebecca, Rebekah, Rifka."
},
"he": {
"pronunciation": "רִבְקָה — rivká (קיצור: רִיבָה / Riva)",
"origin": "תנ״ך — אשת יצחק",
"meaning": "שורש ר־ב־ק (לקשור / לחבר). רבקה ו־Riva הן אותה שם (קיצור). באנגלית Rebecca."
}
} }
} }
] ]
+1
View File
@@ -1,3 +1,4 @@
fastapi==0.115.12 fastapi==0.115.12
uvicorn[standard]==0.34.2 uvicorn[standard]==0.34.2
pydantic==2.11.3 pydantic==2.11.3
python-multipart==0.0.20
+29
View File
@@ -0,0 +1,29 @@
"""Create an extra Stork board (or use the public Start button).
Usage:
STORK_URL=https://stork.levkin.ca \\
STORK_BOARD_TITLE='Dan & Mira' \\
.venv/bin/python scripts/create_board.py
"""
from __future__ import annotations
import os
import httpx
def main() -> int:
base = os.environ.get("STORK_URL", "http://127.0.0.1:8094").rstrip("/")
title = os.environ.get("STORK_BOARD_TITLE", "").strip() or "Name board"
with httpx.Client(base_url=base, timeout=30.0) as client:
res = client.post("/api/boards", json={"title": title})
res.raise_for_status()
data = res.json()
print(f"Board: {data['title']} ({data['id']})")
print(f"Share URL: {data['url']}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+39 -11
View File
@@ -1,8 +1,11 @@
"""Seed starter first names into a local or remote Stork instance. """Seed or refresh starter names (add missing, patch locales on existing).
Usage: Usage:
STORK_URL=http://10.0.10.45:8094 STORK_INVITE=... \\ STORK_URL=https://stork.levkin.ca STORK_BOARD_ID=b_xxx \\
.venv/bin/python scripts/seed_names.py .venv/bin/python scripts/seed_names.py
# or resolve legacy invite:
STORK_URL=... STORK_INVITE=... .venv/bin/python scripts/seed_names.py
""" """
from __future__ import annotations from __future__ import annotations
@@ -20,24 +23,49 @@ SEED = ROOT / "data" / "seed-names.example.json"
def main() -> int: def main() -> int:
base = os.environ.get("STORK_URL", "http://127.0.0.1:8094").rstrip("/") base = os.environ.get("STORK_URL", "http://127.0.0.1:8094").rstrip("/")
board_id = os.environ.get("STORK_BOARD_ID", "").strip()
invite = os.environ.get("STORK_INVITE", "").strip() invite = os.environ.get("STORK_INVITE", "").strip()
display = os.environ.get("STORK_DISPLAY_NAME", "Ilia").strip() or "Ilia" display = os.environ.get("STORK_DISPLAY_NAME", "Ilia").strip() or "Ilia"
if not invite: if not board_id and not invite:
print("Set STORK_INVITE", file=sys.stderr) print("Set STORK_BOARD_ID or STORK_INVITE", file=sys.stderr)
return 1 return 1
names = json.loads(SEED.read_text())["names"] names = json.loads(SEED.read_text())["names"]
with httpx.Client(base_url=base, timeout=30.0) as client:
if not board_id:
resolved = client.get("/api/resolve", params={"invite": invite})
resolved.raise_for_status()
board_id = resolved.json()["id"]
headers = { headers = {
"X-Stork-Invite": invite, "X-Stork-Board": board_id,
"X-Stork-Display-Name": display, "X-Stork-Display-Name": display,
} }
with httpx.Client(base_url=base, timeout=30.0, headers=headers) as client: existing: dict[tuple[str, str], int] = {}
for item in names: for kind in {n.get("kind", "c1") for n in names} | {"c1"}:
res = client.post("/api/names", json=item) listed = client.get("/api/names", params={"kind": kind}, headers=headers)
if res.status_code == 400 and "already exists" in res.text: if listed.status_code != 200:
print(f"skip {item['spelling']} (exists)")
continue continue
for row in listed.json().get("items") or []:
existing[(row["kind"], row["spelling"].casefold())] = row["id"]
for item in names:
kind = item["kind"]
spelling = item["spelling"]
key = (kind, spelling.casefold())
locales = item.get("locales") or {}
if key in existing:
name_id = existing[key]
res = client.patch(
f"/api/names/{name_id}",
headers=headers,
json={"locales": locales},
)
res.raise_for_status() res.raise_for_status()
print(f"added {item['kind']}: {item['spelling']}") print(f"updated locales {kind}: {spelling}")
continue
res = client.post("/api/names", headers=headers, json=item)
res.raise_for_status()
print(f"added {kind}: {spelling}")
print(f"board: {base}/b/{board_id}")
return 0 return 0
Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

+268 -315
View File
@@ -3,87 +3,96 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="light dark" />
<script src="/static/stork-common.js"></script>
<script>StorkUI.applyTheme();</script>
<title>Stork — family name board</title> <title>Stork — family name board</title>
<link rel="icon" href="/static/favicon.png" type="image/png" />
<link rel="apple-touch-icon" href="/static/favicon.png" />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,700&family=Source+Sans+3:wght@400;600;700&display=swap" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,700&family=Source+Sans+3:wght@400;600;700&display=swap" rel="stylesheet" />
<style> <style>
:root {
--sky: #d6e4ef; :root, [data-theme="light"] {
--fog: #eef3f7; --sky: #d6e4ef; --fog: #eef3f7; --paper: #f8faf8; --ink: #18253a; --muted: #4a5566;
--paper: #f8faf8; --line: #c5d3e0; --accent: #2a5f7a; --wing: #b85c48; --up: #2f6b5a; --down: #9a4a3a;
--ink: #18253a; --rank: #1e3a55; --shadow: 0 10px 32px rgba(24,37,58,.07); --radius: 16px;
--muted: #5a6a7e; --input-bg: #fff; --panel: #f8faf8; --logo-filter: none;
--line: #c5d3e0; --body-bg: radial-gradient(900px 420px at 85% -5%, #f0d8cf 0%, transparent 55%),
--accent: #2a5f7a; radial-gradient(1100px 520px at -5% 0%, var(--sky) 0%, transparent 50%),
--wing: #b85c48; linear-gradient(180deg, #e7eef4 0%, var(--fog) 45%, #e9eee8 100%);
--up: #2f6b5a;
--down: #9a4a3a;
--rank: #1e3a55;
--shadow: 0 12px 40px rgba(24, 37, 58, 0.08);
--radius: 18px;
} }
[data-theme="dark"] {
--sky: #1a2433; --fog: #121820; --paper: #161c26; --ink: #e8eef6; --muted: #a8b4c4;
--line: #2e3a4c; --accent: #7eb6d1; --wing: #e0a090; --up: #5fad93; --down: #d48474;
--rank: #c5d4e8; --shadow: 0 10px 32px rgba(0,0,0,.35);
--input-bg: #0f141c; --panel: #1a222e; --logo-filter: invert(1) contrast(1.05);
--body-bg: radial-gradient(900px 420px at 85% -5%, #2a2030 0%, transparent 55%),
radial-gradient(1100px 520px at -5% 0%, #1a2433 0%, transparent 50%),
linear-gradient(180deg, #10151c 0%, #121820 45%, #141a22 100%);
}
.skip{position:absolute;left:-9999px;top:0;background:var(--ink);color:var(--paper);padding:.5rem .75rem;z-index:10}
.skip:focus{left:.5rem;top:.5rem}
.theme-bar{display:flex;gap:.35rem;flex-wrap:wrap;align-items:center;margin:0 0 .85rem;justify-content:flex-end}
.theme-bar span{color:var(--muted);font-size:.9rem;margin-right:.25rem}
.theme-bar button{padding:.35rem .65rem;font-size:.85rem;min-height:2.25rem;background:var(--input-bg);color:var(--ink);border:1px solid var(--line)}
.theme-bar button.active{background:var(--ink);color:var(--paper);border-color:var(--ink)}
.error{color:var(--down);margin:.45rem 0 0;font-size:.95rem;font-weight:600;border-left:3px solid var(--down);padding-left:.55rem}
input[aria-invalid="true"]{border-color:var(--down);box-shadow:0 0 0 1px var(--down)}
* { box-sizing: border-box; } * { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body { body {
margin: 0; margin: 0;
color: var(--ink); color: var(--ink);
font-family: "Source Sans 3", system-ui, sans-serif; font-family: "Source Sans 3", system-ui, sans-serif;
background: background: var(--body-bg);
radial-gradient(900px 420px at 85% -5%, #f0d8cf 0%, transparent 55%),
radial-gradient(1100px 520px at -5% 0%, var(--sky) 0%, transparent 50%),
linear-gradient(180deg, #e7eef4 0%, var(--fog) 40%, #e9eee8 100%);
min-height: 100vh; min-height: 100vh;
} }
@media (prefers-reduced-motion: reduce) { main { max-width: 1000px; margin: 0 auto; padding: 1.25rem 1rem 3.5rem; }
html { scroll-behavior: auto; }
.name-card { transition: none; }
}
main { max-width: 1100px; margin: 0 auto; padding: 1.5rem 1rem 4rem; }
.hero { .hero {
display: grid; display: flex;
gap: 0.35rem; gap: 0.85rem;
margin-bottom: 1.25rem; align-items: center;
animation: rise 0.7s ease both; margin-bottom: 1rem;
} }
@keyframes rise { .hero img {
from { opacity: 0; transform: translateY(10px); } width: 56px;
to { opacity: 1; transform: none; } height: 56px;
border-radius: 50%;
object-fit: cover;
border: 1px solid var(--line);
background: var(--input-bg); filter: var(--logo-filter);
flex: 0 0 auto;
} }
.brand { .brand {
font-family: Fraunces, Georgia, serif; font-family: Fraunces, Georgia, serif;
font-size: clamp(2.4rem, 6vw, 3.4rem); font-size: clamp(1.9rem, 5vw, 2.5rem);
font-weight: 700; font-weight: 700;
letter-spacing: -0.03em; letter-spacing: -0.03em;
margin: 0; margin: 0;
line-height: 1; line-height: 1;
} }
.brand span { .sub { margin: 0.25rem 0 0; color: var(--muted); font-size: 0.95rem; }
color: var(--wing); .sub a { color: var(--accent); font-weight: 600; text-decoration: none; }
font-style: italic;
font-weight: 500;
}
.sub { margin: 0; color: var(--muted); max-width: 36rem; font-size: 1.05rem; }
.panel { .panel {
background: color-mix(in srgb, var(--paper) 92%, white); background: var(--panel);
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: var(--radius); border-radius: var(--radius);
padding: 1.1rem 1.15rem; padding: 1rem;
margin-bottom: 1rem; margin-bottom: 0.85rem;
box-shadow: var(--shadow); box-shadow: var(--shadow);
backdrop-filter: blur(6px);
} }
label { display: block; font-size: 0.85rem; color: var(--muted); margin-bottom: 0.3rem; font-weight: 600; } label { display: block; font-size: 0.95rem; color: var(--muted); margin-bottom: 0.25rem; font-weight: 600; }
input, select, textarea, button { input, textarea, button {
font: inherit; font: inherit;
width: 100%; width: 100%;
padding: 0.7rem 0.8rem; padding: 0.65rem 0.75rem;
border-radius: 12px; border-radius: 11px;
border: 1px solid var(--line); border: 1px solid var(--line);
background: #fff; background: var(--input-bg);
color: var(--ink); color: var(--ink);
} }
textarea { min-height: 3.2rem; resize: vertical; } textarea { min-height: 2.8rem; resize: vertical; }
button { button {
width: auto; width: auto;
cursor: pointer; cursor: pointer;
@@ -92,227 +101,171 @@
border-color: transparent; border-color: transparent;
font-weight: 600; font-weight: 600;
} }
button:focus-visible, input:focus-visible, textarea:focus-visible, select:focus-visible { button:focus-visible, input:focus-visible, textarea:focus-visible {
outline: 2px solid var(--wing); outline: 2px solid var(--wing);
outline-offset: 2px; outline-offset: 2px;
} }
button.secondary { background: #fff; color: var(--ink); border-color: var(--line); } button.secondary { background: var(--input-bg); color: var(--ink); border-color: var(--line); }
button.icon { button.icon {
width: 2rem; width: 2rem; height: 2rem; padding: 0;
height: 2rem; display: inline-grid; place-items: center;
padding: 0; border-radius: 999px; background: var(--input-bg); color: var(--accent);
display: inline-grid; border: 1px solid var(--line); flex: 0 0 auto;
place-items: center;
border-radius: 999px;
background: #fff;
color: var(--accent);
border: 1px solid var(--line);
flex: 0 0 auto;
} }
button.icon:hover { border-color: var(--accent); }
button.icon.playing { background: var(--accent); color: #fff; } button.icon.playing { background: var(--accent); color: #fff; }
button.vote { button.vote {
min-width: 2.4rem; min-width: 2.2rem; padding: 0.3rem 0.45rem;
padding: 0.35rem 0.5rem; background: var(--input-bg); color: var(--ink); border: 1px solid var(--line);
background: #fff;
color: var(--ink);
border: 1px solid var(--line);
} }
button.vote.active-up { background: var(--up); color: #fff; border-color: transparent; } button.vote.active-up { background: var(--up); color: #fff; border-color: transparent; }
button.vote.active-down { background: var(--down); color: #fff; border-color: transparent; } button.vote.active-down { background: var(--down); color: #fff; border-color: transparent; }
.row { display: flex; gap: 0.55rem; flex-wrap: wrap; align-items: end; } button.pill {
.who { padding: 0.2rem 0.55rem;
display: flex; border-radius: 999px;
justify-content: space-between; font-size: 0.8rem;
gap: 0.75rem; letter-spacing: 0.04em;
align-items: baseline; text-transform: uppercase;
flex-wrap: wrap; background: var(--input-bg);
margin-bottom: 0.85rem;
color: var(--muted); color: var(--muted);
font-size: 0.95rem; border: 1px solid var(--line);
} }
.boards { button.pill.active { background: var(--ink); color: var(--paper); border-color: transparent; }
display: grid; .row { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: end; }
gap: 1rem; .who {
margin-bottom: 1rem; display: flex; justify-content: space-between; gap: 0.75rem;
} align-items: baseline; flex-wrap: wrap; margin-bottom: 0.75rem;
@media (min-width: 860px) { color: var(--muted); font-size: 0.92rem;
.boards { grid-template-columns: 1fr 1fr; align-items: start; }
} }
.who a { color: var(--accent); font-weight: 600; text-decoration: none; }
.boards { display: grid; gap: 0.85rem; margin-bottom: 0.85rem; }
@media (min-width: 860px) { .boards { grid-template-columns: 1fr 1fr; align-items: start; } }
.col-head { .col-head {
display: flex; display: flex; align-items: baseline; justify-content: space-between;
align-items: baseline; gap: 0.5rem; margin-bottom: 0.35rem; padding-bottom: 0.45rem;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.75rem;
padding-bottom: 0.55rem;
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--line);
} }
.col-head h2 { .col-head h2 {
font-family: Fraunces, Georgia, serif; font-family: Fraunces, Georgia, serif;
font-size: 1.35rem; font-size: 1.2rem; margin: 0; font-weight: 700;
margin: 0;
font-weight: 700;
}
.col-head .count {
color: var(--muted);
font-size: 0.85rem;
font-weight: 600;
} }
.col-head .count { color: var(--muted); font-size: 0.95rem; font-weight: 600; }
.name-card { .name-card {
display: grid; display: grid;
grid-template-columns: 2.5rem 1fr auto; grid-template-columns: 2.2rem 1fr auto;
gap: 0.65rem; gap: 0.55rem;
align-items: start; align-items: start;
padding: 0.8rem 0.15rem; padding: 0.7rem 0.1rem;
border-top: 1px solid color-mix(in srgb, var(--line) 70%, transparent); border-top: 1px solid color-mix(in srgb, var(--line) 65%, transparent);
transition: background 0.15s ease;
} }
.name-card:first-of-type { border-top: 0; } .name-card:first-of-type { border-top: 0; }
.score { .score {
font-family: Fraunces, Georgia, serif; font-family: Fraunces, Georgia, serif;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
font-weight: 700; font-weight: 700; font-size: 1.2rem; color: var(--rank);
font-size: 1.35rem; text-align: center; padding-top: 0.1rem;
color: var(--rank);
text-align: center;
line-height: 1.2;
padding-top: 0.15rem;
} }
.spelling { .spelling {
font-family: Fraunces, Georgia, serif; font-family: Fraunces, Georgia, serif;
font-size: 1.35rem; font-size: 1.25rem; margin: 0 0 0.1rem; letter-spacing: -0.02em;
margin: 0 0 0.15rem;
letter-spacing: -0.02em;
} }
.meta { color: var(--muted); font-size: 0.88rem; margin: 0 0 0.35rem; } .meta { color: var(--muted); font-size: 0.95rem; margin: 0 0 0.4rem; }
.langs { margin-top: 0.35rem; } .say-row {
.langs summary { display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap;
cursor: pointer; margin-bottom: 0.25rem;
color: var(--accent);
font-weight: 600;
font-size: 0.9rem;
list-style: none;
}
.langs summary::-webkit-details-marker { display: none; }
.langs summary::before { content: "▸ "; }
.langs[open] summary::before { content: "▾ "; }
.lang-row {
display: grid;
gap: 0.25rem;
margin: 0.55rem 0;
padding: 0.55rem 0.65rem;
background: color-mix(in srgb, var(--sky) 35%, white);
border-radius: 12px;
}
.lang-top {
display: flex;
align-items: center;
gap: 0.45rem;
flex-wrap: wrap;
}
.lang-tag {
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--accent);
background: #fff;
border: 1px solid var(--line);
border-radius: 999px;
padding: 0.1rem 0.45rem;
} }
.say { .say {
font-family: Fraunces, Georgia, serif; font-family: Fraunces, Georgia, serif;
font-size: 1rem; font-size: 1.05rem; margin: 0; min-width: 3rem;
margin: 0;
flex: 1;
} }
.lang-detail { margin: 0; color: var(--muted); font-size: 0.88rem; } .pills { display: flex; gap: 0.25rem; }
.votes { display: flex; flex-direction: column; gap: 0.3rem; } details.more { margin-top: 0.25rem; }
.error { color: var(--down); margin: 0.5rem 0 0; } details.more summary {
cursor: pointer; color: var(--accent); font-weight: 600; font-size: 0.85rem;
list-style: none;
}
details.more summary::-webkit-details-marker { display: none; }
.note {
margin: 0.4rem 0 0; padding: 0.5rem 0.6rem;
background: color-mix(in srgb, var(--sky) 30%, white);
border-radius: 10px; color: var(--muted); font-size: 0.86rem;
}
.votes { display: flex; flex-direction: column; gap: 0.25rem; }
.error { color: var(--down); margin: 0.45rem 0 0; }
.hidden { display: none !important; } .hidden { display: none !important; }
.empty { .empty { color: var(--muted); padding: 0.55rem 0.1rem; font-size: 0.92rem; }
color: var(--muted);
padding: 0.75rem 0.2rem 0.35rem;
font-size: 0.95rem;
}
.compose-head { .compose-head {
display: flex; display: flex; flex-wrap: wrap; gap: 0.65rem; align-items: end;
flex-wrap: wrap; justify-content: space-between; margin-bottom: 0.75rem;
gap: 0.75rem;
align-items: end;
justify-content: space-between;
margin-bottom: 0.85rem;
} }
.compose-head h2 { .compose-head h2 {
font-family: Fraunces, Georgia, serif; font-family: Fraunces, Georgia, serif; margin: 0; font-size: 1.15rem;
margin: 0; }
font-size: 1.25rem; .kind-toggle { display: flex; gap: 0.3rem; }
.kind-toggle button {
min-width: 5.5rem;
background: transparent;
color: var(--ink);
border: 1px solid var(--line);
} }
.kind-toggle { display: flex; gap: 0.35rem; }
.kind-toggle button { min-width: 5.5rem; }
.kind-toggle button.active { .kind-toggle button.active {
background: var(--ink); background: var(--ink);
color: #fff; color: var(--paper);
border-color: transparent; border-color: var(--ink);
} }
.grid-3 { display: grid; gap: 0.65rem; } .kind-toggle button:not(.active):hover {
@media (min-width: 720px) { border-color: var(--accent);
.grid-3 { grid-template-columns: repeat(3, 1fr); }
}
.hint { color: var(--muted); font-size: 0.85rem; margin: 0.35rem 0 0; }
.wing-mark {
width: 42px;
height: 42px;
border-radius: 50%;
background: var(--ink);
color: #f4ebe4;
display: grid;
place-items: center;
font-family: Fraunces, Georgia, serif;
font-style: italic;
font-size: 1.2rem;
margin-bottom: 0.65rem;
} }
.grid-3 { display: grid; gap: 0.55rem; }
@media (min-width: 720px) { .grid-3 { grid-template-columns: repeat(3, 1fr); } }
.hint { color: var(--muted); font-size: 0.95rem; margin: 0.25rem 0 0.55rem; }
</style> </style>
</head> </head>
<body> <body>
<main> <a class="skip" href="#main">Skip to content</a>
<main id="main">
<div class="theme-bar" role="group" aria-label="Color theme">
<span>Theme</span>
<button type="button" data-theme-toggle="light">Light</button>
<button type="button" data-theme-toggle="dark">Dark</button>
<button type="button" data-theme-toggle="system">System</button>
</div>
<header class="hero"> <header class="hero">
<div class="wing-mark" aria-hidden="true">ש</div> <img src="/static/logo-mark.png" alt="Stork" width="56" height="56" />
<h1 class="brand">Stork <span>names</span></h1> <div>
<p class="sub">Family board for first and middle names — vote, hear how they sound, and keep notes in English, Russian, and Hebrew.</p> <h1 class="brand">Stork</h1>
<p class="sub">Classic layout · <a href="/">Paper board</a> · <a href="/logos">Logo ideas</a></p>
</div>
</header> </header>
<section id="gate" class="panel"> <section id="gate" class="panel">
<label for="invite">Invite code</label> <label for="invite">Invite code</label>
<input id="invite" autocomplete="off" /> <input id="invite" autocomplete="off" />
<label for="display" style="margin-top:0.75rem">Your name (shown with your ideas and votes)</label> <label for="display" style="margin-top:0.65rem">Your name</label>
<input id="display" autocomplete="nickname" /> <input id="display" autocomplete="nickname" />
<p class="error hidden" id="gate-error"></p> <p class="error hidden" id="gate-error" role="alert" aria-live="polite"></p>
<div class="row" style="margin-top:0.75rem"> <div class="row" style="margin-top:0.65rem">
<button id="enter" type="button">Enter the board</button> <button id="enter" type="button">Enter</button>
</div> </div>
</section> </section>
<section id="board" class="hidden"> <section id="board" class="hidden">
<div class="who"> <div class="who">
<span id="who"></span> <span id="who"></span>
<a href="#suggest" style="color:var(--accent);font-weight:600;text-decoration:none">Suggest a name ↓</a> <a href="#suggest">Suggest a name</a>
</div> </div>
<div class="boards"> <div class="boards">
<section class="panel" aria-labelledby="first-heading"> <section class="panel" aria-labelledby="first-heading">
<div class="col-head"> <div class="col-head">
<h2 id="first-heading">First names</h2> <h2 id="first-heading">First</h2>
<span class="count" id="first-count"></span> <span class="count" id="first-count"></span>
</div> </div>
<div id="list-first"></div> <div id="list-first"></div>
</section> </section>
<section class="panel" aria-labelledby="middle-heading"> <section class="panel" aria-labelledby="middle-heading">
<div class="col-head"> <div class="col-head">
<h2 id="middle-heading">Middle names</h2> <h2 id="middle-heading">Middle</h2>
<span class="count" id="middle-count"></span> <span class="count" id="middle-count"></span>
</div> </div>
<div id="list-middle"></div> <div id="list-middle"></div>
@@ -323,54 +276,52 @@
<div class="compose-head"> <div class="compose-head">
<h2>Suggest a name</h2> <h2>Suggest a name</h2>
<div class="kind-toggle" role="group" aria-label="Name type"> <div class="kind-toggle" role="group" aria-label="Name type">
<button type="button" class="secondary active" data-kind="first">First</button> <button type="button" class="secondary active" data-kind="c1">First</button>
<button type="button" class="secondary" data-kind="middle">Middle</button> <button type="button" class="secondary" data-kind="c2">Middle</button>
</div> </div>
</div> </div>
<label for="spelling">Spelling</label> <label for="spelling">Spelling</label>
<input id="spelling" placeholder="How you want it written" /> <input id="spelling" required aria-required="true" autocomplete="off" />
<p class="hint">Optional notes below — pronunciation can be played aloud on the board.</p> <p class="hint">Pronunciation fields: just the name in that language (e.g. Roze → שושנה).</p>
<div class="grid-3" style="margin-top:0.75rem"> <div class="grid-3">
<div> <div>
<label>English pronunciation</label> <label>English</label>
<input data-loc="en" data-field="pronunciation" placeholder="e.g. SHY" /> <input data-loc="en" data-field="pronunciation" placeholder="Name" />
<label style="margin-top:0.5rem">Origin</label> <label style="margin-top:0.4rem">Origin</label>
<input data-loc="en" data-field="origin" /> <input data-loc="en" data-field="origin" />
<label style="margin-top:0.5rem">Meaning</label> <label style="margin-top:0.4rem">Meaning</label>
<textarea data-loc="en" data-field="meaning"></textarea> <textarea data-loc="en" data-field="meaning"></textarea>
</div> </div>
<div> <div>
<label>Russian pronunciation</label> <label>Russian</label>
<input data-loc="ru" data-field="pronunciation" placeholder="e.g. Шай" /> <input data-loc="ru" data-field="pronunciation" placeholder="Имя" />
<label style="margin-top:0.5rem">Origin</label> <label style="margin-top:0.4rem">Origin</label>
<input data-loc="ru" data-field="origin" /> <input data-loc="ru" data-field="origin" />
<label style="margin-top:0.5rem">Meaning</label> <label style="margin-top:0.4rem">Meaning</label>
<textarea data-loc="ru" data-field="meaning"></textarea> <textarea data-loc="ru" data-field="meaning"></textarea>
</div> </div>
<div> <div>
<label>Hebrew pronunciation</label> <label>Hebrew</label>
<input data-loc="he" data-field="pronunciation" placeholder="e.g. שַׁי" dir="auto" /> <input data-loc="he" data-field="pronunciation" placeholder="שם" dir="auto" />
<label style="margin-top:0.5rem">Origin</label> <label style="margin-top:0.4rem">Origin</label>
<input data-loc="he" data-field="origin" dir="auto" /> <input data-loc="he" data-field="origin" dir="auto" />
<label style="margin-top:0.5rem">Meaning</label> <label style="margin-top:0.4rem">Meaning</label>
<textarea data-loc="he" data-field="meaning" dir="auto"></textarea> <textarea data-loc="he" data-field="meaning" dir="auto"></textarea>
</div> </div>
</div> </div>
<p class="error hidden" id="add-error"></p> <p class="error hidden" id="add-error" role="alert" aria-live="polite"></p>
<div class="row" style="margin-top:0.75rem"> <div class="row" style="margin-top:0.65rem">
<button id="add" type="button">Add to board</button> <button id="add" type="button">Add</button>
</div> </div>
</div> </div>
</section> </section>
</main> </main>
<script> <script>
const state = { kind: "first", first: [], middle: [] }; const state = { kind: "c1", c1: [], c2: [], langById: {} };
const SPEECH_LANG = { en: "en-US", ru: "ru-RU", he: "he-IL" }; const SPEECH_LANG = { en: "en-US", ru: "ru-RU", he: "he-IL" };
let speakingBtn = null; let speakingBtn = null;
function params() { function params() { return new URLSearchParams(location.search); }
return new URLSearchParams(location.search);
}
async function api(path, opts = {}) { async function api(path, opts = {}) {
const res = await fetch(path, { const res = await fetch(path, {
@@ -383,7 +334,7 @@
try { data = text ? JSON.parse(text) : null; } catch { data = { detail: text }; } try { data = text ? JSON.parse(text) : null; } catch { data = { detail: text }; }
if (!res.ok) { if (!res.ok) {
const detail = (data && (data.detail || data.message)) || res.statusText; const detail = (data && (data.detail || data.message)) || res.statusText;
throw new Error(typeof detail === "string" ? detail : JSON.stringify(detail)); throw new Error(StorkUI.formatError(detail));
} }
return data; return data;
} }
@@ -394,93 +345,72 @@
})[c]); })[c]);
} }
function ttsText(pronunciation, spelling) {
const raw = (pronunciation || "").trim() || (spelling || "").trim();
if (!raw) return "";
// Prefer the lead cue before long parenthetical notes.
return raw.split(/[—(]/)[0].replace(/\s+/g, " ").trim();
}
function stopSpeech() { function stopSpeech() {
if (window.speechSynthesis) window.speechSynthesis.cancel(); if (window.speechSynthesis) window.speechSynthesis.cancel();
if (speakingBtn) { if (speakingBtn) { speakingBtn.classList.remove("playing"); speakingBtn = null; }
speakingBtn.classList.remove("playing");
speakingBtn = null;
}
} }
function playPronunciation(lang, pronunciation, spelling, btn) { function play(lang, text, btn) {
if (!window.speechSynthesis) { if (!window.speechSynthesis) {
alert("This browser cannot play speech aloud. Try Chrome or Safari."); alert("Speech not supported in this browser.");
return; return;
} }
const text = ttsText(pronunciation, spelling); const say = (text || "").trim();
if (!text) return; if (!say) return;
stopSpeech(); stopSpeech();
const u = new SpeechSynthesisUtterance(text); const u = new SpeechSynthesisUtterance(say);
u.lang = SPEECH_LANG[lang] || "en-US"; u.lang = SPEECH_LANG[lang] || "en-US";
u.rate = 0.9; u.rate = 0.92;
const voices = speechSynthesis.getVoices(); const voices = speechSynthesis.getVoices();
const match = voices.find((v) => v.lang === u.lang) const match = voices.find((v) => v.lang === u.lang)
|| voices.find((v) => v.lang && v.lang.startsWith(u.lang.slice(0, 2))); || voices.find((v) => v.lang && v.lang.startsWith(u.lang.slice(0, 2)));
if (match) u.voice = match; if (match) u.voice = match;
speakingBtn = btn; speakingBtn = btn;
btn.classList.add("playing"); btn.classList.add("playing");
u.onend = () => { btn.classList.remove("playing"); speakingBtn = null; }; u.onend = u.onerror = () => { btn.classList.remove("playing"); speakingBtn = null; };
u.onerror = () => { btn.classList.remove("playing"); speakingBtn = null; };
speechSynthesis.speak(u); speechSynthesis.speak(u);
} }
function renderLangs(n) { function formFor(n, lang) {
const locales = n.locales || {}; const L = (n.locales && n.locales[lang]) || {};
const blocks = ["en", "ru", "he"].map((lang) => { return (L.pronunciation || "").trim() || (lang === "en" ? n.spelling : "");
const L = locales[lang] || {};
const has = L.pronunciation || L.origin || L.meaning;
if (!has) return "";
const say = L.pronunciation || n.spelling;
const details = [
L.origin && `Origin: ${escapeHtml(L.origin)}`,
L.meaning && escapeHtml(L.meaning),
].filter(Boolean).join(" · ");
return `
<div class="lang-row">
<div class="lang-top">
<span class="lang-tag">${lang}</span>
<p class="say" dir="auto">${escapeHtml(say)}</p>
<button type="button" class="icon" data-play="${lang}"
data-pronunciation="${escapeHtml(L.pronunciation || "")}"
data-spelling="${escapeHtml(n.spelling)}"
aria-label="Play ${lang} pronunciation">▶</button>
</div>
${details ? `<p class="lang-detail" dir="auto">${details}</p>` : ""}
</div>`;
}).filter(Boolean).join("");
if (!blocks) return `<p class="meta">No notes yet</p>`;
return `<div class="langs"><details><summary>Pronunciation, origin & meaning</summary>${blocks}</details></div>`;
} }
function renderList(kind, items) { function noteFor(n, lang) {
const root = document.getElementById(kind === "first" ? "list-first" : "list-middle"); const L = (n.locales && n.locales[lang]) || {};
const count = document.getElementById(kind === "first" ? "first-count" : "middle-count"); return [L.origin, L.meaning].filter(Boolean).join(" — ");
count.textContent = items.length ? `${items.length} on the board` : "empty";
if (!items.length) {
root.innerHTML = `<p class="empty">No ${kind} names yet — add one below.</p>`;
return;
} }
root.innerHTML = items.map((n) => `
function renderCard(n, kind) {
const lang = state.langById[n.id] || "en";
const form = formFor(n, lang);
const note = noteFor(n, lang);
const pills = ["en", "ru", "he"].map((l) =>
`<button type="button" class="pill ${lang === l ? "active" : ""}" data-lang="${l}">${l}</button>`
).join("");
return `
<article class="name-card" data-id="${n.id}" data-kind="${kind}"> <article class="name-card" data-id="${n.id}" data-kind="${kind}">
<div class="score" title="Score">${n.score}</div> <div class="score">${n.score}</div>
<div> <div>
<p class="spelling" dir="auto">${escapeHtml(n.spelling)}</p> <p class="spelling" dir="auto">${escapeHtml(n.spelling)}</p>
<p class="meta">${escapeHtml(n.created_by || "someone")} · ${n.vote_count} vote(s)</p> <p class="meta">${escapeHtml(n.created_by || "someone")} · ${n.vote_count}</p>
${renderLangs(n)} <div class="say-row">
<div class="pills">${pills}</div>
<p class="say" dir="auto">${escapeHtml(form || "—")}</p>
<button type="button" class="icon" data-play
data-lang="${lang}" data-text="${escapeHtml(form)}"
aria-label="Play">▶</button>
</div>
${note ? `<details class="more"><summary>Meaning</summary><p class="note" dir="auto">${escapeHtml(note)}</p></details>` : ""}
</div> </div>
<div class="votes"> <div class="votes">
<button type="button" class="vote ${n.my_vote === 1 ? "active-up" : ""}" data-vote="1" aria-label="Upvote">▲</button> <button type="button" class="vote ${n.my_vote === 1 ? "active-up" : ""}" data-vote="1" aria-label="Upvote">▲</button>
<button type="button" class="vote ${n.my_vote === -1 ? "active-down" : ""}" data-vote="-1" aria-label="Downvote">▼</button> <button type="button" class="vote ${n.my_vote === -1 ? "active-down" : ""}" data-vote="-1" aria-label="Downvote">▼</button>
</div> </div>
</article>`).join(""); </article>`;
}
function bindCard(root, items) {
root.querySelectorAll("button[data-vote]").forEach((btn) => { root.querySelectorAll("button[data-vote]").forEach((btn) => {
btn.addEventListener("click", async () => { btn.addEventListener("click", async () => {
const card = btn.closest(".name-card"); const card = btn.closest(".name-card");
@@ -492,20 +422,31 @@
await refresh(); await refresh();
}); });
}); });
root.querySelectorAll("button[data-lang]").forEach((btn) => {
btn.addEventListener("click", () => {
const id = Number(btn.closest(".name-card").dataset.id);
state.langById[id] = btn.dataset.lang;
const kind = btn.closest(".name-card").dataset.kind;
renderList(kind, kind === "c1" ? state.c1 : state.c2);
});
});
root.querySelectorAll("button[data-play]").forEach((btn) => { root.querySelectorAll("button[data-play]").forEach((btn) => {
btn.addEventListener("click", (e) => { btn.addEventListener("click", () => play(btn.dataset.lang, btn.dataset.text, btn));
e.preventDefault();
e.stopPropagation();
playPronunciation(
btn.dataset.play,
btn.dataset.pronunciation,
btn.dataset.spelling,
btn,
);
});
}); });
} }
function renderList(kind, items) {
const root = document.getElementById(kind === "c1" ? "list-first" : "list-middle");
const count = document.getElementById(kind === "c1" ? "first-count" : "middle-count");
count.textContent = String(items.length);
if (!items.length) {
root.innerHTML = `<p class="empty">None yet</p>`;
return;
}
root.innerHTML = items.map((n) => renderCard(n, kind)).join("");
bindCard(root, items);
}
function localePayload() { function localePayload() {
const locales = { en: {}, ru: {}, he: {} }; const locales = { en: {}, ru: {}, he: {} };
document.querySelectorAll("[data-loc][data-field]").forEach((el) => { document.querySelectorAll("[data-loc][data-field]").forEach((el) => {
@@ -520,75 +461,87 @@
} }
async function refresh() { async function refresh() {
const [first, middle] = await Promise.all([ const first = await api("/api/names?kind=c1");
api("/api/names?kind=first"), state.c1 = first.items || [];
api("/api/names?kind=middle"), state.c2 = [];
]); renderList("c1", state.c1);
state.first = first.items || []; const midRoot = document.getElementById("list-middle");
state.middle = middle.items || []; const midCount = document.getElementById("middle-count");
renderList("first", state.first); if (midRoot) midRoot.innerHTML = `<p class="empty">Use the <a href="/">paper board</a> for more columns.</p>`;
renderList("middle", state.middle); if (midCount) midCount.textContent = "—";
} }
async function showBoard(displayName) { async function showBoard(displayName) {
document.getElementById("gate").classList.add("hidden"); document.getElementById("gate").classList.add("hidden");
document.getElementById("board").classList.remove("hidden"); document.getElementById("board").classList.remove("hidden");
document.getElementById("who").textContent = `Signed in as ${displayName}`; document.getElementById("who").textContent = displayName;
// Chrome loads voices async
if (window.speechSynthesis) speechSynthesis.getVoices(); if (window.speechSynthesis) speechSynthesis.getVoices();
await refresh(); await refresh();
} }
document.querySelectorAll(".kind-toggle button").forEach((btn) => { document.querySelectorAll(".kind-toggle button").forEach((btn) => {
btn.setAttribute("aria-pressed", btn.classList.contains("active") ? "true" : "false");
btn.addEventListener("click", () => { btn.addEventListener("click", () => {
state.kind = btn.dataset.kind; state.kind = btn.dataset.kind;
document.querySelectorAll(".kind-toggle button").forEach((b) => b.classList.toggle("active", b === btn)); document.querySelectorAll(".kind-toggle button").forEach((b) => {
const on = b === btn;
b.classList.toggle("active", on);
b.setAttribute("aria-pressed", on ? "true" : "false");
});
}); });
}); });
document.getElementById("enter").addEventListener("click", async () => { document.getElementById("enter").addEventListener("click", async () => {
const err = document.getElementById("gate-error"); const err = document.getElementById("gate-error");
err.classList.add("hidden"); StorkUI.clearError(err);
try { try {
const invite = document.getElementById("invite").value.trim();
const display_name = document.getElementById("display").value.trim();
const data = await api("/api/session", { const data = await api("/api/session", {
method: "POST", method: "POST",
body: JSON.stringify({ invite, display_name }), body: JSON.stringify({
invite: document.getElementById("invite").value.trim(),
display_name: document.getElementById("display").value.trim(),
}),
}); });
const url = new URL(location.href); const url = new URL(location.href);
url.searchParams.delete("invite"); url.searchParams.delete("invite");
history.replaceState({}, "", url.pathname + (url.search || "")); history.replaceState({}, "", url.pathname + (url.search || ""));
await showBoard(data.display_name); await showBoard(data.display_name);
} catch (e) { } catch (e) {
err.textContent = e.message; StorkUI.showError(err, e.message);
err.classList.remove("hidden");
} }
}); });
document.getElementById("add").addEventListener("click", async () => { document.getElementById("add").addEventListener("click", async () => {
const err = document.getElementById("add-error"); const err = document.getElementById("add-error");
err.classList.add("hidden"); const spellingEl = document.getElementById("spelling");
StorkUI.clearError(err);
spellingEl.removeAttribute("aria-invalid");
const spelling = spellingEl.value.trim();
if (!spelling) {
StorkUI.showError(err, "Enter a name spelling");
spellingEl.setAttribute("aria-invalid", "true");
spellingEl.focus();
return;
}
try { try {
await api("/api/names", { await api("/api/names", {
method: "POST", method: "POST",
body: JSON.stringify({ body: JSON.stringify({
kind: state.kind, kind: state.kind,
spelling: document.getElementById("spelling").value.trim(), spelling,
locales: localePayload(), locales: localePayload(),
}), }),
}); });
clearForm(); clearForm();
await refresh(); await refresh();
} catch (e) { } catch (e) {
err.textContent = e.message; StorkUI.showError(err, e.message);
err.classList.remove("hidden"); spellingEl.setAttribute("aria-invalid", "true");
} }
}); });
if (window.speechSynthesis) { StorkUI.initThemeControls();
speechSynthesis.onvoiceschanged = () => speechSynthesis.getVoices(); if (window.speechSynthesis) speechSynthesis.onvoiceschanged = () => speechSynthesis.getVoices();
}
(async () => { (async () => {
const invite = params().get("invite"); const invite = params().get("invite");
@@ -596,7 +549,7 @@
try { try {
const session = await api("/api/session"); const session = await api("/api/session");
if (session.authenticated) await showBoard(session.display_name); if (session.authenticated) await showBoard(session.display_name);
} catch (_) { /* stay on gate */ } } catch (_) {}
})(); })();
</script> </script>
</body> </body>
Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+215
View File
@@ -0,0 +1,215 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Stork — pick a logo</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,700&family=Source+Sans+3:wght@400;600;700&display=swap" rel="stylesheet" />
<style>
:root {
--ink: #18253a;
--muted: #5a6a7e;
--line: #c5d3e0;
--paper: #f8faf8;
--accent: #2a5f7a;
--wing: #b85c48;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "Source Sans 3", system-ui, sans-serif;
color: var(--ink);
background:
radial-gradient(900px 420px at 85% -5%, #f0d8cf 0%, transparent 55%),
radial-gradient(1100px 520px at -5% 0%, #d6e4ef 0%, transparent 50%),
#eef3f7;
min-height: 100vh;
}
main { max-width: 1100px; margin: 0 auto; padding: 1.5rem 1rem 3rem; }
h1 {
font-family: Fraunces, Georgia, serif;
font-size: clamp(2rem, 5vw, 2.8rem);
margin: 0 0 0.35rem;
letter-spacing: -0.03em;
}
.sub { color: var(--muted); margin: 0 0 1.25rem; max-width: 40rem; }
.nav { margin-bottom: 1.25rem; }
.nav a { color: var(--accent); font-weight: 600; text-decoration: none; }
.grid {
display: grid;
gap: 1rem;
grid-template-columns: 1fr;
}
@media (min-width: 720px) {
.grid { grid-template-columns: 1fr 1fr; }
}
@media (min-width: 1000px) {
.grid { grid-template-columns: repeat(3, 1fr); }
}
.card {
background: var(--paper);
border: 1px solid var(--line);
border-radius: 18px;
overflow: hidden;
box-shadow: 0 12px 40px rgba(24, 37, 58, 0.08);
display: flex;
flex-direction: column;
}
.card.selected {
outline: 3px solid var(--wing);
outline-offset: 2px;
}
.card img {
width: 100%;
aspect-ratio: 1;
object-fit: cover;
display: block;
background: #f5f0e6;
}
.body { padding: 0.9rem 1rem 1.1rem; }
.num {
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--wing);
margin: 0 0 0.2rem;
}
.body h2 {
font-family: Fraunces, Georgia, serif;
font-size: 1.2rem;
margin: 0 0 0.35rem;
}
.body p { margin: 0 0 0.85rem; color: var(--muted); font-size: 0.95rem; }
button {
font: inherit;
font-weight: 600;
border: 1px solid var(--line);
background: #fff;
color: var(--ink);
border-radius: 12px;
padding: 0.55rem 0.85rem;
cursor: pointer;
}
button:hover { border-color: var(--accent); }
button.picked {
background: var(--ink);
color: #fff;
border-color: transparent;
}
.footer {
margin-top: 1.25rem;
padding: 1rem;
border-radius: 18px;
border: 1px solid var(--line);
background: color-mix(in srgb, var(--paper) 92%, white);
}
.footer strong { font-family: Fraunces, Georgia, serif; }
.favicon-row {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
margin-top: 0.75rem;
align-items: center;
}
.favicon-row img {
width: 48px;
height: 48px;
object-fit: cover;
border-radius: 10px;
border: 1px solid var(--line);
background: #f5f0e6;
}
</style>
</head>
<body>
<main>
<div class="nav"><a href="/">← Back to name board</a></div>
<h1>Pick a Stork mark</h1>
<p class="sub">All five logo concepts. Site currently uses <strong>concept 1</strong> (paper stork silhouette) as the header mark and favicon. <a href="/" style="color:var(--accent);font-weight:600;text-decoration:none">Name board →</a></p>
<div class="grid" id="grid"></div>
<div class="footer">
<p style="margin:0 0 0.35rem"><strong id="choice">No pick yet</strong></p>
<p style="margin:0;color:var(--muted);font-size:0.95rem">Favicon-size previews (how they’d look in a browser tab):</p>
<div class="favicon-row" id="favicons"></div>
</div>
</main>
<script>
const concepts = [
{
id: 1,
file: "01-silhouette.png",
title: "Paper stork silhouette",
blurb: "Single black stork in flight on cream paper. Quiet keepsake. Favicon = head + beak crop.",
},
{
id: 2,
file: "02-nest-monogram.png",
title: "Nest monogram",
blurb: "Letter S woven from twigs into a nest circle. Family-board metaphor without a cartoon bird.",
},
{
id: 3,
file: "03-hebrew-stamp.png",
title: "Hebrew שי stamp",
blurb: "Wax-seal with שי (“gift”) and Stork underneath. Favicon = the seal.",
},
{
id: 4,
file: "04-ribbon-stork.png",
title: "Name-ribbon stork",
blurb: "Stork carrying a tiny “Stork” banner. Classic birth announcement.",
},
{
id: 5,
file: "05-trilingual-arcs.png",
title: "Trilingual mark",
blurb: "Three thin arcs (en / ru / he) as a wing. Signals multilingual from the tab.",
},
];
const key = "stork_logo_pick";
let picked = Number(localStorage.getItem(key) || "1");
if (!localStorage.getItem(key)) localStorage.setItem(key, "1");
function render() {
const grid = document.getElementById("grid");
grid.innerHTML = concepts.map((c) => `
<article class="card ${picked === c.id ? "selected" : ""}" data-id="${c.id}">
<img src="/static/logos/${c.file}" alt="${c.title}" loading="lazy" />
<div class="body">
<p class="num">Concept ${c.id}</p>
<h2>${c.title}</h2>
<p>${c.blurb}</p>
<button type="button" class="${picked === c.id ? "picked" : ""}" data-pick="${c.id}">
${picked === c.id ? "Your pick" : "Pick this"}
</button>
</div>
</article>`).join("");
document.getElementById("favicons").innerHTML = concepts.map((c) =>
`<img src="/static/logos/${c.file}" alt="Favicon preview ${c.id}" title="${c.title}" />`
).join("");
const choice = concepts.find((c) => c.id === picked);
document.getElementById("choice").textContent = choice
? `Your pick: #${choice.id} — ${choice.title}`
: "No pick yet — tap Pick this on a card";
grid.querySelectorAll("button[data-pick]").forEach((btn) => {
btn.addEventListener("click", () => {
picked = Number(btn.dataset.pick);
localStorage.setItem(key, String(picked));
render();
});
});
}
render();
</script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 814 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 724 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 931 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 647 KiB

+96
View File
@@ -0,0 +1,96 @@
/** Shared helpers for Stork v1 + v2. */
(function (global) {
function formatError(detail) {
if (detail == null || detail === "") return "Something went wrong";
if (typeof detail === "string") {
try {
const parsed = JSON.parse(detail);
return formatError(parsed);
} catch {
return detail;
}
}
if (Array.isArray(detail)) {
return detail
.map((item) => {
if (!item || typeof item !== "object") return String(item);
const loc = Array.isArray(item.loc) ? item.loc.filter((x) => x !== "body").join(".") : "";
const msg = item.msg || "Invalid value";
if (loc === "spelling" || (item.loc && item.loc.includes("spelling"))) {
if (String(msg).toLowerCase().includes("at least 1")) return "Enter a name spelling";
return `Spelling: ${msg}`;
}
return loc ? `${loc}: ${msg}` : msg;
})
.join(". ");
}
if (typeof detail === "object" && detail.msg) return String(detail.msg);
return String(detail);
}
function showError(el, detail) {
if (!el) return;
el.textContent = formatError(detail);
el.classList.remove("hidden");
}
function clearError(el) {
if (!el) return;
el.textContent = "";
el.classList.add("hidden");
}
const THEME_KEY = "stork_theme";
function systemTheme() {
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
function currentTheme() {
return localStorage.getItem(THEME_KEY) || "system";
}
function resolvedTheme() {
const t = currentTheme();
return t === "system" ? systemTheme() : t;
}
function applyTheme() {
const resolved = resolvedTheme();
document.documentElement.setAttribute("data-theme", resolved);
document.documentElement.style.colorScheme = resolved;
document.querySelectorAll("[data-theme-toggle]").forEach((btn) => {
const mode = btn.getAttribute("data-theme-toggle");
const active = currentTheme() === mode;
btn.setAttribute("aria-pressed", active ? "true" : "false");
btn.classList.toggle("active", active);
});
}
function setTheme(mode) {
if (mode === "system") localStorage.removeItem(THEME_KEY);
else localStorage.setItem(THEME_KEY, mode);
applyTheme();
}
function initThemeControls() {
applyTheme();
document.querySelectorAll("[data-theme-toggle]").forEach((btn) => {
btn.addEventListener("click", () => setTheme(btn.getAttribute("data-theme-toggle")));
});
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => {
if (currentTheme() === "system") applyTheme();
});
}
global.StorkUI = {
formatError,
showError,
clearError,
applyTheme,
setTheme,
initThemeControls,
currentTheme,
resolvedTheme,
};
})(window);
+990
View File
@@ -0,0 +1,990 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="light dark" />
<script src="/static/stork-common.js"></script>
<script>StorkUI.applyTheme();</script>
<title>Stork — family name board</title>
<link rel="icon" href="/static/favicon.png" type="image/png" />
<link rel="apple-touch-icon" href="/static/favicon.png" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=Source+Sans+3:wght@400;600&display=swap" rel="stylesheet" />
<style>
:root, [data-theme="light"] {
--paper: #f3ead8; --ink: #141414; --muted: #5c5348; --rule: #c9b89a; --chip: #efe6d2;
--up: #1f1f1f; --down: #6b3a32; --focus: #2a2a2a; --sheet: color-mix(in srgb, var(--paper) 70%, white);
--input-bg: #fffdf8; --logo-filter: none; --rec: #8b3a2f;
--body-bg: radial-gradient(ellipse at 50% 0%, #faf4e8 0%, transparent 55%),
repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(80,60,30,.015) 2px, rgba(80,60,30,.015) 3px),
var(--paper);
}
[data-theme="dark"] {
--paper: #1a1814; --ink: #f0e6d4; --muted: #b5a890; --rule: #3f3a32; --chip: #242018;
--up: #e8dcc4; --down: #e0a090; --focus: #f0e6d4; --sheet: #221f1a;
--input-bg: #12100e; --logo-filter: invert(1) contrast(1.05); --rec: #e09080;
--body-bg: radial-gradient(ellipse at 50% 0%, #2a241c 0%, transparent 55%), var(--paper);
}
.skip{position:absolute;left:-9999px;top:0;background:var(--ink);color:var(--paper);padding:.5rem .75rem;z-index:10}
.skip:focus{left:.5rem;top:.5rem}
.theme-bar{display:flex;gap:.35rem;flex-wrap:wrap;align-items:center;margin:0 0 .85rem;justify-content:flex-end}
.theme-bar span{color:var(--muted);font-size:.9rem;margin-right:.25rem}
.theme-bar button{padding:.35rem .65rem;font-size:.85rem;min-height:2.25rem;background:var(--input-bg);color:var(--ink);border:1px solid var(--rule)}
.theme-bar button.active{background:var(--ink);color:var(--paper);border-color:var(--ink)}
.error{color:var(--down);margin:.45rem 0 0;font-size:.95rem;font-weight:600;border-left:3px solid var(--down);padding-left:.55rem}
input[aria-invalid="true"]{border-color:var(--down);box-shadow:0 0 0 1px var(--down)}
* { box-sizing: border-box; }
body {
margin: 0;
color: var(--ink);
font-family: "Source Sans 3", system-ui, sans-serif;
background: var(--body-bg);
min-height: 100vh;
}
main { max-width: 1180px; margin: 0 auto; padding: 1.5rem 1rem 4rem; }
.topnav {
display: flex; gap: 1rem; flex-wrap: wrap; align-items: center;
margin-bottom: 1.25rem; font-size: 0.9rem;
}
.topnav a { color: var(--muted); text-decoration: underline; text-underline-offset: 3px; }
.hero {
display: grid;
grid-template-columns: auto 1fr;
gap: 1rem 1.25rem;
align-items: center;
padding-bottom: 1.25rem;
margin-bottom: 1.25rem;
border-bottom: 1px solid var(--rule);
}
.hero img {
width: 88px; height: 88px; object-fit: contain;
filter: var(--logo-filter);
}
.brand {
font-family: "Libre Baskerville", Georgia, serif;
font-size: clamp(2rem, 5vw, 2.75rem);
margin: 0;
letter-spacing: -0.02em;
font-weight: 700;
}
.tag {
margin: 0.35rem 0 0;
color: var(--muted);
max-width: 32rem;
line-height: 1.45;
}
.sheet {
background: var(--sheet);
border: 1px solid var(--rule);
padding: 1rem 1.05rem 1.1rem;
margin-bottom: 1rem;
box-shadow: 0 1px 0 rgba(255,255,255,0.5) inset;
}
label { display: block; font-size: 0.95rem; color: var(--muted); margin-bottom: 0.25rem; letter-spacing: 0.02em; }
input, textarea, button {
font: inherit; width: 100%;
padding: 0.6rem 0.7rem;
border: 1px solid var(--rule);
background: var(--input-bg);
color: var(--ink);
border-radius: 0;
}
textarea { min-height: 2.6rem; resize: vertical; }
button {
width: auto; cursor: pointer;
background: var(--ink); color: var(--paper);
border-color: var(--ink); font-weight: 600;
padding: 0.55rem 0.95rem;
}
button.ghost { background: transparent; color: var(--ink); }
button:focus-visible, input:focus-visible, textarea:focus-visible {
outline: 3px solid var(--focus); outline-offset: 2px;
}
.row { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: end; }
.who {
display: flex; justify-content: space-between; gap: 0.75rem;
flex-wrap: wrap; align-items: baseline; margin-bottom: 0.85rem;
color: var(--muted); font-size: 0.92rem;
}
.who a { color: var(--ink); }
.boards-row {
display: flex; gap: 0.75rem; align-items: stretch; margin-bottom: 1rem;
}
.boards {
display: grid; gap: 1rem; flex: 1; min-width: 0;
grid-template-columns: 1fr;
}
@media (min-width: 700px) {
.boards { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (min-width: 1100px) {
.boards.cols-3, .boards.cols-4 { grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); }
}
.boards .sheet { margin-bottom: 0; }
.add-col {
flex: 0 0 auto;
align-self: stretch;
min-width: 3rem;
writing-mode: horizontal-tb;
background: transparent;
color: var(--muted);
border: 1px dashed var(--rule);
font-size: 1.6rem;
line-height: 1;
padding: 0.5rem;
display: grid;
place-items: center;
}
.add-col:hover, .add-col:focus-visible { color: var(--ink); border-color: var(--ink); }
.add-col:disabled { opacity: 0.35; cursor: default; }
.col-title {
font-family: "Libre Baskerville", Georgia, serif;
font-size: 1.05rem; margin: 0 0 0.65rem;
padding-bottom: 0.4rem;
border-bottom: 1px solid var(--rule);
display: flex; justify-content: space-between; gap: 0.5rem; align-items: center;
}
.col-title .title-edit {
flex: 1; min-width: 0;
font-family: "Libre Baskerville", Georgia, serif;
font-size: 1.05rem; font-weight: 700;
padding: 0.15rem 0.25rem;
border: 1px solid transparent;
background: transparent;
color: var(--ink);
}
.col-title .title-edit:hover,
.col-title .title-edit:focus {
border-color: var(--rule);
background: var(--input-bg);
outline: none;
}
.col-title .title-edit::placeholder { color: var(--muted); font-weight: 400; font-style: italic; }
.col-title .meta-count { color: var(--muted); font-family: "Source Sans 3", sans-serif; font-size: 0.8rem; font-weight: 600; flex: 0 0 auto; }
.col-title .remove-col {
all: unset; cursor: pointer; color: var(--muted); font-size: 0.85rem;
padding: 0.15rem 0.35rem; flex: 0 0 auto;
}
.col-title .remove-col:hover { color: var(--down); }
.card {
display: grid;
grid-template-columns: 2rem 1fr auto;
gap: 0.55rem;
padding: 0.7rem 0;
border-top: 1px solid color-mix(in srgb, var(--rule) 70%, transparent);
}
.card:first-of-type { border-top: 0; padding-top: 0.15rem; }
.score {
font-family: "Libre Baskerville", Georgia, serif;
font-variant-numeric: tabular-nums;
font-weight: 700; text-align: center; padding-top: 0.15rem;
}
.name-btn {
all: unset; cursor: pointer; display: block; width: 100%;
font-family: "Libre Baskerville", Georgia, serif;
font-size: 1.2rem; letter-spacing: -0.01em;
}
.name-btn:hover { text-decoration: underline; text-underline-offset: 3px; }
.meta { margin: 0.15rem 0 0; color: var(--muted); font-size: 0.8rem; }
.votes { display: flex; flex-direction: column; gap: 0.25rem; }
.votes button {
min-width: 2.1rem; padding: 0.25rem 0.35rem;
background: transparent; color: var(--ink); border: 1px solid var(--rule);
}
.votes button.active-up { background: var(--up); color: var(--paper); border-color: var(--up); }
.votes button.active-down { background: var(--down); color: #f5ebe4; border-color: var(--down); }
.drawer {
grid-column: 1 / -1;
margin: 0.35rem 0 0.15rem;
padding: 0.7rem 0.75rem;
background: var(--chip);
border: 1px solid var(--rule);
}
.drawer.hidden { display: none; }
.say-row { display: flex; align-items: center; gap: 0.45rem; flex-wrap: wrap; }
.pills { display: flex; gap: 0.2rem; }
.pills button {
padding: 0.15rem 0.45rem; font-size: 0.7rem; letter-spacing: 0.05em;
text-transform: uppercase; background: transparent; color: var(--muted);
border: 1px solid var(--rule);
}
.pills button.active { background: var(--ink); color: var(--paper); border-color: var(--ink); }
.voice-status { color: var(--muted); font-size: 0.8rem; }
.voice-status strong { color: var(--ink); font-weight: 600; }
.edit-grid { display: grid; gap: 0.4rem; margin-top: 0.55rem; }
.edit-grid label { margin: 0; font-size: 0.78rem; }
.edit-grid input, .edit-grid textarea {
padding: 0.4rem 0.5rem; font-size: 0.95rem;
}
.edit-grid textarea { min-height: 2.4rem; }
.notes-view {
margin-top: 0.55rem; font-size: 0.9rem; line-height: 1.4; color: var(--ink);
}
.notes-view .k { color: var(--muted); font-size: 0.78rem; display: block; margin-top: 0.35rem; }
.notes-view .k:first-child { margin-top: 0; }
.notes-view .empty-hint { color: var(--muted); font-style: italic; }
.voice-row {
display: flex; flex-wrap: wrap; gap: 0.35rem; align-items: center;
margin-top: 0.55rem;
}
.voice-row button { padding: 0.35rem 0.6rem; font-size: 0.82rem; }
.voice-row button.recording {
background: var(--rec); color: #fff; border-color: var(--rec);
animation: pulse 1.2s ease-in-out infinite;
}
@keyframes pulse {
50% { opacity: 0.75; }
}
.play {
width: 2rem; height: 2rem; padding: 0;
display: inline-grid; place-items: center;
background: transparent; color: var(--ink); border: 1px solid var(--rule);
}
.play.playing { background: var(--ink); color: var(--paper); }
.note { margin: 0.4rem 0 0; color: var(--muted); font-size: 0.8rem; line-height: 1.35; }
.empty { color: var(--muted); font-size: 0.92rem; padding: 0.35rem 0; }
.hidden { display: none !important; }
.compose-head {
display: flex; justify-content: space-between; gap: 0.75rem;
flex-wrap: wrap; align-items: end; margin-bottom: 0.75rem;
}
.compose-head h2 {
font-family: "Libre Baskerville", Georgia, serif;
font-size: 1.1rem; margin: 0;
}
.kind-toggle { display: flex; gap: 0.25rem; flex-wrap: wrap; }
.kind-toggle button.active { background: var(--ink); color: var(--paper); }
details.notes { margin-top: 0.65rem; }
details.notes summary {
cursor: pointer; color: var(--muted); font-size: 0.9rem;
list-style: none;
}
details.notes summary::-webkit-details-marker { display: none; }
.grid-3 { display: grid; gap: 0.55rem; margin-top: 0.55rem; }
@media (min-width: 720px) { .grid-3 { grid-template-columns: repeat(3, 1fr); } }
.hint { color: var(--muted); font-size: 0.9rem; margin: 0.3rem 0 0; }
.foot {
margin-top: 1.5rem; padding-top: 0.85rem;
border-top: 1px solid var(--rule);
color: var(--muted); font-size: 0.85rem;
}
.foot a { color: var(--ink); }
</style>
</head>
<body>
<a class="skip" href="#main">Skip to content</a>
<main id="main">
<div class="theme-bar" role="group" aria-label="Color theme">
<span>Theme</span>
<button type="button" data-theme-toggle="light">Light</button>
<button type="button" data-theme-toggle="dark">Dark</button>
<button type="button" data-theme-toggle="system">System</button>
</div>
<div class="topnav">
<a href="/logos">Logo concepts</a>
<a href="/v1">Classic board</a>
</div>
<header class="hero">
<img src="/static/logo-hero.png" alt="" width="88" height="88" />
<div>
<h1 class="brand">Stork</h1>
<p class="tag" id="board-tag">Family name board — vote, edit how a name is said, and record your own voice when TTS misses.</p>
</div>
</header>
<section id="landing" class="sheet">
<p class="hint" style="margin-top:0">Start a private board, add names, then share the link. No password — the link is the key.</p>
<label for="board-title">Board title</label>
<input id="board-title" value="Name board" autocomplete="off" />
<label for="display-new" style="margin-top:0.6rem">Your name</label>
<input id="display-new" autocomplete="nickname" />
<p class="error hidden" id="landing-error" role="alert" aria-live="polite"></p>
<div class="row" style="margin-top:0.65rem">
<button id="start-board" type="button">Start new board</button>
</div>
</section>
<section id="gate" class="sheet hidden">
<p class="hint" style="margin-top:0" id="gate-hint">Join this board — enter the name others will see on your votes.</p>
<label for="display">Your name</label>
<input id="display" autocomplete="nickname" />
<p class="error hidden" id="gate-error" role="alert" aria-live="polite"></p>
<div class="row" style="margin-top:0.65rem">
<button id="enter" type="button">Enter</button>
</div>
</section>
<section id="board" class="hidden">
<div class="who">
<span id="who"></span>
<span>
<button type="button" class="ghost" id="share-link" style="padding:0.35rem 0.6rem;font-size:0.85rem">Copy share link</button>
<a href="#suggest">Suggest a name</a>
</span>
</div>
<div class="boards-row">
<div class="boards" id="boards"></div>
<button type="button" class="add-col" id="add-col" title="Add another name column" aria-label="Add column">+</button>
</div>
<div class="sheet" id="suggest">
<div class="compose-head">
<h2>Suggest a name</h2>
<div class="kind-toggle" id="kind-toggle"></div>
</div>
<label for="spelling">Spelling</label>
<input id="spelling" required aria-required="true" autocomplete="off" />
<p class="hint">Written form for this column. Prefer a real recording over browser speech.</p>
<details class="notes" open>
<summary>Pronunciation / origin / meaning (optional)</summary>
<div class="grid-3">
<div>
<label>English form</label>
<input data-loc="en" data-field="pronunciation" placeholder="Name" />
<label style="margin-top:0.35rem">Origin</label>
<input data-loc="en" data-field="origin" />
<label style="margin-top:0.35rem">Meaning</label>
<textarea data-loc="en" data-field="meaning"></textarea>
</div>
<div>
<label>Russian form</label>
<input data-loc="ru" data-field="pronunciation" placeholder="Имя" />
<label style="margin-top:0.35rem">Origin</label>
<input data-loc="ru" data-field="origin" />
<label style="margin-top:0.35rem">Meaning</label>
<textarea data-loc="ru" data-field="meaning"></textarea>
</div>
<div>
<label>Hebrew form</label>
<input data-loc="he" data-field="pronunciation" placeholder="שם" dir="auto" />
<label style="margin-top:0.35rem">Origin</label>
<input data-loc="he" data-field="origin" dir="auto" />
<label style="margin-top:0.35rem">Meaning</label>
<textarea data-loc="he" data-field="meaning" dir="auto"></textarea>
</div>
</div>
</details>
<p class="error hidden" id="add-error" role="alert" aria-live="polite"></p>
<div class="row" style="margin-top:0.7rem">
<button id="add" type="button">Add</button>
</div>
</div>
</section>
<p class="foot">Paper board · <a href="/v1">classic layout</a> · mark is logo concept 1</p>
</main>
<script>
const MAX_COLUMNS = 4;
const SPEECH_LANG = { en: "en-US", ru: "ru-RU", he: "he-IL" };
const state = {
kind: "c1",
boardId: null,
boardUrl: null,
columns: [],
lists: {},
openId: null,
editId: null,
langById: {},
recording: null,
};
let speakingBtn = null;
let audioEl = null;
function colLabel(col) {
const label = (col && col.label || "").trim();
return label || "Untitled";
}
function params() { return new URLSearchParams(location.search); }
async function api(path, opts = {}) {
const headers = { ...(opts.headers || {}) };
if (opts.body && !(opts.body instanceof FormData) && !headers["Content-Type"]) {
headers["Content-Type"] = "application/json";
}
const res = await fetch(path, {
credentials: "include",
headers,
...opts,
});
const text = await res.text();
let data = null;
try { data = text ? JSON.parse(text) : null; } catch { data = { detail: text }; }
if (!res.ok) {
const detail = (data && (data.detail || data.message)) || res.statusText;
throw new Error(StorkUI.formatError(detail));
}
return data;
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, (c) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"
})[c]);
}
function stopPlayback() {
if (window.speechSynthesis) window.speechSynthesis.cancel();
if (audioEl) {
audioEl.pause();
audioEl.src = "";
audioEl = null;
}
if (speakingBtn) { speakingBtn.classList.remove("playing"); speakingBtn = null; }
}
function formFor(n, lang) {
const L = (n.locales && n.locales[lang]) || {};
return (L.pronunciation || "").trim() || (lang === "en" ? n.spelling : "");
}
function playName(n, lang, btn) {
stopPlayback();
const hasRec = n.recordings && n.recordings[lang];
speakingBtn = btn;
btn.classList.add("playing");
const done = () => { btn.classList.remove("playing"); speakingBtn = null; };
if (hasRec) {
audioEl = new Audio(`/api/names/${n.id}/recording/${lang}?t=${Date.now()}`);
audioEl.onended = audioEl.onerror = done;
audioEl.play().catch(done);
return;
}
if (!window.speechSynthesis) { done(); return; }
const say = formFor(n, lang);
if (!say) { done(); return; }
const u = new SpeechSynthesisUtterance(say);
u.lang = SPEECH_LANG[lang] || "en-US";
u.rate = 0.92;
const voices = speechSynthesis.getVoices();
const match = voices.find((v) => v.lang === u.lang)
|| voices.find((v) => v.lang && v.lang.startsWith(u.lang.slice(0, 2)));
if (match) u.voice = match;
u.onend = u.onerror = done;
speechSynthesis.speak(u);
}
function itemsFor(kind) {
return state.lists[kind] || [];
}
function renderKindToggle() {
const root = document.getElementById("kind-toggle");
if (!state.columns.some((c) => c.id === state.kind)) {
state.kind = (state.columns[0] && state.columns[0].id) || "c1";
}
root.innerHTML = state.columns.map((c) =>
`<button type="button" class="ghost ${state.kind === c.id ? "active" : ""}" data-kind="${c.id}" aria-pressed="${state.kind === c.id}">${escapeHtml(colLabel(c))}</button>`
).join("");
root.querySelectorAll("button").forEach((btn) => {
btn.addEventListener("click", () => {
state.kind = btn.dataset.kind;
renderKindToggle();
});
});
}
function renderBoards() {
const boards = document.getElementById("boards");
boards.className = `boards cols-${state.columns.length}`;
boards.innerHTML = state.columns.map((col) => `
<section class="sheet" data-col="${col.id}">
<h2 class="col-title">
<input class="title-edit" data-rename="${col.id}" value="${escapeHtml(col.label || "")}" placeholder="Name this column" aria-label="Column title" maxlength="40" />
<span class="meta-count" id="count-${col.id}">0</span>
${col.id !== state.columns[0].id ? `<button type="button" class="remove-col" data-remove="${col.id}" title="Remove column" aria-label="Remove column">✕</button>` : ""}
</h2>
<div id="list-${col.id}"></div>
</section>
`).join("");
boards.querySelectorAll("[data-rename]").forEach((input) => {
const save = async () => {
const id = input.dataset.rename;
const label = input.value.trim();
try {
const updated = await api(`/api/columns/${id}`, {
method: "PATCH",
body: JSON.stringify({ label }),
});
const col = state.columns.find((c) => c.id === id);
if (col) col.label = updated.label;
renderKindToggle();
} catch (e) {
alert(e.message || "Could not rename column");
}
};
input.addEventListener("change", save);
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") { e.preventDefault(); input.blur(); }
});
});
boards.querySelectorAll("[data-remove]").forEach((btn) => {
btn.addEventListener("click", async () => {
const id = btn.dataset.remove;
const col = state.columns.find((c) => c.id === id);
const title = colLabel(col || { label: "" });
const n = col && col.name_count ? col.name_count : 0;
const msg = n
? `Remove column “${title}” and its ${n} name(s)?`
: `Remove column “${title}”?`;
if (!confirm(msg)) return;
try {
const res = await api(`/api/columns/${id}`, { method: "DELETE" });
state.columns = res.items || [];
if (state.kind === id) state.kind = state.columns[0]?.id || "c1";
renderBoards();
renderKindToggle();
await refresh();
} catch (e) {
alert(e.message || "Could not remove column");
}
});
});
const addBtn = document.getElementById("add-col");
addBtn.disabled = state.columns.length >= MAX_COLUMNS;
addBtn.title = addBtn.disabled ? "Maximum 4 columns" : "Add another name column";
state.columns.forEach((col) => renderList(col.id));
}
function renderCard(n, kind) {
const open = state.openId === n.id;
const editing = state.editId === n.id;
const lang = state.langById[n.id] || "en";
const L = (n.locales && n.locales[lang]) || {};
const form = formFor(n, lang);
const hasRec = !!(n.recordings && n.recordings[lang]);
const hasNotes = !!(L.origin || L.meaning || L.pronunciation);
const pills = ["en", "ru", "he"].map((l) =>
`<button type="button" class="${lang === l ? "active" : ""}" data-lang="${l}">${l}</button>`
).join("");
const recActive = state.recording && state.recording.nameId === n.id && state.recording.lang === lang;
const spoken = L.pronunciation || form || "—";
const notesView = `
<div class="notes-view">
<span class="k">Spoken (${lang})</span>
<div dir="auto">${escapeHtml(spoken)}</div>
<span class="k">Origin</span>
<div dir="auto">${L.origin ? escapeHtml(L.origin) : `<span class="empty-hint">Not set</span>`}</div>
<span class="k">Meaning</span>
<div dir="auto">${L.meaning ? escapeHtml(L.meaning) : `<span class="empty-hint">Not set</span>`}</div>
</div>`;
const notesEdit = `
<div class="edit-grid">
<div>
<label for="pron-${n.id}">Spoken form (${lang})</label>
<input id="pron-${n.id}" data-edit="pronunciation" value="${escapeHtml(L.pronunciation || "")}" dir="auto" placeholder="${escapeHtml(form || n.spelling)}" />
</div>
<div>
<label for="orig-${n.id}">Origin</label>
<input id="orig-${n.id}" data-edit="origin" value="${escapeHtml(L.origin || "")}" dir="auto" placeholder="Language / root" />
</div>
<div>
<label for="mean-${n.id}">Meaning</label>
<textarea id="mean-${n.id}" data-edit="meaning" dir="auto" placeholder="What it means">${escapeHtml(L.meaning || "")}</textarea>
</div>
</div>`;
return `
<article class="card" data-id="${n.id}" data-kind="${kind}">
<div class="score">${n.score}</div>
<div>
<button type="button" class="name-btn" data-toggle dir="auto">${escapeHtml(n.spelling)}</button>
<p class="meta">${escapeHtml(n.created_by || "someone")} · ${n.vote_count}${hasRec ? " · voice" : ""}</p>
<div class="drawer ${open ? "" : "hidden"}">
<div class="say-row">
<div class="pills">${pills}</div>
<button type="button" class="play" data-play aria-label="Play pronunciation">▶</button>
<span class="voice-status">${hasRec ? "<strong>Your voice</strong>" : "AI speech"}</span>
</div>
${editing || !hasNotes ? notesEdit : notesView}
<div class="voice-row">
${editing || !hasNotes
? `<button type="button" class="ghost" data-save-notes>Save</button>`
: `<button type="button" class="ghost" data-edit-notes>Edit notes</button>`}
<button type="button" class="ghost ${recActive ? "recording" : ""}" data-record>${recActive ? "Stop" : (hasRec ? "Re-record" : "Record")}</button>
${hasRec ? `<button type="button" class="ghost" data-clear-rec>Use AI speech</button>` : ""}
</div>
<p class="note">Play uses AI until you record; Re-record replaces it.</p>
</div>
</div>
<div class="votes">
<button type="button" class="${n.my_vote === 1 ? "active-up" : ""}" data-vote="1" aria-label="Upvote">▲</button>
<button type="button" class="${n.my_vote === -1 ? "active-down" : ""}" data-vote="-1" aria-label="Downvote">▼</button>
</div>
</article>`;
}
function bind(root, items) {
root.querySelectorAll("[data-toggle]").forEach((btn) => {
btn.addEventListener("click", () => {
const id = Number(btn.closest(".card").dataset.id);
if (state.openId === id) {
state.openId = null;
state.editId = null;
} else {
state.openId = id;
state.editId = null;
}
renderList(btn.closest(".card").dataset.kind);
});
});
root.querySelectorAll("[data-edit-notes]").forEach((btn) => {
btn.addEventListener("click", () => {
state.editId = Number(btn.closest(".card").dataset.id);
renderList(btn.closest(".card").dataset.kind);
});
});
root.querySelectorAll("[data-vote]").forEach((btn) => {
btn.addEventListener("click", async () => {
const card = btn.closest(".card");
const id = card.dataset.id;
let value = Number(btn.dataset.vote);
const item = items.find((x) => String(x.id) === String(id));
if (item && item.my_vote === value) value = 0;
await api(`/api/names/${id}/vote`, { method: "POST", body: JSON.stringify({ value }) });
await refresh();
});
});
root.querySelectorAll("[data-lang]").forEach((btn) => {
btn.addEventListener("click", () => {
const id = Number(btn.closest(".card").dataset.id);
state.langById[id] = btn.dataset.lang;
// Stay in view mode when switching langs unless actively editing
renderList(btn.closest(".card").dataset.kind);
});
});
root.querySelectorAll("[data-play]").forEach((btn) => {
btn.addEventListener("click", () => {
const card = btn.closest(".card");
const id = Number(card.dataset.id);
const item = items.find((x) => x.id === id);
if (!item) return;
playName(item, state.langById[id] || "en", btn);
});
});
root.querySelectorAll("[data-save-notes]").forEach((btn) => {
btn.addEventListener("click", async () => {
const card = btn.closest(".card");
const id = Number(card.dataset.id);
const lang = state.langById[id] || "en";
const locales = {};
locales[lang] = {
pronunciation: card.querySelector('[data-edit="pronunciation"]').value.trim(),
origin: card.querySelector('[data-edit="origin"]').value.trim(),
meaning: card.querySelector('[data-edit="meaning"]').value.trim(),
};
await api(`/api/names/${id}`, { method: "PATCH", body: JSON.stringify({ locales }) });
state.openId = id;
state.editId = null;
await refresh();
});
});
root.querySelectorAll("[data-record]").forEach((btn) => {
btn.addEventListener("click", () => toggleRecord(btn));
});
root.querySelectorAll("[data-clear-rec]").forEach((btn) => {
btn.addEventListener("click", async () => {
const card = btn.closest(".card");
const id = Number(card.dataset.id);
const lang = state.langById[id] || "en";
await api(`/api/names/${id}/recording/${lang}`, { method: "DELETE" });
state.openId = id;
await refresh();
});
});
}
async function toggleRecord(btn) {
const card = btn.closest(".card");
const nameId = Number(card.dataset.id);
const lang = state.langById[nameId] || "en";
if (state.recording && state.recording.nameId === nameId && state.recording.lang === lang) {
state.recording.mediaRecorder.stop();
return;
}
if (state.recording) {
try { state.recording.mediaRecorder.stop(); } catch (_) {}
state.recording = null;
}
if (!navigator.mediaDevices || !window.MediaRecorder) {
alert("Voice recording needs a modern browser with microphone access.");
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mime = MediaRecorder.isTypeSupported("audio/webm;codecs=opus")
? "audio/webm;codecs=opus"
: (MediaRecorder.isTypeSupported("audio/mp4") ? "audio/mp4" : "");
const mediaRecorder = mime ? new MediaRecorder(stream, { mimeType: mime }) : new MediaRecorder(stream);
const chunks = [];
mediaRecorder.ondataavailable = (e) => { if (e.data.size) chunks.push(e.data); };
mediaRecorder.onstop = async () => {
stream.getTracks().forEach((t) => t.stop());
state.recording = null;
const blob = new Blob(chunks, { type: mediaRecorder.mimeType || "audio/webm" });
if (!blob.size) {
renderList(card.dataset.kind);
return;
}
const fd = new FormData();
const ext = blob.type.includes("mp4") ? "m4a" : "webm";
fd.append("file", blob, `voice.${ext}`);
try {
await api(`/api/names/${nameId}/recording/${lang}`, { method: "POST", body: fd });
state.openId = nameId;
await refresh();
} catch (e) {
alert(e.message || "Upload failed");
renderList(card.dataset.kind);
}
};
state.recording = { nameId, lang, mediaRecorder, chunks };
mediaRecorder.start();
renderList(card.dataset.kind);
} catch (e) {
alert("Microphone permission needed to record a pronunciation.");
}
}
function renderList(kind) {
const root = document.getElementById(`list-${kind}`);
const count = document.getElementById(`count-${kind}`);
if (!root || !count) return;
const items = itemsFor(kind);
count.textContent = String(items.length);
if (!items.length) {
root.innerHTML = `<p class="empty">None yet — add one below.</p>`;
return;
}
root.innerHTML = items.map((n) => renderCard(n, kind)).join("");
bind(root, items);
}
function localePayload() {
const locales = { en: {}, ru: {}, he: {} };
document.querySelectorAll("[data-loc][data-field]").forEach((el) => {
locales[el.dataset.loc][el.dataset.field] = el.value.trim();
});
return locales;
}
function clearForm() {
document.getElementById("spelling").value = "";
document.querySelectorAll("[data-loc][data-field]").forEach((el) => { el.value = ""; });
}
function refreshLists() {
state.columns.forEach((col) => renderList(col.id));
}
async function loadColumns() {
const data = await api("/api/columns");
state.columns = data.items || [];
if (!state.columns.some((c) => c.id === state.kind)) {
state.kind = (state.columns[0] && state.columns[0].id) || "c1";
}
}
async function refresh() {
if (!state.columns.length) await loadColumns();
const results = await Promise.all(
state.columns.map((col) => api(`/api/names?kind=${encodeURIComponent(col.id)}`))
);
state.columns.forEach((col, i) => {
state.lists[col.id] = results[i].items || [];
});
refreshLists();
}
async function showBoard(name, board) {
document.getElementById("landing").classList.add("hidden");
document.getElementById("gate").classList.add("hidden");
document.getElementById("board").classList.remove("hidden");
document.getElementById("who").textContent = name;
state.boardId = board && board.id;
state.boardUrl = (board && board.url) || (state.boardId ? `${location.origin}/b/${state.boardId}` : location.href);
const tag = document.getElementById("board-tag");
if (board && board.title) {
tag.textContent = `${board.title} — vote, edit pronunciation, record your voice when AI misses.`;
}
if (window.speechSynthesis) speechSynthesis.getVoices();
await loadColumns();
renderBoards();
renderKindToggle();
await refresh();
}
function pathBoardId() {
const m = location.pathname.match(/^\/b\/([^/]+)\/?$/);
return m ? decodeURIComponent(m[1]) : "";
}
document.getElementById("add-col").addEventListener("click", async () => {
if (state.columns.length >= MAX_COLUMNS) return;
try {
const col = await api("/api/columns", { method: "POST" });
state.columns = [...state.columns, col];
state.kind = col.id;
renderBoards();
renderKindToggle();
await refresh();
const input = document.querySelector(`[data-rename="${col.id}"]`);
if (input) { input.focus(); input.select(); }
} catch (e) {
alert(e.message || "Could not add column");
}
});
document.getElementById("share-link").addEventListener("click", async () => {
const url = state.boardUrl || location.href;
try {
await navigator.clipboard.writeText(url);
const btn = document.getElementById("share-link");
const prev = btn.textContent;
btn.textContent = "Copied";
setTimeout(() => { btn.textContent = prev; }, 1500);
} catch (_) {
prompt("Copy this board link:", url);
}
});
document.getElementById("start-board").addEventListener("click", async () => {
const err = document.getElementById("landing-error");
StorkUI.clearError(err);
const display = document.getElementById("display-new").value.trim();
const title = document.getElementById("board-title").value.trim() || "Name board";
if (!display) {
StorkUI.showError(err, "Enter your name");
return;
}
try {
const created = await api("/api/boards", {
method: "POST",
body: JSON.stringify({ title }),
});
const data = await api("/api/session", {
method: "POST",
body: JSON.stringify({ board_id: created.id, display_name: display }),
});
history.replaceState({}, "", `/b/${created.id}`);
await showBoard(data.display_name, data.board);
} catch (e) {
StorkUI.showError(err, e.message);
}
});
document.getElementById("enter").addEventListener("click", async () => {
const err = document.getElementById("gate-error");
StorkUI.clearError(err);
const boardId = pathBoardId() || state.boardId;
try {
const data = await api("/api/session", {
method: "POST",
body: JSON.stringify({
board_id: boardId || undefined,
invite: params().get("invite") || undefined,
display_name: document.getElementById("display").value.trim(),
}),
});
if (data.board && data.board.id && !pathBoardId()) {
history.replaceState({}, "", `/b/${data.board.id}`);
}
await showBoard(data.display_name, data.board);
} catch (e) {
StorkUI.showError(err, e.message);
}
});
document.getElementById("add").addEventListener("click", async () => {
const err = document.getElementById("add-error");
const spellingEl = document.getElementById("spelling");
StorkUI.clearError(err);
spellingEl.removeAttribute("aria-invalid");
const spelling = spellingEl.value.trim();
if (!spelling) {
StorkUI.showError(err, "Enter a name spelling");
spellingEl.setAttribute("aria-invalid", "true");
spellingEl.focus();
return;
}
try {
await api("/api/names", {
method: "POST",
body: JSON.stringify({
kind: state.kind,
spelling,
locales: localePayload(),
}),
});
clearForm();
await refresh();
} catch (e) {
StorkUI.showError(err, e.message);
spellingEl.setAttribute("aria-invalid", "true");
}
});
StorkUI.initThemeControls();
if (window.speechSynthesis) speechSynthesis.onvoiceschanged = () => speechSynthesis.getVoices();
(async () => {
const boardId = pathBoardId();
const invite = params().get("invite");
// Legacy invite links → /b/{id}
if (invite && !boardId) {
try {
const info = await api(`/api/resolve?invite=${encodeURIComponent(invite)}`);
history.replaceState({}, "", `/b/${info.id}`);
state.boardId = info.id;
state.boardUrl = info.url;
document.getElementById("landing").classList.add("hidden");
document.getElementById("gate").classList.remove("hidden");
document.getElementById("gate-hint").textContent =
`${info.title} — enter the name others will see on your votes.`;
return;
} catch (_) {
// fall through to landing
}
}
if (boardId) {
state.boardId = boardId;
document.getElementById("landing").classList.add("hidden");
try {
const info = await api(`/api/boards/${encodeURIComponent(boardId)}`);
document.getElementById("gate-hint").textContent =
`${info.title} — enter the name others will see on your votes.`;
state.boardUrl = info.url;
} catch (_) {
document.getElementById("gate-hint").textContent = "Board not found.";
}
try {
const session = await api("/api/session");
if (session.authenticated && session.board && session.board.id === boardId) {
await showBoard(session.display_name, session.board);
return;
}
} catch (_) {}
document.getElementById("gate").classList.remove("hidden");
return;
}
try {
const session = await api("/api/session");
if (session.authenticated && session.board) {
history.replaceState({}, "", `/b/${session.board.id}`);
await showBoard(session.display_name, session.board);
return;
}
} catch (_) {}
document.getElementById("landing").classList.remove("hidden");
})();
</script>
</body>
</html>
+363 -58
View File
@@ -1,4 +1,4 @@
"""Stork HTTP API + invite-gated family UI.""" """Stork HTTP API + invite-gated multi-board family UI."""
from __future__ import annotations from __future__ import annotations
@@ -8,21 +8,37 @@ import secrets
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from fastapi import Cookie, Depends, FastAPI, Header, HTTPException, Response from fastapi import Cookie, Depends, FastAPI, File, Header, HTTPException, Response, UploadFile
from fastapi.responses import FileResponse from fastapi.responses import FileResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from stork.db import KINDS, LANGS, Store from stork.db import LANGS, MAX_COLUMNS, Store
DATA_DIR = Path(os.environ.get("STORK_DATA", "./data")) DATA_DIR = Path(os.environ.get("STORK_DATA", "./data"))
INVITE_TOKEN = os.environ.get("STORK_INVITE_TOKEN", "").strip() INVITE_TOKEN = os.environ.get("STORK_INVITE_TOKEN", "").strip()
ADMIN_TOKEN = os.environ.get("STORK_ADMIN_TOKEN", "").strip() ADMIN_TOKEN = os.environ.get("STORK_ADMIN_TOKEN", "").strip()
COOKIE_SECURE = os.environ.get("STORK_COOKIE_SECURE", "false").lower() in {"1", "true", "yes"} COOKIE_SECURE = os.environ.get("STORK_COOKIE_SECURE", "false").lower() in {"1", "true", "yes"}
PUBLIC_BASE = os.environ.get("STORK_PUBLIC_URL", "https://stork.levkin.ca").rstrip("/")
STATIC = Path(__file__).resolve().parent.parent / "static" STATIC = Path(__file__).resolve().parent.parent / "static"
ALLOWED_AUDIO = {
"audio/webm",
"audio/ogg",
"audio/mp4",
"audio/mpeg",
"audio/wav",
"audio/x-m4a",
"audio/aac",
"video/webm",
}
app = FastAPI(title="Stork", docs_url=None, redoc_url=None) app = FastAPI(title="Stork", docs_url=None, redoc_url=None)
store = Store(DATA_DIR / "stork.sqlite3") store = Store(
DATA_DIR / "stork.sqlite3",
bootstrap_invite=INVITE_TOKEN,
bootstrap_title="Family",
)
if STATIC.is_dir(): if STATIC.is_dir():
app.mount("/static", StaticFiles(directory=STATIC), name="static") app.mount("/static", StaticFiles(directory=STATIC), name="static")
@@ -35,11 +51,15 @@ class LocaleIn(BaseModel):
class NameIn(BaseModel): class NameIn(BaseModel):
kind: str = Field(pattern="^(first|middle)$") kind: str = Field(pattern=r"^c[1-4]$")
spelling: str = Field(min_length=1, max_length=80) spelling: str = Field(min_length=1, max_length=80)
locales: dict[str, LocaleIn] = Field(default_factory=dict) locales: dict[str, LocaleIn] = Field(default_factory=dict)
class ColumnPatch(BaseModel):
label: str = Field(default="", max_length=40)
class LocalesPatch(BaseModel): class LocalesPatch(BaseModel):
locales: dict[str, LocaleIn] locales: dict[str, LocaleIn]
@@ -49,40 +69,22 @@ class VoteIn(BaseModel):
class SessionIn(BaseModel): class SessionIn(BaseModel):
invite: str = Field(min_length=1, max_length=200)
display_name: str = Field(min_length=1, max_length=80) display_name: str = Field(min_length=1, max_length=80)
board_id: str | None = Field(default=None, max_length=64)
invite: str | None = Field(default=None, max_length=200)
def _require_invite_configured() -> None: class BoardCreatePublic(BaseModel):
if not INVITE_TOKEN: title: str = Field(default="Name board", min_length=1, max_length=80)
raise HTTPException(503, "STORK_INVITE_TOKEN not configured")
def _invite_ok(invite: str | None) -> bool: class BoardCreate(BaseModel):
_require_invite_configured() title: str = Field(min_length=1, max_length=80)
return bool(invite) and secrets.compare_digest(invite, INVITE_TOKEN) invite: str | None = Field(default=None, max_length=200)
def _session( class BoardPatch(BaseModel):
stork_invite: str | None = Cookie(default=None), title: str = Field(min_length=1, max_length=80)
stork_voter: str | None = Cookie(default=None),
stork_name: str | None = Cookie(default=None),
x_stork_invite: str | None = Header(default=None),
x_stork_display_name: str | None = Header(default=None),
x_stork_voter: str | None = Header(default=None),
) -> dict[str, str]:
invite = x_stork_invite or stork_invite
if not _invite_ok(invite):
raise HTTPException(401, "Invite required")
voter = (x_stork_voter or stork_voter or "").strip()
name = (x_stork_display_name or stork_name or "").strip()
if name and not voter:
# API/script clients without cookies (e.g. seed over HTTP while Secure cookies are on).
digest = hashlib.sha256(name.lower().encode()).hexdigest()[:24]
voter = f"hdr-{digest}"
if not voter or not name:
raise HTTPException(401, "Session required — open the invite link and enter your name")
return {"voter_key": voter, "display_name": name}
def _admin( def _admin(
@@ -94,6 +96,66 @@ def _admin(
raise HTTPException(403, "Admin token required") raise HTTPException(403, "Admin token required")
def _resolve_board(
board_id: str | None = None,
invite: str | None = None,
) -> dict[str, Any]:
board_id = (board_id or "").strip()
invite = (invite or "").strip()
if board_id:
board = store.find_board(board_id)
if board:
return board
if invite:
board = store.find_board_by_invite(invite)
if board:
return board
raise HTTPException(404, "Board not found")
def _session(
stork_board: str | None = Cookie(default=None),
stork_invite: str | None = Cookie(default=None),
stork_voter: str | None = Cookie(default=None),
stork_name: str | None = Cookie(default=None),
x_stork_board: str | None = Header(default=None),
x_stork_invite: str | None = Header(default=None),
x_stork_display_name: str | None = Header(default=None),
x_stork_voter: str | None = Header(default=None),
) -> dict[str, str]:
board = _resolve_board(
board_id=x_stork_board or stork_board,
invite=x_stork_invite or stork_invite,
)
voter = (x_stork_voter or stork_voter or "").strip()
name = (x_stork_display_name or stork_name or "").strip()
if name and not voter:
digest = hashlib.sha256(name.lower().encode()).hexdigest()[:24]
voter = f"hdr-{digest}"
if not voter or not name:
raise HTTPException(401, "Session required — enter your name on this board")
return {
"voter_key": voter,
"display_name": name,
"board_id": board["id"],
"board_title": board["title"],
}
def _board_url(board_id: str) -> str:
return f"{PUBLIC_BASE}/b/{board_id}"
def _cookie_kwargs() -> dict[str, Any]:
return {
"httponly": True,
"samesite": "lax",
"secure": COOKIE_SECURE,
"max_age": 60 * 60 * 24 * 400,
"path": "/",
}
@app.get("/api/health") @app.get("/api/health")
def health() -> dict[str, Any]: def health() -> dict[str, Any]:
return {"ok": True, "service": "stork"} return {"ok": True, "service": "stork"}
@@ -103,51 +165,133 @@ def health() -> dict[str, Any]:
def meta() -> dict[str, Any]: def meta() -> dict[str, Any]:
return { return {
"langs": list(LANGS), "langs": list(LANGS),
"kinds": list(KINDS), "max_columns": MAX_COLUMNS,
"invite_required": True, "multi_board": True,
} }
@app.post("/api/boards")
def create_board_public(body: BoardCreatePublic) -> dict[str, Any]:
"""Start a new board — no password. Share the returned URL with family."""
board = store.create_board(body.title.strip() or "Name board")
return {
"id": board["id"],
"title": board["title"],
"url": _board_url(board["id"]),
}
@app.get("/api/boards/{board_id}")
def get_board_public(board_id: str) -> dict[str, Any]:
board = store.find_board(board_id)
if not board:
raise HTTPException(404, "Board not found")
return {"id": board["id"], "title": board["title"], "url": _board_url(board["id"])}
@app.get("/api/resolve")
def resolve_invite(invite: str = "") -> dict[str, Any]:
"""Map a legacy invite token to a board URL."""
board = store.find_board_by_invite(invite)
if not board:
raise HTTPException(404, "Board not found")
return {"id": board["id"], "title": board["title"], "url": _board_url(board["id"])}
@app.post("/api/session") @app.post("/api/session")
def create_session(body: SessionIn, response: Response) -> dict[str, Any]: def create_session(body: SessionIn, response: Response) -> dict[str, Any]:
if not _invite_ok(body.invite.strip()): board = _resolve_board(board_id=body.board_id, invite=body.invite)
raise HTTPException(403, "Invalid invite")
voter = secrets.token_urlsafe(16) voter = secrets.token_urlsafe(16)
display = body.display_name.strip() display = body.display_name.strip()
cookie_kwargs: dict[str, Any] = { kwargs = _cookie_kwargs()
"httponly": True, response.set_cookie("stork_board", board["id"], **kwargs)
"samesite": "lax", response.set_cookie("stork_voter", voter, **kwargs)
"secure": COOKIE_SECURE, response.set_cookie("stork_name", display, **{**kwargs, "httponly": False})
"max_age": 60 * 60 * 24 * 400, response.delete_cookie("stork_invite", path="/")
"path": "/", return {
"ok": True,
"display_name": display,
"board": {"id": board["id"], "title": board["title"], "url": _board_url(board["id"])},
} }
response.set_cookie("stork_invite", INVITE_TOKEN, **cookie_kwargs)
response.set_cookie("stork_voter", voter, **cookie_kwargs)
response.set_cookie("stork_name", display, **{**cookie_kwargs, "httponly": False})
return {"ok": True, "display_name": display}
@app.get("/api/session") @app.get("/api/session")
def get_session( def get_session(
stork_board: str | None = Cookie(default=None),
stork_invite: str | None = Cookie(default=None), stork_invite: str | None = Cookie(default=None),
stork_voter: str | None = Cookie(default=None), stork_voter: str | None = Cookie(default=None),
stork_name: str | None = Cookie(default=None), stork_name: str | None = Cookie(default=None),
x_stork_board: str | None = Header(default=None),
x_stork_invite: str | None = Header(default=None), x_stork_invite: str | None = Header(default=None),
) -> dict[str, Any]: ) -> dict[str, Any]:
invite = x_stork_invite or stork_invite board = None
authed = _invite_ok(invite) if INVITE_TOKEN else False try:
board = _resolve_board(
board_id=x_stork_board or stork_board,
invite=x_stork_invite or stork_invite,
)
except HTTPException:
board = None
authed = bool(board) and bool(stork_voter) and bool(stork_name)
return { return {
"authenticated": authed and bool(stork_voter) and bool(stork_name), "authenticated": authed,
"display_name": stork_name or "", "display_name": stork_name or "",
"has_invite": authed, "board": (
{"id": board["id"], "title": board["title"], "url": _board_url(board["id"])}
if board
else None
),
} }
@app.get("/api/columns")
def list_columns(session: dict[str, str] = Depends(_session)) -> dict[str, Any]:
return {"items": store.list_columns(session["board_id"]), "max": MAX_COLUMNS}
@app.post("/api/columns")
def add_column(session: dict[str, str] = Depends(_session)) -> dict[str, Any]:
try:
return store.add_column(session["board_id"], "")
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
@app.patch("/api/columns/{column_id}")
def rename_column(
column_id: str,
body: ColumnPatch,
session: dict[str, str] = Depends(_session),
) -> dict[str, Any]:
updated = store.rename_column(session["board_id"], column_id, body.label)
if not updated:
raise HTTPException(404, "Column not found")
return updated
@app.delete("/api/columns/{column_id}")
def delete_column(
column_id: str,
session: dict[str, str] = Depends(_session),
) -> dict[str, Any]:
try:
if not store.delete_column(session["board_id"], column_id):
raise HTTPException(404, "Column not found")
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
return {"ok": True, "items": store.list_columns(session["board_id"])}
@app.get("/api/names") @app.get("/api/names")
def list_names(kind: str = "first", session: dict[str, str] = Depends(_session)) -> dict[str, Any]: def list_names(kind: str = "c1", session: dict[str, str] = Depends(_session)) -> dict[str, Any]:
if kind not in KINDS: try:
raise HTTPException(400, "kind must be first or middle") return {
return {"items": store.list_names(kind, voter_key=session["voter_key"])} "items": store.list_names(
session["board_id"], kind, voter_key=session["voter_key"]
)
}
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
@app.post("/api/names") @app.post("/api/names")
@@ -155,6 +299,7 @@ def add_name(body: NameIn, session: dict[str, str] = Depends(_session)) -> dict[
locales = {k: v.model_dump() for k, v in body.locales.items()} locales = {k: v.model_dump() for k, v in body.locales.items()}
try: try:
return store.add_name( return store.add_name(
board_id=session["board_id"],
kind=body.kind, kind=body.kind,
spelling=body.spelling, spelling=body.spelling,
created_by=session["display_name"], created_by=session["display_name"],
@@ -170,9 +315,8 @@ def patch_name(
body: LocalesPatch, body: LocalesPatch,
session: dict[str, str] = Depends(_session), session: dict[str, str] = Depends(_session),
) -> dict[str, Any]: ) -> dict[str, Any]:
_ = session
locales = {k: v.model_dump() for k, v in body.locales.items()} locales = {k: v.model_dump() for k, v in body.locales.items()}
updated = store.update_locales(name_id, locales) updated = store.update_locales(name_id, locales, board_id=session["board_id"])
if not updated: if not updated:
raise HTTPException(404, "Name not found") raise HTTPException(404, "Name not found")
return updated return updated
@@ -190,6 +334,7 @@ def vote_name(
voter_key=session["voter_key"], voter_key=session["voter_key"],
voter_name=session["display_name"], voter_name=session["display_name"],
value=body.value, value=body.value,
board_id=session["board_id"],
) )
except ValueError as exc: except ValueError as exc:
raise HTTPException(400, str(exc)) from exc raise HTTPException(400, str(exc)) from exc
@@ -198,6 +343,75 @@ def vote_name(
return updated return updated
@app.post("/api/names/{name_id}/recording/{lang}")
async def upload_recording(
name_id: int,
lang: str,
session: dict[str, str] = Depends(_session),
file: UploadFile = File(...),
) -> dict[str, Any]:
if lang not in LANGS:
raise HTTPException(400, "lang must be en, ru, or he")
raw = await file.read()
content_type = (file.content_type or "audio/webm").split(";")[0].strip().lower()
if content_type not in ALLOWED_AUDIO:
raise HTTPException(400, f"unsupported audio type: {content_type}")
ext = "webm"
if "ogg" in content_type:
ext = "ogg"
elif "mp4" in content_type or "m4a" in content_type or "aac" in content_type:
ext = "m4a"
elif "mpeg" in content_type or content_type == "audio/mp3":
ext = "mp3"
elif "wav" in content_type:
ext = "wav"
try:
updated = store.save_recording(
name_id=name_id,
lang=lang,
data=raw,
content_type=content_type if content_type != "video/webm" else "audio/webm",
recorded_by=session["display_name"],
ext=ext,
board_id=session["board_id"],
)
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
if not updated:
raise HTTPException(404, "Name not found")
return updated
@app.get("/api/names/{name_id}/recording/{lang}")
def get_recording(
name_id: int,
lang: str,
session: dict[str, str] = Depends(_session),
) -> FileResponse:
if lang not in LANGS:
raise HTTPException(400, "lang must be en, ru, or he")
found = store.get_recording(name_id, lang, board_id=session["board_id"])
if not found:
raise HTTPException(404, "Recording not found")
path, content_type = found
return FileResponse(path, media_type=content_type, filename=path.name)
@app.delete("/api/names/{name_id}/recording/{lang}")
def delete_recording(
name_id: int,
lang: str,
session: dict[str, str] = Depends(_session),
) -> dict[str, Any]:
try:
updated = store.delete_recording(name_id, lang, board_id=session["board_id"])
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
if not updated:
raise HTTPException(404, "Name not found")
return updated
@app.delete("/api/names/{name_id}") @app.delete("/api/names/{name_id}")
def delete_name(name_id: int, _: None = Depends(_admin)) -> dict[str, Any]: def delete_name(name_id: int, _: None = Depends(_admin)) -> dict[str, Any]:
if not store.delete_name(name_id): if not store.delete_name(name_id):
@@ -205,9 +419,100 @@ def delete_name(name_id: int, _: None = Depends(_admin)) -> dict[str, Any]:
return {"ok": True} return {"ok": True}
@app.get("/api/admin/boards")
def admin_list_boards(_: None = Depends(_admin)) -> dict[str, Any]:
items = []
for b in store.list_boards(include_invite=True):
items.append(
{
"id": b["id"],
"title": b["title"],
"created_at": b["created_at"],
"url": _board_url(b["id"]),
}
)
return {"items": items}
@app.post("/api/admin/boards")
def admin_create_board(body: BoardCreate, _: None = Depends(_admin)) -> dict[str, Any]:
try:
board = store.create_board(body.title, body.invite)
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
return {
"id": board["id"],
"title": board["title"],
"created_at": board["created_at"],
"url": _board_url(board["id"]),
}
@app.patch("/api/admin/boards/{board_id}")
def admin_rename_board(
board_id: str,
body: BoardPatch,
_: None = Depends(_admin),
) -> dict[str, Any]:
try:
updated = store.rename_board(board_id, body.title)
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
if not updated:
raise HTTPException(404, "Board not found")
return {
"id": updated["id"],
"title": updated["title"],
"created_at": updated["created_at"],
"url": _board_url(updated["id"]),
}
@app.delete("/api/admin/boards/{board_id}")
def admin_delete_board(board_id: str, _: None = Depends(_admin)) -> dict[str, Any]:
try:
if not store.delete_board(board_id):
raise HTTPException(404, "Board not found")
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
return {"ok": True}
def _ui_page() -> FileResponse:
page = STATIC / "v2.html"
if not page.is_file():
raise HTTPException(404, "UI missing")
return FileResponse(page)
@app.get("/") @app.get("/")
def home() -> FileResponse: def home() -> FileResponse:
return _ui_page()
@app.get("/b/{board_id}")
def board_page(board_id: str) -> FileResponse:
if not store.find_board(board_id):
raise HTTPException(404, "Board not found")
return _ui_page()
@app.get("/v1")
def v1() -> FileResponse:
index = STATIC / "index.html" index = STATIC / "index.html"
if not index.is_file(): if not index.is_file():
raise HTTPException(404, "UI missing") raise HTTPException(404, "Classic UI missing")
return FileResponse(index) return FileResponse(index)
@app.get("/v2")
def v2() -> RedirectResponse:
return RedirectResponse(url="/", status_code=302)
@app.get("/logos")
def logos() -> FileResponse:
page = STATIC / "logos.html"
if not page.is_file():
raise HTTPException(404, "Logo gallery missing")
return FileResponse(page)
+639 -31
View File
@@ -1,8 +1,9 @@
"""SQLite persistence for names, locales, and votes.""" """SQLite persistence: boards, columns, names, locales, votes, recordings."""
from __future__ import annotations from __future__ import annotations
import json import json
import secrets
import sqlite3 import sqlite3
import threading import threading
import time import time
@@ -10,16 +11,41 @@ from pathlib import Path
from typing import Any from typing import Any
LANGS = ("en", "ru", "he") LANGS = ("en", "ru", "he")
KINDS = ("first", "middle") MAX_COLUMNS = 4
LEGACY_KIND_ORDER = ("first", "middle", "hebrew", "russian")
LEGACY_LABELS = {
"first": "First",
"middle": "Middle",
"hebrew": "Hebrew",
"russian": "Russian",
}
_SCHEMA = """ _SCHEMA = """
CREATE TABLE IF NOT EXISTS boards (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
invite_token TEXT NOT NULL UNIQUE,
created_at REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS board_columns (
board_id TEXT NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
id TEXT NOT NULL,
label TEXT NOT NULL DEFAULT '',
position INTEGER NOT NULL,
created_at REAL NOT NULL,
PRIMARY KEY (board_id, id),
UNIQUE (board_id, position)
);
CREATE TABLE IF NOT EXISTS names ( CREATE TABLE IF NOT EXISTS names (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
kind TEXT NOT NULL CHECK (kind IN ('first', 'middle')), board_id TEXT NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
kind TEXT NOT NULL,
spelling TEXT NOT NULL, spelling TEXT NOT NULL,
created_by TEXT NOT NULL DEFAULT '', created_by TEXT NOT NULL DEFAULT '',
created_at REAL NOT NULL, created_at REAL NOT NULL,
UNIQUE (kind, spelling COLLATE NOCASE) UNIQUE (board_id, kind, spelling COLLATE NOCASE)
); );
CREATE TABLE IF NOT EXISTS locales ( CREATE TABLE IF NOT EXISTS locales (
@@ -40,20 +66,45 @@ CREATE TABLE IF NOT EXISTS votes (
PRIMARY KEY (name_id, voter_key) PRIMARY KEY (name_id, voter_key)
); );
CREATE INDEX IF NOT EXISTS idx_names_kind ON names(kind); CREATE TABLE IF NOT EXISTS recordings (
name_id INTEGER NOT NULL REFERENCES names(id) ON DELETE CASCADE,
lang TEXT NOT NULL CHECK (lang IN ('en', 'ru', 'he')),
path TEXT NOT NULL,
content_type TEXT NOT NULL DEFAULT 'audio/webm',
recorded_by TEXT NOT NULL DEFAULT '',
created_at REAL NOT NULL,
PRIMARY KEY (name_id, lang)
);
CREATE INDEX IF NOT EXISTS idx_names_board_kind ON names(board_id, kind);
CREATE INDEX IF NOT EXISTS idx_votes_name ON votes(name_id); CREATE INDEX IF NOT EXISTS idx_votes_name ON votes(name_id);
""" """
class Store: class Store:
def __init__(self, path: Path) -> None: def __init__(self, path: Path, bootstrap_invite: str = "", bootstrap_title: str = "Family") -> None:
self.path = path self.path = path
self.path.parent.mkdir(parents=True, exist_ok=True) self.path.parent.mkdir(parents=True, exist_ok=True)
self.audio_dir = self.path.parent / "recordings"
self.audio_dir.mkdir(parents=True, exist_ok=True)
self._lock = threading.Lock() self._lock = threading.Lock()
self._conn = sqlite3.connect(str(self.path), check_same_thread=False) self._conn = sqlite3.connect(str(self.path), check_same_thread=False)
self._conn.row_factory = sqlite3.Row self._conn.row_factory = sqlite3.Row
self._conn.execute("PRAGMA foreign_keys = ON") self._conn.execute("PRAGMA foreign_keys = ON")
with self._lock: with self._lock:
# Boards table first so invite bootstrap / attach can run before indexes.
self._conn.execute(
"""
CREATE TABLE IF NOT EXISTS boards (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
invite_token TEXT NOT NULL UNIQUE,
created_at REAL NOT NULL
)
"""
)
self._migrate_legacy_pre_boards()
self._migrate_attach_boards(bootstrap_invite.strip(), bootstrap_title.strip() or "Family")
self._conn.executescript(_SCHEMA) self._conn.executescript(_SCHEMA)
self._conn.commit() self._conn.commit()
@@ -61,9 +112,427 @@ class Store:
with self._lock: with self._lock:
self._conn.close() self._conn.close()
def _table_sql(self, name: str) -> str:
row = self._conn.execute(
"SELECT sql FROM sqlite_master WHERE type='table' AND name=?",
(name,),
).fetchone()
return (row["sql"] or "") if row else ""
def _table_exists(self, name: str) -> bool:
row = self._conn.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?",
(name,),
).fetchone()
return row is not None
def _migrate_legacy_pre_boards(self) -> None:
"""Normalize pre-board schemas so the boards migration can attach cleanly."""
if not self._table_exists("names"):
return
sql = self._table_sql("names")
if "board_id" in sql:
return
# Drop kind CHECK if present
if "CHECK" in sql.upper():
self._conn.executescript(
"""
CREATE TABLE names_legacy (
id INTEGER PRIMARY KEY AUTOINCREMENT,
kind TEXT NOT NULL,
spelling TEXT NOT NULL,
created_by TEXT NOT NULL DEFAULT '',
created_at REAL NOT NULL,
UNIQUE (kind, spelling COLLATE NOCASE)
);
INSERT INTO names_legacy (id, kind, spelling, created_by, created_at)
SELECT id, kind, spelling, created_by, created_at FROM names;
DROP TABLE names;
ALTER TABLE names_legacy RENAME TO names;
"""
)
# Ensure board_columns exists in old shape for later rewrite
if not self._table_exists("board_columns"):
self._conn.execute(
"""
CREATE TABLE board_columns (
id TEXT PRIMARY KEY,
label TEXT NOT NULL DEFAULT '',
position INTEGER NOT NULL UNIQUE,
created_at REAL NOT NULL
)
"""
)
used = [
r["kind"]
for r in self._conn.execute("SELECT DISTINCT kind FROM names").fetchall()
]
ordered: list[str] = []
for legacy in LEGACY_KIND_ORDER:
if legacy in used:
ordered.append(legacy)
for kind in used:
if kind not in ordered:
ordered.append(kind)
if not ordered:
ordered = ["c1"]
for i, old in enumerate(ordered[:MAX_COLUMNS]):
new_id = old if old.startswith("c") and old[1:].isdigit() else f"c{i + 1}"
label = LEGACY_LABELS.get(old, "First" if i == 0 else "")
self._conn.execute(
"INSERT INTO board_columns (id, label, position, created_at) VALUES (?, ?, ?, ?)",
(new_id, label, i, time.time()),
)
if old != new_id:
self._conn.execute("UPDATE names SET kind = ? WHERE kind = ?", (new_id, old))
def _migrate_attach_boards(self, bootstrap_invite: str, bootstrap_title: str) -> None:
names_sql = self._table_sql("names")
cols_sql = self._table_sql("board_columns")
needs_names = self._table_exists("names") and "board_id" not in names_sql
needs_cols = self._table_exists("board_columns") and "board_id" not in cols_sql
if not needs_names and not needs_cols:
# Ensure board_columns exists before seeding a first column.
self._conn.execute(
"""
CREATE TABLE IF NOT EXISTS board_columns (
board_id TEXT NOT NULL,
id TEXT NOT NULL,
label TEXT NOT NULL DEFAULT '',
position INTEGER NOT NULL,
created_at REAL NOT NULL,
PRIMARY KEY (board_id, id),
UNIQUE (board_id, position)
)
"""
)
# Ensure at least one board when invite configured
if bootstrap_invite and not self._conn.execute("SELECT 1 FROM boards LIMIT 1").fetchone():
self._create_board_unlocked(bootstrap_title, bootstrap_invite)
board_id = self._conn.execute(
"SELECT id FROM boards WHERE invite_token = ?", (bootstrap_invite,)
).fetchone()["id"]
self._conn.execute(
"INSERT INTO board_columns (board_id, id, label, position, created_at) VALUES (?, 'c1', 'First', 0, ?)",
(board_id, time.time()),
)
elif bootstrap_invite:
# Keep env invite mapped to a board (create if missing)
found = self._conn.execute(
"SELECT id FROM boards WHERE invite_token = ?", (bootstrap_invite,)
).fetchone()
if not found:
# Prefer updating the oldest board's invite only if unique
oldest = self._conn.execute(
"SELECT id FROM boards ORDER BY created_at ASC LIMIT 1"
).fetchone()
if oldest and self._conn.execute("SELECT COUNT(*) AS n FROM boards").fetchone()["n"] == 1:
self._conn.execute(
"UPDATE boards SET invite_token = ? WHERE id = ?",
(bootstrap_invite, oldest["id"]),
)
else:
self._create_board_unlocked(bootstrap_title, bootstrap_invite)
board_id = self._conn.execute(
"SELECT id FROM boards WHERE invite_token = ?", (bootstrap_invite,)
).fetchone()["id"]
if not self._conn.execute(
"SELECT 1 FROM board_columns WHERE board_id = ?", (board_id,)
).fetchone():
self._conn.execute(
"INSERT INTO board_columns (board_id, id, label, position, created_at) VALUES (?, 'c1', 'First', 0, ?)",
(board_id, time.time()),
)
return
invite = bootstrap_invite or secrets.token_urlsafe(18)
board_id = "b_" + secrets.token_urlsafe(8)
self._conn.execute(
"INSERT OR IGNORE INTO boards (id, title, invite_token, created_at) VALUES (?, ?, ?, ?)",
(board_id, bootstrap_title, invite, time.time()),
)
# If ignore hit (token clash), fetch existing
row = self._conn.execute(
"SELECT id FROM boards WHERE invite_token = ?", (invite,)
).fetchone()
board_id = row["id"]
if needs_cols:
self._conn.executescript(
f"""
CREATE TABLE board_columns_new (
board_id TEXT NOT NULL,
id TEXT NOT NULL,
label TEXT NOT NULL DEFAULT '',
position INTEGER NOT NULL,
created_at REAL NOT NULL,
PRIMARY KEY (board_id, id),
UNIQUE (board_id, position)
);
INSERT INTO board_columns_new (board_id, id, label, position, created_at)
SELECT '{board_id}', id, label, position, created_at FROM board_columns;
DROP TABLE board_columns;
ALTER TABLE board_columns_new RENAME TO board_columns;
"""
)
if needs_names:
self._conn.executescript(
f"""
CREATE TABLE names_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
board_id TEXT NOT NULL,
kind TEXT NOT NULL,
spelling TEXT NOT NULL,
created_by TEXT NOT NULL DEFAULT '',
created_at REAL NOT NULL,
UNIQUE (board_id, kind, spelling COLLATE NOCASE)
);
INSERT INTO names_new (id, board_id, kind, spelling, created_by, created_at)
SELECT id, '{board_id}', kind, spelling, created_by, created_at FROM names;
DROP TABLE names;
ALTER TABLE names_new RENAME TO names;
CREATE INDEX IF NOT EXISTS idx_names_board_kind ON names(board_id, kind);
"""
)
if not self._conn.execute(
"SELECT 1 FROM board_columns WHERE board_id = ?", (board_id,)
).fetchone():
self._conn.execute(
"INSERT INTO board_columns (board_id, id, label, position, created_at) VALUES (?, 'c1', 'First', 0, ?)",
(board_id, time.time()),
)
def _create_board_unlocked(self, title: str, invite_token: str) -> dict[str, Any]:
board_id = "b_" + secrets.token_urlsafe(8)
now = time.time()
self._conn.execute(
"INSERT INTO boards (id, title, invite_token, created_at) VALUES (?, ?, ?, ?)",
(board_id, title[:80], invite_token, now),
)
return {"id": board_id, "title": title[:80], "invite_token": invite_token, "created_at": now}
def _board_row(self, row: sqlite3.Row, *, include_invite: bool = False) -> dict[str, Any]:
out = {
"id": row["id"],
"title": row["title"],
"created_at": row["created_at"],
}
if include_invite:
out["invite_token"] = row["invite_token"]
return out
def find_board_by_invite(self, invite: str) -> dict[str, Any] | None:
invite = (invite or "").strip()
if not invite:
return None
with self._lock:
row = self._conn.execute(
"SELECT * FROM boards WHERE invite_token = ?", (invite,)
).fetchone()
return self._board_row(row) if row else None
def find_board(self, board_id: str) -> dict[str, Any] | None:
board_id = (board_id or "").strip()
if not board_id:
return None
with self._lock:
row = self._conn.execute("SELECT * FROM boards WHERE id = ?", (board_id,)).fetchone()
return self._board_row(row) if row else None
def get_board(self, board_id: str) -> dict[str, Any] | None:
return self.find_board(board_id)
def list_boards(self, *, include_invite: bool = False) -> list[dict[str, Any]]:
with self._lock:
rows = self._conn.execute("SELECT * FROM boards ORDER BY created_at").fetchall()
return [self._board_row(r, include_invite=include_invite) for r in rows]
def create_board(self, title: str, invite_token: str | None = None) -> dict[str, Any]:
title = (title or "").strip()[:80] or "Family"
token = (invite_token or "").strip() or secrets.token_urlsafe(18)
with self._lock:
if self._conn.execute(
"SELECT 1 FROM boards WHERE invite_token = ?", (token,)
).fetchone():
raise ValueError("invite token already in use")
board = self._create_board_unlocked(title, token)
self._conn.execute(
"INSERT INTO board_columns (board_id, id, label, position, created_at) VALUES (?, 'c1', 'First', 0, ?)",
(board["id"], time.time()),
)
self._conn.commit()
return {**board, "invite_token": token}
def rename_board(self, board_id: str, title: str) -> dict[str, Any] | None:
title = (title or "").strip()[:80]
if not title:
raise ValueError("title required")
with self._lock:
cur = self._conn.execute(
"UPDATE boards SET title = ? WHERE id = ?", (title, board_id)
)
if cur.rowcount == 0:
return None
self._conn.commit()
row = self._conn.execute("SELECT * FROM boards WHERE id = ?", (board_id,)).fetchone()
assert row is not None
return self._board_row(row, include_invite=True)
def delete_board(self, board_id: str) -> bool:
with self._lock:
count = self._conn.execute("SELECT COUNT(*) AS n FROM boards").fetchone()["n"]
if count <= 1:
raise ValueError("keep at least one board")
name_ids = [
r["id"]
for r in self._conn.execute(
"SELECT id FROM names WHERE board_id = ?", (board_id,)
).fetchall()
]
for name_id in name_ids:
recs = self._conn.execute(
"SELECT path FROM recordings WHERE name_id = ?", (name_id,)
).fetchall()
for rec in recs:
path = self.audio_dir / rec["path"]
if path.is_file():
path.unlink()
cur = self._conn.execute("DELETE FROM boards WHERE id = ?", (board_id,))
self._conn.commit()
return cur.rowcount > 0
def _column_ids(self, board_id: str) -> set[str]:
rows = self._conn.execute(
"SELECT id FROM board_columns WHERE board_id = ?", (board_id,)
).fetchall()
return {r["id"] for r in rows}
def _column_row(self, board_id: str, row: sqlite3.Row) -> dict[str, Any]:
count = self._conn.execute(
"SELECT COUNT(*) AS n FROM names WHERE board_id = ? AND kind = ?",
(board_id, row["id"]),
).fetchone()["n"]
return {
"id": row["id"],
"label": row["label"],
"position": row["position"],
"created_at": row["created_at"],
"name_count": int(count),
}
def list_columns(self, board_id: str) -> list[dict[str, Any]]:
with self._lock:
rows = self._conn.execute(
"SELECT * FROM board_columns WHERE board_id = ? ORDER BY position",
(board_id,),
).fetchall()
return [self._column_row(board_id, r) for r in rows]
def add_column(self, board_id: str, label: str = "") -> dict[str, Any]:
label = (label or "").strip()[:40]
with self._lock:
if not self._conn.execute("SELECT 1 FROM boards WHERE id = ?", (board_id,)).fetchone():
raise ValueError("unknown board")
rows = self._conn.execute(
"SELECT id, position FROM board_columns WHERE board_id = ? ORDER BY position",
(board_id,),
).fetchall()
if len(rows) >= MAX_COLUMNS:
raise ValueError(f"at most {MAX_COLUMNS} columns")
used = {r["id"] for r in rows}
new_id = next(f"c{i}" for i in range(1, MAX_COLUMNS + 1) if f"c{i}" not in used)
position = max((r["position"] for r in rows), default=-1) + 1
self._conn.execute(
"INSERT INTO board_columns (board_id, id, label, position, created_at) VALUES (?, ?, ?, ?, ?)",
(board_id, new_id, label, position, time.time()),
)
self._conn.commit()
row = self._conn.execute(
"SELECT * FROM board_columns WHERE board_id = ? AND id = ?",
(board_id, new_id),
).fetchone()
assert row is not None
return self._column_row(board_id, row)
def rename_column(self, board_id: str, column_id: str, label: str) -> dict[str, Any] | None:
label = (label or "").strip()[:40]
with self._lock:
cur = self._conn.execute(
"UPDATE board_columns SET label = ? WHERE board_id = ? AND id = ?",
(label, board_id, column_id),
)
if cur.rowcount == 0:
return None
self._conn.commit()
row = self._conn.execute(
"SELECT * FROM board_columns WHERE board_id = ? AND id = ?",
(board_id, column_id),
).fetchone()
assert row is not None
return self._column_row(board_id, row)
def delete_column(self, board_id: str, column_id: str) -> bool:
with self._lock:
count = self._conn.execute(
"SELECT COUNT(*) AS n FROM board_columns WHERE board_id = ?",
(board_id,),
).fetchone()["n"]
if count <= 1:
raise ValueError("keep at least one column")
first = self._conn.execute(
"SELECT id FROM board_columns WHERE board_id = ? ORDER BY position ASC LIMIT 1",
(board_id,),
).fetchone()
if first and first["id"] == column_id:
raise ValueError("cannot remove the first column")
row = self._conn.execute(
"SELECT id FROM board_columns WHERE board_id = ? AND id = ?",
(board_id, column_id),
).fetchone()
if not row:
return False
name_ids = [
r["id"]
for r in self._conn.execute(
"SELECT id FROM names WHERE board_id = ? AND kind = ?",
(board_id, column_id),
).fetchall()
]
for name_id in name_ids:
recs = self._conn.execute(
"SELECT path FROM recordings WHERE name_id = ?", (name_id,)
).fetchall()
for rec in recs:
path = self.audio_dir / rec["path"]
if path.is_file():
path.unlink()
self._conn.execute(
"DELETE FROM names WHERE board_id = ? AND kind = ?",
(board_id, column_id),
)
self._conn.execute(
"DELETE FROM board_columns WHERE board_id = ? AND id = ?",
(board_id, column_id),
)
remaining = self._conn.execute(
"SELECT id FROM board_columns WHERE board_id = ? ORDER BY position",
(board_id,),
).fetchall()
for i, rem in enumerate(remaining):
self._conn.execute(
"UPDATE board_columns SET position = ? WHERE board_id = ? AND id = ?",
(i, board_id, rem["id"]),
)
self._conn.commit()
return True
def _empty_locales(self) -> dict[str, dict[str, str]]: def _empty_locales(self) -> dict[str, dict[str, str]]:
return {lang: {"pronunciation": "", "origin": "", "meaning": ""} for lang in LANGS} return {lang: {"pronunciation": "", "origin": "", "meaning": ""} for lang in LANGS}
def _empty_recordings(self) -> dict[str, bool]:
return {lang: False for lang in LANGS}
def _normalize_locales(self, locales: dict[str, Any] | None) -> dict[str, dict[str, str]]: def _normalize_locales(self, locales: dict[str, Any] | None) -> dict[str, dict[str, str]]:
out = self._empty_locales() out = self._empty_locales()
if not locales: if not locales:
@@ -79,13 +548,20 @@ class Store:
} }
return out return out
def _recordings_flags(self, name_id: int) -> dict[str, bool]:
flags = self._empty_recordings()
for row in self._conn.execute(
"SELECT lang FROM recordings WHERE name_id = ?", (name_id,)
).fetchall():
flags[row["lang"]] = True
return flags
def _row_to_name(self, row: sqlite3.Row, score: int, vote_count: int, my_vote: int | None) -> dict[str, Any]: def _row_to_name(self, row: sqlite3.Row, score: int, vote_count: int, my_vote: int | None) -> dict[str, Any]:
locales = self._empty_locales() locales = self._empty_locales()
cur = self._conn.execute( for loc in self._conn.execute(
"SELECT lang, pronunciation, origin, meaning FROM locales WHERE name_id = ?", "SELECT lang, pronunciation, origin, meaning FROM locales WHERE name_id = ?",
(row["id"],), (row["id"],),
) ).fetchall():
for loc in cur.fetchall():
locales[loc["lang"]] = { locales[loc["lang"]] = {
"pronunciation": loc["pronunciation"], "pronunciation": loc["pronunciation"],
"origin": loc["origin"], "origin": loc["origin"],
@@ -93,23 +569,25 @@ class Store:
} }
return { return {
"id": row["id"], "id": row["id"],
"board_id": row["board_id"],
"kind": row["kind"], "kind": row["kind"],
"spelling": row["spelling"], "spelling": row["spelling"],
"created_by": row["created_by"], "created_by": row["created_by"],
"created_at": row["created_at"], "created_at": row["created_at"],
"locales": locales, "locales": locales,
"recordings": self._recordings_flags(row["id"]),
"score": score, "score": score,
"vote_count": vote_count, "vote_count": vote_count,
"my_vote": my_vote, "my_vote": my_vote,
} }
def list_names(self, kind: str, voter_key: str | None = None) -> list[dict[str, Any]]: def list_names(self, board_id: str, kind: str, voter_key: str | None = None) -> list[dict[str, Any]]:
if kind not in KINDS:
raise ValueError("invalid kind")
with self._lock: with self._lock:
if kind not in self._column_ids(board_id):
raise ValueError("unknown column")
rows = self._conn.execute( rows = self._conn.execute(
"SELECT * FROM names WHERE kind = ? ORDER BY spelling COLLATE NOCASE", "SELECT * FROM names WHERE board_id = ? AND kind = ? ORDER BY spelling COLLATE NOCASE",
(kind,), (board_id, kind),
).fetchall() ).fetchall()
out: list[dict[str, Any]] = [] out: list[dict[str, Any]] = []
for row in rows: for row in rows:
@@ -136,11 +614,18 @@ class Store:
out.sort(key=lambda n: (-n["score"], n["spelling"].casefold())) out.sort(key=lambda n: (-n["score"], n["spelling"].casefold()))
return out return out
def get_name(self, name_id: int, voter_key: str | None = None) -> dict[str, Any] | None: def get_name(
self,
name_id: int,
voter_key: str | None = None,
board_id: str | None = None,
) -> dict[str, Any] | None:
with self._lock: with self._lock:
row = self._conn.execute("SELECT * FROM names WHERE id = ?", (name_id,)).fetchone() row = self._conn.execute("SELECT * FROM names WHERE id = ?", (name_id,)).fetchone()
if not row: if not row:
return None return None
if board_id is not None and row["board_id"] != board_id:
return None
score_row = self._conn.execute( score_row = self._conn.execute(
"SELECT COALESCE(SUM(value), 0) AS score, COUNT(*) AS vote_count FROM votes WHERE name_id = ?", "SELECT COALESCE(SUM(value), 0) AS score, COUNT(*) AS vote_count FROM votes WHERE name_id = ?",
(name_id,), (name_id,),
@@ -162,13 +647,12 @@ class Store:
def add_name( def add_name(
self, self,
board_id: str,
kind: str, kind: str,
spelling: str, spelling: str,
created_by: str, created_by: str,
locales: dict[str, Any] | None = None, locales: dict[str, Any] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
if kind not in KINDS:
raise ValueError("invalid kind")
spelling = spelling.strip() spelling = spelling.strip()
if not spelling or len(spelling) > 80: if not spelling or len(spelling) > 80:
raise ValueError("spelling required (1–80 chars)") raise ValueError("spelling required (1–80 chars)")
@@ -176,13 +660,15 @@ class Store:
locs = self._normalize_locales(locales) locs = self._normalize_locales(locales)
now = time.time() now = time.time()
with self._lock: with self._lock:
if kind not in self._column_ids(board_id):
raise ValueError("unknown column")
try: try:
cur = self._conn.execute( cur = self._conn.execute(
"INSERT INTO names (kind, spelling, created_by, created_at) VALUES (?, ?, ?, ?)", "INSERT INTO names (board_id, kind, spelling, created_by, created_at) VALUES (?, ?, ?, ?, ?)",
(kind, spelling, created_by, now), (board_id, kind, spelling, created_by, now),
) )
except sqlite3.IntegrityError as exc: except sqlite3.IntegrityError as exc:
raise ValueError("name already exists for this kind") from exc raise ValueError("name already exists for this column") from exc
name_id = int(cur.lastrowid) name_id = int(cur.lastrowid)
for lang, payload in locs.items(): for lang, payload in locs.items():
self._conn.execute( self._conn.execute(
@@ -190,17 +676,34 @@ class Store:
(name_id, lang, payload["pronunciation"], payload["origin"], payload["meaning"]), (name_id, lang, payload["pronunciation"], payload["origin"], payload["meaning"]),
) )
self._conn.commit() self._conn.commit()
result = self.get_name(name_id) result = self.get_name(name_id, board_id=board_id)
assert result is not None assert result is not None
return result return result
def update_locales(self, name_id: int, locales: dict[str, Any]) -> dict[str, Any] | None: def update_locales(
locs = self._normalize_locales(locales) self,
name_id: int,
locales: dict[str, Any],
board_id: str | None = None,
) -> dict[str, Any] | None:
if not locales:
return self.get_name(name_id, board_id=board_id)
patch: dict[str, dict[str, str]] = {}
for lang, raw in locales.items():
if lang not in LANGS or not isinstance(raw, dict):
continue
patch[lang] = {
"pronunciation": str(raw.get("pronunciation") or "").strip()[:200],
"origin": str(raw.get("origin") or "").strip()[:200],
"meaning": str(raw.get("meaning") or "").strip()[:500],
}
if not patch:
return self.get_name(name_id, board_id=board_id)
with self._lock: with self._lock:
row = self._conn.execute("SELECT id FROM names WHERE id = ?", (name_id,)).fetchone() row = self._conn.execute("SELECT id, board_id FROM names WHERE id = ?", (name_id,)).fetchone()
if not row: if not row or (board_id is not None and row["board_id"] != board_id):
return None return None
for lang, payload in locs.items(): for lang, payload in patch.items():
self._conn.execute( self._conn.execute(
""" """
INSERT INTO locales (name_id, lang, pronunciation, origin, meaning) INSERT INTO locales (name_id, lang, pronunciation, origin, meaning)
@@ -213,7 +716,7 @@ class Store:
(name_id, lang, payload["pronunciation"], payload["origin"], payload["meaning"]), (name_id, lang, payload["pronunciation"], payload["origin"], payload["meaning"]),
) )
self._conn.commit() self._conn.commit()
return self.get_name(name_id) return self.get_name(name_id, board_id=board_id)
def vote( def vote(
self, self,
@@ -221,6 +724,7 @@ class Store:
voter_key: str, voter_key: str,
voter_name: str, voter_name: str,
value: int, value: int,
board_id: str | None = None,
) -> dict[str, Any] | None: ) -> dict[str, Any] | None:
if value not in (-1, 0, 1): if value not in (-1, 0, 1):
raise ValueError("vote must be -1, 0, or 1") raise ValueError("vote must be -1, 0, or 1")
@@ -229,8 +733,8 @@ class Store:
raise ValueError("invalid voter") raise ValueError("invalid voter")
voter_name = (voter_name or "").strip()[:80] voter_name = (voter_name or "").strip()[:80]
with self._lock: with self._lock:
row = self._conn.execute("SELECT id FROM names WHERE id = ?", (name_id,)).fetchone() row = self._conn.execute("SELECT id, board_id FROM names WHERE id = ?", (name_id,)).fetchone()
if not row: if not row or (board_id is not None and row["board_id"] != board_id):
return None return None
if value == 0: if value == 0:
self._conn.execute( self._conn.execute(
@@ -250,16 +754,120 @@ class Store:
(name_id, voter_key, voter_name, value, time.time()), (name_id, voter_key, voter_name, value, time.time()),
) )
self._conn.commit() self._conn.commit()
return self.get_name(name_id, voter_key=voter_key) return self.get_name(name_id, voter_key=voter_key, board_id=board_id)
def save_recording(
self,
name_id: int,
lang: str,
data: bytes,
content_type: str,
recorded_by: str,
ext: str = "webm",
board_id: str | None = None,
) -> dict[str, Any] | None:
if lang not in LANGS:
raise ValueError("invalid lang")
if not data:
raise ValueError("empty recording")
if len(data) > 2_500_000:
raise ValueError("recording too large (max ~2.5MB)")
recorded_by = (recorded_by or "").strip()[:80]
content_type = (content_type or "audio/webm").split(";")[0].strip()[:80]
with self._lock:
row = self._conn.execute("SELECT id, board_id FROM names WHERE id = ?", (name_id,)).fetchone()
if not row or (board_id is not None and row["board_id"] != board_id):
return None
old = self._conn.execute(
"SELECT path FROM recordings WHERE name_id = ? AND lang = ?",
(name_id, lang),
).fetchone()
rel = f"{name_id}-{lang}.{ext}"
dest = self.audio_dir / rel
dest.write_bytes(data)
if old and old["path"] != rel:
prev = self.audio_dir / old["path"]
if prev.is_file():
prev.unlink()
self._conn.execute(
"""
INSERT INTO recordings (name_id, lang, path, content_type, recorded_by, created_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(name_id, lang) DO UPDATE SET
path = excluded.path,
content_type = excluded.content_type,
recorded_by = excluded.recorded_by,
created_at = excluded.created_at
""",
(name_id, lang, rel, content_type, recorded_by, time.time()),
)
self._conn.commit()
return self.get_name(name_id, board_id=board_id)
def get_recording(
self,
name_id: int,
lang: str,
board_id: str | None = None,
) -> tuple[Path, str] | None:
if lang not in LANGS:
return None
with self._lock:
row = self._conn.execute("SELECT id, board_id FROM names WHERE id = ?", (name_id,)).fetchone()
if not row or (board_id is not None and row["board_id"] != board_id):
return None
rec = self._conn.execute(
"SELECT path, content_type FROM recordings WHERE name_id = ? AND lang = ?",
(name_id, lang),
).fetchone()
if not rec:
return None
path = self.audio_dir / rec["path"]
if not path.is_file():
return None
return path, rec["content_type"]
def delete_recording(
self,
name_id: int,
lang: str,
board_id: str | None = None,
) -> dict[str, Any] | None:
if lang not in LANGS:
raise ValueError("invalid lang")
with self._lock:
row = self._conn.execute("SELECT id, board_id FROM names WHERE id = ?", (name_id,)).fetchone()
if not row or (board_id is not None and row["board_id"] != board_id):
return None
rec = self._conn.execute(
"SELECT path FROM recordings WHERE name_id = ? AND lang = ?",
(name_id, lang),
).fetchone()
if rec:
path = self.audio_dir / rec["path"]
if path.is_file():
path.unlink()
self._conn.execute(
"DELETE FROM recordings WHERE name_id = ? AND lang = ?",
(name_id, lang),
)
self._conn.commit()
return self.get_name(name_id, board_id=board_id)
def delete_name(self, name_id: int) -> bool: def delete_name(self, name_id: int) -> bool:
with self._lock: with self._lock:
recs = self._conn.execute(
"SELECT path FROM recordings WHERE name_id = ?", (name_id,)
).fetchall()
for rec in recs:
path = self.audio_dir / rec["path"]
if path.is_file():
path.unlink()
cur = self._conn.execute("DELETE FROM names WHERE id = ?", (name_id,)) cur = self._conn.execute("DELETE FROM names WHERE id = ?", (name_id,))
self._conn.commit() self._conn.commit()
return cur.rowcount > 0 return cur.rowcount > 0
def export_snapshot(self) -> str: def export_snapshot(self) -> str:
"""Debug helper for tests."""
with self._lock: with self._lock:
names = [dict(r) for r in self._conn.execute("SELECT * FROM names").fetchall()] names = [dict(r) for r in self._conn.execute("SELECT * FROM names").fetchall()]
return json.dumps(names) return json.dumps(names)
+10 -2
View File
@@ -25,10 +25,18 @@ def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> TestClient:
@pytest.fixture() @pytest.fixture()
def authed(client: TestClient) -> TestClient: def family_board(client: TestClient) -> dict:
"""Bootstrap Family board id (from STORK_INVITE_TOKEN)."""
res = client.get("/api/resolve", params={"invite": "test-invite-token"})
assert res.status_code == 200
return res.json()
@pytest.fixture()
def authed(client: TestClient, family_board: dict) -> TestClient:
res = client.post( res = client.post(
"/api/session", "/api/session",
json={"invite": "test-invite-token", "display_name": "Aunt Mira"}, json={"board_id": family_board["id"], "display_name": "Aunt Mira"},
) )
assert res.status_code == 200 assert res.status_code == 200
return client return client
+140 -60
View File
@@ -11,111 +11,191 @@ def test_health(client: TestClient) -> None:
assert res.json()["ok"] is True assert res.json()["ok"] is True
def test_session_rejects_bad_invite(client: TestClient) -> None: def test_logos_gallery(client: TestClient) -> None:
res = client.post("/api/session", json={"invite": "nope", "display_name": "Ilia"}) res = client.get("/logos")
assert res.status_code == 403 assert res.status_code == 200
assert b"Pick a Stork mark" in res.content
def test_home_is_paper_board(client: TestClient) -> None:
res = client.get("/")
assert res.status_code == 200
assert b"Start a private board" in res.content
def test_v2_redirects_home(client: TestClient) -> None:
res = client.get("/v2", follow_redirects=False)
assert res.status_code == 302
assert res.headers["location"] == "/"
def test_public_create_board_and_share_url(client: TestClient) -> None:
created = client.post("/api/boards", json={"title": "Baby Levkin"})
assert created.status_code == 200
board_id = created.json()["id"]
assert board_id.startswith("b_")
assert created.json()["url"].endswith(f"/b/{board_id}")
page = client.get(f"/b/{board_id}")
assert page.status_code == 200
assert b"Start a private board" in page.content or b"Your name" in page.content
def test_join_board_with_display_name(client: TestClient) -> None:
created = client.post("/api/boards", json={"title": "Friends"}).json()
session = client.post(
"/api/session",
json={"board_id": created["id"], "display_name": "Dan"},
)
assert session.status_code == 200
assert session.json()["board"]["id"] == created["id"]
assert client.post("/api/names", json={"kind": "c1", "spelling": "Noa"}).status_code == 200
def test_boards_are_isolated(client: TestClient) -> None:
a = client.post("/api/boards", json={"title": "A"}).json()
b = client.post("/api/boards", json={"title": "B"}).json()
client.post("/api/session", json={"board_id": a["id"], "display_name": "Ilia"})
assert client.post("/api/names", json={"kind": "c1", "spelling": "Roze"}).status_code == 200
client.post("/api/session", json={"board_id": b["id"], "display_name": "Dan"})
assert client.post("/api/names", json={"kind": "c1", "spelling": "Roze"}).status_code == 200
a_names = client.get(
"/api/names?kind=c1",
headers={"X-Stork-Board": a["id"], "X-Stork-Display-Name": "Ilia"},
).json()["items"]
b_names = client.get(
"/api/names?kind=c1",
headers={"X-Stork-Board": b["id"], "X-Stork-Display-Name": "Dan"},
).json()["items"]
assert len(a_names) == 1 and len(b_names) == 1
assert a_names[0]["id"] != b_names[0]["id"]
def test_default_one_column(authed: TestClient) -> None:
cols = authed.get("/api/columns").json()["items"]
assert len(cols) == 1
assert cols[0]["id"] == "c1"
assert cols[0]["label"] == "First"
def test_add_rename_columns(authed: TestClient) -> None:
c2 = authed.post("/api/columns")
assert c2.status_code == 200
assert c2.json()["id"] == "c2"
renamed = authed.patch("/api/columns/c2", json={"label": "Hebrew"})
assert renamed.status_code == 200
assert renamed.json()["label"] == "Hebrew"
assert authed.post("/api/names", json={"kind": "c2", "spelling": "שי"}).status_code == 200
def test_column_delete_by_member(authed: TestClient) -> None:
assert authed.post("/api/columns").status_code == 200
assert authed.delete("/api/columns/c2").status_code == 200
assert len(authed.get("/api/columns").json()["items"]) == 1
def test_cannot_delete_first_column(authed: TestClient) -> None:
assert authed.post("/api/columns").status_code == 200
assert authed.delete("/api/columns/c1").status_code == 400
assert len(authed.get("/api/columns").json()["items"]) == 2
def test_max_four_columns(authed: TestClient) -> None:
for _ in range(3):
assert authed.post("/api/columns").status_code == 200
assert authed.post("/api/columns").status_code == 400
def test_cannot_delete_last_column(authed: TestClient) -> None:
assert authed.delete("/api/columns/c1").status_code == 400
def test_recording_upload_and_play(authed: TestClient) -> None:
created = authed.post("/api/names", json={"kind": "c1", "spelling": "Roze"}).json()
name_id = created["id"]
files = {"file": ("voice.webm", b"fake-webm-bytes", "audio/webm")}
up = authed.post(f"/api/names/{name_id}/recording/en", files=files)
assert up.status_code == 200
assert up.json()["recordings"]["en"] is True
audio = authed.get(f"/api/names/{name_id}/recording/en")
assert audio.status_code == 200
assert audio.content == b"fake-webm-bytes"
def test_session_rejects_missing_board(client: TestClient) -> None:
res = client.post("/api/session", json={"display_name": "Ilia", "board_id": "b_missing"})
assert res.status_code == 404
def test_names_require_session(client: TestClient) -> None: def test_names_require_session(client: TestClient) -> None:
res = client.get("/api/names") res = client.get("/api/names")
assert res.status_code == 401 assert res.status_code in (401, 404)
def test_add_vote_and_rank(authed: TestClient) -> None: def test_add_vote_and_rank(authed: TestClient) -> None:
a = authed.post( a = authed.post(
"/api/names", "/api/names",
json={ json={
"kind": "first", "kind": "c1",
"spelling": "Noa", "spelling": "Noa",
"locales": { "locales": {
"he": {"pronunciation": "No-ah", "origin": "Hebrew", "meaning": "motion / movement"}, "he": {"pronunciation": "No-ah", "origin": "Hebrew", "meaning": "motion"},
"en": {"pronunciation": "NO-uh", "origin": "Hebrew", "meaning": "movement"}, "en": {"pronunciation": "NO-uh", "origin": "Hebrew", "meaning": "movement"},
}, },
}, },
) )
assert a.status_code == 200 assert a.status_code == 200
noa_id = a.json()["id"] noa_id = a.json()["id"]
b = authed.post("/api/names", json={"kind": "c1", "spelling": "Levi"})
b = authed.post("/api/names", json={"kind": "first", "spelling": "Levi"})
assert b.status_code == 200
levi_id = b.json()["id"] levi_id = b.json()["id"]
assert authed.post(f"/api/names/{noa_id}/vote", json={"value": 1}).status_code == 200 assert authed.post(f"/api/names/{noa_id}/vote", json={"value": 1}).status_code == 200
assert authed.post(f"/api/names/{levi_id}/vote", json={"value": -1}).status_code == 200 assert authed.post(f"/api/names/{levi_id}/vote", json={"value": -1}).status_code == 200
listed = authed.get("/api/names?kind=c1").json()["items"]
listed = authed.get("/api/names?kind=first").json()["items"]
assert [n["spelling"] for n in listed] == ["Noa", "Levi"] assert [n["spelling"] for n in listed] == ["Noa", "Levi"]
assert listed[0]["score"] == 1
assert listed[0]["my_vote"] == 1
assert listed[1]["score"] == -1
# clear vote
cleared = authed.post(f"/api/names/{noa_id}/vote", json={"value": 0})
assert cleared.status_code == 200
assert cleared.json()["my_vote"] is None
assert cleared.json()["score"] == 0
def test_duplicate_name_rejected(authed: TestClient) -> None: def test_duplicate_name_rejected(authed: TestClient) -> None:
assert authed.post("/api/names", json={"kind": "first", "spelling": "Maya"}).status_code == 200 assert authed.post("/api/names", json={"kind": "c1", "spelling": "Maya"}).status_code == 200
dup = authed.post("/api/names", json={"kind": "first", "spelling": "maya"}) assert authed.post("/api/names", json={"kind": "c1", "spelling": "maya"}).status_code == 400
assert dup.status_code == 400
def test_middle_names_separate(authed: TestClient) -> None:
assert authed.post("/api/names", json={"kind": "first", "spelling": "Ari"}).status_code == 200
assert authed.post("/api/names", json={"kind": "middle", "spelling": "Ari"}).status_code == 200
first = authed.get("/api/names?kind=first").json()["items"]
middle = authed.get("/api/names?kind=middle").json()["items"]
assert len(first) == 1
assert len(middle) == 1
def test_patch_locales(authed: TestClient) -> None: def test_patch_locales(authed: TestClient) -> None:
created = authed.post("/api/names", json={"kind": "first", "spelling": "Eden"}).json() created = authed.post("/api/names", json={"kind": "c1", "spelling": "Eden"}).json()
patched = authed.patch( patched = authed.patch(
f"/api/names/{created['id']}", f"/api/names/{created['id']}",
json={"locales": {"ru": {"pronunciation": "Э-ден", "origin": "иврит", "meaning": "рай"}}}, json={"locales": {"ru": {"pronunciation": "Э-ден", "origin": "иврит", "meaning": "рай"}}},
) )
assert patched.status_code == 200 assert patched.status_code == 200
assert patched.json()["locales"]["ru"]["meaning"] == "рай" assert patched.json()["locales"]["ru"]["meaning"] == "рай"
assert patched.json()["locales"]["en"]["pronunciation"] == ""
def test_admin_delete(authed: TestClient) -> None: def test_admin_delete_name(authed: TestClient) -> None:
created = authed.post("/api/names", json={"kind": "first", "spelling": "Temp"}).json() created = authed.post("/api/names", json={"kind": "c1", "spelling": "Temp"}).json()
denied = authed.delete(f"/api/names/{created['id']}") assert authed.delete(f"/api/names/{created['id']}").status_code == 403
assert denied.status_code == 403 assert (
ok = authed.delete(f"/api/names/{created['id']}", headers={"X-Stork-Admin": "test-admin-token"}) authed.delete(
assert ok.status_code == 200 f"/api/names/{created['id']}", headers={"X-Stork-Admin": "test-admin-token"}
assert authed.get("/api/names?kind=first").json()["items"] == [] ).status_code
== 200
def test_second_voter_ranking(client: TestClient, authed: TestClient) -> None:
name = authed.post("/api/names", json={"kind": "first", "spelling": "Shai"}).json()
authed.post(f"/api/names/{name['id']}/vote", json={"value": 1})
other = client.post(
"/api/session",
json={"invite": "test-invite-token", "display_name": "Uncle Dan"},
) )
assert other.status_code == 200
client.post(f"/api/names/{name['id']}/vote", json={"value": 1})
listed = client.get("/api/names?kind=first").json()["items"]
assert listed[0]["score"] == 2
assert listed[0]["vote_count"] == 2
def test_header_auth_without_cookies(client: TestClient) -> None: def test_header_auth_with_board(client: TestClient, family_board: dict) -> None:
headers = { headers = {
"X-Stork-Invite": "test-invite-token", "X-Stork-Board": family_board["id"],
"X-Stork-Display-Name": "Seed Bot", "X-Stork-Display-Name": "Seed Bot",
} }
res = client.post( res = client.post(
"/api/names", "/api/names",
headers=headers, headers=headers,
json={"kind": "first", "spelling": "HeaderOnly"}, json={"kind": "c1", "spelling": "HeaderOnly"},
) )
assert res.status_code == 200 assert res.status_code == 200
listed = client.get("/api/names?kind=first", headers=headers) listed = client.get("/api/names?kind=c1", headers=headers)
assert listed.status_code == 200
assert any(n["spelling"] == "HeaderOnly" for n in listed.json()["items"]) assert any(n["spelling"] == "HeaderOnly" for n in listed.json()["items"])
def test_legacy_invite_resolve(client: TestClient, family_board: dict) -> None:
res = client.get("/api/resolve", params={"invite": "test-invite-token"})
assert res.status_code == 200
assert res.json()["id"] == family_board["id"]
+9 -8
View File
@@ -8,13 +8,14 @@ from stork.db import Store
def test_store_ranks_by_score_then_alpha(tmp_path: Path) -> None: def test_store_ranks_by_score_then_alpha(tmp_path: Path) -> None:
store = Store(tmp_path / "t.sqlite3") store = Store(tmp_path / "t.sqlite3", bootstrap_invite="unit-invite")
a = store.add_name("first", "Zed", "x") board_id = store.list_boards()[0]["id"]
store.add_name("first", "Ann", "x") a = store.add_name(board_id, "c1", "Zed", "x")
c = store.add_name("first", "Bo", "x") store.add_name(board_id, "c1", "Ann", "x")
store.vote(a["id"], "v1", "A", 1) c = store.add_name(board_id, "c1", "Bo", "x")
store.vote(c["id"], "v1", "A", 1) store.vote(a["id"], "v1", "A", 1, board_id=board_id)
store.vote(c["id"], "v2", "B", 1) store.vote(c["id"], "v1", "A", 1, board_id=board_id)
ranked = store.list_names("first") store.vote(c["id"], "v2", "B", 1, board_id=board_id)
ranked = store.list_names(board_id, "c1")
assert [n["spelling"] for n in ranked] == ["Bo", "Zed", "Ann"] assert [n["spelling"] for n in ranked] == ["Bo", "Zed", "Ann"]
store.close() store.close()