refactor: restructure state, routing, and more

This commit is contained in:
talksik
2026-03-18 13:11:29 -07:00
parent 0149450bc4
commit 092d142a56
16 changed files with 218 additions and 482 deletions
+15 -6
View File
@@ -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 (
<HashRouter>
<Routes>
<Route path="/settings" element={<SettingsPage />} />
<Route path="/*" element={<PathResolver />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="/" element={<LayoutWithPath />}>
<Route index element={<NetworkSelector />} />
<Route path=":networkId">
<Route index element={<NetworkRoot />} />
<Route path="*" element={<ParticleViewResolver />} />
</Route>
</Route>
</Routes>
</HashRouter>
);
@@ -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 ? (
<BreadcrumbPage className="flex items-center gap-1">
<Home className="size-3.5" />
Networks
</BreadcrumbPage>
) : (
<BreadcrumbLink
@@ -61,7 +53,6 @@ function TopBar({ segments }: { segments: string[] }) {
onClick={() => navigate("/")}
>
<Home className="size-3.5" />
Networks
</BreadcrumbLink>
)}
</BreadcrumbItem>
@@ -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 <NetworkSelector />;
}
const [networkId, ...particleSegments] = segments;
// /:networkId with no particle segments → root particle list
if (particleSegments.length === 0) {
return <ParticleListView networkId={networkId} particleSegments={[]} />;
}
// /:networkId/:p1/:p2/... → resolve and render the container particle
return (
<ParticleViewResolver
networkId={networkId}
particleSegments={particleSegments}
/>
);
})();
export default function LayoutWithPath() {
return (
<div className="flex h-screen flex-col">
<TopBar segments={segments} />
<div className="flex-1 overflow-hidden">{content}</div>
<TopBar />
<Outlet />
</div>
);
}
+18
View File
@@ -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 (
<div className="flex-1 overflow-hidden">
<ParticleListView path={path} />
</div>
);
}
+1 -1
View File
@@ -42,7 +42,7 @@ function NetworkRow({
);
}
export function NetworkSelector() {
export default function NetworkSelector() {
const navigate = useNavigate();
const { data, isPending, error } = useNetworks();
+6 -6
View File
@@ -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 (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground text-sm">
Folder view {networkId}/{particleSegments.join("/")}
Folder view {folderParticle.id}
</p>
</div>
);
@@ -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 (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground text-sm">No particles yet</p>
<div className="flex flex-col h-full items-center justify-center gap-2">
<ControlsIndicator type={"new"} />
</div>
);
}
@@ -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 (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground text-sm">
Particle: {particleSegments.join(" / ")}
Particle: {segments.join(" / ")}
</p>
</div>
);
@@ -45,15 +46,13 @@ export function ParticleViewResolver({ networkId, particleSegments }: ParticleVi
switch (particle.type) {
case "stream":
return <StreamView streamParticle={particle} networkId={networkId} particleSegments={particleSegments} />;
return <StreamView streamParticle={particle} path={path} />;
case "folder":
return <FolderView folderParticle={particle} networkId={networkId} particleSegments={particleSegments} />;
return <FolderView folderParticle={particle} path={path} />;
default:
// For container types we haven't built a view for, fall back to list
if (isContainerType(particle.type)) {
return <ParticleListView networkId={networkId} particleSegments={particleSegments} />;
return <ParticleListView path={path} />;
}
// Leaf particle — placeholder
return (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground text-sm">
+6 -6
View File
@@ -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 (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground text-sm">
Stream view {networkId}/{particleSegments.join("/")}
Stream view {streamParticle.id}
</p>
{isLoading && <p className="text-muted-foreground text-sm">Loading stream data...</p>}
+17 -18
View File
@@ -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;
@@ -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 (
<div className="flex h-screen flex-col">
<div className="drag-region flex items-center gap-3 border-b px-4 py-3">
<div className="drag-region flex items-center gap-3 border-b px-3 py-1">
<WindowControls />
<Button
variant="ghost"
-46
View File
@@ -1,46 +0,0 @@
import { useState, useEffect, useMemo } from "react";
import { subscribeToParticleChildren } from "@/lib/firestore-particles";
import { firestorePath } from "@/lib/firestore-paths";
import type { Particle } from "@/api/types";
interface UseParticleChildrenResult {
children: Particle[];
isLoading: boolean;
error: Error | null;
}
export function useParticleChildren(
networkId: string,
parentSegments: string[],
): UseParticleChildrenResult {
const [children, setChildren] = useState<Particle[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(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 };
}
+49 -13
View File
@@ -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<Particle | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(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<Particle[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(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 };
}
-23
View File
@@ -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("/");
}
+67
View File
@@ -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`;
}
-285
View File
@@ -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 (
<div className="flex h-screen items-center justify-center">
<p className="text-muted-foreground text-sm">Stream not found</p>
</div>
);
}
if (particles.length === 0) {
return (
<div className="flex h-screen flex-col bg-black text-white">
{/* Top overlay */}
<div className="pointer-events-none absolute top-0 right-0 left-0 z-10 px-2 pt-2">
<div className="pointer-events-auto inline-flex">
<Button
variant="ghost"
size="icon"
className="rounded-full bg-black/30 text-white backdrop-blur-sm hover:bg-black/50"
onClick={() => navigate("/")}
>
<ArrowLeft className="h-4 w-4" />
</Button>
</div>
</div>
<div className="flex flex-1 items-center justify-center">
<p className="text-muted-foreground text-sm">
No particles yet. Hold ` to record the first one.
</p>
</div>
<div className="absolute right-0 bottom-0 left-0 z-10 flex items-center justify-between px-4 py-3">
<span className="text-xs font-medium text-white/70">
{stream.name}
</span>
<ReplyIndicator />
</div>
{composingText && streamId && (
<TextComposeOverlay
streamId={streamId}
onClose={() => setComposingText(false)}
/>
)}
{showRecordingOverlay && (
<RecordingOverlay onClose={handleRecordingOverlayClose} />
)}
</div>
);
}
const currentParticle = particles[currentIndex];
return (
<div className="relative h-screen bg-black text-white">
{/* Particle content — fills entire viewport */}
<div className="absolute inset-0">
{currentParticle && (
<ParticleRenderer particle={currentParticle} />
)}
</div>
{/* Top overlay: progress bars + back button */}
<div className="pointer-events-none absolute top-0 right-0 left-0 z-10">
<div className="pointer-events-auto">
<PlaybackPageIndicator
total={particles.length}
current={currentIndex}
onGoTo={goTo}
/>
</div>
<div className="pointer-events-auto mt-1 inline-flex px-2">
<Button
variant="ghost"
size="icon"
className="rounded-full bg-black/30 text-white backdrop-blur-sm hover:bg-black/50"
onClick={() => navigate("/")}
>
<ArrowLeft className="h-4 w-4" />
</Button>
</div>
</div>
{/* Top center overlay for particle author avatar and name */}
<div className="absolute top-0 left-1/2 transform -translate-x-1/2 z-10 mt-3 rounded-full bg-white/10 px-1 py-1 backdrop-blur-sm">
<div className="flex items-center gap-1">
{currentParticle && (
<Avatar size="sm">
<AvatarFallback className="bg-white/20 text-[10px] text-white">
{currentParticle.created_by_email
.split("@")[0]
.slice(0, 2)
.toUpperCase()}
</AvatarFallback>
</Avatar>
)}
<span className="min-w-0 flex-1 truncate text-xs font-medium text-white/70 pr-1">
{currentParticle?.created_by_email.split("@")[0]}
<span className="text-white/40"> &middot; </span>
{stream.name}
</span>
</div>
</div>
{/* Bottom overlay: stream info + reply */}
<div className="absolute right-0 bottom-0 left-0 z-10 flex justify-center px-3 pb-3">
<div className="flex w-full items-center gap-2.5 rounded-full bg-black/30 px-3 py-2 backdrop-blur-sm">
<div className="flex-1 text-left text-sm font-medium text-white/70">
_this is a placeholder for captions_
</div>
<ReplyIndicator />
</div>
</div>
{/* Text compose overlay */}
{composingText && streamId && (
<TextComposeOverlay
streamId={streamId}
onClose={() => setComposingText(false)}
/>
)}
{/* Recording overlay */}
{showRecordingOverlay && (
<RecordingOverlay onClose={handleRecordingOverlayClose} />
)}
</div>
);
}
+3 -3
View File
@@ -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;