diff options
Diffstat (limited to 'src/app/create')
| -rw-r--r-- | src/app/create/page.styles.ts | 168 | ||||
| -rw-r--r-- | src/app/create/page.tsx | 207 |
2 files changed, 375 insertions, 0 deletions
diff --git a/src/app/create/page.styles.ts b/src/app/create/page.styles.ts new file mode 100644 index 0000000..b54e095 --- /dev/null +++ b/src/app/create/page.styles.ts @@ -0,0 +1,168 @@ +import styled from "styled-components"; + +export const Content = styled.div` + max-width: 600px; + margin: 40px auto; + padding: 0 24px 60px; +`; + +export const Heading = styled.h1` + font-size: 22px; + font-weight: 800; + margin: 0 0 4px; +`; + +export const Subheading = styled.p` + font-size: 13px; + color: #909090; + margin: 0 0 32px; +`; + +export const Form = styled.div` + display: flex; + flex-direction: column; + gap: 14px; +`; + +export const FieldGroup = styled.div` + display: flex; + flex-direction: column; + gap: 5px; +`; + +export const Label = styled.label` + font-size: 12px; + font-weight: 600; + color: #606060; + text-transform: uppercase; + letter-spacing: 0.5px; +`; + +export const Input = styled.input` + height: 40px; + padding: 0 12px; + border: 1px solid #d4d4d4; + border-radius: 8px; + font-size: 14px; + color: #1a1a1a; + background-color: #fff; + transition: border-color 0.15s; + &:focus { + outline: none; + border-color: #1a1a1a; + } + &::placeholder { + color: #b0b0b0; + } +`; + +export const Divider = styled.div` + height: 1px; + background-color: #e5e5e5; + margin: 6px 0; +`; + +export const Row = styled.div` + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +`; + +export const GenerateButton = styled.button` + height: 42px; + padding: 0 24px; + border-radius: 10px; + border: none; + background-color: #1a1a1a; + color: #fff; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: background-color 0.15s; + margin-top: 6px; + &:hover { + background-color: #333; + } +`; + +export const ModeButton = styled.button<{ $active: boolean }>` + height: 42px; + padding: 0 24px; + border-radius: 10px; + border: none; + cursor: pointer; + font-size: 14px; + font-weight: 600; + background-color: ${(p) => (p.$active ? "#1a1a1a" : "#e5e5e5")}; + color: ${(p) => (p.$active ? "#fff" : "#1a1a1a")}; + transition: background-color 0.15s; +`; + +export const OutputSection = styled.div` + margin-top: 32px; + display: flex; + flex-direction: column; + gap: 14px; +`; + +export const OutputLabel = styled.div` + font-size: 12px; + font-weight: 600; + color: #606060; + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 5px; +`; + +export const CodeBox = styled.div` + position: relative; + background-color: #f0f0f0; + border: 1px solid #d4d4d4; + border-radius: 10px; + padding: 14px 48px 14px 14px; + font-family: "Courier New", monospace; + font-size: 13px; + color: #1a1a1a; + word-break: break-all; + line-height: 1.5; +`; + +export const CopyButton = styled.button<{ $copied: boolean }>` + position: absolute; + top: 10px; + right: 10px; + width: 30px; + height: 30px; + border-radius: 6px; + border: none; + background-color: ${(p) => (p.$copied ? "#22c55e" : "#d4d4d4")}; + color: ${(p) => (p.$copied ? "#fff" : "#606060")}; + font-size: 13px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background-color 0.15s, color 0.15s; + &:hover { + background-color: ${(p) => (p.$copied ? "#16a34a" : "#c0c0c0")}; + color: #1a1a1a; + } +`; + +export const OpenLink = styled.a` + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 13px; + font-weight: 500; + color: #1a1a1a; + text-decoration: none; + border: 1px solid #d4d4d4; + border-radius: 8px; + padding: 8px 14px; + background-color: #fff; + transition: background-color 0.15s; + &:hover { + background-color: #f0f0f0; + } +`; diff --git a/src/app/create/page.tsx b/src/app/create/page.tsx new file mode 100644 index 0000000..744ab95 --- /dev/null +++ b/src/app/create/page.tsx @@ -0,0 +1,207 @@ +"use client"; +import { useState } from "react"; +import { MdLibraryMusic } from "react-icons/md"; +import { FaCopy, FaCheck, FaExternalLinkAlt } from "react-icons/fa"; +import { Root, Navbar, Logo, LogoIcon, NavLink } from "../styles/shared"; +import { + Content, + Heading, + Subheading, + Form, + FieldGroup, + Label, + Input, + Divider, + Row, + GenerateButton, + OutputSection, + OutputLabel, + CodeBox, + CopyButton, + OpenLink, +} from "./page.styles"; + +interface TypingPayload { + file1?: string; + lrc?: string; + offset?: number; + title?: string; + artist?: string; + skip_backing?: boolean; +} + +export default function CreatePage() { + const [lrc, setLrc] = useState(""); + const [file1, setFile1] = useState(""); + const [offset, setOffset] = useState(""); + + const [typingTitle, setTypingTitle] = useState(""); + const [typingArtist, setTypingArtist] = useState(""); + const [skipBacking, setSkipBacking] = useState(true); + + const [code, setCode] = useState<string | null>(null); + const [copiedCode, setCopiedCode] = useState(false); + const [copiedUrl, setCopiedUrl] = useState(false); + + const resetCopyStates = () => { + setCopiedCode(false); + setCopiedUrl(false); + }; + + const generate = () => { + const payload: TypingPayload = {}; + if (file1.trim()) payload.file1 = file1.trim(); + if (lrc.trim()) payload.lrc = lrc.trim(); + if (offset.trim() !== "") payload.offset = Number(offset); + if (typingTitle.trim()) payload.title = typingTitle.trim(); + if (typingArtist.trim()) payload.artist = typingArtist.trim(); + payload.skip_backing = skipBacking; + + setCode(btoa(JSON.stringify(payload))); + resetCopyStates(); + }; + + const copy = (text: string, which: "code" | "url") => { + navigator.clipboard.writeText(text); + if (which === "code") { + setCopiedCode(true); + setTimeout(() => setCopiedCode(false), 2000); + } else { + setCopiedUrl(true); + setTimeout(() => setCopiedUrl(false), 2000); + } + }; + + const shareUrl = code ? `${window.location.origin}/game?code=${code}` : ""; + + return ( + <Root> + <Navbar> + <Logo href="/typing"> + <LogoIcon> + <MdLibraryMusic /> + </LogoIcon> + LRC-Type + </Logo> + <NavLink href="/typing">← Back</NavLink> + </Navbar> + + <Content> + <Heading>Create a Code</Heading> + <Subheading> + Generate a shareable code for your typing game session. + </Subheading> + + <Form> + <FieldGroup> + <Label>Primary Media</Label> + <Input + type="url" + placeholder="https://example.com/song.mp4" + value={file1} + onChange={(e) => setFile1(e.target.value)} + /> + </FieldGroup> + + <FieldGroup> + <Label>LRC Lyrics</Label> + <Input + type="url" + placeholder="https://example.com/song.lrc" + value={lrc} + onChange={(e) => setLrc(e.target.value)} + /> + </FieldGroup> + + <FieldGroup> + <Label title="Offset in milliseconds. Increase this value if the main audio is ahead of the lyrics."> + LRC Offset (ms) + </Label> + <Input + type="number" + placeholder="0" + value={offset} + onChange={(e) => setOffset(e.target.value)} + step="25" + /> + </FieldGroup> + + <Divider /> + + <Row> + <FieldGroup> + <Label>Title</Label> + <Input + type="text" + placeholder="Song Title" + value={typingTitle} + onChange={(e) => setTypingTitle(e.target.value)} + /> + </FieldGroup> + <FieldGroup> + <Label>Artist</Label> + <Input + type="text" + placeholder="Artist Name" + value={typingArtist} + onChange={(e) => setTypingArtist(e.target.value)} + /> + </FieldGroup> + </Row> + + <Row> + <FieldGroup> + <Label title="When enabled, lyrics inside parentheses are treated as backing lyrics and skipped."> + Skip Backing + </Label> + <Input + type="checkbox" + checked={skipBacking} + onChange={(e) => setSkipBacking(e.target.checked)} + style={{ width: "18px", height: "18px", marginTop: "10px" }} + /> + </FieldGroup> + </Row> + + <GenerateButton onClick={generate}>Generate Code</GenerateButton> + </Form> + + {code && ( + <OutputSection> + <div> + <OutputLabel>Code</OutputLabel> + <CodeBox> + {code} + <CopyButton + $copied={copiedCode} + onClick={() => copy(code, "code")} + aria-label="Copy code" + > + {copiedCode ? <FaCheck /> : <FaCopy />} + </CopyButton> + </CodeBox> + </div> + + <div> + <OutputLabel>Share URL</OutputLabel> + <CodeBox> + {shareUrl} + <CopyButton + $copied={copiedUrl} + onClick={() => copy(shareUrl, "url")} + aria-label="Copy URL" + > + {copiedUrl ? <FaCheck /> : <FaCopy />} + </CopyButton> + </CodeBox> + </div> + + <OpenLink href={shareUrl} target="_blank" rel="noopener noreferrer"> + <FaExternalLinkAlt /> Open in Typing Game + </OpenLink> + </OutputSection> + )} + </Content> + </Root> + ); +} |
