feat: list streams and story-mode catchup

This commit is contained in:
talksik
2026-02-21 09:46:04 -08:00
parent b5f90709de
commit 0cd74c0a8a
33 changed files with 2304 additions and 41 deletions
@@ -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<string, { icon: typeof FileIcon; label: string }> = {
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 (
<div className="flex h-full w-full items-center justify-center p-8">
<Card className="w-full max-w-sm">
<CardHeader className="flex flex-row items-center gap-3">
<Icon className="text-muted-foreground h-6 w-6 shrink-0" />
<div>
<CardTitle className="text-base">{meta.label}</CardTitle>
{title && <CardDescription>{title}</CardDescription>}
</div>
</CardHeader>
<CardContent>
<p className="text-muted-foreground text-xs">
From {particle.created_by_email}
</p>
</CardContent>
</Card>
</div>
);
}
@@ -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<string | null>(cachedUrl ?? null);
const [error, setError] = useState<string | null>(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 (
<div className="text-muted-foreground flex items-center justify-center text-sm">
{error}
</div>
);
}
if (!url) {
return <Skeleton className="h-full w-full rounded-none" />;
}
const data = particle.data as MediaParticleData;
const isAudio = data.mime_type?.startsWith("audio/");
if (isAudio) {
return (
<div className="flex h-full w-full items-center justify-center">
<audio src={url} autoPlay onEnded={onEnded} controls />
</div>
);
}
return (
<video
src={url}
autoPlay
playsInline
onEnded={onEnded}
className="h-full w-full object-contain"
/>
);
}
@@ -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<string | null>(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<HTMLDivElement>) => {
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 <MediaParticleView particle={particle} onEnded={onNext} />;
case "text":
return <TextParticleView particle={particle} />;
default:
return <FallbackParticleView particle={particle} />;
}
};
return (
<div
className="relative flex h-full w-full cursor-pointer items-center justify-center"
onClick={handleClick}
>
{renderContent()}
</div>
);
}
@@ -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 (
<div className="flex items-center justify-center gap-1 py-2">
{Array.from({ length: total }, (_, i) => (
<button
key={i}
onClick={(e) => {
e.stopPropagation();
onGoTo(i);
}}
className="p-1"
>
<div
className={cn(
"h-2.5 rounded-full transition-all",
i === current
? "bg-primary w-6"
: "bg-muted-foreground/30 hover:bg-muted-foreground/50 w-2.5",
)}
/>
</button>
))}
</div>
);
}
const percent = ((current + 1) / total) * 100;
return (
<div className="px-4 py-2">
<Progress value={percent} className="h-1" />
</div>
);
}
@@ -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 (
<ScrollArea className="h-full w-full">
<div className="flex min-h-full items-center justify-center p-8">
<p className="max-w-2xl text-center text-2xl leading-relaxed">
{data.content}
</p>
</div>
</ScrollArea>
);
}