Initial commit
This commit is contained in:
407
app/src/components/StoriesTab/StoryContent.tsx
Normal file
407
app/src/components/StoriesTab/StoryContent.tsx
Normal file
@@ -0,0 +1,407 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user