Compare commits

..

3 Commits

Author SHA1 Message Date
Hollajay
5415741940 p2p poll logic added 2026-03-19 10:52:12 +01:00
Hollajay
68797053ac resolved conflict 2026-03-19 01:07:34 +01:00
Hollajay
c0e53ad652 first commit 2026-03-19 00:41:15 +01:00
35 changed files with 6674 additions and 1873 deletions

43
.gitignore vendored
View File

@@ -1,2 +1,41 @@
dist
node_modules
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

View File

@@ -1,44 +1,2 @@
# P2P Poll App
A peer-to-peer polling application where users create and vote on polls without any central server. All data syncs directly between browsers using WebRTC and CRDTs.
## Features
- **Real-time P2P sync** — poll options and votes sync instantly across all connected peers via WebRTC
- **Collaborative poll title** — editable title that syncs between all participants
- **One vote per user** — each peer gets a stable ID, enforcing one vote per person per option
- **Vote/Unvote toggle** — change your mind anytime
- **Connection status** — see when you're connected and how many peers are in the room
- **Shareable polls** — share via URL with `?room=your-poll-name`
- **No backend required** — runs entirely in the browser
## Tech Stack
- [Yjs](https://yjs.dev/) — CRDT library for conflict-free shared state
- [y-webrtc](https://github.com/yjs/y-webrtc) — WebRTC provider for peer-to-peer connections
- [Vite](https://vitejs.dev/) — Development server and build tool
- Vanilla JavaScript — no framework dependencies
## Getting Started
```bash
npm install
npm run dev
```
Open `http://localhost:5173/?room=my-poll` in multiple browser tabs to test.
To test across devices on the same network:
```bash
npm run dev -- --host
```
Then open the URL shown in the terminal on other devices.
## How It Works
1. Each browser tab creates a Yjs document and connects to other peers via WebRTC
2. Poll options and votes are stored in Yjs shared data types (Y.Map)
3. Changes propagate automatically to all connected peers using CRDTs
4. A public signaling server handles peer discovery; all poll data flows directly between browsers
Remove-Item -Recurse -Force node_modules, .next

BIN
app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

26
app/globals.css Normal file
View File

@@ -0,0 +1,26 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}

25
app/layout.tsx Normal file
View File

@@ -0,0 +1,25 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "p2p polling",
description: "creating a p2p-polling app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html
lang="en"
className={` h-full antialiased`}
>
<body className="min-h-full flex flex-col">{children}</body>
</html>
);
}

47
app/page.tsx Normal file
View File

@@ -0,0 +1,47 @@
"use client";
import { useState } from "react";
import usePeerManager from "../hooks/usePeerManager";
import usePollManager from "../hooks/usePollManager";
import PollCreation from "../components/PollCreation";
import PollActive from "../components/PollActive";
import PeersList from "../components/PeersList";
export default function Page() {
const peerManager = usePeerManager();
const pollManager = usePollManager(peerManager);
const [connectId, setConnectId] = useState("");
return (
<div className="p-6 space-y-4">
<h1 className="text-2xl font-bold">P2P Poll App</h1>
{/* Connect */}
<div className="flex gap-2">
<input
className="border p-2"
placeholder="Peer ID"
value={connectId}
onChange={(e) => setConnectId(e.target.value)}
/>
<button
className="bg-blue-500 text-white px-4"
onClick={() => peerManager.connectToPeer(connectId)}
>
Connect
</button>
</div>
<p>Your ID: {peerManager.peerId}</p>
{pollManager.poll ? (
<PollActive pollManager={pollManager} peerId={peerManager.peerId} />
) : (
<PollCreation pollManager={pollManager} />
)}
<PeersList peers={peerManager.peers} />
</div>
);
}

0
components/Modal.tsx Normal file
View File

View File

@@ -0,0 +1,22 @@
"use client";
type NotificationType = "info" | "success" | "error";
interface NotificationProps {
message: string;
type?: NotificationType;
}
export default function Notification({ message, type = "info" }: NotificationProps) {
const colors: Record<NotificationType, string> = {
info: "bg-blue-100 text-blue-700",
success: "bg-green-100 text-green-700",
error: "bg-red-100 text-red-700",
};
return (
<div className={`px-4 py-2 rounded shadow ${colors[type]}`}>
{message}
</div>
);
}

