Includes multilingual etymology fields, seed data for starter names, and CI-ready Python project layout.
324 lines
12 KiB
HTML
324 lines
12 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>
|
|
<style>
|
|
:root {
|
|
--bg: #f3efe6;
|
|
--ink: #1c2430;
|
|
--muted: #5c6674;
|
|
--card: #fffdf8;
|
|
--line: #d9d0c0;
|
|
--accent: #2f6f6a;
|
|
--up: #2f6f6a;
|
|
--down: #9a4a3a;
|
|
--focus: #c9a227;
|
|
}
|
|
* { box-sizing: border-box; }
|
|
body {
|
|
margin: 0;
|
|
font-family: "Iowan Old Style", "Palatino Linotype", Palatino, Georgia, serif;
|
|
color: var(--ink);
|
|
background:
|
|
radial-gradient(1200px 500px at 10% -10%, #e8dfc8 0%, transparent 55%),
|
|
radial-gradient(900px 400px at 100% 0%, #d7e4df 0%, transparent 50%),
|
|
var(--bg);
|
|
min-height: 100vh;
|
|
}
|
|
main { max-width: 720px; margin: 0 auto; padding: 1.25rem 1rem 3rem; }
|
|
h1 { font-size: 2rem; margin: 0 0 0.25rem; letter-spacing: 0.02em; }
|
|
.sub { color: var(--muted); margin: 0 0 1.25rem; }
|
|
.panel {
|
|
background: var(--card);
|
|
border: 1px solid var(--line);
|
|
border-radius: 14px;
|
|
padding: 1rem;
|
|
margin-bottom: 1rem;
|
|
}
|
|
label { display: block; font-size: 0.9rem; color: var(--muted); margin-bottom: 0.35rem; }
|
|
input, select, textarea, button {
|
|
font: inherit;
|
|
width: 100%;
|
|
padding: 0.65rem 0.75rem;
|
|
border-radius: 10px;
|
|
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;
|
|
padding: 0.65rem 1rem;
|
|
}
|
|
button.secondary { background: transparent; color: var(--ink); border-color: var(--line); }
|
|
button.vote { min-width: 2.5rem; padding: 0.4rem 0.55rem; }
|
|
button.vote.active-up { background: var(--up); color: #fff; }
|
|
button.vote.active-down { background: var(--down); color: #fff; }
|
|
.row { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: end; }
|
|
.row > * { flex: 1 1 140px; }
|
|
.tabs { display: flex; gap: 0.5rem; margin-bottom: 0.75rem; }
|
|
.tabs button { flex: 1; }
|
|
.tabs button.active { outline: 2px solid var(--focus); }
|
|
.name {
|
|
display: grid;
|
|
grid-template-columns: auto 1fr auto;
|
|
gap: 0.75rem;
|
|
align-items: start;
|
|
padding: 0.85rem 0;
|
|
border-top: 1px solid var(--line);
|
|
}
|
|
.name:first-child { border-top: 0; }
|
|
.score {
|
|
font-variant-numeric: tabular-nums;
|
|
font-weight: 700;
|
|
min-width: 2rem;
|
|
text-align: center;
|
|
padding-top: 0.35rem;
|
|
}
|
|
.spelling { font-size: 1.25rem; margin: 0 0 0.2rem; }
|
|
.meta { color: var(--muted); font-size: 0.9rem; margin: 0 0 0.4rem; }
|
|
.langs details { margin-top: 0.35rem; }
|
|
.langs summary { cursor: pointer; color: var(--accent); }
|
|
.lang-block { margin: 0.4rem 0; padding-left: 0.2rem; }
|
|
.lang-block strong { display: inline-block; min-width: 2rem; }
|
|
.votes { display: flex; flex-direction: column; gap: 0.35rem; }
|
|
.error { color: var(--down); margin: 0.5rem 0 0; }
|
|
.hidden { display: none !important; }
|
|
.grid-3 { display: grid; gap: 0.5rem; }
|
|
@media (min-width: 640px) {
|
|
.grid-3 { grid-template-columns: repeat(3, 1fr); }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main>
|
|
<h1>Stork</h1>
|
|
<p class="sub">Family name ideas — vote, add notes in English, Russian, and Hebrew.</p>
|
|
|
|
<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/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</button>
|
|
</div>
|
|
</section>
|
|
|
|
<section id="board" class="hidden">
|
|
<div class="panel">
|
|
<p class="meta" id="who"></p>
|
|
<div class="tabs">
|
|
<button type="button" class="secondary active" data-kind="first">First names</button>
|
|
<button type="button" class="secondary" data-kind="middle">Middle names</button>
|
|
</div>
|
|
<div id="list"></div>
|
|
</div>
|
|
|
|
<div class="panel">
|
|
<h2 style="margin:0 0 0.75rem; font-size:1.15rem">Suggest a name</h2>
|
|
<label for="spelling">Spelling</label>
|
|
<input id="spelling" />
|
|
<div class="grid-3" style="margin-top:0.75rem">
|
|
<div>
|
|
<label>English pronunciation</label>
|
|
<input data-loc="en" data-field="pronunciation" />
|
|
<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" />
|
|
<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" />
|
|
<label style="margin-top:0.5rem">Origin</label>
|
|
<input data-loc="he" data-field="origin" />
|
|
<label style="margin-top:0.5rem">Meaning</label>
|
|
<textarea data-loc="he" data-field="meaning"></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" };
|
|
|
|
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 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 renderLangs(locales) {
|
|
return ["en", "ru", "he"].map((lang) => {
|
|
const L = (locales && locales[lang]) || {};
|
|
const bits = [
|
|
L.pronunciation && `say: ${L.pronunciation}`,
|
|
L.origin && `origin: ${L.origin}`,
|
|
L.meaning && L.meaning,
|
|
].filter(Boolean);
|
|
if (!bits.length) return "";
|
|
return `<div class="lang-block"><strong>${lang}</strong> ${bits.join(" · ")}</div>`;
|
|
}).join("");
|
|
}
|
|
|
|
function renderList(items) {
|
|
const root = document.getElementById("list");
|
|
if (!items.length) {
|
|
root.innerHTML = `<p class="meta">No names yet — add the first idea below.</p>`;
|
|
return;
|
|
}
|
|
root.innerHTML = items.map((n) => {
|
|
const langs = renderLangs(n.locales);
|
|
return `
|
|
<article class="name" data-id="${n.id}">
|
|
<div class="score">${n.score}</div>
|
|
<div>
|
|
<p class="spelling">${escapeHtml(n.spelling)}</p>
|
|
<p class="meta">suggested by ${escapeHtml(n.created_by || "someone")} · ${n.vote_count} vote(s)</p>
|
|
${langs ? `<div class="langs"><details><summary>Origin & pronunciation</summary>${langs}</details></div>` : `<p class="meta">No notes yet</p>`}
|
|
</div>
|
|
<div class="votes">
|
|
<button type="button" class="vote secondary ${n.my_vote === 1 ? "active-up" : ""}" data-vote="1" aria-label="Upvote">▲</button>
|
|
<button type="button" class="vote secondary ${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 id = btn.closest(".name").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();
|
|
});
|
|
});
|
|
}
|
|
|
|
function escapeHtml(s) {
|
|
return String(s).replace(/[&<>"']/g, (c) => ({
|
|
"&": "&", "<": "<", ">": ">", '"': """, "'": "'"
|
|
})[c]);
|
|
}
|
|
|
|
async function refresh() {
|
|
const data = await api(`/api/names?kind=${encodeURIComponent(state.kind)}`);
|
|
renderList(data.items || []);
|
|
}
|
|
|
|
async function showBoard(displayName) {
|
|
document.getElementById("gate").classList.add("hidden");
|
|
document.getElementById("board").classList.remove("hidden");
|
|
document.getElementById("who").textContent = `Signed in as ${displayName}`;
|
|
await refresh();
|
|
}
|
|
|
|
document.querySelectorAll(".tabs button").forEach((btn) => {
|
|
btn.addEventListener("click", async () => {
|
|
state.kind = btn.dataset.kind;
|
|
document.querySelectorAll(".tabs button").forEach((b) => b.classList.toggle("active", b === btn));
|
|
await refresh();
|
|
});
|
|
});
|
|
|
|
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);
|
|
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");
|
|
}
|
|
});
|
|
|
|
(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>
|