Same dialog for keyboard ?, header ?, and footer Help: sharing, votes, columns, and notes/voice icons in one place.
1147 lines
46 KiB
HTML
1147 lines
46 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<meta name="color-scheme" content="light dark" />
|
|
<script src="/static/stork-common.js"></script>
|
|
<script>StorkUI.applyTheme();</script>
|
|
<title>Stork — family name board</title>
|
|
<link rel="icon" href="/static/favicon.png" type="image/png" />
|
|
<link rel="apple-touch-icon" href="/static/favicon.png" />
|
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
<link href="https://fonts.googleapis.com/css2?family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=Source+Sans+3:wght@400;600&display=swap" rel="stylesheet" />
|
|
<style>
|
|
:root, [data-theme="light"] {
|
|
--paper: #f3ead8; --ink: #141414; --muted: #5c5348; --rule: #c9b89a; --chip: #efe6d2;
|
|
--up: #1f1f1f; --down: #6b3a32; --focus: #2a2a2a; --sheet: color-mix(in srgb, var(--paper) 70%, white);
|
|
--input-bg: #fffdf8; --logo-filter: none; --rec: #8b3a2f;
|
|
--body-bg: radial-gradient(ellipse at 50% 0%, #faf4e8 0%, transparent 55%),
|
|
repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(80,60,30,.015) 2px, rgba(80,60,30,.015) 3px),
|
|
var(--paper);
|
|
}
|
|
[data-theme="dark"] {
|
|
--paper: #1a1814; --ink: #f0e6d4; --muted: #b5a890; --rule: #3f3a32; --chip: #242018;
|
|
--up: #e8dcc4; --down: #e0a090; --focus: #f0e6d4; --sheet: #221f1a;
|
|
--input-bg: #12100e; --logo-filter: invert(1) contrast(1.05); --rec: #e09080;
|
|
--body-bg: radial-gradient(ellipse at 50% 0%, #2a241c 0%, transparent 55%), var(--paper);
|
|
}
|
|
.skip{position:absolute;left:-9999px;top:0;background:var(--ink);color:var(--paper);padding:.5rem .75rem;z-index:10}
|
|
.skip:focus{left:.5rem;top:.5rem}
|
|
.theme-bar{display:flex;gap:.35rem;flex-wrap:wrap;align-items:center;margin:0 0 .85rem;justify-content:flex-end}
|
|
.theme-bar span{color:var(--muted);font-size:.9rem;margin-right:.25rem}
|
|
.theme-bar button{padding:.35rem .65rem;font-size:.85rem;min-height:2.25rem;background:var(--input-bg);color:var(--ink);border:1px solid var(--rule)}
|
|
.theme-bar button.active{background:var(--ink);color:var(--paper);border-color:var(--ink)}
|
|
.error{color:var(--down);margin:.45rem 0 0;font-size:.95rem;font-weight:600;border-left:3px solid var(--down);padding-left:.55rem}
|
|
input[aria-invalid="true"]{border-color:var(--down);box-shadow:0 0 0 1px var(--down)}
|
|
* { box-sizing: border-box; }
|
|
body {
|
|
margin: 0;
|
|
color: var(--ink);
|
|
font-family: "Source Sans 3", system-ui, sans-serif;
|
|
background: var(--body-bg);
|
|
min-height: 100vh;
|
|
}
|
|
main { max-width: 1180px; margin: 0 auto; padding: 1.5rem 1rem 4rem; }
|
|
.topnav {
|
|
display: flex; gap: 1rem; flex-wrap: wrap; align-items: center;
|
|
margin-bottom: 1.25rem; font-size: 0.9rem;
|
|
}
|
|
.topnav a { color: var(--muted); text-decoration: underline; text-underline-offset: 3px; }
|
|
.hero {
|
|
display: grid;
|
|
grid-template-columns: auto 1fr;
|
|
gap: 1rem 1.25rem;
|
|
align-items: center;
|
|
padding-bottom: 1.25rem;
|
|
margin-bottom: 1.25rem;
|
|
border-bottom: 1px solid var(--rule);
|
|
}
|
|
.hero img {
|
|
width: 88px; height: 88px; object-fit: contain;
|
|
filter: var(--logo-filter);
|
|
}
|
|
.brand {
|
|
font-family: "Libre Baskerville", Georgia, serif;
|
|
font-size: clamp(2rem, 5vw, 2.75rem);
|
|
margin: 0;
|
|
letter-spacing: -0.02em;
|
|
font-weight: 700;
|
|
}
|
|
.tag {
|
|
margin: 0.35rem 0 0;
|
|
color: var(--muted);
|
|
max-width: 32rem;
|
|
line-height: 1.45;
|
|
}
|
|
.sheet {
|
|
background: var(--sheet);
|
|
border: 1px solid var(--rule);
|
|
padding: 1rem 1.05rem 1.1rem;
|
|
margin-bottom: 1rem;
|
|
box-shadow: 0 1px 0 rgba(255,255,255,0.5) inset;
|
|
}
|
|
label { display: block; font-size: 0.95rem; color: var(--muted); margin-bottom: 0.25rem; letter-spacing: 0.02em; }
|
|
input, textarea, button {
|
|
font: inherit; width: 100%;
|
|
padding: 0.6rem 0.7rem;
|
|
border: 1px solid var(--rule);
|
|
background: var(--input-bg);
|
|
color: var(--ink);
|
|
border-radius: 0;
|
|
}
|
|
textarea { min-height: 2.6rem; resize: vertical; }
|
|
button {
|
|
width: auto; cursor: pointer;
|
|
background: var(--ink); color: var(--paper);
|
|
border-color: var(--ink); font-weight: 600;
|
|
padding: 0.55rem 0.95rem;
|
|
}
|
|
button.ghost { background: transparent; color: var(--ink); }
|
|
button:focus-visible, input:focus-visible, textarea:focus-visible {
|
|
outline: 3px solid var(--focus); outline-offset: 2px;
|
|
}
|
|
.row { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: end; }
|
|
.who {
|
|
display: flex; justify-content: space-between; gap: 0.75rem;
|
|
flex-wrap: wrap; align-items: baseline; margin-bottom: 0.85rem;
|
|
color: var(--muted); font-size: 0.92rem;
|
|
}
|
|
.who a { color: var(--ink); }
|
|
.who-actions {
|
|
display: inline-flex; flex-wrap: wrap; gap: 0.45rem; align-items: center;
|
|
}
|
|
.boards-row {
|
|
display: flex; gap: 0.75rem; align-items: stretch; margin-bottom: 1rem;
|
|
min-width: 0;
|
|
}
|
|
/* Always side-by-side; swipe/scroll sideways on phone instead of stacking. */
|
|
.boards {
|
|
display: flex; gap: 1rem; flex: 1; min-width: 0;
|
|
overflow-x: auto;
|
|
overflow-y: hidden;
|
|
overscroll-behavior-x: contain;
|
|
-webkit-overflow-scrolling: touch;
|
|
scroll-snap-type: x mandatory;
|
|
padding-bottom: 0.35rem;
|
|
scrollbar-width: thin;
|
|
}
|
|
.boards .sheet {
|
|
margin-bottom: 0;
|
|
flex: 0 0 auto;
|
|
width: min(18.5rem, calc(100vw - 5.25rem));
|
|
max-width: 22rem;
|
|
scroll-snap-align: start;
|
|
}
|
|
@media (min-width: 860px) {
|
|
.boards.cols-2 .sheet {
|
|
flex: 1 1 0;
|
|
width: auto;
|
|
min-width: 16rem;
|
|
max-width: none;
|
|
}
|
|
.boards.cols-3 .sheet,
|
|
.boards.cols-4 .sheet {
|
|
flex: 1 1 0;
|
|
width: auto;
|
|
min-width: 14rem;
|
|
max-width: none;
|
|
}
|
|
}
|
|
.add-col {
|
|
flex: 0 0 auto;
|
|
align-self: stretch;
|
|
min-width: 2.75rem;
|
|
writing-mode: horizontal-tb;
|
|
background: var(--sheet);
|
|
color: var(--muted);
|
|
border: 1px dashed var(--rule);
|
|
font-size: 1.6rem;
|
|
line-height: 1;
|
|
padding: 0.5rem;
|
|
display: grid;
|
|
place-items: center;
|
|
}
|
|
.add-col:hover, .add-col:focus-visible { color: var(--ink); border-color: var(--ink); }
|
|
.add-col:disabled { opacity: 0.35; cursor: default; }
|
|
.col-title {
|
|
font-family: "Libre Baskerville", Georgia, serif;
|
|
font-size: 1.05rem; margin: 0 0 0.65rem;
|
|
padding-bottom: 0.4rem;
|
|
border-bottom: 1px solid var(--rule);
|
|
display: flex; justify-content: space-between; gap: 0.5rem; align-items: center;
|
|
}
|
|
.col-title .title-edit {
|
|
flex: 1; min-width: 0;
|
|
font-family: "Libre Baskerville", Georgia, serif;
|
|
font-size: 1.05rem; font-weight: 700;
|
|
padding: 0.15rem 0.25rem;
|
|
border: 1px solid transparent;
|
|
background: transparent;
|
|
color: var(--ink);
|
|
}
|
|
.col-title .title-edit:hover,
|
|
.col-title .title-edit:focus {
|
|
border-color: var(--rule);
|
|
background: var(--input-bg);
|
|
outline: none;
|
|
}
|
|
.col-title .title-edit::placeholder { color: var(--muted); font-weight: 400; font-style: italic; }
|
|
.col-title .meta-count { color: var(--muted); font-family: "Source Sans 3", sans-serif; font-size: 0.8rem; font-weight: 600; flex: 0 0 auto; }
|
|
.col-title .remove-col {
|
|
all: unset; cursor: pointer; color: var(--muted); font-size: 0.85rem;
|
|
padding: 0.15rem 0.35rem; flex: 0 0 auto;
|
|
}
|
|
.col-title .remove-col:hover { color: var(--down); }
|
|
.card {
|
|
display: grid;
|
|
grid-template-columns: 2rem 1fr auto;
|
|
gap: 0.55rem;
|
|
padding: 0.7rem 0;
|
|
border-top: 1px solid color-mix(in srgb, var(--rule) 70%, transparent);
|
|
}
|
|
.card:first-of-type { border-top: 0; padding-top: 0.15rem; }
|
|
.score {
|
|
font-family: "Libre Baskerville", Georgia, serif;
|
|
font-variant-numeric: tabular-nums;
|
|
font-weight: 700; text-align: center; padding-top: 0.15rem;
|
|
}
|
|
.name-btn {
|
|
all: unset; cursor: pointer; display: block; width: 100%;
|
|
font-family: "Libre Baskerville", Georgia, serif;
|
|
font-size: 1.2rem; letter-spacing: -0.01em;
|
|
}
|
|
.name-btn:hover { text-decoration: underline; text-underline-offset: 3px; }
|
|
.meta { margin: 0.15rem 0 0; color: var(--muted); font-size: 0.8rem; }
|
|
.votes { display: flex; flex-direction: column; gap: 0.25rem; }
|
|
.votes button {
|
|
min-width: 2.1rem; padding: 0.25rem 0.35rem;
|
|
background: transparent; color: var(--ink); border: 1px solid var(--rule);
|
|
}
|
|
.votes button.active-up { background: var(--up); color: var(--paper); border-color: var(--up); }
|
|
.votes button.active-down { background: var(--down); color: #f5ebe4; border-color: var(--down); }
|
|
.drawer {
|
|
grid-column: 1 / -1;
|
|
margin: 0.35rem 0 0.15rem;
|
|
padding: 0.7rem 0.75rem;
|
|
background: var(--chip);
|
|
border: 1px solid var(--rule);
|
|
}
|
|
.drawer.hidden { display: none; }
|
|
.say-row { display: flex; align-items: center; gap: 0.45rem; flex-wrap: wrap; }
|
|
.pills { display: flex; gap: 0.2rem; }
|
|
.pills button {
|
|
padding: 0.15rem 0.45rem; font-size: 0.7rem; letter-spacing: 0.05em;
|
|
text-transform: uppercase; background: transparent; color: var(--muted);
|
|
border: 1px solid var(--rule);
|
|
}
|
|
.pills button.active { background: var(--ink); color: var(--paper); border-color: var(--ink); }
|
|
.voice-status { color: var(--muted); font-size: 0.8rem; }
|
|
.voice-status strong { color: var(--ink); font-weight: 600; }
|
|
.edit-grid { display: grid; gap: 0.4rem; margin-top: 0.55rem; }
|
|
.edit-grid label { margin: 0; font-size: 0.78rem; }
|
|
.edit-grid input, .edit-grid textarea {
|
|
padding: 0.4rem 0.5rem; font-size: 0.95rem;
|
|
}
|
|
.edit-grid textarea { min-height: 2.4rem; }
|
|
.notes-view {
|
|
margin-top: 0.55rem; font-size: 0.9rem; line-height: 1.4; color: var(--ink);
|
|
}
|
|
.notes-view .k { color: var(--muted); font-size: 0.78rem; display: block; margin-top: 0.35rem; }
|
|
.notes-view .k:first-child { margin-top: 0; }
|
|
.notes-view .empty-hint { color: var(--muted); font-style: italic; }
|
|
.voice-row {
|
|
display: flex; flex-wrap: wrap; gap: 0.35rem; align-items: center;
|
|
margin-top: 0.55rem;
|
|
}
|
|
.voice-row button.recording {
|
|
background: var(--rec); color: #fff; border-color: var(--rec);
|
|
animation: pulse 1.2s ease-in-out infinite;
|
|
}
|
|
@keyframes pulse {
|
|
50% { opacity: 0.75; }
|
|
}
|
|
.icon-btn, .play {
|
|
width: 2rem; height: 2rem; padding: 0;
|
|
display: inline-grid; place-items: center;
|
|
background: transparent; color: var(--ink); border: 1px solid var(--rule);
|
|
font-size: 0.95rem; line-height: 1;
|
|
}
|
|
.play.playing { background: var(--ink); color: var(--paper); }
|
|
.empty { color: var(--muted); font-size: 0.92rem; padding: 0.35rem 0; }
|
|
.help-dialog {
|
|
border: 1px solid var(--rule);
|
|
background: var(--sheet);
|
|
color: var(--ink);
|
|
padding: 0;
|
|
max-width: min(22rem, calc(100vw - 2rem));
|
|
box-shadow: 0 10px 36px rgba(20, 20, 20, 0.18);
|
|
}
|
|
.help-dialog::backdrop { background: rgba(20, 18, 14, 0.35); }
|
|
.help-dialog .help-inner { padding: 1rem 1.1rem 1.05rem; }
|
|
.help-dialog h2 {
|
|
font-family: "Libre Baskerville", Georgia, serif;
|
|
font-size: 1.05rem; margin: 0 0 0.55rem;
|
|
}
|
|
.help-dialog p { margin: 0 0 0.55rem; color: var(--muted); font-size: 0.9rem; line-height: 1.4; }
|
|
.help-dialog ul {
|
|
margin: 0 0 0.65rem; padding-left: 1.1rem;
|
|
color: var(--muted); font-size: 0.88rem; line-height: 1.45;
|
|
}
|
|
.help-dialog h3 {
|
|
font-family: "Source Sans 3", sans-serif;
|
|
font-size: 0.78rem; font-weight: 600; letter-spacing: 0.04em;
|
|
text-transform: uppercase; color: var(--ink);
|
|
margin: 0.75rem 0 0.35rem;
|
|
}
|
|
.help-dialog h3:first-of-type { margin-top: 0.15rem; }
|
|
.help-dialog kbd {
|
|
font-family: "Source Sans 3", sans-serif;
|
|
font-size: 0.85rem; font-weight: 600;
|
|
border: 1px solid var(--rule); padding: 0.05rem 0.35rem;
|
|
background: var(--input-bg); color: var(--ink);
|
|
}
|
|
.help-dialog .row { margin-top: 0.25rem; }
|
|
button.help-link, a.help-link {
|
|
all: unset; cursor: pointer; color: var(--ink);
|
|
text-decoration: underline; text-underline-offset: 2px;
|
|
font-size: inherit;
|
|
}
|
|
button.help-link:focus-visible {
|
|
outline: 3px solid var(--focus); outline-offset: 2px;
|
|
}
|
|
.who .help-chip {
|
|
all: unset; cursor: pointer;
|
|
display: inline-grid; place-items: center;
|
|
width: 1.85rem; height: 1.85rem;
|
|
border: 1px solid var(--rule); color: var(--muted);
|
|
font-family: "Source Sans 3", sans-serif;
|
|
font-size: 0.95rem; font-weight: 600;
|
|
vertical-align: middle;
|
|
}
|
|
.who .help-chip:hover, .who .help-chip:focus-visible {
|
|
color: var(--ink); border-color: var(--ink);
|
|
}
|
|
.who .help-chip:focus-visible {
|
|
outline: 3px solid var(--focus); outline-offset: 2px;
|
|
}
|
|
.hidden { display: none !important; }
|
|
.compose-head {
|
|
display: flex; justify-content: space-between; gap: 0.75rem;
|
|
flex-wrap: wrap; align-items: end; margin-bottom: 0.75rem;
|
|
}
|
|
.compose-head h2 {
|
|
font-family: "Libre Baskerville", Georgia, serif;
|
|
font-size: 1.1rem; margin: 0;
|
|
}
|
|
.kind-toggle { display: flex; gap: 0.25rem; flex-wrap: wrap; }
|
|
.kind-toggle button.active { background: var(--ink); color: var(--paper); }
|
|
details.notes { margin-top: 0.65rem; }
|
|
details.notes summary {
|
|
cursor: pointer; color: var(--muted); font-size: 0.9rem;
|
|
list-style: none;
|
|
}
|
|
details.notes summary::-webkit-details-marker { display: none; }
|
|
.grid-3 { display: grid; gap: 0.55rem; margin-top: 0.55rem; }
|
|
@media (min-width: 720px) { .grid-3 { grid-template-columns: repeat(3, 1fr); } }
|
|
.hint { color: var(--muted); font-size: 0.9rem; margin: 0.3rem 0 0; }
|
|
.foot {
|
|
margin-top: 1.5rem; padding-top: 0.85rem;
|
|
border-top: 1px solid var(--rule);
|
|
color: var(--muted); font-size: 0.85rem;
|
|
}
|
|
.foot a { color: var(--ink); }
|
|
.foot kbd, .hint kbd {
|
|
font-family: "Source Sans 3", sans-serif;
|
|
font-size: 0.8rem; font-weight: 600;
|
|
border: 1px solid var(--rule); padding: 0.05rem 0.3rem;
|
|
background: var(--input-bg); color: var(--ink);
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<a class="skip" href="#main">Skip to content</a>
|
|
<main id="main">
|
|
<div class="theme-bar" role="group" aria-label="Color theme">
|
|
<span>Theme</span>
|
|
<button type="button" data-theme-toggle="light">Light</button>
|
|
<button type="button" data-theme-toggle="dark">Dark</button>
|
|
<button type="button" data-theme-toggle="system">System</button>
|
|
</div>
|
|
|
|
<div class="topnav">
|
|
<a href="/logos">Logo concepts</a>
|
|
<a href="/v1">Classic board</a>
|
|
</div>
|
|
|
|
<header class="hero">
|
|
<img src="/static/logo-hero.png" alt="" width="88" height="88" />
|
|
<div>
|
|
<h1 class="brand">Stork</h1>
|
|
<p class="tag" id="board-tag">Family name board — vote, edit how a name is said, and record your own voice when TTS misses.</p>
|
|
</div>
|
|
</header>
|
|
|
|
<section id="landing" class="sheet">
|
|
<p class="hint" style="margin-top:0">Start a private board, add names, then share the link. No password — the link is the key.</p>
|
|
<label for="board-title">Board title</label>
|
|
<input id="board-title" value="Name board" autocomplete="off" />
|
|
<label for="display-new" style="margin-top:0.6rem">Your name</label>
|
|
<input id="display-new" autocomplete="nickname" />
|
|
<p class="error hidden" id="landing-error" role="alert" aria-live="polite"></p>
|
|
<div class="row" style="margin-top:0.65rem">
|
|
<button id="start-board" type="button">Start new board</button>
|
|
</div>
|
|
</section>
|
|
|
|
<section id="gate" class="sheet hidden">
|
|
<p class="hint" style="margin-top:0" id="gate-hint">Join this board — enter the name others will see on your votes.</p>
|
|
<label for="display">Your name</label>
|
|
<input id="display" autocomplete="nickname" />
|
|
<p class="error hidden" id="gate-error" role="alert" aria-live="polite"></p>
|
|
<div class="row" style="margin-top:0.65rem">
|
|
<button id="enter" type="button">Enter</button>
|
|
</div>
|
|
</section>
|
|
|
|
<section id="board" class="hidden">
|
|
<div class="who">
|
|
<span id="who"></span>
|
|
<span class="who-actions">
|
|
<button type="button" class="help-chip" data-open-help aria-label="Help" title="Help">?</button>
|
|
<button type="button" class="ghost" id="share-link" style="padding:0.35rem 0.6rem;font-size:0.85rem">Copy share link</button>
|
|
<a href="#suggest">Suggest a name</a>
|
|
</span>
|
|
</div>
|
|
|
|
<div class="boards-row">
|
|
<div class="boards" id="boards"></div>
|
|
<button type="button" class="add-col" id="add-col" title="Add another name column" aria-label="Add column">+</button>
|
|
</div>
|
|
|
|
<div class="sheet" id="suggest">
|
|
<div class="compose-head">
|
|
<h2>Suggest a name</h2>
|
|
<div class="kind-toggle" id="kind-toggle"></div>
|
|
</div>
|
|
<label for="spelling">Spelling</label>
|
|
<input id="spelling" required aria-required="true" autocomplete="off" />
|
|
<p class="hint">Written form for this column. Prefer a real recording over browser speech.</p>
|
|
<details class="notes" open>
|
|
<summary>Pronunciation / origin / meaning (optional)</summary>
|
|
<div class="grid-3">
|
|
<div>
|
|
<label>English form</label>
|
|
<input data-loc="en" data-field="pronunciation" placeholder="Name" />
|
|
<label style="margin-top:0.35rem">Origin</label>
|
|
<input data-loc="en" data-field="origin" />
|
|
<label style="margin-top:0.35rem">Meaning</label>
|
|
<textarea data-loc="en" data-field="meaning"></textarea>
|
|
</div>
|
|
<div>
|
|
<label>Russian form</label>
|
|
<input data-loc="ru" data-field="pronunciation" placeholder="Имя" />
|
|
<label style="margin-top:0.35rem">Origin</label>
|
|
<input data-loc="ru" data-field="origin" />
|
|
<label style="margin-top:0.35rem">Meaning</label>
|
|
<textarea data-loc="ru" data-field="meaning"></textarea>
|
|
</div>
|
|
<div>
|
|
<label>Hebrew form</label>
|
|
<input data-loc="he" data-field="pronunciation" placeholder="שם" dir="auto" />
|
|
<label style="margin-top:0.35rem">Origin</label>
|
|
<input data-loc="he" data-field="origin" dir="auto" />
|
|
<label style="margin-top:0.35rem">Meaning</label>
|
|
<textarea data-loc="he" data-field="meaning" dir="auto"></textarea>
|
|
</div>
|
|
</div>
|
|
</details>
|
|
<p class="error hidden" id="add-error" role="alert" aria-live="polite"></p>
|
|
<div class="row" style="margin-top:0.7rem">
|
|
<button id="add" type="button">Add</button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<p class="foot">Paper board · <button type="button" class="help-link" data-open-help>Help</button> · press <kbd>?</kbd> · <a href="/v1">classic layout</a></p>
|
|
</main>
|
|
<dialog id="help-dialog" class="help-dialog" aria-labelledby="help-title">
|
|
<div class="help-inner">
|
|
<h2 id="help-title">How this board works</h2>
|
|
<h3>Sharing</h3>
|
|
<ul>
|
|
<li>The <strong>/b/…</strong> link is the key — no password. Use <strong>Copy share link</strong>.</li>
|
|
<li>Everyone picks a display name; it shows on votes.</li>
|
|
</ul>
|
|
<h3>Names & votes</h3>
|
|
<ul>
|
|
<li>Tap a name to expand. ▲ / ▼ rank it (tap again to clear).</li>
|
|
<li>Suggest names below; pick which column with the toggles.</li>
|
|
</ul>
|
|
<h3>Columns</h3>
|
|
<ul>
|
|
<li>Rename a column title in place. <strong>+</strong> adds another (up to 4).</li>
|
|
<li><strong>✕</strong> removes an extra column (the first stays).</li>
|
|
<li>On phone, swipe sideways between columns.</li>
|
|
</ul>
|
|
<h3>Notes & voice</h3>
|
|
<ul>
|
|
<li><strong>EN / RU / HE</strong> — spoken form, origin, meaning per language.</li>
|
|
<li><strong>▶</strong> play (AI until you record) · <strong>●</strong> record · <strong>■</strong> stop</li>
|
|
<li><strong>↺</strong> back to AI · <strong>✎</strong> edit notes · <strong>✓</strong> save</li>
|
|
</ul>
|
|
<div class="row">
|
|
<button type="button" id="help-close">Close</button>
|
|
<span class="hint" style="margin:0">Esc or <kbd>?</kbd></span>
|
|
</div>
|
|
</div>
|
|
</dialog>
|
|
<script>
|
|
const MAX_COLUMNS = 4;
|
|
const SPEECH_LANG = { en: "en-US", ru: "ru-RU", he: "he-IL" };
|
|
|
|
const state = {
|
|
kind: "c1",
|
|
boardId: null,
|
|
boardUrl: null,
|
|
columns: [],
|
|
lists: {},
|
|
openId: null,
|
|
editId: null,
|
|
langById: {},
|
|
recording: null,
|
|
};
|
|
let speakingBtn = null;
|
|
let audioEl = null;
|
|
|
|
function isTypingTarget(el) {
|
|
if (!el || !(el instanceof Element)) return false;
|
|
const tag = el.tagName;
|
|
return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || el.isContentEditable;
|
|
}
|
|
|
|
function openHelp() {
|
|
const dlg = document.getElementById("help-dialog");
|
|
if (dlg && !dlg.open) dlg.showModal();
|
|
}
|
|
|
|
function toggleHelp() {
|
|
const dlg = document.getElementById("help-dialog");
|
|
if (!dlg) return;
|
|
if (dlg.open) dlg.close();
|
|
else dlg.showModal();
|
|
}
|
|
|
|
document.getElementById("help-close").addEventListener("click", () => {
|
|
document.getElementById("help-dialog").close();
|
|
});
|
|
document.getElementById("help-dialog").addEventListener("click", (e) => {
|
|
if (e.target === e.currentTarget) e.currentTarget.close();
|
|
});
|
|
document.querySelectorAll("[data-open-help]").forEach((btn) => {
|
|
btn.addEventListener("click", (e) => {
|
|
e.preventDefault();
|
|
openHelp();
|
|
});
|
|
});
|
|
document.addEventListener("keydown", (e) => {
|
|
if (e.key === "?" || (e.key === "/" && e.shiftKey)) {
|
|
if (isTypingTarget(e.target)) return;
|
|
e.preventDefault();
|
|
toggleHelp();
|
|
}
|
|
});
|
|
|
|
function colLabel(col) {
|
|
const label = (col && col.label || "").trim();
|
|
return label || "Untitled";
|
|
}
|
|
|
|
function params() { return new URLSearchParams(location.search); }
|
|
|
|
async function api(path, opts = {}) {
|
|
const headers = { ...(opts.headers || {}) };
|
|
if (opts.body && !(opts.body instanceof FormData) && !headers["Content-Type"]) {
|
|
headers["Content-Type"] = "application/json";
|
|
}
|
|
const res = await fetch(path, {
|
|
credentials: "include",
|
|
headers,
|
|
...opts,
|
|
});
|
|
const text = await res.text();
|
|
let data = null;
|
|
try { data = text ? JSON.parse(text) : null; } catch { data = { detail: text }; }
|
|
if (!res.ok) {
|
|
const detail = (data && (data.detail || data.message)) || res.statusText;
|
|
throw new Error(StorkUI.formatError(detail));
|
|
}
|
|
return data;
|
|
}
|
|
|
|
function escapeHtml(s) {
|
|
return String(s).replace(/[&<>"']/g, (c) => ({
|
|
"&": "&", "<": "<", ">": ">", '"': """, "'": "'"
|
|
})[c]);
|
|
}
|
|
|
|
function stopPlayback() {
|
|
if (window.speechSynthesis) window.speechSynthesis.cancel();
|
|
if (audioEl) {
|
|
audioEl.pause();
|
|
audioEl.src = "";
|
|
audioEl = null;
|
|
}
|
|
if (speakingBtn) { speakingBtn.classList.remove("playing"); speakingBtn = null; }
|
|
}
|
|
|
|
function formFor(n, lang) {
|
|
const L = (n.locales && n.locales[lang]) || {};
|
|
return (L.pronunciation || "").trim() || (lang === "en" ? n.spelling : "");
|
|
}
|
|
|
|
function playName(n, lang, btn) {
|
|
stopPlayback();
|
|
const hasRec = n.recordings && n.recordings[lang];
|
|
speakingBtn = btn;
|
|
btn.classList.add("playing");
|
|
const done = () => { btn.classList.remove("playing"); speakingBtn = null; };
|
|
if (hasRec) {
|
|
audioEl = new Audio(`/api/names/${n.id}/recording/${lang}?t=${Date.now()}`);
|
|
audioEl.onended = audioEl.onerror = done;
|
|
audioEl.play().catch(done);
|
|
return;
|
|
}
|
|
if (!window.speechSynthesis) { done(); return; }
|
|
const say = formFor(n, lang);
|
|
if (!say) { done(); return; }
|
|
const u = new SpeechSynthesisUtterance(say);
|
|
u.lang = SPEECH_LANG[lang] || "en-US";
|
|
u.rate = 0.92;
|
|
const voices = speechSynthesis.getVoices();
|
|
const match = voices.find((v) => v.lang === u.lang)
|
|
|| voices.find((v) => v.lang && v.lang.startsWith(u.lang.slice(0, 2)));
|
|
if (match) u.voice = match;
|
|
u.onend = u.onerror = done;
|
|
speechSynthesis.speak(u);
|
|
}
|
|
|
|
function itemsFor(kind) {
|
|
return state.lists[kind] || [];
|
|
}
|
|
|
|
function renderKindToggle() {
|
|
const root = document.getElementById("kind-toggle");
|
|
if (!state.columns.some((c) => c.id === state.kind)) {
|
|
state.kind = (state.columns[0] && state.columns[0].id) || "c1";
|
|
}
|
|
root.innerHTML = state.columns.map((c) =>
|
|
`<button type="button" class="ghost ${state.kind === c.id ? "active" : ""}" data-kind="${c.id}" aria-pressed="${state.kind === c.id}">${escapeHtml(colLabel(c))}</button>`
|
|
).join("");
|
|
root.querySelectorAll("button").forEach((btn) => {
|
|
btn.addEventListener("click", () => {
|
|
state.kind = btn.dataset.kind;
|
|
renderKindToggle();
|
|
});
|
|
});
|
|
}
|
|
|
|
function renderBoards() {
|
|
const boards = document.getElementById("boards");
|
|
boards.className = `boards cols-${state.columns.length}`;
|
|
boards.innerHTML = state.columns.map((col) => `
|
|
<section class="sheet" data-col="${col.id}">
|
|
<h2 class="col-title">
|
|
<input class="title-edit" data-rename="${col.id}" value="${escapeHtml(col.label || "")}" placeholder="Name this column" aria-label="Column title" maxlength="40" />
|
|
<span class="meta-count" id="count-${col.id}">0</span>
|
|
${col.id !== state.columns[0].id ? `<button type="button" class="remove-col" data-remove="${col.id}" title="Remove column" aria-label="Remove column">✕</button>` : ""}
|
|
</h2>
|
|
<div id="list-${col.id}"></div>
|
|
</section>
|
|
`).join("");
|
|
boards.querySelectorAll("[data-rename]").forEach((input) => {
|
|
const save = async () => {
|
|
const id = input.dataset.rename;
|
|
const label = input.value.trim();
|
|
try {
|
|
const updated = await api(`/api/columns/${id}`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify({ label }),
|
|
});
|
|
const col = state.columns.find((c) => c.id === id);
|
|
if (col) col.label = updated.label;
|
|
renderKindToggle();
|
|
} catch (e) {
|
|
alert(e.message || "Could not rename column");
|
|
}
|
|
};
|
|
input.addEventListener("change", save);
|
|
input.addEventListener("keydown", (e) => {
|
|
if (e.key === "Enter") { e.preventDefault(); input.blur(); }
|
|
});
|
|
});
|
|
boards.querySelectorAll("[data-remove]").forEach((btn) => {
|
|
btn.addEventListener("click", async () => {
|
|
const id = btn.dataset.remove;
|
|
const col = state.columns.find((c) => c.id === id);
|
|
const title = colLabel(col || { label: "" });
|
|
const n = col && col.name_count ? col.name_count : 0;
|
|
const msg = n
|
|
? `Remove column “${title}” and its ${n} name(s)?`
|
|
: `Remove column “${title}”?`;
|
|
if (!confirm(msg)) return;
|
|
try {
|
|
const res = await api(`/api/columns/${id}`, { method: "DELETE" });
|
|
state.columns = res.items || [];
|
|
if (state.kind === id) state.kind = state.columns[0]?.id || "c1";
|
|
renderBoards();
|
|
renderKindToggle();
|
|
await refresh();
|
|
} catch (e) {
|
|
alert(e.message || "Could not remove column");
|
|
}
|
|
});
|
|
});
|
|
const addBtn = document.getElementById("add-col");
|
|
addBtn.disabled = state.columns.length >= MAX_COLUMNS;
|
|
addBtn.title = addBtn.disabled ? "Maximum 4 columns" : "Add another name column";
|
|
state.columns.forEach((col) => renderList(col.id));
|
|
}
|
|
|
|
function renderCard(n, kind) {
|
|
const open = state.openId === n.id;
|
|
const editing = state.editId === n.id;
|
|
const lang = state.langById[n.id] || "en";
|
|
const L = (n.locales && n.locales[lang]) || {};
|
|
const form = formFor(n, lang);
|
|
const hasRec = !!(n.recordings && n.recordings[lang]);
|
|
const hasNotes = !!(L.origin || L.meaning || L.pronunciation);
|
|
const pills = ["en", "ru", "he"].map((l) =>
|
|
`<button type="button" class="${lang === l ? "active" : ""}" data-lang="${l}">${l}</button>`
|
|
).join("");
|
|
const recActive = state.recording && state.recording.nameId === n.id && state.recording.lang === lang;
|
|
const spoken = L.pronunciation || form || "—";
|
|
const notesView = `
|
|
<div class="notes-view">
|
|
<span class="k">Spoken (${lang})</span>
|
|
<div dir="auto">${escapeHtml(spoken)}</div>
|
|
<span class="k">Origin</span>
|
|
<div dir="auto">${L.origin ? escapeHtml(L.origin) : `<span class="empty-hint">Not set</span>`}</div>
|
|
<span class="k">Meaning</span>
|
|
<div dir="auto">${L.meaning ? escapeHtml(L.meaning) : `<span class="empty-hint">Not set</span>`}</div>
|
|
</div>`;
|
|
const notesEdit = `
|
|
<div class="edit-grid">
|
|
<div>
|
|
<label for="pron-${n.id}">Spoken form (${lang})</label>
|
|
<input id="pron-${n.id}" data-edit="pronunciation" value="${escapeHtml(L.pronunciation || "")}" dir="auto" placeholder="${escapeHtml(form || n.spelling)}" />
|
|
</div>
|
|
<div>
|
|
<label for="orig-${n.id}">Origin</label>
|
|
<input id="orig-${n.id}" data-edit="origin" value="${escapeHtml(L.origin || "")}" dir="auto" placeholder="Language / root" />
|
|
</div>
|
|
<div>
|
|
<label for="mean-${n.id}">Meaning</label>
|
|
<textarea id="mean-${n.id}" data-edit="meaning" dir="auto" placeholder="What it means">${escapeHtml(L.meaning || "")}</textarea>
|
|
</div>
|
|
</div>`;
|
|
return `
|
|
<article class="card" data-id="${n.id}" data-kind="${kind}">
|
|
<div class="score">${n.score}</div>
|
|
<div>
|
|
<button type="button" class="name-btn" data-toggle dir="auto">${escapeHtml(n.spelling)}</button>
|
|
<p class="meta">${escapeHtml(n.created_by || "someone")} · ${n.vote_count}${hasRec ? " · voice" : ""}</p>
|
|
<div class="drawer ${open ? "" : "hidden"}">
|
|
<div class="say-row">
|
|
<div class="pills">${pills}</div>
|
|
<button type="button" class="play" data-play aria-label="Play pronunciation" title="Play">▶</button>
|
|
<span class="voice-status">${hasRec ? "<strong>Your voice</strong>" : "AI speech"}</span>
|
|
</div>
|
|
${editing || !hasNotes ? notesEdit : notesView}
|
|
<div class="voice-row">
|
|
${editing || !hasNotes
|
|
? `<button type="button" class="icon-btn" data-save-notes aria-label="Save notes" title="Save">✓</button>`
|
|
: `<button type="button" class="icon-btn" data-edit-notes aria-label="Edit notes" title="Edit notes">✎</button>`}
|
|
<button type="button" class="icon-btn ${recActive ? "recording" : ""}" data-record aria-label="${recActive ? "Stop recording" : (hasRec ? "Re-record" : "Record")}" title="${recActive ? "Stop" : (hasRec ? "Re-record" : "Record")}">${recActive ? "■" : "●"}</button>
|
|
${hasRec ? `<button type="button" class="icon-btn" data-clear-rec aria-label="Use AI speech" title="Use AI speech">↺</button>` : ""}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="votes">
|
|
<button type="button" class="${n.my_vote === 1 ? "active-up" : ""}" data-vote="1" aria-label="Upvote">▲</button>
|
|
<button type="button" class="${n.my_vote === -1 ? "active-down" : ""}" data-vote="-1" aria-label="Downvote">▼</button>
|
|
</div>
|
|
</article>`;
|
|
}
|
|
|
|
function bind(root, items) {
|
|
root.querySelectorAll("[data-toggle]").forEach((btn) => {
|
|
btn.addEventListener("click", () => {
|
|
const id = Number(btn.closest(".card").dataset.id);
|
|
if (state.openId === id) {
|
|
state.openId = null;
|
|
state.editId = null;
|
|
} else {
|
|
state.openId = id;
|
|
state.editId = null;
|
|
}
|
|
renderList(btn.closest(".card").dataset.kind);
|
|
});
|
|
});
|
|
root.querySelectorAll("[data-edit-notes]").forEach((btn) => {
|
|
btn.addEventListener("click", () => {
|
|
state.editId = Number(btn.closest(".card").dataset.id);
|
|
renderList(btn.closest(".card").dataset.kind);
|
|
});
|
|
});
|
|
root.querySelectorAll("[data-vote]").forEach((btn) => {
|
|
btn.addEventListener("click", async () => {
|
|
const card = btn.closest(".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("[data-lang]").forEach((btn) => {
|
|
btn.addEventListener("click", () => {
|
|
const id = Number(btn.closest(".card").dataset.id);
|
|
state.langById[id] = btn.dataset.lang;
|
|
// Stay in view mode when switching langs unless actively editing
|
|
renderList(btn.closest(".card").dataset.kind);
|
|
});
|
|
});
|
|
root.querySelectorAll("[data-play]").forEach((btn) => {
|
|
btn.addEventListener("click", () => {
|
|
const card = btn.closest(".card");
|
|
const id = Number(card.dataset.id);
|
|
const item = items.find((x) => x.id === id);
|
|
if (!item) return;
|
|
playName(item, state.langById[id] || "en", btn);
|
|
});
|
|
});
|
|
root.querySelectorAll("[data-save-notes]").forEach((btn) => {
|
|
btn.addEventListener("click", async () => {
|
|
const card = btn.closest(".card");
|
|
const id = Number(card.dataset.id);
|
|
const lang = state.langById[id] || "en";
|
|
const locales = {};
|
|
locales[lang] = {
|
|
pronunciation: card.querySelector('[data-edit="pronunciation"]').value.trim(),
|
|
origin: card.querySelector('[data-edit="origin"]').value.trim(),
|
|
meaning: card.querySelector('[data-edit="meaning"]').value.trim(),
|
|
};
|
|
await api(`/api/names/${id}`, { method: "PATCH", body: JSON.stringify({ locales }) });
|
|
state.openId = id;
|
|
state.editId = null;
|
|
await refresh();
|
|
});
|
|
});
|
|
root.querySelectorAll("[data-record]").forEach((btn) => {
|
|
btn.addEventListener("click", () => toggleRecord(btn));
|
|
});
|
|
root.querySelectorAll("[data-clear-rec]").forEach((btn) => {
|
|
btn.addEventListener("click", async () => {
|
|
const card = btn.closest(".card");
|
|
const id = Number(card.dataset.id);
|
|
const lang = state.langById[id] || "en";
|
|
await api(`/api/names/${id}/recording/${lang}`, { method: "DELETE" });
|
|
state.openId = id;
|
|
await refresh();
|
|
});
|
|
});
|
|
}
|
|
|
|
async function toggleRecord(btn) {
|
|
const card = btn.closest(".card");
|
|
const nameId = Number(card.dataset.id);
|
|
const lang = state.langById[nameId] || "en";
|
|
if (state.recording && state.recording.nameId === nameId && state.recording.lang === lang) {
|
|
state.recording.mediaRecorder.stop();
|
|
return;
|
|
}
|
|
if (state.recording) {
|
|
try { state.recording.mediaRecorder.stop(); } catch (_) {}
|
|
state.recording = null;
|
|
}
|
|
if (!navigator.mediaDevices || !window.MediaRecorder) {
|
|
alert("Voice recording needs a modern browser with microphone access.");
|
|
return;
|
|
}
|
|
try {
|
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
const mime = MediaRecorder.isTypeSupported("audio/webm;codecs=opus")
|
|
? "audio/webm;codecs=opus"
|
|
: (MediaRecorder.isTypeSupported("audio/mp4") ? "audio/mp4" : "");
|
|
const mediaRecorder = mime ? new MediaRecorder(stream, { mimeType: mime }) : new MediaRecorder(stream);
|
|
const chunks = [];
|
|
mediaRecorder.ondataavailable = (e) => { if (e.data.size) chunks.push(e.data); };
|
|
mediaRecorder.onstop = async () => {
|
|
stream.getTracks().forEach((t) => t.stop());
|
|
state.recording = null;
|
|
const blob = new Blob(chunks, { type: mediaRecorder.mimeType || "audio/webm" });
|
|
if (!blob.size) {
|
|
renderList(card.dataset.kind);
|
|
return;
|
|
}
|
|
const fd = new FormData();
|
|
const ext = blob.type.includes("mp4") ? "m4a" : "webm";
|
|
fd.append("file", blob, `voice.${ext}`);
|
|
try {
|
|
await api(`/api/names/${nameId}/recording/${lang}`, { method: "POST", body: fd });
|
|
state.openId = nameId;
|
|
await refresh();
|
|
} catch (e) {
|
|
alert(e.message || "Upload failed");
|
|
renderList(card.dataset.kind);
|
|
}
|
|
};
|
|
state.recording = { nameId, lang, mediaRecorder, chunks };
|
|
mediaRecorder.start();
|
|
renderList(card.dataset.kind);
|
|
} catch (e) {
|
|
alert("Microphone permission needed to record a pronunciation.");
|
|
}
|
|
}
|
|
|
|
function renderList(kind) {
|
|
const root = document.getElementById(`list-${kind}`);
|
|
const count = document.getElementById(`count-${kind}`);
|
|
if (!root || !count) return;
|
|
const items = itemsFor(kind);
|
|
count.textContent = String(items.length);
|
|
if (!items.length) {
|
|
root.innerHTML = `<p class="empty">None yet — add one below.</p>`;
|
|
return;
|
|
}
|
|
root.innerHTML = items.map((n) => renderCard(n, kind)).join("");
|
|
bind(root, items);
|
|
}
|
|
|
|
function localePayload() {
|
|
const locales = { en: {}, ru: {}, he: {} };
|
|
document.querySelectorAll("[data-loc][data-field]").forEach((el) => {
|
|
locales[el.dataset.loc][el.dataset.field] = el.value.trim();
|
|
});
|
|
return locales;
|
|
}
|
|
|
|
function clearForm() {
|
|
document.getElementById("spelling").value = "";
|
|
document.querySelectorAll("[data-loc][data-field]").forEach((el) => { el.value = ""; });
|
|
}
|
|
|
|
function refreshLists() {
|
|
state.columns.forEach((col) => renderList(col.id));
|
|
}
|
|
|
|
async function loadColumns() {
|
|
const data = await api("/api/columns");
|
|
state.columns = data.items || [];
|
|
if (!state.columns.some((c) => c.id === state.kind)) {
|
|
state.kind = (state.columns[0] && state.columns[0].id) || "c1";
|
|
}
|
|
}
|
|
|
|
async function refresh() {
|
|
if (!state.columns.length) await loadColumns();
|
|
const results = await Promise.all(
|
|
state.columns.map((col) => api(`/api/names?kind=${encodeURIComponent(col.id)}`))
|
|
);
|
|
state.columns.forEach((col, i) => {
|
|
state.lists[col.id] = results[i].items || [];
|
|
});
|
|
refreshLists();
|
|
}
|
|
|
|
async function showBoard(name, board) {
|
|
document.getElementById("landing").classList.add("hidden");
|
|
document.getElementById("gate").classList.add("hidden");
|
|
document.getElementById("board").classList.remove("hidden");
|
|
document.getElementById("who").textContent = name;
|
|
state.boardId = board && board.id;
|
|
state.boardUrl = (board && board.url) || (state.boardId ? `${location.origin}/b/${state.boardId}` : location.href);
|
|
const tag = document.getElementById("board-tag");
|
|
if (board && board.title) {
|
|
tag.textContent = `${board.title} — vote, edit pronunciation, record your voice when AI misses.`;
|
|
}
|
|
if (window.speechSynthesis) speechSynthesis.getVoices();
|
|
await loadColumns();
|
|
renderBoards();
|
|
renderKindToggle();
|
|
await refresh();
|
|
}
|
|
|
|
function pathBoardId() {
|
|
const m = location.pathname.match(/^\/b\/([^/]+)\/?$/);
|
|
return m ? decodeURIComponent(m[1]) : "";
|
|
}
|
|
|
|
document.getElementById("add-col").addEventListener("click", async () => {
|
|
if (state.columns.length >= MAX_COLUMNS) return;
|
|
try {
|
|
const col = await api("/api/columns", { method: "POST" });
|
|
state.columns = [...state.columns, col];
|
|
state.kind = col.id;
|
|
renderBoards();
|
|
renderKindToggle();
|
|
await refresh();
|
|
const input = document.querySelector(`[data-rename="${col.id}"]`);
|
|
if (input) { input.focus(); input.select(); }
|
|
} catch (e) {
|
|
alert(e.message || "Could not add column");
|
|
}
|
|
});
|
|
|
|
document.getElementById("share-link").addEventListener("click", async () => {
|
|
const url = state.boardUrl || location.href;
|
|
try {
|
|
await navigator.clipboard.writeText(url);
|
|
const btn = document.getElementById("share-link");
|
|
const prev = btn.textContent;
|
|
btn.textContent = "Copied";
|
|
setTimeout(() => { btn.textContent = prev; }, 1500);
|
|
} catch (_) {
|
|
prompt("Copy this board link:", url);
|
|
}
|
|
});
|
|
|
|
document.getElementById("start-board").addEventListener("click", async () => {
|
|
const err = document.getElementById("landing-error");
|
|
StorkUI.clearError(err);
|
|
const display = document.getElementById("display-new").value.trim();
|
|
const title = document.getElementById("board-title").value.trim() || "Name board";
|
|
if (!display) {
|
|
StorkUI.showError(err, "Enter your name");
|
|
return;
|
|
}
|
|
try {
|
|
const created = await api("/api/boards", {
|
|
method: "POST",
|
|
body: JSON.stringify({ title }),
|
|
});
|
|
const data = await api("/api/session", {
|
|
method: "POST",
|
|
body: JSON.stringify({ board_id: created.id, display_name: display }),
|
|
});
|
|
history.replaceState({}, "", `/b/${created.id}`);
|
|
await showBoard(data.display_name, data.board);
|
|
} catch (e) {
|
|
StorkUI.showError(err, e.message);
|
|
}
|
|
});
|
|
|
|
document.getElementById("enter").addEventListener("click", async () => {
|
|
const err = document.getElementById("gate-error");
|
|
StorkUI.clearError(err);
|
|
const boardId = pathBoardId() || state.boardId;
|
|
try {
|
|
const data = await api("/api/session", {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
board_id: boardId || undefined,
|
|
invite: params().get("invite") || undefined,
|
|
display_name: document.getElementById("display").value.trim(),
|
|
}),
|
|
});
|
|
if (data.board && data.board.id && !pathBoardId()) {
|
|
history.replaceState({}, "", `/b/${data.board.id}`);
|
|
}
|
|
await showBoard(data.display_name, data.board);
|
|
} catch (e) {
|
|
StorkUI.showError(err, e.message);
|
|
}
|
|
});
|
|
|
|
document.getElementById("add").addEventListener("click", async () => {
|
|
const err = document.getElementById("add-error");
|
|
const spellingEl = document.getElementById("spelling");
|
|
StorkUI.clearError(err);
|
|
spellingEl.removeAttribute("aria-invalid");
|
|
const spelling = spellingEl.value.trim();
|
|
if (!spelling) {
|
|
StorkUI.showError(err, "Enter a name spelling");
|
|
spellingEl.setAttribute("aria-invalid", "true");
|
|
spellingEl.focus();
|
|
return;
|
|
}
|
|
try {
|
|
await api("/api/names", {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
kind: state.kind,
|
|
spelling,
|
|
locales: localePayload(),
|
|
}),
|
|
});
|
|
clearForm();
|
|
await refresh();
|
|
} catch (e) {
|
|
StorkUI.showError(err, e.message);
|
|
spellingEl.setAttribute("aria-invalid", "true");
|
|
}
|
|
});
|
|
|
|
StorkUI.initThemeControls();
|
|
if (window.speechSynthesis) speechSynthesis.onvoiceschanged = () => speechSynthesis.getVoices();
|
|
|
|
(async () => {
|
|
const boardId = pathBoardId();
|
|
const invite = params().get("invite");
|
|
|
|
// Legacy invite links → /b/{id}
|
|
if (invite && !boardId) {
|
|
try {
|
|
const info = await api(`/api/resolve?invite=${encodeURIComponent(invite)}`);
|
|
history.replaceState({}, "", `/b/${info.id}`);
|
|
state.boardId = info.id;
|
|
state.boardUrl = info.url;
|
|
document.getElementById("landing").classList.add("hidden");
|
|
document.getElementById("gate").classList.remove("hidden");
|
|
document.getElementById("gate-hint").textContent =
|
|
`${info.title} — enter the name others will see on your votes.`;
|
|
return;
|
|
} catch (_) {
|
|
// fall through to landing
|
|
}
|
|
}
|
|
|
|
if (boardId) {
|
|
state.boardId = boardId;
|
|
document.getElementById("landing").classList.add("hidden");
|
|
try {
|
|
const info = await api(`/api/boards/${encodeURIComponent(boardId)}`);
|
|
document.getElementById("gate-hint").textContent =
|
|
`${info.title} — enter the name others will see on your votes.`;
|
|
state.boardUrl = info.url;
|
|
} catch (_) {
|
|
document.getElementById("gate-hint").textContent = "Board not found.";
|
|
}
|
|
try {
|
|
const session = await api("/api/session");
|
|
if (session.authenticated && session.board && session.board.id === boardId) {
|
|
await showBoard(session.display_name, session.board);
|
|
return;
|
|
}
|
|
} catch (_) {}
|
|
document.getElementById("gate").classList.remove("hidden");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const session = await api("/api/session");
|
|
if (session.authenticated && session.board) {
|
|
history.replaceState({}, "", `/b/${session.board.id}`);
|
|
await showBoard(session.display_name, session.board);
|
|
return;
|
|
}
|
|
} catch (_) {}
|
|
document.getElementById("landing").classList.remove("hidden");
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|