feat: collab fixes, Cesium OOM mitigation, and frontend precompile
Browse filesCollaboration (ported from build-small-hackathon/storybook_test):
- AI + journey mutual-exclusion locks (server-authoritative), room
capacity cap, SSE reconnect hardening, live photos_state sync,
activity presence beacon, synced journey playback / darkroom develop.
Cesium memory: cap 3D-tiles cache (cacheBytes) so passed-over tiles are
evicted instead of accumulating across the flythrough, dynamic SSE to
coarsen the horizon, globe tile-cache limits, and coarsen+trim before the
end-of-journey overview (the OOM spike). Adds a webglcontextlost guard.
Frontend first-boot latency: precompile the in-browser JSX with esbuild
(build_frontend.py -> index.build.html) so we no longer ship
@babel /standalone (~0.7 MB gzip) or transpile ~7.5k lines on the client's
main thread before first paint. index.html stays the editable source;
app.py serves index.build.html when present and caches the assembled HTML.
- app.py +267 -11
- build_frontend.py +98 -0
- index.build.html +0 -0
- index.html +860 -127
|
@@ -2448,6 +2448,21 @@ RELAY_EVENTS = {
|
|
| 2448 |
"photo_pinned", "photo_moved", "photo_props", "photos_reset",
|
| 2449 |
# navigation that collaborators can optionally follow
|
| 2450 |
"screen_changed", "variant_changed",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2451 |
}
|
| 2452 |
|
| 2453 |
# Types the server originates or manages itself — these must NEVER be relayed
|
|
@@ -2456,8 +2471,20 @@ NON_RELAY_EVENTS = {
|
|
| 2456 |
"init", "me", "presence", "presence_update", "status",
|
| 2457 |
"user_joined", "user_left", "rename", "error",
|
| 2458 |
"join_request", "join_pending", "join_declined", "admit_join", "decline_join", "cancel_join",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2459 |
}
|
| 2460 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2461 |
|
| 2462 |
# NOTE: This transport was a raw WebSocket (/ws/{room_id}). Hugging Face
|
| 2463 |
# Spaces' router silently drops custom (non-Gradio) WebSocket UPGRADE requests
|
|
@@ -2515,6 +2542,114 @@ class Manager:
|
|
| 2515 |
def __init__(self):
|
| 2516 |
self.rooms = {} # room_id -> {session_id: _Client} (admitted)
|
| 2517 |
self.pending = {} # room_id -> {session_id: info} (awaiting host)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2518 |
|
| 2519 |
# ---- admitted participants -------------------------------------------- #
|
| 2520 |
def add_client(self, room_id, client):
|
|
@@ -2609,14 +2744,22 @@ _sse_queues = {} # (room_id, session_id) -> asyncio.Queue
|
|
| 2609 |
|
| 2610 |
|
| 2611 |
def _sse_queue(room_id, session_id):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2612 |
key = (room_id, session_id)
|
| 2613 |
-
q =
|
| 2614 |
-
|
| 2615 |
-
q = asyncio.Queue()
|
| 2616 |
-
_sse_queues[key] = q
|
| 2617 |
return q
|
| 2618 |
|
| 2619 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2620 |
async def _relay_incoming(room_id, session_id, data):
|
| 2621 |
"""Apply one client→server event (formerly a WS receive_json). Shared by the
|
| 2622 |
POST /send endpoint. `data` already has a 'type'."""
|
|
@@ -2645,6 +2788,53 @@ async def _relay_incoming(room_id, session_id, data):
|
|
| 2645 |
pend.event.set()
|
| 2646 |
return
|
| 2647 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2648 |
# explicitly known (RELAY_EVENTS) or simply not a server-owned type
|
| 2649 |
# (NON_RELAY_EVENTS) — this way the frontend can introduce new interaction
|
| 2650 |
# events without the server silently dropping them.
|
|
@@ -2653,7 +2843,7 @@ async def _relay_incoming(room_id, session_id, data):
|
|
| 2653 |
await manager.broadcast(room_id, data, exclude=session_id)
|
| 2654 |
# cursors + transient interaction pings are too chatty to keep the room
|
| 2655 |
# alive on; everything else counts as real activity.
|
| 2656 |
-
if t not in ("cursor_position", "interaction"):
|
| 2657 |
touch_room(room_id)
|
| 2658 |
|
| 2659 |
|
|
@@ -2686,6 +2876,17 @@ async def room_stream(room_id: str, request: Request):
|
|
| 2686 |
host = manager.host_client(room_id, owner_session)
|
| 2687 |
needs_approval = (not is_owner) and (host is not None)
|
| 2688 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2689 |
if needs_approval:
|
| 2690 |
pend = _PendingJoin(info)
|
| 2691 |
manager.add_pending(room_id, pend)
|
|
@@ -2705,9 +2906,18 @@ async def room_stream(room_id: str, request: Request):
|
|
| 2705 |
client = manager.admit(room_id, info)
|
| 2706 |
client.queue = q # reuse the queue the SSE stream is already draining
|
| 2707 |
else:
|
| 2708 |
-
|
| 2709 |
-
|
| 2710 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2711 |
|
| 2712 |
if client is not None:
|
| 2713 |
now = time.time()
|
|
@@ -2720,6 +2930,12 @@ async def room_stream(room_id: str, request: Request):
|
|
| 2720 |
_conn.commit()
|
| 2721 |
touch_room(room_id)
|
| 2722 |
await q.put({"type": "init", "you": _public(info), "participants": manager.presence(room_id)})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2723 |
await manager.broadcast(room_id, {"type": "user_joined", "participant": _public(info)}, exclude=session_id)
|
| 2724 |
await manager.broadcast(room_id, {"type": "presence_update", "participants": manager.presence(room_id)})
|
| 2725 |
|
|
@@ -2738,11 +2954,27 @@ async def room_stream(room_id: str, request: Request):
|
|
| 2738 |
except asyncio.CancelledError:
|
| 2739 |
pass
|
| 2740 |
finally:
|
| 2741 |
-
|
| 2742 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2743 |
manager.disconnect(room_id, session_id)
|
| 2744 |
await manager.broadcast(room_id, {"type": "user_left", "participant": _public(info)})
|
| 2745 |
await manager.broadcast(room_id, {"type": "presence_update", "participants": manager.presence(room_id)})
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2746 |
|
| 2747 |
return StreamingResponse(event_gen(), media_type="text/event-stream", headers={
|
| 2748 |
"Cache-Control": "no-cache",
|
|
@@ -2930,8 +3162,32 @@ def _inline_maplibre(html: str) -> str:
|
|
| 2930 |
return html.replace(marker, "\n".join(parts), 1)
|
| 2931 |
|
| 2932 |
|
|
|
|
|
|
|
|
|
|
| 2933 |
def _app_html() -> str:
|
| 2934 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2935 |
|
| 2936 |
|
| 2937 |
@app.get("/", response_class=HTMLResponse)
|
|
|
|
| 2448 |
"photo_pinned", "photo_moved", "photo_props", "photos_reset",
|
| 2449 |
# navigation that collaborators can optionally follow
|
| 2450 |
"screen_changed", "variant_changed",
|
| 2451 |
+
# LIVE shared image library: the full photo list, relayed on every
|
| 2452 |
+
# add / delete / edit so both people always see the same images instantly.
|
| 2453 |
+
"photos_state",
|
| 2454 |
+
# ambient "what is my collaborator doing / where are they" presence beacon
|
| 2455 |
+
# (navigation, AI activity, etc.) — shown as a floating label by the cursor.
|
| 2456 |
+
"activity",
|
| 2457 |
+
# SYNCED "Play your journey" playback. Only the lock-holding driver emits
|
| 2458 |
+
# these; the peer applies them so both watch the SAME flythrough. The lock
|
| 2459 |
+
# itself (journey_acquire/release/state) is server-owned (NON_RELAY) below.
|
| 2460 |
+
"journey_frame", "journey_transport", "journey_start", "journey_stop",
|
| 2461 |
+
# darkroom develop: which prints have been revealed (shake) so the peer's
|
| 2462 |
+
# darkroom shows the same reveal progress and developed watercolours.
|
| 2463 |
+
"darkroom_reveal", "darkroom_phase", "darkroom_developed",
|
| 2464 |
+
# map fullscreen toggle (optional follow, same as screen/variant)
|
| 2465 |
+
"map_fullscreen",
|
| 2466 |
}
|
| 2467 |
|
| 2468 |
# Types the server originates or manages itself — these must NEVER be relayed
|
|
|
|
| 2471 |
"init", "me", "presence", "presence_update", "status",
|
| 2472 |
"user_joined", "user_left", "rename", "error",
|
| 2473 |
"join_request", "join_pending", "join_declined", "admit_join", "decline_join", "cancel_join",
|
| 2474 |
+
# the AI mutual-exclusion lock is server-authoritative: clients REQUEST it,
|
| 2475 |
+
# the server grants/denies and broadcasts the resulting ai_state. Clients
|
| 2476 |
+
# may not forge ai_state directly, so it's non-relay.
|
| 2477 |
+
"ai_acquire", "ai_release", "ai_state",
|
| 2478 |
+
# the journey mutual-exclusion lock is likewise server-authoritative.
|
| 2479 |
+
"journey_acquire", "journey_release", "journey_refresh", "journey_state",
|
| 2480 |
+
# room-full notice is server-originated
|
| 2481 |
+
"room_full",
|
| 2482 |
}
|
| 2483 |
|
| 2484 |
+
# A room holds at most this many simultaneous participants. The owner plus one
|
| 2485 |
+
# guest = 2. A third connection is rejected with a room_full notice.
|
| 2486 |
+
ROOM_CAPACITY = int(os.environ.get("SOZAI_ROOM_CAPACITY", "2"))
|
| 2487 |
+
|
| 2488 |
|
| 2489 |
# NOTE: This transport was a raw WebSocket (/ws/{room_id}). Hugging Face
|
| 2490 |
# Spaces' router silently drops custom (non-Gradio) WebSocket UPGRADE requests
|
|
|
|
| 2542 |
def __init__(self):
|
| 2543 |
self.rooms = {} # room_id -> {session_id: _Client} (admitted)
|
| 2544 |
self.pending = {} # room_id -> {session_id: info} (awaiting host)
|
| 2545 |
+
# AI mutual-exclusion lock per room. room_id -> {"session_id","name","action","ts"}
|
| 2546 |
+
# or None when free. Only one participant may run AI at a time.
|
| 2547 |
+
self.ai_lock = {}
|
| 2548 |
+
# Journey mutual-exclusion lock per room. The "Play your journey"
|
| 2549 |
+
# flythrough (and the watercolour "develop" pass) are GPU/memory heavy;
|
| 2550 |
+
# letting BOTH people trigger them at once risks an out-of-memory crash.
|
| 2551 |
+
# So only one participant may drive a journey at a time. The driver's
|
| 2552 |
+
# control frames are relayed to the peer so they watch the SAME playback
|
| 2553 |
+
# (see the journey_* relay events) — the peer's button stays disabled
|
| 2554 |
+
# until the driver finishes or stops. room_id -> {session_id,name,ts}.
|
| 2555 |
+
self.journey_lock = {}
|
| 2556 |
+
|
| 2557 |
+
# ---- capacity --------------------------------------------------------- #
|
| 2558 |
+
def participant_count(self, room_id):
|
| 2559 |
+
"""Number of DISTINCT admitted sessions (a person who reconnects with the
|
| 2560 |
+
same session_id counts once, so a refresh doesn't burn a seat)."""
|
| 2561 |
+
return len({sid for sid in self.rooms.get(room_id, {})})
|
| 2562 |
+
|
| 2563 |
+
def has_seat(self, room_id, session_id):
|
| 2564 |
+
"""True if this session can occupy/continue a seat: either it's already
|
| 2565 |
+
in the room (reconnect) or the room is below capacity."""
|
| 2566 |
+
peers = self.rooms.get(room_id, {})
|
| 2567 |
+
if session_id in peers:
|
| 2568 |
+
return True
|
| 2569 |
+
return len(peers) < ROOM_CAPACITY
|
| 2570 |
+
|
| 2571 |
+
# ---- AI lock ---------------------------------------------------------- #
|
| 2572 |
+
AI_LOCK_TIMEOUT = 90 # seconds; a stale lock (crashed holder) auto-expires
|
| 2573 |
+
|
| 2574 |
+
def ai_state(self, room_id):
|
| 2575 |
+
"""Current AI lock for a room as a JSON-safe dict (or {'holder': None})."""
|
| 2576 |
+
lock = self.ai_lock.get(room_id)
|
| 2577 |
+
if lock and (time.time() - lock["ts"]) > self.AI_LOCK_TIMEOUT:
|
| 2578 |
+
# stale — the holder went away without releasing
|
| 2579 |
+
self.ai_lock.pop(room_id, None)
|
| 2580 |
+
lock = None
|
| 2581 |
+
if not lock:
|
| 2582 |
+
return {"holder": None}
|
| 2583 |
+
return {"holder": {k: lock[k] for k in ("session_id", "name", "action")}}
|
| 2584 |
+
|
| 2585 |
+
def ai_acquire(self, room_id, session_id, name, action):
|
| 2586 |
+
"""Try to take the AI lock. Returns True if acquired (or already held by
|
| 2587 |
+
this same session — re-acquire/refresh), False if held by someone else."""
|
| 2588 |
+
lock = self.ai_lock.get(room_id)
|
| 2589 |
+
if lock and (time.time() - lock["ts"]) > self.AI_LOCK_TIMEOUT:
|
| 2590 |
+
lock = None
|
| 2591 |
+
if lock and lock["session_id"] != session_id:
|
| 2592 |
+
return False
|
| 2593 |
+
self.ai_lock[room_id] = {
|
| 2594 |
+
"session_id": session_id, "name": name,
|
| 2595 |
+
"action": action, "ts": time.time(),
|
| 2596 |
+
}
|
| 2597 |
+
return True
|
| 2598 |
+
|
| 2599 |
+
def ai_release(self, room_id, session_id):
|
| 2600 |
+
"""Release the lock if this session holds it."""
|
| 2601 |
+
lock = self.ai_lock.get(room_id)
|
| 2602 |
+
if lock and lock["session_id"] == session_id:
|
| 2603 |
+
self.ai_lock.pop(room_id, None)
|
| 2604 |
+
return True
|
| 2605 |
+
return False
|
| 2606 |
+
|
| 2607 |
+
def ai_release_all(self, room_id, session_id):
|
| 2608 |
+
"""Drop the lock unconditionally if held by this session (used on
|
| 2609 |
+
disconnect so a vanished holder never blocks the room)."""
|
| 2610 |
+
return self.ai_release(room_id, session_id)
|
| 2611 |
+
|
| 2612 |
+
# ---- Journey lock ----------------------------------------------------- #
|
| 2613 |
+
# The flythrough can run for a while, so its lock lives longer than the AI
|
| 2614 |
+
# one. A crashed driver still auto-expires so the room never gets stuck.
|
| 2615 |
+
JOURNEY_LOCK_TIMEOUT = 600 # seconds
|
| 2616 |
+
|
| 2617 |
+
def journey_state(self, room_id):
|
| 2618 |
+
"""Current journey lock for a room as a JSON-safe dict (or {'driver': None})."""
|
| 2619 |
+
lock = self.journey_lock.get(room_id)
|
| 2620 |
+
if lock and (time.time() - lock["ts"]) > self.JOURNEY_LOCK_TIMEOUT:
|
| 2621 |
+
self.journey_lock.pop(room_id, None)
|
| 2622 |
+
lock = None
|
| 2623 |
+
if not lock:
|
| 2624 |
+
return {"driver": None}
|
| 2625 |
+
return {"driver": {k: lock[k] for k in ("session_id", "name")}}
|
| 2626 |
+
|
| 2627 |
+
def journey_acquire(self, room_id, session_id, name):
|
| 2628 |
+
"""Try to take the journey lock. True if acquired (or re-acquired by the
|
| 2629 |
+
same session), False if another participant is already driving."""
|
| 2630 |
+
lock = self.journey_lock.get(room_id)
|
| 2631 |
+
if lock and (time.time() - lock["ts"]) > self.JOURNEY_LOCK_TIMEOUT:
|
| 2632 |
+
lock = None
|
| 2633 |
+
if lock and lock["session_id"] != session_id:
|
| 2634 |
+
return False
|
| 2635 |
+
self.journey_lock[room_id] = {"session_id": session_id, "name": name, "ts": time.time()}
|
| 2636 |
+
return True
|
| 2637 |
+
|
| 2638 |
+
def journey_refresh(self, room_id, session_id):
|
| 2639 |
+
"""Keep a long-running journey lock alive while the driver streams frames."""
|
| 2640 |
+
lock = self.journey_lock.get(room_id)
|
| 2641 |
+
if lock and lock["session_id"] == session_id:
|
| 2642 |
+
lock["ts"] = time.time()
|
| 2643 |
+
return True
|
| 2644 |
+
return False
|
| 2645 |
+
|
| 2646 |
+
def journey_release(self, room_id, session_id):
|
| 2647 |
+
"""Release the journey lock if this session holds it."""
|
| 2648 |
+
lock = self.journey_lock.get(room_id)
|
| 2649 |
+
if lock and lock["session_id"] == session_id:
|
| 2650 |
+
self.journey_lock.pop(room_id, None)
|
| 2651 |
+
return True
|
| 2652 |
+
return False
|
| 2653 |
|
| 2654 |
# ---- admitted participants -------------------------------------------- #
|
| 2655 |
def add_client(self, room_id, client):
|
|
|
|
| 2744 |
|
| 2745 |
|
| 2746 |
def _sse_queue(room_id, session_id):
|
| 2747 |
+
"""Return a FRESH queue for a new SSE connection and mark it as the current
|
| 2748 |
+
one for this (room, session). If the same session reconnects (e.g. a page
|
| 2749 |
+
refresh opens a new stream before the old one's cleanup runs), the newest
|
| 2750 |
+
connection wins: the old generator detects it is no longer current and bows
|
| 2751 |
+
out WITHOUT evicting the live participant. This prevents a reconnect from
|
| 2752 |
+
dropping your own seat or AI lock."""
|
| 2753 |
key = (room_id, session_id)
|
| 2754 |
+
q = asyncio.Queue()
|
| 2755 |
+
_sse_queues[key] = q
|
|
|
|
|
|
|
| 2756 |
return q
|
| 2757 |
|
| 2758 |
|
| 2759 |
+
def _is_current_sse(room_id, session_id, q):
|
| 2760 |
+
return _sse_queues.get((room_id, session_id)) is q
|
| 2761 |
+
|
| 2762 |
+
|
| 2763 |
async def _relay_incoming(room_id, session_id, data):
|
| 2764 |
"""Apply one client→server event (formerly a WS receive_json). Shared by the
|
| 2765 |
POST /send endpoint. `data` already has a 'type'."""
|
|
|
|
| 2788 |
pend.event.set()
|
| 2789 |
return
|
| 2790 |
|
| 2791 |
+
# ---- AI mutual-exclusion lock ----
|
| 2792 |
+
# A client asks to start an AI action; the server grants it only if no one
|
| 2793 |
+
# else holds the lock, then broadcasts the authoritative ai_state to BOTH
|
| 2794 |
+
# people so the other user's AI controls disable. The requester is told
|
| 2795 |
+
# whether it won via a direct ai_state (with a `granted` hint).
|
| 2796 |
+
if t == "ai_acquire" and info is not None:
|
| 2797 |
+
action = str(data.get("action") or "AI")[:40]
|
| 2798 |
+
ok = manager.ai_acquire(room_id, session_id, info["name"], action)
|
| 2799 |
+
# tell the requester the outcome (granted true/false)
|
| 2800 |
+
await manager.send_to(room_id, session_id, {
|
| 2801 |
+
"type": "ai_state", "granted": ok, **manager.ai_state(room_id),
|
| 2802 |
+
})
|
| 2803 |
+
# tell everyone the new state (so the peer disables their buttons)
|
| 2804 |
+
await manager.broadcast(room_id, {"type": "ai_state", **manager.ai_state(room_id)}, exclude=session_id)
|
| 2805 |
+
touch_room(room_id)
|
| 2806 |
+
return
|
| 2807 |
+
|
| 2808 |
+
if t == "ai_release" and info is not None:
|
| 2809 |
+
manager.ai_release(room_id, session_id)
|
| 2810 |
+
await manager.broadcast(room_id, {"type": "ai_state", **manager.ai_state(room_id)})
|
| 2811 |
+
touch_room(room_id)
|
| 2812 |
+
return
|
| 2813 |
+
|
| 2814 |
+
# ---- Journey mutual-exclusion lock ----
|
| 2815 |
+
# Like the AI lock, but for the "Play your journey" flythrough / develop.
|
| 2816 |
+
# The requester is told whether it won; everyone is told the new driver so
|
| 2817 |
+
# the peer's Play button disables. The driver then streams journey_frame
|
| 2818 |
+
# events (relayed normally) so the peer watches the same playback.
|
| 2819 |
+
if t == "journey_acquire" and info is not None:
|
| 2820 |
+
ok = manager.journey_acquire(room_id, session_id, info["name"])
|
| 2821 |
+
await manager.send_to(room_id, session_id, {
|
| 2822 |
+
"type": "journey_state", "granted": ok, **manager.journey_state(room_id),
|
| 2823 |
+
})
|
| 2824 |
+
await manager.broadcast(room_id, {"type": "journey_state", **manager.journey_state(room_id)}, exclude=session_id)
|
| 2825 |
+
touch_room(room_id)
|
| 2826 |
+
return
|
| 2827 |
+
|
| 2828 |
+
if t == "journey_refresh" and info is not None:
|
| 2829 |
+
manager.journey_refresh(room_id, session_id)
|
| 2830 |
+
return
|
| 2831 |
+
|
| 2832 |
+
if t == "journey_release" and info is not None:
|
| 2833 |
+
manager.journey_release(room_id, session_id)
|
| 2834 |
+
await manager.broadcast(room_id, {"type": "journey_state", **manager.journey_state(room_id)})
|
| 2835 |
+
touch_room(room_id)
|
| 2836 |
+
return
|
| 2837 |
+
|
| 2838 |
# explicitly known (RELAY_EVENTS) or simply not a server-owned type
|
| 2839 |
# (NON_RELAY_EVENTS) — this way the frontend can introduce new interaction
|
| 2840 |
# events without the server silently dropping them.
|
|
|
|
| 2843 |
await manager.broadcast(room_id, data, exclude=session_id)
|
| 2844 |
# cursors + transient interaction pings are too chatty to keep the room
|
| 2845 |
# alive on; everything else counts as real activity.
|
| 2846 |
+
if t not in ("cursor_position", "interaction", "activity", "photos_state"):
|
| 2847 |
touch_room(room_id)
|
| 2848 |
|
| 2849 |
|
|
|
|
| 2876 |
host = manager.host_client(room_id, owner_session)
|
| 2877 |
needs_approval = (not is_owner) and (host is not None)
|
| 2878 |
|
| 2879 |
+
# ---- capacity gate: at most ROOM_CAPACITY (2) people per room ----
|
| 2880 |
+
# The owner is never locked out of their own room. Anyone else is rejected
|
| 2881 |
+
# when the room is already full AND they don't already hold a seat (a plain
|
| 2882 |
+
# reconnect with the same session_id keeps its seat). We emit a room_full
|
| 2883 |
+
# notice, then close the stream so the client can show "Room is full".
|
| 2884 |
+
if not is_owner and not manager.has_seat(room_id, session_id):
|
| 2885 |
+
async def _full():
|
| 2886 |
+
yield f'data: {json.dumps({"type": "room_full", "capacity": ROOM_CAPACITY}, separators=(",", ":"))}\n\n'
|
| 2887 |
+
_sse_queues.pop((room_id, session_id), None)
|
| 2888 |
+
return StreamingResponse(_full(), media_type="text/event-stream", headers={"Cache-Control": "no-cache"})
|
| 2889 |
+
|
| 2890 |
if needs_approval:
|
| 2891 |
pend = _PendingJoin(info)
|
| 2892 |
manager.add_pending(room_id, pend)
|
|
|
|
| 2906 |
client = manager.admit(room_id, info)
|
| 2907 |
client.queue = q # reuse the queue the SSE stream is already draining
|
| 2908 |
else:
|
| 2909 |
+
# New connection OR a reconnect by the same session. If the session is
|
| 2910 |
+
# already seated (reconnect), keep its existing _Client but repoint its
|
| 2911 |
+
# queue at this new SSE stream so broadcasts reach the live connection.
|
| 2912 |
+
existing = manager.get_client(room_id, session_id)
|
| 2913 |
+
if existing is not None:
|
| 2914 |
+
existing.queue = q
|
| 2915 |
+
existing.alive = True
|
| 2916 |
+
client = existing
|
| 2917 |
+
else:
|
| 2918 |
+
client = _Client(info)
|
| 2919 |
+
client.queue = q
|
| 2920 |
+
manager.add_client(room_id, client)
|
| 2921 |
|
| 2922 |
if client is not None:
|
| 2923 |
now = time.time()
|
|
|
|
| 2930 |
_conn.commit()
|
| 2931 |
touch_room(room_id)
|
| 2932 |
await q.put({"type": "init", "you": _public(info), "participants": manager.presence(room_id)})
|
| 2933 |
+
# seed the current AI-lock state so a late joiner immediately knows if
|
| 2934 |
+
# the other person is mid-AI-action (and disables their own AI buttons).
|
| 2935 |
+
await q.put({"type": "ai_state", **manager.ai_state(room_id)})
|
| 2936 |
+
# likewise seed the journey-lock state, so a late joiner's Play button
|
| 2937 |
+
# is correctly disabled if the other person is already driving a journey.
|
| 2938 |
+
await q.put({"type": "journey_state", **manager.journey_state(room_id)})
|
| 2939 |
await manager.broadcast(room_id, {"type": "user_joined", "participant": _public(info)}, exclude=session_id)
|
| 2940 |
await manager.broadcast(room_id, {"type": "presence_update", "participants": manager.presence(room_id)})
|
| 2941 |
|
|
|
|
| 2954 |
except asyncio.CancelledError:
|
| 2955 |
pass
|
| 2956 |
finally:
|
| 2957 |
+
# Only tear down if THIS connection is still the current one for the
|
| 2958 |
+
# session. If the same session reconnected (refresh), a newer stream
|
| 2959 |
+
# already took over — this superseded generator must NOT evict the
|
| 2960 |
+
# live participant, drop their seat, or free their AI lock.
|
| 2961 |
+
superseded = not _is_current_sse(room_id, session_id, q)
|
| 2962 |
+
if not superseded:
|
| 2963 |
+
_sse_queues.pop((room_id, session_id), None)
|
| 2964 |
+
if not superseded and manager.get_client(room_id, session_id) is not None:
|
| 2965 |
+
# if this person was holding the AI lock, free it so the room
|
| 2966 |
+
# isn't stuck "someone is generating…" forever after they leave.
|
| 2967 |
+
released = manager.ai_release_all(room_id, session_id)
|
| 2968 |
+
# same for the journey lock: a driver who leaves must not block
|
| 2969 |
+
# the peer's Play button forever.
|
| 2970 |
+
journey_released = manager.journey_release(room_id, session_id)
|
| 2971 |
manager.disconnect(room_id, session_id)
|
| 2972 |
await manager.broadcast(room_id, {"type": "user_left", "participant": _public(info)})
|
| 2973 |
await manager.broadcast(room_id, {"type": "presence_update", "participants": manager.presence(room_id)})
|
| 2974 |
+
if released:
|
| 2975 |
+
await manager.broadcast(room_id, {"type": "ai_state", **manager.ai_state(room_id)})
|
| 2976 |
+
if journey_released:
|
| 2977 |
+
await manager.broadcast(room_id, {"type": "journey_state", **manager.journey_state(room_id)})
|
| 2978 |
|
| 2979 |
return StreamingResponse(event_gen(), media_type="text/event-stream", headers={
|
| 2980 |
"Cache-Control": "no-cache",
|
|
|
|
| 3162 |
return html.replace(marker, "\n".join(parts), 1)
|
| 3163 |
|
| 3164 |
|
| 3165 |
+
_APP_HTML_CACHE: str | None = None
|
| 3166 |
+
|
| 3167 |
+
|
| 3168 |
def _app_html() -> str:
|
| 3169 |
+
"""Assembled app HTML, built once and cached in memory (the file doesn't
|
| 3170 |
+
change at runtime, so re-reading + re-inlining ~500 KB per request is waste).
|
| 3171 |
+
|
| 3172 |
+
Prefers `index.build.html` — the precompiled output of build_frontend.py,
|
| 3173 |
+
which ships plain JS instead of making the browser download @babel/standalone
|
| 3174 |
+
and transpile the whole app on first paint. Falls back to `index.html` (the
|
| 3175 |
+
editable source, which still works via the in-browser Babel fallback) when
|
| 3176 |
+
no build exists, e.g. during local development."""
|
| 3177 |
+
global _APP_HTML_CACHE
|
| 3178 |
+
if _APP_HTML_CACHE is None:
|
| 3179 |
+
built = os.path.join(BASE, "index.build.html")
|
| 3180 |
+
if os.path.exists(built):
|
| 3181 |
+
if os.path.getmtime(os.path.join(BASE, "index.html")) > os.path.getmtime(built):
|
| 3182 |
+
print("[frontend] WARNING: index.html is newer than index.build.html — "
|
| 3183 |
+
"rerun `python build_frontend.py` before deploying.")
|
| 3184 |
+
name = "index.build.html"
|
| 3185 |
+
else:
|
| 3186 |
+
print("[frontend] no index.build.html — serving un-precompiled index.html "
|
| 3187 |
+
"(slow first boot). Run `python build_frontend.py`.")
|
| 3188 |
+
name = "index.html"
|
| 3189 |
+
_APP_HTML_CACHE = _inline_maplibre(_read_html(name))
|
| 3190 |
+
return _APP_HTML_CACHE
|
| 3191 |
|
| 3192 |
|
| 3193 |
@app.get("/", response_class=HTMLResponse)
|
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Precompile the in-browser JSX so first boot doesn't ship @babel/standalone
|
| 3 |
+
(~0.7 MB gzip) and transpile ~7.5k lines of JSX on the client's main thread
|
| 4 |
+
before anything paints.
|
| 5 |
+
|
| 6 |
+
`index.html` stays the editable source: it still works opened standalone via
|
| 7 |
+
the in-browser Babel fallback. This script reads it and writes
|
| 8 |
+
`index.build.html` with:
|
| 9 |
+
* the `<script type="text/babel">` block transpiled to plain JS (esbuild),
|
| 10 |
+
* the `@babel/standalone` CDN <script> removed.
|
| 11 |
+
|
| 12 |
+
The server (app.py) serves `index.build.html` when present, else falls back to
|
| 13 |
+
`index.html`. So the deploy flow is:
|
| 14 |
+
|
| 15 |
+
python build_frontend.py # before `git push` to the Space
|
| 16 |
+
|
| 17 |
+
Requires `npx` (esbuild is fetched on demand) or a local `esbuild` on PATH.
|
| 18 |
+
"""
|
| 19 |
+
import os
|
| 20 |
+
import re
|
| 21 |
+
import shutil
|
| 22 |
+
import subprocess
|
| 23 |
+
import sys
|
| 24 |
+
import tempfile
|
| 25 |
+
|
| 26 |
+
BASE = os.path.dirname(os.path.abspath(__file__))
|
| 27 |
+
SRC = os.path.join(BASE, "index.html")
|
| 28 |
+
OUT = os.path.join(BASE, "index.build.html")
|
| 29 |
+
ESBUILD_VERSION = "0.21.5"
|
| 30 |
+
|
| 31 |
+
# Match the SINGLE in-browser Babel block. The block contains no nested
|
| 32 |
+
# "</script>" (verified), so the non-greedy match captures all of it.
|
| 33 |
+
BABEL_BLOCK_RE = re.compile(r'<script type="text/babel"[^>]*>(.*?)</script>', re.S)
|
| 34 |
+
BABEL_CDN_RE = re.compile(r'[ \t]*<script[^>]*@babel/standalone[^>]*></script>\s*\n', re.S)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _esbuild_cmd():
|
| 38 |
+
"""Prefer a local esbuild; otherwise go through npx (downloads on demand)."""
|
| 39 |
+
if shutil.which("esbuild"):
|
| 40 |
+
return ["esbuild"]
|
| 41 |
+
if shutil.which("npx"):
|
| 42 |
+
return ["npx", "--yes", f"esbuild@{ESBUILD_VERSION}"]
|
| 43 |
+
sys.exit("error: need `esbuild` or `npx` on PATH to precompile the frontend.")
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def transpile(jsx: str) -> str:
|
| 47 |
+
with tempfile.TemporaryDirectory() as td:
|
| 48 |
+
inp = os.path.join(td, "app.jsx")
|
| 49 |
+
outp = os.path.join(td, "app.js")
|
| 50 |
+
with open(inp, "w", encoding="utf-8") as fh:
|
| 51 |
+
fh.write(jsx)
|
| 52 |
+
# Classic JSX runtime against the global React UMD (no auto-import),
|
| 53 |
+
# matching the current `data-presets="react"` behaviour. es2020 keeps
|
| 54 |
+
# broad browser support without down-levelling more than Babel did.
|
| 55 |
+
cmd = _esbuild_cmd() + [
|
| 56 |
+
inp,
|
| 57 |
+
"--jsx-factory=React.createElement",
|
| 58 |
+
"--jsx-fragment=React.Fragment",
|
| 59 |
+
"--target=es2020",
|
| 60 |
+
"--charset=utf8",
|
| 61 |
+
f"--outfile={outp}",
|
| 62 |
+
]
|
| 63 |
+
res = subprocess.run(cmd, capture_output=True, text=True)
|
| 64 |
+
if res.returncode != 0:
|
| 65 |
+
sys.stderr.write(res.stderr)
|
| 66 |
+
sys.exit("error: esbuild failed to transpile the JSX (see above).")
|
| 67 |
+
with open(outp, "r", encoding="utf-8") as fh:
|
| 68 |
+
return fh.read()
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def main() -> None:
|
| 72 |
+
with open(SRC, "r", encoding="utf-8") as fh:
|
| 73 |
+
html = fh.read()
|
| 74 |
+
|
| 75 |
+
m = BABEL_BLOCK_RE.search(html)
|
| 76 |
+
if not m:
|
| 77 |
+
sys.exit('error: no <script type="text/babel"> block found in index.html')
|
| 78 |
+
|
| 79 |
+
js = transpile(m.group(1))
|
| 80 |
+
# Guard against any "</script>" inside string/template literals closing our tag early.
|
| 81 |
+
js = js.replace("</script", "<\\/script")
|
| 82 |
+
|
| 83 |
+
html = html[: m.start()] + "<script>\n" + js + "\n</script>" + html[m.end():]
|
| 84 |
+
html, n = BABEL_CDN_RE.subn("", html)
|
| 85 |
+
if n == 0:
|
| 86 |
+
print("warning: @babel/standalone CDN <script> not found to remove", file=sys.stderr)
|
| 87 |
+
|
| 88 |
+
header = "<!-- GENERATED by build_frontend.py from index.html — do not edit; edit index.html and rerun. -->\n"
|
| 89 |
+
with open(OUT, "w", encoding="utf-8") as fh:
|
| 90 |
+
fh.write(header + html)
|
| 91 |
+
|
| 92 |
+
print(f"wrote {os.path.relpath(OUT, BASE)} "
|
| 93 |
+
f"({len(html) / 1024:.0f} KB, JSX → {len(js) / 1024:.0f} KB plain JS, "
|
| 94 |
+
f"removed {n} babel CDN tag)")
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
if __name__ == "__main__":
|
| 98 |
+
main()
|
|
The diff for this file is too large to render.
See raw diff
|
|
|
|
@@ -3,7 +3,7 @@
|
|
| 3 |
<head>
|
| 4 |
<meta charset="UTF-8" />
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
-
<title>Sozai ·
|
| 7 |
<link rel="icon" type="image/svg+xml" href="/assets/icon.svg" />
|
| 8 |
|
| 9 |
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
@@ -109,6 +109,21 @@
|
|
| 109 |
* { @apply border-border outline-ring/50; }
|
| 110 |
body { @apply bg-background text-foreground; }
|
| 111 |
html { @apply font-sans; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
}
|
| 113 |
</style>
|
| 114 |
|
|
@@ -1195,6 +1210,62 @@
|
|
| 1195 |
};
|
| 1196 |
})();
|
| 1197 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1198 |
// Adapt a SozaiPhotos item to the shape that the map-view components expect.
|
| 1199 |
// x/y are deterministic synthetic percentages used for scrapbook pin positions.
|
| 1200 |
function storeToMoment(p, i) {
|
|
@@ -1246,6 +1317,177 @@
|
|
| 1246 |
useEffect(() => Collab.on("me", setM), []);
|
| 1247 |
return m;
|
| 1248 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1249 |
function initials(name) {
|
| 1250 |
const parts = (name || "Guest").trim().split(/\s+/);
|
| 1251 |
return ((parts[0]?.[0] || "G") + (parts[1]?.[0] || "")).toUpperCase();
|
|
@@ -1277,6 +1519,28 @@
|
|
| 1277 |
);
|
| 1278 |
}
|
| 1279 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1280 |
// ----------------------------------------------------------------- //
|
| 1281 |
// Collaboration bar (share code + link, presence, name, chat toggle)
|
| 1282 |
// ----------------------------------------------------------------- //
|
|
@@ -1355,6 +1619,8 @@
|
|
| 1355 |
</div>
|
| 1356 |
|
| 1357 |
<div className="flex items-center gap-3">
|
|
|
|
|
|
|
| 1358 |
{/* presence */}
|
| 1359 |
<div className="flex items-center gap-1.5">
|
| 1360 |
<Users className="size-4 text-primary" />
|
|
@@ -1652,7 +1918,10 @@
|
|
| 1652 |
}
|
| 1653 |
return (
|
| 1654 |
<div className="flex flex-col items-center gap-4">
|
| 1655 |
-
|
|
|
|
|
|
|
|
|
|
| 1656 |
<div className="absolute inset-0 translate-x-3 translate-y-3 rotate-2 rounded-md border-2 border-primary/30 bg-card" />
|
| 1657 |
<div className="absolute inset-0 translate-x-1.5 translate-y-1.5 rotate-1 rounded-md border-2 border-primary/40 bg-card" />
|
| 1658 |
{/* keyed by index so the slide animation re-triggers on each browse;
|
|
@@ -1804,7 +2073,7 @@
|
|
| 1804 |
<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>
|
| 1805 |
</form>
|
| 1806 |
<div ref={elRef} className="h-40 w-full overflow-hidden rounded-md border-2 border-primary/40" />
|
| 1807 |
-
<p className="mt-1.5 font-mono text-[10px] leading-snug text-muted-foreground">Drag the pin or tap the map
|
| 1808 |
<div className="mt-2 flex items-center gap-2">
|
| 1809 |
<span className="font-mono text-[10px] uppercase tracking-wide text-primary">Radius</span>
|
| 1810 |
<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" />
|
|
@@ -1837,7 +2106,7 @@
|
|
| 1837 |
// ----------------------------------------------------------------- //
|
| 1838 |
// components/details-form.tsx
|
| 1839 |
// ----------------------------------------------------------------- //
|
| 1840 |
-
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 }) {
|
| 1841 |
const CancelGen = () => (
|
| 1842 |
<button type="button" onClick={onCancelGen}
|
| 1843 |
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">
|
|
@@ -1852,6 +2121,13 @@
|
|
| 1852 |
const DEFAULT_TAGS_HINT = "List 3 to 6 short, comma-separated tags for this photo.";
|
| 1853 |
return (
|
| 1854 |
<div className="flex flex-col gap-6">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1855 |
{/* title */}
|
| 1856 |
<div className="space-y-2">
|
| 1857 |
<div className="flex items-center justify-between gap-2">
|
|
@@ -1865,9 +2141,10 @@
|
|
| 1865 |
showTitlePrompt ? "border-primary bg-accent text-foreground" : "border-primary/60 text-primary hover:bg-accent")}>
|
| 1866 |
<Settings className="size-3.5" /> Prompt
|
| 1867 |
</button>
|
| 1868 |
-
<button type="button" onClick={onAutoTitle} disabled={titleBusy}
|
| 1869 |
-
|
| 1870 |
-
|
|
|
|
| 1871 |
</button>
|
| 1872 |
</div>
|
| 1873 |
</div>
|
|
@@ -1937,9 +2214,10 @@
|
|
| 1937 |
showPrompt ? "border-primary bg-accent text-foreground" : "border-primary/60 text-primary hover:bg-accent")}>
|
| 1938 |
<Settings className="size-3.5" /> Prompt
|
| 1939 |
</button>
|
| 1940 |
-
<button type="button" onClick={onAutoCaption} disabled={autoBusy}
|
| 1941 |
-
|
| 1942 |
-
|
|
|
|
| 1943 |
</button>
|
| 1944 |
</div>
|
| 1945 |
</div>
|
|
@@ -2095,12 +2373,22 @@
|
|
| 2095 |
const [tagsBusy, setTagsBusy] = useState(false);
|
| 2096 |
const [tagsError, setTagsError] = useState(null);
|
| 2097 |
const [tagsStage, setTagsStage] = useState("");
|
|
|
|
| 2098 |
|
| 2099 |
-
//
|
| 2100 |
-
//
|
|
|
|
|
|
|
| 2101 |
useEffect(() => Collab.on("object_updated", (m) => {
|
| 2102 |
if (typeof m.index !== "number" || !m.kind) return;
|
| 2103 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2104 |
}), []);
|
| 2105 |
|
| 2106 |
// follow a collaborator to the photo they switch to (when follow is on)
|
|
@@ -2130,23 +2418,15 @@
|
|
| 2130 |
const date = (photo && photo.date != null && photo.date !== "") ? photo.date : DEFAULT_DATE;
|
| 2131 |
const time = (photo && photo.time != null && photo.time !== "") ? photo.time : DEFAULT_TIME;
|
| 2132 |
|
| 2133 |
-
//
|
| 2134 |
-
//
|
| 2135 |
-
useEffect(() => {
|
| 2136 |
-
stackPhotos.forEach((p) => {
|
| 2137 |
-
window.SozaiPhotos.upsert(p.id, {
|
| 2138 |
-
src: p.src, alt: p.alt || "",
|
| 2139 |
-
title: p.title || "", caption: p.caption || "",
|
| 2140 |
-
date: p.date || "", time: p.time || "",
|
| 2141 |
-
});
|
| 2142 |
-
});
|
| 2143 |
-
}, []); // eslint-disable-line
|
| 2144 |
|
| 2145 |
-
// Edit a field on the current photo
|
| 2146 |
-
//
|
|
|
|
|
|
|
| 2147 |
function makeFieldSetter(kind) {
|
| 2148 |
return (value) => {
|
| 2149 |
-
setStackPhotos((prev) => prev.map((p, i) => (i === safeIndex ? { ...p, [kind]: value } : p)));
|
| 2150 |
Collab.send("object_updated", { kind, index: safeIndex, value });
|
| 2151 |
const pid = photo && photo.id;
|
| 2152 |
if (pid) window.SozaiPhotos.upsert(pid, { [kind]: value });
|
|
@@ -2162,17 +2442,15 @@
|
|
| 2162 |
const locationName = (photo && photo.locationName) || "";
|
| 2163 |
const radius = (photo && photo.radius != null) ? photo.radius : null;
|
| 2164 |
// A location edit touches several fields at once (lat/lon/name/radius +
|
| 2165 |
-
// the pinned flag) -
|
| 2166 |
const setLocation = (patch) => {
|
| 2167 |
-
setStackPhotos((prev) => prev.map((p, i) => (i === safeIndex ? { ...p, ...patch } : p)));
|
| 2168 |
const pid = photo && photo.id;
|
| 2169 |
if (pid) window.SozaiPhotos.upsert(pid, patch);
|
| 2170 |
Collab.send("object_updated", { kind: "location", index: safeIndex, value: patch });
|
| 2171 |
};
|
| 2172 |
// reposition the focal point of the current photo (object-position %),
|
| 2173 |
-
// saved on the
|
| 2174 |
function onReposition(fx, fy) {
|
| 2175 |
-
setStackPhotos((prev) => prev.map((p, i) => (i === safeIndex ? { ...p, focusX: fx, focusY: fy } : p)));
|
| 2176 |
const pid = photo && photo.id;
|
| 2177 |
if (pid) window.SozaiPhotos.upsert(pid, { focusX: fx, focusY: fy });
|
| 2178 |
}
|
|
@@ -2218,14 +2496,13 @@
|
|
| 2218 |
};
|
| 2219 |
const id = window.SozaiPhotos.add(newPhoto);
|
| 2220 |
newPhoto.id = id;
|
| 2221 |
-
|
| 2222 |
added++;
|
| 2223 |
// resolve a readable "general area" for the EXIF GPS, in the background
|
| 2224 |
if (exif.lat != null) {
|
| 2225 |
reverseGeocode(exif.lat, exif.lon).then((nm) => {
|
| 2226 |
if (!nm) return;
|
| 2227 |
window.SozaiPhotos.upsert(id, { locationName: nm });
|
| 2228 |
-
setStackPhotos((prev) => prev.map((p) => (p.id === id ? { ...p, locationName: nm } : p)));
|
| 2229 |
});
|
| 2230 |
}
|
| 2231 |
}
|
|
@@ -2244,9 +2521,7 @@
|
|
| 2244 |
title: s.title || "", caption: s.caption || "",
|
| 2245 |
date: DEFAULT_DATE, time: DEFAULT_TIME,
|
| 2246 |
};
|
| 2247 |
-
|
| 2248 |
-
newPhoto.id = id;
|
| 2249 |
-
setStackPhotos((prev) => [...prev, newPhoto]);
|
| 2250 |
setPhotoIndex(base);
|
| 2251 |
}
|
| 2252 |
// "Try a sample" — load the whole built-in photo set at once (with their
|
|
@@ -2263,7 +2538,7 @@
|
|
| 2263 |
date: p.date, time: p.time, lat: p.lat, lon: p.lon,
|
| 2264 |
pinned: p.lat != null && p.lon != null,
|
| 2265 |
}));
|
| 2266 |
-
|
| 2267 |
setPhotoIndex(0);
|
| 2268 |
}
|
| 2269 |
|
|
@@ -2303,7 +2578,7 @@
|
|
| 2303 |
if (typeof idx !== "number") idx = safeIndex;
|
| 2304 |
const removedId = stackPhotos[idx] && stackPhotos[idx].id;
|
| 2305 |
if (removedId) window.SozaiPhotos.remove(removedId);
|
| 2306 |
-
|
| 2307 |
setPhotoIndex((prev) => {
|
| 2308 |
const next = prev > idx ? prev - 1 : prev;
|
| 2309 |
return Math.max(0, next);
|
|
@@ -2325,6 +2600,7 @@
|
|
| 2325 |
setAutoBusy(false); setAutoStage("");
|
| 2326 |
setTitleBusy(false); setTitleStage("");
|
| 2327 |
setTagsBusy(false); setTagsStage("");
|
|
|
|
| 2328 |
}
|
| 2329 |
|
| 2330 |
function changePhoto(delta) {
|
|
@@ -2361,6 +2637,9 @@
|
|
| 2361 |
async function handleAutoCaption() {
|
| 2362 |
if (autoBusy) return;
|
| 2363 |
if (!photo) return;
|
|
|
|
|
|
|
|
|
|
| 2364 |
if (window.sozaiPing) window.sozaiPing("✨ auto-generating caption");
|
| 2365 |
setAutoBusy(true); setAutoError(null);
|
| 2366 |
const token = { cancelled: false, timers: [] };
|
|
@@ -2381,6 +2660,7 @@
|
|
| 2381 |
if (token.cancelled) return; // cancelled mid-flight: drop it
|
| 2382 |
fn(); setAutoBusy(false); setAutoStage("");
|
| 2383 |
if (genRef.current === token) genRef.current = null;
|
|
|
|
| 2384 |
}, wait);
|
| 2385 |
token.timers.push(t);
|
| 2386 |
};
|
|
@@ -2406,6 +2686,9 @@
|
|
| 2406 |
async function handleAutoTitle() {
|
| 2407 |
if (titleBusy) return;
|
| 2408 |
if (!photo) return;
|
|
|
|
|
|
|
|
|
|
| 2409 |
if (window.sozaiPing) window.sozaiPing("✨ auto-generating title");
|
| 2410 |
setTitleBusy(true); setTitleError(null);
|
| 2411 |
const token = { cancelled: false, timers: [] };
|
|
@@ -2425,6 +2708,7 @@
|
|
| 2425 |
if (token.cancelled) return;
|
| 2426 |
fn(); setTitleBusy(false); setTitleStage("");
|
| 2427 |
if (genRef.current === token) genRef.current = null;
|
|
|
|
| 2428 |
}, wait);
|
| 2429 |
token.timers.push(t);
|
| 2430 |
};
|
|
@@ -2477,6 +2761,9 @@
|
|
| 2477 |
async function handleAutoTags() {
|
| 2478 |
if (tagsBusy) return;
|
| 2479 |
if (!photo) return;
|
|
|
|
|
|
|
|
|
|
| 2480 |
if (window.sozaiPing) window.sozaiPing("🏷️ auto-generating tags");
|
| 2481 |
setTagsBusy(true); setTagsError(null);
|
| 2482 |
const token = { cancelled: false, timers: [] };
|
|
@@ -2496,6 +2783,7 @@
|
|
| 2496 |
if (token.cancelled) return;
|
| 2497 |
fn(); setTagsBusy(false); setTagsStage("");
|
| 2498 |
if (genRef.current === token) genRef.current = null;
|
|
|
|
| 2499 |
}, wait);
|
| 2500 |
token.timers.push(t);
|
| 2501 |
};
|
|
@@ -2550,10 +2838,9 @@
|
|
| 2550 |
return;
|
| 2551 |
}
|
| 2552 |
setValidationError(null);
|
| 2553 |
-
//
|
| 2554 |
-
//
|
| 2555 |
-
//
|
| 2556 |
-
// shows "0 photos". GPS photos auto-pin onto the globe like the seed does.
|
| 2557 |
stackPhotos.forEach((p) => window.SozaiPhotos.upsert(p.id, {
|
| 2558 |
src: p.src, alt: p.alt || "",
|
| 2559 |
title: p.title || p.defaultTitle || "",
|
|
@@ -2583,7 +2870,7 @@
|
|
| 2583 |
return (
|
| 2584 |
<section className="w-full max-w-6xl rounded-xl border-2 border-primary bg-card shadow-lg">
|
| 2585 |
<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">
|
| 2586 |
-
<div className="flex items-center gap-2.5"><h1 className="font-display text-2xl text-[#f5edd8]">
|
| 2587 |
<div className="flex items-center gap-2">
|
| 2588 |
<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>
|
| 2589 |
</div>
|
|
@@ -2642,17 +2929,18 @@
|
|
| 2642 |
titlePrompt={settings.titlePrompt || ""} onTitlePromptChange={setTitlePrompt}
|
| 2643 |
onAutoTags={handleAutoTags} tagsBusy={tagsBusy} tagsStage={tagsStage} tagsError={tagsError}
|
| 2644 |
tagsPrompt={settings.tagsPrompt || ""} onTagsPromptChange={(v) => update({ tagsPrompt: v })}
|
| 2645 |
-
onCancelGen={cancelGeneration}
|
|
|
|
| 2646 |
|
| 2647 |
{/* choose how the Map of My Time will look once developed */}
|
| 2648 |
<MapStylePicker variant={mapVariant} onChange={setMapVariant} />
|
| 2649 |
|
| 2650 |
{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}
|
| 2651 |
{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}
|
| 2652 |
-
{/*
|
| 2653 |
<button type="button" onClick={handleSubmit}
|
| 2654 |
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">
|
| 2655 |
-
|
| 2656 |
</button>
|
| 2657 |
</div>
|
| 2658 |
</div>
|
|
@@ -2701,18 +2989,23 @@
|
|
| 2701 |
// components/map/moment-card.tsx
|
| 2702 |
// ----------------------------------------------------------------- //
|
| 2703 |
function MomentCard({ moment, selected, onSelect, rotate = "" }) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2704 |
return (
|
| 2705 |
<button type="button" onClick={() => onSelect && onSelect(moment.id)} style={{ left: `${moment.x}%`, top: `${moment.y}%` }}
|
| 2706 |
-
className={cn("group absolute z-10 w-
|
| 2707 |
<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" />
|
| 2708 |
-
<div className="relative aspect-square w-full overflow-hidden rounded-sm border border-primary/30"><Img src={moment.src} alt={moment.alt} fill sizes="
|
| 2709 |
<div className="px-0.5 pb-0.5 pt-2">
|
| 2710 |
<div className="flex items-start justify-between gap-1">
|
| 2711 |
-
<h3 className="font-serif text-
|
| 2712 |
{moment.favorite ? <Star className="mt-0.5 size-3.5 shrink-0 fill-primary text-primary" /> : null}
|
| 2713 |
</div>
|
| 2714 |
-
<p className="mt-1 font-hand text-[
|
| 2715 |
-
<p className="mt-1.5 font-mono text-[10px] text-primary/70">{
|
| 2716 |
</div>
|
| 2717 |
</button>
|
| 2718 |
);
|
|
@@ -3292,6 +3585,7 @@
|
|
| 3292 |
function MicButton({ field, onText, title }) {
|
| 3293 |
const { settings, update } = useSettings();
|
| 3294 |
const asrCfg = useAsrConfig();
|
|
|
|
| 3295 |
const [state, setState] = useState("idle"); // idle | recording | working | error
|
| 3296 |
const [lang, setLang] = useState("");
|
| 3297 |
const [note, setNote] = useState("");
|
|
@@ -3306,12 +3600,16 @@
|
|
| 3306 |
|
| 3307 |
async function start() {
|
| 3308 |
setNote(""); setLang("");
|
|
|
|
| 3309 |
if (asrCfg && asrCfg.osSupported === false) {
|
| 3310 |
setState("error"); setNote("Nemotron is not supported on this OS."); return;
|
| 3311 |
}
|
| 3312 |
if (!navigator.mediaDevices || typeof MediaRecorder === "undefined") {
|
| 3313 |
setState("error"); setNote("Recording not supported here."); return;
|
| 3314 |
}
|
|
|
|
|
|
|
|
|
|
| 3315 |
try {
|
| 3316 |
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
| 3317 |
streamRef.current = stream;
|
|
@@ -3326,6 +3624,7 @@
|
|
| 3326 |
if (window.sozaiPing) window.sozaiPing("🎤 recording " + field);
|
| 3327 |
} catch (e) {
|
| 3328 |
setState("error"); setNote("Microphone blocked.");
|
|
|
|
| 3329 |
}
|
| 3330 |
}
|
| 3331 |
function stop() {
|
|
@@ -3356,20 +3655,23 @@
|
|
| 3356 |
}
|
| 3357 |
} catch (e) {
|
| 3358 |
setState("error"); setNote("Transcription failed.");
|
|
|
|
|
|
|
| 3359 |
}
|
| 3360 |
}
|
| 3361 |
|
| 3362 |
const recording = state === "recording";
|
| 3363 |
const working = state === "working";
|
| 3364 |
const osBlocked = asrCfg && asrCfg.osSupported === false;
|
|
|
|
| 3365 |
return (
|
| 3366 |
<span className="relative inline-flex items-center gap-1.5">
|
| 3367 |
<button type="button"
|
| 3368 |
onClick={() => (recording ? stop() : start())}
|
| 3369 |
-
disabled={
|
| 3370 |
aria-pressed={recording}
|
| 3371 |
-
title={osBlocked ? "Nemotron is not supported on this OS" : (title || ("Dictate " + field))}
|
| 3372 |
-
className={cn("flex size-7 items-center justify-center rounded-full border-2 transition-colors disabled:opacity-60",
|
| 3373 |
recording ? "border-[oklch(0.55_0.2_25)] bg-[oklch(0.55_0.2_25)] text-white animate-pulse"
|
| 3374 |
: "border-primary/60 text-primary hover:bg-accent")}>
|
| 3375 |
{working ? <Loader2 className="size-3.5 animate-spin" /> : recording ? <Square className="size-3" /> : <Mic className="size-3.5" />}
|
|
@@ -4037,6 +4339,18 @@
|
|
| 4037 |
];
|
| 4038 |
const [transport, setTransport] = useState("car");
|
| 4039 |
const transportMarkerRef = useRef(null);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4040 |
// recording the journey to a downloadable clip (item 5)
|
| 4041 |
const [recording, setRecording] = useState(false);
|
| 4042 |
const recorderRef = useRef(null);
|
|
@@ -4206,7 +4520,7 @@
|
|
| 4206 |
el.className = "sozai-polaroid";
|
| 4207 |
el.style.setProperty("--rot", (p.rot || 0) + "deg");
|
| 4208 |
el.innerHTML =
|
| 4209 |
-
'<img src="' + p.src + '" alt="">' +
|
| 4210 |
(p.caption ? '<div class="cap">' + p.caption.replace(/</g, "<") + '</div>' : '<div class="cap"> </div>');
|
| 4211 |
const del = document.createElement("button");
|
| 4212 |
del.className = "pdel"; del.type = "button"; del.title = "Remove"; del.textContent = "×";
|
|
@@ -4245,7 +4559,7 @@
|
|
| 4245 |
const meta = p.date ? _dmy(p.date) : "";
|
| 4246 |
const objPos = (p.focusX != null ? p.focusX : 50) + "% " + (p.focusY != null ? p.focusY : 50) + "%";
|
| 4247 |
el.innerHTML =
|
| 4248 |
-
'<img src="' + p.src + '" alt="" style="object-position:' + objPos + '">' +
|
| 4249 |
'<div class="cap">' + ((p.caption || p.title || " ").replace(/</g, "<")) +
|
| 4250 |
(meta ? '<span class="sozai-pin-when">' + meta.replace(/</g, "<") + '</span>' : '') +
|
| 4251 |
'</div>';
|
|
@@ -4292,7 +4606,7 @@
|
|
| 4292 |
marker.on("dragend", () => wrap.classList.remove("is-dragging"));
|
| 4293 |
storeMarkersRef.current[p.id] = { marker, el: wrap };
|
| 4294 |
});
|
| 4295 |
-
}, [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]);
|
| 4296 |
|
| 4297 |
// highlight the active stop during a flythrough
|
| 4298 |
useEffect(() => {
|
|
@@ -4331,7 +4645,22 @@
|
|
| 4331 |
}, 420));
|
| 4332 |
return () => { cancelAnimationFrame(raf); ts.forEach(clearTimeout); };
|
| 4333 |
}, [isFullscreen]);
|
| 4334 |
-
function toggleFullscreen() {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4335 |
|
| 4336 |
async function searchCity() {
|
| 4337 |
const q = place.trim();
|
|
@@ -4415,12 +4744,21 @@
|
|
| 4415 |
}
|
| 4416 |
|
| 4417 |
// ---- flythrough ("Map of My Time" video) ---- //
|
| 4418 |
-
function stopFly() {
|
|
|
|
| 4419 |
flyAbortRef.current.stop = true;
|
|
|
|
| 4420 |
setFlying(false);
|
| 4421 |
setFlyIdx(-1);
|
| 4422 |
if (transportMarkerRef.current) { try { transportMarkerRef.current.remove(); } catch (e) {} transportMarkerRef.current = null; }
|
| 4423 |
stopRecording();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4424 |
}
|
| 4425 |
|
| 4426 |
// move the transport emoji marker to a lng/lat (creating it if needed)
|
|
@@ -4465,12 +4803,31 @@
|
|
| 4465 |
|
| 4466 |
async function playFly(opts) {
|
| 4467 |
opts = opts || {};
|
|
|
|
| 4468 |
const map = mapRef.current;
|
| 4469 |
if (!map || flyStops.length === 0) return;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4470 |
flyAbortRef.current = { stop: false };
|
| 4471 |
const token = flyAbortRef.current;
|
| 4472 |
setFlying(true);
|
| 4473 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4474 |
|
| 4475 |
// draw the route as a line under the journey
|
| 4476 |
const routeCoords = flyStops.map((s) => [s.lon, s.lat]);
|
|
@@ -4486,7 +4843,7 @@
|
|
| 4486 |
|
| 4487 |
const spd = (TRANSPORTS.find((t) => t.id === transport) || TRANSPORTS[0]).speed;
|
| 4488 |
// start at the first stop
|
| 4489 |
-
setFlyIdx(0);
|
| 4490 |
setTransportAt(flyStops[0].lon, flyStops[0].lat);
|
| 4491 |
map.flyTo({ center: [flyStops[0].lon, flyStops[0].lat], zoom: 15, pitch: 50, duration: 1400, essential: true });
|
| 4492 |
await new Promise((r) => setTimeout(r, 1500));
|
|
@@ -4513,7 +4870,7 @@
|
|
| 4513 |
requestAnimationFrame(step);
|
| 4514 |
});
|
| 4515 |
if (token.stop) break;
|
| 4516 |
-
setFlyIdx(i);
|
| 4517 |
map.flyTo({ center: [b.lon, b.lat], zoom: 15.5, pitch: 55, duration: 1100, essential: true });
|
| 4518 |
await new Promise((r) => setTimeout(r, 1300)); // dwell to show the photo
|
| 4519 |
}
|
|
@@ -4529,7 +4886,38 @@
|
|
| 4529 |
stopRecording();
|
| 4530 |
setFlying(false);
|
| 4531 |
setFlyIdx(-1);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4532 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4533 |
useEffect(() => () => { flyAbortRef.current.stop = true; stopRecording(); }, []);
|
| 4534 |
|
| 4535 |
if (libMissing) {
|
|
@@ -4598,8 +4986,8 @@
|
|
| 4598 |
</span>
|
| 4599 |
<div className="flex flex-wrap items-center gap-1.5">
|
| 4600 |
{TRANSPORTS.map((t) => (
|
| 4601 |
-
<button key={t.id} type="button" onClick={() =>
|
| 4602 |
-
disabled={flying}
|
| 4603 |
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",
|
| 4604 |
transport === t.id ? "border-primary bg-accent text-foreground" : "border-primary/40 text-muted-foreground hover:bg-accent/50")}>
|
| 4605 |
<span aria-hidden="true">{t.emoji}</span>{t.label}
|
|
@@ -4607,11 +4995,13 @@
|
|
| 4607 |
))}
|
| 4608 |
</div>
|
| 4609 |
<button type="button" onClick={() => (flying ? stopFly() : playFly({ record: false }))}
|
| 4610 |
-
|
| 4611 |
-
{
|
|
|
|
|
|
|
| 4612 |
</button>
|
| 4613 |
-
<button type="button" onClick={() => (flying ? stopFly() : playFly({ record: true }))} disabled={flying}
|
| 4614 |
-
title="Play the journey and save it as a video clip"
|
| 4615 |
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">
|
| 4616 |
<span className={cn("size-2.5 rounded-full", recording ? "animate-pulse bg-[oklch(0.55_0.2_25)]" : "bg-primary")} /> {recording ? "Recording…" : "Record clip"}
|
| 4617 |
</button>
|
|
@@ -4621,6 +5011,9 @@
|
|
| 4621 |
<Download className="size-3.5" /> Download clip
|
| 4622 |
</a>
|
| 4623 |
) : null}
|
|
|
|
|
|
|
|
|
|
| 4624 |
</div>
|
| 4625 |
) : null}
|
| 4626 |
|
|
@@ -4670,10 +5063,11 @@
|
|
| 4670 |
{/* flythrough control (only when there are pinned album photos) */}
|
| 4671 |
{flyStops.length > 0 ? (
|
| 4672 |
<button type="button" onClick={() => (flying ? stopFly() : playFly())}
|
| 4673 |
-
|
| 4674 |
-
|
|
|
|
| 4675 |
{flying ? <X className="size-4" /> : <FastForward className="size-4" />}
|
| 4676 |
-
{flying ? "Stop" : "Play journey"}
|
| 4677 |
</button>
|
| 4678 |
) : null}
|
| 4679 |
|
|
@@ -4907,6 +5301,16 @@
|
|
| 4907 |
];
|
| 4908 |
const [expanded, setExpanded] = useState(false); // in-page immersive view
|
| 4909 |
const [dropArmed, setDropArmed] = useState(false); // drag-from-tray hint
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4910 |
const [armedId, setArmedId] = useState(null); // tap-to-pin armed photo
|
| 4911 |
|
| 4912 |
// tap-to-pin: arm a photo, then the next globe click pins it
|
|
@@ -4962,6 +5366,26 @@
|
|
| 4962 |
});
|
| 4963 |
viewerRef.current = viewer;
|
| 4964 |
try { viewer.clock.shouldAnimate = false; } catch (e) {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4965 |
viewer.scene.camera.setView({
|
| 4966 |
destination: Cesium.Cartesian3.fromDegrees(-123.1207, 49.2827, 1200),
|
| 4967 |
orientation: { heading: Cesium.Math.toRadians(10), pitch: Cesium.Math.toRadians(-25) },
|
|
@@ -5004,6 +5428,24 @@
|
|
| 5004 |
if (cancelled) { viewer.destroy(); return; }
|
| 5005 |
viewer.scene.primitives.add(tileset);
|
| 5006 |
tilesetRef.current = tileset;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5007 |
tileset.style = buildCesiumStyle(Cesium, "watercolor", colorway);
|
| 5008 |
} catch (e) { console.warn("[cesium] OSM buildings failed", e); }
|
| 5009 |
if (!cancelled) setStatus("ready");
|
|
@@ -5089,7 +5531,7 @@
|
|
| 5089 |
});
|
| 5090 |
entitiesRef.current.push(ent);
|
| 5091 |
});
|
| 5092 |
-
}, [pinnedStore.map((p) => p.id + ":" + p.lon + "," + p.lat + ":" + p.caption).join("|"), status]);
|
| 5093 |
|
| 5094 |
// tap-to-pin + drag-to-move on the globe.
|
| 5095 |
const armedRef = useRef(null);
|
|
@@ -5158,8 +5600,10 @@
|
|
| 5158 |
if (viewer && vehicleRef.current) { try { viewer.entities.remove(vehicleRef.current); } catch (e) {} }
|
| 5159 |
vehicleRef.current = null;
|
| 5160 |
}
|
| 5161 |
-
function stopFly() {
|
|
|
|
| 5162 |
flyAbortRef.current.stop = true;
|
|
|
|
| 5163 |
setFlying(false); setFlyIdx(-1);
|
| 5164 |
const viewer = viewerRef.current;
|
| 5165 |
if (viewer) {
|
|
@@ -5169,10 +5613,24 @@
|
|
| 5169 |
try { if (viewer.entities.getById("sozai-route")) viewer.entities.removeById("sozai-route"); } catch (e) {}
|
| 5170 |
}
|
| 5171 |
removeVehicle();
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5172 |
}
|
| 5173 |
-
async function playFly() {
|
|
|
|
|
|
|
| 5174 |
const viewer = viewerRef.current;
|
| 5175 |
if (!viewer || !window.Cesium || flyStops.length < 1) return;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5176 |
const Cesium = window.Cesium;
|
| 5177 |
flyAbortRef.current = { stop: false };
|
| 5178 |
const token = flyAbortRef.current;
|
|
@@ -5275,7 +5733,9 @@
|
|
| 5275 |
}
|
| 5276 |
} catch (e) {}
|
| 5277 |
if (nextStop < stopTimes.length && t >= stopTimes[nextStop] - 0.2) {
|
| 5278 |
-
setFlyIdx(nextStop);
|
|
|
|
|
|
|
| 5279 |
}
|
| 5280 |
});
|
| 5281 |
clockTickRef.current = () => { try { onTick(); } catch (e) {} };
|
|
@@ -5293,17 +5753,49 @@
|
|
| 5293 |
if (clockTickRef.current) { clockTickRef.current(); clockTickRef.current = null; }
|
| 5294 |
viewer.clock.shouldAnimate = false;
|
| 5295 |
if (!token.stop && flyStops.length) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5296 |
const lons = flyStops.map((s) => s.lon), lats = flyStops.map((s) => s.lat);
|
| 5297 |
const cx = (Math.min(...lons) + Math.max(...lons)) / 2, cy = (Math.min(...lats) + Math.max(...lats)) / 2;
|
| 5298 |
-
viewer.scene.camera.flyTo({ destination: Cesium.Cartesian3.fromDegrees(cx, cy - 0.02,
|
| 5299 |
orientation: { heading: 0, pitch: Cesium.Math.toRadians(-45) }, duration: 2.4 });
|
| 5300 |
await new Promise((r) => setTimeout(r, 2400));
|
|
|
|
| 5301 |
}
|
| 5302 |
removeVehicle();
|
| 5303 |
try { if (viewer.entities.getById("sozai-route")) viewer.entities.removeById("sozai-route"); } catch (e) {}
|
| 5304 |
setFlying(false); setFlyIdx(-1);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5305 |
}
|
| 5306 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5307 |
if (status === "no-token") {
|
| 5308 |
return (
|
| 5309 |
<div className="rounded-lg border-2 border-primary/40 bg-accent/30 p-6 text-center">
|
|
@@ -5377,13 +5869,16 @@
|
|
| 5377 |
</span>
|
| 5378 |
<div className="flex flex-wrap items-center gap-1.5">
|
| 5379 |
{CS_TRANSPORTS.map((t) => (
|
| 5380 |
-
<button key={t.id} type="button" onClick={() =>
|
| 5381 |
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",
|
| 5382 |
transport === t.id ? "border-primary bg-accent text-foreground" : "border-primary/40 text-muted-foreground hover:bg-accent/50")}>
|
| 5383 |
<span aria-hidden="true">{t.emoji}</span>{t.label}
|
| 5384 |
</button>
|
| 5385 |
))}
|
| 5386 |
</div>
|
|
|
|
|
|
|
|
|
|
| 5387 |
</div>
|
| 5388 |
) : null}
|
| 5389 |
|
|
@@ -5428,8 +5923,10 @@
|
|
| 5428 |
) : null}
|
| 5429 |
{flyStops.length > 0 && status === "ready" ? (
|
| 5430 |
<button type="button" onClick={() => (flying ? stopFly() : playFly())}
|
| 5431 |
-
|
| 5432 |
-
|
|
|
|
|
|
|
| 5433 |
</button>
|
| 5434 |
) : null}
|
| 5435 |
{flying && flyStops[flyIdx] ? (
|
|
@@ -6195,7 +6692,7 @@
|
|
| 6195 |
<div className="relative w-full overflow-hidden rounded-lg border-2 border-primary/40 bg-[oklch(0.95_0.018_85)]">
|
| 6196 |
<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" />
|
| 6197 |
<RouteLines moments={liveMoments} />
|
| 6198 |
-
<div className="relative h-[
|
| 6199 |
{liveMoments.map((m, i) => <MomentCard key={m.id} moment={m} rotate={rotations[i % rotations.length]} selected={selectedId === m.id} onSelect={setSelectedId} />)}
|
| 6200 |
</div>
|
| 6201 |
</div>
|
|
@@ -6521,10 +7018,10 @@
|
|
| 6521 |
|
| 6522 |
// baths kept from the zip (colors + labels), seen from above
|
| 6523 |
const DR_BATHS = [
|
| 6524 |
-
{ id: "developer", label: "Developer", color: "oklch(0.
|
| 6525 |
-
{ id: "stop", label: "Stop Bath", color: "oklch(0.
|
| 6526 |
-
{ id: "fixer", label: "Fixer", color: "oklch(0.
|
| 6527 |
-
{ id: "wash", label: "Wash", color: "oklch(0.
|
| 6528 |
];
|
| 6529 |
// top-down layout - columns are now driven by flex, not % coordinates
|
| 6530 |
|
|
@@ -6777,7 +7274,8 @@
|
|
| 6777 |
// ---- polaroid print card (shared by both bath phases) --------------- //
|
| 6778 |
function PrintCard({ sheet }) {
|
| 6779 |
const bf = drBathFilter(sheet.level);
|
| 6780 |
-
|
|
|
|
| 6781 |
return (
|
| 6782 |
<div className="w-full" style={{
|
| 6783 |
transform: "rotate(" + sheet.rot + "deg) scale(" + sheet.scale + ")",
|
|
@@ -6885,7 +7383,7 @@
|
|
| 6885 |
style={{ background: "radial-gradient(120% 100% at 50% 0%, oklch(0.20 0.04 55), oklch(0.10 0.02 55) 70%)",
|
| 6886 |
boxShadow: "inset 0 0 70px rgba(0,0,0,0.55)" }}>
|
| 6887 |
|
| 6888 |
-
<div className="flex items-end gap-3 px-4 pb-4 pt-10">
|
| 6889 |
{DR_BATHS.map((b, i) => {
|
| 6890 |
const aboveCards = sheets.filter((s) => s.col === i && s.above);
|
| 6891 |
const subCards = sheets.filter((s) => s.col === i && !s.above);
|
|
@@ -6906,11 +7404,11 @@
|
|
| 6906 |
style={{ aspectRatio: "1 / 1.5", background: "oklch(0.12 0.02 55)", boxShadow: "0 6px 16px rgba(0,0,0,0.5)" }}>
|
| 6907 |
<div className="relative h-full w-full overflow-hidden rounded-[6px]"
|
| 6908 |
style={{ background: "radial-gradient(120% 120% at 50% 0%, " + b.color + ", " + b.edge + ")" }}>
|
| 6909 |
-
|
| 6910 |
-
|
| 6911 |
-
<div className="absolute inset-0" style={{
|
| 6912 |
-
background: "linear-gradient(180deg, rgba(255,255,255,0.
|
| 6913 |
-
<div className="absolute inset-0" style={{ boxShadow: "inset 0 0 24px rgba(0,0,0,0.5)", zIndex: 3 }} />
|
| 6914 |
{/* submerged card lives inside its tray */}
|
| 6915 |
{subCards.map((s) => (
|
| 6916 |
<div key={s.id} className="absolute" style={{ inset: "10%", zIndex: 4 + s.z }}>
|
|
@@ -6959,25 +7457,65 @@
|
|
| 6959 |
}
|
| 6960 |
|
| 6961 |
// ---- phase 2: hanging print, shake to reveal (ported) -------------- //
|
| 6962 |
-
|
| 6963 |
-
function HangingPrint({ photo, index, swayDelay, onRevealed, widthClass }) {
|
| 6964 |
const [progress, setProgress] = useState(0);
|
| 6965 |
const [revealed, setRevealed] = useState(false);
|
| 6966 |
const [dragging, setDragging] = useState(false);
|
| 6967 |
const [tilt, setTilt] = useState(0);
|
| 6968 |
-
const dragState = useRef({ active: false, lastX: 0,
|
| 6969 |
const progressRef = useRef(0);
|
|
|
|
|
|
|
| 6970 |
|
| 6971 |
useEffect(() => { if (revealed) onRevealed(photo.id); }, [revealed, onRevealed, photo.id]);
|
| 6972 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6973 |
// Kick off the watercolour "develop" (flux.2-klein img2img) the moment the
|
| 6974 |
// print is hung, so the result is ready by the time it's shaken in. The
|
| 6975 |
// shake then reveals the DEVELOPED image; if the endpoint is unavailable
|
| 6976 |
// or fails, we fall back to the original photo (no regression).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6977 |
const [developedSrc, setDevelopedSrc] = useState(null);
|
| 6978 |
const [devStatus, setDevStatus] = useState("queued"); // queued|developing|done|base
|
| 6979 |
const developRef = useRef(false);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6980 |
useEffect(() => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6981 |
if (developRef.current || !photo.src) return;
|
| 6982 |
developRef.current = true;
|
| 6983 |
let alive = true;
|
|
@@ -7011,9 +7549,13 @@
|
|
| 7011 |
if (j && j.available && j.image) {
|
| 7012 |
if (alive) {
|
| 7013 |
setDevelopedSrc(j.image); setDevStatus("done");
|
|
|
|
|
|
|
|
|
|
| 7014 |
// write the DEVELOPED watercolour + its pin back into the shared
|
| 7015 |
// store so the map shows the transformed photo, auto-pinned at
|
| 7016 |
-
// its GPS location
|
|
|
|
| 7017 |
try {
|
| 7018 |
window.SozaiPhotos.upsert(photo.id, {
|
| 7019 |
src: j.image,
|
|
@@ -7028,47 +7570,72 @@
|
|
| 7028 |
return;
|
| 7029 |
}
|
| 7030 |
if (j && j.available === false) { // feature genuinely off
|
| 7031 |
-
if (alive) setDevStatus("base");
|
|
|
|
| 7032 |
}
|
| 7033 |
}
|
| 7034 |
} catch (e) { console.warn("[develop] attempt", attempt, "failed", e); }
|
| 7035 |
await sleep(5000);
|
| 7036 |
}
|
| 7037 |
-
if (alive) setDevStatus("base"); // gave up → keep the original
|
| 7038 |
})();
|
| 7039 |
return () => { alive = false; };
|
| 7040 |
-
}, [photo.src, photo.alt, index]);
|
| 7041 |
-
|
| 7042 |
-
|
| 7043 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7044 |
progressRef.current = next; setProgress(next);
|
| 7045 |
-
if (next >= 1
|
| 7046 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7047 |
|
| 7048 |
// No shaking until the watercolour has actually developed (or the develop
|
| 7049 |
// feature is off / gave up, where "base" means we reveal the original).
|
| 7050 |
const canShake = devStatus === "done" || devStatus === "base";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7051 |
function onPointerDown(e) {
|
| 7052 |
if (revealed || !canShake) return;
|
| 7053 |
try { e.currentTarget.setPointerCapture(e.pointerId); } catch (err) {}
|
| 7054 |
-
dragState.current = { active: true, lastX: e.clientX,
|
| 7055 |
setDragging(true);
|
| 7056 |
}
|
| 7057 |
function onPointerMove(e) {
|
| 7058 |
const s = dragState.current;
|
| 7059 |
if (!s.active || revealed || !canShake) return;
|
| 7060 |
const dx = e.clientX - s.lastX;
|
| 7061 |
-
|
| 7062 |
-
const
|
| 7063 |
-
if (
|
| 7064 |
-
|
|
|
|
| 7065 |
s.offset = Math.max(-26, Math.min(26, s.offset + dx));
|
| 7066 |
-
|
|
|
|
|
|
|
|
|
|
| 7067 |
}
|
| 7068 |
function endDrag(e) {
|
| 7069 |
dragState.current.active = false; setDragging(false); setTilt(0);
|
|
|
|
| 7070 |
try { e.currentTarget.releasePointerCapture(e.pointerId); } catch (err) {}
|
| 7071 |
}
|
|
|
|
| 7072 |
|
| 7073 |
const blurPx = (1 - progress) * 14;
|
| 7074 |
const grayscale = (1 - progress) * 100;
|
|
@@ -7082,6 +7649,9 @@
|
|
| 7082 |
className={cn("group relative -mt-1.5 select-none rounded-sm bg-[oklch(0.99_0.01_85)] p-3 pb-16 shadow-2xl",
|
| 7083 |
dragging ? "" : "dr-sway", revealed ? "cursor-default" : (canShake ? "cursor-grab active:cursor-grabbing" : "cursor-progress"))}
|
| 7084 |
style={{ animationDelay: swayDelay + "s",
|
|
|
|
|
|
|
|
|
|
| 7085 |
transform: dragging ? "rotate(" + (tilt * 0.3) + "deg) translateX(" + (tilt * 0.4) + "px)" : undefined,
|
| 7086 |
transition: dragging ? "none" : "transform 0.4s ease" }}
|
| 7087 |
onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={endDrag} onPointerCancel={endDrag}>
|
|
@@ -7123,11 +7693,65 @@
|
|
| 7123 |
const [muted, setMuted] = useState(false);
|
| 7124 |
const sfx = useWaterSfx(muted);
|
| 7125 |
const allRevealed = revealed.size === list.length;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7126 |
|
| 7127 |
const handleRevealed = useCallback((id) => {
|
| 7128 |
setRevealed((prev) => { const next = new Set(prev); next.add(id); return next; });
|
| 7129 |
}, []);
|
| 7130 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7131 |
return (
|
| 7132 |
<div className="fixed inset-0 z-50 flex flex-col overflow-hidden bg-[oklch(0.12_0.022_55)]">
|
| 7133 |
<style dangerouslySetInnerHTML={{ __html:
|
|
@@ -7174,11 +7798,12 @@
|
|
| 7174 |
</button>
|
| 7175 |
</div>
|
| 7176 |
|
| 7177 |
-
{/* centred content
|
| 7178 |
-
|
|
|
|
| 7179 |
<div className="text-center">
|
| 7180 |
-
<h1 className="font-display text-
|
| 7181 |
-
<p className="mt-3 font-serif italic text-
|
| 7182 |
{phase === "baths"
|
| 7183 |
? "developing your photos"
|
| 7184 |
: allRevealed ? "your photos are ready"
|
|
@@ -7187,7 +7812,7 @@
|
|
| 7187 |
</div>
|
| 7188 |
|
| 7189 |
{phase === "baths" ? (
|
| 7190 |
-
<TopDownBaths photos={list} sfx={sfx} onComplete={() =>
|
| 7191 |
) : (
|
| 7192 |
<div className="flex w-full flex-col items-center">
|
| 7193 |
<div className="relative w-full">
|
|
@@ -7200,7 +7825,7 @@
|
|
| 7200 |
: list.length <= 4 ? "w-60 sm:w-72"
|
| 7201 |
: "w-52 sm:w-60";
|
| 7202 |
return (
|
| 7203 |
-
<HangingPrint key={photo.id} photo={photo} index={i} swayDelay={i * 0.4} onRevealed={handleRevealed} widthClass={heroW} />
|
| 7204 |
);
|
| 7205 |
})}
|
| 7206 |
</ul>
|
|
@@ -7228,7 +7853,22 @@
|
|
| 7228 |
function Page({ onOpenSettings, onOpenPets, onOpenTrace, screen, setScreen, mapVariant, setMapVariant }) {
|
| 7229 |
// Each photo carries its own title/caption/date/time - a single source of
|
| 7230 |
// truth so the fields can never desync from the photo list.
|
| 7231 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7232 |
const developPhotos = stackPhotos.map((p) => ({ id: p.id, src: p.src, alt: p.alt, defaultCaption: p.defaultCaption,
|
| 7233 |
lat: p.lat, lon: p.lon, date: p.date, time: p.time, title: p.title, caption: p.caption }));
|
| 7234 |
if (screen === "add")
|
|
@@ -7242,6 +7882,51 @@
|
|
| 7242 |
return <MapOfMyTime onBack={() => setScreen("add")} onOpenSettings={onOpenSettings} onOpenPets={onOpenPets} variant={mapVariant} onChangeVariant={setMapVariant} />;
|
| 7243 |
}
|
| 7244 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7245 |
function Root() {
|
| 7246 |
const [chatOpen, setChatOpen] = useState(false);
|
| 7247 |
const [unread, setUnread] = useState(0);
|
|
@@ -7252,14 +7937,23 @@
|
|
| 7252 |
});
|
| 7253 |
const [settings, setSettings] = useState(loadSettings);
|
| 7254 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7255 |
useEffect(() => { Collab.connect(); }, []);
|
| 7256 |
useEffect(() => {
|
| 7257 |
// load saved scrapbook for this room (if any) and start auto-saving
|
| 7258 |
RoomPersistence.init();
|
|
|
|
| 7259 |
const onHide = () => { try { RoomPersistence.flush(); } catch (e) {} };
|
| 7260 |
window.addEventListener("beforeunload", onHide);
|
| 7261 |
document.addEventListener("visibilitychange", () => { if (document.visibilityState === "hidden") onHide(); });
|
| 7262 |
-
return () => { window.removeEventListener("beforeunload", onHide); RoomPersistence.stop(); };
|
| 7263 |
}, []);
|
| 7264 |
useEffect(() => { applySettings(settings); }, []); // eslint-disable-line
|
| 7265 |
useEffect(() => {
|
|
@@ -7284,16 +7978,20 @@
|
|
| 7284 |
}),
|
| 7285 |
Collab.on("join_pending", () => {
|
| 7286 |
window.__sozaiJoinPending = true;
|
| 7287 |
-
|
| 7288 |
-
text: "Waiting for the host to let you in…" });
|
| 7289 |
}),
|
| 7290 |
Collab.on("join_declined", () => {
|
| 7291 |
ToastStore.dismiss("join-wait");
|
| 7292 |
-
|
| 7293 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7294 |
}),
|
| 7295 |
-
// once we're fully in (init arrives),
|
| 7296 |
-
Collab.on("me", () => { ToastStore.dismiss("join-wait"); window.__sozaiJoinPending = false; }),
|
| 7297 |
];
|
| 7298 |
return () => offs.forEach((off) => off && off());
|
| 7299 |
}, []);
|
|
@@ -7315,26 +8013,61 @@
|
|
| 7315 |
const [followPeer, setFollowPeer] = useState(true);
|
| 7316 |
const followRef = useRef(followPeer);
|
| 7317 |
useEffect(() => { followRef.current = followPeer; window.__sozaiFollow = followPeer; }, [followPeer]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7318 |
useEffect(() => {
|
| 7319 |
const offs = [
|
| 7320 |
-
Collab.on("screen_changed", (m) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7321 |
Collab.on("variant_changed", (m) => { if (followRef.current && m && m.variant) setMapVariant(m.variant); }),
|
| 7322 |
];
|
| 7323 |
return () => offs.forEach((off) => off && off());
|
| 7324 |
}, []);
|
| 7325 |
-
// broadcast our own screen/variant changes for peers who are following
|
| 7326 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7327 |
useEffect(() => { Collab.send("variant_changed", { variant: mapVariant }); }, [mapVariant]);
|
| 7328 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7329 |
const update = (patch) => setSettings((prev) => ({ ...prev, ...patch }));
|
| 7330 |
const openPets = () => setScreen("pets");
|
| 7331 |
const openTrace = () => setScreen("trace");
|
| 7332 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7333 |
return (
|
| 7334 |
<SettingsContext.Provider value={{ settings, update }}>
|
| 7335 |
<div className="flex min-h-screen flex-col">
|
| 7336 |
<CollabBar onToggleChat={() => { setChatOpen((v) => !v); setUnread(0); }} unread={unread} onOpenPets={openPets} onOpenTrace={openTrace} />
|
| 7337 |
-
<main className="flex flex-1 items-
|
| 7338 |
<Page onOpenSettings={() => setSettingsOpen(true)} onOpenPets={openPets} onOpenTrace={openTrace}
|
| 7339 |
screen={screen} setScreen={setScreen} mapVariant={mapVariant} setMapVariant={setMapVariant} />
|
| 7340 |
</main>
|
|
|
|
| 3 |
<head>
|
| 4 |
<meta charset="UTF-8" />
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>Sozai · Upload Photos</title>
|
| 7 |
<link rel="icon" type="image/svg+xml" href="/assets/icon.svg" />
|
| 8 |
|
| 9 |
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
|
|
| 109 |
* { @apply border-border outline-ring/50; }
|
| 110 |
body { @apply bg-background text-foreground; }
|
| 111 |
html { @apply font-sans; }
|
| 112 |
+
/* mobile: never let a stray wide element cause a horizontal scrollbar,
|
| 113 |
+
and use momentum scrolling + a sane tap target / text size. */
|
| 114 |
+
html, body {
|
| 115 |
+
max-width: 100%;
|
| 116 |
+
overflow-x: hidden;
|
| 117 |
+
-webkit-text-size-adjust: 100%;
|
| 118 |
+
scroll-behavior: smooth;
|
| 119 |
+
}
|
| 120 |
+
body { -webkit-overflow-scrolling: touch; overscroll-behavior-y: none; }
|
| 121 |
+
/* media + canvases should never exceed their column on small screens */
|
| 122 |
+
img, video, canvas, svg { max-width: 100%; }
|
| 123 |
+
}
|
| 124 |
+
/* any element that scrolls internally gets momentum + smoothness on iOS */
|
| 125 |
+
.overflow-x-auto, .overflow-y-auto, .overflow-auto, [class*="overflow-"] {
|
| 126 |
+
-webkit-overflow-scrolling: touch;
|
| 127 |
}
|
| 128 |
</style>
|
| 129 |
|
|
|
|
| 1210 |
};
|
| 1211 |
})();
|
| 1212 |
|
| 1213 |
+
// ----------------------------------------------------------------- //
|
| 1214 |
+
// LIVE shared image library. //
|
| 1215 |
+
// RoomPersistence above is the DURABLE layer (server JSON, debounced, //
|
| 1216 |
+
// seeds late joiners). PhotoSync is the LIVE layer: it relays the whole //
|
| 1217 |
+
// photo list over SSE on every local change so BOTH people see the same //
|
| 1218 |
+
// images the instant one of them uploads, deletes, or edits — captions //
|
| 1219 |
+
// and titles included, on every keystroke. //
|
| 1220 |
+
// //
|
| 1221 |
+
// Echo guard: when we APPLY a list received from the peer, we set //
|
| 1222 |
+
// __sozaiApplyingRemote so the resulting store change doesn't bounce a //
|
| 1223 |
+
// photos_state straight back (which would loop forever). //
|
| 1224 |
+
// ----------------------------------------------------------------- //
|
| 1225 |
+
window.__sozaiApplyingRemote = false;
|
| 1226 |
+
const PhotoSync = (function () {
|
| 1227 |
+
let unsub = null;
|
| 1228 |
+
let offRecv = null;
|
| 1229 |
+
let lastSent = ""; // de-dupe identical payloads (e.g. no-op re-renders)
|
| 1230 |
+
|
| 1231 |
+
function broadcast(items) {
|
| 1232 |
+
if (window.__sozaiApplyingRemote) return; // don't echo remote applies
|
| 1233 |
+
if (window.__sozaiSuppressSave) return; // bulk load in progress
|
| 1234 |
+
if (!Collab.isCollab()) return;
|
| 1235 |
+
let payload;
|
| 1236 |
+
try { payload = JSON.stringify(items); } catch (e) { return; }
|
| 1237 |
+
if (payload === lastSent) return; // nothing actually changed
|
| 1238 |
+
lastSent = payload;
|
| 1239 |
+
// send the full list every time. Photos are data-URLs, so this is the
|
| 1240 |
+
// simplest correct model for a 2-person room: last-write-wins, always
|
| 1241 |
+
// converging on an identical array. Sent on EVERY change (keystroke).
|
| 1242 |
+
Collab.send("photos_state", { photos: items });
|
| 1243 |
+
}
|
| 1244 |
+
|
| 1245 |
+
function apply(items) {
|
| 1246 |
+
if (!Array.isArray(items)) return;
|
| 1247 |
+
// remember what we're applying so our own broadcast() suppresses it,
|
| 1248 |
+
// and so we don't re-send it as if it were a local edit.
|
| 1249 |
+
lastSent = (function () { try { return JSON.stringify(items); } catch (e) { return ""; } })();
|
| 1250 |
+
window.__sozaiApplyingRemote = true;
|
| 1251 |
+
try { window.SozaiPhotos.replaceAll(items); }
|
| 1252 |
+
finally { window.__sozaiApplyingRemote = false; }
|
| 1253 |
+
}
|
| 1254 |
+
|
| 1255 |
+
return {
|
| 1256 |
+
init() {
|
| 1257 |
+
if (!Collab.isCollab()) return;
|
| 1258 |
+
// local change → broadcast to the peer
|
| 1259 |
+
unsub = window.SozaiPhotos.subscribe((items) => broadcast(items));
|
| 1260 |
+
// peer change → apply locally
|
| 1261 |
+
offRecv = Collab.on("photos_state", (m) => {
|
| 1262 |
+
if (m && Array.isArray(m.photos)) apply(m.photos);
|
| 1263 |
+
});
|
| 1264 |
+
},
|
| 1265 |
+
stop() { if (unsub) unsub(); if (offRecv) offRecv(); },
|
| 1266 |
+
};
|
| 1267 |
+
})();
|
| 1268 |
+
|
| 1269 |
// Adapt a SozaiPhotos item to the shape that the map-view components expect.
|
| 1270 |
// x/y are deterministic synthetic percentages used for scrapbook pin positions.
|
| 1271 |
function storeToMoment(p, i) {
|
|
|
|
| 1317 |
useEffect(() => Collab.on("me", setM), []);
|
| 1318 |
return m;
|
| 1319 |
}
|
| 1320 |
+
|
| 1321 |
+
// ----------------------------------------------------------------- //
|
| 1322 |
+
// AI mutual-exclusion lock (client side of the server's ai_state). //
|
| 1323 |
+
// Only one collaborator may run AI (auto-caption / title / tags / voice //
|
| 1324 |
+
// transcription) at a time. When one starts, the server grants them the //
|
| 1325 |
+
// lock and broadcasts ai_state; the other person's AI controls disable //
|
| 1326 |
+
// and show "<name> is generating…". Solo mode: always free, never locks. //
|
| 1327 |
+
// ----------------------------------------------------------------- //
|
| 1328 |
+
window.SozaiAI = (function () {
|
| 1329 |
+
let holder = null; // { session_id, name, action } or null
|
| 1330 |
+
let listeners = [];
|
| 1331 |
+
let pendingResolve = null; // resolver for an in-flight acquire()
|
| 1332 |
+
function emit() { listeners.forEach((fn) => { try { fn(holder); } catch (e) {} }); }
|
| 1333 |
+
if (typeof window !== "undefined") {
|
| 1334 |
+
Collab.on("ai_state", (m) => {
|
| 1335 |
+
holder = (m && m.holder) ? m.holder : null;
|
| 1336 |
+
// if this message answers OUR acquire request, resolve the promise
|
| 1337 |
+
if (pendingResolve) {
|
| 1338 |
+
const granted = !!(m && m.granted);
|
| 1339 |
+
const r = pendingResolve; pendingResolve = null;
|
| 1340 |
+
r(granted);
|
| 1341 |
+
}
|
| 1342 |
+
emit();
|
| 1343 |
+
});
|
| 1344 |
+
}
|
| 1345 |
+
return {
|
| 1346 |
+
get() { return holder; },
|
| 1347 |
+
subscribe(fn) { listeners.push(fn); return () => { listeners = listeners.filter((f) => f !== fn); }; },
|
| 1348 |
+
heldByOther(mySession) { return !!holder && holder.session_id !== mySession; },
|
| 1349 |
+
// Try to take the lock for `action`. Resolves true if granted.
|
| 1350 |
+
// Solo (not in a room): instantly true, nothing to coordinate.
|
| 1351 |
+
acquire(action) {
|
| 1352 |
+
if (!Collab.isCollab()) return Promise.resolve(true);
|
| 1353 |
+
return new Promise((resolve) => {
|
| 1354 |
+
if (pendingResolve) { const r = pendingResolve; pendingResolve = null; r(false); }
|
| 1355 |
+
pendingResolve = resolve;
|
| 1356 |
+
Collab.send("ai_acquire", { action: String(action || "AI").slice(0, 40) });
|
| 1357 |
+
// safety: if the server never answers, don't hang forever
|
| 1358 |
+
setTimeout(() => { if (pendingResolve === resolve) { pendingResolve = null; resolve(false); } }, 6000);
|
| 1359 |
+
});
|
| 1360 |
+
},
|
| 1361 |
+
release() {
|
| 1362 |
+
if (!Collab.isCollab()) return;
|
| 1363 |
+
Collab.send("ai_release", {});
|
| 1364 |
+
},
|
| 1365 |
+
};
|
| 1366 |
+
})();
|
| 1367 |
+
|
| 1368 |
+
// React hook: live AI-lock holder + whether THIS user's AI controls should
|
| 1369 |
+
// be disabled (because the peer holds the lock).
|
| 1370 |
+
function useAILock() {
|
| 1371 |
+
const me = useMe();
|
| 1372 |
+
const [holder, setHolder] = useState(window.SozaiAI.get());
|
| 1373 |
+
useEffect(() => window.SozaiAI.subscribe(setHolder), []);
|
| 1374 |
+
const mySession = (me && me.session_id) || Collab.sessionId();
|
| 1375 |
+
const lockedByOther = !!holder && holder.session_id !== mySession;
|
| 1376 |
+
return {
|
| 1377 |
+
holder,
|
| 1378 |
+
lockedByOther,
|
| 1379 |
+
lockLabel: lockedByOther ? `${holder.name || "Someone"} is ${holder.action || "using AI"}…` : null,
|
| 1380 |
+
acquire: (action) => window.SozaiAI.acquire(action),
|
| 1381 |
+
release: () => window.SozaiAI.release(),
|
| 1382 |
+
};
|
| 1383 |
+
}
|
| 1384 |
+
|
| 1385 |
+
// ----------------------------------------------------------------- //
|
| 1386 |
+
// Journey mutual-exclusion lock (client side of the server's //
|
| 1387 |
+
// journey_state). The "Play your journey" flythrough + watercolour //
|
| 1388 |
+
// "develop" are GPU/memory heavy, so only ONE person may drive at a //
|
| 1389 |
+
// time (prevents OOM). The driver streams journey_frame events so the //
|
| 1390 |
+
// peer watches the SAME playback; the peer's Play button stays //
|
| 1391 |
+
// disabled until the driver finishes or stops. Solo: always free. //
|
| 1392 |
+
// ----------------------------------------------------------------- //
|
| 1393 |
+
window.SozaiJourney = (function () {
|
| 1394 |
+
let driver = null; // { session_id, name } or null
|
| 1395 |
+
let listeners = [];
|
| 1396 |
+
let pendingResolve = null;
|
| 1397 |
+
let refreshTimer = null;
|
| 1398 |
+
function emit() { listeners.forEach((fn) => { try { fn(driver); } catch (e) {} }); }
|
| 1399 |
+
if (typeof window !== "undefined") {
|
| 1400 |
+
Collab.on("journey_state", (m) => {
|
| 1401 |
+
driver = (m && m.driver) ? m.driver : null;
|
| 1402 |
+
if (pendingResolve) {
|
| 1403 |
+
const granted = !!(m && m.granted);
|
| 1404 |
+
const r = pendingResolve; pendingResolve = null;
|
| 1405 |
+
r(granted);
|
| 1406 |
+
}
|
| 1407 |
+
emit();
|
| 1408 |
+
});
|
| 1409 |
+
}
|
| 1410 |
+
function startRefresh() {
|
| 1411 |
+
stopRefresh();
|
| 1412 |
+
// keep the long-running lock alive while we drive the playback
|
| 1413 |
+
refreshTimer = setInterval(() => { if (Collab.isCollab()) Collab.send("journey_refresh", {}); }, 30000);
|
| 1414 |
+
}
|
| 1415 |
+
function stopRefresh() { if (refreshTimer) { clearInterval(refreshTimer); refreshTimer = null; } }
|
| 1416 |
+
return {
|
| 1417 |
+
get() { return driver; },
|
| 1418 |
+
subscribe(fn) { listeners.push(fn); return () => { listeners = listeners.filter((f) => f !== fn); }; },
|
| 1419 |
+
drivenByOther(mySession) { return !!driver && driver.session_id !== mySession; },
|
| 1420 |
+
acquire() {
|
| 1421 |
+
if (!Collab.isCollab()) return Promise.resolve(true);
|
| 1422 |
+
return new Promise((resolve) => {
|
| 1423 |
+
if (pendingResolve) { const r = pendingResolve; pendingResolve = null; r(false); }
|
| 1424 |
+
pendingResolve = resolve;
|
| 1425 |
+
Collab.send("journey_acquire", {});
|
| 1426 |
+
setTimeout(() => { if (pendingResolve === resolve) { pendingResolve = null; resolve(false); } }, 6000);
|
| 1427 |
+
});
|
| 1428 |
+
},
|
| 1429 |
+
beginRefresh() { startRefresh(); },
|
| 1430 |
+
release() {
|
| 1431 |
+
stopRefresh();
|
| 1432 |
+
if (!Collab.isCollab()) return;
|
| 1433 |
+
Collab.send("journey_release", {});
|
| 1434 |
+
},
|
| 1435 |
+
// relay one playback control frame to the peer (driver only)
|
| 1436 |
+
frame(payload) { if (Collab.isCollab()) Collab.send("journey_frame", payload || {}); },
|
| 1437 |
+
};
|
| 1438 |
+
})();
|
| 1439 |
+
|
| 1440 |
+
// React hook: live journey driver + whether THIS user's Play button should
|
| 1441 |
+
// be disabled (because the peer is already driving a journey).
|
| 1442 |
+
function useJourneyLock() {
|
| 1443 |
+
const me = useMe();
|
| 1444 |
+
const [driver, setDriver] = useState(window.SozaiJourney.get());
|
| 1445 |
+
useEffect(() => window.SozaiJourney.subscribe(setDriver), []);
|
| 1446 |
+
const mySession = (me && me.session_id) || Collab.sessionId();
|
| 1447 |
+
const drivenByMe = !!driver && driver.session_id === mySession;
|
| 1448 |
+
const drivenByOther = !!driver && driver.session_id !== mySession;
|
| 1449 |
+
return {
|
| 1450 |
+
driver,
|
| 1451 |
+
drivenByMe,
|
| 1452 |
+
drivenByOther,
|
| 1453 |
+
driverLabel: drivenByOther ? `${driver.name || "Someone"} is playing the journey…` : null,
|
| 1454 |
+
acquire: () => window.SozaiJourney.acquire(),
|
| 1455 |
+
release: () => window.SozaiJourney.release(),
|
| 1456 |
+
beginRefresh: () => window.SozaiJourney.beginRefresh(),
|
| 1457 |
+
frame: (p) => window.SozaiJourney.frame(p),
|
| 1458 |
+
};
|
| 1459 |
+
}
|
| 1460 |
+
|
| 1461 |
+
// React hook: the peer's most recent ambient activity (#4) — "where are
|
| 1462 |
+
// they / what are they doing". Combines navigation beacons (activity) with
|
| 1463 |
+
// the AI-lock state so AI work always shows even mid-action. Returns the
|
| 1464 |
+
// freshest peer activity { name, color, label } or null.
|
| 1465 |
+
function usePeerActivity() {
|
| 1466 |
+
const me = useMe();
|
| 1467 |
+
const [act, setAct] = useState(null);
|
| 1468 |
+
const mySession = (me && me.session_id) || Collab.sessionId();
|
| 1469 |
+
useEffect(() => {
|
| 1470 |
+
const offAct = Collab.on("activity", (m) => {
|
| 1471 |
+
if (!m || !m.from || m.from.session_id === mySession) return;
|
| 1472 |
+
setAct({ name: m.from.name, color: m.from.color, label: m.label || "", t: Date.now() });
|
| 1473 |
+
});
|
| 1474 |
+
// a peer leaving clears their activity
|
| 1475 |
+
const offLeft = Collab.on("user_left", () => setAct(null));
|
| 1476 |
+
return () => { offAct && offAct(); offLeft && offLeft(); };
|
| 1477 |
+
}, [mySession]);
|
| 1478 |
+
// AI activity takes precedence and is derived from the authoritative lock
|
| 1479 |
+
const ai = useAILock();
|
| 1480 |
+
const journey = useJourneyLock();
|
| 1481 |
+
if (ai.holder && ai.holder.session_id !== mySession) {
|
| 1482 |
+
return { name: ai.holder.name, color: (act && act.color) || "#7c5cff", label: ai.holder.action || "using AI", ai: true };
|
| 1483 |
+
}
|
| 1484 |
+
// a peer driving the journey is shown the same way (so you always SEE it)
|
| 1485 |
+
if (journey.drivenByOther) {
|
| 1486 |
+
return { name: journey.driver.name, color: (act && act.color) || "#7c5cff", label: "playing the journey", ai: true };
|
| 1487 |
+
}
|
| 1488 |
+
return act;
|
| 1489 |
+
}
|
| 1490 |
+
|
| 1491 |
function initials(name) {
|
| 1492 |
const parts = (name || "Guest").trim().split(/\s+/);
|
| 1493 |
return ((parts[0]?.[0] || "G") + (parts[1]?.[0] || "")).toUpperCase();
|
|
|
|
| 1519 |
);
|
| 1520 |
}
|
| 1521 |
|
| 1522 |
+
// ----------------------------------------------------------------- //
|
| 1523 |
+
// Peer activity pill (#4): a compact, live "Alex is exploring the map" //
|
| 1524 |
+
// indicator. Shows the collaborator's current screen, or their AI action //
|
| 1525 |
+
// when they're running AI. Hidden in solo mode or when nothing is known. //
|
| 1526 |
+
// ----------------------------------------------------------------- //
|
| 1527 |
+
function PeerActivityPill() {
|
| 1528 |
+
const act = usePeerActivity();
|
| 1529 |
+
if (!Collab.isCollab() || !act || !act.label) return null;
|
| 1530 |
+
const color = act.color || "#7c5cff";
|
| 1531 |
+
return (
|
| 1532 |
+
<span className="hidden items-center gap-1.5 rounded-full border-2 px-2.5 py-1 font-mono text-[10px] uppercase tracking-wide sm:inline-flex"
|
| 1533 |
+
style={{ borderColor: color, color }}
|
| 1534 |
+
title={`${act.name || "Collaborator"} is ${act.label}`}>
|
| 1535 |
+
<span className="relative flex size-2">
|
| 1536 |
+
<span className="absolute inline-flex size-full animate-ping rounded-full opacity-60" style={{ background: color }} />
|
| 1537 |
+
<span className="relative inline-flex size-2 rounded-full" style={{ background: color }} />
|
| 1538 |
+
</span>
|
| 1539 |
+
<span className="max-w-[14rem] truncate">{act.name || "Collaborator"} · {act.label}</span>
|
| 1540 |
+
</span>
|
| 1541 |
+
);
|
| 1542 |
+
}
|
| 1543 |
+
|
| 1544 |
// ----------------------------------------------------------------- //
|
| 1545 |
// Collaboration bar (share code + link, presence, name, chat toggle)
|
| 1546 |
// ----------------------------------------------------------------- //
|
|
|
|
| 1619 |
</div>
|
| 1620 |
|
| 1621 |
<div className="flex items-center gap-3">
|
| 1622 |
+
{/* what the other person is doing right now (#4) */}
|
| 1623 |
+
<PeerActivityPill />
|
| 1624 |
{/* presence */}
|
| 1625 |
<div className="flex items-center gap-1.5">
|
| 1626 |
<Users className="size-4 text-primary" />
|
|
|
|
| 1918 |
}
|
| 1919 |
return (
|
| 1920 |
<div className="flex flex-col items-center gap-4">
|
| 1921 |
+
{/* the backing cards lean down-right (translate-x), which visually
|
| 1922 |
+
pulls the stack off-centre; nudge the whole stack left by half that
|
| 1923 |
+
offset so the polaroid sits centred, especially on mobile. */}
|
| 1924 |
+
<div className="relative w-full max-w-xs" style={{ marginRight: "0.375rem", marginLeft: "-0.375rem" }}>
|
| 1925 |
<div className="absolute inset-0 translate-x-3 translate-y-3 rotate-2 rounded-md border-2 border-primary/30 bg-card" />
|
| 1926 |
<div className="absolute inset-0 translate-x-1.5 translate-y-1.5 rotate-1 rounded-md border-2 border-primary/40 bg-card" />
|
| 1927 |
{/* keyed by index so the slide animation re-triggers on each browse;
|
|
|
|
| 2073 |
<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>
|
| 2074 |
</form>
|
| 2075 |
<div ref={elRef} className="h-40 w-full overflow-hidden rounded-md border-2 border-primary/40" />
|
| 2076 |
+
<p className="mt-1.5 font-mono text-[10px] leading-snug text-muted-foreground">Drag the pin or tap the map to set your photo's general area.</p>
|
| 2077 |
<div className="mt-2 flex items-center gap-2">
|
| 2078 |
<span className="font-mono text-[10px] uppercase tracking-wide text-primary">Radius</span>
|
| 2079 |
<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" />
|
|
|
|
| 2106 |
// ----------------------------------------------------------------- //
|
| 2107 |
// components/details-form.tsx
|
| 2108 |
// ----------------------------------------------------------------- //
|
| 2109 |
+
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, aiLockedByOther, aiLockLabel }) {
|
| 2110 |
const CancelGen = () => (
|
| 2111 |
<button type="button" onClick={onCancelGen}
|
| 2112 |
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">
|
|
|
|
| 2121 |
const DEFAULT_TAGS_HINT = "List 3 to 6 short, comma-separated tags for this photo.";
|
| 2122 |
return (
|
| 2123 |
<div className="flex flex-col gap-6">
|
| 2124 |
+
{/* peer is running AI → explain why the AI buttons are disabled */}
|
| 2125 |
+
{aiLockedByOther ? (
|
| 2126 |
+
<div role="status" className="flex items-center gap-2 rounded-md border-2 border-primary/40 bg-accent px-3 py-2 text-sm text-accent-foreground">
|
| 2127 |
+
<Wand2 className="size-4 shrink-0 animate-pulse text-primary" />
|
| 2128 |
+
<span>{aiLockLabel || "Your collaborator is using AI…"} You can use AI again when they're done.</span>
|
| 2129 |
+
</div>
|
| 2130 |
+
) : null}
|
| 2131 |
{/* title */}
|
| 2132 |
<div className="space-y-2">
|
| 2133 |
<div className="flex items-center justify-between gap-2">
|
|
|
|
| 2141 |
showTitlePrompt ? "border-primary bg-accent text-foreground" : "border-primary/60 text-primary hover:bg-accent")}>
|
| 2142 |
<Settings className="size-3.5" /> Prompt
|
| 2143 |
</button>
|
| 2144 |
+
<button type="button" onClick={onAutoTitle} disabled={titleBusy || aiLockedByOther}
|
| 2145 |
+
title={aiLockedByOther ? aiLockLabel : undefined}
|
| 2146 |
+
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 disabled:cursor-not-allowed">
|
| 2147 |
+
<Wand2 className={cn("size-3.5", titleBusy && "animate-pulse")} /> {titleBusy ? "Naming…" : (aiLockedByOther ? "AI busy" : "Auto-generate")}
|
| 2148 |
</button>
|
| 2149 |
</div>
|
| 2150 |
</div>
|
|
|
|
| 2214 |
showPrompt ? "border-primary bg-accent text-foreground" : "border-primary/60 text-primary hover:bg-accent")}>
|
| 2215 |
<Settings className="size-3.5" /> Prompt
|
| 2216 |
</button>
|
| 2217 |
+
<button type="button" onClick={onAutoCaption} disabled={autoBusy || aiLockedByOther}
|
| 2218 |
+
title={aiLockedByOther ? aiLockLabel : undefined}
|
| 2219 |
+
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 disabled:cursor-not-allowed">
|
| 2220 |
+
<Wand2 className={cn("size-3.5", autoBusy && "animate-pulse")} /> {autoBusy ? "Developing…" : (aiLockedByOther ? "AI busy" : "Auto-generate")}
|
| 2221 |
</button>
|
| 2222 |
</div>
|
| 2223 |
</div>
|
|
|
|
| 2373 |
const [tagsBusy, setTagsBusy] = useState(false);
|
| 2374 |
const [tagsError, setTagsError] = useState(null);
|
| 2375 |
const [tagsStage, setTagsStage] = useState("");
|
| 2376 |
+
const aiLock = useAILock(); // server-coordinated AI mutual exclusion
|
| 2377 |
|
| 2378 |
+
// Remote field edits from collaborators. The full photo list also syncs
|
| 2379 |
+
// via photos_state, but object_updated gives a snappier single-field
|
| 2380 |
+
// update; we apply it to the shared store (by resolving index→id) so it
|
| 2381 |
+
// converges with photos_state instead of fighting a private copy.
|
| 2382 |
useEffect(() => Collab.on("object_updated", (m) => {
|
| 2383 |
if (typeof m.index !== "number" || !m.kind) return;
|
| 2384 |
+
if (window.__sozaiApplyingRemote) return; // avoid echo while applying a full list
|
| 2385 |
+
const target = window.SozaiPhotos.all()[m.index];
|
| 2386 |
+
if (!target) return;
|
| 2387 |
+
if (m.kind === "location" && m.value && typeof m.value === "object") {
|
| 2388 |
+
window.SozaiPhotos.upsert(target.id, m.value);
|
| 2389 |
+
} else {
|
| 2390 |
+
window.SozaiPhotos.upsert(target.id, { [m.kind]: m.value });
|
| 2391 |
+
}
|
| 2392 |
}), []);
|
| 2393 |
|
| 2394 |
// follow a collaborator to the photo they switch to (when follow is on)
|
|
|
|
| 2418 |
const date = (photo && photo.date != null && photo.date !== "") ? photo.date : DEFAULT_DATE;
|
| 2419 |
const time = (photo && photo.time != null && photo.time !== "") ? photo.time : DEFAULT_TIME;
|
| 2420 |
|
| 2421 |
+
// (stackPhotos is now a live view of the shared SozaiPhotos store, so
|
| 2422 |
+
// there's nothing to "seed" — the store IS the source of truth.)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2423 |
|
| 2424 |
+
// Edit a field on the current photo. With stackPhotos now mirroring the
|
| 2425 |
+
// shared store (see Page), we write ONCE to the store; the subscription
|
| 2426 |
+
// re-renders us and PhotoSync relays the change to the peer. We still send
|
| 2427 |
+
// object_updated for the lightweight per-field "X is typing" live echo.
|
| 2428 |
function makeFieldSetter(kind) {
|
| 2429 |
return (value) => {
|
|
|
|
| 2430 |
Collab.send("object_updated", { kind, index: safeIndex, value });
|
| 2431 |
const pid = photo && photo.id;
|
| 2432 |
if (pid) window.SozaiPhotos.upsert(pid, { [kind]: value });
|
|
|
|
| 2442 |
const locationName = (photo && photo.locationName) || "";
|
| 2443 |
const radius = (photo && photo.radius != null) ? photo.radius : null;
|
| 2444 |
// A location edit touches several fields at once (lat/lon/name/radius +
|
| 2445 |
+
// the pinned flag) - write the whole patch to the shared store.
|
| 2446 |
const setLocation = (patch) => {
|
|
|
|
| 2447 |
const pid = photo && photo.id;
|
| 2448 |
if (pid) window.SozaiPhotos.upsert(pid, patch);
|
| 2449 |
Collab.send("object_updated", { kind: "location", index: safeIndex, value: patch });
|
| 2450 |
};
|
| 2451 |
// reposition the focal point of the current photo (object-position %),
|
| 2452 |
+
// saved on the shared store so it persists.
|
| 2453 |
function onReposition(fx, fy) {
|
|
|
|
| 2454 |
const pid = photo && photo.id;
|
| 2455 |
if (pid) window.SozaiPhotos.upsert(pid, { focusX: fx, focusY: fy });
|
| 2456 |
}
|
|
|
|
| 2496 |
};
|
| 2497 |
const id = window.SozaiPhotos.add(newPhoto);
|
| 2498 |
newPhoto.id = id;
|
| 2499 |
+
// (store subscription re-renders stackPhotos; no separate copy)
|
| 2500 |
added++;
|
| 2501 |
// resolve a readable "general area" for the EXIF GPS, in the background
|
| 2502 |
if (exif.lat != null) {
|
| 2503 |
reverseGeocode(exif.lat, exif.lon).then((nm) => {
|
| 2504 |
if (!nm) return;
|
| 2505 |
window.SozaiPhotos.upsert(id, { locationName: nm });
|
|
|
|
| 2506 |
});
|
| 2507 |
}
|
| 2508 |
}
|
|
|
|
| 2521 |
title: s.title || "", caption: s.caption || "",
|
| 2522 |
date: DEFAULT_DATE, time: DEFAULT_TIME,
|
| 2523 |
};
|
| 2524 |
+
window.SozaiPhotos.add(newPhoto);
|
|
|
|
|
|
|
| 2525 |
setPhotoIndex(base);
|
| 2526 |
}
|
| 2527 |
// "Try a sample" — load the whole built-in photo set at once (with their
|
|
|
|
| 2538 |
date: p.date, time: p.time, lat: p.lat, lon: p.lon,
|
| 2539 |
pinned: p.lat != null && p.lon != null,
|
| 2540 |
}));
|
| 2541 |
+
// store subscription re-renders stackPhotos
|
| 2542 |
setPhotoIndex(0);
|
| 2543 |
}
|
| 2544 |
|
|
|
|
| 2578 |
if (typeof idx !== "number") idx = safeIndex;
|
| 2579 |
const removedId = stackPhotos[idx] && stackPhotos[idx].id;
|
| 2580 |
if (removedId) window.SozaiPhotos.remove(removedId);
|
| 2581 |
+
// store subscription re-renders stackPhotos (and PhotoSync relays it)
|
| 2582 |
setPhotoIndex((prev) => {
|
| 2583 |
const next = prev > idx ? prev - 1 : prev;
|
| 2584 |
return Math.max(0, next);
|
|
|
|
| 2600 |
setAutoBusy(false); setAutoStage("");
|
| 2601 |
setTitleBusy(false); setTitleStage("");
|
| 2602 |
setTagsBusy(false); setTagsStage("");
|
| 2603 |
+
aiLock.release(); // cancelling ends the AI action → free the shared lock
|
| 2604 |
}
|
| 2605 |
|
| 2606 |
function changePhoto(delta) {
|
|
|
|
| 2637 |
async function handleAutoCaption() {
|
| 2638 |
if (autoBusy) return;
|
| 2639 |
if (!photo) return;
|
| 2640 |
+
if (aiLock.lockedByOther) return; // peer holds the AI lock
|
| 2641 |
+
const got = await aiLock.acquire("generating a caption");
|
| 2642 |
+
if (!got) return; // peer grabbed it first
|
| 2643 |
if (window.sozaiPing) window.sozaiPing("✨ auto-generating caption");
|
| 2644 |
setAutoBusy(true); setAutoError(null);
|
| 2645 |
const token = { cancelled: false, timers: [] };
|
|
|
|
| 2660 |
if (token.cancelled) return; // cancelled mid-flight: drop it
|
| 2661 |
fn(); setAutoBusy(false); setAutoStage("");
|
| 2662 |
if (genRef.current === token) genRef.current = null;
|
| 2663 |
+
aiLock.release(); // free the shared AI lock
|
| 2664 |
}, wait);
|
| 2665 |
token.timers.push(t);
|
| 2666 |
};
|
|
|
|
| 2686 |
async function handleAutoTitle() {
|
| 2687 |
if (titleBusy) return;
|
| 2688 |
if (!photo) return;
|
| 2689 |
+
if (aiLock.lockedByOther) return;
|
| 2690 |
+
const got = await aiLock.acquire("generating a title");
|
| 2691 |
+
if (!got) return;
|
| 2692 |
if (window.sozaiPing) window.sozaiPing("✨ auto-generating title");
|
| 2693 |
setTitleBusy(true); setTitleError(null);
|
| 2694 |
const token = { cancelled: false, timers: [] };
|
|
|
|
| 2708 |
if (token.cancelled) return;
|
| 2709 |
fn(); setTitleBusy(false); setTitleStage("");
|
| 2710 |
if (genRef.current === token) genRef.current = null;
|
| 2711 |
+
aiLock.release(); // free the shared AI lock
|
| 2712 |
}, wait);
|
| 2713 |
token.timers.push(t);
|
| 2714 |
};
|
|
|
|
| 2761 |
async function handleAutoTags() {
|
| 2762 |
if (tagsBusy) return;
|
| 2763 |
if (!photo) return;
|
| 2764 |
+
if (aiLock.lockedByOther) return;
|
| 2765 |
+
const got = await aiLock.acquire("generating tags");
|
| 2766 |
+
if (!got) return;
|
| 2767 |
if (window.sozaiPing) window.sozaiPing("🏷️ auto-generating tags");
|
| 2768 |
setTagsBusy(true); setTagsError(null);
|
| 2769 |
const token = { cancelled: false, timers: [] };
|
|
|
|
| 2783 |
if (token.cancelled) return;
|
| 2784 |
fn(); setTagsBusy(false); setTagsStage("");
|
| 2785 |
if (genRef.current === token) genRef.current = null;
|
| 2786 |
+
aiLock.release(); // free the shared AI lock
|
| 2787 |
}, wait);
|
| 2788 |
token.timers.push(t);
|
| 2789 |
};
|
|
|
|
| 2838 |
return;
|
| 2839 |
}
|
| 2840 |
setValidationError(null);
|
| 2841 |
+
// stackPhotos is now a live view of the shared store, so these are
|
| 2842 |
+
// self-writes that simply ensure defaults (title/caption/pin) are
|
| 2843 |
+
// materialised before we advance to the map. Harmless + idempotent.
|
|
|
|
| 2844 |
stackPhotos.forEach((p) => window.SozaiPhotos.upsert(p.id, {
|
| 2845 |
src: p.src, alt: p.alt || "",
|
| 2846 |
title: p.title || p.defaultTitle || "",
|
|
|
|
| 2870 |
return (
|
| 2871 |
<section className="w-full max-w-6xl rounded-xl border-2 border-primary bg-card shadow-lg">
|
| 2872 |
<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">
|
| 2873 |
+
<div className="flex items-center gap-2.5"><h1 className="font-display text-2xl text-[#f5edd8]">Upload Photos</h1></div>
|
| 2874 |
<div className="flex items-center gap-2">
|
| 2875 |
<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>
|
| 2876 |
</div>
|
|
|
|
| 2929 |
titlePrompt={settings.titlePrompt || ""} onTitlePromptChange={setTitlePrompt}
|
| 2930 |
onAutoTags={handleAutoTags} tagsBusy={tagsBusy} tagsStage={tagsStage} tagsError={tagsError}
|
| 2931 |
tagsPrompt={settings.tagsPrompt || ""} onTagsPromptChange={(v) => update({ tagsPrompt: v })}
|
| 2932 |
+
onCancelGen={cancelGeneration}
|
| 2933 |
+
aiLockedByOther={aiLock.lockedByOther} aiLockLabel={aiLock.lockLabel} />
|
| 2934 |
|
| 2935 |
{/* choose how the Map of My Time will look once developed */}
|
| 2936 |
<MapStylePicker variant={mapVariant} onChange={setMapVariant} />
|
| 2937 |
|
| 2938 |
{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}
|
| 2939 |
{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}
|
| 2940 |
+
{/* advance to the darkroom */}
|
| 2941 |
<button type="button" onClick={handleSubmit}
|
| 2942 |
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">
|
| 2943 |
+
Upload Photos <ArrowRight className="size-4" />
|
| 2944 |
</button>
|
| 2945 |
</div>
|
| 2946 |
</div>
|
|
|
|
| 2989 |
// components/map/moment-card.tsx
|
| 2990 |
// ----------------------------------------------------------------- //
|
| 2991 |
function MomentCard({ moment, selected, onSelect, rotate = "" }) {
|
| 2992 |
+
// We cap albums at a handful of photos, so the scrapbook cards can afford
|
| 2993 |
+
// to be bigger — large enough that the title + caption fit inside the
|
| 2994 |
+
// frame. Text is line-clamped so it can never spill past the card edge,
|
| 2995 |
+
// and the date + time always show at the bottom.
|
| 2996 |
+
const timeLabel = [moment.date, moment.time].filter(Boolean).join(" · ");
|
| 2997 |
return (
|
| 2998 |
<button type="button" onClick={() => onSelect && onSelect(moment.id)} style={{ left: `${moment.x}%`, top: `${moment.y}%` }}
|
| 2999 |
+
className={cn("group absolute z-10 w-52 max-w-[78vw] -translate-x-1/2 -translate-y-1/2 rounded-sm border-2 border-primary bg-card p-2.5 text-left shadow-md transition-transform hover:z-20 hover:scale-105 sm:w-60", rotate, selected && "z-20 ring-4 ring-primary/30")}>
|
| 3000 |
<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" />
|
| 3001 |
+
<div className="relative aspect-square w-full overflow-hidden rounded-sm border border-primary/30"><Img src={moment.src} alt={moment.alt} fill sizes="240px" className="object-cover [filter:saturate(0.85)_contrast(1.05)]" /></div>
|
| 3002 |
<div className="px-0.5 pb-0.5 pt-2">
|
| 3003 |
<div className="flex items-start justify-between gap-1">
|
| 3004 |
+
<h3 className="line-clamp-2 break-words font-serif text-sm font-semibold leading-tight text-foreground">{moment.title}</h3>
|
| 3005 |
{moment.favorite ? <Star className="mt-0.5 size-3.5 shrink-0 fill-primary text-primary" /> : null}
|
| 3006 |
</div>
|
| 3007 |
+
{moment.caption ? <p className="mt-1 line-clamp-2 break-words font-hand text-[13px] leading-snug text-muted-foreground">{moment.caption}</p> : null}
|
| 3008 |
+
{timeLabel ? <p className="mt-1.5 font-mono text-[10px] text-primary/70">{timeLabel}</p> : null}
|
| 3009 |
</div>
|
| 3010 |
</button>
|
| 3011 |
);
|
|
|
|
| 3585 |
function MicButton({ field, onText, title }) {
|
| 3586 |
const { settings, update } = useSettings();
|
| 3587 |
const asrCfg = useAsrConfig();
|
| 3588 |
+
const aiLock = useAILock(); // voice transcription is AI → same lock
|
| 3589 |
const [state, setState] = useState("idle"); // idle | recording | working | error
|
| 3590 |
const [lang, setLang] = useState("");
|
| 3591 |
const [note, setNote] = useState("");
|
|
|
|
| 3600 |
|
| 3601 |
async function start() {
|
| 3602 |
setNote(""); setLang("");
|
| 3603 |
+
if (aiLock.lockedByOther) return; // peer is using AI
|
| 3604 |
if (asrCfg && asrCfg.osSupported === false) {
|
| 3605 |
setState("error"); setNote("Nemotron is not supported on this OS."); return;
|
| 3606 |
}
|
| 3607 |
if (!navigator.mediaDevices || typeof MediaRecorder === "undefined") {
|
| 3608 |
setState("error"); setNote("Recording not supported here."); return;
|
| 3609 |
}
|
| 3610 |
+
// take the AI lock for the whole record→transcribe cycle
|
| 3611 |
+
const got = await aiLock.acquire("dictating");
|
| 3612 |
+
if (!got) { setNote("Collaborator is using AI."); return; }
|
| 3613 |
try {
|
| 3614 |
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
| 3615 |
streamRef.current = stream;
|
|
|
|
| 3624 |
if (window.sozaiPing) window.sozaiPing("🎤 recording " + field);
|
| 3625 |
} catch (e) {
|
| 3626 |
setState("error"); setNote("Microphone blocked.");
|
| 3627 |
+
aiLock.release(); // never acquired audio → free the lock
|
| 3628 |
}
|
| 3629 |
}
|
| 3630 |
function stop() {
|
|
|
|
| 3655 |
}
|
| 3656 |
} catch (e) {
|
| 3657 |
setState("error"); setNote("Transcription failed.");
|
| 3658 |
+
} finally {
|
| 3659 |
+
aiLock.release(); // always free the lock once the cycle ends
|
| 3660 |
}
|
| 3661 |
}
|
| 3662 |
|
| 3663 |
const recording = state === "recording";
|
| 3664 |
const working = state === "working";
|
| 3665 |
const osBlocked = asrCfg && asrCfg.osSupported === false;
|
| 3666 |
+
const micDisabled = working || osBlocked || (aiLock.lockedByOther && !recording);
|
| 3667 |
return (
|
| 3668 |
<span className="relative inline-flex items-center gap-1.5">
|
| 3669 |
<button type="button"
|
| 3670 |
onClick={() => (recording ? stop() : start())}
|
| 3671 |
+
disabled={micDisabled}
|
| 3672 |
aria-pressed={recording}
|
| 3673 |
+
title={osBlocked ? "Nemotron is not supported on this OS" : (aiLock.lockedByOther ? aiLock.lockLabel : (title || ("Dictate " + field)))}
|
| 3674 |
+
className={cn("flex size-7 items-center justify-center rounded-full border-2 transition-colors disabled:opacity-60 disabled:cursor-not-allowed",
|
| 3675 |
recording ? "border-[oklch(0.55_0.2_25)] bg-[oklch(0.55_0.2_25)] text-white animate-pulse"
|
| 3676 |
: "border-primary/60 text-primary hover:bg-accent")}>
|
| 3677 |
{working ? <Loader2 className="size-3.5 animate-spin" /> : recording ? <Square className="size-3" /> : <Mic className="size-3.5" />}
|
|
|
|
| 4339 |
];
|
| 4340 |
const [transport, setTransport] = useState("car");
|
| 4341 |
const transportMarkerRef = useRef(null);
|
| 4342 |
+
|
| 4343 |
+
// #3/#5: journey playback is GPU/CPU heavy and must be driven by ONE
|
| 4344 |
+
// person; the peer watches the SAME flythrough. The driver holds the
|
| 4345 |
+
// journey lock and broadcasts journey_start/frame/stop; the viewer mirrors
|
| 4346 |
+
// it. Transport choice is also synced (journey_transport).
|
| 4347 |
+
const journey = useJourneyLock();
|
| 4348 |
+
const viewerFlyRef = useRef({ active: false, idx: -1 });
|
| 4349 |
+
// pick a transport — synced to the peer so the journey looks the same
|
| 4350 |
+
const chooseTransport = useCallback((id) => {
|
| 4351 |
+
setTransport(id);
|
| 4352 |
+
try { Collab.send("journey_transport", { transport: id }); } catch (e) {}
|
| 4353 |
+
}, []);
|
| 4354 |
// recording the journey to a downloadable clip (item 5)
|
| 4355 |
const [recording, setRecording] = useState(false);
|
| 4356 |
const recorderRef = useRef(null);
|
|
|
|
| 4520 |
el.className = "sozai-polaroid";
|
| 4521 |
el.style.setProperty("--rot", (p.rot || 0) + "deg");
|
| 4522 |
el.innerHTML =
|
| 4523 |
+
'<img src="' + (p.src || "/assets/placeholder.svg").replace(/"/g, """) + '" alt="" onerror="this.onerror=null;this.src=\'/assets/placeholder.svg\'">' +
|
| 4524 |
(p.caption ? '<div class="cap">' + p.caption.replace(/</g, "<") + '</div>' : '<div class="cap"> </div>');
|
| 4525 |
const del = document.createElement("button");
|
| 4526 |
del.className = "pdel"; del.type = "button"; del.title = "Remove"; del.textContent = "×";
|
|
|
|
| 4559 |
const meta = p.date ? _dmy(p.date) : "";
|
| 4560 |
const objPos = (p.focusX != null ? p.focusX : 50) + "% " + (p.focusY != null ? p.focusY : 50) + "%";
|
| 4561 |
el.innerHTML =
|
| 4562 |
+
'<img src="' + (p.src || "/assets/placeholder.svg").replace(/"/g, """) + '" alt="" style="object-position:' + objPos + '" onerror="this.onerror=null;this.src=\'/assets/placeholder.svg\'">' +
|
| 4563 |
'<div class="cap">' + ((p.caption || p.title || " ").replace(/</g, "<")) +
|
| 4564 |
(meta ? '<span class="sozai-pin-when">' + meta.replace(/</g, "<") + '</span>' : '') +
|
| 4565 |
'</div>';
|
|
|
|
| 4606 |
marker.on("dragend", () => wrap.classList.remove("is-dragging"));
|
| 4607 |
storeMarkersRef.current[p.id] = { marker, el: wrap };
|
| 4608 |
});
|
| 4609 |
+
}, [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) + ":" + (p.src || "").slice(0, 64)).join("|"), ready, styleEpoch]);
|
| 4610 |
|
| 4611 |
// highlight the active stop during a flythrough
|
| 4612 |
useEffect(() => {
|
|
|
|
| 4645 |
}, 420));
|
| 4646 |
return () => { cancelAnimationFrame(raf); ts.forEach(clearTimeout); };
|
| 4647 |
}, [isFullscreen]);
|
| 4648 |
+
function toggleFullscreen() {
|
| 4649 |
+
setIsFullscreen((v) => {
|
| 4650 |
+
const next = !v;
|
| 4651 |
+
// #5: mirror the fullscreen state to the peer (optional follow). Only
|
| 4652 |
+
// applied by the receiver when "Following" is on, same as screen view.
|
| 4653 |
+
try { Collab.send("map_fullscreen", { full: next }); } catch (e) {}
|
| 4654 |
+
return next;
|
| 4655 |
+
});
|
| 4656 |
+
}
|
| 4657 |
+
// receive a peer's fullscreen toggle (respecting the follow preference)
|
| 4658 |
+
useEffect(() => {
|
| 4659 |
+
const off = Collab.on("map_fullscreen", (m) => {
|
| 4660 |
+
if (window.__sozaiFollow !== false && m && typeof m.full === "boolean") setIsFullscreen(m.full);
|
| 4661 |
+
});
|
| 4662 |
+
return () => off && off();
|
| 4663 |
+
}, []);
|
| 4664 |
|
| 4665 |
async function searchCity() {
|
| 4666 |
const q = place.trim();
|
|
|
|
| 4744 |
}
|
| 4745 |
|
| 4746 |
// ---- flythrough ("Map of My Time" video) ---- //
|
| 4747 |
+
function stopFly(opts) {
|
| 4748 |
+
opts = opts || {};
|
| 4749 |
flyAbortRef.current.stop = true;
|
| 4750 |
+
viewerFlyRef.current.active = false;
|
| 4751 |
setFlying(false);
|
| 4752 |
setFlyIdx(-1);
|
| 4753 |
if (transportMarkerRef.current) { try { transportMarkerRef.current.remove(); } catch (e) {} transportMarkerRef.current = null; }
|
| 4754 |
stopRecording();
|
| 4755 |
+
// the driver tells the peer to stop too, and frees the lock so either
|
| 4756 |
+
// person can start the next journey. (A viewer stopping locally does NOT
|
| 4757 |
+
// broadcast — it just leaves the shared playback on its own screen.)
|
| 4758 |
+
if (!opts.fromRemote && journey.drivenByMe) {
|
| 4759 |
+
try { Collab.send("journey_stop", {}); } catch (e) {}
|
| 4760 |
+
journey.release();
|
| 4761 |
+
}
|
| 4762 |
}
|
| 4763 |
|
| 4764 |
// move the transport emoji marker to a lng/lat (creating it if needed)
|
|
|
|
| 4803 |
|
| 4804 |
async function playFly(opts) {
|
| 4805 |
opts = opts || {};
|
| 4806 |
+
const asViewer = !!opts.asViewer; // following the peer's journey
|
| 4807 |
const map = mapRef.current;
|
| 4808 |
if (!map || flyStops.length === 0) return;
|
| 4809 |
+
|
| 4810 |
+
// #3: in a room, the journey must be driven by ONE person. A normal
|
| 4811 |
+
// (non-viewer) start acquires the lock; if the peer already holds it we
|
| 4812 |
+
// bail (the button is disabled anyway, this is just belt-and-braces).
|
| 4813 |
+
if (!asViewer && Collab.isCollab()) {
|
| 4814 |
+
const got = await journey.acquire();
|
| 4815 |
+
if (!got) return;
|
| 4816 |
+
journey.beginRefresh();
|
| 4817 |
+
// tell the peer to start watching the same journey
|
| 4818 |
+
try { Collab.send("journey_start", { transport, record: false }); } catch (e) {}
|
| 4819 |
+
}
|
| 4820 |
+
if (asViewer) viewerFlyRef.current = { active: true, idx: -1 };
|
| 4821 |
+
|
| 4822 |
flyAbortRef.current = { stop: false };
|
| 4823 |
const token = flyAbortRef.current;
|
| 4824 |
setFlying(true);
|
| 4825 |
+
// recording stays per-user and only on the driver's explicit request
|
| 4826 |
+
if (opts.record && !asViewer) { const ok = startRecording(); setRecording(ok); }
|
| 4827 |
+
|
| 4828 |
+
// the driver beacons which stop is showing, so the viewer's photo
|
| 4829 |
+
// overlay stays aligned even if animation timing drifts slightly.
|
| 4830 |
+
const beacon = (idx) => { if (!asViewer && journey.drivenByMe) { try { journey.frame({ idx }); } catch (e) {} } };
|
| 4831 |
|
| 4832 |
// draw the route as a line under the journey
|
| 4833 |
const routeCoords = flyStops.map((s) => [s.lon, s.lat]);
|
|
|
|
| 4843 |
|
| 4844 |
const spd = (TRANSPORTS.find((t) => t.id === transport) || TRANSPORTS[0]).speed;
|
| 4845 |
// start at the first stop
|
| 4846 |
+
setFlyIdx(0); beacon(0);
|
| 4847 |
setTransportAt(flyStops[0].lon, flyStops[0].lat);
|
| 4848 |
map.flyTo({ center: [flyStops[0].lon, flyStops[0].lat], zoom: 15, pitch: 50, duration: 1400, essential: true });
|
| 4849 |
await new Promise((r) => setTimeout(r, 1500));
|
|
|
|
| 4870 |
requestAnimationFrame(step);
|
| 4871 |
});
|
| 4872 |
if (token.stop) break;
|
| 4873 |
+
setFlyIdx(i); beacon(i);
|
| 4874 |
map.flyTo({ center: [b.lon, b.lat], zoom: 15.5, pitch: 55, duration: 1100, essential: true });
|
| 4875 |
await new Promise((r) => setTimeout(r, 1300)); // dwell to show the photo
|
| 4876 |
}
|
|
|
|
| 4886 |
stopRecording();
|
| 4887 |
setFlying(false);
|
| 4888 |
setFlyIdx(-1);
|
| 4889 |
+
viewerFlyRef.current.active = false;
|
| 4890 |
+
// driver finished naturally → tell the peer + free the lock
|
| 4891 |
+
if (!asViewer && !token.stop && journey.drivenByMe) {
|
| 4892 |
+
try { Collab.send("journey_stop", {}); } catch (e) {}
|
| 4893 |
+
journey.release();
|
| 4894 |
+
}
|
| 4895 |
}
|
| 4896 |
+
|
| 4897 |
+
// viewer side: follow the peer's journey. journey_start runs the same
|
| 4898 |
+
// local flythrough (stops + transport already synced); journey_frame keeps
|
| 4899 |
+
// the photo index aligned; journey_stop ends it.
|
| 4900 |
+
useEffect(() => {
|
| 4901 |
+
const offs = [
|
| 4902 |
+
Collab.on("journey_transport", (m) => { if (m && m.transport) setTransport(m.transport); }),
|
| 4903 |
+
Collab.on("journey_start", (m) => {
|
| 4904 |
+
if (m && m.transport) setTransport(m.transport);
|
| 4905 |
+
// run on the next tick so the transport state is applied first
|
| 4906 |
+
setTimeout(() => { playFly({ asViewer: true }); }, 60);
|
| 4907 |
+
}),
|
| 4908 |
+
// safety net: if the driver disappears (disconnect) mid-flight, the
|
| 4909 |
+
// server broadcasts journey_state with no driver — stop our viewer run
|
| 4910 |
+
// so we don't keep flying a journey nobody is driving.
|
| 4911 |
+
Collab.on("journey_state", (m) => {
|
| 4912 |
+
if (viewerFlyRef.current.active && m && !m.driver) stopFly({ fromRemote: true });
|
| 4913 |
+
}),
|
| 4914 |
+
Collab.on("journey_frame", (m) => {
|
| 4915 |
+
if (m && typeof m.idx === "number" && viewerFlyRef.current.active) setFlyIdx(m.idx);
|
| 4916 |
+
}),
|
| 4917 |
+
Collab.on("journey_stop", () => { stopFly({ fromRemote: true }); }),
|
| 4918 |
+
];
|
| 4919 |
+
return () => offs.forEach((off) => off && off());
|
| 4920 |
+
}, [flyStops.length]); // eslint-disable-line
|
| 4921 |
useEffect(() => () => { flyAbortRef.current.stop = true; stopRecording(); }, []);
|
| 4922 |
|
| 4923 |
if (libMissing) {
|
|
|
|
| 4986 |
</span>
|
| 4987 |
<div className="flex flex-wrap items-center gap-1.5">
|
| 4988 |
{TRANSPORTS.map((t) => (
|
| 4989 |
+
<button key={t.id} type="button" onClick={() => chooseTransport(t.id)} aria-pressed={transport === t.id} title={t.label}
|
| 4990 |
+
disabled={flying || journey.drivenByOther}
|
| 4991 |
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",
|
| 4992 |
transport === t.id ? "border-primary bg-accent text-foreground" : "border-primary/40 text-muted-foreground hover:bg-accent/50")}>
|
| 4993 |
<span aria-hidden="true">{t.emoji}</span>{t.label}
|
|
|
|
| 4995 |
))}
|
| 4996 |
</div>
|
| 4997 |
<button type="button" onClick={() => (flying ? stopFly() : playFly({ record: false }))}
|
| 4998 |
+
disabled={!flying && journey.drivenByOther}
|
| 4999 |
+
title={journey.drivenByOther ? journey.driverLabel : undefined}
|
| 5000 |
+
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 disabled:opacity-50">
|
| 5001 |
+
{flying ? <X className="size-3.5" /> : <FastForward className="size-3.5" />} {flying ? "Stop" : (journey.drivenByOther ? "Playing…" : "Play")}
|
| 5002 |
</button>
|
| 5003 |
+
<button type="button" onClick={() => (flying ? stopFly() : playFly({ record: true }))} disabled={flying || journey.drivenByOther}
|
| 5004 |
+
title={journey.drivenByOther ? journey.driverLabel : "Play the journey and save it as a video clip"}
|
| 5005 |
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">
|
| 5006 |
<span className={cn("size-2.5 rounded-full", recording ? "animate-pulse bg-[oklch(0.55_0.2_25)]" : "bg-primary")} /> {recording ? "Recording…" : "Record clip"}
|
| 5007 |
</button>
|
|
|
|
| 5011 |
<Download className="size-3.5" /> Download clip
|
| 5012 |
</a>
|
| 5013 |
) : null}
|
| 5014 |
+
{journey.drivenByOther ? (
|
| 5015 |
+
<span className="font-mono text-[10px] uppercase tracking-wide text-primary">{journey.driverLabel}</span>
|
| 5016 |
+
) : null}
|
| 5017 |
</div>
|
| 5018 |
) : null}
|
| 5019 |
|
|
|
|
| 5063 |
{/* flythrough control (only when there are pinned album photos) */}
|
| 5064 |
{flyStops.length > 0 ? (
|
| 5065 |
<button type="button" onClick={() => (flying ? stopFly() : playFly())}
|
| 5066 |
+
disabled={!flying && journey.drivenByOther}
|
| 5067 |
+
title={journey.drivenByOther ? journey.driverLabel : (flying ? "Stop the flythrough" : "Play Your Journey")}
|
| 5068 |
+
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 disabled:opacity-50">
|
| 5069 |
{flying ? <X className="size-4" /> : <FastForward className="size-4" />}
|
| 5070 |
+
{flying ? "Stop" : (journey.drivenByOther ? "Playing…" : "Play journey")}
|
| 5071 |
</button>
|
| 5072 |
) : null}
|
| 5073 |
|
|
|
|
| 5301 |
];
|
| 5302 |
const [expanded, setExpanded] = useState(false); // in-page immersive view
|
| 5303 |
const [dropArmed, setDropArmed] = useState(false); // drag-from-tray hint
|
| 5304 |
+
|
| 5305 |
+
// #3/#5: same journey lock + sync as the watercolor map. The Cesium globe
|
| 5306 |
+
// (photorealistic 3D tiles) is the heaviest path, so the one-driver gate
|
| 5307 |
+
// matters most here. transport choice is synced; the viewer follows.
|
| 5308 |
+
const journey = useJourneyLock();
|
| 5309 |
+
const viewerFlyRef = useRef({ active: false });
|
| 5310 |
+
const chooseTransport = useCallback((id) => {
|
| 5311 |
+
setTransport(id);
|
| 5312 |
+
try { Collab.send("journey_transport", { transport: id }); } catch (e) {}
|
| 5313 |
+
}, []);
|
| 5314 |
const [armedId, setArmedId] = useState(null); // tap-to-pin armed photo
|
| 5315 |
|
| 5316 |
// tap-to-pin: arm a photo, then the next globe click pins it
|
|
|
|
| 5366 |
});
|
| 5367 |
viewerRef.current = viewer;
|
| 5368 |
try { viewer.clock.shouldAnimate = false; } catch (e) {}
|
| 5369 |
+
// ---- memory budget --------------------------------------------- //
|
| 5370 |
+
// The flythrough sweeps the camera across the whole route; with the
|
| 5371 |
+
// default (effectively unbounded) caches Cesium keeps every terrain
|
| 5372 |
+
// and building tile it ever loads, so GPU memory grows the entire
|
| 5373 |
+
// journey and OOMs the tab near the end. Cap the terrain cache and
|
| 5374 |
+
// coarsen distant terrain so passed-over tiles are evicted.
|
| 5375 |
+
try {
|
| 5376 |
+
viewer.scene.globe.tileCacheSize = 60; // default 100
|
| 5377 |
+
viewer.scene.globe.maximumScreenSpaceError = 3; // default 2 (coarser = fewer tiles)
|
| 5378 |
+
viewer.scene.globe.preloadSiblings = false;
|
| 5379 |
+
viewer.scene.fog.enabled = true; // fog culls distant tiles → less to load
|
| 5380 |
+
} catch (e) {}
|
| 5381 |
+
// Graceful path if the GPU context is still lost despite the caps:
|
| 5382 |
+
// show an error instead of a silent white freeze.
|
| 5383 |
+
try {
|
| 5384 |
+
viewer.scene.canvas.addEventListener("webglcontextlost", (ev) => {
|
| 5385 |
+
ev.preventDefault();
|
| 5386 |
+
if (!cancelled) { setStatus("error"); setErrMsg("The 3D view ran out of GPU memory. Reload to try again."); }
|
| 5387 |
+
}, false);
|
| 5388 |
+
} catch (e) {}
|
| 5389 |
viewer.scene.camera.setView({
|
| 5390 |
destination: Cesium.Cartesian3.fromDegrees(-123.1207, 49.2827, 1200),
|
| 5391 |
orientation: { heading: Cesium.Math.toRadians(10), pitch: Cesium.Math.toRadians(-25) },
|
|
|
|
| 5428 |
if (cancelled) { viewer.destroy(); return; }
|
| 5429 |
viewer.scene.primitives.add(tileset);
|
| 5430 |
tilesetRef.current = tileset;
|
| 5431 |
+
// ---- 3D-tiles memory cap (the OSM buildings are the heaviest
|
| 5432 |
+
// consumer and the real OOM source) -------------------------- //
|
| 5433 |
+
// cacheBytes is a HARD ceiling: once exceeded, tiles outside the
|
| 5434 |
+
// view (e.g. everywhere the flythrough already passed) are
|
| 5435 |
+
// unloaded instead of accumulating. dynamicScreenSpaceError makes
|
| 5436 |
+
// tiles toward the horizon much coarser — the forward-looking
|
| 5437 |
+
// chase camera otherwise floods memory with full-detail distance.
|
| 5438 |
+
try {
|
| 5439 |
+
tileset.cacheBytes = 256 * 1024 * 1024; // 256 MB hard cap
|
| 5440 |
+
tileset.maximumCacheOverflowBytes = 64 * 1024 * 1024; // small transient overflow only
|
| 5441 |
+
tileset.maximumScreenSpaceError = 16; // keep near-camera crisp
|
| 5442 |
+
tileset.dynamicScreenSpaceError = true;
|
| 5443 |
+
tileset.dynamicScreenSpaceErrorDensity = 0.0045;
|
| 5444 |
+
tileset.dynamicScreenSpaceErrorFactor = 4.0;
|
| 5445 |
+
tileset.dynamicScreenSpaceErrorHeightFalloff = 0.25;
|
| 5446 |
+
tileset.preloadWhenHidden = false;
|
| 5447 |
+
tileset.preloadFlightDestinations = false; // don't pre-load the overview's tiles in a burst
|
| 5448 |
+
} catch (e) {}
|
| 5449 |
tileset.style = buildCesiumStyle(Cesium, "watercolor", colorway);
|
| 5450 |
} catch (e) { console.warn("[cesium] OSM buildings failed", e); }
|
| 5451 |
if (!cancelled) setStatus("ready");
|
|
|
|
| 5531 |
});
|
| 5532 |
entitiesRef.current.push(ent);
|
| 5533 |
});
|
| 5534 |
+
}, [pinnedStore.map((p) => p.id + ":" + p.lon + "," + p.lat + ":" + p.caption + ":" + (p.src || "").slice(0, 64)).join("|"), status]);
|
| 5535 |
|
| 5536 |
// tap-to-pin + drag-to-move on the globe.
|
| 5537 |
const armedRef = useRef(null);
|
|
|
|
| 5600 |
if (viewer && vehicleRef.current) { try { viewer.entities.remove(vehicleRef.current); } catch (e) {} }
|
| 5601 |
vehicleRef.current = null;
|
| 5602 |
}
|
| 5603 |
+
function stopFly(opts) {
|
| 5604 |
+
opts = opts || {};
|
| 5605 |
flyAbortRef.current.stop = true;
|
| 5606 |
+
viewerFlyRef.current.active = false;
|
| 5607 |
setFlying(false); setFlyIdx(-1);
|
| 5608 |
const viewer = viewerRef.current;
|
| 5609 |
if (viewer) {
|
|
|
|
| 5613 |
try { if (viewer.entities.getById("sozai-route")) viewer.entities.removeById("sozai-route"); } catch (e) {}
|
| 5614 |
}
|
| 5615 |
removeVehicle();
|
| 5616 |
+
if (!opts.fromRemote && journey.drivenByMe) {
|
| 5617 |
+
try { Collab.send("journey_stop", {}); } catch (e) {}
|
| 5618 |
+
journey.release();
|
| 5619 |
+
}
|
| 5620 |
}
|
| 5621 |
+
async function playFly(opts) {
|
| 5622 |
+
opts = opts || {};
|
| 5623 |
+
const asViewer = !!opts.asViewer;
|
| 5624 |
const viewer = viewerRef.current;
|
| 5625 |
if (!viewer || !window.Cesium || flyStops.length < 1) return;
|
| 5626 |
+
// #3: one-driver gate (the 3D globe is the heaviest path → highest OOM risk)
|
| 5627 |
+
if (!asViewer && Collab.isCollab()) {
|
| 5628 |
+
const got = await journey.acquire();
|
| 5629 |
+
if (!got) return;
|
| 5630 |
+
journey.beginRefresh();
|
| 5631 |
+
try { Collab.send("journey_start", { transport }); } catch (e) {}
|
| 5632 |
+
}
|
| 5633 |
+
if (asViewer) viewerFlyRef.current = { active: true };
|
| 5634 |
const Cesium = window.Cesium;
|
| 5635 |
flyAbortRef.current = { stop: false };
|
| 5636 |
const token = flyAbortRef.current;
|
|
|
|
| 5733 |
}
|
| 5734 |
} catch (e) {}
|
| 5735 |
if (nextStop < stopTimes.length && t >= stopTimes[nextStop] - 0.2) {
|
| 5736 |
+
setFlyIdx(nextStop);
|
| 5737 |
+
if (!asViewer && journey.drivenByMe) { try { journey.frame({ idx: nextStop }); } catch (e) {} }
|
| 5738 |
+
nextStop++;
|
| 5739 |
}
|
| 5740 |
});
|
| 5741 |
clockTickRef.current = () => { try { onTick(); } catch (e) {} };
|
|
|
|
| 5753 |
if (clockTickRef.current) { clockTickRef.current(); clockTickRef.current = null; }
|
| 5754 |
viewer.clock.shouldAnimate = false;
|
| 5755 |
if (!token.stop && flyStops.length) {
|
| 5756 |
+
// Pulling back to show the WHOLE route is the single biggest tile
|
| 5757 |
+
// spike (every stop's buildings become visible at once). Coarsen the
|
| 5758 |
+
// 3D tiles hard and trim the cache BEFORE the wide flyTo so it can't
|
| 5759 |
+
// OOM, then restore detail once the camera has settled.
|
| 5760 |
+
const ts0 = tilesetRef.current;
|
| 5761 |
+
try { if (ts0) { ts0.maximumScreenSpaceError = 48; ts0.trimLoadedTiles(); } } catch (e) {}
|
| 5762 |
const lons = flyStops.map((s) => s.lon), lats = flyStops.map((s) => s.lat);
|
| 5763 |
const cx = (Math.min(...lons) + Math.max(...lons)) / 2, cy = (Math.min(...lats) + Math.max(...lats)) / 2;
|
| 5764 |
+
viewer.scene.camera.flyTo({ destination: Cesium.Cartesian3.fromDegrees(cx, cy - 0.02, 2200),
|
| 5765 |
orientation: { heading: 0, pitch: Cesium.Math.toRadians(-45) }, duration: 2.4 });
|
| 5766 |
await new Promise((r) => setTimeout(r, 2400));
|
| 5767 |
+
try { if (ts0) ts0.maximumScreenSpaceError = 16; } catch (e) {} // restore near-camera detail
|
| 5768 |
}
|
| 5769 |
removeVehicle();
|
| 5770 |
try { if (viewer.entities.getById("sozai-route")) viewer.entities.removeById("sozai-route"); } catch (e) {}
|
| 5771 |
setFlying(false); setFlyIdx(-1);
|
| 5772 |
+
viewerFlyRef.current.active = false;
|
| 5773 |
+
if (!asViewer && !token.stop && journey.drivenByMe) {
|
| 5774 |
+
try { Collab.send("journey_stop", {}); } catch (e) {}
|
| 5775 |
+
journey.release();
|
| 5776 |
+
}
|
| 5777 |
}
|
| 5778 |
|
| 5779 |
+
// viewer side: follow the peer's 3D journey (stops + transport synced).
|
| 5780 |
+
useEffect(() => {
|
| 5781 |
+
const offs = [
|
| 5782 |
+
Collab.on("journey_transport", (m) => { if (m && m.transport) setTransport(m.transport); }),
|
| 5783 |
+
Collab.on("journey_start", (m) => {
|
| 5784 |
+
if (m && m.transport) setTransport(m.transport);
|
| 5785 |
+
setTimeout(() => playFly({ asViewer: true }), 60);
|
| 5786 |
+
}),
|
| 5787 |
+
Collab.on("journey_frame", (m) => {
|
| 5788 |
+
if (m && typeof m.idx === "number" && viewerFlyRef.current.active) setFlyIdx(m.idx);
|
| 5789 |
+
}),
|
| 5790 |
+
Collab.on("journey_stop", () => { stopFly({ fromRemote: true }); }),
|
| 5791 |
+
// driver disconnected mid-flight → stop following
|
| 5792 |
+
Collab.on("journey_state", (m) => {
|
| 5793 |
+
if (viewerFlyRef.current.active && m && !m.driver) stopFly({ fromRemote: true });
|
| 5794 |
+
}),
|
| 5795 |
+
];
|
| 5796 |
+
return () => offs.forEach((off) => off && off());
|
| 5797 |
+
}, [flyStops.length]); // eslint-disable-line
|
| 5798 |
+
|
| 5799 |
if (status === "no-token") {
|
| 5800 |
return (
|
| 5801 |
<div className="rounded-lg border-2 border-primary/40 bg-accent/30 p-6 text-center">
|
|
|
|
| 5869 |
</span>
|
| 5870 |
<div className="flex flex-wrap items-center gap-1.5">
|
| 5871 |
{CS_TRANSPORTS.map((t) => (
|
| 5872 |
+
<button key={t.id} type="button" onClick={() => chooseTransport(t.id)} aria-pressed={transport === t.id} title={t.label} disabled={flying || journey.drivenByOther}
|
| 5873 |
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",
|
| 5874 |
transport === t.id ? "border-primary bg-accent text-foreground" : "border-primary/40 text-muted-foreground hover:bg-accent/50")}>
|
| 5875 |
<span aria-hidden="true">{t.emoji}</span>{t.label}
|
| 5876 |
</button>
|
| 5877 |
))}
|
| 5878 |
</div>
|
| 5879 |
+
{journey.drivenByOther ? (
|
| 5880 |
+
<span className="font-mono text-[10px] uppercase tracking-wide text-primary">{journey.driverLabel}</span>
|
| 5881 |
+
) : null}
|
| 5882 |
</div>
|
| 5883 |
) : null}
|
| 5884 |
|
|
|
|
| 5923 |
) : null}
|
| 5924 |
{flyStops.length > 0 && status === "ready" ? (
|
| 5925 |
<button type="button" onClick={() => (flying ? stopFly() : playFly())}
|
| 5926 |
+
disabled={!flying && journey.drivenByOther}
|
| 5927 |
+
title={journey.drivenByOther ? journey.driverLabel : undefined}
|
| 5928 |
+
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 disabled:opacity-50">
|
| 5929 |
+
{flying ? <X className="size-4" /> : <FastForward className="size-4" />} {flying ? "Stop" : (journey.drivenByOther ? "Playing…" : "Play journey")}
|
| 5930 |
</button>
|
| 5931 |
) : null}
|
| 5932 |
{flying && flyStops[flyIdx] ? (
|
|
|
|
| 6692 |
<div className="relative w-full overflow-hidden rounded-lg border-2 border-primary/40 bg-[oklch(0.95_0.018_85)]">
|
| 6693 |
<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" />
|
| 6694 |
<RouteLines moments={liveMoments} />
|
| 6695 |
+
<div className="relative h-[540px] w-full sm:h-[560px]">
|
| 6696 |
{liveMoments.map((m, i) => <MomentCard key={m.id} moment={m} rotate={rotations[i % rotations.length]} selected={selectedId === m.id} onSelect={setSelectedId} />)}
|
| 6697 |
</div>
|
| 6698 |
</div>
|
|
|
|
| 7018 |
|
| 7019 |
// baths kept from the zip (colors + labels), seen from above
|
| 7020 |
const DR_BATHS = [
|
| 7021 |
+
{ id: "developer", label: "Developer", color: "oklch(0.45 0.14 150)", edge: "oklch(0.32 0.1 150)" },
|
| 7022 |
+
{ id: "stop", label: "Stop Bath", color: "oklch(0.5 0.12 90)", edge: "oklch(0.36 0.09 90)" },
|
| 7023 |
+
{ id: "fixer", label: "Fixer", color: "oklch(0.5 0.1 250)", edge: "oklch(0.36 0.08 250)" },
|
| 7024 |
+
{ id: "wash", label: "Wash", color: "oklch(0.55 0.08 220)", edge: "oklch(0.4 0.06 220)" },
|
| 7025 |
];
|
| 7026 |
// top-down layout - columns are now driven by flex, not % coordinates
|
| 7027 |
|
|
|
|
| 7274 |
// ---- polaroid print card (shared by both bath phases) --------------- //
|
| 7275 |
function PrintCard({ sheet }) {
|
| 7276 |
const bf = drBathFilter(sheet.level);
|
| 7277 |
+
// (no animated SVG ripple filter — it flashed/repainted badly on mobile)
|
| 7278 |
+
const filt = bf;
|
| 7279 |
return (
|
| 7280 |
<div className="w-full" style={{
|
| 7281 |
transform: "rotate(" + sheet.rot + "deg) scale(" + sheet.scale + ")",
|
|
|
|
| 7383 |
style={{ background: "radial-gradient(120% 100% at 50% 0%, oklch(0.20 0.04 55), oklch(0.10 0.02 55) 70%)",
|
| 7384 |
boxShadow: "inset 0 0 70px rgba(0,0,0,0.55)" }}>
|
| 7385 |
|
| 7386 |
+
<div className="flex items-end gap-1.5 px-2 pb-3 pt-8 sm:gap-3 sm:px-4 sm:pb-4 sm:pt-10">
|
| 7387 |
{DR_BATHS.map((b, i) => {
|
| 7388 |
const aboveCards = sheets.filter((s) => s.col === i && s.above);
|
| 7389 |
const subCards = sheets.filter((s) => s.col === i && !s.above);
|
|
|
|
| 7404 |
style={{ aspectRatio: "1 / 1.5", background: "oklch(0.12 0.02 55)", boxShadow: "0 6px 16px rgba(0,0,0,0.5)" }}>
|
| 7405 |
<div className="relative h-full w-full overflow-hidden rounded-[6px]"
|
| 7406 |
style={{ background: "radial-gradient(120% 120% at 50% 0%, " + b.color + ", " + b.edge + ")" }}>
|
| 7407 |
+
{/* a single STILL highlight (no animated turbulence / WebGL
|
| 7408 |
+
shimmer — those flashed green/orange on some phones) */}
|
| 7409 |
+
<div className="pointer-events-none absolute inset-0" style={{
|
| 7410 |
+
background: "linear-gradient(180deg, rgba(255,255,255,0.16), rgba(255,255,255,0) 45%)", zIndex: 2 }} />
|
| 7411 |
+
<div className="pointer-events-none absolute inset-0" style={{ boxShadow: "inset 0 0 24px rgba(0,0,0,0.5)", zIndex: 3 }} />
|
| 7412 |
{/* submerged card lives inside its tray */}
|
| 7413 |
{subCards.map((s) => (
|
| 7414 |
<div key={s.id} className="absolute" style={{ inset: "10%", zIndex: 4 + s.z }}>
|
|
|
|
| 7457 |
}
|
| 7458 |
|
| 7459 |
// ---- phase 2: hanging print, shake to reveal (ported) -------------- //
|
| 7460 |
+
// (shake reveal is now distance-based; see HangingPrint.addTravel)
|
| 7461 |
+
function HangingPrint({ photo, index, swayDelay, onRevealed, widthClass, isDeveloper, onDeveloped }) {
|
| 7462 |
const [progress, setProgress] = useState(0);
|
| 7463 |
const [revealed, setRevealed] = useState(false);
|
| 7464 |
const [dragging, setDragging] = useState(false);
|
| 7465 |
const [tilt, setTilt] = useState(0);
|
| 7466 |
+
const dragState = useRef({ active: false, lastX: 0, lastY: 0, offset: 0 });
|
| 7467 |
const progressRef = useRef(0);
|
| 7468 |
+
const revealedRef = useRef(false);
|
| 7469 |
+
useEffect(() => { revealedRef.current = revealed; }, [revealed]);
|
| 7470 |
|
| 7471 |
useEffect(() => { if (revealed) onRevealed(photo.id); }, [revealed, onRevealed, photo.id]);
|
| 7472 |
|
| 7473 |
+
// #5: when the peer reveals this print, reveal it here too (mirror the shake)
|
| 7474 |
+
useEffect(() => {
|
| 7475 |
+
const off = Collab.on("darkroom_reveal", (m) => {
|
| 7476 |
+
if (m && m.id === photo.id) { progressRef.current = 1; setProgress(1); revealedRef.current = true; setRevealed(true); }
|
| 7477 |
+
});
|
| 7478 |
+
return () => off && off();
|
| 7479 |
+
}, [photo.id]);
|
| 7480 |
+
|
| 7481 |
// Kick off the watercolour "develop" (flux.2-klein img2img) the moment the
|
| 7482 |
// print is hung, so the result is ready by the time it's shaken in. The
|
| 7483 |
// shake then reveals the DEVELOPED image; if the endpoint is unavailable
|
| 7484 |
// or fails, we fall back to the original photo (no regression).
|
| 7485 |
+
//
|
| 7486 |
+
// #3 (OOM): the FLUX develop is GPU-heavy. In a room, only ONE person —
|
| 7487 |
+
// the develop-lock holder (isDeveloper) — actually calls /api/img2img. The
|
| 7488 |
+
// developed watercolour is written to the shared SozaiPhotos store, so it
|
| 7489 |
+
// syncs to the peer automatically; the non-developer simply WAITS for that
|
| 7490 |
+
// synced result (photo.src flips to a fresh data URL) instead of hitting
|
| 7491 |
+
// the GPU a second time. Solo: isDeveloper is always true.
|
| 7492 |
const [developedSrc, setDevelopedSrc] = useState(null);
|
| 7493 |
const [devStatus, setDevStatus] = useState("queued"); // queued|developing|done|base
|
| 7494 |
const developRef = useRef(false);
|
| 7495 |
+
const initialSrcRef = useRef(photo.src);
|
| 7496 |
+
|
| 7497 |
+
// Non-developer path: watch for the developed image to arrive via sync.
|
| 7498 |
+
// When the store's src for this photo changes from what we started with,
|
| 7499 |
+
// the watercolour has synced in — treat it as developed + shakeable. Also
|
| 7500 |
+
// listen for the developer's explicit outcome (covers the "develop feature
|
| 7501 |
+
// is off → keep the original" case, where no new src is ever written).
|
| 7502 |
useEffect(() => {
|
| 7503 |
+
if (isDeveloper) return;
|
| 7504 |
+
setDevStatus((s) => (s === "queued" ? "developing" : s));
|
| 7505 |
+
if (photo.src && photo.src !== initialSrcRef.current) {
|
| 7506 |
+
setDevelopedSrc(photo.src);
|
| 7507 |
+
setDevStatus("done");
|
| 7508 |
+
}
|
| 7509 |
+
const off = Collab.on("darkroom_developed", (m) => {
|
| 7510 |
+
if (!m || m.id !== photo.id) return;
|
| 7511 |
+
if (m.status === "base") setDevStatus("base");
|
| 7512 |
+
else if (m.status === "done") setDevStatus((s) => (s === "done" ? s : "developing"));
|
| 7513 |
+
});
|
| 7514 |
+
return () => off && off();
|
| 7515 |
+
}, [isDeveloper, photo.src, photo.id]);
|
| 7516 |
+
|
| 7517 |
+
useEffect(() => {
|
| 7518 |
+
if (!isDeveloper) return; // only the developer hits the GPU
|
| 7519 |
if (developRef.current || !photo.src) return;
|
| 7520 |
developRef.current = true;
|
| 7521 |
let alive = true;
|
|
|
|
| 7549 |
if (j && j.available && j.image) {
|
| 7550 |
if (alive) {
|
| 7551 |
setDevelopedSrc(j.image); setDevStatus("done");
|
| 7552 |
+
// tell the peer this print finished developing (the image
|
| 7553 |
+
// itself syncs via the store; this covers reveal-gating)
|
| 7554 |
+
try { Collab.send("darkroom_developed", { id: photo.id, status: "done" }); } catch (e) {}
|
| 7555 |
// write the DEVELOPED watercolour + its pin back into the shared
|
| 7556 |
// store so the map shows the transformed photo, auto-pinned at
|
| 7557 |
+
// its GPS location. This also SYNCS the watercolour to the peer
|
| 7558 |
+
// (PhotoSync), which is how the non-developer gets the result.
|
| 7559 |
try {
|
| 7560 |
window.SozaiPhotos.upsert(photo.id, {
|
| 7561 |
src: j.image,
|
|
|
|
| 7570 |
return;
|
| 7571 |
}
|
| 7572 |
if (j && j.available === false) { // feature genuinely off
|
| 7573 |
+
if (alive) { setDevStatus("base"); try { Collab.send("darkroom_developed", { id: photo.id, status: "base" }); } catch (e) {} }
|
| 7574 |
+
return;
|
| 7575 |
}
|
| 7576 |
}
|
| 7577 |
} catch (e) { console.warn("[develop] attempt", attempt, "failed", e); }
|
| 7578 |
await sleep(5000);
|
| 7579 |
}
|
| 7580 |
+
if (alive) { setDevStatus("base"); try { Collab.send("darkroom_developed", { id: photo.id, status: "base" }); } catch (e) {} } // gave up → keep the original
|
| 7581 |
})();
|
| 7582 |
return () => { alive = false; };
|
| 7583 |
+
}, [isDeveloper, photo.src, photo.alt, index]);
|
| 7584 |
+
|
| 7585 |
+
// Progress is driven by TOTAL travel distance (sum of |movement|), not by
|
| 7586 |
+
// counting precise left-right-left reversals. Reversals are hard to land
|
| 7587 |
+
// with a thumb and made the shake feel broken on phones; accumulating
|
| 7588 |
+
// distance means any vigorous wiggle reliably develops the print.
|
| 7589 |
+
const SHAKE_DISTANCE_TO_REVEAL = 520; // px of total travel
|
| 7590 |
+
const addTravel = useCallback((dist) => {
|
| 7591 |
+
const next = Math.min(1, progressRef.current + dist / SHAKE_DISTANCE_TO_REVEAL);
|
| 7592 |
progressRef.current = next; setProgress(next);
|
| 7593 |
+
if (next >= 1 && !revealedRef.current) {
|
| 7594 |
+
revealedRef.current = true;
|
| 7595 |
+
setRevealed(true);
|
| 7596 |
+
try { Collab.send("darkroom_reveal", { id: photo.id }); } catch (e) {}
|
| 7597 |
+
}
|
| 7598 |
+
}, [photo.id]);
|
| 7599 |
|
| 7600 |
// No shaking until the watercolour has actually developed (or the develop
|
| 7601 |
// feature is off / gave up, where "base" means we reveal the original).
|
| 7602 |
const canShake = devStatus === "done" || devStatus === "base";
|
| 7603 |
+
// Tell the parent the moment GPU work for this print is finished (done or
|
| 7604 |
+
// base), so it can release the develop lock instantly — the shake/reveal
|
| 7605 |
+
// phase is pure CSS and needs no lock.
|
| 7606 |
+
const reportedRef = useRef(false);
|
| 7607 |
+
useEffect(() => {
|
| 7608 |
+
if (canShake && !reportedRef.current) { reportedRef.current = true; onDeveloped && onDeveloped(photo.id); }
|
| 7609 |
+
}, [canShake, photo.id, onDeveloped]);
|
| 7610 |
+
|
| 7611 |
+
const tiltRafRef = useRef(0);
|
| 7612 |
function onPointerDown(e) {
|
| 7613 |
if (revealed || !canShake) return;
|
| 7614 |
try { e.currentTarget.setPointerCapture(e.pointerId); } catch (err) {}
|
| 7615 |
+
dragState.current = { active: true, lastX: e.clientX, lastY: e.clientY, offset: 0 };
|
| 7616 |
setDragging(true);
|
| 7617 |
}
|
| 7618 |
function onPointerMove(e) {
|
| 7619 |
const s = dragState.current;
|
| 7620 |
if (!s.active || revealed || !canShake) return;
|
| 7621 |
const dx = e.clientX - s.lastX;
|
| 7622 |
+
const dy = e.clientY - s.lastY;
|
| 7623 |
+
const dist = Math.hypot(dx, dy);
|
| 7624 |
+
if (dist < 2) return; // ignore micro-jitter
|
| 7625 |
+
addTravel(dist); // any movement counts toward reveal
|
| 7626 |
+
s.lastX = e.clientX; s.lastY = e.clientY;
|
| 7627 |
s.offset = Math.max(-26, Math.min(26, s.offset + dx));
|
| 7628 |
+
// throttle tilt updates to one per frame so fast shakes stay smooth
|
| 7629 |
+
if (!tiltRafRef.current) {
|
| 7630 |
+
tiltRafRef.current = requestAnimationFrame(() => { tiltRafRef.current = 0; setTilt(dragState.current.offset); });
|
| 7631 |
+
}
|
| 7632 |
}
|
| 7633 |
function endDrag(e) {
|
| 7634 |
dragState.current.active = false; setDragging(false); setTilt(0);
|
| 7635 |
+
if (tiltRafRef.current) { cancelAnimationFrame(tiltRafRef.current); tiltRafRef.current = 0; }
|
| 7636 |
try { e.currentTarget.releasePointerCapture(e.pointerId); } catch (err) {}
|
| 7637 |
}
|
| 7638 |
+
useEffect(() => () => { if (tiltRafRef.current) cancelAnimationFrame(tiltRafRef.current); }, []);
|
| 7639 |
|
| 7640 |
const blurPx = (1 - progress) * 14;
|
| 7641 |
const grayscale = (1 - progress) * 100;
|
|
|
|
| 7649 |
className={cn("group relative -mt-1.5 select-none rounded-sm bg-[oklch(0.99_0.01_85)] p-3 pb-16 shadow-2xl",
|
| 7650 |
dragging ? "" : "dr-sway", revealed ? "cursor-default" : (canShake ? "cursor-grab active:cursor-grabbing" : "cursor-progress"))}
|
| 7651 |
style={{ animationDelay: swayDelay + "s",
|
| 7652 |
+
// touch-action none while shakeable so the phone doesn't scroll the
|
| 7653 |
+
// page out from under the shake gesture (the main cause of jitter)
|
| 7654 |
+
touchAction: (canShake && !revealed) ? "none" : "auto",
|
| 7655 |
transform: dragging ? "rotate(" + (tilt * 0.3) + "deg) translateX(" + (tilt * 0.4) + "px)" : undefined,
|
| 7656 |
transition: dragging ? "none" : "transform 0.4s ease" }}
|
| 7657 |
onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={endDrag} onPointerCancel={endDrag}>
|
|
|
|
| 7693 |
const [muted, setMuted] = useState(false);
|
| 7694 |
const sfx = useWaterSfx(muted);
|
| 7695 |
const allRevealed = revealed.size === list.length;
|
| 7696 |
+
const journey = useJourneyLock(); // reuse the journey lock for the GPU-heavy develop
|
| 7697 |
+
|
| 7698 |
+
// #3: only ONE person runs the FLUX develop (the lock holder). Solo always
|
| 7699 |
+
// wins the lock. The other person is a viewer: they don't hit the GPU and
|
| 7700 |
+
// instead receive each developed watercolour via the shared store. We grab
|
| 7701 |
+
// the lock on mount and — per the requested behaviour — RELEASE IT THE
|
| 7702 |
+
// INSTANT all GPU work is finished (the shake/reveal phase is pure CSS and
|
| 7703 |
+
// needs no lock), rather than holding it until the darkroom closes.
|
| 7704 |
+
const [isDeveloper, setIsDeveloper] = useState(!Collab.isCollab());
|
| 7705 |
+
const heldRef = useRef(false); // do we currently hold the lock?
|
| 7706 |
+
const developedRef = useRef(new Set()); // ids whose GPU work has finished
|
| 7707 |
+
const releaseLock = useCallback(() => {
|
| 7708 |
+
if (heldRef.current) { heldRef.current = false; journey.release(); }
|
| 7709 |
+
}, []); // eslint-disable-line
|
| 7710 |
+
useEffect(() => {
|
| 7711 |
+
let alive = true;
|
| 7712 |
+
(async () => {
|
| 7713 |
+
if (!Collab.isCollab()) { setIsDeveloper(true); return; }
|
| 7714 |
+
const got = await journey.acquire();
|
| 7715 |
+
if (!alive) { if (got) journey.release(); return; }
|
| 7716 |
+
setIsDeveloper(got);
|
| 7717 |
+
if (got) { heldRef.current = true; journey.beginRefresh(); }
|
| 7718 |
+
})();
|
| 7719 |
+
// safety: always release on unmount in case develop never completed
|
| 7720 |
+
return () => { alive = false; releaseLock(); };
|
| 7721 |
+
}, []); // eslint-disable-line
|
| 7722 |
+
|
| 7723 |
+
// each print reports when its GPU work is done; once every print is
|
| 7724 |
+
// finished, free the lock immediately so the peer can start their journey.
|
| 7725 |
+
const onPrintDeveloped = useCallback((id) => {
|
| 7726 |
+
developedRef.current.add(id);
|
| 7727 |
+
if (list.length > 0 && developedRef.current.size >= list.length) releaseLock();
|
| 7728 |
+
}, [list.length, releaseLock]);
|
| 7729 |
|
| 7730 |
const handleRevealed = useCallback((id) => {
|
| 7731 |
setRevealed((prev) => { const next = new Set(prev); next.add(id); return next; });
|
| 7732 |
}, []);
|
| 7733 |
|
| 7734 |
+
// #5: sync the darkroom phase (baths → hang) so both move together. The
|
| 7735 |
+
// developer drives the phase; the viewer follows. Either receiving a phase
|
| 7736 |
+
// event applies it locally.
|
| 7737 |
+
useEffect(() => {
|
| 7738 |
+
const off = Collab.on("darkroom_phase", (m) => { if (m && m.phase) setPhase(m.phase); });
|
| 7739 |
+
return () => off && off();
|
| 7740 |
+
}, []);
|
| 7741 |
+
const goPhase = useCallback((p) => {
|
| 7742 |
+
setPhase(p);
|
| 7743 |
+
try { Collab.send("darkroom_phase", { phase: p }); } catch (e) {}
|
| 7744 |
+
}, []);
|
| 7745 |
+
|
| 7746 |
+
// #5: mirror peer reveals at the parent too (so the X/Y "done" counter and
|
| 7747 |
+
// the "View your map" gate advance for both people).
|
| 7748 |
+
useEffect(() => {
|
| 7749 |
+
const off = Collab.on("darkroom_reveal", (m) => {
|
| 7750 |
+
if (m && m.id) handleRevealed(m.id);
|
| 7751 |
+
});
|
| 7752 |
+
return () => off && off();
|
| 7753 |
+
}, [handleRevealed]);
|
| 7754 |
+
|
| 7755 |
return (
|
| 7756 |
<div className="fixed inset-0 z-50 flex flex-col overflow-hidden bg-[oklch(0.12_0.022_55)]">
|
| 7757 |
<style dangerouslySetInnerHTML={{ __html:
|
|
|
|
| 7798 |
</button>
|
| 7799 |
</div>
|
| 7800 |
|
| 7801 |
+
{/* centred content (scrollable on small screens so tall print rows
|
| 7802 |
+
never get clipped by the fixed overlay) */}
|
| 7803 |
+
<div className="relative flex flex-1 flex-col items-center justify-start gap-6 overflow-y-auto px-5 pb-10 pt-2 sm:justify-center sm:px-8">
|
| 7804 |
<div className="text-center">
|
| 7805 |
+
<h1 className="font-display text-4xl leading-none text-[oklch(0.92_0.04_78)] sm:text-6xl">The Darkroom</h1>
|
| 7806 |
+
<p className="mt-3 font-serif italic text-lg text-[oklch(0.70_0.07_65)] sm:text-xl">
|
| 7807 |
{phase === "baths"
|
| 7808 |
? "developing your photos"
|
| 7809 |
: allRevealed ? "your photos are ready"
|
|
|
|
| 7812 |
</div>
|
| 7813 |
|
| 7814 |
{phase === "baths" ? (
|
| 7815 |
+
<TopDownBaths photos={list} sfx={sfx} onComplete={() => goPhase("hang")} />
|
| 7816 |
) : (
|
| 7817 |
<div className="flex w-full flex-col items-center">
|
| 7818 |
<div className="relative w-full">
|
|
|
|
| 7825 |
: list.length <= 4 ? "w-60 sm:w-72"
|
| 7826 |
: "w-52 sm:w-60";
|
| 7827 |
return (
|
| 7828 |
+
<HangingPrint key={photo.id} photo={photo} index={i} swayDelay={i * 0.4} onRevealed={handleRevealed} widthClass={heroW} isDeveloper={isDeveloper} onDeveloped={onPrintDeveloped} />
|
| 7829 |
);
|
| 7830 |
})}
|
| 7831 |
</ul>
|
|
|
|
| 7853 |
function Page({ onOpenSettings, onOpenPets, onOpenTrace, screen, setScreen, mapVariant, setMapVariant }) {
|
| 7854 |
// Each photo carries its own title/caption/date/time - a single source of
|
| 7855 |
// truth so the fields can never desync from the photo list.
|
| 7856 |
+
//
|
| 7857 |
+
// Upload sync: stackPhotos MUST be a live view of the shared SozaiPhotos
|
| 7858 |
+
// store, not a private copy. The store is what PhotoSync relays to/from
|
| 7859 |
+
// the peer (photos_state). Subscribing here means a peer's upload / edit /
|
| 7860 |
+
// delete — which lands in the store via replaceAll — immediately shows up,
|
| 7861 |
+
// and our own edits get broadcast. setStackPhotos is a thin wrapper that
|
| 7862 |
+
// funnels every local mutation back into the store, so there is exactly
|
| 7863 |
+
// ONE source of truth and uploads always sync both ways.
|
| 7864 |
+
const [stackPhotos, setStackPhotosRaw] = useState(() => window.SozaiPhotos.all());
|
| 7865 |
+
useEffect(() => window.SozaiPhotos.subscribe((items) => setStackPhotosRaw(items.slice())), []);
|
| 7866 |
+
const setStackPhotos = useCallback((next) => {
|
| 7867 |
+
const current = window.SozaiPhotos.all();
|
| 7868 |
+
const resolved = typeof next === "function" ? next(current) : next;
|
| 7869 |
+
if (!Array.isArray(resolved)) return;
|
| 7870 |
+
window.SozaiPhotos.replaceAll(resolved);
|
| 7871 |
+
}, []);
|
| 7872 |
const developPhotos = stackPhotos.map((p) => ({ id: p.id, src: p.src, alt: p.alt, defaultCaption: p.defaultCaption,
|
| 7873 |
lat: p.lat, lon: p.lon, date: p.date, time: p.time, title: p.title, caption: p.caption }));
|
| 7874 |
if (screen === "add")
|
|
|
|
| 7882 |
return <MapOfMyTime onBack={() => setScreen("add")} onOpenSettings={onOpenSettings} onOpenPets={onOpenPets} variant={mapVariant} onChangeVariant={setMapVariant} />;
|
| 7883 |
}
|
| 7884 |
|
| 7885 |
+
// ----------------------------------------------------------------- //
|
| 7886 |
+
// Join gate (#4): a full-screen loading / status screen shown while a //
|
| 7887 |
+
// joiner is connecting or awaiting host approval, so they never see or //
|
| 7888 |
+
// interact with the room before they're actually admitted. //
|
| 7889 |
+
// ----------------------------------------------------------------- //
|
| 7890 |
+
function JoinGate({ state }) {
|
| 7891 |
+
const joining = state === "joining";
|
| 7892 |
+
const pending = state === "pending";
|
| 7893 |
+
const declined = state === "declined";
|
| 7894 |
+
const full = state === "full";
|
| 7895 |
+
const title = joining ? "Joining the room…"
|
| 7896 |
+
: pending ? "Waiting for the host…"
|
| 7897 |
+
: declined ? "Not admitted"
|
| 7898 |
+
: "This room is full";
|
| 7899 |
+
const blurb = joining ? "Connecting you to the shared album."
|
| 7900 |
+
: pending ? "The host needs to let you in. Hang tight; this screen updates the moment they do."
|
| 7901 |
+
: declined ? "The host didn't admit you to this room."
|
| 7902 |
+
: "This room is at its 2-person limit. Ask them to make space, or start your own room.";
|
| 7903 |
+
const spinning = joining || pending;
|
| 7904 |
+
return (
|
| 7905 |
+
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-background p-6">
|
| 7906 |
+
<div className="w-full max-w-sm rounded-lg border-2 border-primary/40 bg-card p-7 text-center shadow-lg">
|
| 7907 |
+
<div className="mx-auto flex size-12 items-center justify-center rounded-full border-2 border-primary/50 text-primary">
|
| 7908 |
+
{spinning ? <Loader2 className="size-6 animate-spin" />
|
| 7909 |
+
: declined ? <X className="size-6" /> : <Users className="size-6" />}
|
| 7910 |
+
</div>
|
| 7911 |
+
<h1 className="mt-4 font-display text-2xl text-foreground">{title}</h1>
|
| 7912 |
+
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">{blurb}</p>
|
| 7913 |
+
{(declined || full) ? (
|
| 7914 |
+
<a href="/"
|
| 7915 |
+
className="mt-5 inline-flex items-center justify-center rounded-md border-2 border-primary bg-primary px-5 py-2.5 font-mono text-xs uppercase tracking-wide text-primary-foreground transition-colors hover:bg-primary/90">
|
| 7916 |
+
Back to start
|
| 7917 |
+
</a>
|
| 7918 |
+
) : (
|
| 7919 |
+
<div className="mt-5 flex items-center justify-center gap-1" aria-hidden="true">
|
| 7920 |
+
<span className="size-1.5 rounded-full bg-primary" style={{ animation: "sozai-bounce 1s infinite", animationDelay: "0ms" }} />
|
| 7921 |
+
<span className="size-1.5 rounded-full bg-primary" style={{ animation: "sozai-bounce 1s infinite", animationDelay: "150ms" }} />
|
| 7922 |
+
<span className="size-1.5 rounded-full bg-primary" style={{ animation: "sozai-bounce 1s infinite", animationDelay: "300ms" }} />
|
| 7923 |
+
</div>
|
| 7924 |
+
)}
|
| 7925 |
+
</div>
|
| 7926 |
+
</div>
|
| 7927 |
+
);
|
| 7928 |
+
}
|
| 7929 |
+
|
| 7930 |
function Root() {
|
| 7931 |
const [chatOpen, setChatOpen] = useState(false);
|
| 7932 |
const [unread, setUnread] = useState(0);
|
|
|
|
| 7937 |
});
|
| 7938 |
const [settings, setSettings] = useState(loadSettings);
|
| 7939 |
|
| 7940 |
+
// ---- join gate (#4) ----------------------------------------------- //
|
| 7941 |
+
// When joining a room we must NOT show the live app until we're actually
|
| 7942 |
+
// admitted (init arrives). Otherwise the joiner sees/can touch the room
|
| 7943 |
+
// while still "pending". joinState: "joining" until init; then "in".
|
| 7944 |
+
// "pending" = waiting for host approval; "declined"/"full" = terminal.
|
| 7945 |
+
// Solo (no /room/ path) is "in" immediately — nothing to wait for.
|
| 7946 |
+
const [joinState, setJoinState] = useState(() => (Collab.isCollab() ? "joining" : "in"));
|
| 7947 |
+
|
| 7948 |
useEffect(() => { Collab.connect(); }, []);
|
| 7949 |
useEffect(() => {
|
| 7950 |
// load saved scrapbook for this room (if any) and start auto-saving
|
| 7951 |
RoomPersistence.init();
|
| 7952 |
+
PhotoSync.init(); // live image relay (instant peer sync)
|
| 7953 |
const onHide = () => { try { RoomPersistence.flush(); } catch (e) {} };
|
| 7954 |
window.addEventListener("beforeunload", onHide);
|
| 7955 |
document.addEventListener("visibilitychange", () => { if (document.visibilityState === "hidden") onHide(); });
|
| 7956 |
+
return () => { window.removeEventListener("beforeunload", onHide); RoomPersistence.stop(); PhotoSync.stop(); };
|
| 7957 |
}, []);
|
| 7958 |
useEffect(() => { applySettings(settings); }, []); // eslint-disable-line
|
| 7959 |
useEffect(() => {
|
|
|
|
| 7978 |
}),
|
| 7979 |
Collab.on("join_pending", () => {
|
| 7980 |
window.__sozaiJoinPending = true;
|
| 7981 |
+
setJoinState("pending");
|
|
|
|
| 7982 |
}),
|
| 7983 |
Collab.on("join_declined", () => {
|
| 7984 |
ToastStore.dismiss("join-wait");
|
| 7985 |
+
setJoinState("declined");
|
| 7986 |
+
}),
|
| 7987 |
+
// room is at the 2-person cap → explain and offer to go home
|
| 7988 |
+
Collab.on("room_full", (m) => {
|
| 7989 |
+
ToastStore.dismiss("join-wait");
|
| 7990 |
+
window.__sozaiRoomFull = true;
|
| 7991 |
+
setJoinState("full");
|
| 7992 |
}),
|
| 7993 |
+
// once we're fully in (init arrives), reveal the app
|
| 7994 |
+
Collab.on("me", () => { ToastStore.dismiss("join-wait"); window.__sozaiJoinPending = false; setJoinState("in"); }),
|
| 7995 |
];
|
| 7996 |
return () => offs.forEach((off) => off && off());
|
| 7997 |
}, []);
|
|
|
|
| 8013 |
const [followPeer, setFollowPeer] = useState(true);
|
| 8014 |
const followRef = useRef(followPeer);
|
| 8015 |
useEffect(() => { followRef.current = followPeer; window.__sozaiFollow = followPeer; }, [followPeer]);
|
| 8016 |
+
// Screens that are PERSONAL — a collaborator entering them should not drag
|
| 8017 |
+
// the other person along. The cat picker ("pets") is explicitly personal:
|
| 8018 |
+
// each person chooses their own cat independently. "trace" (debug/LLM
|
| 8019 |
+
// trace) is likewise private. We neither follow INTO these nor broadcast
|
| 8020 |
+
// our own entry into them.
|
| 8021 |
+
const PRIVATE_SCREENS = React.useRef(new Set(["pets", "trace"])).current;
|
| 8022 |
useEffect(() => {
|
| 8023 |
const offs = [
|
| 8024 |
+
Collab.on("screen_changed", (m) => {
|
| 8025 |
+
if (!followRef.current || !m || !m.screen) return;
|
| 8026 |
+
if (PRIVATE_SCREENS.has(m.screen)) return; // don't follow into a private screen
|
| 8027 |
+
setScreen(m.screen);
|
| 8028 |
+
}),
|
| 8029 |
Collab.on("variant_changed", (m) => { if (followRef.current && m && m.variant) setMapVariant(m.variant); }),
|
| 8030 |
];
|
| 8031 |
return () => offs.forEach((off) => off && off());
|
| 8032 |
}, []);
|
| 8033 |
+
// broadcast our own screen/variant changes for peers who are following —
|
| 8034 |
+
// but never announce entry into a private screen (cat picker, trace), so
|
| 8035 |
+
// the other person isn't yanked away while we pick a cat.
|
| 8036 |
+
useEffect(() => {
|
| 8037 |
+
if (PRIVATE_SCREENS.has(screen)) return;
|
| 8038 |
+
Collab.send("screen_changed", { screen });
|
| 8039 |
+
}, [screen]);
|
| 8040 |
useEffect(() => { Collab.send("variant_changed", { variant: mapVariant }); }, [mapVariant]);
|
| 8041 |
|
| 8042 |
+
// Ambient activity beacon (#4): tell the peer WHERE we are / what we're
|
| 8043 |
+
// doing, as a friendly label — even for private screens like the cat
|
| 8044 |
+
// picker (we announce it without dragging them there). This drives the
|
| 8045 |
+
// "Alex is browsing the map" presence indicator in the collab bar.
|
| 8046 |
+
const SCREEN_ACTIVITY = {
|
| 8047 |
+
add: "adding photos", darkroom: "in the darkroom",
|
| 8048 |
+
map: "exploring the Map of My Time", pets: "choosing a cat",
|
| 8049 |
+
trace: "viewing the AI trace",
|
| 8050 |
+
};
|
| 8051 |
+
useEffect(() => {
|
| 8052 |
+
const label = SCREEN_ACTIVITY[screen] || ("on the " + screen + " screen");
|
| 8053 |
+
Collab.send("activity", { label, screen });
|
| 8054 |
+
}, [screen]);
|
| 8055 |
+
|
| 8056 |
const update = (patch) => setSettings((prev) => ({ ...prev, ...patch }));
|
| 8057 |
const openPets = () => setScreen("pets");
|
| 8058 |
const openTrace = () => setScreen("trace");
|
| 8059 |
|
| 8060 |
+
// #4: while joining / pending / declined / full, show ONLY the gate — the
|
| 8061 |
+
// joiner must never see or touch the room until they're actually admitted.
|
| 8062 |
+
if (joinState !== "in") {
|
| 8063 |
+
return <JoinGate state={joinState} />;
|
| 8064 |
+
}
|
| 8065 |
+
|
| 8066 |
return (
|
| 8067 |
<SettingsContext.Provider value={{ settings, update }}>
|
| 8068 |
<div className="flex min-h-screen flex-col">
|
| 8069 |
<CollabBar onToggleChat={() => { setChatOpen((v) => !v); setUnread(0); }} unread={unread} onOpenPets={openPets} onOpenTrace={openTrace} />
|
| 8070 |
+
<main className="flex flex-1 items-start justify-center overflow-y-auto bg-background p-4 sm:items-center sm:p-8">
|
| 8071 |
<Page onOpenSettings={() => setSettingsOpen(true)} onOpenPets={openPets} onOpenTrace={openTrace}
|
| 8072 |
screen={screen} setScreen={setScreen} mapVariant={mapVariant} setMapVariant={setMapVariant} />
|
| 8073 |
</main>
|