aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/components/Result/index.styled.ts33
-rw-r--r--src/components/Result/index.tsx61
-rw-r--r--src/helpers/fetchSolution.ts2
-rw-r--r--src/helpers/group.ts141
-rw-r--r--src/hooks/useGameState.ts9
-rw-r--r--src/pages/LandingPage.tsx207
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"
send patches to the email below
yukais@pinapelz.com
include the subject [PATCH repo_name]
pinapelz.com
homepage