From b37a7ffc5b19f7330050970ebac7f949a89812a2 Mon Sep 17 00:00:00 2001 From: Pinapelz Date: Mon, 3 Aug 2026 17:37:22 -0700 Subject: feat: group streaks - maintain completion streaks as a group - join via a provided code, streaks increment when you guess correctly --- src/pages/LandingPage.tsx | 207 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 207 insertions(+) (limited to 'src/pages') 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 ( @@ -216,6 +364,65 @@ export function LandingPage() { + setIsCreateOpen((open) => !open)}> + {isCreateOpen ? "▾" : "▸"} Create Group + + {isCreateOpen && ( + + Create Group + + setGroupName(event.target.value)} + maxLength={64} + /> + setUsername(event.target.value)} + maxLength={32} + /> + + {isCreating ? "Creating..." : "Create"} + + + {createMessage && {createMessage}} + + )} + + setIsJoinOpen((open) => !open)}> + {isJoinOpen ? "▾" : "▸"} Join Group + + {isJoinOpen && ( + + Join a Group + + setJoinToken(event.target.value.toUpperCase())} + maxLength={12} + /> + setUsername(event.target.value)} + maxLength={32} + /> + + {isJoining ? "Joining..." : "Join"} + + + {currentMembership && ( + + Current group: {currentMembership.groupName} ({currentMembership.username}) + + )} + {joinMessage && {joinMessage}} + + )} +