14
components/PeersList.tsx Normal file
View File

@@ -0,0 +1,14 @@
"use client";
export default function PeersList({ peers }: { peers: string[] }) {
return (
<div>
<h3>Peers</h3>
{peers.length === 0 ? (
<p>No peers</p>
) : (
peers.map((p) => <div key={p}>{p}</div>)
)}
</div>
);
}

22
components/PollActive.tsx Normal file
View File

@@ -0,0 +1,22 @@
"use client";
import PollOption from "./PollOption";
export default function PollActive({ pollManager, peerId }: any) {
const poll = pollManager.poll;
return (
<div>
<h2 className="text-xl font-bold">{poll.question}</h2>
{poll.options.map((opt: any) => (
<PollOption
key={opt.id}
option={opt}
pollManager={pollManager}
peerId={peerId}
/>
))}
</div>
);
}

View File

@@ -0,0 +1,40 @@
"use client";
import { useState } from "react";
export default function PollCreation({ pollManager }: any) {
const [question, setQuestion] = useState("");
const [options, setOptions] = useState<string[]>(["", ""]);
return (
<div>
<input
className="border p-2 w-full"
placeholder="Question"
value={question}
onChange={(e) => setQuestion(e.target.value)}
/>
{options.map((opt, i) => (
<input
key={i}
className="border p-2 w-full mt-2"
placeholder={`Option ${i + 1}`}
value={opt}
onChange={(e) => {
const newOpts = [...options];
newOpts[i] = e.target.value;
setOptions(newOpts);
}}
/>
))}
<button
className="bg-green-500 text-white px-4 mt-2"
onClick={() => pollManager.createPoll(question, options)}
>
Create Poll
</button>
</div>
);
}

12
components/PollOption.tsx Normal file
View File

@@ -0,0 +1,12 @@
"use client";
export default function PollOption({ option, pollManager, peerId }: any) {
return (
<div
className="border p-2 mt-2 cursor-pointer"
onClick={() => pollManager.vote(option.id, peerId)}
>
{option.text} - {option.votes} votes
</div>
);
}

18
eslint.config.mjs Normal file
View File

@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

58
hooks/usePeerManager.ts Normal file
View File

@@ -0,0 +1,58 @@
"use client";
import { useEffect, useRef, useState } from "react";
import Peer from "peerjs";
export default function usePeerManager() {
const [peerId, setPeerId] = useState<string | null>(null);
const [peers, setPeers] = useState<string[]>([]);
const peerRef = useRef<Peer | null>(null);
const connectionsRef = useRef<Map<string, any>>(new Map());
useEffect(() => {
const peer = new Peer();
peerRef.current = peer;
peer.on("open", (id) => {
setPeerId(id);
});
peer.on("connection", (conn) => {
conn.on("open", () => {
connectionsRef.current.set(conn.peer, conn);
setPeers(Array.from(connectionsRef.current.keys()));
});
conn.on("data", (data) => {
console.log("Received:", data);
});
conn.on("close", () => {
connectionsRef.current.delete(conn.peer);
setPeers(Array.from(connectionsRef.current.keys()));
});
});
return () => {
peer.destroy();
};
}, []);
const connectToPeer = (id: string) => {
if (!peerRef.current) return;
const conn = peerRef.current.connect(id);
conn.on("open", () => {
connectionsRef.current.set(conn.peer, conn);
setPeers(Array.from(connectionsRef.current.keys()));
});
};
const broadcast = (data: any) => {
connectionsRef.current.forEach((conn) => {
if (conn.open) conn.send(data);
});
};
return { peerId, peers, connectToPeer, broadcast };
}

68
hooks/usePollManager.ts Normal file
View File

