aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorPinapelz <yukais@pinapelz.com>2026-06-02 02:12:57 -0700
committerPinapelz <yukais@pinapelz.com>2026-06-02 02:13:10 -0700
commit0335b0ad81169232a3dbb1be1341fdcfce548645 (patch)
tree910593fa5e072ea77f594b6f10ddd96e49452446 /src
parent0d35e75edbc75f186e4a1ed52fbc3549ee9f5cd6 (diff)
migrate to pocketbase backend + auth/login
Diffstat (limited to 'src')
-rw-r--r--src/app/context/auth.tsx69
-rw-r--r--src/app/create/page.styles.ts168
-rw-r--r--src/app/create/page.tsx207
-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.tsx9
-rw-r--r--src/app/lib/pocketbase.ts7
-rw-r--r--src/app/page.styles.ts193
-rw-r--r--src/app/page.tsx144
-rw-r--r--src/app/signin/page.tsx280
-rw-r--r--src/app/styles/shared.ts3
-rw-r--r--src/app/typing/page.styles.ts168
-rw-r--r--src/app/typing/page.tsx161
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>
- </>
- );
-}
send patches to the email below
yukais@pinapelz.com
include the subject [PATCH repo_name]
pinapelz.com
homepage