Files
stork/static/index.html
T
ilia 3f9e04d192
CI / secret-scan (pull_request) Successful in 31s
CI / python-ci (pull_request) Successful in 57s
Refresh board UI with dual columns and pronunciation playback.
Show first and middle names side by side, tighten the visual identity, and add Web Speech play buttons per language.
2026-08-06 17:01:07 -04:00

604 lines
21 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Stork — family name board</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 {
--sky: #d6e4ef;
--fog: #eef3f7;
--paper: #f8faf8;
--ink: #18253a;
--muted: #5a6a7e;
--line: #c5d3e0;
--accent: #2a5f7a;
--wing: #b85c48;
--up: #2f6b5a;
--down: #9a4a3a;
--rank: #1e3a55;
--shadow: 0 12px 40px rgba(24, 37, 58, 0.08);
--radius: 18px;
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
margin: 0;
color: var(--ink);
font-family: "Source Sans 3", system-ui, sans-serif;
background:
radial-gradient(900px 420px at 85% -5%, #f0d8cf 0%, transparent 55%),
radial-gradient(1100px 520px at -5% 0%, var(--sky) 0%, transparent 50%),
linear-gradient(180deg, #e7eef4 0%, var(--fog) 40%, #e9eee8 100%);
min-height: 100vh;
}
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
.name-card { transition: none; }
}
main { max-width: 1100px; margin: 0 auto; padding: 1.5rem 1rem 4rem; }
.hero {
display: grid;
gap: 0.35rem;
margin-bottom: 1.25rem;
animation: rise 0.7s ease both;
}
@keyframes rise {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: none; }
}
.brand {
font-family: Fraunces, Georgia, serif;
font-size: clamp(2.4rem, 6vw, 3.4rem);
font-weight: 700;
letter-spacing: -0.03em;
margin: 0;
line-height: 1;
}
.brand span {
color: var(--wing);
font-style: italic;
font-weight: 500;
}
.sub { margin: 0; color: var(--muted); max-width: 36rem; font-size: 1.05rem; }
.panel {
background: color-mix(in srgb, var(--paper) 92%, white);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 1.1rem 1.15rem;
margin-bottom: 1rem;
box-shadow: var(--shadow);
backdrop-filter: blur(6px);
}
label { display: block; font-size: 0.85rem; color: var(--muted); margin-bottom: 0.3rem; font-weight: 600; }
input, select, textarea, button {
font: inherit;
width: 100%;
padding: 0.7rem 0.8rem;
border-radius: 12px;
border: 1px solid var(--line);
background: #fff;
color: var(--ink);
}
textarea { min-height: 3.2rem; resize: vertical; }
button {
width: auto;
cursor: pointer;
background: var(--accent);
color: #fff;
border-color: transparent;
font-weight: 600;
}
button:focus-visible, input:focus-visible, textarea:focus-visible, select:focus-visible {
outline: 2px solid var(--wing);
outline-offset: 2px;
}
button.secondary { background: #fff; color: var(--ink); border-color: var(--line); }
button.icon {
width: 2rem;
height: 2rem;
padding: 0;
display: inline-grid;
place-items: center;
border-radius: 999px;
background: #fff;
color: var(--accent);
border: 1px solid var(--line);
flex: 0 0 auto;
}
button.icon:hover { border-color: var(--accent); }
button.icon.playing { background: var(--accent); color: #fff; }
button.vote {
min-width: 2.4rem;
padding: 0.35rem 0.5rem;
background: #fff;
color: var(--ink);
border: 1px solid var(--line);
}
button.vote.active-up { background: var(--up); color: #fff; border-color: transparent; }
button.vote.active-down { background: var(--down); color: #fff; border-color: transparent; }
.row { display: flex; gap: 0.55rem; flex-wrap: wrap; align-items: end; }
.who {
display: flex;
justify-content: space-between;
gap: 0.75rem;
align-items: baseline;
flex-wrap: wrap;
margin-bottom: 0.85rem;
color: var(--muted);
font-size: 0.95rem;
}
.boards {
display: grid;
gap: 1rem;
margin-bottom: 1rem;
}
@media (min-width: 860px) {
.boards { grid-template-columns: 1fr 1fr; align-items: start; }
}
.col-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.75rem;
padding-bottom: 0.55rem;
border-bottom: 1px solid var(--line);
}
.col-head h2 {
font-family: Fraunces, Georgia, serif;
font-size: 1.35rem;
margin: 0;
font-weight: 700;
}
.col-head .count {
color: var(--muted);
font-size: 0.85rem;
font-weight: 600;
}
.name-card {
display: grid;
grid-template-columns: 2.5rem 1fr auto;
gap: 0.65rem;
align-items: start;
padding: 0.8rem 0.15rem;
border-top: 1px solid color-mix(in srgb, var(--line) 70%, transparent);
transition: background 0.15s ease;
}
.name-card:first-of-type { border-top: 0; }
.score {
font-family: Fraunces, Georgia, serif;
font-variant-numeric: tabular-nums;
font-weight: 700;
font-size: 1.35rem;
color: var(--rank);
text-align: center;
line-height: 1.2;
padding-top: 0.15rem;
}
.spelling {
font-family: Fraunces, Georgia, serif;
font-size: 1.35rem;
margin: 0 0 0.15rem;
letter-spacing: -0.02em;
}
.meta { color: var(--muted); font-size: 0.88rem; margin: 0 0 0.35rem; }
.langs { margin-top: 0.35rem; }
.langs summary {
cursor: pointer;
color: var(--accent);
font-weight: 600;
font-size: 0.9rem;
list-style: none;
}
.langs summary::-webkit-details-marker { display: none; }
.langs summary::before { content: "▸ "; }
.langs[open] summary::before { content: "▾ "; }
.lang-row {
display: grid;
gap: 0.25rem;
margin: 0.55rem 0;
padding: 0.55rem 0.65rem;
background: color-mix(in srgb, var(--sky) 35%, white);
border-radius: 12px;
}
.lang-top {
display: flex;
align-items: center;
gap: 0.45rem;
flex-wrap: wrap;
}
.lang-tag {
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--accent);
background: #fff;
border: 1px solid var(--line);
border-radius: 999px;
padding: 0.1rem 0.45rem;
}
.say {
font-family: Fraunces, Georgia, serif;
font-size: 1rem;
margin: 0;
flex: 1;
}
.lang-detail { margin: 0; color: var(--muted); font-size: 0.88rem; }
.votes { display: flex; flex-direction: column; gap: 0.3rem; }
.error { color: var(--down); margin: 0.5rem 0 0; }
.hidden { display: none !important; }
.empty {
color: var(--muted);
padding: 0.75rem 0.2rem 0.35rem;
font-size: 0.95rem;
}
.compose-head {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: end;
justify-content: space-between;
margin-bottom: 0.85rem;
}
.compose-head h2 {
font-family: Fraunces, Georgia, serif;
margin: 0;
font-size: 1.25rem;
}
.kind-toggle { display: flex; gap: 0.35rem; }
.kind-toggle button { min-width: 5.5rem; }
.kind-toggle button.active {
background: var(--ink);
color: #fff;
border-color: transparent;
}
.grid-3 { display: grid; gap: 0.65rem; }
@media (min-width: 720px) {
.grid-3 { grid-template-columns: repeat(3, 1fr); }
}
.hint { color: var(--muted); font-size: 0.85rem; margin: 0.35rem 0 0; }
.wing-mark {
width: 42px;
height: 42px;
border-radius: 50%;
background: var(--ink);
color: #f4ebe4;
display: grid;
place-items: center;
font-family: Fraunces, Georgia, serif;
font-style: italic;
font-size: 1.2rem;
margin-bottom: 0.65rem;
}
</style>
</head>
<body>
<main>
<header class="hero">
<div class="wing-mark" aria-hidden="true">ש</div>
<h1 class="brand">Stork <span>names</span></h1>
<p class="sub">Family board for first and middle names — vote, hear how they sound, and keep notes in English, Russian, and Hebrew.</p>
</header>
<section id="gate" class="panel">
<label for="invite">Invite code</label>
<input id="invite" autocomplete="off" />
<label for="display" style="margin-top:0.75rem">Your name (shown with your ideas and votes)</label>
<input id="display" autocomplete="nickname" />
<p class="error hidden" id="gate-error"></p>
<div class="row" style="margin-top:0.75rem">
<button id="enter" type="button">Enter the board</button>
</div>
</section>
<section id="board" class="hidden">
<div class="who">
<span id="who"></span>
<a href="#suggest" style="color:var(--accent);font-weight:600;text-decoration:none">Suggest a name ↓</a>
</div>
<div class="boards">
<section class="panel" aria-labelledby="first-heading">
<div class="col-head">
<h2 id="first-heading">First names</h2>
<span class="count" id="first-count"></span>
</div>
<div id="list-first"></div>
</section>
<section class="panel" aria-labelledby="middle-heading">
<div class="col-head">
<h2 id="middle-heading">Middle names</h2>
<span class="count" id="middle-count"></span>
</div>
<div id="list-middle"></div>
</section>
</div>
<div class="panel" id="suggest">
<div class="compose-head">
<h2>Suggest a name</h2>
<div class="kind-toggle" role="group" aria-label="Name type">
<button type="button" class="secondary active" data-kind="first">First</button>
<button type="button" class="secondary" data-kind="middle">Middle</button>
</div>
</div>
<label for="spelling">Spelling</label>
<input id="spelling" placeholder="How you want it written" />
<p class="hint">Optional notes below — pronunciation can be played aloud on the board.</p>
<div class="grid-3" style="margin-top:0.75rem">
<div>
<label>English pronunciation</label>
<input data-loc="en" data-field="pronunciation" placeholder="e.g. SHY" />
<label style="margin-top:0.5rem">Origin</label>
<input data-loc="en" data-field="origin" />
<label style="margin-top:0.5rem">Meaning</label>
<textarea data-loc="en" data-field="meaning"></textarea>
</div>
<div>
<label>Russian pronunciation</label>
<input data-loc="ru" data-field="pronunciation" placeholder="e.g. Шай" />
<label style="margin-top:0.5rem">Origin</label>
<input data-loc="ru" data-field="origin" />
<label style="margin-top:0.5rem">Meaning</label>
<textarea data-loc="ru" data-field="meaning"></textarea>
</div>
<div>
<label>Hebrew pronunciation</label>
<input data-loc="he" data-field="pronunciation" placeholder="e.g. שַׁי" dir="auto" />
<label style="margin-top:0.5rem">Origin</label>
<input data-loc="he" data-field="origin" dir="auto" />
<label style="margin-top:0.5rem">Meaning</label>
<textarea data-loc="he" data-field="meaning" dir="auto"></textarea>
</div>
</div>
<p class="error hidden" id="add-error"></p>
<div class="row" style="margin-top:0.75rem">
<button id="add" type="button">Add to board</button>
</div>
</div>
</section>
</main>
<script>
const state = { kind: "first", first: [], middle: [] };
const SPEECH_LANG = { en: "en-US", ru: "ru-RU", he: "he-IL" };
let speakingBtn = null;
function params() {
return new URLSearchParams(location.search);
}
async function api(path, opts = {}) {
const res = await fetch(path, {
credentials: "include",
headers: { "Content-Type": "application/json", ...(opts.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(typeof detail === "string" ? detail : JSON.stringify(detail));
}
return data;
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, (c) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"
})[c]);
}
function ttsText(pronunciation, spelling) {
const raw = (pronunciation || "").trim() || (spelling || "").trim();
if (!raw) return "";
// Prefer the lead cue before long parenthetical notes.
return raw.split(/[—(]/)[0].replace(/\s+/g, " ").trim();
}
function stopSpeech() {
if (window.speechSynthesis) window.speechSynthesis.cancel();
if (speakingBtn) {
speakingBtn.classList.remove("playing");
speakingBtn = null;
}
}
function playPronunciation(lang, pronunciation, spelling, btn) {
if (!window.speechSynthesis) {
alert("This browser cannot play speech aloud. Try Chrome or Safari.");
return;
}
const text = ttsText(pronunciation, spelling);
if (!text) return;
stopSpeech();
const u = new SpeechSynthesisUtterance(text);
u.lang = SPEECH_LANG[lang] || "en-US";
u.rate = 0.9;
const voices = speechSynthesis.getVoices();
const match = voices.find((v) => v.lang === u.lang)
|| voices.find((v) => v.lang && v.lang.startsWith(u.lang.slice(0, 2)));
if (match) u.voice = match;
speakingBtn = btn;
btn.classList.add("playing");
u.onend = () => { btn.classList.remove("playing"); speakingBtn = null; };
u.onerror = () => { btn.classList.remove("playing"); speakingBtn = null; };
speechSynthesis.speak(u);
}
function renderLangs(n) {
const locales = n.locales || {};
const blocks = ["en", "ru", "he"].map((lang) => {
const L = locales[lang] || {};
const has = L.pronunciation || L.origin || L.meaning;
if (!has) return "";
const say = L.pronunciation || n.spelling;
const details = [
L.origin && `Origin: ${escapeHtml(L.origin)}`,
L.meaning && escapeHtml(L.meaning),
].filter(Boolean).join(" · ");
return `
<div class="lang-row">
<div class="lang-top">
<span class="lang-tag">${lang}</span>
<p class="say" dir="auto">${escapeHtml(say)}</p>
<button type="button" class="icon" data-play="${lang}"
data-pronunciation="${escapeHtml(L.pronunciation || "")}"
data-spelling="${escapeHtml(n.spelling)}"
aria-label="Play ${lang} pronunciation">▶</button>
</div>
${details ? `<p class="lang-detail" dir="auto">${details}</p>` : ""}
</div>`;
}).filter(Boolean).join("");
if (!blocks) return `<p class="meta">No notes yet</p>`;
return `<div class="langs"><details><summary>Pronunciation, origin & meaning</summary>${blocks}</details></div>`;
}
function renderList(kind, items) {
const root = document.getElementById(kind === "first" ? "list-first" : "list-middle");
const count = document.getElementById(kind === "first" ? "first-count" : "middle-count");
count.textContent = items.length ? `${items.length} on the board` : "empty";
if (!items.length) {
root.innerHTML = `<p class="empty">No ${kind} names yet — add one below.</p>`;
return;
}
root.innerHTML = items.map((n) => `
<article class="name-card" data-id="${n.id}" data-kind="${kind}">
<div class="score" title="Score">${n.score}</div>
<div>
<p class="spelling" dir="auto">${escapeHtml(n.spelling)}</p>
<p class="meta">${escapeHtml(n.created_by || "someone")} · ${n.vote_count} vote(s)</p>
${renderLangs(n)}
</div>
<div class="votes">
<button type="button" class="vote ${n.my_vote === 1 ? "active-up" : ""}" data-vote="1" aria-label="Upvote">▲</button>
<button type="button" class="vote ${n.my_vote === -1 ? "active-down" : ""}" data-vote="-1" aria-label="Downvote">▼</button>
</div>
</article>`).join("");
root.querySelectorAll("button[data-vote]").forEach((btn) => {
btn.addEventListener("click", async () => {
const card = btn.closest(".name-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("button[data-play]").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
playPronunciation(
btn.dataset.play,
btn.dataset.pronunciation,
btn.dataset.spelling,
btn,
);
});
});
}
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 = ""; });
}
async function refresh() {
const [first, middle] = await Promise.all([
api("/api/names?kind=first"),
api("/api/names?kind=middle"),
]);
state.first = first.items || [];
state.middle = middle.items || [];
renderList("first", state.first);
renderList("middle", state.middle);
}
async function showBoard(displayName) {
document.getElementById("gate").classList.add("hidden");
document.getElementById("board").classList.remove("hidden");
document.getElementById("who").textContent = `Signed in as ${displayName}`;
// Chrome loads voices async
if (window.speechSynthesis) speechSynthesis.getVoices();
await refresh();
}
document.querySelectorAll(".kind-toggle button").forEach((btn) => {
btn.addEventListener("click", () => {
state.kind = btn.dataset.kind;
document.querySelectorAll(".kind-toggle button").forEach((b) => b.classList.toggle("active", b === btn));
});
});
document.getElementById("enter").addEventListener("click", async () => {
const err = document.getElementById("gate-error");
err.classList.add("hidden");
try {
const invite = document.getElementById("invite").value.trim();
const display_name = document.getElementById("display").value.trim();
const data = await api("/api/session", {
method: "POST",
body: JSON.stringify({ invite, display_name }),
});
const url = new URL(location.href);
url.searchParams.delete("invite");
history.replaceState({}, "", url.pathname + (url.search || ""));
await showBoard(data.display_name);
} catch (e) {
err.textContent = e.message;
err.classList.remove("hidden");
}
});
document.getElementById("add").addEventListener("click", async () => {
const err = document.getElementById("add-error");
err.classList.add("hidden");
try {
await api("/api/names", {
method: "POST",
body: JSON.stringify({
kind: state.kind,
spelling: document.getElementById("spelling").value.trim(),
locales: localePayload(),
}),
});
clearForm();
await refresh();
} catch (e) {
err.textContent = e.message;
err.classList.remove("hidden");
}
});
if (window.speechSynthesis) {
speechSynthesis.onvoiceschanged = () => speechSynthesis.getVoices();
}
(async () => {
const invite = params().get("invite");
if (invite) document.getElementById("invite").value = invite;
try {
const session = await api("/api/session");
if (session.authenticated) await showBoard(session.display_name);
} catch (_) { /* stay on gate */ }
})();
</script>
</body>
</html>