Compare commits
1 Commits
group-af5e
...
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
|
|
||||||
38
README.md
38
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>
|
|
||||||
1473
package-lock.json
generated
1473
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
20
package.json
20
package.json
@@ -1,20 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "polly-p2p-poll",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite",
|
|
||||||
"build": "tsc --noEmit && vite build",
|
|
||||||
"preview": "vite preview"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"y-indexeddb": "^9.0.12",
|
|
||||||
"y-webrtc": "^10.3.0",
|
|
||||||
"yjs": "^13.6.27"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"typescript": "^5.9.2",
|
|
||||||
"vite": "^7.1.5"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
139
src/app.ts
139
src/app.ts
@@ -1,139 +0,0 @@
|
|||||||
import { getUserId } from "./identity";
|
|
||||||
import {
|
|
||||||
addOption,
|
|
||||||
toggleVote,
|
|
||||||
deleteOption,
|
|
||||||
setDeadline,
|
|
||||||
clearDeadline,
|
|
||||||
createViewModel,
|
|
||||||
} from "./state";
|
|
||||||
import { initSync } from "./sync";
|
|
||||||
import { StatusBar } from "./components/StatusBar";
|
|
||||||
import { PollTitle } from "./components/PollTitle";
|
|
||||||
import { AddOption } from "./components/AddOption";
|
|
||||||
import { PollList } from "./components/PollList";
|
|
||||||
import { ShareSection } from "./components/ShareSection";
|
|
||||||
import { DeadlineTimer } from "./components/DeadlineTimer";
|
|
||||||
|
|
||||||
const ROOM_PARAM = "room";
|
|
||||||
|
|
||||||
function createRoomId(): string {
|
|
||||||
if (typeof crypto.randomUUID === "function") {
|
|
||||||
return `poll-${crypto.randomUUID().slice(0, 8)}`;
|
|
||||||
}
|
|
||||||
return `poll-${Math.random().toString(36).slice(2, 10)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureRoomId(): string {
|
|
||||||
const url = new URL(window.location.href);
|
|
||||||
let roomId = url.searchParams.get(ROOM_PARAM)?.trim();
|
|
||||||
|
|
||||||
if (!roomId) {
|
|
||||||
roomId = createRoomId();
|
|
||||||
url.searchParams.set(ROOM_PARAM, roomId);
|
|
||||||
window.history.replaceState({}, "", url);
|
|
||||||
}
|
|
||||||
|
|
||||||
return roomId;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function initApp(container: HTMLElement): () => void {
|
|
||||||
const roomId = ensureRoomId();
|
|
||||||
const userId = getUserId();
|
|
||||||
const sync = initSync(roomId);
|
|
||||||
|
|
||||||
const shareUrl = window.location.href;
|
|
||||||
|
|
||||||
// --- Actions ---
|
|
||||||
|
|
||||||
const actions = {
|
|
||||||
addOption: (label: string) => {
|
|
||||||
const vm = createViewModel(getViewModelParams());
|
|
||||||
if (vm.votingClosed) return;
|
|
||||||
return addOption(sync.options, label, userId);
|
|
||||||
},
|
|
||||||
toggleVote: (optionId: string) => {
|
|
||||||
const vm = createViewModel(getViewModelParams());
|
|
||||||
if (vm.votingClosed) return;
|
|
||||||
toggleVote(sync.votes, userId, optionId);
|
|
||||||
},
|
|
||||||
deleteOption: (optionId: string) => {
|
|
||||||
deleteOption(sync.options, sync.votes, optionId);
|
|
||||||
},
|
|
||||||
startDeadline: (durationMs: number) => {
|
|
||||||
setDeadline(sync.deadlineMap, durationMs);
|
|
||||||
},
|
|
||||||
clearDeadline: () => {
|
|
||||||
clearDeadline(sync.deadlineMap);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
function getViewModelParams() {
|
|
||||||
return {
|
|
||||||
yTitle: sync.yTitle,
|
|
||||||
options: sync.options,
|
|
||||||
votes: sync.votes,
|
|
||||||
deadlineMap: sync.deadlineMap,
|
|
||||||
roomId,
|
|
||||||
shareUrl,
|
|
||||||
connectionStatus: sync.getConnectionStatus(),
|
|
||||||
peerCount: sync.getPeerCount(),
|
|
||||||
userId,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Build UI ---
|
|
||||||
|
|
||||||
// Header
|
|
||||||
const header = document.createElement("header");
|
|
||||||
header.className = "app-header";
|
|
||||||
|
|
||||||
const wordmark = document.createElement("div");
|
|
||||||
wordmark.className = "app-wordmark";
|
|
||||||
wordmark.innerHTML = `
|
|
||||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
|
||||||
<rect x="2" y="4" width="16" height="2.5" rx="1.25" fill="currentColor"/>
|
|
||||||
<rect x="2" y="8.75" width="11" height="2.5" rx="1.25" fill="currentColor" opacity="0.6"/>
|
|
||||||
<rect x="2" y="13.5" width="13" height="2.5" rx="1.25" fill="currentColor" opacity="0.35"/>
|
|
||||||
</svg>
|
|
||||||
<span>Polly</span>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const statusBar = StatusBar(sync.provider);
|
|
||||||
header.append(wordmark, statusBar);
|
|
||||||
|
|
||||||
// Main card
|
|
||||||
const card = document.createElement("main");
|
|
||||||
card.className = "app-card";
|
|
||||||
|
|
||||||
const pollTitle = PollTitle(sync.doc, sync.yTitle);
|
|
||||||
const addOptionComponent = AddOption((label: string) => {
|
|
||||||
const result = actions.addOption(label);
|
|
||||||
if (result && !result.ok) return result.error;
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
const pollList = PollList(sync.options, sync.votes, userId, () => {
|
|
||||||
const vm = createViewModel(getViewModelParams());
|
|
||||||
return vm.votingClosed;
|
|
||||||
}, actions.toggleVote, actions.deleteOption);
|
|
||||||
const deadlineTimer = DeadlineTimer(
|
|
||||||
sync.deadlineMap,
|
|
||||||
actions.startDeadline,
|
|
||||||
actions.clearDeadline,
|
|
||||||
);
|
|
||||||
|
|
||||||
card.append(pollTitle, addOptionComponent, deadlineTimer, pollList);
|
|
||||||
|
|
||||||
// Footer
|
|
||||||
const footer = document.createElement("footer");
|
|
||||||
footer.className = "app-footer";
|
|
||||||
footer.appendChild(ShareSection(roomId));
|
|
||||||
|
|
||||||
container.append(header, card, footer);
|
|
||||||
|
|
||||||
// --- Cleanup ---
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
sync.destroy();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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,86 +0,0 @@
|
|||||||
import * as Y from "yjs";
|
|
||||||
import { getDeadline } from "../state";
|
|
||||||
|
|
||||||
const DEADLINE_DURATION_MS = 2 * 60 * 1000; // 2 minutes
|
|
||||||
|
|
||||||
export function DeadlineTimer(
|
|
||||||
deadlineMap: Y.Map<unknown>,
|
|
||||||
onStartDeadline: (durationMs: number) => void,
|
|
||||||
onClearDeadline: () => void,
|
|
||||||
): HTMLElement {
|
|
||||||
const wrapper = document.createElement("div");
|
|
||||||
wrapper.className = "deadline-wrapper";
|
|
||||||
|
|
||||||
const timerEl = document.createElement("span");
|
|
||||||
timerEl.className = "deadline-timer";
|
|
||||||
|
|
||||||
const startBtn = document.createElement("button");
|
|
||||||
startBtn.className = "deadline-btn";
|
|
||||||
startBtn.textContent = "Start 2-min vote";
|
|
||||||
startBtn.setAttribute("aria-label", "Start a 2-minute voting deadline");
|
|
||||||
|
|
||||||
const clearBtn = document.createElement("button");
|
|
||||||
clearBtn.className = "deadline-btn deadline-btn--clear";
|
|
||||||
clearBtn.textContent = "Clear";
|
|
||||||
clearBtn.setAttribute("aria-label", "Remove voting deadline");
|
|
||||||
|
|
||||||
wrapper.append(timerEl, startBtn, clearBtn);
|
|
||||||
|
|
||||||
let interval: ReturnType<typeof setInterval> | undefined;
|
|
||||||
|
|
||||||
function render() {
|
|
||||||
const deadline = getDeadline(deadlineMap);
|
|
||||||
const now = Date.now();
|
|
||||||
|
|
||||||
if (deadline === null) {
|
|
||||||
// No deadline set
|
|
||||||
timerEl.textContent = "";
|
|
||||||
timerEl.className = "deadline-timer";
|
|
||||||
startBtn.hidden = false;
|
|
||||||
clearBtn.hidden = true;
|
|
||||||
if (interval) {
|
|
||||||
clearInterval(interval);
|
|
||||||
interval = undefined;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
startBtn.hidden = true;
|
|
||||||
clearBtn.hidden = false;
|
|
||||||
|
|
||||||
if (now >= deadline) {
|
|
||||||
// Voting closed
|
|
||||||
timerEl.textContent = "Voting closed";
|
|
||||||
timerEl.className = "deadline-timer deadline-timer--closed";
|
|
||||||
if (interval) {
|
|
||||||
clearInterval(interval);
|
|
||||||
interval = undefined;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Counting down
|
|
||||||
const remaining = Math.ceil((deadline - now) / 1000);
|
|
||||||
const mins = Math.floor(remaining / 60);
|
|
||||||
const secs = remaining % 60;
|
|
||||||
timerEl.textContent = `Voting closes in ${mins}:${secs.toString().padStart(2, "0")}`;
|
|
||||||
timerEl.className = "deadline-timer deadline-timer--active";
|
|
||||||
|
|
||||||
if (!interval) {
|
|
||||||
interval = setInterval(() => render(), 1000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
startBtn.addEventListener("click", () => {
|
|
||||||
onStartDeadline(DEADLINE_DURATION_MS);
|
|
||||||
});
|
|
||||||
|
|
||||||
clearBtn.addEventListener("click", () => {
|
|
||||||
onClearDeadline();
|
|
||||||
});
|
|
||||||
|
|
||||||
deadlineMap.observe(() => render());
|
|
||||||
render();
|
|
||||||
|
|
||||||
return wrapper;
|
|
||||||
}
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
import * as Y from "yjs";
|
|
||||||
import type { OptionRecord } from "../state";
|
|
||||||
import { PollOption } from "./PollOption";
|
|
||||||
|
|
||||||
export function PollList(
|
|
||||||
yOptions: Y.Map<OptionRecord>,
|
|
||||||
yVotes: Y.Map<string>,
|
|
||||||
userId: string,
|
|
||||||
isVotingClosed: () => boolean,
|
|
||||||
onVote: (optionId: string) => void,
|
|
||||||
onDelete: (optionId: string) => void,
|
|
||||||
): HTMLElement {
|
|
||||||
const wrapper = document.createElement("div");
|
|
||||||
wrapper.className = "poll-list-wrapper";
|
|
||||||
|
|
||||||
const meta = document.createElement("div");
|
|
||||||
meta.className = "poll-list-meta";
|
|
||||||
|
|
||||||
const list = document.createElement("div");
|
|
||||||
list.className = "poll-list";
|
|
||||||
|
|
||||||
const empty = document.createElement("div");
|
|
||||||
empty.className = "poll-list-empty";
|
|
||||||
empty.innerHTML = `
|
|
||||||
<div class="empty-icon">
|
|
||||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<rect x="4" y="10" width="24" height="3" rx="1.5" fill="currentColor" opacity="0.15"/>
|
|
||||||
<rect x="4" y="16" width="18" height="3" rx="1.5" fill="currentColor" opacity="0.1"/>
|
|
||||||
<rect x="4" y="22" width="21" height="3" rx="1.5" fill="currentColor" opacity="0.07"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<p>No options yet — add the first one above.</p>
|
|
||||||
`;
|
|
||||||
|
|
||||||
wrapper.append(meta, list, empty);
|
|
||||||
|
|
||||||
function getEntries() {
|
|
||||||
const entries: Array<{
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
votes: number;
|
|
||||||
voted: boolean;
|
|
||||||
}> = [];
|
|
||||||
|
|
||||||
// Tally votes per option
|
|
||||||
const tally = new Map<string, number>();
|
|
||||||
for (const optionId of yVotes.values()) {
|
|
||||||
tally.set(optionId, (tally.get(optionId) ?? 0) + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const myVote = yVotes.get(userId) ?? null;
|
|
||||||
|
|
||||||
yOptions.forEach((record, id) => {
|
|
||||||
entries.push({
|
|
||||||
id,
|
|
||||||
name: record.label,
|
|
||||||
votes: tally.get(id) ?? 0,
|
|
||||||
voted: myVote === id,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
entries.sort((a, b) => b.votes - a.votes || a.name.localeCompare(b.name));
|
|
||||||
return entries;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getTotalVotes(): number {
|
|
||||||
return yVotes.size;
|
|
||||||
}
|
|
||||||
|
|
||||||
function render() {
|
|
||||||
const entries = getEntries();
|
|
||||||
const total = getTotalVotes();
|
|
||||||
const votingClosed = isVotingClosed();
|
|
||||||
|
|
||||||
// Meta line
|
|
||||||
if (entries.length > 0) {
|
|
||||||
meta.textContent = `${entries.length} option${entries.length !== 1 ? "s" : ""} \u00b7 ${total} vote${total !== 1 ? "s" : ""} total`;
|
|
||||||
meta.style.display = "";
|
|
||||||
} else {
|
|
||||||
meta.style.display = "none";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Empty state
|
|
||||||
empty.style.display = entries.length === 0 ? "" : "none";
|
|
||||||
|
|
||||||
// Diff-render: reuse existing rows when possible
|
|
||||||
const existing = new Map(
|
|
||||||
[...list.querySelectorAll<HTMLElement>(".poll-option")].map((el) => [
|
|
||||||
el.dataset.id,
|
|
||||||
el,
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Remove stale rows
|
|
||||||
existing.forEach((el, id) => {
|
|
||||||
if (!entries.find((e) => e.id === id)) el.remove();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update or insert rows in sorted order
|
|
||||||
entries.forEach((entry, i) => {
|
|
||||||
const newEl = PollOption({
|
|
||||||
...entry,
|
|
||||||
totalVotes: total,
|
|
||||||
votingClosed,
|
|
||||||
onVote,
|
|
||||||
onDelete,
|
|
||||||
});
|
|
||||||
const currentEl = list.children[i] as HTMLElement | undefined;
|
|
||||||
|
|
||||||
if (!currentEl) {
|
|
||||||
list.appendChild(newEl);
|
|
||||||
} else if (currentEl.dataset.id !== entry.id) {
|
|
||||||
list.insertBefore(newEl, currentEl);
|
|
||||||
const old = existing.get(entry.id);
|
|
||||||
if (old && old !== currentEl) old.remove();
|
|
||||||
} else {
|
|
||||||
list.replaceChild(newEl, currentEl);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
yOptions.observeDeep(() => render());
|
|
||||||
yVotes.observe(() => render());
|
|
||||||
render();
|
|
||||||
|
|
||||||
return wrapper;
|
|
||||||
}
|
|
||||||
@@ -1,46 +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;
|
|
||||||
onDelete: (id: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PollOption(props: PollOptionProps): HTMLElement {
|
|
||||||
const { id, name, votes, voted, totalVotes, votingClosed, onVote, onDelete } = props;
|
|
||||||
|
|
||||||
const row = document.createElement("div");
|
|
||||||
row.className = `poll-option${voted ? " poll-option--voted" : ""}`;
|
|
||||||
row.dataset.id = id;
|
|
||||||
|
|
||||||
const pct = totalVotes > 0 ? Math.round((votes / totalVotes) * 100) : 0;
|
|
||||||
|
|
||||||
row.innerHTML = `
|
|
||||||
<div class="poll-option__bar" style="width: ${pct}%"></div>
|
|
||||||
<div class="poll-option__content">
|
|
||||||
<span class="poll-option__name">${escapeHtml(name)}</span>
|
|
||||||
<div class="poll-option__actions">
|
|
||||||
<span class="poll-option__pct">${pct}%</span>
|
|
||||||
<span class="poll-option__count">${votes} vote${votes !== 1 ? "s" : ""}</span>
|
|
||||||
<button class="poll-option__vote-btn" aria-pressed="${voted}"${votingClosed ? " disabled" : ""}>
|
|
||||||
${voted ? "Voted" : "Vote"}
|
|
||||||
</button>
|
|
||||||
<button class="poll-option__delete-btn" aria-label="Remove option">
|
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M2 2l10 10M12 2L2 12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
row.querySelector(".poll-option__vote-btn")!.addEventListener("click", () => onVote(id));
|
|
||||||
row.querySelector(".poll-option__delete-btn")!.addEventListener("click", () => onDelete(id));
|
|
||||||
|
|
||||||
return row;
|
|
||||||
}
|
|
||||||
@@ -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,67 +0,0 @@
|
|||||||
import type { WebrtcProvider } from "y-webrtc";
|
|
||||||
|
|
||||||
export function StatusBar(provider: WebrtcProvider): HTMLElement {
|
|
||||||
const el = document.createElement("div");
|
|
||||||
el.className = "status-bar";
|
|
||||||
|
|
||||||
const dot = document.createElement("span");
|
|
||||||
dot.className = "status-dot connecting";
|
|
||||||
|
|
||||||
const statusText = document.createElement("span");
|
|
||||||
statusText.className = "status-text";
|
|
||||||
statusText.textContent = "Connecting";
|
|
||||||
|
|
||||||
const divider = document.createElement("span");
|
|
||||||
divider.className = "status-divider";
|
|
||||||
divider.textContent = "\u00b7";
|
|
||||||
|
|
||||||
const peerText = document.createElement("span");
|
|
||||||
peerText.className = "status-peers";
|
|
||||||
|
|
||||||
el.append(dot, statusText, divider, peerText);
|
|
||||||
|
|
||||||
// --- Connection state ---
|
|
||||||
|
|
||||||
let syncTimeout: ReturnType<typeof setTimeout> | undefined = setTimeout(() => {
|
|
||||||
statusText.textContent = "Ready";
|
|
||||||
dot.className = "status-dot ready";
|
|
||||||
}, 3000);
|
|
||||||
|
|
||||||
provider.on("synced", ({ synced }: { synced: boolean }) => {
|
|
||||||
if (syncTimeout) {
|
|
||||||
clearTimeout(syncTimeout);
|
|
||||||
syncTimeout = undefined;
|
|
||||||
}
|
|
||||||
dot.className = `status-dot ${synced ? "connected" : "connecting"}`;
|
|
||||||
statusText.textContent = synced ? "Connected" : "Connecting";
|
|
||||||
});
|
|
||||||
|
|
||||||
// Online/offline awareness
|
|
||||||
const handleOffline = () => {
|
|
||||||
dot.className = "status-dot connecting";
|
|
||||||
statusText.textContent = "Offline";
|
|
||||||
};
|
|
||||||
const handleOnline = () => {
|
|
||||||
dot.className = "status-dot connecting";
|
|
||||||
statusText.textContent = "Reconnecting";
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("offline", handleOffline);
|
|
||||||
window.addEventListener("online", handleOnline);
|
|
||||||
|
|
||||||
// --- Peer count ---
|
|
||||||
|
|
||||||
function updatePeerCount() {
|
|
||||||
const total = provider.awareness.getStates().size;
|
|
||||||
const others = total - 1;
|
|
||||||
peerText.textContent =
|
|
||||||
others === 0
|
|
||||||
? "Only you"
|
|
||||||
: `${others} other${others !== 1 ? "s" : ""}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
provider.awareness.on("change", updatePeerCount);
|
|
||||||
updatePeerCount();
|
|
||||||
|
|
||||||
return el;
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
207
src/state.ts
207
src/state.ts
@@ -1,207 +0,0 @@
|
|||||||
import * as Y from "yjs";
|
|
||||||
|
|
||||||
// --- Types ---
|
|
||||||
|
|
||||||
export type ConnectionStatus = "connecting" | "connected" | "offline";
|
|
||||||
|
|
||||||
export interface OptionRecord {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
createdAt: number;
|
|
||||||
createdBy: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PollOptionViewModel extends OptionRecord {
|
|
||||||
voteCount: number;
|
|
||||||
isVotedByMe: boolean;
|
|
||||||
percentage: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PollViewModel {
|
|
||||||
title: string;
|
|
||||||
roomId: string;
|
|
||||||
shareUrl: string;
|
|
||||||
connectionStatus: ConnectionStatus;
|
|
||||||
peerCount: number;
|
|
||||||
options: PollOptionViewModel[];
|
|
||||||
totalVotes: number;
|
|
||||||
myVoteOptionId: string | null;
|
|
||||||
deadline: number | null;
|
|
||||||
votingClosed: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Helpers ---
|
|
||||||
|
|
||||||
export function createOptionId(): string {
|
|
||||||
if (typeof crypto.randomUUID === "function") {
|
|
||||||
return crypto.randomUUID();
|
|
||||||
}
|
|
||||||
return `option-${Math.random().toString(36).slice(2, 10)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeLabel(label: string): string {
|
|
||||||
return label.trim().replace(/\s+/g, " ");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function escapeHtml(value: string): string {
|
|
||||||
return value
|
|
||||||
.replaceAll("&", "&")
|
|
||||||
.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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function deleteOption(
|
|
||||||
options: Y.Map<OptionRecord>,
|
|
||||||
votes: Y.Map<string>,
|
|
||||||
optionId: string,
|
|
||||||
): void {
|
|
||||||
options.delete(optionId);
|
|
||||||
// Clean up votes pointing to this option
|
|
||||||
for (const [userId, votedOptionId] of votes.entries()) {
|
|
||||||
if (votedOptionId === optionId) {
|
|
||||||
votes.delete(userId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Deadline ---
|
|
||||||
|
|
||||||
export function setDeadline(
|
|
||||||
deadlineMap: Y.Map<unknown>,
|
|
||||||
durationMs: number,
|
|
||||||
): void {
|
|
||||||
deadlineMap.set("deadline", Date.now() + durationMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clearDeadline(deadlineMap: Y.Map<unknown>): void {
|
|
||||||
deadlineMap.delete("deadline");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getDeadline(deadlineMap: Y.Map<unknown>): number | null {
|
|
||||||
const val = deadlineMap.get("deadline");
|
|
||||||
return typeof val === "number" ? val : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- ViewModel ---
|
|
||||||
|
|
||||||
export function createViewModel(params: {
|
|
||||||
yTitle: Y.Text;
|
|
||||||
options: Y.Map<OptionRecord>;
|
|
||||||
votes: Y.Map<string>;
|
|
||||||
deadlineMap: Y.Map<unknown>;
|
|
||||||
roomId: string;
|
|
||||||
shareUrl: string;
|
|
||||||
connectionStatus: ConnectionStatus;
|
|
||||||
peerCount: number;
|
|
||||||
userId: string;
|
|
||||||
}): PollViewModel {
|
|
||||||
const {
|
|
||||||
yTitle,
|
|
||||||
options,
|
|
||||||
votes,
|
|
||||||
deadlineMap,
|
|
||||||
roomId,
|
|
||||||
shareUrl,
|
|
||||||
connectionStatus,
|
|
||||||
peerCount,
|
|
||||||
userId,
|
|
||||||
} = params;
|
|
||||||
|
|
||||||
// Tally votes per option
|
|
||||||
const tally = new Map<string, number>();
|
|
||||||
for (const optionId of votes.values()) {
|
|
||||||
tally.set(optionId, (tally.get(optionId) ?? 0) + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
let totalVotes = 0;
|
|
||||||
for (const count of tally.values()) {
|
|
||||||
totalVotes += count;
|
|
||||||
}
|
|
||||||
|
|
||||||
const myVoteOptionId = votes.get(userId) ?? null;
|
|
||||||
const deadline = getDeadline(deadlineMap);
|
|
||||||
const votingClosed = deadline !== null && Date.now() >= deadline;
|
|
||||||
|
|
||||||
// Sort by votes desc, then alphabetically
|
|
||||||
const sortedOptions = Array.from(options.values()).sort((a, b) => {
|
|
||||||
const aVotes = tally.get(a.id) ?? 0;
|
|
||||||
const bVotes = tally.get(b.id) ?? 0;
|
|
||||||
if (bVotes !== aVotes) return bVotes - aVotes;
|
|
||||||
return a.label.localeCompare(b.label);
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
title: getPollTitle(yTitle),
|
|
||||||
roomId,
|
|
||||||
shareUrl,
|
|
||||||
connectionStatus,
|
|
||||||
peerCount,
|
|
||||||
myVoteOptionId,
|
|
||||||
totalVotes,
|
|
||||||
deadline,
|
|
||||||
votingClosed,
|
|
||||||
options: sortedOptions.map((option) => ({
|
|
||||||
...option,
|
|
||||||
voteCount: tally.get(option.id) ?? 0,
|
|
||||||
isVotedByMe: myVoteOptionId === option.id,
|
|
||||||
percentage:
|
|
||||||
totalVotes > 0
|
|
||||||
? Math.round(((tally.get(option.id) ?? 0) / totalVotes) * 100)
|
|
||||||
: 0,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
469
src/styles.css
469
src/styles.css
@@ -1,469 +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);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-dot {
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
border-radius: 50%;
|
|
||||||
flex-shrink: 0;
|
|
||||||
transition: background 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-dot.connecting { background: var(--text-muted); }
|
|
||||||
.status-dot.ready { background: var(--text-muted); }
|
|
||||||
.status-dot.connected { background: var(--success); }
|
|
||||||
|
|
||||||
.status-divider { color: var(--text-muted); }
|
|
||||||
|
|
||||||
/* ── Card ──────────────────────────────────────────────── */
|
|
||||||
.app-card {
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
box-shadow: var(--shadow-card);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Poll Title ────────────────────────────────────────── */
|
|
||||||
.poll-title-wrapper {
|
|
||||||
padding: 1.75rem 1.75rem 1.25rem;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.poll-title-input {
|
|
||||||
width: 100%;
|
|
||||||
font-family: var(--font-display);
|
|
||||||
font-size: 1.5rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-primary);
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
outline: none;
|
|
||||||
line-height: 1.3;
|
|
||||||
letter-spacing: -0.02em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.poll-title-input::placeholder { color: var(--text-muted); }
|
|
||||||
|
|
||||||
/* ── Add Option ────────────────────────────────────────── */
|
|
||||||
.add-option-wrapper {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.625rem;
|
|
||||||
padding: 1.25rem 1.75rem;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-option-input {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
height: 2.5rem;
|
|
||||||
padding: 0 0.875rem;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: var(--text-primary);
|
|
||||||
background: var(--bg);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
outline: none;
|
|
||||||
transition: border-color 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-option-input::placeholder { color: var(--text-muted); }
|
|
||||||
.add-option-input:focus { border-color: var(--border-focus); }
|
|
||||||
|
|
||||||
.add-option-input.shake {
|
|
||||||
animation: shake 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes shake {
|
|
||||||
0%, 100% { transform: translateX(0); }
|
|
||||||
25% { transform: translateX(-4px); }
|
|
||||||
75% { transform: translateX(4px); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-option-btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.375rem;
|
|
||||||
height: 2.5rem;
|
|
||||||
padding: 0 1rem;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--accent-text);
|
|
||||||
background: var(--accent);
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
transition: opacity 0.15s;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-option-btn:hover { opacity: 0.85; }
|
|
||||||
.add-option-btn:active { opacity: 0.7; }
|
|
||||||
|
|
||||||
.add-option-feedback {
|
|
||||||
width: 100%;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--danger);
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Deadline Timer ────────────────────────────────────── */
|
|
||||||
.deadline-wrapper {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 0.75rem 1.75rem;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.deadline-timer {
|
|
||||||
flex: 1;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.deadline-timer--active {
|
|
||||||
color: var(--success);
|
|
||||||
}
|
|
||||||
|
|
||||||
.deadline-timer--closed {
|
|
||||||
color: var(--danger);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.deadline-btn {
|
|
||||||
height: 2rem;
|
|
||||||
padding: 0 0.875rem;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
transition: all 0.15s;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.deadline-btn:hover {
|
|
||||||
border-color: var(--accent);
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.deadline-btn--clear:hover {
|
|
||||||
border-color: var(--danger);
|
|
||||||
color: var(--danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Poll List ─────────────────────────────────────────── */
|
|
||||||
.poll-list-wrapper {
|
|
||||||
padding: 0.5rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.poll-list-meta {
|
|
||||||
padding: 0.5rem 1.75rem 0.75rem;
|
|
||||||
font-size: 0.775rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.poll-list-empty {
|
|
||||||
padding: 3rem 1.75rem;
|
|
||||||
text-align: center;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-icon {
|
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Poll Option ───────────────────────────────────────── */
|
|
||||||
.poll-option {
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
transition: background 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.poll-option:hover {
|
|
||||||
background: var(--surface-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.poll-option__bar {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0 auto 0 0;
|
|
||||||
background: var(--vote-bar);
|
|
||||||
transition: width 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.poll-option--voted .poll-option__bar {
|
|
||||||
background: var(--vote-bar-voted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.poll-option__content {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 0.875rem 1.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.poll-option__name {
|
|
||||||
flex: 1;
|
|
||||||
font-size: 0.9375rem;
|
|
||||||
font-weight: 400;
|
|
||||||
color: var(--text-primary);
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
.poll-option--voted .poll-option__name {
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.poll-option__actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.625rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.poll-option__pct {
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
min-width: 2.5rem;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.poll-option__count {
|
|
||||||
font-size: 0.775rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
min-width: 3.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.poll-option__vote-btn {
|
|
||||||
height: 1.875rem;
|
|
||||||
padding: 0 0.875rem;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
font-weight: 500;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
transition: all 0.15s;
|
|
||||||
white-space: nowrap;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.poll-option__vote-btn:hover:not(:disabled) {
|
|
||||||
border-color: var(--accent);
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.poll-option__vote-btn:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
.poll-option--voted .poll-option__vote-btn {
|
|
||||||
background: var(--accent);
|
|
||||||
color: var(--accent-text);
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.poll-option--voted .poll-option__vote-btn:hover:not(:disabled) {
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.poll-option__delete-btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 1.625rem;
|
|
||||||
height: 1.625rem;
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
color: var(--text-muted);
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.15s, color 0.15s, background 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.poll-option:hover .poll-option__delete-btn { opacity: 1; }
|
|
||||||
.poll-option__delete-btn:hover {
|
|
||||||
color: var(--danger);
|
|
||||||
background: rgba(192, 57, 43, 0.07);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Footer ────────────────────────────────────────────── */
|
|
||||||
.app-footer {
|
|
||||||
padding: 0 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Share Section ─────────────────────────────────────── */
|
|
||||||
.share-section {
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
box-shadow: var(--shadow-card);
|
|
||||||
padding: 1.25rem 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.share-label {
|
|
||||||
font-size: 0.775rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-muted);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
margin-bottom: 0.625rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.share-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.625rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.share-url {
|
|
||||||
flex: 1;
|
|
||||||
font-family: 'DM Mono', 'Fira Mono', monospace;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
background: var(--bg);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
display: block;
|
|
||||||
user-select: all;
|
|
||||||
}
|
|
||||||
|
|
||||||
.share-copy-btn {
|
|
||||||
height: 2rem;
|
|
||||||
padding: 0 0.875rem;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
transition: all 0.15s;
|
|
||||||
white-space: nowrap;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.share-copy-btn:hover {
|
|
||||||
border-color: var(--accent);
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.share-copy-btn--success {
|
|
||||||
color: var(--success) !important;
|
|
||||||
border-color: var(--success) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Responsive ────────────────────────────────────────── */
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
#app { padding: 1rem 0.75rem 3rem; }
|
|
||||||
|
|
||||||
.poll-title-wrapper { padding: 1.25rem 1.25rem 1rem; }
|
|
||||||
.add-option-wrapper { padding: 1rem 1.25rem; }
|
|
||||||
.poll-option__content { padding: 0.875rem 1.25rem; }
|
|
||||||
.poll-list-meta { padding: 0.5rem 1.25rem 0.625rem; }
|
|
||||||
.poll-list-empty { padding: 2.5rem 1.25rem; }
|
|
||||||
.deadline-wrapper { padding: 0.75rem 1.25rem; }
|
|
||||||
|
|
||||||
.poll-option__count { display: none; }
|
|
||||||
|
|
||||||
.share-section { padding: 1rem 1.25rem; }
|
|
||||||
}
|
|
||||||
77
src/sync.ts
77
src/sync.ts
@@ -1,77 +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);
|
|
||||||
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,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