From d945949fede17bc448440b07f48534069cc84fa0 Mon Sep 17 00:00:00 2001 From: Daniel Bulant Date: Fri, 1 May 2026 20:53:24 +0200 Subject: [PATCH] format, prepare party view --- api/src/index.ts | 2 + api/src/party-data.ts | 7 +- api/src/party-types.ts | 21 +- api/src/party/state.ts | 42 ++++ api/src/routes/party-socket.ts | 5 +- api/src/routes/quiz.ts | 11 +- api/src/workflows/quiz.ts | 76 +++---- web/src/components/icons/Spotify.tsx | 78 ++++---- web/src/components/login-form.tsx | 64 +++--- web/src/components/party-qr.tsx | 166 ++++++++-------- web/src/components/party/party-view.tsx | 6 + web/src/components/quick-stats.tsx | 188 +++++++++--------- web/src/components/start-party.tsx | 25 +++ web/src/components/sync-button.tsx | 2 +- web/src/components/ui/main-content.tsx | 10 +- web/src/components/ui/section.tsx | 28 +-- web/src/components/user-info.tsx | 1 - web/src/hooks/use-party-socket.ts | 1 + web/src/hooks/use-party.ts | 1 - web/src/lib/auth-client.ts | 20 +- web/src/lib/party-join.ts | 88 ++++----- web/src/lib/utils.ts | 10 +- web/src/routes/__root.tsx | 250 ++++++++++++------------ web/src/routes/index.tsx | 6 + web/vite.config.ts | 3 +- 25 files changed, 583 insertions(+), 528 deletions(-) create mode 100644 api/src/party/state.ts create mode 100644 web/src/components/party/party-view.tsx create mode 100644 web/src/components/start-party.tsx diff --git a/api/src/index.ts b/api/src/index.ts index 11bb024..ab8682a 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -8,6 +8,7 @@ import "./workflows/party-analysis"; import "./dbos.ts"; import { partyApp } from "./routes/party"; import { partySocketApp, pubsub } from "./routes/party-socket"; +import { quizRoutes } from "./routes/quiz.ts"; import { statsApp } from "./routes/stats.ts"; const app = new Elysia() @@ -19,6 +20,7 @@ const app = new Elysia() .use(partyApp) .use(partyAnalysisApp) .use(partySocketApp) + .use(quizRoutes) .get("/", () => ({ ok: true })), ) .listen(4000); diff --git a/api/src/party-data.ts b/api/src/party-data.ts index e2839d3..7b8222a 100644 --- a/api/src/party-data.ts +++ b/api/src/party-data.ts @@ -1,7 +1,7 @@ import { eq } from "drizzle-orm"; import { db } from "./db"; import { party, partyMember } from "./db/schema"; -import type { PartySnapshot } from "./party-types"; +import type { PartySnapshot, QuizState } from "./party-types"; type DbClient = typeof db; type DbTransaction = Parameters[0] extends ( @@ -58,7 +58,10 @@ export async function getPartyStatus( }, }); return { - party: partyRecord, + party: { + ...partyRecord, + data: partyRecord.data as QuizState, + }, members, }; } diff --git a/api/src/party-types.ts b/api/src/party-types.ts index f1a888d..33d4ec0 100644 --- a/api/src/party-types.ts +++ b/api/src/party-types.ts @@ -1,7 +1,9 @@ import type { InferSelectModel } from "drizzle-orm"; import type { party, partyMember, user } from "./db/schema"; -export type Party = InferSelectModel; +export type Party = Omit, "data"> & { + data: QuizState; +}; export type PartyMember = InferSelectModel; export type User = InferSelectModel; @@ -24,15 +26,20 @@ export type PartySocketOutgoing = | { type: "ping" } | { type: "member_payload"; payload: unknown }; +export type Question = { + text: string; + options: string[]; + correct: number; + startTimestamp: number; + endTimestamp: number; + points: number; +}; + export type QuizState = { - status: "idle" | "running" | "results"; + status: "running" | "results"; workflowId: string | null; questionIndex: number; - currentQuestion: { - text: string; - options: string[]; - correct: number; - } | null; + currentQuestion: Question | null; answers: Record< string, { playerId: string; selected: number; correct: boolean } diff --git a/api/src/party/state.ts b/api/src/party/state.ts new file mode 100644 index 0000000..df01141 --- /dev/null +++ b/api/src/party/state.ts @@ -0,0 +1,42 @@ +import { eq } from "drizzle-orm"; +import type { db as Db } from "../db"; +import { party } from "../db/schema"; +import type { QuizState } from "../party-types"; +import { pubsub } from "../routes/party-socket"; + +export async function updatePartyData( + db: typeof Db, + id: string, + data: QuizState, +) { + const members = await db.query.partyMember.findMany({ + where: { + partyId: id, + }, + with: { + user: true, + }, + }); + const partyObject = await db.query.party.findFirst({ + where: { + id, + }, + }); + if (!partyObject) throw new Error("Missing party"); + + pubsub.publishPartyData(id, { + type: "party_status", + party: { + ...partyObject, + data, + }, + members, + }); + await db + .update(party) + .set({ + data: data, + lastUpdated: new Date(), + }) + .where(eq(party.id, id)); +} diff --git a/api/src/routes/party-socket.ts b/api/src/routes/party-socket.ts index 8b4f1e7..a7a152a 100644 --- a/api/src/routes/party-socket.ts +++ b/api/src/routes/party-socket.ts @@ -4,7 +4,7 @@ import { betterAuthElysia } from "../auth"; import { db } from "../db"; import { getMemberRecord, getPartyStatus } from "../party-data"; -import type { QuizState } from "../party-types"; +import type { PartySocketEvent, QuizState } from "../party-types"; function userTopic(userId: string) { return `user:${userId}`; @@ -24,6 +24,9 @@ export const pubsub = { publish(topic: string, data: string) { this._server?.publish(topic, data); }, + publishPartyData(partyId: string, data: PartySocketEvent) { + pubsub.publish(`party:${partyId}`, JSON.stringify(data)); + }, }; async function broadcastQuizState(ws: any, partyId: string) { diff --git a/api/src/routes/quiz.ts b/api/src/routes/quiz.ts index cdc65d8..e28ceb7 100644 --- a/api/src/routes/quiz.ts +++ b/api/src/routes/quiz.ts @@ -158,16 +158,7 @@ export const quizRoutes = new Elysia() ).quiz as QuizState | undefined; return { - quiz: - quizData ?? - ({ - status: "idle", - workflowId: null, - questionIndex: 0, - currentQuestion: null, - answers: {}, - scores: {}, - } satisfies QuizState), + quiz: quizData, }; }, { auth: true }, diff --git a/api/src/workflows/quiz.ts b/api/src/workflows/quiz.ts index 537eb02..1c2025d 100644 --- a/api/src/workflows/quiz.ts +++ b/api/src/workflows/quiz.ts @@ -1,12 +1,11 @@ import { ConfiguredInstance, DBOS, WorkflowQueue } from "@dbos-inc/dbos-sdk"; import { eq } from "drizzle-orm"; import { db } from "../db"; -import { party, partyMember } from "../db/schema"; +import { partyMember } from "../db/schema"; +import { updatePartyData } from "../party/state"; import type { QuizState } from "../party-types"; -import { pubsub } from "../routes/party-socket"; const TOTAL_QUESTIONS = 5; -const QUESTION_TIMEOUT_SECONDS = 30; export const quizQueue = new WorkflowQueue("quiz_queue", { concurrency: 1, @@ -36,7 +35,6 @@ export class QuizWorkflow extends ConfiguredInstance { // Initialize quiz state await this.updatePartyData(partyId, quizState); - await this.broadcastState(partyId, quizState); // Get party members to initialize scores const members = await this.getPartyMembers(partyId); @@ -52,20 +50,14 @@ export class QuizWorkflow extends ConfiguredInstance { quizState.answers = {}; await this.updatePartyData(partyId, quizState); - await this.broadcastState(partyId, quizState); - await DBOS.setEvent(`quiz_q${i}_question`, question); - await DBOS.setEvent(`quiz_q${i}_status`, "question"); - await DBOS.setEvent(`quiz_q${i}_index`, i); - // Wait for all responses with timeout const memberIds = new Set(members.map((m) => m.userId)); const receivedPlayers = new Set(); while (receivedPlayers.size < memberIds.size) { - const response = await DBOS.recv( - "quiz_responses", - QUESTION_TIMEOUT_SECONDS, - ); + const response = await DBOS.recv("quiz_responses", { + deadlineEpochMS: question.endTimestamp, + }); if (response === null) { // Timeout - fill in missing players with no answer @@ -77,9 +69,9 @@ export class QuizWorkflow extends ConfiguredInstance { selected: -1, correct: false, }; + await this.updatePartyData(partyId, quizState); } } - await this.broadcastState(partyId, quizState); break; } @@ -92,23 +84,16 @@ export class QuizWorkflow extends ConfiguredInstance { if (isCorrect) { quizState.scores[response.playerId] = - (quizState.scores[response.playerId] ?? 0) + 10; + (quizState.scores[response.playerId] ?? 0) + question.points; } await this.updatePartyData(partyId, quizState); - await this.broadcastState(partyId, quizState); - await DBOS.setEvent(`quiz_q${i}_answers`, quizState.answers); - await DBOS.setEvent(`quiz_q${i}_scores`, quizState.scores); - await DBOS.setEvent(`quiz_q${i}_status`, "results"); } } // Quiz complete quizState.status = "results"; await this.updatePartyData(partyId, quizState); - await this.broadcastState(partyId, quizState); - await DBOS.setEvent("quiz_final_status", "results"); - await DBOS.setEvent("quiz_final_scores", quizState.scores); } @DBOS.step() @@ -116,25 +101,8 @@ export class QuizWorkflow extends ConfiguredInstance { partyId: string, quizState: QuizState, ): Promise { - await db.transaction(async (tx) => { - const currentParty = await tx - .select({ data: party.data }) - .from(party) - .where(eq(party.id, partyId)) - .limit(1) - .then((rows) => rows[0]); - - if (!currentParty) return; - - const currentData = (currentParty.data ?? {}) as Record; - await tx - .update(party) - .set({ - data: { ...currentData, quiz: quizState }, - lastUpdated: new Date(), - }) - .where(eq(party.id, partyId)); - }); + console.log(partyId, quizState); + await updatePartyData(db, partyId, quizState); } @DBOS.step() @@ -142,37 +110,46 @@ export class QuizWorkflow extends ConfiguredInstance { text: string; options: string[]; correct: number; + startTimestamp: number; + endTimestamp: number; + points: number; }> { // Placeholder - returns same question for now, question generation comes later const questions: { text: string; options: string[]; correct: number; + points: number; }[] = [ { text: "What is the most common genre in your party's shared taste?", options: ["Hip-Hop", "Rock", "Electronic", "Jazz"], correct: 0, + points: 10, }, { text: "Which artist do most party members follow?", options: ["Artist A", "Artist B", "Artist C", "Artist D"], correct: 1, + points: 10, }, { text: "What percentage of the party shares at least 1 album?", options: ["0-25%", "25-50%", "50-75%", "75-100%"], correct: 2, + points: 10, }, { text: "Who has the most diverse taste in the party?", options: ["Player A", "Player B", "Player C", "Player D"], correct: 0, + points: 10, }, { text: "Which track appears most in everyone's top 50?", options: ["Track A", "Track B", "Track C", "Track D"], correct: 3, + points: 10, }, ]; @@ -180,18 +157,11 @@ export class QuizWorkflow extends ConfiguredInstance { if (!question) { throw new Error("Question not found"); } - return question; - } - - @DBOS.step() - private async broadcastState( - partyId: string, - quizState: QuizState, - ): Promise { - pubsub.publish( - `party:${partyId}`, - JSON.stringify({ type: "quiz_state", quiz: quizState }), - ); + return { + ...question, + startTimestamp: Date.now(), + endTimestamp: Date.now() + 60_000, + }; } @DBOS.step() diff --git a/web/src/components/icons/Spotify.tsx b/web/src/components/icons/Spotify.tsx index 1d46d35..68eb4c8 100644 --- a/web/src/components/icons/Spotify.tsx +++ b/web/src/components/icons/Spotify.tsx @@ -1,47 +1,45 @@ -import React from "react"; - const SpotifyIconIcon = ({ - size = undefined, - color = "#000000", - background = "transparent", - opacity = 1, - rotation = 0, - shadow = 0, - flipHorizontal = false, - flipVertical = false, - padding = 0, + size = undefined, + color = "#000000", + background = "transparent", + opacity = 1, + rotation = 0, + shadow = 0, + flipHorizontal = false, + flipVertical = false, + padding = 0, }) => { - const transforms = []; - if (rotation !== 0) transforms.push(`rotate(${rotation}deg)`); - if (flipHorizontal) transforms.push("scaleX(-1)"); - if (flipVertical) transforms.push("scaleY(-1)"); + const transforms = []; + if (rotation !== 0) transforms.push(`rotate(${rotation}deg)`); + if (flipHorizontal) transforms.push("scaleX(-1)"); + if (flipVertical) transforms.push("scaleY(-1)"); - const viewBoxSize = 256 + padding * 2; - const viewBoxOffset = -padding; - const viewBox = `${viewBoxOffset} ${viewBoxOffset} ${viewBoxSize} ${viewBoxSize}`; + const viewBoxSize = 256 + padding * 2; + const viewBoxOffset = -padding; + const viewBox = `${viewBoxOffset} ${viewBoxOffset} ${viewBoxSize} ${viewBoxSize}`; - return ( - 0 - ? `drop-shadow(0 ${shadow}px ${shadow * 2}px rgba(0,0,0,0.3))` - : undefined, - backgroundColor: background !== "transparent" ? background : undefined, - }} - > - - - ); + return ( + 0 + ? `drop-shadow(0 ${shadow}px ${shadow * 2}px rgba(0,0,0,0.3))` + : undefined, + backgroundColor: background !== "transparent" ? background : undefined, + }} + > + + + ); }; export default SpotifyIconIcon; diff --git a/web/src/components/login-form.tsx b/web/src/components/login-form.tsx index 9a574b8..0930366 100644 --- a/web/src/components/login-form.tsx +++ b/web/src/components/login-form.tsx @@ -1,42 +1,42 @@ import { Button } from "#/components/ui/button"; import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, } from "#/components/ui/card"; import { authClient } from "#/lib/auth-client"; import { cn } from "#/lib/utils"; import SpotifyFilledIcon from "./icons/Spotify"; export function LoginForm({ - className, - ...props + className, + ...props }: React.ComponentProps<"div">) { - return ( -
- - - Login - - Connect your streaming account to continue. - - - - - - -
- ); + return ( +
+ + + Login + + Connect your streaming account to continue. + + + + + + +
+ ); } diff --git a/web/src/components/party-qr.tsx b/web/src/components/party-qr.tsx index c0005c9..74b8407 100644 --- a/web/src/components/party-qr.tsx +++ b/web/src/components/party-qr.tsx @@ -1,15 +1,15 @@ -import { useEffect, useMemo, useState } from "react"; import QRCode from "qrcode"; +import { useEffect, useMemo, useState } from "react"; import { Button } from "#/components/ui/button"; import { - Dialog, - DialogClose, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, } from "#/components/ui/dialog"; import { Item, ItemActions, ItemDescription } from "#/components/ui/item"; import { useUser } from "#/hooks/user"; @@ -17,82 +17,86 @@ import { useUser } from "#/hooks/user"; const QR_SIZE = 220; export function PartyQr() { - const { user } = useUser(); - const [dataUrl, setDataUrl] = useState(null); + const { user } = useUser(); + const [dataUrl, setDataUrl] = useState(null); - const joinUrl = useMemo(() => { - if (typeof window === "undefined" || !user?.id) return null; - const url = new URL(window.location.href); - url.searchParams.delete("redirect"); - url.searchParams.set("join", user.id); - return url.toString(); - }, [user?.id]); + const joinUrl = useMemo(() => { + if (typeof window === "undefined" || !user?.id) return null; + const url = new URL(window.location.href); + url.searchParams.delete("redirect"); + url.searchParams.set("join", user.id); + return url.toString(); + }, [user?.id]); - useEffect(() => { - let isMounted = true; - if (!joinUrl) { - setDataUrl(null); - return undefined; - } + useEffect(() => { + let isMounted = true; + if (!joinUrl) { + setDataUrl(null); + return undefined; + } - QRCode.toDataURL(joinUrl, { width: QR_SIZE, margin: 1 }) - .then((url) => { - if (isMounted) { - setDataUrl(url); - } - }) - .catch(() => { - if (isMounted) { - setDataUrl(null); - } - }); + QRCode.toDataURL(joinUrl, { width: QR_SIZE, margin: 1 }) + .then((url) => { + if (isMounted) { + setDataUrl(url); + } + }) + .catch(() => { + if (isMounted) { + setDataUrl(null); + } + }); - return () => { - isMounted = false; - }; - }, [joinUrl]); + return () => { + isMounted = false; + }; + }, [joinUrl]); - if (!user?.id) return null; + if (!user?.id) return null; - return ( - - Invite someone to your party - - - }>Show QR - - - Party invite - - Scan to join your party on another device. - - -
- {dataUrl ? ( - Party invite QR code - ) : ( -
- Generating QR... -
- )} - {joinUrl ? ( -

- {joinUrl} -

- ) : null} -
- - }>Close - -
-
-
-
- ); + return ( + + Invite someone to your party + + + }> + Show QR + + + + Party invite + + Scan to join your party on another device. + + +
+ {dataUrl ? ( + Party invite QR code + ) : ( +
+ Generating QR... +
+ )} + {joinUrl ? ( +

+ {joinUrl} +

+ ) : null} +
+ + }> + Close + + +
+
+
+
+ ); } diff --git a/web/src/components/party/party-view.tsx b/web/src/components/party/party-view.tsx new file mode 100644 index 0000000..7e8a5c1 --- /dev/null +++ b/web/src/components/party/party-view.tsx @@ -0,0 +1,6 @@ +import { useParty } from "#/hooks/use-party"; + +export function PartyView() { + const { party } = useParty(); + if (!party) return null; +} diff --git a/web/src/components/quick-stats.tsx b/web/src/components/quick-stats.tsx index 3e33a0b..93b5ad2 100644 --- a/web/src/components/quick-stats.tsx +++ b/web/src/components/quick-stats.tsx @@ -1,107 +1,107 @@ -import { client } from "#/lib/eden"; import { useQuery } from "@tanstack/react-query"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "#/components/ui/card"; import { Avatar, AvatarGroup, AvatarImage } from "#/components/ui/avatar"; -import { - Item, - ItemContent, - ItemDescription, - ItemGroup, - ItemMedia, - ItemTitle, -} from "#/components/ui/item"; import { Badge } from "#/components/ui/badge"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "#/components/ui/card"; +import { + Item, + ItemContent, + ItemDescription, + ItemGroup, + ItemMedia, + ItemTitle, +} from "#/components/ui/item"; import { Spinner } from "#/components/ui/spinner"; +import { client } from "#/lib/eden"; import { Section, SectionTitle } from "./ui/section"; const MAX_AVATARS = 6; export function QuickStats() { - const { isLoading, data } = useQuery({ - queryFn: () => client.api.stats.get(), - queryKey: ["stats"], - }); + const { isLoading, data } = useQuery({ + queryFn: () => client.api.stats.get(), + queryKey: ["stats"], + }); - if (isLoading) { - return ( - - - Quick stats - Loading your music highlights. - - -
- - Fetching stats -
-
-
- ); - } + if (isLoading) { + return ( + + + Quick stats + Loading your music highlights. + + +
+ + Fetching stats +
+
+
+ ); + } - const topArtists = data?.data?.topArtists ?? []; - const topTracks = data?.data?.topTracks ?? []; - const topGenres = data?.data?.topGenres ?? []; + const topArtists = data?.data?.topArtists ?? []; + const topTracks = data?.data?.topTracks ?? []; + const topGenres = data?.data?.topGenres ?? []; - return ( -
- Data overview - - - - - {topArtists.slice(0, MAX_AVATARS).map((entry) => ( - - - - ))} - - - - Artists - - - - - - {topTracks.slice(0, MAX_AVATARS).map((entry) => ( - - - - ))} - - - - Tracks - - - - - Genres - - {topGenres.length === 0 - ? "No genres yet." - : topGenres.slice(0, 8).map((genre) => ( - - {genre.name} - - ))} - - - - -
- ); + return ( +
+ Data overview + + + + + {topArtists.slice(0, MAX_AVATARS).map((entry) => ( + + + + ))} + + + + Artists + + + + + + {topTracks.slice(0, MAX_AVATARS).map((entry) => ( + + + + ))} + + + + Tracks + + + + + Genres + + {topGenres.length === 0 + ? "No genres yet." + : topGenres.slice(0, 8).map((genre) => ( + + {genre.name} + + ))} + + + + +
+ ); } diff --git a/web/src/components/start-party.tsx b/web/src/components/start-party.tsx new file mode 100644 index 0000000..0ca5624 --- /dev/null +++ b/web/src/components/start-party.tsx @@ -0,0 +1,25 @@ +import { useParty } from "#/hooks/use-party"; +import { client } from "#/lib/eden"; +import { Button } from "./ui/button"; +import { Empty, EmptyContent, EmptyHeader, EmptyTitle } from "./ui/empty"; + +export function StartParty() { + const { party } = useParty(); + if (!party) return null; + return ( + + + Start party + + + + + + ); +} diff --git a/web/src/components/sync-button.tsx b/web/src/components/sync-button.tsx index 7c4ad11..f108412 100644 --- a/web/src/components/sync-button.tsx +++ b/web/src/components/sync-button.tsx @@ -3,7 +3,7 @@ import { useState } from "react"; import { toast } from "sonner"; import { client } from "#/lib/eden"; import { Button } from "./ui/button"; -import { Item, ItemActions, ItemDescription, ItemTitle } from "./ui/item"; +import { Item, ItemActions, ItemDescription } from "./ui/item"; export function SyncButton() { const query = useQueryClient(); diff --git a/web/src/components/ui/main-content.tsx b/web/src/components/ui/main-content.tsx index 4cb77f1..a554a83 100644 --- a/web/src/components/ui/main-content.tsx +++ b/web/src/components/ui/main-content.tsx @@ -1,11 +1,11 @@ import { cn } from "#/lib/utils"; export function MainContent({ - children, - className, + children, + className, }: { - children: React.ReactNode; - className?: string; + children: React.ReactNode; + className?: string; }) { - return
{children}
; + return
{children}
; } diff --git a/web/src/components/ui/section.tsx b/web/src/components/ui/section.tsx index 33508cc..41c78a1 100644 --- a/web/src/components/ui/section.tsx +++ b/web/src/components/ui/section.tsx @@ -1,25 +1,25 @@ import { cn } from "#/lib/utils"; export function Section({ - className, - children, + className, + children, }: { - className?: string; - children: React.ReactNode; + className?: string; + children: React.ReactNode; }) { - return
{children}
; + return
{children}
; } export function SectionTitle({ - children, - className, + children, + className, }: { - children: React.ReactNode; - className?: string; + children: React.ReactNode; + className?: string; }) { - return ( -

- {children} -

- ); + return ( +

+ {children} +

+ ); } diff --git a/web/src/components/user-info.tsx b/web/src/components/user-info.tsx index 9c4c2dd..d568e1f 100644 --- a/web/src/components/user-info.tsx +++ b/web/src/components/user-info.tsx @@ -1,4 +1,3 @@ -import { useRouteContext } from "@tanstack/react-router"; import { useParty } from "#/hooks/use-party"; import { useUser } from "#/hooks/user"; import { initials } from "#/lib/utils"; diff --git a/web/src/hooks/use-party-socket.ts b/web/src/hooks/use-party-socket.ts index 2b901b9..6691aa7 100644 --- a/web/src/hooks/use-party-socket.ts +++ b/web/src/hooks/use-party-socket.ts @@ -42,6 +42,7 @@ export function usePartySocket({ ws.onmessage = (event) => { const parsed = JSON.parse(event.data) as PartySocketEvent; + console.log(parsed); handlerRef.current?.(parsed); }; diff --git a/web/src/hooks/use-party.ts b/web/src/hooks/use-party.ts index 6ea1243..37ca048 100644 --- a/web/src/hooks/use-party.ts +++ b/web/src/hooks/use-party.ts @@ -1,6 +1,5 @@ import { useCallback, useMemo, useState } from "react"; import type { - PartyMember, PartySocketEvent, PartyState, } from "../../../api/src/party-types"; diff --git a/web/src/lib/auth-client.ts b/web/src/lib/auth-client.ts index 44c6fef..7b205d3 100644 --- a/web/src/lib/auth-client.ts +++ b/web/src/lib/auth-client.ts @@ -1,24 +1,24 @@ +import type { QueryClient } from "@tanstack/react-query"; import { createAuthClient } from "better-auth/react"; import type { AuthSession } from "./auth.serverfn"; -import type { QueryClient } from "@tanstack/react-query"; export const authClient = createAuthClient(); export const sessionQueryKey = ["auth", "session"] as const; export async function fetchSession(): Promise { - const { data } = await authClient.getSession(); - return data ?? null; + const { data } = await authClient.getSession(); + return data ?? null; } export async function signOutAndClearQueryCache({ - navigateToLogin, - queryClient, + navigateToLogin, + queryClient, }: { - navigateToLogin: () => Promise | void; - queryClient: QueryClient; + navigateToLogin: () => Promise | void; + queryClient: QueryClient; }): Promise { - await authClient.signOut(); - queryClient.clear(); - await navigateToLogin(); + await authClient.signOut(); + queryClient.clear(); + await navigateToLogin(); } diff --git a/web/src/lib/party-join.ts b/web/src/lib/party-join.ts index d5aa8d0..3565aa8 100644 --- a/web/src/lib/party-join.ts +++ b/web/src/lib/party-join.ts @@ -1,66 +1,66 @@ const pendingPartyKey = "pendingPartyJoin"; export function readPendingPartyJoin(): string | null { - if (typeof window === "undefined") return null; - return window.localStorage.getItem(pendingPartyKey); + if (typeof window === "undefined") return null; + return window.localStorage.getItem(pendingPartyKey); } export function writePendingPartyJoin(partyHostId: string) { - if (typeof window === "undefined") return; - window.localStorage.setItem(pendingPartyKey, partyHostId); + if (typeof window === "undefined") return; + window.localStorage.setItem(pendingPartyKey, partyHostId); } export function clearPendingPartyJoin() { - if (typeof window === "undefined") return; - window.localStorage.removeItem(pendingPartyKey); + if (typeof window === "undefined") return; + window.localStorage.removeItem(pendingPartyKey); } export function getJoinIdFromLocation(): string | null { - if (typeof window === "undefined") return null; - const params = new URLSearchParams(window.location.search); - const join = params.get("join"); - if (join) return join; - const redirect = params.get("redirect"); - if (!redirect) return null; + if (typeof window === "undefined") return null; + const params = new URLSearchParams(window.location.search); + const join = params.get("join"); + if (join) return join; + const redirect = params.get("redirect"); + if (!redirect) return null; - try { - const redirectUrl = new URL(redirect, window.location.origin); - return redirectUrl.searchParams.get("join"); - } catch { - return null; - } + try { + const redirectUrl = new URL(redirect, window.location.origin); + return redirectUrl.searchParams.get("join"); + } catch { + return null; + } } export function clearJoinIdFromLocation() { - if (typeof window === "undefined") return; - const url = new URL(window.location.href); - const hasJoin = url.searchParams.has("join"); - const redirect = url.searchParams.get("redirect"); - let updatedRedirect: string | null = null; + if (typeof window === "undefined") return; + const url = new URL(window.location.href); + const hasJoin = url.searchParams.has("join"); + const redirect = url.searchParams.get("redirect"); + let updatedRedirect: string | null = null; - if (redirect) { - try { - const redirectUrl = new URL(redirect, window.location.origin); - if (redirectUrl.searchParams.has("join")) { - redirectUrl.searchParams.delete("join"); - updatedRedirect = - redirectUrl.pathname + redirectUrl.search + redirectUrl.hash; - } - } catch { - updatedRedirect = null; - } - } + if (redirect) { + try { + const redirectUrl = new URL(redirect, window.location.origin); + if (redirectUrl.searchParams.has("join")) { + redirectUrl.searchParams.delete("join"); + updatedRedirect = + redirectUrl.pathname + redirectUrl.search + redirectUrl.hash; + } + } catch { + updatedRedirect = null; + } + } - if (!hasJoin && !updatedRedirect) return; + if (!hasJoin && !updatedRedirect) return; - if (hasJoin) { - url.searchParams.delete("join"); - } + if (hasJoin) { + url.searchParams.delete("join"); + } - if (updatedRedirect) { - url.searchParams.set("redirect", updatedRedirect); - } + if (updatedRedirect) { + url.searchParams.set("redirect", updatedRedirect); + } - const nextUrl = url.pathname + url.search + url.hash; - window.history.replaceState({}, "", nextUrl); + const nextUrl = url.pathname + url.search + url.hash; + window.history.replaceState({}, "", nextUrl); } diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index d88c150..7957790 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -2,12 +2,12 @@ import { type ClassValue, clsx } from "clsx"; import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)); + return twMerge(clsx(inputs)); } export function initials(name: string) { - return name - .split(" ") - .map((t) => t[0]) - .join(""); + return name + .split(" ") + .map((t) => t[0]) + .join(""); } diff --git a/web/src/routes/__root.tsx b/web/src/routes/__root.tsx index ecb60e0..e40b640 100644 --- a/web/src/routes/__root.tsx +++ b/web/src/routes/__root.tsx @@ -1,152 +1,152 @@ import { TanStackDevtools } from "@tanstack/react-devtools"; import type { QueryClient } from "@tanstack/react-query"; import { - createRootRouteWithContext, - HeadContent, - redirect, - Scripts, + createRootRouteWithContext, + HeadContent, + redirect, + Scripts, } from "@tanstack/react-router"; import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools"; +import type * as React from "react"; +import { useEffect } from "react"; +import { toast } from "sonner"; +import type { AuthSession } from "#/lib/auth.serverfn"; +import { fetchSession, sessionQueryKey } from "#/lib/auth-client"; +import { client } from "#/lib/eden"; +import { + clearJoinIdFromLocation, + clearPendingPartyJoin, + getJoinIdFromLocation, + readPendingPartyJoin, + writePendingPartyJoin, +} from "#/lib/party-join"; import { Toaster } from "@/components/ui/sonner"; import TanStackQueryDevtools from "../integrations/tanstack-query/devtools"; import appCss from "../styles.css?url"; -import type { AuthSession } from "#/lib/auth.serverfn"; -import { fetchSession, sessionQueryKey } from "#/lib/auth-client"; -import type * as React from "react"; -import { useEffect } from "react"; -import { client } from "#/lib/eden"; -import { - clearPendingPartyJoin, - clearJoinIdFromLocation, - getJoinIdFromLocation, - readPendingPartyJoin, - writePendingPartyJoin, -} from "#/lib/party-join"; -import { toast } from "sonner"; interface MyRouterContext { - queryClient: QueryClient; + queryClient: QueryClient; } export const Route = createRootRouteWithContext()({ - head: () => ({ - meta: [ - { - charSet: "utf-8", - }, - { - name: "viewport", - content: "width=device-width, initial-scale=1", - }, - { - title: "TanStack Start Starter", - }, - ], - links: [ - { - rel: "stylesheet", - href: appCss, - }, - ], - }), - shellComponent: RootDocument, - beforeLoad: async ({ context, location }) => { - const authPublicPaths = new Set(["/login"]); - const isAuthPublicPath = authPublicPaths.has(location.pathname); - let session: AuthSession | null; - if (typeof window === "undefined") { - const { getSession } = await import("../lib/auth.serverfn"); - session = await getSession(); - } else { - session = await context.queryClient.fetchQuery({ - queryKey: sessionQueryKey, - queryFn: fetchSession, - staleTime: 30_000, - }); - } + head: () => ({ + meta: [ + { + charSet: "utf-8", + }, + { + name: "viewport", + content: "width=device-width, initial-scale=1", + }, + { + title: "Music Quiz", + }, + ], + links: [ + { + rel: "stylesheet", + href: appCss, + }, + ], + }), + shellComponent: RootDocument, + beforeLoad: async ({ context, location }) => { + const authPublicPaths = new Set(["/login"]); + const isAuthPublicPath = authPublicPaths.has(location.pathname); + let session: AuthSession | null; + if (typeof window === "undefined") { + const { getSession } = await import("../lib/auth.serverfn"); + session = await getSession(); + } else { + session = await context.queryClient.fetchQuery({ + queryKey: sessionQueryKey, + queryFn: fetchSession, + staleTime: 30_000, + }); + } - const user = session?.user; + const user = session?.user; - if (!user && !isAuthPublicPath) { - throw redirect({ - to: "/login", - search: { redirect: location.href }, - }); - } + if (!user && !isAuthPublicPath) { + throw redirect({ + to: "/login", + search: { redirect: location.href }, + }); + } - return { user, session }; - }, + return { user, session }; + }, }); function RootDocument({ children }: { children: React.ReactNode }) { - const { user } = Route.useRouteContext(); + const { user } = Route.useRouteContext(); - useEffect(() => { - if (typeof window === "undefined") return; - const joinId = getJoinIdFromLocation(); - if (!joinId) return; - const storedId = readPendingPartyJoin(); - if (storedId !== joinId) { - writePendingPartyJoin(joinId); - } - clearJoinIdFromLocation(); - }, []); + useEffect(() => { + if (typeof window === "undefined") return; + const joinId = getJoinIdFromLocation(); + if (!joinId) return; + const storedId = readPendingPartyJoin(); + if (storedId !== joinId) { + writePendingPartyJoin(joinId); + } + clearJoinIdFromLocation(); + }, []); - useEffect(() => { - if (!user) return; - const joinId = readPendingPartyJoin(); - if (!joinId) return; + useEffect(() => { + if (!user) return; + const joinId = readPendingPartyJoin(); + if (!joinId) return; - let cancelled = false; - const attemptJoin = async () => { - try { - const result = await client.api.party.join.post({ - targetUserId: joinId, - }); + let cancelled = false; + const attemptJoin = async () => { + try { + const result = await client.api.party.join.post({ + targetUserId: joinId, + }); - if (result?.error) { - toast.error(result.error); - clearPendingPartyJoin(); - } else { - toast.success("Joined party."); - clearPendingPartyJoin(); - } - } catch (error) { - if (!cancelled) { - toast.error("Failed to join party."); - } - } - }; + if (result?.error) { + toast.error(result.error); + clearPendingPartyJoin(); + } else { + toast.success("Joined party."); + clearPendingPartyJoin(); + } + } catch (_error) { + if (!cancelled) { + toast.error("Failed to join party."); + } + } + }; - attemptJoin(); + attemptJoin(); - return () => { - cancelled = true; - }; - }, [user]); + return () => { + cancelled = true; + }; + }, [user]); - return ( - - - - - - {children} - - , - }, - TanStackQueryDevtools, - ]} - /> - - - - ); + return ( + + + + + + {children} + + , + }, + TanStackQueryDevtools, + ]} + /> + + + + ); } diff --git a/web/src/routes/index.tsx b/web/src/routes/index.tsx index fd496ac..ad4946c 100644 --- a/web/src/routes/index.tsx +++ b/web/src/routes/index.tsx @@ -1,19 +1,25 @@ import { createFileRoute } from "@tanstack/react-router"; +import { PartyView } from "#/components/party/party-view"; import { PartyQr } from "#/components/party-qr"; +import { StartParty } from "#/components/start-party"; import { SyncButton } from "#/components/sync-button"; import { MainContent } from "#/components/ui/main-content"; import { UserInfo } from "#/components/user-info"; +import { useParty } from "#/hooks/use-party"; import { useUser } from "#/hooks/user"; export const Route = createFileRoute("/")({ component: App }); function App() { const { user } = useUser(); + const { party, members } = useParty(); return ( {!user?.lastSyncAt && } {user && } + {party && !party.data && members.length > 1 && } + {party?.data && } ); } diff --git a/web/vite.config.ts b/web/vite.config.ts index 186b21c..ee3bbb7 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -23,8 +23,7 @@ const config = defineConfig({ "/api": { target: "http://localhost:4000", changeOrigin: true, - rewrite: (path) => - path.replace(/^\/api/, "/api"), + rewrite: (path) => path.replace(/^\/api/, "/api"), }, "/api/party-socket/ws": { target: "ws://localhost:4000",