Spaces:
Paused
Paused
Fetching metadata from the HF Docker repository...
| <!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, "&").replace(/</g, "<").replace(/>/g, ">") | |
| .replace(/"/g, """); | |
| 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 & 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, "<") : "") + '</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, "<") + '</div>' : '<div class="cap"> </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 || " ").replace(/</g, "<")) + | |
| (meta ? '<span class="sozai-pin-when">' + meta.replace(/</g, "<") + '</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, """) + '" 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> |