Add static assets

This commit is contained in:
2026-06-10 08:32:46 +02:00
parent d3b839e35b
commit a1f6b4157a
5 changed files with 601 additions and 0 deletions
+138
View File
@@ -0,0 +1,138 @@
// Shared month-grid calendar. Both pages render the exact same widget.
//
// new Calendar(container, {
// canClick(dateStr) -> bool which days are interactive
// isPicked(dateStr) -> bool draw the highlighter ring
// isOffered(dateStr) -> bool soft marker background (poll page)
// decorate(dateStr, cell) add badges/chips
// onToggle(dateStr) click handler
// })
const RING_SVG =
'<svg class="ring" viewBox="0 0 100 100" aria-hidden="true">' +
'<path d="M50 9 C77 7 93 22 92 48 C91 76 74 92 48 91 C21 90 8 74 9 49 C10 24 27 11 53 10" ' +
'fill="none" stroke="#FFD43B" stroke-width="7.5" stroke-linecap="round" opacity="0.95"/></svg>';
const DOW = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
function pad(n) { return String(n).padStart(2, "0"); }
function dateStr(y, m, d) { return `${y}-${pad(m + 1)}-${pad(d)}`; }
function todayStr() {
const t = new Date();
return dateStr(t.getFullYear(), t.getMonth(), t.getDate());
}
function formatDate(ds) {
const [y, m, d] = ds.split("-").map(Number);
return new Date(y, m - 1, d).toLocaleDateString(undefined, {
weekday: "short", day: "numeric", month: "short", year: "numeric",
});
}
class Calendar {
constructor(el, opts) {
this.el = el;
this.opts = opts;
const now = new Date();
this.year = now.getFullYear();
this.month = now.getMonth();
this.render();
}
goTo(ds) {
const [y, m] = ds.split("-").map(Number);
this.year = y;
this.month = m - 1;
this.render();
}
shift(delta) {
const d = new Date(this.year, this.month + delta, 1);
this.year = d.getFullYear();
this.month = d.getMonth();
this.render();
}
render() {
const o = this.opts;
const first = new Date(this.year, this.month, 1);
const daysInMonth = new Date(this.year, this.month + 1, 0).getDate();
const lead = (first.getDay() + 6) % 7; // Monday-first
const today = todayStr();
this.el.innerHTML = "";
this.el.classList.add("cal");
const head = document.createElement("div");
head.className = "cal-head";
const title = document.createElement("div");
title.className = "cal-title";
title.textContent = first.toLocaleDateString(undefined, { month: "long", year: "numeric" });
const nav = document.createElement("div");
nav.className = "cal-nav";
for (const [sym, delta, label] of [["\u2190", -1, "Previous month"], ["\u2192", 1, "Next month"]]) {
const b = document.createElement("button");
b.type = "button";
b.textContent = sym;
b.setAttribute("aria-label", label);
b.addEventListener("click", () => this.shift(delta));
nav.appendChild(b);
}
head.append(title, nav);
this.el.appendChild(head);
const grid = document.createElement("div");
grid.className = "cal-grid";
for (const d of DOW) {
const h = document.createElement("div");
h.className = "dow";
h.textContent = d;
grid.appendChild(h);
}
for (let i = 0; i < lead; i++) {
const blank = document.createElement("div");
blank.className = "day blank";
grid.appendChild(blank);
}
for (let d = 1; d <= daysInMonth; d++) {
const ds = dateStr(this.year, this.month, d);
const clickable = o.canClick && o.canClick(ds);
const cell = document.createElement(clickable ? "button" : "div");
if (clickable) cell.type = "button";
cell.className = "day";
cell.dataset.date = ds;
const num = document.createElement("span");
num.className = "num";
num.textContent = d;
cell.appendChild(num);
if (ds === today) cell.classList.add("today");
if (ds < today) cell.classList.add("past");
if (o.isOffered && o.isOffered(ds)) cell.classList.add("offered");
if (clickable) {
cell.classList.add("clickable");
cell.insertAdjacentHTML("beforeend", RING_SVG);
const picked = o.isPicked && o.isPicked(ds);
if (picked) cell.classList.add("picked");
cell.setAttribute("aria-pressed", picked ? "true" : "false");
cell.setAttribute("aria-label", formatDate(ds));
cell.addEventListener("click", () => {
o.onToggle(ds);
// Repaint only this cell for snappy feel.
const nowPicked = o.isPicked(ds);
cell.classList.toggle("picked", nowPicked);
cell.setAttribute("aria-pressed", nowPicked ? "true" : "false");
if (o.afterToggle) o.afterToggle();
});
}
if (o.decorate) o.decorate(ds, cell);
grid.appendChild(cell);
}
this.el.appendChild(grid);
}
}
+112
View File
@@ -0,0 +1,112 @@
// Create page: pick dates on the calendar, optionally give each a time.
const picked = new Map(); // dateStr -> time ("" if none)
const today = todayStr();
const cal = new Calendar(document.getElementById("calendar"), {
canClick: (ds) => ds >= today,
isPicked: (ds) => picked.has(ds),
onToggle: (ds) => {
if (picked.has(ds)) picked.delete(ds);
else picked.set(ds, "");
},
afterToggle: renderPickedList,
decorate: (ds, cell) => {
const t = picked.get(ds);
if (t) {
const chip = document.createElement("span");
chip.className = "timechip";
chip.textContent = t;
cell.appendChild(chip);
}
},
});
const listEl = document.getElementById("picked-list");
function renderPickedList() {
listEl.innerHTML = "";
if (picked.size === 0) {
listEl.innerHTML = '<p class="empty">Nothing circled yet — tap dates on the calendar above.</p>';
return;
}
for (const ds of [...picked.keys()].sort()) {
const row = document.createElement("div");
row.className = "picked-row";
const d = document.createElement("span");
d.className = "d";
d.textContent = formatDate(ds);
const time = document.createElement("input");
time.type = "time";
time.value = picked.get(ds);
time.setAttribute("aria-label", "Time for " + formatDate(ds) + " (optional)");
time.addEventListener("change", () => {
picked.set(ds, time.value);
cal.render(); // refresh time chips
});
const rm = document.createElement("button");
rm.type = "button";
rm.className = "rm";
rm.textContent = "Remove";
rm.addEventListener("click", () => {
picked.delete(ds);
renderPickedList();
cal.render();
});
row.append(d, time, rm);
listEl.appendChild(row);
}
}
const errEl = document.getElementById("error");
const createBtn = document.getElementById("create-btn");
createBtn.addEventListener("click", async () => {
errEl.textContent = "";
const title = document.getElementById("title").value.trim();
if (!title) { errEl.textContent = "Give the plan a name first."; return; }
if (picked.size === 0) { errEl.textContent = "Circle at least one date on the calendar."; return; }
createBtn.disabled = true;
try {
const res = await fetch("/api/polls", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title,
description: document.getElementById("description").value.trim(),
options: [...picked.entries()].map(([date, time]) => ({ date, time })),
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Something went wrong.");
const shareURL = location.origin + "/p/" + data.id;
const adminURL = shareURL + "?admin=" + data.adminToken;
document.getElementById("share-url").value = shareURL;
document.getElementById("admin-url").value = adminURL;
document.getElementById("open-poll").href = adminURL;
document.getElementById("create-view").hidden = true;
document.getElementById("done-view").hidden = false;
window.scrollTo(0, 0);
} catch (e) {
errEl.textContent = e.message;
} finally {
createBtn.disabled = false;
}
});
document.querySelectorAll("[data-copy]").forEach((btn) => {
btn.addEventListener("click", async () => {
const input = document.getElementById(btn.dataset.copy);
input.select();
try { await navigator.clipboard.writeText(input.value); } catch { document.execCommand("copy"); }
const old = btn.textContent;
btn.textContent = "Copied";
setTimeout(() => (btn.textContent = old), 1200);
});
});
+82
View File
@@ -0,0 +1,82 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Group Dates — new poll</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:wght@700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="wrap">
<header class="site">
<a class="logo" href="/"><span class="dot"></span>Group Dates</a>
<span class="tagline">one calendar for the whole crew</span>
</header>
<div id="create-view">
<h1>Circle the dates that work</h1>
<p class="sub">Pick every day you'd offer for the get-together, add a time if you already know one, and send your friends a single link.</p>
<div class="card">
<label class="field">
<span>What's the plan?</span>
<input type="text" id="title" maxlength="120" placeholder="Pizza & board games at Lena's" autocomplete="off">
</label>
<label class="field">
<span>Details <span class="hint">(optional)</span></span>
<textarea id="description" maxlength="600" placeholder="Bring a game. We'll order around 7."></textarea>
</label>
</div>
<div class="card">
<div id="calendar"></div>
</div>
<div class="card">
<strong style="font-size:14px;">Offered dates</strong>
<div id="picked-list" class="picked-list">
<p class="empty">Nothing circled yet — tap dates on the calendar above.</p>
</div>
</div>
<div class="actions">
<button class="btn btn-primary" id="create-btn">Create poll</button>
<span class="note">Heads-up: once created, a poll can only be closed or deleted, not edited.</span>
</div>
<p class="error" id="error" role="alert"></p>
</div>
<div id="done-view" hidden>
<h1>Your poll is live</h1>
<p class="sub">Send the share link to your friends. Keep the admin link to yourself — it's the only way to close or delete the poll.</p>
<div class="card">
<strong style="font-size:14px;">Share with friends</strong>
<div class="share-link">
<input type="text" id="share-url" readonly>
<button class="btn btn-ghost" data-copy="share-url">Copy</button>
</div>
</div>
<div class="card">
<strong style="font-size:14px;">Admin link (just for you)</strong>
<div class="share-link">
<input type="text" id="admin-url" readonly>
<button class="btn btn-ghost" data-copy="admin-url">Copy</button>
</div>
<p class="note">Anyone with this link can close or delete the poll — save it somewhere safe.</p>
</div>
<div class="actions">
<a class="btn btn-primary" id="open-poll" href="#">Open the poll</a>
<a class="btn btn-ghost" href="/">Make another</a>
</div>
</div>
</div>
<script src="/static/calendar.js"></script>
<script src="/static/create.js"></script>
</body>
</html>
+67
View File
@@ -0,0 +1,67 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Group Dates — poll</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:wght@700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="wrap">
<header class="site">
<a class="logo" href="/"><span class="dot"></span>Group Dates</a>
<span class="tagline">one calendar for the whole crew</span>
</header>
<div id="not-found" hidden>
<h1>Poll not found</h1>
<p class="sub">This poll may have been deleted, or the link is incomplete. Ask whoever sent it for a fresh link — or <a href="/">start a new poll</a>.</p>
</div>
<div id="poll-view" hidden>
<div class="closed-banner" id="closed-banner" hidden>This poll is closed — the dates below are final.</div>
<h1 id="poll-title"></h1>
<p class="sub" id="poll-desc" hidden></p>
<p class="sub" id="vote-hint">Highlighted days are on offer. Circle every one that works for you, then send your answer. Answering again with the same name updates your earlier answer.</p>
<div class="card">
<div id="calendar"></div>
</div>
<div class="card" id="vote-card">
<label class="field">
<span>Your name</span>
<input type="text" id="voter-name" maxlength="60" placeholder="So the group knows who answered" autocomplete="name">
</label>
<div class="actions">
<button class="btn btn-primary" id="vote-btn">Send my answer</button>
<span class="note" id="vote-summary"></span>
</div>
<p class="error" id="vote-error" role="alert"></p>
<p class="ok" id="vote-ok" hidden></p>
</div>
<div class="card">
<strong style="font-size:14px;">Answers so far <span class="hint" id="answer-count"></span></strong>
<div id="results"></div>
</div>
<div class="card" id="admin-card" hidden>
<strong style="font-size:14px;">Organizer tools</strong>
<p class="note" style="margin:6px 0 12px;">Polls can't be edited after creation — only closed (stops new answers) or deleted for good.</p>
<div class="actions">
<button class="btn btn-ghost" id="close-btn">Close poll</button>
<button class="btn btn-danger" id="delete-btn">Delete poll</button>
</div>
<p class="error" id="admin-error" role="alert"></p>
</div>
</div>
</div>
<script src="/static/calendar.js"></script>
<script src="/static/poll.js"></script>
</body>
</html>
+202
View File
@@ -0,0 +1,202 @@
// Poll page: same calendar widget, voters circle the offered days that work.
const pollID = location.pathname.split("/").pop();
const admin = new URLSearchParams(location.search).get("admin") || "";
let poll = null;
let cal = null;
const myPicks = new Set(); // option IDs the current visitor selected
const byDate = new Map(); // dateStr -> option
function $(id) { return document.getElementById(id); }
async function loadPoll() {
const res = await fetch(`/api/polls/${pollID}` + (admin ? `?admin=${encodeURIComponent(admin)}` : ""));
if (!res.ok) {
$("not-found").hidden = false;
return;
}
poll = await res.json();
byDate.clear();
for (const o of poll.options) byDate.set(o.date, o);
renderAll();
}
function voteCount(optionID) {
return poll.votes.filter((v) => v.optionIds.includes(optionID)).length;
}
function renderAll() {
$("poll-view").hidden = false;
$("poll-title").textContent = poll.title;
if (poll.description) {
$("poll-desc").textContent = poll.description;
$("poll-desc").hidden = false;
}
document.title = "Group Dates — " + poll.title;
const canVote = !poll.closed;
$("vote-card").hidden = !canVote;
$("vote-hint").hidden = !canVote;
$("closed-banner").hidden = !poll.closed;
if (poll.isAdmin) {
$("admin-card").hidden = false;
$("close-btn").disabled = poll.closed;
if (poll.closed) $("close-btn").textContent = "Poll is closed";
}
if (!cal) {
cal = new Calendar($("calendar"), {
canClick: (ds) => canVote && byDate.has(ds),
isOffered: (ds) => byDate.has(ds),
isPicked: (ds) => byDate.has(ds) && myPicks.has(byDate.get(ds).id),
onToggle: (ds) => {
const id = byDate.get(ds).id;
if (myPicks.has(id)) myPicks.delete(id);
else myPicks.add(id);
},
afterToggle: updateSummary,
decorate: (ds, cell) => {
const o = byDate.get(ds);
if (!o) return;
if (o.time) {
const chip = document.createElement("span");
chip.className = "timechip";
chip.textContent = o.time;
cell.appendChild(chip);
}
const n = voteCount(o.id);
if (n > 0) {
const badge = document.createElement("span");
badge.className = "badge";
badge.textContent = "\u2713" + n;
cell.appendChild(badge);
}
},
});
if (poll.options.length > 0) cal.goTo(poll.options[0].date);
} else {
cal.render();
}
updateSummary();
renderResults();
}
function updateSummary() {
const n = myPicks.size;
$("vote-summary").textContent =
n === 0 ? "No days circled yet." : n === 1 ? "1 day circled." : `${n} days circled.`;
}
function renderResults() {
const el = $("results");
el.innerHTML = "";
$("answer-count").textContent =
poll.votes.length === 0 ? "" : `(${poll.votes.length} ${poll.votes.length === 1 ? "person" : "people"})`;
if (poll.votes.length === 0) {
el.innerHTML = '<p class="empty">No answers yet. Be the first!</p>';
return;
}
const counts = poll.options.map((o) => voteCount(o.id));
const best = Math.max(...counts);
poll.options.forEach((o, i) => {
const row = document.createElement("div");
row.className = "result-row" + (counts[i] === best && best > 0 ? " best" : "");
const when = document.createElement("span");
when.className = "when";
when.textContent = formatDate(o.date);
if (o.time) {
const t = document.createElement("span");
t.className = "t";
t.textContent = o.time;
when.appendChild(t);
}
if (counts[i] === best && best > 0) {
const tag = document.createElement("span");
tag.className = "best-tag";
tag.textContent = "works best";
when.appendChild(tag);
}
const who = document.createElement("span");
who.className = "who";
const names = poll.votes.filter((v) => v.optionIds.includes(o.id)).map((v) => v.name);
who.textContent = names.join(", ") || "\u2014";
const count = document.createElement("span");
count.className = "count";
count.textContent = counts[i];
row.append(when, who, count);
el.appendChild(row);
});
}
$("vote-btn").addEventListener("click", async () => {
$("vote-error").textContent = "";
$("vote-ok").hidden = true;
const name = $("voter-name").value.trim();
if (!name) { $("vote-error").textContent = "Enter your name first."; return; }
const btn = $("vote-btn");
btn.disabled = true;
try {
const res = await fetch(`/api/polls/${pollID}/votes`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, optionIds: [...myPicks] }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Could not save your answer.");
$("vote-ok").textContent = "Answer saved. You can change it any time by sending again with the same name.";
$("vote-ok").hidden = false;
await loadPoll();
} catch (e) {
$("vote-error").textContent = e.message;
} finally {
btn.disabled = false;
}
});
async function adminAction(method, path, confirmMsg) {
if (!confirm(confirmMsg)) return;
$("admin-error").textContent = "";
const res = await fetch(path, { method, headers: { "X-Admin-Token": admin } });
const data = await res.json();
if (!res.ok) { $("admin-error").textContent = data.error || "Action failed."; return false; }
return true;
}
$("close-btn").addEventListener("click", async () => {
if (await adminAction("POST", `/api/polls/${pollID}/close`,
"Close this poll? Nobody will be able to answer anymore. This can't be undone.")) {
await loadPoll();
}
});
$("delete-btn").addEventListener("click", async () => {
if (await adminAction("DELETE", `/api/polls/${pollID}`,
"Delete this poll and all answers for good?")) {
location.href = "/";
}
});
loadPoll();
// Refresh results every 30s so the group sees answers roll in.
setInterval(async () => {
if (!poll || document.hidden) return;
const res = await fetch(`/api/polls/${pollID}` + (admin ? `?admin=${encodeURIComponent(admin)}` : ""));
if (!res.ok) return;
poll = await res.json();
byDate.clear();
for (const o of poll.options) byDate.set(o.date, o);
cal.render();
updateSummary();
renderResults();
}, 30000);