Spaces:
Paused
Paused
Shereen Lee commited on
Commit ·
bb628b8
1
Parent(s): 87fefcf
feat: Sozai app with ZeroGPU img2img watercolour develop (staged LoRA)
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +3 -0
- .gitignore +5 -0
- README.md +9 -5
- app.py +0 -0
- assets/hummingbird.png +3 -0
- darkroom_preview.html +605 -0
- index.html +0 -0
- landing.html +283 -0
- packages.txt +1 -0
- public/2023-icon-library/ace/alert.png +3 -0
- public/2023-icon-library/ace/erun1.png +3 -0
- public/2023-icon-library/ace/erun2.png +3 -0
- public/2023-icon-library/ace/escratch1.png +3 -0
- public/2023-icon-library/ace/escratch2.png +3 -0
- public/2023-icon-library/ace/itch1.png +3 -0
- public/2023-icon-library/ace/itch2.png +3 -0
- public/2023-icon-library/ace/nerun1.png +3 -0
- public/2023-icon-library/ace/nerun2.png +3 -0
- public/2023-icon-library/ace/nrun1.png +3 -0
- public/2023-icon-library/ace/nrun2.png +3 -0
- public/2023-icon-library/ace/nscratch1.png +3 -0
- public/2023-icon-library/ace/nscratch2.png +3 -0
- public/2023-icon-library/ace/nwrun1.png +3 -0
- public/2023-icon-library/ace/nwrun2.png +3 -0
- public/2023-icon-library/ace/serun1.png +3 -0
- public/2023-icon-library/ace/serun2.png +3 -0
- public/2023-icon-library/ace/sleep1.png +3 -0
- public/2023-icon-library/ace/sleep2.png +3 -0
- public/2023-icon-library/ace/srun1.png +3 -0
- public/2023-icon-library/ace/srun2.png +3 -0
- public/2023-icon-library/ace/sscratch1.png +3 -0
- public/2023-icon-library/ace/sscratch2.png +3 -0
- public/2023-icon-library/ace/still.png +3 -0
- public/2023-icon-library/ace/swrun1.png +3 -0
- public/2023-icon-library/ace/swrun2.png +3 -0
- public/2023-icon-library/ace/wash.png +3 -0
- public/2023-icon-library/ace/wrun1.png +3 -0
- public/2023-icon-library/ace/wrun2.png +3 -0
- public/2023-icon-library/ace/wscratch1.png +3 -0
- public/2023-icon-library/ace/wscratch2.png +3 -0
- public/2023-icon-library/ace/yawn.png +3 -0
- public/2023-icon-library/air/alert.png +3 -0
- public/2023-icon-library/air/erun1.png +3 -0
- public/2023-icon-library/air/erun2.png +3 -0
- public/2023-icon-library/air/escratch1.png +3 -0
- public/2023-icon-library/air/escratch2.png +3 -0
- public/2023-icon-library/air/itch1.png +3 -0
- public/2023-icon-library/air/itch2.png +3 -0
- public/2023-icon-library/air/nerun1.png +3 -0
- public/2023-icon-library/air/nerun2.png +3 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,6 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
*.png filter=lfs diff=lfs merge=lfs -text
|
| 37 |
+
*.mp4 filter=lfs diff=lfs merge=lfs -text
|
| 38 |
+
*.mp3 filter=lfs diff=lfs merge=lfs -text
|
.gitignore
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.gradio
|
| 2 |
+
.vendor_cache
|
| 3 |
+
sozai_rooms.db
|
| 4 |
+
*.db-shm
|
| 5 |
+
*.db-wal
|
README.md
CHANGED
|
@@ -1,13 +1,17 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
sdk_version: 6.18.0
|
| 8 |
-
python_version: '3.
|
| 9 |
app_file: app.py
|
| 10 |
pinned: false
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
---
|
| 12 |
|
| 13 |
Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Sozai
|
| 3 |
+
emoji: 🎨
|
| 4 |
+
colorFrom: yellow
|
| 5 |
+
colorTo: blue
|
| 6 |
sdk: gradio
|
| 7 |
sdk_version: 6.18.0
|
| 8 |
+
python_version: '3.12'
|
| 9 |
app_file: app.py
|
| 10 |
pinned: false
|
| 11 |
+
preload_from_hub:
|
| 12 |
+
- openbmb/MiniCPM-V-4-gguf ggml-model-Q4_K_M.gguf,mmproj-model-f16.gguf
|
| 13 |
+
- Falconsai/nsfw_image_detection
|
| 14 |
+
- nvidia/nemotron-3.5-asr-streaming-0.6b
|
| 15 |
---
|
| 16 |
|
| 17 |
Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
|
app.py
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
assets/hummingbird.png
ADDED
|
Git LFS Details
|
darkroom_preview.html
ADDED
|
@@ -0,0 +1,605 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>Sozai · Darkroom preview</title>
|
| 7 |
+
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
| 8 |
+
<style type="text/tailwindcss">
|
| 9 |
+
@custom-variant dark (&:is(.dark *));
|
| 10 |
+
@theme inline {
|
| 11 |
+
--font-heading: var(--font-sans);
|
| 12 |
+
--font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
| 13 |
+
--font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
| 14 |
+
--color-sidebar-ring: var(--sidebar-ring);
|
| 15 |
+
--color-sidebar-border: var(--sidebar-border);
|
| 16 |
+
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
| 17 |
+
--color-sidebar-accent: var(--sidebar-accent);
|
| 18 |
+
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
| 19 |
+
--color-sidebar-primary: var(--sidebar-primary);
|
| 20 |
+
--color-sidebar-foreground: var(--sidebar-foreground);
|
| 21 |
+
--color-sidebar: var(--sidebar);
|
| 22 |
+
--color-chart-5: var(--chart-5);
|
| 23 |
+
--color-chart-4: var(--chart-4);
|
| 24 |
+
--color-chart-3: var(--chart-3);
|
| 25 |
+
--color-chart-2: var(--chart-2);
|
| 26 |
+
--color-chart-1: var(--chart-1);
|
| 27 |
+
--color-ring: var(--ring);
|
| 28 |
+
--color-input: var(--input);
|
| 29 |
+
--color-border: var(--border);
|
| 30 |
+
--color-destructive: var(--destructive);
|
| 31 |
+
--color-accent-foreground: var(--accent-foreground);
|
| 32 |
+
--color-accent: var(--accent);
|
| 33 |
+
--color-muted-foreground: var(--muted-foreground);
|
| 34 |
+
--color-muted: var(--muted);
|
| 35 |
+
--color-secondary-foreground: var(--secondary-foreground);
|
| 36 |
+
--color-secondary: var(--secondary);
|
| 37 |
+
--color-primary-foreground: var(--primary-foreground);
|
| 38 |
+
--color-primary: var(--primary);
|
| 39 |
+
--color-popover-foreground: var(--popover-foreground);
|
| 40 |
+
--color-popover: var(--popover);
|
| 41 |
+
--color-card-foreground: var(--card-foreground);
|
| 42 |
+
--color-card: var(--card);
|
| 43 |
+
--color-foreground: var(--foreground);
|
| 44 |
+
--color-background: var(--background);
|
| 45 |
+
--radius-sm: calc(var(--radius) * 0.6);
|
| 46 |
+
--radius-md: calc(var(--radius) * 0.8);
|
| 47 |
+
--radius-lg: var(--radius);
|
| 48 |
+
--radius-xl: calc(var(--radius) * 1.4);
|
| 49 |
+
--radius-2xl: calc(var(--radius) * 1.8);
|
| 50 |
+
--radius-3xl: calc(var(--radius) * 2.2);
|
| 51 |
+
--radius-4xl: calc(var(--radius) * 2.6);
|
| 52 |
+
}
|
| 53 |
+
:root {
|
| 54 |
+
--background: oklch(0.97 0.012 85);
|
| 55 |
+
--foreground: oklch(0.28 0.05 264);
|
| 56 |
+
--card: oklch(0.985 0.008 85);
|
| 57 |
+
--card-foreground: oklch(0.28 0.05 264);
|
| 58 |
+
--popover: oklch(0.985 0.008 85);
|
| 59 |
+
--popover-foreground: oklch(0.28 0.05 264);
|
| 60 |
+
--primary: oklch(0.5 0.21 268);
|
| 61 |
+
--primary-foreground: oklch(0.98 0.01 85);
|
| 62 |
+
--secondary: oklch(0.93 0.015 85);
|
| 63 |
+
--secondary-foreground: oklch(0.32 0.06 264);
|
| 64 |
+
--muted: oklch(0.93 0.012 85);
|
| 65 |
+
--muted-foreground: oklch(0.5 0.04 264);
|
| 66 |
+
--accent: oklch(0.9 0.03 264);
|
| 67 |
+
--accent-foreground: oklch(0.32 0.08 264);
|
| 68 |
+
--destructive: oklch(0.577 0.245 27.325);
|
| 69 |
+
--border: oklch(0.85 0.02 264);
|
| 70 |
+
--input: oklch(0.88 0.018 264);
|
| 71 |
+
--ring: oklch(0.5 0.21 268);
|
| 72 |
+
--chart-1: oklch(0.87 0 0); --chart-2: oklch(0.556 0 0); --chart-3: oklch(0.439 0 0);
|
| 73 |
+
--chart-4: oklch(0.371 0 0); --chart-5: oklch(0.269 0 0);
|
| 74 |
+
--radius: 0.625rem;
|
| 75 |
+
--sidebar: oklch(0.985 0 0); --sidebar-foreground: oklch(0.145 0 0);
|
| 76 |
+
--sidebar-primary: oklch(0.205 0 0); --sidebar-primary-foreground: oklch(0.985 0 0);
|
| 77 |
+
--sidebar-accent: oklch(0.97 0 0); --sidebar-accent-foreground: oklch(0.205 0 0);
|
| 78 |
+
--sidebar-border: oklch(0.922 0 0); --sidebar-ring: oklch(0.708 0 0);
|
| 79 |
+
}
|
| 80 |
+
.dark {
|
| 81 |
+
--background: oklch(0.145 0 0); --foreground: oklch(0.985 0 0);
|
| 82 |
+
--card: oklch(0.205 0 0); --card-foreground: oklch(0.985 0 0);
|
| 83 |
+
--popover: oklch(0.205 0 0); --popover-foreground: oklch(0.985 0 0);
|
| 84 |
+
--primary: oklch(0.922 0 0); --primary-foreground: oklch(0.205 0 0);
|
| 85 |
+
--secondary: oklch(0.269 0 0); --secondary-foreground: oklch(0.985 0 0);
|
| 86 |
+
--muted: oklch(0.269 0 0); --muted-foreground: oklch(0.708 0 0);
|
| 87 |
+
--accent: oklch(0.269 0 0); --accent-foreground: oklch(0.985 0 0);
|
| 88 |
+
--destructive: oklch(0.704 0.191 22.216);
|
| 89 |
+
--border: oklch(1 0 0 / 10%); --input: oklch(1 0 0 / 15%); --ring: oklch(0.556 0 0);
|
| 90 |
+
}
|
| 91 |
+
@layer base {
|
| 92 |
+
* { @apply border-border outline-ring/50; }
|
| 93 |
+
body { @apply bg-background text-foreground; }
|
| 94 |
+
html { @apply font-sans; }
|
| 95 |
+
}
|
| 96 |
+
</style>
|
| 97 |
+
<script src="https://unpkg.com/react@18/umd/react.production.min.js" crossorigin></script>
|
| 98 |
+
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js" crossorigin></script>
|
| 99 |
+
<script src="https://unpkg.com/@babel/standalone/babel.min.js" crossorigin></script>
|
| 100 |
+
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.js" crossorigin></script>
|
| 101 |
+
</head>
|
| 102 |
+
<body class="bg-[oklch(0.1_0.02_25)] antialiased">
|
| 103 |
+
<div class="mx-auto max-w-6xl px-4 py-8">
|
| 104 |
+
<p class="mb-4 text-center font-mono text-xs uppercase tracking-widest text-[oklch(0.7_0.08_25)]">
|
| 105 |
+
Standalone preview — top-down baths, then drag a print left/right to shake it developed
|
| 106 |
+
</p>
|
| 107 |
+
<div id="root"></div>
|
| 108 |
+
</div>
|
| 109 |
+
<script type="text/babel" data-presets="react">
|
| 110 |
+
const { useState, useEffect, useRef, useMemo, useCallback } = React;
|
| 111 |
+
function cn(){ return Array.prototype.slice.call(arguments).flat(Infinity).filter(Boolean).join(" "); }
|
| 112 |
+
const _L = window.lucide || {};
|
| 113 |
+
function iconNode(name){ return _L[name] || (_L.icons && _L.icons[name]) || null; }
|
| 114 |
+
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; }
|
| 115 |
+
function Icon({ name, className="", ...rest }){
|
| 116 |
+
const node = iconNode(name);
|
| 117 |
+
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;
|
| 118 |
+
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>);
|
| 119 |
+
}
|
| 120 |
+
const mk = (n) => (props) => <Icon name={n} {...props} />;
|
| 121 |
+
const Sparkles = mk("Sparkles"), ArrowRight = mk("ArrowRight");
|
| 122 |
+
|
| 123 |
+
// ----------------------------------------------------------------- //
|
| 124 |
+
// components/develop/* (ported from storybook_framer.zip)
|
| 125 |
+
// Red-safelight Darkroom with two phases:
|
| 126 |
+
// 1) chemical baths -> REWORKED to a top-down view where each real
|
| 127 |
+
// print travels tray-to-tray one by one, dipping with water ripples
|
| 128 |
+
// and synthesized dip/lift water SFX (Web Audio, no asset files).
|
| 129 |
+
// 2) hang & shake-to-reveal -> faithful port of HangingPhoto.
|
| 130 |
+
// ----------------------------------------------------------------- //
|
| 131 |
+
const Droplet = mk("Droplet"), Volume2 = mk("Volume2"), VolumeX = mk("VolumeX"),
|
| 132 |
+
FastForward = mk("FastForward");
|
| 133 |
+
|
| 134 |
+
const DR_REDUCED = typeof window !== "undefined" && window.matchMedia
|
| 135 |
+
&& window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
| 136 |
+
|
| 137 |
+
// baths kept from the zip (colors + labels), seen from above
|
| 138 |
+
const DR_BATHS = [
|
| 139 |
+
{ id: "developer", label: "Developer", color: "oklch(0.45 0.14 150)", edge: "oklch(0.32 0.1 150)" },
|
| 140 |
+
{ id: "stop", label: "Stop Bath", color: "oklch(0.5 0.12 90)", edge: "oklch(0.36 0.09 90)" },
|
| 141 |
+
{ id: "fixer", label: "Fixer", color: "oklch(0.5 0.1 250)", edge: "oklch(0.36 0.08 250)" },
|
| 142 |
+
{ id: "wash", label: "Wash", color: "oklch(0.55 0.08 220)", edge: "oklch(0.4 0.06 220)" },
|
| 143 |
+
];
|
| 144 |
+
// top-down layout (percent within the bench)
|
| 145 |
+
const DR_TRAY_X = [21, 40, 59, 78];
|
| 146 |
+
const DR_SURFACE_Y = 54, DR_ABOVE_Y = 30, DR_QUEUE_X = 7, DR_EXIT_X = 94;
|
| 147 |
+
|
| 148 |
+
// prints stay latent in the baths; full colour is the shake payoff on the line
|
| 149 |
+
function drBathFilter(p) {
|
| 150 |
+
const blur = (1 - Math.min(1, p * 2.4)) * 4;
|
| 151 |
+
const gray = (1 - p) * 100;
|
| 152 |
+
const bright = 0.5 + p * 0.55;
|
| 153 |
+
const sep = (1 - p) * 32;
|
| 154 |
+
return "blur(" + blur.toFixed(2) + "px) grayscale(" + gray.toFixed(0) + "%) brightness(" + bright.toFixed(2) + ") sepia(" + sep.toFixed(0) + "%)";
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
function drLock() {
|
| 158 |
+
let busy = false; const q = [];
|
| 159 |
+
return {
|
| 160 |
+
acquire() { return new Promise((res) => { if (!busy) { busy = true; res(); } else q.push(res); }); },
|
| 161 |
+
release() { const n = q.shift(); if (n) n(); else busy = false; },
|
| 162 |
+
};
|
| 163 |
+
}
|
| 164 |
+
const drSleep = (ms) => new Promise((r) => setTimeout(r, DR_REDUCED ? Math.min(ms, 120) : ms));
|
| 165 |
+
|
| 166 |
+
// ---- synthesized water SFX (Web Audio; no audio files needed) ------ //
|
| 167 |
+
function useWaterSfx(muted) {
|
| 168 |
+
const ref = useRef(null);
|
| 169 |
+
const mutedRef = useRef(muted);
|
| 170 |
+
function engine() {
|
| 171 |
+
let a = ref.current;
|
| 172 |
+
if (!a) {
|
| 173 |
+
const AC = window.AudioContext || window.webkitAudioContext;
|
| 174 |
+
if (!AC) return null;
|
| 175 |
+
const ac = new AC();
|
| 176 |
+
const master = ac.createGain();
|
| 177 |
+
master.gain.value = mutedRef.current ? 0 : 0.6;
|
| 178 |
+
master.connect(ac.destination);
|
| 179 |
+
a = ref.current = { ac, master };
|
| 180 |
+
}
|
| 181 |
+
if (a.ac.state === "suspended") a.ac.resume();
|
| 182 |
+
return a;
|
| 183 |
+
}
|
| 184 |
+
useEffect(() => {
|
| 185 |
+
mutedRef.current = muted;
|
| 186 |
+
if (ref.current) ref.current.master.gain.value = muted ? 0 : 0.6;
|
| 187 |
+
}, [muted]);
|
| 188 |
+
function noise(ac, dur) {
|
| 189 |
+
const n = Math.max(1, (ac.sampleRate * dur) | 0);
|
| 190 |
+
const b = ac.createBuffer(1, n, ac.sampleRate);
|
| 191 |
+
const d = b.getChannelData(0);
|
| 192 |
+
for (let i = 0; i < n; i++) d[i] = Math.random() * 2 - 1;
|
| 193 |
+
return b;
|
| 194 |
+
}
|
| 195 |
+
const dip = useCallback(() => { // splash going in
|
| 196 |
+
if (mutedRef.current) return;
|
| 197 |
+
const a = engine(); if (!a) return;
|
| 198 |
+
const ac = a.ac, t = ac.currentTime;
|
| 199 |
+
const s = ac.createBufferSource(); s.buffer = noise(ac, 0.45);
|
| 200 |
+
const bp = ac.createBiquadFilter(); bp.type = "bandpass";
|
| 201 |
+
bp.frequency.setValueAtTime(1900, t);
|
| 202 |
+
bp.frequency.exponentialRampToValueAtTime(320, t + 0.4); bp.Q.value = 0.7;
|
| 203 |
+
const g = ac.createGain();
|
| 204 |
+
g.gain.setValueAtTime(0.0001, t);
|
| 205 |
+
g.gain.exponentialRampToValueAtTime(0.55, t + 0.02);
|
| 206 |
+
g.gain.exponentialRampToValueAtTime(0.001, t + 0.45);
|
| 207 |
+
s.connect(bp); bp.connect(g); g.connect(a.master); s.start(t); s.stop(t + 0.46);
|
| 208 |
+
const o = ac.createOscillator(); o.type = "sine";
|
| 209 |
+
o.frequency.setValueAtTime(240, t);
|
| 210 |
+
o.frequency.exponentialRampToValueAtTime(85, t + 0.2);
|
| 211 |
+
const og = ac.createGain();
|
| 212 |
+
og.gain.setValueAtTime(0.45, t);
|
| 213 |
+
og.gain.exponentialRampToValueAtTime(0.001, t + 0.28);
|
| 214 |
+
o.connect(og); og.connect(a.master); o.start(t); o.stop(t + 0.3);
|
| 215 |
+
}, []);
|
| 216 |
+
const drip = useCallback(() => { // drips lifting out
|
| 217 |
+
if (mutedRef.current) return;
|
| 218 |
+
const a = engine(); if (!a) return;
|
| 219 |
+
const ac = a.ac, base = ac.currentTime, n = 3 + ((Math.random() * 3) | 0);
|
| 220 |
+
for (let i = 0; i < n; i++) {
|
| 221 |
+
const t = base + i * (0.06 + Math.random() * 0.09);
|
| 222 |
+
const o = ac.createOscillator(); o.type = "sine";
|
| 223 |
+
const f = 850 + Math.random() * 1300;
|
| 224 |
+
o.frequency.setValueAtTime(f, t);
|
| 225 |
+
o.frequency.exponentialRampToValueAtTime(f * 0.4, t + 0.1);
|
| 226 |
+
const g = ac.createGain();
|
| 227 |
+
g.gain.setValueAtTime(0.0001, t);
|
| 228 |
+
g.gain.exponentialRampToValueAtTime(0.28, t + 0.005);
|
| 229 |
+
g.gain.exponentialRampToValueAtTime(0.0006, t + 0.13);
|
| 230 |
+
o.connect(g); g.connect(a.master); o.start(t); o.stop(t + 0.15);
|
| 231 |
+
}
|
| 232 |
+
}, []);
|
| 233 |
+
const prime = useCallback(() => { engine(); }, []);
|
| 234 |
+
return { dip, drip, prime };
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
// ---- phase 1: top-down chemical baths ------------------------------ //
|
| 238 |
+
function TopDownBaths({ photos, onComplete, sfx }) {
|
| 239 |
+
const list = (photos && photos.length ? photos : []);
|
| 240 |
+
const [sheets, setSheets] = useState(() => list.map((p, i) => ({
|
| 241 |
+
id: p.id || ("dr" + i), src: p.src || null, alt: p.alt || "",
|
| 242 |
+
x: DR_QUEUE_X, y: DR_SURFACE_Y - 2 - i * 1.4, rot: -4 + i * 1.5, scale: 0.86,
|
| 243 |
+
submerged: false, level: 0, tint: null, z: i, opacity: 1,
|
| 244 |
+
})));
|
| 245 |
+
const [ripples, setRipples] = useState([]);
|
| 246 |
+
const [started, setStarted] = useState(false);
|
| 247 |
+
const [done, setDone] = useState(0);
|
| 248 |
+
const alive = useRef(true);
|
| 249 |
+
const total = list.length;
|
| 250 |
+
useEffect(() => () => { alive.current = false; }, []);
|
| 251 |
+
|
| 252 |
+
const patch = useCallback((id, p) => {
|
| 253 |
+
setSheets((prev) => prev.map((s) => (s.id === id ? { ...s, ...p } : s)));
|
| 254 |
+
}, []);
|
| 255 |
+
const addRipple = useCallback((x, y, color) => {
|
| 256 |
+
if (DR_REDUCED) return;
|
| 257 |
+
const rid = "rp" + Math.random().toString(36).slice(2);
|
| 258 |
+
setRipples((r) => [...r, { id: rid, x, y, color }]);
|
| 259 |
+
setTimeout(() => { if (alive.current) setRipples((r) => r.filter((p) => p.id !== rid)); }, 1300);
|
| 260 |
+
}, []);
|
| 261 |
+
|
| 262 |
+
useEffect(() => {
|
| 263 |
+
if (!started) return;
|
| 264 |
+
alive.current = true;
|
| 265 |
+
const locks = DR_BATHS.map(() => drLock());
|
| 266 |
+
let cancelled = false;
|
| 267 |
+
|
| 268 |
+
async function runSheet(sheet, idx) {
|
| 269 |
+
await drSleep(idx * 620);
|
| 270 |
+
if (cancelled) return;
|
| 271 |
+
patch(sheet.id, { z: 100 + idx, scale: 1 });
|
| 272 |
+
for (let b = 0; b < DR_BATHS.length; b++) {
|
| 273 |
+
const bath = DR_BATHS[b];
|
| 274 |
+
await locks[b].acquire();
|
| 275 |
+
if (cancelled || !alive.current) { locks[b].release(); return; }
|
| 276 |
+
patch(sheet.id, { x: DR_TRAY_X[b], y: DR_ABOVE_Y, rot: b % 2 ? 2 : -2 });
|
| 277 |
+
await drSleep(640);
|
| 278 |
+
sfx.dip();
|
| 279 |
+
addRipple(DR_TRAY_X[b], DR_SURFACE_Y, bath.color);
|
| 280 |
+
setTimeout(() => addRipple(DR_TRAY_X[b], DR_SURFACE_Y, bath.color), 150);
|
| 281 |
+
patch(sheet.id, { y: DR_SURFACE_Y, submerged: true, tint: bath.color, level: (b + 1) / (DR_BATHS.length + 2) });
|
| 282 |
+
await drSleep(1150);
|
| 283 |
+
patch(sheet.id, { y: DR_ABOVE_Y, submerged: false, tint: null });
|
| 284 |
+
sfx.drip();
|
| 285 |
+
await drSleep(560);
|
| 286 |
+
locks[b].release();
|
| 287 |
+
}
|
| 288 |
+
// glide off toward the drying line and fade
|
| 289 |
+
patch(sheet.id, { x: DR_EXIT_X, y: DR_ABOVE_Y - 6, rot: 6, scale: 0.7, opacity: 0 });
|
| 290 |
+
await drSleep(620);
|
| 291 |
+
if (alive.current && !cancelled) setDone((c) => c + 1);
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
(async () => {
|
| 295 |
+
sfx.prime();
|
| 296 |
+
await Promise.all(sheets.map((s, i) => runSheet(s, i)));
|
| 297 |
+
if (!cancelled && alive.current) setTimeout(() => { if (alive.current) onComplete(); }, 650);
|
| 298 |
+
})();
|
| 299 |
+
return () => { cancelled = true; };
|
| 300 |
+
}, [started]); // eslint-disable-line
|
| 301 |
+
|
| 302 |
+
const tr = DR_REDUCED ? "all .16s linear"
|
| 303 |
+
: "left .8s cubic-bezier(.4,.1,.2,1), top .62s cubic-bezier(.5,0,.3,1), transform .8s cubic-bezier(.4,.1,.2,1), opacity .55s ease";
|
| 304 |
+
|
| 305 |
+
return (
|
| 306 |
+
<div className="flex w-full flex-col items-center gap-5 py-2">
|
| 307 |
+
<div className="text-center">
|
| 308 |
+
<p className="font-mono text-xs uppercase tracking-[0.3em] text-[oklch(0.78_0.12_25)]">
|
| 309 |
+
{started ? "Bathing " + total + " frames" : total + " frames exposed"}
|
| 310 |
+
</p>
|
| 311 |
+
<h2 className="mt-1 font-mono text-lg font-semibold uppercase tracking-widest text-[oklch(0.9_0.08_25)]">
|
| 312 |
+
{started ? (done >= total ? "Negatives fixed" : done + " / " + total + " through the baths") : "Ready to develop"}
|
| 313 |
+
</h2>
|
| 314 |
+
</div>
|
| 315 |
+
|
| 316 |
+
{/* the bench, viewed from above */}
|
| 317 |
+
<div className="relative w-full max-w-4xl overflow-hidden rounded-lg border-2 border-[oklch(0.4_0.05_25)]"
|
| 318 |
+
style={{ aspectRatio: "1100 / 420",
|
| 319 |
+
background: "radial-gradient(120% 100% at 50% 0%, oklch(0.24 0.06 25), oklch(0.12 0.03 25) 70%)",
|
| 320 |
+
boxShadow: "inset 0 0 70px rgba(0,0,0,0.6)" }}>
|
| 321 |
+
|
| 322 |
+
{/* exposed-stack label */}
|
| 323 |
+
<span className="absolute font-mono text-[10px] uppercase tracking-widest"
|
| 324 |
+
style={{ left: DR_QUEUE_X + "%", top: (DR_SURFACE_Y + 24) + "%", transform: "translate(-50%,0)", color: "oklch(0.72 0.1 25)" }}>
|
| 325 |
+
Exposed
|
| 326 |
+
</span>
|
| 327 |
+
<span className="absolute font-mono text-[10px] uppercase tracking-widest"
|
| 328 |
+
style={{ left: DR_EXIT_X + "%", top: (DR_ABOVE_Y + 4) + "%", transform: "translate(-50%,0)", color: "oklch(0.72 0.1 25)" }}>
|
| 329 |
+
To the line
|
| 330 |
+
</span>
|
| 331 |
+
|
| 332 |
+
{/* trays */}
|
| 333 |
+
{DR_BATHS.map((b, i) => (
|
| 334 |
+
<div key={b.id} style={{ position: "absolute", left: DR_TRAY_X[i] + "%", top: DR_SURFACE_Y + "%", width: "17%", transform: "translate(-50%,-50%)" }}>
|
| 335 |
+
<div className="relative rounded-[10px] border-2 border-[oklch(0.42_0.06_25)] p-1.5"
|
| 336 |
+
style={{ aspectRatio: "5 / 4", background: "oklch(0.16 0.03 25)", boxShadow: "0 6px 16px rgba(0,0,0,0.5)" }}>
|
| 337 |
+
<div className="relative h-full w-full overflow-hidden rounded-[6px]"
|
| 338 |
+
style={{ background: "radial-gradient(120% 120% at 50% 0%, " + b.color + ", " + b.edge + ")" }}>
|
| 339 |
+
<div className="absolute inset-0" style={{ filter: "url(#dr-ripple)",
|
| 340 |
+
background: "linear-gradient(180deg, rgba(255,255,255,0.20), rgba(255,255,255,0) 45%)" }} />
|
| 341 |
+
<div className="absolute inset-0" style={{ boxShadow: "inset 0 0 24px rgba(0,0,0,0.5)" }} />
|
| 342 |
+
</div>
|
| 343 |
+
</div>
|
| 344 |
+
<p className="mt-1 text-center font-mono text-[10px] uppercase tracking-wider text-[oklch(0.78_0.08_25)]">{b.label}</p>
|
| 345 |
+
</div>
|
| 346 |
+
))}
|
| 347 |
+
|
| 348 |
+
{/* ripple rings */}
|
| 349 |
+
{ripples.map((r) => (
|
| 350 |
+
<span key={r.id} className="pointer-events-none absolute rounded-full"
|
| 351 |
+
style={{ left: r.x + "%", top: r.y + "%", width: "13%", aspectRatio: "1",
|
| 352 |
+
border: "2px solid " + (r.color || "#fff"), animation: "dr-ring 1.2s ease-out forwards", mixBlendMode: "screen" }} />
|
| 353 |
+
))}
|
| 354 |
+
|
| 355 |
+
{/* prints */}
|
| 356 |
+
{sheets.map((s) => {
|
| 357 |
+
const bf = drBathFilter(s.level);
|
| 358 |
+
const filt = s.submerged ? ("url(#dr-ripple) " + bf) : bf;
|
| 359 |
+
return (
|
| 360 |
+
<div key={s.id} style={{ position: "absolute", left: s.x + "%", top: s.y + "%", width: "11%",
|
| 361 |
+
transform: "translate(-50%,-50%) rotate(" + s.rot + "deg) scale(" + s.scale + ")",
|
| 362 |
+
zIndex: s.z, opacity: s.opacity, transition: tr }}>
|
| 363 |
+
<div className="relative rounded-sm bg-[oklch(0.99_0.01_85)] p-1 shadow-xl">
|
| 364 |
+
<div className="relative aspect-[4/5] overflow-hidden rounded-[2px] bg-[oklch(0.2_0.02_60)]"
|
| 365 |
+
style={{ filter: filt, transition: "filter 1.1s ease" }}>
|
| 366 |
+
{s.src ? (
|
| 367 |
+
<img src={s.src} alt="" draggable={false} className="absolute inset-0 h-full w-full object-cover"
|
| 368 |
+
onError={(e) => { e.currentTarget.style.display = "none"; }} />
|
| 369 |
+
) : null}
|
| 370 |
+
{/* red emulsion haze while latent */}
|
| 371 |
+
<div className="pointer-events-none absolute inset-0"
|
| 372 |
+
style={{ opacity: 0.8 * (1 - s.level * 2.2 > 0 ? 1 - s.level * 2.2 : 0),
|
| 373 |
+
background: "radial-gradient(circle at 50% 40%, oklch(0.35 0.12 25 / 0.6), oklch(0.18 0.06 25 / 0.85))" }} />
|
| 374 |
+
</div>
|
| 375 |
+
{s.submerged ? (
|
| 376 |
+
<div className="pointer-events-none absolute inset-1 rounded-[2px]"
|
| 377 |
+
style={{ background: s.tint, opacity: 0.4, mixBlendMode: "multiply" }} />
|
| 378 |
+
) : null}
|
| 379 |
+
</div>
|
| 380 |
+
</div>
|
| 381 |
+
);
|
| 382 |
+
})}
|
| 383 |
+
|
| 384 |
+
{/* start overlay (primes audio on the click gesture) */}
|
| 385 |
+
{!started ? (
|
| 386 |
+
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4"
|
| 387 |
+
style={{ background: "oklch(0.1 0.03 25 / 0.74)", backdropFilter: "blur(1px)" }}>
|
| 388 |
+
<button type="button" onClick={() => { sfx.prime(); setStarted(true); }}
|
| 389 |
+
className="inline-flex items-center gap-2 rounded-full border-2 border-[oklch(0.85_0.12_25)] bg-[oklch(0.35_0.14_25)] px-6 py-2.5 font-mono text-sm uppercase tracking-widest text-[oklch(0.95_0.05_85)] transition-colors hover:bg-[oklch(0.42_0.16_25)]">
|
| 390 |
+
<Droplet className="size-4" /> Start developing
|
| 391 |
+
</button>
|
| 392 |
+
</div>
|
| 393 |
+
) : null}
|
| 394 |
+
</div>
|
| 395 |
+
|
| 396 |
+
{/* progress dots, kept from the zip */}
|
| 397 |
+
<div className="flex items-center gap-2" role="status" aria-label="Development progress">
|
| 398 |
+
{list.map((p, i) => (
|
| 399 |
+
<span key={p.id || i} className="h-2 rounded-full transition-all duration-500"
|
| 400 |
+
style={{ width: i < done ? "1.5rem" : "0.5rem",
|
| 401 |
+
background: i < done ? "oklch(0.8 0.14 25)" : "oklch(0.4 0.05 25)" }} />
|
| 402 |
+
))}
|
| 403 |
+
</div>
|
| 404 |
+
</div>
|
| 405 |
+
);
|
| 406 |
+
}
|
| 407 |
+
|
| 408 |
+
// ---- phase 2: hanging print, shake to reveal (ported) -------------- //
|
| 409 |
+
const DR_SHAKES_TO_REVEAL = 8;
|
| 410 |
+
function HangingPrint({ photo, index, swayDelay, onRevealed }) {
|
| 411 |
+
const [progress, setProgress] = useState(0);
|
| 412 |
+
const [revealed, setRevealed] = useState(false);
|
| 413 |
+
const [dragging, setDragging] = useState(false);
|
| 414 |
+
const [tilt, setTilt] = useState(0);
|
| 415 |
+
const dragState = useRef({ active: false, lastX: 0, lastDir: 0, offset: 0 });
|
| 416 |
+
const progressRef = useRef(0);
|
| 417 |
+
|
| 418 |
+
useEffect(() => { if (revealed) onRevealed(photo.id); }, [revealed, onRevealed, photo.id]);
|
| 419 |
+
|
| 420 |
+
const registerShake = useCallback(() => {
|
| 421 |
+
const next = Math.min(1, progressRef.current + 1 / DR_SHAKES_TO_REVEAL);
|
| 422 |
+
progressRef.current = next; setProgress(next);
|
| 423 |
+
if (next >= 1) setRevealed(true);
|
| 424 |
+
}, []);
|
| 425 |
+
|
| 426 |
+
function onPointerDown(e) {
|
| 427 |
+
if (revealed) return;
|
| 428 |
+
try { e.currentTarget.setPointerCapture(e.pointerId); } catch (err) {}
|
| 429 |
+
dragState.current = { active: true, lastX: e.clientX, lastDir: 0, offset: 0 };
|
| 430 |
+
setDragging(true);
|
| 431 |
+
}
|
| 432 |
+
function onPointerMove(e) {
|
| 433 |
+
const s = dragState.current;
|
| 434 |
+
if (!s.active || revealed) return;
|
| 435 |
+
const dx = e.clientX - s.lastX;
|
| 436 |
+
if (Math.abs(dx) < 4) return;
|
| 437 |
+
const dir = dx > 0 ? 1 : -1;
|
| 438 |
+
if (s.lastDir !== 0 && dir !== s.lastDir) registerShake();
|
| 439 |
+
s.lastDir = dir; s.lastX = e.clientX;
|
| 440 |
+
s.offset = Math.max(-26, Math.min(26, s.offset + dx));
|
| 441 |
+
setTilt(s.offset);
|
| 442 |
+
}
|
| 443 |
+
function endDrag(e) {
|
| 444 |
+
dragState.current.active = false; setDragging(false); setTilt(0);
|
| 445 |
+
try { e.currentTarget.releasePointerCapture(e.pointerId); } catch (err) {}
|
| 446 |
+
}
|
| 447 |
+
|
| 448 |
+
const blurPx = (1 - progress) * 14;
|
| 449 |
+
const grayscale = (1 - progress) * 100;
|
| 450 |
+
const brightness = 0.35 + progress * 0.65;
|
| 451 |
+
const fogOpacity = (1 - progress) * 0.9;
|
| 452 |
+
|
| 453 |
+
return (
|
| 454 |
+
<li className="flex w-40 flex-col items-center sm:w-44">
|
| 455 |
+
<div className="relative z-10 h-4 w-3 rounded-sm bg-[oklch(0.45_0.06_60)] shadow-sm" aria-hidden="true" />
|
| 456 |
+
<figure
|
| 457 |
+
className={cn("group relative -mt-1 select-none rounded-sm bg-[oklch(0.99_0.01_85)] p-2 pb-9 shadow-xl",
|
| 458 |
+
dragging ? "" : "dr-sway", revealed ? "cursor-default" : "cursor-grab active:cursor-grabbing")}
|
| 459 |
+
style={{ animationDelay: swayDelay + "s",
|
| 460 |
+
transform: dragging ? "rotate(" + (tilt * 0.3) + "deg) translateX(" + (tilt * 0.4) + "px)" : undefined,
|
| 461 |
+
transition: dragging ? "none" : "transform 0.4s ease" }}
|
| 462 |
+
onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={endDrag} onPointerCancel={endDrag}>
|
| 463 |
+
<div className="relative aspect-[4/5] w-full overflow-hidden rounded-sm bg-[oklch(0.2_0.02_60)]">
|
| 464 |
+
<img src={photo.src || "/assets/placeholder.svg"} alt={revealed ? photo.alt : ""} draggable={false}
|
| 465 |
+
className="h-full w-full object-cover transition-[filter] duration-200"
|
| 466 |
+
style={{ filter: "blur(" + blurPx + "px) grayscale(" + grayscale + "%) brightness(" + brightness + ") sepia(" + ((1 - progress) * 40) + "%)" }} />
|
| 467 |
+
<div className="pointer-events-none absolute inset-0 transition-opacity duration-200"
|
| 468 |
+
style={{ opacity: fogOpacity,
|
| 469 |
+
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" />
|
| 470 |
+
{!revealed ? (
|
| 471 |
+
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
| 472 |
+
<span className="rounded-full bg-[oklch(0.15_0.04_25/0.7)] px-3 py-1 font-mono text-[11px] uppercase tracking-wider text-[oklch(0.95_0.05_25)]">
|
| 473 |
+
{progress > 0 ? "Developing…" : "Shake me"}
|
| 474 |
+
</span>
|
| 475 |
+
</div>
|
| 476 |
+
) : (
|
| 477 |
+
<div className="pointer-events-none absolute right-1.5 top-1.5 text-[oklch(0.95_0.05_85)] drop-shadow">
|
| 478 |
+
<Sparkles className="size-4" aria-hidden="true" />
|
| 479 |
+
</div>
|
| 480 |
+
)}
|
| 481 |
+
</div>
|
| 482 |
+
<figcaption className="absolute inset-x-0 bottom-2 line-clamp-2 px-2 text-center font-mono text-[11px] leading-tight text-[oklch(0.3_0.04_264)]">
|
| 483 |
+
{revealed ? photo.defaultCaption : ("Frame " + String(index + 1).padStart(2, "0"))}
|
| 484 |
+
</figcaption>
|
| 485 |
+
</figure>
|
| 486 |
+
</li>
|
| 487 |
+
);
|
| 488 |
+
}
|
| 489 |
+
|
| 490 |
+
// ---- the Darkroom shell -------------------------------------------- //
|
| 491 |
+
function DarkroomDeveloper({ photos, onDone, onSkip }) {
|
| 492 |
+
const list = (photos && photos.length ? photos : []);
|
| 493 |
+
const [phase, setPhase] = useState("baths");
|
| 494 |
+
const [revealed, setRevealed] = useState(() => new Set());
|
| 495 |
+
const [muted, setMuted] = useState(false);
|
| 496 |
+
const sfx = useWaterSfx(muted);
|
| 497 |
+
const allRevealed = revealed.size === list.length;
|
| 498 |
+
|
| 499 |
+
const handleRevealed = useCallback((id) => {
|
| 500 |
+
setRevealed((prev) => { const next = new Set(prev); next.add(id); return next; });
|
| 501 |
+
}, []);
|
| 502 |
+
|
| 503 |
+
return (
|
| 504 |
+
<section className="relative w-full max-w-6xl overflow-hidden rounded-xl border-2 border-[oklch(0.4_0.1_25)] bg-[oklch(0.16_0.04_25)] shadow-2xl">
|
| 505 |
+
<style dangerouslySetInnerHTML={{ __html:
|
| 506 |
+
"@keyframes dr-safelight{0%,100%{opacity:.85}45%{opacity:.78}55%{opacity:.92}70%{opacity:.82}}" +
|
| 507 |
+
"@keyframes dr-ring{from{transform:translate(-50%,-50%) scale(.2);opacity:.7}to{transform:translate(-50%,-50%) scale(1.7);opacity:0}}" +
|
| 508 |
+
"@keyframes dr-sway-kf{0%,100%{transform:rotate(-1.5deg)}50%{transform:rotate(1.5deg)}}" +
|
| 509 |
+
"@keyframes dr-fade{from{opacity:0}to{opacity:1}}" +
|
| 510 |
+
".dr-flicker{animation:dr-safelight 5s ease-in-out infinite}" +
|
| 511 |
+
".dr-sway{animation:dr-sway-kf 4s ease-in-out infinite;transform-origin:top center}" +
|
| 512 |
+
"@media (prefers-reduced-motion: reduce){.dr-flicker,.dr-sway{animation:none}}"
|
| 513 |
+
}} />
|
| 514 |
+
|
| 515 |
+
{/* hidden filter that ripples the liquid + submerged prints */}
|
| 516 |
+
<svg aria-hidden="true" style={{ position: "absolute", width: 0, height: 0 }}>
|
| 517 |
+
<filter id="dr-ripple" x="-10%" y="-10%" width="120%" height="120%">
|
| 518 |
+
<feTurbulence type="fractalNoise" baseFrequency="0.012 0.02" numOctaves="2" seed="7" result="noise">
|
| 519 |
+
<animate attributeName="baseFrequency" dur="14s"
|
| 520 |
+
values="0.012 0.02;0.016 0.014;0.01 0.022;0.012 0.02" repeatCount="indefinite" />
|
| 521 |
+
</feTurbulence>
|
| 522 |
+
<feDisplacementMap in="SourceGraphic" in2="noise" scale="7" xChannelSelector="R" yChannelSelector="G" />
|
| 523 |
+
</filter>
|
| 524 |
+
</svg>
|
| 525 |
+
|
| 526 |
+
{/* red safelight glow */}
|
| 527 |
+
<div className="dr-flicker pointer-events-none absolute inset-0" aria-hidden="true"
|
| 528 |
+
style={{ background:
|
| 529 |
+
"radial-gradient(120% 90% at 50% 0%, oklch(0.4 0.18 25 / 0.55), transparent 60%), radial-gradient(100% 80% at 50% 120%, oklch(0.3 0.14 25 / 0.5), transparent 55%)" }} />
|
| 530 |
+
|
| 531 |
+
<div className="relative px-5 py-8 sm:px-8">
|
| 532 |
+
<header className="mb-6 flex items-start justify-between gap-4">
|
| 533 |
+
<div className="min-w-0 flex-1 text-center">
|
| 534 |
+
<h1 className="font-mono text-base font-semibold uppercase tracking-[0.3em] text-[oklch(0.88_0.1_25)]">Darkroom</h1>
|
| 535 |
+
<p className="mt-1 font-mono text-xs text-[oklch(0.7_0.08_25)]">
|
| 536 |
+
{phase === "baths"
|
| 537 |
+
? "Running each print through the chemical baths"
|
| 538 |
+
: allRevealed ? "All prints developed"
|
| 539 |
+
: "Shake each print to reveal it — " + revealed.size + "/" + list.length + " done"}
|
| 540 |
+
</p>
|
| 541 |
+
</div>
|
| 542 |
+
<div className="flex shrink-0 items-center gap-2">
|
| 543 |
+
<button type="button" onClick={() => setMuted((m) => !m)} aria-pressed={muted}
|
| 544 |
+
aria-label={muted ? "Unmute water sounds" : "Mute water sounds"}
|
| 545 |
+
className="flex size-9 items-center justify-center rounded-md border-2 border-[oklch(0.45_0.08_25)] text-[oklch(0.85_0.1_25)] transition-colors hover:bg-[oklch(0.28_0.08_25)]">
|
| 546 |
+
{muted ? <VolumeX className="size-4" /> : <Volume2 className="size-4" />}
|
| 547 |
+
</button>
|
| 548 |
+
<button type="button" onClick={() => onSkip && onSkip()}
|
| 549 |
+
className="flex items-center gap-1.5 rounded-md border-2 border-[oklch(0.45_0.08_25)] px-2.5 py-1.5 font-mono text-[11px] uppercase tracking-wide text-[oklch(0.85_0.1_25)] transition-colors hover:bg-[oklch(0.28_0.08_25)]">
|
| 550 |
+
<FastForward className="size-3.5" /> Skip
|
| 551 |
+
</button>
|
| 552 |
+
</div>
|
| 553 |
+
</header>
|
| 554 |
+
|
| 555 |
+
{phase === "baths" ? (
|
| 556 |
+
<TopDownBaths photos={list} sfx={sfx} onComplete={() => setPhase("hang")} />
|
| 557 |
+
) : (
|
| 558 |
+
<div className="flex flex-col items-center">
|
| 559 |
+
<div className="relative w-full">
|
| 560 |
+
<div className="absolute inset-x-0 top-2 h-[2px] bg-[oklch(0.6_0.05_60)]" aria-hidden="true" />
|
| 561 |
+
<ul className="relative flex flex-wrap items-start justify-center gap-6 pt-2 sm:gap-8">
|
| 562 |
+
{list.map((photo, i) => (
|
| 563 |
+
<HangingPrint key={photo.id} photo={photo} index={i} swayDelay={i * 0.4} onRevealed={handleRevealed} />
|
| 564 |
+
))}
|
| 565 |
+
</ul>
|
| 566 |
+
</div>
|
| 567 |
+
<button type="button" onClick={() => onDone && onDone()} disabled={!allRevealed}
|
| 568 |
+
className={cn("mt-10 inline-flex items-center gap-2 rounded-full border-2 px-6 py-2.5 font-mono text-sm uppercase tracking-widest transition-all",
|
| 569 |
+
allRevealed
|
| 570 |
+
? "border-[oklch(0.85_0.12_25)] bg-[oklch(0.35_0.14_25)] text-[oklch(0.95_0.05_85)] hover:bg-[oklch(0.42_0.16_25)]"
|
| 571 |
+
: "cursor-not-allowed border-[oklch(0.4_0.05_25)] text-[oklch(0.6_0.06_25)] opacity-40")}>
|
| 572 |
+
{allRevealed ? "View your map" : "Reveal all prints first"}
|
| 573 |
+
<ArrowRight className="size-4" aria-hidden="true" />
|
| 574 |
+
</button>
|
| 575 |
+
</div>
|
| 576 |
+
)}
|
| 577 |
+
</div>
|
| 578 |
+
</section>
|
| 579 |
+
);
|
| 580 |
+
}
|
| 581 |
+
|
| 582 |
+
// ----------------------------------------------------------------- //
|
| 583 |
+
|
| 584 |
+
|
| 585 |
+
const SAMPLE = [
|
| 586 |
+
{ id: "p1", src: "data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%22320%22%20height%3D%22400%22%20viewBox%3D%220%200%20320%20400%22%3E%3Cdefs%3E%3ClinearGradient%20id%3D%22g%22%20x1%3D%220%22%20y1%3D%220%22%20x2%3D%221%22%20y2%3D%221%22%3E%3Cstop%20offset%3D%220%22%20stop-color%3D%22%23ff7a59%22/%3E%3Cstop%20offset%3D%221%22%20stop-color%3D%22%23c0392b%22/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect%20width%3D%22320%22%20height%3D%22400%22%20fill%3D%22url%28%23g%29%22/%3E%3Ccircle%20cx%3D%22230%22%20cy%3D%22120%22%20r%3D%2270%22%20fill%3D%22%23ffd166%22%20opacity%3D%220.6%22/%3E%3Crect%20x%3D%220%22%20y%3D%22300%22%20width%3D%22320%22%20height%3D%22100%22%20fill%3D%22%237a2e1e%22%20opacity%3D%220.5%22/%3E%3Ctext%20x%3D%22160%22%20y%3D%22210%22%20font-family%3D%22monospace%22%20font-size%3D%2222%22%20fill%3D%22%23fff%22%20text-anchor%3D%22middle%22%20opacity%3D%220.85%22%3EOTTAWA%3C/text%3E%3C/svg%3E", alt: "sample", defaultCaption: "Library of Parliament — beautiful morning!" },
|
| 587 |
+
{ id: "p2", src: "data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%22320%22%20height%3D%22400%22%20viewBox%3D%220%200%20320%20400%22%3E%3Cdefs%3E%3ClinearGradient%20id%3D%22g%22%20x1%3D%220%22%20y1%3D%220%22%20x2%3D%221%22%20y2%3D%221%22%3E%3Cstop%20offset%3D%220%22%20stop-color%3D%22%232ecc71%22/%3E%3Cstop%20offset%3D%221%22%20stop-color%3D%22%2316a085%22/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect%20width%3D%22320%22%20height%3D%22400%22%20fill%3D%22url%28%23g%29%22/%3E%3Ccircle%20cx%3D%22230%22%20cy%3D%22120%22%20r%3D%2270%22%20fill%3D%22%23a8e6cf%22%20opacity%3D%220.6%22/%3E%3Crect%20x%3D%220%22%20y%3D%22300%22%20width%3D%22320%22%20height%3D%22100%22%20fill%3D%22%230e5a43%22%20opacity%3D%220.5%22/%3E%3Ctext%20x%3D%22160%22%20y%3D%22210%22%20font-family%3D%22monospace%22%20font-size%3D%2222%22%20fill%3D%22%23fff%22%20text-anchor%3D%22middle%22%20opacity%3D%220.85%22%3ECANAL%3C/text%3E%3C/svg%3E", alt: "sample", defaultCaption: "Walk along the canal." },
|
| 588 |
+
{ id: "p3", src: "data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%22320%22%20height%3D%22400%22%20viewBox%3D%220%200%20320%20400%22%3E%3Cdefs%3E%3ClinearGradient%20id%3D%22g%22%20x1%3D%220%22%20y1%3D%220%22%20x2%3D%221%22%20y2%3D%221%22%3E%3Cstop%20offset%3D%220%22%20stop-color%3D%22%239b59b6%22/%3E%3Cstop%20offset%3D%221%22%20stop-color%3D%22%238e44ad%22/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect%20width%3D%22320%22%20height%3D%22400%22%20fill%3D%22url%28%23g%29%22/%3E%3Ccircle%20cx%3D%22230%22%20cy%3D%22120%22%20r%3D%2270%22%20fill%3D%22%23f8c8dc%22%20opacity%3D%220.6%22/%3E%3Crect%20x%3D%220%22%20y%3D%22300%22%20width%3D%22320%22%20height%3D%22100%22%20fill%3D%22%234a235a%22%20opacity%3D%220.5%22/%3E%3Ctext%20x%3D%22160%22%20y%3D%22210%22%20font-family%3D%22monospace%22%20font-size%3D%2222%22%20fill%3D%22%23fff%22%20text-anchor%3D%22middle%22%20opacity%3D%220.85%22%3EDINER%3C/text%3E%3C/svg%3E", alt: "sample", defaultCaption: "Pit stop at the old diner." },
|
| 589 |
+
{ id: "p4", src: "data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%22320%22%20height%3D%22400%22%20viewBox%3D%220%200%20320%20400%22%3E%3Cdefs%3E%3ClinearGradient%20id%3D%22g%22%20x1%3D%220%22%20y1%3D%220%22%20x2%3D%221%22%20y2%3D%221%22%3E%3Cstop%20offset%3D%220%22%20stop-color%3D%22%23f1c40f%22/%3E%3Cstop%20offset%3D%221%22%20stop-color%3D%22%23e67e22%22/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect%20width%3D%22320%22%20height%3D%22400%22%20fill%3D%22url%28%23g%29%22/%3E%3Ccircle%20cx%3D%22230%22%20cy%3D%22120%22%20r%3D%2270%22%20fill%3D%22%23fff3b0%22%20opacity%3D%220.6%22/%3E%3Crect%20x%3D%220%22%20y%3D%22300%22%20width%3D%22320%22%20height%3D%22100%22%20fill%3D%22%237e5109%22%20opacity%3D%220.5%22/%3E%3Ctext%20x%3D%22160%22%20y%3D%22210%22%20font-family%3D%22monospace%22%20font-size%3D%2222%22%20fill%3D%22%23fff%22%20text-anchor%3D%22middle%22%20opacity%3D%220.85%22%3ELATTE%3C/text%3E%3C/svg%3E", alt: "sample", defaultCaption: "Best latte on the trip." }
|
| 590 |
+
];
|
| 591 |
+
function Preview(){
|
| 592 |
+
const [done,setDone]=useState(false);
|
| 593 |
+
return (<div className="flex items-center justify-center">
|
| 594 |
+
{done
|
| 595 |
+
? <div className="rounded-xl border-2 border-[oklch(0.4_0.1_25)] bg-[oklch(0.16_0.04_25)] px-8 py-10 text-center">
|
| 596 |
+
<p className="font-mono text-sm uppercase tracking-widest text-[oklch(0.88_0.1_25)]">Map screen would load here ✓</p>
|
| 597 |
+
<button className="mt-4 rounded-full border-2 border-[oklch(0.85_0.12_25)] px-4 py-2 font-mono text-xs uppercase tracking-widest text-[oklch(0.95_0.05_85)] hover:bg-[oklch(0.35_0.14_25)]" onClick={()=>setDone(false)}>Run again</button>
|
| 598 |
+
</div>
|
| 599 |
+
: <DarkroomDeveloper photos={SAMPLE} onDone={()=>setDone(true)} onSkip={()=>setDone(true)} />}
|
| 600 |
+
</div>);
|
| 601 |
+
}
|
| 602 |
+
ReactDOM.createRoot(document.getElementById("root")).render(<Preview />);
|
| 603 |
+
</script>
|
| 604 |
+
</body>
|
| 605 |
+
</html>
|
index.html
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
landing.html
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>Sozai · build memories together</title>
|
| 7 |
+
<link rel="icon" type="image/svg+xml" href="/assets/icon.svg" />
|
| 8 |
+
|
| 9 |
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
| 10 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
| 11 |
+
<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" />
|
| 12 |
+
<style>
|
| 13 |
+
@font-face { font-family: 'AdvercaseBold'; src: url('/assets/fonts/AdvercaseBold.otf') format('opentype'); font-weight: 700; font-style: normal; font-display: swap; }
|
| 14 |
+
@font-face { font-family: 'Inkwell'; src: url('/assets/fonts/Inkwell.ttf') format('truetype'); font-weight: 400; font-style: normal; font-display: swap; }
|
| 15 |
+
</style>
|
| 16 |
+
|
| 17 |
+
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
| 18 |
+
|
| 19 |
+
<!-- Landing palette: cream + forest green, tied to the same token system the
|
| 20 |
+
app uses, with a serif display per the Sozai splash design. -->
|
| 21 |
+
<style type="text/tailwindcss">
|
| 22 |
+
@theme inline {
|
| 23 |
+
--font-display: 'AdvercaseBold', sans-serif;
|
| 24 |
+
--font-hand: 'Inkwell', 'Caveat', cursive;
|
| 25 |
+
--font-serif: 'EB Garamond', serif;
|
| 26 |
+
--font-sans: 'Martian Mono', monospace;
|
| 27 |
+
--font-mono: 'Martian Mono', monospace;
|
| 28 |
+
--color-cream: var(--cream);
|
| 29 |
+
--color-ink: var(--ink);
|
| 30 |
+
--color-forest: var(--forest);
|
| 31 |
+
--color-forest-deep: var(--forest-deep);
|
| 32 |
+
--color-near-black: var(--near-black);
|
| 33 |
+
--color-line: var(--line);
|
| 34 |
+
--color-muted-ink: var(--muted-ink);
|
| 35 |
+
}
|
| 36 |
+
:root {
|
| 37 |
+
--cream: oklch(0.968 0.010 88);
|
| 38 |
+
--cream-2: oklch(0.950 0.010 88);
|
| 39 |
+
--ink: oklch(0.21 0.030 200);
|
| 40 |
+
--muted-ink: oklch(0.53 0.025 210);
|
| 41 |
+
--forest: oklch(0.34 0.10 235);
|
| 42 |
+
--forest-deep: oklch(0.27 0.08 235);
|
| 43 |
+
--near-black: oklch(0.15 0.025 200);
|
| 44 |
+
--line: oklch(0.86 0.016 210);
|
| 45 |
+
}
|
| 46 |
+
@layer base {
|
| 47 |
+
body { @apply bg-cream text-ink; }
|
| 48 |
+
}
|
| 49 |
+
</style>
|
| 50 |
+
|
| 51 |
+
<script src="https://unpkg.com/react@18/umd/react.production.min.js" crossorigin></script>
|
| 52 |
+
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js" crossorigin></script>
|
| 53 |
+
<script src="https://unpkg.com/@babel/standalone/babel.min.js" crossorigin></script>
|
| 54 |
+
</head>
|
| 55 |
+
|
| 56 |
+
<body class="font-sans antialiased bg-cream">
|
| 57 |
+
<div id="root"></div>
|
| 58 |
+
|
| 59 |
+
<script type="text/babel" data-presets="react">
|
| 60 |
+
const { useState } = React;
|
| 61 |
+
|
| 62 |
+
// ----- shared identity (also read by app.html) ----------------------- //
|
| 63 |
+
function sessionId() {
|
| 64 |
+
let s = localStorage.getItem("sozai_session");
|
| 65 |
+
if (!s) { s = (crypto.randomUUID ? crypto.randomUUID() : String(Math.random()).slice(2)); localStorage.setItem("sozai_session", s); }
|
| 66 |
+
return s;
|
| 67 |
+
}
|
| 68 |
+
function storedName() { return localStorage.getItem("sozai_name") || ""; }
|
| 69 |
+
|
| 70 |
+
const CODE_ALPHABET = "23456789ABCDEFGHJKMNPQRSTVWXYZ";
|
| 71 |
+
|
| 72 |
+
// ----- decorative line-art hummingbird ------------------------------- //
|
| 73 |
+
// Drawn inline so it always renders. If a public/images/hummingbird.svg
|
| 74 |
+
// asset exists it is used; otherwise this vector falls back automatically.
|
| 75 |
+
function HummingbirdSVG({ className = "" }) {
|
| 76 |
+
return (
|
| 77 |
+
<svg viewBox="0 0 220 200" fill="none" className={className}
|
| 78 |
+
stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round"
|
| 79 |
+
aria-hidden="true">
|
| 80 |
+
{/* long beak */}
|
| 81 |
+
<path d="M150 92 L205 84" />
|
| 82 |
+
{/* head + breast + belly */}
|
| 83 |
+
<path d="M150 92 q-14 -10 -34 -6 q-26 5 -40 24 q-12 16 -10 33 q1 12 12 18" />
|
| 84 |
+
{/* back curving to tail */}
|
| 85 |
+
<path d="M150 92 q10 8 8 26 q-2 22 -22 40" />
|
| 86 |
+
{/* upper swept wing */}
|
| 87 |
+
<path d="M120 92 q24 -44 78 -64 q-30 30 -40 64 q-4 14 -16 18" />
|
| 88 |
+
{/* lower wing fold */}
|
| 89 |
+
<path d="M120 96 q18 -10 44 -8 q-22 10 -34 24" />
|
| 90 |
+
{/* long forked tail */}
|
| 91 |
+
<path d="M70 152 q-30 18 -58 24 m58 -24 q-22 26 -30 46 m30 -46 q-6 26 -2 44" />
|
| 92 |
+
{/* tiny legs */}
|
| 93 |
+
<path d="M96 138 l-4 12 m12 -10 l-2 12" />
|
| 94 |
+
{/* eye */}
|
| 95 |
+
<circle cx="158" cy="88" r="1.6" fill="currentColor" stroke="none" />
|
| 96 |
+
</svg>
|
| 97 |
+
);
|
| 98 |
+
}
|
| 99 |
+
// Hummingbird artwork. Tries a real image asset first — either a PNG or an
|
| 100 |
+
// SVG — and falls back to the inline vector if the asset is missing. Drop a
|
| 101 |
+
// file at /assets/images/hummingbird.(svg|png) to use your own artwork.
|
| 102 |
+
// `mix-blend-mode: multiply` lets a white-background asset melt into the
|
| 103 |
+
// cream page instead of showing a white box.
|
| 104 |
+
const HUMMINGBIRD_SOURCES = [
|
| 105 |
+
"/assets/images/hummingbird.svg",
|
| 106 |
+
"/assets/images/hummingbird.png",
|
| 107 |
+
];
|
| 108 |
+
function Hummingbird({ className = "" }) {
|
| 109 |
+
const [srcIdx, setSrcIdx] = useState(0);
|
| 110 |
+
const [useInline, setUseInline] = useState(false);
|
| 111 |
+
if (useInline) return <HummingbirdSVG className={className} />;
|
| 112 |
+
return (
|
| 113 |
+
<img
|
| 114 |
+
src={HUMMINGBIRD_SOURCES[srcIdx]}
|
| 115 |
+
alt=""
|
| 116 |
+
aria-hidden="true"
|
| 117 |
+
className={className}
|
| 118 |
+
style={{ mixBlendMode: "multiply" }}
|
| 119 |
+
onError={() => {
|
| 120 |
+
if (srcIdx + 1 < HUMMINGBIRD_SOURCES.length) setSrcIdx(srcIdx + 1);
|
| 121 |
+
else setUseInline(true);
|
| 122 |
+
}}
|
| 123 |
+
/>
|
| 124 |
+
);
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
// ----- the splash ---------------------------------------------------- //
|
| 128 |
+
function Splash() {
|
| 129 |
+
const [creating, setCreating] = useState(false);
|
| 130 |
+
const [codeLength, setCodeLength] = useState(6);
|
| 131 |
+
const [name, setName] = useState(storedName());
|
| 132 |
+
const [joinCode, setJoinCode] = useState("");
|
| 133 |
+
const [busy, setBusy] = useState(false);
|
| 134 |
+
const [error, setError] = useState(null);
|
| 135 |
+
|
| 136 |
+
function persistName() {
|
| 137 |
+
if (name.trim()) localStorage.setItem("sozai_name", name.trim());
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
async function makeRoom() {
|
| 141 |
+
setBusy(true); setError(null); persistName();
|
| 142 |
+
try {
|
| 143 |
+
const res = await fetch("/api/rooms", {
|
| 144 |
+
method: "POST",
|
| 145 |
+
headers: { "Content-Type": "application/json" },
|
| 146 |
+
body: JSON.stringify({ code_length: codeLength, display_name: name, session_id: sessionId() }),
|
| 147 |
+
});
|
| 148 |
+
if (!res.ok) throw new Error("Could not create the room.");
|
| 149 |
+
const data = await res.json();
|
| 150 |
+
location.href = data.room_path;
|
| 151 |
+
} catch (e) {
|
| 152 |
+
setError(e.message || "Something went wrong."); setBusy(false);
|
| 153 |
+
}
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
async function joinRoom() {
|
| 157 |
+
const code = joinCode.trim().toUpperCase();
|
| 158 |
+
if (code.length < 4) { setError("Enter your room code."); return; }
|
| 159 |
+
setBusy(true); setError(null); persistName();
|
| 160 |
+
try {
|
| 161 |
+
const res = await fetch("/api/join-by-code", {
|
| 162 |
+
method: "POST",
|
| 163 |
+
headers: { "Content-Type": "application/json" },
|
| 164 |
+
body: JSON.stringify({ code }),
|
| 165 |
+
});
|
| 166 |
+
if (res.status === 404) throw new Error("No active room with that code.");
|
| 167 |
+
if (!res.ok) throw new Error("Could not join. Check the code and try again.");
|
| 168 |
+
const data = await res.json();
|
| 169 |
+
location.href = data.room_path;
|
| 170 |
+
} catch (e) {
|
| 171 |
+
setError(e.message || "Something went wrong."); setBusy(false);
|
| 172 |
+
}
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
const exampleCode = "8F7A9CKMNPQR".slice(0, codeLength);
|
| 176 |
+
|
| 177 |
+
return (
|
| 178 |
+
<main className="relative min-h-screen w-full overflow-hidden bg-cream">
|
| 179 |
+
{/* looping bird animation blended over the cream bg */}
|
| 180 |
+
<video autoPlay loop muted playsInline aria-hidden="true"
|
| 181 |
+
className="pointer-events-none absolute inset-0 z-0 h-full w-full object-cover"
|
| 182 |
+
style={{ mixBlendMode: "multiply", opacity: 0.32 }}>
|
| 183 |
+
<source src="/assets/birdanimation.mp4" type="video/mp4" />
|
| 184 |
+
</video>
|
| 185 |
+
{/* top-left wordmark */}
|
| 186 |
+
<header className="absolute left-6 top-5 z-20 flex items-baseline gap-2 sm:left-10 sm:top-7">
|
| 187 |
+
<span className="font-display text-2xl leading-none text-ink">Sozai</span>
|
| 188 |
+
</header>
|
| 189 |
+
|
| 190 |
+
{/* content sits above the video */}
|
| 191 |
+
<div className="relative z-10 mx-auto flex min-h-screen max-w-6xl items-center justify-center px-6 md:justify-end md:pr-16 lg:pr-24">
|
| 192 |
+
<div className="w-full max-w-md">
|
| 193 |
+
|
| 194 |
+
<h1 className="text-center font-display text-6xl leading-none text-ink sm:text-7xl">Sozai</h1>
|
| 195 |
+
<p className="mt-3 text-center font-serif text-xl italic text-muted-ink">build memories together</p>
|
| 196 |
+
|
| 197 |
+
{/* make a room */}
|
| 198 |
+
{!creating ? (
|
| 199 |
+
<button type="button" onClick={() => setCreating(true)}
|
| 200 |
+
className="mt-8 w-full rounded-md bg-forest px-6 py-3.5 text-center font-serif text-lg italic tracking-wide text-cream shadow-sm transition-colors hover:bg-forest-deep">
|
| 201 |
+
make a room
|
| 202 |
+
</button>
|
| 203 |
+
) : (
|
| 204 |
+
<div className="mt-8 rounded-md border border-line bg-cream-2/70 p-5">
|
| 205 |
+
<div className="flex items-center justify-between">
|
| 206 |
+
<span className="font-serif text-base italic text-ink">new room</span>
|
| 207 |
+
<button type="button" onClick={() => { setCreating(false); setError(null); }}
|
| 208 |
+
className="font-mono text-[10px] uppercase tracking-widest text-muted-ink hover:text-ink">close</button>
|
| 209 |
+
</div>
|
| 210 |
+
|
| 211 |
+
{/* user-set room code length */}
|
| 212 |
+
<div className="mt-4">
|
| 213 |
+
<div className="flex items-baseline justify-between">
|
| 214 |
+
<label htmlFor="len" className="font-mono text-[11px] uppercase tracking-widest text-muted-ink">Room code length</label>
|
| 215 |
+
<span className="font-mono text-sm text-ink">{codeLength}</span>
|
| 216 |
+
</div>
|
| 217 |
+
<input id="len" type="range" min="4" max="12" step="1" value={codeLength}
|
| 218 |
+
onChange={(e) => setCodeLength(parseInt(e.target.value, 10))}
|
| 219 |
+
className="mt-2 w-full accent-[var(--forest)]" />
|
| 220 |
+
<p className="mt-1 font-mono text-[11px] tracking-widest text-muted-ink">
|
| 221 |
+
e.g. <span className="text-forest">{exampleCode}</span>
|
| 222 |
+
</p>
|
| 223 |
+
</div>
|
| 224 |
+
|
| 225 |
+
{/* display name */}
|
| 226 |
+
<div className="mt-4">
|
| 227 |
+
<label htmlFor="nm" className="font-mono text-[11px] uppercase tracking-widest text-muted-ink">Your name (optional)</label>
|
| 228 |
+
<input id="nm" value={name} onChange={(e) => setName(e.target.value)} maxLength={24}
|
| 229 |
+
placeholder="Guest"
|
| 230 |
+
className="mt-2 w-full rounded-md border border-line bg-cream px-3 py-2 font-serif text-base italic text-ink outline-none placeholder:text-muted-ink/60 focus:border-forest" />
|
| 231 |
+
</div>
|
| 232 |
+
|
| 233 |
+
<button type="button" onClick={makeRoom} disabled={busy}
|
| 234 |
+
className="mt-5 w-full rounded-md bg-forest px-6 py-3 text-center font-serif text-base italic tracking-wide text-cream transition-colors hover:bg-forest-deep disabled:opacity-60">
|
| 235 |
+
{busy ? "creating…" : "create room"}
|
| 236 |
+
</button>
|
| 237 |
+
</div>
|
| 238 |
+
)}
|
| 239 |
+
|
| 240 |
+
{/* or divider */}
|
| 241 |
+
<div className="my-6 flex items-center gap-4">
|
| 242 |
+
<span className="h-px flex-1 bg-line" />
|
| 243 |
+
<span className="font-serif text-sm italic text-muted-ink">or</span>
|
| 244 |
+
<span className="h-px flex-1 bg-line" />
|
| 245 |
+
</div>
|
| 246 |
+
|
| 247 |
+
{/* join by code */}
|
| 248 |
+
<div className="flex items-stretch overflow-hidden rounded-md border border-line bg-cream">
|
| 249 |
+
<input
|
| 250 |
+
value={joinCode}
|
| 251 |
+
onChange={(e) => setJoinCode(e.target.value.toUpperCase().split("").filter((c) => CODE_ALPHABET.includes(c)).join("").slice(0, 12))}
|
| 252 |
+
onKeyDown={(e) => e.key === "Enter" && joinRoom()}
|
| 253 |
+
placeholder="enter room code"
|
| 254 |
+
aria-label="Room code"
|
| 255 |
+
className="min-w-0 flex-1 bg-transparent px-4 py-3 font-serif text-base italic tracking-[0.18em] text-ink outline-none placeholder:not-italic placeholder:tracking-normal placeholder:text-muted-ink/60"
|
| 256 |
+
/>
|
| 257 |
+
<button type="button" onClick={joinRoom} disabled={busy}
|
| 258 |
+
className="shrink-0 bg-near-black px-7 font-serif text-base italic text-cream transition-colors hover:bg-ink disabled:opacity-60">
|
| 259 |
+
join
|
| 260 |
+
</button>
|
| 261 |
+
</div>
|
| 262 |
+
|
| 263 |
+
{error ? (
|
| 264 |
+
<p role="status" className="mt-3 text-center font-mono text-[11px] uppercase tracking-widest text-[oklch(0.55_0.18_25)]">{error}</p>
|
| 265 |
+
) : null}
|
| 266 |
+
|
| 267 |
+
{/* the "no room" option */}
|
| 268 |
+
<div className="mt-6 text-center">
|
| 269 |
+
<a href="/app"
|
| 270 |
+
className="font-mono text-[11px] uppercase tracking-widest text-muted-ink underline-offset-4 transition-colors hover:text-ink hover:underline">
|
| 271 |
+
continue without a room →
|
| 272 |
+
</a>
|
| 273 |
+
</div>
|
| 274 |
+
</div>
|
| 275 |
+
</div>
|
| 276 |
+
</main>
|
| 277 |
+
);
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
ReactDOM.createRoot(document.getElementById("root")).render(<Splash />);
|
| 281 |
+
</script>
|
| 282 |
+
</body>
|
| 283 |
+
</html>
|
packages.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
libheif-dev
|
public/2023-icon-library/ace/alert.png
ADDED
|
|
Git LFS Details
|
public/2023-icon-library/ace/erun1.png
ADDED
|
|
Git LFS Details
|
public/2023-icon-library/ace/erun2.png
ADDED
|
|
Git LFS Details
|
public/2023-icon-library/ace/escratch1.png
ADDED
|
|
Git LFS Details
|
public/2023-icon-library/ace/escratch2.png
ADDED
|
|
Git LFS Details
|
public/2023-icon-library/ace/itch1.png
ADDED
|
|
Git LFS Details
|
public/2023-icon-library/ace/itch2.png
ADDED
|
|
Git LFS Details
|
public/2023-icon-library/ace/nerun1.png
ADDED
|
|
Git LFS Details
|
public/2023-icon-library/ace/nerun2.png
ADDED
|
|
Git LFS Details
|
public/2023-icon-library/ace/nrun1.png
ADDED
|
|
Git LFS Details
|
public/2023-icon-library/ace/nrun2.png
ADDED
|
|
Git LFS Details
|
public/2023-icon-library/ace/nscratch1.png
ADDED
|
|
Git LFS Details
|
public/2023-icon-library/ace/nscratch2.png
ADDED
|
|
Git LFS Details
|
public/2023-icon-library/ace/nwrun1.png
ADDED
|
|
Git LFS Details
|
public/2023-icon-library/ace/nwrun2.png
ADDED
|
|
Git LFS Details
|
public/2023-icon-library/ace/serun1.png
ADDED
|
|
Git LFS Details
|
public/2023-icon-library/ace/serun2.png
ADDED
|
|
Git LFS Details
|
public/2023-icon-library/ace/sleep1.png
ADDED
|
|
Git LFS Details
|
public/2023-icon-library/ace/sleep2.png
ADDED
|
|
Git LFS Details
|
public/2023-icon-library/ace/srun1.png
ADDED
|
|
Git LFS Details
|
public/2023-icon-library/ace/srun2.png
ADDED
|
|
Git LFS Details
|
public/2023-icon-library/ace/sscratch1.png
ADDED
|
|
Git LFS Details
|
public/2023-icon-library/ace/sscratch2.png
ADDED
|
|
Git LFS Details
|
public/2023-icon-library/ace/still.png
ADDED
|
|
Git LFS Details
|
public/2023-icon-library/ace/swrun1.png
ADDED
|
|
Git LFS Details
|
public/2023-icon-library/ace/swrun2.png
ADDED
|
|
Git LFS Details
|
public/2023-icon-library/ace/wash.png
ADDED
|
|
Git LFS Details
|
public/2023-icon-library/ace/wrun1.png
ADDED
|
|
Git LFS Details
|
public/2023-icon-library/ace/wrun2.png
ADDED
|
|
Git LFS Details
|
public/2023-icon-library/ace/wscratch1.png
ADDED
|
|
Git LFS Details
|
public/2023-icon-library/ace/wscratch2.png
ADDED
|
|
Git LFS Details
|
public/2023-icon-library/ace/yawn.png
ADDED
|
|
Git LFS Details
|
public/2023-icon-library/air/alert.png
ADDED
|
|
Git LFS Details
|
public/2023-icon-library/air/erun1.png
ADDED
|
|
Git LFS Details
|
public/2023-icon-library/air/erun2.png
ADDED
|
|
Git LFS Details
|
public/2023-icon-library/air/escratch1.png
ADDED
|
|
Git LFS Details
|
public/2023-icon-library/air/escratch2.png
ADDED
|
|
Git LFS Details
|
public/2023-icon-library/air/itch1.png
ADDED
|
|
Git LFS Details
|
public/2023-icon-library/air/itch2.png
ADDED
|
|
Git LFS Details
|
public/2023-icon-library/air/nerun1.png
ADDED
|
|
Git LFS Details
|
public/2023-icon-library/air/nerun2.png
ADDED
|
|
Git LFS Details
|