diff options
Diffstat (limited to 'src')
| -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 |
6 files changed, 453 insertions, 0 deletions
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" |
