233 lines
6.1 KiB
TypeScript
233 lines
6.1 KiB
TypeScript
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,
|
|
CircleCheck,
|
|
CalendarDays,
|
|
BookOpen,
|
|
FileIcon,
|
|
FolderIcon,
|
|
} from 'lucide-react';
|
|
import { cn } from '@/lib/utils';
|
|
import { formatEventTime } from '@/lib/particle-display';
|
|
|
|
export function ParticlePreview({ particle }: { particle: Particle }) {
|
|
switch (particle.type) {
|
|
case 'text':
|
|
return <TextPreview particle={particle} />;
|
|
case 'media':
|
|
return <MediaPreview particle={particle} />;
|
|
case 'task':
|
|
return <TaskPreview particle={particle} />;
|
|
case 'event':
|
|
return <EventPreview 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-xl 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.properties.object_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}#t=2`}
|
|
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 TaskPreview({
|
|
particle,
|
|
}: {
|
|
particle: Extract<Particle, { type: 'task' }>;
|
|
}) {
|
|
const { title, done } = particle.properties;
|
|
return (
|
|
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-amber-500/10 p-4">
|
|
<CircleCheck
|
|
className={cn(
|
|
'h-6 w-6',
|
|
done
|
|
? 'text-emerald-600/80 dark:text-emerald-400/80'
|
|
: 'text-amber-600/70 dark:text-amber-400/70',
|
|
)}
|
|
/>
|
|
<p
|
|
className={cn(
|
|
'line-clamp-2 text-center text-sm font-medium',
|
|
done && 'text-muted-foreground line-through',
|
|
)}
|
|
>
|
|
{title}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function EventPreview({
|
|
particle,
|
|
}: {
|
|
particle: Extract<Particle, { type: 'event' }>;
|
|
}) {
|
|
const { title, start_at, end_at } = particle.properties;
|
|
return (
|
|
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-violet-500/10 p-4">
|
|
<CalendarDays className="h-6 w-6 text-violet-600/70 dark:text-violet-400/70" />
|
|
<p className="line-clamp-2 text-center text-sm font-medium">{title}</p>
|
|
<span className="text-muted-foreground text-center text-[10px]">
|
|
{formatEventTime(start_at, end_at)}
|
|
</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>
|
|
);
|
|
}
|