feat: add combined codebase

This commit is contained in:
Patrick Charrier
2026-04-15 01:23:33 +02:00
parent 4275cbd795
commit 32e39384d5
19 changed files with 3007 additions and 0 deletions

127
src/components/PollList.ts Normal file
View File

@@ -0,0 +1,127 @@
import * as Y from "yjs";
import type { OptionRecord } from "../state";
import { PollOption } from "./PollOption";
export function PollList(
yOptions: Y.Map<OptionRecord>,
yVotes: Y.Map<string>,
userId: string,
isVotingClosed: () => boolean,
onVote: (optionId: string) => void,
onDelete: (optionId: string) => void,
): HTMLElement {
const wrapper = document.createElement("div");
wrapper.className = "poll-list-wrapper";
const meta = document.createElement("div");
meta.className = "poll-list-meta";
const list = document.createElement("div");
list.className = "poll-list";
const empty = document.createElement("div");
empty.className = "poll-list-empty";
empty.innerHTML = `
<div class="empty-icon">
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="4" y="10" width="24" height="3" rx="1.5" fill="currentColor" opacity="0.15"/>
<rect x="4" y="16" width="18" height="3" rx="1.5" fill="currentColor" opacity="0.1"/>
<rect x="4" y="22" width="21" height="3" rx="1.5" fill="currentColor" opacity="0.07"/>
</svg>
</div>
<p>No options yet — add the first one above.</p>
`;
wrapper.append(meta, list, empty);
function getEntries() {
const entries: Array<{
id: string;
name: string;
votes: number;
voted: boolean;
}> = [];
// Tally votes per option
const tally = new Map<string, number>();
for (const optionId of yVotes.values()) {
tally.set(optionId, (tally.get(optionId) ?? 0) + 1);
}
const myVote = yVotes.get(userId) ?? null;
yOptions.forEach((record, id) => {
entries.push({
id,
name: record.label,
votes: tally.get(id) ?? 0,
voted: myVote === id,
});
});
entries.sort((a, b) => b.votes - a.votes || a.name.localeCompare(b.name));
return entries;
}
function getTotalVotes(): number {
return yVotes.size;
}
function render() {
const entries = getEntries();
const total = getTotalVotes();
const votingClosed = isVotingClosed();
// Meta line
if (entries.length > 0) {
meta.textContent = `${entries.length} option${entries.length !== 1 ? "s" : ""} \u00b7 ${total} vote${total !== 1 ? "s" : ""} total`;
meta.style.display = "";
} else {
meta.style.display = "none";
}
// Empty state
empty.style.display = entries.length === 0 ? "" : "none";
// Diff-render: reuse existing rows when possible
const existing = new Map(
[...list.querySelectorAll<HTMLElement>(".poll-option")].map((el) => [
el.dataset.id,
el,
]),
);
// Remove stale rows
existing.forEach((el, id) => {
if (!entries.find((e) => e.id === id)) el.remove();
});
// Update or insert rows in sorted order
entries.forEach((entry, i) => {
const newEl = PollOption({
...entry,
totalVotes: total,
votingClosed,
onVote,
onDelete,
});
const currentEl = list.children[i] as HTMLElement | undefined;
if (!currentEl) {
list.appendChild(newEl);
} else if (currentEl.dataset.id !== entry.id) {
list.insertBefore(newEl, currentEl);
const old = existing.get(entry.id);
if (old && old !== currentEl) old.remove();
} else {
list.replaceChild(newEl, currentEl);
}
});
}
yOptions.observeDeep(() => render());
yVotes.observe(() => render());
render();
return wrapper;
}