Compare commits
1 Commits
78d872c83e
...
proproposa
| Author | SHA1 | Date | |
|---|---|---|---|
| f9db6aad8f |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,2 +0,0 @@
|
|||||||
node_modules
|
|
||||||
dist
|
|
||||||
102
PLAN.md
102
PLAN.md
@@ -1,102 +0,0 @@
|
|||||||
# Plan: Combined P2P Polling App
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
Merge the three subfolder projects into a single TypeScript + Yjs + Vite application at the repo root, combining the best features from each.
|
|
||||||
|
|
||||||
## Technology Choices
|
|
||||||
- **Language:** TypeScript (from project 3) with strict mode
|
|
||||||
- **CRDT:** Yjs + y-webrtc + y-indexeddb (from projects 1 & 3)
|
|
||||||
- **Build:** Vite (shared by all)
|
|
||||||
- **Package manager:** npm
|
|
||||||
|
|
||||||
## Feature Superset
|
|
||||||
From **project 1** (group-efa16e66):
|
|
||||||
- Collaborative title editing via Y.Text (real-time character-level sync)
|
|
||||||
- Delete options
|
|
||||||
- Diff-rendering for poll list (reuse DOM elements)
|
|
||||||
- Peer count display via awareness
|
|
||||||
- Share section with copy-to-clipboard
|
|
||||||
|
|
||||||
From **project 2** (proposal-8835ffc9):
|
|
||||||
- Deadline/timer system (2-minute voting window with countdown)
|
|
||||||
- Duplicate detection (case-insensitive)
|
|
||||||
|
|
||||||
From **project 3** (proposal-88461784):
|
|
||||||
- TypeScript types & strict mode
|
|
||||||
- Modular architecture (state, sync, render, identity, app layers)
|
|
||||||
- IndexedDB persistence (offline support)
|
|
||||||
- Online/offline connection tracking
|
|
||||||
- Input validation (max lengths, empty checks)
|
|
||||||
- HTML escaping for XSS prevention
|
|
||||||
- ViewModel pattern for clean render layer
|
|
||||||
|
|
||||||
## Data Model (Yjs)
|
|
||||||
```
|
|
||||||
Y.Doc
|
|
||||||
├── poll-meta (Y.Map<string>)
|
|
||||||
│ └── title → Y.Text (collaborative editing from project 1)
|
|
||||||
├── poll-options (Y.Map<OptionRecord>)
|
|
||||||
│ └── [optionId] → { id, label, createdAt, createdBy }
|
|
||||||
├── poll-votes (Y.Map<string>)
|
|
||||||
│ └── [userId] → optionId (single vote per user)
|
|
||||||
└── poll-deadline (Y.Map<any>)
|
|
||||||
└── deadline → number | null (timestamp, from project 2)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Architecture & File Structure
|
|
||||||
```
|
|
||||||
/
|
|
||||||
├── index.html
|
|
||||||
├── package.json
|
|
||||||
├── tsconfig.json
|
|
||||||
├── vite.config.ts (if needed for any plugins)
|
|
||||||
├── src/
|
|
||||||
│ ├── main.ts (entry: mount app)
|
|
||||||
│ ├── app.ts (orchestrator: init sync, bind events, manage state)
|
|
||||||
│ ├── identity.ts (getUserId with localStorage persistence)
|
|
||||||
│ ├── state.ts (types, pure functions, ViewModel creation)
|
|
||||||
│ ├── sync.ts (Yjs doc, WebRTC provider, IndexedDB, connection status)
|
|
||||||
│ ├── render.ts (DOM rendering with escapeHtml, diff-rendering for options)
|
|
||||||
│ ├── components/
|
|
||||||
│ │ ├── PollTitle.ts (collaborative title input bound to Y.Text)
|
|
||||||
│ │ ├── PollList.ts (diff-rendered option list with sorting)
|
|
||||||
│ │ ├── PollOption.ts (single option: vote bar, vote/delete buttons)
|
|
||||||
│ │ ├── AddOption.ts (input + submit with validation & duplicate check)
|
|
||||||
│ │ ├── StatusBar.ts (connection status + peer count)
|
|
||||||
│ │ ├── ShareSection.ts (copy URL to clipboard)
|
|
||||||
│ │ └── DeadlineTimer.ts (set deadline + countdown display, from project 2)
|
|
||||||
│ └── styles.css (merged: project 1's design tokens + project 3's glassmorphism)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Implementation Steps
|
|
||||||
|
|
||||||
### Step 1: Scaffold root project
|
|
||||||
- Create `package.json` with dependencies: yjs, y-webrtc, y-indexeddb, vite, typescript
|
|
||||||
- Create `tsconfig.json` (strict, ES2022, bundler resolution)
|
|
||||||
- Create `index.html` entry point
|
|
||||||
- Create `vite.config.ts` if needed
|
|
||||||
|
|
||||||
### Step 2: Core layer — identity.ts, state.ts, sync.ts
|
|
||||||
- `identity.ts`: port from project 3 (getUserId)
|
|
||||||
- `state.ts`: port types from project 3, add deadline types, add ViewModel with deadline/timer info, add vote percentage calculation from project 1
|
|
||||||
- `sync.ts`: port from project 3, add Y.Text for title (from project 1), add poll-deadline map, add awareness tracking for peer count (from project 1)
|
|
||||||
|
|
||||||
### Step 3: App orchestrator — app.ts, main.ts
|
|
||||||
- `app.ts`: port from project 3, add deadline handlers, add delete option handler, wire up all components
|
|
||||||
- `main.ts`: minimal entry that calls initApp
|
|
||||||
|
|
||||||
### Step 4: Components
|
|
||||||
- `PollTitle.ts`: port collaborative Y.Text editing from project 1, add TypeScript types
|
|
||||||
- `AddOption.ts`: merge project 1 (UI/animation) + project 2 (duplicate detection) + project 3 (validation)
|
|
||||||
- `PollOption.ts`: port from project 1 (vote bar, percentage, delete button), add TypeScript
|
|
||||||
- `PollList.ts`: port diff-rendering from project 1, add TypeScript
|
|
||||||
- `StatusBar.ts`: merge project 1 (peer count) + project 3 (online/offline status)
|
|
||||||
- `ShareSection.ts`: port from project 1, add TypeScript
|
|
||||||
- `DeadlineTimer.ts`: new component porting project 2's deadline/countdown logic to Yjs
|
|
||||||
|
|
||||||
### Step 5: Styling
|
|
||||||
- Merge CSS: use project 1's design tokens and typography as base, incorporate project 3's glassmorphism panel effects, add timer-specific styles from project 2
|
|
||||||
|
|
||||||
### Step 6: Cleanup
|
|
||||||
- Remove three subfolders (after confirming with user)
|
|
||||||
- Update root README.md
|
|
||||||
40
README.md
40
README.md
@@ -1 +1,39 @@
|
|||||||
# P2P Poll App
|
# P2P Poll App
|
||||||
|
|
||||||
|
There are various issues of Trust:
|
||||||
|
The possiblity to generate lots of users that do a lot of things (at a rather low cost)
|
||||||
|
The possibility to put out wrong data, maby not even contradicting but additional to existing data.
|
||||||
|
The possibility to do all kinds of shenenigans like spam other users with some requests
|
||||||
|
|
||||||
|
Due to low programming knowledge, the starting point of this proposal was to mirror how normal groups of people solve issues of trust to then automate and possibly improve the process. There are already some systems out there like Trust flow or random walk. As far as i understand it, the Flexible Trust Web also already does something like this, also maby RWOT and GNUweb but i didn't read into them too much since i discovered them rather late and want to look for feedback anyway. After all, a system with a clearer consensus might be preferable to some.
|
||||||
|
|
||||||
|
If random new people should be able to use the system as equals to previous users, but the system never has real identities as an input, then there is no way to fully prevent the creation of new users to manipulate or sabotage the poll. But it can be assumed, that your friends are rather trustworthy and most likely also their friends and so on. And if someone makes huge ammounts or just one second account, they will probably only have the creator or maby some other people as friends, and even they might already be less socially connected than a normal user.
|
||||||
|
So the social distance to another user should be evaluated to see, whether you should count their vote.
|
||||||
|
This is evaluated for and by every user individually, based on the information they were sent. The ammount of contacts you won't count are displayed to you, such that you get a hint at how many people you are missing but also how many people are not counting you. This encourages people to try to prove others/vise versa and make social connections to officially tie the network closer together such that the voting system works and confirms itself. It would be great, if there was some chat attached to the poll. If people want to prove their (or others) trusworhiness within this system, they are then also encouraged to have productive discussions, probably about the matter of the poll.
|
||||||
|
Everyone in a poll with you is a "contact" of yours.
|
||||||
|
"users" can have "friends".
|
||||||
|
You can also manually mark users as suspicious or trustworthy or normal again.
|
||||||
|
The system for evaluating the trustworthyness of users is somehow a mix between the concepts "weighted path score" and "trust flow" with 5 steps.
|
||||||
|
That means for 5 steps starting with you, all friends and trusted people of people looked at in this step get some trust from the people we look at: 0.8 * The trust of the looked at person (if trusted) + 0.8 * The trust of the looked at person / friends the looked at person has (if friend). Then the trust of the person that received trust may maximally be 100. The Trust you have to yourself is 100.
|
||||||
|
You can also mark someone as trustworthy or untrustworthy. That is then also sent around to everyone if you want(should be the standard, but maby a user wants to just see how the trustworthyness will look like after the change).
|
||||||
|
If you receive such an information, you can make the following calculations immidiately and after every assesment of everyones trustworthyness:
|
||||||
|
If the accused is less trustworthy then the accusing person, decrease the accused trustworthyness to 0 and the accused friends and trustees trustworthyness by the trustworthyness of the accusing person.
|
||||||
|
If the trustworhyness of the accusing person is less than the trustworthyness of the accused, then reduce the trustworthyness of the accusing person to 0 and the accusing persons friends and trustees by the trustworthyness of the accused * 0,2.
|
||||||
|
If you mark someone as trustworthy:
|
||||||
|
The Trust flowing to the trusted person from you will also be 0.8 of your trust.
|
||||||
|
Maby this should also be the effect of beeing "friends" since "trust" might be something you could more intuitively casually deal out after a short chat. If that change were to occur, then the effect would have to be switched around.
|
||||||
|
All contacts can maximally have the Trust 100.
|
||||||
|
|
||||||
|
|
||||||
|
Future matters:
|
||||||
|
If there can be any discrepancy of sent information, depending on what sender you trust most, you will mark one of the senders as untrustworthy and neglect all future information from this user. Since everything can be signed and such, that shouldnˋt be an issue tho, but if it was, the ammount of "useless" messages to already informed people might have to increase to validate received data.
|
||||||
|
A system to showcase the social connections in a 2D - format would be neat.
|
||||||
|
(most likely something like this exists already)
|
||||||
|
Obviously the user would also have to see other context like the total of all votes (trusted or not)
|
||||||
|
|
||||||
|
Anonymous polls:
|
||||||
|
A system of individually assigned trust poses a challenge for a system where you can decide not to trust some voters.
|
||||||
|
If there is no other option some compromises might be makable, such as:
|
||||||
|
-Your Friends can know what you voted for
|
||||||
|
-The Person initiating a poll just decides on the validity of participants according to an own judgement of trust at the moment of poll-creation
|
||||||
|
-A System with clear Consensus of who to trust
|
||||||
12
index.html
12
index.html
@@ -1,12 +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>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="app"></div>
|
|
||||||
<script type="module" src="/src/main.ts"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
1487
package-lock.json
generated
1487
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
21
package.json
21
package.json
@@ -1,21 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "polly-p2p-poll",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "PORT=4444 npx y-webrtc & vite",
|
|
||||||
"build": "tsc --noEmit && vite build",
|
|
||||||
"preview": "vite preview"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"y-indexeddb": "^9.0.12",
|
|
||||||
"y-webrtc": "^10.3.0",
|
|
||||||
"yjs": "^13.6.27",
|
|
||||||
"uuid": "^13.0.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"typescript": "^5.9.2",
|
|
||||||
"vite": "^7.1.5"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
193
src/app.ts
193
src/app.ts
@@ -1,193 +0,0 @@
|
|||||||
import {
|
|
||||||
User,
|
|
||||||
addOption,
|
|
||||||
toggleVote,
|
|
||||||
setDeadline,
|
|
||||||
clearDeadline,
|
|
||||||
getDeadline,
|
|
||||||
} from "./state";
|
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
|
||||||
import { initSync } from "./sync";
|
|
||||||
import { initUserSync } from "./userSync";
|
|
||||||
import { StatusBar } from "./components/StatusBar";
|
|
||||||
import { PollTitle } from "./components/PollTitle";
|
|
||||||
import { AddOption } from "./components/AddOption";
|
|
||||||
import { PollList } from "./components/PollList";
|
|
||||||
import { ShareSection } from "./components/ShareSection";
|
|
||||||
import { DeadlineTimer } from "./components/DeadlineTimer";
|
|
||||||
import { generateUserKeyPair, exportPrivateKey, savePrivateKeyToFile, exportPublicKey, stringToCryptoKey } from "./crypto";
|
|
||||||
|
|
||||||
const ROOM_PARAM = "room";
|
|
||||||
|
|
||||||
function createRoomId(): string {
|
|
||||||
if (typeof crypto.randomUUID === "function") {
|
|
||||||
return `poll-${crypto.randomUUID().slice(0, 8)}`;
|
|
||||||
}
|
|
||||||
return `poll-${Math.random().toString(36).slice(2, 10)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureRoomId(): string {
|
|
||||||
const url = new URL(window.location.href);
|
|
||||||
let roomId = url.searchParams.get(ROOM_PARAM)?.trim();
|
|
||||||
|
|
||||||
if (!roomId) {
|
|
||||||
roomId = createRoomId();
|
|
||||||
url.searchParams.set(ROOM_PARAM, roomId);
|
|
||||||
window.history.replaceState({}, "", url);
|
|
||||||
}
|
|
||||||
|
|
||||||
return roomId;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function initApp(container: HTMLElement): () => void {
|
|
||||||
const roomId = ensureRoomId();
|
|
||||||
const sync = initSync(roomId);
|
|
||||||
const userSync = initUserSync();
|
|
||||||
let user : User | undefined = undefined;
|
|
||||||
|
|
||||||
const shareUrl = window.location.href;
|
|
||||||
|
|
||||||
// --- Actions ---
|
|
||||||
|
|
||||||
const actions = {
|
|
||||||
addOption: (label: string) => {
|
|
||||||
if (!user || isVotingClosed()) return;
|
|
||||||
return addOption(sync.options, label, user.userid);
|
|
||||||
},
|
|
||||||
toggleVote: (optionId: string) => {
|
|
||||||
if (!user || isVotingClosed()) return;
|
|
||||||
toggleVote(sync.votes, user.userid, optionId);
|
|
||||||
},
|
|
||||||
startDeadline: (durationMs: number) => {
|
|
||||||
setDeadline(sync.deadlineMap, durationMs);
|
|
||||||
},
|
|
||||||
clearDeadline: () => {
|
|
||||||
clearDeadline(sync.deadlineMap);
|
|
||||||
},
|
|
||||||
onLoginLogout: async (event: Event) => {
|
|
||||||
if(user){
|
|
||||||
user = undefined
|
|
||||||
return false;
|
|
||||||
} else {
|
|
||||||
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 = {
|
|
||||||
userid: uuid,
|
|
||||||
private_key: key,
|
|
||||||
public_key: undefined, // Note: You might need to import a pub key too!
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log("Login successful for:", uuid);
|
|
||||||
return true;
|
|
||||||
} 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onCreateUser: async (event: Event) => {
|
|
||||||
try {
|
|
||||||
const keypair = await generateUserKeyPair();
|
|
||||||
|
|
||||||
console.log('keypair:', keypair);
|
|
||||||
const uuid = uuidv4();
|
|
||||||
user = {
|
|
||||||
userid: uuid,
|
|
||||||
private_key: keypair.privateKey,
|
|
||||||
public_key: keypair.publicKey,
|
|
||||||
};
|
|
||||||
|
|
||||||
const prvKeyString = await exportPrivateKey(keypair.privateKey);
|
|
||||||
|
|
||||||
savePrivateKeyToFile(prvKeyString,uuid+".pem")
|
|
||||||
|
|
||||||
|
|
||||||
const pubKeyString = await exportPublicKey(keypair.publicKey);
|
|
||||||
|
|
||||||
userSync.users.set(user.userid,pubKeyString)
|
|
||||||
return true;
|
|
||||||
} catch (err) {
|
|
||||||
user = undefined
|
|
||||||
console.error("Failed to create new User!", err);
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
function isVotingClosed() {
|
|
||||||
const deadline = getDeadline(sync.deadlineMap);
|
|
||||||
const votingClosed = deadline !== null && Date.now() >= deadline;
|
|
||||||
return votingClosed;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Build UI ---
|
|
||||||
|
|
||||||
// Header
|
|
||||||
const header = document.createElement("header");
|
|
||||||
header.className = "app-header";
|
|
||||||
|
|
||||||
const wordmark = document.createElement("div");
|
|
||||||
wordmark.className = "app-wordmark";
|
|
||||||
wordmark.innerHTML = `
|
|
||||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
|
||||||
<rect x="2" y="4" width="16" height="2.5" rx="1.25" fill="currentColor"/>
|
|
||||||
<rect x="2" y="8.75" width="11" height="2.5" rx="1.25" fill="currentColor" opacity="0.6"/>
|
|
||||||
<rect x="2" y="13.5" width="13" height="2.5" rx="1.25" fill="currentColor" opacity="0.35"/>
|
|
||||||
</svg>
|
|
||||||
<span>Polly</span>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const statusBar = StatusBar(sync.provider,userSync.provider,actions.onLoginLogout,actions.onCreateUser);
|
|
||||||
header.append(wordmark, statusBar);
|
|
||||||
|
|
||||||
// Main card
|
|
||||||
const card = document.createElement("main");
|
|
||||||
card.className = "app-card";
|
|
||||||
|
|
||||||
const pollTitle = PollTitle(sync.doc, sync.yTitle);
|
|
||||||
const addOptionComponent = AddOption((label: string) => {
|
|
||||||
const result = actions.addOption(label);
|
|
||||||
if (result && !result.ok) return result.error;
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
const pollList = PollList(sync.options, sync.votes, user, isVotingClosed, actions.toggleVote);
|
|
||||||
const deadlineTimer = DeadlineTimer(
|
|
||||||
sync.deadlineMap,
|
|
||||||
actions.startDeadline,
|
|
||||||
actions.clearDeadline,
|
|
||||||
);
|
|
||||||
|
|
||||||
card.append(pollTitle, addOptionComponent, deadlineTimer, pollList);
|
|
||||||
|
|
||||||
// Footer
|
|
||||||
const footer = document.createElement("footer");
|
|
||||||
footer.className = "app-footer";
|
|
||||||
footer.appendChild(ShareSection(roomId));
|
|
||||||
|
|
||||||
container.append(header, card, footer);
|
|
||||||
|
|
||||||
// --- Cleanup ---
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
sync.destroy();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
export function AddOption(
|
|
||||||
onSubmit: (label: string) => string | null,
|
|
||||||
): HTMLElement {
|
|
||||||
const wrapper = document.createElement("div");
|
|
||||||
wrapper.className = "add-option-wrapper";
|
|
||||||
|
|
||||||
const input = document.createElement("input");
|
|
||||||
input.type = "text";
|
|
||||||
input.className = "add-option-input";
|
|
||||||
input.placeholder = "Add an option\u2026";
|
|
||||||
input.maxLength = 100;
|
|
||||||
input.setAttribute("aria-label", "New poll option");
|
|
||||||
|
|
||||||
const btn = document.createElement("button");
|
|
||||||
btn.className = "add-option-btn";
|
|
||||||
btn.setAttribute("aria-label", "Add option");
|
|
||||||
btn.innerHTML = `
|
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
|
||||||
<path d="M8 2v12M2 8h12" stroke="currentColor" stroke-width="1.75" stroke-linecap="round"/>
|
|
||||||
</svg>
|
|
||||||
<span>Add</span>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const feedback = document.createElement("div");
|
|
||||||
feedback.className = "add-option-feedback";
|
|
||||||
feedback.setAttribute("aria-live", "polite");
|
|
||||||
|
|
||||||
wrapper.append(input, btn, feedback);
|
|
||||||
|
|
||||||
function submit() {
|
|
||||||
const name = input.value.trim();
|
|
||||||
if (!name) {
|
|
||||||
input.focus();
|
|
||||||
input.classList.add("shake");
|
|
||||||
input.addEventListener("animationend", () => input.classList.remove("shake"), { once: true });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const error = onSubmit(name);
|
|
||||||
if (error) {
|
|
||||||
feedback.textContent = error;
|
|
||||||
feedback.style.display = "";
|
|
||||||
setTimeout(() => {
|
|
||||||
feedback.textContent = "";
|
|
||||||
feedback.style.display = "none";
|
|
||||||
}, 3000);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
input.value = "";
|
|
||||||
feedback.textContent = "";
|
|
||||||
feedback.style.display = "none";
|
|
||||||
input.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
btn.addEventListener("click", submit);
|
|
||||||
input.addEventListener("keydown", (e) => {
|
|
||||||
if (e.key === "Enter") submit();
|
|
||||||
});
|
|
||||||
input.addEventListener("input", () => {
|
|
||||||
feedback.textContent = "";
|
|
||||||
feedback.style.display = "none";
|
|
||||||
});
|
|
||||||
|
|
||||||
return wrapper;
|
|
||||||
}
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
import * as Y from "yjs";
|
|
||||||
import { getDeadline } from "../state";
|
|
||||||
import { enforceAppendOnly } from "../yDocUtil";
|
|
||||||
|
|
||||||
const DEADLINE_DURATION_MS = 2 * 60 * 1000; // 2 minutes
|
|
||||||
|
|
||||||
export function DeadlineTimer(
|
|
||||||
deadlineMap: Y.Map<unknown>,
|
|
||||||
onStartDeadline: (durationMs: number) => void,
|
|
||||||
onClearDeadline: () => void,
|
|
||||||
): HTMLElement {
|
|
||||||
const wrapper = document.createElement("div");
|
|
||||||
wrapper.className = "deadline-wrapper";
|
|
||||||
|
|
||||||
const timerEl = document.createElement("span");
|
|
||||||
timerEl.className = "deadline-timer";
|
|
||||||
|
|
||||||
const startBtn = document.createElement("button");
|
|
||||||
startBtn.className = "deadline-btn";
|
|
||||||
startBtn.textContent = "Start 2-min vote";
|
|
||||||
startBtn.setAttribute("aria-label", "Start a 2-minute voting deadline");
|
|
||||||
|
|
||||||
const clearBtn = document.createElement("button");
|
|
||||||
clearBtn.className = "deadline-btn deadline-btn--clear";
|
|
||||||
clearBtn.textContent = "Clear";
|
|
||||||
clearBtn.setAttribute("aria-label", "Remove voting deadline");
|
|
||||||
|
|
||||||
wrapper.append(timerEl, startBtn, clearBtn);
|
|
||||||
|
|
||||||
let interval: ReturnType<typeof setInterval> | undefined;
|
|
||||||
|
|
||||||
function render() {
|
|
||||||
const deadline = getDeadline(deadlineMap);
|
|
||||||
const now = Date.now();
|
|
||||||
|
|
||||||
if (deadline === null) {
|
|
||||||
// No deadline set
|
|
||||||
timerEl.textContent = "";
|
|
||||||
timerEl.className = "deadline-timer";
|
|
||||||
startBtn.hidden = false;
|
|
||||||
clearBtn.hidden = true;
|
|
||||||
if (interval) {
|
|
||||||
clearInterval(interval);
|
|
||||||
interval = undefined;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
startBtn.hidden = true;
|
|
||||||
clearBtn.hidden = false;
|
|
||||||
|
|
||||||
if (now >= deadline) {
|
|
||||||
// Voting closed
|
|
||||||
timerEl.textContent = "Voting closed";
|
|
||||||
timerEl.className = "deadline-timer deadline-timer--closed";
|
|
||||||
if (interval) {
|
|
||||||
clearInterval(interval);
|
|
||||||
interval = undefined;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Counting down
|
|
||||||
const remaining = Math.ceil((deadline - now) / 1000);
|
|
||||||
const mins = Math.floor(remaining / 60);
|
|
||||||
const secs = remaining % 60;
|
|
||||||
timerEl.textContent = `Voting closes in ${mins}:${secs.toString().padStart(2, "0")}`;
|
|
||||||
timerEl.className = "deadline-timer deadline-timer--active";
|
|
||||||
|
|
||||||
if (!interval) {
|
|
||||||
interval = setInterval(() => render(), 1000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
startBtn.addEventListener("click", () => {
|
|
||||||
onStartDeadline(DEADLINE_DURATION_MS);
|
|
||||||
});
|
|
||||||
|
|
||||||
clearBtn.addEventListener("click", () => {
|
|
||||||
onClearDeadline();
|
|
||||||
});
|
|
||||||
|
|
||||||
deadlineMap.observe(enforceAppendOnly(deadlineMap,render));
|
|
||||||
render();
|
|
||||||
|
|
||||||
return wrapper;
|
|
||||||
}
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
import * as Y from "yjs";
|
|
||||||
import type { OptionRecord } from "../state";
|
|
||||||
import { PollOption } from "./PollOption";
|
|
||||||
import { enforceAppendOnly } from "../yDocUtil";
|
|
||||||
import { User } from "../state";
|
|
||||||
|
|
||||||
export function PollList(
|
|
||||||
yOptions: Y.Map<OptionRecord>,
|
|
||||||
yVotes: Y.Map<string>,
|
|
||||||
user: User | undefined,
|
|
||||||
isVotingClosed: () => boolean,
|
|
||||||
onVote: (optionId: string) => void,
|
|
||||||
): HTMLElement {
|
|
||||||
|
|
||||||
var currentOptions : { [x: string]: any; } | undefined = undefined
|
|
||||||
var currentVotes : { [x: string]: any; } | undefined = undefined
|
|
||||||
|
|
||||||
const wrapper = document.createElement("div");
|
|
||||||
wrapper.className = "poll-list-wrapper";
|
|
||||||
|
|
||||||
const meta = document.createElement("div");
|
|
||||||
meta.className = "poll-list-meta";
|
|
||||||
|
|
||||||
const list = document.createElement("div");
|
|
||||||
list.className = "poll-list";
|
|
||||||
|
|
||||||
const empty = document.createElement("div");
|
|
||||||
empty.className = "poll-list-empty";
|
|
||||||
empty.innerHTML = `
|
|
||||||
<div class="empty-icon">
|
|
||||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<rect x="4" y="10" width="24" height="3" rx="1.5" fill="currentColor" opacity="0.15"/>
|
|
||||||
<rect x="4" y="16" width="18" height="3" rx="1.5" fill="currentColor" opacity="0.1"/>
|
|
||||||
<rect x="4" y="22" width="21" height="3" rx="1.5" fill="currentColor" opacity="0.07"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<p>No options yet — add the first one above.</p>
|
|
||||||
`;
|
|
||||||
|
|
||||||
wrapper.append(meta, list, empty);
|
|
||||||
|
|
||||||
function getEntries() {
|
|
||||||
const entries: Array<{
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
votes: number;
|
|
||||||
voted: boolean;
|
|
||||||
}> = [];
|
|
||||||
if (currentOptions && currentVotes){
|
|
||||||
|
|
||||||
// Tally votes per option
|
|
||||||
const tally = new Map<string, number>();
|
|
||||||
for (const optionId of Object.values(currentVotes)) {
|
|
||||||
tally.set(optionId, (tally.get(optionId) ?? 0) + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
let myVote = null;
|
|
||||||
if (user) {
|
|
||||||
myVote = currentVotes[user.userid]
|
|
||||||
}
|
|
||||||
|
|
||||||
Object.entries(currentOptions).forEach(([id,record]) => {
|
|
||||||
console.log(`${record}: ${id}`)
|
|
||||||
entries.push({
|
|
||||||
id,
|
|
||||||
name: record.label,
|
|
||||||
votes: tally.get(id) ?? 0,
|
|
||||||
voted: myVote === id,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
entries.sort((a, b) => b.votes - a.votes || a.name.localeCompare(b.name));
|
|
||||||
}
|
|
||||||
|
|
||||||
return entries;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getTotalVotes(): number {
|
|
||||||
return yVotes.size;
|
|
||||||
}
|
|
||||||
|
|
||||||
function render() {
|
|
||||||
const entries = getEntries();
|
|
||||||
const total = getTotalVotes();
|
|
||||||
const votingClosed = isVotingClosed();
|
|
||||||
|
|
||||||
// Meta line
|
|
||||||
if (entries.length > 0) {
|
|
||||||
meta.textContent = `${entries.length} option${entries.length !== 1 ? "s" : ""} \u00b7 ${total} vote${total !== 1 ? "s" : ""} total`;
|
|
||||||
meta.style.display = "";
|
|
||||||
} else {
|
|
||||||
meta.style.display = "none";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Empty state
|
|
||||||
empty.style.display = entries.length === 0 ? "" : "none";
|
|
||||||
|
|
||||||
// Diff-render: reuse existing rows when possible
|
|
||||||
const existing = new Map(
|
|
||||||
[...list.querySelectorAll<HTMLElement>(".poll-option")].map((el) => [
|
|
||||||
el.dataset.id,
|
|
||||||
el,
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Remove stale rows
|
|
||||||
existing.forEach((el, id) => {
|
|
||||||
if (!entries.find((e) => e.id === id)) el.remove();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update or insert rows in sorted order
|
|
||||||
entries.forEach((entry, i) => {
|
|
||||||
const newEl = PollOption({
|
|
||||||
...entry,
|
|
||||||
totalVotes: total,
|
|
||||||
votingClosed,
|
|
||||||
onVote,
|
|
||||||
});
|
|
||||||
const currentEl = list.children[i] as HTMLElement | undefined;
|
|
||||||
|
|
||||||
if (!currentEl) {
|
|
||||||
list.appendChild(newEl);
|
|
||||||
} else if (currentEl.dataset.id !== entry.id) {
|
|
||||||
list.insertBefore(newEl, currentEl);
|
|
||||||
const old = existing.get(entry.id);
|
|
||||||
if (old && old !== currentEl) old.remove();
|
|
||||||
} else {
|
|
||||||
list.replaceChild(newEl, currentEl);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
yOptions.observe(enforceAppendOnly(yOptions,(update : { [x: string]: any; }) => {currentOptions = update}, render));
|
|
||||||
yVotes.observe(enforceAppendOnly(yVotes,(update : { [x: string]: any; }) => {currentVotes = update},render));
|
|
||||||
currentOptions=yOptions.toJSON()
|
|
||||||
currentVotes=yVotes.toJSON()
|
|
||||||
render();
|
|
||||||
|
|
||||||
return wrapper;
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import { escapeHtml } from "../state";
|
|
||||||
|
|
||||||
export interface PollOptionProps {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
votes: number;
|
|
||||||
voted: boolean;
|
|
||||||
totalVotes: number;
|
|
||||||
votingClosed: boolean;
|
|
||||||
onVote: (id: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PollOption(props: PollOptionProps): HTMLElement {
|
|
||||||
const { id, name, votes, voted, totalVotes, votingClosed, onVote } = props;
|
|
||||||
|
|
||||||
const row = document.createElement("div");
|
|
||||||
row.className = `poll-option${voted ? " poll-option--voted" : ""}`;
|
|
||||||
row.dataset.id = id;
|
|
||||||
|
|
||||||
const pct = totalVotes > 0 ? Math.round((votes / totalVotes) * 100) : 0;
|
|
||||||
|
|
||||||
row.innerHTML = `
|
|
||||||
<div class="poll-option__bar" style="width: ${pct}%"></div>
|
|
||||||
<div class="poll-option__content">
|
|
||||||
<span class="poll-option__name">${escapeHtml(name)}</span>
|
|
||||||
<div class="poll-option__actions">
|
|
||||||
<span class="poll-option__pct">${pct}%</span>
|
|
||||||
<span class="poll-option__count">${votes} vote${votes !== 1 ? "s" : ""}</span>
|
|
||||||
<button class="poll-option__vote-btn" aria-pressed="${voted}"${votingClosed ? " disabled" : ""}>
|
|
||||||
${voted ? "Voted" : "Vote"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
row.querySelector(".poll-option__vote-btn")!.addEventListener("click", () => onVote(id));
|
|
||||||
|
|
||||||
return row;
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import * as Y from "yjs";
|
|
||||||
|
|
||||||
export function PollTitle(ydoc: Y.Doc, yTitle: Y.Text): HTMLElement {
|
|
||||||
const wrapper = document.createElement("div");
|
|
||||||
wrapper.className = "poll-title-wrapper";
|
|
||||||
|
|
||||||
const input = document.createElement("input");
|
|
||||||
input.type = "text";
|
|
||||||
input.id = "poll-title";
|
|
||||||
input.className = "poll-title-input";
|
|
||||||
input.placeholder = "Untitled Poll";
|
|
||||||
input.maxLength = 120;
|
|
||||||
input.setAttribute("aria-label", "Poll title");
|
|
||||||
input.value = yTitle.toString();
|
|
||||||
|
|
||||||
wrapper.appendChild(input);
|
|
||||||
|
|
||||||
// Sync from Yjs → input (only when not focused to avoid cursor jump)
|
|
||||||
yTitle.observe(() => {
|
|
||||||
if (document.activeElement !== input) {
|
|
||||||
input.value = yTitle.toString();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sync from input → Yjs
|
|
||||||
input.addEventListener("input", () => {
|
|
||||||
ydoc.transact(() => {
|
|
||||||
yTitle.delete(0, yTitle.length);
|
|
||||||
yTitle.insert(0, input.value);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return wrapper;
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
export function ShareSection(roomName: string): HTMLElement {
|
|
||||||
const url = `${window.location.origin}${window.location.pathname}?room=${encodeURIComponent(roomName)}`;
|
|
||||||
|
|
||||||
const section = document.createElement("div");
|
|
||||||
section.className = "share-section";
|
|
||||||
|
|
||||||
section.innerHTML = `
|
|
||||||
<p class="share-label">Share this poll</p>
|
|
||||||
<div class="share-row">
|
|
||||||
<code class="share-url" title="${url}">${url}</code>
|
|
||||||
<button class="share-copy-btn">Copy link</button>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const copyBtn = section.querySelector<HTMLButtonElement>(".share-copy-btn")!;
|
|
||||||
|
|
||||||
copyBtn.addEventListener("click", async () => {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(url);
|
|
||||||
copyBtn.textContent = "Copied!";
|
|
||||||
copyBtn.classList.add("share-copy-btn--success");
|
|
||||||
setTimeout(() => {
|
|
||||||
copyBtn.textContent = "Copy link";
|
|
||||||
copyBtn.classList.remove("share-copy-btn--success");
|
|
||||||
}, 2000);
|
|
||||||
} catch {
|
|
||||||
// Fallback: select the text
|
|
||||||
const range = document.createRange();
|
|
||||||
const urlEl = section.querySelector(".share-url");
|
|
||||||
if (urlEl) {
|
|
||||||
range.selectNode(urlEl);
|
|
||||||
window.getSelection()?.removeAllRanges();
|
|
||||||
window.getSelection()?.addRange(range);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return section;
|
|
||||||
}
|
|
||||||
@@ -1,174 +0,0 @@
|
|||||||
import type { WebrtcProvider } from "y-webrtc";
|
|
||||||
|
|
||||||
export function StatusBar(provider: WebrtcProvider, user_provider: WebrtcProvider, onLoginLogout: (event: Event) => Promise<boolean>, onCreateUser: (event: Event) => Promise<boolean>): HTMLElement {
|
|
||||||
const el = document.createElement("div");
|
|
||||||
el.className = "status-bar";
|
|
||||||
|
|
||||||
const statusPanel=document.createElement("div");
|
|
||||||
statusPanel.className = "status-bar";
|
|
||||||
|
|
||||||
const divider = document.createElement("span");
|
|
||||||
divider.className = "status-divider";
|
|
||||||
divider.textContent = "\u00b7";
|
|
||||||
|
|
||||||
function getProviderStatus(){
|
|
||||||
|
|
||||||
const dot = document.createElement("span");
|
|
||||||
dot.className = "status-dot connecting";
|
|
||||||
|
|
||||||
const statusText = document.createElement("span");
|
|
||||||
statusText.className = "status-text";
|
|
||||||
statusText.textContent = "Connecting";
|
|
||||||
|
|
||||||
const peerText = document.createElement("span");
|
|
||||||
peerText.className = "status-peers";
|
|
||||||
|
|
||||||
return { dot: dot, statusText: statusText, peerText: peerText}
|
|
||||||
}
|
|
||||||
|
|
||||||
const providerStatusPanel=document.createElement("div");
|
|
||||||
providerStatusPanel.className = "provider-status-container";
|
|
||||||
|
|
||||||
|
|
||||||
const pollProviderText = document.createElement("span");
|
|
||||||
pollProviderText.className = "status-text";
|
|
||||||
pollProviderText.textContent = "Polls: ";
|
|
||||||
const pollProviderElements = getProviderStatus()
|
|
||||||
const pollProviderStatusPanel=document.createElement("div");
|
|
||||||
pollProviderStatusPanel.className = "status-bar";
|
|
||||||
pollProviderStatusPanel.append(pollProviderText,pollProviderElements.dot, pollProviderElements.statusText, divider, pollProviderElements.peerText);
|
|
||||||
|
|
||||||
|
|
||||||
const userProviderText = document.createElement("span");
|
|
||||||
userProviderText.className = "status-text";
|
|
||||||
userProviderText.textContent = "Users: ";
|
|
||||||
const userProviderElements = getProviderStatus()
|
|
||||||
const userProviderStatusPanel=document.createElement("div");
|
|
||||||
userProviderStatusPanel.className = "status-bar";
|
|
||||||
userProviderStatusPanel.append(userProviderText,userProviderElements.dot, userProviderElements.statusText, divider, userProviderElements.peerText);
|
|
||||||
|
|
||||||
providerStatusPanel.append(userProviderStatusPanel,pollProviderStatusPanel)
|
|
||||||
|
|
||||||
const userButtons = document.createElement("div");
|
|
||||||
userButtons.className = "status-bar";
|
|
||||||
|
|
||||||
const loginLabel = document.createElement("label");
|
|
||||||
loginLabel.setAttribute("title", "Select Key File");
|
|
||||||
const loginSpan = document.createElement("span");
|
|
||||||
loginSpan.className = "add-option-btn"
|
|
||||||
loginSpan.textContent = "Login";
|
|
||||||
const loginInput = document.createElement("input");
|
|
||||||
loginInput.type = "file"
|
|
||||||
loginInput.accept = ".pem"
|
|
||||||
loginInput.hidden = true
|
|
||||||
|
|
||||||
|
|
||||||
loginLabel.append(loginSpan,loginInput)
|
|
||||||
|
|
||||||
|
|
||||||
const logoutButton = document.createElement("button");
|
|
||||||
logoutButton.className = "add-option-btn";
|
|
||||||
logoutButton.setAttribute("aria-label", "Logout");
|
|
||||||
logoutButton.innerHTML="<span>Logout</span>"
|
|
||||||
logoutButton.style.display = "none";
|
|
||||||
|
|
||||||
const createUserButton = document.createElement("button");
|
|
||||||
createUserButton.className = "add-option-btn";
|
|
||||||
createUserButton.setAttribute("aria-label", "Create User");
|
|
||||||
createUserButton.innerHTML="<span>Create User</span>"
|
|
||||||
|
|
||||||
async function onLoginLogoutResult(event: Event, loginLogout: (event: Event) => Promise<boolean>){
|
|
||||||
if(await loginLogout(event)){
|
|
||||||
console.log('created / logged in')
|
|
||||||
loginLabel.style.display = "none";
|
|
||||||
logoutButton.style.display = "block";
|
|
||||||
createUserButton.style.display = "none";
|
|
||||||
} else {
|
|
||||||
console.log('logged out')
|
|
||||||
loginLabel.style.display = "block";
|
|
||||||
logoutButton.style.display = "none";
|
|
||||||
createUserButton.hidden = false;
|
|
||||||
createUserButton.style.display = "block";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
loginLabel.addEventListener("change", (e) => onLoginLogoutResult(e,onLoginLogout));
|
|
||||||
logoutButton.addEventListener("click", (e) => onLoginLogoutResult(e,onLoginLogout));
|
|
||||||
createUserButton.addEventListener("click", (e) => onLoginLogoutResult(e,onCreateUser));
|
|
||||||
|
|
||||||
userButtons.append(loginLabel,logoutButton,createUserButton)
|
|
||||||
|
|
||||||
el.append(providerStatusPanel, divider, userButtons);
|
|
||||||
|
|
||||||
// --- Connection state ---
|
|
||||||
|
|
||||||
let syncTimeout: ReturnType<typeof setTimeout> | undefined = setTimeout(() => {
|
|
||||||
pollProviderElements.statusText.textContent = "Ready";
|
|
||||||
pollProviderElements.dot.className = "status-dot ready";
|
|
||||||
}, 3000);
|
|
||||||
|
|
||||||
provider.on("synced", ({ synced }: { synced: boolean }) => {
|
|
||||||
if (syncTimeout) {
|
|
||||||
clearTimeout(syncTimeout);
|
|
||||||
syncTimeout = undefined;
|
|
||||||
}
|
|
||||||
pollProviderElements.dot.className = `status-dot ${synced ? "connected" : "connecting"}`;
|
|
||||||
pollProviderElements.statusText.textContent = synced ? "Connected" : "Connecting";
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
let syncTimeout2: ReturnType<typeof setTimeout> | undefined = setTimeout(() => {
|
|
||||||
userProviderElements.statusText.textContent = "Ready";
|
|
||||||
userProviderElements.dot.className = "status-dot ready";
|
|
||||||
}, 3000);
|
|
||||||
|
|
||||||
user_provider.on("synced", ({ synced }: { synced: boolean }) => {
|
|
||||||
if (syncTimeout2) {
|
|
||||||
clearTimeout(syncTimeout2);
|
|
||||||
syncTimeout2 = undefined;
|
|
||||||
}
|
|
||||||
userProviderElements.dot.className = `status-dot ${synced ? "connected" : "connecting"}`;
|
|
||||||
userProviderElements.statusText.textContent = synced ? "Connected" : "Connecting";
|
|
||||||
});
|
|
||||||
|
|
||||||
// Online/offline awareness
|
|
||||||
const handleOffline = () => {
|
|
||||||
pollProviderElements.dot.className = "status-dot connecting";
|
|
||||||
pollProviderElements.statusText.textContent = "Offline";
|
|
||||||
userProviderElements.dot.className = "status-dot connecting";
|
|
||||||
userProviderElements.statusText.textContent = "Offline";
|
|
||||||
};
|
|
||||||
const handleOnline = () => {
|
|
||||||
pollProviderElements.dot.className = "status-dot connecting";
|
|
||||||
pollProviderElements.statusText.textContent = "Reconnecting";
|
|
||||||
userProviderElements.dot.className = "status-dot connecting";
|
|
||||||
userProviderElements.statusText.textContent = "Reconnecting";
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("offline", handleOffline);
|
|
||||||
window.addEventListener("online", handleOnline);
|
|
||||||
|
|
||||||
// --- Peer count ---
|
|
||||||
|
|
||||||
function updatePeerCount() {
|
|
||||||
const total = provider.awareness.getStates().size;
|
|
||||||
const others = total - 1;
|
|
||||||
pollProviderElements.peerText.textContent =
|
|
||||||
others === 0
|
|
||||||
? "Only you"
|
|
||||||
: `${others} other${others !== 1 ? "s" : ""}`;
|
|
||||||
|
|
||||||
|
|
||||||
const total2 = user_provider.awareness.getStates().size;
|
|
||||||
const others2 = total2 - 1;
|
|
||||||
userProviderElements.peerText.textContent =
|
|
||||||
others2 === 0
|
|
||||||
? "Only you"
|
|
||||||
: `${others2} other${others2 !== 1 ? "s" : ""}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
provider.awareness.on("change", updatePeerCount);
|
|
||||||
updatePeerCount();
|
|
||||||
|
|
||||||
return el;
|
|
||||||
}
|
|
||||||
119
src/crypto.ts
119
src/crypto.ts
@@ -1,119 +0,0 @@
|
|||||||
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 signData = 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)));
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
const USER_ID_KEY = "polly:user-id";
|
|
||||||
|
|
||||||
function createUserId(): string {
|
|
||||||
if (typeof crypto.randomUUID === "function") {
|
|
||||||
return crypto.randomUUID();
|
|
||||||
}
|
|
||||||
return `user-${Math.random().toString(36).slice(2, 10)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getUserId(): string {
|
|
||||||
const existing = localStorage.getItem(USER_ID_KEY);
|
|
||||||
if (existing) return existing;
|
|
||||||
|
|
||||||
const next = createUserId();
|
|
||||||
localStorage.setItem(USER_ID_KEY, next);
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import "./styles.css";
|
|
||||||
import { initApp } from "./app";
|
|
||||||
|
|
||||||
const container = document.querySelector<HTMLElement>("#app");
|
|
||||||
if (!container) {
|
|
||||||
throw new Error("App container not found.");
|
|
||||||
}
|
|
||||||
|
|
||||||
initApp(container);
|
|
||||||
110
src/state.ts
110
src/state.ts
@@ -1,110 +0,0 @@
|
|||||||
import * as Y from "yjs";
|
|
||||||
|
|
||||||
// --- Types ---
|
|
||||||
|
|
||||||
export type ConnectionStatus = "connecting" | "connected" | "offline";
|
|
||||||
|
|
||||||
export interface User{
|
|
||||||
userid: string,
|
|
||||||
private_key: CryptoKey,
|
|
||||||
public_key: CryptoKey | undefined,
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OptionRecord {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
createdAt: number;
|
|
||||||
createdBy: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Helpers ---
|
|
||||||
|
|
||||||
export function createOptionId(): string {
|
|
||||||
if (typeof crypto.randomUUID === "function") {
|
|
||||||
return crypto.randomUUID();
|
|
||||||
}
|
|
||||||
return `option-${Math.random().toString(36).slice(2, 10)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeLabel(label: string): string {
|
|
||||||
return label.trim().replace(/\s+/g, " ");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function escapeHtml(value: string): string {
|
|
||||||
return value
|
|
||||||
.replaceAll("&", "&")
|
|
||||||
.replaceAll("<", "<")
|
|
||||||
.replaceAll(">", ">")
|
|
||||||
.replaceAll('"', """)
|
|
||||||
.replaceAll("'", "'");
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Title ---
|
|
||||||
|
|
||||||
export function getPollTitle(yTitle: Y.Text): string {
|
|
||||||
const title = yTitle.toString();
|
|
||||||
return title || "Untitled Poll";
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Options ---
|
|
||||||
|
|
||||||
export function addOption(
|
|
||||||
options: Y.Map<OptionRecord>,
|
|
||||||
rawLabel: string,
|
|
||||||
userId: string,
|
|
||||||
): { ok: true; optionId: string } | { ok: false; error: string } {
|
|
||||||
const label = normalizeLabel(rawLabel);
|
|
||||||
if (!label) {
|
|
||||||
return { ok: false, error: "Option cannot be empty." };
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedTarget = label.toLocaleLowerCase();
|
|
||||||
const duplicate = Array.from(options.values()).some(
|
|
||||||
(option) => option.label.trim().toLocaleLowerCase() === normalizedTarget,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (duplicate) {
|
|
||||||
return { ok: false, error: "That option already exists." };
|
|
||||||
}
|
|
||||||
|
|
||||||
const option: OptionRecord = {
|
|
||||||
id: createOptionId(),
|
|
||||||
label,
|
|
||||||
createdAt: Date.now(),
|
|
||||||
createdBy: userId,
|
|
||||||
};
|
|
||||||
|
|
||||||
options.set(option.id, option);
|
|
||||||
return { ok: true, optionId: option.id };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toggleVote(
|
|
||||||
votes: Y.Map<string>,
|
|
||||||
userId: string,
|
|
||||||
optionId: string,
|
|
||||||
): void {
|
|
||||||
const current = votes.get(userId);
|
|
||||||
if (current === optionId) {
|
|
||||||
votes.delete(userId);
|
|
||||||
} else {
|
|
||||||
votes.set(userId, optionId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Deadline ---
|
|
||||||
|
|
||||||
export function setDeadline(
|
|
||||||
deadlineMap: Y.Map<unknown>,
|
|
||||||
durationMs: number,
|
|
||||||
): void {
|
|
||||||
deadlineMap.set("deadline", Date.now() + durationMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clearDeadline(deadlineMap: Y.Map<unknown>): void {
|
|
||||||
deadlineMap.delete("deadline");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getDeadline(deadlineMap: Y.Map<unknown>): number | null {
|
|
||||||
const val = deadlineMap.get("deadline");
|
|
||||||
return typeof val === "number" ? val : null;
|
|
||||||
}
|
|
||||||
477
src/styles.css
477
src/styles.css
@@ -1,477 +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;
|
|
||||||
--warning: #8c5300;
|
|
||||||
|
|
||||||
--radius-sm: 6px;
|
|
||||||
--radius-md: 10px;
|
|
||||||
--radius-lg: 14px;
|
|
||||||
|
|
||||||
--font-display: 'Playfair Display', Georgia, serif;
|
|
||||||
--font-body: 'DM Sans', system-ui, sans-serif;
|
|
||||||
|
|
||||||
--shadow-card: 0 1px 3px rgba(0,0,0,0.06), 0 4px 16px rgba(0,0,0,0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Reset ─────────────────────────────────────────────── */
|
|
||||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
||||||
|
|
||||||
/* ── Base ──────────────────────────────────────────────── */
|
|
||||||
html { font-size: 16px; }
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: var(--font-body);
|
|
||||||
background: var(--bg);
|
|
||||||
color: var(--text-primary);
|
|
||||||
min-height: 100vh;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
}
|
|
||||||
|
|
||||||
button, input { font: inherit; }
|
|
||||||
button { cursor: pointer; }
|
|
||||||
|
|
||||||
/* ── Layout ────────────────────────────────────────────── */
|
|
||||||
#app {
|
|
||||||
max-width: 580px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 2rem 1.25rem 4rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Header ────────────────────────────────────────────── */
|
|
||||||
.app-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 0 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-wordmark {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
font-family: var(--font-display);
|
|
||||||
font-size: 1.1rem;
|
|
||||||
color: var(--text-primary);
|
|
||||||
letter-spacing: -0.01em;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Status bar ────────────────────────────────────────── */
|
|
||||||
.status-bar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.4rem;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
.provider-status-container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.4rem;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-dot {
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
border-radius: 50%;
|
|
||||||
flex-shrink: 0;
|
|
||||||
transition: background 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-dot.connecting { background: var(--text-muted); }
|
|
||||||
.status-dot.ready { background: var(--text-muted); }
|
|
||||||
.status-dot.connected { background: var(--success); }
|
|
||||||
|
|
||||||
.status-divider { color: var(--text-muted); }
|
|
||||||
|
|
||||||
/* ── Card ──────────────────────────────────────────────── */
|
|
||||||
.app-card {
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
box-shadow: var(--shadow-card);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Poll Title ────────────────────────────────────────── */
|
|
||||||
.poll-title-wrapper {
|
|
||||||
padding: 1.75rem 1.75rem 1.25rem;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.poll-title-input {
|
|
||||||
width: 100%;
|
|
||||||
font-family: var(--font-display);
|
|
||||||
font-size: 1.5rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-primary);
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
outline: none;
|
|
||||||
line-height: 1.3;
|
|
||||||
letter-spacing: -0.02em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.poll-title-input::placeholder { color: var(--text-muted); }
|
|
||||||
|
|
||||||
/* ── Add Option ────────────────────────────────────────── */
|
|
||||||
.add-option-wrapper {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.625rem;
|
|
||||||
padding: 1.25rem 1.75rem;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-option-input {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
height: 2.5rem;
|
|
||||||
padding: 0 0.875rem;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: var(--text-primary);
|
|
||||||
background: var(--bg);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
outline: none;
|
|
||||||
transition: border-color 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-option-input::placeholder { color: var(--text-muted); }
|
|
||||||
.add-option-input:focus { border-color: var(--border-focus); }
|
|
||||||
|
|
||||||
.add-option-input.shake {
|
|
||||||
animation: shake 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes shake {
|
|
||||||
0%, 100% { transform: translateX(0); }
|
|
||||||
25% { transform: translateX(-4px); }
|
|
||||||
75% { transform: translateX(4px); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-option-btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.375rem;
|
|
||||||
height: 2.5rem;
|
|
||||||
padding: 0 1rem;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--accent-text);
|
|
||||||
background: var(--accent);
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
transition: opacity 0.15s;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-option-btn:hover { opacity: 0.85; }
|
|
||||||
.add-option-btn:active { opacity: 0.7; }
|
|
||||||
|
|
||||||
.add-option-feedback {
|
|
||||||
width: 100%;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--danger);
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Deadline Timer ────────────────────────────────────── */
|
|
||||||
.deadline-wrapper {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 0.75rem 1.75rem;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.deadline-timer {
|
|
||||||
flex: 1;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.deadline-timer--active {
|
|
||||||
color: var(--success);
|
|
||||||
}
|
|
||||||
|
|
||||||
.deadline-timer--closed {
|
|
||||||
color: var(--danger);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.deadline-btn {
|
|
||||||
height: 2rem;
|
|
||||||
padding: 0 0.875rem;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
transition: all 0.15s;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.deadline-btn:hover {
|
|
||||||
border-color: var(--accent);
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.deadline-btn--clear:hover {
|
|
||||||
border-color: var(--danger);
|
|
||||||
color: var(--danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Poll List ─────────────────────────────────────────── */
|
|
||||||
.poll-list-wrapper {
|
|
||||||
padding: 0.5rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.poll-list-meta {
|
|
||||||
padding: 0.5rem 1.75rem 0.75rem;
|
|
||||||
font-size: 0.775rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.poll-list-empty {
|
|
||||||
padding: 3rem 1.75rem;
|
|
||||||
text-align: center;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-icon {
|
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Poll Option ───────────────────────────────────────── */
|
|
||||||
.poll-option {
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
transition: background 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.poll-option:hover {
|
|
||||||
background: var(--surface-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.poll-option__bar {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0 auto 0 0;
|
|
||||||
background: var(--vote-bar);
|
|
||||||
transition: width 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.poll-option--voted .poll-option__bar {
|
|
||||||
background: var(--vote-bar-voted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.poll-option__content {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 0.875rem 1.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.poll-option__name {
|
|
||||||
flex: 1;
|
|
||||||
font-size: 0.9375rem;
|
|
||||||
font-weight: 400;
|
|
||||||
color: var(--text-primary);
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
.poll-option--voted .poll-option__name {
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.poll-option__actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.625rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.poll-option__pct {
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
min-width: 2.5rem;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.poll-option__count {
|
|
||||||
font-size: 0.775rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
min-width: 3.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.poll-option__vote-btn {
|
|
||||||
height: 1.875rem;
|
|
||||||
padding: 0 0.875rem;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
font-weight: 500;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
transition: all 0.15s;
|
|
||||||
white-space: nowrap;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.poll-option__vote-btn:hover:not(:disabled) {
|
|
||||||
border-color: var(--accent);
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.poll-option__vote-btn:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
.poll-option--voted .poll-option__vote-btn {
|
|
||||||
background: var(--accent);
|
|
||||||
color: var(--accent-text);
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.poll-option--voted .poll-option__vote-btn:hover:not(:disabled) {
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.poll-option__delete-btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 1.625rem;
|
|
||||||
height: 1.625rem;
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
color: var(--text-muted);
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.15s, color 0.15s, background 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.poll-option:hover .poll-option__delete-btn { opacity: 1; }
|
|
||||||
.poll-option__delete-btn:hover {
|
|
||||||
color: var(--danger);
|
|
||||||
background: rgba(192, 57, 43, 0.07);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Footer ────────────────────────────────────────────── */
|
|
||||||
.app-footer {
|
|
||||||
padding: 0 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Share Section ─────────────────────────────────────── */
|
|
||||||
.share-section {
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
box-shadow: var(--shadow-card);
|
|
||||||
padding: 1.25rem 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.share-label {
|
|
||||||
font-size: 0.775rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-muted);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
margin-bottom: 0.625rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.share-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.625rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.share-url {
|
|
||||||
flex: 1;
|
|
||||||
font-family: 'DM Mono', 'Fira Mono', monospace;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
background: var(--bg);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
display: block;
|
|
||||||
user-select: all;
|
|
||||||
}
|
|
||||||
|
|
||||||
.share-copy-btn {
|
|
||||||
height: 2rem;
|
|
||||||
padding: 0 0.875rem;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
transition: all 0.15s;
|
|
||||||
white-space: nowrap;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.share-copy-btn:hover {
|
|
||||||
border-color: var(--accent);
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.share-copy-btn--success {
|
|
||||||
color: var(--success) !important;
|
|
||||||
border-color: var(--success) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Responsive ────────────────────────────────────────── */
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
#app { padding: 1rem 0.75rem 3rem; }
|
|
||||||
|
|
||||||
.poll-title-wrapper { padding: 1.25rem 1.25rem 1rem; }
|
|
||||||
.add-option-wrapper { padding: 1rem 1.25rem; }
|
|
||||||
.poll-option__content { padding: 0.875rem 1.25rem; }
|
|
||||||
.poll-list-meta { padding: 0.5rem 1.25rem 0.625rem; }
|
|
||||||
.poll-list-empty { padding: 2.5rem 1.25rem; }
|
|
||||||
.deadline-wrapper { padding: 0.75rem 1.25rem; }
|
|
||||||
|
|
||||||
.poll-option__count { display: none; }
|
|
||||||
|
|
||||||
.share-section { padding: 1rem 1.25rem; }
|
|
||||||
}
|
|
||||||
79
src/sync.ts
79
src/sync.ts
@@ -1,79 +0,0 @@
|
|||||||
import { IndexeddbPersistence } from "y-indexeddb";
|
|
||||||
import { WebrtcProvider } from "y-webrtc";
|
|
||||||
import * as Y from "yjs";
|
|
||||||
|
|
||||||
import type { ConnectionStatus, OptionRecord } from "./state";
|
|
||||||
|
|
||||||
export interface AppSync {
|
|
||||||
doc: Y.Doc;
|
|
||||||
yTitle: Y.Text;
|
|
||||||
options: Y.Map<OptionRecord>;
|
|
||||||
votes: Y.Map<string>;
|
|
||||||
deadlineMap: Y.Map<unknown>;
|
|
||||||
provider: WebrtcProvider;
|
|
||||||
persistence: IndexeddbPersistence;
|
|
||||||
getConnectionStatus: () => ConnectionStatus;
|
|
||||||
getPeerCount: () => number;
|
|
||||||
destroy: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function initSync(roomId: string): AppSync {
|
|
||||||
const doc = new Y.Doc();
|
|
||||||
const yTitle = doc.getText("poll-title");
|
|
||||||
const options = doc.getMap<OptionRecord>("poll-options");
|
|
||||||
const votes = doc.getMap<string>("poll-votes");
|
|
||||||
const deadlineMap = doc.getMap<unknown>("poll-deadline");
|
|
||||||
|
|
||||||
let connectionStatus: ConnectionStatus = navigator.onLine
|
|
||||||
? "connecting"
|
|
||||||
: "offline";
|
|
||||||
|
|
||||||
const provider = new WebrtcProvider(roomId, doc,{
|
|
||||||
signaling: ["ws://localhost:4444", "ws://lynxpi.ddns.net:4444"]
|
|
||||||
});
|
|
||||||
const persistence = new IndexeddbPersistence(roomId, doc);
|
|
||||||
|
|
||||||
const syncConnectionStatus = (status: ConnectionStatus) => {
|
|
||||||
connectionStatus = navigator.onLine ? status : "offline";
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOnline = () => {
|
|
||||||
syncConnectionStatus(provider.connected ? "connected" : "connecting");
|
|
||||||
};
|
|
||||||
const handleOffline = () => {
|
|
||||||
connectionStatus = "offline";
|
|
||||||
};
|
|
||||||
|
|
||||||
provider.on("status", (event: { connected: boolean }) => {
|
|
||||||
syncConnectionStatus(event.connected ? "connected" : "connecting");
|
|
||||||
});
|
|
||||||
|
|
||||||
provider.on("synced", ({ synced }: { synced: boolean }) => {
|
|
||||||
if (synced) syncConnectionStatus("connected");
|
|
||||||
});
|
|
||||||
|
|
||||||
window.addEventListener("online", handleOnline);
|
|
||||||
window.addEventListener("offline", handleOffline);
|
|
||||||
|
|
||||||
return {
|
|
||||||
doc,
|
|
||||||
yTitle,
|
|
||||||
options,
|
|
||||||
votes,
|
|
||||||
deadlineMap,
|
|
||||||
provider,
|
|
||||||
persistence,
|
|
||||||
getConnectionStatus: () => connectionStatus,
|
|
||||||
getPeerCount: () => {
|
|
||||||
const total = provider.awareness.getStates().size;
|
|
||||||
return Math.max(0, total - 1);
|
|
||||||
},
|
|
||||||
destroy: () => {
|
|
||||||
window.removeEventListener("online", handleOnline);
|
|
||||||
window.removeEventListener("offline", handleOffline);
|
|
||||||
persistence.destroy();
|
|
||||||
provider.destroy();
|
|
||||||
doc.destroy();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
import { IndexeddbPersistence } from "y-indexeddb";
|
|
||||||
import { WebrtcProvider } from "y-webrtc";
|
|
||||||
import * as Y from "yjs";
|
|
||||||
|
|
||||||
import type { ConnectionStatus, OptionRecord } from "./state";
|
|
||||||
|
|
||||||
export interface UserSync {
|
|
||||||
doc: Y.Doc;
|
|
||||||
users: Y.Map<string>;
|
|
||||||
provider: WebrtcProvider;
|
|
||||||
persistence: IndexeddbPersistence;
|
|
||||||
getConnectionStatus: () => ConnectionStatus;
|
|
||||||
getPeerCount: () => number;
|
|
||||||
destroy: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function initUserSync(): UserSync {
|
|
||||||
const doc = new Y.Doc();
|
|
||||||
const users = doc.getMap<string>("users");
|
|
||||||
|
|
||||||
let connectionStatus: ConnectionStatus = navigator.onLine
|
|
||||||
? "connecting"
|
|
||||||
: "offline";
|
|
||||||
|
|
||||||
const provider = new WebrtcProvider("users", doc,{
|
|
||||||
signaling: ["ws://localhost:4444", "ws://lynxpi.ddns.net:4444"]
|
|
||||||
});
|
|
||||||
const persistence = new IndexeddbPersistence("users", doc);
|
|
||||||
|
|
||||||
const syncConnectionStatus = (status: ConnectionStatus) => {
|
|
||||||
connectionStatus = navigator.onLine ? status : "offline";
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOnline = () => {
|
|
||||||
syncConnectionStatus(provider.connected ? "connected" : "connecting");
|
|
||||||
};
|
|
||||||
const handleOffline = () => {
|
|
||||||
connectionStatus = "offline";
|
|
||||||
};
|
|
||||||
|
|
||||||
provider.on("status", (event: { connected: boolean }) => {
|
|
||||||
syncConnectionStatus(event.connected ? "connected" : "connecting");
|
|
||||||
});
|
|
||||||
|
|
||||||
provider.on("synced", ({ synced }: { synced: boolean }) => {
|
|
||||||
if (synced) syncConnectionStatus("connected");
|
|
||||||
});
|
|
||||||
|
|
||||||
window.addEventListener("online", handleOnline);
|
|
||||||
window.addEventListener("offline", handleOffline);
|
|
||||||
|
|
||||||
return {
|
|
||||||
doc,
|
|
||||||
users,
|
|
||||||
provider,
|
|
||||||
persistence,
|
|
||||||
getConnectionStatus: () => connectionStatus,
|
|
||||||
getPeerCount: () => {
|
|
||||||
const total = provider.awareness.getStates().size;
|
|
||||||
return Math.max(0, total - 1);
|
|
||||||
},
|
|
||||||
destroy: () => {
|
|
||||||
window.removeEventListener("online", handleOnline);
|
|
||||||
window.removeEventListener("offline", handleOffline);
|
|
||||||
persistence.destroy();
|
|
||||||
provider.destroy();
|
|
||||||
doc.destroy();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import * as Y from "yjs";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Enforces append-only logic on a Y.Map.
|
|
||||||
* Reverts any 'update' or 'delete' actions detected in the observer.
|
|
||||||
*/
|
|
||||||
export function enforceAppendOnly(yMap: Y.Map<any>,update: (update : { [x: string]: any; }) => void, render: () => void) {
|
|
||||||
return (event: Y.YMapEvent<any>, transaction: Y.Transaction) => {
|
|
||||||
var isOperationIllegal = false
|
|
||||||
event.keys.forEach((change, key) => {
|
|
||||||
const { action, oldValue } = change;
|
|
||||||
|
|
||||||
if (action === 'update' || action === 'delete') {
|
|
||||||
isOperationIllegal = true
|
|
||||||
console.log("Illegal Operation: "+action)
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if(!isOperationIllegal) {
|
|
||||||
console.log("Updating Map!")
|
|
||||||
update(yMap.toJSON())
|
|
||||||
render();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2022",
|
|
||||||
"module": "ESNext",
|
|
||||||
"moduleResolution": "Bundler",
|
|
||||||
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
|
||||||
"strict": true,
|
|
||||||
"noEmit": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"allowSyntheticDefaultImports": true,
|
|
||||||
"useDefineForClassFields": true
|
|
||||||
},
|
|
||||||
"include": ["src"]
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user