Add shareable multi-board links with paper UI and voice notes.
Boards use /b/<uid> with no invite password; restore renamable columns, recordings, and seeded locale notes, with first column locked.
@@ -1,5 +1,7 @@
|
|||||||
# Copy to .env on the deploy host (never commit real tokens).
|
# Copy to .env for local run / deploy host (never commit real tokens).
|
||||||
|
# Optional: bootstraps a "Family" board that legacy ?invite= links can resolve.
|
||||||
STORK_INVITE_TOKEN=change-me-long-random
|
STORK_INVITE_TOKEN=change-me-long-random
|
||||||
STORK_ADMIN_TOKEN=change-me-admin-random
|
STORK_ADMIN_TOKEN=change-me-admin-random
|
||||||
STORK_COOKIE_SECURE=false
|
STORK_COOKIE_SECURE=false
|
||||||
STORK_DATA=./data
|
STORK_DATA=./data
|
||||||
|
# STORK_PUBLIC_URL=https://stork.levkin.ca
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ Short orientation for Cursor agents.
|
|||||||
|
|
||||||
## Defaults
|
## Defaults
|
||||||
|
|
||||||
- Family baby-name board (votes + en/ru/he notes). Run/install: `README.md`.
|
- Multi-board baby-name app (`/b/<uid>` share links). Run/install: `README.md`.
|
||||||
- Lint/tests are CI hard gates: `make lint` / `make test` — never `|| true`.
|
- Lint/tests are CI hard gates: `make lint` / `make test` — never `|| true`.
|
||||||
- Secrets: Infisical `/apps/stork` or gitignored `.env`. Never commit tokens.
|
- Secrets: gitignored `.env` or deploy host `.env`. Never commit tokens.
|
||||||
- Deploy/DNS/Caddy/Kuma live in `~/Documents/code/ansible` (`make deploy-stork`).
|
- Deploy/DNS/Caddy/Kuma live in `~/Documents/code/ansible` (`make deploy-stork`).
|
||||||
|
|
||||||
## Close-out
|
## Close-out
|
||||||
|
|
||||||
1. `make test` and `make lint` before claiming done.
|
1. `make test` and `make lint` before claiming done.
|
||||||
2. Do not put LAN IPs or real invites in this repo.
|
2. Do not put LAN IPs or real invites/board ids in this repo.
|
||||||
3. Merge only when the user asks.
|
3. Merge only when the user asks (or when they explicitly say merge when green).
|
||||||
|
|||||||
@@ -19,10 +19,18 @@ run: ## Local uvicorn on :8094 (needs .env)
|
|||||||
@test -f .env || (echo "Copy .env.example to .env first" && exit 1)
|
@test -f .env || (echo "Copy .env.example to .env first" && exit 1)
|
||||||
set -a && . ./.env && set +a && uvicorn stork.app:app --reload --host 127.0.0.1 --port 8094
|
set -a && . ./.env && set +a && uvicorn stork.app:app --reload --host 127.0.0.1 --port 8094
|
||||||
|
|
||||||
seed: ## Seed example first names (usage: make seed STORK_INVITE=... [STORK_URL=...])
|
seed: ## Seed example names (usage: make seed STORK_BOARD_ID=b_… OR STORK_INVITE=…)
|
||||||
@test -n "$(STORK_INVITE)" || (echo "Set STORK_INVITE=..." && exit 1)
|
@if [ -z "$(STORK_BOARD_ID)" ] && [ -z "$(STORK_INVITE)" ]; then \
|
||||||
STORK_URL="$(or $(STORK_URL),http://127.0.0.1:8094)" STORK_INVITE="$(STORK_INVITE)" \
|
echo "Set STORK_BOARD_ID=b_… or STORK_INVITE=…"; exit 1; \
|
||||||
|
fi
|
||||||
|
STORK_URL="$(or $(STORK_URL),http://127.0.0.1:8094)" \
|
||||||
|
STORK_BOARD_ID="$(STORK_BOARD_ID)" STORK_INVITE="$(STORK_INVITE)" \
|
||||||
.venv/bin/python scripts/seed_names.py
|
.venv/bin/python scripts/seed_names.py
|
||||||
|
|
||||||
|
create-board: ## Create empty board + print share URL (usage: make create-board [STORK_BOARD_TITLE='…'])
|
||||||
|
STORK_URL="$(or $(STORK_URL),http://127.0.0.1:8094)" \
|
||||||
|
STORK_BOARD_TITLE="$(or $(STORK_BOARD_TITLE),Name board)" \
|
||||||
|
.venv/bin/python scripts/create_board.py
|
||||||
|
|
||||||
docker-build: ## Build local image
|
docker-build: ## Build local image
|
||||||
docker build -t homelab-stork:latest .
|
docker build -t homelab-stork:latest .
|
||||||
|
|||||||
@@ -1,42 +1,71 @@
|
|||||||
# Stork
|
# Stork
|
||||||
|
|
||||||
Private family board for baby **first** and **middle** name ideas: suggest
|
Private family baby-name boards: suggest names, vote, keep origin / meaning /
|
||||||
names, vote, and keep origin / meaning / pronunciation in English, Russian,
|
pronunciation in English, Russian, and Hebrew, and optionally record how a
|
||||||
and Hebrew.
|
name sounds.
|
||||||
|
|
||||||
Homelab deploy lives in the ansible repo (`make deploy-stork`). This repo is
|
Each board has a unique share link (`/b/<uid>`). Start a board with no password,
|
||||||
the app only — no LAN IPs or production secrets here.
|
add names, share the link. Homelab deploy lives in the ansible repo
|
||||||
|
(`make deploy-stork`). This repo is the app only — no LAN IPs or production
|
||||||
|
secrets here.
|
||||||
|
|
||||||
## Quick start (local)
|
## Quick start (local)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env # set STORK_INVITE_TOKEN (and optional STORK_ADMIN_TOKEN)
|
cp .env.example .env # optional STORK_INVITE_TOKEN bootstraps a "Family" board
|
||||||
make install
|
make install
|
||||||
make test
|
make test
|
||||||
make run
|
make run
|
||||||
```
|
```
|
||||||
|
|
||||||
Open http://127.0.0.1:8094 — enter the invite code and your display name.
|
Open http://127.0.0.1:8094 → **Start new board** → share `/b/…` with family.
|
||||||
Share `http://127.0.0.1:8094/?invite=YOUR_TOKEN` with family for one-tap entry.
|
They open the link and enter a display name.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Renamable columns (First is fixed; `+` adds up to 4; ✕ removes extras)
|
||||||
|
- Votes / ranking per board
|
||||||
|
- Locale notes (en / ru / he) + voice recordings (re-record overwrites)
|
||||||
|
- Paper UI at `/` (classic layout at `/v1`)
|
||||||
|
|
||||||
## API sketch
|
## API sketch
|
||||||
|
|
||||||
| Method | Path | Notes |
|
| Method | Path | Notes |
|
||||||
|--------|------|-------|
|
|--------|------|-------|
|
||||||
| GET | `/api/health` | Liveness |
|
| GET | `/api/health` | Liveness |
|
||||||
| POST | `/api/session` | `{invite, display_name}` → cookies |
|
| POST | `/api/boards` | Start board `{title}` → `{id, url}` |
|
||||||
| GET | `/api/names?kind=first\|middle` | Ranked list |
|
| GET | `/api/boards/{id}` | Public board title + url |
|
||||||
| POST | `/api/names` | Add name + optional locales |
|
| GET | `/api/resolve?invite=` | Legacy invite → board url |
|
||||||
| PATCH | `/api/names/{id}` | Update locale notes |
|
| POST | `/api/session` | `{board_id, display_name}` → cookies |
|
||||||
|
| GET | `/api/columns` | Columns for session board |
|
||||||
|
| POST/PATCH/DELETE | `/api/columns…` | Add / rename / remove (not first) |
|
||||||
|
| GET/POST | `/api/names` | List / add |
|
||||||
|
| PATCH | `/api/names/{id}` | Locale notes |
|
||||||
| POST | `/api/names/{id}/vote` | `{value: 1\|-1\|0}` |
|
| POST | `/api/names/{id}/vote` | `{value: 1\|-1\|0}` |
|
||||||
|
| POST/GET/DELETE | `/api/names/{id}/recording/{lang}` | Voice |
|
||||||
| DELETE | `/api/names/{id}` | Admin header `X-Stork-Admin` |
|
| DELETE | `/api/names/{id}` | Admin header `X-Stork-Admin` |
|
||||||
|
|
||||||
|
## Scripts
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Seed Shai/Roze/Odet/Rivka onto a board
|
||||||
|
make seed STORK_BOARD_ID=b_… # or STORK_INVITE=… for legacy
|
||||||
|
|
||||||
|
# Create an empty board and print share URL
|
||||||
|
.venv/bin/python scripts/create_board.py
|
||||||
|
```
|
||||||
|
|
||||||
## Secrets
|
## Secrets
|
||||||
|
|
||||||
- Local: gitignored `.env`
|
- Local: gitignored `.env`
|
||||||
- Prod: Infisical `/apps/stork` (or host `.env` written by deploy) — never git
|
- Prod: host `/opt/stork/.env` from deploy (never git)
|
||||||
|
|
||||||
|
## CI gates
|
||||||
|
|
||||||
|
Gitea Actions (`.gitea/workflows/ci.yml`): **ruff** and **pytest** are hard gates
|
||||||
|
(no `|| true`). Gitleaks on PRs. Bandit is advisory.
|
||||||
|
|
||||||
## Production shape
|
## Production shape
|
||||||
|
|
||||||
Same host pattern as Compare: Docker on automationlab LXC **225**, public
|
Docker on automationlab; public HTTPS via Caddy (`stork.levkin.ca`). See ansible
|
||||||
HTTPS via Caddy (`stork.levkin.ca`). See ansible `docs/guides/stork-deploy.md`.
|
`docs/guides/stork-deploy.md`.
|
||||||
|
|||||||
@@ -1,87 +1,39 @@
|
|||||||
{
|
{
|
||||||
"names": [
|
"names": [
|
||||||
{
|
{
|
||||||
"kind": "first",
|
"kind": "c1",
|
||||||
"spelling": "Shai",
|
"spelling": "Shai",
|
||||||
"locales": {
|
"locales": {
|
||||||
"en": {
|
"en": {"pronunciation": "Shai", "origin": "Hebrew", "meaning": "Gift. Variants: Shay."},
|
||||||
"pronunciation": "SHY (rhymes with sky)",
|
"ru": {"pronunciation": "Шай", "origin": "иврит", "meaning": "Дар, подарок."},
|
||||||
"origin": "Hebrew (שי)",
|
"he": {"pronunciation": "שי", "origin": "עברית", "meaning": "מתנה."}
|
||||||
"meaning": "Gift / present; sometimes a short form of Isaiah (Yeshayahu). Variants: Shay, Shai."
|
|
||||||
},
|
|
||||||
"ru": {
|
|
||||||
"pronunciation": "Шай",
|
|
||||||
"origin": "иврит",
|
|
||||||
"meaning": "«дар», «подарок»; иногда уменьшительное от Исайи. Варианты: Shay, Shai."
|
|
||||||
},
|
|
||||||
"he": {
|
|
||||||
"pronunciation": "שַׁי — shai",
|
|
||||||
"origin": "עברית",
|
|
||||||
"meaning": "שי — מתנה / מנחה. לפעמים קיצור של ישעיהו. כתיבים: שי, Shay."
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"kind": "first",
|
"kind": "c1",
|
||||||
"spelling": "Roze",
|
"spelling": "Roze",
|
||||||
"locales": {
|
"locales": {
|
||||||
"en": {
|
"en": {"pronunciation": "Roze", "origin": "Latin rosa", "meaning": "Rose. Related: Rose, Rosa, Roza."},
|
||||||
"pronunciation": "ROHZ",
|
"ru": {"pronunciation": "Роуз", "origin": "латинское rosa", "meaning": "Роза. Также: Rose, Rosa, Roza."},
|
||||||
"origin": "Latin rosa / flower name (also Germanic Rose lineage)",
|
"he": {"pronunciation": "שושנה", "origin": "עברית", "meaning": "מקבילה ל־Roze/Rose. גם ורד."}
|
||||||
"meaning": "Rose (the flower); love and beauty. Spelling variant of Rose. Related: Rose, Rosa, Roza (RU/PL), Rosie, Rožė (LT), Roze (LV)."
|
|
||||||
},
|
|
||||||
"ru": {
|
|
||||||
"pronunciation": "Ро́уз / Ро́за",
|
|
||||||
"origin": "латинское rosa; славянская форма Роза",
|
|
||||||
"meaning": "роза (цветок). Варианты: Rose, Rosa, Roza, Rosie."
|
|
||||||
},
|
|
||||||
"he": {
|
|
||||||
"pronunciation": "רוֹז",
|
|
||||||
"origin": "לטינית / שם פרח",
|
|
||||||
"meaning": "ורד / רוזה. כתיבים קרובים: Rose, Rosa, Roza; בעברית גם ורד (Vered)."
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"kind": "first",
|
"kind": "c1",
|
||||||
"spelling": "Odet",
|
"spelling": "Odet",
|
||||||
"locales": {
|
"locales": {
|
||||||
"en": {
|
"en": {"pronunciation": "Odet", "origin": "French (Odette)", "meaning": "Wealth / prosperity."},
|
||||||
"pronunciation": "oh-DET",
|
"ru": {"pronunciation": "Одет", "origin": "французское Odette", "meaning": "Богатство."},
|
||||||
"origin": "French diminutive (Odette) from Germanic od-/ot- “wealth”",
|
"he": {"pronunciation": "אודט", "origin": "צרפתית", "meaning": "אין מקבילה עברית נפוצה."}
|
||||||
"meaning": "Wealth / prosperity. Short form of Odette (Swan Lake). Variants: Odette, Odetta, Ode, Oda."
|
|
||||||
},
|
|
||||||
"ru": {
|
|
||||||
"pronunciation": "Оде́т",
|
|
||||||
"origin": "французское Odette ← германское «богатство»",
|
|
||||||
"meaning": "богатство / достаток. Варианты: Odette, Одетта, Odetta."
|
|
||||||
},
|
|
||||||
"he": {
|
|
||||||
"pronunciation": "אוֹדֶט",
|
|
||||||
"origin": "צרפתית (Odette) משורש גרמאני",
|
|
||||||
"meaning": "עושר / שפע. צורות: Odette, Odetta, Odet."
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"kind": "first",
|
"kind": "c1",
|
||||||
"spelling": "Rivka",
|
"spelling": "Rivka",
|
||||||
"locales": {
|
"locales": {
|
||||||
"en": {
|
"en": {"pronunciation": "Rivka", "origin": "Hebrew", "meaning": "To bind / join. Short form: Riva. English: Rebecca."},
|
||||||
"pronunciation": "riv-KAH / REEV-kah (short form Riva: REE-vah)",
|
"ru": {"pronunciation": "Ривка", "origin": "иврит", "meaning": "Связывать. Кратко: Рива. Ревекка."},
|
||||||
"origin": "Hebrew biblical (רִבְקָה) — wife of Isaac",
|
"he": {"pronunciation": "רבקה", "origin": "תנ״ך", "meaning": "קיצור: ריבה. באנגלית Rebecca."}
|
||||||
"meaning": "From root “to bind / tie / join”; often glossed captivating. One entry for Rivka and Riva (short). English: Rebecca, Rebekah. Also Rifka, Becky."
|
|
||||||
},
|
|
||||||
"ru": {
|
|
||||||
"pronunciation": "Ри́вка (кратко: Ри́ва)",
|
|
||||||
"origin": "библейское еврейское имя (Ревекка)",
|
|
||||||
"meaning": "«связывать», «соединять»; библейская Ревекка. Одна запись для Ривка и Рива. Варианты: Rebecca, Rebekah, Rifka."
|
|
||||||
},
|
|
||||||
"he": {
|
|
||||||
"pronunciation": "רִבְקָה — rivká (קיצור: רִיבָה / Riva)",
|
|
||||||
"origin": "תנ״ך — אשת יצחק",
|
|
||||||
"meaning": "שורש ר־ב־ק (לקשור / לחבר). רבקה ו־Riva הן אותה שם (קיצור). באנגלית Rebecca."
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
fastapi==0.115.12
|
fastapi==0.115.12
|
||||||
uvicorn[standard]==0.34.2
|
uvicorn[standard]==0.34.2
|
||||||
pydantic==2.11.3
|
pydantic==2.11.3
|
||||||
|
python-multipart==0.0.20
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
"""Seed starter first names into a local or remote Stork instance.
|
"""Seed or refresh starter names (add missing, patch locales on existing).
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
STORK_URL=http://10.0.10.45:8094 STORK_INVITE=... \\
|
STORK_URL=https://stork.levkin.ca STORK_BOARD_ID=b_xxx \\
|
||||||
.venv/bin/python scripts/seed_names.py
|
.venv/bin/python scripts/seed_names.py
|
||||||
|
|
||||||
|
# or resolve legacy invite:
|
||||||
|
STORK_URL=... STORK_INVITE=... .venv/bin/python scripts/seed_names.py
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -20,24 +23,49 @@ SEED = ROOT / "data" / "seed-names.example.json"
|
|||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
base = os.environ.get("STORK_URL", "http://127.0.0.1:8094").rstrip("/")
|
base = os.environ.get("STORK_URL", "http://127.0.0.1:8094").rstrip("/")
|
||||||
|
board_id = os.environ.get("STORK_BOARD_ID", "").strip()
|
||||||
invite = os.environ.get("STORK_INVITE", "").strip()
|
invite = os.environ.get("STORK_INVITE", "").strip()
|
||||||
display = os.environ.get("STORK_DISPLAY_NAME", "Ilia").strip() or "Ilia"
|
display = os.environ.get("STORK_DISPLAY_NAME", "Ilia").strip() or "Ilia"
|
||||||
if not invite:
|
if not board_id and not invite:
|
||||||
print("Set STORK_INVITE", file=sys.stderr)
|
print("Set STORK_BOARD_ID or STORK_INVITE", file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
names = json.loads(SEED.read_text())["names"]
|
names = json.loads(SEED.read_text())["names"]
|
||||||
|
with httpx.Client(base_url=base, timeout=30.0) as client:
|
||||||
|
if not board_id:
|
||||||
|
resolved = client.get("/api/resolve", params={"invite": invite})
|
||||||
|
resolved.raise_for_status()
|
||||||
|
board_id = resolved.json()["id"]
|
||||||
headers = {
|
headers = {
|
||||||
"X-Stork-Invite": invite,
|
"X-Stork-Board": board_id,
|
||||||
"X-Stork-Display-Name": display,
|
"X-Stork-Display-Name": display,
|
||||||
}
|
}
|
||||||
with httpx.Client(base_url=base, timeout=30.0, headers=headers) as client:
|
existing: dict[tuple[str, str], int] = {}
|
||||||
for item in names:
|
for kind in {n.get("kind", "c1") for n in names} | {"c1"}:
|
||||||
res = client.post("/api/names", json=item)
|
listed = client.get("/api/names", params={"kind": kind}, headers=headers)
|
||||||
if res.status_code == 400 and "already exists" in res.text:
|
if listed.status_code != 200:
|
||||||
print(f"skip {item['spelling']} (exists)")
|
|
||||||
continue
|
continue
|
||||||
|
for row in listed.json().get("items") or []:
|
||||||
|
existing[(row["kind"], row["spelling"].casefold())] = row["id"]
|
||||||
|
|
||||||
|
for item in names:
|
||||||
|
kind = item["kind"]
|
||||||
|
spelling = item["spelling"]
|
||||||
|
key = (kind, spelling.casefold())
|
||||||
|
locales = item.get("locales") or {}
|
||||||
|
if key in existing:
|
||||||
|
name_id = existing[key]
|
||||||
|
res = client.patch(
|
||||||
|
f"/api/names/{name_id}",
|
||||||
|
headers=headers,
|
||||||
|
json={"locales": locales},
|
||||||
|
)
|
||||||
res.raise_for_status()
|
res.raise_for_status()
|
||||||
print(f"added {item['kind']}: {item['spelling']}")
|
print(f"updated locales {kind}: {spelling}")
|
||||||
|
continue
|
||||||
|
res = client.post("/api/names", headers=headers, json=item)
|
||||||
|
res.raise_for_status()
|
||||||
|
print(f"added {kind}: {spelling}")
|
||||||
|
print(f"board: {base}/b/{board_id}")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
|
After Width: | Height: | Size: 73 KiB |
@@ -3,87 +3,96 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="color-scheme" content="light dark" />
|
||||||
|
<script src="/static/stork-common.js"></script>
|
||||||
|
<script>StorkUI.applyTheme();</script>
|
||||||
<title>Stork — family name board</title>
|
<title>Stork — family name board</title>
|
||||||
|
<link rel="icon" href="/static/favicon.png" type="image/png" />
|
||||||
|
<link rel="apple-touch-icon" href="/static/favicon.png" />
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,700&family=Source+Sans+3:wght@400;600;700&display=swap" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,700&family=Source+Sans+3:wght@400;600;700&display=swap" rel="stylesheet" />
|
||||||
<style>
|
<style>
|
||||||
:root {
|
|
||||||
--sky: #d6e4ef;
|
:root, [data-theme="light"] {
|
||||||
--fog: #eef3f7;
|
--sky: #d6e4ef; --fog: #eef3f7; --paper: #f8faf8; --ink: #18253a; --muted: #4a5566;
|
||||||
--paper: #f8faf8;
|
--line: #c5d3e0; --accent: #2a5f7a; --wing: #b85c48; --up: #2f6b5a; --down: #9a4a3a;
|
||||||
--ink: #18253a;
|
--rank: #1e3a55; --shadow: 0 10px 32px rgba(24,37,58,.07); --radius: 16px;
|
||||||
--muted: #5a6a7e;
|
--input-bg: #fff; --panel: #f8faf8; --logo-filter: none;
|
||||||
--line: #c5d3e0;
|
--body-bg: radial-gradient(900px 420px at 85% -5%, #f0d8cf 0%, transparent 55%),
|
||||||
--accent: #2a5f7a;
|
radial-gradient(1100px 520px at -5% 0%, var(--sky) 0%, transparent 50%),
|
||||||
--wing: #b85c48;
|
linear-gradient(180deg, #e7eef4 0%, var(--fog) 45%, #e9eee8 100%);
|
||||||
--up: #2f6b5a;
|
|
||||||
--down: #9a4a3a;
|
|
||||||
--rank: #1e3a55;
|
|
||||||
--shadow: 0 12px 40px rgba(24, 37, 58, 0.08);
|
|
||||||
--radius: 18px;
|
|
||||||
}
|
}
|
||||||
|
[data-theme="dark"] {
|
||||||
|
--sky: #1a2433; --fog: #121820; --paper: #161c26; --ink: #e8eef6; --muted: #a8b4c4;
|
||||||
|
--line: #2e3a4c; --accent: #7eb6d1; --wing: #e0a090; --up: #5fad93; --down: #d48474;
|
||||||
|
--rank: #c5d4e8; --shadow: 0 10px 32px rgba(0,0,0,.35);
|
||||||
|
--input-bg: #0f141c; --panel: #1a222e; --logo-filter: invert(1) contrast(1.05);
|
||||||
|
--body-bg: radial-gradient(900px 420px at 85% -5%, #2a2030 0%, transparent 55%),
|
||||||
|
radial-gradient(1100px 520px at -5% 0%, #1a2433 0%, transparent 50%),
|
||||||
|
linear-gradient(180deg, #10151c 0%, #121820 45%, #141a22 100%);
|
||||||
|
}
|
||||||
|
.skip{position:absolute;left:-9999px;top:0;background:var(--ink);color:var(--paper);padding:.5rem .75rem;z-index:10}
|
||||||
|
.skip:focus{left:.5rem;top:.5rem}
|
||||||
|
.theme-bar{display:flex;gap:.35rem;flex-wrap:wrap;align-items:center;margin:0 0 .85rem;justify-content:flex-end}
|
||||||
|
.theme-bar span{color:var(--muted);font-size:.9rem;margin-right:.25rem}
|
||||||
|
.theme-bar button{padding:.35rem .65rem;font-size:.85rem;min-height:2.25rem;background:var(--input-bg);color:var(--ink);border:1px solid var(--line)}
|
||||||
|
.theme-bar button.active{background:var(--ink);color:var(--paper);border-color:var(--ink)}
|
||||||
|
.error{color:var(--down);margin:.45rem 0 0;font-size:.95rem;font-weight:600;border-left:3px solid var(--down);padding-left:.55rem}
|
||||||
|
input[aria-invalid="true"]{border-color:var(--down);box-shadow:0 0 0 1px var(--down)}
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
html { scroll-behavior: smooth; }
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
font-family: "Source Sans 3", system-ui, sans-serif;
|
font-family: "Source Sans 3", system-ui, sans-serif;
|
||||||
background:
|
background: var(--body-bg);
|
||||||
radial-gradient(900px 420px at 85% -5%, #f0d8cf 0%, transparent 55%),
|
|
||||||
radial-gradient(1100px 520px at -5% 0%, var(--sky) 0%, transparent 50%),
|
|
||||||
linear-gradient(180deg, #e7eef4 0%, var(--fog) 40%, #e9eee8 100%);
|
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
@media (prefers-reduced-motion: reduce) {
|
main { max-width: 1000px; margin: 0 auto; padding: 1.25rem 1rem 3.5rem; }
|
||||||
html { scroll-behavior: auto; }
|
|
||||||
.name-card { transition: none; }
|
|
||||||
}
|
|
||||||
main { max-width: 1100px; margin: 0 auto; padding: 1.5rem 1rem 4rem; }
|
|
||||||
.hero {
|
.hero {
|
||||||
display: grid;
|
display: flex;
|
||||||
gap: 0.35rem;
|
gap: 0.85rem;
|
||||||
margin-bottom: 1.25rem;
|
align-items: center;
|
||||||
animation: rise 0.7s ease both;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
@keyframes rise {
|
.hero img {
|
||||||
from { opacity: 0; transform: translateY(10px); }
|
width: 56px;
|
||||||
to { opacity: 1; transform: none; }
|
height: 56px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: var(--input-bg); filter: var(--logo-filter);
|
||||||
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
.brand {
|
.brand {
|
||||||
font-family: Fraunces, Georgia, serif;
|
font-family: Fraunces, Georgia, serif;
|
||||||
font-size: clamp(2.4rem, 6vw, 3.4rem);
|
font-size: clamp(1.9rem, 5vw, 2.5rem);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: -0.03em;
|
letter-spacing: -0.03em;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
.brand span {
|
.sub { margin: 0.25rem 0 0; color: var(--muted); font-size: 0.95rem; }
|
||||||
color: var(--wing);
|
.sub a { color: var(--accent); font-weight: 600; text-decoration: none; }
|
||||||
font-style: italic;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
.sub { margin: 0; color: var(--muted); max-width: 36rem; font-size: 1.05rem; }
|
|
||||||
.panel {
|
.panel {
|
||||||
background: color-mix(in srgb, var(--paper) 92%, white);
|
background: var(--panel);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
padding: 1.1rem 1.15rem;
|
padding: 1rem;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 0.85rem;
|
||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow);
|
||||||
backdrop-filter: blur(6px);
|
|
||||||
}
|
}
|
||||||
label { display: block; font-size: 0.85rem; color: var(--muted); margin-bottom: 0.3rem; font-weight: 600; }
|
label { display: block; font-size: 0.95rem; color: var(--muted); margin-bottom: 0.25rem; font-weight: 600; }
|
||||||
input, select, textarea, button {
|
input, textarea, button {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.7rem 0.8rem;
|
padding: 0.65rem 0.75rem;
|
||||||
border-radius: 12px;
|
border-radius: 11px;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
background: #fff;
|
background: var(--input-bg);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
}
|
}
|
||||||
textarea { min-height: 3.2rem; resize: vertical; }
|
textarea { min-height: 2.8rem; resize: vertical; }
|
||||||
button {
|
button {
|
||||||
width: auto;
|
width: auto;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -92,227 +101,171 @@
|
|||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
button:focus-visible, input:focus-visible, textarea:focus-visible, select:focus-visible {
|
button:focus-visible, input:focus-visible, textarea:focus-visible {
|
||||||
outline: 2px solid var(--wing);
|
outline: 2px solid var(--wing);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
button.secondary { background: #fff; color: var(--ink); border-color: var(--line); }
|
button.secondary { background: var(--input-bg); color: var(--ink); border-color: var(--line); }
|
||||||
button.icon {
|
button.icon {
|
||||||
width: 2rem;
|
width: 2rem; height: 2rem; padding: 0;
|
||||||
height: 2rem;
|
display: inline-grid; place-items: center;
|
||||||
padding: 0;
|
border-radius: 999px; background: var(--input-bg); color: var(--accent);
|
||||||
display: inline-grid;
|
border: 1px solid var(--line); flex: 0 0 auto;
|
||||||
place-items: center;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: #fff;
|
|
||||||
color: var(--accent);
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
flex: 0 0 auto;
|
|
||||||
}
|
}
|
||||||
button.icon:hover { border-color: var(--accent); }
|
|
||||||
button.icon.playing { background: var(--accent); color: #fff; }
|
button.icon.playing { background: var(--accent); color: #fff; }
|
||||||
button.vote {
|
button.vote {
|
||||||
min-width: 2.4rem;
|
min-width: 2.2rem; padding: 0.3rem 0.45rem;
|
||||||
padding: 0.35rem 0.5rem;
|
background: var(--input-bg); color: var(--ink); border: 1px solid var(--line);
|
||||||
background: #fff;
|
|
||||||
color: var(--ink);
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
}
|
}
|
||||||
button.vote.active-up { background: var(--up); color: #fff; border-color: transparent; }
|
button.vote.active-up { background: var(--up); color: #fff; border-color: transparent; }
|
||||||
button.vote.active-down { background: var(--down); color: #fff; border-color: transparent; }
|
button.vote.active-down { background: var(--down); color: #fff; border-color: transparent; }
|
||||||
.row { display: flex; gap: 0.55rem; flex-wrap: wrap; align-items: end; }
|
button.pill {
|
||||||
.who {
|
padding: 0.2rem 0.55rem;
|
||||||
display: flex;
|
border-radius: 999px;
|
||||||
justify-content: space-between;
|
font-size: 0.8rem;
|
||||||
gap: 0.75rem;
|
letter-spacing: 0.04em;
|
||||||
align-items: baseline;
|
text-transform: uppercase;
|
||||||
flex-wrap: wrap;
|
background: var(--input-bg);
|
||||||
margin-bottom: 0.85rem;
|
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 0.95rem;
|
border: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
.boards {
|
button.pill.active { background: var(--ink); color: var(--paper); border-color: transparent; }
|
||||||
display: grid;
|
.row { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: end; }
|
||||||
gap: 1rem;
|
.who {
|
||||||
margin-bottom: 1rem;
|
display: flex; justify-content: space-between; gap: 0.75rem;
|
||||||
}
|
align-items: baseline; flex-wrap: wrap; margin-bottom: 0.75rem;
|
||||||
@media (min-width: 860px) {
|
color: var(--muted); font-size: 0.92rem;
|
||||||
.boards { grid-template-columns: 1fr 1fr; align-items: start; }
|
|
||||||
}
|
}
|
||||||
|
.who a { color: var(--accent); font-weight: 600; text-decoration: none; }
|
||||||
|
.boards { display: grid; gap: 0.85rem; margin-bottom: 0.85rem; }
|
||||||
|
@media (min-width: 860px) { .boards { grid-template-columns: 1fr 1fr; align-items: start; } }
|
||||||
.col-head {
|
.col-head {
|
||||||
display: flex;
|
display: flex; align-items: baseline; justify-content: space-between;
|
||||||
align-items: baseline;
|
gap: 0.5rem; margin-bottom: 0.35rem; padding-bottom: 0.45rem;
|
||||||
justify-content: space-between;
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
padding-bottom: 0.55rem;
|
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
.col-head h2 {
|
.col-head h2 {
|
||||||
font-family: Fraunces, Georgia, serif;
|
font-family: Fraunces, Georgia, serif;
|
||||||
font-size: 1.35rem;
|
font-size: 1.2rem; margin: 0; font-weight: 700;
|
||||||
margin: 0;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.col-head .count {
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
}
|
||||||
|
.col-head .count { color: var(--muted); font-size: 0.95rem; font-weight: 600; }
|
||||||
.name-card {
|
.name-card {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 2.5rem 1fr auto;
|
grid-template-columns: 2.2rem 1fr auto;
|
||||||
gap: 0.65rem;
|
gap: 0.55rem;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
padding: 0.8rem 0.15rem;
|
padding: 0.7rem 0.1rem;
|
||||||
border-top: 1px solid color-mix(in srgb, var(--line) 70%, transparent);
|
border-top: 1px solid color-mix(in srgb, var(--line) 65%, transparent);
|
||||||
transition: background 0.15s ease;
|
|
||||||
}
|
}
|
||||||
.name-card:first-of-type { border-top: 0; }
|
.name-card:first-of-type { border-top: 0; }
|
||||||
.score {
|
.score {
|
||||||
font-family: Fraunces, Georgia, serif;
|
font-family: Fraunces, Georgia, serif;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
font-weight: 700;
|
font-weight: 700; font-size: 1.2rem; color: var(--rank);
|
||||||
font-size: 1.35rem;
|
text-align: center; padding-top: 0.1rem;
|
||||||
color: var(--rank);
|
|
||||||
text-align: center;
|
|
||||||
line-height: 1.2;
|
|
||||||
padding-top: 0.15rem;
|
|
||||||
}
|
}
|
||||||
.spelling {
|
.spelling {
|
||||||
font-family: Fraunces, Georgia, serif;
|
font-family: Fraunces, Georgia, serif;
|
||||||
font-size: 1.35rem;
|
font-size: 1.25rem; margin: 0 0 0.1rem; letter-spacing: -0.02em;
|
||||||
margin: 0 0 0.15rem;
|
|
||||||
letter-spacing: -0.02em;
|
|
||||||
}
|
}
|
||||||
.meta { color: var(--muted); font-size: 0.88rem; margin: 0 0 0.35rem; }
|
.meta { color: var(--muted); font-size: 0.95rem; margin: 0 0 0.4rem; }
|
||||||
.langs { margin-top: 0.35rem; }
|
.say-row {
|
||||||
.langs summary {
|
display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap;
|
||||||
cursor: pointer;
|
margin-bottom: 0.25rem;
|
||||||
color: var(--accent);
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
list-style: none;
|
|
||||||
}
|
|
||||||
.langs summary::-webkit-details-marker { display: none; }
|
|
||||||
.langs summary::before { content: "▸ "; }
|
|
||||||
.langs[open] summary::before { content: "▾ "; }
|
|
||||||
.lang-row {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.25rem;
|
|
||||||
margin: 0.55rem 0;
|
|
||||||
padding: 0.55rem 0.65rem;
|
|
||||||
background: color-mix(in srgb, var(--sky) 35%, white);
|
|
||||||
border-radius: 12px;
|
|
||||||
}
|
|
||||||
.lang-top {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.45rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
.lang-tag {
|
|
||||||
font-size: 0.72rem;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--accent);
|
|
||||||
background: #fff;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 999px;
|
|
||||||
padding: 0.1rem 0.45rem;
|
|
||||||
}
|
}
|
||||||
.say {
|
.say {
|
||||||
font-family: Fraunces, Georgia, serif;
|
font-family: Fraunces, Georgia, serif;
|
||||||
font-size: 1rem;
|
font-size: 1.05rem; margin: 0; min-width: 3rem;
|
||||||
margin: 0;
|
|
||||||
flex: 1;
|
|
||||||
}
|
}
|
||||||
.lang-detail { margin: 0; color: var(--muted); font-size: 0.88rem; }
|
.pills { display: flex; gap: 0.25rem; }
|
||||||
.votes { display: flex; flex-direction: column; gap: 0.3rem; }
|
details.more { margin-top: 0.25rem; }
|
||||||
.error { color: var(--down); margin: 0.5rem 0 0; }
|
details.more summary {
|
||||||
|
cursor: pointer; color: var(--accent); font-weight: 600; font-size: 0.85rem;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
details.more summary::-webkit-details-marker { display: none; }
|
||||||
|
.note {
|
||||||
|
margin: 0.4rem 0 0; padding: 0.5rem 0.6rem;
|
||||||
|
background: color-mix(in srgb, var(--sky) 30%, white);
|
||||||
|
border-radius: 10px; color: var(--muted); font-size: 0.86rem;
|
||||||
|
}
|
||||||
|
.votes { display: flex; flex-direction: column; gap: 0.25rem; }
|
||||||
|
.error { color: var(--down); margin: 0.45rem 0 0; }
|
||||||
.hidden { display: none !important; }
|
.hidden { display: none !important; }
|
||||||
.empty {
|
.empty { color: var(--muted); padding: 0.55rem 0.1rem; font-size: 0.92rem; }
|
||||||
color: var(--muted);
|
|
||||||
padding: 0.75rem 0.2rem 0.35rem;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
}
|
|
||||||
.compose-head {
|
.compose-head {
|
||||||
display: flex;
|
display: flex; flex-wrap: wrap; gap: 0.65rem; align-items: end;
|
||||||
flex-wrap: wrap;
|
justify-content: space-between; margin-bottom: 0.75rem;
|
||||||
gap: 0.75rem;
|
|
||||||
align-items: end;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 0.85rem;
|
|
||||||
}
|
}
|
||||||
.compose-head h2 {
|
.compose-head h2 {
|
||||||
font-family: Fraunces, Georgia, serif;
|
font-family: Fraunces, Georgia, serif; margin: 0; font-size: 1.15rem;
|
||||||
margin: 0;
|
}
|
||||||
font-size: 1.25rem;
|
.kind-toggle { display: flex; gap: 0.3rem; }
|
||||||
|
.kind-toggle button {
|
||||||
|
min-width: 5.5rem;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ink);
|
||||||
|
border: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
.kind-toggle { display: flex; gap: 0.35rem; }
|
|
||||||
.kind-toggle button { min-width: 5.5rem; }
|
|
||||||
.kind-toggle button.active {
|
.kind-toggle button.active {
|
||||||
background: var(--ink);
|
background: var(--ink);
|
||||||
color: #fff;
|
color: var(--paper);
|
||||||
border-color: transparent;
|
border-color: var(--ink);
|
||||||
}
|
}
|
||||||
.grid-3 { display: grid; gap: 0.65rem; }
|
.kind-toggle button:not(.active):hover {
|
||||||
@media (min-width: 720px) {
|
border-color: var(--accent);
|
||||||
.grid-3 { grid-template-columns: repeat(3, 1fr); }
|
|
||||||
}
|
|
||||||
.hint { color: var(--muted); font-size: 0.85rem; margin: 0.35rem 0 0; }
|
|
||||||
.wing-mark {
|
|
||||||
width: 42px;
|
|
||||||
height: 42px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--ink);
|
|
||||||
color: #f4ebe4;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
font-family: Fraunces, Georgia, serif;
|
|
||||||
font-style: italic;
|
|
||||||
font-size: 1.2rem;
|
|
||||||
margin-bottom: 0.65rem;
|
|
||||||
}
|
}
|
||||||
|
.grid-3 { display: grid; gap: 0.55rem; }
|
||||||
|
@media (min-width: 720px) { .grid-3 { grid-template-columns: repeat(3, 1fr); } }
|
||||||
|
.hint { color: var(--muted); font-size: 0.95rem; margin: 0.25rem 0 0.55rem; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main>
|
<a class="skip" href="#main">Skip to content</a>
|
||||||
|
<main id="main">
|
||||||
|
<div class="theme-bar" role="group" aria-label="Color theme">
|
||||||
|
<span>Theme</span>
|
||||||
|
<button type="button" data-theme-toggle="light">Light</button>
|
||||||
|
<button type="button" data-theme-toggle="dark">Dark</button>
|
||||||
|
<button type="button" data-theme-toggle="system">System</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<header class="hero">
|
<header class="hero">
|
||||||
<div class="wing-mark" aria-hidden="true">ש</div>
|
<img src="/static/logo-mark.png" alt="Stork" width="56" height="56" />
|
||||||
<h1 class="brand">Stork <span>names</span></h1>
|
<div>
|
||||||
<p class="sub">Family board for first and middle names — vote, hear how they sound, and keep notes in English, Russian, and Hebrew.</p>
|
<h1 class="brand">Stork</h1>
|
||||||
|
<p class="sub">Classic layout · <a href="/">Paper board</a> · <a href="/logos">Logo ideas</a></p>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section id="gate" class="panel">
|
<section id="gate" class="panel">
|
||||||
<label for="invite">Invite code</label>
|
<label for="invite">Invite code</label>
|
||||||
<input id="invite" autocomplete="off" />
|
<input id="invite" autocomplete="off" />
|
||||||
<label for="display" style="margin-top:0.75rem">Your name (shown with your ideas and votes)</label>
|
<label for="display" style="margin-top:0.65rem">Your name</label>
|
||||||
<input id="display" autocomplete="nickname" />
|
<input id="display" autocomplete="nickname" />
|
||||||
<p class="error hidden" id="gate-error"></p>
|
<p class="error hidden" id="gate-error" role="alert" aria-live="polite"></p>
|
||||||
<div class="row" style="margin-top:0.75rem">
|
<div class="row" style="margin-top:0.65rem">
|
||||||
<button id="enter" type="button">Enter the board</button>
|
<button id="enter" type="button">Enter</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="board" class="hidden">
|
<section id="board" class="hidden">
|
||||||
<div class="who">
|
<div class="who">
|
||||||
<span id="who"></span>
|
<span id="who"></span>
|
||||||
<a href="#suggest" style="color:var(--accent);font-weight:600;text-decoration:none">Suggest a name ↓</a>
|
<a href="#suggest">Suggest a name</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="boards">
|
<div class="boards">
|
||||||
<section class="panel" aria-labelledby="first-heading">
|
<section class="panel" aria-labelledby="first-heading">
|
||||||
<div class="col-head">
|
<div class="col-head">
|
||||||
<h2 id="first-heading">First names</h2>
|
<h2 id="first-heading">First</h2>
|
||||||
<span class="count" id="first-count"></span>
|
<span class="count" id="first-count"></span>
|
||||||
</div>
|
</div>
|
||||||
<div id="list-first"></div>
|
<div id="list-first"></div>
|
||||||
</section>
|
</section>
|
||||||
<section class="panel" aria-labelledby="middle-heading">
|
<section class="panel" aria-labelledby="middle-heading">
|
||||||
<div class="col-head">
|
<div class="col-head">
|
||||||
<h2 id="middle-heading">Middle names</h2>
|
<h2 id="middle-heading">Middle</h2>
|
||||||
<span class="count" id="middle-count"></span>
|
<span class="count" id="middle-count"></span>
|
||||||
</div>
|
</div>
|
||||||
<div id="list-middle"></div>
|
<div id="list-middle"></div>
|
||||||
@@ -323,54 +276,52 @@
|
|||||||
<div class="compose-head">
|
<div class="compose-head">
|
||||||
<h2>Suggest a name</h2>
|
<h2>Suggest a name</h2>
|
||||||
<div class="kind-toggle" role="group" aria-label="Name type">
|
<div class="kind-toggle" role="group" aria-label="Name type">
|
||||||
<button type="button" class="secondary active" data-kind="first">First</button>
|
<button type="button" class="secondary active" data-kind="c1">First</button>
|
||||||
<button type="button" class="secondary" data-kind="middle">Middle</button>
|
<button type="button" class="secondary" data-kind="c2">Middle</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<label for="spelling">Spelling</label>
|
<label for="spelling">Spelling</label>
|
||||||
<input id="spelling" placeholder="How you want it written" />
|
<input id="spelling" required aria-required="true" autocomplete="off" />
|
||||||
<p class="hint">Optional notes below — pronunciation can be played aloud on the board.</p>
|
<p class="hint">Pronunciation fields: just the name in that language (e.g. Roze → שושנה).</p>
|
||||||
<div class="grid-3" style="margin-top:0.75rem">
|
<div class="grid-3">
|
||||||
<div>
|
<div>
|
||||||
<label>English pronunciation</label>
|
<label>English</label>
|
||||||
<input data-loc="en" data-field="pronunciation" placeholder="e.g. SHY" />
|
<input data-loc="en" data-field="pronunciation" placeholder="Name" />
|
||||||
<label style="margin-top:0.5rem">Origin</label>
|
<label style="margin-top:0.4rem">Origin</label>
|
||||||
<input data-loc="en" data-field="origin" />
|
<input data-loc="en" data-field="origin" />
|
||||||
<label style="margin-top:0.5rem">Meaning</label>
|
<label style="margin-top:0.4rem">Meaning</label>
|
||||||
<textarea data-loc="en" data-field="meaning"></textarea>
|
<textarea data-loc="en" data-field="meaning"></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label>Russian pronunciation</label>
|
<label>Russian</label>
|
||||||
<input data-loc="ru" data-field="pronunciation" placeholder="e.g. Шай" />
|
<input data-loc="ru" data-field="pronunciation" placeholder="Имя" />
|
||||||
<label style="margin-top:0.5rem">Origin</label>
|
<label style="margin-top:0.4rem">Origin</label>
|
||||||
<input data-loc="ru" data-field="origin" />
|
<input data-loc="ru" data-field="origin" />
|
||||||
<label style="margin-top:0.5rem">Meaning</label>
|
<label style="margin-top:0.4rem">Meaning</label>
|
||||||
<textarea data-loc="ru" data-field="meaning"></textarea>
|
<textarea data-loc="ru" data-field="meaning"></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label>Hebrew pronunciation</label>
|
<label>Hebrew</label>
|
||||||
<input data-loc="he" data-field="pronunciation" placeholder="e.g. שַׁי" dir="auto" />
|
<input data-loc="he" data-field="pronunciation" placeholder="שם" dir="auto" />
|
||||||
<label style="margin-top:0.5rem">Origin</label>
|
<label style="margin-top:0.4rem">Origin</label>
|
||||||
<input data-loc="he" data-field="origin" dir="auto" />
|
<input data-loc="he" data-field="origin" dir="auto" />
|
||||||
<label style="margin-top:0.5rem">Meaning</label>
|
<label style="margin-top:0.4rem">Meaning</label>
|
||||||
<textarea data-loc="he" data-field="meaning" dir="auto"></textarea>
|
<textarea data-loc="he" data-field="meaning" dir="auto"></textarea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="error hidden" id="add-error"></p>
|
<p class="error hidden" id="add-error" role="alert" aria-live="polite"></p>
|
||||||
<div class="row" style="margin-top:0.75rem">
|
<div class="row" style="margin-top:0.65rem">
|
||||||
<button id="add" type="button">Add to board</button>
|
<button id="add" type="button">Add</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
<script>
|
<script>
|
||||||
const state = { kind: "first", first: [], middle: [] };
|
const state = { kind: "c1", c1: [], c2: [], langById: {} };
|
||||||
const SPEECH_LANG = { en: "en-US", ru: "ru-RU", he: "he-IL" };
|
const SPEECH_LANG = { en: "en-US", ru: "ru-RU", he: "he-IL" };
|
||||||
let speakingBtn = null;
|
let speakingBtn = null;
|
||||||
|
|
||||||
function params() {
|
function params() { return new URLSearchParams(location.search); }
|
||||||
return new URLSearchParams(location.search);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function api(path, opts = {}) {
|
async function api(path, opts = {}) {
|
||||||
const res = await fetch(path, {
|
const res = await fetch(path, {
|
||||||
@@ -383,7 +334,7 @@
|
|||||||
try { data = text ? JSON.parse(text) : null; } catch { data = { detail: text }; }
|
try { data = text ? JSON.parse(text) : null; } catch { data = { detail: text }; }
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const detail = (data && (data.detail || data.message)) || res.statusText;
|
const detail = (data && (data.detail || data.message)) || res.statusText;
|
||||||
throw new Error(typeof detail === "string" ? detail : JSON.stringify(detail));
|
throw new Error(StorkUI.formatError(detail));
|
||||||
}
|
}
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
@@ -394,93 +345,72 @@
|
|||||||
})[c]);
|
})[c]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ttsText(pronunciation, spelling) {
|
|
||||||
const raw = (pronunciation || "").trim() || (spelling || "").trim();
|
|
||||||
if (!raw) return "";
|
|
||||||
// Prefer the lead cue before long parenthetical notes.
|
|
||||||
return raw.split(/[—(]/)[0].replace(/\s+/g, " ").trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopSpeech() {
|
function stopSpeech() {
|
||||||
if (window.speechSynthesis) window.speechSynthesis.cancel();
|
if (window.speechSynthesis) window.speechSynthesis.cancel();
|
||||||
if (speakingBtn) {
|
if (speakingBtn) { speakingBtn.classList.remove("playing"); speakingBtn = null; }
|
||||||
speakingBtn.classList.remove("playing");
|
|
||||||
speakingBtn = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function playPronunciation(lang, pronunciation, spelling, btn) {
|
function play(lang, text, btn) {
|
||||||
if (!window.speechSynthesis) {
|
if (!window.speechSynthesis) {
|
||||||
alert("This browser cannot play speech aloud. Try Chrome or Safari.");
|
alert("Speech not supported in this browser.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const text = ttsText(pronunciation, spelling);
|
const say = (text || "").trim();
|
||||||
if (!text) return;
|
if (!say) return;
|
||||||
stopSpeech();
|
stopSpeech();
|
||||||
const u = new SpeechSynthesisUtterance(text);
|
const u = new SpeechSynthesisUtterance(say);
|
||||||
u.lang = SPEECH_LANG[lang] || "en-US";
|
u.lang = SPEECH_LANG[lang] || "en-US";
|
||||||
u.rate = 0.9;
|
u.rate = 0.92;
|
||||||
const voices = speechSynthesis.getVoices();
|
const voices = speechSynthesis.getVoices();
|
||||||
const match = voices.find((v) => v.lang === u.lang)
|
const match = voices.find((v) => v.lang === u.lang)
|
||||||
|| voices.find((v) => v.lang && v.lang.startsWith(u.lang.slice(0, 2)));
|
|| voices.find((v) => v.lang && v.lang.startsWith(u.lang.slice(0, 2)));
|
||||||
if (match) u.voice = match;
|
if (match) u.voice = match;
|
||||||
speakingBtn = btn;
|
speakingBtn = btn;
|
||||||
btn.classList.add("playing");
|
btn.classList.add("playing");
|
||||||
u.onend = () => { btn.classList.remove("playing"); speakingBtn = null; };
|
u.onend = u.onerror = () => { btn.classList.remove("playing"); speakingBtn = null; };
|
||||||
u.onerror = () => { btn.classList.remove("playing"); speakingBtn = null; };
|
|
||||||
speechSynthesis.speak(u);
|
speechSynthesis.speak(u);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderLangs(n) {
|
function formFor(n, lang) {
|
||||||
const locales = n.locales || {};
|
const L = (n.locales && n.locales[lang]) || {};
|
||||||
const blocks = ["en", "ru", "he"].map((lang) => {
|
return (L.pronunciation || "").trim() || (lang === "en" ? n.spelling : "");
|
||||||
const L = locales[lang] || {};
|
|
||||||
const has = L.pronunciation || L.origin || L.meaning;
|
|
||||||
if (!has) return "";
|
|
||||||
const say = L.pronunciation || n.spelling;
|
|
||||||
const details = [
|
|
||||||
L.origin && `Origin: ${escapeHtml(L.origin)}`,
|
|
||||||
L.meaning && escapeHtml(L.meaning),
|
|
||||||
].filter(Boolean).join(" · ");
|
|
||||||
return `
|
|
||||||
<div class="lang-row">
|
|
||||||
<div class="lang-top">
|
|
||||||
<span class="lang-tag">${lang}</span>
|
|
||||||
<p class="say" dir="auto">${escapeHtml(say)}</p>
|
|
||||||
<button type="button" class="icon" data-play="${lang}"
|
|
||||||
data-pronunciation="${escapeHtml(L.pronunciation || "")}"
|
|
||||||
data-spelling="${escapeHtml(n.spelling)}"
|
|
||||||
aria-label="Play ${lang} pronunciation">▶</button>
|
|
||||||
</div>
|
|
||||||
${details ? `<p class="lang-detail" dir="auto">${details}</p>` : ""}
|
|
||||||
</div>`;
|
|
||||||
}).filter(Boolean).join("");
|
|
||||||
if (!blocks) return `<p class="meta">No notes yet</p>`;
|
|
||||||
return `<div class="langs"><details><summary>Pronunciation, origin & meaning</summary>${blocks}</details></div>`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderList(kind, items) {
|
function noteFor(n, lang) {
|
||||||
const root = document.getElementById(kind === "first" ? "list-first" : "list-middle");
|
const L = (n.locales && n.locales[lang]) || {};
|
||||||
const count = document.getElementById(kind === "first" ? "first-count" : "middle-count");
|
return [L.origin, L.meaning].filter(Boolean).join(" — ");
|
||||||
count.textContent = items.length ? `${items.length} on the board` : "empty";
|
|
||||||
if (!items.length) {
|
|
||||||
root.innerHTML = `<p class="empty">No ${kind} names yet — add one below.</p>`;
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
root.innerHTML = items.map((n) => `
|
|
||||||
|
function renderCard(n, kind) {
|
||||||
|
const lang = state.langById[n.id] || "en";
|
||||||
|
const form = formFor(n, lang);
|
||||||
|
const note = noteFor(n, lang);
|
||||||
|
const pills = ["en", "ru", "he"].map((l) =>
|
||||||
|
`<button type="button" class="pill ${lang === l ? "active" : ""}" data-lang="${l}">${l}</button>`
|
||||||
|
).join("");
|
||||||
|
return `
|
||||||
<article class="name-card" data-id="${n.id}" data-kind="${kind}">
|
<article class="name-card" data-id="${n.id}" data-kind="${kind}">
|
||||||
<div class="score" title="Score">${n.score}</div>
|
<div class="score">${n.score}</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="spelling" dir="auto">${escapeHtml(n.spelling)}</p>
|
<p class="spelling" dir="auto">${escapeHtml(n.spelling)}</p>
|
||||||
<p class="meta">${escapeHtml(n.created_by || "someone")} · ${n.vote_count} vote(s)</p>
|
<p class="meta">${escapeHtml(n.created_by || "someone")} · ${n.vote_count}</p>
|
||||||
${renderLangs(n)}
|
<div class="say-row">
|
||||||
|
<div class="pills">${pills}</div>
|
||||||
|
<p class="say" dir="auto">${escapeHtml(form || "—")}</p>
|
||||||
|
<button type="button" class="icon" data-play
|
||||||
|
data-lang="${lang}" data-text="${escapeHtml(form)}"
|
||||||
|
aria-label="Play">▶</button>
|
||||||
|
</div>
|
||||||
|
${note ? `<details class="more"><summary>Meaning</summary><p class="note" dir="auto">${escapeHtml(note)}</p></details>` : ""}
|
||||||
</div>
|
</div>
|
||||||
<div class="votes">
|
<div class="votes">
|
||||||
<button type="button" class="vote ${n.my_vote === 1 ? "active-up" : ""}" data-vote="1" aria-label="Upvote">▲</button>
|
<button type="button" class="vote ${n.my_vote === 1 ? "active-up" : ""}" data-vote="1" aria-label="Upvote">▲</button>
|
||||||
<button type="button" class="vote ${n.my_vote === -1 ? "active-down" : ""}" data-vote="-1" aria-label="Downvote">▼</button>
|
<button type="button" class="vote ${n.my_vote === -1 ? "active-down" : ""}" data-vote="-1" aria-label="Downvote">▼</button>
|
||||||
</div>
|
</div>
|
||||||
</article>`).join("");
|
</article>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindCard(root, items) {
|
||||||
root.querySelectorAll("button[data-vote]").forEach((btn) => {
|
root.querySelectorAll("button[data-vote]").forEach((btn) => {
|
||||||
btn.addEventListener("click", async () => {
|
btn.addEventListener("click", async () => {
|
||||||
const card = btn.closest(".name-card");
|
const card = btn.closest(".name-card");
|
||||||
@@ -492,20 +422,31 @@
|
|||||||
await refresh();
|
await refresh();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
root.querySelectorAll("button[data-lang]").forEach((btn) => {
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
const id = Number(btn.closest(".name-card").dataset.id);
|
||||||
|
state.langById[id] = btn.dataset.lang;
|
||||||
|
const kind = btn.closest(".name-card").dataset.kind;
|
||||||
|
renderList(kind, kind === "c1" ? state.c1 : state.c2);
|
||||||
|
});
|
||||||
|
});
|
||||||
root.querySelectorAll("button[data-play]").forEach((btn) => {
|
root.querySelectorAll("button[data-play]").forEach((btn) => {
|
||||||
btn.addEventListener("click", (e) => {
|
btn.addEventListener("click", () => play(btn.dataset.lang, btn.dataset.text, btn));
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
playPronunciation(
|
|
||||||
btn.dataset.play,
|
|
||||||
btn.dataset.pronunciation,
|
|
||||||
btn.dataset.spelling,
|
|
||||||
btn,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderList(kind, items) {
|
||||||
|
const root = document.getElementById(kind === "c1" ? "list-first" : "list-middle");
|
||||||
|
const count = document.getElementById(kind === "c1" ? "first-count" : "middle-count");
|
||||||
|
count.textContent = String(items.length);
|
||||||
|
if (!items.length) {
|
||||||
|
root.innerHTML = `<p class="empty">None yet</p>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
root.innerHTML = items.map((n) => renderCard(n, kind)).join("");
|
||||||
|
bindCard(root, items);
|
||||||
|
}
|
||||||
|
|
||||||
function localePayload() {
|
function localePayload() {
|
||||||
const locales = { en: {}, ru: {}, he: {} };
|
const locales = { en: {}, ru: {}, he: {} };
|
||||||
document.querySelectorAll("[data-loc][data-field]").forEach((el) => {
|
document.querySelectorAll("[data-loc][data-field]").forEach((el) => {
|
||||||
@@ -520,75 +461,87 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
const [first, middle] = await Promise.all([
|
const first = await api("/api/names?kind=c1");
|
||||||
api("/api/names?kind=first"),
|
state.c1 = first.items || [];
|
||||||
api("/api/names?kind=middle"),
|
state.c2 = [];
|
||||||
]);
|
renderList("c1", state.c1);
|
||||||
state.first = first.items || [];
|
const midRoot = document.getElementById("list-middle");
|
||||||
state.middle = middle.items || [];
|
const midCount = document.getElementById("middle-count");
|
||||||
renderList("first", state.first);
|
if (midRoot) midRoot.innerHTML = `<p class="empty">Use the <a href="/">paper board</a> for more columns.</p>`;
|
||||||
renderList("middle", state.middle);
|
if (midCount) midCount.textContent = "—";
|
||||||
}
|
}
|
||||||
|
|
||||||
async function showBoard(displayName) {
|
async function showBoard(displayName) {
|
||||||
document.getElementById("gate").classList.add("hidden");
|
document.getElementById("gate").classList.add("hidden");
|
||||||
document.getElementById("board").classList.remove("hidden");
|
document.getElementById("board").classList.remove("hidden");
|
||||||
document.getElementById("who").textContent = `Signed in as ${displayName}`;
|
document.getElementById("who").textContent = displayName;
|
||||||
// Chrome loads voices async
|
|
||||||
if (window.speechSynthesis) speechSynthesis.getVoices();
|
if (window.speechSynthesis) speechSynthesis.getVoices();
|
||||||
await refresh();
|
await refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
document.querySelectorAll(".kind-toggle button").forEach((btn) => {
|
document.querySelectorAll(".kind-toggle button").forEach((btn) => {
|
||||||
|
btn.setAttribute("aria-pressed", btn.classList.contains("active") ? "true" : "false");
|
||||||
btn.addEventListener("click", () => {
|
btn.addEventListener("click", () => {
|
||||||
state.kind = btn.dataset.kind;
|
state.kind = btn.dataset.kind;
|
||||||
document.querySelectorAll(".kind-toggle button").forEach((b) => b.classList.toggle("active", b === btn));
|
document.querySelectorAll(".kind-toggle button").forEach((b) => {
|
||||||
|
const on = b === btn;
|
||||||
|
b.classList.toggle("active", on);
|
||||||
|
b.setAttribute("aria-pressed", on ? "true" : "false");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById("enter").addEventListener("click", async () => {
|
document.getElementById("enter").addEventListener("click", async () => {
|
||||||
const err = document.getElementById("gate-error");
|
const err = document.getElementById("gate-error");
|
||||||
err.classList.add("hidden");
|
StorkUI.clearError(err);
|
||||||
try {
|
try {
|
||||||
const invite = document.getElementById("invite").value.trim();
|
|
||||||
const display_name = document.getElementById("display").value.trim();
|
|
||||||
const data = await api("/api/session", {
|
const data = await api("/api/session", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ invite, display_name }),
|
body: JSON.stringify({
|
||||||
|
invite: document.getElementById("invite").value.trim(),
|
||||||
|
display_name: document.getElementById("display").value.trim(),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
const url = new URL(location.href);
|
const url = new URL(location.href);
|
||||||
url.searchParams.delete("invite");
|
url.searchParams.delete("invite");
|
||||||
history.replaceState({}, "", url.pathname + (url.search || ""));
|
history.replaceState({}, "", url.pathname + (url.search || ""));
|
||||||
await showBoard(data.display_name);
|
await showBoard(data.display_name);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
err.textContent = e.message;
|
StorkUI.showError(err, e.message);
|
||||||
err.classList.remove("hidden");
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById("add").addEventListener("click", async () => {
|
document.getElementById("add").addEventListener("click", async () => {
|
||||||
const err = document.getElementById("add-error");
|
const err = document.getElementById("add-error");
|
||||||
err.classList.add("hidden");
|
const spellingEl = document.getElementById("spelling");
|
||||||
|
StorkUI.clearError(err);
|
||||||
|
spellingEl.removeAttribute("aria-invalid");
|
||||||
|
const spelling = spellingEl.value.trim();
|
||||||
|
if (!spelling) {
|
||||||
|
StorkUI.showError(err, "Enter a name spelling");
|
||||||
|
spellingEl.setAttribute("aria-invalid", "true");
|
||||||
|
spellingEl.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await api("/api/names", {
|
await api("/api/names", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
kind: state.kind,
|
kind: state.kind,
|
||||||
spelling: document.getElementById("spelling").value.trim(),
|
spelling,
|
||||||
locales: localePayload(),
|
locales: localePayload(),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
clearForm();
|
clearForm();
|
||||||
await refresh();
|
await refresh();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
err.textContent = e.message;
|
StorkUI.showError(err, e.message);
|
||||||
err.classList.remove("hidden");
|
spellingEl.setAttribute("aria-invalid", "true");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (window.speechSynthesis) {
|
StorkUI.initThemeControls();
|
||||||
speechSynthesis.onvoiceschanged = () => speechSynthesis.getVoices();
|
if (window.speechSynthesis) speechSynthesis.onvoiceschanged = () => speechSynthesis.getVoices();
|
||||||
}
|
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
const invite = params().get("invite");
|
const invite = params().get("invite");
|
||||||
@@ -596,7 +549,7 @@
|
|||||||
try {
|
try {
|
||||||
const session = await api("/api/session");
|
const session = await api("/api/session");
|
||||||
if (session.authenticated) await showBoard(session.display_name);
|
if (session.authenticated) await showBoard(session.display_name);
|
||||||
} catch (_) { /* stay on gate */ }
|
} catch (_) {}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
|
After Width: | Height: | Size: 266 KiB |
|
After Width: | Height: | Size: 14 KiB |
@@ -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>
|
||||||
|
After Width: | Height: | Size: 814 KiB |
|
After Width: | Height: | Size: 724 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 931 KiB |
|
After Width: | Height: | Size: 647 KiB |
@@ -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);
|
||||||
@@ -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) => ({
|
||||||
|
"&": "&", "<": "<", ">": ">", '"': """, "'": "'"
|
||||||
|
})[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>
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Stork HTTP API + invite-gated family UI."""
|
"""Stork HTTP API + invite-gated multi-board family UI."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -8,21 +8,37 @@ import secrets
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import Cookie, Depends, FastAPI, Header, HTTPException, Response
|
from fastapi import Cookie, Depends, FastAPI, File, Header, HTTPException, Response, UploadFile
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse, RedirectResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from stork.db import KINDS, LANGS, Store
|
from stork.db import LANGS, MAX_COLUMNS, Store
|
||||||
|
|
||||||
DATA_DIR = Path(os.environ.get("STORK_DATA", "./data"))
|
DATA_DIR = Path(os.environ.get("STORK_DATA", "./data"))
|
||||||
INVITE_TOKEN = os.environ.get("STORK_INVITE_TOKEN", "").strip()
|
INVITE_TOKEN = os.environ.get("STORK_INVITE_TOKEN", "").strip()
|
||||||
ADMIN_TOKEN = os.environ.get("STORK_ADMIN_TOKEN", "").strip()
|
ADMIN_TOKEN = os.environ.get("STORK_ADMIN_TOKEN", "").strip()
|
||||||
COOKIE_SECURE = os.environ.get("STORK_COOKIE_SECURE", "false").lower() in {"1", "true", "yes"}
|
COOKIE_SECURE = os.environ.get("STORK_COOKIE_SECURE", "false").lower() in {"1", "true", "yes"}
|
||||||
|
PUBLIC_BASE = os.environ.get("STORK_PUBLIC_URL", "https://stork.levkin.ca").rstrip("/")
|
||||||
STATIC = Path(__file__).resolve().parent.parent / "static"
|
STATIC = Path(__file__).resolve().parent.parent / "static"
|
||||||
|
|
||||||
|
ALLOWED_AUDIO = {
|
||||||
|
"audio/webm",
|
||||||
|
"audio/ogg",
|
||||||
|
"audio/mp4",
|
||||||
|
"audio/mpeg",
|
||||||
|
"audio/wav",
|
||||||
|
"audio/x-m4a",
|
||||||
|
"audio/aac",
|
||||||
|
"video/webm",
|
||||||
|
}
|
||||||
|
|
||||||
app = FastAPI(title="Stork", docs_url=None, redoc_url=None)
|
app = FastAPI(title="Stork", docs_url=None, redoc_url=None)
|
||||||
store = Store(DATA_DIR / "stork.sqlite3")
|
store = Store(
|
||||||
|
DATA_DIR / "stork.sqlite3",
|
||||||
|
bootstrap_invite=INVITE_TOKEN,
|
||||||
|
bootstrap_title="Family",
|
||||||
|
)
|
||||||
|
|
||||||
if STATIC.is_dir():
|
if STATIC.is_dir():
|
||||||
app.mount("/static", StaticFiles(directory=STATIC), name="static")
|
app.mount("/static", StaticFiles(directory=STATIC), name="static")
|
||||||
@@ -35,11 +51,15 @@ class LocaleIn(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class NameIn(BaseModel):
|
class NameIn(BaseModel):
|
||||||
kind: str = Field(pattern="^(first|middle)$")
|
kind: str = Field(pattern=r"^c[1-4]$")
|
||||||
spelling: str = Field(min_length=1, max_length=80)
|
spelling: str = Field(min_length=1, max_length=80)
|
||||||
locales: dict[str, LocaleIn] = Field(default_factory=dict)
|
locales: dict[str, LocaleIn] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class ColumnPatch(BaseModel):
|
||||||
|
label: str = Field(default="", max_length=40)
|
||||||
|
|
||||||
|
|
||||||
class LocalesPatch(BaseModel):
|
class LocalesPatch(BaseModel):
|
||||||
locales: dict[str, LocaleIn]
|
locales: dict[str, LocaleIn]
|
||||||
|
|
||||||
@@ -49,40 +69,22 @@ class VoteIn(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class SessionIn(BaseModel):
|
class SessionIn(BaseModel):
|
||||||
invite: str = Field(min_length=1, max_length=200)
|
|
||||||
display_name: str = Field(min_length=1, max_length=80)
|
display_name: str = Field(min_length=1, max_length=80)
|
||||||
|
board_id: str | None = Field(default=None, max_length=64)
|
||||||
|
invite: str | None = Field(default=None, max_length=200)
|
||||||
|
|
||||||
|
|
||||||
def _require_invite_configured() -> None:
|
class BoardCreatePublic(BaseModel):
|
||||||
if not INVITE_TOKEN:
|
title: str = Field(default="Name board", min_length=1, max_length=80)
|
||||||
raise HTTPException(503, "STORK_INVITE_TOKEN not configured")
|
|
||||||
|
|
||||||
|
|
||||||
def _invite_ok(invite: str | None) -> bool:
|
class BoardCreate(BaseModel):
|
||||||
_require_invite_configured()
|
title: str = Field(min_length=1, max_length=80)
|
||||||
return bool(invite) and secrets.compare_digest(invite, INVITE_TOKEN)
|
invite: str | None = Field(default=None, max_length=200)
|
||||||
|
|
||||||
|
|
||||||
def _session(
|
class BoardPatch(BaseModel):
|
||||||
stork_invite: str | None = Cookie(default=None),
|
title: str = Field(min_length=1, max_length=80)
|
||||||
stork_voter: str | None = Cookie(default=None),
|
|
||||||
stork_name: str | None = Cookie(default=None),
|
|
||||||
x_stork_invite: str | None = Header(default=None),
|
|
||||||
x_stork_display_name: str | None = Header(default=None),
|
|
||||||
x_stork_voter: str | None = Header(default=None),
|
|
||||||
) -> dict[str, str]:
|
|
||||||
invite = x_stork_invite or stork_invite
|
|
||||||
if not _invite_ok(invite):
|
|
||||||
raise HTTPException(401, "Invite required")
|
|
||||||
voter = (x_stork_voter or stork_voter or "").strip()
|
|
||||||
name = (x_stork_display_name or stork_name or "").strip()
|
|
||||||
if name and not voter:
|
|
||||||
# API/script clients without cookies (e.g. seed over HTTP while Secure cookies are on).
|
|
||||||
digest = hashlib.sha256(name.lower().encode()).hexdigest()[:24]
|
|
||||||
voter = f"hdr-{digest}"
|
|
||||||
if not voter or not name:
|
|
||||||
raise HTTPException(401, "Session required — open the invite link and enter your name")
|
|
||||||
return {"voter_key": voter, "display_name": name}
|
|
||||||
|
|
||||||
|
|
||||||
def _admin(
|
def _admin(
|
||||||
@@ -94,6 +96,66 @@ def _admin(
|
|||||||
raise HTTPException(403, "Admin token required")
|
raise HTTPException(403, "Admin token required")
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_board(
|
||||||
|
board_id: str | None = None,
|
||||||
|
invite: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
board_id = (board_id or "").strip()
|
||||||
|
invite = (invite or "").strip()
|
||||||
|
if board_id:
|
||||||
|
board = store.find_board(board_id)
|
||||||
|
if board:
|
||||||
|
return board
|
||||||
|
if invite:
|
||||||
|
board = store.find_board_by_invite(invite)
|
||||||
|
if board:
|
||||||
|
return board
|
||||||
|
raise HTTPException(404, "Board not found")
|
||||||
|
|
||||||
|
|
||||||
|
def _session(
|
||||||
|
stork_board: str | None = Cookie(default=None),
|
||||||
|
stork_invite: str | None = Cookie(default=None),
|
||||||
|
stork_voter: str | None = Cookie(default=None),
|
||||||
|
stork_name: str | None = Cookie(default=None),
|
||||||
|
x_stork_board: str | None = Header(default=None),
|
||||||
|
x_stork_invite: str | None = Header(default=None),
|
||||||
|
x_stork_display_name: str | None = Header(default=None),
|
||||||
|
x_stork_voter: str | None = Header(default=None),
|
||||||
|
) -> dict[str, str]:
|
||||||
|
board = _resolve_board(
|
||||||
|
board_id=x_stork_board or stork_board,
|
||||||
|
invite=x_stork_invite or stork_invite,
|
||||||
|
)
|
||||||
|
voter = (x_stork_voter or stork_voter or "").strip()
|
||||||
|
name = (x_stork_display_name or stork_name or "").strip()
|
||||||
|
if name and not voter:
|
||||||
|
digest = hashlib.sha256(name.lower().encode()).hexdigest()[:24]
|
||||||
|
voter = f"hdr-{digest}"
|
||||||
|
if not voter or not name:
|
||||||
|
raise HTTPException(401, "Session required — enter your name on this board")
|
||||||
|
return {
|
||||||
|
"voter_key": voter,
|
||||||
|
"display_name": name,
|
||||||
|
"board_id": board["id"],
|
||||||
|
"board_title": board["title"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _board_url(board_id: str) -> str:
|
||||||
|
return f"{PUBLIC_BASE}/b/{board_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def _cookie_kwargs() -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"httponly": True,
|
||||||
|
"samesite": "lax",
|
||||||
|
"secure": COOKIE_SECURE,
|
||||||
|
"max_age": 60 * 60 * 24 * 400,
|
||||||
|
"path": "/",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
def health() -> dict[str, Any]:
|
def health() -> dict[str, Any]:
|
||||||
return {"ok": True, "service": "stork"}
|
return {"ok": True, "service": "stork"}
|
||||||
@@ -103,51 +165,133 @@ def health() -> dict[str, Any]:
|
|||||||
def meta() -> dict[str, Any]:
|
def meta() -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"langs": list(LANGS),
|
"langs": list(LANGS),
|
||||||
"kinds": list(KINDS),
|
"max_columns": MAX_COLUMNS,
|
||||||
"invite_required": True,
|
"multi_board": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/boards")
|
||||||
|
def create_board_public(body: BoardCreatePublic) -> dict[str, Any]:
|
||||||
|
"""Start a new board — no password. Share the returned URL with family."""
|
||||||
|
board = store.create_board(body.title.strip() or "Name board")
|
||||||
|
return {
|
||||||
|
"id": board["id"],
|
||||||
|
"title": board["title"],
|
||||||
|
"url": _board_url(board["id"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/boards/{board_id}")
|
||||||
|
def get_board_public(board_id: str) -> dict[str, Any]:
|
||||||
|
board = store.find_board(board_id)
|
||||||
|
if not board:
|
||||||
|
raise HTTPException(404, "Board not found")
|
||||||
|
return {"id": board["id"], "title": board["title"], "url": _board_url(board["id"])}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/resolve")
|
||||||
|
def resolve_invite(invite: str = "") -> dict[str, Any]:
|
||||||
|
"""Map a legacy invite token to a board URL."""
|
||||||
|
board = store.find_board_by_invite(invite)
|
||||||
|
if not board:
|
||||||
|
raise HTTPException(404, "Board not found")
|
||||||
|
return {"id": board["id"], "title": board["title"], "url": _board_url(board["id"])}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/session")
|
@app.post("/api/session")
|
||||||
def create_session(body: SessionIn, response: Response) -> dict[str, Any]:
|
def create_session(body: SessionIn, response: Response) -> dict[str, Any]:
|
||||||
if not _invite_ok(body.invite.strip()):
|
board = _resolve_board(board_id=body.board_id, invite=body.invite)
|
||||||
raise HTTPException(403, "Invalid invite")
|
|
||||||
voter = secrets.token_urlsafe(16)
|
voter = secrets.token_urlsafe(16)
|
||||||
display = body.display_name.strip()
|
display = body.display_name.strip()
|
||||||
cookie_kwargs: dict[str, Any] = {
|
kwargs = _cookie_kwargs()
|
||||||
"httponly": True,
|
response.set_cookie("stork_board", board["id"], **kwargs)
|
||||||
"samesite": "lax",
|
response.set_cookie("stork_voter", voter, **kwargs)
|
||||||
"secure": COOKIE_SECURE,
|
response.set_cookie("stork_name", display, **{**kwargs, "httponly": False})
|
||||||
"max_age": 60 * 60 * 24 * 400,
|
response.delete_cookie("stork_invite", path="/")
|
||||||
"path": "/",
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"display_name": display,
|
||||||
|
"board": {"id": board["id"], "title": board["title"], "url": _board_url(board["id"])},
|
||||||
}
|
}
|
||||||
response.set_cookie("stork_invite", INVITE_TOKEN, **cookie_kwargs)
|
|
||||||
response.set_cookie("stork_voter", voter, **cookie_kwargs)
|
|
||||||
response.set_cookie("stork_name", display, **{**cookie_kwargs, "httponly": False})
|
|
||||||
return {"ok": True, "display_name": display}
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/session")
|
@app.get("/api/session")
|
||||||
def get_session(
|
def get_session(
|
||||||
|
stork_board: str | None = Cookie(default=None),
|
||||||
stork_invite: str | None = Cookie(default=None),
|
stork_invite: str | None = Cookie(default=None),
|
||||||
stork_voter: str | None = Cookie(default=None),
|
stork_voter: str | None = Cookie(default=None),
|
||||||
stork_name: str | None = Cookie(default=None),
|
stork_name: str | None = Cookie(default=None),
|
||||||
|
x_stork_board: str | None = Header(default=None),
|
||||||
x_stork_invite: str | None = Header(default=None),
|
x_stork_invite: str | None = Header(default=None),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
invite = x_stork_invite or stork_invite
|
board = None
|
||||||
authed = _invite_ok(invite) if INVITE_TOKEN else False
|
try:
|
||||||
|
board = _resolve_board(
|
||||||
|
board_id=x_stork_board or stork_board,
|
||||||
|
invite=x_stork_invite or stork_invite,
|
||||||
|
)
|
||||||
|
except HTTPException:
|
||||||
|
board = None
|
||||||
|
authed = bool(board) and bool(stork_voter) and bool(stork_name)
|
||||||
return {
|
return {
|
||||||
"authenticated": authed and bool(stork_voter) and bool(stork_name),
|
"authenticated": authed,
|
||||||
"display_name": stork_name or "",
|
"display_name": stork_name or "",
|
||||||
"has_invite": authed,
|
"board": (
|
||||||
|
{"id": board["id"], "title": board["title"], "url": _board_url(board["id"])}
|
||||||
|
if board
|
||||||
|
else None
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/columns")
|
||||||
|
def list_columns(session: dict[str, str] = Depends(_session)) -> dict[str, Any]:
|
||||||
|
return {"items": store.list_columns(session["board_id"]), "max": MAX_COLUMNS}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/columns")
|
||||||
|
def add_column(session: dict[str, str] = Depends(_session)) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
return store.add_column(session["board_id"], "")
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(400, str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@app.patch("/api/columns/{column_id}")
|
||||||
|
def rename_column(
|
||||||
|
column_id: str,
|
||||||
|
body: ColumnPatch,
|
||||||
|
session: dict[str, str] = Depends(_session),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
updated = store.rename_column(session["board_id"], column_id, body.label)
|
||||||
|
if not updated:
|
||||||
|
raise HTTPException(404, "Column not found")
|
||||||
|
return updated
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/columns/{column_id}")
|
||||||
|
def delete_column(
|
||||||
|
column_id: str,
|
||||||
|
session: dict[str, str] = Depends(_session),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
if not store.delete_column(session["board_id"], column_id):
|
||||||
|
raise HTTPException(404, "Column not found")
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(400, str(exc)) from exc
|
||||||
|
return {"ok": True, "items": store.list_columns(session["board_id"])}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/names")
|
@app.get("/api/names")
|
||||||
def list_names(kind: str = "first", session: dict[str, str] = Depends(_session)) -> dict[str, Any]:
|
def list_names(kind: str = "c1", session: dict[str, str] = Depends(_session)) -> dict[str, Any]:
|
||||||
if kind not in KINDS:
|
try:
|
||||||
raise HTTPException(400, "kind must be first or middle")
|
return {
|
||||||
return {"items": store.list_names(kind, voter_key=session["voter_key"])}
|
"items": store.list_names(
|
||||||
|
session["board_id"], kind, voter_key=session["voter_key"]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(400, str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/names")
|
@app.post("/api/names")
|
||||||
@@ -155,6 +299,7 @@ def add_name(body: NameIn, session: dict[str, str] = Depends(_session)) -> dict[
|
|||||||
locales = {k: v.model_dump() for k, v in body.locales.items()}
|
locales = {k: v.model_dump() for k, v in body.locales.items()}
|
||||||
try:
|
try:
|
||||||
return store.add_name(
|
return store.add_name(
|
||||||
|
board_id=session["board_id"],
|
||||||
kind=body.kind,
|
kind=body.kind,
|
||||||
spelling=body.spelling,
|
spelling=body.spelling,
|
||||||
created_by=session["display_name"],
|
created_by=session["display_name"],
|
||||||
@@ -170,9 +315,8 @@ def patch_name(
|
|||||||
body: LocalesPatch,
|
body: LocalesPatch,
|
||||||
session: dict[str, str] = Depends(_session),
|
session: dict[str, str] = Depends(_session),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
_ = session
|
|
||||||
locales = {k: v.model_dump() for k, v in body.locales.items()}
|
locales = {k: v.model_dump() for k, v in body.locales.items()}
|
||||||
updated = store.update_locales(name_id, locales)
|
updated = store.update_locales(name_id, locales, board_id=session["board_id"])
|
||||||
if not updated:
|
if not updated:
|
||||||
raise HTTPException(404, "Name not found")
|
raise HTTPException(404, "Name not found")
|
||||||
return updated
|
return updated
|
||||||
@@ -190,6 +334,7 @@ def vote_name(
|
|||||||
voter_key=session["voter_key"],
|
voter_key=session["voter_key"],
|
||||||
voter_name=session["display_name"],
|
voter_name=session["display_name"],
|
||||||
value=body.value,
|
value=body.value,
|
||||||
|
board_id=session["board_id"],
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(400, str(exc)) from exc
|
raise HTTPException(400, str(exc)) from exc
|
||||||
@@ -198,6 +343,75 @@ def vote_name(
|
|||||||
return updated
|
return updated
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/names/{name_id}/recording/{lang}")
|
||||||
|
async def upload_recording(
|
||||||
|
name_id: int,
|
||||||
|
lang: str,
|
||||||
|
session: dict[str, str] = Depends(_session),
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if lang not in LANGS:
|
||||||
|
raise HTTPException(400, "lang must be en, ru, or he")
|
||||||
|
raw = await file.read()
|
||||||
|
content_type = (file.content_type or "audio/webm").split(";")[0].strip().lower()
|
||||||
|
if content_type not in ALLOWED_AUDIO:
|
||||||
|
raise HTTPException(400, f"unsupported audio type: {content_type}")
|
||||||
|
ext = "webm"
|
||||||
|
if "ogg" in content_type:
|
||||||
|
ext = "ogg"
|
||||||
|
elif "mp4" in content_type or "m4a" in content_type or "aac" in content_type:
|
||||||
|
ext = "m4a"
|
||||||
|
elif "mpeg" in content_type or content_type == "audio/mp3":
|
||||||
|
ext = "mp3"
|
||||||
|
elif "wav" in content_type:
|
||||||
|
ext = "wav"
|
||||||
|
try:
|
||||||
|
updated = store.save_recording(
|
||||||
|
name_id=name_id,
|
||||||
|
lang=lang,
|
||||||
|
data=raw,
|
||||||
|
content_type=content_type if content_type != "video/webm" else "audio/webm",
|
||||||
|
recorded_by=session["display_name"],
|
||||||
|
ext=ext,
|
||||||
|
board_id=session["board_id"],
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(400, str(exc)) from exc
|
||||||
|
if not updated:
|
||||||
|
raise HTTPException(404, "Name not found")
|
||||||
|
return updated
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/names/{name_id}/recording/{lang}")
|
||||||
|
def get_recording(
|
||||||
|
name_id: int,
|
||||||
|
lang: str,
|
||||||
|
session: dict[str, str] = Depends(_session),
|
||||||
|
) -> FileResponse:
|
||||||
|
if lang not in LANGS:
|
||||||
|
raise HTTPException(400, "lang must be en, ru, or he")
|
||||||
|
found = store.get_recording(name_id, lang, board_id=session["board_id"])
|
||||||
|
if not found:
|
||||||
|
raise HTTPException(404, "Recording not found")
|
||||||
|
path, content_type = found
|
||||||
|
return FileResponse(path, media_type=content_type, filename=path.name)
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/names/{name_id}/recording/{lang}")
|
||||||
|
def delete_recording(
|
||||||
|
name_id: int,
|
||||||
|
lang: str,
|
||||||
|
session: dict[str, str] = Depends(_session),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
updated = store.delete_recording(name_id, lang, board_id=session["board_id"])
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(400, str(exc)) from exc
|
||||||
|
if not updated:
|
||||||
|
raise HTTPException(404, "Name not found")
|
||||||
|
return updated
|
||||||
|
|
||||||
|
|
||||||
@app.delete("/api/names/{name_id}")
|
@app.delete("/api/names/{name_id}")
|
||||||
def delete_name(name_id: int, _: None = Depends(_admin)) -> dict[str, Any]:
|
def delete_name(name_id: int, _: None = Depends(_admin)) -> dict[str, Any]:
|
||||||
if not store.delete_name(name_id):
|
if not store.delete_name(name_id):
|
||||||
@@ -205,9 +419,100 @@ def delete_name(name_id: int, _: None = Depends(_admin)) -> dict[str, Any]:
|
|||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/admin/boards")
|
||||||
|
def admin_list_boards(_: None = Depends(_admin)) -> dict[str, Any]:
|
||||||
|
items = []
|
||||||
|
for b in store.list_boards(include_invite=True):
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"id": b["id"],
|
||||||
|
"title": b["title"],
|
||||||
|
"created_at": b["created_at"],
|
||||||
|
"url": _board_url(b["id"]),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {"items": items}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/admin/boards")
|
||||||
|
def admin_create_board(body: BoardCreate, _: None = Depends(_admin)) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
board = store.create_board(body.title, body.invite)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(400, str(exc)) from exc
|
||||||
|
return {
|
||||||
|
"id": board["id"],
|
||||||
|
"title": board["title"],
|
||||||
|
"created_at": board["created_at"],
|
||||||
|
"url": _board_url(board["id"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.patch("/api/admin/boards/{board_id}")
|
||||||
|
def admin_rename_board(
|
||||||
|
board_id: str,
|
||||||
|
body: BoardPatch,
|
||||||
|
_: None = Depends(_admin),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
updated = store.rename_board(board_id, body.title)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(400, str(exc)) from exc
|
||||||
|
if not updated:
|
||||||
|
raise HTTPException(404, "Board not found")
|
||||||
|
return {
|
||||||
|
"id": updated["id"],
|
||||||
|
"title": updated["title"],
|
||||||
|
"created_at": updated["created_at"],
|
||||||
|
"url": _board_url(updated["id"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/admin/boards/{board_id}")
|
||||||
|
def admin_delete_board(board_id: str, _: None = Depends(_admin)) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
if not store.delete_board(board_id):
|
||||||
|
raise HTTPException(404, "Board not found")
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(400, str(exc)) from exc
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
def _ui_page() -> FileResponse:
|
||||||
|
page = STATIC / "v2.html"
|
||||||
|
if not page.is_file():
|
||||||
|
raise HTTPException(404, "UI missing")
|
||||||
|
return FileResponse(page)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
def home() -> FileResponse:
|
def home() -> FileResponse:
|
||||||
|
return _ui_page()
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/b/{board_id}")
|
||||||
|
def board_page(board_id: str) -> FileResponse:
|
||||||
|
if not store.find_board(board_id):
|
||||||
|
raise HTTPException(404, "Board not found")
|
||||||
|
return _ui_page()
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/v1")
|
||||||
|
def v1() -> FileResponse:
|
||||||
index = STATIC / "index.html"
|
index = STATIC / "index.html"
|
||||||
if not index.is_file():
|
if not index.is_file():
|
||||||
raise HTTPException(404, "UI missing")
|
raise HTTPException(404, "Classic UI missing")
|
||||||
return FileResponse(index)
|
return FileResponse(index)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/v2")
|
||||||
|
def v2() -> RedirectResponse:
|
||||||
|
return RedirectResponse(url="/", status_code=302)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/logos")
|
||||||
|
def logos() -> FileResponse:
|
||||||
|
page = STATIC / "logos.html"
|
||||||
|
if not page.is_file():
|
||||||
|
raise HTTPException(404, "Logo gallery missing")
|
||||||
|
return FileResponse(page)
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
"""SQLite persistence for names, locales, and votes."""
|
"""SQLite persistence: boards, columns, names, locales, votes, recordings."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import secrets
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
@@ -10,16 +11,41 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
LANGS = ("en", "ru", "he")
|
LANGS = ("en", "ru", "he")
|
||||||
KINDS = ("first", "middle")
|
MAX_COLUMNS = 4
|
||||||
|
LEGACY_KIND_ORDER = ("first", "middle", "hebrew", "russian")
|
||||||
|
LEGACY_LABELS = {
|
||||||
|
"first": "First",
|
||||||
|
"middle": "Middle",
|
||||||
|
"hebrew": "Hebrew",
|
||||||
|
"russian": "Russian",
|
||||||
|
}
|
||||||
|
|
||||||
_SCHEMA = """
|
_SCHEMA = """
|
||||||
|
CREATE TABLE IF NOT EXISTS boards (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
invite_token TEXT NOT NULL UNIQUE,
|
||||||
|
created_at REAL NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS board_columns (
|
||||||
|
board_id TEXT NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
|
||||||
|
id TEXT NOT NULL,
|
||||||
|
label TEXT NOT NULL DEFAULT '',
|
||||||
|
position INTEGER NOT NULL,
|
||||||
|
created_at REAL NOT NULL,
|
||||||
|
PRIMARY KEY (board_id, id),
|
||||||
|
UNIQUE (board_id, position)
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS names (
|
CREATE TABLE IF NOT EXISTS names (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
kind TEXT NOT NULL CHECK (kind IN ('first', 'middle')),
|
board_id TEXT NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
spelling TEXT NOT NULL,
|
spelling TEXT NOT NULL,
|
||||||
created_by TEXT NOT NULL DEFAULT '',
|
created_by TEXT NOT NULL DEFAULT '',
|
||||||
created_at REAL NOT NULL,
|
created_at REAL NOT NULL,
|
||||||
UNIQUE (kind, spelling COLLATE NOCASE)
|
UNIQUE (board_id, kind, spelling COLLATE NOCASE)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS locales (
|
CREATE TABLE IF NOT EXISTS locales (
|
||||||
@@ -40,20 +66,45 @@ CREATE TABLE IF NOT EXISTS votes (
|
|||||||
PRIMARY KEY (name_id, voter_key)
|
PRIMARY KEY (name_id, voter_key)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_names_kind ON names(kind);
|
CREATE TABLE IF NOT EXISTS recordings (
|
||||||
|
name_id INTEGER NOT NULL REFERENCES names(id) ON DELETE CASCADE,
|
||||||
|
lang TEXT NOT NULL CHECK (lang IN ('en', 'ru', 'he')),
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
content_type TEXT NOT NULL DEFAULT 'audio/webm',
|
||||||
|
recorded_by TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at REAL NOT NULL,
|
||||||
|
PRIMARY KEY (name_id, lang)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_names_board_kind ON names(board_id, kind);
|
||||||
CREATE INDEX IF NOT EXISTS idx_votes_name ON votes(name_id);
|
CREATE INDEX IF NOT EXISTS idx_votes_name ON votes(name_id);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
class Store:
|
class Store:
|
||||||
def __init__(self, path: Path) -> None:
|
def __init__(self, path: Path, bootstrap_invite: str = "", bootstrap_title: str = "Family") -> None:
|
||||||
self.path = path
|
self.path = path
|
||||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.audio_dir = self.path.parent / "recordings"
|
||||||
|
self.audio_dir.mkdir(parents=True, exist_ok=True)
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
self._conn = sqlite3.connect(str(self.path), check_same_thread=False)
|
self._conn = sqlite3.connect(str(self.path), check_same_thread=False)
|
||||||
self._conn.row_factory = sqlite3.Row
|
self._conn.row_factory = sqlite3.Row
|
||||||
self._conn.execute("PRAGMA foreign_keys = ON")
|
self._conn.execute("PRAGMA foreign_keys = ON")
|
||||||
with self._lock:
|
with self._lock:
|
||||||
|
# Boards table first so invite bootstrap / attach can run before indexes.
|
||||||
|
self._conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS boards (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
invite_token TEXT NOT NULL UNIQUE,
|
||||||
|
created_at REAL NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
self._migrate_legacy_pre_boards()
|
||||||
|
self._migrate_attach_boards(bootstrap_invite.strip(), bootstrap_title.strip() or "Family")
|
||||||
self._conn.executescript(_SCHEMA)
|
self._conn.executescript(_SCHEMA)
|
||||||
self._conn.commit()
|
self._conn.commit()
|
||||||
|
|
||||||
@@ -61,9 +112,427 @@ class Store:
|
|||||||
with self._lock:
|
with self._lock:
|
||||||
self._conn.close()
|
self._conn.close()
|
||||||
|
|
||||||
|
def _table_sql(self, name: str) -> str:
|
||||||
|
row = self._conn.execute(
|
||||||
|
"SELECT sql FROM sqlite_master WHERE type='table' AND name=?",
|
||||||
|
(name,),
|
||||||
|
).fetchone()
|
||||||
|
return (row["sql"] or "") if row else ""
|
||||||
|
|
||||||
|
def _table_exists(self, name: str) -> bool:
|
||||||
|
row = self._conn.execute(
|
||||||
|
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?",
|
||||||
|
(name,),
|
||||||
|
).fetchone()
|
||||||
|
return row is not None
|
||||||
|
|
||||||
|
def _migrate_legacy_pre_boards(self) -> None:
|
||||||
|
"""Normalize pre-board schemas so the boards migration can attach cleanly."""
|
||||||
|
if not self._table_exists("names"):
|
||||||
|
return
|
||||||
|
sql = self._table_sql("names")
|
||||||
|
if "board_id" in sql:
|
||||||
|
return
|
||||||
|
# Drop kind CHECK if present
|
||||||
|
if "CHECK" in sql.upper():
|
||||||
|
self._conn.executescript(
|
||||||
|
"""
|
||||||
|
CREATE TABLE names_legacy (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
spelling TEXT NOT NULL,
|
||||||
|
created_by TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at REAL NOT NULL,
|
||||||
|
UNIQUE (kind, spelling COLLATE NOCASE)
|
||||||
|
);
|
||||||
|
INSERT INTO names_legacy (id, kind, spelling, created_by, created_at)
|
||||||
|
SELECT id, kind, spelling, created_by, created_at FROM names;
|
||||||
|
DROP TABLE names;
|
||||||
|
ALTER TABLE names_legacy RENAME TO names;
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
# Ensure board_columns exists in old shape for later rewrite
|
||||||
|
if not self._table_exists("board_columns"):
|
||||||
|
self._conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE board_columns (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
label TEXT NOT NULL DEFAULT '',
|
||||||
|
position INTEGER NOT NULL UNIQUE,
|
||||||
|
created_at REAL NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
used = [
|
||||||
|
r["kind"]
|
||||||
|
for r in self._conn.execute("SELECT DISTINCT kind FROM names").fetchall()
|
||||||
|
]
|
||||||
|
ordered: list[str] = []
|
||||||
|
for legacy in LEGACY_KIND_ORDER:
|
||||||
|
if legacy in used:
|
||||||
|
ordered.append(legacy)
|
||||||
|
for kind in used:
|
||||||
|
if kind not in ordered:
|
||||||
|
ordered.append(kind)
|
||||||
|
if not ordered:
|
||||||
|
ordered = ["c1"]
|
||||||
|
for i, old in enumerate(ordered[:MAX_COLUMNS]):
|
||||||
|
new_id = old if old.startswith("c") and old[1:].isdigit() else f"c{i + 1}"
|
||||||
|
label = LEGACY_LABELS.get(old, "First" if i == 0 else "")
|
||||||
|
self._conn.execute(
|
||||||
|
"INSERT INTO board_columns (id, label, position, created_at) VALUES (?, ?, ?, ?)",
|
||||||
|
(new_id, label, i, time.time()),
|
||||||
|
)
|
||||||
|
if old != new_id:
|
||||||
|
self._conn.execute("UPDATE names SET kind = ? WHERE kind = ?", (new_id, old))
|
||||||
|
|
||||||
|
def _migrate_attach_boards(self, bootstrap_invite: str, bootstrap_title: str) -> None:
|
||||||
|
names_sql = self._table_sql("names")
|
||||||
|
cols_sql = self._table_sql("board_columns")
|
||||||
|
needs_names = self._table_exists("names") and "board_id" not in names_sql
|
||||||
|
needs_cols = self._table_exists("board_columns") and "board_id" not in cols_sql
|
||||||
|
|
||||||
|
if not needs_names and not needs_cols:
|
||||||
|
# Ensure board_columns exists before seeding a first column.
|
||||||
|
self._conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS board_columns (
|
||||||
|
board_id TEXT NOT NULL,
|
||||||
|
id TEXT NOT NULL,
|
||||||
|
label TEXT NOT NULL DEFAULT '',
|
||||||
|
position INTEGER NOT NULL,
|
||||||
|
created_at REAL NOT NULL,
|
||||||
|
PRIMARY KEY (board_id, id),
|
||||||
|
UNIQUE (board_id, position)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
# Ensure at least one board when invite configured
|
||||||
|
if bootstrap_invite and not self._conn.execute("SELECT 1 FROM boards LIMIT 1").fetchone():
|
||||||
|
self._create_board_unlocked(bootstrap_title, bootstrap_invite)
|
||||||
|
board_id = self._conn.execute(
|
||||||
|
"SELECT id FROM boards WHERE invite_token = ?", (bootstrap_invite,)
|
||||||
|
).fetchone()["id"]
|
||||||
|
self._conn.execute(
|
||||||
|
"INSERT INTO board_columns (board_id, id, label, position, created_at) VALUES (?, 'c1', 'First', 0, ?)",
|
||||||
|
(board_id, time.time()),
|
||||||
|
)
|
||||||
|
elif bootstrap_invite:
|
||||||
|
# Keep env invite mapped to a board (create if missing)
|
||||||
|
found = self._conn.execute(
|
||||||
|
"SELECT id FROM boards WHERE invite_token = ?", (bootstrap_invite,)
|
||||||
|
).fetchone()
|
||||||
|
if not found:
|
||||||
|
# Prefer updating the oldest board's invite only if unique
|
||||||
|
oldest = self._conn.execute(
|
||||||
|
"SELECT id FROM boards ORDER BY created_at ASC LIMIT 1"
|
||||||
|
).fetchone()
|
||||||
|
if oldest and self._conn.execute("SELECT COUNT(*) AS n FROM boards").fetchone()["n"] == 1:
|
||||||
|
self._conn.execute(
|
||||||
|
"UPDATE boards SET invite_token = ? WHERE id = ?",
|
||||||
|
(bootstrap_invite, oldest["id"]),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self._create_board_unlocked(bootstrap_title, bootstrap_invite)
|
||||||
|
board_id = self._conn.execute(
|
||||||
|
"SELECT id FROM boards WHERE invite_token = ?", (bootstrap_invite,)
|
||||||
|
).fetchone()["id"]
|
||||||
|
if not self._conn.execute(
|
||||||
|
"SELECT 1 FROM board_columns WHERE board_id = ?", (board_id,)
|
||||||
|
).fetchone():
|
||||||
|
self._conn.execute(
|
||||||
|
"INSERT INTO board_columns (board_id, id, label, position, created_at) VALUES (?, 'c1', 'First', 0, ?)",
|
||||||
|
(board_id, time.time()),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
invite = bootstrap_invite or secrets.token_urlsafe(18)
|
||||||
|
board_id = "b_" + secrets.token_urlsafe(8)
|
||||||
|
self._conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO boards (id, title, invite_token, created_at) VALUES (?, ?, ?, ?)",
|
||||||
|
(board_id, bootstrap_title, invite, time.time()),
|
||||||
|
)
|
||||||
|
# If ignore hit (token clash), fetch existing
|
||||||
|
row = self._conn.execute(
|
||||||
|
"SELECT id FROM boards WHERE invite_token = ?", (invite,)
|
||||||
|
).fetchone()
|
||||||
|
board_id = row["id"]
|
||||||
|
|
||||||
|
if needs_cols:
|
||||||
|
self._conn.executescript(
|
||||||
|
f"""
|
||||||
|
CREATE TABLE board_columns_new (
|
||||||
|
board_id TEXT NOT NULL,
|
||||||
|
id TEXT NOT NULL,
|
||||||
|
label TEXT NOT NULL DEFAULT '',
|
||||||
|
position INTEGER NOT NULL,
|
||||||
|
created_at REAL NOT NULL,
|
||||||
|
PRIMARY KEY (board_id, id),
|
||||||
|
UNIQUE (board_id, position)
|
||||||
|
);
|
||||||
|
INSERT INTO board_columns_new (board_id, id, label, position, created_at)
|
||||||
|
SELECT '{board_id}', id, label, position, created_at FROM board_columns;
|
||||||
|
DROP TABLE board_columns;
|
||||||
|
ALTER TABLE board_columns_new RENAME TO board_columns;
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
if needs_names:
|
||||||
|
self._conn.executescript(
|
||||||
|
f"""
|
||||||
|
CREATE TABLE names_new (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
board_id TEXT NOT NULL,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
spelling TEXT NOT NULL,
|
||||||
|
created_by TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at REAL NOT NULL,
|
||||||
|
UNIQUE (board_id, kind, spelling COLLATE NOCASE)
|
||||||
|
);
|
||||||
|
INSERT INTO names_new (id, board_id, kind, spelling, created_by, created_at)
|
||||||
|
SELECT id, '{board_id}', kind, spelling, created_by, created_at FROM names;
|
||||||
|
DROP TABLE names;
|
||||||
|
ALTER TABLE names_new RENAME TO names;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_names_board_kind ON names(board_id, kind);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
if not self._conn.execute(
|
||||||
|
"SELECT 1 FROM board_columns WHERE board_id = ?", (board_id,)
|
||||||
|
).fetchone():
|
||||||
|
self._conn.execute(
|
||||||
|
"INSERT INTO board_columns (board_id, id, label, position, created_at) VALUES (?, 'c1', 'First', 0, ?)",
|
||||||
|
(board_id, time.time()),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _create_board_unlocked(self, title: str, invite_token: str) -> dict[str, Any]:
|
||||||
|
board_id = "b_" + secrets.token_urlsafe(8)
|
||||||
|
now = time.time()
|
||||||
|
self._conn.execute(
|
||||||
|
"INSERT INTO boards (id, title, invite_token, created_at) VALUES (?, ?, ?, ?)",
|
||||||
|
(board_id, title[:80], invite_token, now),
|
||||||
|
)
|
||||||
|
return {"id": board_id, "title": title[:80], "invite_token": invite_token, "created_at": now}
|
||||||
|
|
||||||
|
def _board_row(self, row: sqlite3.Row, *, include_invite: bool = False) -> dict[str, Any]:
|
||||||
|
out = {
|
||||||
|
"id": row["id"],
|
||||||
|
"title": row["title"],
|
||||||
|
"created_at": row["created_at"],
|
||||||
|
}
|
||||||
|
if include_invite:
|
||||||
|
out["invite_token"] = row["invite_token"]
|
||||||
|
return out
|
||||||
|
|
||||||
|
def find_board_by_invite(self, invite: str) -> dict[str, Any] | None:
|
||||||
|
invite = (invite or "").strip()
|
||||||
|
if not invite:
|
||||||
|
return None
|
||||||
|
with self._lock:
|
||||||
|
row = self._conn.execute(
|
||||||
|
"SELECT * FROM boards WHERE invite_token = ?", (invite,)
|
||||||
|
).fetchone()
|
||||||
|
return self._board_row(row) if row else None
|
||||||
|
|
||||||
|
def find_board(self, board_id: str) -> dict[str, Any] | None:
|
||||||
|
board_id = (board_id or "").strip()
|
||||||
|
if not board_id:
|
||||||
|
return None
|
||||||
|
with self._lock:
|
||||||
|
row = self._conn.execute("SELECT * FROM boards WHERE id = ?", (board_id,)).fetchone()
|
||||||
|
return self._board_row(row) if row else None
|
||||||
|
|
||||||
|
def get_board(self, board_id: str) -> dict[str, Any] | None:
|
||||||
|
return self.find_board(board_id)
|
||||||
|
|
||||||
|
def list_boards(self, *, include_invite: bool = False) -> list[dict[str, Any]]:
|
||||||
|
with self._lock:
|
||||||
|
rows = self._conn.execute("SELECT * FROM boards ORDER BY created_at").fetchall()
|
||||||
|
return [self._board_row(r, include_invite=include_invite) for r in rows]
|
||||||
|
|
||||||
|
def create_board(self, title: str, invite_token: str | None = None) -> dict[str, Any]:
|
||||||
|
title = (title or "").strip()[:80] or "Family"
|
||||||
|
token = (invite_token or "").strip() or secrets.token_urlsafe(18)
|
||||||
|
with self._lock:
|
||||||
|
if self._conn.execute(
|
||||||
|
"SELECT 1 FROM boards WHERE invite_token = ?", (token,)
|
||||||
|
).fetchone():
|
||||||
|
raise ValueError("invite token already in use")
|
||||||
|
board = self._create_board_unlocked(title, token)
|
||||||
|
self._conn.execute(
|
||||||
|
"INSERT INTO board_columns (board_id, id, label, position, created_at) VALUES (?, 'c1', 'First', 0, ?)",
|
||||||
|
(board["id"], time.time()),
|
||||||
|
)
|
||||||
|
self._conn.commit()
|
||||||
|
return {**board, "invite_token": token}
|
||||||
|
|
||||||
|
def rename_board(self, board_id: str, title: str) -> dict[str, Any] | None:
|
||||||
|
title = (title or "").strip()[:80]
|
||||||
|
if not title:
|
||||||
|
raise ValueError("title required")
|
||||||
|
with self._lock:
|
||||||
|
cur = self._conn.execute(
|
||||||
|
"UPDATE boards SET title = ? WHERE id = ?", (title, board_id)
|
||||||
|
)
|
||||||
|
if cur.rowcount == 0:
|
||||||
|
return None
|
||||||
|
self._conn.commit()
|
||||||
|
row = self._conn.execute("SELECT * FROM boards WHERE id = ?", (board_id,)).fetchone()
|
||||||
|
assert row is not None
|
||||||
|
return self._board_row(row, include_invite=True)
|
||||||
|
|
||||||
|
def delete_board(self, board_id: str) -> bool:
|
||||||
|
with self._lock:
|
||||||
|
count = self._conn.execute("SELECT COUNT(*) AS n FROM boards").fetchone()["n"]
|
||||||
|
if count <= 1:
|
||||||
|
raise ValueError("keep at least one board")
|
||||||
|
name_ids = [
|
||||||
|
r["id"]
|
||||||
|
for r in self._conn.execute(
|
||||||
|
"SELECT id FROM names WHERE board_id = ?", (board_id,)
|
||||||
|
).fetchall()
|
||||||
|
]
|
||||||
|
for name_id in name_ids:
|
||||||
|
recs = self._conn.execute(
|
||||||
|
"SELECT path FROM recordings WHERE name_id = ?", (name_id,)
|
||||||
|
).fetchall()
|
||||||
|
for rec in recs:
|
||||||
|
path = self.audio_dir / rec["path"]
|
||||||
|
if path.is_file():
|
||||||
|
path.unlink()
|
||||||
|
cur = self._conn.execute("DELETE FROM boards WHERE id = ?", (board_id,))
|
||||||
|
self._conn.commit()
|
||||||
|
return cur.rowcount > 0
|
||||||
|
|
||||||
|
def _column_ids(self, board_id: str) -> set[str]:
|
||||||
|
rows = self._conn.execute(
|
||||||
|
"SELECT id FROM board_columns WHERE board_id = ?", (board_id,)
|
||||||
|
).fetchall()
|
||||||
|
return {r["id"] for r in rows}
|
||||||
|
|
||||||
|
def _column_row(self, board_id: str, row: sqlite3.Row) -> dict[str, Any]:
|
||||||
|
count = self._conn.execute(
|
||||||
|
"SELECT COUNT(*) AS n FROM names WHERE board_id = ? AND kind = ?",
|
||||||
|
(board_id, row["id"]),
|
||||||
|
).fetchone()["n"]
|
||||||
|
return {
|
||||||
|
"id": row["id"],
|
||||||
|
"label": row["label"],
|
||||||
|
"position": row["position"],
|
||||||
|
"created_at": row["created_at"],
|
||||||
|
"name_count": int(count),
|
||||||
|
}
|
||||||
|
|
||||||
|
def list_columns(self, board_id: str) -> list[dict[str, Any]]:
|
||||||
|
with self._lock:
|
||||||
|
rows = self._conn.execute(
|
||||||
|
"SELECT * FROM board_columns WHERE board_id = ? ORDER BY position",
|
||||||
|
(board_id,),
|
||||||
|
).fetchall()
|
||||||
|
return [self._column_row(board_id, r) for r in rows]
|
||||||
|
|
||||||
|
def add_column(self, board_id: str, label: str = "") -> dict[str, Any]:
|
||||||
|
label = (label or "").strip()[:40]
|
||||||
|
with self._lock:
|
||||||
|
if not self._conn.execute("SELECT 1 FROM boards WHERE id = ?", (board_id,)).fetchone():
|
||||||
|
raise ValueError("unknown board")
|
||||||
|
rows = self._conn.execute(
|
||||||
|
"SELECT id, position FROM board_columns WHERE board_id = ? ORDER BY position",
|
||||||
|
(board_id,),
|
||||||
|
).fetchall()
|
||||||
|
if len(rows) >= MAX_COLUMNS:
|
||||||
|
raise ValueError(f"at most {MAX_COLUMNS} columns")
|
||||||
|
used = {r["id"] for r in rows}
|
||||||
|
new_id = next(f"c{i}" for i in range(1, MAX_COLUMNS + 1) if f"c{i}" not in used)
|
||||||
|
position = max((r["position"] for r in rows), default=-1) + 1
|
||||||
|
self._conn.execute(
|
||||||
|
"INSERT INTO board_columns (board_id, id, label, position, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(board_id, new_id, label, position, time.time()),
|
||||||
|
)
|
||||||
|
self._conn.commit()
|
||||||
|
row = self._conn.execute(
|
||||||
|
"SELECT * FROM board_columns WHERE board_id = ? AND id = ?",
|
||||||
|
(board_id, new_id),
|
||||||
|
).fetchone()
|
||||||
|
assert row is not None
|
||||||
|
return self._column_row(board_id, row)
|
||||||
|
|
||||||
|
def rename_column(self, board_id: str, column_id: str, label: str) -> dict[str, Any] | None:
|
||||||
|
label = (label or "").strip()[:40]
|
||||||
|
with self._lock:
|
||||||
|
cur = self._conn.execute(
|
||||||
|
"UPDATE board_columns SET label = ? WHERE board_id = ? AND id = ?",
|
||||||
|
(label, board_id, column_id),
|
||||||
|
)
|
||||||
|
if cur.rowcount == 0:
|
||||||
|
return None
|
||||||
|
self._conn.commit()
|
||||||
|
row = self._conn.execute(
|
||||||
|
"SELECT * FROM board_columns WHERE board_id = ? AND id = ?",
|
||||||
|
(board_id, column_id),
|
||||||
|
).fetchone()
|
||||||
|
assert row is not None
|
||||||
|
return self._column_row(board_id, row)
|
||||||
|
|
||||||
|
def delete_column(self, board_id: str, column_id: str) -> bool:
|
||||||
|
with self._lock:
|
||||||
|
count = self._conn.execute(
|
||||||
|
"SELECT COUNT(*) AS n FROM board_columns WHERE board_id = ?",
|
||||||
|
(board_id,),
|
||||||
|
).fetchone()["n"]
|
||||||
|
if count <= 1:
|
||||||
|
raise ValueError("keep at least one column")
|
||||||
|
first = self._conn.execute(
|
||||||
|
"SELECT id FROM board_columns WHERE board_id = ? ORDER BY position ASC LIMIT 1",
|
||||||
|
(board_id,),
|
||||||
|
).fetchone()
|
||||||
|
if first and first["id"] == column_id:
|
||||||
|
raise ValueError("cannot remove the first column")
|
||||||
|
row = self._conn.execute(
|
||||||
|
"SELECT id FROM board_columns WHERE board_id = ? AND id = ?",
|
||||||
|
(board_id, column_id),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
return False
|
||||||
|
name_ids = [
|
||||||
|
r["id"]
|
||||||
|
for r in self._conn.execute(
|
||||||
|
"SELECT id FROM names WHERE board_id = ? AND kind = ?",
|
||||||
|
(board_id, column_id),
|
||||||
|
).fetchall()
|
||||||
|
]
|
||||||
|
for name_id in name_ids:
|
||||||
|
recs = self._conn.execute(
|
||||||
|
"SELECT path FROM recordings WHERE name_id = ?", (name_id,)
|
||||||
|
).fetchall()
|
||||||
|
for rec in recs:
|
||||||
|
path = self.audio_dir / rec["path"]
|
||||||
|
if path.is_file():
|
||||||
|
path.unlink()
|
||||||
|
self._conn.execute(
|
||||||
|
"DELETE FROM names WHERE board_id = ? AND kind = ?",
|
||||||
|
(board_id, column_id),
|
||||||
|
)
|
||||||
|
self._conn.execute(
|
||||||
|
"DELETE FROM board_columns WHERE board_id = ? AND id = ?",
|
||||||
|
(board_id, column_id),
|
||||||
|
)
|
||||||
|
remaining = self._conn.execute(
|
||||||
|
"SELECT id FROM board_columns WHERE board_id = ? ORDER BY position",
|
||||||
|
(board_id,),
|
||||||
|
).fetchall()
|
||||||
|
for i, rem in enumerate(remaining):
|
||||||
|
self._conn.execute(
|
||||||
|
"UPDATE board_columns SET position = ? WHERE board_id = ? AND id = ?",
|
||||||
|
(i, board_id, rem["id"]),
|
||||||
|
)
|
||||||
|
self._conn.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
def _empty_locales(self) -> dict[str, dict[str, str]]:
|
def _empty_locales(self) -> dict[str, dict[str, str]]:
|
||||||
return {lang: {"pronunciation": "", "origin": "", "meaning": ""} for lang in LANGS}
|
return {lang: {"pronunciation": "", "origin": "", "meaning": ""} for lang in LANGS}
|
||||||
|
|
||||||
|
def _empty_recordings(self) -> dict[str, bool]:
|
||||||
|
return {lang: False for lang in LANGS}
|
||||||
|
|
||||||
def _normalize_locales(self, locales: dict[str, Any] | None) -> dict[str, dict[str, str]]:
|
def _normalize_locales(self, locales: dict[str, Any] | None) -> dict[str, dict[str, str]]:
|
||||||
out = self._empty_locales()
|
out = self._empty_locales()
|
||||||
if not locales:
|
if not locales:
|
||||||
@@ -79,13 +548,20 @@ class Store:
|
|||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
def _recordings_flags(self, name_id: int) -> dict[str, bool]:
|
||||||
|
flags = self._empty_recordings()
|
||||||
|
for row in self._conn.execute(
|
||||||
|
"SELECT lang FROM recordings WHERE name_id = ?", (name_id,)
|
||||||
|
).fetchall():
|
||||||
|
flags[row["lang"]] = True
|
||||||
|
return flags
|
||||||
|
|
||||||
def _row_to_name(self, row: sqlite3.Row, score: int, vote_count: int, my_vote: int | None) -> dict[str, Any]:
|
def _row_to_name(self, row: sqlite3.Row, score: int, vote_count: int, my_vote: int | None) -> dict[str, Any]:
|
||||||
locales = self._empty_locales()
|
locales = self._empty_locales()
|
||||||
cur = self._conn.execute(
|
for loc in self._conn.execute(
|
||||||
"SELECT lang, pronunciation, origin, meaning FROM locales WHERE name_id = ?",
|
"SELECT lang, pronunciation, origin, meaning FROM locales WHERE name_id = ?",
|
||||||
(row["id"],),
|
(row["id"],),
|
||||||
)
|
).fetchall():
|
||||||
for loc in cur.fetchall():
|
|
||||||
locales[loc["lang"]] = {
|
locales[loc["lang"]] = {
|
||||||
"pronunciation": loc["pronunciation"],
|
"pronunciation": loc["pronunciation"],
|
||||||
"origin": loc["origin"],
|
"origin": loc["origin"],
|
||||||
@@ -93,23 +569,25 @@ class Store:
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
"id": row["id"],
|
"id": row["id"],
|
||||||
|
"board_id": row["board_id"],
|
||||||
"kind": row["kind"],
|
"kind": row["kind"],
|
||||||
"spelling": row["spelling"],
|
"spelling": row["spelling"],
|
||||||
"created_by": row["created_by"],
|
"created_by": row["created_by"],
|
||||||
"created_at": row["created_at"],
|
"created_at": row["created_at"],
|
||||||
"locales": locales,
|
"locales": locales,
|
||||||
|
"recordings": self._recordings_flags(row["id"]),
|
||||||
"score": score,
|
"score": score,
|
||||||
"vote_count": vote_count,
|
"vote_count": vote_count,
|
||||||
"my_vote": my_vote,
|
"my_vote": my_vote,
|
||||||
}
|
}
|
||||||
|
|
||||||
def list_names(self, kind: str, voter_key: str | None = None) -> list[dict[str, Any]]:
|
def list_names(self, board_id: str, kind: str, voter_key: str | None = None) -> list[dict[str, Any]]:
|
||||||
if kind not in KINDS:
|
|
||||||
raise ValueError("invalid kind")
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
|
if kind not in self._column_ids(board_id):
|
||||||
|
raise ValueError("unknown column")
|
||||||
rows = self._conn.execute(
|
rows = self._conn.execute(
|
||||||
"SELECT * FROM names WHERE kind = ? ORDER BY spelling COLLATE NOCASE",
|
"SELECT * FROM names WHERE board_id = ? AND kind = ? ORDER BY spelling COLLATE NOCASE",
|
||||||
(kind,),
|
(board_id, kind),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
out: list[dict[str, Any]] = []
|
out: list[dict[str, Any]] = []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
@@ -136,11 +614,18 @@ class Store:
|
|||||||
out.sort(key=lambda n: (-n["score"], n["spelling"].casefold()))
|
out.sort(key=lambda n: (-n["score"], n["spelling"].casefold()))
|
||||||
return out
|
return out
|
||||||
|
|
||||||
def get_name(self, name_id: int, voter_key: str | None = None) -> dict[str, Any] | None:
|
def get_name(
|
||||||
|
self,
|
||||||
|
name_id: int,
|
||||||
|
voter_key: str | None = None,
|
||||||
|
board_id: str | None = None,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
row = self._conn.execute("SELECT * FROM names WHERE id = ?", (name_id,)).fetchone()
|
row = self._conn.execute("SELECT * FROM names WHERE id = ?", (name_id,)).fetchone()
|
||||||
if not row:
|
if not row:
|
||||||
return None
|
return None
|
||||||
|
if board_id is not None and row["board_id"] != board_id:
|
||||||
|
return None
|
||||||
score_row = self._conn.execute(
|
score_row = self._conn.execute(
|
||||||
"SELECT COALESCE(SUM(value), 0) AS score, COUNT(*) AS vote_count FROM votes WHERE name_id = ?",
|
"SELECT COALESCE(SUM(value), 0) AS score, COUNT(*) AS vote_count FROM votes WHERE name_id = ?",
|
||||||
(name_id,),
|
(name_id,),
|
||||||
@@ -162,13 +647,12 @@ class Store:
|
|||||||
|
|
||||||
def add_name(
|
def add_name(
|
||||||
self,
|
self,
|
||||||
|
board_id: str,
|
||||||
kind: str,
|
kind: str,
|
||||||
spelling: str,
|
spelling: str,
|
||||||
created_by: str,
|
created_by: str,
|
||||||
locales: dict[str, Any] | None = None,
|
locales: dict[str, Any] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
if kind not in KINDS:
|
|
||||||
raise ValueError("invalid kind")
|
|
||||||
spelling = spelling.strip()
|
spelling = spelling.strip()
|
||||||
if not spelling or len(spelling) > 80:
|
if not spelling or len(spelling) > 80:
|
||||||
raise ValueError("spelling required (1–80 chars)")
|
raise ValueError("spelling required (1–80 chars)")
|
||||||
@@ -176,13 +660,15 @@ class Store:
|
|||||||
locs = self._normalize_locales(locales)
|
locs = self._normalize_locales(locales)
|
||||||
now = time.time()
|
now = time.time()
|
||||||
with self._lock:
|
with self._lock:
|
||||||
|
if kind not in self._column_ids(board_id):
|
||||||
|
raise ValueError("unknown column")
|
||||||
try:
|
try:
|
||||||
cur = self._conn.execute(
|
cur = self._conn.execute(
|
||||||
"INSERT INTO names (kind, spelling, created_by, created_at) VALUES (?, ?, ?, ?)",
|
"INSERT INTO names (board_id, kind, spelling, created_by, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||||
(kind, spelling, created_by, now),
|
(board_id, kind, spelling, created_by, now),
|
||||||
)
|
)
|
||||||
except sqlite3.IntegrityError as exc:
|
except sqlite3.IntegrityError as exc:
|
||||||
raise ValueError("name already exists for this kind") from exc
|
raise ValueError("name already exists for this column") from exc
|
||||||
name_id = int(cur.lastrowid)
|
name_id = int(cur.lastrowid)
|
||||||
for lang, payload in locs.items():
|
for lang, payload in locs.items():
|
||||||
self._conn.execute(
|
self._conn.execute(
|
||||||
@@ -190,17 +676,34 @@ class Store:
|
|||||||
(name_id, lang, payload["pronunciation"], payload["origin"], payload["meaning"]),
|
(name_id, lang, payload["pronunciation"], payload["origin"], payload["meaning"]),
|
||||||
)
|
)
|
||||||
self._conn.commit()
|
self._conn.commit()
|
||||||
result = self.get_name(name_id)
|
result = self.get_name(name_id, board_id=board_id)
|
||||||
assert result is not None
|
assert result is not None
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def update_locales(self, name_id: int, locales: dict[str, Any]) -> dict[str, Any] | None:
|
def update_locales(
|
||||||
locs = self._normalize_locales(locales)
|
self,
|
||||||
|
name_id: int,
|
||||||
|
locales: dict[str, Any],
|
||||||
|
board_id: str | None = None,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
if not locales:
|
||||||
|
return self.get_name(name_id, board_id=board_id)
|
||||||
|
patch: dict[str, dict[str, str]] = {}
|
||||||
|
for lang, raw in locales.items():
|
||||||
|
if lang not in LANGS or not isinstance(raw, dict):
|
||||||
|
continue
|
||||||
|
patch[lang] = {
|
||||||
|
"pronunciation": str(raw.get("pronunciation") or "").strip()[:200],
|
||||||
|
"origin": str(raw.get("origin") or "").strip()[:200],
|
||||||
|
"meaning": str(raw.get("meaning") or "").strip()[:500],
|
||||||
|
}
|
||||||
|
if not patch:
|
||||||
|
return self.get_name(name_id, board_id=board_id)
|
||||||
with self._lock:
|
with self._lock:
|
||||||
row = self._conn.execute("SELECT id FROM names WHERE id = ?", (name_id,)).fetchone()
|
row = self._conn.execute("SELECT id, board_id FROM names WHERE id = ?", (name_id,)).fetchone()
|
||||||
if not row:
|
if not row or (board_id is not None and row["board_id"] != board_id):
|
||||||
return None
|
return None
|
||||||
for lang, payload in locs.items():
|
for lang, payload in patch.items():
|
||||||
self._conn.execute(
|
self._conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO locales (name_id, lang, pronunciation, origin, meaning)
|
INSERT INTO locales (name_id, lang, pronunciation, origin, meaning)
|
||||||
@@ -213,7 +716,7 @@ class Store:
|
|||||||
(name_id, lang, payload["pronunciation"], payload["origin"], payload["meaning"]),
|
(name_id, lang, payload["pronunciation"], payload["origin"], payload["meaning"]),
|
||||||
)
|
)
|
||||||
self._conn.commit()
|
self._conn.commit()
|
||||||
return self.get_name(name_id)
|
return self.get_name(name_id, board_id=board_id)
|
||||||
|
|
||||||
def vote(
|
def vote(
|
||||||
self,
|
self,
|
||||||
@@ -221,6 +724,7 @@ class Store:
|
|||||||
voter_key: str,
|
voter_key: str,
|
||||||
voter_name: str,
|
voter_name: str,
|
||||||
value: int,
|
value: int,
|
||||||
|
board_id: str | None = None,
|
||||||
) -> dict[str, Any] | None:
|
) -> dict[str, Any] | None:
|
||||||
if value not in (-1, 0, 1):
|
if value not in (-1, 0, 1):
|
||||||
raise ValueError("vote must be -1, 0, or 1")
|
raise ValueError("vote must be -1, 0, or 1")
|
||||||
@@ -229,8 +733,8 @@ class Store:
|
|||||||
raise ValueError("invalid voter")
|
raise ValueError("invalid voter")
|
||||||
voter_name = (voter_name or "").strip()[:80]
|
voter_name = (voter_name or "").strip()[:80]
|
||||||
with self._lock:
|
with self._lock:
|
||||||
row = self._conn.execute("SELECT id FROM names WHERE id = ?", (name_id,)).fetchone()
|
row = self._conn.execute("SELECT id, board_id FROM names WHERE id = ?", (name_id,)).fetchone()
|
||||||
if not row:
|
if not row or (board_id is not None and row["board_id"] != board_id):
|
||||||
return None
|
return None
|
||||||
if value == 0:
|
if value == 0:
|
||||||
self._conn.execute(
|
self._conn.execute(
|
||||||
@@ -250,16 +754,120 @@ class Store:
|
|||||||
(name_id, voter_key, voter_name, value, time.time()),
|
(name_id, voter_key, voter_name, value, time.time()),
|
||||||
)
|
)
|
||||||
self._conn.commit()
|
self._conn.commit()
|
||||||
return self.get_name(name_id, voter_key=voter_key)
|
return self.get_name(name_id, voter_key=voter_key, board_id=board_id)
|
||||||
|
|
||||||
|
def save_recording(
|
||||||
|
self,
|
||||||
|
name_id: int,
|
||||||
|
lang: str,
|
||||||
|
data: bytes,
|
||||||
|
content_type: str,
|
||||||
|
recorded_by: str,
|
||||||
|
ext: str = "webm",
|
||||||
|
board_id: str | None = None,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
if lang not in LANGS:
|
||||||
|
raise ValueError("invalid lang")
|
||||||
|
if not data:
|
||||||
|
raise ValueError("empty recording")
|
||||||
|
if len(data) > 2_500_000:
|
||||||
|
raise ValueError("recording too large (max ~2.5MB)")
|
||||||
|
recorded_by = (recorded_by or "").strip()[:80]
|
||||||
|
content_type = (content_type or "audio/webm").split(";")[0].strip()[:80]
|
||||||
|
with self._lock:
|
||||||
|
row = self._conn.execute("SELECT id, board_id FROM names WHERE id = ?", (name_id,)).fetchone()
|
||||||
|
if not row or (board_id is not None and row["board_id"] != board_id):
|
||||||
|
return None
|
||||||
|
old = self._conn.execute(
|
||||||
|
"SELECT path FROM recordings WHERE name_id = ? AND lang = ?",
|
||||||
|
(name_id, lang),
|
||||||
|
).fetchone()
|
||||||
|
rel = f"{name_id}-{lang}.{ext}"
|
||||||
|
dest = self.audio_dir / rel
|
||||||
|
dest.write_bytes(data)
|
||||||
|
if old and old["path"] != rel:
|
||||||
|
prev = self.audio_dir / old["path"]
|
||||||
|
if prev.is_file():
|
||||||
|
prev.unlink()
|
||||||
|
self._conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO recordings (name_id, lang, path, content_type, recorded_by, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(name_id, lang) DO UPDATE SET
|
||||||
|
path = excluded.path,
|
||||||
|
content_type = excluded.content_type,
|
||||||
|
recorded_by = excluded.recorded_by,
|
||||||
|
created_at = excluded.created_at
|
||||||
|
""",
|
||||||
|
(name_id, lang, rel, content_type, recorded_by, time.time()),
|
||||||
|
)
|
||||||
|
self._conn.commit()
|
||||||
|
return self.get_name(name_id, board_id=board_id)
|
||||||
|
|
||||||
|
def get_recording(
|
||||||
|
self,
|
||||||
|
name_id: int,
|
||||||
|
lang: str,
|
||||||
|
board_id: str | None = None,
|
||||||
|
) -> tuple[Path, str] | None:
|
||||||
|
if lang not in LANGS:
|
||||||
|
return None
|
||||||
|
with self._lock:
|
||||||
|
row = self._conn.execute("SELECT id, board_id FROM names WHERE id = ?", (name_id,)).fetchone()
|
||||||
|
if not row or (board_id is not None and row["board_id"] != board_id):
|
||||||
|
return None
|
||||||
|
rec = self._conn.execute(
|
||||||
|
"SELECT path, content_type FROM recordings WHERE name_id = ? AND lang = ?",
|
||||||
|
(name_id, lang),
|
||||||
|
).fetchone()
|
||||||
|
if not rec:
|
||||||
|
return None
|
||||||
|
path = self.audio_dir / rec["path"]
|
||||||
|
if not path.is_file():
|
||||||
|
return None
|
||||||
|
return path, rec["content_type"]
|
||||||
|
|
||||||
|
def delete_recording(
|
||||||
|
self,
|
||||||
|
name_id: int,
|
||||||
|
lang: str,
|
||||||
|
board_id: str | None = None,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
if lang not in LANGS:
|
||||||
|
raise ValueError("invalid lang")
|
||||||
|
with self._lock:
|
||||||
|
row = self._conn.execute("SELECT id, board_id FROM names WHERE id = ?", (name_id,)).fetchone()
|
||||||
|
if not row or (board_id is not None and row["board_id"] != board_id):
|
||||||
|
return None
|
||||||
|
rec = self._conn.execute(
|
||||||
|
"SELECT path FROM recordings WHERE name_id = ? AND lang = ?",
|
||||||
|
(name_id, lang),
|
||||||
|
).fetchone()
|
||||||
|
if rec:
|
||||||
|
path = self.audio_dir / rec["path"]
|
||||||
|
if path.is_file():
|
||||||
|
path.unlink()
|
||||||
|
self._conn.execute(
|
||||||
|
"DELETE FROM recordings WHERE name_id = ? AND lang = ?",
|
||||||
|
(name_id, lang),
|
||||||
|
)
|
||||||
|
self._conn.commit()
|
||||||
|
return self.get_name(name_id, board_id=board_id)
|
||||||
|
|
||||||
def delete_name(self, name_id: int) -> bool:
|
def delete_name(self, name_id: int) -> bool:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
|
recs = self._conn.execute(
|
||||||
|
"SELECT path FROM recordings WHERE name_id = ?", (name_id,)
|
||||||
|
).fetchall()
|
||||||
|
for rec in recs:
|
||||||
|
path = self.audio_dir / rec["path"]
|
||||||
|
if path.is_file():
|
||||||
|
path.unlink()
|
||||||
cur = self._conn.execute("DELETE FROM names WHERE id = ?", (name_id,))
|
cur = self._conn.execute("DELETE FROM names WHERE id = ?", (name_id,))
|
||||||
self._conn.commit()
|
self._conn.commit()
|
||||||
return cur.rowcount > 0
|
return cur.rowcount > 0
|
||||||
|
|
||||||
def export_snapshot(self) -> str:
|
def export_snapshot(self) -> str:
|
||||||
"""Debug helper for tests."""
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
names = [dict(r) for r in self._conn.execute("SELECT * FROM names").fetchall()]
|
names = [dict(r) for r in self._conn.execute("SELECT * FROM names").fetchall()]
|
||||||
return json.dumps(names)
|
return json.dumps(names)
|
||||||
|
|||||||
@@ -25,10 +25,18 @@ def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> TestClient:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def authed(client: TestClient) -> TestClient:
|
def family_board(client: TestClient) -> dict:
|
||||||
|
"""Bootstrap Family board id (from STORK_INVITE_TOKEN)."""
|
||||||
|
res = client.get("/api/resolve", params={"invite": "test-invite-token"})
|
||||||
|
assert res.status_code == 200
|
||||||
|
return res.json()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def authed(client: TestClient, family_board: dict) -> TestClient:
|
||||||
res = client.post(
|
res = client.post(
|
||||||
"/api/session",
|
"/api/session",
|
||||||
json={"invite": "test-invite-token", "display_name": "Aunt Mira"},
|
json={"board_id": family_board["id"], "display_name": "Aunt Mira"},
|
||||||
)
|
)
|
||||||
assert res.status_code == 200
|
assert res.status_code == 200
|
||||||
return client
|
return client
|
||||||
|
|||||||
@@ -11,111 +11,191 @@ def test_health(client: TestClient) -> None:
|
|||||||
assert res.json()["ok"] is True
|
assert res.json()["ok"] is True
|
||||||
|
|
||||||
|
|
||||||
def test_session_rejects_bad_invite(client: TestClient) -> None:
|
def test_logos_gallery(client: TestClient) -> None:
|
||||||
res = client.post("/api/session", json={"invite": "nope", "display_name": "Ilia"})
|
res = client.get("/logos")
|
||||||
assert res.status_code == 403
|
assert res.status_code == 200
|
||||||
|
assert b"Pick a Stork mark" in res.content
|
||||||
|
|
||||||
|
|
||||||
|
def test_home_is_paper_board(client: TestClient) -> None:
|
||||||
|
res = client.get("/")
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert b"Start a private board" in res.content
|
||||||
|
|
||||||
|
|
||||||
|
def test_v2_redirects_home(client: TestClient) -> None:
|
||||||
|
res = client.get("/v2", follow_redirects=False)
|
||||||
|
assert res.status_code == 302
|
||||||
|
assert res.headers["location"] == "/"
|
||||||
|
|
||||||
|
|
||||||
|
def test_public_create_board_and_share_url(client: TestClient) -> None:
|
||||||
|
created = client.post("/api/boards", json={"title": "Baby Levkin"})
|
||||||
|
assert created.status_code == 200
|
||||||
|
board_id = created.json()["id"]
|
||||||
|
assert board_id.startswith("b_")
|
||||||
|
assert created.json()["url"].endswith(f"/b/{board_id}")
|
||||||
|
page = client.get(f"/b/{board_id}")
|
||||||
|
assert page.status_code == 200
|
||||||
|
assert b"Start a private board" in page.content or b"Your name" in page.content
|
||||||
|
|
||||||
|
|
||||||
|
def test_join_board_with_display_name(client: TestClient) -> None:
|
||||||
|
created = client.post("/api/boards", json={"title": "Friends"}).json()
|
||||||
|
session = client.post(
|
||||||
|
"/api/session",
|
||||||
|
json={"board_id": created["id"], "display_name": "Dan"},
|
||||||
|
)
|
||||||
|
assert session.status_code == 200
|
||||||
|
assert session.json()["board"]["id"] == created["id"]
|
||||||
|
assert client.post("/api/names", json={"kind": "c1", "spelling": "Noa"}).status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_boards_are_isolated(client: TestClient) -> None:
|
||||||
|
a = client.post("/api/boards", json={"title": "A"}).json()
|
||||||
|
b = client.post("/api/boards", json={"title": "B"}).json()
|
||||||
|
client.post("/api/session", json={"board_id": a["id"], "display_name": "Ilia"})
|
||||||
|
assert client.post("/api/names", json={"kind": "c1", "spelling": "Roze"}).status_code == 200
|
||||||
|
client.post("/api/session", json={"board_id": b["id"], "display_name": "Dan"})
|
||||||
|
assert client.post("/api/names", json={"kind": "c1", "spelling": "Roze"}).status_code == 200
|
||||||
|
a_names = client.get(
|
||||||
|
"/api/names?kind=c1",
|
||||||
|
headers={"X-Stork-Board": a["id"], "X-Stork-Display-Name": "Ilia"},
|
||||||
|
).json()["items"]
|
||||||
|
b_names = client.get(
|
||||||
|
"/api/names?kind=c1",
|
||||||
|
headers={"X-Stork-Board": b["id"], "X-Stork-Display-Name": "Dan"},
|
||||||
|
).json()["items"]
|
||||||
|
assert len(a_names) == 1 and len(b_names) == 1
|
||||||
|
assert a_names[0]["id"] != b_names[0]["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_one_column(authed: TestClient) -> None:
|
||||||
|
cols = authed.get("/api/columns").json()["items"]
|
||||||
|
assert len(cols) == 1
|
||||||
|
assert cols[0]["id"] == "c1"
|
||||||
|
assert cols[0]["label"] == "First"
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_rename_columns(authed: TestClient) -> None:
|
||||||
|
c2 = authed.post("/api/columns")
|
||||||
|
assert c2.status_code == 200
|
||||||
|
assert c2.json()["id"] == "c2"
|
||||||
|
renamed = authed.patch("/api/columns/c2", json={"label": "Hebrew"})
|
||||||
|
assert renamed.status_code == 200
|
||||||
|
assert renamed.json()["label"] == "Hebrew"
|
||||||
|
assert authed.post("/api/names", json={"kind": "c2", "spelling": "שי"}).status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_column_delete_by_member(authed: TestClient) -> None:
|
||||||
|
assert authed.post("/api/columns").status_code == 200
|
||||||
|
assert authed.delete("/api/columns/c2").status_code == 200
|
||||||
|
assert len(authed.get("/api/columns").json()["items"]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_cannot_delete_first_column(authed: TestClient) -> None:
|
||||||
|
assert authed.post("/api/columns").status_code == 200
|
||||||
|
assert authed.delete("/api/columns/c1").status_code == 400
|
||||||
|
assert len(authed.get("/api/columns").json()["items"]) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_max_four_columns(authed: TestClient) -> None:
|
||||||
|
for _ in range(3):
|
||||||
|
assert authed.post("/api/columns").status_code == 200
|
||||||
|
assert authed.post("/api/columns").status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_cannot_delete_last_column(authed: TestClient) -> None:
|
||||||
|
assert authed.delete("/api/columns/c1").status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_recording_upload_and_play(authed: TestClient) -> None:
|
||||||
|
created = authed.post("/api/names", json={"kind": "c1", "spelling": "Roze"}).json()
|
||||||
|
name_id = created["id"]
|
||||||
|
files = {"file": ("voice.webm", b"fake-webm-bytes", "audio/webm")}
|
||||||
|
up = authed.post(f"/api/names/{name_id}/recording/en", files=files)
|
||||||
|
assert up.status_code == 200
|
||||||
|
assert up.json()["recordings"]["en"] is True
|
||||||
|
audio = authed.get(f"/api/names/{name_id}/recording/en")
|
||||||
|
assert audio.status_code == 200
|
||||||
|
assert audio.content == b"fake-webm-bytes"
|
||||||
|
|
||||||
|
|
||||||
|
def test_session_rejects_missing_board(client: TestClient) -> None:
|
||||||
|
res = client.post("/api/session", json={"display_name": "Ilia", "board_id": "b_missing"})
|
||||||
|
assert res.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
def test_names_require_session(client: TestClient) -> None:
|
def test_names_require_session(client: TestClient) -> None:
|
||||||
res = client.get("/api/names")
|
res = client.get("/api/names")
|
||||||
assert res.status_code == 401
|
assert res.status_code in (401, 404)
|
||||||
|
|
||||||
|
|
||||||
def test_add_vote_and_rank(authed: TestClient) -> None:
|
def test_add_vote_and_rank(authed: TestClient) -> None:
|
||||||
a = authed.post(
|
a = authed.post(
|
||||||
"/api/names",
|
"/api/names",
|
||||||
json={
|
json={
|
||||||
"kind": "first",
|
"kind": "c1",
|
||||||
"spelling": "Noa",
|
"spelling": "Noa",
|
||||||
"locales": {
|
"locales": {
|
||||||
"he": {"pronunciation": "No-ah", "origin": "Hebrew", "meaning": "motion / movement"},
|
"he": {"pronunciation": "No-ah", "origin": "Hebrew", "meaning": "motion"},
|
||||||
"en": {"pronunciation": "NO-uh", "origin": "Hebrew", "meaning": "movement"},
|
"en": {"pronunciation": "NO-uh", "origin": "Hebrew", "meaning": "movement"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert a.status_code == 200
|
assert a.status_code == 200
|
||||||
noa_id = a.json()["id"]
|
noa_id = a.json()["id"]
|
||||||
|
b = authed.post("/api/names", json={"kind": "c1", "spelling": "Levi"})
|
||||||
b = authed.post("/api/names", json={"kind": "first", "spelling": "Levi"})
|
|
||||||
assert b.status_code == 200
|
|
||||||
levi_id = b.json()["id"]
|
levi_id = b.json()["id"]
|
||||||
|
|
||||||
assert authed.post(f"/api/names/{noa_id}/vote", json={"value": 1}).status_code == 200
|
assert authed.post(f"/api/names/{noa_id}/vote", json={"value": 1}).status_code == 200
|
||||||
assert authed.post(f"/api/names/{levi_id}/vote", json={"value": -1}).status_code == 200
|
assert authed.post(f"/api/names/{levi_id}/vote", json={"value": -1}).status_code == 200
|
||||||
|
listed = authed.get("/api/names?kind=c1").json()["items"]
|
||||||
listed = authed.get("/api/names?kind=first").json()["items"]
|
|
||||||
assert [n["spelling"] for n in listed] == ["Noa", "Levi"]
|
assert [n["spelling"] for n in listed] == ["Noa", "Levi"]
|
||||||
assert listed[0]["score"] == 1
|
|
||||||
assert listed[0]["my_vote"] == 1
|
|
||||||
assert listed[1]["score"] == -1
|
|
||||||
|
|
||||||
# clear vote
|
|
||||||
cleared = authed.post(f"/api/names/{noa_id}/vote", json={"value": 0})
|
|
||||||
assert cleared.status_code == 200
|
|
||||||
assert cleared.json()["my_vote"] is None
|
|
||||||
assert cleared.json()["score"] == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_duplicate_name_rejected(authed: TestClient) -> None:
|
def test_duplicate_name_rejected(authed: TestClient) -> None:
|
||||||
assert authed.post("/api/names", json={"kind": "first", "spelling": "Maya"}).status_code == 200
|
assert authed.post("/api/names", json={"kind": "c1", "spelling": "Maya"}).status_code == 200
|
||||||
dup = authed.post("/api/names", json={"kind": "first", "spelling": "maya"})
|
assert authed.post("/api/names", json={"kind": "c1", "spelling": "maya"}).status_code == 400
|
||||||
assert dup.status_code == 400
|
|
||||||
|
|
||||||
|
|
||||||
def test_middle_names_separate(authed: TestClient) -> None:
|
|
||||||
assert authed.post("/api/names", json={"kind": "first", "spelling": "Ari"}).status_code == 200
|
|
||||||
assert authed.post("/api/names", json={"kind": "middle", "spelling": "Ari"}).status_code == 200
|
|
||||||
first = authed.get("/api/names?kind=first").json()["items"]
|
|
||||||
middle = authed.get("/api/names?kind=middle").json()["items"]
|
|
||||||
assert len(first) == 1
|
|
||||||
assert len(middle) == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_patch_locales(authed: TestClient) -> None:
|
def test_patch_locales(authed: TestClient) -> None:
|
||||||
created = authed.post("/api/names", json={"kind": "first", "spelling": "Eden"}).json()
|
created = authed.post("/api/names", json={"kind": "c1", "spelling": "Eden"}).json()
|
||||||
patched = authed.patch(
|
patched = authed.patch(
|
||||||
f"/api/names/{created['id']}",
|
f"/api/names/{created['id']}",
|
||||||
json={"locales": {"ru": {"pronunciation": "Э-ден", "origin": "иврит", "meaning": "рай"}}},
|
json={"locales": {"ru": {"pronunciation": "Э-ден", "origin": "иврит", "meaning": "рай"}}},
|
||||||
)
|
)
|
||||||
assert patched.status_code == 200
|
assert patched.status_code == 200
|
||||||
assert patched.json()["locales"]["ru"]["meaning"] == "рай"
|
assert patched.json()["locales"]["ru"]["meaning"] == "рай"
|
||||||
|
assert patched.json()["locales"]["en"]["pronunciation"] == ""
|
||||||
|
|
||||||
|
|
||||||
def test_admin_delete(authed: TestClient) -> None:
|
def test_admin_delete_name(authed: TestClient) -> None:
|
||||||
created = authed.post("/api/names", json={"kind": "first", "spelling": "Temp"}).json()
|
created = authed.post("/api/names", json={"kind": "c1", "spelling": "Temp"}).json()
|
||||||
denied = authed.delete(f"/api/names/{created['id']}")
|
assert authed.delete(f"/api/names/{created['id']}").status_code == 403
|
||||||
assert denied.status_code == 403
|
assert (
|
||||||
ok = authed.delete(f"/api/names/{created['id']}", headers={"X-Stork-Admin": "test-admin-token"})
|
authed.delete(
|
||||||
assert ok.status_code == 200
|
f"/api/names/{created['id']}", headers={"X-Stork-Admin": "test-admin-token"}
|
||||||
assert authed.get("/api/names?kind=first").json()["items"] == []
|
).status_code
|
||||||
|
== 200
|
||||||
|
|
||||||
def test_second_voter_ranking(client: TestClient, authed: TestClient) -> None:
|
|
||||||
name = authed.post("/api/names", json={"kind": "first", "spelling": "Shai"}).json()
|
|
||||||
authed.post(f"/api/names/{name['id']}/vote", json={"value": 1})
|
|
||||||
|
|
||||||
other = client.post(
|
|
||||||
"/api/session",
|
|
||||||
json={"invite": "test-invite-token", "display_name": "Uncle Dan"},
|
|
||||||
)
|
)
|
||||||
assert other.status_code == 200
|
|
||||||
client.post(f"/api/names/{name['id']}/vote", json={"value": 1})
|
|
||||||
listed = client.get("/api/names?kind=first").json()["items"]
|
|
||||||
assert listed[0]["score"] == 2
|
|
||||||
assert listed[0]["vote_count"] == 2
|
|
||||||
|
|
||||||
|
|
||||||
def test_header_auth_without_cookies(client: TestClient) -> None:
|
def test_header_auth_with_board(client: TestClient, family_board: dict) -> None:
|
||||||
headers = {
|
headers = {
|
||||||
"X-Stork-Invite": "test-invite-token",
|
"X-Stork-Board": family_board["id"],
|
||||||
"X-Stork-Display-Name": "Seed Bot",
|
"X-Stork-Display-Name": "Seed Bot",
|
||||||
}
|
}
|
||||||
res = client.post(
|
res = client.post(
|
||||||
"/api/names",
|
"/api/names",
|
||||||
headers=headers,
|
headers=headers,
|
||||||
json={"kind": "first", "spelling": "HeaderOnly"},
|
json={"kind": "c1", "spelling": "HeaderOnly"},
|
||||||
)
|
)
|
||||||
assert res.status_code == 200
|
assert res.status_code == 200
|
||||||
listed = client.get("/api/names?kind=first", headers=headers)
|
listed = client.get("/api/names?kind=c1", headers=headers)
|
||||||
assert listed.status_code == 200
|
|
||||||
assert any(n["spelling"] == "HeaderOnly" for n in listed.json()["items"])
|
assert any(n["spelling"] == "HeaderOnly" for n in listed.json()["items"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_invite_resolve(client: TestClient, family_board: dict) -> None:
|
||||||
|
res = client.get("/api/resolve", params={"invite": "test-invite-token"})
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert res.json()["id"] == family_board["id"]
|
||||||
|
|||||||
@@ -8,13 +8,14 @@ from stork.db import Store
|
|||||||
|
|
||||||
|
|
||||||
def test_store_ranks_by_score_then_alpha(tmp_path: Path) -> None:
|
def test_store_ranks_by_score_then_alpha(tmp_path: Path) -> None:
|
||||||
store = Store(tmp_path / "t.sqlite3")
|
store = Store(tmp_path / "t.sqlite3", bootstrap_invite="unit-invite")
|
||||||
a = store.add_name("first", "Zed", "x")
|
board_id = store.list_boards()[0]["id"]
|
||||||
store.add_name("first", "Ann", "x")
|
a = store.add_name(board_id, "c1", "Zed", "x")
|
||||||
c = store.add_name("first", "Bo", "x")
|
store.add_name(board_id, "c1", "Ann", "x")
|
||||||
store.vote(a["id"], "v1", "A", 1)
|
c = store.add_name(board_id, "c1", "Bo", "x")
|
||||||
store.vote(c["id"], "v1", "A", 1)
|
store.vote(a["id"], "v1", "A", 1, board_id=board_id)
|
||||||
store.vote(c["id"], "v2", "B", 1)
|
store.vote(c["id"], "v1", "A", 1, board_id=board_id)
|
||||||
ranked = store.list_names("first")
|
store.vote(c["id"], "v2", "B", 1, board_id=board_id)
|
||||||
|
ranked = store.list_names(board_id, "c1")
|
||||||
assert [n["spelling"] for n in ranked] == ["Bo", "Zed", "Ann"]
|
assert [n["spelling"] for n in ranked] == ["Bo", "Zed", "Ann"]
|
||||||
store.close()
|
store.close()
|
||||||
|
|||||||