forked from quic-issues/427e7578-d7bf-49c8-aee9-2dd999e25316
feat: add combined codebase
This commit is contained in:
66
src/components/AddOption.ts
Normal file
66
src/components/AddOption.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
export function AddOption(
|
||||
onSubmit: (label: string) => string | null,
|
||||
): HTMLElement {
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "add-option-wrapper";
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "text";
|
||||
input.className = "add-option-input";
|
||||
input.placeholder = "Add an option\u2026";
|
||||
input.maxLength = 100;
|
||||
input.setAttribute("aria-label", "New poll option");
|
||||
|
||||
const btn = document.createElement("button");
|
||||
btn.className = "add-option-btn";
|
||||
btn.setAttribute("aria-label", "Add option");
|
||||
btn.innerHTML = `
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<path d="M8 2v12M2 8h12" stroke="currentColor" stroke-width="1.75" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<span>Add</span>
|
||||
`;
|
||||
|
||||
const feedback = document.createElement("div");
|
||||
feedback.className = "add-option-feedback";
|
||||
feedback.setAttribute("aria-live", "polite");
|
||||
|
||||
wrapper.append(input, btn, feedback);
|
||||
|
||||
function submit() {
|
||||
const name = input.value.trim();
|
||||
if (!name) {
|
||||
input.focus();
|
||||
input.classList.add("shake");
|
||||
input.addEventListener("animationend", () => input.classList.remove("shake"), { once: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const error = onSubmit(name);
|
||||
if (error) {
|
||||
feedback.textContent = error;
|
||||
feedback.style.display = "";
|
||||
setTimeout(() => {
|
||||
feedback.textContent = "";
|
||||
feedback.style.display = "none";
|
||||
}, 3000);
|
||||
return;
|
||||
}
|
||||
|
||||
input.value = "";
|
||||
feedback.textContent = "";
|
||||
feedback.style.display = "none";
|
||||
input.focus();
|
||||
}
|
||||
|
||||
btn.addEventListener("click", submit);
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") submit();
|
||||
});
|
||||
input.addEventListener("input", () => {
|
||||
feedback.textContent = "";
|
||||
feedback.style.display = "none";
|
||||
});
|
||||
|
||||
return wrapper;
|
||||
}
|
||||
86
src/components/DeadlineTimer.ts
Normal file
86
src/components/DeadlineTimer.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import * as Y from "yjs";
|
||||
import { getDeadline } from "../state";
|
||||
|
||||
const DEADLINE_DURATION_MS = 2 * 60 * 1000; // 2 minutes
|
||||
|
||||
export function DeadlineTimer(
|
||||
deadlineMap: Y.Map<unknown>,
|
||||
onStartDeadline: (durationMs: number) => void,
|
||||
onClearDeadline: () => void,
|
||||
): HTMLElement {
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "deadline-wrapper";
|
||||
|
||||
const timerEl = document.createElement("span");
|
||||
timerEl.className = "deadline-timer";
|
||||
|
||||
const startBtn = document.createElement("button");
|
||||
startBtn.className = "deadline-btn";
|
||||
startBtn.textContent = "Start 2-min vote";
|
||||
startBtn.setAttribute("aria-label", "Start a 2-minute voting deadline");
|
||||
|
||||
const clearBtn = document.createElement("button");
|
||||
clearBtn.className = "deadline-btn deadline-btn--clear";
|
||||
clearBtn.textContent = "Clear";
|
||||
clearBtn.setAttribute("aria-label", "Remove voting deadline");
|
||||
|
||||
wrapper.append(timerEl, startBtn, clearBtn);
|
||||
|
||||
let interval: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
function render() {
|
||||
const deadline = getDeadline(deadlineMap);
|
||||
const now = Date.now();
|
||||
|
||||
if (deadline === null) {
|
||||
// No deadline set
|
||||
timerEl.textContent = "";
|
||||
timerEl.className = "deadline-timer";
|
||||
startBtn.hidden = false;
|
||||
clearBtn.hidden = true;
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
interval = undefined;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
startBtn.hidden = true;
|
||||
clearBtn.hidden = false;
|
||||
|
||||
if (now >= deadline) {
|
||||
// Voting closed
|
||||
timerEl.textContent = "Voting closed";
|
||||
timerEl.className = "deadline-timer deadline-timer--closed";
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
interval = undefined;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Counting down
|
||||
const remaining = Math.ceil((deadline - now) / 1000);
|
||||
const mins = Math.floor(remaining / 60);
|
||||
const secs = remaining % 60;
|
||||
timerEl.textContent = `Voting closes in ${mins}:${secs.toString().padStart(2, "0")}`;
|
||||
timerEl.className = "deadline-timer deadline-timer--active";
|
||||
|
||||
if (!interval) {
|
||||
interval = setInterval(() => render(), 1000);
|
||||
}
|
||||
}
|
||||
|
||||
startBtn.addEventListener("click", () => {
|
||||
onStartDeadline(DEADLINE_DURATION_MS);
|
||||
});
|
||||
|
||||
clearBtn.addEventListener("click", () => {
|
||||
onClearDeadline();
|
||||
});
|
||||
|
||||
deadlineMap.observe(() => render());
|
||||
render();
|
||||
|
||||
return wrapper;
|
||||
}
|
||||
127
src/components/PollList.ts
Normal file
127
src/components/PollList.ts
Normal 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;
|
||||
}
|
||||
46
src/components/PollOption.ts
Normal file
46
src/components/PollOption.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { escapeHtml } from "../state";
|
||||
|
||||
export interface PollOptionProps {
|
||||
id: string;
|
||||
name: string;
|
||||
votes: number;
|
||||
voted: boolean;
|
||||
totalVotes: number;
|
||||
votingClosed: boolean;
|
||||
onVote: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
export function PollOption(props: PollOptionProps): HTMLElement {
|
||||
const { id, name, votes, voted, totalVotes, votingClosed, onVote, onDelete } = props;
|
||||
|
||||
const row = document.createElement("div");
|
||||
row.className = `poll-option${voted ? " poll-option--voted" : ""}`;
|
||||
row.dataset.id = id;
|
||||
|
||||
const pct = totalVotes > 0 ? Math.round((votes / totalVotes) * 100) : 0;
|
||||
|
||||
row.innerHTML = `
|
||||
<div class="poll-option__bar" style="width: ${pct}%"></div>
|
||||
<div class="poll-option__content">
|
||||
<span class="poll-option__name">${escapeHtml(name)}</span>
|
||||
<div class="poll-option__actions">
|
||||
<span class="poll-option__pct">${pct}%</span>
|
||||
<span class="poll-option__count">${votes} vote${votes !== 1 ? "s" : ""}</span>
|
||||
<button class="poll-option__vote-btn" aria-pressed="${voted}"${votingClosed ? " disabled" : ""}>
|
||||
${voted ? "Voted" : "Vote"}
|
||||
</button>
|
||||
<button class="poll-option__delete-btn" aria-label="Remove option">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M2 2l10 10M12 2L2 12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
row.querySelector(".poll-option__vote-btn")!.addEventListener("click", () => onVote(id));
|
||||
row.querySelector(".poll-option__delete-btn")!.addEventListener("click", () => onDelete(id));
|
||||
|
||||
return row;
|
||||
}
|
||||
34
src/components/PollTitle.ts
Normal file
34
src/components/PollTitle.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import * as Y from "yjs";
|
||||
|
||||
export function PollTitle(ydoc: Y.Doc, yTitle: Y.Text): HTMLElement {
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "poll-title-wrapper";
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "text";
|
||||
input.id = "poll-title";
|
||||
input.className = "poll-title-input";
|
||||
input.placeholder = "Untitled Poll";
|
||||
input.maxLength = 120;
|
||||
input.setAttribute("aria-label", "Poll title");
|
||||
input.value = yTitle.toString();
|
||||
|
||||
wrapper.appendChild(input);
|
||||
|
||||
// Sync from Yjs → input (only when not focused to avoid cursor jump)
|
||||
yTitle.observe(() => {
|
||||
if (document.activeElement !== input) {
|
||||
input.value = yTitle.toString();
|
||||
}
|
||||
});
|
||||
|
||||
// Sync from input → Yjs
|
||||
input.addEventListener("input", () => {
|
||||
ydoc.transact(() => {
|
||||
yTitle.delete(0, yTitle.length);
|
||||
yTitle.insert(0, input.value);
|
||||
});
|
||||
});
|
||||
|
||||
return wrapper;
|
||||
}
|
||||
39
src/components/ShareSection.ts
Normal file
39
src/components/ShareSection.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
export function ShareSection(roomName: string): HTMLElement {
|
||||
const url = `${window.location.origin}${window.location.pathname}?room=${encodeURIComponent(roomName)}`;
|
||||
|
||||
const section = document.createElement("div");
|
||||
section.className = "share-section";
|
||||
|
||||
section.innerHTML = `
|
||||
<p class="share-label">Share this poll</p>
|
||||
<div class="share-row">
|
||||
<code class="share-url" title="${url}">${url}</code>
|
||||
<button class="share-copy-btn">Copy link</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const copyBtn = section.querySelector<HTMLButtonElement>(".share-copy-btn")!;
|
||||
|
||||
copyBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
copyBtn.textContent = "Copied!";
|
||||
copyBtn.classList.add("share-copy-btn--success");
|
||||
setTimeout(() => {
|
||||
copyBtn.textContent = "Copy link";
|
||||
copyBtn.classList.remove("share-copy-btn--success");
|
||||
}, 2000);
|
||||
} catch {
|
||||
// Fallback: select the text
|
||||
const range = document.createRange();
|
||||
const urlEl = section.querySelector(".share-url");
|
||||
if (urlEl) {
|
||||
range.selectNode(urlEl);
|
||||
window.getSelection()?.removeAllRanges();
|
||||
window.getSelection()?.addRange(range);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return section;
|
||||
}
|
||||
67
src/components/StatusBar.ts
Normal file
67
src/components/StatusBar.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import type { WebrtcProvider } from "y-webrtc";
|
||||
|
||||
export function StatusBar(provider: WebrtcProvider): HTMLElement {
|
||||
const el = document.createElement("div");
|
||||
el.className = "status-bar";
|
||||
|
||||
const dot = document.createElement("span");
|
||||
dot.className = "status-dot connecting";
|
||||
|
||||
const statusText = document.createElement("span");
|
||||
statusText.className = "status-text";
|
||||
statusText.textContent = "Connecting";
|
||||
|
||||
const divider = document.createElement("span");
|
||||
divider.className = "status-divider";
|
||||
divider.textContent = "\u00b7";
|
||||
|
||||
const peerText = document.createElement("span");
|
||||
peerText.className = "status-peers";
|
||||
|
||||
el.append(dot, statusText, divider, peerText);
|
||||
|
||||
// --- Connection state ---
|
||||
|
||||
let syncTimeout: ReturnType<typeof setTimeout> | undefined = setTimeout(() => {
|
||||
statusText.textContent = "Ready";
|
||||
dot.className = "status-dot ready";
|
||||
}, 3000);
|
||||
|
||||
provider.on("synced", ({ synced }: { synced: boolean }) => {
|
||||
if (syncTimeout) {
|
||||
clearTimeout(syncTimeout);
|
||||
syncTimeout = undefined;
|
||||
}
|
||||
dot.className = `status-dot ${synced ? "connected" : "connecting"}`;
|
||||
statusText.textContent = synced ? "Connected" : "Connecting";
|
||||
});
|
||||
|
||||
// Online/offline awareness
|
||||
const handleOffline = () => {
|
||||
dot.className = "status-dot connecting";
|
||||
statusText.textContent = "Offline";
|
||||
};
|
||||
const handleOnline = () => {
|
||||
dot.className = "status-dot connecting";
|
||||
statusText.textContent = "Reconnecting";
|
||||
};
|
||||
|
||||
window.addEventListener("offline", handleOffline);
|
||||
window.addEventListener("online", handleOnline);
|
||||
|
||||
// --- Peer count ---
|
||||
|
||||
function updatePeerCount() {
|
||||
const total = provider.awareness.getStates().size;
|
||||
const others = total - 1;
|
||||
peerText.textContent =
|
||||
others === 0
|
||||
? "Only you"
|
||||
: `${others} other${others !== 1 ? "s" : ""}`;
|
||||
}
|
||||
|
||||
provider.awareness.on("change", updatePeerCount);
|
||||
updatePeerCount();
|
||||
|
||||
return el;
|
||||
}
|
||||
Reference in New Issue
Block a user