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.
This commit is contained in:
+416
-136
@@ -4,45 +4,81 @@
|
||||
<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 {
|
||||
--bg: #f3efe6;
|
||||
--ink: #1c2430;
|
||||
--muted: #5c6674;
|
||||
--card: #fffdf8;
|
||||
--line: #d9d0c0;
|
||||
--accent: #2f6f6a;
|
||||
--up: #2f6f6a;
|
||||
--sky: #d6e4ef;
|
||||
--fog: #eef3f7;
|
||||
--paper: #f8faf8;
|
||||
--ink: #18253a;
|
||||
--muted: #5a6a7e;
|
||||
--line: #c5d3e0;
|
||||
--accent: #2a5f7a;
|
||||
--wing: #b85c48;
|
||||
--up: #2f6b5a;
|
||||
--down: #9a4a3a;
|
||||
--focus: #c9a227;
|
||||
--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;
|
||||
font-family: "Iowan Old Style", "Palatino Linotype", Palatino, Georgia, serif;
|
||||
color: var(--ink);
|
||||
font-family: "Source Sans 3", system-ui, sans-serif;
|
||||
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);
|
||||
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;
|
||||
}
|
||||
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;
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html { scroll-behavior: auto; }
|
||||
.name-card { transition: none; }
|
||||
}
|
||||
label { display: block; font-size: 0.9rem; color: var(--muted); margin-bottom: 0.35rem; }
|
||||
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.65rem 0.75rem;
|
||||
border-radius: 10px;
|
||||
padding: 0.7rem 0.8rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
color: var(--ink);
|
||||
@@ -54,82 +90,250 @@
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border-color: transparent;
|
||||
padding: 0.65rem 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
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;
|
||||
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: start;
|
||||
padding: 0.85rem 0;
|
||||
border-top: 1px solid var(--line);
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 0.85rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.name:first-child { border-top: 0; }
|
||||
.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;
|
||||
min-width: 2rem;
|
||||
font-size: 1.35rem;
|
||||
color: var(--rank);
|
||||
text-align: center;
|
||||
padding-top: 0.35rem;
|
||||
line-height: 1.2;
|
||||
padding-top: 0.15rem;
|
||||
}
|
||||
.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; }
|
||||
.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; }
|
||||
.grid-3 { display: grid; gap: 0.5rem; }
|
||||
@media (min-width: 640px) {
|
||||
.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>
|
||||
<h1>Stork</h1>
|
||||
<p class="sub">Family name ideas — vote, add notes in English, Russian, and Hebrew.</p>
|
||||
<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/votes)</label>
|
||||
<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</button>
|
||||
<button id="enter" type="button">Enter the board</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 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="panel">
|
||||
<h2 style="margin:0 0 0.75rem; font-size:1.15rem">Suggest a name</h2>
|
||||
<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" />
|
||||
<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" />
|
||||
<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>
|
||||
@@ -137,7 +341,7 @@
|
||||
</div>
|
||||
<div>
|
||||
<label>Russian pronunciation</label>
|
||||
<input data-loc="ru" data-field="pronunciation" />
|
||||
<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>
|
||||
@@ -145,11 +349,11 @@
|
||||
</div>
|
||||
<div>
|
||||
<label>Hebrew pronunciation</label>
|
||||
<input data-loc="he" data-field="pronunciation" />
|
||||
<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" />
|
||||
<input data-loc="he" data-field="origin" dir="auto" />
|
||||
<label style="margin-top:0.5rem">Meaning</label>
|
||||
<textarea data-loc="he" data-field="meaning"></textarea>
|
||||
<textarea data-loc="he" data-field="meaning" dir="auto"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<p class="error hidden" id="add-error"></p>
|
||||
@@ -160,7 +364,9 @@
|
||||
</section>
|
||||
</main>
|
||||
<script>
|
||||
const state = { kind: "first" };
|
||||
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);
|
||||
@@ -176,12 +382,130 @@
|
||||
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;
|
||||
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) => ({
|
||||
"&": "&", "<": "<", ">": ">", '"': """, "'": "'"
|
||||
})[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) => {
|
||||
@@ -195,76 +519,30 @@
|
||||
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 || []);
|
||||
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(".tabs button").forEach((btn) => {
|
||||
btn.addEventListener("click", async () => {
|
||||
document.querySelectorAll(".kind-toggle button").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
state.kind = btn.dataset.kind;
|
||||
document.querySelectorAll(".tabs button").forEach((b) => b.classList.toggle("active", b === btn));
|
||||
await refresh();
|
||||
document.querySelectorAll(".kind-toggle button").forEach((b) => b.classList.toggle("active", b === btn));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -280,7 +558,7 @@
|
||||
});
|
||||
const url = new URL(location.href);
|
||||
url.searchParams.delete("invite");
|
||||
history.replaceState({}, "", url.pathname);
|
||||
history.replaceState({}, "", url.pathname + (url.search || ""));
|
||||
await showBoard(data.display_name);
|
||||
} catch (e) {
|
||||
err.textContent = e.message;
|
||||
@@ -308,14 +586,16 @@
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
if (session.authenticated) await showBoard(session.display_name);
|
||||
} catch (_) { /* stay on gate */ }
|
||||
})();
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user