forked from quic-issues/427e7578-d7bf-49c8-aee9-2dd999e25316
195 lines
5.9 KiB
TypeScript
195 lines
5.9 KiB
TypeScript
import { getUserId } from "./identity";
|
|
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();
|
|
};
|
|
}
|