| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8" /> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
| <title>Sozai · build memories together</title> |
| <link rel="icon" type="image/svg+xml" href="/assets/icon.svg" /> |
|
|
| <link rel="preconnect" href="https://fonts.googleapis.com" /> |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> |
| <link href="https://fonts.googleapis.com/css2?family=EB+Garamond:ital,wght@0,400;0,500;1,400&family=Caveat:wght@400;600&family=Martian+Mono:wght@300;400&display=swap" rel="stylesheet" /> |
| <style> |
| @font-face { font-family: 'AdvercaseBold'; src: url('/assets/fonts/AdvercaseBold.otf') format('opentype'); font-weight: 700; font-style: normal; font-display: swap; } |
| @font-face { font-family: 'Inkwell'; src: url('/assets/fonts/Inkwell.ttf') format('truetype'); font-weight: 400; font-style: normal; font-display: swap; } |
| </style> |
|
|
| <script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script> |
|
|
| |
| |
| <style type="text/tailwindcss"> |
| @theme inline { |
| --font-display: 'AdvercaseBold', sans-serif; |
| --font-hand: 'Inkwell', 'Caveat', cursive; |
| --font-serif: 'EB Garamond', serif; |
| --font-sans: 'Martian Mono', monospace; |
| --font-mono: 'Martian Mono', monospace; |
| --color-cream: var(--cream); |
| --color-ink: var(--ink); |
| --color-forest: var(--forest); |
| --color-forest-deep: var(--forest-deep); |
| --color-near-black: var(--near-black); |
| --color-line: var(--line); |
| --color-muted-ink: var(--muted-ink); |
| } |
| :root { |
| --cream: oklch(0.968 0.010 88); |
| --cream-2: oklch(0.950 0.010 88); |
| --ink: oklch(0.21 0.030 200); |
| --muted-ink: oklch(0.53 0.025 210); |
| --forest: oklch(0.34 0.10 235); |
| --forest-deep: oklch(0.27 0.08 235); |
| --near-black: oklch(0.15 0.025 200); |
| --line: oklch(0.86 0.016 210); |
| } |
| @layer base { |
| body { @apply bg-cream text-ink; } |
| } |
| </style> |
|
|
| <script src="https://unpkg.com/react@18.3.1/umd/react.production.min.js" crossorigin></script> |
| <script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js" crossorigin></script> |
| |
| |
| |
| |
| |
| <script src="https://unpkg.com/@babel/standalone@7.26.4/babel.min.js" crossorigin></script> |
| </head> |
|
|
| <body class="font-sans antialiased bg-cream"> |
| <div id="root"></div> |
|
|
| <script type="text/babel" data-presets="react"> |
| const { useState } = React; |
| |
| |
| function sessionId() { |
| let s = localStorage.getItem("sozai_session"); |
| if (!s) { s = (crypto.randomUUID ? crypto.randomUUID() : String(Math.random()).slice(2)); localStorage.setItem("sozai_session", s); } |
| return s; |
| } |
| function storedName() { return localStorage.getItem("sozai_name") || ""; } |
| |
| const CODE_ALPHABET = "23456789ABCDEFGHJKMNPQRSTVWXYZ"; |
| |
| |
| |
| |
| function HummingbirdSVG({ className = "" }) { |
| return ( |
| <svg viewBox="0 0 220 200" fill="none" className={className} |
| stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" |
| aria-hidden="true"> |
| {/* long beak */} |
| <path d="M150 92 L205 84" /> |
| {/* head + breast + belly */} |
| <path d="M150 92 q-14 -10 -34 -6 q-26 5 -40 24 q-12 16 -10 33 q1 12 12 18" /> |
| {/* back curving to tail */} |
| <path d="M150 92 q10 8 8 26 q-2 22 -22 40" /> |
| {/* upper swept wing */} |
| <path d="M120 92 q24 -44 78 -64 q-30 30 -40 64 q-4 14 -16 18" /> |
| {/* lower wing fold */} |
| <path d="M120 96 q18 -10 44 -8 q-22 10 -34 24" /> |
| {/* long forked tail */} |
| <path d="M70 152 q-30 18 -58 24 m58 -24 q-22 26 -30 46 m30 -46 q-6 26 -2 44" /> |
| {/* tiny legs */} |
| <path d="M96 138 l-4 12 m12 -10 l-2 12" /> |
| {/* eye */} |
| <circle cx="158" cy="88" r="1.6" fill="currentColor" stroke="none" /> |
| </svg> |
| ); |
| } |
| |
| |
| |
| |
| |
| const HUMMINGBIRD_SOURCES = [ |
| "/assets/images/hummingbird.svg", |
| "/assets/images/hummingbird.png", |
| ]; |
| function Hummingbird({ className = "" }) { |
| const [srcIdx, setSrcIdx] = useState(0); |
| const [useInline, setUseInline] = useState(false); |
| if (useInline) return <HummingbirdSVG className={className} />; |
| return ( |
| <img |
| src={HUMMINGBIRD_SOURCES[srcIdx]} |
| alt="" |
| aria-hidden="true" |
| className={className} |
| style={{ mixBlendMode: "multiply" }} |
| onError={() => { |
| if (srcIdx + 1 < HUMMINGBIRD_SOURCES.length) setSrcIdx(srcIdx + 1); |
| else setUseInline(true); |
| }} |
| /> |
| ); |
| } |
| |
| |
| function Splash() { |
| const [creating, setCreating] = useState(false); |
| const [codeLength, setCodeLength] = useState(6); |
| const [name, setName] = useState(storedName()); |
| const [joinCode, setJoinCode] = useState(""); |
| const [busy, setBusy] = useState(false); |
| const [error, setError] = useState(null); |
| |
| function persistName() { |
| if (name.trim()) localStorage.setItem("sozai_name", name.trim()); |
| } |
| |
| async function makeRoom() { |
| setBusy(true); setError(null); persistName(); |
| try { |
| const res = await fetch("/api/rooms", { |
| method: "POST", |
| headers: { "Content-Type": "application/json" }, |
| body: JSON.stringify({ code_length: codeLength, display_name: name, session_id: sessionId() }), |
| }); |
| if (!res.ok) throw new Error("Could not create the room."); |
| const data = await res.json(); |
| location.href = data.room_path; |
| } catch (e) { |
| setError(e.message || "Something went wrong."); setBusy(false); |
| } |
| } |
| |
| async function joinRoom() { |
| const code = joinCode.trim().toUpperCase(); |
| if (code.length < 4) { setError("Enter your room code."); return; } |
| setBusy(true); setError(null); persistName(); |
| try { |
| const res = await fetch("/api/join-by-code", { |
| method: "POST", |
| headers: { "Content-Type": "application/json" }, |
| body: JSON.stringify({ code }), |
| }); |
| if (res.status === 404) throw new Error("No active room with that code."); |
| if (!res.ok) throw new Error("Could not join. Check the code and try again."); |
| const data = await res.json(); |
| location.href = data.room_path; |
| } catch (e) { |
| setError(e.message || "Something went wrong."); setBusy(false); |
| } |
| } |
| |
| const exampleCode = "8F7A9CKMNPQR".slice(0, codeLength); |
| |
| return ( |
| <main className="relative min-h-screen w-full overflow-hidden bg-cream"> |
| {/* looping bird animation blended over the cream bg */} |
| <video autoPlay loop muted playsInline aria-hidden="true" |
| className="pointer-events-none absolute inset-0 z-0 h-full w-full object-cover" |
| style={{ mixBlendMode: "multiply", opacity: 0.32 }}> |
| <source src="/assets/intro_video.mp4" type="video/mp4" /> |
| </video> |
| {/* liquid-glass: a full-height frosted panel that fades in from the |
| right edge to about where "Sozai" sits, so the content reads |
| cleanly over the busy map while the aesthetic shows through */} |
| <div aria-hidden="true" className="pointer-events-none absolute inset-y-0 right-0 z-[5] w-full sm:w-4/5 md:w-3/5" |
| style={{ |
| backdropFilter: "blur(12px) saturate(1.08)", |
| WebkitBackdropFilter: "blur(12px) saturate(1.08)", |
| background: "linear-gradient(to left, rgba(245,242,233,0.62), rgba(245,242,233,0))", |
| maskImage: "linear-gradient(to left, #000 45%, transparent 100%)", |
| WebkitMaskImage: "linear-gradient(to left, #000 45%, transparent 100%)", |
| }} /> |
| {/* top-left wordmark with the bird logo */} |
| <header className="absolute left-6 top-5 z-20 flex items-center gap-2 sm:left-10 sm:top-7"> |
| <img src="/assets/images/hummingbird-black.svg" alt="" className="size-9" /> |
| <span className="font-display text-2xl leading-none text-ink">Sozai</span> |
| <span className="font-serif text-lg leading-none text-muted-ink" lang="ja">素材</span> |
| </header> |
| |
| {/* content sits above the video */} |
| <div className="relative z-10 mx-auto flex min-h-screen max-w-6xl items-center justify-center px-6 md:justify-end md:pr-16 lg:pr-24"> |
| <div className="w-full max-w-md"> |
| |
| <h1 className="text-center font-display text-6xl leading-none text-forest sm:text-7xl">Sozai</h1> |
| <p className="mt-3 text-center font-serif text-xl italic text-muted-ink">build memories together</p> |
| |
| {/* make a room */} |
| {!creating ? ( |
| <button type="button" onClick={() => setCreating(true)} |
| className="mt-8 w-full rounded-md bg-forest px-6 py-3.5 text-center font-serif text-lg italic tracking-wide text-cream shadow-sm transition-colors hover:bg-forest-deep"> |
| make a room |
| </button> |
| ) : ( |
| <div className="mt-8 rounded-md border border-line bg-cream-2/70 p-5"> |
| <div className="flex items-center justify-between"> |
| <span className="font-serif text-base italic text-ink">new room</span> |
| <button type="button" onClick={() => { setCreating(false); setError(null); }} |
| className="font-mono text-[10px] uppercase tracking-widest text-muted-ink hover:text-ink">close</button> |
| </div> |
| |
| {/* user-set room code length */} |
| <div className="mt-4"> |
| <div className="flex items-baseline justify-between"> |
| <label htmlFor="len" className="font-mono text-[11px] uppercase tracking-widest text-muted-ink">Room code length</label> |
| <span className="font-mono text-sm text-ink">{codeLength}</span> |
| </div> |
| <input id="len" type="range" min="4" max="12" step="1" value={codeLength} |
| onChange={(e) => setCodeLength(parseInt(e.target.value, 10))} |
| className="mt-2 w-full accent-[var(--forest)]" /> |
| <p className="mt-1 font-mono text-[11px] tracking-widest text-muted-ink"> |
| e.g. <span className="text-forest">{exampleCode}</span> |
| </p> |
| </div> |
| |
| {/* display name */} |
| <div className="mt-4"> |
| <label htmlFor="nm" className="font-mono text-[11px] uppercase tracking-widest text-muted-ink">Your name (optional)</label> |
| <input id="nm" value={name} onChange={(e) => setName(e.target.value)} maxLength={24} |
| placeholder="Guest" |
| className="mt-2 w-full rounded-md border border-line bg-cream px-3 py-2 font-serif text-base italic text-ink outline-none placeholder:text-muted-ink/60 focus:border-forest" /> |
| </div> |
| |
| <button type="button" onClick={makeRoom} disabled={busy} |
| className="mt-5 w-full rounded-md bg-forest px-6 py-3 text-center font-serif text-base italic tracking-wide text-cream transition-colors hover:bg-forest-deep disabled:opacity-60"> |
| {busy ? "creating…" : "create room"} |
| </button> |
| </div> |
| )} |
| |
| {/* or divider */} |
| <div className="my-6 flex items-center gap-4"> |
| <span className="h-px flex-1 bg-line" /> |
| <span className="font-serif text-sm italic text-muted-ink">or</span> |
| <span className="h-px flex-1 bg-line" /> |
| </div> |
| |
| {/* join by code */} |
| <div className="flex items-stretch overflow-hidden rounded-md border border-line bg-cream"> |
| <input |
| value={joinCode} |
| onChange={(e) => setJoinCode(e.target.value.toUpperCase().split("").filter((c) => CODE_ALPHABET.includes(c)).join("").slice(0, 12))} |
| onKeyDown={(e) => e.key === "Enter" && joinRoom()} |
| placeholder="enter room code" |
| aria-label="Room code" |
| className="min-w-0 flex-1 bg-transparent px-4 py-3 font-serif text-base italic tracking-[0.18em] text-ink outline-none placeholder:not-italic placeholder:tracking-normal placeholder:text-muted-ink/60" |
| /> |
| <button type="button" onClick={joinRoom} disabled={busy} |
| className="shrink-0 bg-forest px-7 font-serif text-base italic text-cream transition-colors hover:bg-forest-deep disabled:opacity-60"> |
| join |
| </button> |
| </div> |
| |
| {error ? ( |
| <p role="status" className="mt-3 text-center font-mono text-[11px] uppercase tracking-widest text-[oklch(0.55_0.18_25)]">{error}</p> |
| ) : null} |
| |
| {/* the "no room" option */} |
| <div className="mt-6 text-center"> |
| <a href="/app" |
| className="font-mono text-[11px] uppercase tracking-widest text-muted-ink underline-offset-4 transition-colors hover:text-ink hover:underline"> |
| continue without a room → |
| </a> |
| </div> |
| </div> |
| </div> |
| </main> |
| ); |
| } |
| |
| ReactDOM.createRoot(document.getElementById("root")).render(<Splash />); |
| </script> |
| </body> |
| </html> |