Board UX: dual columns + speak pronunciation #3
+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