diff --git a/js/src/App.tsx b/js/src/App.tsx
index 8638d5f..b55f52a 100644
--- a/js/src/App.tsx
+++ b/js/src/App.tsx
@@ -1,5 +1,5 @@
import { useEffect } from "react";
-import { HashRouter, Routes, Route } from "react-router-dom";
+import { HashRouter, Routes, Route, Outlet } from "react-router-dom";
import { TooltipProvider } from "@/components/ui/tooltip";
import { useAuthStore } from "@/stores/auth-store";
import { LoginPage } from "@/features/auth/login-page";
@@ -8,8 +8,11 @@ import {
QueryClient,
QueryClientProvider,
} from '@tanstack/react-query'
-import PathResolver from "./pages/path-resolver";
-import { SettingsPage } from "./pages/settings-page";
+import SettingsPage from "@/features/settings-page";
+import NetworkSelector from "@/features/network-selector";
+import NetworkRoot from "@/features/network-root";
+import ParticleViewResolver from "@/features/particles/particle-view-resolver";
+import LayoutWithPath from "@/features/layoutwithpath";
const queryClient = new QueryClient();
@@ -37,12 +40,18 @@ const App = () => {
};
function AuthenticatedApp() {
- // NOTE: Hash router provides history, despite using catch-all
return (
- } />
- } />
+ } />
+
+ }>
+ } />
+
+ } />
+ } />
+
+
);
diff --git a/js/src/pages/path-resolver.tsx b/js/src/features/layoutwithpath.tsx
similarity index 69%
rename from js/src/pages/path-resolver.tsx
rename to js/src/features/layoutwithpath.tsx
index 482e40f..be74cf0 100644
--- a/js/src/pages/path-resolver.tsx
+++ b/js/src/features/layoutwithpath.tsx
@@ -1,10 +1,7 @@
-import { useLocation, useNavigate } from "react-router-dom";
-import { Home, Settings } from "lucide-react";
-import { NetworkSelector } from "@/features/network-selector";
-import { ParticleListView } from "@/features/particles/particle-list-view";
-import { ParticleViewResolver } from "@/features/particles/particle-view-resolver";
import { WindowControls } from "@/components/window-controls";
import { Button } from "@/components/ui/button";
+import { Outlet, useLocation, useNavigate } from "react-router-dom";
+import { Home, Settings } from "lucide-react";
import {
Breadcrumb,
BreadcrumbItem,
@@ -14,13 +11,8 @@ import {
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
-import { useAuthStore } from "@/stores/auth-store";
import { useNetworks } from "@/hooks/use-networks";
-function parsePathSegments(path: string): string[] {
- return path.split("/").filter(Boolean);
-}
-
function NetworkBreadcrumbContent({ networkId }: { networkId: string }) {
const { data: networks } = useNetworks();
const network = networks?.find((n) => n.id === networkId);
@@ -39,8 +31,9 @@ function NetworkBreadcrumbContent({ networkId }: { networkId: string }) {
);
}
-function TopBar({ segments }: { segments: string[] }) {
+function TopBar() {
const navigate = useNavigate();
+ const segments = useLocation().pathname.split("/").filter(Boolean);
const networkId = segments[0] ?? null;
return (
@@ -53,7 +46,6 @@ function TopBar({ segments }: { segments: string[] }) {
{segments.length === 0 ? (
- Networks
) : (
navigate("/")}
>
- Networks
)}
@@ -125,41 +116,11 @@ function TopBar({ segments }: { segments: string[] }) {
);
}
-/**
- * URL structure:
- * / → network selector
- * /:networkId → root particles for that network
- * /:networkId/:p1/:p2/... → nested particle view (renders the parent which will use it's children)
- */
-export default function PathResolver() {
- const segments = parsePathSegments(useLocation().pathname);
-
- const content = (() => {
- // No segments → show network selector
- if (segments.length === 0) {
- return ;
- }
-
- const [networkId, ...particleSegments] = segments;
-
- // /:networkId with no particle segments → root particle list
- if (particleSegments.length === 0) {
- return ;
- }
-
- // /:networkId/:p1/:p2/... → resolve and render the container particle
- return (
-
- );
- })();
-
+export default function LayoutWithPath() {
return (
);
}
diff --git a/js/src/features/network-root.tsx b/js/src/features/network-root.tsx
new file mode 100644
index 0000000..b4458dc
--- /dev/null
+++ b/js/src/features/network-root.tsx
@@ -0,0 +1,18 @@
+import { useParams } from "react-router-dom";
+import { particlePath } from "@/lib/particle-path";
+import { ParticleListView } from "@/features/particles/particle-list-view";
+
+/**
+ * Route-level component for /:networkId (index).
+ * Shows root-level particles for the selected network.
+ */
+export default function NetworkRoot() {
+ const { networkId } = useParams();
+ const path = particlePath(networkId!, []);
+
+ return (
+
+ );
+}
diff --git a/js/src/features/network-selector.tsx b/js/src/features/network-selector.tsx
index 4998e5e..22b2bc6 100644
--- a/js/src/features/network-selector.tsx
+++ b/js/src/features/network-selector.tsx
@@ -42,7 +42,7 @@ function NetworkRow({
);
}
-export function NetworkSelector() {
+export default function NetworkSelector() {
const navigate = useNavigate();
const { data, isPending, error } = useNetworks();
diff --git a/js/src/features/particles/folder-view.tsx b/js/src/features/particles/folder-view.tsx
index 41866d4..4100434 100644
--- a/js/src/features/particles/folder-view.tsx
+++ b/js/src/features/particles/folder-view.tsx
@@ -1,19 +1,19 @@
import { Particle } from "@/api/types";
-import { useParticleChildren } from "@/hooks/use-particle-children";
+import { useLiveParticleChildren } from "@/hooks/use-particle";
+import type { ParticlePath } from "@/lib/particle-path";
interface FolderViewProps {
folderParticle: Particle;
- networkId: string;
- particleSegments: string[];
+ path: ParticlePath;
}
-export function FolderView({ networkId, particleSegments, folderParticle }: FolderViewProps) {
- const { children, error, isLoading } = useParticleChildren(networkId, particleSegments);
+export function FolderView({ path, folderParticle }: FolderViewProps) {
+ const { children, error, isLoading } = useLiveParticleChildren(path);
return (
- Folder view — {networkId}/{particleSegments.join("/")}
+ Folder view — {folderParticle.id}
);
diff --git a/js/src/features/particles/particle-list-view.tsx b/js/src/features/particles/particle-list-view.tsx
index 54f66cd..0d3205d 100644
--- a/js/src/features/particles/particle-list-view.tsx
+++ b/js/src/features/particles/particle-list-view.tsx
@@ -1,15 +1,16 @@
-import { useParticleChildren } from "@/hooks/use-particle-children";
+import { useLiveParticleChildren } from "@/hooks/use-particle";
+import type { ParticlePath } from "@/lib/particle-path";
+import ControlsIndicator from "@/features/send/controls-indicator";
interface ParticleListViewProps {
- networkId: string;
- particleSegments: string[];
+ path: ParticlePath;
}
/**
* Grid/list of child particles for a container (folder, stream root, or network root).
*/
-export function ParticleListView({ networkId, particleSegments }: ParticleListViewProps) {
- const { children, isLoading } = useParticleChildren(networkId, particleSegments);
+export function ParticleListView({ path }: ParticleListViewProps) {
+ const { children, isLoading } = useLiveParticleChildren(path);
if (isLoading) {
return (
@@ -21,8 +22,8 @@ export function ParticleListView({ networkId, particleSegments }: ParticleListVi
if (children.length === 0) {
return (
-
-
No particles yet
+
+
);
}
diff --git a/js/src/features/particles/particle-view-resolver.tsx b/js/src/features/particles/particle-view-resolver.tsx
index 7d05ea3..76ef87f 100644
--- a/js/src/features/particles/particle-view-resolver.tsx
+++ b/js/src/features/particles/particle-view-resolver.tsx
@@ -1,20 +1,22 @@
-import { useParticle } from "@/hooks/use-particle";
-import { StreamView } from "./stream-view";
-import { FolderView } from "./folder-view";
-import { ParticleListView } from "./particle-list-view";
+import { useParams } from "react-router-dom";
+import { useLiveParticle } from "@/hooks/use-particle";
+import { particlePath } from "@/lib/particle-path";
import { isContainerType } from "@/api/types";
-
-interface ParticleViewResolverProps {
- networkId: string;
- particleSegments: string[];
-}
+import { StreamView } from "@/features/particles/stream-view";
+import { FolderView } from "@/features/particles/folder-view";
+import { ParticleListView } from "@/features/particles/particle-list-view";
/**
- * Resolves a particle by its path segments and renders the appropriate view
- * based on particle type (e.g. stream would show clips in story mode, folder would list files, etc.)
+ * Route-level component for /:networkId/*.
+ * Reads params from the router, resolves the particle, and renders
+ * the appropriate view based on particle type.
*/
-export function ParticleViewResolver({ networkId, particleSegments }: ParticleViewResolverProps) {
- const { particle, isLoading, error } = useParticle(networkId, particleSegments);
+export default function ParticleViewResolver() {
+ const { networkId, "*": rest } = useParams();
+ const segments = (rest ?? "").split("/").filter(Boolean);
+ const path = particlePath(networkId!, segments);
+
+ const { particle, isLoading, error } = useLiveParticle(path);
if (isLoading) {
return (
@@ -32,12 +34,11 @@ export function ParticleViewResolver({ networkId, particleSegments }: ParticleVi
);
}
- // While the hook is stubbed, particle will be null — show a placeholder
if (!particle) {
return (
- Particle: {particleSegments.join(" / ")}
+ Particle: {segments.join(" / ")}
);
@@ -45,15 +46,13 @@ export function ParticleViewResolver({ networkId, particleSegments }: ParticleVi
switch (particle.type) {
case "stream":
- return
;
+ return
;
case "folder":
- return
;
+ return
;
default:
- // For container types we haven't built a view for, fall back to list
if (isContainerType(particle.type)) {
- return
;
+ return
;
}
- // Leaf particle — placeholder
return (
diff --git a/js/src/features/particles/stream-view.tsx b/js/src/features/particles/stream-view.tsx
index 836ca16..3bb7514 100644
--- a/js/src/features/particles/stream-view.tsx
+++ b/js/src/features/particles/stream-view.tsx
@@ -1,19 +1,19 @@
import { Particle } from "@/api/types";
-import { useParticleChildren } from "@/hooks/use-particle-children";
+import { useLiveParticleChildren } from "@/hooks/use-particle";
+import type { ParticlePath } from "@/lib/particle-path";
interface StreamViewProps {
streamParticle: Particle;
- networkId: string;
- particleSegments: string[];
+ path: ParticlePath;
}
-export function StreamView({ networkId, particleSegments, streamParticle }: StreamViewProps) {
- const { children, error, isLoading } = useParticleChildren(networkId, particleSegments);
+export function StreamView({ path, streamParticle }: StreamViewProps) {
+ const { children, error, isLoading } = useLiveParticleChildren(path);
return (
- Stream view — {networkId}/{particleSegments.join("/")}
+ Stream view — {streamParticle.id}
{isLoading &&
Loading stream data...
}
diff --git a/js/src/features/send/use-recorder.ts b/js/src/features/send/use-recorder.ts
index 60dcebc..f46b364 100644
--- a/js/src/features/send/use-recorder.ts
+++ b/js/src/features/send/use-recorder.ts
@@ -36,7 +36,6 @@ export function useRecorder(
const setMediaStream = useRecordingStore((s) => s.setMediaStream);
const setReviewBlob = useRecordingStore((s) => s.setReviewBlob);
const resetRecording = useRecordingStore((s) => s.reset);
- const addParticleToStream = useAppStore((s) => s.addParticleToStream);
const stopTracks = useCallback(() => {
streamRef.current?.getTracks().forEach((t) => t.stop());
@@ -73,29 +72,29 @@ export function useRecorder(
await apiClient.confirmUpload(object_id);
- const particle = await apiClient.createStreamParticle(streamId, {
- type: "media",
- data: {
- object_id,
- duration_ms: reviewDurationMs,
- mime_type: mimeType,
- },
- });
+ // const particle = await apiClient.createStreamParticle(streamId, {
+ // type: "media",
+ // data: {
+ // object_id,
+ // duration_ms: reviewDurationMs,
+ // mime_type: mimeType,
+ // },
+ // });
+ //
+ // addParticleToStream(streamId, particle);
- addParticleToStream(streamId, particle);
-
- const playbackState = usePlaybackStore.getState();
- if (playbackState.streamId === streamId) {
- usePlaybackStore.setState({
- particles: [...playbackState.particles, particle],
- });
- }
+ // const playbackState = usePlaybackStore.getState();
+ // if (playbackState.streamId === streamId) {
+ // usePlaybackStore.setState({
+ // particles: [...playbackState.particles, particle],
+ // });
+ // }
resetRecording();
} catch (err) {
setError(err instanceof Error ? err.message : "Upload failed");
}
- }, [streamId, networkId, setStatus, setError, resetRecording, addParticleToStream]);
+ }, [streamId, networkId, setStatus, setError, resetRecording]);
const startRecording = useCallback(async () => {
const currentStatus = useRecordingStore.getState().status;
diff --git a/js/src/pages/settings-page.tsx b/js/src/features/settings-page.tsx
similarity index 98%
rename from js/src/pages/settings-page.tsx
rename to js/src/features/settings-page.tsx
index bd938d7..a6d76bb 100644
--- a/js/src/pages/settings-page.tsx
+++ b/js/src/features/settings-page.tsx
@@ -59,7 +59,7 @@ function SettingsGroup({
);
}
-export function SettingsPage() {
+export default function SettingsPage() {
const navigate = useNavigate();
const user = useAuthStore((s) => s.user);
const signOut = useAuthStore((s) => s.signOut);
@@ -68,7 +68,7 @@ export function SettingsPage() {
return (
-
+
([]);
- const [isLoading, setIsLoading] = useState(true);
- const [error, setError] = useState(null);
-
- const collectionPath = useMemo(() => {
- if (parentSegments.length === 0) return firestorePath(networkId, []);
- return `${firestorePath(networkId, parentSegments)}/children`;
- }, [networkId, parentSegments.join("/")]);
-
- useEffect(() => {
- setIsLoading(true);
- setError(null);
- setChildren([]);
-
- const unsubscribe = subscribeToParticleChildren(
- collectionPath,
- (data) => {
- setChildren(data);
- setIsLoading(false);
- },
- (err) => {
- setError(err);
- setIsLoading(false);
- },
- );
-
- return unsubscribe;
- }, [collectionPath]);
-
- return { children, isLoading, error };
-}
diff --git a/js/src/hooks/use-particle.ts b/js/src/hooks/use-particle.ts
index 6a04a1d..1e1e1ec 100644
--- a/js/src/hooks/use-particle.ts
+++ b/js/src/hooks/use-particle.ts
@@ -1,26 +1,24 @@
import { useState, useEffect, useMemo } from "react";
-import { subscribeToParticle } from "@/lib/firestore-particles";
-import { firestorePath } from "@/lib/firestore-paths";
+import { subscribeToParticle, subscribeToParticleChildren } from "@/lib/firestore-particles";
import type { Particle } from "@/api/types";
+import {
+ type ParticlePath,
+ toFirestoreDocPath,
+ toFirestoreChildrenPath,
+} from "@/lib/particle-path";
-interface UseParticleResult {
+interface UseLiveParticleResult {
particle: Particle | null;
isLoading: boolean;
error: Error | null;
}
-export function useParticle(
- networkId: string,
- segments: string[],
-): UseParticleResult {
+export function useLiveParticle(path: ParticlePath): UseLiveParticleResult {
const [particle, setParticle] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
- const path = useMemo(
- () => firestorePath(networkId, segments),
- [networkId, segments.join("/")],
- );
+ const docPath = useMemo(() => toFirestoreDocPath(path), [path]);
useEffect(() => {
setIsLoading(true);
@@ -28,7 +26,7 @@ export function useParticle(
setParticle(null);
const unsubscribe = subscribeToParticle(
- path,
+ docPath,
(data) => {
setParticle(data);
setIsLoading(false);
@@ -40,7 +38,45 @@ export function useParticle(
);
return unsubscribe;
- }, [path]);
+ }, [docPath]);
return { particle, isLoading, error };
}
+
+interface UseLiveParticleChildrenResult {
+ children: Particle[];
+ isLoading: boolean;
+ error: Error | null;
+}
+
+export function useLiveParticleChildren(
+ path: ParticlePath,
+): UseLiveParticleChildrenResult {
+ const [children, setChildren] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ const collectionPath = useMemo(() => toFirestoreChildrenPath(path), [path]);
+
+ useEffect(() => {
+ setIsLoading(true);
+ setError(null);
+ setChildren([]);
+
+ const unsubscribe = subscribeToParticleChildren(
+ collectionPath,
+ (data) => {
+ setChildren(data);
+ setIsLoading(false);
+ },
+ (err) => {
+ setError(err);
+ setIsLoading(false);
+ },
+ );
+
+ return unsubscribe;
+ }, [collectionPath]);
+
+ return { children, isLoading, error };
+}
diff --git a/js/src/lib/firestore-paths.ts b/js/src/lib/firestore-paths.ts
deleted file mode 100644
index 12e245c..0000000
--- a/js/src/lib/firestore-paths.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-/**
- * Map URL segments to Firestore paths.
- *
- * Firestore structure:
- * networks/{networkId}/particles/{particleId}
- * networks/{networkId}/particles/{particleId}/children/{childId}
- * ...and so on for arbitrary depth.
- *
- * Examples:
- * segments = [] → "networks/{nid}/particles"
- * segments = ["p1"] → "networks/{nid}/particles/p1"
- * segments = ["p1", "p2"] → "networks/{nid}/particles/p1/children/p2"
- */
-export function firestorePath(networkId: string, segments: string[]): string {
- const base = `networks/${networkId}/particles`;
- if (segments.length === 0) return base;
-
- const parts: string[] = [base, segments[0]];
- for (let i = 1; i < segments.length; i++) {
- parts.push("children", segments[i]);
- }
- return parts.join("/");
-}
diff --git a/js/src/lib/particle-path.ts b/js/src/lib/particle-path.ts
new file mode 100644
index 0000000..ba9412d
--- /dev/null
+++ b/js/src/lib/particle-path.ts
@@ -0,0 +1,67 @@
+/**
+ * ParticlePath is a branded string type representing a URL-style path
+ * to a particle in the hierarchy: /{networkId}/{segment1}/{segment2}/...
+ *
+ * Using a branded type prevents accidentally passing raw strings where
+ * a validated particle path is expected.
+ */
+declare const __brand: unique symbol;
+export type ParticlePath = string & { readonly [__brand]: true };
+
+/**
+ * Construct a ParticlePath from a network ID and optional particle segments.
+ *
+ * @example
+ * particlePath("net1", []) // => "/net1"
+ * particlePath("net1", ["p1"]) // => "/net1/p1"
+ * particlePath("net1", ["p1","p2"])// => "/net1/p1/p2"
+ */
+export function particlePath(networkId: string, segments: string[] = []): ParticlePath {
+ return `/${[networkId, ...segments].join("/")}` as ParticlePath;
+}
+
+/**
+ * Parse a ParticlePath back into its network ID and particle segments.
+ */
+export function parseParticlePath(path: ParticlePath): {
+ networkId: string;
+ segments: string[];
+} {
+ const parts = path.split("/").filter(Boolean);
+ return { networkId: parts[0], segments: parts.slice(1) };
+}
+
+/**
+ * Convert a ParticlePath to the Firestore document path for that particle.
+ *
+ * Firestore structure:
+ * /net1 → networks/net1/particles (collection)
+ * /net1/p1 → networks/net1/particles/p1 (document)
+ * /net1/p1/p2 → networks/net1/particles/p1/children/p2 (document)
+ */
+export function toFirestoreDocPath(path: ParticlePath): string {
+ const { networkId, segments } = parseParticlePath(path);
+ const base = `networks/${networkId}/particles`;
+ if (segments.length === 0) return base;
+
+ const parts: string[] = [base, segments[0]];
+ for (let i = 1; i < segments.length; i++) {
+ parts.push("children", segments[i]);
+ }
+ return parts.join("/");
+}
+
+/**
+ * Convert a ParticlePath to the Firestore collection path for its children.
+ *
+ * /net1 → networks/net1/particles (root particles)
+ * /net1/p1 → networks/net1/particles/p1/children
+ * /net1/p1/p2 → networks/net1/particles/p1/children/p2/children
+ */
+export function toFirestoreChildrenPath(path: ParticlePath): string {
+ const { segments } = parseParticlePath(path);
+ if (segments.length === 0) {
+ return toFirestoreDocPath(path);
+ }
+ return `${toFirestoreDocPath(path)}/children`;
+}
diff --git a/js/src/pages/stream-player-page.tsx b/js/src/pages/stream-player-page.tsx
deleted file mode 100644
index 2d17a6d..0000000
--- a/js/src/pages/stream-player-page.tsx
+++ /dev/null
@@ -1,285 +0,0 @@
-import { useEffect, useCallback, useState, useMemo } from "react";
-import { useParams, useNavigate } from "react-router-dom";
-import { ArrowLeft } from "lucide-react";
-import { Button } from "@/components/ui/button";
-import { Avatar, AvatarFallback } from "@/components/ui/avatar";
-import { useAppStore } from "@/stores/app-store";
-import { usePlaybackStore } from "@/stores/playback-store";
-import { useRecordingStore } from "@/stores/recording-store";
-import { ParticleRenderer } from "@/features/playback/particle-renderer";
-import { PlaybackPageIndicator } from "@/features/playback/playback-page-indicator";
-import { ReplyIndicator } from "@/features/send/reply-indicator";
-import { TextComposeOverlay } from "@/features/send/text-compose-overlay";
-import { RecordingOverlay } from "@/features/send/recording-overlay";
-import { useRecorder } from "@/features/send/use-recorder";
-
-export function StreamPlayerPage() {
- const { streamId } = useParams<{ streamId: string }>();
- const navigate = useNavigate();
- const networks = useAppStore((s) => s.networks);
- const [composingText, setComposingText] = useState(false);
- const [showRecordingOverlay, setShowRecordingOverlay] = useState(false);
-
- const particles = usePlaybackStore((s) => s.particles);
- const currentIndex = usePlaybackStore((s) => s.currentIndex);
- const initStream = usePlaybackStore((s) => s.initStream);
- const next = usePlaybackStore((s) => s.next);
- const prev = usePlaybackStore((s) => s.prev);
- const goTo = usePlaybackStore((s) => s.goTo);
- const reset = usePlaybackStore((s) => s.reset);
- const pause = usePlaybackStore((s) => s.pause);
- const resume = usePlaybackStore((s) => s.resume);
-
- const networkForStream = useMemo(() => {
- return networks.find((n) =>
- n.streams.some((s) => s.id === streamId),
- );
- }, [networks, streamId]);
-
- const stream = useMemo(() => {
- if (!networkForStream) return null;
- return networkForStream.streams.find((s) => s.id === streamId) || null;
- }, [networkForStream, streamId]);
-
- const { startRecording, stopRecording, cancelRecording, confirmSend } =
- useRecorder(streamId ?? null, networkForStream?.id ?? null);
-
- useEffect(() => {
- if (!stream) return;
-
- const firstUnseenIndex = stream.particles.findIndex((p) => !p.seen);
- const startIndex =
- firstUnseenIndex >= 0
- ? firstUnseenIndex
- : Math.max(0, stream.particles.length - 1);
-
- initStream(stream.id, stream.particles, startIndex);
-
- return () => {
- reset();
- };
- }, [stream?.id]); // eslint-disable-line react-hooks/exhaustive-deps
-
- const handleRecordingOverlayClose = useCallback(() => {
- setShowRecordingOverlay(false);
- resume();
- }, [resume]);
-
- // Unified keyboard handling
- const handleKeyDown = useCallback(
- (e: KeyboardEvent) => {
- if (composingText) return;
-
- const recStatus = useRecordingStore.getState().status;
-
- // Q/Esc during recording or reviewing: cancel immediately
- if (
- (e.key === "q" || e.key === "Q" || e.key === "Escape") &&
- (recStatus === "recording" || recStatus === "reviewing")
- ) {
- e.preventDefault();
- cancelRecording();
- setShowRecordingOverlay(false);
- resume();
- return;
- }
-
- // Enter or backtick during reviewing: send
- if (
- (e.key === "Enter" || e.key === "`") &&
- recStatus === "reviewing"
- ) {
- e.preventDefault();
- confirmSend();
- return;
- }
-
- // Block navigation while recording/uploading
- if (recStatus === "recording" || recStatus === "uploading") {
- return;
- }
-
- if (e.key === "`" && !e.repeat) {
- e.preventDefault();
- pause();
- startRecording();
- setShowRecordingOverlay(true);
- } else if (e.key === "ArrowRight" || e.key === "ArrowDown") {
- e.preventDefault();
- next();
- } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
- e.preventDefault();
- prev();
- } else if (e.key === "Escape") {
- navigate("/");
- } else if (e.key === "t" || e.key === "T") {
- e.preventDefault();
- setComposingText(true);
- }
- },
- [
- composingText,
- next,
- prev,
- navigate,
- pause,
- resume,
- startRecording,
- cancelRecording,
- confirmSend,
- ],
- );
-
- const handleKeyUp = useCallback(
- (e: KeyboardEvent) => {
- if (composingText) return;
-
- const recStatus = useRecordingStore.getState().status;
-
- if (e.key === "`" && recStatus === "recording") {
- e.preventDefault();
- stopRecording();
- // Transitions to reviewing — overlay stays open
- }
- },
- [composingText, stopRecording],
- );
-
- useEffect(() => {
- window.addEventListener("keydown", handleKeyDown);
- window.addEventListener("keyup", handleKeyUp);
- return () => {
- window.removeEventListener("keydown", handleKeyDown);
- window.removeEventListener("keyup", handleKeyUp);
- };
- }, [handleKeyDown, handleKeyUp]);
-
- if (!stream) {
- return (
-
- );
- }
-
- if (particles.length === 0) {
- return (
-
- {/* Top overlay */}
-
-
-
navigate("/")}
- >
-
-
-
-
-
-
- No particles yet. Hold ` to record the first one.
-
-
-
-
- {stream.name}
-
-
-
-
- {composingText && streamId && (
-
setComposingText(false)}
- />
- )}
-
- {showRecordingOverlay && (
-
- )}
-
- );
- }
-
- const currentParticle = particles[currentIndex];
-
- return (
-
- {/* Particle content — fills entire viewport */}
-
- {currentParticle && (
-
- )}
-
-
- {/* Top overlay: progress bars + back button */}
-
-
-
-
navigate("/")}
- >
-
-
-
-
-
- {/* Top center overlay for particle author avatar and name */}
-
-
- {currentParticle && (
-
-
- {currentParticle.created_by_email
- .split("@")[0]
- .slice(0, 2)
- .toUpperCase()}
-
-
- )}
-
- {currentParticle?.created_by_email.split("@")[0]}
- ·
- {stream.name}
-
-
-
-
- {/* Bottom overlay: stream info + reply */}
-
-
-
- _this is a placeholder for captions_
-
-
-
-
-
-
- {/* Text compose overlay */}
- {composingText && streamId && (
-
setComposingText(false)}
- />
- )}
-
- {/* Recording overlay */}
- {showRecordingOverlay && (
-
- )}
-
- );
-}
diff --git a/js/src/stores/playback-store.ts b/js/src/stores/playback-store.ts
index d199e4f..70df166 100644
--- a/js/src/stores/playback-store.ts
+++ b/js/src/stores/playback-store.ts
@@ -1,11 +1,11 @@
import { create } from "zustand";
-import type { StreamParticle } from "@/api/types";
+import type { Particle } from "@/api/types";
type PlaybackStatus = "idle" | "playing" | "ended";
interface PlaybackState {
streamId: string | null;
- particles: StreamParticle[];
+ particles: Particle[];
currentIndex: number;
status: PlaybackStatus;
paused: boolean;
@@ -13,7 +13,7 @@ interface PlaybackState {
initStream: (
streamId: string,
- particles: StreamParticle[],
+ particles: Particle[],
startIndex: number,
) => void;
next: () => void;