import { closestCenter, DndContext, type DragEndEvent, KeyboardSensor, PointerSensor, useSensor, useSensors, } from '@dnd-kit/core'; import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy, } from '@dnd-kit/sortable'; import { Link } from '@tanstack/react-router'; import { AnimatePresence, motion } from 'framer-motion'; import { Download, Plus } from 'lucide-react'; import { useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import Loader from 'react-loaders'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { useToast } from '@/components/ui/use-toast'; import { useHistory } from '@/lib/hooks/useHistory'; import { useAddStoryItem, useExportStoryAudio, useRemoveStoryItem, useReorderStoryItems, useStory, } from '@/lib/hooks/useStories'; import { useStoryPlayback } from '@/lib/hooks/useStoryPlayback'; import { useGenerationStore } from '@/stores/generationStore'; import { useStoryStore } from '@/stores/storyStore'; import { SortableStoryChatItem } from './StoryChatItem'; export function StoryContent() { const { t } = useTranslation(); const selectedStoryId = useStoryStore((state) => state.selectedStoryId); const { data: story, isLoading } = useStory(selectedStoryId); const removeItem = useRemoveStoryItem(); const reorderItems = useReorderStoryItems(); const exportAudio = useExportStoryAudio(); const addStoryItem = useAddStoryItem(); const { toast } = useToast(); const scrollRef = useRef(null); const pendingCount = useGenerationStore((s) => s.pendingGenerationIds.size); // Add generation popover state const [searchQuery, setSearchQuery] = useState(''); const [isAddOpen, setIsAddOpen] = useState(false); const { data: historyData } = useHistory(); // Filter generations not in story and matching search const availableGenerations = useMemo(() => { if (!historyData?.items || !story) return []; const storyGenerationIds = new Set(story.items.map((i) => i.generation_id)); const query = searchQuery.toLowerCase(); return historyData.items.filter( (gen) => gen.status === 'completed' && !storyGenerationIds.has(gen.id) && (gen.text.toLowerCase().includes(query) || gen.profile_name.toLowerCase().includes(query)), ); }, [historyData, story, searchQuery]); // Get track editor height from store for dynamic padding const trackEditorHeight = useStoryStore((state) => state.trackEditorHeight); // Track editor is shown when story has items const hasBottomBar = story && story.items.length > 0; // Calculate dynamic bottom padding: track editor + gap const bottomPadding = hasBottomBar ? trackEditorHeight + 24 : 0; // Drag and drop sensors const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 8, }, }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates, }), ); // Playback state (for auto-scroll and item highlighting) const isPlaying = useStoryStore((state) => state.isPlaying); const currentTimeMs = useStoryStore((state) => state.currentTimeMs); const playbackStoryId = useStoryStore((state) => state.playbackStoryId); // Refs for auto-scrolling to playing item const itemRefsMap = useRef>(new Map()); const lastScrolledItemRef = useRef(null); // Use playback hook useStoryPlayback(story?.items); // Sort items by start_time_ms const sortedItems = useMemo(() => { if (!story?.items) return []; return [...story.items].sort((a, b) => a.start_time_ms - b.start_time_ms); }, [story?.items]); // Find the currently playing item based on timecode const currentlyPlayingItemId = useMemo(() => { if (!isPlaying || playbackStoryId !== story?.id || !sortedItems.length) { return null; } const playingItem = sortedItems.find((item) => { const itemStart = item.start_time_ms; const itemEnd = item.start_time_ms + item.duration * 1000; return currentTimeMs >= itemStart && currentTimeMs < itemEnd; }); return playingItem?.generation_id ?? null; }, [isPlaying, playbackStoryId, story?.id, sortedItems, currentTimeMs]); // Auto-scroll to the currently playing item useEffect(() => { if (!currentlyPlayingItemId || currentlyPlayingItemId === lastScrolledItemRef.current) { return; } const element = itemRefsMap.current.get(currentlyPlayingItemId); if (element && scrollRef.current) { element.scrollIntoView({ behavior: 'smooth', block: 'start' }); lastScrolledItemRef.current = currentlyPlayingItemId; } }, [currentlyPlayingItemId]); // Reset last scrolled item when playback stops useEffect(() => { if (!isPlaying) { lastScrolledItemRef.current = null; } }, [isPlaying]); const handleRemoveItem = (itemId: string) => { if (!story) return; removeItem.mutate( { storyId: story.id, itemId, }, { onError: (error) => { toast({ title: t('storyContent.toast.removeFailed'), description: error.message, variant: 'destructive', }); }, }, ); }; const handleDragEnd = (event: DragEndEvent) => { const { active, over } = event; if (!story || !over || active.id === over.id) return; const oldIndex = sortedItems.findIndex((item) => item.generation_id === active.id); const newIndex = sortedItems.findIndex((item) => item.generation_id === over.id); if (oldIndex === -1 || newIndex === -1) return; // Calculate the new order const newOrder = arrayMove(sortedItems, oldIndex, newIndex); const generationIds = newOrder.map((item) => item.generation_id); // Send reorder request to backend reorderItems.mutate( { storyId: story.id, data: { generation_ids: generationIds }, }, { onError: (error) => { toast({ title: t('storyContent.toast.reorderFailed'), description: error.message, variant: 'destructive', }); }, }, ); }; const handleExportAudio = () => { if (!story) return; exportAudio.mutate( { storyId: story.id, storyName: story.name, }, { onError: (error) => { toast({ title: t('storyContent.toast.exportFailed'), description: error.message, variant: 'destructive', }); }, }, ); }; const handleAddGeneration = (generationId: string) => { if (!story) return; addStoryItem.mutate( { storyId: story.id, data: { generation_id: generationId }, }, { onSuccess: () => { setIsAddOpen(false); setSearchQuery(''); }, onError: (error) => { toast({ title: t('storyContent.toast.addFailed'), description: error.message, variant: 'destructive', }); }, }, ); }; if (!selectedStoryId) { return (

{t('storyContent.selectStory.title')}

{t('storyContent.selectStory.hint')}

); } if (isLoading) { return (
{t('storyContent.loading')}
); } if (!story) { return (

{t('storyContent.notFound.title')}

{t('storyContent.notFound.hint')}

); } return (
{/* Header */}

{story.name}

{story.description && (

{story.description}

)}
{pendingCount > 0 && (
{t('storyContent.generatingCount', { count: pendingCount })}
)}
setSearchQuery(e.target.value)} autoFocus />
{availableGenerations.length === 0 ? (
{searchQuery ? t('storyContent.searchNoMatches') : t('storyContent.searchNoAvailable')}
) : ( availableGenerations.map((gen) => ( )) )}
{story.items.length > 0 && ( )}
{/* Content */}
0 ? `${bottomPadding}px` : undefined }} > {sortedItems.length === 0 ? (

{t('storyContent.empty.title')}

{t('storyContent.empty.hint')}

) : ( item.generation_id)} strategy={verticalListSortingStrategy} >
{sortedItems.map((item, index) => (
{ if (el) { itemRefsMap.current.set(item.generation_id, el); } else { itemRefsMap.current.delete(item.generation_id); } }} > handleRemoveItem(item.id)} currentTimeMs={currentTimeMs} isPlaying={isPlaying && playbackStoryId === story.id} />
))}
)}
); }