img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl group/card flex flex-col", className)}
+ {...props}
+ />
+ )
+}
+
+function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardAction({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export {
+ Card,
+ CardHeader,
+ CardFooter,
+ CardTitle,
+ CardAction,
+ CardDescription,
+ CardContent,
+}
diff --git a/js/src/components/ui/dialog.tsx b/js/src/components/ui/dialog.tsx
new file mode 100644
index 0000000..8e93105
--- /dev/null
+++ b/js/src/components/ui/dialog.tsx
@@ -0,0 +1,155 @@
+"use client"
+
+import * as React from "react"
+import { Dialog as DialogPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import { XIcon } from "lucide-react"
+
+function Dialog({
+ ...props
+}: React.ComponentProps
) {
+ return
+}
+
+function DialogTrigger({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DialogPortal({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DialogClose({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DialogOverlay({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DialogContent({
+ className,
+ children,
+ showCloseButton = true,
+ ...props
+}: React.ComponentProps & {
+ showCloseButton?: boolean
+}) {
+ return (
+
+
+
+ {children}
+ {showCloseButton && (
+
+
+
+ )}
+
+
+ )
+}
+
+function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function DialogFooter({
+ className,
+ showCloseButton = false,
+ children,
+ ...props
+}: React.ComponentProps<"div"> & {
+ showCloseButton?: boolean
+}) {
+ return (
+
+ {children}
+ {showCloseButton && (
+
+
+
+ )}
+
+ )
+}
+
+function DialogTitle({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DialogDescription({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogOverlay,
+ DialogPortal,
+ DialogTitle,
+ DialogTrigger,
+}
diff --git a/js/src/components/ui/dropdown-menu.tsx b/js/src/components/ui/dropdown-menu.tsx
new file mode 100644
index 0000000..26173d7
--- /dev/null
+++ b/js/src/components/ui/dropdown-menu.tsx
@@ -0,0 +1,261 @@
+import * as React from "react"
+import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+import { CheckIcon, ChevronRightIcon } from "lucide-react"
+
+function DropdownMenu({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DropdownMenuPortal({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DropdownMenuTrigger({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DropdownMenuContent({
+ className,
+ align = "start",
+ sideOffset = 4,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+function DropdownMenuGroup({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DropdownMenuItem({
+ className,
+ inset,
+ variant = "default",
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean
+ variant?: "default" | "destructive"
+}) {
+ return (
+
+ )
+}
+
+function DropdownMenuCheckboxItem({
+ className,
+ children,
+ checked,
+ inset,
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean
+}) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+
+function DropdownMenuRadioGroup({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DropdownMenuRadioItem({
+ className,
+ children,
+ inset,
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean
+}) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+
+function DropdownMenuLabel({
+ className,
+ inset,
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean
+}) {
+ return (
+
+ )
+}
+
+function DropdownMenuSeparator({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DropdownMenuShortcut({
+ className,
+ ...props
+}: React.ComponentProps<"span">) {
+ return (
+
+ )
+}
+
+function DropdownMenuSub({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DropdownMenuSubTrigger({
+ className,
+ inset,
+ children,
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean
+}) {
+ return (
+
+ {children}
+
+
+ )
+}
+
+function DropdownMenuSubContent({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export {
+ DropdownMenu,
+ DropdownMenuPortal,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuLabel,
+ DropdownMenuItem,
+ DropdownMenuCheckboxItem,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuSub,
+ DropdownMenuSubTrigger,
+ DropdownMenuSubContent,
+}
diff --git a/js/src/components/ui/progress.tsx b/js/src/components/ui/progress.tsx
new file mode 100644
index 0000000..d73312c
--- /dev/null
+++ b/js/src/components/ui/progress.tsx
@@ -0,0 +1,31 @@
+"use client"
+
+import * as React from "react"
+import { Progress as ProgressPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+
+function Progress({
+ className,
+ value,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+export { Progress }
diff --git a/js/src/components/ui/scroll-area.tsx b/js/src/components/ui/scroll-area.tsx
new file mode 100644
index 0000000..605ef01
--- /dev/null
+++ b/js/src/components/ui/scroll-area.tsx
@@ -0,0 +1,53 @@
+import * as React from "react"
+import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+
+function ScrollArea({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+ {children}
+
+
+
+
+ )
+}
+
+function ScrollBar({
+ className,
+ orientation = "vertical",
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+export { ScrollArea, ScrollBar }
diff --git a/js/src/components/ui/select.tsx b/js/src/components/ui/select.tsx
new file mode 100644
index 0000000..e22c281
--- /dev/null
+++ b/js/src/components/ui/select.tsx
@@ -0,0 +1,186 @@
+"use client"
+
+import * as React from "react"
+import { Select as SelectPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
+
+function Select({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function SelectGroup({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function SelectValue({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function SelectTrigger({
+ className,
+ size = "default",
+ children,
+ ...props
+}: React.ComponentProps & {
+ size?: "sm" | "default"
+}) {
+ return (
+
+ {children}
+
+
+
+
+ )
+}
+
+function SelectContent({
+ className,
+ children,
+ position = "item-aligned",
+ align = "center",
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+ {children}
+
+
+
+
+ )
+}
+
+function SelectLabel({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function SelectItem({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+
+function SelectSeparator({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function SelectScrollUpButton({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+function SelectScrollDownButton({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+export {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectLabel,
+ SelectScrollDownButton,
+ SelectScrollUpButton,
+ SelectSeparator,
+ SelectTrigger,
+ SelectValue,
+}
diff --git a/js/src/components/ui/separator.tsx b/js/src/components/ui/separator.tsx
new file mode 100644
index 0000000..fb11887
--- /dev/null
+++ b/js/src/components/ui/separator.tsx
@@ -0,0 +1,26 @@
+import * as React from "react"
+import { Separator as SeparatorPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+
+function Separator({
+ className,
+ orientation = "horizontal",
+ decorative = true,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export { Separator }
diff --git a/js/src/components/ui/skeleton.tsx b/js/src/components/ui/skeleton.tsx
new file mode 100644
index 0000000..41bcbf9
--- /dev/null
+++ b/js/src/components/ui/skeleton.tsx
@@ -0,0 +1,13 @@
+import { cn } from "@/lib/utils"
+
+function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export { Skeleton }
diff --git a/js/src/features/playback/fallback-particle-view.tsx b/js/src/features/playback/fallback-particle-view.tsx
new file mode 100644
index 0000000..234af19
--- /dev/null
+++ b/js/src/features/playback/fallback-particle-view.tsx
@@ -0,0 +1,61 @@
+import type { StreamParticle } from "@/api/types";
+import { getParticleData } from "@/api/types";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { FileIcon, HelpCircleIcon, ScrollTextIcon, BookOpenIcon } from "lucide-react";
+
+const TYPE_META: Record = {
+ quest: { icon: ScrollTextIcon, label: "Quest" },
+ paper: { icon: BookOpenIcon, label: "Paper" },
+ file: { icon: FileIcon, label: "File" },
+};
+
+interface FallbackParticleViewProps {
+ particle: StreamParticle;
+}
+
+export function FallbackParticleView({ particle }: FallbackParticleViewProps) {
+ const meta = TYPE_META[particle.type] ?? {
+ icon: HelpCircleIcon,
+ label: particle.type,
+ };
+ const Icon = meta.icon;
+ const title = (() => {
+ switch (particle.type) {
+ case "quest":
+ return getParticleData(particle, "quest").title;
+ case "paper":
+ return getParticleData(particle, "paper").title;
+ case "file":
+ return getParticleData(particle, "file").filename;
+ case "folder":
+ return getParticleData(particle, "folder").name;
+ default:
+ return null;
+ }
+ })();
+
+ return (
+
+
+
+
+
+ {meta.label}
+ {title && {title}}
+
+
+
+
+ From {particle.created_by_email}
+
+
+
+
+ );
+}
diff --git a/js/src/features/playback/media-particle-view.tsx b/js/src/features/playback/media-particle-view.tsx
new file mode 100644
index 0000000..9ed7fdd
--- /dev/null
+++ b/js/src/features/playback/media-particle-view.tsx
@@ -0,0 +1,78 @@
+import { useEffect, useState } from "react";
+import type { MediaParticleData, StreamParticle } from "@/api/types";
+import { apiClient } from "@/api/client";
+import { usePlaybackStore } from "@/stores/playback-store";
+import { Skeleton } from "@/components/ui/skeleton";
+
+interface MediaParticleViewProps {
+ particle: StreamParticle;
+ onEnded: () => void;
+}
+
+export function MediaParticleView({
+ particle,
+ onEnded,
+}: MediaParticleViewProps) {
+ const cachedUrl = usePlaybackStore(
+ (s) => s.downloadUrlCache[particle.id],
+ );
+ const cacheDownloadUrl = usePlaybackStore((s) => s.cacheDownloadUrl);
+ const [url, setUrl] = useState(cachedUrl ?? null);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ if (cachedUrl) {
+ setUrl(cachedUrl);
+ return;
+ }
+
+ let cancelled = false;
+ apiClient
+ .getParticleDownloadUrl(particle.id)
+ .then((downloadUrl) => {
+ if (cancelled) return;
+ cacheDownloadUrl(particle.id, downloadUrl);
+ setUrl(downloadUrl);
+ })
+ .catch(() => {
+ if (!cancelled) setError("Failed to load media");
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [particle.id, cachedUrl, cacheDownloadUrl]);
+
+ if (error) {
+ return (
+
+ {error}
+
+ );
+ }
+
+ if (!url) {
+ return ;
+ }
+
+ const data = particle.data as MediaParticleData;
+ const isAudio = data.mime_type?.startsWith("audio/");
+
+ if (isAudio) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/js/src/features/playback/particle-renderer.tsx b/js/src/features/playback/particle-renderer.tsx
new file mode 100644
index 0000000..d781761
--- /dev/null
+++ b/js/src/features/playback/particle-renderer.tsx
@@ -0,0 +1,58 @@
+import { useEffect, useRef } from "react";
+import type { StreamParticle } from "@/api/types";
+import { apiClient } from "@/api/client";
+import { useAppStore } from "@/stores/app-store";
+import { usePlaybackStore } from "@/stores/playback-store";
+import { MediaParticleView } from "./media-particle-view";
+import { TextParticleView } from "./text-particle-view";
+import { FallbackParticleView } from "./fallback-particle-view";
+
+interface ParticleRendererProps {
+ particle: StreamParticle;
+ onNext: () => void;
+ onPrev: () => void;
+}
+
+export function ParticleRenderer({
+ particle,
+ onNext,
+ onPrev,
+}: ParticleRendererProps) {
+ const markParticlesSeen = useAppStore((s) => s.markParticlesSeen);
+ const markedRef = useRef(null);
+
+ useEffect(() => {
+ if (!particle.seen && markedRef.current !== particle.id) {
+ markedRef.current = particle.id;
+ markParticlesSeen([particle.id]);
+ apiClient.markSeen(particle.id).catch(() => {});
+ }
+ }, [particle.id, particle.seen, markParticlesSeen]);
+
+ const handleClick = (e: React.MouseEvent) => {
+ const rect = e.currentTarget.getBoundingClientRect();
+ const x = (e.clientX - rect.left) / rect.width;
+ if (x < 0.3) onPrev();
+ else if (x > 0.7) onNext();
+ };
+
+ const renderContent = () => {
+ switch (particle.type) {
+ case "media":
+ return ;
+ case "text":
+ return ;
+ default:
+ return ;
+ }
+ };
+
+ return (
+
+ {renderContent()}
+
+ );
+}
diff --git a/js/src/features/playback/playback-controls.tsx b/js/src/features/playback/playback-controls.tsx
new file mode 100644
index 0000000..d9fb708
--- /dev/null
+++ b/js/src/features/playback/playback-controls.tsx
@@ -0,0 +1,52 @@
+import { cn } from "@/lib/utils";
+import { Progress } from "@/components/ui/progress";
+
+interface PlaybackControlsProps {
+ total: number;
+ current: number;
+ onGoTo: (index: number) => void;
+}
+
+const DOT_THRESHOLD = 15;
+
+export function PlaybackControls({
+ total,
+ current,
+ onGoTo,
+}: PlaybackControlsProps) {
+ if (total === 0) return null;
+
+ if (total <= DOT_THRESHOLD) {
+ return (
+
+ {Array.from({ length: total }, (_, i) => (
+
+ ))}
+
+ );
+ }
+
+ const percent = ((current + 1) / total) * 100;
+
+ return (
+
+ );
+}
diff --git a/js/src/features/playback/text-particle-view.tsx b/js/src/features/playback/text-particle-view.tsx
new file mode 100644
index 0000000..46ee556
--- /dev/null
+++ b/js/src/features/playback/text-particle-view.tsx
@@ -0,0 +1,20 @@
+import type { StreamParticle, TextParticleData } from "@/api/types";
+import { ScrollArea } from "@/components/ui/scroll-area";
+
+interface TextParticleViewProps {
+ particle: StreamParticle;
+}
+
+export function TextParticleView({ particle }: TextParticleViewProps) {
+ const data = particle.data as TextParticleData;
+
+ return (
+
+
+
+ );
+}
diff --git a/js/src/features/recording/reply-indicator.tsx b/js/src/features/recording/reply-indicator.tsx
new file mode 100644
index 0000000..b25e589
--- /dev/null
+++ b/js/src/features/recording/reply-indicator.tsx
@@ -0,0 +1,38 @@
+import { useRecordingStore } from "@/stores/recording-store";
+import { cn } from "@/lib/utils";
+
+export function ReplyIndicator() {
+ const status = useRecordingStore((s) => s.status);
+
+ if (status === "uploading") {
+ return (
+
+
+ Uploading...
+
+ );
+ }
+
+ if (status === "recording") {
+ return (
+
+
+ Recording... press Q to cancel
+
+ );
+ }
+
+ return (
+
+ Hold{" "}
+
+ `
+ {" "}
+ to reply
+
+ );
+}
diff --git a/js/src/features/recording/use-recorder.ts b/js/src/features/recording/use-recorder.ts
new file mode 100644
index 0000000..75d7cef
--- /dev/null
+++ b/js/src/features/recording/use-recorder.ts
@@ -0,0 +1,180 @@
+import { useCallback, useEffect, useRef } from "react";
+import { apiClient } from "@/api/client";
+import { useAppStore } from "@/stores/app-store";
+import { usePlaybackStore } from "@/stores/playback-store";
+import { useRecordingStore } from "@/stores/recording-store";
+
+const PREFERRED_MIME = "video/webm;codecs=vp9,opus";
+const FALLBACK_MIME = "video/webm";
+
+function getMediaMime(): string {
+ if (MediaRecorder.isTypeSupported(PREFERRED_MIME)) return PREFERRED_MIME;
+ return FALLBACK_MIME;
+}
+
+export function useRecorder(streamId: string | null) {
+ const recorderRef = useRef(null);
+ const streamRef = useRef(null);
+ const chunksRef = useRef([]);
+ const startTimeRef = useRef(0);
+
+ const status = useRecordingStore((s) => s.status);
+ const setStatus = useRecordingStore((s) => s.setStatus);
+ const setError = useRecordingStore((s) => s.setError);
+ const resetRecording = useRecordingStore((s) => s.reset);
+ const addParticleToStream = useAppStore((s) => s.addParticleToStream);
+
+ const stopTracks = useCallback(() => {
+ streamRef.current?.getTracks().forEach((t) => t.stop());
+ streamRef.current = null;
+ recorderRef.current = null;
+ chunksRef.current = [];
+ }, []);
+
+ const upload = useCallback(
+ async (blob: Blob, durationMs: number) => {
+ if (!streamId) return;
+
+ setStatus("uploading");
+
+ const mimeType = blob.type || FALLBACK_MIME;
+ const fileName = `recording-${Date.now()}.webm`;
+
+ const { object, upload_url } = await apiClient.prepareUpload({
+ file_name: fileName,
+ content_type: mimeType,
+ size_bytes: blob.size,
+ });
+
+ await fetch(upload_url, {
+ method: "PUT",
+ headers: { "Content-Type": mimeType },
+ body: blob,
+ });
+
+ await apiClient.confirmUpload(object.id);
+
+ const particle = await apiClient.createStreamParticle(streamId, {
+ type: "media",
+ data: {
+ object_id: object.id,
+ duration_ms: durationMs,
+ mime_type: mimeType,
+ },
+ });
+
+ addParticleToStream(streamId, particle);
+
+ // Also add to playback store's particle list
+ const playbackState = usePlaybackStore.getState();
+ if (playbackState.streamId === streamId) {
+ usePlaybackStore.setState({
+ particles: [...playbackState.particles, particle],
+ });
+ }
+
+ resetRecording();
+ },
+ [streamId, setStatus, resetRecording, addParticleToStream],
+ );
+
+ const startRecording = useCallback(async () => {
+ if (status !== "idle") return;
+
+ try {
+ const mediaStream = await navigator.mediaDevices.getUserMedia({
+ video: true,
+ audio: true,
+ });
+
+ streamRef.current = mediaStream;
+ chunksRef.current = [];
+ startTimeRef.current = Date.now();
+
+ const mime = getMediaMime();
+ const recorder = new MediaRecorder(mediaStream, { mimeType: mime });
+ recorderRef.current = recorder;
+
+ recorder.ondataavailable = (e) => {
+ if (e.data.size > 0) chunksRef.current.push(e.data);
+ };
+
+ recorder.onstop = () => {
+ const durationMs = Date.now() - startTimeRef.current;
+ const blob = new Blob(chunksRef.current, { type: mime });
+ stopTracks();
+
+ if (blob.size > 0) {
+ upload(blob, durationMs).catch((err) => {
+ setError(err instanceof Error ? err.message : "Upload failed");
+ });
+ } else {
+ resetRecording();
+ }
+ };
+
+ recorder.start();
+ setStatus("recording");
+ } catch (err) {
+ stopTracks();
+ setError(
+ err instanceof Error ? err.message : "Failed to start recording",
+ );
+ }
+ }, [status, setStatus, setError, stopTracks, upload, resetRecording]);
+
+ const stopRecording = useCallback(() => {
+ if (recorderRef.current?.state === "recording") {
+ recorderRef.current.stop();
+ }
+ }, []);
+
+ const cancelRecording = useCallback(() => {
+ if (recorderRef.current) {
+ recorderRef.current.ondataavailable = null;
+ recorderRef.current.onstop = null;
+ if (recorderRef.current.state === "recording") {
+ recorderRef.current.stop();
+ }
+ }
+ stopTracks();
+ resetRecording();
+ }, [stopTracks, resetRecording]);
+
+ // Keyboard bindings: backtick to record, q to cancel
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === "`" && !e.repeat) {
+ e.preventDefault();
+ startRecording();
+ }
+ };
+
+ const handleKeyUp = (e: KeyboardEvent) => {
+ if (e.key === "`") {
+ e.preventDefault();
+ stopRecording();
+ }
+ if (e.key === "q" && status === "recording") {
+ e.preventDefault();
+ cancelRecording();
+ }
+ };
+
+ window.addEventListener("keydown", handleKeyDown);
+ window.addEventListener("keyup", handleKeyUp);
+ return () => {
+ window.removeEventListener("keydown", handleKeyDown);
+ window.removeEventListener("keyup", handleKeyUp);
+ };
+ }, [startRecording, stopRecording, cancelRecording, status]);
+
+ // Cleanup on unmount
+ useEffect(() => {
+ return () => {
+ stopTracks();
+ };
+ }, [stopTracks]);
+
+ return { status };
+}
diff --git a/js/src/features/streams/create-stream-dialog.tsx b/js/src/features/streams/create-stream-dialog.tsx
new file mode 100644
index 0000000..1b19971
--- /dev/null
+++ b/js/src/features/streams/create-stream-dialog.tsx
@@ -0,0 +1,112 @@
+import { useState } from "react";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { apiClient } from "@/api/client";
+import { useAppStore } from "@/stores/app-store";
+import type { CreateStreamRequest } from "@/api/types";
+
+interface CreateStreamDialogProps {
+ networkId: string;
+ children: React.ReactNode;
+}
+
+export function CreateStreamDialog({
+ networkId,
+ children,
+}: CreateStreamDialogProps) {
+ const [open, setOpen] = useState(false);
+ const [name, setName] = useState("");
+ const [description, setDescription] = useState("");
+ const [visibility, setVisibility] =
+ useState("network_all");
+ const [isCreating, setIsCreating] = useState(false);
+ const addStream = useAppStore((s) => s.addStream);
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!name.trim()) return;
+
+ setIsCreating(true);
+ try {
+ const stream = await apiClient.createStream(networkId, {
+ name: name.trim(),
+ description: description.trim(),
+ visibility,
+ });
+ addStream(networkId, stream);
+ setOpen(false);
+ setName("");
+ setDescription("");
+ setVisibility("network_all");
+ } finally {
+ setIsCreating(false);
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/js/src/features/streams/stream-list.tsx b/js/src/features/streams/stream-list.tsx
new file mode 100644
index 0000000..a14300d
--- /dev/null
+++ b/js/src/features/streams/stream-list.tsx
@@ -0,0 +1,63 @@
+import { useNavigate } from "react-router-dom";
+import { Badge } from "@/components/ui/badge";
+import { useAppStore } from "@/stores/app-store";
+import { flattenStreams } from "@/lib/stream-utils";
+import { formatDistanceToNow } from "@/lib/time-utils";
+
+export function StreamList() {
+ const networks = useAppStore((s) => s.networks);
+ const selectedNetworkId = useAppStore((s) => s.selectedNetworkId);
+ const navigate = useNavigate();
+
+ const streams = flattenStreams(networks, selectedNetworkId);
+
+ if (streams.length === 0) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {streams.map((stream) => {
+ const lastParticle =
+ stream.particles.length > 0
+ ? stream.particles[stream.particles.length - 1]
+ : null;
+
+ return (
+
+ );
+ })}
+
+ );
+}
diff --git a/js/src/lib/stream-utils.ts b/js/src/lib/stream-utils.ts
new file mode 100644
index 0000000..a8b63f6
--- /dev/null
+++ b/js/src/lib/stream-utils.ts
@@ -0,0 +1,35 @@
+import type { NetworkWithStreams, Stream } from "@/api/types";
+
+export interface FlatStream extends Stream {
+ networkId: string;
+ networkName: string;
+}
+
+export function flattenStreams(
+ networks: NetworkWithStreams[],
+ selectedNetworkId: string | null,
+): FlatStream[] {
+ const filtered = selectedNetworkId
+ ? networks.filter((n) => n.id === selectedNetworkId)
+ : networks;
+
+ const streams: FlatStream[] = filtered.flatMap((n) =>
+ n.streams.map((s) => ({
+ ...s,
+ networkId: n.id,
+ networkName: n.name,
+ })),
+ );
+
+ return streams.sort((a, b) => {
+ const aTime = getLatestParticleTime(a);
+ const bTime = getLatestParticleTime(b);
+ return bTime - aTime;
+ });
+}
+
+function getLatestParticleTime(stream: Stream): number {
+ if (stream.particles.length === 0) return 0;
+ const last = stream.particles[stream.particles.length - 1];
+ return new Date(last.created_at).getTime();
+}
diff --git a/js/src/lib/time-utils.ts b/js/src/lib/time-utils.ts
new file mode 100644
index 0000000..205f660
--- /dev/null
+++ b/js/src/lib/time-utils.ts
@@ -0,0 +1,21 @@
+const MINUTE = 60;
+const HOUR = 3600;
+const DAY = 86400;
+const WEEK = 604800;
+const MONTH = 2592000;
+const YEAR = 31536000;
+
+export function formatDistanceToNow(isoString: string): string {
+ const seconds = Math.floor(
+ (Date.now() - new Date(isoString).getTime()) / 1000,
+ );
+
+ if (seconds < 5) return "just now";
+ if (seconds < MINUTE) return `${seconds}s ago`;
+ if (seconds < HOUR) return `${Math.floor(seconds / MINUTE)}m ago`;
+ if (seconds < DAY) return `${Math.floor(seconds / HOUR)}h ago`;
+ if (seconds < WEEK) return `${Math.floor(seconds / DAY)}d ago`;
+ if (seconds < MONTH) return `${Math.floor(seconds / WEEK)}w ago`;
+ if (seconds < YEAR) return `${Math.floor(seconds / MONTH)}mo ago`;
+ return `${Math.floor(seconds / YEAR)}y ago`;
+}
diff --git a/js/src/main.ts b/js/src/main.ts
index 8ca5c0f..fa7855e 100644
--- a/js/src/main.ts
+++ b/js/src/main.ts
@@ -38,7 +38,7 @@ app.on('ready', () => {
// The server doesn't handle OPTIONS preflight, so we intercept at the
// Electron network layer: inject CORS headers and return 200 for preflight.
session.defaultSession.webRequest.onHeadersReceived(
- { urls: ['https://orion.dev.flowy.live/*'] },
+ { urls: ['https://orion.dev.flowy.live/*', 'https://storage.googleapis.com/*'] },
(details, callback) => {
const headers = { ...details.responseHeaders };
headers['access-control-allow-origin'] = ['*'];
diff --git a/js/src/pages/home-page.tsx b/js/src/pages/home-page.tsx
deleted file mode 100644
index 2e6d9a7..0000000
--- a/js/src/pages/home-page.tsx
+++ /dev/null
@@ -1,26 +0,0 @@
-import { useEffect } from "react";
-import { Button } from "@/components/ui/button";
-import { H1, Muted } from "@/components/ui/typography";
-import { useAuthStore } from "@/stores/auth-store";
-import { useAppStore } from "@/stores/app-store";
-
-export function HomePage() {
- const user = useAuthStore((s) => s.user);
- const isSigningOut = useAuthStore((s) => s.isSigningOut);
- const signOut = useAuthStore((s) => s.signOut);
- const fetchStartup = useAppStore((s) => s.fetchStartup);
-
- useEffect(() => {
- fetchStartup();
- }, [fetchStartup]);
-
- return (
-
-
Welcome, {user?.email_prefix}
- {user?.email}
-
-
- );
-}
diff --git a/js/src/pages/stream-player-page.tsx b/js/src/pages/stream-player-page.tsx
new file mode 100644
index 0000000..bd0e56a
--- /dev/null
+++ b/js/src/pages/stream-player-page.tsx
@@ -0,0 +1,167 @@
+import { useEffect, useCallback } from "react";
+import { useParams, useNavigate } from "react-router-dom";
+import { ArrowLeft } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { useAppStore } from "@/stores/app-store";
+import { usePlaybackStore } from "@/stores/playback-store";
+import { ParticleRenderer } from "@/features/playback/particle-renderer";
+import { PlaybackControls } from "@/features/playback/playback-controls";
+import { ReplyIndicator } from "@/features/recording/reply-indicator";
+import { useRecorder } from "@/features/recording/use-recorder";
+
+export function StreamPlayerPage() {
+ const { streamId } = useParams<{ streamId: string }>();
+ const navigate = useNavigate();
+ const networks = useAppStore((s) => s.networks);
+
+ const particles = usePlaybackStore((s) => s.particles);
+ const currentIndex = usePlaybackStore((s) => s.currentIndex);
+ const status = usePlaybackStore((s) => s.status);
+ 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);
+
+ useRecorder(streamId ?? null);
+
+ // Find the stream across all networks
+ const stream = networks
+ .flatMap((n) => n.streams)
+ .find((s) => s.id === streamId);
+
+ 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
+
+ // Keyboard navigation
+ const handleKeyDown = useCallback(
+ (e: KeyboardEvent) => {
+ 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("/");
+ }
+ },
+ [next, prev, navigate],
+ );
+
+ useEffect(() => {
+ window.addEventListener("keydown", handleKeyDown);
+ return () => window.removeEventListener("keydown", handleKeyDown);
+ }, [handleKeyDown]);
+
+ if (!stream) {
+ return (
+
+ );
+ }
+
+ if (particles.length === 0) {
+ return (
+
+ );
+ }
+
+ const currentParticle = particles[currentIndex];
+
+ return (
+
+ {/* Top bar */}
+
+
+
{stream.name}
+
{/* Spacer for centering */}
+
+
+ {/* Progress */}
+
+
+ {/* Particle content */}
+
+ {currentParticle && (
+
+ )}
+
+
+ {/* Bottom bar */}
+
+
+ {currentParticle?.created_by_email}
+
+
+
+
+ {status === "ended" && (
+
+
+
End of stream
+
+
+
+ )}
+
+ );
+}
+
+function Header({
+ name,
+ onBack,
+}: {
+ name: string;
+ onBack: () => void;
+}) {
+ return (
+
+ );
+}
diff --git a/js/src/pages/streams-page.tsx b/js/src/pages/streams-page.tsx
new file mode 100644
index 0000000..e083970
--- /dev/null
+++ b/js/src/pages/streams-page.tsx
@@ -0,0 +1,110 @@
+import { useEffect } from "react";
+import { Plus, LogOut } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { Muted } from "@/components/ui/typography";
+import { Progress } from "@/components/ui/progress";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { useAppStore } from "@/stores/app-store";
+import { useAuthStore } from "@/stores/auth-store";
+import { StreamList } from "@/features/streams/stream-list";
+import { CreateStreamDialog } from "@/features/streams/create-stream-dialog";
+
+export function StreamsPage() {
+ const fetchStartup = useAppStore((s) => s.fetchStartup);
+ const isLoading = useAppStore((s) => s.isLoading);
+ const networks = useAppStore((s) => s.networks);
+ const selectedNetworkId = useAppStore((s) => s.selectedNetworkId);
+ const setSelectedNetwork = useAppStore((s) => s.setSelectedNetwork);
+ const signOut = useAuthStore((s) => s.signOut);
+ const user = useAuthStore((s) => s.user);
+
+ useEffect(() => {
+ fetchStartup();
+ }, [fetchStartup]);
+
+ const selectedNetwork = selectedNetworkId
+ ? networks.find((n) => n.id === selectedNetworkId)
+ : null;
+
+ return (
+
+ {/* Top bar */}
+
+
+
+ {selectedNetwork && (
+
+
+
+ {selectedNetwork.open_stream_count}/
+ {selectedNetwork.open_stream_capacity} streams
+
+
+ )}
+
+
+
+ {selectedNetworkId && (
+
+
+
+ )}
+
+ {user && (
+
{user.email_prefix}
+ )}
+
+
+
+
+ {/* Stream list */}
+ {isLoading ? (
+
+ ) : (
+
+
+
+ )}
+
+ );
+}
diff --git a/js/src/stores/app-store.ts b/js/src/stores/app-store.ts
index 822cf87..685befb 100644
--- a/js/src/stores/app-store.ts
+++ b/js/src/stores/app-store.ts
@@ -1,24 +1,90 @@
import { create } from "zustand";
import { apiClient } from "@/api/client";
-import type { StartupResponse } from "@/api/types";
+import type {
+ NetworkWithStreams,
+ Stream,
+ StreamParticle,
+} from "@/api/types";
interface AppState {
- startupData: StartupResponse | null;
+ networks: NetworkWithStreams[];
+ selectedNetworkId: string | null;
isLoading: boolean;
+
fetchStartup: () => Promise;
+ setSelectedNetwork: (id: string | null) => void;
+ addStream: (networkId: string, stream: Stream) => void;
+ addParticleToStream: (streamId: string, particle: StreamParticle) => void;
+ markParticlesSeen: (particleIds: string[]) => void;
}
-export const useAppStore = create((set) => ({
- startupData: null,
+export const useAppStore = create((set, get) => ({
+ networks: [],
+ selectedNetworkId: null,
isLoading: false,
fetchStartup: async () => {
set({ isLoading: true });
try {
const data = await apiClient.startup();
- set({ startupData: data });
+ const state = get();
+ const shouldAutoSelect =
+ !state.selectedNetworkId && data.networks.length > 0;
+ set({
+ networks: data.networks,
+ ...(shouldAutoSelect
+ ? { selectedNetworkId: data.networks[0].id }
+ : {}),
+ });
} finally {
set({ isLoading: false });
}
},
+
+ setSelectedNetwork: (id) => {
+ set({ selectedNetworkId: id });
+ },
+
+ addStream: (networkId, stream) => {
+ set({
+ networks: get().networks.map((n) =>
+ n.id === networkId ? { ...n, streams: [stream, ...n.streams] } : n,
+ ),
+ });
+ },
+
+ addParticleToStream: (streamId, particle) => {
+ set({
+ networks: get().networks.map((n) => ({
+ ...n,
+ streams: n.streams.map((s) =>
+ s.id === streamId
+ ? { ...s, particles: [...s.particles, particle] }
+ : s,
+ ),
+ })),
+ });
+ },
+
+ markParticlesSeen: (particleIds) => {
+ const idSet = new Set(particleIds);
+ set({
+ networks: get().networks.map((n) => ({
+ ...n,
+ streams: n.streams.map((s) => {
+ const unseenMarked = s.particles.filter(
+ (p) => !p.seen && idSet.has(p.id),
+ ).length;
+ if (unseenMarked === 0) return s;
+ return {
+ ...s,
+ unseen_count: Math.max(0, s.unseen_count - unseenMarked),
+ particles: s.particles.map((p) =>
+ idSet.has(p.id) ? { ...p, seen: true } : p,
+ ),
+ };
+ }),
+ })),
+ });
+ },
}));
diff --git a/js/src/stores/playback-store.ts b/js/src/stores/playback-store.ts
new file mode 100644
index 0000000..f92db0a
--- /dev/null
+++ b/js/src/stores/playback-store.ts
@@ -0,0 +1,80 @@
+import { create } from "zustand";
+import type { StreamParticle } from "@/api/types";
+
+type PlaybackStatus = "idle" | "playing" | "ended";
+
+interface PlaybackState {
+ streamId: string | null;
+ particles: StreamParticle[];
+ currentIndex: number;
+ status: PlaybackStatus;
+ downloadUrlCache: Record;
+
+ initStream: (
+ streamId: string,
+ particles: StreamParticle[],
+ startIndex: number,
+ ) => void;
+ next: () => void;
+ prev: () => void;
+ goTo: (index: number) => void;
+ cacheDownloadUrl: (particleId: string, url: string) => void;
+ reset: () => void;
+}
+
+export const usePlaybackStore = create((set, get) => ({
+ streamId: null,
+ particles: [],
+ currentIndex: 0,
+ status: "idle",
+ downloadUrlCache: {},
+
+ initStream: (streamId, particles, startIndex) => {
+ set({
+ streamId,
+ particles,
+ currentIndex: startIndex,
+ status: particles.length > 0 ? "playing" : "ended",
+ downloadUrlCache: {},
+ });
+ },
+
+ next: () => {
+ const { currentIndex, particles } = get();
+ if (currentIndex < particles.length - 1) {
+ set({ currentIndex: currentIndex + 1 });
+ } else {
+ set({ status: "ended" });
+ }
+ },
+
+ prev: () => {
+ const { currentIndex } = get();
+ if (currentIndex > 0) {
+ set({ currentIndex: currentIndex - 1, status: "playing" });
+ }
+ },
+
+ goTo: (index) => {
+ const { particles } = get();
+ if (index >= 0 && index < particles.length) {
+ set({ currentIndex: index, status: "playing" });
+ }
+ },
+
+ cacheDownloadUrl: (particleId, url) => {
+ set({
+ downloadUrlCache: { ...get().downloadUrlCache, [particleId]: url },
+ });
+ },
+
+ reset: () => {
+ set({
+ streamId: null,
+ particles: [],
+ currentIndex: 0,
+ status: "idle",
+ downloadUrlCache: {},
+ });
+ },
+}));
diff --git a/js/src/stores/recording-store.ts b/js/src/stores/recording-store.ts
new file mode 100644
index 0000000..0a520ac
--- /dev/null
+++ b/js/src/stores/recording-store.ts
@@ -0,0 +1,21 @@
+import { create } from "zustand";
+
+type RecordingStatus = "idle" | "recording" | "uploading" | "error";
+
+interface RecordingState {
+ status: RecordingStatus;
+ error: string | null;
+
+ setStatus: (status: RecordingStatus) => void;
+ setError: (error: string) => void;
+ reset: () => void;
+}
+
+export const useRecordingStore = create((set) => ({
+ status: "idle",
+ error: null,
+
+ setStatus: (status) => set({ status, error: null }),
+ setError: (error) => set({ status: "error", error }),
+ reset: () => set({ status: "idle", error: null }),
+}));
diff --git a/js/yarn.lock b/js/yarn.lock
index 4da0b37..219f197 100644
--- a/js/yarn.lock
+++ b/js/yarn.lock
@@ -3271,7 +3271,7 @@ cookie@^0.7.1:
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.7.2.tgz#556369c472a2ba910f2979891b526b3436237ed7"
integrity sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==
-cookie@^1.0.2:
+cookie@^1.0.1, cookie@^1.0.2:
version "1.1.1"
resolved "https://registry.yarnpkg.com/cookie/-/cookie-1.1.1.tgz#3bb9bdfc82369db9c2f69c93c9c3ceb310c88b3c"
integrity sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==
@@ -6500,6 +6500,21 @@ react-remove-scroll@^2.6.3:
use-callback-ref "^1.3.3"
use-sidecar "^1.1.3"
+react-router-dom@^7.13.0:
+ version "7.13.0"
+ resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-7.13.0.tgz#8b5f7204fadca680f0e94f207c163f0dcd1cfdf5"
+ integrity sha512-5CO/l5Yahi2SKC6rGZ+HDEjpjkGaG/ncEP7eWFTvFxbHP8yeeI0PxTDjimtpXYlR3b3i9/WIL4VJttPrESIf2g==
+ dependencies:
+ react-router "7.13.0"
+
+react-router@7.13.0:
+ version "7.13.0"
+ resolved "https://registry.yarnpkg.com/react-router/-/react-router-7.13.0.tgz#de9484aee764f4f65b93275836ff5944d7f5bd3b"
+ integrity sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw==
+ dependencies:
+ cookie "^1.0.1"
+ set-cookie-parser "^2.6.0"
+
react-style-singleton@^2.2.2, react-style-singleton@^2.2.3:
version "2.2.3"
resolved "https://registry.yarnpkg.com/react-style-singleton/-/react-style-singleton-2.2.3.tgz#4265608be69a4d70cfe3047f2c6c88b2c3ace388"
@@ -6874,6 +6889,11 @@ serve-static@^2.2.0:
parseurl "^1.3.3"
send "^1.2.0"
+set-cookie-parser@^2.6.0:
+ version "2.7.2"
+ resolved "https://registry.yarnpkg.com/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz#ccd08673a9ae5d2e44ea2a2de25089e67c7edf68"
+ integrity sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==
+
set-function-length@^1.2.2:
version "1.2.2"
resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.2.tgz#aac72314198eaed975cf77b2c3b6b880695e5449"