Add personal significance notes and Esc-to-close cards.

Family can record why a name matters (grandfather, etc.), and Escape collapses an open name drawer.
This commit is contained in:
2026-08-09 14:19:17 -04:00
parent dbcaf5b55c
commit 53363866f3
6 changed files with 141 additions and 85 deletions
+2 -2
View File
@@ -26,7 +26,7 @@ Visitors open the link and enter a display name.
- 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)
- Locale notes (en / ru / he) + personal significance (why the name matters) + voice recordings (re-record overwrites)
- Paper UI at `/`
- Optional read-only boards via `STORK_READONLY_BOARD_IDS`
@@ -43,7 +43,7 @@ Visitors open the link and enter a display name.
| GET | `/api/columns` | Columns for session board |
| POST/PATCH/DELETE | `/api/columns…` | Add / rename / remove (not first; blocked if readonly) |
| GET/POST | `/api/names` | List / add |
| PATCH | `/api/names/{id}` | Locale notes |
| PATCH | `/api/names/{id}` | Locale notes and/or personal significance |
| POST | `/api/names/{id}/vote` | `{value: 1\|-1\|0}` |
| POST/GET/DELETE | `/api/names/{id}/recording/{lang}` | Voice (magic sniff; max ~5 MiB) |
| DELETE | `/api/names/{id}` | Admin header `X-Stork-Admin` |
+29 -4
View File
@@ -449,6 +449,8 @@
</div>
<label for="spelling">Spelling</label>
<input id="spelling" required aria-required="true" autocomplete="off" />
<label for="significance" style="margin-top:0.55rem">Why this name? (optional)</label>
<textarea id="significance" maxlength="500" placeholder="e.g. grandfather’s name, named after…"></textarea>
<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>
@@ -499,7 +501,7 @@
</ul>
<h3>Names &amp; votes</h3>
<ul>
<li>Tap a name to expand. ▲ / ▼ rank it (tap again to clear).</li>
<li>Tap a name to expand. ▲ / ▼ rank it (tap again to clear). <kbd>Esc</kbd> closes an open name.</li>
<li>Suggest names below; pick which column with the toggles.</li>
</ul>
<h3>Columns</h3>
@@ -510,6 +512,7 @@
</ul>
<h3>Notes &amp; voice</h3>
<ul>
<li><strong>Why this name?</strong> — personal note (grandfather, family story, etc.).</li>
<li><strong>EN / RU / HE</strong> — spoken form, origin, meaning per language.</li>
<li><strong>▶</strong> play (AI until you record) · <strong>●</strong> record · <strong>■</strong> stop</li>
<li><strong>↺</strong> back to AI · <strong>✎</strong> edit notes · <strong>✓</strong> save</li>
@@ -575,7 +578,17 @@
if (isTypingTarget(e.target)) return;
e.preventDefault();
toggleHelp();
return;
}
if (e.key !== "Escape") return;
const help = document.getElementById("help-dialog");
if (help && help.open) return; // <dialog> closes itself on Esc
if (state.openId == null && state.editId == null) return;
e.preventDefault();
stopPlayback();
state.openId = null;
state.editId = null;
refreshLists();
});
function colLabel(col) {
@@ -741,7 +754,8 @@
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 sig = (n.significance || "").trim();
const hasNotes = !!(L.origin || L.meaning || L.pronunciation || sig);
const pills = ["en", "ru", "he"].map((l) =>
`<button type="button" class="${lang === l ? "active" : ""}" data-lang="${l}">${l}</button>`
).join("");
@@ -749,6 +763,8 @@
const spoken = L.pronunciation || form || "—";
const notesView = `
<div class="notes-view">
<span class="k">Why this name?</span>
<div dir="auto">${sig ? escapeHtml(sig) : `<span class="empty-hint">Not set</span>`}</div>
<span class="k">Spoken (${lang})</span>
<div dir="auto">${escapeHtml(spoken)}</div>
<span class="k">Origin</span>
@@ -758,6 +774,10 @@
</div>`;
const notesEdit = `
<div class="edit-grid">
<div>
<label for="sig-${n.id}">Why this name?</label>
<textarea id="sig-${n.id}" data-edit="significance" dir="auto" placeholder="e.g. grandfather’s name, named after…" maxlength="500">${escapeHtml(sig)}</textarea>
</div>
<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)}" />
@@ -776,7 +796,7 @@
<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>
<p class="meta">${escapeHtml(n.created_by || "someone")} · ${n.vote_count}${hasRec ? " · voice" : ""}${sig ? ` · ${escapeHtml(sig.length > 42 ? sig.slice(0, 40) + "…" : sig)}` : ""}</p>
<div class="drawer ${open ? "" : "hidden"}">
<div class="say-row">
<div class="pills">${pills}</div>
@@ -853,13 +873,16 @@
const card = btn.closest(".card");
const id = Number(card.dataset.id);
const lang = state.langById[id] || "en";
const sigEl = card.querySelector('[data-edit="significance"]');
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 }) });
const body = { locales };
if (sigEl) body.significance = sigEl.value.trim();
await api(`/api/names/${id}`, { method: "PATCH", body: JSON.stringify(body) });
state.openId = id;
state.editId = null;
await refresh();
@@ -956,6 +979,7 @@
function clearForm() {
document.getElementById("spelling").value = "";
document.getElementById("significance").value = "";
document.querySelectorAll("[data-loc][data-field]").forEach((el) => { el.value = ""; });
}
@@ -1132,6 +1156,7 @@
body: JSON.stringify({
kind: state.kind,
spelling,
significance: document.getElementById("significance").value.trim(),
locales: localePayload(),
}),
});
+17 -15
View File
@@ -21,11 +21,7 @@ INVITE_TOKEN = os.environ.get("STORK_INVITE_TOKEN", "").strip()
ADMIN_TOKEN = os.environ.get("STORK_ADMIN_TOKEN", "").strip()
COOKIE_SECURE = os.environ.get("STORK_COOKIE_SECURE", "false").lower() in {"1", "true", "yes"}
PUBLIC_BASE = os.environ.get("STORK_PUBLIC_URL", "https://stork.levkin.ca").rstrip("/")
READONLY_BOARD_IDS = {
x.strip()
for x in os.environ.get("STORK_READONLY_BOARD_IDS", "").split(",")
if x.strip()
}
READONLY_BOARD_IDS = {x.strip() for x in os.environ.get("STORK_READONLY_BOARD_IDS", "").split(",") if x.strip()}
def _env_int(name: str, default: int) -> int:
@@ -75,6 +71,7 @@ class LocaleIn(BaseModel):
class NameIn(BaseModel):
kind: str = Field(pattern=r"^c[1-4]$")
spelling: str = Field(min_length=1, max_length=80)
significance: str = Field(default="", max_length=500)
locales: dict[str, LocaleIn] = Field(default_factory=dict)
@@ -82,8 +79,9 @@ class ColumnPatch(BaseModel):
label: str = Field(default="", max_length=40)
class LocalesPatch(BaseModel):
locales: dict[str, LocaleIn]
class NamePatch(BaseModel):
locales: dict[str, LocaleIn] = Field(default_factory=dict)
significance: str | None = Field(default=None, max_length=500)
class VoteIn(BaseModel):
@@ -364,11 +362,7 @@ def delete_column(
@app.get("/api/names")
def list_names(kind: str = "c1", session: dict[str, str] = Depends(_session)) -> dict[str, Any]:
try:
return {
"items": store.list_names(
session["board_id"], kind, voter_key=session["voter_key"]
)
}
return {"items": store.list_names(session["board_id"], kind, voter_key=session["voter_key"])}
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
@@ -384,6 +378,7 @@ def add_name(body: NameIn, session: dict[str, str] = Depends(_session)) -> dict[
spelling=body.spelling,
created_by=session["display_name"],
locales=locales,
significance=body.significance,
)
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
@@ -392,12 +387,19 @@ def add_name(body: NameIn, session: dict[str, str] = Depends(_session)) -> dict[
@app.patch("/api/names/{name_id}")
def patch_name(
name_id: int,
body: LocalesPatch,
body: NamePatch,
session: dict[str, str] = Depends(_session),
) -> dict[str, Any]:
_assert_writable(session["board_id"])
locales = {k: v.model_dump() for k, v in body.locales.items()}
updated = store.update_locales(name_id, locales, board_id=session["board_id"])
locales = {k: v.model_dump() for k, v in body.locales.items()} if body.locales else None
if body.significance is None and not locales:
raise HTTPException(400, "nothing to update")
updated = store.update_name(
name_id,
locales=locales,
significance=body.significance,
board_id=session["board_id"],
)
if not updated:
raise HTTPException(404, "Name not found")
return updated
+59 -56
View File
@@ -44,6 +44,7 @@ CREATE TABLE IF NOT EXISTS names (
kind TEXT NOT NULL,
spelling TEXT NOT NULL,
created_by TEXT NOT NULL DEFAULT '',
significance TEXT NOT NULL DEFAULT '',
created_at REAL NOT NULL,
UNIQUE (board_id, kind, spelling COLLATE NOCASE)
);
@@ -106,6 +107,7 @@ class Store:
self._migrate_legacy_pre_boards()
self._migrate_attach_boards(bootstrap_invite.strip(), bootstrap_title.strip() or "Family")
self._conn.executescript(_SCHEMA)
self._migrate_significance_column()
self._conn.commit()
def close(self) -> None:
@@ -163,10 +165,7 @@ class Store:
)
"""
)
used = [
r["kind"]
for r in self._conn.execute("SELECT DISTINCT kind FROM names").fetchall()
]
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:
@@ -224,9 +223,7 @@ class Store:
).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()
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 = ?",
@@ -253,9 +250,7 @@ class Store:
(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()
row = self._conn.execute("SELECT id FROM boards WHERE invite_token = ?", (invite,)).fetchone()
board_id = row["id"]
if needs_cols:
@@ -295,14 +290,20 @@ class Store:
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():
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 _migrate_significance_column(self) -> None:
"""Add personal significance note on names (why this name matters to the family)."""
if not self._table_exists("names"):
return
cols = {r[1] for r in self._conn.execute("PRAGMA table_info(names)").fetchall()}
if "significance" not in cols:
self._conn.execute("ALTER TABLE names ADD COLUMN significance TEXT NOT NULL DEFAULT ''")
def _create_board_unlocked(self, title: str, invite_token: str) -> dict[str, Any]:
board_id = "b_" + secrets.token_urlsafe(8)
now = time.time()
@@ -327,9 +328,7 @@ class Store:
if not invite:
return None
with self._lock:
row = self._conn.execute(
"SELECT * FROM boards WHERE invite_token = ?", (invite,)
).fetchone()
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:
@@ -352,9 +351,7 @@ class Store:
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():
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(
@@ -369,9 +366,7 @@ class Store:
if not title:
raise ValueError("title required")
with self._lock:
cur = self._conn.execute(
"UPDATE boards SET title = ? WHERE id = ?", (title, board_id)
)
cur = self._conn.execute("UPDATE boards SET title = ? WHERE id = ?", (title, board_id))
if cur.rowcount == 0:
return None
self._conn.commit()
@@ -388,15 +383,10 @@ class Store:
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()
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()
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():
@@ -440,9 +430,7 @@ class Store:
return deleted
def _column_ids(self, board_id: str) -> set[str]:
rows = self._conn.execute(
"SELECT id FROM board_columns WHERE board_id = ?", (board_id,)
).fetchall()
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]:
@@ -537,9 +525,7 @@ class Store:
).fetchall()
]
for name_id in name_ids:
recs = self._conn.execute(
"SELECT path FROM recordings WHERE name_id = ?", (name_id,)
).fetchall()
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():
@@ -587,9 +573,7 @@ class Store:
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():
for row in self._conn.execute("SELECT lang FROM recordings WHERE name_id = ?", (name_id,)).fetchall():
flags[row["lang"]] = True
return flags
@@ -610,6 +594,7 @@ class Store:
"kind": row["kind"],
"spelling": row["spelling"],
"created_by": row["created_by"],
"significance": (row["significance"] if "significance" in row.keys() else "") or "",
"created_at": row["created_at"],
"locales": locales,
"recordings": self._recordings_flags(row["id"]),
@@ -689,11 +674,13 @@ class Store:
spelling: str,
created_by: str,
locales: dict[str, Any] | None = None,
significance: str = "",
) -> dict[str, Any]:
spelling = spelling.strip()
if not spelling or len(spelling) > 80:
raise ValueError("spelling required (1–80 chars)")
created_by = (created_by or "").strip()[:80]
significance = (significance or "").strip()[:500]
locs = self._normalize_locales(locales)
now = time.time()
with self._lock:
@@ -701,8 +688,11 @@ class Store:
raise ValueError("unknown column")
try:
cur = self._conn.execute(
"INSERT INTO names (board_id, kind, spelling, created_by, created_at) VALUES (?, ?, ?, ?, ?)",
(board_id, kind, spelling, created_by, now),
"""
INSERT INTO names (board_id, kind, spelling, created_by, significance, created_at)
VALUES (?, ?, ?, ?, ?, ?)
""",
(board_id, kind, spelling, created_by, significance, now),
)
except sqlite3.IntegrityError as exc:
raise ValueError("name already exists for this column") from exc
@@ -717,29 +707,36 @@ class Store:
assert result is not None
return result
def update_locales(
def update_name(
self,
name_id: int,
locales: dict[str, Any],
*,
locales: dict[str, Any] | None = None,
significance: str | None = None,
board_id: str | None = None,
) -> dict[str, Any] | None:
if not locales:
"""Patch locales and/or personal significance for a name."""
if significance is None and 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)
if locales:
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],
}
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
if significance is not None:
self._conn.execute(
"UPDATE names SET significance = ? WHERE id = ?",
((significance or "").strip()[:500], name_id),
)
for lang, payload in patch.items():
self._conn.execute(
"""
@@ -755,6 +752,14 @@ class Store:
self._conn.commit()
return self.get_name(name_id, board_id=board_id)
def update_locales(
self,
name_id: int,
locales: dict[str, Any],
board_id: str | None = None,
) -> dict[str, Any] | None:
return self.update_name(name_id, locales=locales, board_id=board_id)
def vote(
self,
name_id: int,
@@ -897,9 +902,7 @@ class Store:
def delete_name(self, name_id: int) -> bool:
with self._lock:
recs = self._conn.execute(
"SELECT path FROM recordings WHERE name_id = ?", (name_id,)
).fetchall()
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():
+23 -8
View File
@@ -284,14 +284,32 @@ def test_patch_locales(authed: TestClient) -> None:
assert patched.json()["locales"]["en"]["pronunciation"] == ""
def test_significance_on_add_and_patch(authed: TestClient) -> None:
created = authed.post(
"/api/names",
json={
"kind": "c1",
"spelling": "Levi",
"significance": "Grandfather’s Hebrew name",
},
).json()
assert created["significance"] == "Grandfather’s Hebrew name"
patched = authed.patch(
f"/api/names/{created['id']}",
json={"significance": "Named after Ilia’s grandfather"},
)
assert patched.status_code == 200
assert patched.json()["significance"] == "Named after Ilia’s grandfather"
listed = authed.get("/api/names?kind=c1").json()["items"]
levi = next(n for n in listed if n["spelling"] == "Levi")
assert levi["significance"] == "Named after Ilia’s grandfather"
def test_admin_delete_name(authed: TestClient) -> None:
created = authed.post("/api/names", json={"kind": "c1", "spelling": "Temp"}).json()
assert authed.delete(f"/api/names/{created['id']}").status_code == 403
assert (
authed.delete(
f"/api/names/{created['id']}", headers={"X-Stork-Admin": "test-admin-token"}
).status_code
== 200
authed.delete(f"/api/names/{created['id']}", headers={"X-Stork-Admin": "test-admin-token"}).status_code == 200
)
@@ -326,10 +344,7 @@ def test_readonly_board_blocks_mutations(tmp_path, monkeypatch) -> None:
"/api/session",
json={"board_id": board_id, "display_name": "Visitor"},
)
assert (
client2.post("/api/names", json={"kind": "c1", "spelling": "X"}).status_code
== 403
)
assert client2.post("/api/names", json={"kind": "c1", "spelling": "X"}).status_code == 403
assert client2.get("/api/names?kind=c1").status_code == 200
app_mod.store.close()
# close first store if still open
+11
View File
@@ -19,3 +19,14 @@ def test_store_ranks_by_score_then_alpha(tmp_path: Path) -> None:
ranked = store.list_names(board_id, "c1")
assert [n["spelling"] for n in ranked] == ["Bo", "Zed", "Ann"]
store.close()
def test_store_significance(tmp_path: Path) -> None:
store = Store(tmp_path / "sig.sqlite3", bootstrap_invite="sig-invite")
board_id = store.list_boards()[0]["id"]
n = store.add_name(board_id, "c1", "Noa", "x", significance="Aunt Mira’s middle name")
assert n["significance"] == "Aunt Mira’s middle name"
updated = store.update_name(n["id"], significance="Family favorite", board_id=board_id)
assert updated is not None
assert updated["significance"] == "Family favorite"
store.close()