feat: initial conversational flow (#37)
* chore: only set visibility for container particles * create reusable controls indicator for reply or new * compress the size of top bar * refactor: restructure state, routing, and more * introduce stream compose flow * feat: compose new stream full flow * implement stream player * fix: prevent redirect for signed object urls * fix: implement stream playback cleaner structure * refactor: layout file name * feat: show stream name in breadcrumbs * chore: tweak padding * chore: adjust position of audio bars * feat: show latest particle preview in stream list * fix: remove console log * refactor: reorder classes * fix: avoid passing in updated_at to firestore particle * refactor: extract properties for container particles to flat fields in firestore * make the stream previews look alive * feat: show audio bars during audio clip playback * feat: order streams by last child creation * feat: playback where I left off * chore: remove unused store * fix: recording mode not using shared state * chore: clean unused variable * remove unused imports * fix: improve controls indicator immersion * feat: show playback progress in bar & auto-play text * feat: auto-exit stream on playback completion * fix: jittery media playback progress * fix: navigate during state change is invalid with react router * fix: buggy exit progress when changing clips * feat: add app icon * update package.json info * feat: only show streams visible to me * feat: show seen indicator on particles * fix: prevent unnecessary effects * fix: play new particle after playback is ended * use contols indicator for exit timer
This commit was merged in pull request #37.
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { Particle } from "@/api/types";
|
||||
import { apiClient } from "@/api/client";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Video,
|
||||
Mic,
|
||||
ScrollText,
|
||||
BookOpen,
|
||||
FileIcon,
|
||||
FolderIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
export function ParticlePreview({ particle }: { particle: Particle }) {
|
||||
switch (particle.type) {
|
||||
case "text":
|
||||
return <TextPreview particle={particle} />;
|
||||
case "media":
|
||||
return <MediaPreview particle={particle} />;
|
||||
case "quest":
|
||||
return <QuestPreview particle={particle} />;
|
||||
case "paper":
|
||||
return <PaperPreview particle={particle} />;
|
||||
case "file":
|
||||
return <FilePreview particle={particle} />;
|
||||
case "folder":
|
||||
return <FolderPreview particle={particle} />;
|
||||
default:
|
||||
return <EmptyPreview />;
|
||||
}
|
||||
}
|
||||
|
||||
function TextPreview({ particle }: { particle: Extract<Particle, { type: "text" }> }) {
|
||||
const truncated =
|
||||
particle.properties.content.length > 30
|
||||
? particle.properties.content.slice(0, 30) + "..."
|
||||
: particle.properties.content;
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center p-4">
|
||||
<p className="line-clamp-4 text-center text-4xl leading-relaxed">
|
||||
{truncated}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MediaPreview({ particle }: { particle: Extract<Particle, { type: "media" }> }) {
|
||||
const { mime_type, duration_ms } = particle.properties;
|
||||
const isVideo = mime_type.startsWith("video");
|
||||
const durationSec = Math.round(duration_ms / 1000);
|
||||
const durationLabel = `${Math.floor(durationSec / 60)}:${String(durationSec % 60).padStart(2, "0")}`;
|
||||
|
||||
if (isVideo) {
|
||||
return <VideoThumbnail particleId={particle.id} duration={durationLabel} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-black/90">
|
||||
<Mic className="h-8 w-8 text-white/60" />
|
||||
<span className="font-mono text-xs text-white/50">{durationLabel}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VideoThumbnail({
|
||||
particleId,
|
||||
duration,
|
||||
}: {
|
||||
particleId: string;
|
||||
duration: string;
|
||||
}) {
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
apiClient
|
||||
.getParticleDownloadUrl(particleId)
|
||||
.then((downloadUrl) => {
|
||||
if (!cancelled) setUrl(downloadUrl);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setError(true);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [particleId]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center bg-black/80">
|
||||
<Video className="h-8 w-8 text-white/40" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!url) {
|
||||
return <Skeleton className="h-full w-full rounded-none" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full bg-black">
|
||||
<video
|
||||
src={url}
|
||||
preload="metadata"
|
||||
muted
|
||||
playsInline
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
<span className="absolute right-1.5 bottom-1.5 rounded bg-black/70 px-1.5 py-0.5 font-mono text-[10px] text-white/80">
|
||||
{duration}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QuestPreview({ particle }: { particle: Extract<Particle, { type: "quest" }> }) {
|
||||
const { title, status } = particle.properties;
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-amber-500/10 p-4">
|
||||
<ScrollText className="h-6 w-6 text-amber-600/70 dark:text-amber-400/70" />
|
||||
<p className="line-clamp-2 text-center text-sm font-medium">
|
||||
{title}
|
||||
</p>
|
||||
{status && (
|
||||
<span className="text-muted-foreground text-[10px] uppercase tracking-wide">
|
||||
{status}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PaperPreview({ particle }: { particle: Extract<Particle, { type: "paper" }> }) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-blue-500/10 p-4">
|
||||
<BookOpen className="h-6 w-6 text-blue-600/70 dark:text-blue-400/70" />
|
||||
<p className="line-clamp-2 text-center text-sm font-medium">
|
||||
{particle.properties.title}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FilePreview({ particle }: { particle: Extract<Particle, { type: "file" }> }) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-emerald-500/10 p-4">
|
||||
<FileIcon className="h-6 w-6 text-emerald-600/70 dark:text-emerald-400/70" />
|
||||
<p className="text-muted-foreground line-clamp-1 text-center text-xs">
|
||||
{particle.properties.filename}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FolderPreview({ particle }: { particle: Extract<Particle, { type: "folder" }> }) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-orange-500/10 p-4">
|
||||
<FolderIcon className="h-6 w-6 text-orange-600/70 dark:text-orange-400/70" />
|
||||
<p className="text-muted-foreground line-clamp-1 text-center text-xs">
|
||||
{particle.properties.name}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyPreview() {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<p className="text-muted-foreground text-xs italic">No messages yet</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user