sozai / index.html
Shereen Lee
fix: smoother Cesium globe (on-demand render) + Polaroid-framed photo pins
241eca4
Raw
History Blame Contribute Delete
471 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sozai · Add to Album</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>
/* size-adjust normalizes the small apparent size of these custom fonts so
they don't look tiny next to Martian Mono at the same font-size. Tunable. */
@font-face { font-family: 'AdvercaseBold'; src: url('/assets/fonts/AdvercaseBold.otf') format('opentype'); font-weight: 700; font-style: normal; font-display: swap; size-adjust: 112%; }
@font-face { font-family: 'AdvercaseRegular'; src: url('/assets/fonts/AdvercaseRegular.otf') format('opentype'); font-weight: 400; font-style: normal; font-display: swap; size-adjust: 112%; }
@font-face { font-family: 'Inkwell'; src: url('/assets/fonts/Inkwell.ttf') format('truetype'); font-weight: 400; font-style: normal; font-display: swap; size-adjust: 135%; }
</style>
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<!-- Design tokens ported verbatim from app/globals.css -->
<style type="text/tailwindcss">
@custom-variant dark (&:is(.dark *));
@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-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
:root {
--background: oklch(0.968 0.010 88);
--foreground: oklch(0.21 0.030 200);
--card: oklch(0.982 0.007 88);
--card-foreground: oklch(0.21 0.030 200);
--popover: oklch(0.982 0.007 88);
--popover-foreground: oklch(0.21 0.030 200);
--primary: oklch(0.34 0.10 235);
--primary-foreground: oklch(0.97 0.008 88);
--secondary: oklch(0.93 0.010 88);
--secondary-foreground: oklch(0.30 0.040 210);
--muted: oklch(0.93 0.010 88);
--muted-foreground: oklch(0.53 0.025 210);
--accent: oklch(0.92 0.015 200);
--accent-foreground: oklch(0.30 0.040 210);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.86 0.016 210);
--input: oklch(0.90 0.012 200);
--ring: oklch(0.34 0.10 235);
--chart-1: oklch(0.87 0 0); --chart-2: oklch(0.556 0 0); --chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0); --chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0); --sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0); --sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0); --sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0); --sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.10 0.022 235); --foreground: oklch(0.96 0.006 88);
--card: oklch(0.14 0.020 235); --card-foreground: oklch(0.96 0.006 88);
--popover: oklch(0.14 0.020 235); --popover-foreground: oklch(0.96 0.006 88);
--primary: oklch(0.72 0.08 235); --primary-foreground: oklch(0.10 0.022 235);
--secondary: oklch(0.18 0.018 235); --secondary-foreground: oklch(0.96 0.006 88);
--muted: oklch(0.18 0.018 235); --muted-foreground: oklch(0.58 0.018 235);
--accent: oklch(0.20 0.022 235); --accent-foreground: oklch(0.96 0.006 88);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%); --input: oklch(1 0 0 / 15%); --ring: oklch(0.72 0.08 235);
}
@layer base {
* { @apply border-border outline-ring/50; }
body { @apply bg-background text-foreground; }
html { @apply font-sans; }
}
</style>
<!-- Accessibility settings: applied as classes on <html> by the app. -->
<style>
html.a11y-large-text { font-size: 125%; }
html.a11y-dyslexia, html.a11y-dyslexia body {
font-family: "Comic Sans MS", "Trebuchet MS", Verdana, sans-serif !important;
letter-spacing: 0.02em;
word-spacing: 0.08em;
}
html.a11y-underline-links a { text-decoration: underline; text-underline-offset: 2px; }
html.a11y-high-contrast {
--background: oklch(1 0 0);
--foreground: oklch(0.12 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.12 0 0);
--muted-foreground: oklch(0.22 0 0);
--primary: oklch(0.32 0.18 268);
--primary-foreground: oklch(1 0 0);
--border: oklch(0.2 0 0);
--accent: oklch(0.9 0.02 268);
--accent-foreground: oklch(0.12 0 0);
--ring: oklch(0.2 0 0);
}
html.a11y-high-contrast.dark {
--background: oklch(0.08 0 0);
--foreground: oklch(1 0 0);
--card: oklch(0.12 0 0);
--card-foreground: oklch(1 0 0);
--muted-foreground: oklch(0.85 0 0);
--border: oklch(0.9 0 0);
}
html.a11y-reduce-motion *,
html.a11y-reduce-motion *::before,
html.a11y-reduce-motion *::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
scroll-behavior: auto !important;
}
@keyframes sozai-shimmer { from { background-position: 200% 0; } to { background-position: -200% 0; } }
@keyframes sozai-bounce { 0%, 80%, 100% { transform: translateY(0); opacity: 0.5; } 40% { transform: translateY(-5px); opacity: 1; } }
/* polaroid slide-in when browsing photos (item: moving/reveal effect) */
/* springy: overshoot, counter-settle, then rest — gives the browse weight */
@keyframes sozai-slide-in-right {
0% { transform: translateX(54%) rotate(6deg) scale(0.95); opacity: 0; }
55% { transform: translateX(-5%) rotate(-2.2deg) scale(1.025); opacity: 1; }
78% { transform: translateX(2%) rotate(1deg) scale(0.995); }
100% { transform: translateX(0) rotate(0deg) scale(1); opacity: 1; }
}
@keyframes sozai-slide-in-left {
0% { transform: translateX(-54%) rotate(-6deg) scale(0.95); opacity: 0; }
55% { transform: translateX(5%) rotate(2.2deg) scale(1.025); opacity: 1; }
78% { transform: translateX(-2%) rotate(-1deg) scale(0.995); }
100% { transform: translateX(0) rotate(0deg) scale(1); opacity: 1; }
}
.sozai-slide-right { animation: sozai-slide-in-right 0.55s cubic-bezier(0.34, 1.42, 0.5, 1); }
.sozai-slide-left { animation: sozai-slide-in-left 0.55s cubic-bezier(0.34, 1.42, 0.5, 1); }
/* gentle sway on the browse arrows, nudging toward the flick direction */
@keyframes sozai-sway-l { 0%,100%{transform:translateX(0)} 50%{transform:translateX(-3px)} }
@keyframes sozai-sway-r { 0%,100%{transform:translateX(0)} 50%{transform:translateX(3px)} }
.sozai-sway-l { animation: sozai-sway-l 1.9s ease-in-out infinite; }
.sozai-sway-r { animation: sozai-sway-r 1.9s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) { .sozai-sway-l, .sozai-sway-r { animation: none; } }
/* (reduced motion is handled globally by the html.a11y-reduce-motion rule above) */
/* polaroid teardown when the user removes it (presses ×) - it crumples,
tilts, desaturates and drops away as if tossed out */
@keyframes sozai-destroy {
0% { transform: scale(1) rotate(0deg) translateY(0); opacity: 1; filter: none; }
25% { transform: scale(1.06) rotate(-3deg) translateY(-6px); opacity: 1; }
55% { transform: scale(0.84) rotate(8deg) translateY(8px); opacity: 0.85; filter: grayscale(0.4) blur(0.4px); }
100% { transform: scale(0.35) rotate(22deg) translateY(120px); opacity: 0; filter: grayscale(1) blur(2px); }
}
.sozai-destroy { animation: sozai-destroy 0.5s cubic-bezier(0.55, 0, 0.85, 0.3) forwards;
transform-origin: 50% 60%; pointer-events: none; }
/* a quick "torn" diagonal flash overlaid while it crumples */
@keyframes sozai-tear-flash { 0% { opacity: 0; } 30% { opacity: 0.9; } 100% { opacity: 0; } }
.sozai-tear { position: absolute; inset: 0; pointer-events: none; border-radius: inherit;
background: linear-gradient(115deg, transparent 44%, rgba(255,255,255,0.85) 49%, rgba(0,0,0,0.18) 51%, transparent 56%);
animation: sozai-tear-flash 0.5s ease-out forwards; }
/* Annotated activity pins for the open-source watercolor map */
.sozai-pin { display: flex; flex-direction: column; align-items: center; transform: translateY(-2px); cursor: default; }
.sozai-pin-bubble {
display: inline-flex; align-items: center; gap: 6px;
max-width: 220px; padding: 4px 9px;
background: rgba(255,255,255,0.92);
border: 2px solid var(--primary, #7c3aed);
border-radius: 9px;
font: 600 12px/1.2 ui-sans-serif, system-ui, sans-serif;
color: #333; box-shadow: 0 2px 6px rgba(0,0,0,0.18);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.sozai-pin-emoji { font-size: 15px; }
.sozai-pin-text { overflow: hidden; text-overflow: ellipsis; }
.sozai-pin-stem {
width: 2px; height: 12px;
background: var(--primary, #7c3aed);
border-radius: 2px;
}
.maplibregl-ctrl-attrib { font-size: 10px; }
/* ---- "Develop" scrapbook variant (ported from the london scroll) ---- */
.dv-paper { background: var(--background); position: relative; overflow: hidden;
box-shadow: inset 0 16px 22px -20px rgba(60,55,40,.22); }
.dv-paper::before { content:""; position:absolute; inset:0; pointer-events:none;
background: linear-gradient(100deg, rgba(233,196,138,.18) 0%, rgba(244,233,206,.10) 22%,
rgba(173,205,222,.16) 48%, rgba(196,214,224,.10) 64%, rgba(224,186,160,.14) 82%, rgba(168,142,176,.18) 100%); }
.dv-paper::after { content:""; position:absolute; inset:0; opacity:.5; mix-blend-mode:multiply; pointer-events:none;
background:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='240' height='240'%3E%3Cfilter id='g'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.75' numOctaves='2'/%3E%3CfeColorMatrix values='0 0 0 0 0.45 0 0 0 0 0.43 0 0 0 0 0.38 0 0 0 0.05 0'/%3E%3C/filter%3E%3Crect width='240' height='240' filter='url(%23g)'/%3E%3C/svg%3E"); }
.dv-slot { position:absolute; border:1px solid oklch(0.21 0.030 200 / 0.14); border-radius:5px; z-index:1;
transition: border-color .25s, background .25s; }
.dv-slot.target { border-color: oklch(0.21 0.030 200 / 0.40); background: oklch(0.21 0.030 200 / 0.025); }
.dv-slot.filled { border-color: transparent; }
.dv-dim { position:absolute; inset:0; background: oklch(0.21 0.030 200 / 0.16); opacity:0; pointer-events:none; z-index:50; }
.dv-piece { position:absolute; touch-action:none; user-select:none; -webkit-user-select:none;
pointer-events:auto;
background:#fff; padding: var(--pad,10px) var(--pad,10px) 0; perspective:1200px;
filter: drop-shadow(0 3px 5px rgba(60,50,35,.15)) drop-shadow(0 12px 22px rgba(60,50,35,.13));
transition: filter .35s cubic-bezier(0.16,1,0.3,1); will-change: left, top, width, transform; }
.dv-flip { position:relative; transform-style:preserve-3d; transition: transform .7s cubic-bezier(0.16,1,0.3,1); }
.dv-piece.flipped .dv-flip { transform: rotateY(180deg); }
.dv-front { backface-visibility:hidden; -webkit-backface-visibility:hidden; }
.dv-back { position:absolute; inset:0; transform: rotateY(180deg); backface-visibility:hidden; -webkit-backface-visibility:hidden;
background:#fff; display:flex; flex-direction:column; gap:.3em; padding: calc(var(--pad,10px)*1.1) calc(var(--pad,10px)*1.2); }
.dv-bhead { font-size: clamp(11px,.95vw,15px); letter-spacing:.04em; color:var(--foreground); font-family:'EB Garamond',Georgia,serif; font-weight:500; }
.dv-blabel { display:flex; flex-direction:column; gap:.1em; font-size: clamp(8px,.7vw,10px); letter-spacing:.08em;
text-transform:uppercase; color:var(--muted-foreground); font-family:'Martian Mono',monospace; }
.dv-btime, .dv-bloc { width:100%; border:none; border-bottom:1px solid oklch(0.21 0.030 200 / 0.25); outline:none; background:transparent;
font-family:'Inkwell','Caveat',cursive; font-size: clamp(13px,1.1vw,17px); color:var(--foreground); padding:.05em 0; }
.dv-btime:focus, .dv-bloc:focus { border-bottom-color:var(--ring); }
.dv-btime::placeholder, .dv-bloc::placeholder { color:oklch(0.70 0.015 210); }
.dv-bwrite { flex:1; width:100%; border:none; outline:none; resize:none; background:transparent;
font-family:'Inkwell','Caveat',cursive; font-size: clamp(14px,1.15vw,18px); color:var(--foreground); line-height:1.25; margin-top:.2em; }
.dv-bwrite::placeholder { color:oklch(0.70 0.015 210); }
.dv-frame { border:1.5px solid; line-height:0; }
.dv-piece img { display:block; width:100%; aspect-ratio:1/1; object-fit:cover; pointer-events:none; -webkit-user-drag:none; }
.dv-piece.dragging { filter: drop-shadow(0 6px 9px rgba(60,50,35,.2)) drop-shadow(0 26px 38px rgba(60,50,35,.22)); z-index:60 !important; cursor:grabbing; }
.dv-desk .dv-piece { cursor:grab; }
.dv-settled .dv-piece { cursor:pointer; }
.dv-piece figcaption { padding: calc(var(--pad,10px)*.62) calc(var(--pad,10px)*.3) calc(var(--pad,10px)*.95);
text-align:center; position:relative; white-space:nowrap; overflow:hidden; font-family:'EB Garamond',Georgia,serif; }
.dv-cap { font-size: clamp(10px,.85vw,14px); letter-spacing:.035em; color:var(--foreground); }
.dv-cap .when { font-family:'Inkwell','Caveat',cursive; font-size:1.22em; color:oklch(0.42 0.025 210); margin-left:.5em; }
.dv-cap .where { font-family:'EB Garamond',serif; font-style:italic; color:var(--muted-foreground); }
.dv-stroke { position:absolute; left:16%; bottom:8%; width:68%; height:7px; overflow:visible; opacity:0; }
.dv-stroke path { fill:none; stroke-width:2.4; stroke-linecap:round; stroke-dasharray:100; stroke-dashoffset:100;
transition: stroke-dashoffset .55s cubic-bezier(0.16,1,0.3,1); }
.dv-settled .dv-piece { transition: transform .45s cubic-bezier(0.16,1,0.3,1), filter .45s cubic-bezier(0.16,1,0.3,1); }
.dv-settled .dv-piece:hover, .dv-settled .dv-piece:focus-visible {
transform: translateY(-7px) rotate(var(--rot)) !important; z-index:40;
filter: drop-shadow(0 5px 8px rgba(60,50,35,.17)) drop-shadow(0 20px 30px rgba(60,50,35,.19)); }
.dv-settled .dv-piece:hover .dv-stroke, .dv-settled .dv-piece:focus-visible .dv-stroke { opacity:1; }
.dv-settled .dv-piece:hover .dv-stroke path, .dv-settled .dv-piece:focus-visible .dv-stroke path { stroke-dashoffset:0; }
.dv-settled .dv-piece.flipped { z-index:45; }
.dv-link { font-family:'Inkwell','Caveat',cursive; font-size: clamp(16px,1.4vw,21px); color:var(--foreground);
border-bottom:1.6px solid transparent; transition: border-color .2s; }
.dv-link:hover { border-bottom-color:var(--foreground); }
.dv-go { font-family:'Inkwell','Caveat',cursive; font-size: clamp(16px,1.4vw,21px);
background:var(--foreground); color:var(--primary-foreground); padding:.25em 1em; border-radius:999px; transition: opacity .3s, background .25s; }
.dv-go:hover { background:oklch(0.15 0.025 200); }
.dv-go:disabled { opacity:.3; cursor:default; }
/* ---- Polaroid photo pins on the watercolor map ---- */
.sozai-polaroid { background:#fff; padding:5px 5px 0; border-radius:2px;
box-shadow: 0 2px 5px rgba(0,0,0,.25), 0 10px 20px rgba(0,0,0,.18);
transform: rotate(var(--rot,-3deg)) scale(var(--scale,1)); transform-origin:bottom center;
transition: transform .2s; cursor:grab; }
.sozai-polaroid-wrap.is-dragging .sozai-polaroid { cursor:grabbing; }
.sozai-polaroid:hover { z-index:50; }
.sozai-polaroid img { display:block; width:104px; height:104px; object-fit:cover; }
.sozai-polaroid .cap { font-family:'Martian Mono',monospace; font-size:9px; line-height:1.25; color:var(--foreground);
text-align:center; padding:4px 3px 5px; max-width:88px; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden; }
.sozai-polaroid .pdel { position:absolute; top:-8px; right:-8px; width:18px; height:18px; border-radius:50%;
background:#fff; border:1.5px solid var(--primary,#7c3aed); color:var(--primary,#7c3aed);
font-size:11px; line-height:1; display:none; align-items:center; justify-content:center; cursor:pointer; z-index:3; }
/* rotate / scale controls, shown on hover */
.sozai-polaroid-wrap .pctl { position:absolute; width:18px; height:18px; border-radius:50%;
background:#fff; border:1.5px solid var(--primary,#7c3aed); color:var(--primary,#7c3aed);
font-size:12px; line-height:1; display:none; align-items:center; justify-content:center; cursor:pointer; z-index:3; }
.sozai-polaroid-wrap .prot-l { top:-8px; left:-8px; }
.sozai-polaroid-wrap .prot-r { top:-8px; left:14px; }
.sozai-polaroid-wrap .pbig { bottom:-8px; right:-8px; }
.sozai-polaroid-wrap .psmall{ bottom:-8px; right:14px; }
.sozai-polaroid-wrap { position:relative; }
.sozai-polaroid-wrap:hover .pdel,
.sozai-polaroid-wrap:hover .pctl { display:flex; }
.sozai-polaroid .sozai-pin-when { display:block; font-family:'Martian Mono',monospace; font-size:8px;
letter-spacing:.04em; text-transform:uppercase; color:var(--muted-foreground); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
/* active stop during a flythrough */
.sozai-polaroid-wrap.is-active { z-index:60; }
.sozai-polaroid-wrap.is-active .sozai-polaroid { transform: rotate(0deg) scale(calc(var(--scale,1) * 1.18));
box-shadow: 0 0 0 3px var(--primary,#7c3aed), 0 12px 28px rgba(0,0,0,.3); }
/* moving transport marker during the journey */
.sozai-transport { font-size:26px; line-height:1; filter: drop-shadow(0 3px 4px rgba(0,0,0,.35));
transform: rotate(var(--heading,0deg)); transition: transform .15s linear; will-change: transform; }
/* drop hint when dragging a photo from the tray over the map */
.sozai-map-dropzone.drop-armed::after { content:"Drop to pin here"; position:absolute; inset:0;
display:flex; align-items:center; justify-content:center; z-index:9;
background:rgba(124,58,237,.10); border:3px dashed var(--primary,#7c3aed); border-radius:.5rem;
font-family:'Martian Mono',monospace; font-size:12px; text-transform:uppercase; letter-spacing:.08em; color:var(--primary,#7c3aed); pointer-events:none; }
/* ---- print: turn the current album view into a clean keepsake -------- */
/* Cmd/Ctrl-P drops the interactive chrome, flattens to white, prints photos
in true colour, and keeps each photo+caption from splitting across pages.
Add class "no-print" to hide an element, "print-keep" to force-show a
button. */
@media print {
@page { margin: 12mm; }
*, *::before, *::after {
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
box-shadow: none !important;
text-shadow: none !important;
}
html, body { background: #fff !important; }
/* hide interactive / non-content chrome */
nav, video, audio,
[role="dialog"], [role="toolbar"], [role="tablist"],
.no-print { display: none !important; }
button:not(.print-keep) { display: none !important; }
/* keep photos, polaroids and cards whole across page breaks */
figure, img, .sozai-polaroid, .sozai-polaroid-wrap, .dv-piece, .sozai-pin {
break-inside: avoid; page-break-inside: avoid;
}
img { max-width: 100% !important; height: auto !important; }
/* let content use the full page width once chrome is gone */
.max-w-6xl, .max-w-5xl, .max-w-4xl { max-width: none !important; }
}
</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>
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.js" crossorigin></script>
<!-- exifr: lightweight EXIF parser for GPS + date metadata from uploaded photos -->
<script src="https://cdn.jsdelivr.net/npm/exifr/dist/lite.umd.js" crossorigin></script>
<!-- heic2any: decode iPhone HEIC/HEIF uploads to JPEG so they render in-browser -->
<script src="https://cdn.jsdelivr.net/npm/heic2any@0.0.4/dist/heic2any.min.js" crossorigin></script>
<!-- MapLibre GL JS: free, open-source map renderer (no API key). Powers the
watercolor map (real ArcGIS Watercolor.stylx palette on free OpenFreeMap
tiles) plus OSM/CARTO basemaps. Geocoding via OpenStreetMap Nominatim.
Loading strategy (most robust first):
1. The server (app.py) INLINES the cached MapLibre CSS+JS in place of the
MAPLIBRE_INLINE marker below - no extra request, no MIME/route issues.
2. If the page is opened standalone (marker not replaced), a multi-CDN
loader fetches MapLibre and sets window.maplibregl. -->
<!--MAPLIBRE_INLINE-->
<script>
(function () {
// If the server already inlined MapLibre, the global exists - skip CDNs.
if (window.maplibregl) { console.log("[maplibre] using inlined build"); return; }
// Otherwise (standalone file), pull CSS + JS from CDNs with fallbacks.
var css = document.createElement("link");
css.rel = "stylesheet";
var cssSources = [
"https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.css",
"https://cdn.jsdelivr.net/npm/maplibre-gl@4.7.1/dist/maplibre-gl.css",
"https://cdnjs.cloudflare.com/ajax/libs/maplibre-gl/4.7.1/maplibre-gl.css"
];
var ci = 0;
css.href = cssSources[ci];
css.onerror = function () { if (++ci < cssSources.length) css.href = cssSources[ci]; };
document.head.appendChild(css);
var jsSources = [
"/vendor/maplibre-gl.js",
"https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.js",
"https://cdn.jsdelivr.net/npm/maplibre-gl@4.7.1/dist/maplibre-gl.js",
"https://cdnjs.cloudflare.com/ajax/libs/maplibre-gl/4.7.1/maplibre-gl.js"
];
function loadJS(i) {
if (i >= jsSources.length) { console.error("[maplibre] all sources failed to load"); return; }
var s = document.createElement("script");
s.src = jsSources[i];
s.async = false;
s.onload = function () {
if (window.maplibregl) console.log("[maplibre] loaded from", jsSources[i]);
else { console.warn("[maplibre] loaded but no global from", jsSources[i]); loadJS(i + 1); }
};
s.onerror = function () { console.warn("[maplibre] failed to load", jsSources[i]); loadJS(i + 1); };
document.head.appendChild(s);
}
loadJS(0);
})();
</script>
<!-- ----------------------------------------------------------------- -->
<!-- Collaboration transport (plain JS): SSE (inbound) + POST (outbound) -->
<!-- over /api/rooms/{id}/stream and /send. No-ops cleanly in solo. -->
<!-- ----------------------------------------------------------------- -->
<script>
window.Collab = (function () {
// Transport: Server-Sent Events (inbound) + POST (outbound). HF Spaces'
// proxy drops custom WebSocket upgrades, so we use the same SSE+POST
// channel Gradio itself uses on Spaces. EventSource auto-reconnects on
// its own, so there's no manual reconnect loop here.
let es = null, me = null, participants = [];
const listeners = {};
function roomId() {
const m = location.pathname.match(/^\/room\/([^\/?#]+)/);
return m ? decodeURIComponent(m[1]) : null;
}
function sid() {
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 name() { return localStorage.getItem("sozai_name") || ""; }
function on(type, fn) {
(listeners[type] = listeners[type] || []).push(fn);
return () => { listeners[type] = (listeners[type] || []).filter((f) => f !== fn); };
}
function emit(type, d) { (listeners[type] || []).forEach((fn) => { try { fn(d); } catch (e) {} }); }
function send(type, payload) {
const id = roomId();
if (!id) return;
const event = Object.assign({ type }, payload || {});
try {
fetch(`/api/rooms/${encodeURIComponent(id)}/send`, {
method: "POST",
headers: { "Content-Type": "application/json" },
// keepalive lets the final send (e.g. on tab close) still go out
keepalive: type === "rename",
body: JSON.stringify({ session_id: sid(), event }),
}).catch(() => {});
} catch (e) {}
}
function connect() {
const id = roomId();
if (!id) { emit("status", { connected: false, solo: true }); return; }
try { if (es) es.close(); } catch (_) {}
const url = `/api/rooms/${encodeURIComponent(id)}/stream?session_id=${encodeURIComponent(sid())}&name=${encodeURIComponent(name() || "Guest")}`;
try { es = new EventSource(url); } catch (e) { return; }
es.onopen = () => emit("status", { connected: true, solo: false });
es.onmessage = (e) => {
let m; try { m = JSON.parse(e.data); } catch (_) { return; }
if (m.type === "init") { me = m.you; participants = m.participants || []; emit("me", me); emit("presence", participants); }
else if (m.type === "presence_update") { participants = m.participants || []; emit("presence", participants); }
emit(m.type, m); emit("any", m);
};
// EventSource fires onerror on transient drops and reconnects itself;
// we just surface the status. A closed (readyState 2) stream means the
// server ended it (e.g. join_declined) - don't fight it.
es.onerror = () => {
emit("status", { connected: false, solo: false });
};
}
function rename(n) { localStorage.setItem("sozai_name", n); if (me) me.name = n; send("rename", { name: n }); }
// Broadcast a brief "I just interacted with X" ping so collaborators can
// SEE what each other is touching (a label that floats near their cursor).
let _lastPt = { x: 0.5, y: 0.5 };
if (typeof window !== "undefined") {
window.addEventListener("pointermove", (e) => { _lastPt = { x: e.clientX / window.innerWidth, y: e.clientY / window.innerHeight }; }, { passive: true });
}
function ping(label) { send("interaction", { label: String(label).slice(0, 40), x: _lastPt.x, y: _lastPt.y }); }
return {
connect, send, on, rename, roomId, ping,
sessionId: sid,
getMe: () => me, getParticipants: () => participants, isCollab: () => !!roomId(),
};
})();
if (typeof window !== "undefined") window.sozaiPing = (label) => { try { Collab.ping(label); } catch (e) {} };
// ----------------------------------------------------------------- //
// Automatic "what are you doing" presence. So collaborators can SEE //
// each other press buttons, focus fields, and type - not just move the //
// cursor - we emit a throttled interaction ping on these DOM events //
// app-wide (capture phase, so it works regardless of React handlers). //
// Accessibility settings live in localStorage per-user and are never //
// broadcast, so each person keeps their own a11y prefs while everything //
// else stays in sync. //
// ----------------------------------------------------------------- //
if (typeof window !== "undefined") {
let _lastPing = 0;
const _throttledPing = (label) => {
const now = Date.now();
if (now - _lastPing < 350) return; // don't flood on rapid keystrokes
_lastPing = now;
try { Collab.ping(label); } catch (e) {}
};
const _labelFor = (el) => {
if (!el) return "";
return (
el.getAttribute("aria-label") ||
el.getAttribute("title") ||
el.getAttribute("placeholder") ||
(el.textContent || "").trim() ||
el.name || ""
).replace(/\s+/g, " ").trim().slice(0, 24);
};
// button / link presses
document.addEventListener("pointerdown", (e) => {
if (!Collab.isCollab()) return;
const btn = e.target && e.target.closest && e.target.closest("button, [role='button'], a");
if (!btn) return;
const lbl = _labelFor(btn);
_throttledPing(lbl ? "🔘 " + lbl : "🔘 clicked");
}, true);
// focusing an input / textarea / contenteditable
document.addEventListener("focusin", (e) => {
if (!Collab.isCollab()) return;
const f = e.target;
if (!f) return;
const tag = (f.tagName || "").toLowerCase();
const editable = f.isContentEditable;
if (tag !== "input" && tag !== "textarea" && tag !== "select" && !editable) return;
const lbl = _labelFor(f);
_throttledPing(lbl ? "✏️ " + lbl : "✏️ typing");
}, true);
// live typing into a field
document.addEventListener("input", (e) => {
if (!Collab.isCollab()) return;
const f = e.target;
if (!f) return;
const tag = (f.tagName || "").toLowerCase();
if (tag !== "input" && tag !== "textarea" && !f.isContentEditable) return;
const lbl = _labelFor(f);
_throttledPing(lbl ? "✏️ " + lbl : "✏️ typing");
}, true);
}
// ----------------------------------------------------------------- //
// Chime - in-browser notification sounds. //
// //
// The `chime` Python library plays .wav files via the OS (afplay/aplay //
// /winsound), which only makes sound on the machine running Python - no //
// use in a web app where each PARTICIPANT needs to hear it in their own //
// browser. So we keep chime's idea (named, themed cues) but synthesize //
// them with the Web Audio API. Themes are short note sequences; the //
// user picks one (and can mute) in Settings. //
// ----------------------------------------------------------------- //
window.SozaiChime = (function () {
let ctx = null;
function ac() {
if (!ctx) {
try { ctx = new (window.AudioContext || window.webkitAudioContext)(); }
catch (e) { ctx = null; }
}
// browsers suspend audio until a user gesture; resume opportunistically
if (ctx && ctx.state === "suspended") { try { ctx.resume(); } catch (e) {} }
return ctx;
}
// each theme maps an event -> array of [freqHz, startSec, durSec]
const THEMES = {
chime: { message: [[660,0,0.12],[880,0.10,0.16]], join: [[523,0,0.1],[784,0.09,0.18]], leave: [[784,0,0.1],[523,0.09,0.18]], alert: [[880,0,0.1],[880,0.16,0.12]] },
soft: { message: [[523,0,0.16]], join: [[587,0,0.14],[740,0.12,0.18]], leave: [[494,0,0.18]], alert: [[660,0,0.12],[660,0.18,0.12]] },
bright: { message: [[784,0,0.09],[1047,0.08,0.13]], join: [[659,0,0.08],[988,0.07,0.16]], leave: [[988,0,0.08],[659,0.07,0.16]], alert: [[1175,0,0.09],[1175,0.14,0.1]] },
retro: { message: [[330,0,0.08],[494,0.07,0.08],[659,0.14,0.12]], join: [[262,0,0.08],[392,0.07,0.08],[523,0.14,0.14]], leave: [[523,0,0.08],[392,0.07,0.08],[262,0.14,0.14]], alert: [[440,0,0.08],[440,0.12,0.08]] },
};
function playSeq(seq, vol) {
const a = ac();
if (!a || !seq) return;
const t0 = a.currentTime;
const g = a.createGain();
g.gain.value = vol == null ? 0.2 : vol;
g.connect(a.destination);
seq.forEach(([freq, start, dur]) => {
const osc = a.createOscillator();
const eg = a.createGain();
osc.type = "sine";
osc.frequency.value = freq;
// little attack/decay so it sounds like a chime, not a beep
eg.gain.setValueAtTime(0.0001, t0 + start);
eg.gain.exponentialRampToValueAtTime(1.0, t0 + start + 0.01);
eg.gain.exponentialRampToValueAtTime(0.0001, t0 + start + dur);
osc.connect(eg); eg.connect(g);
osc.start(t0 + start);
osc.stop(t0 + start + dur + 0.02);
});
}
return {
themes: () => Object.keys(THEMES),
play(event, opts) {
const o = opts || {};
if (o.enabled === false) return;
const theme = THEMES[o.theme] || THEMES.chime;
playSeq(theme[event] || theme.message, o.volume);
},
// call once from a user gesture to unlock audio on iOS/Safari
unlock() { const a = ac(); if (a && a.state === "suspended") { try { a.resume(); } catch (e) {} } },
};
})();
// unlock audio on the first user interaction (browsers require a gesture)
if (typeof window !== "undefined") {
const _unlock = () => { try { window.SozaiChime.unlock(); } catch (e) {} window.removeEventListener("pointerdown", _unlock); window.removeEventListener("keydown", _unlock); };
window.addEventListener("pointerdown", _unlock); window.addEventListener("keydown", _unlock);
}
</script>
</head>
<body class="font-sans antialiased bg-background">
<div id="root"></div>
<!-- gradio.Server backend via the official JS client (used by save_to_album) -->
<script type="module">
window.__gradioReady = (async () => {
try {
const { client } = await import("https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js");
return await client(location.origin);
} catch (e) { return null; }
})();
</script>
<script type="text/babel" data-presets="react">
const { useState, useEffect, useRef, useCallback, useMemo } = React;
const Collab = window.Collab;
// ----------------------------------------------------------------- //
// Easter egg: "Neko" - the classic desktop cat that chases the cursor.
// Sprite frames (32x32 GIFs) bundled from the historical neko archive,
// embedded as data URIs so no extra asset requests are needed.
// ----------------------------------------------------------------- //
const NEKO_FRAMES = {1:"data:image/gif;base64,R0lGODlhIAAgAJEAAP///wAA/QAAAAAAACH5BAEAAAEALAAAAAAgACAAQQJ2jI8oK+mvgJyUNpgE0LVLfWEOw5GiyG3WFJ4GyVhwi3r25mJl7qYVzfPdcLxXTMYoloafT07IBOqYrOJjZ81qDzDkQrtosj7SU8p3znZ/Jut5XFVDZUrSMFaOqKJOM7WTp/dH1DPYFzTHEvh0uDXS6JixGFlUAAAh/wtNQUNHQ29uIAQDEDEAAAABV3JpdHRlbiBieSBHSUZDb252ZXJ0ZXIgMi4zLjcgb2YgSmFuIDI5LCAxOTk0ADs=",2:"data:image/gif;base64,R0lGODlhIAAgAJEAAP///wAA/QAAIAAAACH5BAEAAAEALAAAAAAgACAAQQKcjI8oM5IPjQAD2ItxdTGtzIBcp2SAgE7ZSKqV9cLXy3YqFc75SZagvOr5agcVMTJhBGKg5UaYNEkpPChuB2OEjsipiQu5zYAYsMf0YlrM5wtLzBbKe6l6uY6Su9Ka7NpKRRYYGJekpXa1BRjD2FdFJ4j2p+clFVeEM6U4FyAWqHYpITnoNuf5+WVamapaJurKeUMkyylhllebC1EAACH/C01BQ0dDb24gBAMQMQAAAAFXcml0dGVuIGJ5IEdJRkNvbnZlcnRlciAyLjMuNyBvZiBKYW4gMjksIDE5OTQAOw==",3:"data:image/gif;base64,R0lGODlhIAAgAJEAAP///wAA/QAAAAAAACH5BAEAAAEALAAAAAAgACAAQQJ8jI8nm+2/Ipi0TtGE3VdT5nhM5HXkg0JVyWKgwsVfusiWmOb6jrb1zEOUAMMVzSbDhJAxpYrZudBE0Zvm1XMmSMag9/skicGGIfX0Na8+Wl2xmZOsk0foqr21d596IJ/JEvfW5Pdn84OXRyRmdeUmFSBWFEEWMlaJmalTAAAh/wtNQUNHQ29uIAQDEDEAAAABV3JpdHRlbiBieSBHSUZDb252ZXJ0ZXIgMi4zLjcgb2YgSmFuIDI5LCAxOTk0ADs=",4:"data:image/gif;base64,R0lGODlhIAAgAJEAAP///wAA/QAAAAAAACH5BAEAAAEALAAAAAAgACAAQQKJjI+pyywAH5wxiCvSjbTT7Eyg5YnNiWBbhwHjghkSV37vzNavs5MuCgyqVEEF7tEyXYrIWmS2TNGcnp5MgnNaNVXqVvo7QInFULiMTqc3qwo627a9P6KPGlpSuRtYXTsKFsfRxPLVRyXyJeMypYOShXh2Asno+GgjaAeEBVbFJOmjeLenxlNqUAAAIf8LTUFDR0NvbiAEAxAxAAAAAVdyaXR0ZW4gYnkgR0lGQ29udmVydGVyIDIuMy43IG9mIEphbiAyOSwgMTk5NAA7",5:"data:image/gif;base64,R0lGODlhIAAgAJEAAP///wAA/QAAAAAAACH5BAEAAAEALAAAAAAgACAAQQKGjI+pyyYAo5RCiRoefnPyDnBYcjnhhaLN6nwlS0YXGFLduMxn+dEQDgsKh8IeCMgy+mSt30Fkoik9jWnt99NlLaisUZVCMl4ZMvGMThNVaoSWIg5aKesl1R2KXW83Lc6VIWU3AhXoMwdlhgjn4pTTZziYUxgp8zXWtecVB8NWZtYWKjoqVAAAIf8LTUFDR0NvbiAEAxAxAAAAAVdyaXR0ZW4gYnkgR0lGQ29udmVydGVyIDIuMy43IG9mIEphbiAyOSwgMTk5NAA7",6:"data:image/gif;base64,R0lGODlhIAAgAJEAAP///wAA/AAAAAAAACH5BAEAAAEALAAAAAAgACAAQQJ/jI+puwIvGBLuAdqs3rzHg0kNRUFf9YlSqFQdFLgbasim/V6pyvf+D7LsejSGjZQozWCTXI4kS+GETlYTioxhsRor8AsOi1te8BA19DnSMeHPxa4J421PxiP1MINOXf/S9CeYJ/gCBTLlV8WGtjWlZJJ0yLM1NzZxUna5yclZAAAh/wtNQUNHQ29uIAQDEDEAAAABV3JpdHRlbiBieSBHSUZDb252ZXJ0ZXIgMi4zLjcgb2YgSmFuIDI5LCAxOTk0ADs=",7:"data:image/gif;base64,R0lGODlhIAAgAJEAAP///wAA/QAAAAAAACH5BAEAAAEALAAAAAAgACAAQQKFjI+pyyECopzyvXMZpBt0X0VCY2zPZFlkSVXpqrVgKHtkh9cczPdY5ltZdKPgZ9YyOUSNI9KJanJ0smJMhPSkXrwT12ENisdkxVAELG9PuzGbGoZBq5Bi/YpKuir4PJYoNfU3qLRwhHOWoxW35Ec1w4hgQuPkJYf1sRYZaLKmllYWKupTAAAh/wtNQUNHQ29uIAQDEDEAAAABV3JpdHRlbiBieSBHSUZDb252ZXJ0ZXIgMi4zLjcgb2YgSmFuIDI5LCAxOTk0ADs=",8:"data:image/gif;base64,R0lGODlhIAAgAJEAAP///wAA/QAAAAAAACH5BAEAAAEALAAAAAAgACAAQQKFjI+pyyEAoZhStmtorLsDgS2PNI0fiJRT5pWhYnqdtqqv03LfecNW5uoJU5rPYYQc4mQt0OoVa0ZUUyiTibohrz+lCaXKKsdkYfipnQG/3TRpyxtGr+LQnB4EirikaamdcHcys4HWECNY6PY21ehktUNYUWfXR2jYg3hW9nWUN/aJWdZQAAAh/wtNQUNHQ29uIAQDEDEAAAABV3JpdHRlbiBieSBHSUZDb252ZXJ0ZXIgMi4zLjcgb2YgSmFuIDI5LCAxOTk0ADs=",9:"data:image/gif;base64,R0lGODlhIAAgAJEAAP///wAA/QAAAAAAACH5BAEAAAEALAAAAAAgACAAQQJ7jI8pku0tgJxUsgdtXZH2ixlcV1lciH5oSEak1qbm9AKtuuY6a+0sZ1r4RKUiLdeiKTU9zIJpXAofwKRHeRqOQMOul2j7UptiBVlc23TTRq5sufGsXh+pnAe16eHuzD7K1KfgAogleODylPdX9VbzeDY3QjNSZnmJiVIAACH/C01BQ0dDb24gBAMQMQAAAAFXcml0dGVuIGJ5IEdJRkNvbnZlcnRlciAyLjMuNyBvZiBKYW4gMjksIDE5OTQAOw==",10:"data:image/gif;base64,R0lGODlhIAAgAJEAAP///wAA/AAAAAAAACH5BAEAAAEALAAAAAAgACAAQQKOjI8ip7kIYxCg2oufjCr322wSRWEWKY5guZSVlhos4L70GztndsL5hEsEfwwcSzb81UwZIpLZdJJ2Nptv1FpCG6GYa/l1Pl+KT1J6naRzpFawsxYdj0CauPYxn1NaqJ3Yt3VXxfMHWPWFKHWzg2fIFugBqOdXNkmF2aUEJjlIY1a2x3ZGJwakqfZoumpQAAAh/wtNQUNHQ29uIAQDEDEAAAABV3JpdHRlbiBieSBHSUZDb252ZXJ0ZXIgMi4zLjcgb2YgSmFuIDI5LCAxOTk0ADs=",11:"data:image/gif;base64,R0lGODlhIAAgAJEAAP///wAA/QAAAAAAACH5BAEAAAEALAAAAAAgACAAQQKDjI+puyLnHJi0UtemuHVrCzxMEkWdJ46qUXpgirhvB4ZZbbn6yve+GvmRcCHMCBVA4WS225KTYzCJnw6wZeLshMlUqcgNi8fhbBHmm2LRq+mLDWluJNSL9KKbRZ21/OvmVxXyR0Ln9yWocTdYx2h3R0fjiNSzRkMpZMkIRxZE9gnKUAAAIf8LTUFDR0NvbiAEAxAxAAAAAVdyaXR0ZW4gYnkgR0lGQ29udmVydGVyIDIuMy43IG9mIEphbiAyOSwgMTk5NAA7",12:"data:image/gif;base64,R0lGODlhIAAgAJEAAP///wAA/QAAAAAAACH5BAEAAAEALAAAAAAgACAAQQKHjI+pyywAXhQSxlWtjnu2Q1lUCAnf6ZCaGYwUOLaS2m0sElZzd6MuWEIJh8TP7mLQvVKyWq2XFI2kzqCj+hxmnlbtryUqisfkMsa1LB8nrC20QZqp3kbsSpGW0exv+XybhZfhBtiFYccRZgSmIzWolRgpYniSwzMzhkZD5xMTRVmU52lGulAAACH/C01BQ0dDb24gBAMQMQAAAAFXcml0dGVuIGJ5IEdJRkNvbnZlcnRlciAyLjMuNyBvZiBKYW4gMjksIDE5OTQAOw==",13:"data:image/gif;base64,R0lGODlhIAAgAJEAAP///wAA/QAAIAAAACH5BAEAAAEALAAAAAAgACAAQQKPjI+pqyINopzzQTDozLoagTGHMwzOCViOyH5gtLbBu9WdI1lzp2VlGTHJhsSicUjj6S6xJI+y/DyDTxVslvz1lKzTikTRYkxR4mlUPqrXbNm5jb6l3dMrAxR1Tuc0VJjHUab3d7Nj98LRkVho+IAoIUbFhTa4BQQl4oRDiBE0lylwCQnzZkSiUwqnusoqUgAAIf8LTUFDR0NvbiAEAxAxAAAAAVdyaXR0ZW4gYnkgR0lGQ29udmVydGVyIDIuMy43IG9mIEphbiAyOSwgMTk5NAA7",14:"data:image/gif;base64,R0lGODlhIAAgAJEAAP///wAA/QAAIAAAACH5BAEAAAEALAAAAAAgACAAQQKIjI+pyyIAgWPoQZFs3LxzXFnOSDVgdpYNNBjW4EmBFqdo3A16rvb+D6xcgrPI6dWZhGgyYUS3w9VKAmgMNr09dSPSoVuFtYjksvmcQc+U3yZRY4MD5c4kxWFUIO0uZr6OgyflVjRoOIQieOhhU4GFE5UURxNp1AUVNba21eUD9qXEpjZKWkpUAAAh/wtNQUNHQ29uIAQDEDEAAAABV3JpdHRlbiBieSBHSUZDb252ZXJ0ZXIgMi4zLjcgb2YgSmFuIDI5LCAxOTk0ADs=",15:"data:image/gif;base64,R0lGODlhIAAgAJEAAP///wAA/QAAIAAAACH5BAEAAAEALAAAAAAgACAAQQKNjI8ZMqKPBJi0ziYy3EvSMVji5HAHA4JV6mRk130yVZqQN+ZAbXqhvuLZhr1VikY85X6vpAEHFAk30GhlqvAxrdisRGUB/zQ2aIbsdaof57Zrt86oftvuDbetq6s5OxW4tYPG8WZ1NcTHVWY44rcwY+iIx9iwCACmiHiJeQFy1tTDKdj2BErltpaquloAACH/C01BQ0dDb24gBAMQMQAAAAFXcml0dGVuIGJ5IEdJRkNvbnZlcnRlciAyLjMuNyBvZiBKYW4gMjksIDE5OTQAOw==",16:"data:image/gif;base64,R0lGODlhIAAgAJEAAP///wAA/QAAIAAAACH5BAEAAAEALAAAAAAgACAAQQKSjI+pm+ISBJh0DgqZPrJ6egHOtnSfyIGKNLTfM5KReVpV9sgIXYWvzuhkdsMGLAckRnouH4rkADk/l6KOV+tNrBvsKRSTeUG/5BGzsyQb27UbSHOF3VHqlNvV2tDQrWO69yRm0gK4h1eCdbH4IgQ1RtUGl7VXtQZZg8hWY6i5eQJzMwmaBjDwZuCYdoqqihqkWQAAIf8LTUFDR0NvbiAEAxAxAAAAAVdyaXR0ZW4gYnkgR0lGQ29udmVydGVyIDIuMy43IG9mIEphbiAyOSwgMTk5NAA7",17:"data:image/gif;base64,R0lGODlhIAAgAJEAAP///wAA/QAAIAAAACH5BAEAAAEALAAAAAAgACAAQQKWjH8yAJKwHJrUCIazlrVeDTJcZwlfY37pmZLIt4TD6L7QEGN4VJfBGQI5TL2OquhhRFYQFK2m0GVyTxcwiKlad6Cd1ogNfX/THBaXGSdFyLYb3ORceO85V4dTJ2PmGNEdpTSVBahwV5anl4DWJKik+HIlBvkQ1gBouYEkGTZ2kWIWNnNEwZlJCPVHdllCCcf6ZuUaW1MAACH/C01BQ0dDb24gBAMQMQAAAAFXcml0dGVuIGJ5IEdJRkNvbnZlcnRlciAyLjMuNyBvZiBKYW4gMjksIDE5OTQAOw==",18:"data:image/gif;base64,R0lGODlhIAAgAJEAAP///wAA/QAAIAAAACH5BAEAAAEALAAAAAAgACAAQQKXjH8CA5KwHJrUKIhzlrVeDTbdJJRYaTboWY7kEsKi+w62ZjOc236x5mjREJfd0GPSoRSM2dHyaUKkAyPP93M+BTlQzsrLgsAPzE18hpA7n/XzDQ8wnXPtNiddfN2eZt5vR8NllkHFR8TVhbP3ljhFOHVIghUi1CgWGIZ5srXJOaKAkpdVVeThGXOIUsaxGmfx+comKRtXAAAh/wtNQUNHQ29uIAQDEDEAAAABV3JpdHRlbiBieSBHSUZDb252ZXJ0ZXIgMi4zLjcgb2YgSmFuIDI5LCAxOTk0ADs=",19:"data:image/gif;base64,R0lGODlhIAAgAKIAAP///wAA/QAAPAAAIAAAAAAAAAAAAAAAACH5BAEAAAEALAAAAAAgACAAQgOOGLrcvmO8OQe4+BJNiLNZqIXSFzERiG3cSb1PREqRR8linrmBqoeEEmxILA5BQmPDl+vYjMwVC/BcNqdAnvWXYWlRGByQ1UkqFam0+sxuvyxmNzqMih9/tSoxOqJ2znxUflRHaYJdWIQVXDondoF9O19bjFh2EJBZk2CMXZsLAmE0V04pboFll3KrrK0OCQAh/wtNQUNHQ29uIAQDEDEAAAABV3JpdHRlbiBieSBHSUZDb252ZXJ0ZXIgMi4zLjcgb2YgSmFuIDI5LCAxOTk0ADs=",20:"data:image/gif;base64,R0lGODlhIAAgAJEAAP///wAA/QAAIAAAACH5BAEAAAEALAAAAAAgACAAQQKNjI+pJ7L/BJh0jumaY7XT61HBpmgdCAxDA7WRRJGGXMLhfQ3jzae0CwwKIbDfcNbbGFs2C8oZCalwqCkg06uANFwZVwpSLYXd8viITiMkZ7StHWzGSPCE/IMZuXFzYDflYZVDlOU0FbZwp8Wj0njFUfjUKLaDoSilseZVqMVypDi54pl2KVanhpqqClEAACH/C01BQ0dDb24gBAMQMQAAAAFXcml0dGVuIGJ5IEdJRkNvbnZlcnRlciAyLjMuNyBvZiBKYW4gMjksIDE5OTQAOw==",21:"data:image/gif;base64,R0lGODlhIAAgAJEAAP///wAA/QAAIAAAACH5BAEAAAEALAAAAAAgACAAQQKQjI+pyykAo4SiHYHzk6NPnVnBM0wSJoobUKXMWpYcILOuAU91e49sxQPaeg4KUZWhhI4rk5P32sQitSpNmWo6jUSBrAbZMY0gH7T3AF58RwWq3cBEzvDisI7bcrv6D3pmAkbnlnMVdoi1oGV4CAa299I3NyZ5d1NINdn1+DioorSS5GmxoYZjBpc2sqaKB1cAACH/C01BQ0dDb24gBAMQMQAAAAFXcml0dGVuIGJ5IEdJRkNvbnZlcnRlciAyLjMuNyBvZiBKYW4gMjksIDE5OTQAOw==",22:"data:image/gif;base64,R0lGODlhIAAgAJEAAP///wAA/QAAIAAAACH5BAEAAAEALAAAAAAgACAAQQKQjI+pyykAo4SiGYHzk6NPnVnXNkEDUIkimapOdJ4cIKPuBU91ewdP1QL+epYNj3jQUEJIUul5dGxiOVNOhlE5n5RmtTqIam0gXxd58SXNYvTI3WDBFcZ5knsmbvFt+qxUY7NCIlNTGJG1sBdI9YW4gifRx7AH5WX1J6inozNJiYKBGKrZMxRkRnpjujZk51YAACH/C01BQ0dDb24gBAMQMQAAAAFXcml0dGVuIGJ5IEdJRkNvbnZlcnRlciAyLjMuNyBvZiBKYW4gMjksIDE5OTQAOw==",23:"data:image/gif;base64,R0lGODlhIAAgAJEAAP///wAA/QAAAAAAACH5BAEAAAEALAAAAAAgACAAQQJ3jC9yy90pAIiySuWyo5Xbj2kI9FnQcqbeFIrPWsaJqyXxPdH6blAtP4KBgAGOTUYM3o7D3VGI5JFYMqMIgs0mt7TILzlDdLjFaRQ4hX5dXu+SuGJeWCd2GYdbv2DQi87TZ+JkhscyGFiiF5SFxKSH9UYmOUkJVAAAIf8LTUFDR0NvbiAEAxAxAAAAAVdyaXR0ZW4gYnkgR0lGQ29udmVydGVyIDIuMy43IG9mIEphbiAyOSwgMTk5NAA7",24:"data:image/gif;base64,R0lGODlhIAAgAJEAAP///wAA/QAAAAAAACH5BAEAAAEALAAAAAAgACAAQQKAjC9yy90qAIiySuVyoLbzbmFGkiVJ9U2kI45hqz0TSL9xfNY6fPc3xfMhXDshIpVDGYmqWjKkMUmBOs/m8ss9Uabe9BtciqOT8U9p3hxpYdx1xhYnU2hjBEi/S70X1dYZZbJTFfYVUlV3N9IihchVx/JHmGUI0vYgGJe2ydmZVgAAIf8LTUFDR0NvbiAEAxAxAAAAAVdyaXR0ZW4gYnkgR0lGQ29udmVydGVyIDIuMy43IG9mIEphbiAyOSwgMTk5NAA7",25:"data:image/gif;base64,R0lGODlhIAAgAJEAAP///wAA/QAAAAAAACH5BAEAAAEALAAAAAAgACAAQQJ4jI+pyykAoZhRtvtqFVdl+YUfwHXIUx6ouYRayLaaFQfTjU92zuv134EZhD/XJpKKoSgk0Ap4QkJVnNFrx7ohtxIkbwoOA5MqcSZ1Bo+qG6h1RqsZuaQxfEbO3qVqJslndhV4lxcEcuQV12A1x4jB6NS1QSRW+VMAACH/C01BQ0dDb24gBAMQMQAAAAFXcml0dGVuIGJ5IEdJRkNvbnZlcnRlciAyLjMuNyBvZiBKYW4gMjksIDE5OTQAOw==",26:"data:image/gif;base64,R0lGODlhIAAgAJEAAP///wAA/QAAAAAAACH5BAEAAAEALAAAAAAgACAAQQJ9jI+pyykAoZhRtvtkttdVKkUg1x2P4KGlMmWuOK3L9slGi+NBzrf2X9qodiIgUQQ41X6019J4K0J3KFolSV21kKFukjcNi2VDRNnoMpGAzeOa/LVuz8HuKM38eN9abnu6pUGHdjWYdyVl49L0gYdRyJVUiCEnuPeVOKYpUwAAIf8LTUFDR0NvbiAEAxAxAAAAAVdyaXR0ZW4gYnkgR0lGQ29udmVydGVyIDIuMy43IG9mIEphbiAyOSwgMTk5NAA7",27:"data:image/gif;base64,R0lGODlhIAAgAJEAAP///wAA/QAAAAAAACH5BAEAAAEALAAAAAAgACAAQQKCjI+pyykAoxCtzhnBy5jXsEGdJoXad0woamYk91BrerWiezL1rvK+PAt+QiqhgXjBlXIsl01DZCWfshaw0VPePNgRUSLyGcfksnkIPSus41pKSymitTjMFau02e86Z3gpMUd3MiJiYWWSxLXytBVo8QcmGQbJMZk4ZBW2UZOn9mlUAAAh/wtNQUNHQ29uIAQDEDEAAAABV3JpdHRlbiBieSBHSUZDb252ZXJ0ZXIgMi4zLjcgb2YgSmFuIDI5LCAxOTk0ADs=",28:"data:image/gif;base64,R0lGODlhIAAgAJEAAP///wAA/QAAAAAAACH5BAEAAAEALAAAAAAgACAAQQJ9jI+pywIgjnsvshuEnhTODWIG51Gc1llioq5NB8OtO4LVGc8IyGtZD6QJL7rPcJcipY7Km2xoO/FikJWvaXoSUZVs19M7isfkMs2hE6JZpvFplGNSqWkM9luB5uL6HInbZqe0MRfoMogyKPLxl6L14oinuCVzwwi4ZKYpVgAAIf8LTUFDR0NvbiAEAxAxAAAAAVdyaXR0ZW4gYnkgR0lGQ29udmVydGVyIDIuMy43IG9mIEphbiAyOSwgMTk5NAA7",29:"data:image/gif;base64,R0lGODlhIAAgAJEAAP///wAA/QAAAAAAACH5BAEAAAEALAAAAAAgACAAQQJ3jI+py+3/BACi1klFxFh2T3UZRJaIxqCBCoEgB09VZF1wLcusyff+svtFVjiKyfbK3IypZWwpCRqSuVj0yhRqt9yu96uozcDjk8Un3qmi2qhI1rNw3CJpwvV8loHzvHV/YpVB5cHG59TXZximl4ZUWOKI4gjGUAAAIf8LTUFDR0NvbiAEAxAxAAAAAVdyaXR0ZW4gYnkgR0lGQ29udmVydGVyIDIuMy43IG9mIEphbiAyOSwgMTk5NAA7",30:"data:image/gif;base64,R0lGODlhIAAgAJEAAP///wAA/QAAAAAAACH5BAEAAAEALAAAAAAgACAAQQJ8jI+pyybtoEQCAIGxvVjWbX3gGHLTM6VqtAaiSJYmlGVkLXftzqA8X/P9DkJXaKeBcW6XhvKDy12aC6UslqwUh9wuMbjlPsLdcSKIBJ+97J9WTHVsyDSYdvT2zGNYunHPNxWio2D1BAIVd8bE8bKXV9gHBgWlMoky2QZRAAAh/wtNQUNHQ29uIAQDEDEAAAABV3JpdHRlbiBieSBHSUZDb252ZXJ0ZXIgMi4zLjcgb2YgSmFuIDI5LCAxOTk0ADs=",31:"data:image/gif;base64,R0lGODlhIAAgAJEAAP///wAA/QAAAAAAACH5BAEAAAEALAAAAAAgACAAQQJ5jI+pyyEAoXixioZplfFipAFTt1mfonnnEoakuDKuuU72PTk4Hht5z/r5NsLezEKLHTs2oOwFA+ZmlEuxYYM+XFmJ8wsOH7YqMSgplUypzqWIaCzJt2ktvN59e8E4ZlOckhK2dHVS1RK4l0EEVXITxCgyMvdnZglWAAAh/wtNQUNHQ29uIAQDEDEAAAABV3JpdHRlbiBieSBHSUZDb252ZXJ0ZXIgMi4zLjcgb2YgSmFuIDI5LCAxOTk0ADs=",32:"data:image/gif;base64,R0lGODlhIAAgAJEAAP///wAA/QAAAAAAACH5BAEAAAEALAAAAAAgACAAQQKOjI+pyyFiBJjzSQrga+tiCnGJl3kmGXKplEZZJI7gZ8YMCTqVaPW94/M1WrYDEdjJuXajI8JEUxZ1lkrl8Zoqas8sT/PBsJxbrPWcAW207Lb7OerAbaHLivmV5VBPcjccRvbzlyYmNXgDGNiGs/hWddXip0JjhEenWDj5Z4ZWcploiIYz2XkGSbr5tqpVAAAh/wtNQUNHQ29uIAQDEDEAAAABV3JpdHRlbiBieSBHSUZDb252ZXJ0ZXIgMi4zLjcgb2YgSmFuIDI5LCAxOTk0ADs="};
// ----------------------------------------------------------------- //
// Pet: an oneko-style companion that chases the cursor. Sprites are the
// 2023 icon library (76 animals) served from /sprites/<animal>/<name>.png.
// Each animal shares the same sprite vocabulary (still, sleep, wash, yawn,
// and 8 directional 2-frame runs). If sprite images can't load, it falls
// back to the classic embedded cat (NEKO_FRAMES).
// ----------------------------------------------------------------- //
const PET_SPRITE = "/sprites/";
// direction (octant) -> the two run-frame sprite basenames
const PET_DIR_FRAMES = {
e: ["erun1", "erun2"], ne: ["nerun1", "nerun2"], n: ["nrun1", "nrun2"],
nw: ["nwrun1", "nwrun2"], w: ["wrun1", "wrun2"], sw: ["swrun1", "swrun2"],
s: ["srun1", "srun2"], se: ["serun1", "serun2"],
};
const PET_IDLE = { still: "still", wash: "wash", yawn: "yawn", sleep1: "sleep1", sleep2: "sleep2", alert: "alert", itch1: "itch1", itch2: "itch2" };
function petUrl(animal, name) { return PET_SPRITE + encodeURIComponent(animal) + "/" + name; }
// classic-cat fallback frame numbers, keyed by the sprite basename
const PET_CLASSIC = {
still: 25, wash: 31, yawn: 26, sleep1: 29, sleep2: 30, alert: 32, itch1: 27, itch2: 28,
erun1: 5, erun2: 6, nerun1: 3, nerun2: 4, nrun1: 1, nrun2: 2, nwrun1: 15, nwrun2: 16,
wrun1: 13, wrun2: 14, swrun1: 11, swrun2: 12, srun1: 9, srun2: 10, serun1: 7, serun2: 8,
};
function octant(theta) {
const pi = Math.PI;
if (theta >= -pi / 8 && theta <= pi / 8) return "e";
if (theta > pi / 8 && theta < 3 * pi / 8) return "ne";
if (theta >= 3 * pi / 8 && theta <= 5 * pi / 8) return "n";
if (theta > 5 * pi / 8 && theta < 7 * pi / 8) return "nw";
if (theta >= 7 * pi / 8 || theta <= -7 * pi / 8) return "w";
if (theta > -7 * pi / 8 && theta < -5 * pi / 8) return "sw";
if (theta >= -5 * pi / 8 && theta <= -3 * pi / 8) return "s";
return "se";
}
function Pet({ enabled, animal }) {
const ref = useRef(null);
const usingFallbackRef = useRef(false);
const state = useRef({ x: 200, y: 200, mx: 200, my: 200, sprite: "still", run: 0, idle: 0, phase: 0 });
// Preload this animal's sprites; if the first one errors, use the
// embedded classic cat instead so the pet always shows up.
useEffect(() => {
if (!enabled) return;
usingFallbackRef.current = false;
const probe = new Image();
probe.onerror = () => { usingFallbackRef.current = true; };
probe.src = petUrl(animal, "still.png");
// warm the cache for the common frames
["nrun1", "srun1", "erun1", "wrun1", "sleep1", "wash", "yawn"].forEach((n) => {
const im = new Image(); im.src = petUrl(animal, n + ".png");
});
}, [enabled, animal]);
useEffect(() => {
if (!enabled) return;
const s = state.current;
s.x = window.innerWidth / 2; s.y = window.innerHeight / 2; s.mx = s.x; s.my = s.y;
const onMove = (e) => { s.mx = e.clientX; s.my = e.clientY; };
window.addEventListener("mousemove", onMove);
const SPEED = 13;
let raf = null, last = 0;
function setSprite(name) {
s.sprite = name;
const el = ref.current;
if (!el) return;
const img = el.firstChild;
if (!img) return;
if (usingFallbackRef.current) {
img.src = NEKO_FRAMES[PET_CLASSIC[name] || 25];
} else {
img.src = petUrl(animal, name + ".png");
}
}
function step(ts) {
raf = requestAnimationFrame(step);
if (ts - last < 130) return;
last = ts;
const dx = s.mx - s.x, dy = s.my - s.y;
const dist = Math.hypot(dx, dy);
if (dist > 18) {
s.idle = 0;
const theta = Math.atan2(-dy, dx);
const move = Math.min(SPEED, dist);
s.x += (dx / dist) * move; s.y += (dy / dist) * move;
const frames = PET_DIR_FRAMES[octant(theta)];
s.run = s.run ? 0 : 1;
setSprite(frames[s.run]);
} else {
s.idle++;
// sit briefly, wash, yawn, then drift to sleep (alternating frames)
if (s.idle < 4) setSprite("still");
else if (s.idle < 8) setSprite(s.idle % 2 ? "wash" : "still");
else if (s.idle < 12) setSprite("yawn");
else setSprite(s.idle % 2 ? "sleep1" : "sleep2");
}
const el = ref.current;
if (el) el.style.transform = `translate(${s.x - 16}px, ${s.y - 16}px)`;
}
// initialize the first frame
setSprite("still");
raf = requestAnimationFrame(step);
return () => { window.removeEventListener("mousemove", onMove); if (raf) cancelAnimationFrame(raf); };
}, [enabled, animal]);
if (!enabled) return null;
return (
<div ref={ref} aria-hidden="true"
className="pointer-events-none fixed left-0 top-0 z-[60]"
style={{ width: 32, height: 32, imageRendering: "pixelated", willChange: "transform" }}>
<img alt="" src={petUrl(animal, "still.png")} width="32" height="32"
style={{ imageRendering: "pixelated" }}
onError={(e) => { usingFallbackRef.current = true; e.currentTarget.src = NEKO_FRAMES[25]; }} />
</div>
);
}
// ----------------------------------------------------------------- //
// Settings: accessibility options + easter-egg toggle. Lifted to Root,
// persisted to localStorage, applied as classes/vars on <html>.
// ----------------------------------------------------------------- //
const DEFAULT_SETTINGS = {
largeText: false,
highContrast: false,
reduceMotion: false,
dyslexiaFont: false,
underlineLinks: false,
darkMode: false,
petEnabled: false,
petAnimal: "tabby",
captionPrompt: "",
titlePrompt: "",
tagsPrompt: "",
asrLanguage: "auto",
asrProfile: "Most accurate - 1.12 s",
chatSound: true,
chatSoundTheme: "chime",
chatSoundVolume: 0.6,
chatPopup: true,
};
const SettingsContext = React.createContext({ settings: DEFAULT_SETTINGS, update: () => {} });
function loadSettings() {
try {
const raw = localStorage.getItem("sozai_settings");
if (raw) return Object.assign({}, DEFAULT_SETTINGS, JSON.parse(raw));
} catch (e) {}
return DEFAULT_SETTINGS;
}
function useSettings() { return React.useContext(SettingsContext); }
// A compact dark-mode toggle usable anywhere (shown in the top bar so it is
// visible to everyone without opening Settings).
function DarkModeToggle({ compact }) {
const { settings, update } = useSettings();
const on = !!settings.darkMode;
return (
<button type="button" onClick={() => update({ darkMode: !on })}
aria-pressed={on} aria-label={on ? "Switch to light mode" : "Switch to dark mode"}
title={on ? "Light mode" : "Dark mode"}
className={cn(
"flex items-center gap-1.5 rounded-md border-2 border-primary/60 text-primary transition-colors hover:bg-accent",
compact ? "size-9 justify-center" : "px-2.5 py-1.5 font-mono text-xs uppercase tracking-wide"
)}>
{on ? <Sun className="size-4" /> : <Moon className="size-4" />}
{compact ? null : (on ? "Light" : "Dark")}
</button>
);
}
function applySettings(s) {
const root = document.documentElement;
root.classList.toggle("a11y-large-text", !!s.largeText);
root.classList.toggle("a11y-high-contrast", !!s.highContrast);
root.classList.toggle("a11y-reduce-motion", !!s.reduceMotion);
root.classList.toggle("a11y-dyslexia", !!s.dyslexiaFont);
root.classList.toggle("a11y-underline-links", !!s.underlineLinks);
root.classList.toggle("dark", !!s.darkMode);
try { window.__sozaiReduceMotion = !!s.reduceMotion; } catch (e) {}
// mirror sound prefs so non-React code (e.g. the upload overlay) can play
// notification sounds that respect the user's settings
try {
window.__sozaiSound = {
enabled: s.chatSound !== false,
theme: s.chatSoundTheme || "chime",
volume: typeof s.chatSoundVolume === "number" ? s.chatSoundVolume : 0.6,
};
} catch (e) {}
}
// ----------------------------------------------------------------- //
// helpers (ports of lib/utils.ts, next/image, lucide-react)
// ----------------------------------------------------------------- //
function cn(...inputs) {
const out = [];
const walk = (v) => {
if (!v) return;
if (typeof v === "string" || typeof v === "number") out.push(v);
else if (Array.isArray(v)) v.forEach(walk);
else if (typeof v === "object") { for (const k in v) if (v[k]) out.push(k); }
};
inputs.forEach(walk);
return out.join(" ");
}
// Play the "polaroid torn up" teardown on a DOM element, then run `done`.
// Respects reduced-motion (skips straight to done) and never double-fires.
function destroyPolaroidEl(el, done) {
const reduce = !!window.__sozaiReduceMotion;
if (!el || reduce) { done && done(); return; }
if (el.__destroying) return;
el.__destroying = true;
// a quick tear flash overlaid on the polaroid
try {
const tear = document.createElement("div");
tear.className = "sozai-tear";
el.appendChild(tear);
} catch (e) {}
el.classList.add("sozai-destroy");
let called = false;
const finish = () => {
if (called) return;
called = true;
// clean up the imperatively-added bits in case React reuses this node
try {
el.classList.remove("sozai-destroy");
el.__destroying = false;
const t = el.querySelector(".sozai-tear");
if (t) t.remove();
} catch (e) {}
done && done();
};
el.addEventListener("animationend", finish, { once: true });
// safety fallback in case animationend doesn't fire
setTimeout(finish, 600);
}
function Img({ src, alt = "", className = "", fill, sizes, priority, ...rest }) {
const base = fill ? "absolute inset-0 h-full w-full" : "";
return <img src={src || "/assets/placeholder.svg"} alt={alt} className={cn(base, className)} loading={priority ? "eager" : "lazy"} {...rest} />;
}
const _L = window.lucide || {};
function iconNode(name) { return _L[name] || (_L.icons && _L.icons[name]) || null; }
function camelAttrs(a = {}) {
const o = {};
for (const k in a) {
if (k === "class") { o.className = a[k]; continue; }
o[k.replace(/-([a-z])/g, (_, c) => c.toUpperCase())] = a[k];
}
return o;
}
function Icon({ name, className = "", ...rest }) {
const node = iconNode(name);
const children = Array.isArray(node) ? node.map((t, i) => {
const tag = Array.isArray(t) ? t[0] : t.tag;
const attrs = Array.isArray(t) ? (t[1] || {}) : (t.attrs || {});
return React.createElement(tag, { key: i, ...camelAttrs(attrs) });
}) : null;
return (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"
fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
className={className} aria-hidden="true" {...rest}>{children}</svg>
);
}
const mk = (n) => (props) => <Icon name={n} {...props} />;
const Camera = mk("Camera"), X = mk("X"), ChevronLeft = mk("ChevronLeft"), ChevronRight = mk("ChevronRight"),
ArrowRight = mk("ArrowRight"), FolderPlus = mk("FolderPlus"), Calendar = mk("Calendar"), Clock = mk("Clock"),
Plus = mk("Plus"), Upload = mk("Upload"), Share2 = mk("Share2"), Settings = mk("Settings"),
ChevronDown = mk("ChevronDown"), SlidersHorizontal = mk("SlidersHorizontal"), MapPin = mk("MapPin"),
Sparkles = mk("Sparkles"), Star = mk("Star"), MapIcon = mk("Map"), FolderClosed = mk("FolderClosed"),
BookOpen = mk("BookOpen"), Copy = mk("Copy"), Check = mk("Check"), Users = mk("Users"),
MessageCircle = mk("MessageCircle"), Send = mk("Send"), Link2 = mk("Link2"), Home = mk("Home"),
Moon = mk("Moon"), Sun = mk("Sun"), PawPrint = mk("PawPrint"), Cat = mk("Cat"),
LayoutGrid = mk("LayoutGrid"), Wand2 = mk("Wand2"), Search = mk("Search"),
Droplet = mk("Droplet"), Volume2 = mk("Volume2"), VolumeX = mk("VolumeX"), FastForward = mk("FastForward"),
ImageIcon = mk("Image"), Loader2 = mk("Loader"), Trash2 = mk("Trash2"), Navigation = mk("Navigation"),
Palette = mk("Palette"), Pencil = mk("Pencil"),
Maximize2 = mk("Maximize2"), Minimize2 = mk("Minimize2"),
Shuffle = mk("Shuffle"), Printer = mk("Printer"), RotateCcw = mk("RotateCcw"),
ImagePlus = mk("ImagePlus"), Pin = mk("Pin"), Cpu = mk("Cpu"), Download = mk("Download"),
Mic = mk("Mic"), Square = mk("Square"),
CheckCircle = mk("CheckCircle2"), AlertTriangle = mk("AlertTriangle"), XCircle = mk("XCircle"),
ShieldAlert = mk("ShieldAlert"), FileWarning = mk("FileWarning");
// ----------------------------------------------------------------- //
// data (port of lib/album-data.ts; asset paths point at /assets)
// ----------------------------------------------------------------- //
const albums = [
{ id: "vancouver", name: "Vancouver", photoCount: 18, thumbnail: "/assets/photos/gastown-steam-clock.jpg" },
{ id: "road-trip", name: "Road Trip", photoCount: 42, thumbnail: "/assets/photos/mountain-sunset.jpg" },
{ id: "blossoms", name: "Blossoms", photoCount: 12, thumbnail: "/assets/photos/cherry-blossoms.jpg" },
{ id: "gardens", name: "Gardens", photoCount: 8, thumbnail: "/assets/photos/succulent-garden.jpg" },
];
const tripMeta = { location: "Vancouver", period: "2023-2025", date: "Feb 13, 2024", momentCount: 6, photoCount: 18 };
const moments = [
{ id: 1, title: "Gastown Steam Clock", caption: "Steam o'clock in Gastown.", date: "Feb 13, 2024", time: "7:52 AM", src: "/assets/photos/gastown-steam-clock.jpg", alt: "The Gastown steam clock and brick buildings in Vancouver", x: 22, y: 30, lat: 49.2840, lon: -123.1091, note: "downtown", favorite: true },
{ id: 2, title: "Waterfront Gardens", caption: "Flowers along the boardwalk.", date: "Jul 1, 2023", time: "4:13 PM", src: "/assets/photos/waterfront-gardens.jpg", alt: "A waterfront park with flowers and a boardwalk", x: 24, y: 70, lat: 49.1979, lon: -122.9157 },
{ id: 3, title: "Succulent Garden", caption: "Someone's incredible succulent garden.", date: "Jul 1, 2023", time: "4:17 PM", src: "/assets/photos/succulent-garden.jpg", alt: "A lush front-yard succulent garden", x: 50, y: 28, lat: 49.1981, lon: -122.9176 },
{ id: 4, title: "Cherry Blossoms", caption: "Cherry blossoms against a clear sky.", date: "Apr 3, 2025", time: "5:29 PM", src: "/assets/photos/cherry-blossoms.jpg", alt: "Cherry blossoms in full bloom against a blue sky", x: 72, y: 30, lat: 49.2697, lon: -123.2535, note: "so pretty" },
{ id: 5, title: "Maple Canopy", caption: "Spring maples catching fire.", date: "May 6, 2024", time: "5:00 PM", src: "/assets/photos/maple-canopy.jpg", alt: "Red and green maple leaves against a blue sky", x: 88, y: 62, lat: 49.2629, lon: -123.2060 },
{ id: 6, title: "Mountain Pass at Dusk", caption: "Golden hour over the mountains.", date: "Aug 7, 2023", time: "7:34 PM", src: "/assets/photos/mountain-sunset.jpg", alt: "Layered mountains at sunset on a road trip", x: 50, y: 70, lat: 53.0286, lon: -119.1697, note: "the long drive" },
];
const photos = [
{ id: "p1", src: "/assets/photos/gastown-steam-clock.jpg", alt: "The Gastown steam clock and brick buildings in Vancouver", defaultTitle: "Gastown Steam Clock", defaultCaption: "Steam o'clock in Gastown.", date: "Feb 13, 2024", time: "7:52 AM", lat: 49.2840, lon: -123.1091 },
{ id: "p2", src: "/assets/photos/cherry-blossoms.jpg", alt: "Cherry blossoms in full bloom against a blue sky", defaultTitle: "Cherry Blossoms", defaultCaption: "Cherry blossoms against a clear sky.", date: "Apr 3, 2025", time: "5:29 PM", lat: 49.2697, lon: -123.2535 },
{ id: "p3", src: "/assets/photos/mountain-sunset.jpg", alt: "Layered mountains at sunset on a road trip", defaultTitle: "Mountain Pass", defaultCaption: "Golden hour over the mountains.", date: "Aug 7, 2023", time: "7:34 PM", lat: 53.0286, lon: -119.1697 },
{ id: "p4", src: "/assets/photos/succulent-garden.jpg", alt: "A lush front-yard succulent garden", defaultTitle: "Succulent Garden", defaultCaption: "Someone's incredible succulent garden.", date: "Jul 1, 2023", time: "4:17 PM", lat: 49.1981, lon: -122.9176 },
];
// ----------------------------------------------------------------- //
// Shared photo store. Album photos (with their editable caption / date /
// time / title) live here so they flow from "Add to Album" into the Map of
// My Time draft tray, where the user can pin them. Pin coordinates persist
// too. Mirrors the window.Collab / window.__gradioReady global pattern.
// ----------------------------------------------------------------- //
// One-time cleanup: older builds persisted photos under this key. Remove any
// stale copy so previously-saved/shared images don't reappear after this
// memory-only change.
try { localStorage.removeItem("sozai_photos_v1"); } catch (e) {}
window.SozaiPhotos = (function () {
// NOTE: photos are intentionally NOT persisted to localStorage. Solo mode
// is ephemeral (refresh = empty), and room photos live on the SERVER
// (via /api/rooms/{id}/state), pulled in on join. Keeping a local copy
// would (a) make solo photos survive a refresh and (b) bleed old/deleted
// photos into a room you later join - both unwanted. The store is purely
// in-memory; the room-state sync is the only durable source.
let listeners = [];
function seed() {
return photos.map((p) => ({
id: p.id, src: p.src, alt: p.alt || "",
title: p.defaultTitle || "", caption: p.defaultCaption || "",
// Carry each photo's real EXIF date/time + GPS through. Photos that
// have coordinates auto-pin on the map, exactly like uploaded EXIF
// photos do.
date: p.date || "", time: p.time || "",
lon: p.lon != null ? p.lon : null, lat: p.lat != null ? p.lat : null,
pinned: p.lat != null && p.lon != null,
}));
}
// Always start from the built-in seed (sample photos). No localStorage.
let items = []; // default: empty album — "try a sample" loads the set
function persist() { /* intentionally no-op: photos are never stored locally */ }
function emit() { persist(); listeners.forEach((fn) => { try { fn(items); } catch (e) {} }); }
return {
all() { return items.slice(); },
pinned() { return items.filter((p) => p.pinned && p.lon != null && p.lat != null); },
get(id) { return items.find((p) => p.id === id) || null; },
subscribe(fn) { listeners.push(fn); return () => { listeners = listeners.filter((f) => f !== fn); }; },
// upsert fields onto a photo (by id), creating it if new (uploads)
upsert(id, patch) {
const i = items.findIndex((p) => p.id === id);
if (i === -1) items = [...items, Object.assign({ id, src: "", alt: "", title: "", caption: "", date: "", time: "", lon: null, lat: null, locationName: "", radius: null, layout: "square", pinned: false }, patch)];
else items = items.map((p) => (p.id === id ? Object.assign({}, p, patch) : p));
emit();
},
add(photo) {
const id = photo.id || ("u" + Date.now().toString(36) + Math.random().toString(36).slice(2, 5));
items = [...items, Object.assign({ id, src: "", alt: "", title: "", caption: "", date: "", time: "", lon: null, lat: null, locationName: "", radius: null, layout: "square", pinned: false }, photo, { id })];
emit();
return id;
},
pin(id, lon, lat) { this.upsert(id, { lon, lat, pinned: true }); },
move(id, lon, lat) { this.upsert(id, { lon, lat }); },
// per-photo canvas transform on the map (rotation in deg, scale 0.5-2)
setStyleProps(id, props) { this.upsert(id, props); },
unpin(id) { this.upsert(id, { lon: null, lat: null, pinned: false, rot: 0, scale: 1 }); },
// send every pinned photo back to the tray (reset the canvas)
resetPins() {
items = items.map((p) => (p.pinned ? Object.assign({}, p, { lon: null, lat: null, pinned: false, rot: 0, scale: 1 }) : p));
emit();
},
remove(id) { items = items.filter((p) => p.id !== id); emit(); },
replaceAll(next) { items = next.slice(); emit(); },
};
})();
// React hook that subscribes to the SozaiPhotos store and re-renders on
// every add / remove / upsert. Returns the live items array.
function usePhotos() {
const [items, setItems] = useState(() => window.SozaiPhotos.all());
useEffect(() => window.SozaiPhotos.subscribe(setItems), []);
return items;
}
// ----------------------------------------------------------------- //
// Export the finished scrapbook as a printable keepsake (→ Save as PDF). //
// Builds a clean, self-contained HTML document from the current photos + //
// captions and opens the browser's print dialog. No server round-trip, so //
// it works offline and never uploads the user's images anywhere. //
// ----------------------------------------------------------------- //
function exportScrapbook(opts) {
const o = opts || {};
const title = o.title || "My Sozai Scrapbook";
const photos = window.SozaiPhotos.all().filter((p) => p && p.src);
if (!photos.length) {
return { ok: false, reason: "empty" };
}
const esc = (s) => String(s == null ? "" : s)
.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
const today = new Date().toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric" });
const cards = photos.map((p) => {
const meta = [p.date, p.time].filter(Boolean).map(esc).join(" · ");
const tags = (p.tags || "").trim();
return `
<figure class="card">
<div class="frame"><img src="${esc(p.src)}" alt="${esc(p.alt || "")}" /></div>
<figcaption>
${p.title ? `<h2>${esc(p.title)}</h2>` : ""}
${p.caption ? `<p class="cap">${esc(p.caption)}</p>` : ""}
${meta ? `<p class="meta">${meta}</p>` : ""}
${tags ? `<p class="tags">${esc(tags)}</p>` : ""}
${(p.lat != null && p.lon != null) ? `<p class="geo">📍 ${Number(p.lat).toFixed(4)}, ${Number(p.lon).toFixed(4)}</p>` : ""}
</figcaption>
</figure>`;
}).join("");
const doc = `<!DOCTYPE html>
<html lang="en"><head><meta charset="utf-8" />
<title>${esc(title)}</title>
<style>
@page { margin: 16mm; }
* { box-sizing: border-box; }
body { font-family: Georgia, "Times New Roman", serif; color: #23201c; margin: 0; padding: 28px;
background: #fbf7ef; }
header.book { text-align: center; margin-bottom: 28px; border-bottom: 2px solid #cdbfa6; padding-bottom: 16px; }
header.book h1 { font-size: 30px; margin: 0 0 6px; letter-spacing: .01em; }
header.book p { margin: 0; color: #7a6f5d; font-size: 13px; font-style: italic; }
.grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 22px; }
.card { break-inside: avoid; background: #fff; border: 1px solid #e5dcc8; border-radius: 4px;
box-shadow: 0 1px 3px rgba(0,0,0,.08); padding: 10px 10px 14px; }
.frame { background: #fff; padding: 6px 6px 0; }
.frame img { display: block; width: 100%; height: 230px; object-fit: cover; border-radius: 2px; }
figcaption { padding: 8px 4px 0; }
figcaption h2 { font-size: 17px; margin: 6px 0 4px; }
.cap { font-family: "Segoe Script", "Comic Sans MS", cursive; font-size: 14px; margin: 0 0 6px; color: #3a342c; }
.meta { font-family: ui-monospace, Menlo, monospace; font-size: 10px; letter-spacing: .04em;
text-transform: uppercase; color: #8a7d68; margin: 4px 0 2px; }
.tags { font-size: 11px; color: #6b8e5a; margin: 2px 0; }
.geo { font-family: ui-monospace, Menlo, monospace; font-size: 10px; color: #998a72; margin: 2px 0 0; }
footer.book { margin-top: 26px; text-align: center; color: #998a72; font-size: 11px; font-style: italic; }
@media print { body { background: #fff; padding: 0; } .card { box-shadow: none; } }
</style></head>
<body>
<header class="book">
<h1>${esc(title)}</h1>
<p>${photos.length} ${photos.length === 1 ? "memory" : "memories"} · exported ${esc(today)}</p>
</header>
<div class="grid">${cards}</div>
<footer class="book">Made with Sozai · build memories together</footer>
<script>window.onload = function(){ setTimeout(function(){ window.focus(); window.print(); }, 350); };<\/script>
</body></html>`;
const w = window.open("", "_blank");
if (!w) return { ok: false, reason: "popup-blocked" };
w.document.open();
w.document.write(doc);
w.document.close();
return { ok: true, count: photos.length };
}
// //
// When in a room, the scrapbook (photos, captions, pins, transforms) //
// is saved to the server so a returning user sees it as they left it. //
// • on join → GET the saved state and load it into the store //
// • on change → PUT (debounced) with optimistic version tracking //
// • on conflict → adopt the server's newer state //
// Live edits still flow over the WebSocket; this is the durable layer //
// plus the seed for anyone who joins later. No-ops in solo mode. //
// ----------------------------------------------------------------- //
const RoomPersistence = (function () {
let roomId = null;
let version = 0;
let saveTimer = null;
let unsub = null;
let loading = false;
let dirtyWhileLoading = false;
const SAVE_DEBOUNCE = 1200;
async function load() {
loading = true;
try {
const res = await fetch(`/api/rooms/${encodeURIComponent(roomId)}/state`);
if (!res.ok) {
// couldn't read room state - start the room empty rather than
// leaking whatever photos happen to be in local memory.
window.__sozaiSuppressSave = true;
window.SozaiPhotos.replaceAll([]);
window.__sozaiSuppressSave = false;
return;
}
const data = await res.json();
version = data.version || 0;
// ALWAYS adopt the server's room state as the source of truth on join.
// If the room has photos, show them (shared, persistent). If it's a
// fresh/empty room, start EMPTY - never carry over the local seed or
// any leftover/deleted photos from a previous solo session.
const serverPhotos = (data.state && Array.isArray(data.state.photos))
? data.state.photos : [];
window.__sozaiSuppressSave = true;
window.SozaiPhotos.replaceAll(serverPhotos);
window.__sozaiSuppressSave = false;
} catch (e) {
// network error - start the room empty rather than bleeding local photos
window.__sozaiSuppressSave = true;
window.SozaiPhotos.replaceAll([]);
window.__sozaiSuppressSave = false;
} finally {
loading = false;
if (dirtyWhileLoading) { dirtyWhileLoading = false; scheduleSave(); }
}
}
async function save() {
if (!roomId) return;
const photos = window.SozaiPhotos.all();
try {
const res = await fetch(`/api/rooms/${encodeURIComponent(roomId)}/state`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ state: { photos, savedAt: Date.now() }, base_version: version }),
});
if (!res.ok) return;
const data = await res.json();
if (data.conflict && data.state && Array.isArray(data.state.photos)) {
// someone saved a newer version; adopt it rather than clobber
version = data.version || version;
window.__sozaiSuppressSave = true;
window.SozaiPhotos.replaceAll(data.state.photos);
window.__sozaiSuppressSave = false;
} else if (typeof data.version === "number") {
version = data.version;
}
} catch (e) { /* will retry on next change */ }
}
function scheduleSave() {
if (!roomId) return;
if (loading) { dirtyWhileLoading = true; return; }
if (window.__sozaiSuppressSave) return;
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(save, SAVE_DEBOUNCE);
}
return {
async init() {
if (!Collab.isCollab()) return; // solo → no persistence
roomId = Collab.roomId();
if (!roomId) return;
await load();
unsub = window.SozaiPhotos.subscribe(() => scheduleSave());
},
// force an immediate flush (e.g. before unload)
flush() { if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; } return save(); },
stop() { if (unsub) unsub(); if (saveTimer) clearTimeout(saveTimer); },
};
})();
// Adapt a SozaiPhotos item to the shape that the map-view components expect.
// x/y are deterministic synthetic percentages used for scrapbook pin positions.
function storeToMoment(p, i) {
return {
id: p.id,
title: p.title || p.alt || "Untitled",
caption: p.caption || "",
date: p.date || "",
time: p.time || "",
src: p.src,
alt: p.alt || p.title || "",
lat: p.lat != null ? p.lat : null,
lon: p.lon != null ? p.lon : null,
locationName: p.locationName || "",
radius: p.radius != null ? p.radius : null,
layout: p.layout || "square",
x: 10 + ((i * 73 + 11) % 75),
y: 20 + ((i * 47 + 23) % 55),
favorite: false,
note: "",
};
}
// Parse a free-form date + time ("June 7, 2026" + "10:42 AM") into a
// sortable timestamp for the flythrough. Falls back to array order.
function photoTimestamp(p) {
const raw = [p.date, p.time].filter(Boolean).join(" ").trim();
if (raw) { const t = Date.parse(raw); if (!isNaN(t)) return t; }
const justTime = (p.time || "").trim();
if (justTime) { const t = Date.parse("Jan 1 2000 " + justTime); if (!isNaN(t)) return t; }
return null;
}
// ----------------------------------------------------------------- //
// collaboration hooks
// ----------------------------------------------------------------- //
function useStatus() {
const [s, setS] = useState({ connected: false, solo: !Collab.isCollab() });
useEffect(() => Collab.on("status", setS), []);
return s;
}
function usePresence() {
const [p, setP] = useState(Collab.getParticipants());
useEffect(() => Collab.on("presence", (list) => setP(list || [])), []);
return p;
}
function useMe() {
const [m, setM] = useState(Collab.getMe());
useEffect(() => Collab.on("me", setM), []);
return m;
}
function initials(name) {
const parts = (name || "Guest").trim().split(/\s+/);
return ((parts[0]?.[0] || "G") + (parts[1]?.[0] || "")).toUpperCase();
}
function Avatar({ p, size = 28 }) {
return (
<span title={p.name} style={{ background: p.color, width: size, height: size }}
className="inline-flex shrink-0 items-center justify-center rounded-full text-[11px] font-bold text-white ring-2 ring-card">
{initials(p.name)}
</span>
);
}
async function copy(text) {
try { await navigator.clipboard.writeText(text); return true; }
catch (e) {
const ta = document.createElement("textarea"); ta.value = text; document.body.appendChild(ta);
ta.select(); try { document.execCommand("copy"); } catch (_) {} document.body.removeChild(ta); return true;
}
}
function CopyButton({ value, label, icon: IconC }) {
const [done, setDone] = useState(false);
return (
<button type="button"
onClick={async () => { await copy(value); setDone(true); setTimeout(() => setDone(false), 1400); }}
className="flex items-center gap-1.5 rounded-md border-2 border-primary/60 px-2.5 py-1.5 font-mono text-xs uppercase tracking-wide text-foreground transition-colors hover:bg-accent">
{done ? <Check className="size-3.5 text-primary" /> : (IconC ? <IconC className="size-3.5 text-primary" /> : <Copy className="size-3.5 text-primary" />)}
{done ? "Copied" : label}
</button>
);
}
// ----------------------------------------------------------------- //
// Collaboration bar (share code + link, presence, name, chat toggle)
// ----------------------------------------------------------------- //
function CollabBar({ onToggleChat, unread, onOpenPets, onOpenTrace }) {
const status = useStatus();
const participants = usePresence();
const me = useMe();
const [code, setCode] = useState(null);
const [editing, setEditing] = useState(false);
const [nameDraft, setNameDraft] = useState("");
const [creating, setCreating] = useState(false);
useEffect(() => {
const id = Collab.roomId();
if (!id) return;
fetch("/api/rooms/" + encodeURIComponent(id))
.then((r) => (r.ok ? r.json() : null))
.then((d) => d && setCode(d.share_code))
.catch(() => {});
}, []);
const link = Collab.roomId() ? `${location.origin}/room/${Collab.roomId()}` : "";
async function startCollab() {
setCreating(true);
try {
const r = await fetch("/api/rooms", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code_length: 6, session_id: Collab.sessionId(), display_name: localStorage.getItem("sozai_name") || "" }),
});
const d = await r.json();
location.href = d.room_path;
} catch (e) { setCreating(false); }
}
// Solo mode bar -------------------------------------------------- //
if (status.solo) {
return (
<div className="flex flex-wrap items-center justify-between gap-3 border-b-2 border-dashed border-primary/40 bg-card px-5 py-3 sm:px-7">
<a href="/" className="flex items-center gap-2 text-foreground">
<img src="/assets/images/hummingbird-black.svg" alt="" className="size-8" /> <span className="font-display text-lg leading-none">Sozai</span>
</a>
<div className="flex items-center gap-2">
<span className="font-mono text-[11px] uppercase tracking-wide text-muted-foreground">Solo session</span>
<button type="button" onClick={onOpenTrace} aria-label="Open LLM trace"
className="flex items-center gap-1.5 rounded-md border-2 border-primary/60 px-2.5 py-1.5 font-mono text-xs uppercase tracking-wide text-primary transition-colors hover:bg-accent">
<Cpu className="size-4" /> Trace
</button>
<button type="button" onClick={onOpenPets} aria-label="Open the menagerie"
className="flex items-center gap-1.5 rounded-md border-2 border-primary/60 px-2.5 py-1.5 font-mono text-xs uppercase tracking-wide text-primary transition-colors hover:bg-accent">
<PawPrint className="size-4" /> Pets
</button>
<DarkModeToggle compact />
<button type="button" onClick={startCollab} disabled={creating}
className="flex items-center gap-1.5 rounded-md border-2 border-primary bg-primary px-3 py-1.5 font-mono text-xs font-semibold uppercase tracking-wide text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-60">
<Users className="size-3.5" /> {creating ? "Creating…" : "Start collaborating"}
</button>
</div>
</div>
);
}
// Collaborative bar ---------------------------------------------- //
return (
<div className="flex flex-wrap items-center justify-between gap-3 border-b-2 border-dashed border-primary/40 bg-card px-5 py-3 sm:px-7">
<div className="flex flex-wrap items-center gap-3">
<a href="/" className="flex items-center gap-2 text-foreground">
<img src="/assets/images/hummingbird-black.svg" alt="" className="size-8" /> <span className="font-display text-lg leading-none">Sozai</span>
</a>
<span className="flex items-center gap-1.5 font-mono text-[11px] uppercase tracking-wide text-muted-foreground">
<span className={cn("size-2 rounded-full", status.connected ? "bg-[oklch(0.6_0.17_150)]" : "bg-[oklch(0.7_0.15_70)]")} />
{status.connected ? "Live" : "Reconnecting…"}
</span>
{code ? <CopyButton value={code} label={code} icon={Copy} /> : null}
<CopyButton value={link} label="Link" icon={Link2} />
</div>
<div className="flex items-center gap-3">
{/* presence */}
<div className="flex items-center gap-1.5">
<Users className="size-4 text-primary" />
<div className="flex -space-x-2">
{participants.slice(0, 5).map((p) => <Avatar key={p.session_id} p={p} />)}
</div>
{participants.length > 5 ? <span className="font-mono text-xs text-muted-foreground">+{participants.length - 5}</span> : null}
</div>
{/* your name */}
{editing ? (
<input autoFocus defaultValue={me ? me.name : (localStorage.getItem("sozai_name") || "")}
onChange={(e) => setNameDraft(e.target.value)}
onBlur={() => { if (nameDraft.trim()) Collab.rename(nameDraft.trim()); setEditing(false); }}
onKeyDown={(e) => { if (e.key === "Enter") { if (nameDraft.trim()) Collab.rename(nameDraft.trim()); setEditing(false); } }}
maxLength={24}
className="w-28 rounded-md border-2 border-primary/60 bg-card px-2 py-1 text-xs text-foreground outline-none" />
) : (
<button type="button" onClick={() => { setNameDraft(""); setEditing(true); }}
className="flex items-center gap-1.5 rounded-md border-2 border-primary/40 px-2.5 py-1.5 text-xs text-foreground transition-colors hover:bg-accent">
{me ? <Avatar p={me} size={20} /> : null}
<span className="font-mono uppercase tracking-wide">{me ? me.name : "You"}</span>
</button>
)}
{/* trace + pets + dark mode (visible to everyone) */}
<button type="button" onClick={onOpenTrace} aria-label="Open LLM trace"
className="flex size-9 items-center justify-center rounded-md border-2 border-primary/60 text-primary transition-colors hover:bg-accent" title="LLM Trace">
<Cpu className="size-4" />
</button>
<button type="button" onClick={onOpenPets} aria-label="Open the menagerie"
className="flex size-9 items-center justify-center rounded-md border-2 border-primary/60 text-primary transition-colors hover:bg-accent" title="The Menagerie">
<PawPrint className="size-4" />
</button>
<DarkModeToggle compact />
{/* chat */}
<button type="button" onClick={onToggleChat} aria-label="Toggle chat"
className="relative flex size-9 items-center justify-center rounded-md border-2 border-primary/60 text-primary transition-colors hover:bg-accent">
<MessageCircle className="size-4" />
{unread ? <span className="absolute -right-1.5 -top-1.5 flex size-4 items-center justify-center rounded-full bg-primary text-[9px] font-bold text-primary-foreground">{unread}</span> : null}
</button>
</div>
</div>
);
}
// ----------------------------------------------------------------- //
// Live cursors overlay
// ----------------------------------------------------------------- //
function Cursors() {
const [cursors, setCursors] = useState({});
const last = useRef(0);
useEffect(() => {
if (!Collab.isCollab()) return;
const offMove = Collab.on("cursor_position", (m) => {
if (!m.from) return;
setCursors((prev) => ({ ...prev, [m.from.session_id]: { x: m.x, y: m.y, name: m.from.name, color: m.from.color, t: Date.now() } }));
});
const offLeft = Collab.on("user_left", (m) => {
const sidp = m.participant && m.participant.session_id;
if (sidp) setCursors((prev) => { const n = { ...prev }; delete n[sidp]; return n; });
});
// a collaborator interacted with a control - float a label by their cursor
const offPing = Collab.on("interaction", (m) => {
if (!m.from) return;
setCursors((prev) => {
const cur = prev[m.from.session_id] || { name: m.from.name, color: m.from.color };
return { ...prev, [m.from.session_id]: { ...cur, x: m.x, y: m.y, name: m.from.name, color: m.from.color, t: Date.now(), ping: m.label, pingT: Date.now() } };
});
});
const onMouse = (e) => {
const now = Date.now();
if (now - last.current < 45) return;
last.current = now;
Collab.send("cursor_position", { x: e.clientX / window.innerWidth, y: e.clientY / window.innerHeight });
};
window.addEventListener("mousemove", onMouse);
const prune = setInterval(() => setCursors((prev) => {
const now = Date.now(); const n = {};
for (const k in prev) {
if (now - prev[k].t < 8000) {
const c = { ...prev[k] };
if (c.pingT && now - c.pingT > 2500) { c.ping = null; }
n[k] = c;
}
}
return n;
}), 1000);
return () => { offMove(); offLeft(); offPing(); window.removeEventListener("mousemove", onMouse); clearInterval(prune); };
}, []);
return (
<div className="pointer-events-none fixed inset-0 z-50">
{Object.entries(cursors).map(([sid, c]) => (
<div key={sid} className="absolute -translate-x-1 -translate-y-1 transition-all duration-75"
style={{ left: `${c.x * 100}%`, top: `${c.y * 100}%` }}>
<svg width="18" height="18" viewBox="0 0 24 24" fill={c.color} stroke="white" strokeWidth="1.5">
<path d="M5 3l6 16 2-7 7-2L5 3z" />
</svg>
<span className="ml-3 rounded px-1.5 py-0.5 text-[10px] font-semibold text-white" style={{ background: c.color }}>{c.name}</span>
{c.ping && (Date.now() - (c.pingT || 0) < 2500) ? (
<span className="absolute left-3 top-5 whitespace-nowrap rounded-full border-2 px-2 py-0.5 text-[10px] font-semibold shadow-sm"
style={{ borderColor: c.color, background: "white", color: c.color }}>
{c.ping}
</span>
) : null}
</div>
))}
</div>
);
}
// ----------------------------------------------------------------- //
// Chat dock
// ----------------------------------------------------------------- //
function fmtTime(ts) {
try {
return new Date(ts).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
} catch (e) { return ""; }
}
function ChatDock({ open, onClose, onIncoming }) {
const { settings } = useSettings();
const [messages, setMessages] = useState([]);
const [text, setText] = useState("");
const me = useMe();
const endRef = useRef(null);
const openRef = useRef(open);
useEffect(() => { openRef.current = open; }, [open]);
useEffect(() => {
if (!Collab.isCollab()) return;
return Collab.on("chat_message", (m) => {
if (!m.from) return;
setMessages((prev) => [...prev, { name: m.from.name, color: m.from.color, text: m.text, mine: false, ts: m.ts || Date.now() }]);
// notify: sound + toast when a message arrives (chat closed gets the badge too)
if (settings.chatSound) {
window.SozaiChime.play("message", { enabled: settings.chatSound, theme: settings.chatSoundTheme, volume: settings.chatSoundVolume });
}
if (!openRef.current) {
onIncoming && onIncoming();
if (settings.chatPopup && window.SozaiToast) {
window.SozaiToast.show({ name: m.from.name, color: m.from.color, text: m.text });
}
}
});
}, [settings.chatSound, settings.chatSoundTheme, settings.chatSoundVolume, settings.chatPopup]);
useEffect(() => { endRef.current && endRef.current.scrollIntoView({ behavior: "smooth" }); }, [messages, open]);
function sendMsg() {
const t = text.trim();
if (!t) return;
const ts = Date.now();
Collab.send("chat_message", { text: t, ts });
setMessages((prev) => [...prev, { name: me ? me.name : "You", color: me ? me.color : "#7c5cff", text: t, mine: true, ts }]);
setText("");
}
if (!open) return null;
return (
<div className="fixed bottom-4 right-4 z-40 flex h-96 w-80 max-w-[90vw] flex-col overflow-hidden rounded-xl border-2 border-primary bg-card shadow-lg">
<div className="flex items-center justify-between border-b-2 border-dashed border-primary/50 px-4 py-2.5">
<span className="flex items-center gap-2 font-mono text-xs font-semibold uppercase tracking-widest text-foreground">
<MessageCircle className="size-4 text-primary" /> Room chat
</span>
<button type="button" onClick={onClose} aria-label="Close chat" className="text-primary hover:scale-110"><X className="size-4" /></button>
</div>
<div className="flex-1 space-y-2.5 overflow-y-auto px-4 py-3">
{messages.length === 0 ? <p className="text-center text-xs text-muted-foreground">Say hi to the room 👋</p> : null}
{messages.map((m, i) => (
<div key={i} className={cn("flex flex-col", m.mine && "items-end")}>
<span className="flex items-baseline gap-1.5">
<span className="font-mono text-[10px] uppercase tracking-wide" style={{ color: m.color }}>{m.name}</span>
{m.ts ? <span className="font-mono text-[9px] text-muted-foreground">{fmtTime(m.ts)}</span> : null}
</span>
<span className={cn("mt-0.5 max-w-[85%] rounded-md border-2 px-2.5 py-1.5 text-sm", m.mine ? "border-primary bg-accent text-accent-foreground" : "border-primary/40 bg-card text-foreground")}>{m.text}</span>
</div>
))}
<div ref={endRef} />
</div>
<div className="flex items-center gap-2 border-t-2 border-dashed border-primary/40 px-3 py-2.5">
<input value={text} onChange={(e) => setText(e.target.value)} onKeyDown={(e) => e.key === "Enter" && sendMsg()}
placeholder="Message…" className="min-w-0 flex-1 rounded-md border-2 border-primary/60 bg-card px-2.5 py-1.5 text-sm text-foreground outline-none placeholder:text-muted-foreground" />
<button type="button" onClick={sendMsg} aria-label="Send"
className="flex size-9 shrink-0 items-center justify-center rounded-md border-2 border-primary bg-primary text-primary-foreground transition-colors hover:bg-primary/90">
<Send className="size-4" />
</button>
</div>
</div>
);
}
// ----------------------------------------------------------------- //
// components/photo-stack.tsx (editable title / caption / date / time)
// ----------------------------------------------------------------- //
// A few built-in sample photos the user can drop in instead of uploading.
const SAMPLE_PHOTOS = [
{ src: "/assets/photos/gastown-steam-clock.jpg", alt: "The Gastown steam clock, Vancouver", title: "Gastown Steam Clock", caption: "Steam o'clock in Gastown." },
{ src: "/assets/photos/waterfront-gardens.jpg", alt: "A waterfront park with flowers", title: "Waterfront Gardens", caption: "Flowers along the boardwalk." },
{ src: "/assets/photos/cherry-blossoms.jpg", alt: "Cherry blossoms against a blue sky", title: "Cherry Blossoms", caption: "Cherry blossoms against a clear sky." },
{ src: "/assets/photos/maple-canopy.jpg", alt: "Red and green maple leaves", title: "Maple Canopy", caption: "Spring maples catching fire." },
];
// A photo cropped with object-cover whose focal point the user can drag to
// change what's framed (useful for high-res images that get cropped). The
// focus is stored as object-position percentages on the photo. Dragging the
// image pans the visible window; we commit the new focus via onReposition.
function DraggableImage({ src, alt, focusX, focusY, onReposition }) {
const ref = useRef(null);
const drag = useRef(null);
const [fx, setFx] = useState(focusX);
const [fy, setFy] = useState(focusY);
// keep local focus in sync if the photo (or its stored focus) changes
useEffect(() => { setFx(focusX); setFy(focusY); }, [src, focusX, focusY]);
function onPointerDown(e) {
const el = ref.current; if (!el) return;
el.setPointerCapture && el.setPointerCapture(e.pointerId);
drag.current = { x: e.clientX, y: e.clientY, fx, fy, w: el.clientWidth, h: el.clientHeight };
}
function onPointerMove(e) {
if (!drag.current) return;
const d = drag.current;
// dragging right should reveal the LEFT of the image → focus decreases.
// scale movement so a full-width drag spans the whole 0-100 range.
const dx = (e.clientX - d.x) / Math.max(1, d.w) * 100;
const dy = (e.clientY - d.y) / Math.max(1, d.h) * 100;
setFx(Math.min(100, Math.max(0, d.fx - dx)));
setFy(Math.min(100, Math.max(0, d.fy - dy)));
}
function onPointerUp(e) {
if (!drag.current) return;
drag.current = null;
if (onReposition) onReposition(Math.round(fx), Math.round(fy));
}
return (
<div ref={ref}
onPointerDown={onPointerDown} onPointerMove={onPointerMove}
onPointerUp={onPointerUp} onPointerCancel={onPointerUp}
className="group absolute inset-0 cursor-grab touch-none active:cursor-grabbing"
title="Drag to reposition">
<img src={src || "/assets/placeholder.svg"} alt={alt}
draggable={false}
className="h-full w-full select-none object-cover"
style={{ objectPosition: fx + "% " + fy + "%" }} />
{/* subtle reposition affordance */}
<span className="pointer-events-none absolute bottom-1.5 left-1.5 flex items-center gap-1 rounded bg-black/45 px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-wide text-white opacity-0 transition-opacity group-hover:opacity-100">
<Navigation className="size-2.5" /> drag to reframe
</span>
</div>
);
}
function PhotoStack({ photo, index, total, slideDir, destroying, onDestroyed, onAdd, onAddSample, onLoadSamples, onReposition, onDelete, title, caption, date, time, onTitleChange, onCaptionChange, onDateChange, onTimeChange }) {
if (!photo) {
// no photos left - show an empty polaroid inviting an upload, plus a
// row of sample photos the user can add with one tap.
return (
<div className="flex flex-col items-center gap-4">
<button type="button" onClick={() => onAdd && onAdd()}
aria-label="Add a photo"
className="group relative w-full max-w-xs cursor-pointer rounded-md border-2 border-dashed border-primary/50 bg-card p-3 text-left shadow-sm transition-colors hover:border-primary hover:bg-accent/40">
{/* the photo window */}
<div className="relative flex aspect-square flex-col items-center justify-center gap-2 overflow-hidden rounded-sm border-2 border-dashed border-primary/40 bg-[oklch(0.97_0.005_210)]">
<Upload className="size-8 text-primary/60 transition-transform group-hover:-translate-y-0.5" />
<span className="font-mono text-[11px] uppercase tracking-widest text-primary/70">Upload a photo</span>
</div>
{/* the white caption strip of a polaroid */}
<div className="mt-3 flex h-12 items-center justify-center rounded-sm">
<span className="font-hand text-sm text-muted-foreground">Your album is empty - add a memory</span>
</div>
</button>
<p className="text-center font-serif text-xs italic text-primary">Tap the polaroid to upload your own photos.</p>
<button type="button" onClick={() => onLoadSamples && onLoadSamples()}
className="rounded-md border-2 border-primary bg-primary px-5 py-2.5 font-mono text-xs font-semibold uppercase tracking-wide text-primary-foreground transition-colors hover:bg-primary/90">
✨ Try a sample album
</button>
{/* or add individual samples */}
<div className="w-full max-w-xs">
<p className="mb-1.5 text-center font-mono text-[10px] uppercase tracking-widest text-muted-foreground">Or add one at a time</p>
<div className="grid grid-cols-4 gap-2">
{SAMPLE_PHOTOS.map((s, i) => (
<button key={i} type="button" onClick={() => onAddSample && onAddSample(s)}
title={"Add: " + s.title} aria-label={"Add sample: " + s.title}
className="group relative aspect-square overflow-hidden rounded-sm border-2 border-primary/40 transition-colors hover:border-primary">
<Img src={s.src} alt={s.alt} fill className="object-cover transition-transform group-hover:scale-105" />
<span className="absolute inset-0 flex items-end justify-center bg-gradient-to-t from-black/50 to-transparent p-1 opacity-0 transition-opacity group-hover:opacity-100">
<Plus className="size-4 text-white" />
</span>
</button>
))}
</div>
</div>
</div>
);
}
return (
<div className="flex flex-col items-center gap-4">
<div className="relative w-full max-w-xs">
<div className="absolute inset-0 translate-x-3 translate-y-3 rotate-2 rounded-md border-2 border-primary/30 bg-card" />
<div className="absolute inset-0 translate-x-1.5 translate-y-1.5 rotate-1 rounded-md border-2 border-primary/40 bg-card" />
{/* keyed by index so the slide animation re-triggers on each browse;
the direction follows whether you went forward or back. When the
user removes a photo, `destroying` swaps in the teardown anim and
onAnimationEnd actually drops it. We only act on the destroy
animation (not the bubbled tear-flash or a slide-in). */}
<div key={destroying ? "destroy-" + index : "card-" + index}
onAnimationEnd={(e) => {
if (destroying && onDestroyed && e.target === e.currentTarget
&& e.animationName && e.animationName.indexOf("sozai-destroy") !== -1) {
onDestroyed();
}
}}
className={cn("relative rounded-md border-2 border-primary bg-card p-3 shadow-sm",
destroying ? "sozai-destroy" : (slideDir === "left" ? "sozai-slide-left" : "sozai-slide-right"))}>
{destroying ? <span className="sozai-tear" /> : null}
<div className="relative">
<div className="relative aspect-square overflow-hidden rounded-sm border-2 border-primary/70">
<DraggableImage src={photo.src} alt={photo.alt}
focusX={photo.focusX != null ? photo.focusX : 50}
focusY={photo.focusY != null ? photo.focusY : 50}
onReposition={onReposition} />
<span className="pointer-events-none absolute right-2 top-2 rounded-sm bg-card/90 px-2 py-0.5 font-mono text-xs font-semibold text-primary">{index + 1}/{total}</span>
</div>
{/* delete this photo: a small x at the polaroid's corner */}
{onDelete ? (
<button type="button" onClick={onDelete} aria-label="Delete this photo" title="Delete this photo"
className="absolute -right-2.5 -top-2.5 z-30 flex size-7 items-center justify-center rounded-full border-2 border-[#800020] bg-card text-[#800020] shadow-md transition-colors hover:bg-[#800020] hover:text-white">
<X className="size-3.5" />
</button>
) : null}
</div>
<div className="mt-3 rounded-sm border-2 border-primary/70 px-3 py-2">
<input
value={title}
onChange={(e) => onTitleChange(e.target.value)}
placeholder="Add a title..."
aria-label="Photo title"
className="w-full bg-transparent font-hand text-lg text-foreground outline-none placeholder:font-normal placeholder:text-muted-foreground"
/>
<textarea
value={caption}
onChange={(e) => onCaptionChange(e.target.value)}
rows={2}
placeholder="Add a caption..."
aria-label="Photo caption"
className="mt-1.5 w-full resize-none bg-transparent font-hand text-sm text-foreground outline-none placeholder:text-muted-foreground"
/>
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
<span className="flex items-center gap-1.5"><Calendar className="size-3.5 text-primary" />
<input value={date} onChange={(e) => onDateChange(e.target.value)} aria-label="Date" placeholder="Date"
className="w-28 bg-transparent text-xs text-foreground outline-none placeholder:text-muted-foreground" />
</span>
<span className="flex items-center gap-1.5"><Clock className="size-3.5 text-primary" />
<input value={time} onChange={(e) => onTimeChange(e.target.value)} aria-label="Time" placeholder="Time"
className="w-20 bg-transparent text-xs text-foreground outline-none placeholder:text-muted-foreground" />
</span>
</div>
</div>
</div>
</div>
</div>
);
}
// ----------------------------------------------------------------- //
// Location picker - a click-to-open radius map for a photo's "general
// area". Reuses the same free MapLibre + Nominatim stack as the big map,
// so a photo's place is editable by dragging a pin or typing an address.
// ----------------------------------------------------------------- //
function LocationPicker({ lat, lon, radius, locationName, onChange, onClose }) {
const elRef = useRef(null);
const mapRef = useRef(null);
const markerRef = useRef(null);
const ptRef = useRef({ lon: lon != null ? lon : -123.116, lat: lat != null ? lat : 49.2462 });
const [addr, setAddr] = useState("");
const [busy, setBusy] = useState(false);
const [rad, setRad] = useState(radius || 600);
const [name, setName] = useState(locationName || "");
const radRef = useRef(rad);
radRef.current = rad;
function paintCircle() {
const m = mapRef.current; if (!m) return;
const src = m.getSource("loc-rad");
if (src) src.setData(circlePolygon(ptRef.current.lon, ptRef.current.lat, radRef.current));
}
async function commit(extra) {
const c = ptRef.current;
let nm = name;
const rg = await reverseGeocode(c.lat, c.lon);
if (rg) { nm = rg; setName(rg); }
onChange(Object.assign({ lat: c.lat, lon: c.lon, locationName: nm, radius: radRef.current, pinned: true }, extra || {}));
}
useEffect(() => {
if (!window.maplibregl || !elRef.current) return;
const map = new window.maplibregl.Map({
container: elRef.current, style: watercolorVectorStyle(),
center: [ptRef.current.lon, ptRef.current.lat], zoom: lat != null ? 13 : 10,
attributionControl: false,
});
mapRef.current = map;
map.on("load", () => {
map.resize();
map.addSource("loc-rad", { type: "geojson", data: circlePolygon(ptRef.current.lon, ptRef.current.lat, radRef.current) });
map.addLayer({ id: "loc-rad-fill", type: "fill", source: "loc-rad", paint: { "fill-color": "rgb(91,60,196)", "fill-opacity": 0.14 } });
map.addLayer({ id: "loc-rad-line", type: "line", source: "loc-rad", paint: { "line-color": "rgb(91,60,196)", "line-width": 1.4, "line-dasharray": [2, 2] } });
});
map.on("click", (e) => {
ptRef.current = { lon: e.lngLat.lng, lat: e.lngLat.lat };
markerRef.current && markerRef.current.setLngLat([e.lngLat.lng, e.lngLat.lat]);
paintCircle(); commit();
});
const marker = new window.maplibregl.Marker({ draggable: true, color: "#5b3cc4" })
.setLngLat([ptRef.current.lon, ptRef.current.lat]).addTo(map);
markerRef.current = marker;
marker.on("drag", () => { const ll = marker.getLngLat(); ptRef.current = { lon: ll.lng, lat: ll.lat }; paintCircle(); });
marker.on("dragend", () => commit());
return () => map.remove();
}, []); // eslint-disable-line
async function search(e) {
e.preventDefault();
const q = addr.trim(); if (!q) return;
setBusy(true);
try {
const hits = await geocode(q, { limit: 1 });
if (hits.length) {
ptRef.current = { lon: hits[0].lon, lat: hits[0].lat };
markerRef.current && markerRef.current.setLngLat([hits[0].lon, hits[0].lat]);
mapRef.current && mapRef.current.flyTo({ center: [hits[0].lon, hits[0].lat], zoom: 13, duration: 800 });
paintCircle();
const nm = (hits[0].label || q).split(",").slice(0, 2).join(",").trim();
setName(nm);
onChange({ lat: hits[0].lat, lon: hits[0].lon, locationName: nm, radius: radRef.current, pinned: true });
}
} finally { setBusy(false); }
}
function onRad(v) {
setRad(v); radRef.current = v; paintCircle();
onChange({ lat: ptRef.current.lat, lon: ptRef.current.lon, locationName: name, radius: v, pinned: true });
}
function clearLoc() { onChange({ lat: null, lon: null, locationName: "", radius: null, pinned: false }); onClose && onClose(); }
return (
<div className="absolute right-0 top-full z-50 mt-2 w-72 rounded-lg border-2 border-primary bg-card p-3 shadow-xl">
<form onSubmit={search} className="mb-2 flex items-center gap-1.5">
<input value={addr} onChange={(e) => setAddr(e.target.value)} placeholder="Search an address or place…"
className="min-w-0 flex-1 rounded-md border-2 border-primary/40 bg-card px-2.5 py-1.5 font-mono text-[11px] text-foreground outline-none placeholder:text-muted-foreground focus:border-primary" />
<button type="submit" disabled={busy} className="shrink-0 rounded-md border-2 border-primary bg-primary px-2.5 py-1.5 font-mono text-[11px] uppercase text-primary-foreground disabled:opacity-50">{busy ? "…" : "Go"}</button>
</form>
<div ref={elRef} className="h-40 w-full overflow-hidden rounded-md border-2 border-primary/40" />
<p className="mt-1.5 font-mono text-[10px] leading-snug text-muted-foreground">Drag the pin or tap the map — this is your photo's general area.</p>
<div className="mt-2 flex items-center gap-2">
<span className="font-mono text-[10px] uppercase tracking-wide text-primary">Radius</span>
<input type="range" min="100" max="5000" step="100" value={rad} onChange={(e) => onRad(parseInt(e.target.value, 10))} className="min-w-0 flex-1 accent-primary" />
<span className="w-12 shrink-0 text-right font-mono text-[10px] text-muted-foreground">{rad >= 1000 ? (rad / 1000).toFixed(1) + "km" : rad + "m"}</span>
</div>
<div className="mt-2 flex items-center justify-between gap-2">
<button type="button" onClick={clearLoc} className="font-mono text-[10px] uppercase tracking-wide text-muted-foreground hover:text-foreground">Clear</button>
<button type="button" onClick={onClose} className="rounded-md border-2 border-primary px-3 py-1 font-mono text-[10px] uppercase tracking-wide text-primary hover:bg-accent">Done</button>
</div>
</div>
);
}
function LocationField({ lat, lon, locationName, radius, onChange }) {
const [open, setOpen] = useState(false);
const has = !!(locationName || lat != null);
const label = locationName || (lat != null ? Number(lat).toFixed(3) + ", " + Number(lon).toFixed(3) : "Add location");
return (
<div className="relative flex min-w-0 flex-1 items-center gap-2 px-2.5 py-2.5">
<MapPin className="size-4 shrink-0 text-primary" />
<button type="button" onClick={() => setOpen((v) => !v)} title={has ? label : "Add a location"}
className={cn("min-w-0 flex-1 truncate text-left text-sm outline-none", has ? "text-foreground" : "text-muted-foreground")}>
{label}
</button>
{open ? <LocationPicker lat={lat} lon={lon} radius={radius} locationName={locationName} onChange={onChange} onClose={() => setOpen(false)} /> : null}
</div>
);
}
// ----------------------------------------------------------------- //
// components/details-form.tsx
// ----------------------------------------------------------------- //
function DetailsForm({ title, onTitleChange, caption, onCaptionChange, date, onDateChange, time, onTimeChange, lat, lon, locationName, radius, onLocationChange, tags, onTagsChange, onSubmit, onAutoCaption, autoBusy, autoStage, autoError, captionPrompt, onCaptionPromptChange, captionModel, onAutoTitle, titleBusy, titleStage, titleError, titlePrompt, onTitlePromptChange, onAutoTags, tagsBusy, tagsStage, tagsError, tagsPrompt, onTagsPromptChange, onCancelGen }) {
const CancelGen = () => (
<button type="button" onClick={onCancelGen}
className="relative shrink-0 rounded-full border-2 border-primary/40 bg-card/85 px-2.5 py-0.5 font-mono text-[10px] uppercase tracking-wide text-primary transition-colors hover:bg-accent hover:text-foreground">
Cancel
</button>
);
const [showPrompt, setShowPrompt] = useState(false);
const [showTitlePrompt, setShowTitlePrompt] = useState(false);
const [showTagsPrompt, setShowTagsPrompt] = useState(false);
const DEFAULT_PROMPT_HINT = "Write a short, warm one-sentence caption for a personal photo album.";
const DEFAULT_TITLE_HINT = "Write a very short, evocative title (2-5 words) for this photo.";
const DEFAULT_TAGS_HINT = "List 3 to 6 short, comma-separated tags for this photo.";
return (
<div className="flex flex-col gap-6">
{/* title */}
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<label htmlFor="title" className="font-mono text-xs font-semibold uppercase tracking-wide text-foreground">Title</label>
<div className="flex items-center gap-1.5">
<MicButton field="title" title="Dictate the title"
onText={(t) => onTitleChange(((title || "").trim() ? title.trim() + " " : "") + t)} />
<button type="button" onClick={() => setShowTitlePrompt((v) => !v)} aria-expanded={showTitlePrompt}
title="Customize the AI title prompt"
className={cn("flex items-center gap-1.5 rounded-md border-2 px-2.5 py-1 font-mono text-[11px] uppercase tracking-wide transition-colors",
showTitlePrompt ? "border-primary bg-accent text-foreground" : "border-primary/60 text-primary hover:bg-accent")}>
<Settings className="size-3.5" /> Prompt
</button>
<button type="button" onClick={onAutoTitle} disabled={titleBusy}
className="flex items-center gap-1.5 rounded-md border-2 border-primary/60 px-2.5 py-1 font-mono text-[11px] uppercase tracking-wide text-primary transition-colors hover:bg-accent disabled:opacity-60">
<Wand2 className={cn("size-3.5", titleBusy && "animate-pulse")} /> {titleBusy ? "Naming…" : "Auto-generate"}
</button>
</div>
</div>
{/* editable, independent title prompt */}
{showTitlePrompt ? (
<div className="rounded-md border-2 border-primary/50 bg-accent/30 p-3">
<div className="flex items-center justify-between gap-2">
<label htmlFor="title-prompt" className="font-mono text-[10px] font-semibold uppercase tracking-wide text-foreground">Title prompt</label>
<span className="flex items-center gap-1.5 font-mono text-[10px] uppercase tracking-wide text-muted-foreground" title="Active vision-language model">
<Cpu className="size-3 text-primary" /> {captionModel || "loading model…"}
</span>
</div>
<textarea id="title-prompt" value={titlePrompt} onChange={(e) => onTitlePromptChange(e.target.value)} rows={2}
placeholder={DEFAULT_TITLE_HINT}
className="mt-2 w-full resize-none rounded-md border-2 border-primary/40 bg-card px-2.5 py-2 text-xs leading-relaxed text-foreground outline-none placeholder:text-muted-foreground focus:border-primary" />
<div className="mt-1.5 flex items-center justify-between gap-2">
<p className="font-mono text-[9px] leading-snug tracking-wide text-muted-foreground">Separate from the caption prompt. Leave blank to use the default.</p>
{titlePrompt ? (
<button type="button" onClick={() => onTitlePromptChange("")}
className="shrink-0 font-mono text-[9px] uppercase tracking-widest text-primary underline-offset-2 hover:underline">reset</button>
) : null}
</div>
</div>
) : null}
<div className="relative rounded-md border-2 border-primary/70 bg-card">
<input
id="title"
value={title}
onChange={(e) => onTitleChange(e.target.value)}
placeholder="Name this moment…"
aria-label="Photo title"
className="w-full rounded-md bg-transparent px-3 py-2.5 font-hand text-lg text-foreground outline-none placeholder:font-normal placeholder:text-muted-foreground"
/>
{/* developing overlay - same darkroom effect as the caption */}
{titleBusy ? (
<div className="absolute inset-0 flex items-center justify-center gap-2 overflow-hidden rounded-md"
role="status" aria-live="polite"
style={{ background: "linear-gradient(180deg, var(--card), color-mix(in oklch, var(--accent) 60%, var(--card)))" }}>
<span aria-hidden="true" className="absolute inset-0"
style={{ background: "linear-gradient(110deg, transparent 20%, color-mix(in oklch, var(--primary) 22%, transparent) 50%, transparent 80%)", backgroundSize: "200% 100%", animation: "sozai-shimmer 1.3s linear infinite" }} />
<span className="relative flex items-center gap-2 font-mono text-[11px] uppercase tracking-widest text-primary">
<Wand2 className="size-3.5 animate-pulse" /> {titleStage || "Naming…"}
</span>
<span className="relative flex gap-1" aria-hidden="true">
<span className="size-1.5 rounded-full bg-primary" style={{ animation: "sozai-bounce 1s infinite", animationDelay: "0ms" }} />
<span className="size-1.5 rounded-full bg-primary" style={{ animation: "sozai-bounce 1s infinite", animationDelay: "150ms" }} />
<span className="size-1.5 rounded-full bg-primary" style={{ animation: "sozai-bounce 1s infinite", animationDelay: "300ms" }} />
</span>
<CancelGen />
</div>
) : null}
</div>
{titleError ? <p role="status" className="font-mono text-[11px] uppercase tracking-wide text-muted-foreground">{titleError}</p> : null}
</div>
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<label htmlFor="caption" className="font-mono text-xs font-semibold uppercase tracking-wide text-foreground">Caption</label>
<div className="flex items-center gap-1.5">
<MicButton field="caption" title="Dictate the caption"
onText={(t) => onCaptionChange(((caption || "").trim() ? caption.trim() + " " : "") + t)} />
<button type="button" onClick={() => setShowPrompt((v) => !v)} aria-expanded={showPrompt}
title="Customize the AI prompt"
className={cn("flex items-center gap-1.5 rounded-md border-2 px-2.5 py-1 font-mono text-[11px] uppercase tracking-wide transition-colors",
showPrompt ? "border-primary bg-accent text-foreground" : "border-primary/60 text-primary hover:bg-accent")}>
<Settings className="size-3.5" /> Prompt
</button>
<button type="button" onClick={onAutoCaption} disabled={autoBusy}
className="flex items-center gap-1.5 rounded-md border-2 border-primary/60 px-2.5 py-1 font-mono text-[11px] uppercase tracking-wide text-primary transition-colors hover:bg-accent disabled:opacity-60">
<Wand2 className={cn("size-3.5", autoBusy && "animate-pulse")} /> {autoBusy ? "Developing…" : "Auto-generate"}
</button>
</div>
</div>
{/* editable prompt + which model is in use */}
{showPrompt ? (
<div className="rounded-md border-2 border-primary/50 bg-accent/30 p-3">
<div className="flex items-center justify-between gap-2">
<label htmlFor="cap-prompt" className="font-mono text-[10px] font-semibold uppercase tracking-wide text-foreground">Caption prompt</label>
<span className="flex items-center gap-1.5 font-mono text-[10px] uppercase tracking-wide text-muted-foreground" title="Active vision-language model">
<Cpu className="size-3 text-primary" /> {captionModel || "loading model…"}
</span>
</div>
<textarea id="cap-prompt" value={captionPrompt} onChange={(e) => onCaptionPromptChange(e.target.value)} rows={2}
placeholder={DEFAULT_PROMPT_HINT}
className="mt-2 w-full resize-none rounded-md border-2 border-primary/40 bg-card px-2.5 py-2 text-xs leading-relaxed text-foreground outline-none placeholder:text-muted-foreground focus:border-primary" />
<div className="mt-1.5 flex items-center justify-between gap-2">
<p className="font-mono text-[9px] leading-snug tracking-wide text-muted-foreground">Steers how captions are written. Leave blank to use the default.</p>
{captionPrompt ? (
<button type="button" onClick={() => onCaptionPromptChange("")}
className="shrink-0 font-mono text-[9px] uppercase tracking-widest text-primary underline-offset-2 hover:underline">reset</button>
) : null}
</div>
</div>
) : null}
<div className="relative rounded-md border-2 border-primary/70 bg-card">
<textarea id="caption" value={caption} onChange={(e) => onCaptionChange(e.target.value)} rows={2}
placeholder="Write something about this moment..."
className="w-full resize-none rounded-md bg-transparent px-3 py-2.5 font-hand text-lg leading-relaxed text-foreground outline-none placeholder:text-muted-foreground" />
{/* developing overlay: a darkroom-flavored caption "developing in the tray" */}
{autoBusy ? (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2 overflow-hidden rounded-md"
role="status" aria-live="polite"
style={{ background: "linear-gradient(180deg, var(--card), color-mix(in oklch, var(--accent) 60%, var(--card)))" }}>
{/* sweeping "developer" shimmer */}
<span aria-hidden="true" className="absolute inset-0"
style={{ background: "linear-gradient(110deg, transparent 20%, color-mix(in oklch, var(--primary) 22%, transparent) 50%, transparent 80%)", backgroundSize: "200% 100%", animation: "sozai-shimmer 1.3s linear infinite" }} />
<span className="relative flex items-center gap-2 font-mono text-[11px] uppercase tracking-widest text-primary">
<Wand2 className="size-3.5 animate-pulse" /> {autoStage || "Developing…"}
</span>
<span className="relative flex gap-1" aria-hidden="true">
<span className="size-1.5 rounded-full bg-primary" style={{ animation: "sozai-bounce 1s infinite", animationDelay: "0ms" }} />
<span className="size-1.5 rounded-full bg-primary" style={{ animation: "sozai-bounce 1s infinite", animationDelay: "150ms" }} />
<span className="size-1.5 rounded-full bg-primary" style={{ animation: "sozai-bounce 1s infinite", animationDelay: "300ms" }} />
</span>
<CancelGen />
</div>
) : null}
</div>
{autoError ? <p role="status" className="font-mono text-[11px] uppercase tracking-wide text-muted-foreground">{autoError}</p> : null}
</div>
<div className="space-y-2">
<span className="font-mono text-xs font-semibold uppercase tracking-wide text-foreground">When &amp; Where</span>
{/* date + time share a row; location gets its own line (it bled before) */}
<div className="flex items-stretch divide-x-2 divide-primary/40 rounded-md border-2 border-primary/70 bg-card">
<div className="flex min-w-0 flex-1 items-center gap-2 px-3 py-2.5">
<Calendar className="size-4 shrink-0 text-primary" />
<input value={date} onChange={(e) => onDateChange(e.target.value)} aria-label="Date" placeholder="Date"
className="min-w-0 w-full bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground" />
</div>
<div className="flex min-w-0 flex-1 items-center gap-2 px-3 py-2.5">
<Clock className="size-4 shrink-0 text-primary" />
<input value={time} onChange={(e) => onTimeChange(e.target.value)} aria-label="Time" placeholder="Time"
className="min-w-0 w-full bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground" />
</div>
</div>
<div className="flex items-stretch rounded-md border-2 border-primary/70 bg-card">
<LocationField lat={lat} lon={lon} locationName={locationName} radius={radius} onChange={onLocationChange} />
</div>
</div>
</div>
);
}
// ----------------------------------------------------------------- //
// components/album-sidebar.tsx
// ----------------------------------------------------------------- //
function AlbumSidebar({ albums, selectedId, onSelect, onNewAlbum }) {
return (
<div className="flex flex-col rounded-md border-2 border-primary/70 bg-card p-4">
<span className="font-mono text-xs font-semibold uppercase tracking-wide text-foreground">Add to</span>
<button type="button" onClick={onNewAlbum}
className="mt-3 flex items-center gap-2.5 rounded-sm border-2 border-dashed border-primary/60 px-3 py-2.5 text-sm font-medium text-primary transition-colors hover:bg-accent">
<FolderPlus className="size-5" /><span className="font-mono uppercase tracking-wide">New Album</span>
</button>
<ul className="mt-3 flex flex-col gap-2">
{albums.map((album) => {
const selected = album.id === selectedId;
return (
<li key={album.id}>
<button type="button" onClick={() => onSelect(album.id)} aria-pressed={selected}
className={cn("flex w-full items-center gap-3 rounded-sm border-2 px-2 py-2 text-left transition-colors", selected ? "border-primary bg-accent" : "border-transparent hover:bg-accent/60")}>
<span className="relative size-12 shrink-0 overflow-hidden rounded-sm border-2 border-primary/70"><Img src={album.thumbnail} alt="" fill className="object-cover" sizes="48px" /></span>
<span className="min-w-0">
<span className="block truncate font-mono text-sm font-semibold uppercase tracking-wide text-foreground">{album.name}</span>
<span className="block text-xs text-muted-foreground">{album.photoCount} photos</span>
</span>
</button>
</li>
);
})}
</ul>
</div>
);
}
// ----------------------------------------------------------------- //
// components/add-to-album.tsx (+ shared field sync)
// ----------------------------------------------------------------- //
const DEFAULT_DATE = "June 7, 2026";
const DEFAULT_TIME = "10:42 AM";
function AddToAlbum({ onAdded, onOpenSettings, onOpenPets, mapVariant, setMapVariant, stackPhotos, setStackPhotos }) {
const { settings, update } = useSettings();
const [captionModel, setCaptionModel] = useState("");
// Ask the backend which vision-language model is active, to show in the UI.
useEffect(() => {
let alive = true;
(async () => {
try {
const gr = await window.__gradioReady;
if (!gr) return;
const res = await gr.predict("/caption_model", {});
const out = Array.isArray(res.data) ? res.data[0] : res.data;
if (alive && out) setCaptionModel(String(out).trim());
} catch (e) {}
})();
return () => { alive = false; };
}, []);
const setCaptionPrompt = (val) => update({ captionPrompt: val });
const setTitlePrompt = (val) => update({ titlePrompt: val });
const addPhotoRef = useRef(null);
const [destroying, setDestroying] = useState(false); // polaroid teardown animation in progress
const removingRef = useRef(false); // blocks re-entry from a second click
const removeTokenRef = useRef(0); // increments per click; identifies the active removal
const doneTokenRef = useRef(-1); // the token already handled (prevents double-remove)
const pendingRemoveRef = useRef(null); // index captured when the X was pressed
const [photoIndex, setPhotoIndex] = useState(0);
const [slideDir, setSlideDir] = useState("right"); // polaroid slide direction when browsing
const [tags, setTags] = useState("ottawa, architecture, history, travel");
const [selectedAlbum] = useState("vancouver");
const [confirmation, setConfirmation] = useState(null);
const [validationError, setValidationError] = useState(null);
const [autoBusy, setAutoBusy] = useState(false);
const [autoStage, setAutoStage] = useState("");
const [autoError, setAutoError] = useState(null);
const [titleBusy, setTitleBusy] = useState(false);
const [titleError, setTitleError] = useState(null);
const [titleStage, setTitleStage] = useState("");
const [tagsBusy, setTagsBusy] = useState(false);
const [tagsError, setTagsError] = useState(null);
const [tagsStage, setTagsStage] = useState("");
// remote field edits from collaborators - apply by photo index onto the
// single stackPhotos source of truth.
useEffect(() => Collab.on("object_updated", (m) => {
if (typeof m.index !== "number" || !m.kind) return;
setStackPhotos((prev) => prev.map((p, i) => (i === m.index ? { ...p, [m.kind]: m.value } : p)));
}), []);
// follow a collaborator to the photo they switch to (when follow is on)
useEffect(() => Collab.on("active_image", (m) => {
if (window.__sozaiFollow !== false && typeof m.index === "number") setPhotoIndex(m.index);
}), []);
// Keep photoIndex valid whenever the photo set changes (removals, adds,
// collab edits). This is the single source of truth that prevents
// `photo` from ever being undefined and blanking the card.
useEffect(() => {
if (stackPhotos.length === 0) return;
if (photoIndex > stackPhotos.length - 1) {
setPhotoIndex(stackPhotos.length - 1);
} else if (photoIndex < 0) {
setPhotoIndex(0);
}
}, [stackPhotos.length, photoIndex]);
const safeIndex = stackPhotos.length ? Math.min(Math.max(photoIndex, 0), stackPhotos.length - 1) : 0;
const photo = stackPhotos[safeIndex];
// title/caption/date/time now live ON the photo object - one source of
// truth, so they can never desync from the photo list (which was the bug
// behind the wrong counter + jumping to empty after one removal).
const title = (photo && photo.title != null) ? photo.title : "";
const caption = (photo && photo.caption != null) ? photo.caption : "";
const date = (photo && photo.date != null && photo.date !== "") ? photo.date : DEFAULT_DATE;
const time = (photo && photo.time != null && photo.time !== "") ? photo.time : DEFAULT_TIME;
// Seed the shared photo store with the album photos + their fields so the
// Map of My Time draft tray can pick and pin them (persisted).
useEffect(() => {
stackPhotos.forEach((p) => {
window.SozaiPhotos.upsert(p.id, {
src: p.src, alt: p.alt || "",
title: p.title || "", caption: p.caption || "",
date: p.date || "", time: p.time || "",
});
});
}, []); // eslint-disable-line
// Edit a field on the current photo: updates the single source of truth,
// broadcasts to collaborators, and mirrors to the shared store.
function makeFieldSetter(kind) {
return (value) => {
setStackPhotos((prev) => prev.map((p, i) => (i === safeIndex ? { ...p, [kind]: value } : p)));
Collab.send("object_updated", { kind, index: safeIndex, value });
const pid = photo && photo.id;
if (pid) window.SozaiPhotos.upsert(pid, { [kind]: value });
};
}
const setTitle = makeFieldSetter("title");
const setCaption = makeFieldSetter("caption");
const setDate = makeFieldSetter("date");
const setTime = makeFieldSetter("time");
// The current photo's location (general area), edited via the radius map.
const lat = (photo && photo.lat != null) ? photo.lat : null;
const lon = (photo && photo.lon != null) ? photo.lon : null;
const locationName = (photo && photo.locationName) || "";
const radius = (photo && photo.radius != null) ? photo.radius : null;
// A location edit touches several fields at once (lat/lon/name/radius +
// the pinned flag) - mirror the whole patch to the photo and shared store.
const setLocation = (patch) => {
setStackPhotos((prev) => prev.map((p, i) => (i === safeIndex ? { ...p, ...patch } : p)));
const pid = photo && photo.id;
if (pid) window.SozaiPhotos.upsert(pid, patch);
Collab.send("object_updated", { kind: "location", index: safeIndex, value: patch });
};
// reposition the focal point of the current photo (object-position %),
// saved on the photo + shared store so it persists.
function onReposition(fx, fy) {
setStackPhotos((prev) => prev.map((p, i) => (i === safeIndex ? { ...p, focusX: fx, focusY: fy } : p)));
const pid = photo && photo.id;
if (pid) window.SozaiPhotos.upsert(pid, { focusX: fx, focusY: fy });
}
// tags are a single string for the active photo; mirror them onto the
// shared store (by photo id) so they persist and show up in exports.
const setTagsSynced = (value) => {
setTags(value);
const pid = stackPhotos[safeIndex] && stackPhotos[safeIndex].id;
if (pid) window.SozaiPhotos.upsert(pid, { tags: value });
};
async function onAddPhotos(e) {
const files = Array.from(e.target.files || []);
e.target.value = "";
const base = stackPhotos.length;
// enforce the top-level image cap
if (base >= MAX_IMAGES) {
window.alert("You cannot upload more than " + MAX_IMAGES + " images.");
return;
}
const room = MAX_IMAGES - base; // how many more we can accept
let filesToAdd = files;
if (files.length > room) {
filesToAdd = files.slice(0, room);
window.alert("You cannot upload more than " + MAX_IMAGES + " images. "
+ "Only the first " + room + " of your " + files.length + " selected image"
+ (files.length === 1 ? "" : "s") + " were added.");
}
let added = 0;
for (const file of filesToAdd) {
// stop if we somehow hit the cap mid-batch
if (base + added >= MAX_IMAGES) break;
// shared pipeline: validates .png/.jpg/.jpeg, runs NSFW, shows overlay
const result = await processUpload(file);
if (!result.ok) continue; // rejected (bad type / nsfw / unreadable)
const dataUrl = result.dataUrl;
const exif = await readExif(file);
const newPhoto = {
src: dataUrl, alt: file.name.replace(/\.[^.]+$/, ""),
title: "", caption: "",
date: exif.date || DEFAULT_DATE, time: exif.time || DEFAULT_TIME,
...(exif.lat != null ? { lat: exif.lat, lon: exif.lon, pinned: true } : {}),
};
const id = window.SozaiPhotos.add(newPhoto);
newPhoto.id = id;
setStackPhotos((prev) => [...prev, newPhoto]);
added++;
// resolve a readable "general area" for the EXIF GPS, in the background
if (exif.lat != null) {
reverseGeocode(exif.lat, exif.lon).then((nm) => {
if (!nm) return;
window.SozaiPhotos.upsert(id, { locationName: nm });
setStackPhotos((prev) => prev.map((p) => (p.id === id ? { ...p, locationName: nm } : p)));
});
}
}
if (added > 0) setPhotoIndex(base + added - 1);
}
// add a built-in sample photo (no upload / NSFW needed - trusted asset)
function onAddSample(s) {
const base = stackPhotos.length;
if (base >= MAX_IMAGES) {
window.alert("You cannot upload more than " + MAX_IMAGES + " images.");
return;
}
const newPhoto = {
src: s.src, alt: s.alt || "",
title: s.title || "", caption: s.caption || "",
date: DEFAULT_DATE, time: DEFAULT_TIME,
};
const id = window.SozaiPhotos.add(newPhoto);
newPhoto.id = id;
setStackPhotos((prev) => [...prev, newPhoto]);
setPhotoIndex(base);
}
// "Try a sample" — load the whole built-in photo set at once (with their
// real metadata + GPS), into both the stack and the shared store.
function onLoadSamples() {
const loaded = photos.slice(0, MAX_IMAGES).map((p) => ({
id: p.id, src: p.src, alt: p.alt || "",
title: p.defaultTitle || "", caption: p.defaultCaption || "",
date: p.date || DEFAULT_DATE, time: p.time || DEFAULT_TIME,
lat: p.lat != null ? p.lat : null, lon: p.lon != null ? p.lon : null,
}));
loaded.forEach((p) => window.SozaiPhotos.upsert(p.id, {
src: p.src, alt: p.alt, title: p.title, caption: p.caption,
date: p.date, time: p.time, lat: p.lat, lon: p.lon,
pinned: p.lat != null && p.lon != null,
}));
setStackPhotos(loaded);
setPhotoIndex(0);
}
// Release the one-removal-per-click guard only AFTER a removal has
// actually committed (the photo count changed) and re-rendered.
const prevLenRef = useRef(stackPhotos.length);
useEffect(() => {
if (stackPhotos.length !== prevLenRef.current) {
prevLenRef.current = stackPhotos.length;
}
}, [stackPhotos.length]);
function onRemovePhoto() {
if (stackPhotos.length < 1) return; // nothing to remove
if (destroying || removingRef.current) return;
const idx = safeIndex; // capture the photo being removed NOW
const token = ++removeTokenRef.current; // unique id for THIS click
removingRef.current = true; // blocks a second click until this completes
pendingRemoveRef.current = idx;
if (window.__sozaiReduceMotion) { doRemovePhoto(idx, token); return; }
setDestroying(true); // PhotoStack adds the teardown class; onAnimationEnd → doRemovePhoto
// Safety net: if animationend never fires, remove anyway. Both the
// animationend handler and this timer call doRemovePhoto with the SAME
// token; doRemovePhoto runs once per token, so the later one is a no-op
// no matter how late it arrives (slow device, delayed animationend, …).
setTimeout(() => doRemovePhoto(idx, token), 700);
}
function doRemovePhoto(idx, token) {
// Accept only the first call for the current click's token. A call with
// no/old token (e.g. a stale animationend from a previous click) is
// ignored - this is what prevents double-removal regardless of timing.
if (typeof token !== "number") token = removeTokenRef.current;
if (token !== removeTokenRef.current) return; // stale → ignore
if (doneTokenRef.current === token) return; // already handled this click
doneTokenRef.current = token;
if (typeof idx !== "number") idx = pendingRemoveRef.current;
if (typeof idx !== "number") idx = safeIndex;
const removedId = stackPhotos[idx] && stackPhotos[idx].id;
if (removedId) window.SozaiPhotos.remove(removedId);
setStackPhotos((prev) => prev.filter((_, i) => i !== idx));
setPhotoIndex((prev) => {
const next = prev > idx ? prev - 1 : prev;
return Math.max(0, next);
});
setDestroying(false);
removingRef.current = false; // allow the next click immediately
}
// A run of any auto-generation registers a token here. The photo browser
// is locked while it's set (so a result can't land on the wrong photo),
// and Cancel flips `cancelled` + clears the timers so the async result is
// dropped instead of written back.
const genRef = useRef(null);
const genBusy = autoBusy || titleBusy || tagsBusy;
function cancelGeneration() {
const g = genRef.current;
if (g) { g.cancelled = true; g.timers.forEach((t) => { clearInterval(t); clearTimeout(t); }); }
genRef.current = null;
setAutoBusy(false); setAutoStage("");
setTitleBusy(false); setTitleStage("");
setTagsBusy(false); setTagsStage("");
}
function changePhoto(delta) {
if (genBusy) return; // locked while developing
if (stackPhotos.length <= 1) return; // nothing to browse
setSlideDir(delta > 0 ? "right" : "left");
setPhotoIndex((prev) => {
const next = (prev + delta + stackPhotos.length) % stackPhotos.length;
Collab.send("active_image", { index: next });
return next;
});
setConfirmation(null);
setAutoError(null);
}
// Auto-generate a caption for the current photo. Calls the backend
// /autocaption endpoint (MiniCPM vision-language model) with the user's
// optional custom prompt. While it runs, a darkroom-flavored sequence of
// status lines keeps the user in the loop. On failure / offline use, it
// falls back to a sensible local suggestion so the button always works.
const AUTO_STAGES = [
"Loading the negative…",
"Reading the light…",
"Letting it soak…",
"Finding the words…",
"Almost developed…",
];
const TITLE_STAGES = [
"Loading the negative…",
"Reading the light…",
"Naming the moment…",
"Almost there…",
];
async function handleAutoCaption() {
if (autoBusy) return;
if (!photo) return;
if (window.sozaiPing) window.sozaiPing("✨ auto-generating caption");
setAutoBusy(true); setAutoError(null);
const token = { cancelled: false, timers: [] };
genRef.current = token;
let stageIdx = 0;
setAutoStage(AUTO_STAGES[0]);
const stageTimer = setInterval(() => {
stageIdx = Math.min(stageIdx + 1, AUTO_STAGES.length - 1);
setAutoStage(AUTO_STAGES[stageIdx]);
}, 900);
token.timers.push(stageTimer);
const started = Date.now();
const finish = (fn) => {
// ensure the lovely animation is visible for at least a beat
const wait = Math.max(0, 700 - (Date.now() - started));
const t = setTimeout(() => {
clearInterval(stageTimer);
if (token.cancelled) return; // cancelled mid-flight: drop it
fn(); setAutoBusy(false); setAutoStage("");
if (genRef.current === token) genRef.current = null;
}, wait);
token.timers.push(t);
};
try {
const gr = await window.__gradioReady;
if (gr) {
const res = await gr.predict("/autocaption", {
image_path: photo.src, alt: photo.alt || "", prompt: settings.captionPrompt || "",
});
const out = Array.isArray(res.data) ? res.data[0] : res.data;
if (out && String(out).trim()) { finish(() => setCaption(String(out).trim())); return; }
}
throw new Error("no-backend");
} catch (e) {
const base = (photo.alt || photo.defaultTitle || "this moment").replace(/\.$/, "");
finish(() => {
setCaption(base.charAt(0).toUpperCase() + base.slice(1) + ".");
setAutoError("Used an offline suggestion - connect the caption model for AI captions.");
});
}
}
async function handleAutoTitle() {
if (titleBusy) return;
if (!photo) return;
if (window.sozaiPing) window.sozaiPing("✨ auto-generating title");
setTitleBusy(true); setTitleError(null);
const token = { cancelled: false, timers: [] };
genRef.current = token;
let stageIdx = 0;
setTitleStage(TITLE_STAGES[0]);
const stageTimer = setInterval(() => {
stageIdx = Math.min(stageIdx + 1, TITLE_STAGES.length - 1);
setTitleStage(TITLE_STAGES[stageIdx]);
}, 900);
token.timers.push(stageTimer);
const started = Date.now();
const finish = (fn) => {
const wait = Math.max(0, 700 - (Date.now() - started));
const t = setTimeout(() => {
clearInterval(stageTimer);
if (token.cancelled) return;
fn(); setTitleBusy(false); setTitleStage("");
if (genRef.current === token) genRef.current = null;
}, wait);
token.timers.push(t);
};
// a title-specific default so a blank prompt still yields a *title*,
// kept independent from the caption prompt.
const titleInstruction = (settings.titlePrompt || "").trim();
try {
const gr = await window.__gradioReady;
if (gr) {
const res = await gr.predict("/autocaption", {
image_path: photo.src, alt: photo.alt || "", prompt: titleInstruction, kind: "title",
});
let out = Array.isArray(res.data) ? res.data[0] : res.data;
out = String(out || "").trim().split("\n")[0].replace(/^["']|["']$/g, "").trim();
if (out) { finish(() => setTitle(out)); return; }
}
throw new Error("no-backend");
} catch (e) {
const base = (photo.alt || photo.defaultTitle || "this moment").replace(/\.$/, "");
const words = base.split(/\s+/).slice(0, 4).join(" ");
finish(() => {
setTitle(words.charAt(0).toUpperCase() + words.slice(1));
setTitleError("Used an offline suggestion - connect the caption model for AI titles.");
});
}
}
// Tags auto-generate. Whatever the model returns (MiniCPM, or a
// dictated/Nemotron source) MUST be comma-separated - we normalize it and
// ERROR OUT if it isn't, rather than silently writing garbage tags.
const TAGS_STAGES = ["Loading the negative…", "Reading the light…", "Sorting the keywords…", "Almost there…"];
function normalizeTagsClient(s) {
if (!s) return "";
const parts = String(s).split(/[,\n;•·]+/);
const seen = new Set(); const out = [];
for (let p of parts) {
let tag = p.replace(/^[\s\d.)(]+/, "").trim().replace(/^["']|["']$/g, "").toLowerCase().replace(/\s+/g, " ");
if (tag && !seen.has(tag) && tag.length <= 40) { seen.add(tag); out.push(tag); }
}
return out.join(", ");
}
function isCommaSeparatedClient(s) {
const t = (s || "").trim();
if (!t) return false;
if (t.includes(",")) return t.split(",").every((seg) => seg.trim().length > 0);
// a single tag is allowed; but a long multi-word phrase with no commas
// is treated as NOT valid tag output
return t.split(/\s+/).length <= 3;
}
async function handleAutoTags() {
if (tagsBusy) return;
if (!photo) return;
if (window.sozaiPing) window.sozaiPing("🏷️ auto-generating tags");
setTagsBusy(true); setTagsError(null);
const token = { cancelled: false, timers: [] };
genRef.current = token;
let stageIdx = 0;
setTagsStage(TAGS_STAGES[0]);
const stageTimer = setInterval(() => {
stageIdx = Math.min(stageIdx + 1, TAGS_STAGES.length - 1);
setTagsStage(TAGS_STAGES[stageIdx]);
}, 900);
token.timers.push(stageTimer);
const started = Date.now();
const finish = (fn) => {
const wait = Math.max(0, 700 - (Date.now() - started));
const t = setTimeout(() => {
clearInterval(stageTimer);
if (token.cancelled) return;
fn(); setTagsBusy(false); setTagsStage("");
if (genRef.current === token) genRef.current = null;
}, wait);
token.timers.push(t);
};
try {
const gr = await window.__gradioReady;
if (gr) {
const res = await gr.predict("/autocaption", {
image_path: photo.src, alt: photo.alt || "", prompt: settings.tagsPrompt || "", kind: "tags",
});
let out = Array.isArray(res.data) ? res.data[0] : res.data;
const normalized = normalizeTagsClient(out);
if (!isCommaSeparatedClient(normalized)) {
// generated output wasn't valid comma-separated tags → error out
finish(() => setTagsError("Generated tags weren't comma-separated. Try again or edit manually."));
return;
}
finish(() => { setTags(normalized); });
return;
}
throw new Error("no-backend");
} catch (e) {
// offline fallback derived from alt text - still comma-separated
const base = (photo.alt || photo.defaultTitle || "").toLowerCase();
const words = base.split(/[^a-z0-9]+/).filter((w) => w.length > 2).slice(0, 5);
const fb = normalizeTagsClient(words.join(", "));
finish(() => {
if (isCommaSeparatedClient(fb)) {
setTags(fb);
setTagsError("Used an offline suggestion - connect the model for AI tags.");
} else {
setTagsError("Couldn't generate comma-separated tags. Please add them manually.");
}
});
}
}
async function handleSubmit() {
// ---- required-field validation (item: form validation) ----
const problems = [];
if (!photo || !photo.src) problems.push("Add at least one photo.");
if (!(title || "").trim()) problems.push("Title can't be empty.");
// caption is optional
if (!(date || "").trim()) problems.push("Date can't be empty.");
if (!(time || "").trim()) problems.push("Time can't be empty.");
// tags are optional, but if provided they must be comma-separated
if ((tags || "").trim() && !isCommaSeparatedClient(normalizeTagsClient(tags))) {
problems.push("Tags must be comma-separated.");
}
if (problems.length) {
setValidationError(problems.join(" "));
setConfirmation(null);
return;
}
setValidationError(null);
// Push the album photos into the shared store so the MAP (and any live
// room) actually receives them. stackPhotos and SozaiPhotos are separate,
// and joining a room clears the store's seed — without this the next page
// shows "0 photos". GPS photos auto-pin onto the globe like the seed does.
stackPhotos.forEach((p) => window.SozaiPhotos.upsert(p.id, {
src: p.src, alt: p.alt || "",
title: p.title || p.defaultTitle || "",
caption: p.caption || p.defaultCaption || "",
date: p.date || "", time: p.time || "",
lat: p.lat != null ? p.lat : null, lon: p.lon != null ? p.lon : null,
pinned: p.lat != null && p.lon != null,
focusX: p.focusX, focusY: p.focusY,
}));
if (window.sozaiPing) window.sozaiPing("📥 added to album");
const album = albums.find((a) => a.id === selectedAlbum);
try {
const gr = await window.__gradioReady;
if (gr) {
const res = await gr.predict("/save_to_album", { album: album ? album.name : "", caption, tags });
const msg = Array.isArray(res.data) ? res.data[0] : res.data;
setConfirmation(msg);
onAdded && onAdded();
return;
}
} catch (e) {}
// Album is optional - never block developing on it.
setConfirmation(album ? `Added to "${album.name}".` : "Photo developed.");
onAdded && onAdded();
}
return (
<section className="w-full max-w-6xl rounded-xl border-2 border-primary bg-card shadow-lg">
<header className="flex items-center justify-between gap-4 border-b-2 border-dashed border-[#f5edd8]/30 bg-[#13294b] px-5 py-4 sm:px-7">
<div className="flex items-center gap-2.5"><h1 className="font-display text-2xl text-[#f5edd8]">Develop Photos</h1></div>
<div className="flex items-center gap-2">
<button type="button" onClick={() => onOpenSettings && onOpenSettings()} aria-label="Settings" className="flex size-9 items-center justify-center rounded-md border-2 border-[#f5edd8]/50 text-[#f5edd8] transition-colors hover:bg-white/10"><Settings className="size-4" /></button>
</div>
</header>
<div className="grid gap-8 px-5 py-6 sm:px-7 lg:grid-cols-2">
<div className="flex flex-col items-center gap-4">
<PhotoStack
photo={photo} index={safeIndex} total={stackPhotos.length} slideDir={slideDir}
destroying={destroying} onDestroyed={doRemovePhoto}
onAdd={() => addPhotoRef.current && addPhotoRef.current.click()}
onAddSample={onAddSample}
onLoadSamples={onLoadSamples}
onReposition={onReposition}
onDelete={onRemovePhoto}
title={title} caption={caption} date={date} time={time}
onTitleChange={setTitle} onCaptionChange={setCaption}
onDateChange={setDate} onTimeChange={setTime} />
<input ref={addPhotoRef} type="file" accept={ACCEPT_ATTR} multiple className="hidden" onChange={onAddPhotos} />
{stackPhotos.length >= 1 && (
<div className="mt-3 flex w-full max-w-xs items-center justify-center gap-3">
<button type="button" onClick={() => changePhoto(-1)} disabled={genBusy || stackPhotos.length <= 1}
aria-label="Previous photo" title={genBusy ? "Developing… cancel to browse" : "Previous photo"}
className="flex size-10 shrink-0 items-center justify-center rounded-full border-2 border-primary text-primary transition-colors hover:bg-accent disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent"><ChevronLeft className="size-4" /></button>
<button type="button" onClick={() => addPhotoRef.current && addPhotoRef.current.click()}
className="flex flex-1 items-center justify-center gap-2 rounded-full border-2 border-primary bg-primary px-4 py-2.5 font-mono text-xs font-semibold uppercase tracking-widest text-primary-foreground transition-colors hover:bg-primary/90">
<Plus className="size-4" /> Upload
</button>
<button type="button" onClick={() => changePhoto(1)} disabled={genBusy || stackPhotos.length <= 1}
aria-label="Next photo" title={genBusy ? "Developing… cancel to browse" : "Next photo"}
className="flex size-10 shrink-0 items-center justify-center rounded-full border-2 border-primary text-primary transition-colors hover:bg-accent disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent"><ChevronRight className="size-4" /></button>
</div>
)}
{/* How this works - matches the polaroid width, all-mono */}
<div className="mt-2 w-full max-w-xs rounded-lg border-2 border-dashed border-primary/30 bg-card/50 px-4 py-3">
<h3 className="mb-2 font-mono text-xs font-semibold uppercase tracking-widest text-primary">How this works</h3>
<ol className="space-y-2 font-mono text-xs leading-snug text-muted-foreground">
<li><span className="font-semibold text-foreground">1.</span> Upload up to 8 photos, solo or live with your friends.</li>
<li><span className="font-semibold text-foreground">2.</span> Caption your photos, or let AI decide for you.</li>
<li><span className="font-semibold text-foreground">3.</span> Wait for your watercolour renditions to process.</li>
<li><span className="font-semibold text-foreground">4.</span> Reveal your new photos and arrange them on up to 6 canvases.</li>
</ol>
</div>
</div>
<div className="flex flex-col gap-6">
<DetailsForm
title={title} onTitleChange={setTitle}
caption={caption} onCaptionChange={setCaption}
date={date} onDateChange={setDate}
time={time} onTimeChange={setTime}
lat={lat} lon={lon} locationName={locationName} radius={radius} onLocationChange={setLocation}
tags={tags} onTagsChange={setTagsSynced}
onSubmit={handleSubmit}
onAutoCaption={handleAutoCaption} autoBusy={autoBusy} autoStage={autoStage} autoError={autoError}
captionPrompt={settings.captionPrompt || ""} onCaptionPromptChange={setCaptionPrompt} captionModel={captionModel}
onAutoTitle={handleAutoTitle} titleBusy={titleBusy} titleStage={titleStage} titleError={titleError}
titlePrompt={settings.titlePrompt || ""} onTitlePromptChange={setTitlePrompt}
onAutoTags={handleAutoTags} tagsBusy={tagsBusy} tagsStage={tagsStage} tagsError={tagsError}
tagsPrompt={settings.tagsPrompt || ""} onTagsPromptChange={(v) => update({ tagsPrompt: v })}
onCancelGen={cancelGeneration} />
{/* choose how the Map of My Time will look once developed */}
<MapStylePicker variant={mapVariant} onChange={setMapVariant} />
{validationError ? <p role="alert" className="rounded-md border-2 border-[oklch(0.55_0.18_25)]/50 bg-[oklch(0.55_0.18_25)]/10 px-3 py-2 text-center font-mono text-[11px] uppercase tracking-wide text-[oklch(0.5_0.18_25)]">{validationError}</p> : null}
{confirmation ? <p role="status" className="rounded-md border-2 border-primary/40 bg-accent px-3 py-2 text-center text-sm text-accent-foreground">{confirmation}</p> : null}
{/* develop = advance to the darkroom, so an arrow rather than a plus */}
<button type="button" onClick={handleSubmit}
className="flex w-full items-center justify-center gap-2 rounded-md border-2 border-primary bg-primary px-4 py-3 font-mono text-sm font-semibold uppercase tracking-wide text-primary-foreground transition-colors hover:bg-primary/90">
Develop Photos <ArrowRight className="size-4" />
</button>
</div>
</div>
</section>
);
}
// ----------------------------------------------------------------- //
// components/map/side-rail.tsx
// ----------------------------------------------------------------- //
const railItems = [
{ id: "camera", label: "Camera", icon: Camera }, { id: "map", label: "Map", icon: MapIcon },
];
function SideRail({ active = "map", onNavigate, onOpenSettings, onOpenPets }) {
return (
<nav aria-label="Primary" className="flex shrink-0 flex-row items-center justify-between border-b-2 border-dashed border-primary/40 px-4 py-3 sm:flex-col sm:justify-between sm:gap-6 sm:border-b-0 sm:border-r-2 sm:px-3 sm:py-6">
<ul className="flex flex-row items-center gap-4 sm:flex-col sm:gap-6">
{railItems.map(({ id, label, icon: IconC }) => {
const isActive = id === active;
return (
<li key={id}>
<button type="button" onClick={() => onNavigate && onNavigate(id)} aria-current={isActive ? "page" : undefined}
className={cn("flex flex-col items-center gap-1 text-primary/70 transition-colors hover:text-primary sm:gap-1.5", isActive && "text-primary")}>
<span className={cn("flex size-9 items-center justify-center rounded-md border-2 border-transparent sm:size-10", isActive && "border-primary bg-accent")}><IconC className="size-4 sm:size-5" /></span>
<span className="hidden font-mono text-[10px] font-semibold uppercase tracking-wide sm:block">{label}</span>
</button>
</li>
);
})}
</ul>
<div className="flex flex-row items-center gap-4 sm:flex-col sm:gap-6">
<button type="button" onClick={() => onOpenPets && onOpenPets()} className="flex flex-col items-center gap-1 text-primary/70 transition-colors hover:text-primary sm:gap-1.5">
<span className="flex size-9 items-center justify-center sm:size-10"><PawPrint className="size-4 sm:size-5" /></span>
<span className="hidden font-mono text-[10px] font-semibold uppercase tracking-wide sm:block">Pets</span>
</button>
<button type="button" onClick={() => onOpenSettings && onOpenSettings()} className="flex flex-col items-center gap-1 text-primary/70 transition-colors hover:text-primary sm:gap-1.5">
<span className="flex size-9 items-center justify-center sm:size-10"><Settings className="size-4 sm:size-5" /></span>
<span className="hidden font-mono text-[10px] font-semibold uppercase tracking-wide sm:block">Settings</span>
</button>
</div>
</nav>
);
}
// ----------------------------------------------------------------- //
// components/map/moment-card.tsx
// ----------------------------------------------------------------- //
function MomentCard({ moment, selected, onSelect, rotate = "" }) {
return (
<button type="button" onClick={() => onSelect && onSelect(moment.id)} style={{ left: `${moment.x}%`, top: `${moment.y}%` }}
className={cn("group absolute z-10 w-44 -translate-x-1/2 -translate-y-1/2 rounded-sm border-2 border-primary bg-card p-2 text-left shadow-md transition-transform hover:z-20 hover:scale-105 sm:w-52", rotate, selected && "z-20 ring-4 ring-primary/30")}>
<span aria-hidden="true" className="absolute -top-2.5 left-1/2 h-4 w-12 -translate-x-1/2 -rotate-2 rounded-sm bg-primary/15" />
<div className="relative aspect-square w-full overflow-hidden rounded-sm border border-primary/30"><Img src={moment.src} alt={moment.alt} fill sizes="208px" className="object-cover [filter:saturate(0.85)_contrast(1.05)]" /></div>
<div className="px-0.5 pb-0.5 pt-2">
<div className="flex items-start justify-between gap-1">
<h3 className="font-serif text-xs font-semibold leading-tight text-foreground">{moment.title}</h3>
{moment.favorite ? <Star className="mt-0.5 size-3.5 shrink-0 fill-primary text-primary" /> : null}
</div>
<p className="mt-1 font-hand text-[12px] leading-snug text-muted-foreground">{moment.caption}</p>
<p className="mt-1.5 font-mono text-[10px] text-primary/70">{moment.date}</p>
</div>
</button>
);
}
// ----------------------------------------------------------------- //
// components/map/timeline.tsx
// ----------------------------------------------------------------- //
function Timeline({ date, moments, selectedId, onSelect }) {
// Order chronologically by calendar DATE (photos span multiple years) and
// label each stop with its date - not the literal time of day.
const shortDate = (s) => { const d = new Date(s); return isNaN(d) ? (s || "") : d.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" }); };
const dtMs = (m) => (new Date((m.date || "") + " " + (m.time || "")).getTime() || new Date(m.date).getTime() || 0);
const sorted = (moments || []).slice().sort((a, b) => dtMs(a) - dtMs(b));
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-4">
<h2 className="font-serif text-sm font-semibold text-foreground">{date}</h2>
<span className="font-serif text-xs italic text-muted-foreground">your journey, in order</span>
</div>
<div className="relative">
<span aria-hidden="true" className="absolute left-0 right-0 top-3 h-0.5 bg-primary/30" />
<ol className="relative flex items-start justify-between">
{sorted.map((m, i) => {
const isActive = m.id === selectedId;
return (
<li key={m.id} className="flex flex-1 flex-col items-center gap-2">
<button type="button" onClick={() => onSelect && onSelect(m.id)} aria-current={isActive ? "true" : undefined} aria-label={`${m.title} on ${shortDate(m.date)}`}
className={cn("z-10 flex size-6 items-center justify-center rounded-full border-2 border-primary bg-card font-mono text-xs font-bold text-primary transition-colors hover:bg-accent", isActive && "bg-primary text-primary-foreground")}>{i + 1}</button>
<span className="text-center font-mono text-[10px] leading-tight text-muted-foreground">{shortDate(m.date)}<br />{m.time}</span>
</li>
);
})}
</ol>
</div>
</div>
);
}
// ----------------------------------------------------------------- //
// components/map/map-of-my-time.tsx (+ shared selection)
// ----------------------------------------------------------------- //
const rotations = ["-rotate-2", "rotate-1", "-rotate-1", "rotate-2", "-rotate-1", "rotate-1", "-rotate-2"];
function HeaderButton({ icon: IconC, label, onClick }) {
return (
<button type="button" onClick={onClick} className="flex items-center gap-2 rounded-md border-2 border-primary/70 px-3 py-1.5 font-mono text-xs uppercase tracking-wide text-foreground transition-colors hover:bg-accent">
<IconC className="size-3.5 text-primary" />{label}
</button>
);
}
function RouteLines({ moments: momentList }) {
const list = momentList || moments;
const points = list.map((m) => `${m.x},${m.y}`).join(" ");
return (
<svg aria-hidden="true" viewBox="0 0 100 100" preserveAspectRatio="none" className="pointer-events-none absolute inset-0 h-full w-full">
<polyline points={points} fill="none" stroke="oklch(0.5 0.21 268)" strokeWidth="0.4" strokeDasharray="1.5 1.5" strokeLinecap="round" opacity="0.5" />
</svg>
);
}
function Legend() {
return (
<div className="flex flex-col gap-3 rounded-lg border-2 border-primary/40 bg-card p-4">
<h3 className="font-serif text-sm font-semibold text-foreground">Legend</h3>
<ul className="flex flex-col gap-2 font-serif text-sm italic text-muted-foreground">
<li className="flex items-center gap-2"><MapPin className="size-3.5 shrink-0 text-primary" />moments</li>
<li className="flex items-center gap-2"><span aria-hidden="true" className="w-3.5 shrink-0 border-t-2 border-dashed border-primary" />route</li>
</ul>
<div className="mt-1 border-t border-primary/20 pt-2 font-serif text-xs italic leading-relaxed text-muted-foreground">{tripMeta.momentCount} moments · {tripMeta.photoCount} photos</div>
</div>
);
}
// ----------------------------------------------------------------- //
// Map of My Time - multiple browsable layout variants.
//
// The "map" family below renders a real, interactive map of a city the
// user picks, in the spirit of the Cambridge watercolor demo - but built
// entirely on FREE, OPEN-SOURCE pieces instead of the paid ArcGIS stack:
// • Renderer: MapLibre GL JS (BSD-licensed, no API key)
// • Geocoding: OpenStreetMap Nominatim (open, no key)
// • Tiles: open raster sources the user can switch between
// Each "map" variant just swaps the raster tile source, so the same
// watercolor experience can be applied to ANY city and fully customized.
// ----------------------------------------------------------------- //
// OpenFreeMap: fully free, no API key, no signup - OpenMapTiles vector
// schema. We hand-write a watercolor STYLE on top of it, so the painterly
// look (inspired by John Nelson's ArcGIS watercolor) applies to ANY city
// worldwide without per-city work or paid hosting.
const OPENFREEMAP_TILES = "https://tiles.openfreemap.org/planet";
// Authentic watercolor paper texture extracted from Watercolor.stylx
const PAPER_TEXTURE = "data:image/jpeg;base64,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";
// A watercolor MapLibre style built on free OSM vector tiles, using the
// ACTUAL palette extracted from the ArcGIS "Watercolor.stylx" (John Nelson's
// watercolor styles). Colors below are the real CIM symbol values:
// Water 122,142,245 / wash 158,170,215 · Park/Grass 112,168,0 ·
// Forest 38,115,0 · Wetland 0,168,132 · Sand 215,194,158 ·
// Paper white w/ grey pencil strokes · roads = pencil (black @ opacity).
// We use OpenFreeMap's TileJSON (free, no key) so tiles + glyphs resolve.
function watercolorVectorStyle(palette) {
const p = palette || {};
const paper = p.paper || "#ffffff"; // stylx Paper
const water = p.water || "rgb(122,142,245)";
const waterWash = p.waterWash || "rgb(158,170,215)";
const grass = p.grass || "rgb(112,168,0)";
const forest = p.forest || "rgb(38,115,0)";
const wetland = p.wetland || "rgb(0,168,132)";
const sand = p.sand || "rgb(215,194,158)";
const path = p.path || "rgb(205,205,102)"; // stylx Path
const pencil = p.pencil || "rgb(0,0,0)"; // stylx Pencil
const buildingCol = p.building || "rgb(168,112,0)"; // raw umber wash
const label = p.label || "rgb(78,78,78)";
const labelHalo = p.labelHalo || "rgba(255,255,255,0.9)";
const fillA = p.fillAlpha != null ? p.fillAlpha : 0.5; // watercolor wash
const woodA = p.woodAlpha != null ? p.woodAlpha : 0.55;
return {
version: 8,
glyphs: "https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf",
sources: {
// TileJSON URL (not a raw {z}/{x}/{y} template) so MapLibre fetches
// the correct tile + minzoom/maxzoom config from OpenFreeMap.
ofm: { type: "vector", url: OPENFREEMAP_TILES,
attribution: "© OpenStreetMap contributors, © OpenFreeMap · Watercolor palette: ArcGIS / J. Nelson" },
},
layers: [
{ id: "paper", type: "background", paint: { "background-color": paper } },
// landcover washes
{ id: "wood", type: "fill", source: "ofm", "source-layer": "landcover",
filter: ["match", ["get", "class"], ["wood", "forest"], true, false],
paint: { "fill-color": forest, "fill-opacity": woodA } },
{ id: "wood-bleed", type: "line", source: "ofm", "source-layer": "landcover",
filter: ["match", ["get", "class"], ["wood", "forest"], true, false],
paint: { "line-color": forest, "line-width": 3, "line-blur": 4, "line-opacity": 0.5 } },
{ id: "grass", type: "fill", source: "ofm", "source-layer": "landcover",
filter: ["match", ["get", "class"], ["grass", "park", "meadow", "scrub", "wetland"], true, false],
paint: { "fill-color": grass, "fill-opacity": fillA } },
{ id: "grass-bleed", type: "line", source: "ofm", "source-layer": "landcover",
filter: ["match", ["get", "class"], ["grass", "park", "meadow", "scrub"], true, false],
paint: { "line-color": grass, "line-width": 3, "line-blur": 4, "line-opacity": 0.45 } },
{ id: "sand", type: "fill", source: "ofm", "source-layer": "landcover",
filter: ["match", ["get", "class"], ["sand", "beach"], true, false],
paint: { "fill-color": sand, "fill-opacity": 0.6 } },
{ id: "park", type: "fill", source: "ofm", "source-layer": "park",
paint: { "fill-color": grass, "fill-opacity": fillA } },
{ id: "park-bleed", type: "line", source: "ofm", "source-layer": "park",
paint: { "line-color": grass, "line-width": 3, "line-blur": 4, "line-opacity": 0.45 } },
{ id: "landuse-wetland", type: "fill", source: "ofm", "source-layer": "landuse",
filter: ["match", ["get", "class"], ["wetland", "swamp"], true, false],
paint: { "fill-color": wetland, "fill-opacity": 0.4 } },
// water with a soft feathered "wash" edge (blur emulates the bleed)
{ id: "water", type: "fill", source: "ofm", "source-layer": "water",
paint: { "fill-color": waterWash, "fill-opacity": 0.7 } },
{ id: "water-edge", type: "line", source: "ofm", "source-layer": "water",
paint: { "line-color": water, "line-width": 2.6, "line-blur": 3.5, "line-opacity": 0.6 } },
{ id: "waterway", type: "line", source: "ofm", "source-layer": "waterway",
paint: { "line-color": waterWash, "line-width": ["interpolate", ["linear"], ["zoom"], 8, 0.8, 16, 3.2], "line-blur": 1.4 } },
// roads drawn as pencil strokes (knockout-casing + graphite line)
{ id: "road-casing", type: "line", source: "ofm", "source-layer": "transportation",
filter: ["match", ["get", "class"], ["motorway", "trunk", "primary", "secondary", "tertiary"], true, false],
layout: { "line-cap": "round", "line-join": "round" },
paint: { "line-color": paper,
"line-width": ["interpolate", ["linear"], ["zoom"], 8, 2.2, 14, 9, 18, 26] } },
{ id: "road", type: "line", source: "ofm", "source-layer": "transportation",
filter: ["match", ["get", "class"], ["motorway", "trunk", "primary", "secondary", "tertiary", "minor", "service", "street"], true, false],
layout: { "line-cap": "round", "line-join": "round" },
paint: { "line-color": pencil, "line-opacity": 0.5,
"line-width": ["interpolate", ["linear"], ["zoom"], 8, 0.5, 14, 2.2, 18, 7] } },
{ id: "path", type: "line", source: "ofm", "source-layer": "transportation",
filter: ["match", ["get", "class"], ["path", "track", "footway", "pedestrian"], true, false],
paint: { "line-color": path, "line-dasharray": [2, 2], "line-width": 1.4, "line-opacity": 0.85 } },
// buildings: faint raw-umber wash
{ id: "buildings", type: "fill", source: "ofm", "source-layer": "building", minzoom: 13,
paint: { "fill-color": buildingCol, "fill-opacity": 0.18 } },
// labels
{ id: "place-labels", type: "symbol", source: "ofm", "source-layer": "place",
filter: ["match", ["get", "class"], ["city", "town", "village", "suburb", "neighbourhood"], true, false],
layout: { "text-field": ["coalesce", ["get", "name:en"], ["get", "name"]],
"text-font": ["Noto Sans Regular"], "text-size": ["interpolate", ["linear"], ["zoom"], 8, 11, 14, 16],
"text-transform": "none", "text-letter-spacing": 0.05 },
paint: { "text-color": label, "text-halo-color": labelHalo, "text-halo-width": 1.6 } },
{ id: "road-labels", type: "symbol", source: "ofm", "source-layer": "transportation_name", minzoom: 13,
layout: { "text-field": ["coalesce", ["get", "name:en"], ["get", "name"]], "symbol-placement": "line",
"text-font": ["Noto Sans Regular"], "text-size": 11 },
paint: { "text-color": label, "text-halo-color": labelHalo, "text-halo-width": 1.4 } },
],
};
}
// Watercolor palettes - all derived from the stylx. "classic" is the
// authentic ArcGIS watercolor; the others are tints of the same recipe.
const WC_PALETTES = {
classic: {},
sepia: { paper: "#f3ead4", water: "rgb(150,150,210)", waterWash: "rgb(180,182,205)",
grass: "rgb(150,160,70)", forest: "rgb(90,120,40)", wetland: "rgb(80,150,120)",
sand: "rgb(215,194,158)", building: "rgb(168,112,0)", label: "rgb(90,75,55)",
fillAlpha: 0.45, woodAlpha: 0.5 },
dusk: { paper: "#22272e", water: "rgb(90,110,200)", waterWash: "rgb(70,90,140)",
grass: "rgb(70,110,40)", forest: "rgb(30,70,30)", wetland: "rgb(0,120,95)",
sand: "rgb(120,110,80)", path: "rgb(150,150,90)", pencil: "rgb(220,225,235)",
building: "rgb(120,90,40)", label: "rgb(225,230,238)", labelHalo: "rgba(20,24,30,0.85)",
fillAlpha: 0.4, woodAlpha: 0.45 },
// sunrise: warm dawn wash - peach paper, coral water, golden light
sunrise: { paper: "#fdeede", water: "rgb(245,150,120)", waterWash: "rgb(250,190,160)",
grass: "rgb(196,170,80)", forest: "rgb(150,140,60)", wetland: "rgb(220,160,110)",
sand: "rgb(245,212,160)", path: "rgb(214,150,90)", pencil: "rgb(120,70,55)",
building: "rgb(214,120,70)", label: "rgb(120,60,45)", labelHalo: "rgba(255,245,235,0.9)",
fillAlpha: 0.5, woodAlpha: 0.5 },
};
// The four watercolor color ways the user can switch between, right on the
// map. classic = the authentic ArcGIS palette; the rest are tints.
const WC_COLORWAYS = [
{ id: "classic", label: "Classic", swatch: "#9eaad7" },
{ id: "sepia", label: "Sepia", swatch: "#c2a26a" },
{ id: "sunrise", label: "Sunrise", swatch: "#f5967a" },
{ id: "dusk", label: "Dusk", swatch: "#5a6ec8" },
];
// Only the watercolor vector style remains. The painterly look applies to
// ANY city (free OSM vector tiles, no key). The specific COLORWAY (classic /
// sepia / sunrise / dusk) is chosen live on the map, not here.
const TILE_SOURCES = {
watercolor: { label: "Watercolor", kind: "vector", palette: "classic" },
};
const MAP_VARIANTS = [
{ id: "watercolor", label: "2D Map", icon: MapIcon, kind: "map", tile: "watercolor", blurb: "A painterly watercolor map of any city - free, no API key. Switch colorways and pin your photos right on it." },
{ id: "cesium", label: "3D Globe", icon: MapIcon, kind: "cesium", blurb: "Photorealistic 3D buildings with a watercolor or by-material style. Pins and the journey flythrough work here too." },
{ id: "develop", label: "Canvas", icon: ImagePlus, kind: "develop", blurb: "Drag your photos onto the page, then “develop” them into a keepsake scroll." },
{ id: "scrapbook", label: "Scrapbook", icon: ImageIcon, kind: "layout", blurb: "Pinned prints on a hand-drawn map." },
{ id: "grid", label: "Gallery", icon: LayoutGrid, kind: "layout", blurb: "A clean contact-sheet grid of moments." },
{ id: "timeline", label: "Timeline", icon: Clock, kind: "layout", blurb: "A vertical, chronological story of the day." },
];
// Read image File(s) into data URLs for in-browser use (no upload/server).
function readImageFile(file) {
return new Promise((resolve, reject) => {
if (!file || (file.type && !/^image\//.test(file.type))) { reject(new Error("not an image")); return; }
const r = new FileReader();
r.onload = () => resolve(r.result);
r.onerror = () => reject(new Error("read failed"));
r.readAsDataURL(file);
});
}
// Try to read GPS + timestamp from EXIF metadata embedded in a photo file.
// Returns { lat, lon, date, time } - any field may be null if not present.
// Fails silently (returns all-null) if exifr isn't loaded or the file has no EXIF.
async function readExif(file) {
const out = { lat: null, lon: null, date: null, time: null };
try {
if (typeof window.exifr === "undefined") return out;
const data = await window.exifr.parse(file, { gps: true, tiff: true, ifd0: true, exif: true });
if (!data) return out;
if (data.latitude != null && data.longitude != null) {
out.lat = data.latitude;
out.lon = data.longitude;
}
const raw = data.DateTimeOriginal || data.DateTime || data.CreateDate;
if (raw) {
// EXIF format: "YYYY:MM:DD HH:MM:SS"
const m = String(raw instanceof Date ? raw.toISOString() : raw)
.match(/(\d{4})[:\-](\d{2})[:\-](\d{2})[T ](\d{2}):(\d{2})/);
if (m) {
const [, yr, mo, dy, hr, min] = m;
const months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
out.date = months[parseInt(mo,10)-1] + " " + parseInt(dy,10) + ", " + yr;
const h = parseInt(hr,10);
out.time = (h % 12 || 12) + ":" + min + " " + (h < 12 ? "AM" : "PM");
}
}
} catch (_) {}
return out;
}
// NSFW gate for uploads. Sends the image to the server's content classifier.
// Fails OPEN (returns blocked:false) on any network/model error so a missing
// optional model never stops people from uploading their own photos.
async function nsfwGate(dataUrl, name) {
try {
const res = await fetch("/api/nsfw", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ data_url: dataUrl, name: name || "upload" }),
});
if (!res.ok) return { blocked: false, available: false, score: 0 };
return await res.json();
} catch (e) {
return { blocked: false, available: false, score: 0 };
}
}
// ----------------------------------------------------------------- //
// Shared upload pipeline + status overlay. //
// //
// Every upload entry point (Add to Album, map polaroids, the darkroom //
// "use my photos") funnels through processUpload() so they all get the //
// same behavior: //
// 1. validate the file extension - we accept ONLY .png/.jpg/.jpeg //
// 2. show a "processing / uploading" overlay //
// 3. run the server NSFW gate //
// 4. report ACCEPTED (✓) or REJECTED with a reason (bad type / NSFW) //
// //
// A tiny pub/sub store drives a single global overlay mounted at Root, //
// so the feedback looks identical everywhere in the app. //
// ----------------------------------------------------------------- //
const MAX_POLAROIDS = 5; // cap: at most 5 polaroids may be pinned on the map
// ── Top-level upload cap ─────────────────────────────────────────────
// Maximum number of images allowed in an album. Change this one value to
// raise/lower the limit. Uploading beyond it shows an alert.
const MAX_IMAGES = 6;
const ACCEPTED_EXTS = ["png", "jpg", "jpeg", "jfif", "webp", "gif", "bmp", "tif", "tiff", "avif", "heic", "heif"];
const ACCEPTED_MIMES = ["image/png", "image/jpeg", "image/jpg", "image/webp", "image/gif", "image/bmp", "image/tiff", "image/avif", "image/heic", "image/heif"];
// image/* + explicit HEIC/HEIF exts (some platforms don't map .heic to a
// mime in the picker, so the extensions keep them selectable).
const ACCEPT_ATTR = "image/*,.png,.jpg,.jpeg,.jfif,.webp,.gif,.bmp,.tif,.tiff,.avif,.heic,.heif";
function fileExt(name) {
const m = /\.([^.]+)$/.exec(name || "");
return m ? m[1].toLowerCase() : "";
}
// Accept anything the browser calls an image, or any known image extension
// (covers HEIC/HEIF/webp/etc. whose mime may be blank on some platforms).
function isAcceptedImage(file) {
if (!file) return false;
const extOk = ACCEPTED_EXTS.includes(fileExt(file.name));
const typeOk = (file.type || "").toLowerCase().startsWith("image/");
return extOk || typeOk;
}
// Browsers (outside Safari) can't render HEIC/HEIF in an <img>, so convert
// to JPEG on upload when the heic2any decoder is available. EXIF is read
// from the ORIGINAL file separately, before this runs.
async function maybeConvertHeic(file) {
const ext = fileExt(file.name);
const isHeic = ext === "heic" || ext === "heif" || /heic|heif/.test((file.type || "").toLowerCase());
if (!isHeic || typeof window.heic2any === "undefined") return file;
try {
let out = await window.heic2any({ blob: file, toType: "image/jpeg", quality: 0.92 });
if (Array.isArray(out)) out = out[0];
return new File([out], file.name.replace(/\.[^.]+$/, ".jpg"), { type: "image/jpeg" });
} catch (e) { return file; }
}
// Global upload-status store (pub/sub). Items: {id,name,status,reason}.
// status: "processing" | "accepted" | "rejected"
const UploadStatus = (function () {
let items = [];
const subs = new Set();
const emit = () => subs.forEach((fn) => { try { fn(items.slice()); } catch (e) {} });
let _seq = 0;
return {
subscribe(fn) { subs.add(fn); fn(items.slice()); return () => subs.delete(fn); },
begin(name) {
const id = "up_" + (++_seq) + "_" + Date.now();
items = [...items, { id, name: name || "image", status: "processing", reason: "" }];
emit();
return id;
},
update(id, patch) {
items = items.map((it) => (it.id === id ? { ...it, ...patch } : it));
emit();
// play an error chime on rejection (respects the user's sound prefs)
if (patch && patch.status === "rejected") {
try {
const snd = window.__sozaiSound;
if (snd && snd.enabled && window.SozaiChime) {
window.SozaiChime.play("alert", { enabled: true, theme: snd.theme, volume: snd.volume });
}
} catch (e) {}
}
// auto-clear finished items after a short delay
const it = items.find((x) => x.id === id);
if (it && it.status !== "processing") {
const ttl = it.status === "rejected" ? 9000 : 5000;
setTimeout(() => { items = items.filter((x) => x.id !== id); emit(); }, ttl);
}
},
clear() { items = []; emit(); },
};
})();
// Process a single File end-to-end. Returns:
// { ok:true, dataUrl, file } on success
// { ok:false, reason:"type"|"nsfw"|"read", message } on rejection
// Always drives the global overlay so the user sees processing → result.
async function processUpload(file) {
const name = (file && file.name) || "image";
const id = UploadStatus.begin(name);
// 1) extension / type gate
if (!isAcceptedImage(file)) {
UploadStatus.update(id, {
status: "rejected", reason: "type",
message: "That file isn't an image we can read.",
});
return { ok: false, reason: "type", message: "That file isn't an image we can read." };
}
// 2) read to a data URL (converting HEIC/HEIF to JPEG first so it renders)
let dataUrl;
try {
const imgFile = await maybeConvertHeic(file);
dataUrl = await readImageFile(imgFile);
} catch (e) {
UploadStatus.update(id, { status: "rejected", reason: "read", message: "Could not read this file." });
return { ok: false, reason: "read", message: "Could not read this file." };
}
// 3) NSFW gate (fails OPEN if the model/server is unavailable)
try {
const gate = await nsfwGate(dataUrl, name);
if (gate && gate.blocked) {
UploadStatus.update(id, {
status: "rejected", reason: "nsfw",
message: "This image was blocked by the content filter.",
});
return { ok: false, reason: "nsfw", message: "This image was blocked by the content filter." };
}
} catch (e) { /* treat as safe */ }
// 4) accepted
UploadStatus.update(id, { status: "accepted", message: "Added" });
return { ok: true, dataUrl, file };
}
// Global overlay that renders the live upload statuses. Mounted once at Root.
function UploadOverlay() {
const [items, setItems] = useState([]);
useEffect(() => UploadStatus.subscribe(setItems), []);
if (!items.length) return null;
return (
<div className="pointer-events-none fixed right-4 top-32 z-[120] flex w-[min(92vw,340px)] flex-col gap-2">
{items.map((it) => {
const isProc = it.status === "processing";
const isOk = it.status === "accepted";
return (
<div key={it.id}
className={cn(
"pointer-events-auto flex items-start gap-2.5 rounded-lg border-2 bg-card px-3 py-2.5 shadow-lg transition-all",
isProc ? "border-primary/40" : isOk ? "border-emerald-500/60" : "border-red-500/60"
)}>
<span className="mt-0.5 shrink-0">
{isProc ? <Loader2 className="size-4 animate-spin text-primary" />
: isOk ? <CheckCircle className="size-4 text-emerald-600" />
: it.reason === "nsfw" ? <ShieldAlert className="size-4 text-red-600" />
: it.reason === "type" ? <FileWarning className="size-4 text-red-600" />
: <XCircle className="size-4 text-red-600" />}
</span>
<div className="min-w-0 flex-1">
<p className="truncate font-mono text-[11px] font-semibold text-foreground">{it.name}</p>
<p className={cn("mt-0.5 text-[11px]",
isProc ? "text-muted-foreground" : isOk ? "text-emerald-700" : "text-red-700")}>
{isProc ? "Processing & checking…" : isOk ? "Accepted - added to your album" : it.message}
</p>
</div>
</div>
);
})}
</div>
);
}
// ----------------------------------------------------------------- //
// Toast / notification center. //
// A small pub/sub drives a stack of toasts at the top-right. Used for: //
// • chat message pings (auto-dismiss) //
// • join-approval requests for the host (with Admit / Decline) //
// Exposed on window so non-React code (Collab handlers) can raise them. //
// ----------------------------------------------------------------- //
const ToastStore = (function () {
let items = [];
const subs = new Set();
let seq = 0;
const emit = () => subs.forEach((fn) => { try { fn(items.slice()); } catch (e) {} });
return {
subscribe(fn) { subs.add(fn); fn(items.slice()); return () => subs.delete(fn); },
push(t) {
// honor an explicit id (so it can be dismissed by name); de-dupe it
const id = (t && t.id) || ("t" + (++seq) + "_" + Date.now());
if (t && t.id && items.some((x) => x.id === t.id)) return id; // already shown
const item = Object.assign({ kind: "info", sticky: false }, t, { id });
items = [...items, item];
emit();
if (!item.sticky) {
const ttl = item.ttl || 6000;
setTimeout(() => this.dismiss(id), ttl);
}
return id;
},
dismiss(id) { items = items.filter((x) => x.id !== id); emit(); },
clear() { items = []; emit(); },
};
})();
// chat ping convenience (used by ChatDock)
window.SozaiToast = {
show({ name, color, text }) {
ToastStore.push({ kind: "chat", name, color, text });
},
};
function ToastCenter() {
const [items, setItems] = useState([]);
useEffect(() => ToastStore.subscribe(setItems), []);
if (!items.length) return null;
return (
<div className="pointer-events-none fixed right-4 top-16 z-[130] flex w-[min(92vw,320px)] flex-col gap-2">
{items.map((it) => (
<div key={it.id}
className={cn("pointer-events-auto overflow-hidden rounded-lg border-2 bg-card shadow-xl transition-all",
it.kind === "join" ? "border-primary" : "border-primary/40")}>
{it.kind === "chat" ? (
<button type="button" onClick={() => { it.onClick && it.onClick(); ToastStore.dismiss(it.id); }}
className="flex w-full items-start gap-2.5 px-3 py-2.5 text-left hover:bg-accent/40">
<MessageCircle className="mt-0.5 size-4 shrink-0" style={{ color: it.color || "var(--primary)" }} />
<span className="min-w-0 flex-1">
<span className="block font-mono text-[10px] uppercase tracking-wide" style={{ color: it.color }}>{it.name}</span>
<span className="mt-0.5 block truncate text-sm text-foreground">{it.text}</span>
</span>
</button>
) : it.kind === "join" ? (
<div className="px-3 py-2.5">
<div className="flex items-start gap-2.5">
<Users className="mt-0.5 size-4 shrink-0 text-primary" />
<div className="min-w-0 flex-1">
<p className="text-sm text-foreground"><span className="font-semibold">{it.name || "Someone"}</span> wants to join your room.</p>
</div>
</div>
<div className="mt-2.5 flex items-center gap-2">
<button type="button" onClick={() => { it.onAdmit && it.onAdmit(); ToastStore.dismiss(it.id); }}
className="flex-1 rounded-md border-2 border-primary bg-primary px-2 py-1.5 font-mono text-[10px] font-semibold uppercase tracking-wide text-primary-foreground transition-colors hover:bg-primary/90">Admit</button>
<button type="button" onClick={() => { it.onDecline && it.onDecline(); ToastStore.dismiss(it.id); }}
className="flex-1 rounded-md border-2 border-primary/50 px-2 py-1.5 font-mono text-[10px] font-semibold uppercase tracking-wide text-primary transition-colors hover:bg-accent">Decline</button>
</div>
</div>
) : (
<div className="flex items-start gap-2.5 px-3 py-2.5">
<span className="mt-0.5 text-sm text-foreground">{it.text}</span>
</div>
)}
</div>
))}
</div>
);
}
// Streams the recorded audio to /api/transcribe, then calls onText with the
// result and surfaces the detected language. Degrades gracefully if the
// browser can't record or the server model is unavailable.
// ----------------------------------------------------------------- //
const LANG_NAMES = { en: "English", fr: "French", es: "Spanish", de: "German", it: "Italian",
pt: "Portuguese", nl: "Dutch", ja: "Japanese", zh: "Chinese", "zh-cn": "Chinese", "zh-tw": "Chinese",
ko: "Korean", hi: "Hindi", ar: "Arabic", ru: "Russian", vi: "Vietnamese", pl: "Polish",
tr: "Turkish", sv: "Swedish", uk: "Ukrainian", id: "Indonesian", th: "Thai" };
// App-wide ASR config (languages, latency/accuracy profiles), fetched once
// from /api/config and cached on window so every MicButton shares it.
function useAsrConfig() {
const [cfg, setCfg] = useState(window.__sozaiAsrCfg || null);
useEffect(() => {
if (window.__sozaiAsrCfg) { setCfg(window.__sozaiAsrCfg); return; }
let alive = true;
(async () => {
try {
const res = await fetch("/api/config");
if (!res.ok) return;
const j = await res.json();
const c = {
languages: j.asrLanguages || [["Auto-detect", "auto"]],
profiles: j.asrProfiles || ["Most accurate - 1.12 s"],
defaultProfile: j.asrDefaultProfile || "Most accurate - 1.12 s",
osSupported: j.asrOsSupported !== false,
};
window.__sozaiAsrCfg = c;
if (alive) setCfg(c);
} catch (e) {}
})();
return () => { alive = false; };
}, []);
return cfg;
}
function MicButton({ field, onText, title }) {
const { settings, update } = useSettings();
const asrCfg = useAsrConfig();
const [state, setState] = useState("idle"); // idle | recording | working | error
const [lang, setLang] = useState("");
const [note, setNote] = useState("");
const [showCfg, setShowCfg] = useState(false);
const recRef = useRef(null);
const chunksRef = useRef([]);
const streamRef = useRef(null);
const asrLanguage = settings.asrLanguage || "auto";
const asrProfile = settings.asrProfile || "Most accurate - 1.12 s";
async function start() {
setNote(""); setLang("");
if (asrCfg && asrCfg.osSupported === false) {
setState("error"); setNote("Nemotron is not supported on this OS."); return;
}
if (!navigator.mediaDevices || typeof MediaRecorder === "undefined") {
setState("error"); setNote("Recording not supported here."); return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
streamRef.current = stream;
const mime = ["audio/webm;codecs=opus", "audio/webm", "audio/ogg", "audio/mp4"].find((m) => MediaRecorder.isTypeSupported && MediaRecorder.isTypeSupported(m)) || "";
const rec = new MediaRecorder(stream, mime ? { mimeType: mime } : undefined);
chunksRef.current = [];
rec.ondataavailable = (e) => { if (e.data && e.data.size) chunksRef.current.push(e.data); };
rec.onstop = onStop;
rec.start();
recRef.current = rec;
setState("recording");
if (window.sozaiPing) window.sozaiPing("🎤 recording " + field);
} catch (e) {
setState("error"); setNote("Microphone blocked.");
}
}
function stop() {
const rec = recRef.current;
if (rec && rec.state !== "inactive") { try { rec.stop(); } catch (e) {} }
if (streamRef.current) { streamRef.current.getTracks().forEach((t) => t.stop()); streamRef.current = null; }
}
async function onStop() {
setState("working");
try {
const blob = new Blob(chunksRef.current, { type: (recRef.current && recRef.current.mimeType) || "audio/webm" });
const dataUrl = await new Promise((res, rej) => { const r = new FileReader(); r.onload = () => res(r.result); r.onerror = rej; r.readAsDataURL(blob); });
const resp = await fetch("/api/transcribe", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ data_url: dataUrl, field, language: asrLanguage, chunk_profile: asrProfile }),
});
const data = resp.ok ? await resp.json() : null;
if (!data) { setState("error"); setNote("Transcription failed."); return; }
if (data.unsupported_os) { setState("error"); setNote("Nemotron is not supported on this OS."); return; }
if (data.available === false) { setState("error"); setNote("Voice model unavailable on the server."); return; }
if (data.text) {
onText(data.text);
setLang(data.language || "");
setState("idle");
setNote(data.language ? "Heard " + (LANG_NAMES[data.language.toLowerCase()] || data.language) : "");
} else {
setState("idle"); setNote("Didn't catch that - try again.");
}
} catch (e) {
setState("error"); setNote("Transcription failed.");
}
}
const recording = state === "recording";
const working = state === "working";
const osBlocked = asrCfg && asrCfg.osSupported === false;
return (
<span className="relative inline-flex items-center gap-1.5">
<button type="button"
onClick={() => (recording ? stop() : start())}
disabled={working || osBlocked}
aria-pressed={recording}
title={osBlocked ? "Nemotron is not supported on this OS" : (title || ("Dictate " + field))}
className={cn("flex size-7 items-center justify-center rounded-full border-2 transition-colors disabled:opacity-60",
recording ? "border-[oklch(0.55_0.2_25)] bg-[oklch(0.55_0.2_25)] text-white animate-pulse"
: "border-primary/60 text-primary hover:bg-accent")}>
{working ? <Loader2 className="size-3.5 animate-spin" /> : recording ? <Square className="size-3" /> : <Mic className="size-3.5" />}
</button>
{/* combined ASR + prompt settings, set BEFORE recording */}
<button type="button" onClick={() => setShowCfg((v) => !v)} aria-expanded={showCfg}
title="Recording & prompt settings"
className={cn("flex size-7 items-center justify-center rounded-full border-2 transition-colors",
showCfg ? "border-primary bg-accent text-foreground" : "border-primary/40 text-primary/80 hover:bg-accent")}>
<SlidersHorizontal className="size-3.5" />
</button>
{lang ? <span className="rounded-full border border-primary/40 px-1.5 font-mono text-[9px] uppercase tracking-wide text-primary" title="Detected language">{(LANG_NAMES[lang.toLowerCase()] || lang)}</span> : null}
{note && !lang ? <span className="font-mono text-[9px] uppercase tracking-wide text-muted-foreground">{note}</span> : null}
{/* recording loading overlay (mirrors the auto-generate developing look) */}
{(recording || working) ? (
<span className="pointer-events-none absolute left-1/2 top-9 z-30 flex -translate-x-1/2 items-center gap-2 whitespace-nowrap rounded-md border-2 border-primary/50 bg-card px-3 py-1.5 shadow-lg"
role="status" aria-live="polite">
{recording ? (
<>
<span className="size-2 animate-pulse rounded-full bg-[oklch(0.55_0.2_25)]" />
<span className="font-mono text-[10px] uppercase tracking-widest text-foreground">Recording… tap to stop</span>
</>
) : (
<>
<Loader2 className="size-3.5 animate-spin text-primary" />
<span className="font-mono text-[10px] uppercase tracking-widest text-primary">Transcribing…</span>
<span className="flex gap-1" aria-hidden="true">
<span className="size-1.5 rounded-full bg-primary" style={{ animation: "sozai-bounce 1s infinite", animationDelay: "0ms" }} />
<span className="size-1.5 rounded-full bg-primary" style={{ animation: "sozai-bounce 1s infinite", animationDelay: "150ms" }} />
<span className="size-1.5 rounded-full bg-primary" style={{ animation: "sozai-bounce 1s infinite", animationDelay: "300ms" }} />
</span>
</>
)}
</span>
) : null}
{/* settings popover */}
{showCfg ? (
<div className="absolute right-0 top-9 z-40 w-72 rounded-md border-2 border-primary/50 bg-card p-3 shadow-xl">
<div className="flex items-center justify-between">
<span className="font-mono text-[10px] font-semibold uppercase tracking-wide text-foreground">Recording settings</span>
<button type="button" onClick={() => setShowCfg(false)} className="text-muted-foreground hover:text-foreground"><X className="size-3.5" /></button>
</div>
{osBlocked ? (
<p className="mt-2 rounded border border-[oklch(0.55_0.18_25)]/40 bg-[oklch(0.55_0.18_25)]/10 px-2 py-1.5 font-mono text-[10px] leading-snug text-[oklch(0.5_0.18_25)]">
Nemotron is not supported on this OS (Linux only). Recording is disabled.
</p>
) : null}
{/* language */}
<label className="mt-2.5 block font-mono text-[10px] uppercase tracking-wide text-muted-foreground">Language</label>
<select value={asrLanguage} onChange={(e) => update({ asrLanguage: e.target.value })}
className="mt-1 w-full rounded-md border-2 border-primary/40 bg-card px-2 py-1.5 text-xs text-foreground outline-none focus:border-primary">
{(asrCfg ? asrCfg.languages : [["Auto-detect", "auto"]]).map((opt) => (
<option key={opt[1]} value={opt[1]}>{opt[0]}</option>
))}
</select>
{/* latency / accuracy */}
<label className="mt-2.5 block font-mono text-[10px] uppercase tracking-wide text-muted-foreground">Latency / accuracy</label>
<select value={asrProfile} onChange={(e) => update({ asrProfile: e.target.value })}
className="mt-1 w-full rounded-md border-2 border-primary/40 bg-card px-2 py-1.5 text-xs text-foreground outline-none focus:border-primary">
{(asrCfg ? asrCfg.profiles : ["Most accurate - 1.12 s"]).map((p) => (
<option key={p} value={p}>{p}</option>
))}
</select>
<p className="mt-2.5 font-mono text-[9px] leading-snug tracking-wide text-muted-foreground">
These apply to voice dictation only.
</p>
</div>
) : null}
</span>
);
}
// ----------------------------------------------------------------- //
// LLM Trace - shared icon map for the trace views (caption / NSFW / ASR).
// ----------------------------------------------------------------- //
const TRACE_ICON = { autocaption: "✨", autotitle: "🏷️", nsfw_check: "🛡️", transcribe: "🎤" };
// ----------------------------------------------------------------- //
// TraceScreen - full-page version of the LLM trace view, opened from the
// "Trace" button in the top bar. Embeds the Phoenix UI and a recent-calls
// list, with a back button to return to Add to Album.
// ----------------------------------------------------------------- //
function TraceScreen({ onBack, onOpenSettings }) {
const [data, setData] = useState({ events: [], phoenix: false, phoenix_url: null, stats: null });
const [loading, setLoading] = useState(false);
const [selected, setSelected] = useState(0);
const [filterType, setFilterType] = useState("all");
const [filterStatus, setFilterStatus] = useState("all");
const [copied, setCopied] = useState(false);
const timerRef = useRef(null);
async function refresh() {
setLoading(true);
try {
const res = await fetch("/api/trace?limit=80");
if (res.ok) setData(await res.json());
} catch (e) {} finally { setLoading(false); }
}
useEffect(() => {
refresh();
timerRef.current = setInterval(refresh, 2500);
return () => { if (timerRef.current) clearInterval(timerRef.current); };
}, []);
const allEvents = data.events || [];
const stats = data.stats || { total: 0, errors: 0, error_rate: 0, avg_ms: 0, p95_ms: 0, tokens_total: 0, by_type: {} };
const types = Object.keys(stats.by_type || {});
const phoenixDirect = data.phoenix_direct || data.phoenix_url || "/phoenix/";
const events = allEvents.filter((e) =>
(filterType === "all" || e.name === filterType) &&
(filterStatus === "all" || e.status === filterStatus));
const sel = events[selected] || events[0] || null;
function prettyJSON(s) {
if (s == null) return "";
try { return JSON.stringify(JSON.parse(s), null, 2); } catch (e) { return String(s); }
}
function fmtTime(ts) {
if (!ts) return "";
try { return new Date(ts * 1000).toLocaleTimeString(); } catch (e) { return ""; }
}
function copyDetail() {
if (!sel) return;
try {
navigator.clipboard.writeText(JSON.stringify(sel, null, 2));
setCopied(true); setTimeout(() => setCopied(false), 1500);
} catch (e) {}
}
const Stat = ({ label, value, tone }) => (
<div className="flex flex-col rounded-md border-2 border-primary/25 bg-card px-3 py-2">
<span className="font-mono text-[9px] uppercase tracking-wide text-muted-foreground">{label}</span>
<span className={cn("font-mono text-lg font-semibold", tone || "text-foreground")}>{value}</span>
</div>
);
return (
<div className="mx-auto flex w-full max-w-6xl flex-col gap-4">
{/* header */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<button type="button" onClick={onBack}
className="flex items-center gap-1.5 rounded-md border-2 border-primary/60 px-3 py-1.5 font-mono text-xs uppercase tracking-wide text-primary transition-colors hover:bg-accent">
<ChevronLeft className="size-4" /> Back
</button>
<span className="flex items-center gap-2 font-mono text-sm font-semibold uppercase tracking-wide text-foreground">
<Cpu className="size-5 text-primary" /> LLM Trace
{data.phoenix
? <span className="rounded-full border border-primary/40 px-1.5 font-mono text-[9px] uppercase tracking-wide text-primary">Phoenix</span>
: <span className="rounded-full border border-primary/30 px-1.5 font-mono text-[9px] uppercase tracking-wide text-muted-foreground">local</span>}
</span>
</div>
<div className="flex items-center gap-2">
{loading ? <Loader2 className="size-4 animate-spin text-primary" /> : null}
{data.phoenix ? (
<a href={phoenixDirect} target="_blank" rel="noreferrer"
className="inline-flex items-center gap-1 rounded-md border-2 border-primary/60 px-3 py-1.5 font-mono text-[11px] uppercase tracking-wide text-primary transition-colors hover:bg-accent">
Open full Phoenix <ChevronRight className="size-3" />
</a>
) : null}
<button type="button" onClick={() => onOpenSettings && onOpenSettings()} aria-label="Settings"
title="Settings" className="flex size-9 items-center justify-center rounded-md border-2 border-primary/70 text-primary transition-colors hover:bg-accent">
<Settings className="size-4" />
</button>
</div>
</div>
{/* summary stats */}
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4 lg:grid-cols-6">
<Stat label="Calls" value={stats.total} />
<Stat label="Errors" value={stats.errors} tone={stats.errors ? "text-[oklch(0.5_0.2_25)]" : "text-foreground"} />
<Stat label="Error rate" value={(stats.error_rate * 100).toFixed(0) + "%"} tone={stats.error_rate ? "text-[oklch(0.5_0.2_25)]" : "text-foreground"} />
<Stat label="Avg latency" value={stats.avg_ms + "ms"} />
<Stat label="p50" value={(stats.p50_ms || 0) + "ms"} />
<Stat label="p95" value={(stats.p95_ms || 0) + "ms"} />
<Stat label="p99" value={(stats.p99_ms || 0) + "ms"} />
<Stat label="Tokens" value={stats.tokens_total} />
<Stat label="Prompt tok" value={stats.tokens_prompt || 0} />
<Stat label="Completion tok" value={stats.tokens_completion || 0} />
<Stat label="Tokens/sec" value={stats.avg_tokens_per_sec || 0} />
<Stat label="Cost" value={"$" + (stats.cost_usd || 0).toFixed(2)} />
</div>
<p className="-mt-2 font-mono text-[9px] leading-snug tracking-wide text-muted-foreground">
Cost is $0 - these run as local open-source models (no per-token API billing). Token counts use the caption model's tokenizer when loaded, else a word-based estimate.
</p>
{/* filters */}
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-[9px] uppercase tracking-wide text-muted-foreground">Filter</span>
<button type="button" onClick={() => { setFilterType("all"); setSelected(0); }} aria-pressed={filterType === "all"}
className={cn("rounded-full border-2 px-2.5 py-1 font-mono text-[10px] uppercase tracking-wide transition-colors",
filterType === "all" ? "border-primary bg-accent text-foreground" : "border-primary/40 text-muted-foreground hover:bg-accent/50")}>
all types
</button>
{types.map((t) => (
<button key={t} type="button" onClick={() => { setFilterType(t); setSelected(0); }} aria-pressed={filterType === t}
className={cn("flex items-center gap-1 rounded-full border-2 px-2.5 py-1 font-mono text-[10px] uppercase tracking-wide transition-colors",
filterType === t ? "border-primary bg-accent text-foreground" : "border-primary/40 text-muted-foreground hover:bg-accent/50")}>
<span aria-hidden="true">{TRACE_ICON[t] || "•"}</span>{t}
<span className="text-muted-foreground">{stats.by_type[t] ? stats.by_type[t].count : 0}</span>
</button>
))}
<span className="mx-1 h-4 w-px bg-primary/20" />
{["all", "ok", "error"].map((s) => (
<button key={s} type="button" onClick={() => { setFilterStatus(s); setSelected(0); }} aria-pressed={filterStatus === s}
className={cn("rounded-full border-2 px-2.5 py-1 font-mono text-[10px] uppercase tracking-wide transition-colors",
filterStatus === s ? "border-primary bg-accent text-foreground" : "border-primary/40 text-muted-foreground hover:bg-accent/50")}>
{s}
</button>
))}
</div>
{events.length === 0 ? (
<p className="rounded-lg border-2 border-dashed border-primary/30 bg-accent/20 px-3 py-10 text-center font-mono text-[12px] uppercase tracking-wide text-muted-foreground">
{allEvents.length === 0
? "No calls yet - generate a caption or record a voice note, and the span shows up here."
: "No spans match this filter."}
</p>
) : (
<div className="grid grid-cols-1 gap-3 md:grid-cols-[minmax(0,1fr)_minmax(0,1.5fr)]">
{/* span list */}
<div className="overflow-hidden rounded-lg border-2 border-primary/30 bg-card">
<div className="grid grid-cols-[1fr_auto_auto] gap-2 border-b-2 border-primary/20 bg-accent/30 px-3 py-2 font-mono text-[9px] uppercase tracking-wide text-muted-foreground">
<span>name</span><span>latency</span><span>status</span>
</div>
<ul className="max-h-[60vh] divide-y divide-primary/10 overflow-auto">
{events.map((e, i) => (
<li key={e.id || i}>
<button type="button" onClick={() => setSelected(i)}
className={cn("grid w-full grid-cols-[1fr_auto_auto] items-center gap-2 px-3 py-2 text-left transition-colors",
i === selected ? "bg-accent" : "hover:bg-accent/40")}>
<span className="flex min-w-0 flex-col">
<span className="flex items-center gap-1.5">
<span aria-hidden="true">{TRACE_ICON[e.name] || "•"}</span>
<span className="truncate font-mono text-[11px] font-semibold text-foreground">{e.name}</span>
</span>
{e.ended ? <span className="font-mono text-[9px] text-muted-foreground">{fmtTime(e.ended)}</span> : null}
</span>
<span className="font-mono text-[10px] text-muted-foreground">{typeof e.duration_ms === "number" ? e.duration_ms + "ms" : "-"}</span>
<span className={cn("rounded-full px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-wide",
e.status === "ok" ? "bg-primary/15 text-primary"
: e.status === "error" ? "bg-[oklch(0.55_0.2_25)]/15 text-[oklch(0.5_0.2_25)]"
: "bg-accent text-muted-foreground")}>{e.status}</span>
</button>
</li>
))}
</ul>
</div>
{/* span detail */}
<div className="overflow-auto rounded-lg border-2 border-primary/30 bg-card p-4">
{sel ? (
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="flex items-center gap-1.5 font-mono text-sm font-semibold text-foreground">
<span aria-hidden="true">{TRACE_ICON[sel.name] || "•"}</span>{sel.name}
<span className="rounded border border-primary/40 px-1 font-mono text-[8px] uppercase tracking-wide text-primary">{sel.kind || "LLM"}</span>
</span>
<div className="flex items-center gap-2">
<button type="button" onClick={copyDetail}
className="inline-flex items-center gap-1 rounded-md border-2 border-primary/50 px-2 py-1 font-mono text-[9px] uppercase tracking-wide text-primary transition-colors hover:bg-accent">
{copied ? <Check className="size-3" /> : <Copy className="size-3" />} {copied ? "copied" : "copy"}
</button>
<span className={cn("rounded-full px-2 py-0.5 font-mono text-[10px] uppercase tracking-wide",
sel.status === "ok" ? "bg-primary/15 text-primary"
: sel.status === "error" ? "bg-[oklch(0.55_0.2_25)]/15 text-[oklch(0.5_0.2_25)]"
: "bg-accent text-muted-foreground")}>{sel.status}</span>
</div>
</div>
<div className="grid grid-cols-2 gap-x-6 gap-y-1 font-mono text-[10px] text-muted-foreground sm:grid-cols-3">
{sel.model ? <span className="col-span-2 truncate sm:col-span-3"><span className="text-foreground/60">model</span> {sel.model}</span> : null}
{sel.provider ? <span><span className="text-foreground/60">provider</span> {sel.provider}</span> : null}
{sel.finish_reason ? <span><span className="text-foreground/60">finish</span> {sel.finish_reason}</span> : null}
{typeof sel.duration_ms === "number" ? <span><span className="text-foreground/60">latency</span> {sel.duration_ms}ms</span> : null}
{sel.ended ? <span><span className="text-foreground/60">time</span> {fmtTime(sel.ended)}</span> : null}
{sel.file ? <span className="truncate"><span className="text-foreground/60">file</span> {sel.file}</span> : null}
{typeof sel.tokens_in === "number" ? <span><span className="text-foreground/60">tok in</span> {sel.tokens_in}</span> : null}
{typeof sel.tokens_out === "number" ? <span><span className="text-foreground/60">tok out</span> {sel.tokens_out}</span> : null}
{typeof sel.tokens_total === "number" ? <span><span className="text-foreground/60">tok total</span> {sel.tokens_total}</span> : null}
{sel.tokens_per_sec ? <span><span className="text-foreground/60">tok/sec</span> {sel.tokens_per_sec}</span> : null}
{typeof sel.cost_usd === "number" ? <span><span className="text-foreground/60">cost</span> ${sel.cost_usd.toFixed(2)}</span> : null}
{sel.span_id ? <span className="truncate"><span className="text-foreground/60">span</span> {sel.span_id}</span> : null}
{sel.trace_id ? <span className="col-span-2 truncate sm:col-span-3"><span className="text-foreground/60">trace</span> {sel.trace_id}</span> : null}
</div>
{sel.system ? (
<div>
<p className="mb-1 font-mono text-[9px] font-semibold uppercase tracking-wide text-muted-foreground">System</p>
<pre className="overflow-auto whitespace-pre-wrap rounded-md border-2 border-primary/20 bg-accent/20 p-2 font-mono text-[11px] leading-snug text-foreground/90">{sel.system}</pre>
</div>
) : null}
{sel.invocation && Object.keys(sel.invocation).length ? (
<div>
<p className="mb-1 font-mono text-[9px] font-semibold uppercase tracking-wide text-muted-foreground">Invocation parameters</p>
<pre className="overflow-auto rounded-md border-2 border-primary/20 bg-accent/20 p-2 font-mono text-[11px] leading-snug text-foreground/90">{JSON.stringify(sel.invocation, null, 2)}</pre>
</div>
) : null}
{sel.input ? (
<div>
<p className="mb-1 font-mono text-[9px] font-semibold uppercase tracking-wide text-muted-foreground">Input</p>
<pre className="max-h-52 overflow-auto rounded-md border-2 border-primary/20 bg-accent/20 p-2 font-mono text-[11px] leading-snug text-foreground/90">{prettyJSON(sel.input)}</pre>
</div>
) : null}
{sel.output ? (
<div>
<p className="mb-1 font-mono text-[9px] font-semibold uppercase tracking-wide text-muted-foreground">Output</p>
<pre className="max-h-52 overflow-auto whitespace-pre-wrap rounded-md border-2 border-primary/20 bg-accent/20 p-2 font-mono text-[11px] leading-snug text-foreground/90">{sel.output}</pre>
</div>
) : null}
{sel.error ? (
<div>
<p className="mb-1 font-mono text-[9px] font-semibold uppercase tracking-wide text-[oklch(0.5_0.2_25)]">Error</p>
<pre className="overflow-auto whitespace-pre-wrap rounded-md border-2 border-[oklch(0.55_0.2_25)]/30 bg-[oklch(0.55_0.2_25)]/10 p-2 font-mono text-[11px] leading-snug text-[oklch(0.5_0.2_25)]">{sel.error}</pre>
</div>
) : null}
</div>
) : (
<p className="py-10 text-center font-mono text-[11px] uppercase tracking-wide text-muted-foreground">Select a span</p>
)}
</div>
</div>
)}
</div>
);
}
// ----------------------------------------------------------------- //
// Accurate little illustrations of each view style, so the preview shows
// what the user will actually get (item: render a real preview image).
function VariantThumb({ id }) {
const common = { width: "100%", height: "100%", viewBox: "0 0 120 56", preserveAspectRatio: "xMidYMid slice", "aria-hidden": true };
if (id === "watercolor") {
return (
<svg {...common}>
<rect width="120" height="56" fill="#eaf0d8" />
<path d="M0 38 Q30 30 60 36 T120 32 V56 H0 Z" fill="#cfe0ff" opacity="0.85" />
<path d="M0 20 Q40 14 80 20 T120 16" stroke="#9eaad7" strokeWidth="3" fill="none" opacity="0.7" />
<circle cx="34" cy="26" r="3" fill="#3b7d4f" /><circle cx="72" cy="32" r="3" fill="#3b7d4f" />
{/* a couple of polaroid pins */}
<g transform="rotate(-6 30 18)"><rect x="24" y="10" width="14" height="16" fill="#fff" stroke="#ccc" /><rect x="26" y="12" width="10" height="9" fill="#c8912e" /></g>
<g transform="rotate(5 82 16)"><rect x="76" y="8" width="14" height="16" fill="#fff" stroke="#ccc" /><rect x="78" y="10" width="10" height="9" fill="#b6485c" /></g>
</svg>
);
}
if (id === "cesium") {
return (
<svg {...common}>
<rect width="120" height="56" fill="#1f2b3e" />
<ellipse cx="60" cy="64" rx="80" ry="40" fill="#34506e" />
<g fill="#c8b48a"><rect x="40" y="30" width="8" height="20" /><rect x="52" y="22" width="9" height="28" /><rect x="65" y="34" width="7" height="16" /><rect x="75" y="26" width="9" height="24" /></g>
<g fill="#9fb6cf" opacity="0.5"><rect x="40" y="30" width="3" height="20" /><rect x="52" y="22" width="3" height="28" /><rect x="75" y="26" width="3" height="24" /></g>
<g transform="rotate(-5 58 16)"><rect x="52" y="8" width="13" height="15" fill="#fff" /><rect x="54" y="10" width="9" height="8" fill="#2F5D7C" /></g>
</svg>
);
}
if (id === "develop") {
return (
<svg {...common}>
<rect width="120" height="56" fill="#efe6d6" />
{[[14,-8],[40,5],[68,-4],[92,7]].map(([x,r],i)=>(
<g key={i} transform={`rotate(${r} ${x+9} 28)`}><rect x={x} y="14" width="18" height="22" fill="#fff" stroke="#d8c39a" /><rect x={x+2} y="16" width="14" height="13" fill={["#b6485c","#2F5D7C","#C8912E","#3b7d4f"][i]} opacity="0.8" /></g>
))}
</svg>
);
}
if (id === "scrapbook") {
return (
<svg {...common}>
<rect width="120" height="56" fill="#f1e7d4" />
<path d="M10 40 Q40 20 70 30 T110 18" stroke="#b6485c" strokeWidth="1.5" fill="none" strokeDasharray="3 3" opacity="0.6" />
{[[20,16],[54,28],[84,14]].map(([x,y],i)=>(<g key={i} transform={`rotate(${i%2?6:-5} ${x+8} ${y+8})`}><rect x={x} y={y} width="17" height="20" fill="#fff" stroke="#ccc" /><rect x={x+2} y={y+2} width="13" height="12" fill={["#C8912E","#2F5D7C","#b6485c"][i]} opacity="0.8" /></g>))}
</svg>
);
}
if (id === "grid") {
return (
<svg {...common}>
<rect width="120" height="56" fill="#f3efe6" />
{[0,1,2,3].map((c)=>[0,1].map((r)=>(<rect key={c+"-"+r} x={8+c*27} y={8+r*24} width="22" height="18" fill={["#cfe0ff","#e9d8c4","#d6e4c4","#e9c4cf"][(c+r)%4]} stroke="#fff" strokeWidth="2" />)))}
</svg>
);
}
// timeline
return (
<svg {...common}>
<rect width="120" height="56" fill="#f3efe6" />
<line x1="22" y1="6" x2="22" y2="50" stroke="#C8912E" strokeWidth="2" />
{[12,28,44].map((y,i)=>(<g key={i}><circle cx="22" cy={y} r="3" fill="#C8912E" /><rect x="32" y={y-5} width="30" height="10" rx="2" fill="#fff" stroke="#ddd" /><rect x="66" y={y-4} width="40" height="3" fill="#ddd" /></g>))}
</svg>
);
}
function MapStylePicker({ variant, onChange }) {
const current = MAP_VARIANTS.find((v) => v.id === variant) || MAP_VARIANTS[0];
return (
<div className="rounded-md border-2 border-primary/70 bg-card p-4">
<div className="flex items-center justify-between gap-2">
<span className="flex items-center gap-2 font-mono text-xs font-semibold uppercase tracking-wide text-foreground">
<Palette className="size-4 text-primary" /> Views
</span>
{null}
</div>
<p className="mt-1 text-xs leading-snug text-muted-foreground">Pick how your memories render once they're developed. You can change this later, too.</p>
<div className="mt-3 grid grid-cols-2 gap-2.5 sm:grid-cols-3">
{MAP_VARIANTS.map((v) => {
const IconC = v.icon || MapIcon;
const sel = v.id === variant;
return (
<button key={v.id} type="button" onClick={() => onChange(v.id)}
aria-pressed={sel} title={v.blurb}
className={cn(
"group relative flex flex-col overflow-hidden rounded-lg border-2 text-left transition-all",
sel ? "border-primary ring-2 ring-primary/30" : "border-primary/30 hover:border-primary/70"
)}>
{/* accurate preview of the layout */}
<span className="relative block h-16 w-full overflow-hidden" aria-hidden="true">
<VariantThumb id={v.id} />
<span className="absolute right-1.5 top-1.5 flex size-6 items-center justify-center rounded-md border border-black/10 bg-card/80 backdrop-blur-sm">
<IconC className="size-3.5 text-primary" />
</span>
{sel ? (
<span className="absolute left-1.5 top-1.5 flex size-5 items-center justify-center rounded-full bg-primary text-primary-foreground">
<Check className="size-3" />
</span>
) : null}
</span>
<span className="px-2.5 py-2">
<span className="font-mono text-[11px] font-semibold uppercase tracking-wide text-foreground">{v.label}</span>
</span>
</button>
);
})}
</div>
</div>
);
}
// ----------------------------------------------------------------- //
// WatercolorMap - an open-source re-imagining of the Cambridge
// watercolor demo. Renders a painterly, interactive map of ANY city the
// user picks (geocoded via OpenStreetMap Nominatim) using MapLibre GL JS
// and free raster tiles. Users type an activity and a place; we geocode
// it (or they click the map) and drop an annotated marker - their own
// little "a morning run before class" story, for any city, no paid keys.
// ----------------------------------------------------------------- //
async function geocode(query, opts = {}) {
const params = new URLSearchParams({ q: query, format: "jsonv2", limit: opts.limit || 1, addressdetails: 1 });
const res = await fetch("https://nominatim.openstreetmap.org/search?" + params.toString(), {
headers: { "Accept": "application/json" },
});
if (!res.ok) throw new Error("geocode failed");
const data = await res.json();
return data.map((d) => ({ lat: parseFloat(d.lat), lon: parseFloat(d.lon), label: d.display_name, type: d.type }));
}
// Reverse-geocode coordinates to a short, human "general area" - we keep it
// intentionally coarse (neighbourhood + city), both for friendliness and
// because a photo's location is a *general* place, not a survey pin.
async function reverseGeocode(lat, lon) {
try {
const params = new URLSearchParams({ lat, lon, format: "jsonv2", zoom: 14, addressdetails: 1 });
const res = await fetch("https://nominatim.openstreetmap.org/reverse?" + params.toString(), {
headers: { "Accept": "application/json" },
});
if (!res.ok) return null;
const d = await res.json();
const a = d.address || {};
const area = a.neighbourhood || a.suburb || a.quarter || a.hamlet || a.village || a.town || a.city_district;
const city = a.city || a.town || a.village || a.municipality || a.county || a.state;
const parts = [...new Set([area, city].filter(Boolean))];
return parts.slice(0, 2).join(", ") || (d.display_name ? d.display_name.split(",").slice(0, 2).join(",").trim() : null);
} catch (_) { return null; }
}
// A radius circle as a GeoJSON polygon (meters), so MapLibre can draw a real
// ground circle that scales with zoom (a 'circle' layer is pixel-sized).
function circlePolygon(lon, lat, meters, points = 56) {
const coords = [];
const dLat = meters / 110574;
const dLon = meters / (111320 * Math.cos(lat * Math.PI / 180) || 1);
for (let i = 0; i <= points; i++) {
const t = (2 * Math.PI * i) / points;
coords.push([lon + dLon * Math.cos(t), lat + dLat * Math.sin(t)]);
}
return { type: "Feature", geometry: { type: "Polygon", coordinates: [coords] } };
}
// Lazily load CesiumJS (heavy) only when the user opens the 3D globe view.
// Pulls the UMD build + CSS from a CDN; resolves window.Cesium.
const CESIUM_VER = "1.124.0";
let _cesiumPromise = null;
function loadCesium() {
if (window.Cesium) return Promise.resolve(window.Cesium);
if (_cesiumPromise) return _cesiumPromise;
_cesiumPromise = new Promise((resolve, reject) => {
// Cesium needs a base URL to find its workers/assets.
window.CESIUM_BASE_URL = "https://cdn.jsdelivr.net/npm/cesium@" + CESIUM_VER + "/Build/Cesium/";
const css = document.createElement("link");
css.rel = "stylesheet";
css.href = "https://cdn.jsdelivr.net/npm/cesium@" + CESIUM_VER + "/Build/Cesium/Widgets/widgets.css";
document.head.appendChild(css);
const s = document.createElement("script");
s.src = "https://cdn.jsdelivr.net/npm/cesium@" + CESIUM_VER + "/Build/Cesium/Cesium.js";
s.async = true;
s.onload = () => { if (window.Cesium) resolve(window.Cesium); else reject(new Error("Cesium global missing")); };
s.onerror = () => reject(new Error("Cesium failed to load"));
document.head.appendChild(s);
});
return _cesiumPromise;
}
// Fetch runtime config (Cesium ion token) once; cached.
let _cfgPromise = null;
function getClientConfig() {
if (_cfgPromise) return _cfgPromise;
_cfgPromise = fetch("/api/config").then((r) => r.ok ? r.json() : {}).catch(() => ({}));
return _cfgPromise;
}
function buildMapStyle(tileKey, colorway) {
// Only the watercolor vector style exists now. Pick the palette by the
// chosen colorway (classic / sepia / sunrise / dusk).
const key = colorway || (TILE_SOURCES[tileKey] || TILE_SOURCES.watercolor).palette || "classic";
return watercolorVectorStyle(WC_PALETTES[key] || WC_PALETTES.classic);
}
const ACTIVITY_ICONS = ["🏃", "🚶", "☕", "📷", "🍜", "🚲", "🛶", "🎨", "🌳", "🏛️", "🎶", "🛍️"];
// Subscribe a component to the shared photo store.
function useSozaiPhotos() {
const [items, setItems] = useState(() => window.SozaiPhotos.all());
useEffect(() => window.SozaiPhotos.subscribe(setItems), []);
return items;
}
// Build the ordered flythrough stops from the pinned photos: sorted by
// timestamp when available, else by array order. Shared by both map engines
// so the "video" sequence is identical on the watercolor map and Cesium.
function buildFlyStops(pinnedPhotos) {
const withTs = pinnedPhotos.map((p, i) => ({ p, i, ts: photoTimestamp(p) }));
const haveTs = withTs.filter((x) => x.ts != null);
const sorted = haveTs.length === withTs.length
? withTs.slice().sort((a, b) => a.ts - b.ts)
: withTs; // mixed/missing timestamps → keep pin order
return sorted.map((x) => x.p);
}
// Shared photo tray - used by BOTH the watercolor map and the Cesium globe
// so album photos (and their captions/dates/times) appear everywhere. Cards
// are draggable onto the map and clickable to arm tap-to-pin; a reset
// control sends everything back to the tray (the map is a canvas).
function PhotoTray({ onPick, armedId, hint }) {
const items = useSozaiPhotos();
const pinned = items.filter((p) => p.pinned && p.lon != null && p.lat != null);
const unpinned = items.filter((p) => !p.pinned);
return (
<div className="rounded-lg border-2 border-primary/40 bg-card p-4">
<div className="flex items-center justify-between gap-2">
<span className="flex items-center gap-2 font-mono text-xs font-semibold uppercase tracking-wide text-foreground"><ImageIcon className="size-4 text-primary" /> Photos from your album</span>
<div className="flex items-center gap-2">
<span className="font-mono text-[10px] uppercase tracking-wide text-muted-foreground">{pinned.length} pinned · {unpinned.length} to place</span>
{pinned.length ? (
<button type="button" onClick={() => { window.SozaiPhotos.resetPins(); Collab.send("photos_reset", {}); }}
title="Send every photo back to the tray"
className="flex items-center gap-1 rounded-md border-2 border-primary/50 px-2 py-0.5 font-mono text-[10px] uppercase tracking-wide text-primary transition-colors hover:bg-accent">
<RotateCcw className="size-3" /> Reset
</button>
) : null}
</div>
</div>
<p className="mt-1 text-xs leading-snug text-muted-foreground">{hint || "Drag a photo onto the map - or tap it, then tap the map - to pin it where it happened. Captions carry over from “Add to Album.”"}</p>
{unpinned.length ? (
<ul className="mt-3 flex gap-3 overflow-x-auto pb-1">
{unpinned.map((p) => {
const arming = armedId === p.id;
const meta = [p.date, p.time].filter(Boolean).join(" · ");
return (
<li key={p.id} className="shrink-0">
<button type="button" onClick={() => onPick && onPick(p)}
draggable
onDragStart={(e) => { e.dataTransfer.setData("application/sozai-photo", p.id); e.dataTransfer.effectAllowed = "copy"; }}
className={cn("flex w-32 cursor-grab flex-col overflow-hidden rounded-md border-2 text-left transition-all active:cursor-grabbing",
arming ? "border-primary ring-2 ring-primary/30" : "border-primary/30 hover:border-primary/70")}>
<span className="relative block h-20 w-full overflow-hidden bg-accent/40">
<img src={p.src} alt={p.alt || ""} draggable={false} className="h-full w-full object-cover" />
<span className="absolute right-1 top-1 flex size-5 items-center justify-center rounded-full bg-primary text-primary-foreground"><Pin className="size-3" /></span>
</span>
<span className="flex flex-col gap-0.5 px-2 py-1.5">
<span className="line-clamp-1 text-[11px] font-semibold text-foreground">{p.title || "Untitled"}</span>
<span className="line-clamp-1 text-[10px] text-muted-foreground">{p.caption || "-"}</span>
{meta ? <span className="font-mono text-[9px] uppercase tracking-wide text-primary/70">{meta}</span> : null}
</span>
</button>
</li>
);
})}
</ul>
) : (
<p className="mt-3 rounded-md border-2 border-dashed border-primary/30 bg-accent/20 px-3 py-2 text-center font-mono text-[10px] uppercase tracking-wide text-muted-foreground">
{pinned.length ? "All album photos are pinned. Press “Play journey” on the map." : "Add photos in “Add to Album” first."}
</p>
)}
</div>
);
}
function WatercolorMap({ tileKey = "watercolor", cityName }) {
const mapRef = useRef(null);
const containerRef = useRef(null);
const fsWrapRef = useRef(null);
const markersRef = useRef([]);
const [ready, setReady] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const [libMissing, setLibMissing] = useState(false);
const [place, setPlace] = useState(cityName || tripMeta.location || "Ottawa");
const [searching, setSearching] = useState(false);
const [searchErr, setSearchErr] = useState(null);
const [center, setCenter] = useState({ lon: -123.1207, lat: 49.2827, zoom: 11 }); // Vancouver default
const [activities, setActivities] = useState([]);
const [draft, setDraft] = useState({ text: "", where: "", icon: "🏃" });
const [adding, setAdding] = useState(false);
const [addErr, setAddErr] = useState(null);
const [pickOnMap, setPickOnMap] = useState(false);
const [tileFellBack, setTileFellBack] = useState(false);
const [effectiveTile, setEffectiveTile] = useState(tileKey);
const [colorway, setColorway] = useState(() => {
try { return localStorage.getItem("sozai_wc_colorway") || "classic"; } catch (e) { return "classic"; }
});
const [styleEpoch, setStyleEpoch] = useState(0);
// Polaroid photo pins the user drops on the map
const [polaroids, setPolaroids] = useState([]);
const [pinMode, setPinMode] = useState(false); // armed with a pending photo
const [polaroidBusy, setPolaroidBusy] = useState(false); // upload in progress
const pendingPhotoRef = useRef(null); // {src, caption, date, time, id} waiting to be placed
const polaroidMarkersRef = useRef([]);
const polaroidFileRef = useRef(null);
const storeMarkersRef = useRef({}); // id -> {marker, el}
// album photos from the shared store (carry caption/date/time from "Add
// to Album"); the draft tray lets the user pin them onto the map.
const storePhotos = useSozaiPhotos();
const pinnedStore = storePhotos.filter((p) => p.pinned && p.lon != null && p.lat != null);
// ordered stops for the flythrough (sorted by timestamp when available)
const flyStops = buildFlyStops(pinnedStore);
// flythrough ("Map of My Time" video) state
const [flying, setFlying] = useState(false);
const [flyIdx, setFlyIdx] = useState(-1);
const flyAbortRef = useRef({ stop: false });
const [dropArmed, setDropArmed] = useState(false); // drag-from-tray hint
// transport that travels along the journey (item 5)
const TRANSPORTS = [
{ id: "car", label: "Car", emoji: "🚗", speed: 1 },
{ id: "bike", label: "Bicycle", emoji: "🚲", speed: 0.75 },
{ id: "walk", label: "Walk", emoji: "🚶", speed: 0.5 },
{ id: "plane", label: "Plane", emoji: "✈️", speed: 1.6 },
{ id: "train", label: "Train", emoji: "🚆", speed: 1.3 },
];
const [transport, setTransport] = useState("car");
const transportMarkerRef = useRef(null);
// recording the journey to a downloadable clip (item 5)
const [recording, setRecording] = useState(false);
const recorderRef = useRef(null);
const recChunksRef = useRef([]);
const [clipUrl, setClipUrl] = useState(null);
// is the active style one of the painterly watercolor (vector) styles?
const isWatercolor = ((TILE_SOURCES[tileKey] || {}).kind === "vector") && !tileFellBack;
// init map once
useEffect(() => {
if (mapRef.current || !containerRef.current) return;
let map;
let retryTimer = null;
let cancelled = false;
function start() {
if (cancelled || mapRef.current) return;
if (!window.maplibregl) {
// the MapLibre script may still be downloading - retry briefly
retryTimer = setTimeout(start, 300);
return;
}
try {
map = new window.maplibregl.Map({
container: containerRef.current,
style: buildMapStyle(tileKey, colorway),
center: [center.lon, center.lat],
zoom: center.zoom,
attributionControl: { compact: true },
preserveDrawingBuffer: true, // needed to record the canvas to a clip
});
} catch (e) {
console.error("[map] init failed", e);
setLibMissing(true);
return;
}
map.addControl(new window.maplibregl.NavigationControl({ showCompass: true }), "top-right");
map.on("load", () => setReady(true));
// Safety net: reveal the map even if 'load' is slow/never fires.
map._readyTimer = setTimeout(() => setReady(true), 4000);
// The watercolor style uses key-free OpenFreeMap vector tiles, so
// there is no longer a raster basemap to fall back to. On a genuine,
// sustained source failure we simply re-apply the classic watercolor
// style (key-free) and reveal the map rather than leaving it blank.
let hardErrors = 0;
map.on("error", (ev) => {
const err = ev && ev.error;
const msg = (err && (err.message || String(err))) || "";
const status = err && err.status;
const authFail = status === 401 || status === 403 || status === 429 ||
/\b(401|403|429)\b/.test(msg);
const sourceFail = !!ev.sourceId && /Failed to fetch|NetworkError|load|JSON|style/i.test(msg);
if (authFail) hardErrors += 5; else if (sourceFail) hardErrors += 1;
if (authFail || sourceFail) console.warn("[map] source error", { msg, status, sourceId: ev.sourceId });
if (hardErrors >= 4 && !tileFellBackRef.current) {
tileFellBackRef.current = true;
setTileFellBack(true);
try { map.setStyle(buildMapStyle("watercolor", "classic")); } catch (e) {}
setReady(true);
}
});
map.on("click", (e) => {
const ll = e.lngLat;
// dropping a Polaroid takes priority when armed
if (pinModeRef.current && pendingPhotoRef.current) {
const pending = pendingPhotoRef.current;
pendingPhotoRef.current = null;
setPinMode(false);
// a store photo (object with an id) persists onto the shared store;
// a bare data-URL string is an ad-hoc local polaroid.
if (pending && typeof pending === "object" && pending.storeId) {
window.SozaiPhotos.pin(pending.storeId, ll.lng, ll.lat);
if (mapRef.current) mapRef.current.flyTo({ center: [ll.lng, ll.lat], zoom: Math.max(mapRef.current.getZoom(), 14), duration: 900 });
} else {
const src = (pending && typeof pending === "object") ? pending.src : pending;
const cap = (pending && typeof pending === "object") ? (pending.caption || "") : "";
setPolaroids((prev) => {
if (prev.length >= MAX_POLAROIDS) {
setAddErr("You can pin up to " + MAX_POLAROIDS + " polaroids.");
return prev;
}
return [...prev, {
id: "pl" + Date.now() + "-" + Math.random().toString(36).slice(2, 5),
src, caption: cap, lon: ll.lng, lat: ll.lat,
rot: Math.random() * 10 - 5,
}];
});
}
return;
}
if (pickOnMapRef.current) {
addActivityAt(draftRef.current, { lon: ll.lng, lat: ll.lat });
setPickOnMap(false);
}
});
mapRef.current = map;
}
start();
// last resort: if MapLibre never loads at all, show the offline notice
const giveUp = setTimeout(() => { if (!mapRef.current && !window.maplibregl) setLibMissing(true); }, 9000);
return () => {
cancelled = true;
if (retryTimer) clearTimeout(retryTimer);
clearTimeout(giveUp);
if (map) { if (map._readyTimer) clearTimeout(map._readyTimer); map.remove(); }
mapRef.current = null;
};
}, []); // eslint-disable-line
// refs so the map click handler sees fresh values
const pickOnMapRef = useRef(false);
const draftRef = useRef(draft);
const tileFellBackRef = useRef(false);
const pinModeRef = useRef(false);
useEffect(() => { pickOnMapRef.current = pickOnMap; }, [pickOnMap]);
useEffect(() => { draftRef.current = draft; }, [draft]);
useEffect(() => { pinModeRef.current = pinMode; }, [pinMode]);
// swap tiles/colorway when the style changes without tearing down the map
useEffect(() => {
const map = mapRef.current;
if (!map || !ready) return;
tileFellBackRef.current = false;
setTileFellBack(false);
setEffectiveTile(tileKey);
try { localStorage.setItem("sozai_wc_colorway", colorway); } catch (e) {}
map.setStyle(buildMapStyle(tileKey, colorway));
// setStyle wipes custom markers/layers - bump an epoch so the marker
// effect re-adds them once the new style has loaded.
map.once("styledata", () => setStyleEpoch((e) => e + 1));
}, [tileKey, colorway, ready]);
// re-render markers whenever activities change
useEffect(() => {
const map = mapRef.current;
if (!map || !window.maplibregl) return;
markersRef.current.forEach((m) => m.remove());
markersRef.current = [];
activities.forEach((a) => {
const el = document.createElement("div");
el.className = "sozai-pin";
el.innerHTML =
'<div class="sozai-pin-bubble">' +
'<span class="sozai-pin-emoji">' + (a.icon || "📍") + '</span>' +
'<span class="sozai-pin-text">' + (a.text ? a.text.replace(/</g, "&lt;") : "") + '</span>' +
'</div><div class="sozai-pin-stem"></div>';
const marker = new window.maplibregl.Marker({ element: el, anchor: "bottom" })
.setLngLat([a.lon, a.lat])
.addTo(map);
markersRef.current.push(marker);
});
}, [activities, ready, styleEpoch]);
// render the Polaroid photo pins
useEffect(() => {
const map = mapRef.current;
if (!map || !window.maplibregl) return;
polaroidMarkersRef.current.forEach((m) => m.remove());
polaroidMarkersRef.current = [];
polaroids.forEach((p) => {
const wrap = document.createElement("div");
wrap.className = "sozai-polaroid-wrap";
const el = document.createElement("div");
el.className = "sozai-polaroid";
el.style.setProperty("--rot", (p.rot || 0) + "deg");
el.innerHTML =
'<img src="' + p.src + '" alt="">' +
(p.caption ? '<div class="cap">' + p.caption.replace(/</g, "&lt;") + '</div>' : '<div class="cap">&nbsp;</div>');
const del = document.createElement("button");
del.className = "pdel"; del.type = "button"; del.title = "Remove"; del.textContent = "×";
del.addEventListener("click", (ev) => {
ev.stopPropagation();
destroyPolaroidEl(el, () => setPolaroids((prev) => prev.filter((x) => x.id !== p.id)));
});
wrap.appendChild(el); wrap.appendChild(del);
const marker = new window.maplibregl.Marker({ element: wrap, anchor: "bottom" })
.setLngLat([p.lon, p.lat])
.addTo(map);
polaroidMarkersRef.current.push(marker);
});
}, [polaroids, ready, styleEpoch]);
// render the album photos that have been pinned, as a movable canvas:
// each is draggable, rotatable and scalable, and every change persists to
// the shared store (so it survives reloads and syncs across both maps).
useEffect(() => {
const map = mapRef.current;
if (!map || !window.maplibregl) return;
Object.values(storeMarkersRef.current).forEach((o) => o.marker.remove());
storeMarkersRef.current = {};
// Each pin sits exactly at the center point the user picked in the
// location picker - no auto-scatter, so a pin renders where it's placed.
pinnedStore.forEach((p) => {
const rot = p.rot || 0;
const scale = p.scale || 1;
const wrap = document.createElement("div");
wrap.className = "sozai-polaroid-wrap";
const el = document.createElement("div");
el.className = "sozai-polaroid";
el.style.setProperty("--rot", rot + "deg");
el.style.setProperty("--scale", scale);
const _dmy = (s) => { const d = new Date(s); if (isNaN(d)) return s; const z = (n) => String(n).padStart(2, "0"); return z(d.getDate()) + "/" + z(d.getMonth() + 1) + "/" + d.getFullYear(); };
const meta = p.date ? _dmy(p.date) : "";
const objPos = (p.focusX != null ? p.focusX : 50) + "% " + (p.focusY != null ? p.focusY : 50) + "%";
el.innerHTML =
'<img src="' + p.src + '" alt="" style="object-position:' + objPos + '">' +
'<div class="cap">' + ((p.caption || p.title || "&nbsp;").replace(/</g, "&lt;")) +
(meta ? '<span class="sozai-pin-when">' + meta.replace(/</g, "&lt;") + '</span>' : '') +
'</div>';
// controls: remove · rotate · scale
const del = document.createElement("button");
del.className = "pdel"; del.type = "button"; del.title = "Send back to tray"; del.textContent = "×";
del.addEventListener("click", (ev) => {
ev.stopPropagation();
destroyPolaroidEl(el, () => window.SozaiPhotos.unpin(p.id));
});
const rotL = document.createElement("button");
rotL.className = "pctl prot-l"; rotL.type = "button"; rotL.title = "Rotate left"; rotL.textContent = "⟲";
rotL.addEventListener("click", (ev) => { ev.stopPropagation(); const v = (p.rot || 0) - 6; window.SozaiPhotos.setStyleProps(p.id, { rot: v }); Collab.send("photo_props", { id: p.id, props: { rot: v } }); });
const rotR = document.createElement("button");
rotR.className = "pctl prot-r"; rotR.type = "button"; rotR.title = "Rotate right"; rotR.textContent = "⟳";
rotR.addEventListener("click", (ev) => { ev.stopPropagation(); const v = (p.rot || 0) + 6; window.SozaiPhotos.setStyleProps(p.id, { rot: v }); Collab.send("photo_props", { id: p.id, props: { rot: v } }); });
const big = document.createElement("button");
big.className = "pctl pbig"; big.type = "button"; big.title = "Bigger"; big.textContent = "+";
big.addEventListener("click", (ev) => { ev.stopPropagation(); const v = Math.min(2, (p.scale || 1) + 0.12); window.SozaiPhotos.setStyleProps(p.id, { scale: v }); Collab.send("photo_props", { id: p.id, props: { scale: v } }); });
const small = document.createElement("button");
small.className = "pctl psmall"; small.type = "button"; small.title = "Smaller"; small.textContent = "−";
small.addEventListener("click", (ev) => { ev.stopPropagation(); const v = Math.max(0.5, (p.scale || 1) - 0.12); window.SozaiPhotos.setStyleProps(p.id, { scale: v }); Collab.send("photo_props", { id: p.id, props: { scale: v } }); });
wrap.appendChild(el);
wrap.appendChild(del); wrap.appendChild(rotL); wrap.appendChild(rotR); wrap.appendChild(big); wrap.appendChild(small);
const marker = new window.maplibregl.Marker({ element: wrap, anchor: "bottom", draggable: true })
.setLngLat([p.lon, p.lat]).addTo(map);
// persist the new position when the user finishes dragging it
marker.on("dragend", () => {
const ll = marker.getLngLat();
window.SozaiPhotos.move(p.id, ll.lng, ll.lat);
Collab.send("photo_moved", { id: p.id, lon: ll.lng, lat: ll.lat });
});
// live-broadcast position during the drag (throttled) so collaborators
// see the polaroid glide in real time instead of jumping at drop.
let _lastDragSend = 0;
marker.on("drag", () => {
const now = Date.now();
if (now - _lastDragSend < 60) return;
_lastDragSend = now;
const ll = marker.getLngLat();
Collab.send("photo_moved", { id: p.id, lon: ll.lng, lat: ll.lat });
});
marker.on("dragstart", () => wrap.classList.add("is-dragging"));
marker.on("dragend", () => wrap.classList.remove("is-dragging"));
storeMarkersRef.current[p.id] = { marker, el: wrap };
});
}, [pinnedStore.map((p) => p.id + ":" + p.lon + "," + p.lat + ":" + p.caption + ":" + (p.rot || 0) + ":" + (p.scale || 1) + ":" + (p.focusX != null ? p.focusX : 50) + "," + (p.focusY != null ? p.focusY : 50)).join("|"), ready, styleEpoch]);
// highlight the active stop during a flythrough
useEffect(() => {
Object.entries(storeMarkersRef.current).forEach(([id, o]) => {
o.el.classList.toggle("is-active", flying && flyStops[flyIdx] && flyStops[flyIdx].id === id);
});
});
// "Expand" the map in-page: the map section grows to fill the viewport
// as an overlay (the user emerges into the map) rather than putting the
// whole browser page into fullscreen. Escape exits. The GL canvas is
// resized whenever the expanded state changes so tiles fill the space.
useEffect(() => {
function onKey(e) { if (e.key === "Escape") setIsFullscreen(false); }
if (isFullscreen) document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [isFullscreen]);
useEffect(() => {
// lock body scroll while expanded so the immersive view is clean
if (isFullscreen) {
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => { document.body.style.overflow = prev; };
}
}, [isFullscreen]);
useEffect(() => {
// Resize the GL canvas several times as the expand/collapse layout
// settles - a single early resize() leaves the map blank/collapsed.
const doResize = () => { if (mapRef.current) { try { mapRef.current.resize(); } catch (e) {} } };
const raf = requestAnimationFrame(doResize);
const ts = [setTimeout(doResize, 120), setTimeout(doResize, 350)];
// On expand, ease the camera out so the much bigger canvas shows more.
if (isFullscreen) ts.push(setTimeout(() => {
const m = mapRef.current; if (!m) return;
try { m.easeTo({ zoom: Math.max(1, m.getZoom() - 1.6), duration: 900 }); } catch (e) {}
}, 420));
return () => { cancelAnimationFrame(raf); ts.forEach(clearTimeout); };
}, [isFullscreen]);
function toggleFullscreen() { setIsFullscreen((v) => !v); }
async function searchCity() {
const q = place.trim();
if (!q) return;
setSearching(true); setSearchErr(null);
try {
const hits = await geocode(q, { limit: 1 });
if (!hits.length) throw new Error("No place found. Try a more specific name.");
const { lat, lon } = hits[0];
setCenter({ lat, lon, zoom: 13 });
if (mapRef.current) mapRef.current.flyTo({ center: [lon, lat], zoom: 13, duration: 1500 });
} catch (e) {
setSearchErr(e.message || "Could not find that city.");
} finally {
setSearching(false);
}
}
function addActivityAt(d, coords) {
const item = { id: Date.now() + "-" + Math.random().toString(36).slice(2, 6), text: d.text.trim() || "My activity", icon: d.icon, lon: coords.lon, lat: coords.lat };
setActivities((prev) => [...prev, item]);
if (mapRef.current) mapRef.current.flyTo({ center: [coords.lon, coords.lat], zoom: Math.max(mapRef.current.getZoom(), 14), duration: 1000 });
setDraft({ text: "", where: "", icon: d.icon });
setAddErr(null);
}
async function addActivity() {
if (!draft.text.trim()) { setAddErr("Describe the activity first."); return; }
// if a place is given, geocode it; otherwise drop at the current center
if (draft.where.trim()) {
setAdding(true); setAddErr(null);
try {
const hits = await geocode(draft.where.trim() + " " + place, { limit: 1 });
const hit = hits[0] || (await geocode(draft.where.trim(), { limit: 1 }))[0];
if (!hit) throw new Error("Couldn't find that spot - try 'pick on map'.");
addActivityAt(draft, { lon: hit.lon, lat: hit.lat });
} catch (e) {
setAddErr(e.message || "Couldn't place that activity.");
} finally {
setAdding(false);
}
} else {
const c = mapRef.current ? mapRef.current.getCenter() : { lng: center.lon, lat: center.lat };
addActivityAt(draft, { lon: c.lng, lat: c.lat });
}
}
function removeActivity(id) {
setActivities((prev) => prev.filter((a) => a.id !== id));
}
// pick a photo, then arm "pin mode" so the next map click drops it
async function onPolaroidPick(e) {
const file = (e.target.files || [])[0];
if (polaroidFileRef.current) polaroidFileRef.current.value = "";
if (!file) return;
// cap: at most 5 polaroids on the map
if (polaroids.length >= MAX_POLAROIDS) {
setAddErr("You can pin up to " + MAX_POLAROIDS + " polaroids. Remove one to add another.");
return;
}
setPolaroidBusy(true);
const started = Date.now();
const result = await processUpload(file); // validates type, runs NSFW, shows overlay
// keep the developing animation visible for at least a beat
const wait = Math.max(0, 650 - (Date.now() - started));
await new Promise((r) => setTimeout(r, wait));
setPolaroidBusy(false);
if (!result.ok) return;
pendingPhotoRef.current = result.dataUrl;
setPinMode(true);
setAddErr(null);
}
// arm pin mode for an album photo from the draft tray (preserves its
// caption/date/time via the store id)
function pickStorePhoto(p) {
pendingPhotoRef.current = { storeId: p.id, src: p.src, caption: p.caption };
setPinMode(true);
setAddErr(null);
}
// ---- flythrough ("Map of My Time" video) ---- //
function stopFly() {
flyAbortRef.current.stop = true;
setFlying(false);
setFlyIdx(-1);
if (transportMarkerRef.current) { try { transportMarkerRef.current.remove(); } catch (e) {} transportMarkerRef.current = null; }
stopRecording();
}
// move the transport emoji marker to a lng/lat (creating it if needed)
function setTransportAt(lon, lat, heading) {
const map = mapRef.current; if (!map || !window.maplibregl) return;
if (!transportMarkerRef.current) {
const el = document.createElement("div");
el.className = "sozai-transport";
el.textContent = (TRANSPORTS.find((t) => t.id === transport) || TRANSPORTS[0]).emoji;
transportMarkerRef.current = new window.maplibregl.Marker({ element: el, anchor: "center" }).setLngLat([lon, lat]).addTo(map);
} else {
transportMarkerRef.current.setLngLat([lon, lat]);
}
if (heading != null) { const el = transportMarkerRef.current.getElement(); el.style.setProperty("--heading", heading + "deg"); }
}
// ---- recording the journey to a downloadable WebM (item 5) ---- //
function startRecording() {
try {
const canvas = mapRef.current && mapRef.current.getCanvas && mapRef.current.getCanvas();
if (!canvas || !canvas.captureStream || typeof MediaRecorder === "undefined") return false;
const stream = canvas.captureStream(30);
const mime = ["video/webm;codecs=vp9", "video/webm;codecs=vp8", "video/webm"].find((m) => MediaRecorder.isTypeSupported && MediaRecorder.isTypeSupported(m)) || "video/webm";
const rec = new MediaRecorder(stream, { mimeType: mime });
recChunksRef.current = [];
rec.ondataavailable = (e) => { if (e.data && e.data.size) recChunksRef.current.push(e.data); };
rec.onstop = () => {
const blob = new Blob(recChunksRef.current, { type: "video/webm" });
setClipUrl((prev) => { if (prev) URL.revokeObjectURL(prev); return URL.createObjectURL(blob); });
};
rec.start();
recorderRef.current = rec;
return true;
} catch (e) { console.warn("[record] failed", e); return false; }
}
function stopRecording() {
const rec = recorderRef.current;
if (rec && rec.state !== "inactive") { try { rec.stop(); } catch (e) {} }
recorderRef.current = null;
setRecording(false);
}
async function playFly(opts) {
opts = opts || {};
const map = mapRef.current;
if (!map || flyStops.length === 0) return;
flyAbortRef.current = { stop: false };
const token = flyAbortRef.current;
setFlying(true);
if (opts.record) { const ok = startRecording(); setRecording(ok); }
// draw the route as a line under the journey
const routeCoords = flyStops.map((s) => [s.lon, s.lat]);
try {
if (map.getLayer("sozai-route")) map.removeLayer("sozai-route");
if (map.getSource("sozai-route")) map.removeSource("sozai-route");
if (routeCoords.length > 1) {
map.addSource("sozai-route", { type: "geojson", data: { type: "Feature", geometry: { type: "LineString", coordinates: routeCoords } } });
map.addLayer({ id: "sozai-route", type: "line", source: "sozai-route",
paint: { "line-color": "#B6485C", "line-width": 3, "line-opacity": 0.6, "line-dasharray": [1, 1.4] } });
}
} catch (e) {}
const spd = (TRANSPORTS.find((t) => t.id === transport) || TRANSPORTS[0]).speed;
// gentle ease-in-out so every camera move accelerates and settles
// smoothly instead of starting/stopping abruptly (smoother travel).
const ease = (t) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2);
// start at the first stop
setFlyIdx(0);
setTransportAt(flyStops[0].lon, flyStops[0].lat);
map.flyTo({ center: [flyStops[0].lon, flyStops[0].lat], zoom: 15, pitch: 50, duration: 1600, curve: 1.42, easing: ease, essential: true });
await new Promise((r) => setTimeout(r, 1500));
if (!token.stop) await new Promise((r) => setTimeout(r, 1200)); // dwell on first photo
// travel each leg: interpolate the transport marker, camera follows
for (let i = 1; i < flyStops.length; i++) {
if (token.stop) break;
const a = flyStops[i - 1], b = flyStops[i];
const heading = (Math.atan2(b.lon - a.lon, b.lat - a.lat) * 180 / Math.PI);
const legMs = Math.max(1400, 2600 / spd);
const t0 = performance.now();
await new Promise((resolve) => {
function step(now) {
if (token.stop) { resolve(); return; }
const k = Math.min(1, (now - t0) / legMs);
const ease = k < 0.5 ? 2 * k * k : 1 - Math.pow(-2 * k + 2, 2) / 2;
const lon = a.lon + (b.lon - a.lon) * ease;
const lat = a.lat + (b.lat - a.lat) * ease;
setTransportAt(lon, lat, heading);
map.setCenter([lon, lat]);
if (k < 1) requestAnimationFrame(step); else resolve();
}
requestAnimationFrame(step);
});
if (token.stop) break;
setFlyIdx(i);
map.flyTo({ center: [b.lon, b.lat], zoom: 15.5, pitch: 55, duration: 1300, curve: 1.42, easing: ease, essential: true });
await new Promise((r) => setTimeout(r, 1400)); // dwell to show the photo
}
if (!token.stop && flyStops.length > 1) {
const lons = flyStops.map((s) => s.lon), lats = flyStops.map((s) => s.lat);
try { map.fitBounds([[Math.min(...lons), Math.min(...lats)], [Math.max(...lons), Math.max(...lats)]], { padding: 90, pitch: 0, bearing: 0, duration: 2200, easing: ease }); } catch (e) {}
await new Promise((r) => setTimeout(r, 2100));
}
// tidy up
if (transportMarkerRef.current) { try { transportMarkerRef.current.remove(); } catch (e) {} transportMarkerRef.current = null; }
try { if (map.getLayer("sozai-route")) map.removeLayer("sozai-route"); if (map.getSource("sozai-route")) map.removeSource("sozai-route"); } catch (e) {}
stopRecording();
setFlying(false);
setFlyIdx(-1);
}
useEffect(() => () => { flyAbortRef.current.stop = true; stopRecording(); }, []);
if (libMissing) {
return (
<div className="rounded-lg border-2 border-primary/40 bg-accent/30 p-6 text-center">
<MapIcon className="mx-auto size-8 text-primary" />
<p className="mt-2 font-mono text-sm font-semibold uppercase tracking-wide text-foreground">Map engine offline</p>
<p className="mt-1 text-sm text-muted-foreground">The map renderer (MapLibre GL) couldn't load. The server tries to cache it locally at <span className="font-mono">/vendor/maplibre-gl.js</span>; if that's empty the server had no internet to fetch it. No API key is required.</p>
<button type="button" onClick={() => { setLibMissing(false); setReady(false); setTimeout(() => window.location.reload(), 50); }}
className="mt-3 inline-flex items-center gap-1.5 rounded-md border-2 border-primary bg-primary px-3 py-1.5 font-mono text-xs font-semibold uppercase tracking-wide text-primary-foreground transition-colors hover:bg-primary/90">
<Navigation className="size-3.5" /> Retry
</button>
</div>
);
}
return (
<div ref={fsWrapRef}
className={cn("flex flex-col gap-4", isFullscreen && "fixed inset-0 z-[80] overflow-auto bg-background p-4 sm:p-6")}>
{/* city search */}
<div className="flex flex-col gap-2 sm:flex-row sm:items-end">
<div className="flex-1">
<label htmlFor="wc-city" className="font-mono text-xs font-semibold uppercase tracking-wide text-foreground">City</label>
<div className="mt-1 flex items-center gap-2 rounded-md border-2 border-primary/60 bg-card px-3 py-2">
<Search className="size-4 text-primary" />
<input id="wc-city" value={place} onChange={(e) => setPlace(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && searchCity()}
placeholder="Search any city, e.g. Kyoto, Lisbon, Nairobi…"
className="min-w-0 flex-1 bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground" />
</div>
</div>
<button type="button" onClick={searchCity} disabled={searching}
className="flex items-center justify-center gap-2 rounded-md border-2 border-primary bg-primary px-4 py-2.5 font-mono text-xs font-semibold uppercase tracking-wide text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-60">
{searching ? <Loader2 className="size-4 animate-spin" /> : <Navigation className="size-4" />} Go
</button>
</div>
{/* watercolor colorway switcher - change the palette right on the map */}
<div className="flex flex-wrap items-center gap-2">
<span className="flex items-center gap-1.5 font-mono text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
<Palette className="size-3.5 text-primary" /> Colorway
</span>
<div className="flex flex-wrap items-center gap-1.5">
{WC_COLORWAYS.map((c) => {
const sel = c.id === colorway;
return (
<button key={c.id} type="button" onClick={() => setColorway(c.id)}
aria-pressed={sel} title={c.label}
className={cn(
"flex items-center gap-1.5 rounded-full border-2 px-2.5 py-1 font-mono text-[11px] uppercase tracking-wide transition-colors",
sel ? "border-primary bg-accent text-foreground" : "border-primary/40 text-muted-foreground hover:bg-accent/50"
)}>
<span className="size-3 rounded-full ring-1 ring-black/10" style={{ background: c.swatch }} aria-hidden="true" />
{c.label}
</button>
);
})}
</div>
</div>
{/* journey: choose transport, play, and optionally record a clip */}
{flyStops.length > 0 ? (
<div className="flex flex-wrap items-center gap-2">
<span className="flex items-center gap-1.5 font-mono text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
<FastForward className="size-3.5 text-primary" /> Journey
</span>
<div className="flex flex-wrap items-center gap-1.5">
{TRANSPORTS.map((t) => (
<button key={t.id} type="button" onClick={() => setTransport(t.id)} aria-pressed={transport === t.id} title={t.label}
disabled={flying}
className={cn("flex items-center gap-1 rounded-full border-2 px-2 py-1 font-mono text-[11px] uppercase tracking-wide transition-colors disabled:opacity-50",
transport === t.id ? "border-primary bg-accent text-foreground" : "border-primary/40 text-muted-foreground hover:bg-accent/50")}>
<span aria-hidden="true">{t.emoji}</span>{t.label}
</button>
))}
</div>
<button type="button" onClick={() => (flying ? stopFly() : playFly({ record: false }))}
className="flex items-center gap-1.5 rounded-md border-2 border-primary bg-primary px-3 py-1.5 font-mono text-[11px] font-semibold uppercase tracking-wide text-primary-foreground transition-colors hover:bg-primary/90">
{flying ? <X className="size-3.5" /> : <FastForward className="size-3.5" />} {flying ? "Stop" : "Play"}
</button>
<button type="button" onClick={() => (flying ? stopFly() : playFly({ record: true }))} disabled={flying}
title="Play the journey and save it as a video clip"
className="flex items-center gap-1.5 rounded-md border-2 border-primary/60 px-3 py-1.5 font-mono text-[11px] uppercase tracking-wide text-primary transition-colors hover:bg-accent disabled:opacity-50">
<span className={cn("size-2.5 rounded-full", recording ? "animate-pulse bg-[oklch(0.55_0.2_25)]" : "bg-primary")} /> {recording ? "Recording…" : "Record clip"}
</button>
{clipUrl ? (
<a href={clipUrl} download="sozai-journey.webm"
className="flex items-center gap-1.5 rounded-md border-2 border-primary/60 px-3 py-1.5 font-mono text-[11px] uppercase tracking-wide text-primary transition-colors hover:bg-accent">
<Download className="size-3.5" /> Download clip
</a>
) : null}
</div>
) : null}
{searchErr ? <p role="status" className="font-mono text-[11px] uppercase tracking-wide text-[oklch(0.55_0.18_25)]">{searchErr}</p> : null}
{tileFellBack ? (
<p className="flex items-start gap-1.5 font-mono text-[10px] leading-snug tracking-wide text-muted-foreground">
<Droplet className="mt-0.5 size-3 shrink-0 text-primary" />
The map tiles had trouble loading, so we reset to the classic watercolor style - it's fully key-free, so the map keeps working.
</p>
) : null}
{/* the map itself, with the authentic watercolor paper + vignette */}
<div className={cn("sozai-map-dropzone relative w-full overflow-hidden rounded-lg border-2 border-primary/40", isFullscreen && "flex-1 min-h-0", dropArmed && "drop-armed")}
onDragOver={(e) => { if (e.dataTransfer.types.includes("application/sozai-photo")) { e.preventDefault(); setDropArmed(true); } }}
onDragLeave={(e) => { if (e.currentTarget === e.target) setDropArmed(false); }}
onDrop={(e) => {
const id = e.dataTransfer.getData("application/sozai-photo");
setDropArmed(false);
if (!id || !mapRef.current) return;
e.preventDefault();
const rect = e.currentTarget.getBoundingClientRect();
const pt = [e.clientX - rect.left, e.clientY - rect.top];
const ll = mapRef.current.unproject(pt);
window.SozaiPhotos.pin(id, ll.lng, ll.lat);
Collab.send("photo_pinned", { id, lon: ll.lng, lat: ll.lat });
}}>
<div ref={containerRef} className={cn("w-full", isFullscreen ? "h-full min-h-[60vh]" : "h-[52vh] min-h-[340px] sm:h-[62vh] sm:min-h-[480px]")} />
{/* real ArcGIS watercolor paper grain, multiplied over the map */}
{isWatercolor ? (
<div className="pointer-events-none absolute inset-0" aria-hidden="true"
style={{ backgroundImage: "url(" + PAPER_TEXTURE + ")", backgroundSize: "400px 400px",
mixBlendMode: "multiply", opacity: 0.5 }} />
) : null}
<div className="pointer-events-none absolute inset-0 rounded-lg" style={{ boxShadow: "inset 0 0 70px 50px var(--card)" }} aria-hidden="true" />
{/* fullscreen toggle */}
<button type="button" onClick={toggleFullscreen}
aria-label={isFullscreen ? "Exit fullscreen" : "Enter fullscreen"} title={isFullscreen ? "Exit fullscreen" : "Fullscreen"}
className="absolute left-2 top-2 z-10 flex size-9 items-center justify-center rounded-md border-2 border-primary/70 bg-card/90 text-primary shadow-sm transition-colors hover:bg-accent">
{isFullscreen ? <Minimize2 className="size-4" /> : <Maximize2 className="size-4" />}
</button>
{!ready ? (
<div className="absolute inset-0 flex items-center justify-center bg-card/60">
<span className="flex items-center gap-2 font-mono text-xs uppercase tracking-wide text-primary"><Loader2 className="size-4 animate-spin" /> Painting the map…</span>
</div>
) : null}
{/* flythrough control (only when there are pinned album photos) */}
{flyStops.length > 0 ? (
<button type="button" onClick={() => (flying ? stopFly() : playFly())}
title={flying ? "Stop the flythrough" : "Play Your Journey"}
className="absolute right-2 top-2 z-10 flex items-center gap-1.5 rounded-md border-2 border-primary bg-card/90 px-2.5 py-2 font-mono text-[10px] font-semibold uppercase tracking-wide text-primary shadow-sm transition-colors hover:bg-accent">
{flying ? <X className="size-4" /> : <FastForward className="size-4" />}
{flying ? "Stop" : "Play journey"}
</button>
) : null}
{/* current-photo overlay while the flythrough plays */}
{flying && flyStops[flyIdx] ? (
<div className="pointer-events-none absolute bottom-3 left-1/2 z-10 w-[min(78%,360px)] -translate-x-1/2">
<div className="overflow-hidden rounded-md border-2 border-primary bg-card/95 shadow-lg">
<img src={flyStops[flyIdx].src} alt="" className="h-36 w-full object-cover" />
<div className="px-3 py-2">
<p className="font-mono text-[10px] uppercase tracking-wide text-primary">
{(flyIdx + 1) + " / " + flyStops.length}
{[flyStops[flyIdx].date, flyStops[flyIdx].time].filter(Boolean).length ? " · " + [flyStops[flyIdx].date, flyStops[flyIdx].time].filter(Boolean).join(" · ") : ""}
</p>
<p className="mt-0.5 text-sm leading-snug text-foreground">{flyStops[flyIdx].caption || flyStops[flyIdx].title || ""}</p>
</div>
</div>
</div>
) : null}
</div>
{/* add an activity */}
<div className="rounded-lg border-2 border-primary/40 bg-card p-4">
<span className="flex items-center gap-2 font-mono text-xs font-semibold uppercase tracking-wide text-foreground"><Pencil className="size-4 text-primary" /> Add an activity</span>
<div className="mt-3 grid grid-cols-[auto_1fr] gap-2 sm:grid-cols-[auto_1fr_1fr_auto]">
<select aria-label="Activity icon" value={draft.icon} onChange={(e) => setDraft((d) => ({ ...d, icon: e.target.value }))}
className="rounded-md border-2 border-primary/60 bg-card px-2 py-2 text-lg outline-none">
{ACTIVITY_ICONS.map((ic) => <option key={ic} value={ic}>{ic}</option>)}
</select>
<input value={draft.text} onChange={(e) => setDraft((d) => ({ ...d, text: e.target.value }))}
placeholder="What did you do?"
className="rounded-md border-2 border-primary/60 bg-card px-3 py-2 text-sm text-foreground outline-none placeholder:text-muted-foreground" />
<input value={draft.where} onChange={(e) => setDraft((d) => ({ ...d, where: e.target.value }))}
onKeyDown={(e) => e.key === "Enter" && addActivity()}
placeholder="Where? (optional)"
className="col-span-2 rounded-md border-2 border-primary/60 bg-card px-3 py-2 text-sm text-foreground outline-none placeholder:text-muted-foreground sm:col-span-1" />
<button type="button" onClick={addActivity} disabled={adding}
className="col-span-2 flex items-center justify-center gap-1.5 rounded-md border-2 border-primary bg-primary px-3 py-2 font-mono text-xs font-semibold uppercase tracking-wide text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-60 sm:col-span-1">
{adding ? <Loader2 className="size-4 animate-spin" /> : <Plus className="size-4" />} Add
</button>
</div>
<div className="mt-2 flex flex-wrap items-center gap-2">
<button type="button" onClick={() => { if (!draft.text.trim()) { setAddErr("Describe the activity first."); return; } setAddErr(null); setPickOnMap((v) => !v); }}
className={cn("flex items-center gap-1.5 rounded-md border-2 px-2.5 py-1 font-mono text-[10px] uppercase tracking-wide transition-colors",
pickOnMap ? "border-primary bg-accent text-primary" : "border-primary/50 text-primary hover:bg-accent")}>
<MapPin className="size-3.5" /> {pickOnMap ? "Click the map to drop it…" : "Pick on map"}
</button>
<span className="font-mono text-[10px] uppercase tracking-wide text-muted-foreground">or type a place and hit Add</span>
</div>
{addErr ? <p role="status" className="mt-2 font-mono text-[11px] uppercase tracking-wide text-[oklch(0.55_0.18_25)]">{addErr}</p> : null}
</div>
{/* draft tray: album photos waiting to be placed on the map (shared) */}
<PhotoTray onPick={pickStorePhoto} armedId={pinMode && pendingPhotoRef.current ? pendingPhotoRef.current.storeId : null} />
{pinMode && pendingPhotoRef.current && pendingPhotoRef.current.storeId ? (
<p className="-mt-1 flex items-center gap-1.5 font-mono text-[10px] uppercase tracking-wide text-primary">
<MapPin className="size-3.5" /> Click the map to pin this photo
<button type="button" onClick={() => { pendingPhotoRef.current = null; setPinMode(false); }} className="ml-1 underline">cancel</button>
</p>
) : null}
{/* pin a Polaroid photo */}
<div className="rounded-lg border-2 border-primary/40 bg-card p-4">
<span className="flex items-center gap-2 font-mono text-xs font-semibold uppercase tracking-wide text-foreground"><Pin className="size-4 text-primary" /> Pin a Polaroid</span>
<p className="mt-1 text-xs leading-snug text-muted-foreground">Upload a photo from a memory, then click the map to pin it where it happened.</p>
<div className="mt-3 flex flex-wrap items-center gap-2">
<input ref={polaroidFileRef} type="file" accept={ACCEPT_ATTR} onChange={onPolaroidPick} className="hidden" id="wc-polaroid" />
<div className="relative">
<button type="button" onClick={() => polaroidFileRef.current && polaroidFileRef.current.click()} disabled={polaroidBusy}
className="flex items-center gap-1.5 rounded-md border-2 border-primary bg-primary px-3 py-2 font-mono text-xs font-semibold uppercase tracking-wide text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-90">
<ImagePlus className="size-4" /> Choose photo
</button>
{polaroidBusy ? (
<span className="absolute inset-0 flex items-center justify-center gap-2 overflow-hidden rounded-md"
role="status" aria-live="polite"
style={{ background: "linear-gradient(180deg, var(--primary), color-mix(in oklch, var(--primary) 80%, #000))" }}>
<span aria-hidden="true" className="absolute inset-0"
style={{ background: "linear-gradient(110deg, transparent 20%, rgba(255,255,255,0.35) 50%, transparent 80%)", backgroundSize: "200% 100%", animation: "sozai-shimmer 1.1s linear infinite" }} />
<span className="relative flex items-center gap-1.5 font-mono text-[10px] uppercase tracking-widest text-primary-foreground">
<Loader2 className="size-3.5 animate-spin" /> Developing…
</span>
</span>
) : null}
</div>
{pinMode ? (
<span className="flex items-center gap-1.5 rounded-md border-2 border-primary bg-accent px-2.5 py-1.5 font-mono text-[10px] uppercase tracking-wide text-primary">
<MapPin className="size-3.5" /> Click the map to drop your Polaroid
<button type="button" onClick={() => { pendingPhotoRef.current = null; setPinMode(false); }} className="ml-1 underline">cancel</button>
</span>
) : (
<span className="font-mono text-[10px] uppercase tracking-wide text-muted-foreground">{polaroids.length}/{MAX_POLAROIDS} pinned</span>
)}
</div>
</div>
{/* the story list (echoes Cambridge's narrative panel) */}
{activities.length ? (
<ul className="flex flex-col gap-2">
{activities.map((a) => (
<li key={a.id} className="flex items-center gap-3 rounded-md border-2 border-primary/30 bg-card px-3 py-2">
<span className="text-xl" aria-hidden="true">{a.icon}</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm text-foreground">{a.text}</span>
<span className="block font-mono text-[10px] uppercase tracking-wide text-muted-foreground">{a.lat.toFixed(4)}, {a.lon.toFixed(4)}</span>
</span>
<button type="button" onClick={() => removeActivity(a.id)} aria-label="Remove activity" className="text-primary transition-transform hover:scale-110"><Trash2 className="size-4" /></button>
</li>
))}
</ul>
) : (
<p className="text-center text-sm text-muted-foreground">No activities yet - search a city, then add your first one above.</p>
)}
</div>
);
}
// ----------------------------------------------------------------- //
// CesiumMap - a photorealistic 3D globe (CesiumJS + OSM Buildings) as an
// alternative to the watercolor map. Offers two 3D Tiles styles:
// • "material" - buildings colored by their material (from the user's
// provided 3D Tiles styling sample).
// • "watercolor" - the buildings washed in the ArcGIS watercolor palette
// extracted from Watercolor.stylx, keyed by height for a painterly feel.
// Pinned album photos appear as billboards and the same timestamp-ordered
// flythrough plays here too. Requires a Cesium ion token (server-provided).
// ----------------------------------------------------------------- //
// Compose a photo into a small Polaroid-style card on a <canvas> so it can
// be used directly as a Cesium billboard image: white frame, a heavier
// bottom margin (the classic Polaroid look), and a soft drop shadow. No
// caption text - just the framed photo. Returns a Promise<HTMLCanvasElement>.
function makePolaroidCanvas(src, opts) {
opts = opts || {};
const photo = opts.photo || 168; // inner photo square (device px)
const pad = opts.pad || 14; // white border on top/left/right
const bottom = opts.bottom || 40; // heavier bottom border
const margin = opts.margin || 12; // breathing room for the shadow
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
try {
const w = photo + pad * 2, h = photo + pad + bottom;
const cw = w + margin * 2, ch = h + margin * 2;
const cv = document.createElement("canvas");
cv.width = cw; cv.height = ch;
const ctx = cv.getContext("2d");
// soft shadow under the card
ctx.save();
ctx.shadowColor = "rgba(0,0,0,0.38)";
ctx.shadowBlur = 9; ctx.shadowOffsetY = 5;
ctx.fillStyle = "#fdfdf7"; // warm white card
const rx = margin, ry = margin, rr = 4;
ctx.beginPath();
ctx.moveTo(rx + rr, ry);
ctx.arcTo(rx + w, ry, rx + w, ry + h, rr);
ctx.arcTo(rx + w, ry + h, rx, ry + h, rr);
ctx.arcTo(rx, ry + h, rx, ry, rr);
ctx.arcTo(rx, ry, rx + w, ry, rr);
ctx.closePath(); ctx.fill();
ctx.restore();
// photo, object-fit: cover into the photo square
const dx = margin + pad, dy = margin + pad;
const ir = img.width / img.height, dr = 1; // square target
let sw, sh, sx, sy;
if (ir > dr) { sh = img.height; sw = sh * dr; sx = (img.width - sw) / 2; sy = 0; }
else { sw = img.width; sh = sw / dr; sx = 0; sy = (img.height - sh) / 2; }
ctx.drawImage(img, sx, sy, sw, sh, dx, dy, photo, photo);
// thin inner edge around the photo
ctx.strokeStyle = "rgba(0,0,0,0.10)"; ctx.lineWidth = 1;
ctx.strokeRect(dx + 0.5, dy + 0.5, photo - 1, photo - 1);
resolve(cv);
} catch (e) { reject(e); }
};
img.onerror = reject;
img.src = src;
});
}
function buildCesiumStyle(Cesium, kind, colorway) {
if (kind === "realistic") {
// Solid, light buildings (no transparency) so sun lighting can model
// them - tinted subtly by material/height for variety, like the Cesium
// OSM Buildings "metadata styling" reference.
return new Cesium.Cesium3DTileStyle({
defines: { h: "${feature['cesium#estimatedHeight']}", mat: "${feature['building:material']}" },
color: {
conditions: [
["${mat} === 'glass'", "color('#c4d6e0')"],
["${mat} === 'brick'", "color('#c8a085')"],
["${mat} === 'concrete'", "color('#d3d3cc')"],
["${mat} === 'metal'", "color('#ced3d8')"],
["${mat} === 'stone'", "color('#d9d4c8')"],
["${h} >= 120", "color('#edece7')"],
["${h} >= 50", "color('#e7e5de')"],
["true", "color('#f0ede7')"],
],
},
});
}
if (kind === "material") {
// From the user's sample: color OSM buildings by building:material.
return new Cesium.Cesium3DTileStyle({
defines: { material: "${feature['building:material']}" },
color: {
conditions: [
["${material} === null", "color('white')"],
["${material} === 'glass'", "color('skyblue', 0.5)"],
["${material} === 'concrete'", "color('grey')"],
["${material} === 'brick'", "color('indianred')"],
["${material} === 'stone'", "color('lightslategrey')"],
["${material} === 'metal'", "color('lightgrey')"],
["${material} === 'steel'", "color('lightsteelblue')"],
["true", "color('white')"],
],
},
});
}
// Watercolor wash, by height, using the real stylx palette per colorway.
// Raw Umber / Sienna / Dust family from Watercolor.stylx.
const WASH = {
classic: ["rgb(168,112,0)", "rgb(205,170,102)", "rgb(215,194,158)", "rgb(158,170,215)"],
sepia: ["rgb(150,110,40)", "rgb(196,162,106)", "rgb(224,200,160)", "rgb(180,182,205)"],
sunrise: ["rgb(214,120,70)", "rgb(245,160,110)", "rgb(250,205,150)", "rgb(245,150,120)"],
dusk: ["rgb(120,90,40)", "rgb(150,120,70)", "rgb(120,110,80)", "rgb(90,110,200)"],
};
const c = WASH[colorway] || WASH.classic;
return new Cesium.Cesium3DTileStyle({
defines: { h: "${feature['cesium#estimatedHeight']}" },
color: {
conditions: [
// softer, more translucent washes so towers read as painted forms
// that blend with the map rather than solid colour blocks
["${h} >= 140", "color('" + c[0] + "', 0.72)"],
["${h} >= 80", "color('" + c[1] + "', 0.66)"],
["${h} >= 45", "color('" + c[2] + "', 0.6)"],
["${h} >= 20", "color('" + c[2] + "', 0.54)"],
["true", "color('" + c[3] + "', 0.48)"],
],
},
});
}
function CesiumMap({ cityName }) {
const containerRef = useRef(null);
const viewerRef = useRef(null);
const tilesetRef = useRef(null);
const postProcessRef = useRef(null); // watercolour posterize stage (off in Realistic)
const cfgRef = useRef(null); // client config (stadia key for imagery swap)
const prevStyleRef = useRef("watercolor");
const [status, setStatus] = useState("loading"); // loading | ready | no-token | error
const [errMsg, setErrMsg] = useState("");
const [style, setStyle] = useState("watercolor"); // watercolor | material
const [colorway, setColorway] = useState(() => {
try { return localStorage.getItem("sozai_wc_colorway") || "classic"; } catch (e) { return "classic"; }
});
const [place, setPlace] = useState(cityName || tripMeta.location || "Ottawa");
const [searching, setSearching] = useState(false);
const storePhotos = useSozaiPhotos();
const pinnedStore = storePhotos.filter((p) => p.pinned && p.lon != null && p.lat != null);
const [flying, setFlying] = useState(false);
const [flyIdx, setFlyIdx] = useState(-1);
const flyAbortRef = useRef({ stop: false });
const entitiesRef = useRef([]);
const vehicleRef = useRef(null); // the moving transport entity
const clockTickRef = useRef(null); // remover for the clock onTick listener
const [transport, setTransport] = useState("car");
// Each transport maps to a real glTF model. Cesium auto-orients the model
// along its velocity, so it truly faces its heading as it travels. A local
// override at /assets/models/<id>.glb is used first if present; otherwise a
// CORS-friendly Khronos sample model (served via jsDelivr) is used.
const GLTF_BASE = "https://cdn.jsdelivr.net/gh/KhronosGroup/glTF-Sample-Assets@main/Models";
const CS_TRANSPORTS = [
{ id: "car", label: "Car", emoji: "🚗", speed: 1, scale: 4.5,
models: ["/assets/models/car.glb", GLTF_BASE + "/CesiumMilkTruck/glTF-Binary/CesiumMilkTruck.glb"] },
{ id: "bike", label: "Bicycle", emoji: "🚲", speed: 0.75, scale: 3.0,
models: ["/assets/models/bike.glb", GLTF_BASE + "/CesiumMilkTruck/glTF-Binary/CesiumMilkTruck.glb"] },
{ id: "walk", label: "Walk", emoji: "🚶", speed: 0.5, scale: 2.4,
models: ["/assets/models/walk.glb", GLTF_BASE + "/CesiumMan/glTF-Binary/CesiumMan.glb"] },
{ id: "plane", label: "Plane", emoji: "✈️", speed: 1.6, scale: 1.0,
models: ["/assets/models/plane.glb", GLTF_BASE + "/CesiumAir/glTF-Binary/CesiumAir.glb"] },
{ id: "train", label: "Train", emoji: "🚆", speed: 1.3, scale: 5.0,
models: ["/assets/models/train.glb", GLTF_BASE + "/CesiumMilkTruck/glTF-Binary/CesiumMilkTruck.glb"] },
];
const [expanded, setExpanded] = useState(false); // in-page immersive view
const [dropArmed, setDropArmed] = useState(false); // drag-from-tray hint
const [armedId, setArmedId] = useState(null); // tap-to-pin armed photo
// tap-to-pin: arm a photo, then the next globe click pins it
function pickCesiumPhoto(p) { setArmedId(p.id); }
// expand: lock body scroll + resize the canvas
useEffect(() => {
function onKey(e) { if (e.key === "Escape") setExpanded(false); }
if (expanded) {
document.addEventListener("keydown", onKey);
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => { document.removeEventListener("keydown", onKey); document.body.style.overflow = prev; };
}
}, [expanded]);
useEffect(() => {
const doResize = () => { try { if (viewerRef.current) viewerRef.current.resize(); } catch (e) {} };
const ts = [setTimeout(doResize, 90), setTimeout(doResize, 320)];
// On expand, fly the camera out so the much bigger globe shows more,
// preserving the current heading/pitch.
if (expanded) ts.push(setTimeout(() => {
const viewer = viewerRef.current, Cesium = window.Cesium;
if (!viewer || !Cesium) return;
try {
const cam = viewer.camera;
const carto = Cesium.Cartographic.clone(cam.positionCartographic);
cam.flyTo({
destination: Cesium.Cartesian3.fromRadians(carto.longitude, carto.latitude, Math.min(carto.height * 2.5, 9000000)),
orientation: { heading: cam.heading, pitch: cam.pitch, roll: cam.roll },
duration: 1.4,
});
} catch (e) {}
}, 440));
return () => ts.forEach(clearTimeout);
}, [expanded]);
// init the viewer once
useEffect(() => {
let cancelled = false;
(async () => {
try {
const cfg = await getClientConfig();
cfgRef.current = cfg;
if (!cfg || !cfg.cesiumIonToken) { if (!cancelled) setStatus("no-token"); return; }
const Cesium = await loadCesium();
if (cancelled) return;
Cesium.Ion.defaultAccessToken = cfg.cesiumIonToken;
const viewer = new Cesium.Viewer(containerRef.current, {
terrain: Cesium.Terrain.fromWorldTerrain(),
baseLayerPicker: false, geocoder: false, homeButton: false,
sceneModePicker: false, navigationHelpButton: false, animation: false,
timeline: false, fullscreenButton: false, infoBox: false, selectionIndicator: false,
// Render on demand instead of every single frame: the globe only
// redraws when something actually changes (camera move, tile load,
// a pin edit). This removes the constant-repaint jank when the view
// is idle. The journey flythrough flips this off while it runs (see
// playFly), since it animates the clock every frame.
requestRenderMode: true, maximumRenderTimeChange: Infinity,
});
viewerRef.current = viewer;
try { viewer.clock.shouldAnimate = false; } catch (e) {}
// Smoother edges (anti-aliasing) on buildings + polaroids, and gentle
// camera inertia so panning/zooming glides to a stop instead of
// snapping. Wrapped in try/catch as these vary across Cesium builds.
try { viewer.scene.msaaSamples = 4; } catch (e) {}
try {
const ctl = viewer.scene.screenSpaceCameraController;
ctl.inertiaSpin = 0.85; ctl.inertiaTranslate = 0.85; ctl.inertiaZoom = 0.85;
} catch (e) {}
viewer.scene.camera.setView({
destination: Cesium.Cartesian3.fromDegrees(-123.1207, 49.2827, 1200),
orientation: { heading: Cesium.Math.toRadians(10), pitch: Cesium.Math.toRadians(-25) },
});
// Immersive watercolour basemap: Stamen Watercolor tiles (via Stadia
// Maps). Needs a free Stadia API key (SOZAI_STADIA_KEY) - without it
// the tiles 401 on dynamic domains, so we keep the default imagery.
try {
if (cfg.stadiaMapsKey) {
viewer.imageryLayers.removeAll();
viewer.imageryLayers.addImageryProvider(new Cesium.UrlTemplateImageryProvider({
url: "https://tiles.stadiamaps.com/tiles/stamen_watercolor/{z}/{x}/{y}.jpg?api_key=" + cfg.stadiaMapsKey,
maximumLevel: 16, // watercolour tiles thin out past z16
credit: "Map tiles by Stamen Design, hosted by Stadia Maps; data © OpenStreetMap",
}));
}
} catch (e) { console.warn("[cesium] watercolor imagery failed", e); }
// Painterly post-process: posterize into flat washes + darken edges.
try {
postProcessRef.current = viewer.scene.postProcessStages.add(new Cesium.PostProcessStage({
fragmentShader: `
uniform sampler2D colorTexture;
in vec2 v_textureCoordinates;
void main() {
vec2 uv = v_textureCoordinates;
vec3 c = texture(colorTexture, uv).rgb;
c = floor(c * 8.0 + 0.5) / 8.0; // softer washes (was 6 - less flat)
float luma = dot(c, vec3(0.299, 0.587, 0.114));
c = mix(vec3(luma), c, 0.72); // ease off the vibrance (~28% desaturate)
float o = 1.0 / 1024.0;
vec3 dx = texture(colorTexture, uv+vec2(o,0.)).rgb - texture(colorTexture, uv-vec2(o,0.)).rgb;
vec3 dy = texture(colorTexture, uv+vec2(0.,o)).rgb - texture(colorTexture, uv-vec2(0.,o)).rgb;
float edge = clamp(length(dx)+length(dy), 0.0, 1.0);
out_FragColor = vec4(c * (1.0 - 0.3*edge), 1.0);
}`,
}));
} catch (e) { console.warn("[cesium] watercolor post-process failed", e); }
try {
const tileset = await Cesium.createOsmBuildingsAsync();
if (cancelled) { viewer.destroy(); return; }
viewer.scene.primitives.add(tileset);
tilesetRef.current = tileset;
tileset.style = buildCesiumStyle(Cesium, "watercolor", colorway);
} catch (e) { console.warn("[cesium] OSM buildings failed", e); }
// In requestRenderMode the scene paints on demand, so force one
// render now that imagery + buildings + post-process are all set up.
try { viewer.scene.requestRender(); } catch (e) {}
if (!cancelled) setStatus("ready");
} catch (e) {
if (!cancelled) { setStatus("error"); setErrMsg(e.message || "Cesium failed to load."); }
}
})();
return () => {
cancelled = true;
flyAbortRef.current.stop = true;
if (viewerRef.current) { try { viewerRef.current.destroy(); } catch (e) {} viewerRef.current = null; }
};
}, []); // eslint-disable-line
// apply style changes
useEffect(() => {
const viewer = viewerRef.current, Cesium = window.Cesium, ts = tilesetRef.current;
if (!viewer || !Cesium) return;
// building colours
try { if (ts) ts.style = buildCesiumStyle(Cesium, style, colorway); } catch (e) {}
try { localStorage.setItem("sozai_wc_colorway", colorway); } catch (e) {}
const isReal = style === "realistic";
// Realistic = drop the painterly posterize shader and let real sun
// lighting shade the building faces (the depth in the reference shots).
try { if (postProcessRef.current) postProcessRef.current.enabled = !isReal; } catch (e) {}
try {
if (isReal) {
// freeze the sun at a daytime angle so towers are lit, not dark
viewer.clock.currentTime = Cesium.JulianDate.fromIso8601("2024-06-21T18:30:00Z");
}
} catch (e) {}
// Swap the basemap only when crossing into/out of Realistic: clean Bing
// aerial for Realistic, watercolour tiles (if keyed) otherwise.
const wasReal = prevStyleRef.current === "realistic";
if (status === "ready" && wasReal !== isReal) {
(async () => {
try {
viewer.imageryLayers.removeAll();
if (isReal || !(cfgRef.current && cfgRef.current.stadiaMapsKey)) {
const prov = await Cesium.createWorldImageryAsync();
if (viewerRef.current) viewer.imageryLayers.addImageryProvider(prov);
} else {
viewer.imageryLayers.addImageryProvider(new Cesium.UrlTemplateImageryProvider({
url: "https://tiles.stadiamaps.com/tiles/stamen_watercolor/{z}/{x}/{y}.jpg?api_key=" + cfgRef.current.stadiaMapsKey,
maximumLevel: 16,
credit: "Map tiles by Stamen Design, hosted by Stadia Maps; data © OpenStreetMap",
}));
}
} catch (e) { console.warn("[cesium] imagery swap failed", e); }
})();
}
prevStyleRef.current = style;
// on-demand render so style/colour/imagery swaps show without a nudge
try { viewer.scene.requestRender(); } catch (e) {}
}, [style, colorway, status]);
// render pinned photos as larger Polaroid-framed billboards (no caption
// text). Each photo is composed onto a white Polaroid card on a canvas
// first, so the globe shows framed prints instead of bare squares.
useEffect(() => {
const viewer = viewerRef.current;
if (!viewer || !window.Cesium || status !== "ready") return;
const Cesium = window.Cesium;
let cancelled = false;
entitiesRef.current.forEach((e) => { try { viewer.entities.remove(e); } catch (_) {} });
entitiesRef.current = [];
(async () => {
for (const p of pinnedStore) {
let image = p.src, aspect = 0.86;
try {
const cv = await makePolaroidCanvas(p.src);
image = cv; aspect = cv.width / cv.height;
} catch (_) { /* fall back to the raw photo */ }
if (cancelled || !viewerRef.current) return;
const W = 132; // on-screen width (was 86, square)
const ent = viewer.entities.add({
id: "sozai-" + p.id,
position: Cesium.Cartesian3.fromDegrees(p.lon, p.lat, 40),
billboard: {
image, width: W, height: Math.round(W / aspect),
verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
heightReference: Cesium.HeightReference.NONE,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
},
});
entitiesRef.current.push(ent);
}
try { viewer.scene.requestRender(); } catch (_) {}
})();
return () => { cancelled = true; };
}, [pinnedStore.map((p) => p.id + ":" + p.lon + "," + p.lat).join("|"), status]);
// tap-to-pin + drag-to-move on the globe.
const armedRef = useRef(null);
useEffect(() => { armedRef.current = armedId; }, [armedId]);
useEffect(() => {
const viewer = viewerRef.current;
if (!viewer || !window.Cesium || status !== "ready") return;
const Cesium = window.Cesium;
const handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
let dragId = null;
function toLngLat(cartesian) {
const c = Cesium.Cartographic.fromCartesian(cartesian);
return { lon: Cesium.Math.toDegrees(c.longitude), lat: Cesium.Math.toDegrees(c.latitude) };
}
// LEFT_DOWN: start dragging a pinned photo if one is under the cursor
handler.setInputAction((click) => {
const picked = viewer.scene.pick(click.position);
if (picked && picked.id && typeof picked.id.id === "string" && picked.id.id.indexOf("sozai-") === 0) {
dragId = picked.id.id.slice("sozai-".length);
viewer.scene.screenSpaceCameraController.enableInputs = false;
}
}, Cesium.ScreenSpaceEventType.LEFT_DOWN);
// MOUSE_MOVE: move the dragged photo along the ground
handler.setInputAction((movement) => {
if (!dragId) return;
const cart = viewer.camera.pickEllipsoid(movement.endPosition, viewer.scene.globe.ellipsoid);
if (cart) { const { lon, lat } = toLngLat(cart); const ent = viewer.entities.getById("sozai-" + dragId); if (ent) ent.position = Cesium.Cartesian3.fromDegrees(lon, lat, 40); try { viewer.scene.requestRender(); } catch (_) {} }
}, Cesium.ScreenSpaceEventType.MOUSE_MOVE);
// LEFT_UP: commit a drag, OR pin an armed photo at the click point
handler.setInputAction((click) => {
viewer.scene.screenSpaceCameraController.enableInputs = true;
if (dragId) {
const ent = viewer.entities.getById("sozai-" + dragId);
if (ent) { const { lon, lat } = toLngLat(ent.position.getValue(Cesium.JulianDate.now())); window.SozaiPhotos.move(dragId, lon, lat); Collab.send("photo_moved", { id: dragId, lon, lat }); }
dragId = null; return;
}
const armed = armedRef.current;
if (armed) {
const cart = viewer.camera.pickEllipsoid(click.position, viewer.scene.globe.ellipsoid);
if (cart) { const { lon, lat } = toLngLat(cart); window.SozaiPhotos.pin(armed, lon, lat); Collab.send("photo_pinned", { id: armed, lon, lat }); setArmedId(null); }
}
}, Cesium.ScreenSpaceEventType.LEFT_UP);
return () => { try { handler.destroy(); } catch (e) {} };
}, [status]);
async function searchCity() {
const q = place.trim();
if (!q || !viewerRef.current || !window.Cesium) return;
setSearching(true);
try {
const hits = await geocode(q, { limit: 1 });
if (hits.length) {
const { lat, lon } = hits[0];
viewerRef.current.scene.camera.flyTo({
destination: window.Cesium.Cartesian3.fromDegrees(lon, lat - 0.012, 1400),
orientation: { heading: window.Cesium.Math.toRadians(10), pitch: window.Cesium.Math.toRadians(-30) },
duration: 2,
});
}
} catch (e) {} finally { setSearching(false); }
}
const flyStops = buildFlyStops(pinnedStore);
function removeVehicle() {
const viewer = viewerRef.current;
if (viewer && vehicleRef.current) { try { viewer.entities.remove(vehicleRef.current); } catch (e) {} }
vehicleRef.current = null;
}
function stopFly() {
flyAbortRef.current.stop = true;
setFlying(false); setFlyIdx(-1);
const viewer = viewerRef.current;
if (viewer) {
try { viewer.trackedEntity = undefined; } catch (e) {}
try { viewer.clock.shouldAnimate = false; } catch (e) {}
if (clockTickRef.current) { try { clockTickRef.current(); } catch (e) {} clockTickRef.current = null; }
try { if (viewer.entities.getById("sozai-route")) viewer.entities.removeById("sozai-route"); } catch (e) {}
// restore on-demand rendering after an interrupted journey
try { viewer.scene.requestRenderMode = true; viewer.scene.requestRender(); } catch (e) {}
}
removeVehicle();
}
async function playFly() {
const viewer = viewerRef.current;
if (!viewer || !window.Cesium || flyStops.length < 1) return;
const Cesium = window.Cesium;
flyAbortRef.current = { stop: false };
const token = flyAbortRef.current;
setFlying(true);
// the journey animates the clock every frame, so render continuously
// while it plays (restored to on-demand in stopFly / at the end).
try { viewer.scene.requestRenderMode = false; } catch (e) {}
const tdef = CS_TRANSPORTS.find((t) => t.id === transport) || CS_TRANSPORTS[0];
const isPlane = tdef.id === "plane";
const cruiseH = isPlane ? 600 : 6; // metres above ground while travelling
// seconds to spend per leg, scaled by the transport's speed
const legSecs = Math.max(3, 6 / tdef.speed);
// build a time-sampled position along the whole route, so Cesium can
// interpolate position AND derive heading from velocity automatically.
const start = Cesium.JulianDate.now();
const sampled = new Cesium.SampledPositionProperty();
const stopTimes = [];
let tSec = 0;
for (let i = 0; i < flyStops.length; i++) {
if (i > 0) {
// a couple of mid-samples per leg keeps the heading smooth on curves
const a = flyStops[i - 1], b = flyStops[i];
for (let s = 1; s <= 3; s++) {
const k = s / 4, midT = tSec + legSecs * k;
const arc = isPlane ? Math.sin(k * Math.PI) * 300 : 0; // gentle plane arc
sampled.addSample(Cesium.JulianDate.addSeconds(start, midT, new Cesium.JulianDate()),
Cesium.Cartesian3.fromDegrees(a.lon + (b.lon - a.lon) * k, a.lat + (b.lat - a.lat) * k, cruiseH + arc));
}
tSec += legSecs;
}
const stopT = Cesium.JulianDate.addSeconds(start, tSec, new Cesium.JulianDate());
stopTimes.push(tSec);
sampled.addSample(stopT, Cesium.Cartesian3.fromDegrees(flyStops[i].lon, flyStops[i].lat, isPlane ? 120 : cruiseH));
tSec += 1.6; // brief dwell at each stop
}
const totalSecs = tSec;
sampled.setInterpolationOptions({ interpolationDegree: 2, interpolationAlgorithm: Cesium.HermitePolynomialApproximation });
// configure the viewer clock to play through the journey once
viewer.clock.startTime = start.clone();
viewer.clock.stopTime = Cesium.JulianDate.addSeconds(start, totalSecs, new Cesium.JulianDate());
viewer.clock.currentTime = start.clone();
viewer.clock.clockRange = Cesium.ClockRange.CLAMPED;
viewer.clock.multiplier = 1;
viewer.clock.shouldAnimate = true;
// the moving vehicle: a real glTF model, auto-oriented along velocity
removeVehicle();
const modelUri = tdef.models[0];
vehicleRef.current = viewer.entities.add({
position: sampled,
orientation: new Cesium.VelocityOrientationProperty(sampled),
model: {
uri: modelUri,
minimumPixelSize: 48, maximumScale: 800, scale: tdef.scale,
// if the local override 404s, swap to the Khronos fallback url
},
});
// graceful fallback if the (optional) local model file is missing
try {
const probe = await fetch(modelUri, { method: "HEAD" });
if (!probe.ok && tdef.models[1]) vehicleRef.current.model.uri = tdef.models[1];
} catch (e) { if (tdef.models[1]) vehicleRef.current.model.uri = tdef.models[1]; }
// draw the route as a dashed ground polyline
try {
if (viewer.entities.getById("sozai-route")) viewer.entities.removeById("sozai-route");
if (flyStops.length > 1) {
const positions = [];
flyStops.forEach((s) => { positions.push(s.lon, s.lat, 6); });
viewer.entities.add({ id: "sozai-route",
polyline: { positions: Cesium.Cartesian3.fromDegreesArrayHeights(positions),
width: 4, clampToGround: true,
material: new Cesium.PolylineDashMaterialProperty({ color: Cesium.Color.fromCssColorString("#B6485C") }) } });
}
} catch (e) {}
// camera trails the vehicle; reveal each photo as the clock passes its stop
let nextStop = 0;
const onTick = viewer.clock.onTick.addEventListener((clock) => {
if (token.stop) return;
const t = Cesium.JulianDate.secondsDifference(clock.currentTime, start);
// immersive chase camera: sit behind the moving point along the
// direction of travel and a little above it, looking ahead down the
// route (vs. a fixed south-facing top-down view).
try {
const lookAhead = isPlane ? 4.0 : 2.0; // s ahead to aim at
const chaseDist = isPlane ? 1400 : 260; // m behind the point
const chaseHeight = isPlane ? 700 : 140; // m above the point
const now = clock.currentTime;
const pos = sampled.getValue(now);
const ahead = sampled.getValue(Cesium.JulianDate.addSeconds(now, lookAhead, new Cesium.JulianDate())) || pos;
if (pos && ahead) {
const fwd = Cesium.Cartesian3.normalize(Cesium.Cartesian3.subtract(ahead, pos, new Cesium.Cartesian3()), new Cesium.Cartesian3());
const up = Cesium.Cartesian3.normalize(pos, new Cesium.Cartesian3()); // geocentric up
const cam = Cesium.Cartesian3.clone(pos);
Cesium.Cartesian3.add(cam, Cesium.Cartesian3.multiplyByScalar(fwd, -chaseDist, new Cesium.Cartesian3()), cam);
Cesium.Cartesian3.add(cam, Cesium.Cartesian3.multiplyByScalar(up, chaseHeight, new Cesium.Cartesian3()), cam);
const dir = Cesium.Cartesian3.normalize(Cesium.Cartesian3.subtract(ahead, cam, new Cesium.Cartesian3()), new Cesium.Cartesian3());
viewer.scene.camera.setView({ destination: cam, orientation: { direction: dir, up: up } });
}
} catch (e) {}
if (nextStop < stopTimes.length && t >= stopTimes[nextStop] - 0.2) {
setFlyIdx(nextStop); nextStop++;
}
});
clockTickRef.current = () => { try { onTick(); } catch (e) {} };
// wait until the clock reaches the end (or the user stops it)
await new Promise((resolve) => {
const iv = setInterval(() => {
if (token.stop) { clearInterval(iv); resolve(); return; }
const t = Cesium.JulianDate.secondsDifference(viewer.clock.currentTime, start);
if (t >= totalSecs - 0.05) { clearInterval(iv); resolve(); }
}, 120);
});
// tidy up
if (clockTickRef.current) { clockTickRef.current(); clockTickRef.current = null; }
viewer.clock.shouldAnimate = false;
if (!token.stop && flyStops.length) {
const lons = flyStops.map((s) => s.lon), lats = flyStops.map((s) => s.lat);
const cx = (Math.min(...lons) + Math.max(...lons)) / 2, cy = (Math.min(...lats) + Math.max(...lats)) / 2;
viewer.scene.camera.flyTo({ destination: Cesium.Cartesian3.fromDegrees(cx, cy - 0.02, 2600),
orientation: { heading: 0, pitch: Cesium.Math.toRadians(-45) }, duration: 2.4 });
await new Promise((r) => setTimeout(r, 2400));
}
removeVehicle();
try { if (viewer.entities.getById("sozai-route")) viewer.entities.removeById("sozai-route"); } catch (e) {}
// back to on-demand rendering now that the journey is over
try { viewer.scene.requestRenderMode = true; viewer.scene.requestRender(); } catch (e) {}
setFlying(false); setFlyIdx(-1);
}
if (status === "no-token") {
return (
<div className="rounded-lg border-2 border-primary/40 bg-accent/30 p-6 text-center">
<MapIcon className="mx-auto size-8 text-primary" />
<p className="mt-2 font-mono text-sm font-semibold uppercase tracking-wide text-foreground">3D globe needs a Cesium ion token</p>
<p className="mt-1 text-sm text-muted-foreground">Set <span className="font-mono">SOZAI_CESIUM_ION_TOKEN</span> in the server environment, then reload. The watercolor map works without any token.</p>
</div>
);
}
if (status === "error") {
return (
<div className="rounded-lg border-2 border-primary/40 bg-accent/30 p-6 text-center">
<MapIcon className="mx-auto size-8 text-primary" />
<p className="mt-2 font-mono text-sm font-semibold uppercase tracking-wide text-foreground">Couldn't load the 3D globe</p>
<p className="mt-1 text-sm text-muted-foreground">{errMsg}</p>
</div>
);
}
return (
<div className={cn("flex flex-col gap-4", expanded && "fixed inset-0 z-[80] overflow-auto bg-background p-4 sm:p-6")}>
{/* controls */}
<div className="flex flex-col gap-2 sm:flex-row sm:items-end">
<div className="flex-1">
<label htmlFor="cs-city" className="font-mono text-xs font-semibold uppercase tracking-wide text-foreground">City</label>
<div className="mt-1 flex items-center gap-2 rounded-md border-2 border-primary/60 bg-card px-3 py-2">
<Search className="size-4 text-primary" />
<input id="cs-city" value={place} onChange={(e) => setPlace(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && searchCity()}
placeholder="Search any city…"
className="min-w-0 flex-1 bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground" />
</div>
</div>
<button type="button" onClick={searchCity} disabled={searching}
className="flex items-center justify-center gap-2 rounded-md border-2 border-primary bg-primary px-4 py-2.5 font-mono text-xs font-semibold uppercase tracking-wide text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-60">
{searching ? <Loader2 className="size-4 animate-spin" /> : <Navigation className="size-4" />} Go
</button>
</div>
{/* style + colorway */}
<div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-1.5">
<span className="font-mono text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">3D style</span>
{[{ id: "watercolor", label: "Watercolor" }, { id: "realistic", label: "Realistic" }, { id: "material", label: "By material" }].map((s) => (
<button key={s.id} type="button" onClick={() => setStyle(s.id)} aria-pressed={style === s.id}
className={cn("rounded-full border-2 px-2.5 py-1 font-mono text-[11px] uppercase tracking-wide transition-colors",
style === s.id ? "border-primary bg-accent text-foreground" : "border-primary/40 text-muted-foreground hover:bg-accent/50")}>
{s.label}
</button>
))}
</div>
{style === "watercolor" ? (
<div className="flex items-center gap-1.5">
<Palette className="size-3.5 text-primary" />
{WC_COLORWAYS.map((c) => (
<button key={c.id} type="button" onClick={() => setColorway(c.id)} aria-pressed={colorway === c.id} title={c.label}
className={cn("flex items-center gap-1 rounded-full border-2 px-2 py-1 font-mono text-[10px] uppercase tracking-wide transition-colors",
colorway === c.id ? "border-primary bg-accent text-foreground" : "border-primary/40 text-muted-foreground hover:bg-accent/50")}>
<span className="size-2.5 rounded-full ring-1 ring-black/10" style={{ background: c.swatch }} />{c.label}
</button>
))}
</div>
) : null}
</div>
{/* journey transport selector (mirrors the watercolor map) */}
{flyStops.length > 0 && status === "ready" ? (
<div className="flex flex-wrap items-center gap-2">
<span className="flex items-center gap-1.5 font-mono text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
<FastForward className="size-3.5 text-primary" /> Journey
</span>
<div className="flex flex-wrap items-center gap-1.5">
{CS_TRANSPORTS.map((t) => (
<button key={t.id} type="button" onClick={() => setTransport(t.id)} aria-pressed={transport === t.id} title={t.label} disabled={flying}
className={cn("flex items-center gap-1 rounded-full border-2 px-2 py-1 font-mono text-[11px] uppercase tracking-wide transition-colors disabled:opacity-50",
transport === t.id ? "border-primary bg-accent text-foreground" : "border-primary/40 text-muted-foreground hover:bg-accent/50")}>
<span aria-hidden="true">{t.emoji}</span>{t.label}
</button>
))}
</div>
</div>
) : null}
{/* globe (drop target) */}
<div className={cn("sozai-map-dropzone relative w-full overflow-hidden rounded-lg border-2 border-primary/40", expanded && "flex-1 min-h-0", dropArmed && "drop-armed")}
onDragOver={(e) => { if (e.dataTransfer.types.includes("application/sozai-photo")) { e.preventDefault(); setDropArmed(true); } }}
onDragLeave={(e) => { if (e.currentTarget === e.target) setDropArmed(false); }}
onDrop={(e) => {
const id = e.dataTransfer.getData("application/sozai-photo");
setDropArmed(false);
const viewer = viewerRef.current;
if (!id || !viewer || !window.Cesium) return;
e.preventDefault();
const Cesium = window.Cesium;
const rect = e.currentTarget.getBoundingClientRect();
const pos = new Cesium.Cartesian2(e.clientX - rect.left, e.clientY - rect.top);
const cart = viewer.camera.pickEllipsoid(pos, viewer.scene.globe.ellipsoid);
if (cart) {
const c = Cesium.Cartographic.fromCartesian(cart);
const lon = Cesium.Math.toDegrees(c.longitude), lat = Cesium.Math.toDegrees(c.latitude);
window.SozaiPhotos.pin(id, lon, lat); Collab.send("photo_pinned", { id, lon, lat });
}
}}>
<div ref={containerRef} className={cn("w-full", expanded ? "h-full min-h-[60vh]" : "h-[420px] sm:h-[520px]")} />
{/* expand toggle */}
{status === "ready" ? (
<button type="button" onClick={() => setExpanded((v) => !v)}
aria-label={expanded ? "Collapse map" : "Expand map"} title={expanded ? "Collapse" : "Expand into map"}
className="absolute left-2 top-2 z-10 flex size-9 items-center justify-center rounded-md border-2 border-primary/70 bg-card/90 text-primary shadow-sm transition-colors hover:bg-accent">
{expanded ? <Minimize2 className="size-4" /> : <Maximize2 className="size-4" />}
</button>
) : null}
{status === "loading" ? (
<div className="absolute inset-0 flex items-center justify-center bg-card/60">
<span className="flex items-center gap-2 font-mono text-xs uppercase tracking-wide text-primary"><Loader2 className="size-4 animate-spin" /> Spinning up the globe…</span>
</div>
) : null}
{armedId ? (
<div className="absolute left-1/2 top-2 z-10 -translate-x-1/2 rounded-md border-2 border-primary bg-card/95 px-3 py-1.5 font-mono text-[10px] uppercase tracking-wide text-primary shadow-sm">
<MapPin className="mr-1 inline size-3.5" /> Click the globe to pin · <button type="button" className="underline" onClick={() => setArmedId(null)}>cancel</button>
</div>
) : null}
{flyStops.length > 0 && status === "ready" ? (
<button type="button" onClick={() => (flying ? stopFly() : playFly())}
className="absolute right-2 top-2 z-10 flex items-center gap-1.5 rounded-md border-2 border-primary bg-card/90 px-2.5 py-2 font-mono text-[10px] font-semibold uppercase tracking-wide text-primary shadow-sm transition-colors hover:bg-accent">
{flying ? <X className="size-4" /> : <FastForward className="size-4" />} {flying ? "Stop" : "Play journey"}
</button>
) : null}
{flying && flyStops[flyIdx] ? (
<div className="pointer-events-none absolute bottom-3 left-1/2 z-10 w-[min(78%,360px)] -translate-x-1/2">
<div className="overflow-hidden rounded-md border-2 border-primary bg-card/95 shadow-lg">
<img src={flyStops[flyIdx].src} alt="" className="h-36 w-full object-cover" />
<div className="px-3 py-2">
<p className="font-mono text-[10px] uppercase tracking-wide text-primary">
{(flyIdx + 1) + " / " + flyStops.length}
{[flyStops[flyIdx].date, flyStops[flyIdx].time].filter(Boolean).length ? " · " + [flyStops[flyIdx].date, flyStops[flyIdx].time].filter(Boolean).join(" · ") : ""}
</p>
<p className="mt-0.5 text-sm leading-snug text-foreground">{flyStops[flyIdx].caption || flyStops[flyIdx].title || ""}</p>
</div>
</div>
</div>
) : null}
</div>
{/* shared photo tray - same album photos as the watercolor map */}
{status === "ready" ? <PhotoTray onPick={pickCesiumPhoto} armedId={armedId} hint="Drag a photo onto the globe - or tap it, then click the globe - to pin it. Pinned photos can be dragged to reposition." /> : null}
<p className="flex items-start gap-1.5 font-mono text-[10px] leading-snug tracking-wide text-muted-foreground">
<Droplet className="mt-0.5 size-3 shrink-0 text-primary" />
Photorealistic 3D buildings via Cesium OSM Buildings. Photos pinned here and on the watercolor map stay in sync - press “Play journey” to fly the timeline.
</p>
</div>
);
}
function MapVariantSwitcher({ value, onChange }) {
return (
<div className="flex flex-wrap items-center gap-2" role="tablist" aria-label="Map layout">
{MAP_VARIANTS.map((v) => {
const active = v.id === value;
const IconC = v.icon;
return (
<button key={v.id} type="button" role="tab" aria-selected={active} onClick={() => onChange(v.id)}
title={v.blurb}
className={cn("flex items-center gap-1.5 rounded-md border-2 px-3 py-1.5 font-mono text-xs uppercase tracking-wide transition-colors",
active ? "border-primary bg-primary text-primary-foreground" : "border-primary/60 text-foreground hover:bg-accent")}>
<IconC className={cn("size-3.5", active ? "" : "text-primary")} />{v.label}
</button>
);
})}
</div>
);
}
// Variant: gallery / contact-sheet grid
function MapGridView({ selectedId, onSelect, moments: momentList }) {
const list = momentList || moments;
return (
<ul className="grid grid-cols-2 gap-4 sm:grid-cols-3">
{list.map((m, i) => {
const active = selectedId === m.id;
return (
<li key={m.id}>
<button type="button" onClick={() => onSelect(m.id)} aria-pressed={active}
className={cn("group flex w-full flex-col overflow-hidden rounded-lg border-2 bg-card text-left transition-all hover:-translate-y-0.5",
active ? "border-primary ring-4 ring-primary/20" : "border-primary/30 hover:border-primary/60")}>
<span className="relative block aspect-[4/3] overflow-hidden border-b-2 border-primary/20">
<Img src={m.src} alt={m.alt} fill className="object-cover [filter:saturate(0.9)]" sizes="240px" />
<span className="absolute left-2 top-2 flex size-6 items-center justify-center rounded-full border-2 border-primary bg-card font-mono text-xs font-bold text-primary">{m.id}</span>
{m.favorite ? <Star className="absolute right-2 top-2 size-4 fill-primary text-primary" /> : null}
</span>
<span className="flex flex-col gap-1 p-3">
<span className="font-mono text-xs font-bold uppercase tracking-wide text-foreground">{m.title}</span>
<span className="line-clamp-2 text-xs text-muted-foreground">{m.caption}</span>
<span className="mt-1 flex items-center gap-1.5 font-mono text-[10px] uppercase tracking-wide text-primary/70"><Clock className="size-3" />{m.time}</span>
</span>
</button>
</li>
);
})}
</ul>
);
}
// Variant: vertical chronological timeline
function MapTimelineView({ selectedId, onSelect, moments: momentList }) {
const list = momentList || moments;
return (
<ol className="relative ml-3 flex flex-col gap-6 border-l-2 border-dashed border-primary/40 pl-6">
{list.map((m) => {
const active = selectedId === m.id;
return (
<li key={m.id} className="relative">
<span aria-hidden="true" className={cn("absolute -left-[1.95rem] top-1 flex size-6 items-center justify-center rounded-full border-2 font-mono text-xs font-bold",
active ? "border-primary bg-primary text-primary-foreground" : "border-primary bg-card text-primary")}>{m.id}</span>
<button type="button" onClick={() => onSelect(m.id)} aria-pressed={active}
className={cn("flex w-full items-center gap-4 rounded-lg border-2 bg-card p-3 text-left transition-colors",
active ? "border-primary ring-4 ring-primary/20" : "border-primary/30 hover:border-primary/60 hover:bg-accent/40")}>
<span className="relative size-20 shrink-0 overflow-hidden rounded-md border-2 border-primary/30">
<Img src={m.src} alt={m.alt} fill className="object-cover" sizes="80px" />
</span>
<span className="min-w-0 flex-1">
<span className="flex items-center gap-2">
<span className="font-mono text-sm font-bold uppercase tracking-wide text-foreground">{m.title}</span>
{m.favorite ? <Star className="size-3.5 fill-primary text-primary" /> : null}
</span>
<span className="mt-0.5 block text-sm text-muted-foreground">{m.caption}</span>
<span className="mt-1 flex items-center gap-3 font-mono text-[10px] uppercase tracking-wide text-primary/70">
<span className="flex items-center gap-1"><Clock className="size-3" />{m.time}</span>
<span className="flex items-center gap-1"><MapPin className="size-3" />{m.title}</span>
</span>
</span>
</button>
</li>
);
})}
</ol>
);
}
// ----------------------------------------------------------------- //
// DevelopScroll - a draggable "develop your memories" scrapbook, ported
// from the standalone london scroll. Pieces (stamp-style cards) start in a
// pile; drag each onto one of 8 slots, then "develop" to shake + fix them
// down into a keepsake page. Flip a settled card to leave a note. Users can
// upload their own photos (data URLs) which replace the moment images.
// Built with imperative DOM via refs (matching the original mechanics).
// ----------------------------------------------------------------- //
function DevelopScroll({ moments: momentList }) {
const stageRef = useRef(null);
const pageRef = useRef(null);
const slotsRef = useRef(null);
const layerRef = useRef(null);
const dimRef = useRef(null);
const piecesRef = useRef({}); // id -> element
const slottedRef = useRef([]); // slot index -> id
const homesRef = useRef([]);
const phaseRef = useRef("desk");
const fileInputRef = useRef(null);
const uploadsRef = useRef({}); // id -> data URL override
const [canDevelop, setCanDevelop] = useState(false);
const [phase, setPhase] = useState("desk");
const [uploadCount, setUploadCount] = useState(0);
// NSFW scan progress overlay: {active, pct, label, blocked:[]}
const [scan, setScan] = useState(null);
const EASE = "cubic-bezier(0.16,1,0.3,1)";
const CARD_H = 1.10;
const COLS = [13.6, 37.8, 62, 86.2];
const reduced = (typeof window.__sozaiReduceMotion !== "undefined")
? window.__sozaiReduceMotion
: (window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches);
// build the data from app moments (up to 8) so it reflects the user's trip
// The original colored each card by who was in the photo (maya/alex/sam):
// madder red, prussian blue, gamboge yellow - with tinted frames and
// matching hand-drawn underlines. Our photos have no people metadata, so
// we cycle the same three pigments across the cards to keep that look.
const PIGMENTS = [
{ c: "#B6485C", tint: "rgba(182,72,92,.42)" }, // madder
{ c: "#2F5D7C", tint: "rgba(47,93,124,.42)" }, // prussian
{ c: "#C8912E", tint: "rgba(200,145,46,.48)" }, // gamboge
];
// parse "2:30 PM" / "14:30" → minutes since midnight for chronological sort
function parseTimeMin(s) {
if (!s) return 9999;
const m = String(s).match(/(\d+):(\d+)\s*(am|pm)?/i);
if (!m) return 9999;
let h = parseInt(m[1], 10), mn = parseInt(m[2], 10);
const ap = (m[3] || "").toLowerCase();
if (ap === "pm" && h !== 12) h += 12;
else if (ap === "am" && h === 12) h = 0;
return h * 60 + mn;
}
const src = momentList || moments;
const items = useMemo(() => src.slice(0, 8).map((m, i) => ({
id: "dv" + m.id, src: m.src, alt: m.alt || m.title,
name: (m.title || "moment").toLowerCase(), t: m.time || "", hr: parseTimeMin(m.time),
note: m.note || m.caption || "",
col: PIGMENTS[i % PIGMENTS.length].c, tint: PIGMENTS[i % PIGMENTS.length].tint,
})), [src]); // eslint-disable-line
const $page = () => pageRef.current;
const pageRect = () => {
const el = pageRef.current;
if (!el) return { left: 0, top: 0, width: 0, height: 0 };
const r = el.getBoundingClientRect();
// Defensive: if the height collapsed (aspect-ratio not resolved yet, or
// measured mid-transition) the cards would all clamp to the top. Derive
// it from the width instead — the stage is a fixed 3:2, capped at 78vh.
if (r.height < 80 && r.width > 80) {
const h = Math.min(r.width * 2 / 3, window.innerHeight * 0.78);
return { left: r.left, top: r.top, width: r.width, height: h };
}
return r;
};
const slotChildren = () => (slotsRef.current ? Array.from(slotsRef.current.children) : []);
const slotOf = (id) => { const i = slottedRef.current.indexOf(id); return i < 0 ? null : i; };
// slot geometry in PAGE-RELATIVE pixels (matches the loose-piece layer)
const slotBox = (i) => {
const s = slotChildren()[i];
return { left: parseFloat(s.style.left) || 0, top: parseFloat(s.style.top) || 0,
width: parseFloat(s.style.width) || 0, height: parseFloat(s.style.height) || 0 };
};
function genHomes() {
const cells = [...Array(items.length).keys()].sort(() => Math.random() - 0.5);
homesRef.current = cells.map((c) => {
let fx = (c % 4) * 0.235 + 0.155 + (Math.random() * 0.07 - 0.035);
let fy = (c < 4 ? 0.24 : 0.62) + (Math.random() * 0.12 - 0.06);
if (fx < 0.32 && fy < 0.44) fy = 0.44 + Math.random() * 0.06;
return { fx, fy, rot: Math.random() * 13 - 6.5, wf: 0.185 + Math.random() * 0.045 };
});
}
function layoutSlots() {
const page = $page(); if (!page || !slotsRef.current) return;
const pr = pageRect(); // defensive height so slots get real size
const head = page.querySelector(".dv-plate");
const topM = (head ? head.offsetTop + head.offsetHeight : 0) + 14;
const botM = pr.height * 0.034;
const midGap = pr.height * 0.045;
const rows = items.length > 4 ? 2 : 1;
const w = Math.min(pr.width * 0.225, (pr.height - topM - botM - midGap) / (rows * CARD_H * 1.02));
const h = w * CARD_H * 1.02;
slotChildren().forEach((s, i) => {
const left = pr.width * COLS[i % 4] / 100 - w / 2;
const top = (i < 4 ? topM : topM + h + midGap);
Object.assign(s.style, { left: left + "px", top: top + "px", width: w + "px", height: h + "px" });
});
}
function deskHome(i) {
const pr = pageRect();
const h = homesRef.current[i];
// Width-based card size, exactly like the original scroll. The page now
// has a fixed aspect ratio, so two rows always fit. Clamp x/y so a card
// can never stray off the page edges.
const w = pr.width * h.wf;
const x = Math.max(6, Math.min(h.fx * pr.width - w / 2, pr.width - w - 6));
const y = Math.max(6, Math.min(h.fy * pr.height - w * CARD_H / 2, pr.height - w * CARD_H - 6));
return { x, y, w, rot: h.rot };
}
function place(el, p, immediate) {
el.style.setProperty("--rot", p.rot + "deg");
el.style.setProperty("--pad", (p.w * 0.05) + "px");
el.style.transform = "rotate(" + p.rot + "deg)";
const from = { left: el.style.left, top: el.style.top, width: el.style.width };
const to = { left: p.x + "px", top: p.y + "px", width: p.w + "px" };
// Set the final position immediately as the source of truth - the
// element ALWAYS ends up here, even if the animation is interrupted.
Object.assign(el.style, to);
if (immediate || reduced || !from.left) return Promise.resolve();
// Cosmetic slide from the previous spot to the (already-set) final spot.
const a = el.animate([from, to], { duration: p.dur || 850, delay: p.delay || 0, easing: EASE });
return a.finished.catch(() => {});
}
function scatterAll(animate) {
items.forEach((m, i) => {
const el = piecesRef.current[m.id]; if (!el) return;
const p = deskHome(i);
el.style.opacity = 1; // source of truth - never rely on fill:forwards
if (animate && !reduced) {
// heavier, weightier cascade: longer travel + more stagger
place(el, { ...p, dur: 950, delay: i * 75 });
el.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 560, delay: i * 75 });
} else {
Object.assign(el.style, { left: p.x + "px", top: p.y + "px", width: p.w + "px", transform: "rotate(" + p.rot + "deg)" });
el.style.setProperty("--rot", p.rot + "deg");
el.style.setProperty("--pad", (p.w * 0.05) + "px");
}
});
}
function checkComplete() {
setCanDevelop(!slottedRef.current.some((s) => !s) && phaseRef.current === "desk" && slottedRef.current.length === items.length);
}
function slotAt(x, y) {
for (const s of slotChildren()) {
const r = s.getBoundingClientRect();
if (x >= r.left - 6 && x <= r.right + 6 && y >= r.top - 6 && y <= r.bottom + 6) return +s.dataset.i;
}
return null;
}
function assign(id, idx, opts) {
opts = opts || {};
if (slottedRef.current[idx] && slottedRef.current[idx] !== id) return sendHome(id);
const prev = slotOf(id);
if (prev !== null) { slottedRef.current[prev] = null; slotChildren()[prev].classList.remove("filled"); }
slottedRef.current[idx] = id;
slotChildren()[idx].classList.add("filled");
const r = slotBox(idx), el = piecesRef.current[id], w = r.width * 0.96;
return place(el, { x: r.left + (r.width - w) / 2, y: r.top + (r.height - w * CARD_H) / 2, w, rot: Math.sin(idx * 7) * 1.8, dur: opts.dur || 620, delay: opts.delay || 0 }).then(checkComplete);
}
function unassign(id) {
const i = slotOf(id);
if (i !== null) { slottedRef.current[i] = null; slotChildren()[i].classList.remove("filled"); }
sendHome(id); checkComplete();
}
function sendHome(id) {
const i = items.findIndex((m) => m.id === id);
return place(piecesRef.current[id], { ...deskHome(i), dur: 650 });
}
// build piece elements once
useEffect(() => {
slottedRef.current = new Array(items.length).fill(null);
genHomes();
// create slots
const slotsEl = slotsRef.current;
slotsEl.innerHTML = "";
for (let i = 0; i < items.length; i++) {
const d = document.createElement("div");
d.className = "dv-slot"; d.dataset.i = i;
slotsEl.appendChild(d);
}
// create pieces
const layer = layerRef.current;
items.forEach((m) => {
const el = document.createElement("figure");
el.className = "dv-piece"; el.dataset.id = m.id; el.tabIndex = 0;
el.setAttribute("aria-label", m.name + ", " + m.t);
el.innerHTML =
'<div class="dv-flip">' +
'<div class="dv-front">' +
'<div class="dv-frame" style="border-color:' + m.tint + '"><img src="' + m.src + '" alt=""></div>' +
'<figcaption><div class="dv-cap">' + m.name + '<span class="when">' + m.t + '</span><span class="where"></span></div>' +
'<svg class="dv-stroke" viewBox="0 0 100 8" preserveAspectRatio="none" aria-hidden="true"><path d="M2,5 C22,2 40,6 56,4 S 84,5 98,3" pathLength="100" stroke="' + m.col + '"/></svg>' +
'</figcaption>' +
'</div>' +
'<div class="dv-back">' +
'<div class="dv-bhead">' + m.name + '</div>' +
'<label class="dv-blabel">When' +
'<input class="dv-btime" type="text" value="' + (m.t || "").replace(/"/g, "&quot;") + '" placeholder="e.g. 2:20p" aria-label="Time for ' + m.name + '"></label>' +
'<label class="dv-blabel">Where' +
'<input class="dv-bloc" type="text" value="" placeholder="e.g. Hyde Park" aria-label="Location for ' + m.name + '"></label>' +
'<textarea class="dv-bwrite" placeholder="add your own note…" aria-label="Add a note">' + (m.note || "") + '</textarea>' +
'</div>' +
'</div>';
layer.appendChild(el);
piecesRef.current[m.id] = el;
});
layoutSlots();
// restore any saved edits, and wire input listeners on each card back
editsRef.current = loadEdits();
items.forEach((m) => {
const el = piecesRef.current[m.id]; if (!el) return;
const saved = editsRef.current[m.id] || {};
if (saved.time != null) applyEditToDOM(m.id, "time", saved.time);
if (saved.loc != null) applyEditToDOM(m.id, "loc", saved.loc);
if (saved.note != null) applyEditToDOM(m.id, "note", saved.note);
else { const l = el.querySelector(".dv-cap .where"); if (l) l.textContent = ""; }
const wire = (sel, field) => {
const node = el.querySelector(sel); if (!node) return;
node.addEventListener("input", () => {
editsRef.current[m.id] = Object.assign({}, editsRef.current[m.id], { [field]: node.value });
saveEdits();
if (field === "time" || field === "loc") applyEditToDOM(m.id, field, node.value);
Collab.send("dv_edit", { id: m.id, field, value: node.value });
});
// don't let typing inside the card trigger a flip / drag
node.addEventListener("pointerdown", (ev) => ev.stopPropagation());
node.addEventListener("click", (ev) => ev.stopPropagation());
};
wire(".dv-btime", "time"); wire(".dv-bloc", "loc"); wire(".dv-bwrite", "note");
});
// First scatter: WAIT until the page actually has a measured height
// (fonts/layout can settle a frame or two late). Poll briefly, then
// place immediately (no fly-in) so nothing collapses to the top edge.
let tries = 0;
let rafId = 0;
const firstScatter = () => {
const pr = pageRect();
if (pr.height < 80 && tries < 40) { tries++; rafId = requestAnimationFrame(firstScatter); return; }
layoutSlots();
items.forEach((m, i) => {
const el = piecesRef.current[m.id];
const p = deskHome(i);
Object.assign(el.style, { left: p.x + "px", top: p.y + "px", width: p.w + "px", opacity: 0, transform: "rotate(" + p.rot + "deg)" });
el.style.setProperty("--rot", p.rot + "deg");
el.style.setProperty("--pad", (p.w * 0.05) + "px");
if (!reduced) el.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 450, delay: i * 45, fill: "forwards" });
else el.style.opacity = 1;
});
};
rafId = requestAnimationFrame(firstScatter);
// resize handling
let rT;
const onResize = () => {
clearTimeout(rT);
rT = setTimeout(() => {
layoutSlots();
if (phaseRef.current !== "desk") return;
items.forEach((m, i) => {
const si = slotOf(m.id);
if (si !== null) { const r = slotBox(si), w = r.width * 0.96;
place(piecesRef.current[m.id], { x: r.left + (r.width - w) / 2, y: r.top + (r.height - w * CARD_H) / 2, w, rot: Math.sin(si * 7) * 1.8 }, true);
} else place(piecesRef.current[m.id], deskHome(i), true);
});
}, 150);
};
window.addEventListener("resize", onResize);
return () => { window.removeEventListener("resize", onResize); cancelAnimationFrame(rafId); clearTimeout(rT);
items.forEach((m) => { const el = piecesRef.current[m.id]; if (el && el.parentNode) el.parentNode.removeChild(el); });
piecesRef.current = {};
};
}, []); // eslint-disable-line
// pointer drag handlers on the layer
useEffect(() => {
const layer = layerRef.current; if (!layer) return;
let drag = null;
const onDown = (e) => {
if (phaseRef.current !== "desk") return;
const el = e.target.closest(".dv-piece"); if (!el) return;
el.setPointerCapture(e.pointerId);
drag = { el, dx: e.clientX - el.offsetLeft, dy: e.clientY - el.offsetTop, moved: false, x0: e.clientX, y0: e.clientY };
el.classList.add("dragging");
};
const onMove = (e) => {
if (!drag) return;
if (Math.hypot(e.clientX - drag.x0, e.clientY - drag.y0) > 4) drag.moved = true;
drag.el.style.left = (e.clientX - drag.dx) + "px";
drag.el.style.top = (e.clientY - drag.dy) + "px";
const i = slotAt(e.clientX, e.clientY);
slotChildren().forEach((s, k) => s.classList.toggle("target", k === i && !slottedRef.current[k]));
};
const onUp = (e) => {
if (!drag) return;
const el = drag.el, moved = drag.moved; el.classList.remove("dragging"); drag = null;
slotChildren().forEach((s) => s.classList.remove("target"));
const id = el.dataset.id, i = slotAt(e.clientX, e.clientY);
if (i !== null && moved) assign(id, i);
else if (!moved && slotOf(id) !== null) unassign(id);
};
const onKey = (e) => {
if (phaseRef.current !== "desk" || e.key !== "Enter") return;
const el = e.target.closest(".dv-piece"); if (!el) return;
const id = el.dataset.id;
if (slotOf(id) !== null) { unassign(id); return; }
const free = slottedRef.current.indexOf(null);
if (free > -1) assign(id, free);
};
layer.addEventListener("pointerdown", onDown);
layer.addEventListener("pointermove", onMove);
layer.addEventListener("pointerup", onUp);
layer.addEventListener("keydown", onKey);
return () => {
layer.removeEventListener("pointerdown", onDown);
layer.removeEventListener("pointermove", onMove);
layer.removeEventListener("pointerup", onUp);
layer.removeEventListener("keydown", onKey);
};
}, []); // eslint-disable-line
// flip on click/enter when settled (handlers live on the page)
useEffect(() => {
const page = pageRef.current; if (!page) return;
const onClick = (e) => {
if (phaseRef.current !== "settled") return;
if (e.target.closest(".dv-bwrite")) return;
const el = e.target.closest(".dv-piece"); if (!el) return;
el.classList.toggle("flipped");
};
const onKey = (e) => {
if (phaseRef.current !== "settled" || e.key !== "Enter" || e.target.closest(".dv-bwrite")) return;
const el = e.target.closest(".dv-piece"); if (!el) return;
el.classList.toggle("flipped");
};
page.addEventListener("click", onClick);
page.addEventListener("keydown", onKey);
return () => { page.removeEventListener("click", onClick); page.removeEventListener("keydown", onKey); };
}, []); // eslint-disable-line
// per-card editable fields (caption note, time, location), persisted to
// localStorage and broadcast to collaborators. Keyed by piece id.
const editsRef = useRef({});
function loadEdits() {
try { return JSON.parse(localStorage.getItem("sozai_dv_edits_v1") || "{}"); } catch (e) { return {}; }
}
function saveEdits() {
try { localStorage.setItem("sozai_dv_edits_v1", JSON.stringify(editsRef.current)); } catch (e) {}
}
function applyEditToDOM(id, field, value) {
const el = piecesRef.current[id]; if (!el) return;
const sel = field === "note" ? ".dv-bwrite" : field === "time" ? ".dv-btime" : ".dv-bloc";
const node = el.querySelector(sel);
if (node && node.value !== value) node.value = value;
// keep the front caption time + location in sync
if (field === "time") { const w = el.querySelector(".dv-cap .when"); if (w) w.textContent = value; }
if (field === "loc") { const l = el.querySelector(".dv-cap .where"); if (l) l.textContent = value ? (" · " + value) : ""; }
}
// receive collaborators' card edits (time / location / note) and apply
useEffect(() => Collab.on("dv_edit", (m) => {
if (!m || !m.id || !m.field) return;
editsRef.current[m.id] = Object.assign({}, editsRef.current[m.id], { [m.field]: m.value });
saveEdits();
applyEditToDOM(m.id, m.field === "loc" ? "loc" : m.field, m.value);
}), []);
function setPhaseBoth(p) { phaseRef.current = p; setPhase(p); }
// soft synthesized "paper shuffle" whoosh - no asset needed, plays on the
// click so the browser allows it.
function playShuffleSfx() {
try {
const AC = window.AudioContext || window.webkitAudioContext; if (!AC) return;
const ctx = (playShuffleSfx._ctx = playShuffleSfx._ctx || new AC());
if (ctx.state === "suspended") ctx.resume();
const dur = 0.55, buf = ctx.createBuffer(1, Math.floor(ctx.sampleRate * dur), ctx.sampleRate);
const data = buf.getChannelData(0);
for (let i = 0; i < data.length; i++) data[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / data.length, 2.2);
const src = ctx.createBufferSource(); src.buffer = buf;
const bp = ctx.createBiquadFilter(); bp.type = "bandpass"; bp.frequency.value = 1500; bp.Q.value = 0.6;
const g = ctx.createGain(); g.gain.value = 0.16;
src.connect(bp); bp.connect(g); g.connect(ctx.destination); src.start();
} catch (e) {}
}
function doShuffle() {
if (phaseRef.current !== "desk") return;
playShuffleSfx();
genHomes();
slottedRef.current.fill(null);
slotChildren().forEach((s) => s.classList.remove("filled"));
scatterAll(true); checkComplete();
}
function doOrganize() {
if (phaseRef.current !== "desk") return;
const sorted = [...items].sort((a, b) => a.hr - b.hr);
slottedRef.current.fill(null);
slotChildren().forEach((s) => s.classList.remove("filled"));
sorted.forEach((m, i) => {
// mark slot filled synchronously so checkComplete sees all filled
slottedRef.current[i] = m.id;
slotChildren()[i].classList.add("filled");
const r = slotBox(i);
if (!r.width) return;
const w = r.width * 0.91;
// scatter centre within the slot cell, bias toward slot middle
const jx = (Math.random() * 0.22 - 0.11) * r.width;
const jy = (Math.random() * 0.14 - 0.07) * r.height;
const x = Math.max(4, r.left + (r.width - w) / 2 + jx);
const y = Math.max(4, r.top + (r.height - w * CARD_H) / 2 + jy);
// alternate lean direction, vary magnitude 3-8°
const sign = i % 2 === 0 ? 1 : -1;
const rot = sign * (3 + Math.random() * 5);
const el = piecesRef.current[m.id]; if (!el) return;
place(el, { x, y, w, rot, dur: 680, delay: i * 70 });
});
checkComplete();
}
async function doDevelop() {
if (phaseRef.current !== "desk" || slottedRef.current.some((s) => !s)) return;
if (window.sozaiPing) window.sozaiPing("🎞️ developing the scrapbook");
setPhaseBoth("flying");
if (!reduced) {
if (dimRef.current) dimRef.current.animate([{ opacity: 0 }, { opacity: 1, offset: 0.3 }, { opacity: 0 }], { duration: 750, easing: "ease-in-out" });
await Promise.all(items.map((m, i) => {
const el = piecesRef.current[m.id];
const rot = parseFloat(getComputedStyle(el).getPropertyValue("--rot")) || 0;
return el.animate([
{ transform: "rotate(" + rot + "deg)" },
{ transform: "rotate(" + (rot - 2.6) + "deg)" },
{ transform: "rotate(" + (rot + 2.6) + "deg)" },
{ transform: "rotate(" + (rot - 1.4) + "deg)" },
{ transform: "rotate(" + rot + "deg)" },
], { duration: 460, delay: i * 28, easing: "ease-in-out" }).finished;
}));
}
await Promise.all(items.map((m) => {
const id = m.id, i = slotOf(id), r = slotBox(i), el = piecesRef.current[id], w = r.width * 0.96;
return place(el, { x: r.left + (r.width - w) / 2, y: r.top + (r.height - w * CARD_H) / 2, w, rot: Math.sin(i * 7) * 1.8, dur: 450 });
}));
// re-anchor pieces into the page using percentages so they stay put
// through resizes. The layer covers the page, so px values are already
// page-relative - convert straight to percentages.
const pr = pageRect();
items.forEach((m) => {
const el = piecesRef.current[m.id];
const left = parseFloat(el.style.left) || 0;
const top = parseFloat(el.style.top) || 0;
const w = parseFloat(el.style.width) || 0;
Object.assign(el.style, {
left: (left / pr.width * 100) + "%",
top: (top / pr.height * 100) + "%",
width: (w / pr.width * 100) + "%",
});
pageRef.current.appendChild(el);
});
setPhaseBoth("settled");
}
function doBack() {
if (phaseRef.current !== "settled") return;
const pr = pageRect();
items.forEach((m) => {
const el = piecesRef.current[m.id];
// current position as page-relative px (from the % anchored in the page)
const r = el.getBoundingClientRect();
el.classList.remove("flipped");
Object.assign(el.style, { left: (r.left - pr.left) + "px", top: (r.top - pr.top) + "px", width: r.width + "px" });
layerRef.current.appendChild(el);
});
slottedRef.current.fill(null);
slotChildren().forEach((s) => s.classList.remove("filled"));
setPhaseBoth("desk"); scatterAll(true); checkComplete();
}
// replace photos with the user's own uploads (fills pieces in order)
async function onUpload(e) {
const files = Array.from(e.target.files || []);
if (!files.length) return;
const blocked = [];
let idx = 0, done = 0;
const total = Math.min(files.length, items.length);
// a gentle "fetching the safety model…" progress that fills as each
// image is scanned (the first call may also load the model server-side).
setScan({ active: true, pct: 4, label: "Checking your photos…", blocked: [] });
let creep = setInterval(() => setScan((s) => (s && s.pct < 90 ? { ...s, pct: s.pct + 2 } : s)), 220);
for (const f of files) {
if (idx >= items.length) break;
// shared pipeline: validate type (.png/.jpg/.jpeg), read, NSFW gate
setScan((s) => s ? { ...s, label: "Scanning " + (f.name || "photo") + "…" } : s);
const result = await processUpload(f);
done++;
setScan((s) => s ? { ...s, pct: Math.max(s.pct, Math.round((done / total) * 100)) } : s);
if (!result.ok) {
if (result.reason === "type") blocked.push((f.name || "a photo") + " (not .png/.jpg)");
else if (result.reason === "nsfw") blocked.push(f.name || "a photo");
continue;
}
const url = result.dataUrl;
const m = items[idx];
uploadsRef.current[m.id] = url;
window.SozaiPhotos.upsert(m.id, { src: url });
const img = piecesRef.current[m.id] && piecesRef.current[m.id].querySelector(".dv-front img");
if (img) img.src = url;
idx++;
}
clearInterval(creep);
setScan({ active: false, pct: 100, label: "Done", blocked });
setUploadCount((c) => c + idx);
if (fileInputRef.current) fileInputRef.current.value = "";
// clear the overlay shortly after, leaving any block notice visible briefly
setTimeout(() => setScan((s) => (s && s.blocked && s.blocked.length ? s : null)), blocked.length ? 4000 : 600);
}
return (
<div className="flex flex-col gap-3">
{/* NSFW scan progress / block notice */}
{scan && scan.active ? (
<div className="fixed inset-0 z-[90] flex items-center justify-center bg-background/70 backdrop-blur-sm">
<div className="w-[min(90%,420px)] rounded-lg border-2 border-primary bg-card p-5 shadow-lg">
<div className="flex items-center gap-2 font-mono text-xs font-semibold uppercase tracking-wide text-foreground">
<Loader2 className="size-4 animate-spin text-primary" /> {scan.label}
</div>
<div className="mt-3 h-2.5 w-full overflow-hidden rounded-full bg-accent">
<div className="h-full rounded-full bg-primary transition-all duration-200" style={{ width: scan.pct + "%" }} />
</div>
<p className="mt-2 text-right font-mono text-[11px] text-muted-foreground">{scan.pct}%</p>
<p className="mt-1 font-mono text-[10px] leading-snug tracking-wide text-muted-foreground">
Photos are checked for inappropriate content before they're added. This also fetches the safety model on first use.
</p>
</div>
</div>
) : null}
{scan && !scan.active && scan.blocked && scan.blocked.length ? (
<div className="rounded-md border-2 border-[oklch(0.55_0.18_25)]/60 bg-[oklch(0.55_0.18_25)]/10 px-3 py-2">
<p className="font-mono text-[11px] font-semibold uppercase tracking-wide text-[oklch(0.5_0.2_25)]">
{scan.blocked.length} photo{scan.blocked.length > 1 ? "s" : ""} blocked by the content filter
</p>
<p className="mt-0.5 text-xs text-muted-foreground">{scan.blocked.join(", ")} {scan.blocked.length > 1 ? "were" : "was"} not added.</p>
</div>
) : null}
{/* toolbar */}
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-lg text-[oklch(0.42_0.025_210)]" style={{ fontFamily: "'Inkwell','Caveat',cursive" }}>
{phase === "settled"
? "where your day comes together · flip a card to leave a note"
: "drag each moment onto the page in the order you remember it"}
</p>
<div className="flex flex-wrap items-center gap-4">
{phase !== "settled" ? (
<>
<input ref={fileInputRef} type="file" accept={ACCEPT_ATTR} multiple onChange={onUpload} className="hidden" id="dv-upload" />
<button type="button" onClick={() => fileInputRef.current && fileInputRef.current.click()} className="dv-link inline-flex items-center gap-1.5"><ImagePlus className="size-4" /> use my photos</button>
<button type="button" onClick={doShuffle} className="dv-link inline-flex items-center gap-1.5"><Shuffle className="size-4" /> shuffle</button>
<button type="button" onClick={doOrganize} className="dv-link inline-flex items-center gap-1.5"><LayoutGrid className="size-4" /> auto-layout</button>
<button type="button" onClick={doDevelop} disabled={!canDevelop} className="dv-go">done developing</button>
</>
) : (
<>
<button type="button" onClick={doBack} className="dv-link inline-flex items-center gap-1.5"><RotateCcw className="size-4" /> back to the pile</button>
<button type="button" onClick={() => window.print()} className="dv-link inline-flex items-center gap-1.5"><Printer className="size-4" /> print</button>
</>
)}
</div>
</div>
{uploadCount ? <p className="font-mono text-[10px] uppercase tracking-wide text-muted-foreground">{uploadCount} of your own photo{uploadCount === 1 ? "" : "s"} added</p> : null}
{/* the scroll page - fixed aspect ratio (like a scrapbook page) so
its height always tracks its width; this keeps the two rows of
cards filling the page instead of leaving a huge empty area. */}
<div ref={stageRef} className="relative mx-auto w-full overflow-hidden rounded-lg"
style={{ aspectRatio: "3 / 2", maxHeight: "78vh" }}>
<div ref={pageRef} className={cn("dv-paper absolute inset-0 overflow-hidden rounded-lg border-2 border-primary/30", phase === "settled" ? "dv-settled" : "dv-desk")}>
<header className="dv-plate absolute left-[2.6%] top-[3.2%] z-[2]">
<div className="font-serif text-[11px] uppercase tracking-[0.24em] text-muted-foreground">{tripMeta.location} · A Day in Frames</div>
<h2 className="font-serif text-lg italic text-foreground">{tripMeta.date}</h2>
</header>
<div ref={slotsRef} />
<div ref={dimRef} className="dv-dim" />
</div>
{/* the loose pieces float in a layer that exactly covers the page,
so positions are page-relative and cards can't spill off-screen */}
<div ref={layerRef} className="pointer-events-none absolute inset-0 z-[40]" />
</div>
<p className="font-mono text-[10px] uppercase tracking-wide text-muted-foreground">Tip: drag a card onto a frame; click a placed card (once developed) to flip it and write a note. “Use my photos” swaps in your own images.</p>
</div>
);
}
function MapOfMyTime({ onBack, onOpenSettings, onOpenPets, variant = "scrapbook", onChangeVariant }) {
const [selectedId, setSelectedIdRaw] = useState(null);
useEffect(() => Collab.on("selection", (m) => setSelectedIdRaw(m.value)), []);
const setSelectedId = (v) => { setSelectedIdRaw(v); Collab.send("selection", { value: v }); };
const current = MAP_VARIANTS.find((v) => v.id === variant) || MAP_VARIANTS[0];
const storePhotos = usePhotos();
// The store starts EMPTY, so any photo in it was loaded by the user -
// whether uploaded ("u" ids) or from the sample album ("p" ids). Only fall
// back to the curated demo moments when the store is genuinely empty, so
// developed watercolours always carry through to the map.
const hasUserPhotos = storePhotos.some((p) => p && p.src);
const liveMoments = hasUserPhotos ? storePhotos.map(storeToMoment) : moments;
return (
<section className="flex w-full max-w-7xl flex-col overflow-hidden rounded-xl border-2 border-primary bg-card shadow-lg sm:flex-row">
<SideRail active="map" onNavigate={(id) => id === "camera" && onBack && onBack()} onOpenSettings={onOpenSettings} onOpenPets={onOpenPets} />
<div className="flex min-w-0 flex-1 flex-col">
<header className="flex items-center justify-between gap-4 border-b-2 border-dashed border-primary/50 px-5 py-4 sm:px-7">
<div className="flex items-center gap-2.5"><Camera className="size-5 text-primary" /><h1 className="font-mono text-base font-semibold uppercase tracking-widest text-foreground">Your Journey</h1></div>
<div className="flex items-center gap-2">
<HeaderButton icon={Download} label="Export" onClick={() => {
const r = exportScrapbook({ title: (tripMeta && tripMeta.location ? tripMeta.location + " - " : "") + "My Sozai Scrapbook" });
if (!r.ok && r.reason === "empty") window.alert("Add at least one photo before exporting.");
else if (!r.ok && r.reason === "popup-blocked") window.alert("Couldn't open the export window - allow pop-ups and try again.");
}} />
<button type="button" onClick={() => onOpenSettings && onOpenSettings()} aria-label="Settings" className="flex size-9 items-center justify-center rounded-md border-2 border-primary/70 text-primary transition-colors hover:bg-accent"><Settings className="size-4" /></button>
</div>
</header>
<div className="flex flex-col gap-4 px-5 py-5 sm:px-7">
{/* variant switcher + location */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="font-serif text-lg italic text-foreground">{tripMeta.location}</h2>
<p className="mt-0.5 font-mono text-[10px] uppercase tracking-widest text-muted-foreground">{tripMeta.period}</p>
</div>
<MapVariantSwitcher value={variant} onChange={onChangeVariant} />
</div>
{current.kind === "cesium" ? (
<CesiumMap cityName={tripMeta.location} />
) : current.kind === "map" ? (
<WatercolorMap tileKey={current.tile} cityName={tripMeta.location} />
) : current.kind === "develop" ? (
<DevelopScroll moments={liveMoments} />
) : variant === "scrapbook" ? (
<>
<div className="relative w-full overflow-hidden rounded-lg border-2 border-primary/40 bg-[oklch(0.95_0.018_85)]">
<img src="/assets/photos/scrapbook-map.png" alt="" aria-hidden="true" className="pointer-events-none absolute inset-0 h-full w-full object-cover opacity-60 mix-blend-multiply" />
<RouteLines moments={liveMoments} />
<div className="relative h-[460px] w-full sm:h-[520px]">
{liveMoments.map((m, i) => <MomentCard key={m.id} moment={m} rotate={rotations[i % rotations.length]} selected={selectedId === m.id} onSelect={setSelectedId} />)}
</div>
</div>
<div className="grid gap-6 lg:grid-cols-[1fr_280px]">
<Timeline date={tripMeta.date} moments={liveMoments} selectedId={selectedId} onSelect={setSelectedId} />
<Legend />
</div>
</>
) : variant === "grid" ? (
<MapGridView selectedId={selectedId} onSelect={setSelectedId} moments={liveMoments} />
) : (
<MapTimelineView selectedId={selectedId} onSelect={setSelectedId} moments={liveMoments} />
)}
</div>
</div>
</section>
);
}
// ----------------------------------------------------------------- //
// Settings panel: accessibility options + easter-egg toggle.
// ----------------------------------------------------------------- //
function Toggle({ id, label, description, checked, onChange }) {
return (
<div className="flex items-start justify-between gap-4 rounded-md border-2 border-primary/40 bg-card px-3.5 py-3">
<label htmlFor={id} className="min-w-0 cursor-pointer">
<span className="block font-mono text-xs font-semibold uppercase tracking-wide text-foreground">{label}</span>
{description ? <span className="mt-0.5 block text-xs leading-snug text-muted-foreground">{description}</span> : null}
</label>
<button
id={id}
type="button"
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
className={cn(
"relative mt-0.5 inline-flex h-6 w-11 shrink-0 items-center rounded-full border-2 transition-colors",
checked ? "border-primary bg-primary" : "border-primary/50 bg-card"
)}>
<span className={cn("inline-block size-4 rounded-full bg-card transition-transform", checked ? "translate-x-5 bg-primary-foreground" : "translate-x-0.5 bg-primary/70")} />
</button>
</div>
);
}
function SettingsPanel({ open, onClose, onOpenPets }) {
const { settings, update } = useSettings();
const [promptDraft, setPromptDraft] = useState(settings.captionPrompt || "");
const [titleDraft, setTitleDraft] = useState(settings.titlePrompt || "");
useEffect(() => { if (open) setPromptDraft(settings.captionPrompt || ""); }, [open]); // eslint-disable-line
useEffect(() => { if (open) setTitleDraft(settings.titlePrompt || ""); }, [open]); // eslint-disable-line
if (!open) return null;
const set = (k) => (v) => update({ [k]: v });
const PROMPT_PRESETS = [
{ label: "Warm & personal", value: "Write a short, warm one-sentence caption for a personal photo album." },
{ label: "Witty one-liner", value: "Write a witty, playful one-line caption for this photo. Keep it under 12 words." },
{ label: "Poetic", value: "Write a single evocative, poetic line describing the mood of this photo." },
{ label: "Just the facts", value: "Describe what is happening in this photo in one plain, factual sentence." },
];
const TITLE_PRESETS = [
{ label: "Evocative", value: "Write a very short, evocative title (2-5 words) for this photo. Reply with only the title." },
{ label: "One word", value: "Give this photo a single striking one-word title. Reply with only that word." },
{ label: "Place & moment", value: "Write a short title naming the place and the moment, like 'Morning in Ottawa'. Under 5 words." },
{ label: "Playful", value: "Write a short, playful title for this photo. Under 5 words." },
];
return (
<div className="fixed inset-0 z-[55] flex items-center justify-center bg-black/40 p-4" role="dialog" aria-modal="true" aria-label="Settings">
<div className="flex max-h-[85vh] w-full max-w-md flex-col overflow-hidden rounded-xl border-2 border-primary bg-card shadow-lg">
<div className="flex items-center justify-between border-b-2 border-dashed border-primary/50 px-5 py-3.5">
<span className="flex items-center gap-2 font-mono text-sm font-semibold uppercase tracking-widest text-foreground">
<Settings className="size-4 text-primary" /> Settings
</span>
<button type="button" onClick={onClose} aria-label="Close settings" className="text-primary hover:scale-110"><X className="size-4" /></button>
</div>
<div className="flex-1 space-y-6 overflow-y-auto px-5 py-5">
<section className="space-y-3">
<h3 className="font-mono text-xs font-bold uppercase tracking-wide text-primary">Accessibility</h3>
<Toggle id="set-large-text" label="Larger text" description="Increase the overall text size for easier reading." checked={settings.largeText} onChange={set("largeText")} />
<Toggle id="set-high-contrast" label="High contrast" description="Boost contrast between text and backgrounds." checked={settings.highContrast} onChange={set("highContrast")} />
<Toggle id="set-dyslexia" label="Dyslexia-friendly font" description="Use a more legible, evenly-spaced typeface." checked={settings.dyslexiaFont} onChange={set("dyslexiaFont")} />
<Toggle id="set-underline" label="Underline links" description="Always underline links, not just on hover." checked={settings.underlineLinks} onChange={set("underlineLinks")} />
<Toggle id="set-reduce-motion" label="Reduce motion" description="Minimize animations and transitions." checked={settings.reduceMotion} onChange={set("reduceMotion")} />
<Toggle id="set-dark" label="Dark mode" description="Switch to a darker color theme." checked={settings.darkMode} onChange={set("darkMode")} />
</section>
<section className="space-y-3">
<h3 className="font-mono text-xs font-bold uppercase tracking-wide text-primary">Notifications</h3>
<Toggle id="set-chat-sound" label="Chat sound" description="Play a chime when a new chat message arrives." checked={settings.chatSound} onChange={set("chatSound")} />
<Toggle id="set-chat-popup" label="Chat pop-ups" description="Show a pop-up when a message arrives while chat is closed." checked={settings.chatPopup} onChange={set("chatPopup")} />
<div className="rounded-md border-2 border-primary/40 bg-card px-3.5 py-3">
<label htmlFor="chat-sound-theme" className="block font-mono text-xs font-semibold uppercase tracking-wide text-foreground">Sound theme</label>
<div className="mt-2 flex items-center gap-2">
<select id="chat-sound-theme" value={settings.chatSoundTheme} onChange={(e) => update({ chatSoundTheme: e.target.value })}
disabled={!settings.chatSound}
className="flex-1 rounded-md border-2 border-primary/60 bg-card px-2.5 py-1.5 text-sm text-foreground outline-none focus:border-primary disabled:opacity-50">
{(window.SozaiChime ? window.SozaiChime.themes() : ["chime"]).map((th) => (
<option key={th} value={th}>{th.charAt(0).toUpperCase() + th.slice(1)}</option>
))}
</select>
<button type="button" disabled={!settings.chatSound}
onClick={() => window.SozaiChime && window.SozaiChime.play("message", { enabled: true, theme: settings.chatSoundTheme, volume: settings.chatSoundVolume })}
className="rounded-md border-2 border-primary px-3 py-1.5 font-mono text-[10px] font-semibold uppercase tracking-wide text-primary transition-colors hover:bg-accent disabled:opacity-50">Test</button>
</div>
<label htmlFor="chat-sound-vol" className="mt-3 block font-mono text-[10px] uppercase tracking-wide text-muted-foreground">Volume</label>
<input id="chat-sound-vol" type="range" min="0" max="1" step="0.05" value={settings.chatSoundVolume}
disabled={!settings.chatSound}
onChange={(e) => update({ chatSoundVolume: parseFloat(e.target.value) })}
className="mt-1 w-full accent-[var(--primary)] disabled:opacity-50" />
</div>
</section>
<section className="space-y-3">
<h3 className="font-mono text-xs font-bold uppercase tracking-wide text-primary">AI text</h3>
<div className="rounded-md border-2 border-primary/40 bg-card px-3.5 py-3">
<label htmlFor="caption-prompt" className="block font-mono text-xs font-semibold uppercase tracking-wide text-foreground">Custom caption prompt</label>
<p className="mt-0.5 text-xs leading-snug text-muted-foreground">Tell the caption model how you want it to write. Leave blank to use the default.</p>
<textarea id="caption-prompt" value={promptDraft} onChange={(e) => setPromptDraft(e.target.value)} rows={3}
placeholder="e.g. Write a witty one-liner under 10 words…"
className="mt-2 w-full resize-none rounded-md border-2 border-primary/60 bg-card px-3 py-2 text-sm text-foreground outline-none placeholder:text-muted-foreground" />
<div className="mt-2 flex flex-wrap gap-1.5">
{PROMPT_PRESETS.map((p) => (
<button key={p.label} type="button" onClick={() => setPromptDraft(p.value)}
className="rounded-full border-2 border-primary/50 px-2.5 py-1 font-mono text-[10px] uppercase tracking-wide text-primary transition-colors hover:bg-accent">{p.label}</button>
))}
</div>
<div className="mt-3 flex items-center gap-2">
<button type="button" onClick={() => update({ captionPrompt: promptDraft.trim() })}
className="rounded-md border-2 border-primary bg-primary px-3 py-1.5 font-mono text-xs font-semibold uppercase tracking-wide text-primary-foreground transition-colors hover:bg-primary/90">Save prompt</button>
<button type="button" onClick={() => { setPromptDraft(""); update({ captionPrompt: "" }); }}
className="rounded-md border-2 border-primary/50 px-3 py-1.5 font-mono text-xs uppercase tracking-wide text-primary transition-colors hover:bg-accent">Reset</button>
{settings.captionPrompt ? <span className="font-mono text-[10px] uppercase tracking-wide text-muted-foreground">custom prompt active</span> : null}
</div>
</div>
<div className="rounded-md border-2 border-primary/40 bg-card px-3.5 py-3">
<label htmlFor="title-prompt-settings" className="block font-mono text-xs font-semibold uppercase tracking-wide text-foreground">Custom title prompt</label>
<p className="mt-0.5 text-xs leading-snug text-muted-foreground">Separate from the caption prompt - steers how titles are auto-generated. Leave blank for the default.</p>
<textarea id="title-prompt-settings" value={titleDraft} onChange={(e) => setTitleDraft(e.target.value)} rows={2}
placeholder="e.g. A single striking one-word title…"
className="mt-2 w-full resize-none rounded-md border-2 border-primary/60 bg-card px-3 py-2 text-sm text-foreground outline-none placeholder:text-muted-foreground" />
<div className="mt-2 flex flex-wrap gap-1.5">
{TITLE_PRESETS.map((p) => (
<button key={p.label} type="button" onClick={() => setTitleDraft(p.value)}
className="rounded-full border-2 border-primary/50 px-2.5 py-1 font-mono text-[10px] uppercase tracking-wide text-primary transition-colors hover:bg-accent">{p.label}</button>
))}
</div>
<div className="mt-3 flex items-center gap-2">
<button type="button" onClick={() => update({ titlePrompt: titleDraft.trim() })}
className="rounded-md border-2 border-primary bg-primary px-3 py-1.5 font-mono text-xs font-semibold uppercase tracking-wide text-primary-foreground transition-colors hover:bg-primary/90">Save prompt</button>
<button type="button" onClick={() => { setTitleDraft(""); update({ titlePrompt: "" }); }}
className="rounded-md border-2 border-primary/50 px-3 py-1.5 font-mono text-xs uppercase tracking-wide text-primary transition-colors hover:bg-accent">Reset</button>
{settings.titlePrompt ? <span className="font-mono text-[10px] uppercase tracking-wide text-muted-foreground">custom prompt active</span> : null}
</div>
</div>
</section>
<section className="space-y-3">
<h3 className="font-mono text-xs font-bold uppercase tracking-wide text-primary">Easter eggs</h3>
<button type="button" onClick={() => { onClose && onClose(); onOpenPets && onOpenPets(); }}
className="flex w-full items-center justify-between gap-3 rounded-md border-2 border-primary/50 bg-accent/40 px-3.5 py-3 text-left transition-colors hover:bg-accent">
<span className="flex items-center gap-2.5">
<PawPrint className="size-5 text-primary" />
<span>
<span className="block font-mono text-xs font-semibold uppercase tracking-wide text-foreground">The Menagerie</span>
<span className="block text-xs text-muted-foreground">Pick a pixel pet to roam your screen.</span>
</span>
</span>
<ChevronRight className="size-4 text-primary" />
</button>
</section>
</div>
<div className="border-t-2 border-dashed border-primary/40 px-5 py-3">
<button type="button" onClick={onClose} className="w-full rounded-md border-2 border-primary bg-primary px-4 py-2.5 font-mono text-sm font-semibold uppercase tracking-wide text-primary-foreground transition-colors hover:bg-primary/90">Done</button>
</div>
</div>
</div>
);
}
// ----------------------------------------------------------------- //
// The Menagerie: a dedicated, playful easter-egg gallery where users
// pick one of the 76 oneko-style pets to roam their screen.
// ----------------------------------------------------------------- //
const PET_FALLBACK_ANIMALS = ["ace", "air", "alien", "black", "black2", "blue", "blue-marine", "blue-tabby", "boobookitty", "brown-bsd-daemon", "brown-dog", "calico", "calico-tabby", "captain-goodnight", "caz", "coke-bottle", "colourful", "dave", "deedee", "dog", "doom", "earth", "face", "fancy", "ff3mog", "fire", "ghetto", "ghost", "gray", "green-ghost", "holiday", "jess", "kina", "kuramecha", "lucky", "lucy", "lucy-dog", "marmalade", "mermaid", "metroid", "mike", "mini", "moka", "multi", "nekocool", "neon", "orange", "pac-man", "peach", "penguin", "penguin-2", "pink", "pink-nose-neko", "rainbow", "red-bsd-daemon", "robot", "rocket", "rose", "royal", "silver", "silversky", "skunk", "socks", "sonic", "spirit", "spooky", "tabby", "tentacle", "tie-fighter", "turtle", "usa", "white", "valentine", "water", "worms", "zelda3"];
function prettyAnimal(name) {
return (name || "").split("-").map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
}
// A small looping sprite preview for the gallery tiles. Walks the run
// frames; falls back to the classic embedded cat if sprites don't load.
function PetPreview({ animal, size = 40, playing = true }) {
const [frameIdx, setFrameIdx] = useState(0);
const [failed, setFailed] = useState(false);
const cycle = ["srun1", "srun2", "still", "erun1", "erun2", "still"];
useEffect(() => {
if (!playing) { setFrameIdx(0); return; }
const t = setInterval(() => setFrameIdx((i) => (i + 1) % cycle.length), 220);
return () => clearInterval(t);
}, [playing]);
const name = playing ? cycle[frameIdx] : "still";
if (failed) {
const map = { still: 25, srun1: 9, srun2: 10, erun1: 5, erun2: 6 };
return <img alt="" src={NEKO_FRAMES[map[name] || 25]} width={size} height={size} style={{ imageRendering: "pixelated" }} />;
}
return (
<img alt="" width={size} height={size} style={{ imageRendering: "pixelated" }}
src={petUrl(animal, name + ".png")}
onError={() => setFailed(true)} />
);
}
function PetsScreen({ onBack }) {
const { settings, update } = useSettings();
const [animals, setAnimals] = useState(PET_FALLBACK_ANIMALS);
const [query, setQuery] = useState("");
const [hovered, setHovered] = useState(null);
useEffect(() => {
let alive = true;
fetch("/api/sprites").then((r) => (r.ok ? r.json() : null)).then((d) => {
if (alive && d && Array.isArray(d.animals) && d.animals.length) setAnimals(d.animals);
}).catch(() => {});
return () => { alive = false; };
}, []);
const filtered = animals.filter((a) => a.toLowerCase().includes(query.trim().toLowerCase()));
const selected = settings.petAnimal;
const enabled = settings.petEnabled;
function choose(animal) {
update({ petAnimal: animal, petEnabled: true });
}
return (
<section className="w-full max-w-6xl overflow-hidden rounded-xl border-2 border-primary bg-card shadow-lg">
{/* eye-catching hero header */}
<header className="relative overflow-hidden border-b-2 border-dashed border-primary/50 px-5 py-6 sm:px-7"
style={{ background: "linear-gradient(120deg, var(--accent), transparent 70%)" }}>
<div className="pointer-events-none absolute inset-0 opacity-30" aria-hidden="true"
style={{ backgroundImage: "radial-gradient(circle at 20% 30%, var(--primary) 0, transparent 9%), radial-gradient(circle at 70% 60%, var(--primary) 0, transparent 7%), radial-gradient(circle at 85% 20%, var(--primary) 0, transparent 6%)", backgroundSize: "auto" }} />
<div className="relative flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<span className="flex size-11 items-center justify-center rounded-xl border-2 border-primary bg-card"><PawPrint className="size-6 text-primary" /></span>
<div>
<h1 className="flex items-center gap-2 font-mono text-xl font-bold uppercase tracking-widest text-foreground">The Menagerie <Sparkles className="size-4 text-primary" /></h1>
<p className="mt-0.5 text-sm text-muted-foreground">Adopt a pixel companion - it follows your cursor across every screen.</p>
</div>
</div>
<button type="button" onClick={onBack}
className="flex items-center gap-1.5 rounded-md border-2 border-primary/70 px-3 py-1.5 font-mono text-xs uppercase tracking-wide text-foreground transition-colors hover:bg-accent">
<ChevronLeft className="size-3.5 text-primary" /> Back
</button>
</div>
</header>
<div className="px-5 py-6 sm:px-7">
{/* current pet + master toggle */}
<div className="mb-5 flex flex-wrap items-center justify-between gap-4 rounded-lg border-2 border-primary/40 bg-accent/30 px-4 py-3">
<div className="flex items-center gap-3">
<span className="flex size-14 items-center justify-center rounded-lg border-2 border-primary/60 bg-card">
<PetPreview animal={selected} size={44} playing />
</span>
<div>
<span className="block font-mono text-xs uppercase tracking-wide text-muted-foreground">Current pet</span>
<span className="block font-mono text-base font-bold uppercase tracking-wide text-foreground">{prettyAnimal(selected)}</span>
</div>
</div>
<button type="button" onClick={() => update({ petEnabled: !enabled })}
className={cn("flex items-center gap-2 rounded-md border-2 px-4 py-2 font-mono text-xs font-semibold uppercase tracking-wide transition-colors",
enabled ? "border-primary bg-primary text-primary-foreground hover:bg-primary/90" : "border-primary/60 text-primary hover:bg-accent")}>
<Cat className="size-4" /> {enabled ? "Pet is roaming - turn off" : "Let it roam"}
</button>
</div>
{/* search */}
<div className="mb-4 flex items-center gap-2 rounded-md border-2 border-primary/60 bg-card px-3 py-2">
<Search className="size-4 text-primary" />
<input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search animals…"
className="min-w-0 flex-1 bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground" />
<span className="font-mono text-[11px] uppercase tracking-wide text-muted-foreground">{filtered.length} pets</span>
</div>
{/* gallery grid */}
<ul className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-6">
{filtered.map((a) => {
const isSel = a === selected;
return (
<li key={a}>
<button type="button" onClick={() => choose(a)}
onMouseEnter={() => setHovered(a)} onMouseLeave={() => setHovered(null)}
aria-pressed={isSel}
className={cn("group flex w-full flex-col items-center gap-2 rounded-lg border-2 px-2 py-3 transition-all hover:-translate-y-0.5",
isSel ? "border-primary bg-accent shadow-md" : "border-primary/30 bg-card hover:border-primary/60")}>
<span className="flex size-12 items-center justify-center">
<PetPreview animal={a} size={40} playing={hovered === a || isSel} />
</span>
<span className="line-clamp-1 text-center font-mono text-[10px] uppercase tracking-wide text-foreground">{prettyAnimal(a)}</span>
{isSel ? <span className="flex items-center gap-1 font-mono text-[9px] uppercase tracking-wide text-primary"><Check className="size-3" />Chosen</span> : null}
</button>
</li>
);
})}
</ul>
{filtered.length === 0 ? <p className="mt-6 text-center text-sm text-muted-foreground">No animals match “{query}”.</p> : null}
</div>
</section>
);
}
// ----------------------------------------------------------------- //
// Darkroom develop sequence (ported from darkroom_preview.html).
// Shown after "Add to Album" and before the Map of My Time.
// ----------------------------------------------------------------- //
// honors both the OS setting and the in-app "reduce motion" preference
function drReduced() {
try {
if (window.__sozaiReduceMotion) return true;
return !!(window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches);
} catch (e) { return false; }
}
// baths kept from the zip (colors + labels), seen from above
const DR_BATHS = [
{ id: "developer", label: "Developer", color: "oklch(0.38 0.11 52)", edge: "oklch(0.24 0.08 50)" },
{ id: "stop", label: "Stop Bath", color: "oklch(0.72 0.16 82)", edge: "oklch(0.54 0.13 80)" },
{ id: "fixer", label: "Fixer", color: "oklch(0.44 0.05 225)", edge: "oklch(0.30 0.04 225)" },
{ id: "wash", label: "Wash", color: "oklch(0.54 0.06 212)", edge: "oklch(0.40 0.04 212)" },
];
// top-down layout - columns are now driven by flex, not % coordinates
// prints stay latent in the baths; full colour is the shake payoff on the line
function drBathFilter(p) {
const blur = (1 - Math.min(1, p * 2.4)) * 4;
const gray = (1 - p) * 100;
const bright = 0.5 + p * 0.55;
const sep = (1 - p) * 32;
return "blur(" + blur.toFixed(2) + "px) grayscale(" + gray.toFixed(0) + "%) brightness(" + bright.toFixed(2) + ") sepia(" + sep.toFixed(0) + "%)";
}
function drLock() {
let busy = false; const q = [];
return {
acquire() { return new Promise((res) => { if (!busy) { busy = true; res(); } else q.push(res); }); },
release() { const n = q.shift(); if (n) n(); else busy = false; },
};
}
const drSleep = (ms) => new Promise((r) => setTimeout(r, drReduced() ? Math.min(ms, 120) : ms));
// ---- synthesized water SFX (Web Audio) ------------------------------ //
function useWaterSfx(muted) {
const ref = useRef(null);
const mutedRef = useRef(muted);
const bubBufRef = useRef(null); // decoded bubbles MP3 buffer
function engine() {
let a = ref.current;
if (!a) {
const AC = window.AudioContext || window.webkitAudioContext;
if (!AC) return null;
const ac = new AC();
const master = ac.createGain();
master.gain.value = mutedRef.current ? 0 : 0.25;
master.connect(ac.destination);
a = ref.current = { ac, master };
// kick off buffer load; ready well before first dip
if (!bubBufRef.current) {
fetch("/assets/liecio-water-bubbles-257594.mp3")
.then((r) => r.arrayBuffer())
.then((ab) => ac.decodeAudioData(ab))
.then((buf) => { bubBufRef.current = buf; })
.catch(() => {});
}
}
if (a.ac.state === "suspended") a.ac.resume();
return a;
}
useEffect(() => {
mutedRef.current = muted;
if (ref.current) ref.current.master.gain.value = muted ? 0 : 0.25;
}, [muted]);
const dip = useCallback(() => { // photo hits the bath
if (mutedRef.current) return;
const a = engine(); if (!a) return;
const ac = a.ac, t = ac.currentTime;
if (bubBufRef.current) {
// play ~900 ms of the bubbles file, faded at end
const src = ac.createBufferSource();
src.buffer = bubBufRef.current;
const g = ac.createGain();
g.gain.setValueAtTime(0.6, t);
g.gain.setValueAtTime(0.6, t + 0.65);
g.gain.linearRampToValueAtTime(0, t + 0.92);
src.connect(g); g.connect(a.master);
src.start(t); src.stop(t + 0.93);
} else {
// synthetic fallback while file loads
const n = Math.max(1, (ac.sampleRate * 0.45) | 0);
const b = ac.createBuffer(1, n, ac.sampleRate);
const d = b.getChannelData(0);
for (let i = 0; i < n; i++) d[i] = Math.random() * 2 - 1;
const s = ac.createBufferSource(); s.buffer = b;
const bp = ac.createBiquadFilter(); bp.type = "bandpass";
bp.frequency.setValueAtTime(1900, t);
bp.frequency.exponentialRampToValueAtTime(320, t + 0.4); bp.Q.value = 0.7;
const g = ac.createGain();
g.gain.setValueAtTime(0.0001, t);
g.gain.exponentialRampToValueAtTime(0.18, t + 0.02);
g.gain.exponentialRampToValueAtTime(0.001, t + 0.45);
s.connect(bp); bp.connect(g); g.connect(a.master); s.start(t); s.stop(t + 0.46);
}
// low thud on entry (kept regardless of buffer)
const o = ac.createOscillator(); o.type = "sine";
o.frequency.setValueAtTime(200, t);
o.frequency.exponentialRampToValueAtTime(70, t + 0.18);
const og = ac.createGain();
og.gain.setValueAtTime(0.12, t);
og.gain.exponentialRampToValueAtTime(0.001, t + 0.22);
o.connect(og); og.connect(a.master); o.start(t); o.stop(t + 0.23);
}, []);
const drip = useCallback(() => { // drips lifting out
if (mutedRef.current) return;
const a = engine(); if (!a) return;
const ac = a.ac, base = ac.currentTime, n = 3 + ((Math.random() * 3) | 0);
for (let i = 0; i < n; i++) {
const t = base + i * (0.06 + Math.random() * 0.09);
const o = ac.createOscillator(); o.type = "sine";
const f = 850 + Math.random() * 1300;
o.frequency.setValueAtTime(f, t);
o.frequency.exponentialRampToValueAtTime(f * 0.4, t + 0.1);
const g = ac.createGain();
g.gain.setValueAtTime(0.0001, t);
g.gain.exponentialRampToValueAtTime(0.09, t + 0.005);
g.gain.exponentialRampToValueAtTime(0.0006, t + 0.13);
o.connect(g); g.connect(a.master); o.start(t); o.stop(t + 0.15);
}
}, []);
const prime = useCallback(() => { engine(); }, []);
return { dip, drip, prime };
}
// ---- WebGL ambient water ripple (liquid shimmer, no image texture) -- //
// Methodology: converts the provided TypeScript WaterRippleImage to plain
// JS (no types, no imports). Strips image-texture sampling from the GLSL
// fragment shader - we only need the animated simplex-noise surface ripple
// rendered as an opaque amber liquid. One WebGL context fills the bench
// canvas; all tray/print divs layer on top via CSS z-index stacking.
// ResizeObserver keeps the canvas pixel-sharp as the bench resizes.
function WaterRippleCanvas({ baseColor = [0.14, 0.09, 0.03], scale = 5, illumination = 0.18, className = "", rippleStyle = {} }) {
const canvasRef = useRef(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const gl = canvas.getContext("webgl", { alpha: true, antialias: true }) ||
canvas.getContext("experimental-webgl");
if (!gl) return;
const VERT = [
"precision mediump float;",
"attribute vec2 a_pos; varying vec2 vUv;",
"void main(){vUv=.5*(a_pos+1.);gl_Position=vec4(a_pos,0.,1.);}"
].join("\n");
const FRAG = [
// highp (not mediump): the simplex-noise perm() math reaches ~2.8M,
// which overflows mediump on GPUs that honour it literally (many
// Mac/Intel/mobile) and renders as flashing green/orange triangles.
"precision highp float;",
"varying vec2 vUv;",
"uniform float u_time,u_ratio,u_scale,u_illum;",
"uniform vec3 u_color;",
"vec3 p289(vec3 x){return x-floor(x*(1./289.))*289.;}",
"vec2 p289v(vec2 x){return x-floor(x*(1./289.))*289.;}",
"vec3 perm(vec3 x){return p289(((x*34.)+1.)*x);}",
"float snoise(vec2 v){",
" const vec4 C=vec4(0.211324865405187,0.366025403784439,-0.577350269189626,0.024390243902439);",
" vec2 i=floor(v+dot(v,C.yy));",
" vec2 x0=v-i+dot(i,C.xx);",
" vec2 i1=(x0.x>x0.y)?vec2(1.,0.):vec2(0.,1.);",
" vec4 x12=x0.xyxy+C.xxzz;x12.xy-=i1;",
" i=p289v(i);",
" vec3 p=perm(perm(i.y+vec3(0.,i1.y,1.))+i.x+vec3(0.,i1.x,1.));",
" vec3 m=max(.5-vec3(dot(x0,x0),dot(x12.xy,x12.xy),dot(x12.zw,x12.zw)),0.);",
" m=m*m;m=m*m;",
" vec3 x2=2.*fract(p*C.www)-1.;",
" vec3 h=abs(x2)-.5;",
" vec3 a0=x2-floor(x2+.5);",
" m*=1.79284291400159-0.85373472095314*(a0*a0+h*h);",
" vec3 g;g.x=a0.x*x0.x+h.x*x0.y;g.yz=a0.yz*x12.xz+h.yz*x12.yw;",
" return 130.*dot(m,g);}",
"mat2 rot2(float r){return mat2(cos(r),sin(r),-sin(r),cos(r));}",
"float sn(vec2 uv,float t,float sc){",
" vec2 n=vec2(.1),N=vec2(.1);mat2 m=rot2(.5);",
" for(int j=0;j<10;j++){uv*=m;n*=m;",
" vec2 q=uv*sc+float(j)+n+(.5+.5*float(j))*(mod(float(j),2.)-1.)*t;",
" n+=sin(q);N+=cos(q)/sc;sc*=1.2;}",
" return N.x+N.y+.1;}",
"void main(){",
" vec2 uv=vec2(vUv.x*u_ratio,1.-vUv.y);",
" float t=.002*u_time;",
" float outer=snoise((.3+.1*sin(t))*uv+vec2(0.,.2*t));",
" float surf=sn(2.*uv+outer*.2,t,u_scale);",
" surf*=pow(uv.y*.55+.45,.4);",
" surf=pow(max(surf,0.),2.);",
" vec3 col=u_color+u_illum*vec3(.88,.70,.32)*surf;",
" gl_FragColor=vec4(col,.92);}"
].join("\n");
function mkSh(type, src) {
const sh = gl.createShader(type);
gl.shaderSource(sh, src);
gl.compileShader(sh);
return sh;
}
const prog = gl.createProgram();
gl.attachShader(prog, mkSh(gl.VERTEX_SHADER, VERT));
gl.attachShader(prog, mkSh(gl.FRAGMENT_SHADER, FRAG));
gl.linkProgram(prog);
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
console.warn("WaterRippleCanvas:", gl.getProgramInfoLog(prog)); return;
}
gl.useProgram(prog);
const vbo = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1, 1,-1, -1,1, 1,1]), gl.STATIC_DRAW);
const loc = gl.getAttribLocation(prog, "a_pos");
gl.enableVertexAttribArray(loc);
gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
const uT = gl.getUniformLocation(prog, "u_time");
const uR = gl.getUniformLocation(prog, "u_ratio");
const uS = gl.getUniformLocation(prog, "u_scale");
const uI = gl.getUniformLocation(prog, "u_illum");
const uC = gl.getUniformLocation(prog, "u_color");
gl.uniform1f(uS, scale);
gl.uniform1f(uI, illumination);
gl.uniform3f(uC, baseColor[0], baseColor[1], baseColor[2]);
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
function resize() {
const dpr = Math.min(devicePixelRatio || 1, 2);
canvas.width = Math.floor(canvas.offsetWidth * dpr);
canvas.height = Math.floor(canvas.offsetHeight * dpr);
gl.viewport(0, 0, canvas.width, canvas.height);
if (canvas.height > 0) gl.uniform1f(uR, canvas.width / canvas.height);
}
resize();
const ro = typeof ResizeObserver !== "undefined" ? new ResizeObserver(resize) : null;
if (ro) ro.observe(canvas);
let raf;
function render() {
gl.uniform1f(uT, performance.now());
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
raf = requestAnimationFrame(render);
}
raf = requestAnimationFrame(render);
return () => {
cancelAnimationFrame(raf);
if (ro) ro.disconnect();
gl.deleteBuffer(vbo);
gl.deleteProgram(prog);
};
}, []); // eslint-disable-line
return (
<canvas ref={canvasRef} className={className} aria-hidden="true"
style={{ position: "absolute", inset: 0, width: "100%", height: "100%", display: "block", zIndex: 0, ...rippleStyle }} />
);
}
// ---- polaroid print card (shared by both bath phases) --------------- //
function PrintCard({ sheet }) {
const bf = drBathFilter(sheet.level);
const filt = sheet.submerged ? ("url(#dr-ripple) " + bf) : bf;
return (
<div className="w-full" style={{
transform: "rotate(" + sheet.rot + "deg) scale(" + sheet.scale + ")",
opacity: sheet.opacity,
transition: drReduced() ? "all .16s linear" : "transform .8s cubic-bezier(.4,.1,.2,1), opacity .55s ease",
}}>
<div className="relative rounded-sm bg-[oklch(0.99_0.01_85)] p-1 shadow-xl">
<div className="relative aspect-[4/5] overflow-hidden rounded-[2px] bg-[oklch(0.16_0.02_55)]"
style={{ filter: filt, transition: "filter 1.1s ease" }}>
{sheet.src ? (
<img src={sheet.src} alt="" draggable={false} className="absolute inset-0 h-full w-full object-cover"
onError={(e) => { e.currentTarget.style.display = "none"; }} />
) : null}
<div className="pointer-events-none absolute inset-0"
style={{ opacity: 0.8 * Math.max(0, 1 - sheet.level * 2.2),
background: "radial-gradient(circle at 50% 40%, oklch(0.38 0.09 55 / 0.55), oklch(0.16 0.04 55 / 0.82))" }} />
</div>
{sheet.submerged ? (
<div className="pointer-events-none absolute inset-1 rounded-[2px]"
style={{ background: sheet.tint, opacity: 0.4, mixBlendMode: "multiply" }} />
) : null}
</div>
</div>
);
}
// ---- phase 1: top-down chemical baths ------------------------------ //
function TopDownBaths({ photos, onComplete, sfx }) {
const list = (photos && photos.length ? photos : []);
// col: tray index 0-3, or "queue" (waiting), or "exit" (done)
// above: true = hovering above tray, false = submerged
const [sheets, setSheets] = useState(() => list.map((p, i) => ({
id: p.id || ("dr" + i), src: p.src || null, alt: p.alt || "",
col: "queue", above: true,
rot: -4 + i * 1.5, scale: 0.86,
submerged: false, level: 0, tint: null, z: i, opacity: 1,
})));
const [trayRipples, setTrayRipples] = useState([[], [], [], []]);
const [started, setStarted] = useState(false);
const [done, setDone] = useState(0);
const BATH_MSGS = ["developing...", "giving your photos an acid rinse...", "stop bath, almost done..."];
const [msgIdx, setMsgIdx] = useState(0);
const alive = useRef(true);
useEffect(() => () => { alive.current = false; }, []);
const patch = useCallback((id, p) => {
setSheets((prev) => prev.map((s) => (s.id === id ? { ...s, ...p } : s)));
}, []);
const addRipple = useCallback((trayIdx, color) => {
if (drReduced()) return;
const rid = "rp" + Math.random().toString(36).slice(2);
setTrayRipples((prev) => { const next = prev.map((a) => [...a]); next[trayIdx] = [...next[trayIdx], { id: rid, color }]; return next; });
setTimeout(() => { if (alive.current) setTrayRipples((prev) => { const next = prev.map((a) => [...a]); next[trayIdx] = next[trayIdx].filter((r) => r.id !== rid); return next; }); }, 1300);
}, []);
useEffect(() => {
if (!started) return;
alive.current = true;
const locks = DR_BATHS.map(() => drLock());
let cancelled = false;
async function runSheet(sheet, idx) {
await drSleep(idx * 620);
if (cancelled) return;
patch(sheet.id, { z: 100 + idx, scale: 1 });
for (let b = 0; b < DR_BATHS.length; b++) {
const bath = DR_BATHS[b];
await locks[b].acquire();
if (cancelled || !alive.current) { locks[b].release(); return; }
patch(sheet.id, { col: b, above: true, rot: b % 2 ? 2 : -2 });
await drSleep(640);
sfx.dip();
addRipple(b, bath.color);
setTimeout(() => addRipple(b, bath.color), 150);
patch(sheet.id, { col: b, above: false, submerged: true, tint: bath.color, level: (b + 1) / (DR_BATHS.length + 2) });
await drSleep(1150);
patch(sheet.id, { col: b, above: true, submerged: false, tint: null });
sfx.drip();
await drSleep(560);
locks[b].release();
}
patch(sheet.id, { col: "exit", above: true, rot: 6, scale: 0.7, opacity: 0 });
await drSleep(620);
if (alive.current && !cancelled) setDone((c) => c + 1);
}
(async () => {
sfx.prime();
await Promise.all(sheets.map((s, i) => runSheet(s, i)));
if (!cancelled && alive.current) setTimeout(() => { if (alive.current) onComplete(); }, 650);
})();
return () => { cancelled = true; };
}, [started]); // eslint-disable-line
useEffect(() => {
if (!started) return;
const id = setInterval(() => setMsgIdx((i) => Math.min(i + 1, BATH_MSGS.length - 1)), 3800);
return () => clearInterval(id);
}, [started]); // eslint-disable-line
return (
<div className="flex w-full flex-col items-center gap-5 py-2">
{/* bench: flex row so each tray owns its column */}
<div className="relative w-full max-w-5xl overflow-hidden rounded-lg border-2 border-[oklch(0.35_0.06_55)]"
style={{ background: "radial-gradient(120% 100% at 50% 0%, oklch(0.20 0.04 55), oklch(0.10 0.02 55) 70%)",
boxShadow: "inset 0 0 70px rgba(0,0,0,0.55)" }}>
<div className="flex items-end gap-3 px-4 pb-4 pt-10">
{DR_BATHS.map((b, i) => {
const aboveCards = sheets.filter((s) => s.col === i && s.above);
const subCards = sheets.filter((s) => s.col === i && !s.above);
const rings = trayRipples[i] || [];
return (
<div key={b.id} className="flex flex-1 flex-col items-center">
{/* card zone - fixed height so tray tops stay level */}
<div className="relative w-full" style={{ height: "110px" }}>
{aboveCards.map((s) => (
<div key={s.id} className="absolute bottom-0 left-1/2 -translate-x-1/2"
style={{ width: "70%", zIndex: s.z }}>
<div className="dr-card-in"><PrintCard sheet={s} /></div>
</div>
))}
</div>
{/* tray */}
<div className="relative w-full rounded-[10px] border-2 border-[oklch(0.38_0.06_55)] p-1.5"
style={{ aspectRatio: "1 / 1.5", background: "oklch(0.12 0.02 55)", boxShadow: "0 6px 16px rgba(0,0,0,0.5)" }}>
<div className="relative h-full w-full overflow-hidden rounded-[6px]"
style={{ background: "radial-gradient(120% 120% at 50% 0%, " + b.color + ", " + b.edge + ")" }}>
<WaterRippleCanvas baseColor={[0.5, 0.48, 0.44]} scale={5} illumination={0.55}
rippleStyle={{ mixBlendMode: "overlay", opacity: 0.16, zIndex: 1 }} />
<div className="absolute inset-0" style={{ filter: "url(#dr-ripple)",
background: "linear-gradient(180deg, rgba(255,255,255,0.20), rgba(255,255,255,0) 45%)", zIndex: 2 }} />
<div className="absolute inset-0" style={{ boxShadow: "inset 0 0 24px rgba(0,0,0,0.5)", zIndex: 3 }} />
{/* submerged card lives inside its tray */}
{subCards.map((s) => (
<div key={s.id} className="absolute" style={{ inset: "10%", zIndex: 4 + s.z }}>
<div className="dr-card-sink"><PrintCard sheet={s} /></div>
</div>
))}
{/* ripple rings anchored to this tray */}
{rings.map((r) => (
<span key={r.id} className="pointer-events-none absolute left-1/2 top-1/2 rounded-full"
style={{ width: "80%", aspectRatio: "1", border: "2px solid " + r.color,
animation: "dr-ring 1.2s ease-out forwards", mixBlendMode: "screen",
transform: "translate(-50%,-50%)" }} />
))}
</div>
</div>
<p className="mt-1.5 text-center font-serif italic text-[11px] text-[oklch(0.68_0.07_62)]">{b.label}</p>
</div>
);
})}
</div>
{/* start overlay */}
{!started ? (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4"
style={{ background: "oklch(0.09 0.02 55 / 0.82)", backdropFilter: "blur(1.5px)", zIndex: 10 }}>
<button type="button" onClick={() => { sfx.prime(); setStarted(true); }}
className="inline-flex items-center gap-2 rounded-full border-2 border-[oklch(0.72_0.10_68)] bg-[oklch(0.28_0.08_58)] px-6 py-2.5 font-serif italic text-base text-[oklch(0.92_0.04_80)] transition-colors hover:bg-[oklch(0.36_0.10_62)]">
<Droplet className="size-4" /> develop
</button>
</div>
) : null}
</div>
{/* message ticker */}
{started ? (
<div className="w-full max-w-5xl" role="status" aria-live="polite">
<div className="h-px bg-[oklch(0.28_0.05_55)]" />
<p key={msgIdx} className="mt-2 text-center font-serif italic text-[13px] text-[oklch(0.55_0.07_62)]"
style={{ animation: "dr-fade 1s ease" }}>
{BATH_MSGS[msgIdx]}
</p>
</div>
) : null}
</div>
);
}
// ---- phase 2: hanging print, shake to reveal (ported) -------------- //
const DR_SHAKES_TO_REVEAL = 4;
function HangingPrint({ photo, index, swayDelay, onRevealed, widthClass }) {
const [progress, setProgress] = useState(0);
const [revealed, setRevealed] = useState(false);
const [dragging, setDragging] = useState(false);
const [tilt, setTilt] = useState(0);
const dragState = useRef({ active: false, lastX: 0, lastDir: 0, offset: 0 });
const progressRef = useRef(0);
useEffect(() => { if (revealed) onRevealed(photo.id); }, [revealed, onRevealed, photo.id]);
// Kick off the watercolour "develop" (flux.2-klein img2img) the moment the
// print is hung, so the result is ready by the time it's shaken in. The
// shake then reveals the DEVELOPED image; if the endpoint is unavailable
// or fails, we fall back to the original photo (no regression).
const [developedSrc, setDevelopedSrc] = useState(null);
const [devStatus, setDevStatus] = useState("queued"); // queued|developing|done|base
const developRef = useRef(false);
useEffect(() => {
if (developRef.current || !photo.src) return;
developRef.current = true;
let alive = true;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
(async () => {
// resolve the photo to a data URL once
let dataUrl = photo.src;
try {
if (!dataUrl.startsWith("data:")) {
const blob = await (await fetch(photo.src)).blob();
dataUrl = await new Promise((res, rej) => {
const fr = new FileReader();
fr.onload = () => res(fr.result); fr.onerror = rej;
fr.readAsDataURL(blob);
});
}
} catch (e) { console.warn("[develop] could not read photo", e); if (alive) setDevStatus("base"); return; }
// stagger so 4 prints don't all hit the GPU at once, then RETRY through
// the cold-pipe build / transient 500s / timeouts until a watercolour
// comes back (the old code gave up after one failed call).
await sleep(index * 1500);
for (let attempt = 0; attempt < 12 && alive; attempt++) {
if (alive) setDevStatus("developing");
try {
const r = await fetch("/api/img2img", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ data_url: dataUrl, name: photo.alt || "photo" }),
});
if (r.ok) {
const j = await r.json();
if (j && j.available && j.image) {
if (alive) {
setDevelopedSrc(j.image); setDevStatus("done");
// write the DEVELOPED watercolour + its pin back into the shared
// store so the map shows the transformed photo, auto-pinned at
// its GPS location (falls through to the room sync too).
try {
window.SozaiPhotos.upsert(photo.id, {
src: j.image,
lat: photo.lat != null ? photo.lat : null,
lon: photo.lon != null ? photo.lon : null,
pinned: photo.lat != null && photo.lon != null,
caption: photo.caption || photo.defaultCaption || "",
date: photo.date || "", time: photo.time || "",
});
} catch (e) {}
}
return;
}
if (j && j.available === false) { // feature genuinely off
if (alive) setDevStatus("base"); return;
}
}
} catch (e) { console.warn("[develop] attempt", attempt, "failed", e); }
await sleep(5000);
}
if (alive) setDevStatus("base"); // gave up → keep the original
})();
return () => { alive = false; };
}, [photo.src, photo.alt, index]);
const registerShake = useCallback(() => {
const next = Math.min(1, progressRef.current + 1 / DR_SHAKES_TO_REVEAL);
progressRef.current = next; setProgress(next);
if (next >= 1) setRevealed(true);
}, []);
// No shaking until the watercolour has actually developed (or the develop
// feature is off / gave up, where "base" means we reveal the original).
const canShake = devStatus === "done" || devStatus === "base";
function onPointerDown(e) {
if (revealed || !canShake) return;
try { e.currentTarget.setPointerCapture(e.pointerId); } catch (err) {}
dragState.current = { active: true, lastX: e.clientX, lastDir: 0, offset: 0 };
setDragging(true);
}
function onPointerMove(e) {
const s = dragState.current;
if (!s.active || revealed || !canShake) return;
const dx = e.clientX - s.lastX;
if (Math.abs(dx) < 4) return;
const dir = dx > 0 ? 1 : -1;
if (s.lastDir !== 0 && dir !== s.lastDir) registerShake();
s.lastDir = dir; s.lastX = e.clientX;
s.offset = Math.max(-26, Math.min(26, s.offset + dx));
setTilt(s.offset);
}
function endDrag(e) {
dragState.current.active = false; setDragging(false); setTilt(0);
try { e.currentTarget.releasePointerCapture(e.pointerId); } catch (err) {}
}
const blurPx = (1 - progress) * 14;
const grayscale = (1 - progress) * 100;
const brightness = 0.35 + progress * 0.65;
const fogOpacity = (1 - progress) * 0.9;
return (
<li className={cn("flex flex-col items-center", widthClass || "w-60 sm:w-72")}>
<div className="relative z-10 h-6 w-4 rounded-sm bg-[oklch(0.45_0.06_60)] shadow-sm" aria-hidden="true" />
<figure
className={cn("group relative -mt-1.5 select-none rounded-sm bg-[oklch(0.99_0.01_85)] p-3 pb-16 shadow-2xl",
dragging ? "" : "dr-sway", revealed ? "cursor-default" : (canShake ? "cursor-grab active:cursor-grabbing" : "cursor-progress"))}
style={{ animationDelay: swayDelay + "s",
transform: dragging ? "rotate(" + (tilt * 0.3) + "deg) translateX(" + (tilt * 0.4) + "px)" : undefined,
transition: dragging ? "none" : "transform 0.4s ease" }}
onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={endDrag} onPointerCancel={endDrag}>
<div className="relative aspect-[4/5] w-full overflow-hidden rounded-sm bg-[oklch(0.2_0.02_60)]">
<img src={(revealed && developedSrc) ? developedSrc : (photo.src || "/assets/placeholder.svg")} alt={revealed ? photo.alt : ""} draggable={false}
className="h-full w-full object-cover transition-[filter] duration-200"
style={{ filter: "blur(" + blurPx + "px) grayscale(" + grayscale + "%) brightness(" + brightness + ") sepia(" + ((1 - progress) * 40) + "%)" }} />
<div className="pointer-events-none absolute inset-0 transition-opacity duration-200"
style={{ opacity: fogOpacity,
background: "radial-gradient(circle at 50% 40%, oklch(0.35 0.12 25 / 0.65), oklch(0.18 0.06 25 / 0.85))" }} aria-hidden="true" />
{!revealed ? (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<span className={cn("flex items-center gap-2 rounded-full px-4 py-1.5 font-serif italic text-sm",
canShake ? "bg-[oklch(0.15_0.04_25/0.7)] text-[oklch(0.95_0.05_25)]" : "bg-black/65 text-white/90")}>
{!canShake ? (<><Loader2 className="size-4 animate-spin" aria-hidden="true" /> developing…</>)
: progress > 0 ? "keep shaking…" : "developed · shake me"}
</span>
</div>
) : (
<div className="pointer-events-none absolute right-2.5 top-2.5 text-[oklch(0.95_0.05_85)] drop-shadow">
<Sparkles className="size-6" aria-hidden="true" />
</div>
)}
</div>
<figcaption className="absolute inset-x-0 bottom-3 line-clamp-3 px-3 text-center font-hand text-base leading-snug text-[oklch(0.3_0.04_264)]"
style={revealed ? { animation: "dr-cap-in .6s ease-out both" } : undefined}>
{revealed ? photo.defaultCaption : ""}
</figcaption>
</figure>
</li>
);
}
// ---- the Darkroom shell -------------------------------------------- //
function DarkroomDeveloper({ photos, onDone, onSkip }) {
const list = (photos && photos.length ? photos : []);
const [phase, setPhase] = useState("baths");
const [revealed, setRevealed] = useState(() => new Set());
const [muted, setMuted] = useState(false);
const sfx = useWaterSfx(muted);
const allRevealed = revealed.size === list.length;
const handleRevealed = useCallback((id) => {
setRevealed((prev) => { const next = new Set(prev); next.add(id); return next; });
}, []);
return (
<div className="fixed inset-0 z-50 flex flex-col overflow-hidden bg-[oklch(0.12_0.022_55)]">
<style dangerouslySetInnerHTML={{ __html:
"@keyframes dr-safelight{0%,100%{opacity:.85}45%{opacity:.78}55%{opacity:.92}70%{opacity:.82}}" +
"@keyframes dr-ring{from{transform:translate(-50%,-50%) scale(.2);opacity:.7}to{transform:translate(-50%,-50%) scale(1.7);opacity:0}}" +
"@keyframes dr-sway-kf{0%,100%{transform:rotate(-1.5deg)}50%{transform:rotate(1.5deg)}}" +
"@keyframes dr-fade{from{opacity:0}to{opacity:1}}" +
"@keyframes dr-cap-in{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}" +
"@keyframes dr-card-in{from{opacity:0;transform:translateX(-22px) scale(.95)}to{opacity:1;transform:none}}" +
"@keyframes dr-card-sink{from{opacity:0;transform:translateY(-14px) scale(.97)}to{opacity:1;transform:none}}" +
".dr-flicker{animation:dr-safelight 5s ease-in-out infinite}" +
".dr-sway{animation:dr-sway-kf 4s ease-in-out infinite;transform-origin:top center}" +
".dr-card-in{animation:dr-card-in .42s cubic-bezier(.34,1.18,.5,1) both}" +
".dr-card-sink{animation:dr-card-sink .5s cubic-bezier(.4,.1,.2,1) both}" +
"@media (prefers-reduced-motion: reduce){.dr-flicker,.dr-sway,.dr-card-in,.dr-card-sink{animation:none}}"
}} />
{/* hidden filter that ripples the liquid + submerged prints */}
<svg aria-hidden="true" style={{ position: "absolute", width: 0, height: 0 }}>
<filter id="dr-ripple" x="-10%" y="-10%" width="120%" height="120%">
<feTurbulence type="fractalNoise" baseFrequency="0.012 0.02" numOctaves="2" seed="7" result="noise">
<animate attributeName="baseFrequency" dur="14s"
values="0.012 0.02;0.016 0.014;0.01 0.022;0.012 0.02" repeatCount="indefinite" />
</feTurbulence>
<feDisplacementMap in="SourceGraphic" in2="noise" scale="7" xChannelSelector="R" yChannelSelector="G" />
</filter>
</svg>
{/* amber safelight glow - fills the whole screen */}
<div className="dr-flicker pointer-events-none absolute inset-0" aria-hidden="true"
style={{ background:
"radial-gradient(130% 90% at 50% 0%, oklch(0.58 0.18 60 / 0.32), transparent 65%), radial-gradient(100% 80% at 50% 120%, oklch(0.44 0.12 56 / 0.24), transparent 58%)" }} />
{/* top-right controls */}
<div className="relative flex shrink-0 items-center justify-end gap-2 px-5 pt-4 sm:px-8">
<button type="button" onClick={() => setMuted((m) => !m)} aria-pressed={muted}
aria-label={muted ? "Unmute water sounds" : "Mute water sounds"}
className="flex size-8 items-center justify-center rounded-md border border-[oklch(0.30_0.05_55)] text-[oklch(0.55_0.07_62)] transition-colors hover:bg-[oklch(0.18_0.03_55)]">
{muted ? <VolumeX className="size-3.5" /> : <Volume2 className="size-3.5" />}
</button>
<button type="button" onClick={() => onSkip && onSkip()}
className="flex items-center gap-1 rounded-md border border-[oklch(0.30_0.05_55)] px-2 py-1.5 font-serif italic text-[10px] text-[oklch(0.55_0.07_62)] transition-colors hover:bg-[oklch(0.18_0.03_55)]">
<FastForward className="size-3" /> Skip
</button>
</div>
{/* centred content */}
<div className="relative flex flex-1 flex-col items-center justify-center gap-6 px-5 pb-10 sm:px-8">
<div className="text-center">
<h1 className="font-display text-5xl leading-none text-[oklch(0.92_0.04_78)] sm:text-6xl">The Darkroom</h1>
<p className="mt-3 font-serif italic text-xl text-[oklch(0.70_0.07_65)]">
{phase === "baths"
? "developing your photos"
: allRevealed ? "your photos are ready"
: "shake each print to reveal it · " + revealed.size + "/" + list.length + " done"}
</p>
</div>
{phase === "baths" ? (
<TopDownBaths photos={list} sfx={sfx} onComplete={() => setPhase("hang")} />
) : (
<div className="flex w-full flex-col items-center">
<div className="relative w-full">
<div className="absolute inset-x-0 top-3 h-[2px] bg-[oklch(0.6_0.05_60)]" aria-hidden="true" />
<ul className="relative flex flex-wrap items-start justify-center gap-8 pt-3 sm:gap-12">
{list.map((photo, i) => {
// fewer prints = bigger heroes; many still fit by wrapping
const heroW = list.length <= 1 ? "w-[20rem] sm:w-[26rem]"
: list.length === 2 ? "w-72 sm:w-[22rem]"
: list.length <= 4 ? "w-60 sm:w-72"
: "w-52 sm:w-60";
return (
<HangingPrint key={photo.id} photo={photo} index={i} swayDelay={i * 0.4} onRevealed={handleRevealed} widthClass={heroW} />
);
})}
</ul>
</div>
<button type="button" onClick={() => onDone && onDone()} disabled={!allRevealed}
className={cn("mt-10 inline-flex items-center gap-2 rounded-full border-2 px-6 py-2.5 font-serif italic text-base transition-all",
allRevealed
? "border-[oklch(0.78_0.09_65)] bg-[oklch(0.28_0.08_58)] text-[oklch(0.94_0.03_80)] hover:bg-[oklch(0.36_0.10_62)]"
: "cursor-not-allowed border-[oklch(0.38_0.04_55)] text-[oklch(0.58_0.06_60)] opacity-40")}>
{allRevealed ? "View your map" : "Reveal all prints first"}
<ArrowRight className="size-4" aria-hidden="true" />
</button>
</div>
)}
</div>
</div>
);
}
// ----------------------------------------------------------------- //
// ----------------------------------------------------------------- //
// app/page.tsx + collaboration shell
// ----------------------------------------------------------------- //
function Page({ onOpenSettings, onOpenPets, onOpenTrace, screen, setScreen, mapVariant, setMapVariant }) {
// Each photo carries its own title/caption/date/time - a single source of
// truth so the fields can never desync from the photo list.
const [stackPhotos, setStackPhotos] = useState(() => []); // default: empty
const developPhotos = stackPhotos.map((p) => ({ id: p.id, src: p.src, alt: p.alt, defaultCaption: p.defaultCaption,
lat: p.lat, lon: p.lon, date: p.date, time: p.time, title: p.title, caption: p.caption }));
if (screen === "add")
return <AddToAlbum onAdded={() => setScreen("darkroom")} onOpenSettings={onOpenSettings} onOpenPets={onOpenPets} mapVariant={mapVariant} setMapVariant={setMapVariant} stackPhotos={stackPhotos} setStackPhotos={setStackPhotos} />;
if (screen === "darkroom")
return <DarkroomDeveloper photos={developPhotos} onDone={() => setScreen("map")} onSkip={() => setScreen("map")} />;
if (screen === "pets")
return <PetsScreen onBack={() => setScreen("add")} />;
if (screen === "trace")
return <TraceScreen onBack={() => setScreen("add")} onOpenSettings={onOpenSettings} />;
return <MapOfMyTime onBack={() => setScreen("add")} onOpenSettings={onOpenSettings} onOpenPets={onOpenPets} variant={mapVariant} onChangeVariant={setMapVariant} />;
}
function Root() {
const [chatOpen, setChatOpen] = useState(false);
const [unread, setUnread] = useState(0);
const [settingsOpen, setSettingsOpen] = useState(false);
const [screen, setScreen] = useState("add");
const [mapVariant, setMapVariant] = useState(() => {
try { return localStorage.getItem("sozai_map_variant") || "scrapbook"; } catch (e) { return "scrapbook"; }
});
const [settings, setSettings] = useState(loadSettings);
useEffect(() => { Collab.connect(); }, []);
useEffect(() => {
// load saved scrapbook for this room (if any) and start auto-saving
RoomPersistence.init();
const onHide = () => { try { RoomPersistence.flush(); } catch (e) {} };
window.addEventListener("beforeunload", onHide);
document.addEventListener("visibilitychange", () => { if (document.visibilityState === "hidden") onHide(); });
return () => { window.removeEventListener("beforeunload", onHide); RoomPersistence.stop(); };
}, []);
useEffect(() => { applySettings(settings); }, []); // eslint-disable-line
useEffect(() => {
applySettings(settings);
try { localStorage.setItem("sozai_settings", JSON.stringify(settings)); } catch (e) {}
}, [settings]);
useEffect(() => { try { localStorage.setItem("sozai_map_variant", mapVariant); } catch (e) {} }, [mapVariant]);
// ---- join approval (item: host admits/declines joiners) ---- //
// As host: a join_request raises an Admit/Decline toast.
// As joiner: join_pending shows a waiting notice, join_declined explains.
useEffect(() => {
const offs = [
Collab.on("join_request", (m) => {
const p = m && m.participant;
if (!p) return;
ToastStore.push({
kind: "join", sticky: true, name: p.name,
onAdmit: () => Collab.send("admit_join", { session_id: p.session_id }),
onDecline: () => Collab.send("decline_join", { session_id: p.session_id }),
});
}),
Collab.on("join_pending", () => {
window.__sozaiJoinPending = true;
ToastStore.push({ kind: "info", sticky: true, id: "join-wait",
text: "Waiting for the host to let you in…" });
}),
Collab.on("join_declined", () => {
ToastStore.dismiss("join-wait");
ToastStore.push({ kind: "info", ttl: 8000,
text: "The host didn't admit you to this room." });
}),
// once we're fully in (init arrives), clear any waiting toast
Collab.on("me", () => { ToastStore.dismiss("join-wait"); window.__sozaiJoinPending = false; }),
];
return () => offs.forEach((off) => off && off());
}, []);
// ---- live collaboration sync (item 12) ---- //
// photo pins/moves/resets from collaborators → apply to the shared store.
// The store never re-broadcasts, so there is no echo loop.
useEffect(() => {
const offs = [
Collab.on("photo_pinned", (m) => { if (m && m.id) window.SozaiPhotos.pin(m.id, m.lon, m.lat); }),
Collab.on("photo_moved", (m) => { if (m && m.id) window.SozaiPhotos.move(m.id, m.lon, m.lat); }),
Collab.on("photos_reset", () => window.SozaiPhotos.resetPins()),
Collab.on("photo_props", (m) => { if (m && m.id) window.SozaiPhotos.setStyleProps(m.id, m.props || {}); }),
];
return () => offs.forEach((off) => off && off());
}, []);
// optional "follow" - when a collaborator switches the map/scrapbook view,
// mirror it so both are on the same page (can be toggled off per user).
const [followPeer, setFollowPeer] = useState(true);
const followRef = useRef(followPeer);
useEffect(() => { followRef.current = followPeer; window.__sozaiFollow = followPeer; }, [followPeer]);
useEffect(() => {
const offs = [
Collab.on("screen_changed", (m) => { if (followRef.current && m && m.screen) setScreen(m.screen); }),
Collab.on("variant_changed", (m) => { if (followRef.current && m && m.variant) setMapVariant(m.variant); }),
];
return () => offs.forEach((off) => off && off());
}, []);
// broadcast our own screen/variant changes for peers who are following
useEffect(() => { Collab.send("screen_changed", { screen }); }, [screen]);
useEffect(() => { Collab.send("variant_changed", { variant: mapVariant }); }, [mapVariant]);
const update = (patch) => setSettings((prev) => ({ ...prev, ...patch }));
const openPets = () => setScreen("pets");
const openTrace = () => setScreen("trace");
return (
<SettingsContext.Provider value={{ settings, update }}>
<div className="flex min-h-screen flex-col">
<CollabBar onToggleChat={() => { setChatOpen((v) => !v); setUnread(0); }} unread={unread} onOpenPets={openPets} onOpenTrace={openTrace} />
<main className="flex flex-1 items-center justify-center bg-background p-4 sm:p-8">
<Page onOpenSettings={() => setSettingsOpen(true)} onOpenPets={openPets} onOpenTrace={openTrace}
screen={screen} setScreen={setScreen} mapVariant={mapVariant} setMapVariant={setMapVariant} />
</main>
{Collab.isCollab() ? <Cursors /> : null}
{Collab.isCollab() ? (
<button type="button" onClick={() => setFollowPeer((v) => !v)}
title={followPeer ? "You'll follow when a collaborator changes the view" : "You can roam independently"}
className={cn("fixed bottom-4 left-4 z-[70] flex items-center gap-1.5 rounded-full border-2 px-3 py-1.5 font-mono text-[10px] uppercase tracking-wide shadow-md transition-colors",
followPeer ? "border-primary bg-primary text-primary-foreground" : "border-primary/50 bg-card text-primary hover:bg-accent")}>
<span className={cn("size-2 rounded-full", followPeer ? "bg-primary-foreground" : "bg-primary")} />
{followPeer ? "Following" : "Roaming"}
</button>
) : null}
{Collab.isCollab() ? (
<ChatDock open={chatOpen} onClose={() => setChatOpen(false)} onIncoming={() => setUnread((u) => u + 1)} />
) : null}
<SettingsPanel open={settingsOpen} onClose={() => setSettingsOpen(false)} onOpenPets={openPets} />
<Pet enabled={settings.petEnabled && !settings.reduceMotion} animal={settings.petAnimal} />
<UploadOverlay />
<ToastCenter />
</div>
</SettingsContext.Provider>
);
}
ReactDOM.createRoot(document.getElementById("root")).render(<Root />);
</script>
</body>
</html>