Compare commits
No commits in common. "792d46beb3538227e1078366131aa074e76e8db9" and "21f859c4807b7e76f4d41810231c46e41f09dfed" have entirely different histories.
792d46beb3
...
21f859c480
30 changed files with 803 additions and 845 deletions
|
|
@ -1,7 +0,0 @@
|
|||
# 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 @@
|
|||
# API
|
||||
# web
|
||||
|
||||
To install dependencies:
|
||||
|
||||
|
|
@ -9,32 +9,7 @@ bun install
|
|||
To run:
|
||||
|
||||
```bash
|
||||
bun run dev
|
||||
bun run index.ts
|
||||
```
|
||||
|
||||
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
|
||||
This project was created using `bun init` in bun v1.3.11. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ 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()
|
||||
|
|
@ -20,7 +19,6 @@ 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, QuizState } from "./party-types";
|
||||
import type { PartySnapshot } from "./party-types";
|
||||
|
||||
type DbClient = typeof db;
|
||||
type DbTransaction = Parameters<typeof db.transaction>[0] extends (
|
||||
|
|
@ -58,10 +58,7 @@ export async function getPartyStatus(
|
|||
},
|
||||
});
|
||||
return {
|
||||
party: {
|
||||
...partyRecord,
|
||||
data: partyRecord.data as QuizState,
|
||||
},
|
||||
party: partyRecord,
|
||||
members,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
import type { InferSelectModel } from "drizzle-orm";
|
||||
import type { party, partyMember, user } from "./db/schema";
|
||||
|
||||
export type Party = Omit<InferSelectModel<typeof party>, "data"> & {
|
||||
data: QuizState;
|
||||
};
|
||||
export type Party = InferSelectModel<typeof party>;
|
||||
export type PartyMember = InferSelectModel<typeof partyMember>;
|
||||
export type User = InferSelectModel<typeof user>;
|
||||
|
||||
|
|
@ -26,20 +24,15 @@ export type PartySocketOutgoing =
|
|||
| { type: "ping" }
|
||||
| { type: "member_payload"; payload: unknown };
|
||||
|
||||
export type Question = {
|
||||
export type QuizState = {
|
||||
status: "idle" | "running" | "results";
|
||||
workflowId: string | null;
|
||||
questionIndex: number;
|
||||
currentQuestion: {
|
||||
text: string;
|
||||
options: string[];
|
||||
correct: number;
|
||||
startTimestamp: number;
|
||||
endTimestamp: number;
|
||||
points: number;
|
||||
};
|
||||
|
||||
export type QuizState = {
|
||||
status: "running" | "results";
|
||||
workflowId: string | null;
|
||||
questionIndex: number;
|
||||
currentQuestion: Question | null;
|
||||
} | null;
|
||||
answers: Record<
|
||||
string,
|
||||
{ playerId: string; selected: number; correct: boolean }
|
||||
|
|
|
|||
|
|
@ -1,42 +0,0 @@
|
|||
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 { PartySocketEvent, QuizState } from "../party-types";
|
||||
import type { QuizState } from "../party-types";
|
||||
|
||||
function userTopic(userId: string) {
|
||||
return `user:${userId}`;
|
||||
|
|
@ -24,9 +24,6 @@ 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) {
|
||||
|
|
|
|||
|
|
@ -76,19 +76,21 @@ export const quizRoutes = new Elysia()
|
|||
.post(
|
||||
"/response",
|
||||
async ({ user, body, params, set }) => {
|
||||
const party = await db.query.party.findFirst({
|
||||
where: {
|
||||
id: params.partyId,
|
||||
},
|
||||
});
|
||||
const existingQuiz = await db
|
||||
.select({ data: party.data })
|
||||
.from(party)
|
||||
.where(eq(party.id, params.partyId))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0]);
|
||||
|
||||
if (!party) {
|
||||
if (!existingQuiz) {
|
||||
set.status = 404;
|
||||
return { error: "Party not found" };
|
||||
}
|
||||
const quizData = party.data as QuizState | null;
|
||||
|
||||
console.log("response quiz data", party, quizData);
|
||||
const quizData = (
|
||||
(existingQuiz.data ?? {}) as Record<string, unknown>
|
||||
).quiz as QuizState | undefined;
|
||||
|
||||
if (!quizData || quizData.status !== "running") {
|
||||
set.status = 400;
|
||||
|
|
@ -106,6 +108,27 @@ export const quizRoutes = new Elysia()
|
|||
"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" };
|
||||
},
|
||||
{
|
||||
|
|
@ -135,7 +158,16 @@ export const quizRoutes = new Elysia()
|
|||
).quiz as QuizState | undefined;
|
||||
|
||||
return {
|
||||
quiz: quizData,
|
||||
quiz:
|
||||
quizData ??
|
||||
({
|
||||
status: "idle",
|
||||
workflowId: null,
|
||||
questionIndex: 0,
|
||||
currentQuestion: null,
|
||||
answers: {},
|
||||
scores: {},
|
||||
} satisfies QuizState),
|
||||
};
|
||||
},
|
||||
{ auth: true },
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import { ConfiguredInstance, DBOS, WorkflowQueue } from "@dbos-inc/dbos-sdk";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "../db";
|
||||
import { partyMember } from "../db/schema";
|
||||
import { updatePartyData } from "../party/state";
|
||||
import { party, partyMember } from "../db/schema";
|
||||
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,
|
||||
|
|
@ -34,10 +35,11 @@ export class QuizWorkflow extends ConfiguredInstance {
|
|||
};
|
||||
|
||||
// Initialize quiz state
|
||||
await QuizWorkflow.updatePartyData(partyId, quizState);
|
||||
await this.updatePartyData(partyId, quizState);
|
||||
await this.broadcastState(partyId, quizState);
|
||||
|
||||
// Get party members to initialize scores
|
||||
const members = await QuizWorkflow.getPartyMembers(partyId);
|
||||
const members = await this.getPartyMembers(partyId);
|
||||
for (const member of members) {
|
||||
quizState.scores[member.userId] = 0;
|
||||
}
|
||||
|
|
@ -45,19 +47,25 @@ export class QuizWorkflow extends ConfiguredInstance {
|
|||
for (let i = 0; i < TOTAL_QUESTIONS; i++) {
|
||||
quizState.questionIndex = i;
|
||||
|
||||
const question = await QuizWorkflow.generateQuestion(i);
|
||||
const question = await this.generateQuestion(i);
|
||||
quizState.currentQuestion = question;
|
||||
quizState.answers = {};
|
||||
|
||||
await QuizWorkflow.updatePartyData(partyId, quizState);
|
||||
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", {
|
||||
deadlineEpochMS: question.endTimestamp,
|
||||
});
|
||||
const response = await DBOS.recv<Response>(
|
||||
"quiz_responses",
|
||||
QUESTION_TIMEOUT_SECONDS,
|
||||
);
|
||||
|
||||
if (response === null) {
|
||||
// Timeout - fill in missing players with no answer
|
||||
|
|
@ -69,9 +77,9 @@ export class QuizWorkflow extends ConfiguredInstance {
|
|||
selected: -1,
|
||||
correct: false,
|
||||
};
|
||||
await QuizWorkflow.updatePartyData(partyId, quizState);
|
||||
}
|
||||
}
|
||||
await this.broadcastState(partyId, quizState);
|
||||
break;
|
||||
}
|
||||
|
||||
|
|
@ -84,72 +92,87 @@ export class QuizWorkflow extends ConfiguredInstance {
|
|||
|
||||
if (isCorrect) {
|
||||
quizState.scores[response.playerId] =
|
||||
(quizState.scores[response.playerId] ?? 0) + question.points;
|
||||
(quizState.scores[response.playerId] ?? 0) + 10;
|
||||
}
|
||||
|
||||
await QuizWorkflow.updatePartyData(partyId, quizState);
|
||||
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 QuizWorkflow.updatePartyData(partyId, quizState);
|
||||
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()
|
||||
private static async updatePartyData(
|
||||
private async updatePartyData(
|
||||
partyId: string,
|
||||
quizState: QuizState,
|
||||
): Promise<void> {
|
||||
console.log(partyId, quizState);
|
||||
await updatePartyData(db, partyId, quizState);
|
||||
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));
|
||||
});
|
||||
}
|
||||
|
||||
@DBOS.step()
|
||||
static async generateQuestion(index: number): Promise<{
|
||||
async generateQuestion(index: number): Promise<{
|
||||
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,
|
||||
},
|
||||
];
|
||||
|
||||
|
|
@ -157,15 +180,22 @@ export class QuizWorkflow extends ConfiguredInstance {
|
|||
if (!question) {
|
||||
throw new Error("Question not found");
|
||||
}
|
||||
return {
|
||||
...question,
|
||||
startTimestamp: Date.now(),
|
||||
endTimestamp: Date.now() + 60_000,
|
||||
};
|
||||
return question;
|
||||
}
|
||||
|
||||
@DBOS.step()
|
||||
private static async getPartyMembers(
|
||||
private async broadcastState(
|
||||
partyId: string,
|
||||
quizState: QuizState,
|
||||
): Promise<void> {
|
||||
pubsub.publish(
|
||||
`party:${partyId}`,
|
||||
JSON.stringify({ type: "quiz_state", quiz: quizState }),
|
||||
);
|
||||
}
|
||||
|
||||
@DBOS.step()
|
||||
private async getPartyMembers(
|
||||
partyId: string,
|
||||
): Promise<{ id: string; userId: string }[]> {
|
||||
return db
|
||||
|
|
|
|||
240
web/README.md
240
web/README.md
|
|
@ -1,20 +1,238 @@
|
|||
# Web
|
||||
Welcome to your new TanStack Start app!
|
||||
|
||||
A Tanstack start React web app, using Shadcn components.
|
||||
# Getting Started
|
||||
|
||||
Install dependencies:
|
||||
```sh
|
||||
To run this application:
|
||||
|
||||
```bash
|
||||
bun install
|
||||
bun --bun run dev
|
||||
```
|
||||
|
||||
Run locally:
|
||||
```sh
|
||||
bun run dev
|
||||
# Building For Production
|
||||
|
||||
To build this application for production:
|
||||
|
||||
```bash
|
||||
bun --bun run build
|
||||
```
|
||||
|
||||
By default connects to local backend (see `../api`)
|
||||
## Testing
|
||||
|
||||
Important files to update:
|
||||
This project uses [Vitest](https://vitest.dev/) for testing. You can run the tests with:
|
||||
|
||||
- `src/routes/index.tsx` has main route details
|
||||
- `src/components/party.tsx` contains quiz-specific details (game view, etc)
|
||||
```bash
|
||||
bun --bun run test
|
||||
```
|
||||
|
||||
## 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,3 +1,5 @@
|
|||
import React from "react";
|
||||
|
||||
const SpotifyIconIcon = ({
|
||||
size = undefined,
|
||||
color = "#000000",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import QRCode from "qrcode";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import QRCode from "qrcode";
|
||||
import { Button } from "#/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -59,9 +59,7 @@ export function PartyQr() {
|
|||
<ItemDescription>Invite someone to your party</ItemDescription>
|
||||
<ItemActions>
|
||||
<Dialog>
|
||||
<DialogTrigger render={<Button size="sm" variant="outline" />}>
|
||||
Show QR
|
||||
</DialogTrigger>
|
||||
<DialogTrigger render={<Button size="sm" variant="outline" />}>Show QR</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Party invite</DialogTitle>
|
||||
|
|
@ -90,9 +88,7 @@ export function PartyQr() {
|
|||
) : null}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogClose render={<Button variant="outline" />}>
|
||||
Close
|
||||
</DialogClose>
|
||||
<DialogClose render={<Button variant="outline" />}>Close</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
|
|
|||
|
|
@ -1,15 +0,0 @@
|
|||
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 />;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,130 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
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,6 +1,5 @@
|
|||
import { client } from "#/lib/eden";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Avatar, AvatarGroup, AvatarImage } from "#/components/ui/avatar";
|
||||
import { Badge } from "#/components/ui/badge";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
|
|
@ -8,6 +7,7 @@ import {
|
|||
CardHeader,
|
||||
CardTitle,
|
||||
} from "#/components/ui/card";
|
||||
import { Avatar, AvatarGroup, AvatarImage } from "#/components/ui/avatar";
|
||||
import {
|
||||
Item,
|
||||
ItemContent,
|
||||
|
|
@ -16,8 +16,8 @@ import {
|
|||
ItemMedia,
|
||||
ItemTitle,
|
||||
} from "#/components/ui/item";
|
||||
import { Badge } from "#/components/ui/badge";
|
||||
import { Spinner } from "#/components/ui/spinner";
|
||||
import { client } from "#/lib/eden";
|
||||
import { Section, SectionTitle } from "./ui/section";
|
||||
|
||||
const MAX_AVATARS = 6;
|
||||
|
|
|
|||
|
|
@ -1,32 +0,0 @@
|
|||
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 { client } from "#/lib/eden";
|
||||
import { Button } from "./ui/button";
|
||||
import { Item, ItemActions, ItemDescription } from "./ui/item";
|
||||
import { Item, ItemActions, ItemDescription, ItemTitle } from "./ui/item";
|
||||
|
||||
export function SyncButton() {
|
||||
const query = useQueryClient();
|
||||
|
|
|
|||
|
|
@ -1,12 +1,10 @@
|
|||
import { useRouteContext } from "@tanstack/react-router";
|
||||
import { useParty } from "#/hooks/use-party";
|
||||
import { useUser } from "#/hooks/user";
|
||||
import { client } from "#/lib/eden";
|
||||
import { initials } from "#/lib/utils";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar";
|
||||
import { Button } from "./ui/button";
|
||||
import {
|
||||
Item,
|
||||
ItemActions,
|
||||
ItemContent,
|
||||
ItemDescription,
|
||||
ItemMedia,
|
||||
|
|
@ -36,11 +34,6 @@ export function UserInfo() {
|
|||
: "No party yet"}
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
{party && members.length > 1 && (
|
||||
<Button onClick={() => client.api.party.leave.post()}>Leave</Button>
|
||||
)}
|
||||
</ItemActions>
|
||||
</Item>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,7 +42,6 @@ export function usePartySocket({
|
|||
|
||||
ws.onmessage = (event) => {
|
||||
const parsed = JSON.parse(event.data) as PartySocketEvent;
|
||||
console.log(parsed);
|
||||
handlerRef.current?.(parsed);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useCallback, useMemo, useState } from "react";
|
||||
import type {
|
||||
PartyMember,
|
||||
PartySocketEvent,
|
||||
PartyState,
|
||||
} from "../../../api/src/party-types";
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
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();
|
||||
|
||||
|
|
|
|||
|
|
@ -7,22 +7,22 @@ import {
|
|||
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 { 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 {
|
||||
clearJoinIdFromLocation,
|
||||
clearPendingPartyJoin,
|
||||
clearJoinIdFromLocation,
|
||||
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 { toast } from "sonner";
|
||||
|
||||
interface MyRouterContext {
|
||||
queryClient: QueryClient;
|
||||
|
|
@ -39,7 +39,7 @@ export const Route = createRootRouteWithContext<MyRouterContext>()({
|
|||
content: "width=device-width, initial-scale=1",
|
||||
},
|
||||
{
|
||||
title: "Music Quiz",
|
||||
title: "TanStack Start Starter",
|
||||
},
|
||||
],
|
||||
links: [
|
||||
|
|
@ -111,7 +111,7 @@ function RootDocument({ children }: { children: React.ReactNode }) {
|
|||
toast.success("Joined party.");
|
||||
clearPendingPartyJoin();
|
||||
}
|
||||
} catch (_error) {
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
toast.error("Failed to join party.");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,25 +1,19 @@
|
|||
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,7 +23,8 @@ 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