Compare commits

..

1 Commits

Author SHA1 Message Date
Patrick Charrier
32e39384d5 feat: add combined codebase 2026-04-15 01:23:33 +02:00
21 changed files with 3008 additions and 207 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
node_modules
dist

102
PLAN.md Normal file
View File

@@ -0,0 +1,102 @@
# Plan: Combined P2P Polling App
## Overview
Merge the three subfolder projects into a single TypeScript + Yjs + Vite application at the repo root, combining the best features from each.
## Technology Choices
- **Language:** TypeScript (from project 3) with strict mode
- **CRDT:** Yjs + y-webrtc + y-indexeddb (from projects 1 & 3)
- **Build:** Vite (shared by all)
- **Package manager:** npm
## Feature Superset
From **project 1** (group-efa16e66):
- Collaborative title editing via Y.Text (real-time character-level sync)
- Delete options
- Diff-rendering for poll list (reuse DOM elements)
- Peer count display via awareness
- Share section with copy-to-clipboard
From **project 2** (proposal-8835ffc9):
- Deadline/timer system (2-minute voting window with countdown)
- Duplicate detection (case-insensitive)
From **project 3** (proposal-88461784):
- TypeScript types & strict mode
- Modular architecture (state, sync, render, identity, app layers)
- IndexedDB persistence (offline support)
- Online/offline connection tracking
- Input validation (max lengths, empty checks)
- HTML escaping for XSS prevention
- ViewModel pattern for clean render layer
## Data Model (Yjs)
```
Y.Doc
├── poll-meta (Y.Map<string>)
│ └── title → Y.Text (collaborative editing from project 1)
├── poll-options (Y.Map<OptionRecord>)
│ └── [optionId] → { id, label, createdAt, createdBy }
├── poll-votes (Y.Map<string>)
│ └── [userId] → optionId (single vote per user)
└── poll-deadline (Y.Map<any>)
└── deadline → number | null (timestamp, from project 2)
```
## Architecture & File Structure
```
/
├── index.html
├── package.json
├── tsconfig.json
├── vite.config.ts (if needed for any plugins)
├── src/
│ ├── main.ts (entry: mount app)
│ ├── app.ts (orchestrator: init sync, bind events, manage state)
│ ├── identity.ts (getUserId with localStorage persistence)
│ ├── state.ts (types, pure functions, ViewModel creation)
│ ├── sync.ts (Yjs doc, WebRTC provider, IndexedDB, connection status)
│ ├── render.ts (DOM rendering with escapeHtml, diff-rendering for options)
│ ├── components/
│ │ ├── PollTitle.ts (collaborative title input bound to Y.Text)
│ │ ├── PollList.ts (diff-rendered option list with sorting)
│ │ ├── PollOption.ts (single option: vote bar, vote/delete buttons)
│ │ ├── AddOption.ts (input + submit with validation & duplicate check)
│ │ ├── StatusBar.ts (connection status + peer count)
│ │ ├── ShareSection.ts (copy URL to clipboard)
│ │ └── DeadlineTimer.ts (set deadline + countdown display, from project 2)
│ └── styles.css (merged: project 1's design tokens + project 3's glassmorphism)
```
## Implementation Steps
### Step 1: Scaffold root project
- Create `package.json` with dependencies: yjs, y-webrtc, y-indexeddb, vite, typescript
- Create `tsconfig.json` (strict, ES2022, bundler resolution)
- Create `index.html` entry point
- Create `vite.config.ts` if needed
### Step 2: Core layer — identity.ts, state.ts, sync.ts
- `identity.ts`: port from project 3 (getUserId)
- `state.ts`: port types from project 3, add deadline types, add ViewModel with deadline/timer info, add vote percentage calculation from project 1
- `sync.ts`: port from project 3, add Y.Text for title (from project 1), add poll-deadline map, add awareness tracking for peer count (from project 1)
### Step 3: App orchestrator — app.ts, main.ts
- `app.ts`: port from project 3, add deadline handlers, add delete option handler, wire up all components
- `main.ts`: minimal entry that calls initApp
### Step 4: Components
- `PollTitle.ts`: port collaborative Y.Text editing from project 1, add TypeScript types
- `AddOption.ts`: merge project 1 (UI/animation) + project 2 (duplicate detection) + project 3 (validation)
- `PollOption.ts`: port from project 1 (vote bar, percentage, delete button), add TypeScript
- `PollList.ts`: port diff-rendering from project 1, add TypeScript
- `StatusBar.ts`: merge project 1 (peer count) + project 3 (online/offline status)
- `ShareSection.ts`: port from project 1, add TypeScript
- `DeadlineTimer.ts`: new component porting project 2's deadline/countdown logic to Yjs
### Step 5: Styling
- Merge CSS: use project 1's design tokens and typography as base, incorporate project 3's glassmorphism panel effects, add timer-specific styles from project 2
### Step 6: Cleanup
- Remove three subfolders (after confirming with user)
- Update root README.md

View File

@@ -1,200 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>PeertoPeer Poll up to 14 users (no vote loops)</title>
<style>
body {font-family:Arial,Helvetica,sans-serif; margin:20px;}
#options li {margin:5px 0;}
button {margin-left:5px;}
</style>
</head>
<body>
<h2>PeertoPeer Poll (max14 participants)</h2>
<div id="setup">
<label>Your Peer ID: <input id="myId" readonly size="30"></label><br><br>
<label>Room host ID (leave empty if you are the first client):
<input id="hostId" placeholder="host peer id">
</label>
<button id="joinBtn">Join / Create Room</button>
<p id="status"></p>
</div>
<hr>
<h3>Poll</h3>
<ul id="options"></ul>
<input id="newOption" placeholder="New option text">
<button id="addOptionBtn">Add option</button>
<script src="https://unpkg.com/peerjs@1.5.4/dist/peerjs.min.js"></script>
<script>
/* ---------- 1. Initialise PeerJS ---------- */
const peer = new Peer(); // public signalling server
const myIdInput = document.getElementById('myId');
const statusEl = document.getElementById('status');
let isHost = false; // true for the first client
let connections = []; // all open DataConnections
let knownPeers = new Set(); // IDs of every peer we know (including host)
peer.on('open', id => {
myIdInput.value = id;
statusEl.textContent = 'Enter a host ID (or leave empty) and click Join.';
});
/* ---------- 2. Accept incoming connections (host side) ---------- */
peer.on('connection', incoming => {
registerConnection(incoming);
});
/* ---------- 3. Join / create a room ---------- */
document.getElementById('joinBtn').onclick = () => {
const hostId = document.getElementById('hostId').value.trim();
if (hostId) {
isHost = false;
connectToPeer(hostId);
statusEl.textContent = `Connecting to host ${hostId}`;
} else {
isHost = true;
knownPeers.add(peer.id);
statusEl.textContent = 'Room created share this Peer ID with others.';
}
};
/* ---------- 4. Helper: connect to a remote peer ---------- */
function connectToPeer(peerId) {
if (knownPeers.has(peerId)) return;
const conn = peer.connect(peerId);
registerConnection(conn);
}
/* ---------- 5. Register a DataConnection ---------- */
function registerConnection(conn) {
if (!conn) return;
connections.push(conn);
knownPeers.add(conn.peer);
conn.on('open', () => {
// 1⃣ Send full poll state
conn.send({type: 'full', payload: poll, msgId: crypto.randomUUID()});
// 2⃣ If we are the host, tell the newcomer about other peers
if (isHost) {
const others = Array.from(knownPeers).filter(id => id !== conn.peer && id !== peer.id);
if (others.length) conn.send({type: 'peer-list', payload: others, msgId: crypto.randomUUID()});
}
});
conn.on('data', data => handleMessage(data, conn.peer));
conn.on('close', () => {
connections = connections.filter(c => c !== conn);
knownPeers.delete(conn.peer);
});
}
/* ---------- 6. Shared poll state ---------- */
let poll = {options: []}; // each option: {id, text, votes}
/* ---------- 7. Track my own votes ---------- */
const myVotes = new Set(); // option.id values
/* ---------- 8. Remember which messages we already processed ---------- */
const seenMsgIds = new Set();
/* ---------- 9. Broadcast helper (skip the sender) ---------- */
function broadcast(msg, except = null) {
connections.forEach(c => {
if (c.open && c.peer !== except) c.send(msg);
});
}
/* ---------- 10. Message handling ---------- */
function handleMessage(msg, senderId) {
// Ignore duplicates
if (seenMsgIds.has(msg.msgId)) return;
seenMsgIds.add(msg.msgId);
switch (msg.type) {
case 'full':
poll = msg.payload;
render();
break;
case 'add':
poll.options.push(msg.payload);
render();
broadcast(msg, senderId); // forward once
break;
case 'vote':
const opt = poll.options.find(o => o.id === msg.payload.id);
if (opt) opt.votes++;
render();
broadcast(msg, senderId);
break;
case 'peer-list':
msg.payload.forEach(id => {
if (id !== peer.id && !knownPeers.has(id)) connectToPeer(id);
});
break;
}
}
/* ---------- 11. UI rendering ---------- */
function render() {
const ul = document.getElementById('options');
ul.innerHTML = '';
poll.options.forEach(opt => {
const li = document.createElement('li');
li.textContent = `${opt.text} ${opt.votes} vote(s)`;
const btn = document.createElement('button');
btn.textContent = myVotes.has(opt.id) ? 'Voted' : 'Vote';
btn.disabled = myVotes.has(opt.id);
btn.onclick = () => {
// Record locally prevents doubleclick on the same client
myVotes.add(opt.id);
btn.textContent = 'Voted';
btn.disabled = true;
const voteMsg = {
type: 'vote',
payload: {id: opt.id},
msgId: crypto.randomUUID()
};
// Apply locally first
opt.votes++;
render();
// Send to all peers
broadcast(voteMsg);
};
li.appendChild(btn);
ul.appendChild(li);
});
}
/* ---------- 12. Add new option ---------- */
document.getElementById('addOptionBtn').onclick = () => {
const txt = document.getElementById('newOption').value.trim();
if (!txt) return;
const option = {id: crypto.randomUUID(), text: txt, votes: 0};
poll.options.push(option);
render();
const addMsg = {
type: 'add',
payload: option,
msgId: crypto.randomUUID()
};
broadcast(addMsg);
document.getElementById('newOption').value = '';
};
</script>
</body>
</html>

View File

@@ -1,7 +1 @@
# P2P Poll App
This is a very simple polling app using peerjs.
It runs as a single HTML file and can handle multiple users in one poll room.
The app once open is self explanatory.
App created with the help of GPTOSS120B.

12
index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Polly — P2P Polls</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

1473
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

20
package.json Normal file
View File

@@ -0,0 +1,20 @@
{
"name": "polly-p2p-poll",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"y-indexeddb": "^9.0.12",
"y-webrtc": "^10.3.0",
"yjs": "^13.6.27"
},
"devDependencies": {
"typescript": "^5.9.2",
"vite": "^7.1.5"
}
}

139
src/app.ts Normal file
View File

@@ -0,0 +1,139 @@
import { getUserId } from "./identity";
import {
addOption,
toggleVote,
deleteOption,
setDeadline,
clearDeadline,
createViewModel,
} from "./state";
import { initSync } from "./sync";
import { StatusBar } from "./components/StatusBar";
import { PollTitle } from "./components/PollTitle";
import { AddOption } from "./components/AddOption";
import { PollList } from "./components/PollList";
import { ShareSection } from "./components/ShareSection";
import { DeadlineTimer } from "./components/DeadlineTimer";
const ROOM_PARAM = "room";
function createRoomId(): string {
if (typeof crypto.randomUUID === "function") {
return `poll-${crypto.randomUUID().slice(0, 8)}`;
}
return `poll-${Math.random().toString(36).slice(2, 10)}`;
}
function ensureRoomId(): string {
const url = new URL(window.location.href);
let roomId = url.searchParams.get(ROOM_PARAM)?.trim();
if (!roomId) {
roomId = createRoomId();
url.searchParams.set(ROOM_PARAM, roomId);
window.history.replaceState({}, "", url);
}
return roomId;
}
export function initApp(container: HTMLElement): () => void {
const roomId = ensureRoomId();
const userId = getUserId();
const sync = initSync(roomId);
const shareUrl = window.location.href;
// --- Actions ---
const actions = {
addOption: (label: string) => {
const vm = createViewModel(getViewModelParams());
if (vm.votingClosed) return;
return addOption(sync.options, label, userId);
},
toggleVote: (optionId: string) => {
const vm = createViewModel(getViewModelParams());
if (vm.votingClosed) return;
toggleVote(sync.votes, userId, optionId);
},
deleteOption: (optionId: string) => {
deleteOption(sync.options, sync.votes, optionId);
},
startDeadline: (durationMs: number) => {
setDeadline(sync.deadlineMap, durationMs);
},
clearDeadline: () => {
clearDeadline(sync.deadlineMap);
},
};
function getViewModelParams() {
return {
yTitle: sync.yTitle,
options: sync.options,
votes: sync.votes,
deadlineMap: sync.deadlineMap,
roomId,
shareUrl,
connectionStatus: sync.getConnectionStatus(),
peerCount: sync.getPeerCount(),
userId,
};
}
// --- Build UI ---
// Header
const header = document.createElement("header");
header.className = "app-header";
const wordmark = document.createElement("div");
wordmark.className = "app-wordmark";
wordmark.innerHTML = `
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<rect x="2" y="4" width="16" height="2.5" rx="1.25" fill="currentColor"/>
<rect x="2" y="8.75" width="11" height="2.5" rx="1.25" fill="currentColor" opacity="0.6"/>
<rect x="2" y="13.5" width="13" height="2.5" rx="1.25" fill="currentColor" opacity="0.35"/>
</svg>
<span>Polly</span>
`;
const statusBar = StatusBar(sync.provider);
header.append(wordmark, statusBar);
// Main card
const card = document.createElement("main");
card.className = "app-card";
const pollTitle = PollTitle(sync.doc, sync.yTitle);
const addOptionComponent = AddOption((label: string) => {
const result = actions.addOption(label);
if (result && !result.ok) return result.error;
return null;
});
const pollList = PollList(sync.options, sync.votes, userId, () => {
const vm = createViewModel(getViewModelParams());
return vm.votingClosed;
}, actions.toggleVote, actions.deleteOption);
const deadlineTimer = DeadlineTimer(
sync.deadlineMap,
actions.startDeadline,
actions.clearDeadline,
);
card.append(pollTitle, addOptionComponent, deadlineTimer, pollList);
// Footer
const footer = document.createElement("footer");
footer.className = "app-footer";
footer.appendChild(ShareSection(roomId));
container.append(header, card, footer);
// --- Cleanup ---
return () => {
sync.destroy();
};
}

