diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/context/auth.tsx | 69 | ||||
| -rw-r--r-- | src/app/create/page.styles.ts | 168 | ||||
| -rw-r--r-- | src/app/create/page.tsx | 207 | ||||
| -rw-r--r-- | src/app/game/[slug]/game.stat.ts (renamed from src/app/game/game.stat.ts) | 0 | ||||
| -rw-r--r-- | src/app/game/[slug]/game.utils.ts (renamed from src/app/game/game.utils.ts) | 0 | ||||
| -rw-r--r-- | src/app/game/[slug]/page.styles.ts (renamed from src/app/game/page.styles.ts) | 13 | ||||
| -rw-r--r-- | src/app/game/[slug]/page.tsx (renamed from src/app/game/page.tsx) | 188 | ||||
| -rw-r--r-- | src/app/layout.tsx | 9 | ||||
| -rw-r--r-- | src/app/lib/pocketbase.ts | 7 | ||||
| -rw-r--r-- | src/app/page.styles.ts | 193 | ||||
| -rw-r--r-- | src/app/page.tsx | 144 | ||||
| -rw-r--r-- | src/app/signin/page.tsx | 280 | ||||
| -rw-r--r-- | src/app/styles/shared.ts | 3 | ||||
| -rw-r--r-- | src/app/typing/page.styles.ts | 168 | ||||
| -rw-r--r-- | src/app/typing/page.tsx | 161 |
15 files changed, 728 insertions, 882 deletions
diff --git a/src/app/context/auth.tsx b/src/app/context/auth.tsx new file mode 100644 index 0000000..f7e7cc2 --- /dev/null +++ b/src/app/context/auth.tsx @@ -0,0 +1,69 @@ +"use client"; +import { + createContext, + useCallback, + useContext, + useEffect, + useState, +} from "react"; +import type { RecordModel } from "pocketbase"; +import pb from "../lib/pocketbase"; + +interface AuthContextValue { + user: RecordModel | null; + loading: boolean; + signIn: (email: string, password: string) => Promise<void>; + signUp: (email: string, username: string, password: string, passwordConfirm: string) => Promise<void>; + signOut: () => void; +} + +const AuthContext = createContext<AuthContextValue>({ + user: null, + loading: true, + signIn: async () => {}, + signUp: async () => {}, + signOut: () => {}, +}); + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const [user, setUser] = useState<RecordModel | null>(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + setUser(pb.authStore.record ?? null); + setLoading(false); + const unsub = pb.authStore.onChange(() => { + setUser(pb.authStore.record ?? null); + }); + return () => unsub(); + }, []); + + const signIn = useCallback(async (email: string, password: string) => { + await pb.collection("users").authWithPassword(email, password); + setUser(pb.authStore.record ?? null); + }, []); + + const signUp = useCallback( + async (email: string, username: string, password: string, passwordConfirm: string) => { + await pb.collection("users").create({ email, username, password, passwordConfirm }); + await pb.collection("users").authWithPassword(email, password); + setUser(pb.authStore.record ?? null); + }, + [] + ); + + const signOut = useCallback(() => { + pb.authStore.clear(); + setUser(null); + }, []); + + return ( + <AuthContext.Provider value={{ user, loading, signIn, signUp, signOut }}> + {children} + </AuthContext.Provider> + ); +} + +export function useAuth() { + return useContext(AuthContext); +} diff --git a/src/app/create/page.styles.ts b/src/app/create/page.styles.ts deleted file mode 100644 index b54e095..0000000 --- a/src/app/create/page.styles.ts +++ /dev/null @@ -1,168 +0,0 @@ -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 deleted file mode 100644 index 744ab95..0000000 --- a/src/app/create/page.tsx +++ /dev/null @@ -1,207 +0,0 @@ -"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/game/game.stat.ts b/src/app/game/[slug]/game.stat.ts index 43136e6..43136e6 100644 --- a/src/app/game/game.stat.ts +++ b/src/app/game/[slug]/game.stat.ts diff --git a/src/app/game/game.utils.ts b/src/app/game/[slug]/game.utils.ts index b2037e5..b2037e5 100644 --- a/src/app/game/game.utils.ts +++ b/src/app/game/[slug]/game.utils.ts diff --git a/src/app/game/page.styles.ts b/src/app/game/[slug]/page.styles.ts index d410339..1b5880b 100644 --- a/src/app/game/page.styles.ts +++ b/src/app/game/[slug]/page.styles.ts @@ -221,7 +221,6 @@ export const LineTimingBar = styled.div` width: 100%; height: 3px; background: rgba(255, 255, 255, 0.12); - border-radius: 2px; overflow: hidden; `; @@ -232,7 +231,6 @@ export const LineTimingFill = styled.div.attrs<{ $pct: number }>((props) => ({ }))<{ $pct: number }>` height: 100%; width: 100%; - border-radius: 2px; background: #7c3aed; transform-origin: left; will-change: transform; @@ -262,7 +260,6 @@ export const CharBox = styled.span<{ 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 }) => { @@ -333,7 +330,6 @@ export const GameFooter = styled.footer` 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; @@ -354,7 +350,6 @@ export const ProgressWrap = styled.div` flex: 1; height: 6px; background: rgba(255, 255, 255, 0.12); - border-radius: 3px; overflow: hidden; cursor: pointer; `; @@ -366,7 +361,6 @@ export const ProgressFill = styled.div.attrs<{ $pct: number }>((props) => ({ }))<{ $pct: number }>` height: 100%; background: #7c3aed; - border-radius: 3px; transition: width 0.3s linear; `; @@ -437,7 +431,6 @@ export const PreviewWrap = styled.div` 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; @@ -486,7 +479,6 @@ export const SongArtistText = styled.p` export const StartBtn = styled.button` padding: 14px 40px; - border-radius: 12px; background: #7c3aed; color: #ffffff; font-size: 18px; @@ -521,7 +513,6 @@ export const CodeInputRow = styled.div` 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; @@ -540,7 +531,6 @@ export const CodeInputField = styled.input` 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; @@ -604,7 +594,6 @@ export const StatsGrid = styled.div` 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; @@ -631,7 +620,6 @@ export const ActionRow = styled.div` export const PlayAgainBtn = styled.button` padding: 10px 28px; - border-radius: 12px; background: #7c3aed; color: #ffffff; font-size: 15px; @@ -648,7 +636,6 @@ export const PlayAgainBtn = styled.button` export const HomeBtn = styled.button` padding: 10px 28px; - border-radius: 12px; background: transparent; border: 1px solid rgba(255, 255, 255, 0.20); color: #ffffff; diff --git a/src/app/game/page.tsx b/src/app/game/[slug]/page.tsx index bce01b3..9d449ce 100644 --- a/src/app/game/page.tsx +++ b/src/app/game/[slug]/page.tsx @@ -9,7 +9,8 @@ import { useState, Suspense, } from "react"; -import { useSearchParams, useRouter } from "next/navigation"; +import { useRouter, useParams } from "next/navigation"; +import pb from "../../lib/pocketbase"; import Link from "next/link"; import { FaRedo } from "react-icons/fa"; import { MdLibraryMusic } from "react-icons/md"; @@ -60,10 +61,6 @@ import { SongTitleText, SongArtistText, StartBtn, - CodeSection, - CodeInputRow, - CodeInputField, - CodeLoadBtn, ResultsOverlay, ResultsCard, ResultsTitle, @@ -92,7 +89,8 @@ const BACKGROUND_OPACITY_KEY = "lrcType.backgroundOpacity"; const AUDIO_VOLUME_KEY = "lrcType.audioVolume"; function GameInner() { - const searchParams = useSearchParams(); + const params = useParams<{ slug: string }>(); + const slug = params?.slug ?? ""; const router = useRouter(); useEffect(() => { @@ -121,32 +119,30 @@ function GameInner() { 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 [backgroundOpacity, setBackgroundOpacity] = useState(0); + const [audioVolume, setAudioVolume] = useState(100); const [isPreviewPlaying, setIsPreviewPlaying] = useState(false); const [skipBacking, setSkipBacking] = useState(false); const isVideo = useMemo(() => isVideoUrl(audioUrl), [audioUrl]); - + useEffect(() => { + const storedOpacity = localStorage.getItem(BACKGROUND_OPACITY_KEY); + if (storedOpacity !== null) { + const parsed = Number(storedOpacity); + if (Number.isFinite(parsed)) + setBackgroundOpacity(Math.min(100, Math.max(0, parsed))); + } + const storedVolume = localStorage.getItem(AUDIO_VOLUME_KEY); + if (storedVolume !== null) { + const parsed = Number(storedVolume); + if (Number.isFinite(parsed)) + setAudioVolume(Math.min(100, Math.max(0, parsed))); + } + }, []); useEffect(() => { localStorage.setItem(BACKGROUND_OPACITY_KEY, String(backgroundOpacity)); @@ -194,7 +190,7 @@ function GameInner() { const gameLines = useMemo( () => parseLrcLines(lrcContent, { skipBacking }), - [lrcContent, skipBacking] + [lrcContent, skipBacking], ); const isReady = !loadingLrc && !!lrcContent && !!audioUrl; @@ -213,7 +209,9 @@ function GameInner() { const gRef = useRef(g); const currentLineContent = - g.displayedLineIdx >= 0 ? gameLines[g.displayedLineIdx]?.content ?? "" : ""; + g.displayedLineIdx >= 0 + ? (gameLines[g.displayedLineIdx]?.content ?? "") + : ""; useEffect(() => { charRefs.current = []; @@ -317,7 +315,10 @@ function GameInner() { } const mediaCurrentMs = currentMs - offsetRef.current; - const pct = Math.min(100, Math.max(0, (mediaCurrentMs / firstMediaMs) * 100)); + const pct = Math.min( + 100, + Math.max(0, (mediaCurrentMs / firstMediaMs) * 100), + ); return { pct, remainingMs }; }, [gameLines, currentMs, offset]); @@ -423,7 +424,7 @@ function GameInner() { setLoadingLrc(false); }); } - if (typeof data.file1 === "string") setAudioUrl(data.file1); + if (typeof data.media === "string") setAudioUrl(data.media); if (typeof data.offset === "number") setOffset(data.offset); if (typeof data.offset === "string" && data.offset.trim() !== "") setOffset(Number(data.offset)); @@ -436,13 +437,25 @@ function GameInner() { }, []); 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 {} + if (!slug) return; + pb.collection("charts") + .getOne(slug) + .then((record) => { + loadData({ + media: (record as Record<string, unknown>).media, + lrc: (record as Record<string, unknown>).lrc, + offset: (record as Record<string, unknown>).offset, + title: (record as Record<string, unknown>).title, + artist: (record as Record<string, unknown>).artist, + }); + }) + .catch(() => { + try { + const json = atob(slug); + const data = JSON.parse(json) as Record<string, unknown>; + loadData(data); + } catch {} + }); }, []); // eslint-disable-line react-hooks/exhaustive-deps const handlePreviewToggle = useCallback(() => { @@ -452,9 +465,12 @@ function GameInner() { if (media.paused) { void media.play().catch(() => { - toast.error("Unable to start preview. Try interacting with the page again.", { - theme: "dark", - }); + toast.error( + "Unable to start preview. Try interacting with the page again.", + { + theme: "dark", + }, + ); }); return; } @@ -521,21 +537,6 @@ function GameInner() { 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; @@ -575,7 +576,10 @@ function GameInner() { if (intermissionRemaining > 5000) { e.preventDefault(); const targetMs = firstMs - 3000; - media.currentTime = Math.max(0, (targetMs - offsetRef.current) / 1000); + media.currentTime = Math.max( + 0, + (targetMs - offsetRef.current) / 1000, + ); setCurrentMs(media.currentTime * 1000 + offsetRef.current); return; } @@ -621,18 +625,17 @@ function GameInner() { }} > <MdLibraryMusic style={{ fontSize: 20, color: "#a78bfa" }} /> - LRC-Type + TypingMIXX </Link> - <Link - href="/create" + href="/" style={{ fontSize: 13, color: "rgba(255,255,255,0.6)", textDecoration: "none", }} > - Create + Home </Link> </div> </GameNavbar> @@ -643,8 +646,10 @@ function GameInner() { <StartCard> {!isReady ? ( <> - <SongTitleText>LRC-Type</SongTitleText> - <SongArtistText>Enter a game code to begin!</SongArtistText> + <SongTitleText>Loading...</SongTitleText> + <SongArtistText> + Please wait while we load the chart + </SongArtistText> </> ) : ( <> @@ -677,21 +682,6 @@ function GameInner() { /> </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> @@ -709,27 +699,20 @@ function GameInner() { /> </OpacityControl> )} - <CodeSection> - <div - style={{ - fontSize: 11, - color: "rgba(255,255,255,0.3)", - letterSpacing: 1, - textTransform: "uppercase", - }} + <PreviewWrap> + <PreviewBtn + onClick={handlePreviewToggle} + disabled={!audioUrl} + suppressHydrationWarning > - 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> + {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> </StartCard> </StartOverlay> )} @@ -812,7 +795,7 @@ function GameInner() { <UpcomingText> {gameLines[0] && gameLines[0].content.trim() === "" ? "[INTERMISSION]" - : gameLines[0]?.content ?? ""} + : (gameLines[0]?.content ?? "")} </UpcomingText> </UpcomingWrap> <CurrentWrap style={{ position: "relative" }}> @@ -820,7 +803,11 @@ function GameInner() { <LineTimingMeta> Time to first line:{" "} <LineTimingValue> - {Math.max(0, intermissionData.remainingMs / 1000).toFixed(1)}s + {Math.max( + 0, + intermissionData.remainingMs / 1000, + ).toFixed(1)} + s </LineTimingValue> </LineTimingMeta> </LineTimingRow> @@ -832,7 +819,8 @@ function GameInner() { textAlign: "center", }} > - {intermissionData.remainingMs > 5000 && "Press Space to skip long intermissions"} + {intermissionData.remainingMs > 5000 && + "Press Space to skip long intermissions"} </div> <LineTimingBar> <LineTimingFill $pct={intermissionData.pct} /> @@ -850,7 +838,7 @@ function GameInner() { {gameLines[g.displayedLineIdx + 1] && gameLines[g.displayedLineIdx + 1].content.trim() === "" ? "[INTERMISSION]" - : gameLines[g.displayedLineIdx + 1]?.content ?? ""} + : (gameLines[g.displayedLineIdx + 1]?.content ?? "")} </UpcomingText> </UpcomingWrap> <CurrentWrap style={{ position: "relative" }}> @@ -867,7 +855,7 @@ function GameInner() { <LineTimingValue> {calculateCPSNeeded( gameLines[g.displayedLineIdx].content, - currentLineTime / 1000 + currentLineTime / 1000, ).toFixed(1)} </LineTimingValue> </LineTimingMeta> diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 67d8da9..2608b32 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,8 +1,9 @@ import type { Metadata } from "next"; import StyledComponentsRegistry from "./registry"; +import { AuthProvider } from "./context/auth"; export const metadata: Metadata = { - title: "LRC-Type", + title: "TypingMIXX", description: "A typing game powered by LRC lyrics. Type along to your favourite songs!", }; @@ -15,8 +16,10 @@ export default function RootLayout({ return ( <html lang="en"> <body> - <StyledComponentsRegistry>{children}</StyledComponentsRegistry> + <StyledComponentsRegistry> + <AuthProvider>{children}</AuthProvider> + </StyledComponentsRegistry> </body> </html> ); -}
\ No newline at end of file +} diff --git a/src/app/lib/pocketbase.ts b/src/app/lib/pocketbase.ts new file mode 100644 index 0000000..b9aeb84 --- /dev/null +++ b/src/app/lib/pocketbase.ts @@ -0,0 +1,7 @@ +import PocketBase from "pocketbase"; + +const pb = new PocketBase( + process.env.NEXT_PUBLIC_POCKETBASE_URL ?? "http://127.0.0.1:8090" +); + +export default pb; diff --git a/src/app/page.styles.ts b/src/app/page.styles.ts index 50d28d7..468566a 100644 --- a/src/app/page.styles.ts +++ b/src/app/page.styles.ts @@ -1,25 +1,40 @@ -import styled from "styled-components"; +import styled, { createGlobalStyle } from "styled-components"; import Link from "next/link"; +import { + Root as BaseRoot, + Navbar as BaseNavbar, + Logo as BaseLogo, + LogoIcon as BaseLogoIcon, + NavLink as BaseNavLink, + NavCtaLink as BaseNavCtaLink, +} from "./styles/shared"; -export const NavLeft = styled.div` +// ── Base components (previously in the old app/page.styles.ts) ─────────────── + +const BaseNavLeft = styled.div` display: flex; align-items: center; gap: 14px; `; -export const NavCenter = styled.div` +const BaseNavCenter = styled.div` display: flex; align-items: center; flex: 0 1 560px; `; -export const SearchBox = styled.div` +const BaseNavRight = styled.div` + display: flex; + align-items: center; + gap: 6px; +`; + +const BaseSearchBox = 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; @@ -28,7 +43,7 @@ export const SearchBox = styled.div` } `; -export const SearchInput = styled.input` +const BaseSearchInput = styled.input` flex: 1; height: 100%; padding: 0 14px; @@ -42,7 +57,7 @@ export const SearchInput = styled.input` } `; -export const SearchButton = styled.button` +const BaseSearchButton = styled.button` width: 52px; height: 100%; background-color: #e8e8e8; @@ -60,15 +75,7 @@ export const SearchButton = styled.button` } `; -export const NavRight = styled.div` - display: flex; - align-items: center; - gap: 6px; -`; - - - -export const ChipsBar = styled.div` +const BaseChipsBar = styled.div` display: flex; align-items: center; gap: 10px; @@ -80,10 +87,9 @@ export const ChipsBar = styled.div` } `; -export const Chip = styled.button<{ $active?: boolean }>` +const BaseChip = 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; @@ -107,9 +113,8 @@ export const CardGrid = styled.div` gap: 20px; `; -export const Card = styled(Link)` +const BaseCard = styled(Link)` cursor: pointer; - border-radius: 14px; text-decoration: none; color: inherit; display: block; @@ -120,11 +125,10 @@ export const Card = styled(Link)` } `; -export const ThumbnailWrapper = styled.div` +const BaseThumbnailWrapper = styled.div` width: 100%; aspect-ratio: 16 / 9; background-color: #e4e4e4; - border-radius: 12px; display: flex; align-items: center; justify-content: center; @@ -147,9 +151,8 @@ export const PlayOverlay = styled.div` align-items: center; justify-content: center; background: rgba(0, 0, 0, 0); - border-radius: 12px; transition: background 0.2s; - ${Card}:hover & { + ${BaseCard}:hover & { background: rgba(0, 0, 0, 0.25); } `; @@ -157,7 +160,6 @@ export const PlayOverlay = styled.div` export const PlayCircle = styled.div` width: 48px; height: 48px; - border-radius: 50%; background: rgba(0, 0, 0, 0.7); color: #fff; display: flex; @@ -167,7 +169,7 @@ export const PlayCircle = styled.div` opacity: 0; transform: scale(0.8); transition: opacity 0.2s, transform 0.2s; - ${Card}:hover & { + ${BaseCard}:hover & { opacity: 1; transform: scale(1); } @@ -187,7 +189,7 @@ export const CardInfo = styled.div` min-width: 0; `; -export const CardTitle = styled.span` +const BaseCardTitle = styled.span` font-size: 14px; font-weight: 600; color: #1a1a1a; @@ -198,13 +200,13 @@ export const CardTitle = styled.span` overflow: hidden; `; -export const CardSub = styled.span` +const BaseCardSub = styled.span` font-size: 12px; color: #909090; line-height: 1.3; `; -export const EmptyState = styled.div` +const BaseEmptyState = styled.div` grid-column: 1 / -1; padding: 48px 0; text-align: center; @@ -212,40 +214,119 @@ export const EmptyState = styled.div` color: #909090; `; -export const CtaSection = styled.div` - padding: 32px 24px; - border-top: 1px solid #e5e5e5; - margin-top: 8px; +// ── Dark-themed exports ─────────────────────────────────────────────────────── + +export { GridContainer as BaseGridContainer }; + +export const TypingGlobalStyle = createGlobalStyle` + html, + body { + background-color: #0b0b10; + } `; -export const SectionHeading = styled.h2` - font-size: 17px; - font-weight: 700; - color: #1a1a1a; - margin: 0 0 14px; +export const Root = styled(BaseRoot)` + background-color: #0b0b10; + color: #f5f5f5; `; -export const OpenPlayerLink = styled(Link)` - display: inline-flex; - align-items: center; - gap: 8px; - padding: 10px 22px; - border-radius: 10px; - background-color: #1a1a1a; +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; - font-size: 14px; - font-weight: 600; - text-decoration: none; - transition: background-color 0.15s; &:hover { - background-color: #333; + background-color: #222838; + border-color: #3a4154; } `; -export const PlayerDescription = styled.p` - font-size: 13px; - color: #909090; - margin: 14px 0 0; - line-height: 1.6; - max-width: 480px; +export const NavLeft = styled(BaseNavLeft)``; +export const NavCenter = styled(BaseNavCenter)``; +export const NavRight = styled(BaseNavRight)``; + +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; `; diff --git a/src/app/page.tsx b/src/app/page.tsx index f8e1c1b..7a9b718 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,5 +1,143 @@ -import { redirect } from "next/navigation"; +"use client"; +import { useEffect, useState } from "react"; +import { FaPlay, FaMusic, FaSearch } from "react-icons/fa"; +import { MdLibraryMusic } from "react-icons/md"; +import { useAuth } from "./context/auth"; +import pb from "./lib/pocketbase"; +import { + Root, + Navbar, + Logo, + LogoIcon, + NavCtaLink, + NavLeft, + NavCenter, + SearchBox, + SearchInput, + SearchButton, + NavRight, -export default function HomePage() { - redirect("/typing"); + GridContainer, + CardGrid, + Card, + ThumbnailWrapper, + Thumbnail, + PlayOverlay, + PlayCircle, + CardMeta, + CardInfo, + CardTitle, + CardSub, + EmptyState, + TypingGlobalStyle, +} from "./page.styles"; + +interface ChartRecord { + id: string; + title: string; + artist: string; + thumbnail: string; + lrc: string; + media: string; + offset: number; +} + + +export default function TypingPage() { + const { user, signOut } = useAuth(); + const [charts, setCharts] = useState<ChartRecord[]>([]); + const [search, setSearch] = useState(""); + + useEffect(() => { + pb.collection("charts") + .getFullList<ChartRecord>({ sort: "-created" }) + .then(setCharts) + .catch(console.error); + }, []); + + const normalizedSearch = search.trim().toLowerCase(); + const filtered = normalizedSearch + ? charts.filter( + (item) => + item.title.toLowerCase().includes(normalizedSearch) || + item.artist.toLowerCase().includes(normalizedSearch), + ) + : charts; + + return ( + <> + <TypingGlobalStyle /> + <Root> + <Navbar> + <NavLeft> + <Logo href="/"> + <LogoIcon> + <MdLibraryMusic /> + </LogoIcon> + TypingMIXX + </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> + {user ? ( + <> + <span style={{ fontSize: 13, color: "#b0b3bd", padding: "0 6px", maxWidth: 140, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}> + {user.username || user.name} + </span> + <NavCtaLink href="#" onClick={(e) => { e.preventDefault(); signOut(); }}> + Sign out + </NavCtaLink> + </> + ) : ( + <NavCtaLink href="/signin">Sign in</NavCtaLink> + )} + </NavRight> + </Navbar> + + <GridContainer> + <CardGrid> + {filtered.length === 0 ? ( + <EmptyState>No results found.</EmptyState> + ) : ( + filtered.map((item) => ( + <Card key={item.id} href={`/game/${item.id}`} 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/signin/page.tsx b/src/app/signin/page.tsx new file mode 100644 index 0000000..67a4fd3 --- /dev/null +++ b/src/app/signin/page.tsx @@ -0,0 +1,280 @@ +"use client"; +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import styled, { createGlobalStyle, keyframes } from "styled-components"; +import { MdLibraryMusic } from "react-icons/md"; +import Link from "next/link"; +import { useAuth } from "../context/auth"; + +const GlobalStyle = createGlobalStyle` + html, body { + margin: 0; + padding: 0; + background-color: #0b0b10; + font-family: "Roboto", "Segoe UI", Arial, sans-serif; + } +`; + +const slideUp = keyframes` + from { transform: translateY(20px); opacity: 0; } + to { transform: translateY(0); opacity: 1; } +`; + +const Page = styled.div` + min-height: 100vh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 24px 16px; + background-color: #0b0b10; +`; + +const Card = styled.div` + width: 100%; + max-width: 400px; + background: #0f111a; + border: 1px solid #1f1f2a; + padding: 36px 32px 32px; + animation: ${slideUp} 0.25s ease; +`; + +const BrandRow = styled(Link)` + display: flex; + align-items: center; + gap: 8px; + text-decoration: none; + margin-bottom: 28px; +`; + +const BrandIcon = styled.span` + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 24px; + background: #f5f5f5; + color: #0b0b10; + font-size: 12px; +`; + +const BrandName = styled.span` + font-size: 18px; + font-weight: 800; + color: #f5f5f5; + letter-spacing: 0.3px; +`; + +const Tabs = styled.div` + display: flex; + gap: 4px; + background: #1a1d29; + padding: 4px; + margin-bottom: 28px; +`; + +const Tab = styled.button<{ $active: boolean }>` + flex: 1; + padding: 9px 0; + border: none; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: background 0.15s, color 0.15s; + background: ${(p) => (p.$active ? "#2a2f3d" : "transparent")}; + color: ${(p) => (p.$active ? "#f5f5f5" : "#8b90a0")}; + &:hover { color: #f5f5f5; } +`; + +const Field = styled.div` + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 16px; +`; + +const Label = styled.label` + font-size: 12px; + font-weight: 600; + color: #8b90a0; + letter-spacing: 0.5px; + text-transform: uppercase; +`; + +const Input = styled.input` + padding: 11px 14px; + border: 1px solid #2a2f3d; + background: #141824; + color: #f5f5f5; + font-size: 14px; + outline: none; + transition: border-color 0.15s; + &:focus { border-color: #a78bfa; } + &::placeholder { color: #4b5563; } +`; + +const ErrorMsg = styled.p` + font-size: 13px; + color: #f87171; + margin: 0 0 16px; + padding: 10px 14px; + background: rgba(248, 113, 113, 0.08); + border: 1px solid rgba(248, 113, 113, 0.2) +`; + +const SubmitBtn = styled.button` + width: 100%; + padding: 12px; + margin-top: 4px; + border: none; + background: #a78bfa; + color: #fff; + font-size: 14px; + font-weight: 700; + cursor: pointer; + transition: background 0.15s, opacity 0.15s; + &:hover:not(:disabled) { background: #9061f9; } + &:disabled { opacity: 0.5; cursor: not-allowed; } +`; + +type Mode = "signin" | "register"; + +export default function SignInPage() { + const router = useRouter(); + const { user, signIn, signUp } = useAuth(); + + const [mode, setMode] = useState<Mode>("signin"); + const [email, setEmail] = useState(""); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [passwordConfirm, setPasswordConfirm] = useState(""); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(false); + + + useEffect(() => { + if (user) router.replace("/"); + }, [user, router]); + + const switchMode = (next: Mode) => { + setMode(next); + setError(""); + setEmail(""); + setUsername(""); + setPassword(""); + setPasswordConfirm(""); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + + if (mode === "register" && password !== passwordConfirm) { + setError("Passwords do not match."); + return; + } + + setBusy(true); + try { + if (mode === "signin") { + await signIn(email, password); + } else { + await signUp(email, username, password, passwordConfirm); + } + router.replace("/"); + } catch (err: unknown) { + setError( + err instanceof Error ? err.message : "Something went wrong. Please try again." + ); + } finally { + setBusy(false); + } + }; + + return ( + <> + <GlobalStyle /> + <Page> + <Card> + <BrandRow href="/"> + <BrandIcon><MdLibraryMusic /></BrandIcon> + <BrandName>TypingMIXX</BrandName> + </BrandRow> + + <Tabs> + <Tab $active={mode === "signin"} onClick={() => switchMode("signin")}> + Sign in + </Tab> + <Tab $active={mode === "register"} onClick={() => switchMode("register")}> + Register + </Tab> + </Tabs> + + <form onSubmit={handleSubmit}> + <Field> + <Label htmlFor="auth-email">Email</Label> + <Input + id="auth-email" + type="email" + placeholder="you@example.com" + value={email} + onChange={(e) => setEmail(e.target.value)} + required + autoFocus + /> + </Field> + + {mode === "register" && ( + <Field> + <Label htmlFor="auth-username">Username</Label> + <Input + id="auth-username" + type="text" + placeholder="your username" + value={username} + onChange={(e) => setUsername(e.target.value)} + required + autoComplete="username" + /> + </Field> + )} + + <Field> + <Label htmlFor="auth-password">Password</Label> + <Input + id="auth-password" + type="password" + placeholder="••••••••" + value={password} + onChange={(e) => setPassword(e.target.value)} + required + /> + </Field> + + {mode === "register" && ( + <Field> + <Label htmlFor="auth-confirm">Confirm password</Label> + <Input + id="auth-confirm" + type="password" + placeholder="••••••••" + value={passwordConfirm} + onChange={(e) => setPasswordConfirm(e.target.value)} + required + /> + </Field> + )} + + {error && <ErrorMsg>{error}</ErrorMsg>} + + <SubmitBtn type="submit" disabled={busy}> + {busy + ? mode === "signin" ? "Signing in…" : "Creating account…" + : mode === "signin" ? "Sign in" : "Create account"} + </SubmitBtn> + </form> + </Card> + </Page> + </> + ); +} diff --git a/src/app/styles/shared.ts b/src/app/styles/shared.ts index d1b0232..ce240c5 100644 --- a/src/app/styles/shared.ts +++ b/src/app/styles/shared.ts @@ -40,7 +40,6 @@ export const LogoIcon = styled.span` justify-content: center; background-color: #1a1a1a; color: #fff; - border-radius: 6px; width: 30px; height: 22px; font-size: 10px; @@ -52,7 +51,6 @@ export const NavLink = styled(Link)` color: #606060; text-decoration: none; padding: 6px 10px; - border-radius: 8px; transition: background-color 0.15s, color 0.15s; &:hover { background-color: #f0f0f0; @@ -66,7 +64,6 @@ export const NavCtaLink = styled(Link)` 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; diff --git a/src/app/typing/page.styles.ts b/src/app/typing/page.styles.ts deleted file mode 100644 index 34f4ebc..0000000 --- a/src/app/typing/page.styles.ts +++ /dev/null @@ -1,168 +0,0 @@ -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 deleted file mode 100644 index 27ad173..0000000 --- a/src/app/typing/page.tsx +++ /dev/null @@ -1,161 +0,0 @@ -"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> - </> - ); -} |
