diff --git a/index.html b/index.html index e238790..c82abdf 100644 --- a/index.html +++ b/index.html @@ -3,45 +3,11 @@ - P2P Poll + Polly — P2P Polls -
-
-

P2P Poll

-
- Connecting... - 1 peer -
-
- -
- -
- -
-
- - -
- -
Total votes: 0
- -
- -

No options yet. Add one above!

-
- - -
- +
- + \ No newline at end of file diff --git a/src/components/AddOption.js b/src/components/AddOption.js new file mode 100644 index 0000000..8b069a6 --- /dev/null +++ b/src/components/AddOption.js @@ -0,0 +1,47 @@ +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 = ` + + Add + `; + + 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; +} \ No newline at end of file diff --git a/src/components/PollList.js b/src/components/PollList.js new file mode 100644 index 0000000..c94fbb6 --- /dev/null +++ b/src/components/PollList.js @@ -0,0 +1,77 @@ +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 = ` +
+ + + + + +
+

No options yet — add the first one above.

+ `; + + 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; +} \ No newline at end of file diff --git a/src/components/PollOption.js b/src/components/PollOption.js new file mode 100644 index 0000000..92d6410 --- /dev/null +++ b/src/components/PollOption.js @@ -0,0 +1,44 @@ +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 = ` +
+
+ ${escapeHtml(name)} +
+ ${pct}% + ${votes} vote${votes !== 1 ? 's' : ''} + + +
+
+ `; + + 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, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} \ No newline at end of file diff --git a/src/components/PollTitle.js b/src/components/PollTitle.js new file mode 100644 index 0000000..43a4444 --- /dev/null +++ b/src/components/PollTitle.js @@ -0,0 +1,34 @@ +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; +} \ No newline at end of file diff --git a/src/components/ShareSection.js b/src/components/ShareSection.js new file mode 100644 index 0000000..12d54cf --- /dev/null +++ b/src/components/ShareSection.js @@ -0,0 +1,38 @@ +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 = ` +

Share this poll

+
+ ${url} + +
+ `; + + 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; +} \ No newline at end of file diff --git a/src/components/StatusBar.js b/src/components/StatusBar.js new file mode 100644 index 0000000..f9ca786 --- /dev/null +++ b/src/components/StatusBar.js @@ -0,0 +1,50 @@ +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; +} \ No newline at end of file diff --git a/src/main.js b/src/main.js index e534ab3..315f96e 100644 --- a/src/main.js +++ b/src/main.js @@ -1,183 +1,41 @@ -import * as Y from 'yjs'; -import { WebrtcProvider } from 'y-webrtc'; +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'; -// --- Peer ID (stable across reloads) --- +const app = document.getElementById('app'); -function getOrCreatePeerId() { - let id = localStorage.getItem('peer-id'); - if (!id) { - id = crypto.randomUUID(); - localStorage.setItem('peer-id', id); - } - return id; -} +// Header: logo + status +const header = document.createElement('header'); +header.className = 'app-header'; -const peerId = getOrCreatePeerId(); +const wordmark = document.createElement('div'); +wordmark.className = 'app-wordmark'; +wordmark.innerHTML = ` + + Polly +`; -// --- Room name from URL --- +header.append(wordmark, StatusBar()); -function getRoomName() { - const params = new URLSearchParams(window.location.search); - return params.get('room') || 'default-poll'; -} +// Main card +const card = document.createElement('main'); +card.className = 'app-card'; -const roomName = getRoomName(); +card.append( + PollTitle(), + AddOption(), + PollList(), +); -// --- Yjs setup --- +// Footer +const footer = document.createElement('footer'); +footer.className = 'app-footer'; +footer.appendChild(ShareSection()); -const ydoc = new Y.Doc(); -const provider = new WebrtcProvider(roomName, ydoc); -const yOptions = ydoc.getMap('poll-options'); -const yTitle = ydoc.getText('poll-title'); - -// --- Data operations --- - -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); -} - -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); - } -} - -// --- Poll title (collaborative) --- - -function bindTitle() { - const titleInput = document.getElementById('poll-title'); - - yTitle.observe(() => { - if (document.activeElement !== titleInput) { - titleInput.value = yTitle.toString(); - } - }); - - titleInput.addEventListener('input', () => { - ydoc.transact(() => { - yTitle.delete(0, yTitle.length); - yTitle.insert(0, titleInput.value); - }); - }); - - titleInput.value = yTitle.toString(); -} - -// --- UI rendering --- - -const container = document.getElementById('poll-options'); -const emptyState = document.getElementById('empty-state'); -const totalCountEl = document.getElementById('total-count'); - -function render() { - container.innerHTML = ''; - - const entries = []; - let totalVotes = 0; - - yOptions.forEach((optionMap, id) => { - const voteCount = optionMap.get('votes').size; - totalVotes += voteCount; - entries.push({ - id, - name: optionMap.get('name'), - votes: voteCount, - voted: optionMap.get('votes').has(peerId), - }); - }); - - entries.sort((a, b) => b.votes - a.votes || a.name.localeCompare(b.name)); - - totalCountEl.textContent = totalVotes; - emptyState.className = entries.length > 0 ? 'hidden' : ''; - - for (const entry of entries) { - const div = document.createElement('div'); - div.className = 'poll-option'; - - const nameSpan = document.createElement('span'); - nameSpan.className = 'option-name'; - nameSpan.textContent = entry.name; - - const voteCount = document.createElement('span'); - voteCount.className = 'vote-count'; - voteCount.textContent = entry.votes; - - const voteBtn = document.createElement('button'); - voteBtn.className = entry.voted ? 'vote-btn voted' : 'vote-btn'; - voteBtn.textContent = entry.voted ? 'Unvote' : 'Vote'; - voteBtn.addEventListener('click', () => toggleVote(entry.id)); - - div.append(nameSpan, voteCount, voteBtn); - container.appendChild(div); - } -} - -// Re-render on any change in the shared state -yOptions.observeDeep(() => render()); - -// --- Add option handlers --- - -const input = document.getElementById('option-input'); -const addBtn = document.getElementById('add-btn'); - -addBtn.addEventListener('click', () => { - const name = input.value.trim(); - if (name) { - addOption(name); - input.value = ''; - } -}); - -input.addEventListener('keydown', (e) => { - if (e.key === 'Enter') addBtn.click(); -}); - -// --- Connection status & peer count --- - -const statusEl = document.getElementById('connection-status'); -const peerCountEl = document.getElementById('peer-count'); - -provider.on('synced', ({ synced }) => { - statusEl.textContent = synced ? 'Connected' : 'Connecting...'; - statusEl.className = synced ? 'connected' : 'connecting'; -}); - -function updatePeerCount() { - const count = provider.awareness.getStates().size; - peerCountEl.textContent = `${count} peer${count !== 1 ? 's' : ''}`; -} - -provider.awareness.on('change', updatePeerCount); -updatePeerCount(); - -// --- Share section --- - -function initShareSection() { - const shareUrl = document.getElementById('share-url'); - const copyBtn = document.getElementById('copy-btn'); - const url = `${window.location.origin}${window.location.pathname}?room=${roomName}`; - shareUrl.textContent = url; - - copyBtn.addEventListener('click', () => { - navigator.clipboard.writeText(url).then(() => { - copyBtn.textContent = 'Copied!'; - setTimeout(() => { copyBtn.textContent = 'Copy'; }, 2000); - }); - }); -} - -// --- Initialize --- - -bindTitle(); -initShareSection(); -render(); +app.append(header, card, footer); \ No newline at end of file diff --git a/src/style.css b/src/style.css index a8344c7..6876b2e 100644 --- a/src/style.css +++ b/src/style.css @@ -1,257 +1,412 @@ +/* ── 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: #0b0a14; - --surface: #1f1740; - --surface-alt: #2a174f; - --text: #e8e3ff; - --text-secondary: #b8a7d2; - --text-muted: #8a7aaa; - --accent: #6bd3ff; - --accent-hover: #4fc3f7; - --accent-light: rgba(107, 211, 255, 0.1); - --border: #2a1950; - --shadow: rgba(0, 0, 0, 0.3); - --success: #4caf50; - --warning: #ff9800; + --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); } -* { - margin: 0; - padding: 0; - box-sizing: border-box; -} +/* ── Reset ─────────────────────────────────────────────── */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +/* ── Base ──────────────────────────────────────────────── */ +html { font-size: 16px; } body { - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + font-family: var(--font-body); background: var(--bg); - color: var(--text); + color: var(--text-primary); min-height: 100vh; + -webkit-font-smoothing: antialiased; } +/* ── Layout ────────────────────────────────────────────── */ #app { - max-width: 600px; + max-width: 580px; margin: 0 auto; - padding: 2rem 1rem; + padding: 2rem 1.25rem 4rem; + display: flex; + flex-direction: column; + gap: 1.25rem; } -header { +/* ── Header ────────────────────────────────────────────── */ +.app-header { display: flex; - justify-content: space-between; align-items: center; - margin-bottom: 1.5rem; + justify-content: space-between; + padding: 0 0.25rem; } -h1 { - font-size: 1.5rem; -} - -#status { - display: flex; - gap: 1rem; - font-size: 0.85rem; - color: var(--text-secondary); -} - -#connection-status::before { - content: ""; - display: inline-block; - width: 8px; - height: 8px; - border-radius: 50%; - margin-right: 4px; - vertical-align: middle; -} - -#connection-status.connected::before { - background: var(--success); -} - -#connection-status.connecting::before { - background: var(--warning); -} - -/* Poll title */ - -#poll-title-section { - margin-bottom: 1.5rem; -} - -#poll-title { - width: 100%; - font-size: 1.3rem; - font-weight: 600; - border: none; - border-bottom: 2px solid transparent; - background: transparent; - color: var(--text); - padding: 0.4rem 0; - outline: none; -} - -#poll-title::placeholder { - color: var(--text-muted); -} - -#poll-title:hover { - border-bottom-color: var(--border); -} - -#poll-title:focus { - border-bottom-color: var(--accent); -} - -/* Add option */ - -#add-option { +.app-wordmark { display: flex; + align-items: center; gap: 0.5rem; - margin-bottom: 1rem; + font-family: var(--font-display); + font-size: 1.1rem; + color: var(--text-primary); + letter-spacing: -0.01em; } -#option-input { - flex: 1; - padding: 0.6rem 0.8rem; - border: 1px solid var(--border); - border-radius: 6px; - font-size: 1rem; - outline: none; - background: var(--surface); - color: var(--text); -} - -#option-input::placeholder { - color: var(--text-muted); -} - -#option-input:focus { - border-color: var(--accent); -} - -#add-btn { - padding: 0.6rem 1.2rem; - background: var(--accent); - color: var(--bg); - border: none; - border-radius: 6px; - font-size: 1rem; - font-weight: 600; - cursor: pointer; -} - -#add-btn:hover { - background: var(--accent-hover); -} - -/* Total votes */ - -#total-votes { - font-size: 0.85rem; +/* ── Status bar ────────────────────────────────────────── */ +.status-bar { + display: flex; + align-items: center; + gap: 0.4rem; + font-size: 0.8rem; color: var(--text-secondary); - margin-bottom: 1rem; +} + +.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 options */ +.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; - background: var(--surface); - padding: 0.8rem 1rem; - border-radius: 8px; - margin-bottom: 0.5rem; + 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); } -.option-name { - flex: 1; - font-size: 1rem; -} - -.vote-count { - font-size: 0.9rem; - font-weight: 600; - color: var(--text-secondary); - margin-right: 0.75rem; - min-width: 1.5rem; - text-align: center; -} - -.vote-btn { - padding: 0.4rem 1rem; - border: 2px solid var(--accent); - border-radius: 6px; - font-size: 0.85rem; - cursor: pointer; - background: transparent; +.poll-option__vote-btn:hover { + border-color: var(--accent); color: var(--accent); } -.vote-btn:hover { - background: var(--accent-light); -} - -.vote-btn.voted { +.poll-option--voted .poll-option__vote-btn { background: var(--accent); - color: var(--bg); + color: var(--accent-text); + border-color: var(--accent); } -.vote-btn.voted:hover { - background: var(--accent-hover); +.poll-option--voted .poll-option__vote-btn:hover { + opacity: 0.8; } -/* Empty state */ - -#empty-state { - text-align: center; - color: var(--text-muted); - padding: 2rem; -} - -#empty-state.hidden { - display: none; -} - -/* Share section */ - -#share-section { - margin-top: 2rem; - padding-top: 1.5rem; - border-top: 1px solid var(--border); - font-size: 0.85rem; - color: var(--text-secondary); -} - -#share-url-container { +.poll-option__delete-btn { display: flex; - gap: 0.5rem; - margin-top: 0.5rem; 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; } -#share-url { - flex: 1; +.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); - padding: 0.5rem 0.75rem; - border-radius: 6px; 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; - color: var(--text); + display: block; + user-select: all; } -#copy-btn { - padding: 0.5rem 1rem; +.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: 6px; + border-radius: var(--radius-sm); cursor: pointer; - font-size: 0.8rem; - color: var(--text-secondary); + transition: all 0.15s; + white-space: nowrap; + flex-shrink: 0; } -#copy-btn:hover { - background: var(--accent-light); +.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; } +} \ No newline at end of file diff --git a/src/utils/store.js b/src/utils/store.js new file mode 100644 index 0000000..857c6e4 --- /dev/null +++ b/src/utils/store.js @@ -0,0 +1,79 @@ +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; +} \ No newline at end of file