sozai / landing.html
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
Raw
History Blame Contribute Delete
14.6 kB
<!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>
<!-- 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>