Compare commits

..

2 Commits

30 changed files with 1173 additions and 10095 deletions

24
.gitignore vendored
View File

@@ -1,24 +1,2 @@
# Nuxt dev/build outputs
.output
.data
.nuxt
.nitro
.cache
dist
# Node dependencies
node_modules
# Logs
logs
*.log
# Misc
.DS_Store
.fleet
.idea
# Local env files
.env
.env.*
!.env.example
node_modules

135
README.md
View File

@@ -1,129 +1,44 @@
# 🗳️ P2P Verified Polling App
# P2P Poll App
A decentralized, real-time polling application built with **Nuxt 3**, **Yjs**, and **WebRTC**. This app allows users to create and participate in polls where every vote is cryptographically signed and verified peer-to-peer, ensuring data integrity without a central authority "owning" the results.
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
## 🌟 Key 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
* **Serverless Real-time Sync:** Uses **Yjs** (CRDTs) and **WebRTC** to sync poll data directly between browsers. No database is required for live updates.
* **Persistence with Nitro:** While the logic is P2P, the **Nuxt/Nitro** backend provides a "Snapshot" service to ensure polls persist even after all peers go offline.
* **Cryptographic Integrity:** Every vote is signed using **RSA-PSS (Web Crypto API)**. Each user has a unique private key (stored locally via `.pem` files) to ensure votes cannot be forged or tampered with.
* **Chained Verification:** Implements a "History-Signing" logic where each new vote signs the entire preceding state of the poll, creating a verifiable chain of trust.
* **Privacy First:** Users identify via UUIDs and Public/Private key pairs rather than traditional accounts.
## 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
## ⚙️ How It Works
### 1. Identity Creation
When a new user is created, the system generates a unique **UUID (User ID)** and an **RSA Key Pair**. The user is prompted to save their **Private Key** as a `.pem` file, named after their User ID (e.g., `550e8400-e29b.pem`). This file acts as their "Passport"—it is never uploaded to the server and must be kept secure by the user.
### 2. Authentication
Upon returning to the app, users load their local `.pem` file. The application extracts the Private Key for signing and the UUID for identification. No passwords or central servers are involved in this local-first login process.
### 3. Joining a Poll
When a user joins a poll, the app fetches the latest binary snapshot from the server to populate a local **Y.Doc**. This ensures the user sees the current state immediately, even before connecting to other peers.
### 4. The P2P Mesh
The app establishes connections to other active voters via a WebRTC signaling server. Any changes made to the poll (adding options or voting) are broadcasted instantly to all peers using Conflict-free Replicated Data Types (CRDTs) to prevent sync conflicts.
### 5. Casting a Signed Vote
To ensure security, the voting process follows a strict cryptographic chain:
* The app captures the current list of votes.
* It appends the new vote data (User ID + Timestamp).
* It signs the **entire array** (the previous history + the new vote) using the user's RSA private key.
* The signed update is merged into the shared Yjs Map and broadcasted.
### 6. Distributed Verification
Whenever a peer receives a new update, they fetch the voter's **Public Key** from the API. They then verify that the signature matches the current state of the poll history. If a signature is invalid or the history has been tampered with, the vote is rejected by the peer's local state.
---
## 🛠️ Tech Stack
* **Framework:** [Nuxt 3](https://nuxt.com/) (Vue 3 + TypeScript)
* **Conflict-Free Replicated Data Types (CRDT):** [Yjs](https://yjs.dev/)
* **P2P Transport:** `y-webrtc`
* **Security:** Web Crypto API (SubtleCrypto)
* **Backend/Storage:** Nitro (Nuxt's server engine) with filesystem storage drivers
# AI Disclaimer
This App was developed with the assistance of AI.
# Nuxt Minimal Starter
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
## Setup
Make sure to install dependencies:
## Getting Started
```bash
# npm
npm install
# pnpm
pnpm install
# yarn
yarn install
# bun
bun install
```
## Development Server
Start the development server on `http://localhost:3000`:
```bash
# npm
npm run dev
# pnpm
pnpm dev
# yarn
yarn dev
# bun
bun run dev
```
## Production
Open `http://localhost:5173/?room=my-poll` in multiple browser tabs to test.
Build the application for production:
To test across devices on the same network:
```bash
# npm
npm run build
# pnpm
pnpm build
# yarn
yarn build
# bun
bun run build
npm run dev -- --host
```
Locally preview production build:
Then open the URL shown in the terminal on other devices.
```bash
# npm
npm run preview
## How It Works
# pnpm
pnpm preview
# yarn
yarn preview
# bun
bun run preview
```
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.
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

View File

@@ -1,175 +0,0 @@
<style>
/* Basic styling to make it look clean */
body {
font-family: system-ui, -apple-system, sans-serif;
background-color: #f4f4f9;
color: #333;
margin: 0;
display: flex;
justify-content: center;
padding: 2rem;
}
header {
margin-bottom: 2rem;
text-align: center;
}
h1 { margin: 0 0 0.5rem 0; }
input {
flex-grow: 1;
padding: 0.75rem;
border: 1px solid #ccc;
border-radius: 6px;
font-size: 1rem;
}
button,
.button {
background: #3b82f6;
color: white;
border: none;
padding: 0.75rem 1rem;
border-radius: 6px;
cursor: pointer;
font-weight: bold;
transition: background 0.2s;
}
button:hover,
.button:hover { background: #2563eb; }
.status {
font-size: 0.85rem;
color: #666;
}
.status .connected { color: #10b981; font-weight: bold; }
.connectionFailed { color: #FF2525; font-weight: bold; }
.poll-container {
background: white;
padding: 2rem;
border-radius: 12px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
width: 100%;
max-width: 500px;
}
.back-btn {
margin-left: 1rem;
padding: 0.2rem 0.5rem;
font-size: 0.7rem;
background: #64748b;
}
/* Hide the actual file input */
input[type="file"] {
display: none;
}
</style>
<template>
<div class="poll-container">
<header>
<h1 @click="activePollId = null" style="cursor:pointer">P2P Polling App 🗳</h1>
<div class="status">
<button v-if="activePollId" @click="activePollId = null" class="back-btn"> Back To List</button>
<span :class="{ 'connected': isConnected }">
{{ isConnected ? 'Synced' : 'Waiting for other Peers...' }}
</span>
<span> | Peers online: {{ connectedPeers }}</span>
<h2 v-if="connectionAttempFailed" class="connectionFailed"> Connection to Signaling Server Failed!</h2>
<div v-if="user===null" style="margin-top: 10px;">
<button @click="createUser">Create New User</button>
Or
<label title="Select Key File">
<span class="button">Login</span>
<input
type="file"
accept=".pem"
@change="loadUser"
/>
</label>
</div>
</div>
</header>
<main>
<PollList v-if="!activePollId" :userid="user?.userid" @select-poll="selectPoll" />
<Poll v-else :activePollId="activePollId" :userid="user?.userid" :poll-data="pollData" :addOption="addOption" :vote="vote"/>
</main>
</div>
</template>
<script setup lang="ts">
import { v4 as uuidv4 } from 'uuid';
const activePollId = ref<string | null>(null);
const user = shallowRef<UserData | null>(null);
const { pollData, isConnected, connectionAttempFailed, connectedPeers, addOption, vote } = usePoll(activePollId,user);
const selectPoll = (id: string) => {
activePollId.value = id;
};
const createUser = async () => {
try {
const keypair : CryptoKeyPair = await generateUserKeyPair();
console.log('keypair:', keypair);
const uuid = uuidv4();
user.value = {
userid: uuid,
private_key: keypair.privateKey,
public_key: keypair.publicKey,
};
const prvKeyString = await exportPrivateKey(keypair.privateKey);
await savePrivateKeyToFile(prvKeyString,uuid+".pem")
const pubKeyString = await exportPublicKey(keypair.publicKey);
await $fetch(`/api/users/${uuid}`, {
method: 'POST',
body: { public_key: pubKeyString }
});
} catch (err) {
user.value = null
console.error("Failed to create new User!", err);
}
};
const loadUser = async (event: Event) => {
const target = event.target as HTMLInputElement;
const file = target.files?.[0];
if (file) {
try {
const content = await file.text();
console.log("File loaded: ");
if (file.name && content) {
try {
const uuid = file.name.replace(".pem", "");
// Standardize the string for the importer
const pkBase64 = content.replace(/-----BEGIN PRIVATE KEY-----|-----END PRIVATE KEY-----/g, "").replace(/\s+/g, "");
const key = await stringToCryptoKey(pkBase64, "private");
user.value = {
userid: uuid,
private_key: key,
public_key: undefined, // Note: You might need to import a pub key too!
};
console.log("Login successful for:", uuid);
} catch (err) {
console.error("Crypto Import Error:", err);
alert("The file content is not a valid Private Key.");
}
}
} catch (e) {
console.error("Failed to read file", e);
}
}
};
</script>

View File

@@ -1,83 +0,0 @@
<style scoped>
.poll-list {
list-style: none;
padding: 0;
margin: 0;
}
.poll-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
margin-bottom: 0.5rem;
}
.poll-title {
font-size: 1.1rem;
color: #3b82f6;
text-transform: uppercase;
letter-spacing: 1px;
}
.add-option-form {
display: flex;
gap: 0.5rem;
margin-bottom: 2rem;
}
.option-name { font-weight: 500; }
.vote-section { display: flex; align-items: center; gap: 1rem; }
.vote-count { font-size: 0.9rem; color: #475569; }
.vote-btn { padding: 0.4rem 0.8rem; background: #10b981; }
.vote-btn:hover { background: #059669; }
.vote-btn:disabled,
.vote-btn[disabled] { background: #888888; }
.vote-btn:disabled:hover,
.vote-btn[disabled]:hover { background: #AAAAAA; }
</style>
<template>
<div>
<h2 class="poll-title">Poll: {{ activePollId }}</h2>
<p v-if="Object.keys(pollData).length==0">Note: Add at least one Option to save the Poll.</p>
<form @submit.prevent="handleAddNewOption" class="add-option-form" v-if="userid">
<input v-model="newOption" placeholder="Enter a new poll option..." required />
<button type="submit">Add Option</button>
</form>
<ul class="poll-list">
<li v-for="(votes, optionName) in pollData" :key="optionName" class="poll-item">
<span class="option-name">{{ optionName }}</span>
<div class="vote-section">
<span class="vote-count">{{ votes.length }} {{ votes.length === 1 ? 'vote' : 'votes' }}</span>
<button @click="vote(String(optionName))" class="vote-btn" :disabled="userid==undefined || voted(votes)">+1</button>
</div>
</li>
</ul>
</div>
</template>
<script setup lang="ts">
import type { PollProps, SignedData, VoteData } from '@/utils/types'
const props = defineProps<PollProps>()
const newOption = ref('');
const handleAddNewOption = () => {
props.addOption(newOption.value);
newOption.value = '';
};
const voted = (votes: SignedData<VoteData>[]) => {
for(let vote of votes){
if(vote.data.userid == props.userid){
return true;
}
}
return false;
}
</script>

View File

@@ -1,64 +0,0 @@
<style scoped>
.poll-list { margin-top: 1rem; }
.empty-state { text-align: center; color: #94a3b8; font-style: italic; }
.create-poll { display: flex; gap: 0.5rem; margin-bottom: 1.5rem; }
.poll-links { list-style: none; padding: 0; }
.poll-link-btn {
width: 100%;
text-align: left;
background: #f1f5f9;
color: #1e293b;
margin-bottom: 0.5rem;
display: flex;
justify-content: space-between;
}
.poll-link-btn:hover { background: #e2e8f0; }
</style>
<template>
<div class="poll-list">
<h3>Available Polls</h3>
<ul v-if="polls && polls.length > 0" class="poll-links">
<li v-for="id in polls" :key="id">
<button class="poll-link-btn" @click="$emit('select-poll', id)">
{{ id }} <span></span>
</button>
</li>
</ul>
<p v-else class="empty-state">No polls found. Create the first one!</p>
<div class="create-poll" v-if="userid !== undefined">
<input
v-model="newPollId"
placeholder="Enter new poll name..."
@keyup.enter="createPoll"
/>
<button @click="createPoll">Create & Join</button>
</div>
</div>
</template>
<script setup lang="ts">
import type { PollListProps } from '@/utils/types'
const props = defineProps<PollListProps>()
const newPollId = ref('');
const polls = ref<string[]>([]);
// Fetch existing polls on mount
const fetchPolls = async () => {
const data = await $fetch<{ polls: string[] }>('/api/polls');
polls.value = data.polls;
};
const createPoll = () => {
const id = newPollId.value.trim().toLowerCase().replace(/\s+/g, '-');
if (id) {
// In a real app, you might want to POST to create it first,
// but here we just navigate to it and let usePoll handle the save.
emit('select-poll', id);
}
};
const emit = defineEmits(['select-poll']);
onMounted(fetchPolls);
</script>

View File

@@ -1,131 +0,0 @@
// composables/usePoll.ts
import { ref, watch, onUnmounted } from 'vue';
import * as Y from 'yjs';
export const usePoll = (pollId: Ref<string | null>, user: Ref<UserData | null>) => {
const pollData = ref<PollData>({});
const isConnected = ref(false);
const connectionAttempFailed = ref(false);
const connectedPeers = ref(1);
let ydoc: Y.Doc | null = null;
let provider: any = null;
let yMap: Y.Map<SignedData<VoteData>[]> | null = null;
const cleanup = () => {
if (provider) provider.disconnect();
if (ydoc) ydoc.destroy();
isConnected.value = false;
pollData.value = {};
};
const initPoll = async (id: string) => {
cleanup(); // Clear previous session
ydoc = new Y.Doc();
// 1. Fetch Snapshot from Nuxt API
try {
const response = await $fetch<{ update: number[] | null }>(`/api/polls/${id}`).catch((e) => {
console.error("Failed to get poll: " + id,e)
});
//trust the server without verification.
if (response?.update) {
Y.applyUpdate(ydoc, new Uint8Array(response.update));
}
} catch (err) {
console.error('Persistence fetch failed', err);
}
yMap = ydoc.getMap<SignedData<VoteData>[]>('shared-poll');
// 2. Local State Sync
yMap.observe(async () => {
await performUpdateAndVerify();
saveStateToServer(id);
});
await performUpdateAndVerify();
// 3. P2P Connection
const { WebrtcProvider } = await import('y-webrtc');
provider = new WebrtcProvider(`nuxt-p2p-${id}`, ydoc, {
signaling: ["ws://localhost:4444", "ws://lynxpi.ddns.net:4444"]
});
provider.on('synced', (arg: {synced: boolean}) => {
isConnected.value = arg.synced;
console.log('Connection synced:', arg.synced) // "connected" or "disconnected"
});
provider.on('status', (event: { connected: boolean }) => {
console.log('Connection status:', event.connected) // "connected" or "disconnected"
})
provider.on('peers', (data: any) => {
connectedPeers.value = data.webrtcPeers.length + 1
});
};
const saveStateToServer = async (id: string) => {
if (!ydoc) return;
const stateUpdate = Y.encodeStateAsUpdate(ydoc);
await $fetch(`/api/polls/${id}`, {
method: 'POST',
body: { update: Array.from(stateUpdate) }
}).catch((e) => {
console.error("Failed to update poll",e)
});
};
// Watch for ID changes (e.g., user clicks a link or goes back)
watch(pollId, (newId) => {
if (newId && import.meta.client) {
initPoll(newId);
} else {
cleanup();
}
}, { immediate: true });
onUnmounted(cleanup);
const addOption = (optionName: string) => {
if (yMap && !yMap.has(optionName)) yMap.set(optionName, []);
};
const performUpdateAndVerify = async () => {
const pollDataUpdate = yMap!.toJSON();
console.log("Poll Data Update: ", pollDataUpdate)
for(var option in pollDataUpdate){
console.log("verifying votes for option: " + option);
const votes = pollDataUpdate[option] || [];
const verified = await verifyAllVotesForOption(votes);
if(!verified){
console.error("Failed to verify option: "+option)
return;
}
}
console.log("All options verified! :)")
pollData.value = pollDataUpdate
}
const vote = async (optionName: string) => {
const currentUser = user.value;
if (currentUser != undefined && yMap?.has(optionName)) {
const voteData = [...(yMap.get(optionName) || [])];
if(voteData != undefined && currentUser.private_key){
var unsignedVoteData : VoteData = {
userid: currentUser.userid,
timestamp: new Date().toISOString()
}
var newVote : SignedData<VoteData> = {
data: unsignedVoteData,
signature: "",
}
voteData?.push(newVote)
const signature = await signVote(voteData,currentUser.private_key);
newVote.signature=signature
yMap?.set(optionName, voteData);
}
}
};
return { pollData, isConnected, connectionAttempFailed, connectedPeers, addOption, vote };
};

View File

@@ -1,2 +0,0 @@
export const user = (user: Ref<UserData | null>) => {
}

View File

@@ -1,187 +0,0 @@
// utils/crypto.ts
export const generateUserKeyPair = async () => {
return await window.crypto.subtle.generateKey(
{
name: "RSASSA-PKCS1-v1_5",
modulusLength: 2048,
publicExponent: new Uint8Array([1, 0, 1]), // 65537
hash: "SHA-256",
},
true, // extractable
["sign", "verify"]
);
};
export const signVote = async (data: any, privateKey: CryptoKey) => {
const encoder = new TextEncoder();
const encodedData = encoder.encode(JSON.stringify(data));
const signature = await window.crypto.subtle.sign(
"RSASSA-PKCS1-v1_5",
privateKey,
encodedData
);
// Convert to Base64 or Hex to store in Yjs easily
return btoa(String.fromCharCode(...new Uint8Array(signature)));
};
export const verifyVote = async (data: any, signatureStr: string, publicKey: CryptoKey) => {
const encoder = new TextEncoder();
const encodedData = encoder.encode(JSON.stringify(data));
// Convert Base64 back to Uint8Array
const signature = Uint8Array.from(atob(signatureStr), c => c.charCodeAt(0));
return await window.crypto.subtle.verify(
"RSASSA-PKCS1-v1_5",
publicKey,
signature,
encodedData
);
};
/**
* Verifies a specific vote within an array of votes by
* reconstructing the "signed state" at that point in time.
*/
export const verifyChainedVote = async (
voteData: SignedData<VoteData>[],
index: number
) => {
const voteToVerify = voteData[index];
console.log("Verifying vote: " + voteToVerify)
if(voteToVerify) {
// 1. Reconstruct the exact data state the user signed
// We need the array exactly as it was when they pushed their vote
const historicalState = voteData.slice(0, index + 1).map((v, i) => {
if (i === index) {
// For the current vote, the signature must be empty string
// because it wasn't signed yet when passed to signVote
return { ...v, signature: "" };
}
return v;
});
try {
// 2. Fetch public key
const response = await $fetch<{ public_key: string }>(`/api/users/${voteToVerify.data.userid}`);
console.log("Got key: ",response)
const pubKey = await stringToCryptoKey(response.public_key, 'public');
console.log("Using pubKey to verify Vote.")
// 3. Verify: Does this historicalState match the signature?
return await verifyVote(historicalState, voteToVerify.signature, pubKey);
} catch (err) {
console.error("Verification failed")
console.error(err);
return false;
}
}
console.error("Vote is undefined or null");
return false;
};
export const verifyAllVotesForOption = async (votes: SignedData<VoteData>[]) => {
console.log("verifying votes for option ",votes);
for (let i = votes.length-1; i >= 0 ; i--) {
const isValid = await verifyChainedVote(votes, i);
if(!isValid){
console.error("Error! Invalid Vote at: " + i,votes)
return false;
}
}
return true;
};
// Helper to convert ArrayBuffer to Base64 string
const bufferToBase64 = (buf: ArrayBuffer) =>
window.btoa(String.fromCharCode(...new Uint8Array(buf)));
export const exportPublicKey = async (key: CryptoKey) => {
// Export Public Key
const exportedPublic = await window.crypto.subtle.exportKey("spki", key);
const publicKeyString = bufferToBase64(exportedPublic);
return publicKeyString;
};
export const exportPrivateKey = async (key: CryptoKey) => {
// Export Private Key
const exportedPrivate = await window.crypto.subtle.exportKey("pkcs8", key);
const privateKeyString = bufferToBase64(exportedPrivate);
return privateKeyString;
};
/**
* Converts a Base64 string back into a usable CryptoKey object
* @param keyStr The Base64 string (without PEM headers)
* @param type 'public' or 'private'
*/
export const stringToCryptoKey = async (keyStr: string, type: 'public' | 'private'): Promise<CryptoKey> => {
// 1. Convert Base64 string to a Uint8Array (binary)
const binaryString = window.atob(keyStr);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
// 2. Identify the format based on the key type
// Public keys usually use 'spki', Private keys use 'pkcs8'
const format = type === 'public' ? 'spki' : 'pkcs8';
const usages: KeyUsage[] = type === 'public' ? ['verify'] : ['sign'];
// 3. Import the key
return await window.crypto.subtle.importKey(
format,
bytes.buffer,
{
name: "RSASSA-PKCS1-v1_5",
hash: "SHA-256",
},
true, // extractable (set to false if you want to lock it in memory)
usages
);
};
export const savePrivateKeyToFile = (privateKeyStr: string, filename: string) => {
// Optional: Wrap in PEM headers for standard formatting
const pemHeader = "-----BEGIN PRIVATE KEY-----\n";
const pemFooter = "\n-----END PRIVATE KEY-----";
const fileContent = pemHeader + privateKeyStr + pemFooter;
const blob = new Blob([fileContent], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
// Cleanup
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
export const loadPrivateKeyFromFile = async (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
const content = e.target?.result as string;
// Clean up the string by removing PEM headers and newlines
const cleanKey = content
.replace("-----BEGIN PRIVATE KEY-----", "")
.replace("-----END PRIVATE KEY-----", "")
.replace(/\s+/g, ""); // Removes all whitespace/newlines
resolve(cleanKey);
};
reader.onerror = () => reject("Error reading file");
reader.readAsText(file);
});
};

View File

@@ -1,36 +0,0 @@
export interface PollProps {
userid: string | undefined,
activePollId: string,
pollData: PollData,
addOption: (name: string) => void,
vote: (optionName: string) => void
}
export interface PollListProps {
userid: string | undefined,
}
export interface PollData extends Record<string, SignedData<VoteData>[]> {
}
export interface SignedData<T> {
data: T,
signature: string
}
export interface VoteData {
userid: string,
timestamp: string
}
export interface OptionData {
userid: string,
timestamp: string,
optionName: string
}
export interface UserData {
userid: string,
private_key: CryptoKey | undefined,
public_key: CryptoKey | undefined
}

13
index.html Normal file
View File

@@ -0,0 +1,13 @@
<!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>

View File

@@ -1,23 +0,0 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
compatibilityDate: '2025-07-15',
devtools: { enabled: true },
vite: {
optimizeDeps: {
include: ['yjs', 'y-webrtc']
}
},
// ... existing config
nitro: {
storage: {
polls: {
driver: 'fs',
base: './.data/polls'
},
users: {
driver: 'fs',
base: './.data/users'
}
}
}
})

9285
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,20 +1,18 @@
{
"name": "p2p-poll",
"type": "module",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"build": "nuxt build",
"dev": "PORT=4444 npx y-webrtc & nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare"
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"nuxt": "^4.1.3",
"uuid": "^13.0.0",
"vue": "^3.5.30",
"vue-router": "^5.0.3",
"y-webrtc": "^10.3.0",
"yjs": "^13.6.30"
"yjs": "^13.6.0",
"y-webrtc": "^10.3.0"
},
"devDependencies": {
"vite": "^6.0.0"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -1,2 +0,0 @@
User-Agent: *
Disallow:

View File

@@ -1,68 +0,0 @@
import * as Y from 'yjs';
// server/api/polls/[id].ts
export default defineEventHandler(async (event) => {
const method = event.node.req.method;
const pollId = getRouterParam(event, 'id');
// We use Nitro's built-in storage.
// 'polls' is the storage namespace.
const storage = useStorage('polls');
if (!pollId) {
throw createError({ statusCode: 400, statusMessage: 'Poll ID required' });
}
// GET: Fetch the saved Yjs document state
if (method === 'GET') {
const data = await storage.getItem(`poll:${pollId}`);
// Return the array of numbers (or null if it doesn't exist yet)
return { update: data || null };
}
// POST: Save a new Yjs document state
if (method === 'POST') {
const body = await readBody(event);
if (body.update && Array.isArray(body.update)) {
// create a temp Y.Doc to encode the Data
const tempDoc = new Y.Doc();
Y.applyUpdate(tempDoc, new Uint8Array(body.update));
const yMap = tempDoc.getMap('shared-poll');
const pollData = yMap.toJSON();
// verify pollData
for(var option in pollData){
const votes = pollData[option] || [];
var pubKeys: CryptoKey[] = [];
const verifyAllVotesForOption = async (votes: SignedData<VoteData>[]) => {
console.log("verifying votes for option " + option,votes);
// check last votes first. if there is something wrong, its likely in the last vote.
for (let i = votes.length-1; i >= 0 ; i--) {
const userStorage = useStorage('users');
const votePubKeyString = await userStorage.getItem(`user:${votes[i]?.data.userid}`);
//console.log("Using public key: "+votePubKeyString)
const votePubKey = await stringToCryptoKey(String(votePubKeyString),'public')
const isValid = await verifyChainedVote(votes, i,votePubKey);
if(!isValid){
console.error("Error! Invalid Vote at: " + i,votes)
return false;
}
}
return true;
};
const verified = await verifyAllVotesForOption(votes);
if(!verified){
console.error("Failed to verify option: "+option)
throw createError({ statusCode: 400, statusMessage: 'PollData contains unverifyable content!' });
}
}
// Save the binary update (sent as an array of numbers) to storage
await storage.setItem(`poll:${pollId}`, body.update);
return { success: true };
}
throw createError({ statusCode: 400, statusMessage: 'Invalid update payload' });
}
});

View File

@@ -1,15 +0,0 @@
// server/api/polls/index.get.ts
export default defineEventHandler(async () => {
const storage = useStorage('polls');
// Get all keys in the 'polls' namespace
const allKeys = await storage.getKeys();
// Filter for our specific poll prefix and strip it for the UI
// poll:my-id -> my-id
const polls = allKeys
.filter(key => key.startsWith('poll:'))
.map(key => key.replace('poll:', ''));
return { polls };
});

View File

@@ -1,41 +0,0 @@
// server/api/users/[id].ts
export default defineEventHandler(async (event) => {
const method = event.node.req.method;
const userId = getRouterParam(event, 'id');
// We use Nitro's built-in storage.
// 'polls' is the storage namespace.
const storage = useStorage('users');
if (!userId) {
throw createError({ statusCode: 400, statusMessage: 'User ID required' });
}
// GET: Fetch the saved Yjs document state
if (method === 'GET') {
const data = await storage.getItem(`user:${userId}`);
// Return the array of numbers (or null if it doesn't exist yet)
return { public_key: data };
}
// POST: Save a new Yjs document state
if (method === 'POST') {
const body = await readBody(event);
if (body.public_key) {
const data = await storage.getItem(`user:${userId}`);
if (data == undefined || data == null) {
// Save the binary update (sent as an array of numbers) to storage
await storage.setItem(`user:${userId}`, body.public_key);
console.log("New User created: " + userId)
console.log("Public Key: " + body.public_key);
return { success: true };
}
throw createError({ statusCode: 400, statusMessage: 'User already exists.' });
}
throw createError({ statusCode: 400, statusMessage: 'Invalid update payload' });
}
});

View File

@@ -1,86 +0,0 @@
import { SignedData, VoteData } from "./types";
/**
* Gets the WebCrypto API regardless of environment (Node vs Browser)
*/
const getCrypto = () => {
return (globalThis as any).crypto;
};
export const verifyVote = async (data: any, signatureStr: string, publicKey: CryptoKey) => {
const encoder = new TextEncoder();
const encodedData = encoder.encode(JSON.stringify(data));
// Convert Base64 back to Uint8Array
const signature = Uint8Array.from(atob(signatureStr), c => c.charCodeAt(0));
return await getCrypto().subtle.verify(
"RSASSA-PKCS1-v1_5",
publicKey,
signature,
encodedData
);
};
/**
* Verifies a specific vote within an array of votes by
* reconstructing the "signed state" at that point in time.
*/
export const verifyChainedVote = async (
voteData: SignedData<VoteData>[],
index: number,
pubKey: CryptoKey
) => {
const voteToVerify = voteData[index];
console.log("Verifying vote: " + voteToVerify)
if(voteToVerify) {
// 1. Reconstruct the exact data state the user signed
// We need the array exactly as it was when they pushed their vote
const historicalState = voteData.slice(0, index + 1).map((v, i) => {
if (i === index) {
// For the current vote, the signature must be empty string
// because it wasn't signed yet when passed to signVote
return { ...v, signature: "" };
}
return v;
});
try {
// 3. Verify: Does this historicalState match the signature?
return await verifyVote(historicalState, voteToVerify.signature, pubKey);
} catch (err) {
console.error("Verification failed")
console.error(err);
return false;
}
}
console.error("Vote is undefined or null");
return false;
};
/**
* Converts a Base64 string back into a usable CryptoKey object
* @param keyStr The Base64 string (without PEM headers)
* @param type 'public' or 'private'
*/
export const stringToCryptoKey = async (keyStr: string, type: 'public' | 'private'): Promise<CryptoKey> => {
// 1. Convert Base64 string to a Uint8Array (binary)
const bytes = Buffer.from(keyStr, 'base64');
// 2. Identify the format based on the key type
// Public keys usually use 'spki', Private keys use 'pkcs8'
const format = type === 'public' ? 'spki' : 'pkcs8';
const usages: KeyUsage[] = type === 'public' ? ['verify'] : ['sign'];
// 3. Import the key
return await getCrypto().subtle.importKey(
format,
bytes,
{
name: "RSASSA-PKCS1-v1_5",
hash: "SHA-256",
},
true, // extractable (set to false if you want to lock it in memory)
usages
);
};

View File

@@ -1,36 +0,0 @@
export interface PollProps {
userid: string | undefined,
activePollId: string,
pollData: PollData,
addOption: (name: string) => void,
vote: (optionName: string) => void
}
export interface PollListProps {
userid: string | undefined,
}
export interface PollData extends Record<string, SignedData<VoteData>[]> {
}
export interface SignedData<T> {
data: T,
signature: string
}
export interface VoteData {
userid: string,
timestamp: string
}
export interface OptionData {
userid: string,
timestamp: string,
optionName: string
}
export interface UserData {
userid: string,
private_key: CryptoKey | undefined,
public_key: CryptoKey | undefined
}

View File

@@ -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 = `
<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

@@ -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 = `
<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

@@ -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 = `
<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

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

View File

@@ -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 = `
<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

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

41
src/main.js Normal file
View File

@@ -0,0 +1,41 @@
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);

412
src/style.css Normal file
View File

@@ -0,0 +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: #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; }
}

79
src/utils/store.js Normal file
View File

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

View File

@@ -1,18 +0,0 @@
{
// https://nuxt.com/docs/guide/concepts/typescript
"files": [],
"references": [
{
"path": "./.nuxt/tsconfig.app.json"
},
{
"path": "./.nuxt/tsconfig.server.json"
},
{
"path": "./.nuxt/tsconfig.shared.json"
},
{
"path": "./.nuxt/tsconfig.node.json"
}
]
}