format, prepare party view
This commit is contained in:
parent
6c965b9065
commit
d945949fed
25 changed files with 583 additions and 528 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<typeof db.transaction>[0] extends (
|
||||
|
|
@ -58,7 +58,10 @@ export async function getPartyStatus(
|
|||
},
|
||||
});
|
||||
return {
|
||||
party: partyRecord,
|
||||
party: {
|
||||
...partyRecord,
|
||||
data: partyRecord.data as QuizState,
|
||||
},
|
||||
members,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import type { InferSelectModel } from "drizzle-orm";
|
||||
import type { party, partyMember, user } from "./db/schema";
|
||||
|
||||
export type Party = InferSelectModel<typeof party>;
|
||||
export type Party = Omit<InferSelectModel<typeof party>, "data"> & {
|
||||
data: QuizState;
|
||||
};
|
||||
export type PartyMember = InferSelectModel<typeof partyMember>;
|
||||
export type User = InferSelectModel<typeof user>;
|
||||
|
||||
|
|
@ -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 }
|
||||
|
|
|
|||
42
api/src/party/state.ts
Normal file
42
api/src/party/state.ts
Normal file
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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<string>();
|
||||
|
||||
while (receivedPlayers.size < memberIds.size) {
|
||||
const response = await DBOS.recv<Response>(
|
||||
"quiz_responses",
|
||||
QUESTION_TIMEOUT_SECONDS,
|
||||
);
|
||||
const response = await DBOS.recv<Response>("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<void> {
|
||||
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<string, unknown>;
|
||||
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<void> {
|
||||
pubsub.publish(
|
||||
`party:${partyId}`,
|
||||
JSON.stringify({ type: "quiz_state", quiz: quizState }),
|
||||
);
|
||||
return {
|
||||
...question,
|
||||
startTimestamp: Date.now(),
|
||||
endTimestamp: Date.now() + 60_000,
|
||||
};
|
||||
}
|
||||
|
||||
@DBOS.step()
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox={viewBox}
|
||||
width={size}
|
||||
height={size}
|
||||
fill={color}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{
|
||||
opacity,
|
||||
transform: transforms.join(" ") || undefined,
|
||||
filter:
|
||||
shadow > 0
|
||||
? `drop-shadow(0 ${shadow}px ${shadow * 2}px rgba(0,0,0,0.3))`
|
||||
: undefined,
|
||||
backgroundColor: background !== "transparent" ? background : undefined,
|
||||
}}
|
||||
>
|
||||
<path d="M128 0C57.308 0 0 57.309 0 128c0 70.696 57.309 128 128 128c70.697 0 128-57.304 128-128C256 57.314 198.697.007 127.998.007zm58.699 184.614c-2.293 3.76-7.215 4.952-10.975 2.644c-30.053-18.357-67.885-22.515-112.44-12.335a7.98 7.98 0 0 1-9.552-6.007a7.97 7.97 0 0 1 6-9.553c48.76-11.14 90.583-6.344 124.323 14.276c3.76 2.308 4.952 7.215 2.644 10.975m15.667-34.853c-2.89 4.695-9.034 6.178-13.726 3.289c-34.406-21.148-86.853-27.273-127.548-14.92c-5.278 1.594-10.852-1.38-12.454-6.649c-1.59-5.278 1.386-10.842 6.655-12.446c46.485-14.106 104.275-7.273 143.787 17.007c4.692 2.89 6.175 9.034 3.286 13.72zm1.345-36.293C162.457 88.964 94.394 86.71 55.007 98.666c-6.325 1.918-13.014-1.653-14.93-7.978c-1.917-6.328 1.65-13.012 7.98-14.935C93.27 62.027 168.434 64.68 215.929 92.876c5.702 3.376 7.566 10.724 4.188 16.405c-3.362 5.69-10.73 7.565-16.4 4.187z" />
|
||||
</svg>
|
||||
);
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox={viewBox}
|
||||
width={size}
|
||||
height={size}
|
||||
fill={color}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{
|
||||
opacity,
|
||||
transform: transforms.join(" ") || undefined,
|
||||
filter:
|
||||
shadow > 0
|
||||
? `drop-shadow(0 ${shadow}px ${shadow * 2}px rgba(0,0,0,0.3))`
|
||||
: undefined,
|
||||
backgroundColor: background !== "transparent" ? background : undefined,
|
||||
}}
|
||||
>
|
||||
<path d="M128 0C57.308 0 0 57.309 0 128c0 70.696 57.309 128 128 128c70.697 0 128-57.304 128-128C256 57.314 198.697.007 127.998.007zm58.699 184.614c-2.293 3.76-7.215 4.952-10.975 2.644c-30.053-18.357-67.885-22.515-112.44-12.335a7.98 7.98 0 0 1-9.552-6.007a7.97 7.97 0 0 1 6-9.553c48.76-11.14 90.583-6.344 124.323 14.276c3.76 2.308 4.952 7.215 2.644 10.975m15.667-34.853c-2.89 4.695-9.034 6.178-13.726 3.289c-34.406-21.148-86.853-27.273-127.548-14.92c-5.278 1.594-10.852-1.38-12.454-6.649c-1.59-5.278 1.386-10.842 6.655-12.446c46.485-14.106 104.275-7.273 143.787 17.007c4.692 2.89 6.175 9.034 3.286 13.72zm1.345-36.293C162.457 88.964 94.394 86.71 55.007 98.666c-6.325 1.918-13.014-1.653-14.93-7.978c-1.917-6.328 1.65-13.012 7.98-14.935C93.27 62.027 168.434 64.68 215.929 92.876c5.702 3.376 7.566 10.724 4.188 16.405c-3.362 5.69-10.73 7.565-16.4 4.187z" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default SpotifyIconIcon;
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className={cn("flex flex-col gap-6", className)} {...props}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Login</CardTitle>
|
||||
<CardDescription>
|
||||
Connect your streaming account to continue.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() =>
|
||||
authClient.signIn.social({
|
||||
provider: "spotify",
|
||||
})
|
||||
}
|
||||
>
|
||||
<SpotifyFilledIcon />
|
||||
Login via Spotify
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-6", className)} {...props}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Login</CardTitle>
|
||||
<CardDescription>
|
||||
Connect your streaming account to continue.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() =>
|
||||
authClient.signIn.social({
|
||||
provider: "spotify",
|
||||
})
|
||||
}
|
||||
>
|
||||
<SpotifyFilledIcon />
|
||||
Login via Spotify
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string | null>(null);
|
||||
const { user } = useUser();
|
||||
const [dataUrl, setDataUrl] = useState<string | null>(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 (
|
||||
<Item className="px-0 justify-between">
|
||||
<ItemDescription>Invite someone to your party</ItemDescription>
|
||||
<ItemActions>
|
||||
<Dialog>
|
||||
<DialogTrigger render={<Button size="sm" variant="outline" />}>Show QR</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Party invite</DialogTitle>
|
||||
<DialogDescription>
|
||||
Scan to join your party on another device.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
{dataUrl ? (
|
||||
<img
|
||||
alt="Party invite QR code"
|
||||
src={dataUrl}
|
||||
width={QR_SIZE}
|
||||
height={QR_SIZE}
|
||||
className="rounded-xl border border-border bg-white p-2"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex size-[220px] items-center justify-center rounded-xl border border-dashed border-border text-muted-foreground">
|
||||
Generating QR...
|
||||
</div>
|
||||
)}
|
||||
{joinUrl ? (
|
||||
<p className="max-w-[280px] break-all text-xs text-muted-foreground">
|
||||
{joinUrl}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogClose render={<Button variant="outline" />}>Close</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
);
|
||||
return (
|
||||
<Item className="px-0 justify-between">
|
||||
<ItemDescription>Invite someone to your party</ItemDescription>
|
||||
<ItemActions>
|
||||
<Dialog>
|
||||
<DialogTrigger render={<Button size="sm" variant="outline" />}>
|
||||
Show QR
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Party invite</DialogTitle>
|
||||
<DialogDescription>
|
||||
Scan to join your party on another device.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
{dataUrl ? (
|
||||
<img
|
||||
alt="Party invite QR code"
|
||||
src={dataUrl}
|
||||
width={QR_SIZE}
|
||||
height={QR_SIZE}
|
||||
className="rounded-xl border border-border bg-white p-2"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex size-[220px] items-center justify-center rounded-xl border border-dashed border-border text-muted-foreground">
|
||||
Generating QR...
|
||||
</div>
|
||||
)}
|
||||
{joinUrl ? (
|
||||
<p className="max-w-[280px] break-all text-xs text-muted-foreground">
|
||||
{joinUrl}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogClose render={<Button variant="outline" />}>
|
||||
Close
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
6
web/src/components/party/party-view.tsx
Normal file
6
web/src/components/party/party-view.tsx
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { useParty } from "#/hooks/use-party";
|
||||
|
||||
export function PartyView() {
|
||||
const { party } = useParty();
|
||||
if (!party) return null;
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<Card size="sm">
|
||||
<CardHeader>
|
||||
<CardTitle>Quick stats</CardTitle>
|
||||
<CardDescription>Loading your music highlights.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Spinner />
|
||||
Fetching stats
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card size="sm">
|
||||
<CardHeader>
|
||||
<CardTitle>Quick stats</CardTitle>
|
||||
<CardDescription>Loading your music highlights.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Spinner />
|
||||
Fetching stats
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Section>
|
||||
<SectionTitle>Data overview</SectionTitle>
|
||||
<ItemGroup>
|
||||
<Item size="sm" variant="muted">
|
||||
<ItemMedia>
|
||||
<AvatarGroup>
|
||||
{topArtists.slice(0, MAX_AVATARS).map((entry) => (
|
||||
<Avatar key={entry.artistId} size="sm">
|
||||
<AvatarImage
|
||||
src={entry.artist?.images?.[0]?.url || undefined}
|
||||
alt={entry.artist?.name || ""}
|
||||
/>
|
||||
</Avatar>
|
||||
))}
|
||||
</AvatarGroup>
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>Artists</ItemTitle>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
<Item size="sm" variant="muted">
|
||||
<ItemMedia>
|
||||
<AvatarGroup>
|
||||
{topTracks.slice(0, MAX_AVATARS).map((entry) => (
|
||||
<Avatar key={entry.trackId} size="sm">
|
||||
<AvatarImage
|
||||
src={entry.track?.album?.images?.[0]?.url || undefined}
|
||||
alt={entry.track?.name || ""}
|
||||
/>
|
||||
</Avatar>
|
||||
))}
|
||||
</AvatarGroup>
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>Tracks</ItemTitle>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
<Item size="sm" variant="muted">
|
||||
<ItemContent>
|
||||
<ItemTitle>Genres</ItemTitle>
|
||||
<ItemDescription className="flex flex-wrap gap-2">
|
||||
{topGenres.length === 0
|
||||
? "No genres yet."
|
||||
: topGenres.slice(0, 8).map((genre) => (
|
||||
<Badge key={genre.name} variant="outline">
|
||||
{genre.name}
|
||||
</Badge>
|
||||
))}
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
</ItemGroup>
|
||||
</Section>
|
||||
);
|
||||
return (
|
||||
<Section>
|
||||
<SectionTitle>Data overview</SectionTitle>
|
||||
<ItemGroup>
|
||||
<Item size="sm" variant="muted">
|
||||
<ItemMedia>
|
||||
<AvatarGroup>
|
||||
{topArtists.slice(0, MAX_AVATARS).map((entry) => (
|
||||
<Avatar key={entry.artistId} size="sm">
|
||||
<AvatarImage
|
||||
src={entry.artist?.images?.[0]?.url || undefined}
|
||||
alt={entry.artist?.name || ""}
|
||||
/>
|
||||
</Avatar>
|
||||
))}
|
||||
</AvatarGroup>
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>Artists</ItemTitle>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
<Item size="sm" variant="muted">
|
||||
<ItemMedia>
|
||||
<AvatarGroup>
|
||||
{topTracks.slice(0, MAX_AVATARS).map((entry) => (
|
||||
<Avatar key={entry.trackId} size="sm">
|
||||
<AvatarImage
|
||||
src={entry.track?.album?.images?.[0]?.url || undefined}
|
||||
alt={entry.track?.name || ""}
|
||||
/>
|
||||
</Avatar>
|
||||
))}
|
||||
</AvatarGroup>
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>Tracks</ItemTitle>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
<Item size="sm" variant="muted">
|
||||
<ItemContent>
|
||||
<ItemTitle>Genres</ItemTitle>
|
||||
<ItemDescription className="flex flex-wrap gap-2">
|
||||
{topGenres.length === 0
|
||||
? "No genres yet."
|
||||
: topGenres.slice(0, 8).map((genre) => (
|
||||
<Badge key={genre.name} variant="outline">
|
||||
{genre.name}
|
||||
</Badge>
|
||||
))}
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
</ItemGroup>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
25
web/src/components/start-party.tsx
Normal file
25
web/src/components/start-party.tsx
Normal file
|
|
@ -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 (
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>Start party</EmptyTitle>
|
||||
</EmptyHeader>
|
||||
<EmptyContent>
|
||||
<Button
|
||||
onClick={() =>
|
||||
client.api.party({ partyId: party.id }).quiz.start.post()
|
||||
}
|
||||
>
|
||||
Start
|
||||
</Button>
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 <main className={cn("min-h-screen p-8", className)}>{children}</main>;
|
||||
return <main className={cn("min-h-screen p-8", className)}>{children}</main>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 <section className={cn("", className)}>{children}</section>;
|
||||
return <section className={cn("", className)}>{children}</section>;
|
||||
}
|
||||
|
||||
export function SectionTitle({
|
||||
children,
|
||||
className,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<h2 className={cn("font-heading text-base font-medium", className)}>
|
||||
{children}
|
||||
</h2>
|
||||
);
|
||||
return (
|
||||
<h2 className={cn("font-heading text-base font-medium", className)}>
|
||||
{children}
|
||||
</h2>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ export function usePartySocket({
|
|||
|
||||
ws.onmessage = (event) => {
|
||||
const parsed = JSON.parse(event.data) as PartySocketEvent;
|
||||
console.log(parsed);
|
||||
handlerRef.current?.(parsed);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { useCallback, useMemo, useState } from "react";
|
||||
import type {
|
||||
PartyMember,
|
||||
PartySocketEvent,
|
||||
PartyState,
|
||||
} from "../../../api/src/party-types";
|
||||
|
|
|
|||
|
|
@ -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<AuthSession | null> {
|
||||
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> | void;
|
||||
queryClient: QueryClient;
|
||||
navigateToLogin: () => Promise<void> | void;
|
||||
queryClient: QueryClient;
|
||||
}): Promise<void> {
|
||||
await authClient.signOut();
|
||||
queryClient.clear();
|
||||
await navigateToLogin();
|
||||
await authClient.signOut();
|
||||
queryClient.clear();
|
||||
await navigateToLogin();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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("");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<MyRouterContext>()({
|
||||
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 (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<head>
|
||||
<HeadContent />
|
||||
</head>
|
||||
<body className="font-sans antialiased wrap-anywhere dark">
|
||||
{children}
|
||||
<Toaster />
|
||||
<TanStackDevtools
|
||||
config={{
|
||||
position: "bottom-right",
|
||||
}}
|
||||
plugins={[
|
||||
{
|
||||
name: "Tanstack Router",
|
||||
render: <TanStackRouterDevtoolsPanel />,
|
||||
},
|
||||
TanStackQueryDevtools,
|
||||
]}
|
||||
/>
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<head>
|
||||
<HeadContent />
|
||||
</head>
|
||||
<body className="font-sans antialiased wrap-anywhere dark">
|
||||
{children}
|
||||
<Toaster />
|
||||
<TanStackDevtools
|
||||
config={{
|
||||
position: "bottom-right",
|
||||
}}
|
||||
plugins={[
|
||||
{
|
||||
name: "Tanstack Router",
|
||||
render: <TanStackRouterDevtoolsPanel />,
|
||||
},
|
||||
TanStackQueryDevtools,
|
||||
]}
|
||||
/>
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<MainContent>
|
||||
<UserInfo />
|
||||
{!user?.lastSyncAt && <SyncButton />}
|
||||
{user && <PartyQr />}
|
||||
{party && !party.data && members.length > 1 && <StartParty />}
|
||||
{party?.data && <PartyView />}
|
||||
</MainContent>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue