diff --git a/app/src/components/Generation/FloatingGenerateBox.tsx b/app/src/components/Generation/FloatingGenerateBox.tsx index 10a0ca44..2d6a91c9 100644 --- a/app/src/components/Generation/FloatingGenerateBox.tsx +++ b/app/src/components/Generation/FloatingGenerateBox.tsx @@ -222,28 +222,30 @@ export function FloatingGenerateBox({ isPlayerOpen = false, showVoiceSelector = >
{showVoiceSelector && ( - +
+ +
)} ( - + diff --git a/app/src/components/StoriesTab/StoryContent.tsx b/app/src/components/StoriesTab/StoryContent.tsx index bf532ab4..3892353c 100644 --- a/app/src/components/StoriesTab/StoryContent.tsx +++ b/app/src/components/StoriesTab/StoryContent.tsx @@ -13,11 +13,15 @@ import { sortableKeyboardCoordinates, verticalListSortingStrategy, } from '@dnd-kit/sortable'; -import { Download } from 'lucide-react'; -import { useEffect, useMemo, useRef } from 'react'; +import { Download, Plus } from 'lucide-react'; +import { useEffect, useMemo, useRef, useState } from 'react'; 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, @@ -36,9 +40,28 @@ export function StoryContent() { const removeItem = useRemoveStoryItem(); const reorderItems = useReorderStoryItems(); const exportAudio = useExportStoryAudio(); + const addStoryItem = useAddStoryItem(); const { toast } = useToast(); const scrollRef = useRef(null); + // 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) => + !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); @@ -183,6 +206,30 @@ export function StoryContent() { ); }; + const handleAddGeneration = (generationId: string) => { + if (!story) return; + + addStoryItem.mutate( + { + storyId: story.id, + data: { generation_id: generationId }, + }, + { + onSuccess: () => { + setIsAddOpen(false); + setSearchQuery(''); + }, + onError: (error) => { + toast({ + title: 'Failed to add generation', + description: error.message, + variant: 'destructive', + }); + }, + }, + ); + }; + if (!selectedStoryId) { return (
@@ -223,17 +270,60 @@ export function StoryContent() {

{story.description}

)}
- {story.items.length > 0 && ( - - )} +
+ + + + + +
+ setSearchQuery(e.target.value)} + autoFocus + /> +
+
+ {availableGenerations.length === 0 ? ( +
+ {searchQuery + ? 'No matching generations found' + : 'No available generations'} +
+ ) : ( + availableGenerations.map((gen) => ( + + )) + )} +
+
+
+ {story.items.length > 0 && ( + + )} +
{/* Content */} diff --git a/app/src/components/StoriesTab/StoryList.tsx b/app/src/components/StoriesTab/StoryList.tsx index f3053f30..ea5e892a 100644 --- a/app/src/components/StoriesTab/StoryList.tsx +++ b/app/src/components/StoriesTab/StoryList.tsx @@ -1,4 +1,4 @@ -import { Plus, BookOpen } from 'lucide-react'; +import { Plus, BookOpen, MoreHorizontal, Pencil, Trash2 } from 'lucide-react'; import { useState } from 'react'; import { Button } from '@/components/ui/button'; import { @@ -9,11 +9,32 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea'; import { Label } from '@/components/ui/label'; import { useToast } from '@/components/ui/use-toast'; -import { useStories, useCreateStory } from '@/lib/hooks/useStories'; +import { + useStories, + useCreateStory, + useUpdateStory, + useDeleteStory, +} from '@/lib/hooks/useStories'; import { useStoryStore } from '@/stores/storyStore'; import { cn } from '@/lib/utils/cn'; import { formatDate } from '@/lib/utils/format'; @@ -23,7 +44,13 @@ export function StoryList() { const selectedStoryId = useStoryStore((state) => state.selectedStoryId); const setSelectedStoryId = useStoryStore((state) => state.setSelectedStoryId); const createStory = useCreateStory(); + const updateStory = useUpdateStory(); + const deleteStory = useDeleteStory(); const [createDialogOpen, setCreateDialogOpen] = useState(false); + const [editDialogOpen, setEditDialogOpen] = useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [editingStory, setEditingStory] = useState<{ id: string; name: string; description?: string } | null>(null); + const [deletingStoryId, setDeletingStoryId] = useState(null); const [newStoryName, setNewStoryName] = useState(''); const [newStoryDescription, setNewStoryDescription] = useState(''); const { toast } = useToast(); @@ -65,6 +92,76 @@ export function StoryList() { ); }; + const handleEditClick = (story: { id: string; name: string; description?: string }) => { + setEditingStory(story); + setNewStoryName(story.name); + setNewStoryDescription(story.description || ''); + setEditDialogOpen(true); + }; + + const handleUpdateStory = () => { + if (!editingStory || !newStoryName.trim()) { + toast({ + title: 'Name required', + description: 'Please enter a story name', + variant: 'destructive', + }); + return; + } + + updateStory.mutate( + { + storyId: editingStory.id, + data: { + name: newStoryName.trim(), + description: newStoryDescription.trim() || undefined, + }, + }, + { + onSuccess: () => { + setEditDialogOpen(false); + setEditingStory(null); + setNewStoryName(''); + setNewStoryDescription(''); + }, + onError: (error) => { + toast({ + title: 'Failed to update story', + description: error.message, + variant: 'destructive', + }); + }, + }, + ); + }; + + const handleDeleteClick = (storyId: string) => { + setDeletingStoryId(storyId); + setDeleteDialogOpen(true); + }; + + const handleDeleteConfirm = () => { + if (!deletingStoryId) return; + + deleteStory.mutate(deletingStoryId, { + onSuccess: () => { + // Clear selection if deleting the currently selected story + if (selectedStoryId === deletingStoryId) { + setSelectedStoryId(null); + } + setDeleteDialogOpen(false); + setDeletingStoryId(null); + }, + onError: (error) => { + toast({ + title: 'Failed to delete story', + description: error.message, + variant: 'destructive', + }); + }, + }); + }; + if (isLoading) { return (
@@ -99,16 +196,19 @@ export function StoryList() {
setSelectedStoryId(story.id)} > -
-
+
+
-
+ + + + + + + handleEditClick(story)}> + + Edit + + handleDeleteClick(story.id)} + className="text-destructive focus:text-destructive" + > + + Delete + + +
)) @@ -169,6 +294,76 @@ export function StoryList() { + + {/* Edit Story Dialog */} + + + + Edit Story + + Update the story name and description. + + +
+
+ + setNewStoryName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + handleUpdateStory(); + } + }} + /> +
+
+ +