mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-20 15:20:39 -07:00
Add version control to track editor, restyle story list
- Story items can be pinned to a specific generation version via toolbar dropdown (shows when clip is selected and has >1 version) - version_id column on story_items with migration, validated against the generation's versions before saving - Split/duplicate preserve the source clip's pinned version - Export and playback resolve version-specific audio paths - Extracted _build_item_detail helper in stories.py (DRY cleanup) - Story list restyled from rounded cards to flat rows with rounded hover/active states, gradient header fade, and dynamic bottom padding that accounts for track editor + generate box
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { Plus, BookOpen, MoreHorizontal, Pencil, Trash2 } from 'lucide-react';
|
||||
import { BookOpen, MoreHorizontal, Pencil, Plus, Trash2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -29,7 +29,13 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { useStories, useCreateStory, useUpdateStory, useDeleteStory } from '@/lib/hooks/useStories';
|
||||
import {
|
||||
useCreateStory,
|
||||
useDeleteStory,
|
||||
useStories,
|
||||
useStory,
|
||||
useUpdateStory,
|
||||
} from '@/lib/hooks/useStories';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { formatDate } from '@/lib/utils/format';
|
||||
import { useStoryStore } from '@/stores/storyStore';
|
||||
@@ -38,6 +44,8 @@ export function StoryList() {
|
||||
const { data: stories, isLoading } = useStories();
|
||||
const selectedStoryId = useStoryStore((state) => state.selectedStoryId);
|
||||
const setSelectedStoryId = useStoryStore((state) => state.setSelectedStoryId);
|
||||
const trackEditorHeight = useStoryStore((state) => state.trackEditorHeight);
|
||||
const { data: selectedStory } = useStory(selectedStoryId);
|
||||
const createStory = useCreateStory();
|
||||
const updateStory = useUpdateStory();
|
||||
const deleteStory = useDeleteStory();
|
||||
@@ -170,20 +178,29 @@ export function StoryList() {
|
||||
}
|
||||
|
||||
const storyList = stories || [];
|
||||
const hasTrackEditor = selectedStoryId && selectedStory && selectedStory.items.length > 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4 px-1">
|
||||
<h2 className="text-2xl font-bold">Stories</h2>
|
||||
<Button onClick={() => setCreateDialogOpen(true)} size="sm">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
New Story
|
||||
</Button>
|
||||
<div className="h-full flex flex-col relative overflow-hidden">
|
||||
{/* Scroll Mask */}
|
||||
<div className="absolute top-0 left-0 right-0 h-16 bg-gradient-to-b from-background to-transparent z-10 pointer-events-none" />
|
||||
|
||||
{/* Fixed Header */}
|
||||
<div className="absolute top-0 left-0 right-0 z-20">
|
||||
<div className="flex items-center justify-between mb-4 px-1">
|
||||
<h2 className="text-2xl font-bold">Stories</h2>
|
||||
<Button onClick={() => setCreateDialogOpen(true)} size="sm">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
New Story
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Story List */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto space-y-2">
|
||||
{/* Scrollable Story List */}
|
||||
<div
|
||||
className="flex-1 overflow-y-auto pt-14 relative z-0"
|
||||
style={{ paddingBottom: hasTrackEditor ? `${trackEditorHeight + 140}px` : '170px' }}
|
||||
>
|
||||
{storyList.length === 0 ? (
|
||||
<div className="text-center py-12 px-5 border-2 border-dashed border-muted rounded-2xl text-muted-foreground">
|
||||
<BookOpen className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
@@ -191,75 +208,68 @@ export function StoryList() {
|
||||
<p className="text-xs mt-2">Create your first story to get started</p>
|
||||
</div>
|
||||
) : (
|
||||
storyList.map((story) => (
|
||||
<div
|
||||
key={story.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
'h-24 p-4 border rounded-2xl transition-colors group flex items-center cursor-pointer',
|
||||
selectedStoryId === story.id && 'bg-muted border-primary',
|
||||
)}
|
||||
aria-label={
|
||||
selectedStoryId === story.id
|
||||
? `Story ${story.name}, ${story.item_count} ${story.item_count === 1 ? 'item' : 'items'}, ${formatDate(story.updated_at)}. Selected. Press Enter to select.`
|
||||
: `Story ${story.name}, ${story.item_count} ${story.item_count === 1 ? 'item' : 'items'}, ${formatDate(story.updated_at)}. Press Enter to select.`
|
||||
}
|
||||
aria-pressed={selectedStoryId === story.id}
|
||||
onClick={() => setSelectedStoryId(story.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.target !== e.currentTarget) return;
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setSelectedStoryId(story.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2 w-full min-w-0">
|
||||
<div className="flex-1 min-w-0 text-left overflow-hidden">
|
||||
<h3 className="font-medium truncate">{story.name}</h3>
|
||||
{story.description && (
|
||||
<p className="text-sm text-muted-foreground mt-1 truncate">
|
||||
{story.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-3 mt-2 text-xs text-muted-foreground">
|
||||
<span>
|
||||
{story.item_count} {story.item_count === 1 ? 'item' : 'items'}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>{formatDate(story.updated_at)}</span>
|
||||
<div className="space-y-0.5">
|
||||
{storyList.map((story) => (
|
||||
<div
|
||||
key={story.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
'px-5 py-3 rounded-lg transition-colors group flex items-center cursor-pointer',
|
||||
selectedStoryId === story.id ? 'bg-muted' : 'hover:bg-muted/50',
|
||||
)}
|
||||
aria-label={`Story ${story.name}, ${story.item_count} ${story.item_count === 1 ? 'item' : 'items'}, ${formatDate(story.updated_at)}`}
|
||||
aria-pressed={selectedStoryId === story.id}
|
||||
onClick={() => setSelectedStoryId(story.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.target !== e.currentTarget) return;
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setSelectedStoryId(story.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2 w-full min-w-0">
|
||||
<div className="flex-1 min-w-0 text-left overflow-hidden">
|
||||
<h3 className="text-sm font-medium truncate">{story.name}</h3>
|
||||
<div className="flex items-center gap-2 mt-1 text-xs text-muted-foreground">
|
||||
<span>
|
||||
{story.item_count} {story.item_count === 1 ? 'item' : 'items'}
|
||||
</span>
|
||||
<span>·</span>
|
||||
<span>{formatDate(story.updated_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label={`Actions for ${story.name}`}
|
||||
>
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
</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>
|
||||
<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()}
|
||||
aria-label={`Actions for ${story.name}`}
|
||||
>
|
||||
<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>
|
||||
))
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import {
|
||||
Check,
|
||||
Copy,
|
||||
GalleryVerticalEnd,
|
||||
GripHorizontal,
|
||||
Minus,
|
||||
Pause,
|
||||
@@ -12,6 +14,12 @@ import {
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import WaveSurfer from 'wavesurfer.js';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import type { StoryItemDetail } from '@/lib/api/types';
|
||||
@@ -19,6 +27,7 @@ import {
|
||||
useDuplicateStoryItem,
|
||||
useMoveStoryItem,
|
||||
useRemoveStoryItem,
|
||||
useSetStoryItemVersion,
|
||||
useSplitStoryItem,
|
||||
useTrimStoryItem,
|
||||
} from '@/lib/hooks/useStories';
|
||||
@@ -28,12 +37,14 @@ import { useStoryStore } from '@/stores/storyStore';
|
||||
// Clip waveform component with trim support
|
||||
function ClipWaveform({
|
||||
generationId,
|
||||
versionId,
|
||||
width,
|
||||
trimStartMs,
|
||||
trimEndMs,
|
||||
duration,
|
||||
}: {
|
||||
generationId: string;
|
||||
versionId?: string;
|
||||
width: number;
|
||||
trimStartMs: number;
|
||||
trimEndMs: number;
|
||||
@@ -79,7 +90,9 @@ function ClipWaveform({
|
||||
|
||||
wavesurferRef.current = wavesurfer;
|
||||
|
||||
const audioUrl = apiClient.getAudioUrl(generationId);
|
||||
const audioUrl = versionId
|
||||
? apiClient.getVersionAudioUrl(versionId)
|
||||
: apiClient.getAudioUrl(generationId);
|
||||
wavesurfer.load(audioUrl).catch(() => {
|
||||
// Ignore load errors
|
||||
});
|
||||
@@ -88,7 +101,7 @@ function ClipWaveform({
|
||||
wavesurfer.destroy();
|
||||
wavesurferRef.current = null;
|
||||
};
|
||||
}, [generationId, fullWaveformWidth]);
|
||||
}, [generationId, versionId, fullWaveformWidth]);
|
||||
|
||||
return (
|
||||
<div className="w-full h-full opacity-60 overflow-hidden">
|
||||
@@ -135,12 +148,57 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
||||
const splitItem = useSplitStoryItem();
|
||||
const duplicateItem = useDuplicateStoryItem();
|
||||
const removeItem = useRemoveStoryItem();
|
||||
const setItemVersion = useSetStoryItemVersion();
|
||||
const { toast } = useToast();
|
||||
|
||||
// Selection state
|
||||
const selectedClipId = useStoryStore((state) => state.selectedClipId);
|
||||
const setSelectedClipId = useStoryStore((state) => state.setSelectedClipId);
|
||||
|
||||
// Selected clip item (for version picker)
|
||||
const selectedItem = useMemo(
|
||||
() => (selectedClipId ? items.find((i) => i.id === selectedClipId) : undefined),
|
||||
[selectedClipId, items],
|
||||
);
|
||||
const selectedItemVersions = selectedItem?.versions;
|
||||
const hasMultipleVersions = selectedItemVersions && selectedItemVersions.length > 1;
|
||||
|
||||
// Determine which version label is active for the selected clip
|
||||
const activeVersionLabel = useMemo(() => {
|
||||
if (!selectedItem || !selectedItemVersions) return null;
|
||||
// If the item has a pinned version_id, find its label
|
||||
if (selectedItem.version_id) {
|
||||
const pinned = selectedItemVersions.find((v) => v.id === selectedItem.version_id);
|
||||
return pinned?.label ?? null;
|
||||
}
|
||||
// Otherwise use the generation's default version
|
||||
const defaultVersion = selectedItemVersions.find((v) => v.is_default);
|
||||
return defaultVersion?.label ?? null;
|
||||
}, [selectedItem, selectedItemVersions]);
|
||||
|
||||
const handleSetVersion = useCallback(
|
||||
(versionId: string | null) => {
|
||||
if (!selectedClipId) return;
|
||||
setItemVersion.mutate(
|
||||
{
|
||||
storyId,
|
||||
itemId: selectedClipId,
|
||||
data: { version_id: versionId },
|
||||
},
|
||||
{
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: 'Failed to set version',
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
[selectedClipId, storyId, setItemVersion, toast],
|
||||
);
|
||||
|
||||
// Trim state
|
||||
const [trimmingItem, setTrimmingItem] = useState<string | null>(null);
|
||||
const [trimSide, setTrimSide] = useState<'start' | 'end' | null>(null);
|
||||
@@ -788,6 +846,49 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
{hasMultipleVersions && (
|
||||
<>
|
||||
<div className="w-px h-4 bg-border mx-1" />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-7 gap-1.5 px-2 text-xs"
|
||||
title="Change version/take"
|
||||
>
|
||||
<GalleryVerticalEnd className="h-3.5 w-3.5" />
|
||||
<span className="max-w-[80px] truncate">
|
||||
{activeVersionLabel ?? 'default'}
|
||||
</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="center" className="min-w-[160px]">
|
||||
{selectedItemVersions.map((version) => {
|
||||
const isActive = selectedItem?.version_id
|
||||
? version.id === selectedItem.version_id
|
||||
: version.is_default;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={version.id}
|
||||
onClick={() => handleSetVersion(version.id)}
|
||||
className="gap-2 text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn('h-3 w-3', isActive ? 'opacity-100' : 'opacity-0')}
|
||||
/>
|
||||
<span className="truncate">{version.label}</span>
|
||||
{version.effects_chain && version.effects_chain.length > 0 && (
|
||||
<span className="text-muted-foreground ml-auto text-[10px]">
|
||||
{version.effects_chain.length} fx
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -958,6 +1059,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
||||
<div className="absolute inset-0 top-3">
|
||||
<ClipWaveform
|
||||
generationId={item.generation_id}
|
||||
versionId={item.version_id}
|
||||
width={clipWidth}
|
||||
trimStartMs={displayTrimStart}
|
||||
trimEndMs={displayTrimEnd}
|
||||
|
||||
Reference in New Issue
Block a user