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 charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>Stork — family name board</title>
|
<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>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--bg: #f3efe6;
|
--sky: #d6e4ef;
|
||||||
--ink: #1c2430;
|
--fog: #eef3f7;
|
||||||
--muted: #5c6674;
|
--paper: #f8faf8;
|
||||||
--card: #fffdf8;
|
--ink: #18253a;
|
||||||
--line: #d9d0c0;
|
--muted: #5a6a7e;
|
||||||
--accent: #2f6f6a;
|
--line: #c5d3e0;
|
||||||
--up: #2f6f6a;
|
--accent: #2a5f7a;
|
||||||
|
--wing: #b85c48;
|
||||||
|
--up: #2f6b5a;
|
||||||
--down: #9a4a3a;
|
--down: #9a4a3a;
|
||||||
--focus: #c9a227;
|
--rank: #1e3a55;
|
||||||
|
--shadow: 0 12px 40px rgba(24, 37, 58, 0.08);
|
||||||
|
--radius: 18px;
|
||||||
}
|
}
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
|
html { scroll-behavior: smooth; }
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: "Iowan Old Style", "Palatino Linotype", Palatino, Georgia, serif;
|
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
|
font-family: "Source Sans 3", system-ui, sans-serif;
|
||||||
background:
|
background:
|
||||||
radial-gradient(1200px 500px at 10% -10%, #e8dfc8 0%, transparent 55%),
|
radial-gradient(900px 420px at 85% -5%, #f0d8cf 0%, transparent 55%),
|
||||||
radial-gradient(900px 400px at 100% 0%, #d7e4df 0%, transparent 50%),
|
radial-gradient(1100px 520px at -5% 0%, var(--sky) 0%, transparent 50%),
|
||||||
var(--bg);
|
linear-gradient(180deg, #e7eef4 0%, var(--fog) 40%, #e9eee8 100%);
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
main { max-width: 720px; margin: 0 auto; padding: 1.25rem 1rem 3rem; }
|
@media (prefers-reduced-motion: reduce) {
|
||||||
h1 { font-size: 2rem; margin: 0 0 0.25rem; letter-spacing: 0.02em; }
|
html { scroll-behavior: auto; }
|
||||||
.sub { color: var(--muted); margin: 0 0 1.25rem; }
|
.name-card { transition: none; }
|
||||||
.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; }
|
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 {
|
input, select, textarea, button {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.65rem 0.75rem;
|
padding: 0.7rem 0.8rem;
|
||||||
border-radius: 10px;
|
border-radius: 12px;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
background: #fff;
|
background: #fff;
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
@@ -54,82 +90,250 @@
|
|||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
padding: 0.65rem 1rem;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
button.secondary { background: transparent; color: var(--ink); border-color: var(--line); }
|
button:focus-visible, input:focus-visible, textarea:focus-visible, select:focus-visible {
|
||||||
button.vote { min-width: 2.5rem; padding: 0.4rem 0.55rem; }
|
outline: 2px solid var(--wing);
|
||||||
button.vote.active-up { background: var(--up); color: #fff; }
|
outline-offset: 2px;
|
||||||
button.vote.active-down { background: var(--down); color: #fff; }
|
}
|
||||||
.row { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: end; }
|
button.secondary { background: #fff; color: var(--ink); border-color: var(--line); }
|
||||||
.row > * { flex: 1 1 140px; }
|
button.icon {
|
||||||
.tabs { display: flex; gap: 0.5rem; margin-bottom: 0.75rem; }
|
width: 2rem;
|
||||||
.tabs button { flex: 1; }
|
height: 2rem;
|
||||||
.tabs button.active { outline: 2px solid var(--focus); }
|
padding: 0;
|
||||||
.name {
|
display: inline-grid;
|
||||||
display: grid;
|
place-items: center;
|
||||||
grid-template-columns: auto 1fr auto;
|
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;
|
gap: 0.75rem;
|
||||||
align-items: start;
|
align-items: baseline;
|
||||||
padding: 0.85rem 0;
|
flex-wrap: wrap;
|
||||||
border-top: 1px solid var(--line);
|
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 {
|
.score {
|
||||||
|
font-family: Fraunces, Georgia, serif;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
min-width: 2rem;
|
font-size: 1.35rem;
|
||||||
|
color: var(--rank);
|
||||||
text-align: center;
|
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; }
|
.spelling {
|
||||||
.meta { color: var(--muted); font-size: 0.9rem; margin: 0 0 0.4rem; }
|
font-family: Fraunces, Georgia, serif;
|
||||||
.langs details { margin-top: 0.35rem; }
|
font-size: 1.35rem;
|
||||||
.langs summary { cursor: pointer; color: var(--accent); }
|
margin: 0 0 0.15rem;
|
||||||
.lang-block { margin: 0.4rem 0; padding-left: 0.2rem; }
|
letter-spacing: -0.02em;
|
||||||
.lang-block strong { display: inline-block; min-width: 2rem; }
|
}
|
||||||
.votes { display: flex; flex-direction: column; gap: 0.35rem; }
|
.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; }
|
.error { color: var(--down); margin: 0.5rem 0 0; }
|
||||||
.hidden { display: none !important; }
|
.hidden { display: none !important; }
|
||||||
.grid-3 { display: grid; gap: 0.5rem; }
|
.empty {
|
||||||
@media (min-width: 640px) {
|
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); }
|
.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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main>
|
<main>
|
||||||
<h1>Stork</h1>
|
<header class="hero">
|
||||||
<p class="sub">Family name ideas — vote, add notes in English, Russian, and Hebrew.</p>
|
<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">
|
<section id="gate" class="panel">
|
||||||
<label for="invite">Invite code</label>
|
<label for="invite">Invite code</label>
|
||||||
<input id="invite" autocomplete="off" />
|
<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" />
|
<input id="display" autocomplete="nickname" />
|
||||||
<p class="error hidden" id="gate-error"></p>
|
<p class="error hidden" id="gate-error"></p>
|
||||||
<div class="row" style="margin-top:0.75rem">
|
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="board" class="hidden">
|
<section id="board" class="hidden">
|
||||||
<div class="panel">
|
<div class="who">
|
||||||
<p class="meta" id="who"></p>
|
<span id="who"></span>
|
||||||
<div class="tabs">
|
<a href="#suggest" style="color:var(--accent);font-weight:600;text-decoration:none">Suggest a name ↓</a>
|
||||||
<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>
|
||||||
|
|
||||||
<div class="panel">
|
<div class="boards">
|
||||||
<h2 style="margin:0 0 0.75rem; font-size:1.15rem">Suggest a name</h2>
|
<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>
|
<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 class="grid-3" style="margin-top:0.75rem">
|
||||||
<div>
|
<div>
|
||||||
<label>English pronunciation</label>
|
<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>
|
<label style="margin-top:0.5rem">Origin</label>
|
||||||
<input data-loc="en" data-field="origin" />
|
<input data-loc="en" data-field="origin" />
|
||||||
<label style="margin-top:0.5rem">Meaning</label>
|
<label style="margin-top:0.5rem">Meaning</label>
|
||||||
@@ -137,7 +341,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label>Russian pronunciation</label>
|
<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>
|
<label style="margin-top:0.5rem">Origin</label>
|
||||||
<input data-loc="ru" data-field="origin" />
|
<input data-loc="ru" data-field="origin" />
|
||||||
<label style="margin-top:0.5rem">Meaning</label>
|
<label style="margin-top:0.5rem">Meaning</label>
|
||||||
@@ -145,11 +349,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label>Hebrew pronunciation</label>
|
<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>
|
<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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<p class="error hidden" id="add-error"></p>
|
<p class="error hidden" id="add-error"></p>
|
||||||
@@ -160,7 +364,9 @@
|
|||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
<script>
|
<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() {
|
function params() {
|
||||||
return new URLSearchParams(location.search);
|
return new URLSearchParams(location.search);
|
||||||
@@ -176,12 +382,130 @@
|
|||||||
let data = null;
|
let data = null;
|
||||||
try { data = text ? JSON.parse(text) : null; } catch { data = { detail: text }; }
|
try { data = text ? JSON.parse(text) : null; } catch { data = { detail: text }; }
|
||||||
if (!res.ok) {
|
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));
|
throw new Error(typeof detail === "string" ? detail : JSON.stringify(detail));
|
||||||
}
|
}
|
||||||
return data;
|
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() {
|
function localePayload() {
|
||||||
const locales = { en: {}, ru: {}, he: {} };
|
const locales = { en: {}, ru: {}, he: {} };
|
||||||
document.querySelectorAll("[data-loc][data-field]").forEach((el) => {
|
document.querySelectorAll("[data-loc][data-field]").forEach((el) => {
|
||||||
@@ -195,76 +519,30 @@
|
|||||||
document.querySelectorAll("[data-loc][data-field]").forEach((el) => { el.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() {
|
async function refresh() {
|
||||||
const data = await api(`/api/names?kind=${encodeURIComponent(state.kind)}`);
|
const [first, middle] = await Promise.all([
|
||||||
renderList(data.items || []);
|
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) {
|
async function showBoard(displayName) {
|
||||||
document.getElementById("gate").classList.add("hidden");
|
document.getElementById("gate").classList.add("hidden");
|
||||||
document.getElementById("board").classList.remove("hidden");
|
document.getElementById("board").classList.remove("hidden");
|
||||||
document.getElementById("who").textContent = `Signed in as ${displayName}`;
|
document.getElementById("who").textContent = `Signed in as ${displayName}`;
|
||||||
|
// Chrome loads voices async
|
||||||
|
if (window.speechSynthesis) speechSynthesis.getVoices();
|
||||||
await refresh();
|
await refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
document.querySelectorAll(".tabs button").forEach((btn) => {
|
document.querySelectorAll(".kind-toggle button").forEach((btn) => {
|
||||||
btn.addEventListener("click", async () => {
|
btn.addEventListener("click", () => {
|
||||||
state.kind = btn.dataset.kind;
|
state.kind = btn.dataset.kind;
|
||||||
document.querySelectorAll(".tabs button").forEach((b) => b.classList.toggle("active", b === btn));
|
document.querySelectorAll(".kind-toggle button").forEach((b) => b.classList.toggle("active", b === btn));
|
||||||
await refresh();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -280,7 +558,7 @@
|
|||||||
});
|
});
|
||||||
const url = new URL(location.href);
|
const url = new URL(location.href);
|
||||||
url.searchParams.delete("invite");
|
url.searchParams.delete("invite");
|
||||||
history.replaceState({}, "", url.pathname);
|
history.replaceState({}, "", url.pathname + (url.search || ""));
|
||||||
await showBoard(data.display_name);
|
await showBoard(data.display_name);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
err.textContent = e.message;
|
err.textContent = e.message;
|
||||||
@@ -308,14 +586,16 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (window.speechSynthesis) {
|
||||||
|
speechSynthesis.onvoiceschanged = () => speechSynthesis.getVoices();
|
||||||
|
}
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
const invite = params().get("invite");
|
const invite = params().get("invite");
|
||||||
if (invite) document.getElementById("invite").value = invite;
|
if (invite) document.getElementById("invite").value = invite;
|
||||||
try {
|
try {
|
||||||
const session = await api("/api/session");
|
const session = await api("/api/session");
|
||||||
if (session.authenticated) {
|
if (session.authenticated) await showBoard(session.display_name);
|
||||||
await showBoard(session.display_name);
|
|
||||||
}
|
|
||||||
} catch (_) { /* stay on gate */ }
|
} catch (_) { /* stay on gate */ }
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user