refactor: organize components flatter

This commit is contained in:
talksik
2026-03-21 11:20:49 -07:00
parent e1375eddfa
commit 2262bf4f3b
5 changed files with 4 additions and 4 deletions
@@ -0,0 +1,60 @@
import type { Particle } 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: Particle;
}
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 particle.properties.title;
case "paper":
return particle.properties.title;
case "file":
return particle.properties.filename;
case "folder":
return particle.properties.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,101 @@
import { useEffect, useRef, useState } from "react";
import type { Particle } from "@/api/types";
import { useDownloadUrl } from "@/hooks/use-download-url";
import { Skeleton } from "@/components/ui/skeleton";
import { AudioLevelBars } from "@/components/audio/audio-level-bars";
import { useAudioSource } from "@/components/audio/use-audio-source";
type MediaParticle = Extract<Particle, { type: "media" }>;
interface MediaParticleViewProps {
particle: MediaParticle;
paused: boolean;
onEnded: () => void;
onProgress?: (ratio: number) => void;
}
export function MediaParticleView({
particle,
paused,
onEnded,
onProgress,
}: MediaParticleViewProps) {
const { data: url, error } = useDownloadUrl(particle.properties.object_id);
const videoRef = useRef<HTMLVideoElement>(null);
const audioRef = useRef<HTMLAudioElement>(null);
const isAudio = particle.properties.mime_type?.startsWith("audio/");
// WORKAROUND: useAudioSource needs an audio element, but audioRef is only set after mount
const [audioEl, setAudioEl] = useState<HTMLAudioElement | null>(null);
const audioSource = useAudioSource(audioEl);
useEffect(() => {
const el = isAudio ? audioRef.current : videoRef.current;
if (!el) return;
if (paused) {
el.pause();
} else {
el.play().catch(() => {
console.warn("Playback failed", { particleId: particle.id });
});
}
}, [paused, isAudio, particle.id]);
if (error) {
return (
<div className="text-muted-foreground flex items-center justify-center text-sm">
Failed to load media
</div>
);
}
if (!url) {
return <Skeleton className="h-full w-full rounded-none" />;
}
if (isAudio) {
return (
<div className="relative flex h-full w-full items-center justify-center bg-black/90">
<audio
ref={(el) => {
audioRef.current = el;
setAudioEl(el);
}}
crossOrigin="anonymous"
src={url}
autoPlay
onEnded={onEnded}
onTimeUpdate={(e) => {
const { currentTime, duration } = e.currentTarget;
if (duration > 0) onProgress?.(currentTime / duration);
}}
/>
{audioSource && (
<div className="z-10 absolute bottom-15">
<AudioLevelBars sourceNode={audioSource.sourceNode} />
</div>
)}
</div>
);
}
return (
<div className="relative h-full w-full">
<video
ref={videoRef}
src={url}
autoPlay
playsInline
onEnded={onEnded}
onTimeUpdate={(e) => {
const { currentTime, duration } = e.currentTarget;
if (duration > 0) onProgress?.(currentTime / duration);
}}
className="h-full w-full object-cover"
/>
</div>
);
}
@@ -0,0 +1,56 @@
import { cn } from "@/lib/utils";
interface PlaybackPageIndicatorProps {
total: number;
current: number;
progress: number;
onGoTo: (index: number) => void;
}
export function PlaybackPageIndicator({
total,
current,
progress,
onGoTo,
}: PlaybackPageIndicatorProps) {
if (total === 0) return null;
return (
<div className="flex w-full items-center gap-0.5 px-1">
{Array.from({ length: total }, (_, i) => (
<button
key={i}
onClick={(e) => {
e.stopPropagation();
onGoTo(i);
}}
className="group relative h-3 flex-1"
>
{/* Dim track */}
<div
className={cn(
"absolute inset-x-0 top-1 h-1 rounded-full bg-white/30",
"group-hover:h-1.5 group-hover:top-0.5",
)}
/>
{/* Fill */}
<div
className={cn(
"absolute left-0 top-1 h-1 rounded-full bg-white/90",
"group-hover:h-1.5 group-hover:top-0.5",
)}
style={{
width:
i < current
? "100%"
: i === current
? `${progress * 100}%`
: "0%",
transition: i === current ? "width 300ms linear" : "none",
}}
/>
</button>
))}
</div>
);
}
+4 -4
View File
@@ -5,10 +5,10 @@ import type { Particle } from "@/api/types";
import { useLiveParticleChildren } from "@/hooks/use-particle";
import { parseParticlePath, toFirestoreDocPath, type ParticlePath } from "@/lib/particle-path";
import { ComposeOverlay } from "@/features/compose/compose-overlay";
import { PlaybackPageIndicator } from "@/features/playback/playback-page-indicator";
import { MediaParticleView } from "@/features/playback/media-particle-view";
import { TextParticleView } from "@/features/playback/text-particle-view";
import { FallbackParticleView } from "@/features/playback/fallback-particle-view";
import { PlaybackPageIndicator } from "@/features/particles/playback-page-indicator";
import { MediaParticleView } from "@/features/particles/media-particle-view";
import { TextParticleView } from "@/features/particles/text-particle-view";
import { FallbackParticleView } from "@/features/particles/fallback-particle-view";
import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar";
import ControlsIndicator from "@/features/compose/controls-indicator";
import { updateStreamPlaybackMarker } from "@/lib/firestore-particles";
@@ -0,0 +1,77 @@
import { useEffect, useRef } from "react";
import type { Particle } from "@/api/types";
import { cn } from "@/lib/utils";
type TextParticle = Extract<Particle, { type: "text" }>;
interface TextParticleViewProps {
particle: TextParticle;
paused: boolean;
onEnded: () => void;
onProgress?: (ratio: number) => void;
}
const WORDS_PER_MINUTE = 200;
const MIN_DURATION_S = 3;
const MAX_DURATION_S = 15;
const TICK_MS = 100;
function computeReadDuration(text: string): number {
const wordCount = text.trim().split(/\s+/).length;
const seconds = (wordCount / WORDS_PER_MINUTE) * 60;
return Math.min(Math.max(seconds, MIN_DURATION_S), MAX_DURATION_S);
}
function getTextStyle(length: number) {
if (length < 50) return { size: "text-5xl", weight: "font-semibold" };
if (length < 150) return { size: "text-3xl", weight: "font-semibold" };
if (length < 300) return { size: "text-2xl", weight: "font-normal" };
return { size: "text-lg", weight: "font-normal" };
}
export function TextParticleView({
particle,
paused,
onEnded,
onProgress,
}: TextParticleViewProps) {
const style = getTextStyle(particle.properties.content.length);
const durationS = computeReadDuration(particle.properties.content);
const elapsedRef = useRef(0);
// Reset elapsed when particle changes
useEffect(() => {
elapsedRef.current = 0;
}, [particle.id]);
useEffect(() => {
if (paused) return;
const interval = setInterval(() => {
elapsedRef.current += TICK_MS / 1000;
const ratio = Math.min(elapsedRef.current / durationS, 1);
onProgress?.(ratio);
if (ratio >= 1) {
clearInterval(interval);
onEnded();
}
}, TICK_MS);
return () => clearInterval(interval);
}, [paused, durationS, onEnded, onProgress, particle.id]);
return (
<div className="flex h-full w-full items-center justify-center bg-gradient-to-b from-black/40 via-black/20 to-black/40 p-8">
<p
className={cn(
"max-w-2xl text-center leading-relaxed text-white",
style.size,
style.weight,
)}
>
{particle.properties.content}
</p>
</div>
);
}