mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-18 06:10:43 -07:00
Enhance FloatingGenerateBox and StoryContent with new features and improved UI
- Refactored FloatingGenerateBox to improve layout and ensure consistent styling for the voice selector. - Added a popover component to StoryContent for adding generations, including search functionality for better user experience. - Implemented story item editing and deletion capabilities in StoryList, enhancing story management features. - Updated import statements and added new hooks for better organization and functionality across components.
This commit is contained in:
@@ -222,28 +222,30 @@ export function FloatingGenerateBox({ isPlayerOpen = false, showVoiceSelector =
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{showVoiceSelector && (
|
||||
<Select
|
||||
value={selectedProfileId || ''}
|
||||
onValueChange={(value) => setSelectedProfileId(value || null)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs bg-card border-border rounded-full hover:bg-background/50 transition-all flex-1">
|
||||
<SelectValue placeholder="Select a voice..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{profiles?.map((profile) => (
|
||||
<SelectItem key={profile.id} value={profile.id} className="text-xs">
|
||||
{profile.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex-1">
|
||||
<Select
|
||||
value={selectedProfileId || ''}
|
||||
onValueChange={(value) => setSelectedProfileId(value || null)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs bg-card border-border rounded-full hover:bg-background/50 transition-all w-full">
|
||||
<SelectValue placeholder="Select a voice..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{profiles?.map((profile) => (
|
||||
<SelectItem key={profile.id} value={profile.id} className="text-xs">
|
||||
{profile.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="language"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormItem className="flex-1 space-y-0">
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger className="h-8 text-xs bg-card border-border rounded-full hover:bg-background/50 transition-all">
|
||||
@@ -267,7 +269,7 @@ export function FloatingGenerateBox({ isPlayerOpen = false, showVoiceSelector =
|
||||
control={form.control}
|
||||
name="modelSize"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormItem className="flex-1 space-y-0">
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger className="h-8 text-xs bg-card border-border rounded-full hover:bg-background/50 transition-all">
|
||||
|
||||
@@ -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<HTMLDivElement>(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 (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
@@ -223,17 +270,60 @@ export function StoryContent() {
|
||||
<p className="text-sm text-muted-foreground mt-1">{story.description}</p>
|
||||
)}
|
||||
</div>
|
||||
{story.items.length > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleExportAudio}
|
||||
disabled={exportAudio.isPending}
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Export Audio
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Popover open={isAddOpen} onOpenChange={setIsAddOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80 p-0" align="end">
|
||||
<div className="p-2 border-b">
|
||||
<Input
|
||||
placeholder="Search by name or transcript..."
|
||||
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
|
||||
? 'No matching generations found'
|
||||
: 'No available generations'}
|
||||
</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" />
|
||||
Export Audio
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
|
||||
@@ -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<string | null>(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 (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
@@ -99,16 +196,19 @@ export function StoryList() {
|
||||
<div
|
||||
key={story.id}
|
||||
className={cn(
|
||||
'p-4 border rounded-md cursor-pointer hover:bg-muted/50 transition-colors',
|
||||
'h-24 p-4 border rounded-md transition-colors group flex items-center',
|
||||
selectedStoryId === story.id && 'bg-muted border-primary',
|
||||
)}
|
||||
onClick={() => setSelectedStoryId(story.id)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2 w-full min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 min-w-0 text-left cursor-pointer overflow-hidden"
|
||||
onClick={() => setSelectedStoryId(story.id)}
|
||||
>
|
||||
<h3 className="font-medium truncate">{story.name}</h3>
|
||||
{story.description && (
|
||||
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">
|
||||
<p className="text-sm text-muted-foreground mt-1 truncate">
|
||||
{story.description}
|
||||
</p>
|
||||
)}
|
||||
@@ -117,7 +217,32 @@ export function StoryList() {
|
||||
<span>•</span>
|
||||
<span>{formatDate(story.updated_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => handleEditClick(story)}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDeleteClick(story.id)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
@@ -169,6 +294,76 @@ export function StoryList() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Edit Story Dialog */}
|
||||
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Story</DialogTitle>
|
||||
<DialogDescription>
|
||||
Update the story name and description.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-story-name">Name</Label>
|
||||
<Input
|
||||
id="edit-story-name"
|
||||
placeholder="My Story"
|
||||
value={newStoryName}
|
||||
onChange={(e) => setNewStoryName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleUpdateStory();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-story-description">Description (optional)</Label>
|
||||
<Textarea
|
||||
id="edit-story-description"
|
||||
placeholder="A conversation between..."
|
||||
value={newStoryDescription}
|
||||
onChange={(e) => setNewStoryDescription(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setEditDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleUpdateStory} disabled={updateStory.isPending}>
|
||||
{updateStory.isPending ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Story Confirmation Dialog */}
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete the story and all its items. This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction asChild>
|
||||
<Button
|
||||
onClick={handleDeleteConfirm}
|
||||
disabled={deleteStory.isPending}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{deleteStory.isPending ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import * as PopoverPrimitive from '@radix-ui/react-popover';
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
const Popover = PopoverPrimitive.Root;
|
||||
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger;
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
));
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent };
|
||||
Reference in New Issue
Block a user