Implement story management features and update dependencies

- Introduced story management functionality, including creating, listing, and managing story items.
- Added new components for story display and interaction, including StoriesTab, StoryList, and StoryContent.
- Integrated drag-and-drop functionality for reordering story items using @dnd-kit.
- Updated dependencies for @dnd-kit packages to enhance drag-and-drop capabilities.
- Bumped version for @voicebox/app, @voicebox/landing, @voicebox/tauri, and @voicebox/web to 0.1.5.
- Enhanced audio playback features to support story mode with auto-play functionality.
- Improved error handling and user feedback through toast notifications in story-related actions.
This commit is contained in:
Jamie Pine
2026-01-28 19:10:58 -08:00
parent 9d5d6cb56a
commit 3204e193fa
22 changed files with 2396 additions and 29 deletions
@@ -1,6 +1,7 @@
import { AnimatePresence, motion } from 'framer-motion';
import { Loader2, Sparkles } from 'lucide-react';
import { Loader2, MessageSquare, Sparkles } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { useMatchRoute } from '@tanstack/react-router';
import { Button } from '@/components/ui/button';
import { Form, FormControl, FormField, FormItem, FormMessage } from '@/components/ui/form';
import {
@@ -13,22 +14,55 @@ import {
import { Textarea } from '@/components/ui/textarea';
import { LANGUAGE_OPTIONS } from '@/lib/constants/languages';
import { useGenerationForm } from '@/lib/hooks/useGenerationForm';
import { useProfile } from '@/lib/hooks/useProfiles';
import { useProfile, useProfiles } from '@/lib/hooks/useProfiles';
import { useStory, useAddStoryItem } from '@/lib/hooks/useStories';
import { useUIStore } from '@/stores/uiStore';
import { useStoryStore } from '@/stores/storyStore';
import { useToast } from '@/components/ui/use-toast';
import { cn } from '@/lib/utils/cn';
interface FloatingGenerateBoxProps {
isPlayerOpen: boolean;
showVoiceSelector?: boolean;
}
export function FloatingGenerateBox({ isPlayerOpen }: FloatingGenerateBoxProps) {
export function FloatingGenerateBox({ isPlayerOpen, showVoiceSelector = false }: FloatingGenerateBoxProps) {
const selectedProfileId = useUIStore((state) => state.selectedProfileId);
const setSelectedProfileId = useUIStore((state) => state.setSelectedProfileId);
const { data: selectedProfile } = useProfile(selectedProfileId || '');
const { data: profiles } = useProfiles();
const [isExpanded, setIsExpanded] = useState(false);
const [isInstructMode, setIsInstructMode] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const matchRoute = useMatchRoute();
const isStoriesRoute = matchRoute({ to: '/stories' });
const selectedStoryId = useStoryStore((state) => state.selectedStoryId);
const { data: currentStory } = useStory(selectedStoryId);
const addStoryItem = useAddStoryItem();
const { toast } = useToast();
const { form, handleSubmit, isPending } = useGenerationForm({
onSuccess: () => {
onSuccess: async (generationId) => {
setIsExpanded(false);
// If on stories route and a story is selected, add generation to story
if (isStoriesRoute && selectedStoryId && generationId) {
try {
await addStoryItem.mutateAsync({
storyId: selectedStoryId,
data: { generation_id: generationId },
});
toast({
title: 'Added to story',
description: `Generation added to "${currentStory?.name || 'story'}"`,
});
} catch (error) {
toast({
title: 'Failed to add to story',
description: error instanceof Error ? error.message : 'Could not add generation to story',
variant: 'destructive',
});
}
}
},
});
@@ -69,7 +103,12 @@ export function FloatingGenerateBox({ isPlayerOpen }: FloatingGenerateBoxProps)
return (
<motion.div
ref={containerRef}
className="fixed left-[calc(5rem+2rem)] right-auto w-[calc((100%-5rem-4rem)/2-1rem)]"
className={cn(
'fixed right-auto',
isStoriesRoute
? 'left-[calc(5rem+2rem+((100%-5rem-4rem)/2)+1.5rem)] w-[calc((100%-5rem-4rem)/2-1rem)]'
: 'left-[calc(5rem+2rem)] w-[calc((100%-5rem-4rem)/2-1rem)]',
)}
style={{
bottom: isPlayerOpen ? 'calc(7rem + 1.5rem)' : '1.5rem',
}}
@@ -85,17 +124,26 @@ export function FloatingGenerateBox({ isPlayerOpen }: FloatingGenerateBoxProps)
className="flex-1"
transition={{ duration: 0.3, ease: 'easeOut' }}
>
{isInstructMode && (
<span className="text-xs text-accent font-medium mb-1 block">
Delivery instructions:
</span>
)}
<FormField
control={form.control}
name="text"
name={isInstructMode ? 'instruct' : 'text'}
render={({ field }) => (
<FormItem>
<FormControl>
<Textarea
placeholder={
selectedProfile
? `Generate speech using ${selectedProfile.name}...`
: 'Select a voice profile above...'
isInstructMode
? 'Add delivery instructions...'
: isStoriesRoute && currentStory
? `Generate speech for "${currentStory.name}"...`
: selectedProfile
? `Generate speech using ${selectedProfile.name}...`
: 'Select a voice profile above...'
}
className="resize-none bg-transparent border-none focus-visible:ring-0 focus-visible:ring-offset-0 focus:outline-none focus:ring-0 outline-none ring-0 rounded-2xl text-sm placeholder:text-muted-foreground/60 overflow-hidden transition-all"
style={{
@@ -114,18 +162,43 @@ export function FloatingGenerateBox({ isPlayerOpen }: FloatingGenerateBoxProps)
/>
</motion.div>
<Button
type="submit"
disabled={isPending || !selectedProfileId}
className="h-10 w-10 rounded-full bg-accent hover:bg-accent/90 hover:scale-105 text-accent-foreground shadow-lg hover:shadow-accent/50 shrink-0 transition-all duration-200"
size="icon"
>
{isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Sparkles className="h-4 w-4" />
)}
</Button>
<div className="relative shrink-0">
<Button
type="submit"
disabled={isPending || !selectedProfileId}
className="h-10 w-10 rounded-full bg-accent hover:bg-accent/90 hover:scale-105 text-accent-foreground shadow-lg hover:shadow-accent/50 transition-all duration-200"
size="icon"
>
{isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Sparkles className="h-4 w-4" />
)}
</Button>
<AnimatePresence>
{isExpanded && (
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ duration: 0.2 }}
className="absolute top-0 right-[calc(100%+0.5rem)]"
>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => setIsInstructMode(!isInstructMode)}
className={`h-10 w-10 rounded-full bg-card border border-border hover:bg-background/50 transition-all duration-200 ${
isInstructMode ? 'text-accent' : ''
}`}
>
<MessageSquare className="h-4 w-4" />
</Button>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
<AnimatePresence>
@@ -137,6 +210,24 @@ export function FloatingGenerateBox({ isPlayerOpen }: FloatingGenerateBoxProps)
className=" mt-3"
>
<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>
)}
<FormField
control={form.control}
name="language"