@@ -0,0 +1,68 @@
"use client";
import { useState } from "react";
type Option = {
id: string;
text: string;
votes: number;
voters: string[];
};
type Poll = {
id: string;
question: string;
options: Option[];
};
export default function usePollManager(peerManager: any) {
const [poll, setPoll] = useState<Poll | null>(null);
const [myVote, setMyVote] = useState<string | null>(null);
const createPoll = (question: string, options: string[]) => {
const newPoll: Poll = {
id: Date.now().toString(),
question,
options: options.map((opt, i) => ({
id: `opt-${i}`,
text: opt,
votes: 0,
voters: [],
})),
};
setPoll(newPoll);
peerManager.broadcast({ type: "poll", poll: newPoll });
};
const vote = (optionId: string, peerId: string) => {
if (!poll) return;
const updated = { ...poll };
updated.options.forEach((opt) => {
const index = opt.voters.indexOf(peerId);
if (index !== -1) {
opt.voters.splice(index, 1);
opt.votes--;
}
});
const option = updated.options.find((o) => o.id === optionId);
if (!option) return;
option.votes++;
option.voters.push(peerId);
setPoll(updated);
setMyVote(optionId);
peerManager.broadcast({
type: "vote",
optionId,
voterId: peerId,
});
};
return { poll, createPoll, vote, myVote };
}

View File

@@ -1,13 +0,0 @@
<!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>
<link rel="stylesheet" href="/src/style.css">
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

9
next.config.ts Normal file
View File

@@ -0,0 +1,9 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
experimental: {
turbopackFileSystemCacheForDev: true,
}
};
export default nextConfig;

7177
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,18 +1,27 @@
{
"name": "p2p-poll",
"name": "p2p-polling",
"version": "0.1.0",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"yjs": "^13.6.0",
"y-webrtc": "^10.3.0"
"next": "16.2.0",
"peerjs": "^1.5.5",
"react": "19.2.4",
"react-dom": "19.2.4"
},
"devDependencies": {
"vite": "^6.0.0"
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.0",
"tailwindcss": "^4",
"typescript": "^5"
}
}

7
postcss.config.mjs Normal file
View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

1
public/file.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

1
public/globe.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

1
public/next.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
public/vercel.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

1
public/window.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

View File

@@ -1,47 +0,0 @@
import { addOption } from '../utils/store.js';
export function AddOption() {
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…';
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');
// Plus icon
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>
`;
wrapper.append(input, btn);
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;
}
addOption(name);
input.value = '';
input.focus();
}
btn.addEventListener('click', submit);
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') submit();
});
return wrapper;
}

View File

@@ -1,77 +0,0 @@
import { yOptions, getEntries, getTotalVotes } from '../utils/store.js';
import { PollOption } from './PollOption.js';
export function PollList() {
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 render() {
const entries = getEntries();
const total = getTotalVotes();
// Meta line
if (entries.length > 0) {
meta.textContent = `${entries.length} option${entries.length !== 1 ? 's' : ''} · ${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('.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 });
const currentEl = list.children[i];
if (!currentEl) {
list.appendChild(newEl);
} else if (currentEl.dataset.id !== entry.id) {
list.insertBefore(newEl, currentEl);
// Remove the now-displaced old element if it was this id
const old = existing.get(entry.id);
if (old && old !== currentEl) old.remove();
} else {
// Replace in-place so vote bar animation triggers
list.replaceChild(newEl, currentEl);
}
});
}
yOptions.observeDeep(() => render());
render();
return wrapper;
}

View File

@@ -1,44 +0,0 @@
import { toggleVote, deleteOption } from '../utils/store.js';
/**
* @param {{ id: string, name: string, votes: number, voted: boolean, totalVotes: number }} entry
*/
export function PollOption({ id, name, votes, voted, totalVotes }) {
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}">
${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', () => toggleVote(id));
row.querySelector('.poll-option__delete-btn').addEventListener('click', () => deleteOption(id));
return row;
}
function escapeHtml(str) {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}

View File

@@ -1,34 +0,0 @@
import { ydoc, yTitle } from '../utils/store.js';
export function PollTitle() {
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

@@ -1,38 +0,0 @@
import { roomName } from '../utils/store.js';
export function ShareSection() {
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('.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();
range.selectNode(section.querySelector('.share-url'));
window.getSelection().removeAllRanges();
window.getSelection().addRange(range);
}
});
return section;
}

View File

