diff options
| author | Pinapelz <yukais@pinapelz.com> | 2026-06-01 21:19:05 -0700 |
|---|---|---|
| committer | Pinapelz <yukais@pinapelz.com> | 2026-06-01 21:19:51 -0700 |
| commit | 0d35e75edbc75f186e4a1ed52fbc3549ee9f5cd6 (patch) | |
| tree | 90abc1a6d556fc54e4277907dc340736791a5677 /src | |
init commit
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/create/page.styles.ts | 168 | ||||
| -rw-r--r-- | src/app/create/page.tsx | 207 | ||||
| -rw-r--r-- | src/app/favicon.ico | bin | 0 -> 15406 bytes | |||
| -rw-r--r-- | src/app/game/game.stat.ts | 82 | ||||
| -rw-r--r-- | src/app/game/game.utils.ts | 55 | ||||
| -rw-r--r-- | src/app/game/page.styles.ts | 663 | ||||
| -rw-r--r-- | src/app/game/page.tsx | 1011 | ||||
| -rw-r--r-- | src/app/globals.css | 6 | ||||
| -rw-r--r-- | src/app/layout.tsx | 22 | ||||
| -rw-r--r-- | src/app/page.styles.ts | 251 | ||||
| -rw-r--r-- | src/app/page.tsx | 5 | ||||
| -rw-r--r-- | src/app/registry.tsx | 26 | ||||
| -rw-r--r-- | src/app/styles/shared.ts | 78 | ||||
| -rw-r--r-- | src/app/typing/page.styles.ts | 168 | ||||
| -rw-r--r-- | src/app/typing/page.tsx | 161 | ||||
| -rw-r--r-- | src/app/use_timer.ts | 33 |
16 files changed, 2936 insertions, 0 deletions
diff --git a/src/app/create/page.styles.ts b/src/app/create/page.styles.ts new file mode 100644 index 0000000..b54e095 --- /dev/null +++ b/src/app/create/page.styles.ts @@ -0,0 +1,168 @@ +import styled from "styled-components"; + +export const Content = styled.div` + max-width: 600px; + margin: 40px auto; + padding: 0 24px 60px; +`; + +export const Heading = styled.h1` + font-size: 22px; + font-weight: 800; + margin: 0 0 4px; +`; + +export const Subheading = styled.p` + font-size: 13px; + color: #909090; + margin: 0 0 32px; +`; + +export const Form = styled.div` + display: flex; + flex-direction: column; + gap: 14px; +`; + +export const FieldGroup = styled.div` + display: flex; + flex-direction: column; + gap: 5px; +`; + +export const Label = styled.label` + font-size: 12px; + font-weight: 600; + color: #606060; + text-transform: uppercase; + letter-spacing: 0.5px; +`; + +export const Input = styled.input` + height: 40px; + padding: 0 12px; + border: 1px solid #d4d4d4; + border-radius: 8px; + font-size: 14px; + color: #1a1a1a; + background-color: #fff; + transition: border-color 0.15s; + &:focus { + outline: none; + border-color: #1a1a1a; + } + &::placeholder { + color: #b0b0b0; + } +`; + +export const Divider = styled.div` + height: 1px; + background-color: #e5e5e5; + margin: 6px 0; +`; + +export const Row = styled.div` + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +`; + +export const GenerateButton = styled.button` + height: 42px; + padding: 0 24px; + border-radius: 10px; + border: none; + background-color: #1a1a1a; + color: #fff; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: background-color 0.15s; + margin-top: 6px; + &:hover { + background-color: #333; + } +`; + +export const ModeButton = styled.button<{ $active: boolean }>` + height: 42px; + padding: 0 24px; + border-radius: 10px; + border: none; + cursor: pointer; + font-size: 14px; + font-weight: 600; + background-color: ${(p) => (p.$active ? "#1a1a1a" : "#e5e5e5")}; + color: ${(p) => (p.$active ? "#fff" : "#1a1a1a")}; + transition: background-color 0.15s; +`; + +export const OutputSection = styled.div` + margin-top: 32px; + display: flex; + flex-direction: column; + gap: 14px; +`; + +export const OutputLabel = styled.div` + font-size: 12px; + font-weight: 600; + color: #606060; + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 5px; +`; + +export const CodeBox = styled.div` + position: relative; + background-color: #f0f0f0; + border: 1px solid #d4d4d4; + border-radius: 10px; + padding: 14px 48px 14px 14px; + font-family: "Courier New", monospace; + font-size: 13px; + color: #1a1a1a; + word-break: break-all; + line-height: 1.5; +`; + +export const CopyButton = styled.button<{ $copied: boolean }>` + position: absolute; + top: 10px; + right: 10px; + width: 30px; + height: 30px; + border-radius: 6px; + border: none; + background-color: ${(p) => (p.$copied ? "#22c55e" : "#d4d4d4")}; + color: ${(p) => (p.$copied ? "#fff" : "#606060")}; + font-size: 13px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background-color 0.15s, color 0.15s; + &:hover { + background-color: ${(p) => (p.$copied ? "#16a34a" : "#c0c0c0")}; + color: #1a1a1a; + } +`; + +export const OpenLink = styled.a` + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 13px; + font-weight: 500; + color: #1a1a1a; + text-decoration: none; + border: 1px solid #d4d4d4; + border-radius: 8px; + padding: 8px 14px; + background-color: #fff; + transition: background-color 0.15s; + &:hover { + background-color: #f0f0f0; + } +`; diff --git a/src/app/create/page.tsx b/src/app/create/page.tsx new file mode 100644 index 0000000..744ab95 --- /dev/null +++ b/src/app/create/page.tsx @@ -0,0 +1,207 @@ +"use client"; +import { useState } from "react"; +import { MdLibraryMusic } from "react-icons/md"; +import { FaCopy, FaCheck, FaExternalLinkAlt } from "react-icons/fa"; +import { Root, Navbar, Logo, LogoIcon, NavLink } from "../styles/shared"; +import { + Content, + Heading, + Subheading, + Form, + FieldGroup, + Label, + Input, + Divider, + Row, + GenerateButton, + OutputSection, + OutputLabel, + CodeBox, + CopyButton, + OpenLink, +} from "./page.styles"; + +interface TypingPayload { + file1?: string; + lrc?: string; + offset?: number; + title?: string; + artist?: string; + skip_backing?: boolean; +} + +export default function CreatePage() { + const [lrc, setLrc] = useState(""); + const [file1, setFile1] = useState(""); + const [offset, setOffset] = useState(""); + + const [typingTitle, setTypingTitle] = useState(""); + const [typingArtist, setTypingArtist] = useState(""); + const [skipBacking, setSkipBacking] = useState(true); + + const [code, setCode] = useState<string | null>(null); + const [copiedCode, setCopiedCode] = useState(false); + const [copiedUrl, setCopiedUrl] = useState(false); + + const resetCopyStates = () => { + setCopiedCode(false); + setCopiedUrl(false); + }; + + const generate = () => { + const payload: TypingPayload = {}; + if (file1.trim()) payload.file1 = file1.trim(); + if (lrc.trim()) payload.lrc = lrc.trim(); + if (offset.trim() !== "") payload.offset = Number(offset); + if (typingTitle.trim()) payload.title = typingTitle.trim(); + if (typingArtist.trim()) payload.artist = typingArtist.trim(); + payload.skip_backing = skipBacking; + + setCode(btoa(JSON.stringify(payload))); + resetCopyStates(); + }; + + const copy = (text: string, which: "code" | "url") => { + navigator.clipboard.writeText(text); + if (which === "code") { + setCopiedCode(true); + setTimeout(() => setCopiedCode(false), 2000); + } else { + setCopiedUrl(true); + setTimeout(() => setCopiedUrl(false), 2000); + } + }; + + const shareUrl = code ? `${window.location.origin}/game?code=${code}` : ""; + + return ( + <Root> + <Navbar> + <Logo href="/typing"> + <LogoIcon> + <MdLibraryMusic /> + </LogoIcon> + LRC-Type + </Logo> + <NavLink href="/typing">← Back</NavLink> + </Navbar> + + <Content> + <Heading>Create a Code</Heading> + <Subheading> + Generate a shareable code for your typing game session. + </Subheading> + + <Form> + <FieldGroup> + <Label>Primary Media</Label> + <Input + type="url" + placeholder="https://example.com/song.mp4" + value={file1} + onChange={(e) => setFile1(e.target.value)} + /> + </FieldGroup> + + <FieldGroup> + <Label>LRC Lyrics</Label> + <Input + type="url" + placeholder="https://example.com/song.lrc" + value={lrc} + onChange={(e) => setLrc(e.target.value)} + /> + </FieldGroup> + + <FieldGroup> + <Label title="Offset in milliseconds. Increase this value if the main audio is ahead of the lyrics."> + LRC Offset (ms) + </Label> + <Input + type="number" + placeholder="0" + value={offset} + onChange={(e) => setOffset(e.target.value)} + step="25" + /> + </FieldGroup> + + <Divider /> + + <Row> + <FieldGroup> + <Label>Title</Label> + <Input + type="text" + placeholder="Song Title" + value={typingTitle} + onChange={(e) => setTypingTitle(e.target.value)} + /> + </FieldGroup> + <FieldGroup> + <Label>Artist</Label> + <Input + type="text" + placeholder="Artist Name" + value={typingArtist} + onChange={(e) => setTypingArtist(e.target.value)} + /> + </FieldGroup> + </Row> + + <Row> + <FieldGroup> + <Label title="When enabled, lyrics inside parentheses are treated as backing lyrics and skipped."> + Skip Backing + </Label> + <Input + type="checkbox" + checked={skipBacking} + onChange={(e) => setSkipBacking(e.target.checked)} + style={{ width: "18px", height: "18px", marginTop: "10px" }} + /> + </FieldGroup> + </Row> + + <GenerateButton onClick={generate}>Generate Code</GenerateButton> + </Form> + + {code && ( + <OutputSection> + <div> + <OutputLabel>Code</OutputLabel> + <CodeBox> + {code} + <CopyButton + $copied={copiedCode} + onClick={() => copy(code, "code")} + aria-label="Copy code" + > + {copiedCode ? <FaCheck /> : <FaCopy />} + </CopyButton> + </CodeBox> + </div> + + <div> + <OutputLabel>Share URL</OutputLabel> + <CodeBox> + {shareUrl} + <CopyButton + $copied={copiedUrl} + onClick={() => copy(shareUrl, "url")} + aria-label="Copy URL" + > + {copiedUrl ? <FaCheck /> : <FaCopy />} + </CopyButton> + </CodeBox> + </div> + + <OpenLink href={shareUrl} target="_blank" rel="noopener noreferrer"> + <FaExternalLinkAlt /> Open in Typing Game + </OpenLink> + </OutputSection> + )} + </Content> + </Root> + ); +} diff --git a/src/app/favicon.ico b/src/app/favicon.ico Binary files differnew file mode 100644 index 0000000..28f11e9 --- /dev/null +++ b/src/app/favicon.ico diff --git a/src/app/game/game.stat.ts b/src/app/game/game.stat.ts new file mode 100644 index 0000000..43136e6 --- /dev/null +++ b/src/app/game/game.stat.ts @@ -0,0 +1,82 @@ +export interface GState { + displayedLineIdx: number; + typedCount: number; + lineCompleted: boolean; + combo: number; + maxCombo: number; + score: number; + totalCorrect: number; + totalMiss: number; + linesCleared: number; + wpm: number; +} + +export type GAction = + | { type: "ADVANCE"; newIdx: number; prevCompleted: boolean } + | { type: "CORRECT"; willComplete: boolean } + | { type: "WRONG" } + | { type: "RESET" }; + +export const initialGState: GState = { + displayedLineIdx: -1, + typedCount: 0, + lineCompleted: false, + combo: 0, + maxCombo: 0, + score: 0, + totalCorrect: 0, + totalMiss: 0, + linesCleared: 0, + wpm: 0, +}; + +export function gReducer(state: GState, action: GAction): GState { + switch (action.type) { + case "ADVANCE": { + const prevIdx = state.displayedLineIdx; + const comboReset = !action.prevCompleted && prevIdx >= 0; + return { + ...state, + displayedLineIdx: action.newIdx, + typedCount: 0, + lineCompleted: false, + combo: comboReset ? 0 : state.combo, + }; + } + + case "CORRECT": { + const newTypedCount = state.typedCount + 1; + const newCombo = state.combo + 1; + const newMaxCombo = Math.max(state.maxCombo, newCombo); + const comboBonus = Math.min(50, Math.floor(newCombo / 10) * 5); + const newScore = state.score + 10 + comboBonus; + const newTotalCorrect = state.totalCorrect + 1; + if (action.willComplete) { + return { + ...state, + typedCount: newTypedCount, + lineCompleted: true, + combo: newCombo, + maxCombo: newMaxCombo, + score: newScore, + totalCorrect: newTotalCorrect, + linesCleared: state.linesCleared + 1, + }; + } + return { + ...state, + typedCount: newTypedCount, + combo: newCombo, + maxCombo: newMaxCombo, + score: newScore, + totalCorrect: newTotalCorrect, + }; + } + case "WRONG": + return { ...state, totalMiss: state.totalMiss + 1, combo: 0 }; + case "RESET": + return { ...initialGState }; + default: + return state; + } +}
\ No newline at end of file diff --git a/src/app/game/game.utils.ts b/src/app/game/game.utils.ts new file mode 100644 index 0000000..b2037e5 --- /dev/null +++ b/src/app/game/game.utils.ts @@ -0,0 +1,55 @@ +export interface GameLine { + millisecond: number; + content: string; +} + +export function parseLrcLines( + lrcText: string, + options?: { skipBacking?: boolean } +): GameLine[] { + const result: GameLine[] = []; + const lineRegex = /\[(\d{2,3}):(\d{2})\.(\d{2,3})\]/g; + const { skipBacking = false } = options ?? {}; + + for (const rawLine of lrcText.split("\n")) { + const timestamps: number[] = []; + let match: RegExpExecArray | null; + let lastIndex = 0; + + lineRegex.lastIndex = 0; + while ((match = lineRegex.exec(rawLine)) !== null) { + const minutes = parseInt(match[1], 10); + const seconds = parseInt(match[2], 10); + const msField = match[3]; + const ms = + msField.length === 2 + ? parseInt(msField, 10) * 10 + : parseInt(msField, 10); + timestamps.push(minutes * 60_000 + seconds * 1_000 + ms); + lastIndex = match.index + match[0].length; + } + + if (timestamps.length === 0) continue; + + const content = (skipBacking + ? rawLine.slice(lastIndex).replace(/\([^)]*\)/g, "") + : rawLine.slice(lastIndex) + ).trim(); + + for (const ms of timestamps) { + result.push({ millisecond: ms, content }); + } + } + + result.sort((a, b) => a.millisecond - b.millisecond); + return result; +} + +export function calculateCPSNeeded(text: string, seconds: number): number { + return text.length / seconds; +} + +export function formatTime(ms: number): string { + const s = Math.max(0, Math.floor(ms / 1000)); + return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`; +} diff --git a/src/app/game/page.styles.ts b/src/app/game/page.styles.ts new file mode 100644 index 0000000..d410339 --- /dev/null +++ b/src/app/game/page.styles.ts @@ -0,0 +1,663 @@ +import styled, { keyframes, css, createGlobalStyle } from "styled-components"; + +/* ----- ANIMATIONS ----- */ + +export const pulseAnim = keyframes` + 0% { opacity: 1; } + 50% { opacity: 0.4; } + 100% { opacity: 1; } +`; + +export const wrongShakeAnim = keyframes` + 0% { transform: translateX(0); } + 20% { transform: translateX(-4px); } + 40% { transform: translateX(4px); } + 60% { transform: translateX(-4px); } + 80% { transform: translateX(4px); } + 100% { transform: translateX(0); } +`; + +export const clearPopAnim = keyframes` + 0% { transform: scale(0.8); opacity: 0; } + 40% { transform: scale(1.2); opacity: 1; } + 100% { transform: scale(1.0); opacity: 0; } +`; + +export const fadeInUpAnim = keyframes` + 0% { transform: translateY(10px); opacity: 0; } + 100% { transform: translateY(0); opacity: 1; } +`; + +export const comboScaleAnim = keyframes` + 0% { transform: scale(1); } + 50% { transform: scale(1.4); } + 100% { transform: scale(1); } +`; + +export const glowAnim = keyframes` + 0% { box-shadow: 0 0 4px 0px rgba(124, 58, 237, 0.4); } + 50% { box-shadow: 0 0 16px 4px rgba(124, 58, 237, 0.8); } + 100% { box-shadow: 0 0 4px 0px rgba(124, 58, 237, 0.4); } +`; + +export const GameGlobalStyle = createGlobalStyle` + html, + body { + height: 100%; + overflow: hidden; + } +`; + +/* ----- LAYOUT ----- */ + +export const GameRoot = styled.div` + position: fixed; + inset: 0; + display: flex; + flex-direction: column; + background: #0d0d14; + color: #ffffff; + font-family: "Roboto", "Segoe UI", Arial, sans-serif; + overflow: hidden; + z-index: 0; +`; + +export const BackgroundVideo = styled.video` + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + pointer-events: none; + z-index: 0; +`; + +export const GameNavbar = styled.nav` + position: sticky; + top: 0; + display: flex; + flex-direction: row; + align-items: center; + height: 52px; + padding: 0 20px; + background: rgba(13, 13, 20, 0.75); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + z-index: 20; +`; + +export const GameContent = styled.div` + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + position: relative; + z-index: 1; +`; + +/* ----- HUD ----- */ + +export const HUD = styled.div` + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + gap: 24px; + padding: 10px 24px; + background: rgba(13, 13, 20, 0.75); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + position: relative; + z-index: 2; +`; + +export const HudStat = styled.div` + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; +`; + +export const HudValue = styled.span` + font-size: 22px; + font-weight: 700; + color: #ffffff; +`; + +export const HudLabel = styled.span` + font-size: 10px; + letter-spacing: 1.5px; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.45); +`; + +export const ComboValue = styled(HudValue)<{ $animate: boolean }>` + ${({ $animate }) => + $animate && + css` + animation: ${comboScaleAnim} 0.25s ease; + `} +`; + +/* ----- MAIN GAME AREA ----- */ + +export const GameArea = styled.div` + position: relative; + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 24px 32px; + gap: 24px; + overflow: hidden; + + & > * { + position: relative; + z-index: 1; + } +`; + +export const UpcomingWrap = styled.div` + width: 100%; + max-width: 800px; + display: flex; + flex-direction: column; + gap: 6px; +`; + +export const UpcomingLabel = styled.span` + font-size: 10px; + letter-spacing: 2px; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.30); + margin-bottom: 2px; +`; + +export const UpcomingText = styled.p` + font-size: 20px; + color: rgba(255, 255, 255, 0.30); + font-weight: 400; + font-family: "Inter", "Segoe UI", "Helvetica Neue", Arial, sans-serif; + min-height: 28px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin: 0; +`; + +export const CurrentWrap = styled.div` + width: 100%; + max-width: 800px; + display: flex; + flex-direction: column; + gap: 10px; +`; + +export const LineTimingRow = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +`; + +export const LineTimingMeta = styled.div` + display: flex; + align-items: center; + justify-content: flex-start; + font-size: 13px; + letter-spacing: 1px; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.45); +`; + +export const LineTimingValue = styled.span` + font-variant-numeric: tabular-nums; +`; + +export const LineTimingBar = styled.div` + width: 100%; + height: 3px; + background: rgba(255, 255, 255, 0.12); + border-radius: 2px; + overflow: hidden; +`; + +export const LineTimingFill = styled.div.attrs<{ $pct: number }>((props) => ({ + style: { + transform: `scaleX(${props.$pct / 100})`, + }, +}))<{ $pct: number }>` + height: 100%; + width: 100%; + border-radius: 2px; + background: #7c3aed; + transform-origin: left; + will-change: transform; +`; + +export const CharRow = styled.div` + display: flex; + flex-wrap: wrap; + gap: 2px; + align-items: flex-end; + min-height: 64px; +`; + +export const WordWrap = styled.span` + display: inline-flex; + gap: 2px; + white-space: nowrap; +`; + +export const CharBox = styled.span<{ + $state: "typed" | "active" | "pending" | "wrong"; +}>` + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 36px; + font-weight: 700; + font-family: "Inter", "Segoe UI", "Helvetica Neue", Arial, sans-serif; + padding: 0 3px; + border-radius: 4px; + transition: all 0.08s ease; + + ${({ $state }) => { + switch ($state) { + case "typed": + return css` + color: #22c55e; + opacity: 0.7; + `; + case "active": + return css` + color: #fbbf24; + border-bottom: 3px solid #fbbf24; + animation: ${pulseAnim} 1s ease infinite; + `; + case "pending": + return css` + color: rgba(255, 255, 255, 0.25); + `; + case "wrong": + return css` + color: #ef4444; + animation: ${wrongShakeAnim} 0.3s ease; + `; + } + }} +`; + +export const ClearToast = styled.div` + position: absolute; + font-size: 28px; + font-weight: 800; + color: #22c55e; + animation: ${clearPopAnim} 0.7s ease forwards; + pointer-events: none; +`; + +export const GetReadyText = styled.p` + font-size: 28px; + color: rgba(255, 255, 255, 0.50); + font-weight: 500; + text-align: center; + animation: ${pulseAnim} 1.5s ease infinite; + margin: 0; +`; + +export const CompletedLineFade = styled.div` + font-size: 18px; + color: rgba(255, 255, 255, 0.20); + margin-top: 4px; + min-height: 26px; + transition: opacity 0.3s; +`; + +/* ----- FOOTER ----- */ + +export const GameFooter = styled.footer` + display: flex; + align-items: center; + gap: 12px; + padding: 12px 24px; + background: rgba(255, 255, 255, 0.04); + border-top: 1px solid rgba(255, 255, 255, 0.06); + position: relative; + z-index: 2; +`; + +export const ControlBtn = styled.button` + width: 40px; + height: 40px; + border-radius: 50%; + border: 1px solid rgba(255, 255, 255, 0.20); + background: rgba(255, 255, 255, 0.08); + color: #ffffff; + font-size: 14px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: background 0.15s; + + &:hover { + background: rgba(255, 255, 255, 0.15); + } +`; + +export const ProgressWrap = styled.div` + flex: 1; + height: 6px; + background: rgba(255, 255, 255, 0.12); + border-radius: 3px; + overflow: hidden; + cursor: pointer; +`; + +export const ProgressFill = styled.div.attrs<{ $pct: number }>((props) => ({ + style: { + width: `${props.$pct}%`, + }, +}))<{ $pct: number }>` + height: 100%; + background: #7c3aed; + border-radius: 3px; + transition: width 0.3s linear; +`; + +export const TimeText = styled.span` + font-size: 12px; + color: rgba(255, 255, 255, 0.50); + font-family: monospace; + white-space: nowrap; +`; + +/* ----- START SCREEN ----- */ + +export const StartOverlay = styled.div` + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(13, 13, 20, 0.96); + z-index: 10; + animation: ${fadeInUpAnim} 0.4s ease; +`; + +export const StartCard = styled.div` + display: flex; + flex-direction: column; + align-items: center; + gap: 20px; + padding: 40px; + max-width: 520px; + width: 100%; + text-align: center; +`; + +export const OpacityControl = styled.div` + width: 100%; + display: flex; + flex-direction: column; + gap: 8px; + align-items: stretch; +`; + +export const OpacityLabel = styled.div` + font-size: 11px; + color: rgba(255, 255, 255, 0.45); + letter-spacing: 1px; + text-transform: uppercase; + display: flex; + justify-content: space-between; + align-items: center; +`; + +export const OpacityValue = styled.span` + font-variant-numeric: tabular-nums; +`; + +export const OpacitySlider = styled.input` + width: 100%; +`; + +export const PreviewWrap = styled.div` + width: 100%; + display: flex; + flex-direction: column; + gap: 8px; +`; + +export const PreviewBtn = styled.button` + width: 100%; + padding: 10px 16px; + border-radius: 10px; + border: 1px solid rgba(255, 255, 255, 0.2); + background: rgba(255, 255, 255, 0.08); + color: #ffffff; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.15s; + + &:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.16); + } + + &:disabled { + opacity: 0.45; + cursor: not-allowed; + } +`; + +export const PreviewHint = styled.div` + font-size: 11px; + color: rgba(255, 255, 255, 0.45); + text-align: center; +`; + +export const CountdownNumber = styled.div` + font-size: 72px; + font-weight: 900; + color: #ffffff; + line-height: 1; + letter-spacing: 2px; +`; + +export const SongTitleText = styled.h1` + font-size: 32px; + font-weight: 800; + color: #ffffff; + line-height: 1.2; + margin: 0; +`; + +export const SongArtistText = styled.p` + font-size: 16px; + color: rgba(255, 255, 255, 0.50); + margin: 0; +`; + +export const StartBtn = styled.button` + padding: 14px 40px; + border-radius: 12px; + background: #7c3aed; + color: #ffffff; + font-size: 18px; + font-weight: 700; + border: none; + cursor: pointer; + transition: all 0.15s; + + &:hover:not(:disabled) { + background: #6d28d9; + transform: translateY(-2px); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +`; + +export const CodeSection = styled.div` + width: 100%; + display: flex; + flex-direction: column; + gap: 8px; +`; + +export const CodeInputRow = styled.div` + display: flex; + gap: 8px; +`; + +export const CodeInputField = styled.input` + flex: 1; + padding: 8px 12px; + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.15); + background: rgba(255, 255, 255, 0.06); + color: #ffffff; + font-size: 13px; + outline: none; + transition: border-color 0.15s; + + &:focus { + border-color: rgba(255, 255, 255, 0.35); + } + + &::placeholder { + color: rgba(255, 255, 255, 0.30); + } +`; + +export const CodeLoadBtn = styled.button` + padding: 8px 16px; + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.15); + background: rgba(255, 255, 255, 0.08); + color: #ffffff; + font-size: 13px; + cursor: pointer; + white-space: nowrap; + transition: background 0.15s; + + &:hover { + background: rgba(255, 255, 255, 0.15); + } +`; + +/* ----- RESULTS SCREEN ----- */ + +export const ResultsOverlay = styled.div` + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(13, 13, 20, 0.96); + z-index: 10; + animation: ${fadeInUpAnim} 0.4s ease; +`; + +export const ResultsCard = styled.div` + display: flex; + flex-direction: column; + align-items: center; + gap: 24px; + padding: 40px; + max-width: 540px; + width: 100%; + text-align: center; +`; + +export const ResultsTitle = styled.p` + font-size: 14px; + letter-spacing: 3px; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.40); + margin: 0; +`; + +export const BigScore = styled.h2` + font-size: 64px; + font-weight: 900; + color: #ffffff; + line-height: 1; + margin: 0; +`; + +export const StatsGrid = styled.div` + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 16px; + width: 100%; +`; + +export const StatBlock = styled.div` + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 12px; + padding: 16px; + display: flex; + flex-direction: column; + gap: 4px; +`; + +export const StatValue = styled.span` + font-size: 28px; + font-weight: 700; + color: #ffffff; +`; + +export const StatLabel = styled.span` + font-size: 11px; + letter-spacing: 1.5px; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.40); +`; + +export const ActionRow = styled.div` + display: flex; + gap: 12px; +`; + +export const PlayAgainBtn = styled.button` + padding: 10px 28px; + border-radius: 12px; + background: #7c3aed; + color: #ffffff; + font-size: 15px; + font-weight: 700; + border: none; + cursor: pointer; + transition: all 0.15s; + + &:hover { + background: #6d28d9; + transform: translateY(-2px); + } +`; + +export const HomeBtn = styled.button` + padding: 10px 28px; + border-radius: 12px; + background: transparent; + border: 1px solid rgba(255, 255, 255, 0.20); + color: #ffffff; + font-size: 15px; + font-weight: 700; + cursor: pointer; + transition: all 0.15s; + + &:hover { + background: rgba(255, 255, 255, 0.08); + } +`; diff --git a/src/app/game/page.tsx b/src/app/game/page.tsx new file mode 100644 index 0000000..bce01b3 --- /dev/null +++ b/src/app/game/page.tsx @@ -0,0 +1,1011 @@ +"use client"; +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useReducer, + useRef, + useState, + Suspense, +} from "react"; +import { useSearchParams, useRouter } from "next/navigation"; +import Link from "next/link"; +import { FaRedo } from "react-icons/fa"; +import { MdLibraryMusic } from "react-icons/md"; +import { toast, ToastContainer } from "react-toastify"; +import "react-toastify/dist/ReactToastify.css"; +import { + GameRoot, + GameGlobalStyle, + GameNavbar, + GameContent, + HUD, + HudStat, + HudValue, + HudLabel, + ComboValue, + GameArea, + UpcomingWrap, + UpcomingLabel, + UpcomingText, + CurrentWrap, + LineTimingMeta, + LineTimingValue, + LineTimingRow, + LineTimingBar, + LineTimingFill, + CharRow, + WordWrap, + CharBox, + ClearToast, + GetReadyText, + BackgroundVideo, + OpacityControl, + OpacityLabel, + OpacitySlider, + OpacityValue, + PreviewWrap, + PreviewBtn, + PreviewHint, + CompletedLineFade, + GameFooter, + ControlBtn, + ProgressWrap, + ProgressFill, + TimeText, + StartOverlay, + StartCard, + CountdownNumber, + SongTitleText, + SongArtistText, + StartBtn, + CodeSection, + CodeInputRow, + CodeInputField, + CodeLoadBtn, + ResultsOverlay, + ResultsCard, + ResultsTitle, + BigScore, + StatsGrid, + StatBlock, + StatValue, + StatLabel, + ActionRow, + PlayAgainBtn, + HomeBtn, +} from "./page.styles"; +import { gReducer, initialGState } from "./game.stat"; +import { formatTime, parseLrcLines, calculateCPSNeeded } from "./game.utils"; + +type GamePhase = "idle" | "countdown" | "playing" | "paused" | "finished"; + +const VIDEO_EXTENSIONS = new Set(["mp4", "webm", "mov", "m4v", "ogv"]); +const isVideoUrl = (url: string) => { + if (!url) return false; + const cleaned = url.split("?")[0].split("#")[0]; + const ext = cleaned.split(".").pop()?.toLowerCase(); + return !!ext && VIDEO_EXTENSIONS.has(ext); +}; +const BACKGROUND_OPACITY_KEY = "lrcType.backgroundOpacity"; +const AUDIO_VOLUME_KEY = "lrcType.audioVolume"; + +function GameInner() { + const searchParams = useSearchParams(); + const router = useRouter(); + + useEffect(() => { + window.scrollTo({ top: 0, left: 0, behavior: "auto" }); + }, []); + + const audioRef = useRef<HTMLAudioElement>(null); + const videoRef = useRef<HTMLVideoElement>(null); + const gameStartTimeRef = useRef<number>(0); + const lastHandledIdxRef = useRef(-1); + const lastLineAdvanceAtRef = useRef(0); + + const [phase, setPhase] = useState<GamePhase>("idle"); + const [currentMs, setCurrentMs] = useState(0); + const [lineTimingPct, setLineTimingPct] = useState(0); + const [lineRemainingMs, setLineRemainingMs] = useState(0); + const [currentLineTime, setCurrentLineTime] = useState(0); + const [duration, setDuration] = useState(0); + const [progressPct, setProgressPct] = useState(0); + const [gameDurationMs, setGameDurationMs] = useState(0); + + const [lrcContent, setLrcContent] = useState(""); + const [audioUrl, setAudioUrl] = useState(""); + const [songTitle, setSongTitle] = useState("Unknown Title"); + const [songArtist, setSongArtist] = useState("Unknown Artist"); + const [offset, setOffset] = useState(0); + const [loadingLrc, setLoadingLrc] = useState(false); + + const [codeInput, setCodeInput] = useState(""); + const [wrongChar, setWrongChar] = useState(false); + const [clearShowing, setClearShowing] = useState(false); + const [comboAnimKey, setComboAnimKey] = useState(0); + const [countdown, setCountdown] = useState(0); + const [backgroundOpacity, setBackgroundOpacity] = useState(() => { + if (typeof window === "undefined") return 0; + const stored = localStorage.getItem(BACKGROUND_OPACITY_KEY); + if (stored === null) return 0; + const parsed = Number(stored); + if (!Number.isFinite(parsed)) return 0; + return Math.min(100, Math.max(0, parsed)); + }); + const [audioVolume, setAudioVolume] = useState(() => { + if (typeof window === "undefined") return 100; + const stored = localStorage.getItem(AUDIO_VOLUME_KEY); + if (stored === null) return 100; + const parsed = Number(stored); + if (!Number.isFinite(parsed)) return 100; + return Math.min(100, Math.max(0, parsed)); + }); + const [isPreviewPlaying, setIsPreviewPlaying] = useState(false); + const [skipBacking, setSkipBacking] = useState(false); + const isVideo = useMemo(() => isVideoUrl(audioUrl), [audioUrl]); + + + + useEffect(() => { + localStorage.setItem(BACKGROUND_OPACITY_KEY, String(backgroundOpacity)); + }, [backgroundOpacity]); + + useEffect(() => { + localStorage.setItem(AUDIO_VOLUME_KEY, String(audioVolume)); + }, [audioVolume]); + + useEffect(() => { + const media = isVideo ? videoRef.current : audioRef.current; + if (!media) return; + media.volume = audioVolume / 100; + }, [audioVolume, isVideo, audioUrl]); + + useEffect(() => { + const media = isVideo ? videoRef.current : audioRef.current; + if (!media) { + setIsPreviewPlaying(false); + return; + } + const handlePlay = () => setIsPreviewPlaying(true); + const handlePause = () => setIsPreviewPlaying(false); + const handleEnded = () => setIsPreviewPlaying(false); + media.addEventListener("play", handlePlay); + media.addEventListener("pause", handlePause); + media.addEventListener("ended", handleEnded); + return () => { + media.removeEventListener("play", handlePlay); + media.removeEventListener("pause", handlePause); + media.removeEventListener("ended", handleEnded); + }; + }, [isVideo, audioUrl]); + + useEffect(() => { + setIsPreviewPlaying(false); + }, [audioUrl]); + + const charRowRef = useRef<HTMLDivElement | null>(null); + const charRefs = useRef<(HTMLSpanElement | null)[]>([]); + const [wrapSpaceIndicators, setWrapSpaceIndicators] = useState<boolean[]>([]); + const countdownIntervalRef = useRef<number | null>(null); + + const [g, dispatch] = useReducer(gReducer, initialGState); + + const gameLines = useMemo( + () => parseLrcLines(lrcContent, { skipBacking }), + [lrcContent, skipBacking] + ); + const isReady = !loadingLrc && !!lrcContent && !!audioUrl; + + const accuracy = + g.totalCorrect + g.totalMiss > 0 + ? Math.round((g.totalCorrect / (g.totalCorrect + g.totalMiss)) * 100) + : 100; + + const elapsedMs = + phase === "playing" + ? Math.max(1, Date.now() - gameStartTimeRef.current) + : gameDurationMs; + + const wpm = + elapsedMs > 0 ? Math.round(g.totalCorrect / 5 / (elapsedMs / 60000)) : 0; + + const gRef = useRef(g); + const currentLineContent = + g.displayedLineIdx >= 0 ? gameLines[g.displayedLineIdx]?.content ?? "" : ""; + + useEffect(() => { + charRefs.current = []; + }, [currentLineContent]); + + useLayoutEffect(() => { + if (!charRowRef.current) return; + let frame = 0; + const text = currentLineContent.toLowerCase(); + + const recompute = () => { + const nodes = charRefs.current; + const indicators = new Array(text.length).fill(false); + for (let i = 0; i < text.length - 1; i += 1) { + if (text[i] !== " ") continue; + const curr = nodes[i]; + const next = nodes[i + 1]; + if (!curr || !next) continue; + const currRect = curr.getBoundingClientRect(); + const nextRect = next.getBoundingClientRect(); + if (nextRect.top - currRect.top > 1) { + indicators[i] = true; + } + } + setWrapSpaceIndicators(indicators); + }; + + const schedule = () => { + cancelAnimationFrame(frame); + frame = requestAnimationFrame(() => { + frame = requestAnimationFrame(recompute); + }); + }; + + schedule(); + + if (document.fonts?.ready) { + document.fonts.ready.then(schedule); + } + + const observer = new ResizeObserver(schedule); + observer.observe(charRowRef.current); + window.addEventListener("resize", schedule); + + return () => { + observer.disconnect(); + window.removeEventListener("resize", schedule); + cancelAnimationFrame(frame); + }; + }, [currentLineContent]); + useEffect(() => { + gRef.current = g; + }, [g]); + + const phaseRef = useRef<GamePhase>("idle"); + useEffect(() => { + phaseRef.current = phase; + }, [phase]); + + const offsetRef = useRef(0); + useEffect(() => { + offsetRef.current = offset; + }, [offset]); + + useEffect(() => { + if (!("mediaSession" in navigator)) return; + const mediaSession = navigator.mediaSession; + mediaSession.setActionHandler("pause", () => {}); + return () => { + mediaSession.setActionHandler("pause", null); + }; + }, []); + + useEffect(() => { + return () => { + if (countdownIntervalRef.current !== null) { + clearInterval(countdownIntervalRef.current); + countdownIntervalRef.current = null; + } + }; + }, []); + + const lineAnimRef = useRef({ startMs: 0, endMs: 0, startPerf: 0 }); + + const timeBasedLineIdx = useMemo(() => { + if (!gameLines.length) return -1; + let idx = -1; + for (let i = 0; i < gameLines.length; i++) { + if (gameLines[i].millisecond <= currentMs) idx = i; + else break; + } + return idx; + }, [currentMs, gameLines]); + + const intermissionData = useMemo(() => { + const firstMs = gameLines[0]?.millisecond ?? 0; + const firstMediaMs = firstMs - offsetRef.current; + const remainingMs = Math.max(0, firstMs - currentMs); + if (!gameLines.length || firstMediaMs <= 0) { + return { pct: remainingMs === 0 ? 100 : 0, remainingMs }; + } + + const mediaCurrentMs = currentMs - offsetRef.current; + const pct = Math.min(100, Math.max(0, (mediaCurrentMs / firstMediaMs) * 100)); + + return { pct, remainingMs }; + }, [gameLines, currentMs, offset]); + + useEffect(() => { + const idx = g.displayedLineIdx; + if (idx < 0 || !gameLines[idx]) { + lineAnimRef.current = { startMs: 0, endMs: 0, startPerf: 0 }; + setLineTimingPct(0); + setLineRemainingMs(0); + setCurrentLineTime(-1); + return; + } + const start = gameLines[idx].millisecond; + const end = gameLines[idx + 1]?.millisecond ?? start + 5000; + lineAnimRef.current = { + startMs: start, + endMs: end, + startPerf: performance.now(), + }; + setLineTimingPct(0); + const currentLineTime = end - start; + setLineRemainingMs(Math.max(0, currentLineTime)); + setCurrentLineTime(Math.max(currentLineTime, currentLineTime)); + }, [g.displayedLineIdx, gameLines]); + + useEffect(() => { + if (phase !== "playing") return; + let rafId = 0; + const tick = () => { + const { startMs, endMs, startPerf } = lineAnimRef.current; + if (endMs <= startMs) { + setLineTimingPct(100); + setLineRemainingMs(0); + } else { + const elapsed = performance.now() - startPerf; + const duration = endMs - startMs; + const pct = Math.min(100, Math.max(0, (elapsed / duration) * 100)); + const remaining = Math.max(0, duration - elapsed); + setLineTimingPct(pct); + setLineRemainingMs(remaining); + } + rafId = requestAnimationFrame(tick); + }; + rafId = requestAnimationFrame(tick); + return () => cancelAnimationFrame(rafId); + }, [phase]); + + useEffect(() => { + const media = isVideo ? videoRef.current : audioRef.current; + if (!media) return; + const onTimeUpdate = () => { + setCurrentMs(media.currentTime * 1000 + offsetRef.current); + if (media.duration && !isNaN(media.duration)) { + setDuration(media.duration * 1000); + setProgressPct((media.currentTime / media.duration) * 100); + } + }; + const onLoadedMetadata = () => { + if (!isNaN(media.duration)) { + setDuration(media.duration * 1000); + setGameDurationMs(media.duration * 1000); + } + }; + const onEnded = () => { + if (phaseRef.current === "playing") { + setPhase("finished"); + setGameDurationMs(Date.now() - gameStartTimeRef.current); + return; + } + setIsPreviewPlaying(false); + }; + media.addEventListener("timeupdate", onTimeUpdate); + media.addEventListener("loadedmetadata", onLoadedMetadata); + media.addEventListener("ended", onEnded); + return () => { + media.removeEventListener("timeupdate", onTimeUpdate); + media.removeEventListener("loadedmetadata", onLoadedMetadata); + media.removeEventListener("ended", onEnded); + }; + }, [isVideo, audioUrl]); + + useEffect(() => { + if (phaseRef.current !== "playing") return; + if (timeBasedLineIdx < 0) return; + if (timeBasedLineIdx <= lastHandledIdxRef.current) return; + lastHandledIdxRef.current = timeBasedLineIdx; + lastLineAdvanceAtRef.current = performance.now(); + dispatch({ + type: "ADVANCE", + newIdx: timeBasedLineIdx, + prevCompleted: gRef.current.lineCompleted, + }); + }, [timeBasedLineIdx]); + + const loadData = useCallback((data: Record<string, unknown>) => { + if (typeof data.lrc === "string" && data.lrc) { + setLoadingLrc(true); + fetch(data.lrc) + .then((r) => r.text()) + .then((t) => { + setLrcContent(t); + setLoadingLrc(false); + }); + } + if (typeof data.file1 === "string") setAudioUrl(data.file1); + if (typeof data.offset === "number") setOffset(data.offset); + if (typeof data.offset === "string" && data.offset.trim() !== "") + setOffset(Number(data.offset)); + if (typeof data.title === "string") setSongTitle(data.title); + if (typeof data.artist === "string") setSongArtist(data.artist); + if (typeof data.skip_backing === "boolean") + setSkipBacking(data.skip_backing); + if (typeof data.skip_backing === "string") + setSkipBacking(data.skip_backing === "true"); + }, []); + + useEffect(() => { + const code = searchParams.get("code"); + if (!code) return; + try { + const json = atob(code); + const data = JSON.parse(json) as Record<string, unknown>; + loadData(data); + } catch {} + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + const handlePreviewToggle = useCallback(() => { + if (phase !== "idle") return; + const media = isVideo ? videoRef.current : audioRef.current; + if (!media || !audioUrl) return; + + if (media.paused) { + void media.play().catch(() => { + toast.error("Unable to start preview. Try interacting with the page again.", { + theme: "dark", + }); + }); + return; + } + + media.pause(); + }, [phase, isVideo, audioUrl]); + + const handleStart = useCallback(() => { + const media = isVideo ? videoRef.current : audioRef.current; + if (!media || !lrcContent || !audioUrl) return; + if (countdownIntervalRef.current !== null) { + clearInterval(countdownIntervalRef.current); + countdownIntervalRef.current = null; + } + dispatch({ type: "RESET" }); + lastHandledIdxRef.current = -1; + media.pause(); + media.currentTime = 0; + setIsPreviewPlaying(false); + setPhase("countdown"); + setCountdown(5); + setGameDurationMs(0); + setProgressPct(0); + setCurrentMs(0); + + const beginPlayback = () => { + media.currentTime = 0; + media.play(); + setPhase("playing"); + gameStartTimeRef.current = Date.now(); + }; + + countdownIntervalRef.current = window.setInterval(() => { + setCountdown((c) => { + if (c <= 1) { + if (countdownIntervalRef.current !== null) { + clearInterval(countdownIntervalRef.current); + countdownIntervalRef.current = null; + } + beginPlayback(); + return 0; + } + return c - 1; + }); + }, 1000); + }, [lrcContent, audioUrl, gameLines, isVideo]); + + const handleRestart = useCallback(() => { + const media = isVideo ? videoRef.current : audioRef.current; + if (media) { + media.pause(); + media.currentTime = 0; + } + setIsPreviewPlaying(false); + if (countdownIntervalRef.current !== null) { + clearInterval(countdownIntervalRef.current); + countdownIntervalRef.current = null; + } + setCountdown(0); + dispatch({ type: "RESET" }); + lastHandledIdxRef.current = -1; + setPhase("idle"); + setCurrentMs(0); + setProgressPct(0); + }, [isVideo]); + + const handleLoadCode = useCallback(() => { + if (!codeInput.trim()) return; + try { + const json = atob(codeInput.trim()); + const data = JSON.parse(json) as Record<string, unknown>; + loadData(data); + handleRestart(); + toast.success("Song loaded!", { theme: "dark" }); + } catch { + toast.error("Invalid code. Please check and try again.", { + theme: "dark", + }); + } + }, [codeInput, loadData, handleRestart]); + + const handleKeyPress = useCallback( + (char: string) => { + if (phaseRef.current !== "playing") return; + const line = gameLines[gRef.current.displayedLineIdx]; + if (!line || gRef.current.lineCompleted) return; + const expected = line.content[gRef.current.typedCount]; + if (expected === undefined) return; + if (char.toLowerCase() === expected.toLowerCase()) { + const willComplete = gRef.current.typedCount + 1 >= line.content.length; + dispatch({ type: "CORRECT", willComplete }); + if (willComplete) { + setClearShowing(true); + setTimeout(() => setClearShowing(false), 700); + setComboAnimKey((k) => k + 1); + } + } else { + if (performance.now() - lastLineAdvanceAtRef.current < 100) return; + dispatch({ type: "WRONG" }); + setWrongChar(true); + setTimeout(() => setWrongChar(false), 320); + } + }, + [gameLines], + ); + + useEffect(() => { + if (phase !== "playing") return; + const handler = (e: KeyboardEvent) => { + if (e.key === " ") { + const idx = gRef.current.displayedLineIdx; + if (idx < 0 && gameLines.length > 0) { + const firstMs = gameLines[0]?.millisecond ?? 0; + const media = isVideo ? videoRef.current : audioRef.current; + if (media) { + const currentMsLocal = media.currentTime * 1000 + offsetRef.current; + const intermissionRemaining = Math.max(0, firstMs - currentMsLocal); + if (intermissionRemaining > 5000) { + e.preventDefault(); + const targetMs = firstMs - 3000; + media.currentTime = Math.max(0, (targetMs - offsetRef.current) / 1000); + setCurrentMs(media.currentTime * 1000 + offsetRef.current); + return; + } + } + } + } + if (e.key.length === 1) { + e.preventDefault(); + handleKeyPress(e.key); + } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [phase, handleKeyPress, gameLines, isVideo]); + + return ( + <GameRoot> + <ToastContainer theme="dark" /> + {!isVideo && ( + <audio ref={audioRef} src={audioUrl || undefined} preload="auto" /> + )} + {isVideo && ( + <BackgroundVideo + ref={videoRef} + src={audioUrl || undefined} + preload="auto" + playsInline + style={{ opacity: backgroundOpacity / 100 }} + /> + )} + <GameNavbar style={{ justifyContent: "space-between" }}> + <div style={{ display: "flex", alignItems: "center", gap: 16 }}> + <Link + href="/" + style={{ + display: "flex", + alignItems: "center", + gap: 8, + textDecoration: "none", + color: "#ffffff", + fontWeight: 700, + fontSize: 15, + }} + > + <MdLibraryMusic style={{ fontSize: 20, color: "#a78bfa" }} /> + LRC-Type + </Link> + + <Link + href="/create" + style={{ + fontSize: 13, + color: "rgba(255,255,255,0.6)", + textDecoration: "none", + }} + > + Create + </Link> + </div> + </GameNavbar> + + <GameContent style={{ position: "relative" }}> + {phase === "idle" && ( + <StartOverlay> + <StartCard> + {!isReady ? ( + <> + <SongTitleText>LRC-Type</SongTitleText> + <SongArtistText>Enter a game code to begin!</SongArtistText> + </> + ) : ( + <> + <SongTitleText> + {loadingLrc ? "Loading..." : songTitle} + </SongTitleText> + <SongArtistText>{songArtist}</SongArtistText> + </> + )} + + <StartBtn + onClick={handleStart} + disabled={!isReady} + suppressHydrationWarning + > + {loadingLrc ? "Loading song..." : "▶ Start Game"} + </StartBtn> + + <OpacityControl> + <OpacityLabel> + Volume + <OpacityValue>{audioVolume}%</OpacityValue> + </OpacityLabel> + <OpacitySlider + type="range" + min="0" + max="100" + value={audioVolume} + onChange={(e) => setAudioVolume(Number(e.target.value))} + /> + </OpacityControl> + + <PreviewWrap> + <PreviewBtn + onClick={handlePreviewToggle} + disabled={!audioUrl} + suppressHydrationWarning + > + {isPreviewPlaying ? "⏸ Pause Preview" : "▶ Preview Audio"} + </PreviewBtn> + <PreviewHint> + {audioUrl + ? "Use preview to test your volume before starting." + : "Load a chart to enable audio preview."} + </PreviewHint> + </PreviewWrap> + + {isVideo && ( + <OpacityControl> + <OpacityLabel> + Background opacity + <OpacityValue>{backgroundOpacity}%</OpacityValue> + </OpacityLabel> + <OpacitySlider + type="range" + min="0" + max="100" + value={backgroundOpacity} + onChange={(e) => + setBackgroundOpacity(Number(e.target.value)) + } + /> + </OpacityControl> + )} + <CodeSection> + <div + style={{ + fontSize: 11, + color: "rgba(255,255,255,0.3)", + letterSpacing: 1, + textTransform: "uppercase", + }} + > + Load a chart + </div> + <CodeInputRow> + <CodeInputField + placeholder="Enter a LRC-Type code..." + value={codeInput} + onChange={(e) => setCodeInput(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleLoadCode()} + /> + <CodeLoadBtn onClick={handleLoadCode}>Load</CodeLoadBtn> + </CodeInputRow> + </CodeSection> + </StartCard> + </StartOverlay> + )} + + {phase === "countdown" && ( + <StartOverlay> + <StartCard> + <SongTitleText>Get Ready</SongTitleText> + <CountdownNumber>{countdown}</CountdownNumber> + </StartCard> + </StartOverlay> + )} + + {phase === "finished" && ( + <ResultsOverlay> + <ResultsCard> + <ResultsTitle>Results {songTitle}</ResultsTitle> + <BigScore>{g.score.toLocaleString()}</BigScore> + <StatsGrid> + <StatBlock> + <StatValue>{accuracy}%</StatValue> + <StatLabel>Accuracy</StatLabel> + </StatBlock> + <StatBlock> + <StatValue>x{g.maxCombo}</StatValue> + <StatLabel>Max Combo</StatLabel> + </StatBlock> + <StatBlock> + <StatValue>{wpm}</StatValue> + <StatLabel>WPM</StatLabel> + </StatBlock> + <StatBlock> + <StatValue>{g.totalMiss}</StatValue> + <StatLabel>Missed Chars</StatLabel> + </StatBlock> + </StatsGrid> + <ActionRow> + <PlayAgainBtn onClick={handleRestart}>Play Again</PlayAgainBtn> + <HomeBtn onClick={() => router.push("/typing")}>Home</HomeBtn> + </ActionRow> + </ResultsCard> + </ResultsOverlay> + )} + + <HUD> + <HudStat> + <HudValue>{g.score.toLocaleString()}</HudValue> + <HudLabel>Score</HudLabel> + </HudStat> + <HudStat> + <ComboValue + $animate={comboAnimKey > 0} + key={`combo-${comboAnimKey}`} + > + x{g.combo} + </ComboValue> + <HudLabel>Combo</HudLabel> + </HudStat> + <HudStat> + <HudValue>{accuracy}%</HudValue> + <HudLabel>Accuracy</HudLabel> + </HudStat> + <HudStat> + <HudValue>{wpm}</HudValue> + <HudLabel>WPM</HudLabel> + </HudStat> + <HudStat> + <HudValue>{g.totalMiss}</HudValue> + <HudLabel>Misses</HudLabel> + </HudStat> + </HUD> + + <GameArea> + {phase === "playing" && + g.displayedLineIdx < 0 && + gameLines.length > 0 && ( + <> + <UpcomingWrap> + <UpcomingLabel>Next</UpcomingLabel> + <UpcomingText> + {gameLines[0] && gameLines[0].content.trim() === "" + ? "[INTERMISSION]" + : gameLines[0]?.content ?? ""} + </UpcomingText> + </UpcomingWrap> + <CurrentWrap style={{ position: "relative" }}> + <LineTimingRow> + <LineTimingMeta> + Time to first line:{" "} + <LineTimingValue> + {Math.max(0, intermissionData.remainingMs / 1000).toFixed(1)}s + </LineTimingValue> + </LineTimingMeta> + </LineTimingRow> + <div + style={{ + fontSize: 12, + color: "rgba(255,255,255,0.6)", + marginTop: 8, + textAlign: "center", + }} + > + {intermissionData.remainingMs > 5000 && "Press Space to skip long intermissions"} + </div> + <LineTimingBar> + <LineTimingFill $pct={intermissionData.pct} /> + </LineTimingBar> + <CharRow ref={charRowRef} /> + <CompletedLineFade>[INTERMISSION]</CompletedLineFade> + </CurrentWrap> + </> + )} + {g.displayedLineIdx >= 0 && gameLines[g.displayedLineIdx] && ( + <> + <UpcomingWrap> + <UpcomingLabel>Next</UpcomingLabel> + <UpcomingText> + {gameLines[g.displayedLineIdx + 1] && + gameLines[g.displayedLineIdx + 1].content.trim() === "" + ? "[INTERMISSION]" + : gameLines[g.displayedLineIdx + 1]?.content ?? ""} + </UpcomingText> + </UpcomingWrap> + <CurrentWrap style={{ position: "relative" }}> + <LineTimingRow> + <LineTimingMeta> + Time left:{" "} + <LineTimingValue> + {Math.max(0, lineRemainingMs / 1000).toFixed(1)}s + </LineTimingValue> + </LineTimingMeta> + {gameLines[g.displayedLineIdx].content.trim() !== "" && ( + <LineTimingMeta> + Estimated CPS:{" "} + <LineTimingValue> + {calculateCPSNeeded( + gameLines[g.displayedLineIdx].content, + currentLineTime / 1000 + ).toFixed(1)} + </LineTimingValue> + </LineTimingMeta> + )} + </LineTimingRow> + <LineTimingBar> + <LineTimingFill $pct={lineTimingPct} /> + </LineTimingBar> + <CharRow ref={charRowRef}> + {gameLines[g.displayedLineIdx].content.trim() !== "" && + (() => { + const rawText = gameLines[g.displayedLineIdx].content; + const text = rawText.toLowerCase(); + const tokens = text.split(/(\s+)/).filter(Boolean); + let renderIndex = 0; + return tokens.flatMap((token, tokenIdx) => { + if (/^\s+$/.test(token)) { + return token.split("").map((ch, spaceIdx) => { + let state: "typed" | "active" | "pending" | "wrong"; + if (renderIndex < g.typedCount) state = "typed"; + else if (renderIndex === g.typedCount) + state = wrongChar ? "wrong" : "active"; + else state = "pending"; + const charIndex = renderIndex; + const showIndicator = + ch === " " && + wrapSpaceIndicators[charIndex] && + state !== "typed"; + const displayChar = + ch === " " + ? showIndicator + ? "␣" + : "\u00A0" + : ch; + const element = ( + <CharBox + key={`space-${tokenIdx}-${spaceIdx}`} + $state={state} + ref={(el) => { + charRefs.current[charIndex] = el; + }} + > + {displayChar} + </CharBox> + ); + renderIndex += 1; + return element; + }); + } + + const wordChars = token.split("").map((ch, charIdx) => { + let state: "typed" | "active" | "pending" | "wrong"; + if (renderIndex < g.typedCount) state = "typed"; + else if (renderIndex === g.typedCount) + state = wrongChar ? "wrong" : "active"; + else state = "pending"; + const charIndex = renderIndex; + const element = ( + <CharBox + key={`char-${tokenIdx}-${charIdx}`} + $state={state} + ref={(el) => { + charRefs.current[charIndex] = el; + }} + > + {ch} + </CharBox> + ); + renderIndex += 1; + return element; + }); + + return ( + <WordWrap key={`word-${tokenIdx}`}> + {wordChars} + </WordWrap> + ); + }); + })()} + </CharRow> + {clearShowing && <ClearToast>CLEAR!</ClearToast>} + <CompletedLineFade> + {gameLines[g.displayedLineIdx].content.trim() === "" + ? "[INTERMISSION]" + : g.lineCompleted + ? "Cleared - waiting for next line..." + : gameLines[g.displayedLineIdx].content} + </CompletedLineFade> + </CurrentWrap> + </> + )} + {phase === "idle" && ( + <GetReadyText style={{ opacity: 0.3 }}> + Start the game to begin typing + </GetReadyText> + )} + </GameArea> + + <GameFooter> + <ControlBtn onClick={handleRestart} title="Restart"> + <FaRedo /> + </ControlBtn> + <ProgressWrap> + <ProgressFill $pct={progressPct} /> + </ProgressWrap> + <TimeText> + {formatTime(Math.max(0, currentMs))} / {formatTime(duration)} + </TimeText> + </GameFooter> + </GameContent> + </GameRoot> + ); +} + +export default function GamePage() { + return ( + <> + <GameGlobalStyle /> + <Suspense + fallback={ + <GameRoot> + <div + style={{ + flex: 1, + display: "flex", + alignItems: "center", + justifyContent: "center", + fontSize: 18, + color: "rgba(255,255,255,0.5)", + }} + > + Loading... + </div> + </GameRoot> + } + > + <GameInner /> + </Suspense> + </> + ); +} diff --git a/src/app/globals.css b/src/app/globals.css new file mode 100644 index 0000000..6cfbe02 --- /dev/null +++ b/src/app/globals.css @@ -0,0 +1,6 @@ +html, +body { + margin: 0; + padding: 0; + background-color: #f9f9f9; +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx new file mode 100644 index 0000000..67d8da9 --- /dev/null +++ b/src/app/layout.tsx @@ -0,0 +1,22 @@ +import type { Metadata } from "next"; +import StyledComponentsRegistry from "./registry"; + +export const metadata: Metadata = { + title: "LRC-Type", + description: + "A typing game powered by LRC lyrics. Type along to your favourite songs!", +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + <html lang="en"> + <body> + <StyledComponentsRegistry>{children}</StyledComponentsRegistry> + </body> + </html> + ); +}
\ No newline at end of file diff --git a/src/app/page.styles.ts b/src/app/page.styles.ts new file mode 100644 index 0000000..50d28d7 --- /dev/null +++ b/src/app/page.styles.ts @@ -0,0 +1,251 @@ +import styled from "styled-components"; +import Link from "next/link"; + +export const NavLeft = styled.div` + display: flex; + align-items: center; + gap: 14px; +`; + +export const NavCenter = styled.div` + display: flex; + align-items: center; + flex: 0 1 560px; +`; + +export const SearchBox = styled.div` + display: flex; + align-items: center; + flex: 1; + height: 38px; + border: 1px solid #d4d4d4; + border-radius: 10px; + overflow: hidden; + background-color: #f0f0f0; + transition: border-color 0.2s; + &:focus-within { + border-color: #1a1a1a; + } +`; + +export const SearchInput = styled.input` + flex: 1; + height: 100%; + padding: 0 14px; + background: transparent; + border: none; + outline: none; + color: #1a1a1a; + font-size: 14px; + &::placeholder { + color: #909090; + } +`; + +export const SearchButton = styled.button` + width: 52px; + height: 100%; + background-color: #e8e8e8; + border: none; + border-left: 1px solid #d4d4d4; + color: #606060; + font-size: 14px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + &:hover { + background-color: #d4d4d4; + color: #1a1a1a; + } +`; + +export const NavRight = styled.div` + display: flex; + align-items: center; + gap: 6px; +`; + + + +export const ChipsBar = styled.div` + display: flex; + align-items: center; + gap: 10px; + padding: 14px 24px; + overflow-x: auto; + background-color: #f9f9f9; + &::-webkit-scrollbar { + display: none; + } +`; + +export const Chip = styled.button<{ $active?: boolean }>` + white-space: nowrap; + padding: 7px 16px; + border-radius: 10px; + border: 1px solid ${(p) => (p.$active ? "transparent" : "#d4d4d4")}; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s; + background-color: ${(p) => (p.$active ? "#1a1a1a" : "transparent")}; + color: ${(p) => (p.$active ? "#fff" : "#606060")}; + &:hover { + background-color: ${(p) => (p.$active ? "#333" : "#f0f0f0")}; + color: ${(p) => (p.$active ? "#fff" : "#1a1a1a")}; + } +`; + +export const GridContainer = styled.div` + padding: 8px 24px 24px; +`; + +export const CardGrid = styled.div` + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 20px; +`; + +export const Card = styled(Link)` + cursor: pointer; + border-radius: 14px; + text-decoration: none; + color: inherit; + display: block; + transition: transform 0.15s, box-shadow 0.15s; + &:hover { + transform: translateY(-2px); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08); + } +`; + +export const ThumbnailWrapper = styled.div` + width: 100%; + aspect-ratio: 16 / 9; + background-color: #e4e4e4; + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + color: #c0c0c0; + font-size: 36px; + overflow: hidden; + position: relative; +`; + +export const Thumbnail = styled.img` + width: 100%; + height: 100%; + object-fit: cover; +`; + +export const PlayOverlay = styled.div` + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0); + border-radius: 12px; + transition: background 0.2s; + ${Card}:hover & { + background: rgba(0, 0, 0, 0.25); + } +`; + +export const PlayCircle = styled.div` + width: 48px; + height: 48px; + border-radius: 50%; + background: rgba(0, 0, 0, 0.7); + color: #fff; + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + opacity: 0; + transform: scale(0.8); + transition: opacity 0.2s, transform 0.2s; + ${Card}:hover & { + opacity: 1; + transform: scale(1); + } +`; + +export const CardMeta = styled.div` + display: flex; + gap: 12px; + margin-top: 12px; + padding: 0 4px 12px; +`; + +export const CardInfo = styled.div` + display: flex; + flex-direction: column; + gap: 3px; + min-width: 0; +`; + +export const CardTitle = styled.span` + font-size: 14px; + font-weight: 600; + color: #1a1a1a; + line-height: 1.35; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +`; + +export const CardSub = styled.span` + font-size: 12px; + color: #909090; + line-height: 1.3; +`; + +export const EmptyState = styled.div` + grid-column: 1 / -1; + padding: 48px 0; + text-align: center; + font-size: 14px; + color: #909090; +`; + +export const CtaSection = styled.div` + padding: 32px 24px; + border-top: 1px solid #e5e5e5; + margin-top: 8px; +`; + +export const SectionHeading = styled.h2` + font-size: 17px; + font-weight: 700; + color: #1a1a1a; + margin: 0 0 14px; +`; + +export const OpenPlayerLink = styled(Link)` + display: inline-flex; + align-items: center; + gap: 8px; + padding: 10px 22px; + border-radius: 10px; + background-color: #1a1a1a; + color: #fff; + font-size: 14px; + font-weight: 600; + text-decoration: none; + transition: background-color 0.15s; + &:hover { + background-color: #333; + } +`; + +export const PlayerDescription = styled.p` + font-size: 13px; + color: #909090; + margin: 14px 0 0; + line-height: 1.6; + max-width: 480px; +`; diff --git a/src/app/page.tsx b/src/app/page.tsx new file mode 100644 index 0000000..f8e1c1b --- /dev/null +++ b/src/app/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function HomePage() { + redirect("/typing"); +} diff --git a/src/app/registry.tsx b/src/app/registry.tsx new file mode 100644 index 0000000..4a5a4e1 --- /dev/null +++ b/src/app/registry.tsx @@ -0,0 +1,26 @@ +"use client"; +import React, { useState } from "react"; +import { useServerInsertedHTML } from "next/navigation"; +import { ServerStyleSheet, StyleSheetManager } from "styled-components"; + +export default function StyledComponentsRegistry({ + children, +}: { + children: React.ReactNode; +}) { + const [styledComponentsStyleSheet] = useState(() => new ServerStyleSheet()); + + useServerInsertedHTML(() => { + const styles = styledComponentsStyleSheet.getStyleElement(); + styledComponentsStyleSheet.instance.clearTag(); + return <>{styles}</>; + }); + + if (typeof window !== "undefined") return <>{children}</>; + + return ( + <StyleSheetManager sheet={styledComponentsStyleSheet.instance}> + {children as React.ReactElement} + </StyleSheetManager> + ); +} diff --git a/src/app/styles/shared.ts b/src/app/styles/shared.ts new file mode 100644 index 0000000..d1b0232 --- /dev/null +++ b/src/app/styles/shared.ts @@ -0,0 +1,78 @@ +import styled from "styled-components"; +import Link from "next/link"; + +export const Root = styled.div` + min-height: 100vh; + background-color: #f9f9f9; + color: #1a1a1a; + font-family: "Roboto", "Segoe UI", Arial, sans-serif; +`; + +export const Navbar = styled.nav` + position: sticky; + top: 0; + z-index: 100; + display: flex; + align-items: center; + justify-content: space-between; + height: 56px; + padding: 0 20px; + background-color: #ffffffee; + backdrop-filter: blur(12px); + border-bottom: 1px solid #e5e5e5; +`; + +export const Logo = styled(Link)` + font-size: 17px; + font-weight: 800; + letter-spacing: 0.3px; + color: #1a1a1a; + text-decoration: none; + display: flex; + align-items: center; + gap: 7px; + user-select: none; +`; + +export const LogoIcon = styled.span` + display: inline-flex; + align-items: center; + justify-content: center; + background-color: #1a1a1a; + color: #fff; + border-radius: 6px; + width: 30px; + height: 22px; + font-size: 10px; +`; + +export const NavLink = styled(Link)` + font-size: 13px; + font-weight: 500; + color: #606060; + text-decoration: none; + padding: 6px 10px; + border-radius: 8px; + transition: background-color 0.15s, color 0.15s; + &:hover { + background-color: #f0f0f0; + color: #1a1a1a; + } +`; + +export const NavCtaLink = styled(Link)` + font-size: 13px; + font-weight: 600; + color: #1a1a1a; + text-decoration: none; + padding: 6px 12px; + border-radius: 999px; + background-color: #f5f5f5; + border: 1px solid #e5e5e5; + transition: background-color 0.15s, border-color 0.15s, color 0.15s; + &:hover { + background-color: #ededed; + border-color: #d4d4d4; + color: #1a1a1a; + } +`; diff --git a/src/app/typing/page.styles.ts b/src/app/typing/page.styles.ts new file mode 100644 index 0000000..34f4ebc --- /dev/null +++ b/src/app/typing/page.styles.ts @@ -0,0 +1,168 @@ +import styled, { createGlobalStyle } from "styled-components"; +import { + Root as BaseRoot, + Navbar as BaseNavbar, + Logo as BaseLogo, + LogoIcon as BaseLogoIcon, + NavLink as BaseNavLink, + NavCtaLink as BaseNavCtaLink, +} from "../styles/shared"; +import { + NavLeft, + NavCenter, + SearchBox as BaseSearchBox, + SearchInput as BaseSearchInput, + SearchButton as BaseSearchButton, + NavRight, + ChipsBar as BaseChipsBar, + Chip as BaseChip, + GridContainer, + CardGrid, + Card as BaseCard, + ThumbnailWrapper as BaseThumbnailWrapper, + Thumbnail, + PlayOverlay, + PlayCircle, + CardMeta, + CardInfo, + CardTitle as BaseCardTitle, + CardSub as BaseCardSub, + EmptyState as BaseEmptyState, + CtaSection as BaseCtaSection, + SectionHeading as BaseSectionHeading, + OpenPlayerLink as BaseOpenPlayerLink, + PlayerDescription as BasePlayerDescription, +} from "../page.styles"; + +export { NavLeft, NavCenter, NavRight, GridContainer, CardGrid, Thumbnail, PlayOverlay, PlayCircle, CardMeta, CardInfo }; + +export const TypingGlobalStyle = createGlobalStyle` + html, + body { + background-color: #0b0b10; + } +`; + +export const Root = styled(BaseRoot)` + background-color: #0b0b10; + color: #f5f5f5; +`; + +export const Navbar = styled(BaseNavbar)` + background-color: rgba(11, 11, 16, 0.9); + border-bottom: 1px solid #1f1f2a; +`; + +export const Logo = styled(BaseLogo)` + color: #f5f5f5; +`; + +export const LogoIcon = styled(BaseLogoIcon)` + background-color: #f5f5f5; + color: #0b0b10; +`; + +export const NavLink = styled(BaseNavLink)` + color: #b0b3bd; + &:hover { + background-color: #1a1d29; + color: #fff; + } +`; + +export const NavCtaLink = styled(BaseNavCtaLink)` + background-color: #1a1d29; + border-color: #2a2f3d; + color: #fff; + &:hover { + background-color: #222838; + border-color: #3a4154; + } +`; + +export const SearchBox = styled(BaseSearchBox)` + border-color: #2a2f3d; + background-color: #141824; + &:focus-within { + border-color: #4b5563; + } +`; + +export const SearchInput = styled(BaseSearchInput)` + color: #f5f5f5; + &::placeholder { + color: #8b90a0; + } +`; + +export const SearchButton = styled(BaseSearchButton)` + background-color: #1a1d29; + border-left-color: #2a2f3d; + color: #c0c4d0; + &:hover { + background-color: #222838; + color: #fff; + } +`; + + + +export const ChipsBar = styled(BaseChipsBar)` + background-color: #0f111a; +`; + +export const Chip = styled(BaseChip)` + border-color: #2a2f3d; + color: #b8bcc7; + background-color: transparent; + &:hover { + background-color: #1a1d29; + color: #fff; + } +`; + +export const Card = styled(BaseCard)` + border: 1px solid #1f1f2a; + background-color: #0f111a; + &:hover { + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.35); + border-color: #2a2f3d; + } +`; + +export const ThumbnailWrapper = styled(BaseThumbnailWrapper)` + background-color: #1a1d29; + color: #4b5563; +`; + +export const CardTitle = styled(BaseCardTitle)` + color: #f5f5f5; +`; + +export const CardSub = styled(BaseCardSub)` + color: #9aa0ad; +`; + +export const EmptyState = styled(BaseEmptyState)` + color: #9aa0ad; +`; + +export const CtaSection = styled(BaseCtaSection)` + border-top-color: #1f1f2a; +`; + +export const SectionHeading = styled(BaseSectionHeading)` + color: #f5f5f5; +`; + +export const OpenPlayerLink = styled(BaseOpenPlayerLink)` + background-color: #f5f5f5; + color: #0b0b10; + &:hover { + background-color: #e5e7eb; + } +`; + +export const PlayerDescription = styled(BasePlayerDescription)` + color: #9aa0ad; +`;
\ No newline at end of file diff --git a/src/app/typing/page.tsx b/src/app/typing/page.tsx new file mode 100644 index 0000000..27ad173 --- /dev/null +++ b/src/app/typing/page.tsx @@ -0,0 +1,161 @@ +"use client"; +import { useEffect, useState } from "react"; +import { FaPlay, FaMusic, FaSearch } from "react-icons/fa"; +import { MdLibraryMusic } from "react-icons/md"; +import { + Root, + Navbar, + Logo, + LogoIcon, + NavCtaLink, + NavLeft, + NavCenter, + SearchBox, + SearchInput, + SearchButton, + NavRight, + + ChipsBar, + Chip, + GridContainer, + CardGrid, + Card, + ThumbnailWrapper, + Thumbnail, + PlayOverlay, + PlayCircle, + CardMeta, + CardInfo, + CardTitle, + CardSub, + EmptyState, + TypingGlobalStyle, +} from "./page.styles"; + +interface TypingEntry { + title: string; + artist: string; + thumbnail: string; + code: string; +} + +type TypingData = Record<string, TypingEntry[]>; + +function capitalize(s: string) { + return s.charAt(0).toUpperCase() + s.slice(1); +} + +export default function TypingPage() { + const [data, setData] = useState<TypingData>({}); + const [activeChip, setActiveChip] = useState("all"); + const [search, setSearch] = useState(""); + + useEffect(() => { + fetch("/typing.json") + .then((r) => r.json()) + .then((json: TypingData) => setData(json)) + .catch(() => {}); + }, []); + + const categories = Object.keys(data); + const chips = [ + { key: "all", label: "All" }, + ...categories.map((category) => ({ + key: category, + label: capitalize(category), + })), + ]; + + const visibleItems: TypingEntry[] = + activeChip === "all" ? Object.values(data).flat() : data[activeChip] ?? []; + + const normalizedSearch = search.trim().toLowerCase(); + const searchableItems = normalizedSearch ? Object.values(data).flat() : visibleItems; + + const filtered = normalizedSearch + ? searchableItems.filter( + (item) => + item.title.toLowerCase().includes(normalizedSearch) || + item.artist.toLowerCase().includes(normalizedSearch), + ) + : searchableItems; + + return ( + <> + <TypingGlobalStyle /> + <Root> + <Navbar> + <NavLeft> + <Logo href="/"> + <LogoIcon> + <MdLibraryMusic /> + </LogoIcon> + LRC-Type + </Logo> + </NavLeft> + + <NavCenter> + <SearchBox> + <SearchInput + placeholder="Search typing charts..." + value={search} + onChange={(e) => setSearch(e.target.value)} + /> + <SearchButton aria-label="Search"> + <FaSearch /> + </SearchButton> + </SearchBox> + </NavCenter> + + <NavRight> + <NavCtaLink href="/create">Create</NavCtaLink> + + </NavRight> + </Navbar> + + <ChipsBar> + {chips.map((chip) => ( + <Chip + key={chip.key} + $active={chip.key === activeChip} + onClick={() => setActiveChip(chip.key)} + > + {chip.label} + </Chip> + ))} + </ChipsBar> + + <GridContainer> + <CardGrid> + {filtered.length === 0 ? ( + <EmptyState>No results found.</EmptyState> + ) : ( + filtered.map((item) => ( + <Card key={item.code} href={`/game?code=${item.code}`} target="_blank" rel="noopener noreferrer"> + <ThumbnailWrapper> + {item.thumbnail ? ( + <Thumbnail src={item.thumbnail} alt={item.title} /> + ) : ( + <FaMusic /> + )} + <PlayOverlay> + <PlayCircle> + <FaPlay /> + </PlayCircle> + </PlayOverlay> + </ThumbnailWrapper> + <CardMeta> + <CardInfo> + <CardTitle>{item.title}</CardTitle> + <CardSub>{item.artist}</CardSub> + </CardInfo> + </CardMeta> + </Card> + )) + )} + </CardGrid> + </GridContainer> + </Root> + </> + ); +} diff --git a/src/app/use_timer.ts b/src/app/use_timer.ts new file mode 100644 index 0000000..487591d --- /dev/null +++ b/src/app/use_timer.ts @@ -0,0 +1,33 @@ +import { useEffect, useState, useCallback } from "react"; + +function useTimer(speed = 1) { + const [paused, setPaused] = useState(true); + const play = useCallback(() => setPaused(false), []); + const pause = useCallback(() => setPaused(true), []); + + const [currentMillisecond, setCurrentMillisecond] = useState(0); + const reset = useCallback(() => setCurrentMillisecond(0), []); + + useEffect(() => { + if (!paused) { + let last = Date.now(); + const timer = window.setInterval(() => { + const now = Date.now(); + setCurrentMillisecond((cm) => cm + (now - last) * speed); + last = now; + }, 97); + return () => window.clearInterval(timer); + } + }, [paused, speed]); + + return { + currentMillisecond, + setCurrentMillisecond, + reset, + paused, + play, + pause + }; +} + +export default useTimer;
\ No newline at end of file |