View 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;
}

View 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
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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

17
src/identity.ts Normal file
View File

@@ -0,0 +1,17 @@
const USER_ID_KEY = "polly:user-id";
function createUserId(): string {
if (typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
return `user-${Math.random().toString(36).slice(2, 10)}`;
}
export function getUserId(): string {
const existing = localStorage.getItem(USER_ID_KEY);
if (existing) return existing;
const next = createUserId();
localStorage.setItem(USER_ID_KEY, next);
return next;
}

9
src/main.ts Normal file
View File

@@ -0,0 +1,9 @@
import "./styles.css";
import { initApp } from "./app";
const container = document.querySelector<HTMLElement>("#app");
if (!container) {
throw new Error("App container not found.");
}
initApp(container);

207
src/state.ts Normal file
View File

@@ -0,0 +1,207 @@
import * as Y from "yjs";
// --- Types ---
export type ConnectionStatus = "connecting" | "connected" | "offline";
export interface OptionRecord {
id: string;
label: string;
createdAt: number;
createdBy: string;
}
export interface PollOptionViewModel extends OptionRecord {
voteCount: number;
isVotedByMe: boolean;
percentage: number;
}
export interface PollViewModel {
title: string;
roomId: string;
shareUrl: string;
connectionStatus: ConnectionStatus;
peerCount: number;
options: PollOptionViewModel[];
totalVotes: number;
myVoteOptionId: string | null;
deadline: number | null;
votingClosed: boolean;
}
// --- Helpers ---
export function createOptionId(): string {
if (typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
return `option-${Math.random().toString(36).slice(2, 10)}`;
}
function normalizeLabel(label: string): string {
return label.trim().replace(/\s+/g, " ");
}
export function escapeHtml(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
// --- Title ---
export function getPollTitle(yTitle: Y.Text): string {
const title = yTitle.toString();
return title || "Untitled Poll";
}
// --- Options ---
export function addOption(
options: Y.Map<OptionRecord>,
rawLabel: string,
userId: string,
): { ok: true; optionId: string } | { ok: false; error: string } {
const label = normalizeLabel(rawLabel);
if (!label) {
return { ok: false, error: "Option cannot be empty." };
}
const normalizedTarget = label.toLocaleLowerCase();
const duplicate = Array.from(options.values()).some(
(option) => option.label.trim().toLocaleLowerCase() === normalizedTarget,
);
if (duplicate) {
return { ok: false, error: "That option already exists." };
}
const option: OptionRecord = {
id: createOptionId(),
label,
createdAt: Date.now(),
createdBy: userId,
};
options.set(option.id, option);
return { ok: true, optionId: option.id };
}
export function toggleVote(
votes: Y.Map<string>,
userId: string,
optionId: string,
): void {
const current = votes.get(userId);
if (current === optionId) {
votes.delete(userId);
} else {
votes.set(userId, optionId);
}
}
export function deleteOption(
options: Y.Map<OptionRecord>,
votes: Y.Map<string>,
optionId: string,
): void {
options.delete(optionId);
// Clean up votes pointing to this option
for (const [userId, votedOptionId] of votes.entries()) {
if (votedOptionId === optionId) {
votes.delete(userId);
}
}
}
// --- Deadline ---
export function setDeadline(
deadlineMap: Y.Map<unknown>,
durationMs: number,
): void {
deadlineMap.set("deadline", Date.now() + durationMs);
}
export function clearDeadline(deadlineMap: Y.Map<unknown>): void {
deadlineMap.delete("deadline");
}
export function getDeadline(deadlineMap: Y.Map<unknown>): number | null {
const val = deadlineMap.get("deadline");
return typeof val === "number" ? val : null;
}
// --- ViewModel ---
export function createViewModel(params: {
yTitle: Y.Text;
options: Y.Map<OptionRecord>;
votes: Y.Map<string>;
deadlineMap: Y.Map<unknown>;
roomId: string;
shareUrl: string;
connectionStatus: ConnectionStatus;
peerCount: number;
userId: string;
}): PollViewModel {
const {
yTitle,
options,
votes,
deadlineMap,
roomId,
shareUrl,
connectionStatus,
peerCount,
userId,
} = params;
// Tally votes per option
const tally = new Map<string, number>();
for (const optionId of votes.values()) {
tally.set(optionId, (tally.get(optionId) ?? 0) + 1);
}
let totalVotes = 0;
for (const count of tally.values()) {
totalVotes += count;
}
const myVoteOptionId = votes.get(userId) ?? null;
const deadline = getDeadline(deadlineMap);
const votingClosed = deadline !== null && Date.now() >= deadline;
// Sort by votes desc, then alphabetically
const sortedOptions = Array.from(options.values()).sort((a, b) => {
const aVotes = tally.get(a.id) ?? 0;
const bVotes = tally.get(b.id) ?? 0;
if (bVotes !== aVotes) return bVotes - aVotes;
return a.label.localeCompare(b.label);
});
return {
title: getPollTitle(yTitle),
roomId,
shareUrl,
connectionStatus,
peerCount,
myVoteOptionId,
totalVotes,
deadline,
votingClosed,
options: sortedOptions.map((option) => ({
...option,
voteCount: tally.get(option.id) ?? 0,
isVotedByMe: myVoteOptionId === option.id,
percentage:
totalVotes > 0
? Math.round(((tally.get(option.id) ?? 0) / totalVotes) * 100)
: 0,
})),
};
}

469
src/styles.css Normal file
View File

@@ -0,0 +1,469 @@
/* ── Fonts ─────────────────────────────────────────────── */
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@300;400;500;600&family=Playfair+Display:wght@500&display=swap');
/* ── Tokens ────────────────────────────────────────────── */
:root {
--bg: #F7F6F2;
--surface: #FFFFFF;
--surface-hover: #FAFAF8;
--border: #E8E5DF;
--border-focus: #1A1A1A;
--text-primary: #1A1A1A;
--text-secondary: #6B6860;
--text-muted: #AAA79F;
--accent: #1A1A1A;
--accent-text: #FFFFFF;
--vote-bar: rgba(26, 26, 26, 0.07);
--vote-bar-voted: rgba(26, 26, 26, 0.12);
--success: #2D7D46;
--danger: #C0392B;
--warning: #8c5300;
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 14px;
--font-display: 'Playfair Display', Georgia, serif;
--font-body: 'DM Sans', system-ui, sans-serif;
--shadow-card: 0 1px 3px rgba(0,0,0,0.06), 0 4px 16px rgba(0,0,0,0.05);
}
/* ── Reset ─────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
/* ── Base ──────────────────────────────────────────────── */
html { font-size: 16px; }
body {
font-family: var(--font-body);
background: var(--bg);
color: var(--text-primary);
min-height: 100vh;
-webkit-font-smoothing: antialiased;
}
button, input { font: inherit; }
button { cursor: pointer; }
/* ── Layout ────────────────────────────────────────────── */
#app {
max-width: 580px;
margin: 0 auto;
padding: 2rem 1.25rem 4rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
/* ── Header ────────────────────────────────────────────── */
.app-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 0.25rem;
}
.app-wordmark {
display: flex;
align-items: center;
gap: 0.5rem;
font-family: var(--font-display);
font-size: 1.1rem;
color: var(--text-primary);
letter-spacing: -0.01em;
}
/* ── Status bar ────────────────────────────────────────── */
.status-bar {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.8rem;
color: var(--text-secondary);
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
transition: background 0.3s;
}
.status-dot.connecting { background: var(--text-muted); }
.status-dot.ready { background: var(--text-muted); }
.status-dot.connected { background: var(--success); }
.status-divider { color: var(--text-muted); }
/* ── Card ──────────────────────────────────────────────── */
.app-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
overflow: hidden;
}
/* ── Poll Title ────────────────────────────────────────── */
.poll-title-wrapper {
padding: 1.75rem 1.75rem 1.25rem;
border-bottom: 1px solid var(--border);
}
.poll-title-input {
width: 100%;
font-family: var(--font-display);
font-size: 1.5rem;
font-weight: 500;
color: var(--text-primary);
background: transparent;
border: none;
outline: none;
line-height: 1.3;
letter-spacing: -0.02em;
}
.poll-title-input::placeholder { color: var(--text-muted); }
/* ── Add Option ────────────────────────────────────────── */
.add-option-wrapper {
display: flex;
flex-wrap: wrap;
gap: 0.625rem;
padding: 1.25rem 1.75rem;
border-bottom: 1px solid var(--border);
}
.add-option-input {
flex: 1;
min-width: 0;
height: 2.5rem;
padding: 0 0.875rem;
font-size: 0.9rem;
color: var(--text-primary);
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
outline: none;
transition: border-color 0.15s;
}
.add-option-input::placeholder { color: var(--text-muted); }
.add-option-input:focus { border-color: var(--border-focus); }
.add-option-input.shake {
animation: shake 0.3s ease;
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-4px); }
75% { transform: translateX(4px); }
}
.add-option-btn {
display: flex;
align-items: center;
gap: 0.375rem;
height: 2.5rem;
padding: 0 1rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--accent-text);
background: var(--accent);
border: none;
border-radius: var(--radius-sm);
transition: opacity 0.15s;
white-space: nowrap;
}
.add-option-btn:hover { opacity: 0.85; }
.add-option-btn:active { opacity: 0.7; }
.add-option-feedback {
width: 100%;
font-size: 0.8rem;
color: var(--danger);
display: none;
}
/* ── Deadline Timer ────────────────────────────────────── */
.deadline-wrapper {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1.75rem;
border-bottom: 1px solid var(--border);
font-size: 0.85rem;
}
.deadline-timer {
flex: 1;
font-weight: 500;
}
.deadline-timer--active {
color: var(--success);
}
.deadline-timer--closed {
color: var(--danger);
font-weight: 600;
}
.deadline-btn {
height: 2rem;
padding: 0 0.875rem;
font-size: 0.8rem;
font-weight: 500;
color: var(--text-secondary);
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
transition: all 0.15s;
white-space: nowrap;
}
.deadline-btn:hover {
border-color: var(--accent);
color: var(--accent);
}
.deadline-btn--clear:hover {
border-color: var(--danger);
color: var(--danger);
}
/* ── Poll List ─────────────────────────────────────────── */
.poll-list-wrapper {
padding: 0.5rem 0;
}
.poll-list-meta {
padding: 0.5rem 1.75rem 0.75rem;
font-size: 0.775rem;
color: var(--text-muted);
letter-spacing: 0.02em;
text-transform: uppercase;
font-weight: 500;
}
.poll-list-empty {
padding: 3rem 1.75rem;
text-align: center;
color: var(--text-muted);
font-size: 0.875rem;
}
.empty-icon {
margin-bottom: 0.75rem;
opacity: 0.6;
}
/* ── Poll Option ───────────────────────────────────────── */
.poll-option {
position: relative;
overflow: hidden;
transition: background 0.15s;
}
.poll-option:hover {
background: var(--surface-hover);
}
.poll-option__bar {
position: absolute;
inset: 0 auto 0 0;
background: var(--vote-bar);
transition: width 0.5s cubic-bezier(0.4, 0, 0.2, 1);
pointer-events: none;
}
.poll-option--voted .poll-option__bar {
background: var(--vote-bar-voted);
}
.poll-option__content {
position: relative;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.875rem 1.75rem;
}
.poll-option__name {
flex: 1;
font-size: 0.9375rem;
font-weight: 400;
color: var(--text-primary);
word-break: break-word;
}
.poll-option--voted .poll-option__name {
font-weight: 500;
}
.poll-option__actions {
display: flex;
align-items: center;
gap: 0.625rem;
flex-shrink: 0;
}
.poll-option__pct {
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-secondary);
min-width: 2.5rem;
text-align: right;
}
.poll-option__count {
font-size: 0.775rem;
color: var(--text-muted);
min-width: 3.5rem;
}
.poll-option__vote-btn {
height: 1.875rem;
padding: 0 0.875rem;
font-size: 0.8125rem;
font-weight: 500;
border-radius: var(--radius-sm);
transition: all 0.15s;
white-space: nowrap;
background: transparent;
color: var(--text-secondary);
border: 1px solid var(--border);
}
.poll-option__vote-btn:hover:not(:disabled) {
border-color: var(--accent);
color: var(--accent);
}
.poll-option__vote-btn:disabled {
opacity: 0.5;
cursor: default;
}
.poll-option--voted .poll-option__vote-btn {
background: var(--accent);
color: var(--accent-text);
border-color: var(--accent);
}
.poll-option--voted .poll-option__vote-btn:hover:not(:disabled) {
opacity: 0.8;
}
.poll-option__delete-btn {
display: flex;
align-items: center;
justify-content: center;
width: 1.625rem;
height: 1.625rem;
background: transparent;
border: none;
border-radius: var(--radius-sm);
color: var(--text-muted);
opacity: 0;
transition: opacity 0.15s, color 0.15s, background 0.15s;
}
.poll-option:hover .poll-option__delete-btn { opacity: 1; }
.poll-option__delete-btn:hover {
color: var(--danger);
background: rgba(192, 57, 43, 0.07);
}
/* ── Footer ────────────────────────────────────────────── */
.app-footer {
padding: 0 0.25rem;
}
/* ── Share Section ─────────────────────────────────────── */
.share-section {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
padding: 1.25rem 1.5rem;
}
.share-label {
font-size: 0.775rem;
font-weight: 500;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.625rem;
}
.share-row {
display: flex;
align-items: center;
gap: 0.625rem;
}
.share-url {
flex: 1;
font-family: 'DM Mono', 'Fira Mono', monospace;
font-size: 0.8rem;
color: var(--text-secondary);
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 0.5rem 0.75rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: block;
user-select: all;
}
.share-copy-btn {
height: 2rem;
padding: 0 0.875rem;
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-secondary);
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
transition: all 0.15s;
white-space: nowrap;
flex-shrink: 0;
}
.share-copy-btn:hover {
border-color: var(--accent);
color: var(--accent);
}
.share-copy-btn--success {
color: var(--success) !important;
border-color: var(--success) !important;
}
/* ── Responsive ────────────────────────────────────────── */
@media (max-width: 480px) {
#app { padding: 1rem 0.75rem 3rem; }
.poll-title-wrapper { padding: 1.25rem 1.25rem 1rem; }
.add-option-wrapper { padding: 1rem 1.25rem; }
.poll-option__content { padding: 0.875rem 1.25rem; }
.poll-list-meta { padding: 0.5rem 1.25rem 0.625rem; }
.poll-list-empty { padding: 2.5rem 1.25rem; }
.deadline-wrapper { padding: 0.75rem 1.25rem; }
.poll-option__count { display: none; }
.share-section { padding: 1rem 1.25rem; }
}

