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_ADMIN_TOKEN=change-me-admin-random
STORK_COOKIE_SECURE=false
STORK_DATA=./data
# STORK_PUBLIC_URL=https://stork.levkin.ca
+4 -4
View File
@@ -4,13 +4,13 @@ Short orientation for Cursor agents.
## 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`.
- 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`).
## Close-out
1. `make test` and `make lint` before claiming done.
2. Do not put LAN IPs or real invites in this repo.
3. Merge only when the user asks.
2. Do not put LAN IPs or real invites/board ids in this repo.
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)
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=...])
@test -n "$(STORK_INVITE)" || (echo "Set STORK_INVITE=..." && exit 1)
STORK_URL="$(or $(STORK_URL),http://127.0.0.1:8094)" STORK_INVITE="$(STORK_INVITE)" \
seed: ## Seed example names (usage: make seed STORK_BOARD_ID=b_… OR STORK_INVITE=…)
@if [ -z "$(STORK_BOARD_ID)" ] && [ -z "$(STORK_INVITE)" ]; then \
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
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 -t homelab-stork:latest .
+44 -15
View File
@@ -1,42 +1,71 @@
# Stork
Private family board for baby **first** and **middle** name ideas: suggest
names, vote, and keep origin / meaning / pronunciation in English, Russian,
and Hebrew.
Private family baby-name boards: suggest names, vote, keep origin / meaning /
pronunciation in English, Russian, and Hebrew, and optionally record how a
name sounds.
Homelab deploy lives in the ansible repo (`make deploy-stork`). This repo is
the app only — no LAN IPs or production secrets here.
Each board has a unique share link (`/b/<uid>`). Start a board with no password,
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)
```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 test
make run
```
Open http://127.0.0.1:8094 — enter the invite code and your display name.
Share `http://127.0.0.1:8094/?invite=YOUR_TOKEN` with family for one-tap entry.
Open http://127.0.0.1:8094 → **Start new board** → share `/b/…` with family.
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
| Method | Path | Notes |
|--------|------|-------|
| GET | `/api/health` | Liveness |
| POST | `/api/session` | `{invite, display_name}` → cookies |
| GET | `/api/names?kind=first\|middle` | Ranked list |
| POST | `/api/names` | Add name + optional locales |
| PATCH | `/api/names/{id}` | Update locale notes |
| POST | `/api/boards` | Start board `{title}` → `{id, url}` |
| GET | `/api/boards/{id}` | Public board title + url |
| GET | `/api/resolve?invite=` | Legacy invite → board url |
| 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/GET/DELETE | `/api/names/{id}/recording/{lang}` | Voice |
| 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
- 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
Same host pattern as Compare: Docker on automationlab LXC **225**, public
HTTPS via Caddy (`stork.levkin.ca`). See ansible `docs/guides/stork-deploy.md`.
Docker on automationlab; public HTTPS via Caddy (`stork.levkin.ca`). See ansible
`docs/guides/stork-deploy.md`.
+16 -64
View File
@@ -1,87 +1,39 @@
{
"names": [
{
"kind": "first",
"kind": "c1",
"spelling": "Shai",
"locales": {
"en": {
"pronunciation": "SHY (rhymes with sky)",
"origin": "Hebrew (שי)",
"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."
}
"en": {"pronunciation": "Shai", "origin": "Hebrew", "meaning": "Gift. Variants: Shay."},
"ru": {"pronunciation": "Шай", "origin": "иврит", "meaning": "Дар, подарок."},
"he": {"pronunciation": "שי", "origin": "עברית", "meaning": "מתנה."}
}
},
{
"kind": "first",
"kind": "c1",
"spelling": "Roze",
"locales": {
"en": {
"pronunciation": "ROHZ",
"origin": "Latin rosa / flower name (also Germanic Rose lineage)",
"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)."
}
"en": {"pronunciation": "Roze", "origin": "Latin rosa", "meaning": "Rose. Related: Rose, Rosa, Roza."},
"ru": {"pronunciation": "Роуз", "origin": "латинское rosa", "meaning": "Роза. Также: Rose, Rosa, Roza."},
"he": {"pronunciation": "שושנה", "origin": "עברית", "meaning": "מקבילה ל־Roze/Rose. גם ורד."}
}
},
{
"kind": "first",
"kind": "c1",
"spelling": "Odet",
"locales": {
"en": {
"pronunciation": "oh-DET",
"origin": "French diminutive (Odette) from Germanic od-/ot- “wealth”",
"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."
}
"en": {"pronunciation": "Odet", "origin": "French (Odette)", "meaning": "Wealth / prosperity."},
"ru": {"pronunciation": "Одет", "origin": "французское Odette", "meaning": "Богатство."},
"he": {"pronunciation": "אודט", "origin": "צרפתית", "meaning": "אין מקבילה עברית נפוצה."}
}
},
{
"kind": "first",
"kind": "c1",
"spelling": "Rivka",
"locales": {
"en": {
"pronunciation": "riv-KAH / REEV-kah (short form Riva: REE-vah)",
"origin": "Hebrew biblical (רִבְקָה) — wife of Isaac",
"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."
}
"en": {"pronunciation": "Rivka", "origin": "Hebrew", "meaning": "To bind / join. Short form: Riva. English: Rebecca."},
"ru": {"pronunciation": "Ривка", "origin": "иврит", "meaning": "Связывать. Кратко: Рива. Ревекка."},
"he": {"pronunciation": "רבקה", "origin": "תנ״ך", "meaning": "קיצור: ריבה. באנגלית Rebecca."}
}
}
]
+1
View File
@@ -1,3 +1,4 @@
fastapi==0.115.12
uvicorn[standard]==0.34.2
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:
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
# or resolve legacy invite:
STORK_URL=... STORK_INVITE=... .venv/bin/python scripts/seed_names.py
"""
from __future__ import annotations
@@ -20,24 +23,49 @@ SEED = ROOT / "data" / "seed-names.example.json"
def main() -> int:
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()
display = os.environ.get("STORK_DISPLAY_NAME", "Ilia").strip() or "Ilia"
if not invite:
print("Set STORK_INVITE", file=sys.stderr)
if not board_id and not invite:
print("Set STORK_BOARD_ID or STORK_INVITE", file=sys.stderr)
return 1
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 = {
"X-Stork-Invite": invite,
"X-Stork-Board": board_id,
"X-Stork-Display-Name": display,
}
with httpx.Client(base_url=base, timeout=30.0, headers=headers) as client:
for item in names:
res = client.post("/api/names", json=item)
if res.status_code == 400 and "already exists" in res.text:
print(f"skip {item['spelling']} (exists)")
existing: dict[tuple[str, str], int] = {}
for kind in {n.get("kind", "c1") for n in names} | {"c1"}:
listed = client.get("/api/names", params={"kind": kind}, headers=headers)
if listed.status_code != 200:
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()
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
Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

+268 -315
View File
@@ -3,87 +3,96 @@
<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=Fraunces:opsz,wght@9..144,500;9..144,700&family=Source+Sans+3:wght@400;600;700&display=swap" rel="stylesheet" />
<style>
:root {
--sky: #d6e4ef;
--fog: #eef3f7;
--paper: #f8faf8;
--ink: #18253a;
--muted: #5a6a7e;
--line: #c5d3e0;
--accent: #2a5f7a;
--wing: #b85c48;
--up: #2f6b5a;
--down: #9a4a3a;
--rank: #1e3a55;
--shadow: 0 12px 40px rgba(24, 37, 58, 0.08);
--radius: 18px;
:root, [data-theme="light"] {
--sky: #d6e4ef; --fog: #eef3f7; --paper: #f8faf8; --ink: #18253a; --muted: #4a5566;
--line: #c5d3e0; --accent: #2a5f7a; --wing: #b85c48; --up: #2f6b5a; --down: #9a4a3a;
--rank: #1e3a55; --shadow: 0 10px 32px rgba(24,37,58,.07); --radius: 16px;
--input-bg: #fff; --panel: #f8faf8; --logo-filter: none;
--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) 45%, #e9eee8 100%);
}
[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; }
html { scroll-behavior: smooth; }
body {
margin: 0;
color: var(--ink);
font-family: "Source Sans 3", system-ui, sans-serif;
background:
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%);
background: var(--body-bg);
min-height: 100vh;
}
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
.name-card { transition: none; }
}
main { max-width: 1100px; margin: 0 auto; padding: 1.5rem 1rem 4rem; }
main { max-width: 1000px; margin: 0 auto; padding: 1.25rem 1rem 3.5rem; }
.hero {
display: grid;
gap: 0.35rem;
margin-bottom: 1.25rem;
animation: rise 0.7s ease both;
display: flex;
gap: 0.85rem;
align-items: center;
margin-bottom: 1rem;
}
@keyframes rise {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: none; }
.hero img {
width: 56px;
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 {
font-family: Fraunces, Georgia, serif;
font-size: clamp(2.4rem, 6vw, 3.4rem);
font-size: clamp(1.9rem, 5vw, 2.5rem);
font-weight: 700;
letter-spacing: -0.03em;
margin: 0;
line-height: 1;
}
.brand span {
color: var(--wing);
font-style: italic;
font-weight: 500;
}
.sub { margin: 0; color: var(--muted); max-width: 36rem; font-size: 1.05rem; }
.sub { margin: 0.25rem 0 0; color: var(--muted); font-size: 0.95rem; }
.sub a { color: var(--accent); font-weight: 600; text-decoration: none; }
.panel {
background: color-mix(in srgb, var(--paper) 92%, white);
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 1.1rem 1.15rem;
margin-bottom: 1rem;
padding: 1rem;
margin-bottom: 0.85rem;
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; }
input, select, textarea, button {
label { display: block; font-size: 0.95rem; color: var(--muted); margin-bottom: 0.25rem; font-weight: 600; }
input, textarea, button {
font: inherit;
width: 100%;
padding: 0.7rem 0.8rem;
border-radius: 12px;
padding: 0.65rem 0.75rem;
border-radius: 11px;
border: 1px solid var(--line);
background: #fff;
background: var(--input-bg);
color: var(--ink);
}
textarea { min-height: 3.2rem; resize: vertical; }
textarea { min-height: 2.8rem; resize: vertical; }
button {
width: auto;
cursor: pointer;
@@ -92,227 +101,171 @@
border-color: transparent;
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-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 {
width: 2rem;
height: 2rem;
padding: 0;
display: inline-grid;
place-items: center;
border-radius: 999px;
background: #fff;
color: var(--accent);
border: 1px solid var(--line);
flex: 0 0 auto;
width: 2rem; height: 2rem; padding: 0;
display: inline-grid; place-items: center;
border-radius: 999px; background: var(--input-bg); 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.vote {
min-width: 2.4rem;
padding: 0.35rem 0.5rem;
background: #fff;
color: var(--ink);
border: 1px solid var(--line);
min-width: 2.2rem; padding: 0.3rem 0.45rem;
background: var(--input-bg); color: var(--ink); border: 1px solid var(--line);
}
button.vote.active-up { background: var(--up); 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; }
.who {
display: flex;
justify-content: space-between;
gap: 0.75rem;
align-items: baseline;
flex-wrap: wrap;
margin-bottom: 0.85rem;
button.pill {
padding: 0.2rem 0.55rem;
border-radius: 999px;
font-size: 0.8rem;
letter-spacing: 0.04em;
text-transform: uppercase;
background: var(--input-bg);
color: var(--muted);
font-size: 0.95rem;
border: 1px solid var(--line);
}
.boards {
display: grid;
gap: 1rem;
margin-bottom: 1rem;
}
@media (min-width: 860px) {
.boards { grid-template-columns: 1fr 1fr; align-items: start; }
button.pill.active { background: var(--ink); color: var(--paper); border-color: transparent; }
.row { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: end; }
.who {
display: flex; justify-content: space-between; gap: 0.75rem;
align-items: baseline; flex-wrap: wrap; margin-bottom: 0.75rem;
color: var(--muted); font-size: 0.92rem;
}
.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 {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.75rem;
padding-bottom: 0.55rem;
display: flex; align-items: baseline; justify-content: space-between;
gap: 0.5rem; margin-bottom: 0.35rem; padding-bottom: 0.45rem;
border-bottom: 1px solid var(--line);
}
.col-head h2 {
font-family: Fraunces, Georgia, serif;
font-size: 1.35rem;
margin: 0;
font-weight: 700;
}
.col-head .count {
color: var(--muted);
font-size: 0.85rem;
font-weight: 600;
font-size: 1.2rem; margin: 0; font-weight: 700;
}
.col-head .count { color: var(--muted); font-size: 0.95rem; font-weight: 600; }
.name-card {
display: grid;
grid-template-columns: 2.5rem 1fr auto;
gap: 0.65rem;
grid-template-columns: 2.2rem 1fr auto;
gap: 0.55rem;
align-items: start;
padding: 0.8rem 0.15rem;
border-top: 1px solid color-mix(in srgb, var(--line) 70%, transparent);
transition: background 0.15s ease;
padding: 0.7rem 0.1rem;
border-top: 1px solid color-mix(in srgb, var(--line) 65%, transparent);
}
.name-card:first-of-type { border-top: 0; }
.score {
font-family: Fraunces, Georgia, serif;
font-variant-numeric: tabular-nums;
font-weight: 700;
font-size: 1.35rem;
color: var(--rank);
text-align: center;
line-height: 1.2;
padding-top: 0.15rem;
font-weight: 700; font-size: 1.2rem; color: var(--rank);
text-align: center; padding-top: 0.1rem;
}
.spelling {
font-family: Fraunces, Georgia, serif;
font-size: 1.35rem;
margin: 0 0 0.15rem;
letter-spacing: -0.02em;
font-size: 1.25rem; margin: 0 0 0.1rem; letter-spacing: -0.02em;
}
.meta { color: var(--muted); font-size: 0.88rem; margin: 0 0 0.35rem; }
.langs { margin-top: 0.35rem; }
.langs summary {
cursor: pointer;
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;
.meta { color: var(--muted); font-size: 0.95rem; margin: 0 0 0.4rem; }
.say-row {
display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap;
margin-bottom: 0.25rem;
}
.say {
font-family: Fraunces, Georgia, serif;
font-size: 1rem;
margin: 0;
flex: 1;
font-size: 1.05rem; margin: 0; min-width: 3rem;
}
.lang-detail { margin: 0; color: var(--muted); font-size: 0.88rem; }
.votes { display: flex; flex-direction: column; gap: 0.3rem; }
.error { color: var(--down); margin: 0.5rem 0 0; }
.pills { display: flex; gap: 0.25rem; }
details.more { margin-top: 0.25rem; }
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; }
.empty {
color: var(--muted);
padding: 0.75rem 0.2rem 0.35rem;
font-size: 0.95rem;
}
.empty { color: var(--muted); padding: 0.55rem 0.1rem; font-size: 0.92rem; }
.compose-head {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: end;
justify-content: space-between;
margin-bottom: 0.85rem;
display: flex; flex-wrap: wrap; gap: 0.65rem; align-items: end;
justify-content: space-between; margin-bottom: 0.75rem;
}
.compose-head h2 {
font-family: Fraunces, Georgia, serif;
margin: 0;
font-size: 1.25rem;
font-family: Fraunces, Georgia, serif; margin: 0; font-size: 1.15rem;
}
.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 {
background: var(--ink);
color: #fff;
border-color: transparent;
color: var(--paper);
border-color: var(--ink);
}
.grid-3 { display: grid; gap: 0.65rem; }
@media (min-width: 720px) {
.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;
.kind-toggle button:not(.active):hover {
border-color: var(--accent);
}
.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>
</head>
<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">
<div class="wing-mark" aria-hidden="true">ש</div>
<h1 class="brand">Stork <span>names</span></h1>
<p class="sub">Family board for first and middle names — vote, hear how they sound, and keep notes in English, Russian, and Hebrew.</p>
<img src="/static/logo-mark.png" alt="Stork" width="56" height="56" />
<div>
<h1 class="brand">Stork</h1>
<p class="sub">Classic layout · <a href="/">Paper board</a> · <a href="/logos">Logo ideas</a></p>
</div>
</header>
<section id="gate" class="panel">
<label for="invite">Invite code</label>
<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" />
<p class="error hidden" id="gate-error"></p>
<div class="row" style="margin-top:0.75rem">
<button id="enter" type="button">Enter the board</button>
<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>
<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 class="boards">
<section class="panel" aria-labelledby="first-heading">
<div class="col-head">
<h2 id="first-heading">First names</h2>
<h2 id="first-heading">First</h2>
<span class="count" id="first-count"></span>
</div>
<div id="list-first"></div>
</section>
<section class="panel" aria-labelledby="middle-heading">
<div class="col-head">
<h2 id="middle-heading">Middle names</h2>
<h2 id="middle-heading">Middle</h2>
<span class="count" id="middle-count"></span>
</div>
<div id="list-middle"></div>
@@ -323,54 +276,52 @@
<div class="compose-head">
<h2>Suggest a name</h2>
<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" data-kind="middle">Middle</button>
<button type="button" class="secondary active" data-kind="c1">First</button>
<button type="button" class="secondary" data-kind="c2">Middle</button>
</div>
</div>
<label for="spelling">Spelling</label>
<input id="spelling" placeholder="How you want it written" />
<p class="hint">Optional notes below — pronunciation can be played aloud on the board.</p>
<div class="grid-3" style="margin-top:0.75rem">
<input id="spelling" required aria-required="true" autocomplete="off" />
<p class="hint">Pronunciation fields: just the name in that language (e.g. Roze → שושנה).</p>
<div class="grid-3">
<div>
<label>English pronunciation</label>
<input data-loc="en" data-field="pronunciation" placeholder="e.g. SHY" />
<label style="margin-top:0.5rem">Origin</label>
<label>English</label>
<input data-loc="en" data-field="pronunciation" placeholder="Name" />
<label style="margin-top:0.4rem">Origin</label>
<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>
</div>
<div>
<label>Russian pronunciation</label>
<input data-loc="ru" data-field="pronunciation" placeholder="e.g. Шай" />
<label style="margin-top:0.5rem">Origin</label>
<label>Russian</label>
<input data-loc="ru" data-field="pronunciation" placeholder="Имя" />
<label style="margin-top:0.4rem">Origin</label>
<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>
</div>
<div>
<label>Hebrew pronunciation</label>
<input data-loc="he" data-field="pronunciation" placeholder="e.g. שַׁי" dir="auto" />
<label style="margin-top:0.5rem">Origin</label>
<label>Hebrew</label>
<input data-loc="he" data-field="pronunciation" placeholder="שם" dir="auto" />
<label style="margin-top:0.4rem">Origin</label>
<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>
</div>
</div>
<p class="error hidden" id="add-error"></p>
<div class="row" style="margin-top:0.75rem">
<button id="add" type="button">Add to board</button>
<p class="error hidden" id="add-error" role="alert" aria-live="polite"></p>
<div class="row" style="margin-top:0.65rem">
<button id="add" type="button">Add</button>
</div>
</div>
</section>
</main>
<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" };
let speakingBtn = null;
function params() {
return new URLSearchParams(location.search);
}
function params() { return new URLSearchParams(location.search); }
async function api(path, opts = {}) {
const res = await fetch(path, {
@@ -383,7 +334,7 @@
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(typeof detail === "string" ? detail : JSON.stringify(detail));
throw new Error(StorkUI.formatError(detail));
}
return data;
}
@@ -394,93 +345,72 @@
})[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() {
if (window.speechSynthesis) window.speechSynthesis.cancel();
if (speakingBtn) {
speakingBtn.classList.remove("playing");
speakingBtn = null;
}
if (speakingBtn) { speakingBtn.classList.remove("playing"); speakingBtn = null; }
}
function playPronunciation(lang, pronunciation, spelling, btn) {
function play(lang, text, btn) {
if (!window.speechSynthesis) {
alert("This browser cannot play speech aloud. Try Chrome or Safari.");
alert("Speech not supported in this browser.");
return;
}
const text = ttsText(pronunciation, spelling);
if (!text) return;
const say = (text || "").trim();
if (!say) return;
stopSpeech();
const u = new SpeechSynthesisUtterance(text);
const u = new SpeechSynthesisUtterance(say);
u.lang = SPEECH_LANG[lang] || "en-US";
u.rate = 0.9;
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;
speakingBtn = btn;
btn.classList.add("playing");
u.onend = () => { btn.classList.remove("playing"); speakingBtn = null; };
u.onerror = () => { btn.classList.remove("playing"); speakingBtn = null; };
u.onend = u.onerror = () => { btn.classList.remove("playing"); speakingBtn = null; };
speechSynthesis.speak(u);
}
function renderLangs(n) {
const locales = n.locales || {};
const blocks = ["en", "ru", "he"].map((lang) => {
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 formFor(n, lang) {
const L = (n.locales && n.locales[lang]) || {};
return (L.pronunciation || "").trim() || (lang === "en" ? n.spelling : "");
}
function renderList(kind, items) {
const root = document.getElementById(kind === "first" ? "list-first" : "list-middle");
const count = document.getElementById(kind === "first" ? "first-count" : "middle-count");
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;
function noteFor(n, lang) {
const L = (n.locales && n.locales[lang]) || {};
return [L.origin, L.meaning].filter(Boolean).join(" — ");
}
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}">
<div class="score" title="Score">${n.score}</div>
<div class="score">${n.score}</div>
<div>
<p class="spelling" dir="auto">${escapeHtml(n.spelling)}</p>
<p class="meta">${escapeHtml(n.created_by || "someone")} · ${n.vote_count} vote(s)</p>
${renderLangs(n)}
<p class="meta">${escapeHtml(n.created_by || "someone")} · ${n.vote_count}</p>
<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 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-down" : ""}" data-vote="-1" aria-label="Downvote">▼</button>
</div>
</article>`).join("");
</article>`;
}
function bindCard(root, items) {
root.querySelectorAll("button[data-vote]").forEach((btn) => {
btn.addEventListener("click", async () => {
const card = btn.closest(".name-card");
@@ -492,20 +422,31 @@
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) => {
btn.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
playPronunciation(
btn.dataset.play,
btn.dataset.pronunciation,
btn.dataset.spelling,
btn,
);
});
btn.addEventListener("click", () => play(btn.dataset.lang, btn.dataset.text, 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() {
const locales = { en: {}, ru: {}, he: {} };
document.querySelectorAll("[data-loc][data-field]").forEach((el) => {
@@ -520,75 +461,87 @@
}
async function refresh() {
const [first, middle] = await Promise.all([
api("/api/names?kind=first"),
api("/api/names?kind=middle"),
]);
state.first = first.items || [];
state.middle = middle.items || [];
renderList("first", state.first);
renderList("middle", state.middle);
const first = await api("/api/names?kind=c1");
state.c1 = first.items || [];
state.c2 = [];
renderList("c1", state.c1);
const midRoot = document.getElementById("list-middle");
const midCount = document.getElementById("middle-count");
if (midRoot) midRoot.innerHTML = `<p class="empty">Use the <a href="/">paper board</a> for more columns.</p>`;
if (midCount) midCount.textContent = "—";
}
async function showBoard(displayName) {
document.getElementById("gate").classList.add("hidden");
document.getElementById("board").classList.remove("hidden");
document.getElementById("who").textContent = `Signed in as ${displayName}`;
// Chrome loads voices async
document.getElementById("who").textContent = displayName;
if (window.speechSynthesis) speechSynthesis.getVoices();
await refresh();
}
document.querySelectorAll(".kind-toggle button").forEach((btn) => {
btn.setAttribute("aria-pressed", btn.classList.contains("active") ? "true" : "false");
btn.addEventListener("click", () => {
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 () => {
const err = document.getElementById("gate-error");
err.classList.add("hidden");
StorkUI.clearError(err);
try {
const invite = document.getElementById("invite").value.trim();
const display_name = document.getElementById("display").value.trim();
const data = await api("/api/session", {
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);
url.searchParams.delete("invite");
history.replaceState({}, "", url.pathname + (url.search || ""));
await showBoard(data.display_name);
} catch (e) {
err.textContent = e.message;
err.classList.remove("hidden");
StorkUI.showError(err, e.message);
}
});
document.getElementById("add").addEventListener("click", async () => {
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 {
await api("/api/names", {
method: "POST",
body: JSON.stringify({
kind: state.kind,
spelling: document.getElementById("spelling").value.trim(),
spelling,
locales: localePayload(),
}),
});
clearForm();
await refresh();
} catch (e) {
err.textContent = e.message;
err.classList.remove("hidden");
StorkUI.showError(err, e.message);
spellingEl.setAttribute("aria-invalid", "true");
}
});
if (window.speechSynthesis) {
speechSynthesis.onvoiceschanged = () => speechSynthesis.getVoices();
}
StorkUI.initThemeControls();
if (window.speechSynthesis) speechSynthesis.onvoiceschanged = () => speechSynthesis.getVoices();
(async () => {
const invite = params().get("invite");
@@ -596,7 +549,7 @@
try {
const session = await api("/api/session");
if (session.authenticated) await showBoard(session.display_name);
} catch (_) { /* stay on gate */ }
} catch (_) {}
})();
</script>
</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
@@ -8,21 +8,37 @@ import secrets
from pathlib import Path
from typing import Any
from fastapi import Cookie, Depends, FastAPI, Header, HTTPException, Response
from fastapi.responses import FileResponse
from fastapi import Cookie, Depends, FastAPI, File, Header, HTTPException, Response, UploadFile
from fastapi.responses import FileResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
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"))
INVITE_TOKEN = os.environ.get("STORK_INVITE_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"}
PUBLIC_BASE = os.environ.get("STORK_PUBLIC_URL", "https://stork.levkin.ca").rstrip("/")
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)
store = Store(DATA_DIR / "stork.sqlite3")
store = Store(
DATA_DIR / "stork.sqlite3",
bootstrap_invite=INVITE_TOKEN,
bootstrap_title="Family",
)
if STATIC.is_dir():
app.mount("/static", StaticFiles(directory=STATIC), name="static")
@@ -35,11 +51,15 @@ class LocaleIn(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)
locales: dict[str, LocaleIn] = Field(default_factory=dict)
class ColumnPatch(BaseModel):
label: str = Field(default="", max_length=40)
class LocalesPatch(BaseModel):
locales: dict[str, LocaleIn]
@@ -49,40 +69,22 @@ class VoteIn(BaseModel):
class SessionIn(BaseModel):
invite: str = Field(min_length=1, max_length=200)
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:
if not INVITE_TOKEN:
raise HTTPException(503, "STORK_INVITE_TOKEN not configured")
class BoardCreatePublic(BaseModel):
title: str = Field(default="Name board", min_length=1, max_length=80)
def _invite_ok(invite: str | None) -> bool:
_require_invite_configured()
return bool(invite) and secrets.compare_digest(invite, INVITE_TOKEN)
class BoardCreate(BaseModel):
title: str = Field(min_length=1, max_length=80)
invite: str | None = Field(default=None, max_length=200)
def _session(
stork_invite: str | None = Cookie(default=None),
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}
class BoardPatch(BaseModel):
title: str = Field(min_length=1, max_length=80)
def _admin(
@@ -94,6 +96,66 @@ def _admin(
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")
def health() -> dict[str, Any]:
return {"ok": True, "service": "stork"}
@@ -103,51 +165,133 @@ def health() -> dict[str, Any]:
def meta() -> dict[str, Any]:
return {
"langs": list(LANGS),
"kinds": list(KINDS),
"invite_required": True,
"max_columns": MAX_COLUMNS,
"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")
def create_session(body: SessionIn, response: Response) -> dict[str, Any]:
if not _invite_ok(body.invite.strip()):
raise HTTPException(403, "Invalid invite")
board = _resolve_board(board_id=body.board_id, invite=body.invite)
voter = secrets.token_urlsafe(16)
display = body.display_name.strip()
cookie_kwargs: dict[str, Any] = {
"httponly": True,
"samesite": "lax",
"secure": COOKIE_SECURE,
"max_age": 60 * 60 * 24 * 400,
"path": "/",
kwargs = _cookie_kwargs()
response.set_cookie("stork_board", board["id"], **kwargs)
response.set_cookie("stork_voter", voter, **kwargs)
response.set_cookie("stork_name", display, **{**kwargs, "httponly": False})
response.delete_cookie("stork_invite", 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")
def get_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),
) -> dict[str, Any]:
invite = x_stork_invite or stork_invite
authed = _invite_ok(invite) if INVITE_TOKEN else False
board = None
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 {
"authenticated": authed and bool(stork_voter) and bool(stork_name),
"authenticated": authed,
"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")
def list_names(kind: str = "first", session: dict[str, str] = Depends(_session)) -> dict[str, Any]:
if kind not in KINDS:
raise HTTPException(400, "kind must be first or middle")
return {"items": store.list_names(kind, voter_key=session["voter_key"])}
def list_names(kind: str = "c1", session: dict[str, str] = Depends(_session)) -> dict[str, Any]:
try:
return {
"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")
@@ -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()}
try:
return store.add_name(
board_id=session["board_id"],
kind=body.kind,
spelling=body.spelling,
created_by=session["display_name"],
@@ -170,9 +315,8 @@ def patch_name(
body: LocalesPatch,
session: dict[str, str] = Depends(_session),
) -> dict[str, Any]:
_ = session
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:
raise HTTPException(404, "Name not found")
return updated
@@ -190,6 +334,7 @@ def vote_name(
voter_key=session["voter_key"],
voter_name=session["display_name"],
value=body.value,
board_id=session["board_id"],
)
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
@@ -198,6 +343,75 @@ def vote_name(
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}")
def delete_name(name_id: int, _: None = Depends(_admin)) -> dict[str, Any]:
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}
@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("/")
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"
if not index.is_file():
raise HTTPException(404, "UI missing")
raise HTTPException(404, "Classic UI missing")
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
import json
import secrets
import sqlite3
import threading
import time
@@ -10,16 +11,41 @@ from pathlib import Path
from typing import Any
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 = """
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 (
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,
created_by TEXT NOT NULL DEFAULT '',
created_at REAL NOT NULL,
UNIQUE (kind, spelling COLLATE NOCASE)
UNIQUE (board_id, kind, spelling COLLATE NOCASE)
);
CREATE TABLE IF NOT EXISTS locales (
@@ -40,20 +66,45 @@ CREATE TABLE IF NOT EXISTS votes (
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);
"""
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.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._conn = sqlite3.connect(str(self.path), check_same_thread=False)
self._conn.row_factory = sqlite3.Row
self._conn.execute("PRAGMA foreign_keys = ON")
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.commit()
@@ -61,9 +112,427 @@ class Store:
with self._lock:
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]]:
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]]:
out = self._empty_locales()
if not locales:
@@ -79,13 +548,20 @@ class Store:
}
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]:
locales = self._empty_locales()
cur = self._conn.execute(
for loc in self._conn.execute(
"SELECT lang, pronunciation, origin, meaning FROM locales WHERE name_id = ?",
(row["id"],),
)
for loc in cur.fetchall():
).fetchall():
locales[loc["lang"]] = {
"pronunciation": loc["pronunciation"],
"origin": loc["origin"],
@@ -93,23 +569,25 @@ class Store:
}
return {
"id": row["id"],
"board_id": row["board_id"],
"kind": row["kind"],
"spelling": row["spelling"],
"created_by": row["created_by"],
"created_at": row["created_at"],
"locales": locales,
"recordings": self._recordings_flags(row["id"]),
"score": score,
"vote_count": vote_count,
"my_vote": my_vote,
}
def list_names(self, kind: str, voter_key: str | None = None) -> list[dict[str, Any]]:
if kind not in KINDS:
raise ValueError("invalid kind")
def list_names(self, board_id: str, kind: str, voter_key: str | None = None) -> list[dict[str, Any]]:
with self._lock:
if kind not in self._column_ids(board_id):
raise ValueError("unknown column")
rows = self._conn.execute(
"SELECT * FROM names WHERE kind = ? ORDER BY spelling COLLATE NOCASE",
(kind,),
"SELECT * FROM names WHERE board_id = ? AND kind = ? ORDER BY spelling COLLATE NOCASE",
(board_id, kind),
).fetchall()
out: list[dict[str, Any]] = []
for row in rows:
@@ -136,11 +614,18 @@ class Store:
out.sort(key=lambda n: (-n["score"], n["spelling"].casefold()))
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:
row = self._conn.execute("SELECT * FROM names WHERE id = ?", (name_id,)).fetchone()
if not row:
return None
if board_id is not None and row["board_id"] != board_id:
return None
score_row = self._conn.execute(
"SELECT COALESCE(SUM(value), 0) AS score, COUNT(*) AS vote_count FROM votes WHERE name_id = ?",
(name_id,),
@@ -162,13 +647,12 @@ class Store:
def add_name(
self,
board_id: str,
kind: str,
spelling: str,
created_by: str,
locales: dict[str, Any] | None = None,
) -> dict[str, Any]:
if kind not in KINDS:
raise ValueError("invalid kind")
spelling = spelling.strip()
if not spelling or len(spelling) > 80:
raise ValueError("spelling required (1–80 chars)")
@@ -176,13 +660,15 @@ class Store:
locs = self._normalize_locales(locales)
now = time.time()
with self._lock:
if kind not in self._column_ids(board_id):
raise ValueError("unknown column")
try:
cur = self._conn.execute(
"INSERT INTO names (kind, spelling, created_by, created_at) VALUES (?, ?, ?, ?)",
(kind, spelling, created_by, now),
"INSERT INTO names (board_id, kind, spelling, created_by, created_at) VALUES (?, ?, ?, ?, ?)",
(board_id, kind, spelling, created_by, now),
)
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)
for lang, payload in locs.items():
self._conn.execute(
@@ -190,17 +676,34 @@ class Store:
(name_id, lang, payload["pronunciation"], payload["origin"], payload["meaning"]),
)
self._conn.commit()
result = self.get_name(name_id)
result = self.get_name(name_id, board_id=board_id)
assert result is not None
return result
def update_locales(self, name_id: int, locales: dict[str, Any]) -> dict[str, Any] | None:
locs = self._normalize_locales(locales)
def update_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:
row = self._conn.execute("SELECT id FROM names WHERE id = ?", (name_id,)).fetchone()
if not row:
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
for lang, payload in locs.items():
for lang, payload in patch.items():
self._conn.execute(
"""
INSERT INTO locales (name_id, lang, pronunciation, origin, meaning)
@@ -213,7 +716,7 @@ class Store:
(name_id, lang, payload["pronunciation"], payload["origin"], payload["meaning"]),
)
self._conn.commit()
return self.get_name(name_id)
return self.get_name(name_id, board_id=board_id)
def vote(
self,
@@ -221,6 +724,7 @@ class Store:
voter_key: str,
voter_name: str,
value: int,
board_id: str | None = None,
) -> dict[str, Any] | None:
if value not in (-1, 0, 1):
raise ValueError("vote must be -1, 0, or 1")
@@ -229,8 +733,8 @@ class Store:
raise ValueError("invalid voter")
voter_name = (voter_name or "").strip()[:80]
with self._lock:
row = self._conn.execute("SELECT id FROM names WHERE id = ?", (name_id,)).fetchone()
if not row:
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
if value == 0:
self._conn.execute(
@@ -250,16 +754,120 @@ class Store:
(name_id, voter_key, voter_name, value, time.time()),
)
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:
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,))
self._conn.commit()
return cur.rowcount > 0
def export_snapshot(self) -> str:
"""Debug helper for tests."""
with self._lock:
names = [dict(r) for r in self._conn.execute("SELECT * FROM names").fetchall()]
return json.dumps(names)
+10 -2
View File
@@ -25,10 +25,18 @@ def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> TestClient:
@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(
"/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
return client
+140 -60
View File
@@ -11,111 +11,191 @@ def test_health(client: TestClient) -> None:
assert res.json()["ok"] is True
def test_session_rejects_bad_invite(client: TestClient) -> None:
res = client.post("/api/session", json={"invite": "nope", "display_name": "Ilia"})
assert res.status_code == 403
def test_logos_gallery(client: TestClient) -> None:
res = client.get("/logos")
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:
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:
a = authed.post(
"/api/names",
json={
"kind": "first",
"kind": "c1",
"spelling": "Noa",
"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"},
},
},
)
assert a.status_code == 200
noa_id = a.json()["id"]
b = authed.post("/api/names", json={"kind": "first", "spelling": "Levi"})
assert b.status_code == 200
b = authed.post("/api/names", json={"kind": "c1", "spelling": "Levi"})
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/{levi_id}/vote", json={"value": -1}).status_code == 200
listed = authed.get("/api/names?kind=first").json()["items"]
listed = authed.get("/api/names?kind=c1").json()["items"]
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:
assert authed.post("/api/names", json={"kind": "first", "spelling": "Maya"}).status_code == 200
dup = authed.post("/api/names", json={"kind": "first", "spelling": "maya"})
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
assert authed.post("/api/names", json={"kind": "c1", "spelling": "Maya"}).status_code == 200
assert authed.post("/api/names", json={"kind": "c1", "spelling": "maya"}).status_code == 400
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(
f"/api/names/{created['id']}",
json={"locales": {"ru": {"pronunciation": "Э-ден", "origin": "иврит", "meaning": "рай"}}},
)
assert patched.status_code == 200
assert patched.json()["locales"]["ru"]["meaning"] == "рай"
assert patched.json()["locales"]["en"]["pronunciation"] == ""
def test_admin_delete(authed: TestClient) -> None:
created = authed.post("/api/names", json={"kind": "first", "spelling": "Temp"}).json()
denied = authed.delete(f"/api/names/{created['id']}")
assert denied.status_code == 403
ok = authed.delete(f"/api/names/{created['id']}", headers={"X-Stork-Admin": "test-admin-token"})
assert ok.status_code == 200
assert authed.get("/api/names?kind=first").json()["items"] == []
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"},
def test_admin_delete_name(authed: TestClient) -> None:
created = authed.post("/api/names", json={"kind": "c1", "spelling": "Temp"}).json()
assert authed.delete(f"/api/names/{created['id']}").status_code == 403
assert (
authed.delete(
f"/api/names/{created['id']}", headers={"X-Stork-Admin": "test-admin-token"}
).status_code
== 200
)
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 = {
"X-Stork-Invite": "test-invite-token",
"X-Stork-Board": family_board["id"],
"X-Stork-Display-Name": "Seed Bot",
}
res = client.post(
"/api/names",
headers=headers,
json={"kind": "first", "spelling": "HeaderOnly"},
json={"kind": "c1", "spelling": "HeaderOnly"},
)
assert res.status_code == 200
listed = client.get("/api/names?kind=first", headers=headers)
assert listed.status_code == 200
listed = client.get("/api/names?kind=c1", headers=headers)
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:
store = Store(tmp_path / "t.sqlite3")
a = store.add_name("first", "Zed", "x")
store.add_name("first", "Ann", "x")
c = store.add_name("first", "Bo", "x")
store.vote(a["id"], "v1", "A", 1)
store.vote(c["id"], "v1", "A", 1)
store.vote(c["id"], "v2", "B", 1)
ranked = store.list_names("first")
store = Store(tmp_path / "t.sqlite3", bootstrap_invite="unit-invite")
board_id = store.list_boards()[0]["id"]
a = store.add_name(board_id, "c1", "Zed", "x")
store.add_name(board_id, "c1", "Ann", "x")
c = store.add_name(board_id, "c1", "Bo", "x")
store.vote(a["id"], "v1", "A", 1, board_id=board_id)
store.vote(c["id"], "v1", "A", 1, board_id=board_id)
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"]
store.close()