@@ -1,50 +0,0 @@
import { provider } from '../utils/store.js';
export function StatusBar() {
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 = '·';
const peerText = document.createElement('span');
peerText.className = 'status-peers';
el.append(dot, statusText, divider, peerText);
// --- Connection state ---
let syncTimeout = setTimeout(() => {
statusText.textContent = 'Ready';
dot.className = 'status-dot ready';
}, 3000);
provider.on('synced', ({ synced }) => {
clearTimeout(syncTimeout);
dot.className = `status-dot ${synced ? 'connected' : 'connecting'}`;
statusText.textContent = synced ? 'Connected' : 'Connecting';
});
// --- 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;
}

View File

@@ -1,41 +0,0 @@
import { StatusBar } from './components/StatusBar.js';
import { PollTitle } from './components/PollTitle.js';
import { AddOption } from './components/AddOption.js';
import { PollList } from './components/PollList.js';
import { ShareSection } from './components/ShareSection.js';
const app = document.getElementById('app');
// Header: logo + status
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>
`;
header.append(wordmark, StatusBar());
// Main card
const card = document.createElement('main');
card.className = 'app-card';
card.append(
PollTitle(),
AddOption(),
PollList(),
);
// Footer
const footer = document.createElement('footer');
footer.className = 'app-footer';
footer.appendChild(ShareSection());
app.append(header, card, footer);

View File

@@ -1,412 +0,0 @@
/* ── 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;
--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;
}
/* ── 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;
gap: 0.625rem;
padding: 1.25rem 1.75rem;
border-bottom: 1px solid var(--border);
}
.add-option-input {
flex: 1;
height: 2.5rem;
padding: 0 0.875rem;
font-family: var(--font-body);
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-family: var(--font-body);
font-size: 0.875rem;
font-weight: 500;
color: var(--accent-text);
background: var(--accent);
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
transition: opacity 0.15s;
white-space: nowrap;
}
.add-option-btn:hover { opacity: 0.85; }
.add-option-btn:active { opacity: 0.7; }
/* ── 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-family: var(--font-body);
font-size: 0.8125rem;
font-weight: 500;
border-radius: var(--radius-sm);
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
background: transparent;
color: var(--text-secondary);
border: 1px solid var(--border);
}
.poll-option__vote-btn:hover {
border-color: var(--accent);
color: var(--accent);
}
.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 {
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);
cursor: pointer;
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-family: var(--font-body);
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-secondary);
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
cursor: pointer;
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; }
.poll-option__count { display: none; }
.share-section { padding: 1rem 1.25rem; }
}

View File

@@ -1,79 +0,0 @@
import * as Y from 'yjs';
import { WebrtcProvider } from 'y-webrtc';
// --- Peer ID (stable across reloads) ---
function getOrCreatePeerId() {
let id = localStorage.getItem('peer-id');
if (!id) {
id = crypto.randomUUID();
localStorage.setItem('peer-id', id);
}
return id;
}
// --- Room name from URL ---
function getRoomName() {
const params = new URLSearchParams(window.location.search);
return params.get('room') || 'default-poll';
}
// --- Yjs setup ---
export const peerId = getOrCreatePeerId();
export const roomName = getRoomName();
export const ydoc = new Y.Doc();
export const provider = new WebrtcProvider(roomName, ydoc);
export const yOptions = ydoc.getMap('poll-options');
export const yTitle = ydoc.getText('poll-title');
// --- Data operations ---
export function addOption(name) {
const id = crypto.randomUUID();
const optionMap = new Y.Map();
optionMap.set('name', name);
optionMap.set('votes', new Y.Map());
yOptions.set(id, optionMap);
}
export function toggleVote(optionId) {
const optionMap = yOptions.get(optionId);
if (!optionMap) return;
const votes = optionMap.get('votes');
if (votes.has(peerId)) {
votes.delete(peerId);
} else {
votes.set(peerId, true);
}
}
export function deleteOption(optionId) {
yOptions.delete(optionId);
}
// --- Derived read helpers ---
export function getEntries() {
const entries = [];
yOptions.forEach((optionMap, id) => {
entries.push({
id,
name: optionMap.get('name'),
votes: optionMap.get('votes').size,
voted: optionMap.get('votes').has(peerId),
});
});
entries.sort((a, b) => b.votes - a.votes || a.name.localeCompare(b.name));
return entries;
}
export function getTotalVotes() {
let total = 0;
yOptions.forEach((optionMap) => {
total += optionMap.get('votes').size;
});
return total;
}

34
tsconfig.json Normal file
View File

@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}