77
src/sync.ts Normal file
View File

@@ -0,0 +1,77 @@
import { IndexeddbPersistence } from "y-indexeddb";
import { WebrtcProvider } from "y-webrtc";
import * as Y from "yjs";
import type { ConnectionStatus, OptionRecord } from "./state";
export interface AppSync {
doc: Y.Doc;
yTitle: Y.Text;
options: Y.Map<OptionRecord>;
votes: Y.Map<string>;
deadlineMap: Y.Map<unknown>;
provider: WebrtcProvider;
persistence: IndexeddbPersistence;
getConnectionStatus: () => ConnectionStatus;
getPeerCount: () => number;
destroy: () => void;
}
export function initSync(roomId: string): AppSync {
const doc = new Y.Doc();
const yTitle = doc.getText("poll-title");
const options = doc.getMap<OptionRecord>("poll-options");
const votes = doc.getMap<string>("poll-votes");
const deadlineMap = doc.getMap<unknown>("poll-deadline");
let connectionStatus: ConnectionStatus = navigator.onLine
? "connecting"
: "offline";
const provider = new WebrtcProvider(roomId, doc);
const persistence = new IndexeddbPersistence(roomId, doc);
const syncConnectionStatus = (status: ConnectionStatus) => {
connectionStatus = navigator.onLine ? status : "offline";
};
const handleOnline = () => {
syncConnectionStatus(provider.connected ? "connected" : "connecting");
};
const handleOffline = () => {
connectionStatus = "offline";
};
provider.on("status", (event: { connected: boolean }) => {
syncConnectionStatus(event.connected ? "connected" : "connecting");
});
provider.on("synced", ({ synced }: { synced: boolean }) => {
if (synced) syncConnectionStatus("connected");
});
window.addEventListener("online", handleOnline);
window.addEventListener("offline", handleOffline);
return {
doc,
yTitle,
options,
votes,
deadlineMap,
provider,
persistence,
getConnectionStatus: () => connectionStatus,
getPeerCount: () => {
const total = provider.awareness.getStates().size;
return Math.max(0, total - 1);
},
destroy: () => {
window.removeEventListener("online", handleOnline);
window.removeEventListener("offline", handleOffline);
persistence.destroy();
provider.destroy();
doc.destroy();
},
};
}

15
tsconfig.json Normal file
View File

@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"skipLibCheck": true,
"allowSyntheticDefaultImports": true,
"useDefineForClassFields": true
},
"include": ["src"]
}