Files
stork/static/index.html
T
ilia 427544e06b
CI / secret-scan (pull_request) Successful in 34s
CI / python-ci (pull_request) Successful in 1m23s
Fix phone layout so name columns scroll sideways
Keep columns side-by-side with horizontal snap-scroll instead of
stacking the second column under the first on small screens.
2026-08-07 14:58:13 -04:00

569 lines
22 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=Fraunces:opsz,wght@9..144,500;9..144,700&family=Source+Sans+3:wght@400;600;700&display=swap" rel="stylesheet" />
<style>
:root, [data-theme="light"] {
--sky: #d6e4ef; --fog: #eef3f7; --paper: #f8faf8; --ink: #18253a; --muted: #4a5566;
--line: #c5d3e0; --accent: #2a5f7a; --wing: #b85c48; --up: #2f6b5a; --down: #9a4a3a;
--rank: #1e3a55; --shadow: 0 10px 32px rgba(24,37,58,.07); --radius: 16px;
--input-bg: #fff; --panel: #f8faf8; --logo-filter: none;
--body-bg: radial-gradient(900px 420px at 85% -5%, #f0d8cf 0%, transparent 55%),
radial-gradient(1100px 520px at -5% 0%, var(--sky) 0%, transparent 50%),
linear-gradient(180deg, #e7eef4 0%, var(--fog) 45%, #e9eee8 100%);
}
[data-theme="dark"] {
--sky: #1a2433; --fog: #121820; --paper: #161c26; --ink: #e8eef6; --muted: #a8b4c4;
--line: #2e3a4c; --accent: #7eb6d1; --wing: #e0a090; --up: #5fad93; --down: #d48474;
--rank: #c5d4e8; --shadow: 0 10px 32px rgba(0,0,0,.35);
--input-bg: #0f141c; --panel: #1a222e; --logo-filter: invert(1) contrast(1.05);
--body-bg: radial-gradient(900px 420px at 85% -5%, #2a2030 0%, transparent 55%),
radial-gradient(1100px 520px at -5% 0%, #1a2433 0%, transparent 50%),
linear-gradient(180deg, #10151c 0%, #121820 45%, #141a22 100%);
}
.skip{position:absolute;left:-9999px;top:0;background:var(--ink);color:var(--paper);padding:.5rem .75rem;z-index:10}
.skip:focus{left:.5rem;top:.5rem}
.theme-bar{display:flex;gap:.35rem;flex-wrap:wrap;align-items:center;margin:0 0 .85rem;justify-content:flex-end}
.theme-bar span{color:var(--muted);font-size:.9rem;margin-right:.25rem}
.theme-bar button{padding:.35rem .65rem;font-size:.85rem;min-height:2.25rem;background:var(--input-bg);color:var(--ink);border:1px solid var(--line)}
.theme-bar button.active{background:var(--ink);color:var(--paper);border-color:var(--ink)}
.error{color:var(--down);margin:.45rem 0 0;font-size:.95rem;font-weight:600;border-left:3px solid var(--down);padding-left:.55rem}
input[aria-invalid="true"]{border-color:var(--down);box-shadow:0 0 0 1px var(--down)}
* { box-sizing: border-box; }
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: 1000px; margin: 0 auto; padding: 1.25rem 1rem 3.5rem; }
.hero {
display: flex;
gap: 0.85rem;
align-items: center;
margin-bottom: 1rem;
}
.hero img {
width: 56px;
height: 56px;
border-radius: 50%;
object-fit: cover;
border: 1px solid var(--line);
background: var(--input-bg); filter: var(--logo-filter);
flex: 0 0 auto;
}
.brand {
font-family: Fraunces, Georgia, serif;
font-size: clamp(1.9rem, 5vw, 2.5rem);
font-weight: 700;
letter-spacing: -0.03em;
margin: 0;
line-height: 1;
}
.sub { margin: 0.25rem 0 0; color: var(--muted); font-size: 0.95rem; }
.sub a { color: var(--accent); font-weight: 600; text-decoration: none; }
.panel {
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 1rem;
margin-bottom: 0.85rem;
box-shadow: var(--shadow);
}
label { display: block; font-size: 0.95rem; color: var(--muted); margin-bottom: 0.25rem; font-weight: 600; }
input, textarea, button {
font: inherit;
width: 100%;
padding: 0.65rem 0.75rem;
border-radius: 11px;
border: 1px solid var(--line);
background: var(--input-bg);
color: var(--ink);
}
textarea { min-height: 2.8rem; 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 {
outline: 2px solid var(--wing);
outline-offset: 2px;
}
button.secondary { background: var(--input-bg); color: var(--ink); border-color: var(--line); }
button.icon {
width: 2rem; height: 2rem; padding: 0;
display: inline-grid; place-items: center;
border-radius: 999px; background: var(--input-bg); color: var(--accent);
border: 1px solid var(--line); flex: 0 0 auto;
}
button.icon.playing { background: var(--accent); color: #fff; }
button.vote {
min-width: 2.2rem; padding: 0.3rem 0.45rem;
background: var(--input-bg); color: var(--ink); border: 1px solid var(--line);
}
button.vote.active-up { background: var(--up); color: #fff; border-color: transparent; }
button.vote.active-down { background: var(--down); color: #fff; border-color: transparent; }
button.pill {
padding: 0.2rem 0.55rem;
border-radius: 999px;
font-size: 0.8rem;
letter-spacing: 0.04em;
text-transform: uppercase;
background: var(--input-bg);
color: var(--muted);
border: 1px solid var(--line);
}
button.pill.active { background: var(--ink); color: var(--paper); border-color: transparent; }
.row { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: end; }
.who {
display: flex; justify-content: space-between; gap: 0.75rem;
align-items: baseline; flex-wrap: wrap; margin-bottom: 0.75rem;
color: var(--muted); font-size: 0.92rem;
}
.who a { color: var(--accent); font-weight: 600; text-decoration: none; }
.boards {
display: flex; gap: 0.85rem; margin-bottom: 0.85rem;
overflow-x: auto; -webkit-overflow-scrolling: touch;
scroll-snap-type: x mandatory; overscroll-behavior-x: contain;
padding-bottom: 0.25rem;
}
.boards > section {
flex: 0 0 auto;
width: min(18.5rem, calc(100vw - 2.5rem));
scroll-snap-align: start;
}
@media (min-width: 860px) {
.boards > section { flex: 1 1 0; width: auto; min-width: 16rem; }
}
.col-head {
display: flex; align-items: baseline; justify-content: space-between;
gap: 0.5rem; margin-bottom: 0.35rem; padding-bottom: 0.45rem;
border-bottom: 1px solid var(--line);
}
.col-head h2 {
font-family: Fraunces, Georgia, serif;
font-size: 1.2rem; margin: 0; font-weight: 700;
}
.col-head .count { color: var(--muted); font-size: 0.95rem; font-weight: 600; }
.name-card {
display: grid;
grid-template-columns: 2.2rem 1fr auto;
gap: 0.55rem;
align-items: start;
padding: 0.7rem 0.1rem;
border-top: 1px solid color-mix(in srgb, var(--line) 65%, transparent);
}
.name-card:first-of-type { border-top: 0; }
.score {
font-family: Fraunces, Georgia, serif;
font-variant-numeric: tabular-nums;
font-weight: 700; font-size: 1.2rem; color: var(--rank);
text-align: center; padding-top: 0.1rem;
}
.spelling {
font-family: Fraunces, Georgia, serif;
font-size: 1.25rem; margin: 0 0 0.1rem; letter-spacing: -0.02em;
}
.meta { color: var(--muted); font-size: 0.95rem; margin: 0 0 0.4rem; }
.say-row {
display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap;
margin-bottom: 0.25rem;
}
.say {
font-family: Fraunces, Georgia, serif;
font-size: 1.05rem; margin: 0; min-width: 3rem;
}
.pills { display: flex; gap: 0.25rem; }
details.more { margin-top: 0.25rem; }
details.more summary {
cursor: pointer; color: var(--accent); font-weight: 600; font-size: 0.85rem;
list-style: none;
}
details.more summary::-webkit-details-marker { display: none; }
.note {
margin: 0.4rem 0 0; padding: 0.5rem 0.6rem;
background: color-mix(in srgb, var(--sky) 30%, white);
border-radius: 10px; color: var(--muted); font-size: 0.86rem;
}
.votes { display: flex; flex-direction: column; gap: 0.25rem; }
.error { color: var(--down); margin: 0.45rem 0 0; }
.hidden { display: none !important; }
.empty { color: var(--muted); padding: 0.55rem 0.1rem; font-size: 0.92rem; }
.compose-head {
display: flex; flex-wrap: wrap; gap: 0.65rem; align-items: end;
justify-content: space-between; margin-bottom: 0.75rem;
}
.compose-head h2 {
font-family: Fraunces, Georgia, serif; margin: 0; font-size: 1.15rem;
}
.kind-toggle { display: flex; gap: 0.3rem; }
.kind-toggle button {
min-width: 5.5rem;
background: transparent;
color: var(--ink);
border: 1px solid var(--line);
}
.kind-toggle button.active {
background: var(--ink);
color: var(--paper);
border-color: var(--ink);
}
.kind-toggle button:not(.active):hover {
border-color: var(--accent);
}
.grid-3 { display: grid; gap: 0.55rem; }
@media (min-width: 720px) { .grid-3 { grid-template-columns: repeat(3, 1fr); } }
.hint { color: var(--muted); font-size: 0.95rem; margin: 0.25rem 0 0.55rem; }
</style>
</head>
<body>
<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">
<img src="/static/logo-mark.png" alt="Stork" width="56" height="56" />
<div>
<h1 class="brand">Stork</h1>
<p class="sub">Classic layout · <a href="/">Paper board</a> · <a href="/logos">Logo ideas</a></p>
</div>
</header>
<section id="gate" class="panel">
<label for="invite">Invite code</label>
<input id="invite" autocomplete="off" />
<label for="display" style="margin-top:0.65rem">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>
<a href="#suggest">Suggest a name</a>
</div>
<div class="boards">
<section class="panel" aria-labelledby="first-heading">
<div class="col-head">
<h2 id="first-heading">First</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</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="c1">First</button>
<button type="button" class="secondary" data-kind="c2">Middle</button>
</div>
</div>
<label for="spelling">Spelling</label>
<input id="spelling" required aria-required="true" autocomplete="off" />
<p class="hint">Pronunciation fields: just the name in that language (e.g. Roze → שושנה).</p>
<div class="grid-3">
<div>
<label>English</label>
<input data-loc="en" data-field="pronunciation" placeholder="Name" />
<label style="margin-top:0.4rem">Origin</label>
<input data-loc="en" data-field="origin" />
<label style="margin-top:0.4rem">Meaning</label>
<textarea data-loc="en" data-field="meaning"></textarea>
</div>
<div>
<label>Russian</label>
<input data-loc="ru" data-field="pronunciation" placeholder="Имя" />
<label style="margin-top:0.4rem">Origin</label>
<input data-loc="ru" data-field="origin" />
<label style="margin-top:0.4rem">Meaning</label>
<textarea data-loc="ru" data-field="meaning"></textarea>
</div>
<div>
<label>Hebrew</label>
<input data-loc="he" data-field="pronunciation" placeholder="שם" dir="auto" />
<label style="margin-top:0.4rem">Origin</label>
<input data-loc="he" data-field="origin" dir="auto" />
<label style="margin-top:0.4rem">Meaning</label>
<textarea data-loc="he" data-field="meaning" dir="auto"></textarea>
</div>
</div>
<p class="error hidden" id="add-error" role="alert" aria-live="polite"></p>
<div class="row" style="margin-top:0.65rem">
<button id="add" type="button">Add</button>
</div>
</div>
</section>
</main>
<script>
const state = { kind: "c1", c1: [], c2: [], langById: {} };
const SPEECH_LANG = { en: "en-US", ru: "ru-RU", he: "he-IL" };
let speakingBtn = null;
function params() { return new URLSearchParams(location.search); }
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(StorkUI.formatError(detail));
}
return data;
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, (c) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"
})[c]);
}
function stopSpeech() {
if (window.speechSynthesis) window.speechSynthesis.cancel();
if (speakingBtn) { speakingBtn.classList.remove("playing"); speakingBtn = null; }
}
function play(lang, text, btn) {
if (!window.speechSynthesis) {
alert("Speech not supported in this browser.");
return;
}
const say = (text || "").trim();
if (!say) return;
stopSpeech();
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;
speakingBtn = btn;
btn.classList.add("playing");
u.onend = u.onerror = () => { btn.classList.remove("playing"); speakingBtn = null; };
speechSynthesis.speak(u);
}
function formFor(n, lang) {
const L = (n.locales && n.locales[lang]) || {};
return (L.pronunciation || "").trim() || (lang === "en" ? n.spelling : "");
}
function noteFor(n, lang) {
const L = (n.locales && n.locales[lang]) || {};
return [L.origin, L.meaning].filter(Boolean).join(" — ");
}
function renderCard(n, kind) {
const lang = state.langById[n.id] || "en";
const form = formFor(n, lang);
const note = noteFor(n, lang);
const pills = ["en", "ru", "he"].map((l) =>
`<button type="button" class="pill ${lang === l ? "active" : ""}" data-lang="${l}">${l}</button>`
).join("");
return `
<article class="name-card" data-id="${n.id}" data-kind="${kind}">
<div class="score">${n.score}</div>
<div>
<p class="spelling" dir="auto">${escapeHtml(n.spelling)}</p>
<p class="meta">${escapeHtml(n.created_by || "someone")} · ${n.vote_count}</p>
<div class="say-row">
<div class="pills">${pills}</div>
<p class="say" dir="auto">${escapeHtml(form || "—")}</p>
<button type="button" class="icon" data-play
data-lang="${lang}" data-text="${escapeHtml(form)}"
aria-label="Play">▶</button>
</div>
${note ? `<details class="more"><summary>Meaning</summary><p class="note" dir="auto">${escapeHtml(note)}</p></details>` : ""}
</div>
<div class="votes">
<button type="button" class="vote ${n.my_vote === 1 ? "active-up" : ""}" data-vote="1" aria-label="Upvote">▲</button>
<button type="button" class="vote ${n.my_vote === -1 ? "active-down" : ""}" data-vote="-1" aria-label="Downvote">▼</button>
</div>
</article>`;
}
function bindCard(root, items) {
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-lang]").forEach((btn) => {
btn.addEventListener("click", () => {
const id = Number(btn.closest(".name-card").dataset.id);
state.langById[id] = btn.dataset.lang;
const kind = btn.closest(".name-card").dataset.kind;
renderList(kind, kind === "c1" ? state.c1 : state.c2);
});
});
root.querySelectorAll("button[data-play]").forEach((btn) => {
btn.addEventListener("click", () => play(btn.dataset.lang, btn.dataset.text, btn));
});
}
function renderList(kind, items) {
const root = document.getElementById(kind === "c1" ? "list-first" : "list-middle");
const count = document.getElementById(kind === "c1" ? "first-count" : "middle-count");
count.textContent = String(items.length);
if (!items.length) {
root.innerHTML = `<p class="empty">None yet</p>`;
return;
}
root.innerHTML = items.map((n) => renderCard(n, kind)).join("");
bindCard(root, items);
}
function localePayload() {
const locales = { en: {}, ru: {}, he: {} };
document.querySelectorAll("[data-loc][data-field]").forEach((el) => {
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 = await api("/api/names?kind=c1");
state.c1 = first.items || [];
state.c2 = [];
renderList("c1", state.c1);
const midRoot = document.getElementById("list-middle");
const midCount = document.getElementById("middle-count");
if (midRoot) midRoot.innerHTML = `<p class="empty">Use the <a href="/">paper board</a> for more columns.</p>`;
if (midCount) midCount.textContent = "—";
}
async function showBoard(displayName) {
document.getElementById("gate").classList.add("hidden");
document.getElementById("board").classList.remove("hidden");
document.getElementById("who").textContent = displayName;
if (window.speechSynthesis) speechSynthesis.getVoices();
await refresh();
}
document.querySelectorAll(".kind-toggle button").forEach((btn) => {
btn.setAttribute("aria-pressed", btn.classList.contains("active") ? "true" : "false");
btn.addEventListener("click", () => {
state.kind = btn.dataset.kind;
document.querySelectorAll(".kind-toggle button").forEach((b) => {
const on = b === btn;
b.classList.toggle("active", on);
b.setAttribute("aria-pressed", on ? "true" : "false");
});
});
});
document.getElementById("enter").addEventListener("click", async () => {
const err = document.getElementById("gate-error");
StorkUI.clearError(err);
try {
const data = await api("/api/session", {
method: "POST",
body: JSON.stringify({
invite: document.getElementById("invite").value.trim(),
display_name: document.getElementById("display").value.trim(),
}),
});
const url = new URL(location.href);
url.searchParams.delete("invite");
history.replaceState({}, "", url.pathname + (url.search || ""));
await showBoard(data.display_name);
} catch (e) {
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 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 (_) {}
})();
</script>
</body>
</html>