Spaces:
Paused
Paused
Shereen Lee
feat: cesium+2D expand(big+zoomout), scrapbook shuffle/autolayout fixes, print stylesheet, liquid-glass landing, bird logo (black), views rename, EXIF metadata, polaroid/copy polish, corner bird video
30f1da2 | <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> | |
| <!-- Landing palette: cream + forest green, tied to the same token system the | |
| app uses, with a serif display per the Sozai splash design. --> | |
| <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/umd/react.production.min.js" crossorigin></script> | |
| <script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js" crossorigin></script> | |
| <script src="https://unpkg.com/@babel/standalone/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; | |
| // ----- shared identity (also read by app.html) ----------------------- // | |
| 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"; | |
| // ----- decorative line-art hummingbird ------------------------------- // | |
| // Drawn inline so it always renders. If a public/images/hummingbird.svg | |
| // asset exists it is used; otherwise this vector falls back automatically. | |
| 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> | |
| ); | |
| } | |
| // Hummingbird artwork. Tries a real image asset first - either a PNG or an | |
| // SVG - and falls back to the inline vector if the asset is missing. Drop a | |
| // file at /assets/images/hummingbird.(svg|png) to use your own artwork. | |
| // `mix-blend-mode: multiply` lets a white-background asset melt into the | |
| // cream page instead of showing a white box. | |
| 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); | |
| }} | |
| /> | |
| ); | |
| } | |
| // ----- the splash ---------------------------------------------------- // | |
| 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> |