From 35fbb1b47dcdef10731301ca5a3bdee1fcd86c7b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Felix=20F=C3=B6rtsch?=
Date: Wed, 4 Mar 2026 23:01:11 +0100
Subject: [PATCH] normalize biome config (lineWidth 80, quoteStyle double),
switch server to bun native, add CLAUDE.md
Co-Authored-By: Claude Opus 4.6
---
.gitignore | 3 +
mise.toml => .mise.toml | 1 -
CLAUDE.md | 52 ++++
biome.json | 9 +-
bun.lock | 4 +-
deploy.sh | 3 +
index.html | 2 +-
package.json | 3 +-
src/client/hooks/use-round-mutation.ts | 62 +++--
src/client/hooks/use-round.ts | 6 +-
src/client/hooks/use-sw-update.ts | 46 ++++
src/client/index.css | 6 +
src/client/lib/api-client.ts | 24 +-
src/client/lib/sessions.ts | 36 +++
src/client/lib/utils.ts | 6 +-
src/client/main.tsx | 10 +-
src/client/router.tsx | 12 +-
src/client/routes/$uuid/admin.tsx | 231 ++++++++++++------
src/client/routes/$uuid/home.tsx | 99 ++++++++
src/client/routes/$uuid/route.tsx | 241 ++++++++++---------
src/client/routes/__root.tsx | 101 +++++++-
src/client/routes/index.tsx | 12 +-
src/server/app.ts | 24 +-
src/server/features/rounds/router.ts | 104 +++++---
src/server/features/rounds/schema.ts | 26 +-
src/server/features/rounds/service.ts | 214 ++++++++--------
src/server/index.ts | 14 +-
src/server/shared/db/index.ts | 16 +-
src/server/shared/db/schema/index.ts | 10 +-
src/server/shared/db/schema/movies.ts | 6 +-
src/server/shared/db/schema/round-history.ts | 6 +-
src/server/shared/db/schema/round-users.ts | 6 +-
src/server/shared/db/schema/rounds.ts | 6 +-
src/server/shared/db/schema/votes.ts | 6 +-
src/server/shared/lib/env.ts | 6 +-
src/shared/algorithm.ts | 70 +++---
src/shared/round-state.ts | 22 +-
src/shared/types.ts | 33 +--
tests/shared/algorithm.test.ts | 38 +--
tests/shared/round-state.test.ts | 24 +-
40 files changed, 1053 insertions(+), 547 deletions(-)
rename mise.toml => .mise.toml (64%)
create mode 100644 CLAUDE.md
create mode 100644 src/client/hooks/use-sw-update.ts
create mode 100644 src/client/lib/sessions.ts
create mode 100644 src/client/routes/$uuid/home.tsx
diff --git a/.gitignore b/.gitignore
index a47c985..75ee0f5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,3 +4,6 @@ dist/
.DS_Store
data/
drizzle/meta/
+.mise.local.toml
+.env.local
+.env.*.local
diff --git a/mise.toml b/.mise.toml
similarity index 64%
rename from mise.toml
rename to .mise.toml
index b406cd3..fa27325 100644
--- a/mise.toml
+++ b/.mise.toml
@@ -1,3 +1,2 @@
[tools]
bun = "1.3.0"
-node = "22"
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..98fb318
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,52 @@
+# movie-select — Collaborative Movie Picker
+
+Collaborative movie selection app where groups vote on movies to watch together.
+
+## Stack
+
+- **Frontend:** React 19, Vite, Tailwind CSS 4, TanStack Router, TanStack Query
+- **Backend:** Hono (Bun), Drizzle ORM, PostgreSQL
+- **Linting:** Biome 2.x (tabs, 80 chars, double quotes)
+
+## Project Structure
+
+```
+src/
+├── client/ ← React SPA (routes/, components/, hooks/, lib/)
+├── server/ ← Hono API (features/, shared/)
+└── shared/ ← isomorphic code (types, algorithms)
+```
+
+## Local Development
+
+```bash
+bun install
+bun run dev # frontend (Vite)
+bun run dev:server # backend (Bun --watch)
+```
+
+## Deployment
+
+```bash
+./deploy.sh
+```
+
+Deploys to Uberspace (`serve.uber.space`):
+- Frontend → `/var/www/virtual/serve/html/movie-select/`
+- Backend → `~/services/movie-select/` (systemd: `movie-select.service`, port 3003)
+- Route: `/movie-select/api/*` → port 3003 (prefix removed)
+
+## Environment Variables
+
+See `.env.example`:
+- `DATABASE_URL` — PostgreSQL connection string
+- `PORT` — server port (default 3003)
+
+## Database
+
+PostgreSQL via Drizzle ORM. Migrations in `drizzle/`.
+
+```bash
+bun run db:generate
+bun run db:migrate
+```
diff --git a/biome.json b/biome.json
index 629048a..4e9b18a 100644
--- a/biome.json
+++ b/biome.json
@@ -11,7 +11,14 @@
}
},
"formatter": {
- "indentStyle": "tab"
+ "indentStyle": "tab",
+ "lineWidth": 80
+ },
+ "javascript": {
+ "formatter": {
+ "quoteStyle": "double",
+ "semicolons": "asNeeded"
+ }
},
"linter": {
"enabled": true,
diff --git a/bun.lock b/bun.lock
index 5386e36..3ad1905 100644
--- a/bun.lock
+++ b/bun.lock
@@ -1,10 +1,10 @@
{
"lockfileVersion": 1,
+ "configVersion": 0,
"workspaces": {
"": {
"name": "movie-select",
"dependencies": {
- "@hono/node-server": "^1.13.0",
"@hono/zod-validator": "^0.7.6",
"@tanstack/react-query": "^5.62.0",
"@tanstack/react-router": "^1.92.0",
@@ -146,8 +146,6 @@
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.19.12", "", { "os": "win32", "cpu": "x64" }, "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA=="],
- "@hono/node-server": ["@hono/node-server@1.19.10", "", { "peerDependencies": { "hono": "^4" } }, "sha512-hZ7nOssGqRgyV3FVVQdfi+U4q02uB23bpnYpdvNXkYTRRyWx84b7yf1ans+dnJ/7h41sGL3CeQTfO+ZGxuO+Iw=="],
-
"@hono/zod-validator": ["@hono/zod-validator@0.7.6", "", { "peerDependencies": { "hono": ">=3.9.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Io1B6d011Gj1KknV4rXYz4le5+5EubcWEU/speUjuw9XMMIaP3n78yXLhjd2A3PXaXaUwEAluOiAyLqhBEJgsw=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
diff --git a/deploy.sh b/deploy.sh
index 2b753fe..efbfa55 100755
--- a/deploy.sh
+++ b/deploy.sh
@@ -11,6 +11,9 @@ bun install
echo "==> Building client (Vite)..."
bun run build
+echo "==> Stamping service worker version..."
+sed -i '' "s/__SW_VERSION__/$(date +%s)/" dist/client/sw.js
+
echo "==> Syncing static files to ${REMOTE_HOST}:${REMOTE_STATIC_DIR} ..."
rsync -avz --delete \
--exclude='.DS_Store' \
diff --git a/index.html b/index.html
index a3f26f2..c78e6db 100644
--- a/index.html
+++ b/index.html
@@ -2,7 +2,7 @@
-
+
diff --git a/package.json b/package.json
index 7d3d72e..9d2eff2 100644
--- a/package.json
+++ b/package.json
@@ -5,7 +5,7 @@
"type": "module",
"scripts": {
"dev": "vite",
- "dev:server": "node --watch --env-file=.env src/server/index.ts",
+ "dev:server": "bun --watch --env-file=.env src/server/index.ts",
"build": "vite build",
"build:server": "true",
"test": "vitest run",
@@ -17,7 +17,6 @@
"db:studio": "drizzle-kit studio"
},
"dependencies": {
- "@hono/node-server": "^1.13.0",
"@hono/zod-validator": "^0.7.6",
"@tanstack/react-query": "^5.62.0",
"@tanstack/react-router": "^1.92.0",
diff --git a/src/client/hooks/use-round-mutation.ts b/src/client/hooks/use-round-mutation.ts
index 2812ac7..c7bbdb4 100644
--- a/src/client/hooks/use-round-mutation.ts
+++ b/src/client/hooks/use-round-mutation.ts
@@ -1,82 +1,98 @@
-import { useMutation, useQueryClient } from "@tanstack/react-query";
-import type { State } from "@/shared/types.ts";
-import { api } from "../lib/api-client.ts";
+import { useMutation, useQueryClient } from "@tanstack/react-query"
+import type { State } from "@/shared/types.ts"
+import { api } from "../lib/api-client.ts"
function useRoundMutation(uuid: string, mutationFn: () => Promise) {
- const qc = useQueryClient();
+ const qc = useQueryClient()
return useMutation({
mutationFn,
onSuccess: (state) => {
- qc.setQueryData(["round", uuid], state);
+ qc.setQueryData(["round", uuid], state)
},
- });
+ })
+}
+
+export function useSetName(uuid: string) {
+ const qc = useQueryClient()
+ return useMutation({
+ mutationFn: (name: string) => api.setName(uuid, name),
+ onSuccess: (state) => qc.setQueryData(["round", uuid], state),
+ })
}
export function useAddUser(uuid: string) {
- const qc = useQueryClient();
+ const qc = useQueryClient()
return useMutation({
mutationFn: (user: string) => api.addUser(uuid, user),
onSuccess: (state) => qc.setQueryData(["round", uuid], state),
- });
+ })
+}
+
+export function useRemoveUser(uuid: string) {
+ const qc = useQueryClient()
+ return useMutation({
+ mutationFn: (user: string) => api.removeUser(uuid, user),
+ onSuccess: (state) => qc.setQueryData(["round", uuid], state),
+ })
}
export function useFinishSetup(uuid: string) {
- return useRoundMutation(uuid, () => api.finishSetup(uuid));
+ return useRoundMutation(uuid, () => api.finishSetup(uuid))
}
export function useAddMovie(uuid: string) {
- const qc = useQueryClient();
+ const qc = useQueryClient()
return useMutation({
mutationFn: ({ user, title }: { user: string; title: string }) =>
api.addMovie(uuid, user, title),
onSuccess: (state) => qc.setQueryData(["round", uuid], state),
- });
+ })
}
export function useRemoveMovie(uuid: string) {
- const qc = useQueryClient();
+ const qc = useQueryClient()
return useMutation({
mutationFn: ({ user, title }: { user: string; title: string }) =>
api.removeMovie(uuid, user, title),
onSuccess: (state) => qc.setQueryData(["round", uuid], state),
- });
+ })
}
export function useVote(uuid: string) {
- const qc = useQueryClient();
+ const qc = useQueryClient()
return useMutation({
mutationFn: ({
user,
ratings,
}: {
- user: string;
- ratings: Record;
+ user: string
+ ratings: Record
}) => api.vote(uuid, user, ratings),
onSuccess: (state) => qc.setQueryData(["round", uuid], state),
- });
+ })
}
export function useMarkDone(uuid: string) {
- const qc = useQueryClient();
+ const qc = useQueryClient()
return useMutation({
mutationFn: ({ user, phase }: { user: string; phase: 1 | 2 }) =>
api.markDone(uuid, user, phase),
onSuccess: (state) => qc.setQueryData(["round", uuid], state),
- });
+ })
}
export function useSetPhase(uuid: string) {
- const qc = useQueryClient();
+ const qc = useQueryClient()
return useMutation({
mutationFn: (phase: 1 | 2 | 3) => api.setPhase(uuid, phase),
onSuccess: (state) => qc.setQueryData(["round", uuid], state),
- });
+ })
}
export function useNewRound(uuid: string) {
- const qc = useQueryClient();
+ const qc = useQueryClient()
return useMutation({
mutationFn: (winner: string) => api.newRound(uuid, winner),
onSuccess: (state) => qc.setQueryData(["round", uuid], state),
- });
+ })
}
diff --git a/src/client/hooks/use-round.ts b/src/client/hooks/use-round.ts
index 913a8a3..feb3d9c 100644
--- a/src/client/hooks/use-round.ts
+++ b/src/client/hooks/use-round.ts
@@ -1,5 +1,5 @@
-import { useQuery } from "@tanstack/react-query";
-import { api } from "../lib/api-client.ts";
+import { useQuery } from "@tanstack/react-query"
+import { api } from "../lib/api-client.ts"
export function useRound(uuid: string) {
return useQuery({
@@ -7,5 +7,5 @@ export function useRound(uuid: string) {
queryFn: () => api.getState(uuid),
enabled: uuid.length > 0,
refetchInterval: 5000,
- });
+ })
}
diff --git a/src/client/hooks/use-sw-update.ts b/src/client/hooks/use-sw-update.ts
new file mode 100644
index 0000000..301cf8b
--- /dev/null
+++ b/src/client/hooks/use-sw-update.ts
@@ -0,0 +1,46 @@
+import { useEffect, useState } from "react"
+
+export function useSwUpdate() {
+ const [waitingWorker, setWaitingWorker] = useState(null)
+
+ useEffect(() => {
+ if (!("serviceWorker" in navigator)) return
+
+ navigator.serviceWorker
+ .register("/movie-select/sw.js")
+ .then((registration) => {
+ // Already a waiting worker from a previous visit
+ if (registration.waiting) {
+ setWaitingWorker(registration.waiting)
+ }
+
+ registration.addEventListener("updatefound", () => {
+ const newWorker = registration.installing
+ if (!newWorker) return
+
+ newWorker.addEventListener("statechange", () => {
+ if (
+ newWorker.state === "installed" &&
+ navigator.serviceWorker.controller
+ ) {
+ setWaitingWorker(newWorker)
+ }
+ })
+ })
+ })
+
+ // Reload when the new worker takes over
+ let refreshing = false
+ navigator.serviceWorker.addEventListener("controllerchange", () => {
+ if (refreshing) return
+ refreshing = true
+ window.location.reload()
+ })
+ }, [])
+
+ function applyUpdate() {
+ waitingWorker?.postMessage("skipWaiting")
+ }
+
+ return { updateAvailable: waitingWorker !== null, applyUpdate }
+}
diff --git a/src/client/index.css b/src/client/index.css
index 4b98750..1e66a47 100644
--- a/src/client/index.css
+++ b/src/client/index.css
@@ -8,6 +8,7 @@
@layer base {
html {
-webkit-tap-highlight-color: transparent;
+ touch-action: pan-x pan-y;
}
button,
input,
@@ -51,3 +52,8 @@ input[type="range"]::-moz-range-thumb {
.safe-b {
padding-bottom: max(2.5rem, env(safe-area-inset-bottom));
}
+
+/* Safe-area padding for fixed bottom tab bar */
+.safe-b-tab {
+ padding-bottom: env(safe-area-inset-bottom);
+}
diff --git a/src/client/lib/api-client.ts b/src/client/lib/api-client.ts
index f9174c4..ff751fe 100644
--- a/src/client/lib/api-client.ts
+++ b/src/client/lib/api-client.ts
@@ -1,11 +1,11 @@
-import type { State } from "@/shared/types.ts";
+import type { State } from "@/shared/types.ts"
-const BASE = "/movie-select/api/rounds";
+const BASE = "/movie-select/api/rounds"
interface ApiResponse {
- ok: boolean;
- state: State;
- error?: string;
+ ok: boolean
+ state: State
+ error?: string
}
async function request(
@@ -17,16 +17,20 @@ async function request(
method,
headers: body ? { "Content-Type": "application/json" } : undefined,
body: body ? JSON.stringify(body) : undefined,
- });
- const data = (await res.json()) as ApiResponse;
- if (!res.ok || !data.ok) throw new Error(data.error ?? "Request failed");
- return data.state;
+ })
+ const data = (await res.json()) as ApiResponse
+ if (!res.ok || !data.ok) throw new Error(data.error ?? "Request failed")
+ return data.state
}
export const api = {
getState: (uuid: string) => request("GET", `/${uuid}`),
addUser: (uuid: string, user: string) =>
request("POST", `/${uuid}/users`, { user }),
+ setName: (uuid: string, name: string) =>
+ request("PATCH", `/${uuid}`, { name }),
+ removeUser: (uuid: string, user: string) =>
+ request("DELETE", `/${uuid}/users`, { user }),
finishSetup: (uuid: string) => request("POST", `/${uuid}/setup`),
addMovie: (uuid: string, user: string, title: string) =>
request("POST", `/${uuid}/movies`, { user, title }),
@@ -40,4 +44,4 @@ export const api = {
request("POST", `/${uuid}/phase`, { phase }),
newRound: (uuid: string, winner: string) =>
request("POST", `/${uuid}/new-round`, { winner }),
-};
+}
diff --git a/src/client/lib/sessions.ts b/src/client/lib/sessions.ts
new file mode 100644
index 0000000..cd85c98
--- /dev/null
+++ b/src/client/lib/sessions.ts
@@ -0,0 +1,36 @@
+const STORAGE_KEY = "movie-select:sessions"
+const MAX_SESSIONS = 20
+
+export interface Session {
+ uuid: string
+ createdAt: string
+ role: "admin" | "user"
+ userName?: string
+ sessionName?: string
+}
+
+export function getSessions(): Session[] {
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY)
+ if (!raw) return []
+ return JSON.parse(raw) as Session[]
+ } catch {
+ return []
+ }
+}
+
+export function saveSession(session: Session): void {
+ const sessions = getSessions().filter((s) => s.uuid !== session.uuid)
+ sessions.unshift(session)
+ if (sessions.length > MAX_SESSIONS) sessions.length = MAX_SESSIONS
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(sessions))
+}
+
+export function removeSession(uuid: string): void {
+ const sessions = getSessions().filter((s) => s.uuid !== uuid)
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(sessions))
+}
+
+export function clearSessions(): void {
+ localStorage.removeItem(STORAGE_KEY)
+}
diff --git a/src/client/lib/utils.ts b/src/client/lib/utils.ts
index ac680b3..5e1abdf 100644
--- a/src/client/lib/utils.ts
+++ b/src/client/lib/utils.ts
@@ -1,6 +1,6 @@
-import { type ClassValue, clsx } from "clsx";
-import { twMerge } from "tailwind-merge";
+import { type ClassValue, clsx } from "clsx"
+import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
- return twMerge(clsx(inputs));
+ return twMerge(clsx(inputs))
}
diff --git a/src/client/main.tsx b/src/client/main.tsx
index 8dd0e66..6555873 100644
--- a/src/client/main.tsx
+++ b/src/client/main.tsx
@@ -1,10 +1,10 @@
-import { StrictMode } from "react";
-import { createRoot } from "react-dom/client";
-import { App } from "./router.tsx";
-import "./index.css";
+import { StrictMode } from "react"
+import { createRoot } from "react-dom/client"
+import { App } from "./router.tsx"
+import "./index.css"
createRoot(document.getElementById("root")!).render(
,
-);
+)
diff --git a/src/client/router.tsx b/src/client/router.tsx
index 4c95775..48fc6d7 100644
--- a/src/client/router.tsx
+++ b/src/client/router.tsx
@@ -1,6 +1,6 @@
-import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
-import { createRouter, RouterProvider } from "@tanstack/react-router";
-import { routeTree } from "./routes/__root.tsx";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
+import { createRouter, RouterProvider } from "@tanstack/react-router"
+import { routeTree } from "./routes/__root.tsx"
const queryClient = new QueryClient({
defaultOptions: {
@@ -8,17 +8,17 @@ const queryClient = new QueryClient({
staleTime: 2000,
},
},
-});
+})
const router = createRouter({
routeTree,
basepath: "/movie-select",
-});
+})
export function App() {
return (
- );
+ )
}
diff --git a/src/client/routes/$uuid/admin.tsx b/src/client/routes/$uuid/admin.tsx
index 715ceea..c9d61d5 100644
--- a/src/client/routes/$uuid/admin.tsx
+++ b/src/client/routes/$uuid/admin.tsx
@@ -1,47 +1,96 @@
-import { useParams } from "@tanstack/react-router";
-import { useRef, useState } from "react";
-import { decideMovie } from "@/shared/algorithm.ts";
-import { collectCompleteRatings } from "@/shared/round-state.ts";
-import type { State } from "@/shared/types.ts";
-import { useRound } from "../../hooks/use-round.ts";
+import { useParams } from "@tanstack/react-router"
+import { useEffect, useRef, useState } from "react"
+import { decideMovie } from "@/shared/algorithm.ts"
+import { collectCompleteRatings } from "@/shared/round-state.ts"
+import type { State } from "@/shared/types.ts"
+import { useRound } from "../../hooks/use-round.ts"
import {
useAddUser,
useFinishSetup,
useNewRound,
-} from "../../hooks/use-round-mutation.ts";
-import { ResultSection } from "./route.tsx";
+ useRemoveUser,
+ useSetName,
+} from "../../hooks/use-round-mutation.ts"
+import { saveSession } from "../../lib/sessions.ts"
+import { ResultSection } from "./route.tsx"
export function AdminRoute() {
- const { uuid } = useParams({ strict: false }) as { uuid: string };
- const { data: state, refetch } = useRound(uuid);
+ const { uuid } = useParams({ strict: false }) as { uuid: string }
+ const { data: state, refetch } = useRound(uuid)
+
+ const roundName = state?.name
+ useEffect(() => {
+ if (roundName !== undefined) {
+ saveSession({
+ uuid,
+ role: "admin",
+ createdAt: new Date().toISOString(),
+ sessionName: roundName ?? undefined,
+ })
+ }
+ }, [uuid, roundName])
if (!state) {
- return Loading…
;
+ return Loading…
}
if (!state.setupDone) {
- return ;
+ return
}
- return ;
+ return
}
function AdminSetup({ uuid, state }: { uuid: string; state: State }) {
- const [draft, setDraft] = useState("");
- const inputRef = useRef(null);
- const addUser = useAddUser(uuid);
- const finishSetup = useFinishSetup(uuid);
+ const [draft, setDraft] = useState("")
+ const [nameDraft, setNameDraft] = useState(state.name ?? "")
+ const inputRef = useRef(null)
+ const setName = useSetName(uuid)
+ const addUser = useAddUser(uuid)
+ const removeUser = useRemoveUser(uuid)
+ const finishSetup = useFinishSetup(uuid)
async function handleAdd() {
- const name = draft.trim();
- if (!name) return;
- await addUser.mutateAsync(name);
- setDraft("");
- inputRef.current?.focus();
+ const name = draft.trim()
+ if (!name) return
+ await addUser.mutateAsync(name)
+ setDraft("")
+ inputRef.current?.focus()
+ }
+
+ function handleNameBlur() {
+ const trimmed = nameDraft.trim()
+ if (trimmed !== (state.name ?? "")) {
+ setName.mutate(trimmed)
+ }
}
return (
<>
+ Session Name{" "}
+ · optional
+
+
+ setNameDraft(e.target.value)}
+ onBlur={handleNameBlur}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ e.preventDefault()
+ ;(e.target as HTMLInputElement).blur()
+ }
+ }}
+ />
+
+
Add People
@@ -61,8 +110,8 @@ function AdminSetup({ uuid, state }: { uuid: string; state: State }) {
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
- e.preventDefault();
- handleAdd();
+ e.preventDefault()
+ handleAdd()
}
}}
/>
@@ -77,11 +126,15 @@ function AdminSetup({ uuid, state }: { uuid: string; state: State }) {
{state.users.map((name) => (
- -
- {name}
+
-
+
))}
@@ -95,13 +148,13 @@ function AdminSetup({ uuid, state }: { uuid: string; state: State }) {
Next →
- {(addUser.error || finishSetup.error) && (
+ {(addUser.error || removeUser.error || finishSetup.error) && (
- {(addUser.error ?? finishSetup.error)?.message}
+ {(addUser.error ?? removeUser.error ?? finishSetup.error)?.message}
)}
>
- );
+ )
}
function AdminStatus({
@@ -109,46 +162,54 @@ function AdminStatus({
state,
onRefresh,
}: {
- uuid: string;
- state: State;
- onRefresh: () => void;
+ uuid: string
+ state: State
+ onRefresh: () => void
}) {
- const newRound = useNewRound(uuid);
- const d1 = state.doneUsersPhase1 ?? [];
- const d2 = state.doneUsersPhase2 ?? [];
- const totalSteps = state.users.length * 2;
- const completedSteps = d1.length + d2.length;
+ const newRound = useNewRound(uuid)
+ const d1 = state.doneUsersPhase1 ?? []
+ const d2 = state.doneUsersPhase2 ?? []
+ const totalSteps = state.users.length * 2
+ const completedSteps = d1.length + d2.length
const phaseTitle =
state.phase === 1
? "Phase 1 · Movie Collection"
: state.phase === 2
? "Phase 2 · Voting"
- : "Phase 3 · Results";
+ : "Phase 3 · Results"
function computeWinner(): string {
- const movieTitles = state.movies.map((m) => m.title);
- if (movieTitles.length === 0) return "";
+ const movieTitles = state.movies.map((m) => m.title)
+ if (movieTitles.length === 0) return ""
const ratings = collectCompleteRatings(
state.users,
movieTitles,
state.votes ?? {},
- );
- const voters = Object.keys(ratings);
- if (voters.length === 0) return "";
+ )
+ const voters = Object.keys(ratings)
+ if (voters.length === 0) return ""
return decideMovie({
movies: movieTitles,
people: voters,
ratings,
- }).winner.movie;
+ }).winner.movie
}
function handleCopy(link: string, btn: HTMLButtonElement) {
navigator.clipboard.writeText(link).then(() => {
- btn.textContent = "✓ Copied";
+ btn.textContent = "✓ Copied"
setTimeout(() => {
- btn.textContent = "Copy";
- }, 1400);
- });
+ btn.textContent = "Copy"
+ }, 1400)
+ })
+ }
+
+ function handleShare(link: string, name: string) {
+ if (navigator.share) {
+ navigator.share({ title: `Movie Select — ${name}`, url: link })
+ } else {
+ navigator.clipboard.writeText(link)
+ }
}
return (
@@ -166,9 +227,9 @@ function AdminStatus({