diff options
| author | Pinapelz <yukais@pinapelz.com> | 2026-08-03 17:37:22 -0700 |
|---|---|---|
| committer | Pinapelz <yukais@pinapelz.com> | 2026-08-03 17:40:15 -0700 |
| commit | b37a7ffc5b19f7330050970ebac7f949a89812a2 (patch) | |
| tree | a7c194528273721902874c9e71f3245934c0168d | |
| parent | 1f52e962086856ddd6981139e2d4aca20201e9cb (diff) | |
feat: group streaks
- maintain completion streaks as a group
- join via a provided code, streaks increment when you guess correctly
| -rw-r--r-- | server/daily.ts | 49 | ||||
| -rw-r--r-- | server/db/groups.ts | 363 | ||||
| -rw-r--r-- | server/db/schema.ts | 2 | ||||
| -rw-r--r-- | server/group.ts | 99 | ||||
| -rw-r--r-- | server/index.ts | 4 | ||||
| -rw-r--r-- | src/components/Result/index.styled.ts | 33 | ||||
| -rw-r--r-- | src/components/Result/index.tsx | 61 | ||||
| -rw-r--r-- | src/helpers/fetchSolution.ts | 2 | ||||
| -rw-r--r-- | src/helpers/group.ts | 141 | ||||
| -rw-r--r-- | src/hooks/useGameState.ts | 9 | ||||
| -rw-r--r-- | src/pages/LandingPage.tsx | 207 |
11 files changed, 949 insertions, 21 deletions
diff --git a/server/daily.ts b/server/daily.ts index f3eb9d8..6a9a83c 100644 --- a/server/daily.ts +++ b/server/daily.ts @@ -13,6 +13,7 @@ import { getDailySong, getDailyMusicVideo, } from "./shared"; +import { recordDailySolve, recordMvSolve } from "./db/groups"; export const dailyRouter = Router(); @@ -69,6 +70,8 @@ dailyRouter.post("/guess", guessLimiter, (req, res) => { state?: unknown; sig?: unknown; guess?: unknown; + username?: unknown; + groupId?: unknown; }; if (!verifySessionToken(body.sessionToken, today)) { @@ -135,6 +138,28 @@ dailyRouter.post("/guess", guessLimiter, (req, res) => { guesses: [...state.guesses, nextGuess], }; + const hasFinishedRound = nextState.didGuess || nextState.currentTry >= 6; + + if ( + hasFinishedRound && + typeof body.groupId === "string" && + body.groupId.trim().length > 0 && + typeof body.username === "string" && + body.username.trim().length > 0 + ) { + try { + recordDailySolve( + body.groupId, + body.username, + today, + nextState.currentTry, + nextState.didGuess + ); + } catch (error) { + console.error("Failed to record group daily solve", error); + } + } + res.json({ state: nextState, sig: signState(nextState), @@ -149,6 +174,8 @@ dailyRouter.post("/guessMV", guessLimiter, (req, res) => { state?: unknown; sig?: unknown; guess?: unknown; + username?: unknown; + groupId?: unknown; }; if (!verifySessionToken(body.sessionToken, today)) { @@ -215,6 +242,28 @@ dailyRouter.post("/guessMV", guessLimiter, (req, res) => { guesses: [...state.guesses, nextGuess], }; + const hasFinishedRound = nextState.didGuess || nextState.currentTry >= 6; + + if ( + hasFinishedRound && + typeof body.groupId === "string" && + body.groupId.trim().length > 0 && + typeof body.username === "string" && + body.username.trim().length > 0 + ) { + try { + recordMvSolve( + body.groupId, + body.username, + today, + nextState.currentTry, + nextState.didGuess + ); + } catch (error) { + console.error("Failed to record group MV solve", error); + } + } + res.json({ state: nextState, sig: signState(nextState), diff --git a/server/db/groups.ts b/server/db/groups.ts index 1060501..12e5362 100644 --- a/server/db/groups.ts +++ b/server/db/groups.ts @@ -1,27 +1,350 @@ -import { db } from "./index"; import crypto from "node:crypto"; +import { getUtcDate } from "../shared"; +import { db } from "./index"; -export function createGroup(name: string) { - const id = crypto.randomUUID(); - const joinCode = crypto - .randomBytes(3) - .toString("hex") - .toUpperCase(); +type GroupMode = "daily" | "mv"; + +function previousUtcDate(date: string): string { + const value = new Date(`${date}T00:00:00Z`); + value.setUTCDate(value.getUTCDate() - 1); + return value.toISOString().slice(0, 10); +} - db.prepare(` - INSERT INTO groups - (id, name, join_code, created_at) - VALUES (?, ?, ?, ?) - `).run( - id, - name, - joinCode, - Date.now() - ); +function getSolveTableName(mode: GroupMode): "daily_solves" | "mv_solves" { + return mode === "mv" ? "mv_solves" : "daily_solves"; +} +function getStreakColumns(mode: GroupMode): { + currentStreak: "current_streak" | "mv_current_streak"; + lastCompleted: "last_completed" | "mv_last_completed"; +} { + if (mode === "mv") { return { - id, - name, - joinCode + currentStreak: "mv_current_streak", + lastCompleted: "mv_last_completed", }; + } + + return { + currentStreak: "current_streak", + lastCompleted: "last_completed", + }; +} + +function isGroupSolvedOnDate(groupId: string, date: string, mode: GroupMode): boolean { + const tableName = getSolveTableName(mode); + const solvedCount = db + .prepare( + ` + SELECT COUNT(DISTINCT username) as count + FROM ${tableName} + WHERE group_id = ? AND date = ? + AND solved = 1 + ` + ) + .get(groupId, date) as { count: number }; + + return !!solvedCount && solvedCount.count >= 1; +} + +function getStoredGroupStreak( + groupId: string, + mode: GroupMode +): { + currentStreak: number; + lastCompleted: string | null; +} { + const columns = getStreakColumns(mode); + const row = db + .prepare( + ` + SELECT ${columns.currentStreak} as current_streak, ${columns.lastCompleted} as last_completed + FROM groups + WHERE id = ? + ` + ) + .get(groupId) as + | { current_streak: number; last_completed: string | null } + | undefined; + + if (!row) { + return { currentStreak: 0, lastCompleted: null }; + } + + return { + currentStreak: row.current_streak, + lastCompleted: row.last_completed, + }; +} + +function getActiveGroupStreak( + groupId: string, + mode: GroupMode, + today = getUtcDate() +): { + currentStreak: number; + lastCompleted: string | null; +} { + const stored = getStoredGroupStreak(groupId, mode); + const columns = getStreakColumns(mode); + + if (!stored.lastCompleted) { + if (stored.currentStreak !== 0) { + db.prepare( + ` + UPDATE groups + SET ${columns.currentStreak} = 0 + WHERE id = ? + ` + ).run(groupId); + } + + return { currentStreak: 0, lastCompleted: null }; + } + + const yesterday = previousUtcDate(today); + if (stored.lastCompleted === today || stored.lastCompleted === yesterday) { + return stored; + } + + if (stored.currentStreak !== 0) { + db.prepare( + ` + UPDATE groups + SET ${columns.currentStreak} = 0 + WHERE id = ? + ` + ).run(groupId); + } + + return { + currentStreak: 0, + lastCompleted: stored.lastCompleted, + }; +} + +function updateGroupStreakForToday( + groupId: string, + mode: GroupMode, + today = getUtcDate() +): { + currentStreak: number; + lastCompleted: string | null; +} { + const stored = getActiveGroupStreak(groupId, mode, today); + const columns = getStreakColumns(mode); + + if (!isGroupSolvedOnDate(groupId, today, mode)) { + return stored; + } + + if (stored.lastCompleted === today) { + return stored; + } + + const yesterday = previousUtcDate(today); + const nextStreak = stored.lastCompleted === yesterday ? stored.currentStreak + 1 : 1; + + db.prepare( + ` + UPDATE groups + SET ${columns.currentStreak} = ?, ${columns.lastCompleted} = ? + WHERE id = ? + ` + ).run(nextStreak, today, groupId); + + return { + currentStreak: nextStreak, + lastCompleted: today, + }; +} + +export function createGroup(name: string) { + const normalizedName = name.trim().slice(0, 64); + if (!normalizedName) { + throw new Error("Group name is required"); + } + + const existingGroup = db + .prepare( + ` + SELECT id FROM groups WHERE name = ? + ` + ) + .get(normalizedName) as { id: string } | undefined; + + if (existingGroup) { + throw new Error("Group already exists"); + } + + const id = crypto.randomUUID(); + const joinCode = crypto.randomBytes(3).toString("hex").toUpperCase(); + + db.prepare( + ` + INSERT INTO groups (id, name, join_code, created_at) + VALUES (?, ?, ?, ?) + ` + ).run(id, normalizedName, joinCode, Date.now()); + + return { + id, + name: normalizedName, + joinCode, + }; +} + +export function getGroupByJoinCode( + joinCode: string +): { id: string; name: string; joinCode: string } | null { + const normalizedCode = joinCode.trim().toUpperCase(); + + const group = db + .prepare( + ` + SELECT id, name, join_code + FROM groups + WHERE join_code = ? + ` + ) + .get(normalizedCode) as + | { id: string; name: string; join_code: string } + | undefined; + + if (!group) return null; + + return { + id: group.id, + name: group.name, + joinCode: group.join_code, + }; +} + +export function recordGroupJoin(groupId: string, username: string) { + const normalizedUsername = username.trim().slice(0, 32); + if (!normalizedUsername) { + throw new Error("Username is required"); + } + + // Check if the user is already a member of the group + const existingJoin = db + .prepare( + ` + SELECT group_id + FROM group_members + WHERE group_id = ? AND username = ? + ` + ) + .get(groupId, normalizedUsername) as { group_id: string } | undefined; + if (existingJoin) return; + + // add the user to the group + db.prepare( + ` + INSERT INTO group_members (group_id, username, joined_at) + VALUES (?, ?, ?) + ` + ).run(groupId, normalizedUsername, Date.now()); +} + +function recordSolve( + tableName: "daily_solves" | "mv_solves", + groupId: string, + username: string, + date: string, + attempt: number, + guessed: boolean +) { + const today = getUtcDate(); + if (date !== today) { + throw new Error(`Only today's solves can be recorded. Expected ${today}, got ${date}.`); + } + + const normalizedUsername = username.trim().slice(0, 32); + const membership = db + .prepare( + ` + SELECT group_id + FROM group_members + WHERE group_id = ? AND username = ? + ` + ) + .get(groupId, normalizedUsername) as { group_id: string } | undefined; + + if (!membership) return; + + const boundedAttempt = Math.max(1, Math.min(6, Math.floor(attempt))); + db.prepare( + ` + INSERT INTO ${tableName} (group_id, username, date, solved, attempts) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(group_id, username, date) + DO UPDATE SET solved = excluded.solved, attempts = excluded.attempts + ` + ).run(groupId, normalizedUsername, date, guessed ? 1 : 0, boundedAttempt); + + const mode: GroupMode = tableName === "mv_solves" ? "mv" : "daily"; + updateGroupStreakForToday(groupId, mode, today); +} + +export function recordDailySolve( + groupId: string, + username: string, + date: string, + attempt: number, + guessed: boolean +) { + recordSolve("daily_solves", groupId, username, date, attempt, guessed); +} + +export function recordMvSolve( + groupId: string, + username: string, + date: string, + attempt: number, + guessed: boolean +) { + recordSolve("mv_solves", groupId, username, date, attempt, guessed); +} + +export function getGroupDailyStatus( + groupId: string, + date: string, + mode: GroupMode = "daily" +): { + groupId: string; + groupName: string; + currentStreak: number; + finishedUsers: string[]; +} | null { + const group = db + .prepare( + ` + SELECT id, name + FROM groups + WHERE id = ? + ` + ) + .get(groupId) as { id: string; name: string } | undefined; + if (!group) return null; + + const streak = getActiveGroupStreak(groupId, mode, date); + const solveTable = getSolveTableName(mode); + const finishedRows = db + .prepare( + ` + SELECT username + FROM ${solveTable} + WHERE group_id = ? AND date = ? + AND (solved = 1 OR attempts >= 6) + ORDER BY username ASC + ` + ).all(groupId, date) as Array<{ username: string }>; + + return { + groupId: group.id, + groupName: group.name, + currentStreak: streak.currentStreak, + finishedUsers: finishedRows.map((row) => row.username), + }; } diff --git a/server/db/schema.ts b/server/db/schema.ts index 8fc2532..eca4dcc 100644 --- a/server/db/schema.ts +++ b/server/db/schema.ts @@ -7,6 +7,8 @@ CREATE TABLE IF NOT EXISTS groups ( join_code TEXT UNIQUE NOT NULL, current_streak INTEGER NOT NULL DEFAULT 0, last_completed TEXT, + mv_current_streak INTEGER NOT NULL DEFAULT 0, + mv_last_completed TEXT, created_at INTEGER NOT NULL ); diff --git a/server/group.ts b/server/group.ts new file mode 100644 index 0000000..be71cc3 --- /dev/null +++ b/server/group.ts @@ -0,0 +1,99 @@ +import { Router } from "express"; +import { + createGroup, + getGroupDailyStatus, + getGroupByJoinCode, + recordGroupJoin, +} from "./db/groups"; +import { getUtcDate } from "./shared"; + +export const groupRouter = Router(); + +groupRouter.post("/create-group", (req, res) => { + const body = req.body as { + name?: unknown; + username?: unknown; + }; + + if (typeof body.name !== "string" || body.name.trim().length === 0) { + res.status(400).json({ error: "Group name is required." }); + return; + } + + try { + const group = createGroup(body.name); + + if (typeof body.username === "string" && body.username.trim()) { + recordGroupJoin(group.id, body.username); + } + + res.json({ + group: { + id: group.id, + name: group.name, + joinToken: group.joinCode, + }, + }); + } catch (error) { + res + .status(500) + .json({ error: "Group already exists, choose a different name" }); + } +}); + +groupRouter.post("/join-group", (req, res) => { + const body = req.body as { + joinToken?: unknown; + username?: unknown; + }; + + // validate request + if (typeof body.joinToken !== "string" || body.joinToken.trim().length === 0) { + res.status(400).json({ error: "joinToken is required." }); + return; + } + if (typeof body.username !== "string" || body.username.trim().length === 0) { + res.status(400).json({ error: "username is required." }); + return; + } + const group = getGroupByJoinCode(body.joinToken); + if (!group) { + res.status(404).json({ error: "Group not found for that token." }); + return; + } + try { + recordGroupJoin(group.id, body.username); + } catch { + res.status(500).json({ error: "Unable to join group." }); + return; + } + + res.json({ + groupId: group.id, + groupName: group.name, + joinToken: group.joinCode, + username: body.username.trim(), + }); +}); + +groupRouter.get("/group-status", (req, res) => { + const groupId = req.query.groupId; + const date = req.query.date; + const mode = req.query.mode; + + if (typeof groupId !== "string" || groupId.trim().length === 0) { + res.status(400).json({ error: "groupId is required." }); + return; + } + + const targetDate = typeof date === "string" && date.trim() ? date : getUtcDate(); + const normalizedMode = mode === "mv" ? "mv" : "daily"; + const status = getGroupDailyStatus(groupId, targetDate, normalizedMode); + + if (!status) { + res.status(404).json({ error: "Group not found." }); + return; + } + + res.json(status); +}); diff --git a/server/index.ts b/server/index.ts index b076de0..5f11652 100644 --- a/server/index.ts +++ b/server/index.ts @@ -3,7 +3,8 @@ import cors from "cors"; import dotenv from "dotenv"; import { dailyRouter } from "./daily"; import { selectRouter } from "./select"; -import "./db/schema" +import { groupRouter } from "./group"; +import "./db/schema"; dotenv.config(); @@ -15,6 +16,7 @@ const SERVER_PORT = process.env.SERVER_PORT || 3001; app.use(dailyRouter); app.use(selectRouter); +app.use(groupRouter); const enableDailyMusicVideo = process.env.ENABLE_DAILY_MUSIC_VIDEO === "true"; diff --git a/src/components/Result/index.styled.ts b/src/components/Result/index.styled.ts index 3b2d33d..9b502f7 100644 --- a/src/components/Result/index.styled.ts +++ b/src/components/Result/index.styled.ts @@ -36,3 +36,36 @@ export const TimeToNext = styled.h4` color: var(--cl-gray-6); margin-top: 16px; `; + +export const GroupStatus = styled.div` + margin-top: 14px; + padding: 10px 12px; + border: 1px solid var(--cl-gray-4); + border-radius: 6px; + width: 100%; + max-width: 560px; +`; + +export const GroupHeading = styled.h4` + font-family: "Roboto Mono", monospace; + font-size: 0.85rem; + font-weight: 700; + color: var(--cl-white); + margin: 0 0 6px 0; +`; + +export const GroupMeta = styled.p` + font-family: "Roboto Mono", monospace; + font-size: 0.8rem; + font-weight: 400; + color: var(--cl-gray-7); + margin: 0; + + strong { + color: var(--cl-green-6); + } + + & + & { + margin-top: 6px; + } +`; diff --git a/src/components/Result/index.tsx b/src/components/Result/index.tsx index 94a59cf..3f35933 100644 --- a/src/components/Result/index.tsx +++ b/src/components/Result/index.tsx @@ -11,6 +11,11 @@ import { MiniYouTubePlayer } from "../MiniYouTubePlayer"; import * as Styled from "./index.styled"; import { theme } from "../../constants"; import GuessDistributionChart from '../Chart'; +import { + getGroupDailyStatus, + getStoredGroupMembership, + GroupDailyStatus, +} from "../../helpers/group"; interface SolutionProps { didGuess: boolean; @@ -106,6 +111,7 @@ export function Result({ onPlayAgain, }: Props) { const [timeLeftStr, setTimeLeftStr] = useState<string>(''); + const [groupStatus, setGroupStatus] = React.useState<GroupDailyStatus | null>(null); React.useEffect(() => { const updateTimeLeft = () => { @@ -133,6 +139,37 @@ export function Result({ const isUnlimited = mode === "unlimited"; const chartMode = mode === "dailyMV" ? "dailyMV" : "daily"; + React.useEffect(() => { + if (isUnlimited) { + setGroupStatus(null); + return; + } + + const membership = getStoredGroupMembership(); + if (!membership) { + setGroupStatus(null); + return; + } + + let cancelled = false; + + getGroupDailyStatus(membership.groupId, sessionDate, chartMode) + .then((status) => { + if (!cancelled) { + setGroupStatus(status); + } + }) + .catch(() => { + if (!cancelled) { + setGroupStatus(null); + } + }); + + return () => { + cancelled = true; + }; + }, [isUnlimited, sessionDate, chartMode]); + if (didGuess) { const textForTry = ["Perfect!", "Wow!", "Super!", "Congrats!", "Nice!"]; @@ -157,6 +194,18 @@ export function Result({ {!isUnlimited && <ShareButton guesses={guesses} variant="green" />} + {groupStatus && ( + <Styled.GroupStatus> + <Styled.GroupHeading>{groupStatus.groupName}</Styled.GroupHeading> + <Styled.GroupMeta> + Group streak: <strong>{groupStatus.currentStreak}</strong> + </Styled.GroupMeta> + <Styled.GroupMeta> + Finished today: {groupStatus.finishedUsers.length > 0 ? groupStatus.finishedUsers.join(", ") : "No one yet"} + </Styled.GroupMeta> + </Styled.GroupStatus> + )} + {isUnlimited && onPlayAgain ? ( <Button variant="green" onClick={onPlayAgain}> Play Again @@ -191,6 +240,18 @@ export function Result({ {!isUnlimited && <ShareButton guesses={guesses} variant="red" />} + {groupStatus && ( + <Styled.GroupStatus> + <Styled.GroupHeading>{groupStatus.groupName}</Styled.GroupHeading> + <Styled.GroupMeta> + Group streak: <strong>{groupStatus.currentStreak}</strong> + </Styled.GroupMeta> + <Styled.GroupMeta> + Finished today: {groupStatus.finishedUsers.length > 0 ? groupStatus.finishedUsers.join(", ") : "No one yet"} + </Styled.GroupMeta> + </Styled.GroupStatus> + )} + {isUnlimited && onPlayAgain ? ( <Button variant="red" onClick={onPlayAgain}> Play Again diff --git a/src/helpers/fetchSolution.ts b/src/helpers/fetchSolution.ts index b7895bb..a91932a 100644 --- a/src/helpers/fetchSolution.ts +++ b/src/helpers/fetchSolution.ts @@ -55,6 +55,8 @@ interface SubmitDailyGuessRequest { state: DailyGameState; sig: string; guess?: SongGuessPayload | null; + username?: string; + groupId?: string; } interface SubmitDailyGuessResponse { diff --git a/src/helpers/group.ts b/src/helpers/group.ts new file mode 100644 index 0000000..2a10c36 --- /dev/null +++ b/src/helpers/group.ts @@ -0,0 +1,141 @@ +const API_URL = import.meta.env.VITE_HEARDLE_API_URL ?? "http://localhost:3001"; + +export const GROUP_MEMBERSHIP_STORAGE_KEY = "groupMembership"; + +export interface GroupMembership { + groupId: string; + groupName: string; + username: string; + joinToken?: string; +} + +export interface GroupDailyStatus { + groupId: string; + groupName: string; + currentStreak: number; + finishedUsers: string[]; +} + +export type GroupStatusMode = "daily" | "dailyMV"; + +export function getStoredGroupMembership(): GroupMembership | null { + const raw = localStorage.getItem(GROUP_MEMBERSHIP_STORAGE_KEY); + if (!raw) return null; + + try { + const parsed = JSON.parse(raw) as Partial<GroupMembership>; + if ( + typeof parsed.groupId !== "string" || + typeof parsed.groupName !== "string" || + typeof parsed.username !== "string" + ) { + localStorage.removeItem(GROUP_MEMBERSHIP_STORAGE_KEY); + return null; + } + + return { + groupId: parsed.groupId, + groupName: parsed.groupName, + username: parsed.username, + joinToken: typeof parsed.joinToken === "string" ? parsed.joinToken : undefined, + }; + } catch { + localStorage.removeItem(GROUP_MEMBERSHIP_STORAGE_KEY); + return null; + } +} + +export function saveGroupMembership(membership: GroupMembership) { + localStorage.setItem(GROUP_MEMBERSHIP_STORAGE_KEY, JSON.stringify(membership)); +} + +export async function createGroup( + name: string, + username: string +): Promise<GroupMembership> { + const response = await fetch(`${API_URL}/create-group`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ name, username }), + }); + + if (!response.ok) { + const fallback = `Failed to create group: ${response.statusText}`; + try { + const payload = (await response.json()) as { error?: unknown }; + throw new Error(typeof payload.error === "string" ? payload.error : fallback); + } catch { + throw new Error(fallback); + } + } + + const payload = (await response.json()) as { + group: { + id: string; + name: string; + joinToken: string; + }; + }; + + return { + groupId: payload.group.id, + groupName: payload.group.name, + username, + joinToken: payload.group.joinToken, + }; +} + +export async function joinGroupByToken( + joinToken: string, + username: string +): Promise<GroupMembership> { + const response = await fetch(`${API_URL}/join-group`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ joinToken, username }), + }); + + if (!response.ok) { + const fallback = `Failed to join group: ${response.statusText}`; + try { + const payload = (await response.json()) as { error?: unknown }; + throw new Error(typeof payload.error === "string" ? payload.error : fallback); + } catch { + throw new Error(fallback); + } + } + + const payload = (await response.json()) as { + groupId: string; + groupName: string; + username: string; + joinToken: string; + }; + + return { + groupId: payload.groupId, + groupName: payload.groupName, + username: payload.username, + joinToken: payload.joinToken, + }; +} + +export async function getGroupDailyStatus( + groupId: string, + date: string, + mode: GroupStatusMode = "daily" +): Promise<GroupDailyStatus> { + const response = await fetch( + `${API_URL}/group-status?groupId=${encodeURIComponent(groupId)}&date=${encodeURIComponent(date)}&mode=${encodeURIComponent(mode)}` + ); + + if (!response.ok) { + throw new Error(`Failed to fetch group status: ${response.statusText}`); + } + + return (await response.json()) as GroupDailyStatus; +} diff --git a/src/hooks/useGameState.ts b/src/hooks/useGameState.ts index 0cd448e..07eb242 100644 --- a/src/hooks/useGameState.ts +++ b/src/hooks/useGameState.ts @@ -6,6 +6,7 @@ import { submitDailyGuess, submitDailyMVGuess, } from "../helpers/fetchSolution"; +import { getStoredGroupMembership } from "../helpers/group"; export type GameMode = "daily" | "dailyMV"; @@ -231,6 +232,8 @@ export function useGameState({ setIsSubmitting(true); try { + const membership = getStoredGroupMembership(); + const res = await submitGuessFn({ sessionToken, sig: stats.sig, @@ -241,6 +244,8 @@ export function useGameState({ guesses: guesses.filter(isAnsweredGuess), }, guess: null, + groupId: membership?.groupId, + username: membership?.username, }); applyServerState(res.state, res.sig); @@ -282,6 +287,8 @@ export function useGameState({ setIsSubmitting(true); try { + const membership = getStoredGroupMembership(); + const res = await submitGuessFn({ sessionToken, sig: stats.sig, @@ -295,6 +302,8 @@ export function useGameState({ artist: selectedSong.artist, name: selectedSong.name, }, + groupId: membership?.groupId, + username: membership?.username, }); applyServerState(res.state, res.sig); diff --git a/src/pages/LandingPage.tsx b/src/pages/LandingPage.tsx index 85c0369..7cf2cb7 100644 --- a/src/pages/LandingPage.tsx +++ b/src/pages/LandingPage.tsx @@ -1,7 +1,14 @@ +import React from "react"; import { useNavigate } from "react-router-dom"; import styled from "styled-components"; import { appName } from "../constants"; +import { + createGroup, + getStoredGroupMembership, + joinGroupByToken, + saveGroupMembership, +} from "../helpers/group"; const getColor = (variant?: "pink" | "purple" | "cyan") => { switch (variant) { @@ -147,6 +154,84 @@ const ModeDescription = styled.span` margin-top: 6px; `; +const GroupJoinCard = styled.div` + width: 100%; + max-width: 440px; + border: 1px solid var(--cl-gray-4); + border-radius: 8px; + padding: 14px; + display: flex; + flex-direction: column; + gap: 10px; +`; + +const ExpandButton = styled.button` + width: 100%; + max-width: 440px; + border: 1px solid var(--cl-gray-4); + border-radius: 8px; + background: transparent; + color: var(--cl-white); + padding: 12px 14px; + font-family: "Roboto Mono", monospace; + font-size: 0.88rem; + font-weight: 700; + text-align: left; + cursor: pointer; + + &:hover { + border-color: var(--cl-cyan-6); + } +`; + +const GroupJoinTitle = styled.h3` + margin: 0; + font-family: "Roboto Mono", monospace; + font-size: 0.9rem; + font-weight: 700; + color: var(--cl-white); +`; + +const GroupJoinRow = styled.div` + display: flex; + gap: 10px; + flex-wrap: wrap; +`; + +const GroupInput = styled.input` + flex: 1; + min-width: 180px; + background: transparent; + border: 1px solid var(--cl-gray-4); + color: var(--cl-white); + padding: 10px; + font-family: "Roboto Mono", monospace; + font-size: 0.8rem; +`; + +const JoinButton = styled.button` + border: 1px solid var(--cl-cyan-6); + color: var(--cl-cyan-6); + background: transparent; + padding: 10px 14px; + font-family: "Roboto Mono", monospace; + font-size: 0.8rem; + font-weight: 600; + cursor: pointer; + + &:hover { + background: var(--cl-cyan-6); + color: var(--cl-white, #fff); + } +`; + +const GroupStatus = styled.p` + margin: 0; + font-family: "Roboto Mono", monospace; + font-size: 0.76rem; + color: var(--cl-gray-7); +`; + const Footer = styled.div` margin-top: 8px; display: flex; @@ -180,6 +265,69 @@ const GitHubIcon = styled.svg` export function LandingPage() { const navigate = useNavigate(); + const initialMembership = React.useMemo(() => getStoredGroupMembership(), []); + const [currentMembership, setCurrentMembership] = React.useState(initialMembership); + + const [groupName, setGroupName] = React.useState(""); + const [joinToken, setJoinToken] = React.useState( + initialMembership?.joinToken ?? "" + ); + const [username, setUsername] = React.useState( + initialMembership?.username ?? localStorage.getItem("groupUsername") ?? "" + ); + const [joinMessage, setJoinMessage] = React.useState(""); + const [createMessage, setCreateMessage] = React.useState(""); + const [isJoining, setIsJoining] = React.useState(false); + const [isCreating, setIsCreating] = React.useState(false); + const [isCreateOpen, setIsCreateOpen] = React.useState(false); + const [isJoinOpen, setIsJoinOpen] = React.useState(false); + + const createNewGroup = React.useCallback(async () => { + if (!groupName.trim() || !username.trim()) { + setCreateMessage("Enter group name and username."); + return; + } + + setIsCreating(true); + try { + const membership = await createGroup(groupName, username); + saveGroupMembership(membership); + setCurrentMembership(membership); + localStorage.setItem("groupUsername", membership.username); + setJoinToken(membership.joinToken ?? ""); + setCreateMessage( + `Created ${membership.groupName}. Join token: ${membership.joinToken}` + ); + } catch (error) { + setCreateMessage( + error instanceof Error ? error.message : "Unable to create group." + ); + } finally { + setIsCreating(false); + } + }, [groupName, username]); + + const joinGroup = React.useCallback(async () => { + if (!joinToken.trim() || !username.trim()) { + setJoinMessage("Enter both token and username."); + return; + } + + setIsJoining(true); + try { + const membership = await joinGroupByToken(joinToken, username); + saveGroupMembership(membership); + setCurrentMembership(membership); + localStorage.setItem("groupUsername", membership.username); + setJoinMessage(`Joined ${membership.groupName} as ${membership.username}`); + } catch (error) { + setJoinMessage( + error instanceof Error ? error.message : "Unable to join group." + ); + } finally { + setIsJoining(false); + } + }, [joinToken, username]); return ( <Container> @@ -216,6 +364,65 @@ export function LandingPage() { </div> </ModeGroups> + <ExpandButton onClick={() => setIsCreateOpen((open) => !open)}> + {isCreateOpen ? "▾" : "▸"} Create Group + </ExpandButton> + {isCreateOpen && ( + <GroupJoinCard> + <GroupJoinTitle>Create Group</GroupJoinTitle> + <GroupJoinRow> + <GroupInput + placeholder="Group name" + value={groupName} + onChange={(event) => setGroupName(event.target.value)} + maxLength={64} + /> + <GroupInput + placeholder="Username" + value={username} + onChange={(event) => setUsername(event.target.value)} + maxLength={32} + /> + <JoinButton onClick={createNewGroup} disabled={isCreating}> + {isCreating ? "Creating..." : "Create"} + </JoinButton> + </GroupJoinRow> + {createMessage && <GroupStatus>{createMessage}</GroupStatus>} + </GroupJoinCard> + )} + + <ExpandButton onClick={() => setIsJoinOpen((open) => !open)}> + {isJoinOpen ? "▾" : "▸"} Join Group + </ExpandButton> + {isJoinOpen && ( + <GroupJoinCard> + <GroupJoinTitle>Join a Group</GroupJoinTitle> + <GroupJoinRow> + <GroupInput + placeholder="Join token" + value={joinToken} + onChange={(event) => setJoinToken(event.target.value.toUpperCase())} + maxLength={12} + /> + <GroupInput + placeholder="Username" + value={username} + onChange={(event) => setUsername(event.target.value)} + maxLength={32} + /> + <JoinButton onClick={joinGroup} disabled={isJoining}> + {isJoining ? "Joining..." : "Join"} + </JoinButton> + </GroupJoinRow> + {currentMembership && ( + <GroupStatus> + Current group: {currentMembership.groupName} ({currentMembership.username}) + </GroupStatus> + )} + {joinMessage && <GroupStatus>{joinMessage}</GroupStatus>} + </GroupJoinCard> + )} + <Footer> <GitHubLink href="https://github.com/pinapelz/k-heardle" |
