From a7fbb2fa97a1618168adcede439b61bcab144db1 Mon Sep 17 00:00:00 2001 From: JeanChristophe <46056211+JeanlChristophe@users.noreply.github.com> Date: Wed, 22 Oct 2025 15:50:00 +0200 Subject: [PATCH 01/34] Add homeserver-backed live leaderboard (#2) * Add homeserver-backed live leaderboard * Add root workspace scripts for app dev server --- package.json | 13 + pubky-live-vote/LICENSE | 21 ++ pubky-live-vote/README.md | 93 ++++++ pubky-live-vote/index.html | 12 + pubky-live-vote/package.json | 24 ++ pubky-live-vote/src/App.tsx | 13 + pubky-live-vote/src/components/Layout.css | 71 +++++ pubky-live-vote/src/components/Layout.tsx | 44 +++ .../src/components/Leaderboard.css | 80 ++++++ .../src/components/Leaderboard.tsx | 109 +++++++ pubky-live-vote/src/components/LoginCard.tsx | 54 ++++ pubky-live-vote/src/components/Panel.css | 105 +++++++ .../src/components/PopularVoteBoard.css | 86 ++++++ .../src/components/PopularVoteBoard.tsx | 110 ++++++++ .../src/components/ProjectCard.css | 89 ++++++ .../src/components/ProjectCard.tsx | 88 ++++++ .../src/components/ProjectList.css | 14 + .../src/components/ProjectList.tsx | 33 +++ .../src/components/ScoreSlider.css | 47 +++ .../src/components/ScoreSlider.tsx | 36 +++ .../src/components/SubmissionBanner.css | 27 ++ .../src/components/SubmissionBanner.tsx | 39 +++ pubky-live-vote/src/components/TagInput.css | 82 ++++++ pubky-live-vote/src/components/TagInput.tsx | 97 +++++++ pubky-live-vote/src/context/AuthContext.tsx | 92 ++++++ .../src/context/ProjectContext.tsx | 151 ++++++++++ .../src/hooks/useLiveLeaderboard.ts | 68 +++++ pubky-live-vote/src/index.css | 57 ++++ pubky-live-vote/src/main.tsx | 10 + pubky-live-vote/src/services/cacheQueue.ts | 74 +++++ pubky-live-vote/src/services/homeserverApi.ts | 64 +++++ pubky-live-vote/src/services/leaderboard.ts | 267 ++++++++++++++++++ pubky-live-vote/src/services/pubkyClient.ts | 177 ++++++++++++ .../src/services/sampleProjects.ts | 79 ++++++ pubky-live-vote/src/types/project.ts | 61 ++++ pubky-live-vote/tsconfig.base.json | 15 + pubky-live-vote/tsconfig.json | 8 + pubky-live-vote/tsconfig.node.json | 9 + pubky-live-vote/vite.config.ts | 9 + 39 files changed, 2528 insertions(+) create mode 100644 package.json create mode 100644 pubky-live-vote/LICENSE create mode 100644 pubky-live-vote/README.md create mode 100644 pubky-live-vote/index.html create mode 100644 pubky-live-vote/package.json create mode 100644 pubky-live-vote/src/App.tsx create mode 100644 pubky-live-vote/src/components/Layout.css create mode 100644 pubky-live-vote/src/components/Layout.tsx create mode 100644 pubky-live-vote/src/components/Leaderboard.css create mode 100644 pubky-live-vote/src/components/Leaderboard.tsx create mode 100644 pubky-live-vote/src/components/LoginCard.tsx create mode 100644 pubky-live-vote/src/components/Panel.css create mode 100644 pubky-live-vote/src/components/PopularVoteBoard.css create mode 100644 pubky-live-vote/src/components/PopularVoteBoard.tsx create mode 100644 pubky-live-vote/src/components/ProjectCard.css create mode 100644 pubky-live-vote/src/components/ProjectCard.tsx create mode 100644 pubky-live-vote/src/components/ProjectList.css create mode 100644 pubky-live-vote/src/components/ProjectList.tsx create mode 100644 pubky-live-vote/src/components/ScoreSlider.css create mode 100644 pubky-live-vote/src/components/ScoreSlider.tsx create mode 100644 pubky-live-vote/src/components/SubmissionBanner.css create mode 100644 pubky-live-vote/src/components/SubmissionBanner.tsx create mode 100644 pubky-live-vote/src/components/TagInput.css create mode 100644 pubky-live-vote/src/components/TagInput.tsx create mode 100644 pubky-live-vote/src/context/AuthContext.tsx create mode 100644 pubky-live-vote/src/context/ProjectContext.tsx create mode 100644 pubky-live-vote/src/hooks/useLiveLeaderboard.ts create mode 100644 pubky-live-vote/src/index.css create mode 100644 pubky-live-vote/src/main.tsx create mode 100644 pubky-live-vote/src/services/cacheQueue.ts create mode 100644 pubky-live-vote/src/services/homeserverApi.ts create mode 100644 pubky-live-vote/src/services/leaderboard.ts create mode 100644 pubky-live-vote/src/services/pubkyClient.ts create mode 100644 pubky-live-vote/src/services/sampleProjects.ts create mode 100644 pubky-live-vote/src/types/project.ts create mode 100644 pubky-live-vote/tsconfig.base.json create mode 100644 pubky-live-vote/tsconfig.json create mode 100644 pubky-live-vote/tsconfig.node.json create mode 100644 pubky-live-vote/vite.config.ts diff --git a/package.json b/package.json new file mode 100644 index 0000000..4a836f4 --- /dev/null +++ b/package.json @@ -0,0 +1,13 @@ +{ + "name": "pubky-vote-hackathon-2025", + "private": true, + "workspaces": [ + "pubky-live-vote" + ], + "scripts": { + "dev": "npm run dev --workspace=pubky-live-vote", + "build": "npm run build --workspace=pubky-live-vote", + "preview": "npm run preview --workspace=pubky-live-vote", + "install:app": "npm install --prefix pubky-live-vote" + } +} diff --git a/pubky-live-vote/LICENSE b/pubky-live-vote/LICENSE new file mode 100644 index 0000000..86d572e --- /dev/null +++ b/pubky-live-vote/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Pubky Live Vote contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/pubky-live-vote/README.md b/pubky-live-vote/README.md new file mode 100644 index 0000000..59d66ec --- /dev/null +++ b/pubky-live-vote/README.md @@ -0,0 +1,93 @@ +# Pubky Live Vote + +Pubky Live Vote is a responsive hackathon voting interface that uses the Pubky JavaScript SDK for authentication and ballot storage. It is designed for 1–2 day events where dozens of voters need to authenticate quickly, score projects across the official rubric, provide feedback, and watch the leaderboard update in real time. + +## Features + +- **Pubky authentication** – voters connect through the Pubky Ring QR flow. A mock client is bundled for offline development and falls back automatically if the SDK is unavailable. +- **Mobile and desktop friendly UI** – adaptive layout for phones, tablets, and desktops with high-contrast styling. +- **Full rubric support** – sliders for Complexity, Creativity / Practicality, Team Presentation, and Feedback Quality plus a readiness toggle. +- **Popular vote ranking** – drag-friendly ranking board with explicit self-vote blocking once the voter selects their own project. +- **Feedback & tagging** – comment box and quick tag helper per project. +- **Offline cache** – ballots queue in local storage whenever the network is down and flush automatically once connectivity is restored. +- **Live leaderboard** – polls the Pubky homeserver summary (or aggregates ballots directly) every 10 seconds, showing the voter count and data source for transparency. + +## Getting started + +### Prerequisites + +- Node.js 18+ +- npm 9+ + +Optional: install the Pubky homeserver testnet tools if you want to target a local network. + +### Installation + +```bash +cd pubky-live-vote +npm install +``` + +> **Tip:** if you prefer to stay at the repository root, the workspace wrapper also lets you run `npm run install:app` to install +> dependencies without changing directories. + +### Development + +```bash +npm run dev +``` + +From the repository root you can use `npm run dev` as well—the root `package.json` proxies the command to the app workspace. + +The dev server runs on [http://localhost:5173](http://localhost:5173). The first load will automatically request a Pubky Ring session and render the QR code. Scan it with the Ring mobile app to authenticate. Without the app you can use the bundled mock client, which auto-accepts the login after a short delay. + +### Production build + +```bash +npm run build +``` + +The build command compiles the Vite project and performs type checking. + +### Configuring the homeserver + +By default the client tries to create a Pubky SDK instance against the staging homeserver and falls back to the local mock client. To explicitly point at a local testnet you can expose `window.__PUBKY_CONFIG__` before the bundle loads: + +```html + +``` + +Then, update the login flow to trust ballots signed by that server. All ballots are stored under `pubky-live-vote/ballots/.json`. + +### Folder structure + +``` +src/ + components/ # UI components and styling + context/ # Auth and project state providers + services/ # Pubky client adapter, offline queue, sample data + types/ # Shared TypeScript types +``` + +## Testing the offline queue + +1. Open the dev server and authenticate. +2. Fill out a few scores and press **Submit ballot**. +3. Toggle your browser to offline mode and make more changes. +4. Submit again. The queue will store the ballot locally. +5. Reconnect; the app auto-flushes pending ballots and refreshes the submission timestamp. + +## Live leaderboard data flow + +1. The client requests `pubky-live-vote/summary.json` from the configured homeserver every 10 seconds. If the summary is offline, it falls back to `pubky-live-vote/ballots/index.json` and aggregates the raw ballots locally. +2. Each project’s component scores are normalised to a 0–100 range, the official weights are applied, and tie-ready totals are produced. The UI highlights whether the snapshot came from the summary file, raw ballots, or a local preview. +3. The table updates in place without a page reload, giving organisers and participants a reliable view of standings as ballots from up to 24 voters (and beyond) land during the event. + +## License + +This project is released under the MIT license. See [LICENSE](./LICENSE). diff --git a/pubky-live-vote/index.html b/pubky-live-vote/index.html new file mode 100644 index 0000000..d5c81a3 --- /dev/null +++ b/pubky-live-vote/index.html @@ -0,0 +1,12 @@ + + + + + + Pubky Live Vote + + +
+ + + diff --git a/pubky-live-vote/package.json b/pubky-live-vote/package.json new file mode 100644 index 0000000..e765a25 --- /dev/null +++ b/pubky-live-vote/package.json @@ -0,0 +1,24 @@ +{ + "name": "pubky-live-vote", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@synonymdev/pubky": "0.6.0-rc.6", + "dayjs": "^1.11.10", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@types/react": "^18.2.66", + "@types/react-dom": "^18.2.22", + "@vitejs/plugin-react": "^4.2.1", + "typescript": "^5.3.3", + "vite": "^5.1.0" + } +} diff --git a/pubky-live-vote/src/App.tsx b/pubky-live-vote/src/App.tsx new file mode 100644 index 0000000..6352c94 --- /dev/null +++ b/pubky-live-vote/src/App.tsx @@ -0,0 +1,13 @@ +import { AuthProvider } from './context/AuthContext'; +import { ProjectProvider } from './context/ProjectContext'; +import { Layout } from './components/Layout'; + +const App = () => ( + + + + + +); + +export default App; diff --git a/pubky-live-vote/src/components/Layout.css b/pubky-live-vote/src/components/Layout.css new file mode 100644 index 0000000..48fffd8 --- /dev/null +++ b/pubky-live-vote/src/components/Layout.css @@ -0,0 +1,71 @@ +.app-shell { + display: flex; + flex-direction: column; + min-height: 100vh; + padding: 1.5rem clamp(1rem, 4vw, 3rem) 3rem; + gap: 1.5rem; + box-sizing: border-box; +} + +.app-header { + display: flex; + flex-direction: column; + gap: 1rem; + background: linear-gradient(135deg, rgba(14, 165, 233, 0.15), rgba(30, 64, 175, 0.35)); + padding: clamp(1.25rem, 3vw, 2rem); + border-radius: 24px; + border: 1px solid var(--border); + box-shadow: 0 18px 40px rgba(15, 23, 42, 0.45); +} + +.app-header h1 { + font-size: clamp(1.5rem, 2.6vw, 2.75rem); + margin: 0; +} + +.subtitle { + margin: 0; + color: rgba(226, 232, 240, 0.75); +} + +.app-main { + display: grid; + grid-template-columns: minmax(0, 360px) minmax(0, 1fr); + gap: 1.5rem; + align-items: flex-start; +} + +.left-column, +.right-column { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.app-footer { + background: rgba(15, 23, 42, 0.75); + border: 1px solid var(--border); + border-radius: 24px; + padding: clamp(1rem, 3vw, 1.75rem); + backdrop-filter: blur(14px); +} + +@media (max-width: 960px) { + .app-main { + grid-template-columns: 1fr; + } +} + +@media (max-width: 640px) { + .app-shell { + padding: 1rem; + } + + .app-header { + border-radius: 20px; + } + + .app-footer { + border-radius: 20px; + } +} diff --git a/pubky-live-vote/src/components/Layout.tsx b/pubky-live-vote/src/components/Layout.tsx new file mode 100644 index 0000000..50be755 --- /dev/null +++ b/pubky-live-vote/src/components/Layout.tsx @@ -0,0 +1,44 @@ +import { useAuth } from '../context/AuthContext'; +import { useProjects } from '../context/ProjectContext'; +import { LoginCard } from './LoginCard'; +import { ProjectList } from './ProjectList'; +import { Leaderboard } from './Leaderboard'; +import { PopularVoteBoard } from './PopularVoteBoard'; +import { SubmissionBanner } from './SubmissionBanner'; +import './Layout.css'; + +export const Layout = () => { + const { user } = useAuth(); + const { submitBallot, hasPendingChanges, lastSubmittedAt } = useProjects(); + + return ( +
+
+
+

Pubky Live Vote

+

Real-time voting for the Pubky Hackathon

+
+ +
+ +
+
+ + {user && } +
+
+ +
+
+ +
+ +
+
+ ); +}; diff --git a/pubky-live-vote/src/components/Leaderboard.css b/pubky-live-vote/src/components/Leaderboard.css new file mode 100644 index 0000000..6d265ba --- /dev/null +++ b/pubky-live-vote/src/components/Leaderboard.css @@ -0,0 +1,80 @@ +.leaderboard { + display: grid; + gap: 1rem; +} + +.leaderboard header h2 { + margin: 0; +} + +.leaderboard__meta { + display: flex; + flex-wrap: wrap; + gap: 0.5rem 1rem; + align-items: center; + font-size: 0.9rem; + color: rgba(226, 232, 240, 0.75); + margin-top: 0.35rem; +} + +.leaderboard__source { + color: rgba(125, 211, 252, 0.85); +} + +.leaderboard__error { + margin: 0.5rem 0 0; + font-size: 0.9rem; + color: #fca5a5; +} + +.leaderboard__table { + display: grid; + gap: 0.65rem; + overflow-x: auto; +} + +.leaderboard__row { + display: grid; + grid-template-columns: 60px minmax(160px, 1fr) repeat(7, minmax(60px, 1fr)); + gap: 0.75rem; + align-items: center; + background: rgba(15, 23, 42, 0.55); + padding: 0.75rem 1rem; + border-radius: 14px; + border: 1px solid rgba(148, 163, 184, 0.18); +} + +.leaderboard__row--header { + background: rgba(30, 41, 59, 0.75); + border: 1px solid rgba(148, 163, 184, 0.3); + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.08em; + font-weight: 600; +} + +.strong { + font-weight: 700; + color: #bae6fd; +} + +@media (max-width: 960px) { + .leaderboard__row, + .leaderboard__row--header { + grid-template-columns: 50px minmax(140px, 1fr) repeat(7, minmax(50px, 1fr)); + } +} + +@media (max-width: 720px) { + .leaderboard__row, + .leaderboard__row--header { + grid-template-columns: 45px minmax(130px, 1fr) repeat(7, minmax(45px, 1fr)); + font-size: 0.85rem; + } +} + +@media (max-width: 640px) { + .leaderboard__table { + overflow-x: auto; + } +} diff --git a/pubky-live-vote/src/components/Leaderboard.tsx b/pubky-live-vote/src/components/Leaderboard.tsx new file mode 100644 index 0000000..beb8558 --- /dev/null +++ b/pubky-live-vote/src/components/Leaderboard.tsx @@ -0,0 +1,109 @@ +import { useMemo } from 'react'; +import { useProjects } from '../context/ProjectContext'; +import { useLiveLeaderboard } from '../hooks/useLiveLeaderboard'; +import type { LeaderboardEntry, Project } from '../types/project'; +import './Leaderboard.css'; + +const SOURCE_LABEL: Record<'remote-summary' | 'ballots' | 'local', string> = { + 'remote-summary': 'Pubky homeserver summary', + ballots: 'Aggregated ballots', + local: 'Local preview' +}; + +const formatUpdatedAt = (iso: string) => { + const parsed = Date.parse(iso); + if (Number.isNaN(parsed)) return 'unknown'; + const diffMs = Date.now() - parsed; + if (diffMs < 30_000) return 'just now'; + if (diffMs < 3_600_000) { + const minutes = Math.round(diffMs / 60_000); + return `${minutes} min ago`; + } + const hours = Math.round(diffMs / 3_600_000); + if (hours < 48) { + return `${hours} hr${hours === 1 ? '' : 's'} ago`; + } + return new Date(parsed).toLocaleString(); +}; + +const formatVoterCount = (count: number) => + `${count} voter${count === 1 ? '' : 's'}`; + +const numberFormatter = new Intl.NumberFormat(undefined, { + minimumFractionDigits: 0, + maximumFractionDigits: 1 +}); + +const integerFormatter = new Intl.NumberFormat(undefined, { + minimumFractionDigits: 0, + maximumFractionDigits: 0 +}); + +export const Leaderboard = () => { + const { projects, popularRanking } = useProjects(); + const { entries, totalVoters, generatedAt, source, isLoading, error } = useLiveLeaderboard( + projects, + popularRanking + ); + + const rows = useMemo(() => (entries.length ? entries : placeholderRows(projects)), [entries, projects]); + + return ( +
+
+

Live Leaderboard

+
+ {isLoading ? 'Loading scores…' : `${formatVoterCount(totalVoters)} · ${formatUpdatedAt(generatedAt)}`} + Source: {SOURCE_LABEL[source]} +
+ {error &&

{error.message}

} +
+
+
+ Rank + Project + Total + Complexity + Creativity + Readiness + Presentation + Feedback + Popular + AI +
+ {rows.map((row, index) => ( +
+ {index + 1} + {row.projectName} + + {numberFormatter.format(row.total)} + + {integerFormatter.format(row.components.complexity)} + {integerFormatter.format(row.components.creativity)} + {integerFormatter.format(row.components.readiness)} + {integerFormatter.format(row.components.presentation)} + {integerFormatter.format(row.components.feedback)} + {integerFormatter.format(row.components.popular)} + {integerFormatter.format(row.components.ai)} +
+ ))} +
+
+ ); +}; + +const placeholderRows = (projects: Project[]): LeaderboardEntry[] => + projects.map((project) => ({ + projectId: project.id, + projectName: project.name, + total: 0, + components: { + complexity: 0, + creativity: 0, + readiness: 0, + presentation: 0, + feedback: 0, + popular: 0, + ai: project.aiScore ?? 0 + } + })); diff --git a/pubky-live-vote/src/components/LoginCard.tsx b/pubky-live-vote/src/components/LoginCard.tsx new file mode 100644 index 0000000..40b1750 --- /dev/null +++ b/pubky-live-vote/src/components/LoginCard.tsx @@ -0,0 +1,54 @@ +import { useEffect } from 'react'; +import { useAuth } from '../context/AuthContext'; +import './Panel.css'; + +export const LoginCard = () => { + const { user, qrCodeSvg, isAuthenticating, connect, disconnect } = useAuth(); + + useEffect(() => { + if (!user) { + void connect(); + } + }, [user, connect]); + + return ( +
+
+
+

{user ? 'Connected to Pubky' : 'Connect with Pubky Ring'}

+

+ {user ? 'Your identity is linked. You can vote, comment, and tag projects.' : 'Scan the QR code with Pubky Ring to authenticate.'} +

+
+ {user && ( + + )} +
+ + {!user && ( +
+ {qrCodeSvg ? ( +
+ ) : ( +
{isAuthenticating ? 'Waiting for QR code…' : 'Unable to load QR code'}
+ )} +
+ )} + + {user && ( +
+
+
Display Name
+
{user.displayName ?? 'Anonymous Builder'}
+
+
+
Public Key
+
{user.publicKey}
+
+
+ )} +
+ ); +}; diff --git a/pubky-live-vote/src/components/Panel.css b/pubky-live-vote/src/components/Panel.css new file mode 100644 index 0000000..cc1edba --- /dev/null +++ b/pubky-live-vote/src/components/Panel.css @@ -0,0 +1,105 @@ +.panel { + background: rgba(15, 23, 42, 0.8); + border: 1px solid var(--border); + border-radius: 24px; + padding: clamp(1.25rem, 3vw, 1.75rem); + display: flex; + flex-direction: column; + gap: 1.25rem; + box-shadow: 0 16px 32px rgba(15, 23, 42, 0.45); + backdrop-filter: blur(16px); +} + +.panel__header { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.panel__header h2 { + margin: 0; + font-size: clamp(1.1rem, 2vw, 1.5rem); +} + +.panel__subtitle { + margin: 0; + color: rgba(226, 232, 240, 0.65); +} + +.button { + align-self: flex-start; + border-radius: 12px; + padding: 0.55rem 1.1rem; + border: none; + background: var(--accent); + color: #0b1120; + font-weight: 600; + cursor: pointer; + transition: transform 0.18s ease, box-shadow 0.18s ease; +} + +.button:hover { + transform: translateY(-1px); + box-shadow: 0 8px 18px rgba(14, 165, 233, 0.25); +} + +.button:disabled { + cursor: not-allowed; + opacity: 0.55; + transform: none; + box-shadow: none; +} + +.button--ghost { + background: transparent; + color: var(--text); + border: 1px solid rgba(148, 163, 184, 0.35); +} + +.button--ghost:hover { + background: rgba(148, 163, 184, 0.08); + box-shadow: none; +} + +.qr-wrapper { + display: flex; + justify-content: center; + align-items: center; + min-height: 220px; + background: rgba(15, 23, 42, 0.6); + border-radius: 18px; + border: 1px solid rgba(148, 163, 184, 0.2); +} + +.qr-container svg { + width: min(220px, 60vw); + height: auto; +} + +.qr-placeholder { + color: rgba(226, 232, 240, 0.6); +} + +.identity-list { + display: grid; + gap: 0.75rem; + margin: 0; +} + +.identity-list dt { + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: rgba(148, 163, 184, 0.75); +} + +.identity-list dd { + margin: 0.2rem 0 0; + font-weight: 600; +} + +.mono { + font-family: 'JetBrains Mono', 'Fira Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, 'Courier New', monospace; + font-size: 0.82rem; + word-break: break-all; +} diff --git a/pubky-live-vote/src/components/PopularVoteBoard.css b/pubky-live-vote/src/components/PopularVoteBoard.css new file mode 100644 index 0000000..01a3c36 --- /dev/null +++ b/pubky-live-vote/src/components/PopularVoteBoard.css @@ -0,0 +1,86 @@ +.popular { + gap: 1.5rem; +} + +.popular__body { + display: grid; + gap: 1.25rem; +} + +.popular__self-select { + display: grid; + gap: 0.35rem; + font-size: 0.9rem; +} + +.popular__self-select select { + border-radius: 12px; + border: 1px solid rgba(148, 163, 184, 0.35); + background: rgba(15, 23, 42, 0.55); + color: inherit; + padding: 0.5rem 0.75rem; +} + +.popular h3 { + margin: 0; + font-size: 1rem; +} + +.popular__pool { + display: grid; + gap: 0.75rem; + margin: 0; + padding: 0; + list-style: none; +} + +.popular__pool button { + width: 100%; + justify-content: flex-start; +} + +.popular__ranking ol { + margin: 0; + padding-left: 0; + list-style: none; + display: grid; + gap: 0.75rem; +} + +.popular__ranking li { + display: grid; + grid-template-columns: 48px minmax(0, 1fr) 40px; + gap: 0.5rem; + align-items: center; + padding: 0.65rem 0.85rem; + border-radius: 12px; + border: 1px solid rgba(148, 163, 184, 0.25); + background: rgba(15, 23, 42, 0.55); + cursor: grab; +} + +.popular__ranking li:active { + cursor: grabbing; +} + +.popular__position { + font-weight: 700; + color: #bae6fd; +} + +.popular__ranking button { + background: transparent; + border: none; + color: rgba(148, 163, 184, 0.9); + font-size: 1.25rem; + cursor: pointer; +} + +.popular__ranking button:hover { + color: var(--danger); +} + +.popular__empty { + margin: 0; + color: rgba(226, 232, 240, 0.6); +} diff --git a/pubky-live-vote/src/components/PopularVoteBoard.tsx b/pubky-live-vote/src/components/PopularVoteBoard.tsx new file mode 100644 index 0000000..7004859 --- /dev/null +++ b/pubky-live-vote/src/components/PopularVoteBoard.tsx @@ -0,0 +1,110 @@ +import { DragEvent, useMemo, useState } from 'react'; +import { useProjects } from '../context/ProjectContext'; +import './PopularVoteBoard.css'; +import './Panel.css'; + +export const PopularVoteBoard = () => { + const { projects, popularRanking, setPopularRanking, userProjectId, setUserProjectId } = useProjects(); + const [draggedId, setDraggedId] = useState(null); + + const selectableProjects = useMemo( + () => + projects.filter((project) => project.id !== userProjectId).sort((a, b) => a.name.localeCompare(b.name)), + [projects, userProjectId] + ); + + const addToRanking = (projectId: string) => { + if (popularRanking.includes(projectId)) return; + setPopularRanking([...popularRanking, projectId]); + }; + + const removeFromRanking = (projectId: string) => { + setPopularRanking(popularRanking.filter((id) => id !== projectId)); + }; + + const onDragStart = (event: DragEvent, projectId: string) => { + setDraggedId(projectId); + event.dataTransfer.effectAllowed = 'move'; + }; + + const onDragOver = (event: DragEvent, targetId: string) => { + event.preventDefault(); + if (!draggedId || draggedId === targetId) return; + const newOrder = [...popularRanking]; + const from = newOrder.indexOf(draggedId); + const to = newOrder.indexOf(targetId); + newOrder.splice(from, 1); + newOrder.splice(to, 0, draggedId); + setPopularRanking(newOrder); + }; + + const onDragEnd = () => setDraggedId(null); + + return ( +
+
+
+

Popular Vote

+

Rank up to five favourite projects. Self votes are blocked automatically.

+
+
+ +
+ +
+

Available projects

+
    + {selectableProjects.map((project) => ( +
  • + +
  • + ))} +
+
+ +
+

Ranking

+
    + {popularRanking.map((projectId, index) => { + const project = projects.find((item) => item.id === projectId); + if (!project) return null; + return ( +
  1. onDragStart(event, projectId)} + onDragOver={(event) => onDragOver(event, projectId)} + onDragEnd={onDragEnd} + > + #{index + 1} + {project.name} + +
  2. + ); + })} +
+ {popularRanking.length === 0 &&

Select favourites to start ranking them.

} +
+
+
+ ); +}; diff --git a/pubky-live-vote/src/components/ProjectCard.css b/pubky-live-vote/src/components/ProjectCard.css new file mode 100644 index 0000000..b37a8b8 --- /dev/null +++ b/pubky-live-vote/src/components/ProjectCard.css @@ -0,0 +1,89 @@ +.project-card { + background: rgba(15, 23, 42, 0.65); + border-radius: 20px; + border: 1px solid rgba(148, 163, 184, 0.18); + padding: 1.25rem; + display: flex; + flex-direction: column; + gap: 1.25rem; + transition: border 0.2s ease, transform 0.2s ease; +} + +.project-card:hover { + transform: translateY(-3px); + border-color: rgba(56, 189, 248, 0.45); +} + +.project-card__header { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.project-card__header h3 { + margin: 0; + font-size: 1.3rem; +} + +.project-card__description { + margin: 0; + color: rgba(226, 232, 240, 0.7); +} + +.project-card__tags { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.tag { + background: rgba(56, 189, 248, 0.15); + color: #bae6fd; + padding: 0.25rem 0.6rem; + border-radius: 999px; + font-size: 0.8rem; +} + +.project-card__scores { + display: grid; + gap: 1rem; +} + +.project-card__meta { + display: grid; + gap: 1rem; +} + +.checkbox { + display: flex; + align-items: center; + gap: 0.65rem; + font-weight: 500; +} + +.checkbox input { + width: 20px; + height: 20px; + border-radius: 6px; + border: 1px solid rgba(148, 163, 184, 0.35); + accent-color: var(--accent); +} + +.project-card__comment { + display: grid; + gap: 0.35rem; +} + +.project-card__comment textarea { + resize: vertical; + min-height: 90px; + border-radius: 12px; + border: 1px solid rgba(148, 163, 184, 0.25); + padding: 0.75rem; + background: rgba(15, 23, 42, 0.55); +} + +.project-card__comment textarea:focus { + outline: 2px solid rgba(56, 189, 248, 0.45); + outline-offset: 2px; +} diff --git a/pubky-live-vote/src/components/ProjectCard.tsx b/pubky-live-vote/src/components/ProjectCard.tsx new file mode 100644 index 0000000..3d1906c --- /dev/null +++ b/pubky-live-vote/src/components/ProjectCard.tsx @@ -0,0 +1,88 @@ +import { ChangeEvent } from 'react'; +import type { Project, ScoreComponent } from '../types/project'; +import { ScoreSlider } from './ScoreSlider'; +import { TagInput } from './TagInput'; +import './ProjectCard.css'; + +interface Props { + project: Project; + onScoreChange: (projectId: string, component: ScoreComponent, value: number) => void; + onReadinessToggle: (projectId: string, ready: boolean) => void; + onCommentChange: (projectId: string, comment: string) => void; + onTagsChange: (projectId: string, tags: string[]) => void; +} + +const SCORE_LABELS: Record = { + complexity: 'Complexity', + creativity: 'Creativity / Practicality', + presentation: 'Team Presentation', + feedback: 'Feedback Quality' +}; + +export const ProjectCard = ({ project, onScoreChange, onReadinessToggle, onCommentChange, onTagsChange }: Props) => { + const handleSlider = (component: ScoreComponent) => (value: number) => { + onScoreChange(project.id, component, value); + }; + + const handleComment = (event: ChangeEvent) => { + onCommentChange(project.id, event.target.value); + }; + + const handleReadiness = (event: ChangeEvent) => { + onReadinessToggle(project.id, event.target.checked); + }; + + const handleTagsUpdate = (tags: string[]) => { + onTagsChange(project.id, tags); + }; + + return ( +
+
+
+

{project.name}

+

{project.description}

+
+
+ {project.tags.map((tag) => ( + + #{tag} + + ))} +
+
+ +
+ {Object.entries(SCORE_LABELS).map(([component, label]) => ( + + ))} +
+ +
+ +
+ +