Files
stork/static/v2.html
T
ilia 126a0a245b
CI / secret-scan (pull_request) Successful in 32s
CI / python-ci (pull_request) Successful in 52s
Expand board help and add a phone-friendly Help control
Same dialog for keyboard ?, header ?, and footer Help: sharing,
votes, columns, and notes/voice icons in one place.
2026-08-07 16:09:52 -04:00

1147 lines
46 KiB
HTML

<!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); }
.who-actions {
display: inline-flex; flex-wrap: wrap; gap: 0.45rem; align-items: center;
}
.boards-row {
display: flex; gap: 0.75rem; align-items: stretch; margin-bottom: 1rem;
min-width: 0;
}
/* Always side-by-side; swipe/scroll sideways on phone instead of stacking. */
.boards {
display: flex; gap: 1rem; flex: 1; min-width: 0;
overflow-x: auto;
overflow-y: hidden;
overscroll-behavior-x: contain;
-webkit-overflow-scrolling: touch;
scroll-snap-type: x mandatory;
padding-bottom: 0.35rem;
scrollbar-width: thin;
}
.boards .sheet {
margin-bottom: 0;
flex: 0 0 auto;
width: min(18.5rem, calc(100vw - 5.25rem));
max-width: 22rem;
scroll-snap-align: start;
}
@media (min-width: 860px) {
.boards.cols-2 .sheet {
flex: 1 1 0;
width: auto;
min-width: 16rem;
max-width: none;
}
.boards.cols-3 .sheet,
.boards.cols-4 .sheet {
flex: 1 1 0;
width: auto;
min-width: 14rem;
max-width: none;
}
}
.add-col {
flex: 0 0 auto;
align-self: stretch;
min-width: 2.75rem;
writing-mode: horizontal-tb;
background: var(--sheet);
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.recording {
background: var(--rec); color: #fff; border-color: var(--rec);
animation: pulse 1.2s ease-in-out infinite;
}
@keyframes pulse {
50% { opacity: 0.75; }
}
.icon-btn, .play {
width: 2rem; height: 2rem; padding: 0;
display: inline-grid; place-items: center;
background: transparent; color: var(--ink); border: 1px solid var(--rule);
font-size: 0.95rem; line-height: 1;
}
.play.playing { background: var(--ink); color: var(--paper); }
.empty { color: var(--muted); font-size: 0.92rem; padding: 0.35rem 0; }
.help-dialog {
border: 1px solid var(--rule);
background: var(--sheet);
color: var(--ink);
padding: 0;
max-width: min(22rem, calc(100vw - 2rem));
box-shadow: 0 10px 36px rgba(20, 20, 20, 0.18);
}
.help-dialog::backdrop { background: rgba(20, 18, 14, 0.35); }
.help-dialog .help-inner { padding: 1rem 1.1rem 1.05rem; }
.help-dialog h2 {
font-family: "Libre Baskerville", Georgia, serif;
font-size: 1.05rem; margin: 0 0 0.55rem;
}
.help-dialog p { margin: 0 0 0.55rem; color: var(--muted); font-size: 0.9rem; line-height: 1.4; }
.help-dialog ul {
margin: 0 0 0.65rem; padding-left: 1.1rem;
color: var(--muted); font-size: 0.88rem; line-height: 1.45;
}
.help-dialog h3 {
font-family: "Source Sans 3", sans-serif;
font-size: 0.78rem; font-weight: 600; letter-spacing: 0.04em;
text-transform: uppercase; color: var(--ink);
margin: 0.75rem 0 0.35rem;
}
.help-dialog h3:first-of-type { margin-top: 0.15rem; }
.help-dialog kbd {
font-family: "Source Sans 3", sans-serif;
font-size: 0.85rem; font-weight: 600;
border: 1px solid var(--rule); padding: 0.05rem 0.35rem;
background: var(--input-bg); color: var(--ink);
}
.help-dialog .row { margin-top: 0.25rem; }
button.help-link, a.help-link {
all: unset; cursor: pointer; color: var(--ink);
text-decoration: underline; text-underline-offset: 2px;
font-size: inherit;
}
button.help-link:focus-visible {
outline: 3px solid var(--focus); outline-offset: 2px;
}
.who .help-chip {
all: unset; cursor: pointer;
display: inline-grid; place-items: center;
width: 1.85rem; height: 1.85rem;
border: 1px solid var(--rule); color: var(--muted);
font-family: "Source Sans 3", sans-serif;
font-size: 0.95rem; font-weight: 600;
vertical-align: middle;
}
.who .help-chip:hover, .who .help-chip:focus-visible {
color: var(--ink); border-color: var(--ink);
}
.who .help-chip:focus-visible {
outline: 3px solid var(--focus); outline-offset: 2px;
}
.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); }
.foot kbd, .hint kbd {
font-family: "Source Sans 3", sans-serif;
font-size: 0.8rem; font-weight: 600;
border: 1px solid var(--rule); padding: 0.05rem 0.3rem;
background: var(--input-bg); 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 class="who-actions">
<button type="button" class="help-chip" data-open-help aria-label="Help" title="Help">?</button>
<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 · <button type="button" class="help-link" data-open-help>Help</button> · press <kbd>?</kbd> · <a href="/v1">classic layout</a></p>
</main>
<dialog id="help-dialog" class="help-dialog" aria-labelledby="help-title">
<div class="help-inner">
<h2 id="help-title">How this board works</h2>
<h3>Sharing</h3>
<ul>
<li>The <strong>/b/…</strong> link is the key — no password. Use <strong>Copy share link</strong>.</li>
<li>Everyone picks a display name; it shows on votes.</li>
</ul>
<h3>Names &amp; votes</h3>
<ul>
<li>Tap a name to expand. ▲ / ▼ rank it (tap again to clear).</li>
<li>Suggest names below; pick which column with the toggles.</li>
</ul>
<h3>Columns</h3>
<ul>
<li>Rename a column title in place. <strong>+</strong> adds another (up to 4).</li>
<li><strong></strong> removes an extra column (the first stays).</li>
<li>On phone, swipe sideways between columns.</li>
</ul>
<h3>Notes &amp; voice</h3>
<ul>
<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>
</ul>
<div class="row">
<button type="button" id="help-close">Close</button>
<span class="hint" style="margin:0">Esc or <kbd>?</kbd></span>
</div>
</div>
</dialog>
<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 isTypingTarget(el) {
if (!el || !(el instanceof Element)) return false;
const tag = el.tagName;
return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || el.isContentEditable;
}
function openHelp() {
const dlg = document.getElementById("help-dialog");
if (dlg && !dlg.open) dlg.showModal();
}
function toggleHelp() {
const dlg = document.getElementById("help-dialog");
if (!dlg) return;
if (dlg.open) dlg.close();
else dlg.showModal();
}
document.getElementById("help-close").addEventListener("click", () => {
document.getElementById("help-dialog").close();
});
document.getElementById("help-dialog").addEventListener("click", (e) => {
if (e.target === e.currentTarget) e.currentTarget.close();
});
document.querySelectorAll("[data-open-help]").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.preventDefault();
openHelp();
});
});
document.addEventListener("keydown", (e) => {
if (e.key === "?" || (e.key === "/" && e.shiftKey)) {
if (isTypingTarget(e.target)) return;
e.preventDefault();
toggleHelp();
}
});
function colLabel(col) {
const label = (col && col.label || "").trim();
return label || "Untitled";
}
function params() { return new URLSearchParams(location.search); }
async function api(path, opts = {}) {
const headers = { ...(opts.headers || {}) };
if (opts.body && !(opts.body instanceof FormData) && !headers["Content-Type"]) {
headers["Content-Type"] = "application/json";
}
const res = await fetch(path, {
credentials: "include",
headers,
...opts,
});
const text = await res.text();
let data = null;
try { data = text ? JSON.parse(text) : null; } catch { data = { detail: text }; }
if (!res.ok) {
const detail = (data && (data.detail || data.message)) || res.statusText;
throw new Error(StorkUI.formatError(detail));
}
return data;
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, (c) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"
})[c]);
}
function stopPlayback() {
if (window.speechSynthesis) window.speechSynthesis.cancel();
if (audioEl) {
audioEl.pause();
audioEl.src = "";
audioEl = null;
}
if (speakingBtn) { speakingBtn.classList.remove("playing"); speakingBtn = null; }
}
function formFor(n, lang) {
const L = (n.locales && n.locales[lang]) || {};
return (L.pronunciation || "").trim() || (lang === "en" ? n.spelling : "");
}
function playName(n, lang, btn) {
stopPlayback();
const hasRec = n.recordings && n.recordings[lang];
speakingBtn = btn;
btn.classList.add("playing");
const done = () => { btn.classList.remove("playing"); speakingBtn = null; };
if (hasRec) {
audioEl = new Audio(`/api/names/${n.id}/recording/${lang}?t=${Date.now()}`);
audioEl.onended = audioEl.onerror = done;
audioEl.play().catch(done);
return;
}
if (!window.speechSynthesis) { done(); return; }
const say = formFor(n, lang);
if (!say) { done(); return; }
const u = new SpeechSynthesisUtterance(say);
u.lang = SPEECH_LANG[lang] || "en-US";
u.rate = 0.92;
const voices = speechSynthesis.getVoices();
const match = voices.find((v) => v.lang === u.lang)
|| voices.find((v) => v.lang && v.lang.startsWith(u.lang.slice(0, 2)));
if (match) u.voice = match;
u.onend = u.onerror = done;
speechSynthesis.speak(u);
}
function itemsFor(kind) {
return state.lists[kind] || [];
}
function renderKindToggle() {
const root = document.getElementById("kind-toggle");
if (!state.columns.some((c) => c.id === state.kind)) {
state.kind = (state.columns[0] && state.columns[0].id) || "c1";
}
root.innerHTML = state.columns.map((c) =>
`<button type="button" class="ghost ${state.kind === c.id ? "active" : ""}" data-kind="${c.id}" aria-pressed="${state.kind === c.id}">${escapeHtml(colLabel(c))}</button>`
).join("");
root.querySelectorAll("button").forEach((btn) => {
btn.addEventListener("click", () => {
state.kind = btn.dataset.kind;
renderKindToggle();
});
});
}
function renderBoards() {
const boards = document.getElementById("boards");
boards.className = `boards cols-${state.columns.length}`;
boards.innerHTML = state.columns.map((col) => `
<section class="sheet" data-col="${col.id}">
<h2 class="col-title">
<input class="title-edit" data-rename="${col.id}" value="${escapeHtml(col.label || "")}" placeholder="Name this column" aria-label="Column title" maxlength="40" />
<span class="meta-count" id="count-${col.id}">0</span>
${col.id !== state.columns[0].id ? `<button type="button" class="remove-col" data-remove="${col.id}" title="Remove column" aria-label="Remove column">✕</button>` : ""}
</h2>
<div id="list-${col.id}"></div>
</section>
`).join("");
boards.querySelectorAll("[data-rename]").forEach((input) => {
const save = async () => {
const id = input.dataset.rename;
const label = input.value.trim();
try {
const updated = await api(`/api/columns/${id}`, {
method: "PATCH",
body: JSON.stringify({ label }),
});
const col = state.columns.find((c) => c.id === id);
if (col) col.label = updated.label;
renderKindToggle();
} catch (e) {
alert(e.message || "Could not rename column");
}
};
input.addEventListener("change", save);
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") { e.preventDefault(); input.blur(); }
});
});
boards.querySelectorAll("[data-remove]").forEach((btn) => {
btn.addEventListener("click", async () => {
const id = btn.dataset.remove;
const col = state.columns.find((c) => c.id === id);
const title = colLabel(col || { label: "" });
const n = col && col.name_count ? col.name_count : 0;
const msg = n
? `Remove column “${title}” and its ${n} name(s)?`
: `Remove column “${title}”?`;
if (!confirm(msg)) return;
try {
const res = await api(`/api/columns/${id}`, { method: "DELETE" });
state.columns = res.items || [];
if (state.kind === id) state.kind = state.columns[0]?.id || "c1";
renderBoards();
renderKindToggle();
await refresh();
} catch (e) {
alert(e.message || "Could not remove column");
}
});
});
const addBtn = document.getElementById("add-col");
addBtn.disabled = state.columns.length >= MAX_COLUMNS;
addBtn.title = addBtn.disabled ? "Maximum 4 columns" : "Add another name column";
state.columns.forEach((col) => renderList(col.id));
}
function renderCard(n, kind) {
const open = state.openId === n.id;
const editing = state.editId === n.id;
const lang = state.langById[n.id] || "en";
const L = (n.locales && n.locales[lang]) || {};
const form = formFor(n, lang);
const hasRec = !!(n.recordings && n.recordings[lang]);
const hasNotes = !!(L.origin || L.meaning || L.pronunciation);
const pills = ["en", "ru", "he"].map((l) =>
`<button type="button" class="${lang === l ? "active" : ""}" data-lang="${l}">${l}</button>`
).join("");
const recActive = state.recording && state.recording.nameId === n.id && state.recording.lang === lang;
const spoken = L.pronunciation || form || "—";
const notesView = `
<div class="notes-view">
<span class="k">Spoken (${lang})</span>
<div dir="auto">${escapeHtml(spoken)}</div>
<span class="k">Origin</span>
<div dir="auto">${L.origin ? escapeHtml(L.origin) : `<span class="empty-hint">Not set</span>`}</div>
<span class="k">Meaning</span>
<div dir="auto">${L.meaning ? escapeHtml(L.meaning) : `<span class="empty-hint">Not set</span>`}</div>
</div>`;
const notesEdit = `
<div class="edit-grid">
<div>
<label for="pron-${n.id}">Spoken form (${lang})</label>
<input id="pron-${n.id}" data-edit="pronunciation" value="${escapeHtml(L.pronunciation || "")}" dir="auto" placeholder="${escapeHtml(form || n.spelling)}" />
</div>
<div>
<label for="orig-${n.id}">Origin</label>
<input id="orig-${n.id}" data-edit="origin" value="${escapeHtml(L.origin || "")}" dir="auto" placeholder="Language / root" />
</div>
<div>
<label for="mean-${n.id}">Meaning</label>
<textarea id="mean-${n.id}" data-edit="meaning" dir="auto" placeholder="What it means">${escapeHtml(L.meaning || "")}</textarea>
</div>
</div>`;
return `
<article class="card" data-id="${n.id}" data-kind="${kind}">
<div class="score">${n.score}</div>
<div>
<button type="button" class="name-btn" data-toggle dir="auto">${escapeHtml(n.spelling)}</button>
<p class="meta">${escapeHtml(n.created_by || "someone")} · ${n.vote_count}${hasRec ? " · voice" : ""}</p>
<div class="drawer ${open ? "" : "hidden"}">
<div class="say-row">
<div class="pills">${pills}</div>
<button type="button" class="play" data-play aria-label="Play pronunciation" title="Play">▶</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="icon-btn" data-save-notes aria-label="Save notes" title="Save">✓</button>`
: `<button type="button" class="icon-btn" data-edit-notes aria-label="Edit notes" title="Edit notes">✎</button>`}
<button type="button" class="icon-btn ${recActive ? "recording" : ""}" data-record aria-label="${recActive ? "Stop recording" : (hasRec ? "Re-record" : "Record")}" title="${recActive ? "Stop" : (hasRec ? "Re-record" : "Record")}">${recActive ? "■" : "●"}</button>
${hasRec ? `<button type="button" class="icon-btn" data-clear-rec aria-label="Use AI speech" title="Use AI speech">↺</button>` : ""}
</div>
</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>