408 lines
14 KiB
TypeScript
408 lines
14 KiB
TypeScript
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<HTMLDivElement>(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<Map<string, HTMLDivElement>>(new Map());
|
|
const lastScrolledItemRef = useRef<string | null>(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 (
|
|
<div className="flex items-center justify-center h-full text-muted-foreground">
|
|
<div className="text-center">
|
|
<p className="text-lg font-medium mb-2">{t('storyContent.selectStory.title')}</p>
|
|
<p className="text-sm">{t('storyContent.selectStory.hint')}</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex items-center justify-center h-full">
|
|
<div className="text-muted-foreground">{t('storyContent.loading')}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!story) {
|
|
return (
|
|
<div className="flex items-center justify-center h-full text-muted-foreground">
|
|
<div className="text-center">
|
|
<p className="text-lg font-medium mb-2">{t('storyContent.notFound.title')}</p>
|
|
<p className="text-sm">{t('storyContent.notFound.hint')}</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col h-full min-h-0">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between mb-4 px-1">
|
|
<div>
|
|
<h2 className="text-2xl font-bold">{story.name}</h2>
|
|
{story.description && (
|
|
<p className="text-sm text-muted-foreground mt-1">{story.description}</p>
|
|
)}
|
|
</div>
|
|
<div className="flex gap-2 items-center">
|
|
<AnimatePresence>
|
|
{pendingCount > 0 && (
|
|
<motion.div
|
|
initial={{ opacity: 0, scale: 0.9, width: 0 }}
|
|
animate={{ opacity: 1, scale: 1, width: 'auto' }}
|
|
exit={{ opacity: 0, scale: 0.9, width: 0 }}
|
|
transition={{ duration: 0.2 }}
|
|
>
|
|
<Link
|
|
to="/"
|
|
className="flex items-center gap-2 h-8 pl-1.5 pr-3 rounded-full bg-card border border-border hover:bg-muted/50 transition-all duration-200 cursor-pointer"
|
|
>
|
|
<div className="shrink-0 w-10 h-5 overflow-hidden flex items-center justify-center">
|
|
<div className="scale-[0.45]">
|
|
<Loader type="line-scale" active />
|
|
</div>
|
|
</div>
|
|
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
|
{t('storyContent.generatingCount', { count: pendingCount })}
|
|
</span>
|
|
</Link>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
<Popover open={isAddOpen} onOpenChange={setIsAddOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button variant="outline" size="sm">
|
|
<Plus className="mr-2 h-4 w-4" />
|
|
{t('storyContent.add')}
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-80 p-0" align="end">
|
|
<div className="p-2 border-b">
|
|
<Input
|
|
placeholder={t('storyContent.searchPlaceholder')}
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
<div className="max-h-60 overflow-y-auto">
|
|
{availableGenerations.length === 0 ? (
|
|
<div className="p-4 text-center text-sm text-muted-foreground">
|
|
{searchQuery
|
|
? t('storyContent.searchNoMatches')
|
|
: t('storyContent.searchNoAvailable')}
|
|
</div>
|
|
) : (
|
|
availableGenerations.map((gen) => (
|
|
<button
|
|
key={gen.id}
|
|
type="button"
|
|
className="w-full text-left px-3 py-2 hover:bg-muted transition-colors border-b last:border-b-0"
|
|
onClick={() => handleAddGeneration(gen.id)}
|
|
>
|
|
<div className="font-medium text-sm">{gen.profile_name}</div>
|
|
<div className="text-xs text-muted-foreground truncate">
|
|
{gen.text.length > 50 ? `${gen.text.substring(0, 50)}...` : gen.text}
|
|
</div>
|
|
</button>
|
|
))
|
|
)}
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
{story.items.length > 0 && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={handleExportAudio}
|
|
disabled={exportAudio.isPending}
|
|
>
|
|
<Download className="mr-2 h-4 w-4" />
|
|
{t('storyContent.exportAudio')}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div
|
|
ref={scrollRef}
|
|
className="flex-1 min-h-0 overflow-y-auto space-y-3"
|
|
style={{ paddingBottom: bottomPadding > 0 ? `${bottomPadding}px` : undefined }}
|
|
>
|
|
{sortedItems.length === 0 ? (
|
|
<div className="text-center py-12 px-5 border-2 border-dashed border-muted rounded-md text-muted-foreground">
|
|
<p className="text-sm">{t('storyContent.empty.title')}</p>
|
|
<p className="text-xs mt-2">{t('storyContent.empty.hint')}</p>
|
|
</div>
|
|
) : (
|
|
<DndContext
|
|
sensors={sensors}
|
|
collisionDetection={closestCenter}
|
|
onDragEnd={handleDragEnd}
|
|
>
|
|
<SortableContext
|
|
items={sortedItems.map((item) => item.generation_id)}
|
|
strategy={verticalListSortingStrategy}
|
|
>
|
|
<div className="space-y-3">
|
|
{sortedItems.map((item, index) => (
|
|
<div
|
|
key={item.id}
|
|
ref={(el) => {
|
|
if (el) {
|
|
itemRefsMap.current.set(item.generation_id, el);
|
|
} else {
|
|
itemRefsMap.current.delete(item.generation_id);
|
|
}
|
|
}}
|
|
>
|
|
<SortableStoryChatItem
|
|
item={item}
|
|
storyId={story.id}
|
|
index={index}
|
|
onRemove={() => handleRemoveItem(item.id)}
|
|
currentTimeMs={currentTimeMs}
|
|
isPlaying={isPlaying && playbackStoryId === story.id}
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</SortableContext>
|
|
</DndContext>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|