Compare commits
3 commits
21f859c480
...
792d46beb3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
792d46beb3 | ||
|
|
d945949fed | ||
|
|
6c965b9065 |
30 changed files with 842 additions and 800 deletions
7
README.md
Normal file
7
README.md
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
# Quiz app + api
|
||||||
|
|
||||||
|
A simple music quiz app
|
||||||
|
|
||||||
|
See `api` and `web` folders for more specific info.
|
||||||
|
|
||||||
|
Requires bun.js for runtime and postgres, easiest way to set postgres up is to get Docker.
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# web
|
# API
|
||||||
|
|
||||||
To install dependencies:
|
To install dependencies:
|
||||||
|
|
||||||
|
|
@ -9,7 +9,32 @@ bun install
|
||||||
To run:
|
To run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun run index.ts
|
bun run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
This project was created using `bun init` in bun v1.3.11. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.
|
Requires running Postgres and environment setup.
|
||||||
|
|
||||||
|
Create `.env` file (in this folder):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Leave as is if using docker/default Postgres install
|
||||||
|
DATABASE_URL=postgres://postgres:postgres@localhost/postgres
|
||||||
|
BETTER_AUTH_URL=http://127.0.0.1:3000
|
||||||
|
|
||||||
|
# random secure secret; can be left as is for development/testing
|
||||||
|
BETTER_AUTH_SECRET=vGIwQFYm4kUPHOhFNt882IbFaWhLklke
|
||||||
|
|
||||||
|
# Create a spotify web app.
|
||||||
|
# Add http://127.0.0.1:3000/api/auth/callback/spotify as callback URL there
|
||||||
|
# Copy and paste Client ID and Client secret to the following places:
|
||||||
|
SPOTIFY_CLIENT_ID=
|
||||||
|
SPOTIFY_CLIENT_SECRET=
|
||||||
|
```
|
||||||
|
|
||||||
|
Important files for editing:
|
||||||
|
|
||||||
|
- `src/workflows/quiz.ts` has the main Quiz loop.
|
||||||
|
- Questions are generated in `src/party/` files.
|
||||||
|
- `src/workflows/sync.ts` gets data from spotify and saves into database
|
||||||
|
- `src/workflows/party-analysis.ts` generates analysis details of a given party, to be used for question generation
|
||||||
|
- `src/party-types.ts` contains type definitions of party data, shared with frontend
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import "./workflows/party-analysis";
|
||||||
import "./dbos.ts";
|
import "./dbos.ts";
|
||||||
import { partyApp } from "./routes/party";
|
import { partyApp } from "./routes/party";
|
||||||
import { partySocketApp, pubsub } from "./routes/party-socket";
|
import { partySocketApp, pubsub } from "./routes/party-socket";
|
||||||
|
import { quizRoutes } from "./routes/quiz.ts";
|
||||||
import { statsApp } from "./routes/stats.ts";
|
import { statsApp } from "./routes/stats.ts";
|
||||||
|
|
||||||
const app = new Elysia()
|
const app = new Elysia()
|
||||||
|
|
@ -19,6 +20,7 @@ const app = new Elysia()
|
||||||
.use(partyApp)
|
.use(partyApp)
|
||||||
.use(partyAnalysisApp)
|
.use(partyAnalysisApp)
|
||||||
.use(partySocketApp)
|
.use(partySocketApp)
|
||||||
|
.use(quizRoutes)
|
||||||
.get("/", () => ({ ok: true })),
|
.get("/", () => ({ ok: true })),
|
||||||
)
|
)
|
||||||
.listen(4000);
|
.listen(4000);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { db } from "./db";
|
import { db } from "./db";
|
||||||
import { party, partyMember } from "./db/schema";
|
import { party, partyMember } from "./db/schema";
|
||||||
import type { PartySnapshot } from "./party-types";
|
import type { PartySnapshot, QuizState } from "./party-types";
|
||||||
|
|
||||||
type DbClient = typeof db;
|
type DbClient = typeof db;
|
||||||
type DbTransaction = Parameters<typeof db.transaction>[0] extends (
|
type DbTransaction = Parameters<typeof db.transaction>[0] extends (
|
||||||
|
|
@ -58,7 +58,10 @@ export async function getPartyStatus(
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
party: partyRecord,
|
party: {
|
||||||
|
...partyRecord,
|
||||||
|
data: partyRecord.data as QuizState,
|
||||||
|
},
|
||||||
members,
|
members,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import type { InferSelectModel } from "drizzle-orm";
|
import type { InferSelectModel } from "drizzle-orm";
|
||||||
import type { party, partyMember, user } from "./db/schema";
|
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 PartyMember = InferSelectModel<typeof partyMember>;
|
||||||
export type User = InferSelectModel<typeof user>;
|
export type User = InferSelectModel<typeof user>;
|
||||||
|
|
||||||
|
|
@ -24,15 +26,20 @@ export type PartySocketOutgoing =
|
||||||
| { type: "ping" }
|
| { type: "ping" }
|
||||||
| { type: "member_payload"; payload: unknown };
|
| { type: "member_payload"; payload: unknown };
|
||||||
|
|
||||||
|
export type Question = {
|
||||||
|
text: string;
|
||||||
|
options: string[];
|
||||||
|
correct: number;
|
||||||
|
startTimestamp: number;
|
||||||
|
endTimestamp: number;
|
||||||
|
points: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type QuizState = {
|
export type QuizState = {
|
||||||
status: "idle" | "running" | "results";
|
status: "running" | "results";
|
||||||
workflowId: string | null;
|
workflowId: string | null;
|
||||||
questionIndex: number;
|
questionIndex: number;
|
||||||
currentQuestion: {
|
currentQuestion: Question | null;
|
||||||
text: string;
|
|
||||||
options: string[];
|
|
||||||
correct: number;
|
|
||||||
} | null;
|
|
||||||
answers: Record<
|
answers: Record<
|
||||||
string,
|
string,
|
||||||
{ playerId: string; selected: number; correct: boolean }
|
{ 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 { db } from "../db";
|
||||||
import { getMemberRecord, getPartyStatus } from "../party-data";
|
import { getMemberRecord, getPartyStatus } from "../party-data";
|
||||||
import type { QuizState } from "../party-types";
|
import type { PartySocketEvent, QuizState } from "../party-types";
|
||||||
|
|
||||||
function userTopic(userId: string) {
|
function userTopic(userId: string) {
|
||||||
return `user:${userId}`;
|
return `user:${userId}`;
|
||||||
|
|
@ -24,6 +24,9 @@ export const pubsub = {
|
||||||
publish(topic: string, data: string) {
|
publish(topic: string, data: string) {
|
||||||
this._server?.publish(topic, data);
|
this._server?.publish(topic, data);
|
||||||
},
|
},
|
||||||
|
publishPartyData(partyId: string, data: PartySocketEvent) {
|
||||||
|
pubsub.publish(`party:${partyId}`, JSON.stringify(data));
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
async function broadcastQuizState(ws: any, partyId: string) {
|
async function broadcastQuizState(ws: any, partyId: string) {
|
||||||
|
|
|
||||||
|
|
@ -76,21 +76,19 @@ export const quizRoutes = new Elysia()
|
||||||
.post(
|
.post(
|
||||||
"/response",
|
"/response",
|
||||||
async ({ user, body, params, set }) => {
|
async ({ user, body, params, set }) => {
|
||||||
const existingQuiz = await db
|
const party = await db.query.party.findFirst({
|
||||||
.select({ data: party.data })
|
where: {
|
||||||
.from(party)
|
id: params.partyId,
|
||||||
.where(eq(party.id, params.partyId))
|
},
|
||||||
.limit(1)
|
});
|
||||||
.then((rows) => rows[0]);
|
|
||||||
|
|
||||||
if (!existingQuiz) {
|
if (!party) {
|
||||||
set.status = 404;
|
set.status = 404;
|
||||||
return { error: "Party not found" };
|
return { error: "Party not found" };
|
||||||
}
|
}
|
||||||
|
const quizData = party.data as QuizState | null;
|
||||||
|
|
||||||
const quizData = (
|
console.log("response quiz data", party, quizData);
|
||||||
(existingQuiz.data ?? {}) as Record<string, unknown>
|
|
||||||
).quiz as QuizState | undefined;
|
|
||||||
|
|
||||||
if (!quizData || quizData.status !== "running") {
|
if (!quizData || quizData.status !== "running") {
|
||||||
set.status = 400;
|
set.status = 400;
|
||||||
|
|
@ -108,27 +106,6 @@ export const quizRoutes = new Elysia()
|
||||||
"quiz_responses",
|
"quiz_responses",
|
||||||
);
|
);
|
||||||
|
|
||||||
const updatedParty = await db
|
|
||||||
.select({ data: party.data })
|
|
||||||
.from(party)
|
|
||||||
.where(eq(party.id, params.partyId))
|
|
||||||
.limit(1)
|
|
||||||
.then((rows) => rows[0]);
|
|
||||||
|
|
||||||
const updatedQuizData = (
|
|
||||||
(updatedParty?.data ?? {}) as Record<string, unknown>
|
|
||||||
).quiz as QuizState | undefined;
|
|
||||||
|
|
||||||
if (updatedQuizData) {
|
|
||||||
pubsub.publish(
|
|
||||||
`party:${params.partyId}`,
|
|
||||||
JSON.stringify({
|
|
||||||
type: "quiz_state",
|
|
||||||
quiz: updatedQuizData,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { message: "Response recorded" };
|
return { message: "Response recorded" };
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -158,16 +135,7 @@ export const quizRoutes = new Elysia()
|
||||||
).quiz as QuizState | undefined;
|
).quiz as QuizState | undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
quiz:
|
quiz: quizData,
|
||||||
quizData ??
|
|
||||||
({
|
|
||||||
status: "idle",
|
|
||||||
workflowId: null,
|
|
||||||
questionIndex: 0,
|
|
||||||
currentQuestion: null,
|
|
||||||
answers: {},
|
|
||||||
scores: {},
|
|
||||||
} satisfies QuizState),
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
{ auth: true },
|
{ auth: true },
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,11 @@
|
||||||
import { ConfiguredInstance, DBOS, WorkflowQueue } from "@dbos-inc/dbos-sdk";
|
import { ConfiguredInstance, DBOS, WorkflowQueue } from "@dbos-inc/dbos-sdk";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { db } from "../db";
|
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 type { QuizState } from "../party-types";
|
||||||
import { pubsub } from "../routes/party-socket";
|
|
||||||
|
|
||||||
const TOTAL_QUESTIONS = 5;
|
const TOTAL_QUESTIONS = 5;
|
||||||
const QUESTION_TIMEOUT_SECONDS = 30;
|
|
||||||
|
|
||||||
export const quizQueue = new WorkflowQueue("quiz_queue", {
|
export const quizQueue = new WorkflowQueue("quiz_queue", {
|
||||||
concurrency: 1,
|
concurrency: 1,
|
||||||
|
|
@ -35,11 +34,10 @@ export class QuizWorkflow extends ConfiguredInstance {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Initialize quiz state
|
// Initialize quiz state
|
||||||
await this.updatePartyData(partyId, quizState);
|
await QuizWorkflow.updatePartyData(partyId, quizState);
|
||||||
await this.broadcastState(partyId, quizState);
|
|
||||||
|
|
||||||
// Get party members to initialize scores
|
// Get party members to initialize scores
|
||||||
const members = await this.getPartyMembers(partyId);
|
const members = await QuizWorkflow.getPartyMembers(partyId);
|
||||||
for (const member of members) {
|
for (const member of members) {
|
||||||
quizState.scores[member.userId] = 0;
|
quizState.scores[member.userId] = 0;
|
||||||
}
|
}
|
||||||
|
|
@ -47,25 +45,19 @@ export class QuizWorkflow extends ConfiguredInstance {
|
||||||
for (let i = 0; i < TOTAL_QUESTIONS; i++) {
|
for (let i = 0; i < TOTAL_QUESTIONS; i++) {
|
||||||
quizState.questionIndex = i;
|
quizState.questionIndex = i;
|
||||||
|
|
||||||
const question = await this.generateQuestion(i);
|
const question = await QuizWorkflow.generateQuestion(i);
|
||||||
quizState.currentQuestion = question;
|
quizState.currentQuestion = question;
|
||||||
quizState.answers = {};
|
quizState.answers = {};
|
||||||
|
|
||||||
await this.updatePartyData(partyId, quizState);
|
await QuizWorkflow.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
|
// Wait for all responses with timeout
|
||||||
const memberIds = new Set(members.map((m) => m.userId));
|
const memberIds = new Set(members.map((m) => m.userId));
|
||||||
const receivedPlayers = new Set<string>();
|
const receivedPlayers = new Set<string>();
|
||||||
|
|
||||||
while (receivedPlayers.size < memberIds.size) {
|
while (receivedPlayers.size < memberIds.size) {
|
||||||
const response = await DBOS.recv<Response>(
|
const response = await DBOS.recv<Response>("quiz_responses", {
|
||||||
"quiz_responses",
|
deadlineEpochMS: question.endTimestamp,
|
||||||
QUESTION_TIMEOUT_SECONDS,
|
});
|
||||||
);
|
|
||||||
|
|
||||||
if (response === null) {
|
if (response === null) {
|
||||||
// Timeout - fill in missing players with no answer
|
// Timeout - fill in missing players with no answer
|
||||||
|
|
@ -77,9 +69,9 @@ export class QuizWorkflow extends ConfiguredInstance {
|
||||||
selected: -1,
|
selected: -1,
|
||||||
correct: false,
|
correct: false,
|
||||||
};
|
};
|
||||||
|
await QuizWorkflow.updatePartyData(partyId, quizState);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await this.broadcastState(partyId, quizState);
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -92,87 +84,72 @@ export class QuizWorkflow extends ConfiguredInstance {
|
||||||
|
|
||||||
if (isCorrect) {
|
if (isCorrect) {
|
||||||
quizState.scores[response.playerId] =
|
quizState.scores[response.playerId] =
|
||||||
(quizState.scores[response.playerId] ?? 0) + 10;
|
(quizState.scores[response.playerId] ?? 0) + question.points;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.updatePartyData(partyId, quizState);
|
await QuizWorkflow.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
|
// Quiz complete
|
||||||
quizState.status = "results";
|
quizState.status = "results";
|
||||||
await this.updatePartyData(partyId, quizState);
|
await QuizWorkflow.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()
|
@DBOS.step()
|
||||||
private async updatePartyData(
|
private static async updatePartyData(
|
||||||
partyId: string,
|
partyId: string,
|
||||||
quizState: QuizState,
|
quizState: QuizState,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await db.transaction(async (tx) => {
|
console.log(partyId, quizState);
|
||||||
const currentParty = await tx
|
await updatePartyData(db, partyId, quizState);
|
||||||
.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));
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@DBOS.step()
|
@DBOS.step()
|
||||||
async generateQuestion(index: number): Promise<{
|
static async generateQuestion(index: number): Promise<{
|
||||||
text: string;
|
text: string;
|
||||||
options: string[];
|
options: string[];
|
||||||
correct: number;
|
correct: number;
|
||||||
|
startTimestamp: number;
|
||||||
|
endTimestamp: number;
|
||||||
|
points: number;
|
||||||
}> {
|
}> {
|
||||||
// Placeholder - returns same question for now, question generation comes later
|
// Placeholder - returns same question for now, question generation comes later
|
||||||
const questions: {
|
const questions: {
|
||||||
text: string;
|
text: string;
|
||||||
options: string[];
|
options: string[];
|
||||||
correct: number;
|
correct: number;
|
||||||
|
points: number;
|
||||||
}[] = [
|
}[] = [
|
||||||
{
|
{
|
||||||
text: "What is the most common genre in your party's shared taste?",
|
text: "What is the most common genre in your party's shared taste?",
|
||||||
options: ["Hip-Hop", "Rock", "Electronic", "Jazz"],
|
options: ["Hip-Hop", "Rock", "Electronic", "Jazz"],
|
||||||
correct: 0,
|
correct: 0,
|
||||||
|
points: 10,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: "Which artist do most party members follow?",
|
text: "Which artist do most party members follow?",
|
||||||
options: ["Artist A", "Artist B", "Artist C", "Artist D"],
|
options: ["Artist A", "Artist B", "Artist C", "Artist D"],
|
||||||
correct: 1,
|
correct: 1,
|
||||||
|
points: 10,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: "What percentage of the party shares at least 1 album?",
|
text: "What percentage of the party shares at least 1 album?",
|
||||||
options: ["0-25%", "25-50%", "50-75%", "75-100%"],
|
options: ["0-25%", "25-50%", "50-75%", "75-100%"],
|
||||||
correct: 2,
|
correct: 2,
|
||||||
|
points: 10,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: "Who has the most diverse taste in the party?",
|
text: "Who has the most diverse taste in the party?",
|
||||||
options: ["Player A", "Player B", "Player C", "Player D"],
|
options: ["Player A", "Player B", "Player C", "Player D"],
|
||||||
correct: 0,
|
correct: 0,
|
||||||
|
points: 10,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: "Which track appears most in everyone's top 50?",
|
text: "Which track appears most in everyone's top 50?",
|
||||||
options: ["Track A", "Track B", "Track C", "Track D"],
|
options: ["Track A", "Track B", "Track C", "Track D"],
|
||||||
correct: 3,
|
correct: 3,
|
||||||
|
points: 10,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -180,22 +157,15 @@ export class QuizWorkflow extends ConfiguredInstance {
|
||||||
if (!question) {
|
if (!question) {
|
||||||
throw new Error("Question not found");
|
throw new Error("Question not found");
|
||||||
}
|
}
|
||||||
return question;
|
return {
|
||||||
|
...question,
|
||||||
|
startTimestamp: Date.now(),
|
||||||
|
endTimestamp: Date.now() + 60_000,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@DBOS.step()
|
@DBOS.step()
|
||||||
private async broadcastState(
|
private static async getPartyMembers(
|
||||||
partyId: string,
|
|
||||||
quizState: QuizState,
|
|
||||||
): Promise<void> {
|
|
||||||
pubsub.publish(
|
|
||||||
`party:${partyId}`,
|
|
||||||
JSON.stringify({ type: "quiz_state", quiz: quizState }),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@DBOS.step()
|
|
||||||
private async getPartyMembers(
|
|
||||||
partyId: string,
|
partyId: string,
|
||||||
): Promise<{ id: string; userId: string }[]> {
|
): Promise<{ id: string; userId: string }[]> {
|
||||||
return db
|
return db
|
||||||
|
|
|
||||||
240
web/README.md
240
web/README.md
|
|
@ -1,238 +1,20 @@
|
||||||
Welcome to your new TanStack Start app!
|
# Web
|
||||||
|
|
||||||
# Getting Started
|
A Tanstack start React web app, using Shadcn components.
|
||||||
|
|
||||||
To run this application:
|
Install dependencies:
|
||||||
|
```sh
|
||||||
```bash
|
|
||||||
bun install
|
bun install
|
||||||
bun --bun run dev
|
|
||||||
```
|
```
|
||||||
|
|
||||||
# Building For Production
|
Run locally:
|
||||||
|
```sh
|
||||||
To build this application for production:
|
bun run dev
|
||||||
|
|
||||||
```bash
|
|
||||||
bun --bun run build
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Testing
|
By default connects to local backend (see `../api`)
|
||||||
|
|
||||||
This project uses [Vitest](https://vitest.dev/) for testing. You can run the tests with:
|
Important files to update:
|
||||||
|
|
||||||
```bash
|
- `src/routes/index.tsx` has main route details
|
||||||
bun --bun run test
|
- `src/components/party.tsx` contains quiz-specific details (game view, etc)
|
||||||
```
|
|
||||||
|
|
||||||
## Styling
|
|
||||||
|
|
||||||
This project uses [Tailwind CSS](https://tailwindcss.com/) for styling.
|
|
||||||
|
|
||||||
### Removing Tailwind CSS
|
|
||||||
|
|
||||||
If you prefer not to use Tailwind CSS:
|
|
||||||
|
|
||||||
1. Remove the demo pages in `src/routes/demo/`
|
|
||||||
2. Replace the Tailwind import in `src/styles.css` with your own styles
|
|
||||||
3. Remove `tailwindcss()` from the plugins array in `vite.config.ts`
|
|
||||||
4. Uninstall the packages: `bun install @tailwindcss/vite tailwindcss -D`
|
|
||||||
|
|
||||||
## Linting & Formatting
|
|
||||||
|
|
||||||
This project uses [Biome](https://biomejs.dev/) for linting and formatting. The following scripts are available:
|
|
||||||
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun --bun run lint
|
|
||||||
bun --bun run format
|
|
||||||
bun --bun run check
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
## Setting up Better Auth
|
|
||||||
|
|
||||||
1. Generate and set the `BETTER_AUTH_SECRET` environment variable in your `.env.local`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bunx --bun @better-auth/cli secret
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Visit the [Better Auth documentation](https://www.better-auth.com) to unlock the full potential of authentication in your app.
|
|
||||||
|
|
||||||
### Adding a Database (Optional)
|
|
||||||
|
|
||||||
Better Auth can work in stateless mode, but to persist user data, add a database:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// src/lib/auth.ts
|
|
||||||
import { betterAuth } from "better-auth";
|
|
||||||
import { Pool } from "pg";
|
|
||||||
|
|
||||||
export const auth = betterAuth({
|
|
||||||
database: new Pool({
|
|
||||||
connectionString: process.env.DATABASE_URL,
|
|
||||||
}),
|
|
||||||
// ... rest of config
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
Then run migrations:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bunx --bun @better-auth/cli migrate
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Routing
|
|
||||||
|
|
||||||
This project uses [TanStack Router](https://tanstack.com/router) with file-based routing. Routes are managed as files in `src/routes`.
|
|
||||||
|
|
||||||
### Adding A Route
|
|
||||||
|
|
||||||
To add a new route to your application just add a new file in the `./src/routes` directory.
|
|
||||||
|
|
||||||
TanStack will automatically generate the content of the route file for you.
|
|
||||||
|
|
||||||
Now that you have two routes you can use a `Link` component to navigate between them.
|
|
||||||
|
|
||||||
### Adding Links
|
|
||||||
|
|
||||||
To use SPA (Single Page Application) navigation you will need to import the `Link` component from `@tanstack/react-router`.
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { Link } from "@tanstack/react-router";
|
|
||||||
```
|
|
||||||
|
|
||||||
Then anywhere in your JSX you can use it like so:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
<Link to="/about">About</Link>
|
|
||||||
```
|
|
||||||
|
|
||||||
This will create a link that will navigate to the `/about` route.
|
|
||||||
|
|
||||||
More information on the `Link` component can be found in the [Link documentation](https://tanstack.com/router/v1/docs/framework/react/api/router/linkComponent).
|
|
||||||
|
|
||||||
### Using A Layout
|
|
||||||
|
|
||||||
In the File Based Routing setup the layout is located in `src/routes/__root.tsx`. Anything you add to the root route will appear in all the routes. The route content will appear in the JSX where you render `{children}` in the `shellComponent`.
|
|
||||||
|
|
||||||
Here is an example layout that includes a header:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { HeadContent, Scripts, createRootRoute } from '@tanstack/react-router'
|
|
||||||
|
|
||||||
export const Route = createRootRoute({
|
|
||||||
head: () => ({
|
|
||||||
meta: [
|
|
||||||
{ charSet: 'utf-8' },
|
|
||||||
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
|
|
||||||
{ title: 'My App' },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
shellComponent: ({ children }) => (
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<HeadContent />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<header>
|
|
||||||
<nav>
|
|
||||||
<Link to="/">Home</Link>
|
|
||||||
<Link to="/about">About</Link>
|
|
||||||
</nav>
|
|
||||||
</header>
|
|
||||||
{children}
|
|
||||||
<Scripts />
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
),
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
More information on layouts can be found in the [Layouts documentation](https://tanstack.com/router/latest/docs/framework/react/guide/routing-concepts#layouts).
|
|
||||||
|
|
||||||
## Server Functions
|
|
||||||
|
|
||||||
TanStack Start provides server functions that allow you to write server-side code that seamlessly integrates with your client components.
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { createServerFn } from '@tanstack/react-start'
|
|
||||||
|
|
||||||
const getServerTime = createServerFn({
|
|
||||||
method: 'GET',
|
|
||||||
}).handler(async () => {
|
|
||||||
return new Date().toISOString()
|
|
||||||
})
|
|
||||||
|
|
||||||
// Use in a component
|
|
||||||
function MyComponent() {
|
|
||||||
const [time, setTime] = useState('')
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
getServerTime().then(setTime)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return <div>Server time: {time}</div>
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## API Routes
|
|
||||||
|
|
||||||
You can create API routes by using the `server` property in your route definitions:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
|
||||||
import { json } from '@tanstack/react-start'
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/api/hello')({
|
|
||||||
server: {
|
|
||||||
handlers: {
|
|
||||||
GET: () => json({ message: 'Hello, World!' }),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
## Data Fetching
|
|
||||||
|
|
||||||
There are multiple ways to fetch data in your application. You can use TanStack Query to fetch data from a server. But you can also use the `loader` functionality built into TanStack Router to load the data for a route before it's rendered.
|
|
||||||
|
|
||||||
For example:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/people')({
|
|
||||||
loader: async () => {
|
|
||||||
const response = await fetch('https://swapi.dev/api/people')
|
|
||||||
return response.json()
|
|
||||||
},
|
|
||||||
component: PeopleComponent,
|
|
||||||
})
|
|
||||||
|
|
||||||
function PeopleComponent() {
|
|
||||||
const data = Route.useLoaderData()
|
|
||||||
return (
|
|
||||||
<ul>
|
|
||||||
{data.results.map((person) => (
|
|
||||||
<li key={person.name}>{person.name}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Loaders simplify your data fetching logic dramatically. Check out more information in the [Loader documentation](https://tanstack.com/router/latest/docs/framework/react/guide/data-loading#loader-parameters).
|
|
||||||
|
|
||||||
# Demo files
|
|
||||||
|
|
||||||
Files prefixed with `demo` can be safely deleted. They are there to provide a starting point for you to play around with the features you've installed.
|
|
||||||
|
|
||||||
# Learn More
|
|
||||||
|
|
||||||
You can learn more about all of the offerings from TanStack in the [TanStack documentation](https://tanstack.com).
|
|
||||||
|
|
||||||
For TanStack Start specific documentation, visit [TanStack Start](https://tanstack.com/start).
|
|
||||||
|
|
|
||||||
|
|
@ -1,47 +1,45 @@
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
const SpotifyIconIcon = ({
|
const SpotifyIconIcon = ({
|
||||||
size = undefined,
|
size = undefined,
|
||||||
color = "#000000",
|
color = "#000000",
|
||||||
background = "transparent",
|
background = "transparent",
|
||||||
opacity = 1,
|
opacity = 1,
|
||||||
rotation = 0,
|
rotation = 0,
|
||||||
shadow = 0,
|
shadow = 0,
|
||||||
flipHorizontal = false,
|
flipHorizontal = false,
|
||||||
flipVertical = false,
|
flipVertical = false,
|
||||||
padding = 0,
|
padding = 0,
|
||||||
}) => {
|
}) => {
|
||||||
const transforms = [];
|
const transforms = [];
|
||||||
if (rotation !== 0) transforms.push(`rotate(${rotation}deg)`);
|
if (rotation !== 0) transforms.push(`rotate(${rotation}deg)`);
|
||||||
if (flipHorizontal) transforms.push("scaleX(-1)");
|
if (flipHorizontal) transforms.push("scaleX(-1)");
|
||||||
if (flipVertical) transforms.push("scaleY(-1)");
|
if (flipVertical) transforms.push("scaleY(-1)");
|
||||||
|
|
||||||
const viewBoxSize = 256 + padding * 2;
|
const viewBoxSize = 256 + padding * 2;
|
||||||
const viewBoxOffset = -padding;
|
const viewBoxOffset = -padding;
|
||||||
const viewBox = `${viewBoxOffset} ${viewBoxOffset} ${viewBoxSize} ${viewBoxSize}`;
|
const viewBox = `${viewBoxOffset} ${viewBoxOffset} ${viewBoxSize} ${viewBoxSize}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
viewBox={viewBox}
|
viewBox={viewBox}
|
||||||
width={size}
|
width={size}
|
||||||
height={size}
|
height={size}
|
||||||
fill={color}
|
fill={color}
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
style={{
|
style={{
|
||||||
opacity,
|
opacity,
|
||||||
transform: transforms.join(" ") || undefined,
|
transform: transforms.join(" ") || undefined,
|
||||||
filter:
|
filter:
|
||||||
shadow > 0
|
shadow > 0
|
||||||
? `drop-shadow(0 ${shadow}px ${shadow * 2}px rgba(0,0,0,0.3))`
|
? `drop-shadow(0 ${shadow}px ${shadow * 2}px rgba(0,0,0,0.3))`
|
||||||
: undefined,
|
: undefined,
|
||||||
backgroundColor: background !== "transparent" ? background : 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" />
|
<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>
|
</svg>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default SpotifyIconIcon;
|
export default SpotifyIconIcon;
|
||||||
|
|
|
||||||
|
|
@ -1,42 +1,42 @@
|
||||||
import { Button } from "#/components/ui/button";
|
import { Button } from "#/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardDescription,
|
CardDescription,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "#/components/ui/card";
|
} from "#/components/ui/card";
|
||||||
import { authClient } from "#/lib/auth-client";
|
import { authClient } from "#/lib/auth-client";
|
||||||
import { cn } from "#/lib/utils";
|
import { cn } from "#/lib/utils";
|
||||||
import SpotifyFilledIcon from "./icons/Spotify";
|
import SpotifyFilledIcon from "./icons/Spotify";
|
||||||
|
|
||||||
export function LoginForm({
|
export function LoginForm({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"div">) {
|
}: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex flex-col gap-6", className)} {...props}>
|
<div className={cn("flex flex-col gap-6", className)} {...props}>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Login</CardTitle>
|
<CardTitle>Login</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Connect your streaming account to continue.
|
Connect your streaming account to continue.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Button
|
<Button
|
||||||
className="w-full"
|
className="w-full"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
authClient.signIn.social({
|
authClient.signIn.social({
|
||||||
provider: "spotify",
|
provider: "spotify",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SpotifyFilledIcon />
|
<SpotifyFilledIcon />
|
||||||
Login via Spotify
|
Login via Spotify
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,15 @@
|
||||||
import { useEffect, useMemo, useState } from "react";
|
|
||||||
import QRCode from "qrcode";
|
import QRCode from "qrcode";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Button } from "#/components/ui/button";
|
import { Button } from "#/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogClose,
|
DialogClose,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "#/components/ui/dialog";
|
} from "#/components/ui/dialog";
|
||||||
import { Item, ItemActions, ItemDescription } from "#/components/ui/item";
|
import { Item, ItemActions, ItemDescription } from "#/components/ui/item";
|
||||||
import { useUser } from "#/hooks/user";
|
import { useUser } from "#/hooks/user";
|
||||||
|
|
@ -17,82 +17,86 @@ import { useUser } from "#/hooks/user";
|
||||||
const QR_SIZE = 220;
|
const QR_SIZE = 220;
|
||||||
|
|
||||||
export function PartyQr() {
|
export function PartyQr() {
|
||||||
const { user } = useUser();
|
const { user } = useUser();
|
||||||
const [dataUrl, setDataUrl] = useState<string | null>(null);
|
const [dataUrl, setDataUrl] = useState<string | null>(null);
|
||||||
|
|
||||||
const joinUrl = useMemo(() => {
|
const joinUrl = useMemo(() => {
|
||||||
if (typeof window === "undefined" || !user?.id) return null;
|
if (typeof window === "undefined" || !user?.id) return null;
|
||||||
const url = new URL(window.location.href);
|
const url = new URL(window.location.href);
|
||||||
url.searchParams.delete("redirect");
|
url.searchParams.delete("redirect");
|
||||||
url.searchParams.set("join", user.id);
|
url.searchParams.set("join", user.id);
|
||||||
return url.toString();
|
return url.toString();
|
||||||
}, [user?.id]);
|
}, [user?.id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let isMounted = true;
|
let isMounted = true;
|
||||||
if (!joinUrl) {
|
if (!joinUrl) {
|
||||||
setDataUrl(null);
|
setDataUrl(null);
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
QRCode.toDataURL(joinUrl, { width: QR_SIZE, margin: 1 })
|
QRCode.toDataURL(joinUrl, { width: QR_SIZE, margin: 1 })
|
||||||
.then((url) => {
|
.then((url) => {
|
||||||
if (isMounted) {
|
if (isMounted) {
|
||||||
setDataUrl(url);
|
setDataUrl(url);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (isMounted) {
|
if (isMounted) {
|
||||||
setDataUrl(null);
|
setDataUrl(null);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
isMounted = false;
|
isMounted = false;
|
||||||
};
|
};
|
||||||
}, [joinUrl]);
|
}, [joinUrl]);
|
||||||
|
|
||||||
if (!user?.id) return null;
|
if (!user?.id) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Item className="px-0 justify-between">
|
<Item className="px-0 justify-between">
|
||||||
<ItemDescription>Invite someone to your party</ItemDescription>
|
<ItemDescription>Invite someone to your party</ItemDescription>
|
||||||
<ItemActions>
|
<ItemActions>
|
||||||
<Dialog>
|
<Dialog>
|
||||||
<DialogTrigger render={<Button size="sm" variant="outline" />}>Show QR</DialogTrigger>
|
<DialogTrigger render={<Button size="sm" variant="outline" />}>
|
||||||
<DialogContent>
|
Show QR
|
||||||
<DialogHeader>
|
</DialogTrigger>
|
||||||
<DialogTitle>Party invite</DialogTitle>
|
<DialogContent>
|
||||||
<DialogDescription>
|
<DialogHeader>
|
||||||
Scan to join your party on another device.
|
<DialogTitle>Party invite</DialogTitle>
|
||||||
</DialogDescription>
|
<DialogDescription>
|
||||||
</DialogHeader>
|
Scan to join your party on another device.
|
||||||
<div className="flex flex-col items-center gap-4">
|
</DialogDescription>
|
||||||
{dataUrl ? (
|
</DialogHeader>
|
||||||
<img
|
<div className="flex flex-col items-center gap-4">
|
||||||
alt="Party invite QR code"
|
{dataUrl ? (
|
||||||
src={dataUrl}
|
<img
|
||||||
width={QR_SIZE}
|
alt="Party invite QR code"
|
||||||
height={QR_SIZE}
|
src={dataUrl}
|
||||||
className="rounded-xl border border-border bg-white p-2"
|
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>
|
<div className="flex size-[220px] items-center justify-center rounded-xl border border-dashed border-border text-muted-foreground">
|
||||||
)}
|
Generating QR...
|
||||||
{joinUrl ? (
|
</div>
|
||||||
<p className="max-w-[280px] break-all text-xs text-muted-foreground">
|
)}
|
||||||
{joinUrl}
|
{joinUrl ? (
|
||||||
</p>
|
<p className="max-w-[280px] break-all text-xs text-muted-foreground">
|
||||||
) : null}
|
{joinUrl}
|
||||||
</div>
|
</p>
|
||||||
<DialogFooter>
|
) : null}
|
||||||
<DialogClose render={<Button variant="outline" />}>Close</DialogClose>
|
</div>
|
||||||
</DialogFooter>
|
<DialogFooter>
|
||||||
</DialogContent>
|
<DialogClose render={<Button variant="outline" />}>
|
||||||
</Dialog>
|
Close
|
||||||
</ItemActions>
|
</DialogClose>
|
||||||
</Item>
|
</DialogFooter>
|
||||||
);
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</ItemActions>
|
||||||
|
</Item>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
15
web/src/components/party/party-view.tsx
Normal file
15
web/src/components/party/party-view.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { useParty } from "#/hooks/use-party";
|
||||||
|
import { Question } from "./question";
|
||||||
|
import { Results } from "./results";
|
||||||
|
|
||||||
|
export function PartyView() {
|
||||||
|
const { party } = useParty();
|
||||||
|
if (!party?.data) return null;
|
||||||
|
|
||||||
|
switch (party.data.status) {
|
||||||
|
case "running":
|
||||||
|
return <Question />;
|
||||||
|
case "results":
|
||||||
|
return <Results />;
|
||||||
|
}
|
||||||
|
}
|
||||||
130
web/src/components/party/question.tsx
Normal file
130
web/src/components/party/question.tsx
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { Button } from "#/components/ui/button";
|
||||||
|
import {
|
||||||
|
Item,
|
||||||
|
ItemContent,
|
||||||
|
ItemDescription,
|
||||||
|
ItemGroup,
|
||||||
|
ItemHeader,
|
||||||
|
ItemTitle,
|
||||||
|
} from "#/components/ui/item";
|
||||||
|
import {
|
||||||
|
Progress,
|
||||||
|
ProgressLabel,
|
||||||
|
ProgressValue,
|
||||||
|
} from "#/components/ui/progress";
|
||||||
|
import { Section, SectionTitle } from "#/components/ui/section";
|
||||||
|
import { Spinner } from "#/components/ui/spinner";
|
||||||
|
import { useParty } from "#/hooks/use-party";
|
||||||
|
import { useUser } from "#/hooks/user";
|
||||||
|
import { client } from "#/lib/eden";
|
||||||
|
|
||||||
|
function formatTimeLeft(milliseconds: number) {
|
||||||
|
const clamped = Math.max(0, milliseconds);
|
||||||
|
const totalSeconds = Math.ceil(clamped / 1000);
|
||||||
|
const minutes = Math.floor(totalSeconds / 60)
|
||||||
|
.toString()
|
||||||
|
.padStart(2, "0");
|
||||||
|
const seconds = (totalSeconds % 60).toString().padStart(2, "0");
|
||||||
|
return `${minutes}:${seconds}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Question() {
|
||||||
|
const { party, members } = useParty();
|
||||||
|
const { user } = useUser();
|
||||||
|
const [now, setNow] = useState(() => Date.now());
|
||||||
|
const [selected, setSelected] = useState<number | null>(null);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const question = party?.data?.currentQuestion;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = window.setInterval(() => {
|
||||||
|
setNow(Date.now());
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!question) return null;
|
||||||
|
|
||||||
|
const partyId = party.id;
|
||||||
|
const timeLeft = formatTimeLeft(question.endTimestamp - now);
|
||||||
|
const answeredCount = Object.keys(party.data.answers).length;
|
||||||
|
const hasResponded = user ? party.data.answers[user.id] != null : false;
|
||||||
|
const currentSelection =
|
||||||
|
selected ?? (user ? (party.data.answers[user.id]?.selected ?? null) : null);
|
||||||
|
const progressValue = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(
|
||||||
|
100,
|
||||||
|
((question.endTimestamp - now) /
|
||||||
|
(question.endTimestamp - question.startTimestamp)) *
|
||||||
|
100,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
async function handleAnswer(optionIndex: number) {
|
||||||
|
if (!partyId || hasResponded || isSubmitting) return;
|
||||||
|
setSelected(optionIndex);
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
await client.api.party({ partyId }).quiz.response.post({
|
||||||
|
selected: optionIndex,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Section>
|
||||||
|
<SectionTitle>Question {party.data.questionIndex + 1}</SectionTitle>
|
||||||
|
<ItemGroup>
|
||||||
|
<Item variant="muted">
|
||||||
|
<ItemContent>
|
||||||
|
<ItemTitle>{question.text}</ItemTitle>
|
||||||
|
<ItemDescription>{question.points} points</ItemDescription>
|
||||||
|
</ItemContent>
|
||||||
|
</Item>
|
||||||
|
<Item variant="muted">
|
||||||
|
<ItemContent>
|
||||||
|
<ItemHeader>
|
||||||
|
<Progress value={progressValue}>
|
||||||
|
<ProgressLabel>
|
||||||
|
{hasResponded ? "You responded" : "Waiting on your response"}
|
||||||
|
</ProgressLabel>
|
||||||
|
<ProgressValue>{() => timeLeft}</ProgressValue>
|
||||||
|
</Progress>
|
||||||
|
</ItemHeader>
|
||||||
|
<ItemDescription>
|
||||||
|
{answeredCount} / {members.length} responses
|
||||||
|
</ItemDescription>
|
||||||
|
</ItemContent>
|
||||||
|
</Item>
|
||||||
|
{question.options.map((option, index) => {
|
||||||
|
const isSelected = currentSelection === index;
|
||||||
|
return (
|
||||||
|
<Item key={option} variant={isSelected ? "outline" : "default"}>
|
||||||
|
<ItemContent>
|
||||||
|
<ItemHeader>
|
||||||
|
<ItemTitle>
|
||||||
|
{index + 1}. {option}
|
||||||
|
</ItemTitle>
|
||||||
|
{isSelected && <ItemDescription>Selected</ItemDescription>}
|
||||||
|
</ItemHeader>
|
||||||
|
</ItemContent>
|
||||||
|
<Button
|
||||||
|
variant={isSelected ? "secondary" : "default"}
|
||||||
|
disabled={hasResponded || isSubmitting}
|
||||||
|
onClick={() => handleAnswer(index)}
|
||||||
|
>
|
||||||
|
{isSubmitting && isSelected ? <Spinner /> : "Answer"}
|
||||||
|
</Button>
|
||||||
|
</Item>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ItemGroup>
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
web/src/components/party/results.tsx
Normal file
42
web/src/components/party/results.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { useParty } from "#/hooks/use-party";
|
||||||
|
|
||||||
|
export function Results() {
|
||||||
|
const { party, members } = useParty();
|
||||||
|
if (!party?.data) return null;
|
||||||
|
|
||||||
|
const leaderboard = members
|
||||||
|
.map((member) => ({
|
||||||
|
member,
|
||||||
|
score: party.data.scores[member.userId] ?? 0,
|
||||||
|
}))
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
b.score - a.score ||
|
||||||
|
Number(a.member.joinedAt) - Number(b.member.joinedAt),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="text-2xl font-semibold text-foreground">Leaderboard</h2>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{leaderboard.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">No scores yet.</p>
|
||||||
|
) : (
|
||||||
|
leaderboard.map(({ member, score }, index) => (
|
||||||
|
<div
|
||||||
|
key={member.id}
|
||||||
|
className="flex items-center justify-between rounded-xl border border-foreground/10 bg-card px-4 py-3"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-foreground">
|
||||||
|
{index + 1}. {member.user?.name ?? "Unknown player"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">{score} points</p>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,107 +1,107 @@
|
||||||
import { client } from "#/lib/eden";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
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 { 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 { 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 { Spinner } from "#/components/ui/spinner";
|
||||||
|
import { client } from "#/lib/eden";
|
||||||
import { Section, SectionTitle } from "./ui/section";
|
import { Section, SectionTitle } from "./ui/section";
|
||||||
|
|
||||||
const MAX_AVATARS = 6;
|
const MAX_AVATARS = 6;
|
||||||
|
|
||||||
export function QuickStats() {
|
export function QuickStats() {
|
||||||
const { isLoading, data } = useQuery({
|
const { isLoading, data } = useQuery({
|
||||||
queryFn: () => client.api.stats.get(),
|
queryFn: () => client.api.stats.get(),
|
||||||
queryKey: ["stats"],
|
queryKey: ["stats"],
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<Card size="sm">
|
<Card size="sm">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Quick stats</CardTitle>
|
<CardTitle>Quick stats</CardTitle>
|
||||||
<CardDescription>Loading your music highlights.</CardDescription>
|
<CardDescription>Loading your music highlights.</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex items-center gap-2 text-muted-foreground">
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
<Spinner />
|
<Spinner />
|
||||||
Fetching stats
|
Fetching stats
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const topArtists = data?.data?.topArtists ?? [];
|
const topArtists = data?.data?.topArtists ?? [];
|
||||||
const topTracks = data?.data?.topTracks ?? [];
|
const topTracks = data?.data?.topTracks ?? [];
|
||||||
const topGenres = data?.data?.topGenres ?? [];
|
const topGenres = data?.data?.topGenres ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section>
|
<Section>
|
||||||
<SectionTitle>Data overview</SectionTitle>
|
<SectionTitle>Data overview</SectionTitle>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Item size="sm" variant="muted">
|
<Item size="sm" variant="muted">
|
||||||
<ItemMedia>
|
<ItemMedia>
|
||||||
<AvatarGroup>
|
<AvatarGroup>
|
||||||
{topArtists.slice(0, MAX_AVATARS).map((entry) => (
|
{topArtists.slice(0, MAX_AVATARS).map((entry) => (
|
||||||
<Avatar key={entry.artistId} size="sm">
|
<Avatar key={entry.artistId} size="sm">
|
||||||
<AvatarImage
|
<AvatarImage
|
||||||
src={entry.artist?.images?.[0]?.url || undefined}
|
src={entry.artist?.images?.[0]?.url || undefined}
|
||||||
alt={entry.artist?.name || ""}
|
alt={entry.artist?.name || ""}
|
||||||
/>
|
/>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
))}
|
))}
|
||||||
</AvatarGroup>
|
</AvatarGroup>
|
||||||
</ItemMedia>
|
</ItemMedia>
|
||||||
<ItemContent>
|
<ItemContent>
|
||||||
<ItemTitle>Artists</ItemTitle>
|
<ItemTitle>Artists</ItemTitle>
|
||||||
</ItemContent>
|
</ItemContent>
|
||||||
</Item>
|
</Item>
|
||||||
<Item size="sm" variant="muted">
|
<Item size="sm" variant="muted">
|
||||||
<ItemMedia>
|
<ItemMedia>
|
||||||
<AvatarGroup>
|
<AvatarGroup>
|
||||||
{topTracks.slice(0, MAX_AVATARS).map((entry) => (
|
{topTracks.slice(0, MAX_AVATARS).map((entry) => (
|
||||||
<Avatar key={entry.trackId} size="sm">
|
<Avatar key={entry.trackId} size="sm">
|
||||||
<AvatarImage
|
<AvatarImage
|
||||||
src={entry.track?.album?.images?.[0]?.url || undefined}
|
src={entry.track?.album?.images?.[0]?.url || undefined}
|
||||||
alt={entry.track?.name || ""}
|
alt={entry.track?.name || ""}
|
||||||
/>
|
/>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
))}
|
))}
|
||||||
</AvatarGroup>
|
</AvatarGroup>
|
||||||
</ItemMedia>
|
</ItemMedia>
|
||||||
<ItemContent>
|
<ItemContent>
|
||||||
<ItemTitle>Tracks</ItemTitle>
|
<ItemTitle>Tracks</ItemTitle>
|
||||||
</ItemContent>
|
</ItemContent>
|
||||||
</Item>
|
</Item>
|
||||||
<Item size="sm" variant="muted">
|
<Item size="sm" variant="muted">
|
||||||
<ItemContent>
|
<ItemContent>
|
||||||
<ItemTitle>Genres</ItemTitle>
|
<ItemTitle>Genres</ItemTitle>
|
||||||
<ItemDescription className="flex flex-wrap gap-2">
|
<ItemDescription className="flex flex-wrap gap-2">
|
||||||
{topGenres.length === 0
|
{topGenres.length === 0
|
||||||
? "No genres yet."
|
? "No genres yet."
|
||||||
: topGenres.slice(0, 8).map((genre) => (
|
: topGenres.slice(0, 8).map((genre) => (
|
||||||
<Badge key={genre.name} variant="outline">
|
<Badge key={genre.name} variant="outline">
|
||||||
{genre.name}
|
{genre.name}
|
||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
</ItemDescription>
|
</ItemDescription>
|
||||||
</ItemContent>
|
</ItemContent>
|
||||||
</Item>
|
</Item>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Section>
|
</Section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
32
web/src/components/start-party.tsx
Normal file
32
web/src/components/start-party.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { toast } from "sonner";
|
||||||
|
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={async () => {
|
||||||
|
try {
|
||||||
|
await client.api.party({ partyId: party.id }).quiz.start.post();
|
||||||
|
} catch (e) {
|
||||||
|
toast(
|
||||||
|
(e as Error)?.message || "Unknown error while starting party",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Start
|
||||||
|
</Button>
|
||||||
|
</EmptyContent>
|
||||||
|
</Empty>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -3,7 +3,7 @@ import { useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { client } from "#/lib/eden";
|
import { client } from "#/lib/eden";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
import { Item, ItemActions, ItemDescription, ItemTitle } from "./ui/item";
|
import { Item, ItemActions, ItemDescription } from "./ui/item";
|
||||||
|
|
||||||
export function SyncButton() {
|
export function SyncButton() {
|
||||||
const query = useQueryClient();
|
const query = useQueryClient();
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import { cn } from "#/lib/utils";
|
import { cn } from "#/lib/utils";
|
||||||
|
|
||||||
export function MainContent({
|
export function MainContent({
|
||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
className?: string;
|
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";
|
import { cn } from "#/lib/utils";
|
||||||
|
|
||||||
export function Section({
|
export function Section({
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
className?: string;
|
className?: string;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return <section className={cn("", className)}>{children}</section>;
|
return <section className={cn("", className)}>{children}</section>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SectionTitle({
|
export function SectionTitle({
|
||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<h2 className={cn("font-heading text-base font-medium", className)}>
|
<h2 className={cn("font-heading text-base font-medium", className)}>
|
||||||
{children}
|
{children}
|
||||||
</h2>
|
</h2>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
import { useRouteContext } from "@tanstack/react-router";
|
|
||||||
import { useParty } from "#/hooks/use-party";
|
import { useParty } from "#/hooks/use-party";
|
||||||
import { useUser } from "#/hooks/user";
|
import { useUser } from "#/hooks/user";
|
||||||
|
import { client } from "#/lib/eden";
|
||||||
import { initials } from "#/lib/utils";
|
import { initials } from "#/lib/utils";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
import {
|
import {
|
||||||
Item,
|
Item,
|
||||||
|
ItemActions,
|
||||||
ItemContent,
|
ItemContent,
|
||||||
ItemDescription,
|
ItemDescription,
|
||||||
ItemMedia,
|
ItemMedia,
|
||||||
|
|
@ -34,6 +36,11 @@ export function UserInfo() {
|
||||||
: "No party yet"}
|
: "No party yet"}
|
||||||
</ItemDescription>
|
</ItemDescription>
|
||||||
</ItemContent>
|
</ItemContent>
|
||||||
|
<ItemActions>
|
||||||
|
{party && members.length > 1 && (
|
||||||
|
<Button onClick={() => client.api.party.leave.post()}>Leave</Button>
|
||||||
|
)}
|
||||||
|
</ItemActions>
|
||||||
</Item>
|
</Item>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ export function usePartySocket({
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
const parsed = JSON.parse(event.data) as PartySocketEvent;
|
const parsed = JSON.parse(event.data) as PartySocketEvent;
|
||||||
|
console.log(parsed);
|
||||||
handlerRef.current?.(parsed);
|
handlerRef.current?.(parsed);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import type {
|
import type {
|
||||||
PartyMember,
|
|
||||||
PartySocketEvent,
|
PartySocketEvent,
|
||||||
PartyState,
|
PartyState,
|
||||||
} from "../../../api/src/party-types";
|
} from "../../../api/src/party-types";
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,24 @@
|
||||||
|
import type { QueryClient } from "@tanstack/react-query";
|
||||||
import { createAuthClient } from "better-auth/react";
|
import { createAuthClient } from "better-auth/react";
|
||||||
import type { AuthSession } from "./auth.serverfn";
|
import type { AuthSession } from "./auth.serverfn";
|
||||||
import type { QueryClient } from "@tanstack/react-query";
|
|
||||||
|
|
||||||
export const authClient = createAuthClient();
|
export const authClient = createAuthClient();
|
||||||
|
|
||||||
export const sessionQueryKey = ["auth", "session"] as const;
|
export const sessionQueryKey = ["auth", "session"] as const;
|
||||||
|
|
||||||
export async function fetchSession(): Promise<AuthSession | null> {
|
export async function fetchSession(): Promise<AuthSession | null> {
|
||||||
const { data } = await authClient.getSession();
|
const { data } = await authClient.getSession();
|
||||||
return data ?? null;
|
return data ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function signOutAndClearQueryCache({
|
export async function signOutAndClearQueryCache({
|
||||||
navigateToLogin,
|
navigateToLogin,
|
||||||
queryClient,
|
queryClient,
|
||||||
}: {
|
}: {
|
||||||
navigateToLogin: () => Promise<void> | void;
|
navigateToLogin: () => Promise<void> | void;
|
||||||
queryClient: QueryClient;
|
queryClient: QueryClient;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
await authClient.signOut();
|
await authClient.signOut();
|
||||||
queryClient.clear();
|
queryClient.clear();
|
||||||
await navigateToLogin();
|
await navigateToLogin();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,66 +1,66 @@
|
||||||
const pendingPartyKey = "pendingPartyJoin";
|
const pendingPartyKey = "pendingPartyJoin";
|
||||||
|
|
||||||
export function readPendingPartyJoin(): string | null {
|
export function readPendingPartyJoin(): string | null {
|
||||||
if (typeof window === "undefined") return null;
|
if (typeof window === "undefined") return null;
|
||||||
return window.localStorage.getItem(pendingPartyKey);
|
return window.localStorage.getItem(pendingPartyKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function writePendingPartyJoin(partyHostId: string) {
|
export function writePendingPartyJoin(partyHostId: string) {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
window.localStorage.setItem(pendingPartyKey, partyHostId);
|
window.localStorage.setItem(pendingPartyKey, partyHostId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearPendingPartyJoin() {
|
export function clearPendingPartyJoin() {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
window.localStorage.removeItem(pendingPartyKey);
|
window.localStorage.removeItem(pendingPartyKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getJoinIdFromLocation(): string | null {
|
export function getJoinIdFromLocation(): string | null {
|
||||||
if (typeof window === "undefined") return null;
|
if (typeof window === "undefined") return null;
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
const join = params.get("join");
|
const join = params.get("join");
|
||||||
if (join) return join;
|
if (join) return join;
|
||||||
const redirect = params.get("redirect");
|
const redirect = params.get("redirect");
|
||||||
if (!redirect) return null;
|
if (!redirect) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const redirectUrl = new URL(redirect, window.location.origin);
|
const redirectUrl = new URL(redirect, window.location.origin);
|
||||||
return redirectUrl.searchParams.get("join");
|
return redirectUrl.searchParams.get("join");
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearJoinIdFromLocation() {
|
export function clearJoinIdFromLocation() {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
const url = new URL(window.location.href);
|
const url = new URL(window.location.href);
|
||||||
const hasJoin = url.searchParams.has("join");
|
const hasJoin = url.searchParams.has("join");
|
||||||
const redirect = url.searchParams.get("redirect");
|
const redirect = url.searchParams.get("redirect");
|
||||||
let updatedRedirect: string | null = null;
|
let updatedRedirect: string | null = null;
|
||||||
|
|
||||||
if (redirect) {
|
if (redirect) {
|
||||||
try {
|
try {
|
||||||
const redirectUrl = new URL(redirect, window.location.origin);
|
const redirectUrl = new URL(redirect, window.location.origin);
|
||||||
if (redirectUrl.searchParams.has("join")) {
|
if (redirectUrl.searchParams.has("join")) {
|
||||||
redirectUrl.searchParams.delete("join");
|
redirectUrl.searchParams.delete("join");
|
||||||
updatedRedirect =
|
updatedRedirect =
|
||||||
redirectUrl.pathname + redirectUrl.search + redirectUrl.hash;
|
redirectUrl.pathname + redirectUrl.search + redirectUrl.hash;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
updatedRedirect = null;
|
updatedRedirect = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hasJoin && !updatedRedirect) return;
|
if (!hasJoin && !updatedRedirect) return;
|
||||||
|
|
||||||
if (hasJoin) {
|
if (hasJoin) {
|
||||||
url.searchParams.delete("join");
|
url.searchParams.delete("join");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (updatedRedirect) {
|
if (updatedRedirect) {
|
||||||
url.searchParams.set("redirect", updatedRedirect);
|
url.searchParams.set("redirect", updatedRedirect);
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextUrl = url.pathname + url.search + url.hash;
|
const nextUrl = url.pathname + url.search + url.hash;
|
||||||
window.history.replaceState({}, "", nextUrl);
|
window.history.replaceState({}, "", nextUrl);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,12 @@ import { type ClassValue, clsx } from "clsx";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs));
|
return twMerge(clsx(inputs));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function initials(name: string) {
|
export function initials(name: string) {
|
||||||
return name
|
return name
|
||||||
.split(" ")
|
.split(" ")
|
||||||
.map((t) => t[0])
|
.map((t) => t[0])
|
||||||
.join("");
|
.join("");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,152 +1,152 @@
|
||||||
import { TanStackDevtools } from "@tanstack/react-devtools";
|
import { TanStackDevtools } from "@tanstack/react-devtools";
|
||||||
import type { QueryClient } from "@tanstack/react-query";
|
import type { QueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
createRootRouteWithContext,
|
createRootRouteWithContext,
|
||||||
HeadContent,
|
HeadContent,
|
||||||
redirect,
|
redirect,
|
||||||
Scripts,
|
Scripts,
|
||||||
} from "@tanstack/react-router";
|
} from "@tanstack/react-router";
|
||||||
import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools";
|
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 { Toaster } from "@/components/ui/sonner";
|
||||||
import TanStackQueryDevtools from "../integrations/tanstack-query/devtools";
|
import TanStackQueryDevtools from "../integrations/tanstack-query/devtools";
|
||||||
import appCss from "../styles.css?url";
|
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 {
|
interface MyRouterContext {
|
||||||
queryClient: QueryClient;
|
queryClient: QueryClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Route = createRootRouteWithContext<MyRouterContext>()({
|
export const Route = createRootRouteWithContext<MyRouterContext>()({
|
||||||
head: () => ({
|
head: () => ({
|
||||||
meta: [
|
meta: [
|
||||||
{
|
{
|
||||||
charSet: "utf-8",
|
charSet: "utf-8",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "viewport",
|
name: "viewport",
|
||||||
content: "width=device-width, initial-scale=1",
|
content: "width=device-width, initial-scale=1",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "TanStack Start Starter",
|
title: "Music Quiz",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
links: [
|
links: [
|
||||||
{
|
{
|
||||||
rel: "stylesheet",
|
rel: "stylesheet",
|
||||||
href: appCss,
|
href: appCss,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
shellComponent: RootDocument,
|
shellComponent: RootDocument,
|
||||||
beforeLoad: async ({ context, location }) => {
|
beforeLoad: async ({ context, location }) => {
|
||||||
const authPublicPaths = new Set(["/login"]);
|
const authPublicPaths = new Set(["/login"]);
|
||||||
const isAuthPublicPath = authPublicPaths.has(location.pathname);
|
const isAuthPublicPath = authPublicPaths.has(location.pathname);
|
||||||
let session: AuthSession | null;
|
let session: AuthSession | null;
|
||||||
if (typeof window === "undefined") {
|
if (typeof window === "undefined") {
|
||||||
const { getSession } = await import("../lib/auth.serverfn");
|
const { getSession } = await import("../lib/auth.serverfn");
|
||||||
session = await getSession();
|
session = await getSession();
|
||||||
} else {
|
} else {
|
||||||
session = await context.queryClient.fetchQuery({
|
session = await context.queryClient.fetchQuery({
|
||||||
queryKey: sessionQueryKey,
|
queryKey: sessionQueryKey,
|
||||||
queryFn: fetchSession,
|
queryFn: fetchSession,
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = session?.user;
|
const user = session?.user;
|
||||||
|
|
||||||
if (!user && !isAuthPublicPath) {
|
if (!user && !isAuthPublicPath) {
|
||||||
throw redirect({
|
throw redirect({
|
||||||
to: "/login",
|
to: "/login",
|
||||||
search: { redirect: location.href },
|
search: { redirect: location.href },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return { user, session };
|
return { user, session };
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
function RootDocument({ children }: { children: React.ReactNode }) {
|
function RootDocument({ children }: { children: React.ReactNode }) {
|
||||||
const { user } = Route.useRouteContext();
|
const { user } = Route.useRouteContext();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
const joinId = getJoinIdFromLocation();
|
const joinId = getJoinIdFromLocation();
|
||||||
if (!joinId) return;
|
if (!joinId) return;
|
||||||
const storedId = readPendingPartyJoin();
|
const storedId = readPendingPartyJoin();
|
||||||
if (storedId !== joinId) {
|
if (storedId !== joinId) {
|
||||||
writePendingPartyJoin(joinId);
|
writePendingPartyJoin(joinId);
|
||||||
}
|
}
|
||||||
clearJoinIdFromLocation();
|
clearJoinIdFromLocation();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
const joinId = readPendingPartyJoin();
|
const joinId = readPendingPartyJoin();
|
||||||
if (!joinId) return;
|
if (!joinId) return;
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const attemptJoin = async () => {
|
const attemptJoin = async () => {
|
||||||
try {
|
try {
|
||||||
const result = await client.api.party.join.post({
|
const result = await client.api.party.join.post({
|
||||||
targetUserId: joinId,
|
targetUserId: joinId,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result?.error) {
|
if (result?.error) {
|
||||||
toast.error(result.error);
|
toast.error(result.error);
|
||||||
clearPendingPartyJoin();
|
clearPendingPartyJoin();
|
||||||
} else {
|
} else {
|
||||||
toast.success("Joined party.");
|
toast.success("Joined party.");
|
||||||
clearPendingPartyJoin();
|
clearPendingPartyJoin();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (_error) {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
toast.error("Failed to join party.");
|
toast.error("Failed to join party.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
attemptJoin();
|
attemptJoin();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="en" suppressHydrationWarning>
|
<html lang="en" suppressHydrationWarning>
|
||||||
<head>
|
<head>
|
||||||
<HeadContent />
|
<HeadContent />
|
||||||
</head>
|
</head>
|
||||||
<body className="font-sans antialiased wrap-anywhere dark">
|
<body className="font-sans antialiased wrap-anywhere dark">
|
||||||
{children}
|
{children}
|
||||||
<Toaster />
|
<Toaster />
|
||||||
<TanStackDevtools
|
<TanStackDevtools
|
||||||
config={{
|
config={{
|
||||||
position: "bottom-right",
|
position: "bottom-right",
|
||||||
}}
|
}}
|
||||||
plugins={[
|
plugins={[
|
||||||
{
|
{
|
||||||
name: "Tanstack Router",
|
name: "Tanstack Router",
|
||||||
render: <TanStackRouterDevtoolsPanel />,
|
render: <TanStackRouterDevtoolsPanel />,
|
||||||
},
|
},
|
||||||
TanStackQueryDevtools,
|
TanStackQueryDevtools,
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<Scripts />
|
<Scripts />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,25 @@
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { PartyView } from "#/components/party/party-view";
|
||||||
import { PartyQr } from "#/components/party-qr";
|
import { PartyQr } from "#/components/party-qr";
|
||||||
|
import { StartParty } from "#/components/start-party";
|
||||||
import { SyncButton } from "#/components/sync-button";
|
import { SyncButton } from "#/components/sync-button";
|
||||||
import { MainContent } from "#/components/ui/main-content";
|
import { MainContent } from "#/components/ui/main-content";
|
||||||
import { UserInfo } from "#/components/user-info";
|
import { UserInfo } from "#/components/user-info";
|
||||||
|
import { useParty } from "#/hooks/use-party";
|
||||||
import { useUser } from "#/hooks/user";
|
import { useUser } from "#/hooks/user";
|
||||||
|
|
||||||
export const Route = createFileRoute("/")({ component: App });
|
export const Route = createFileRoute("/")({ component: App });
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const { user } = useUser();
|
const { user } = useUser();
|
||||||
|
const { party, members } = useParty();
|
||||||
return (
|
return (
|
||||||
<MainContent>
|
<MainContent>
|
||||||
<UserInfo />
|
<UserInfo />
|
||||||
{!user?.lastSyncAt && <SyncButton />}
|
{!user?.lastSyncAt && <SyncButton />}
|
||||||
{user && <PartyQr />}
|
{user && <PartyQr />}
|
||||||
|
{party && !party.data && members.length > 1 && <StartParty />}
|
||||||
|
{party?.data && <PartyView />}
|
||||||
</MainContent>
|
</MainContent>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,7 @@ const config = defineConfig({
|
||||||
"/api": {
|
"/api": {
|
||||||
target: "http://localhost:4000",
|
target: "http://localhost:4000",
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
rewrite: (path) =>
|
rewrite: (path) => path.replace(/^\/api/, "/api"),
|
||||||
path.replace(/^\/api/, "/api"),
|
|
||||||
},
|
},
|
||||||
"/api/party-socket/ws": {
|
"/api/party-socket/ws": {
|
||||||
target: "ws://localhost:4000",
|
target: "ws://localhost:4000",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue