mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-16 21:30:39 -07:00
Refactor story management components and enhance track editor integration
- Updated AppFrame to conditionally render StoryTrackEditor based on the selected story and route. - Modified FloatingGenerateBox to adjust its position based on the visibility of the track editor. - Improved StoriesTab by removing direct track editor rendering and relying on the new store state for height management. - Enhanced StoryContent to dynamically calculate bottom padding based on the track editor's height. - Introduced trackEditorHeight state in storyStore for better UI management of the track editor's visibility and size.
This commit is contained in:
@@ -1,18 +1,35 @@
|
||||
import { useRouterState } from '@tanstack/react-router';
|
||||
import { TitleBarDragRegion } from '@/components/TitleBarDragRegion';
|
||||
import { AudioPlayer } from '@/components/AudioPlayer/AudioPlayer';
|
||||
import { StoryTrackEditor } from '@/components/StoriesTab/StoryTrackEditor';
|
||||
import { TOP_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { useStoryStore } from '@/stores/storyStore';
|
||||
import { useStory } from '@/lib/hooks/useStories';
|
||||
|
||||
interface AppFrameProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function AppFrame({ children }: AppFrameProps) {
|
||||
const routerState = useRouterState();
|
||||
const isStoriesRoute = routerState.location.pathname === '/stories';
|
||||
|
||||
const selectedStoryId = useStoryStore((state) => state.selectedStoryId);
|
||||
const { data: story } = useStory(selectedStoryId);
|
||||
|
||||
// Show track editor when on stories route with a selected story that has items
|
||||
const showTrackEditor = isStoriesRoute && selectedStoryId && story && story.items.length > 0;
|
||||
|
||||
return (
|
||||
<div className={cn('h-screen bg-background flex flex-col overflow-hidden', TOP_SAFE_AREA_PADDING)}>
|
||||
<TitleBarDragRegion />
|
||||
{children}
|
||||
<AudioPlayer />
|
||||
{showTrackEditor ? (
|
||||
<StoryTrackEditor storyId={story.id} items={story.items} />
|
||||
) : (
|
||||
<AudioPlayer />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,11 +22,11 @@ import { useToast } from '@/components/ui/use-toast';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
interface FloatingGenerateBoxProps {
|
||||
isPlayerOpen: boolean;
|
||||
isPlayerOpen?: boolean;
|
||||
showVoiceSelector?: boolean;
|
||||
}
|
||||
|
||||
export function FloatingGenerateBox({ isPlayerOpen, showVoiceSelector = false }: FloatingGenerateBoxProps) {
|
||||
export function FloatingGenerateBox({ isPlayerOpen = false, showVoiceSelector = false }: FloatingGenerateBoxProps) {
|
||||
const selectedProfileId = useUIStore((state) => state.selectedProfileId);
|
||||
const setSelectedProfileId = useUIStore((state) => state.setSelectedProfileId);
|
||||
const { data: selectedProfile } = useProfile(selectedProfileId || '');
|
||||
@@ -37,9 +37,13 @@ export function FloatingGenerateBox({ isPlayerOpen, showVoiceSelector = false }:
|
||||
const matchRoute = useMatchRoute();
|
||||
const isStoriesRoute = matchRoute({ to: '/stories' });
|
||||
const selectedStoryId = useStoryStore((state) => state.selectedStoryId);
|
||||
const trackEditorHeight = useStoryStore((state) => state.trackEditorHeight);
|
||||
const { data: currentStory } = useStory(selectedStoryId);
|
||||
const addStoryItem = useAddStoryItem();
|
||||
const { toast } = useToast();
|
||||
|
||||
// Calculate if track editor is visible (on stories route with items)
|
||||
const hasTrackEditor = isStoriesRoute && currentStory && currentStory.items.length > 0;
|
||||
|
||||
const { form, handleSubmit, isPending } = useGenerationForm({
|
||||
onSuccess: async (generationId) => {
|
||||
@@ -110,7 +114,13 @@ export function FloatingGenerateBox({ isPlayerOpen, showVoiceSelector = false }:
|
||||
: 'left-[calc(5rem+2rem)] w-[calc((100%-5rem-4rem)/2-1rem)]',
|
||||
)}
|
||||
style={{
|
||||
bottom: isPlayerOpen ? 'calc(7rem + 1.5rem)' : '1.5rem',
|
||||
// On stories route: offset by track editor height when visible
|
||||
// On other routes: offset by audio player height when visible
|
||||
bottom: hasTrackEditor
|
||||
? `${trackEditorHeight + 24}px`
|
||||
: isPlayerOpen
|
||||
? 'calc(7rem + 1.5rem)'
|
||||
: '1.5rem',
|
||||
}}
|
||||
>
|
||||
<motion.div
|
||||
|
||||
@@ -1,18 +1,8 @@
|
||||
import { FloatingGenerateBox } from '@/components/Generation/FloatingGenerateBox';
|
||||
import { StoryContent } from './StoryContent';
|
||||
import { StoryList } from './StoryList';
|
||||
import { StoryTrackEditor } from './StoryTrackEditor';
|
||||
import { usePlayerStore } from '@/stores/playerStore';
|
||||
import { useStoryStore } from '@/stores/storyStore';
|
||||
import { useStory } from '@/lib/hooks/useStories';
|
||||
|
||||
export function StoriesTab() {
|
||||
const audioUrl = usePlayerStore((state) => state.audioUrl);
|
||||
const selectedStoryId = useStoryStore((state) => state.selectedStoryId);
|
||||
const { data: story } = useStory(selectedStoryId);
|
||||
|
||||
const hasTrackEditor = selectedStoryId && story && story.items.length > 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0 overflow-hidden">
|
||||
{/* Main content area */}
|
||||
@@ -27,16 +17,9 @@ export function StoriesTab() {
|
||||
<StoryContent />
|
||||
</div>
|
||||
|
||||
{/* Floating Generate Box */}
|
||||
<FloatingGenerateBox isPlayerOpen={!!audioUrl || !!hasTrackEditor} showVoiceSelector />
|
||||
{/* Floating Generate Box - position is managed via storyStore.trackEditorHeight */}
|
||||
<FloatingGenerateBox showVoiceSelector />
|
||||
</div>
|
||||
|
||||
{/* Track Editor - at bottom when a story with items is selected */}
|
||||
{hasTrackEditor && (
|
||||
<div className="shrink-0 mt-4 px-1">
|
||||
<StoryTrackEditor storyId={story.id} items={story.items} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,15 +17,15 @@ import { Download, Pause, Play } from 'lucide-react';
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { useStory, useRemoveStoryItem, useExportStoryAudio, useReorderStoryItems } from '@/lib/hooks/useStories';
|
||||
import { useStoryStore } from '@/stores/storyStore';
|
||||
import { usePlayerStore } from '@/stores/playerStore';
|
||||
import { SortableStoryChatItem } from './StoryChatItem';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { useStoryPlayback } from '@/lib/hooks/useStoryPlayback';
|
||||
|
||||
// Height of the floating generate box plus some padding
|
||||
const GENERATE_BOX_HEIGHT = 160;
|
||||
|
||||
export function StoryContent() {
|
||||
const selectedStoryId = useStoryStore((state) => state.selectedStoryId);
|
||||
const { data: story, isLoading } = useStory(selectedStoryId);
|
||||
@@ -34,8 +34,15 @@ export function StoryContent() {
|
||||
const exportAudio = useExportStoryAudio();
|
||||
const { toast } = useToast();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const audioUrl = usePlayerStore((state) => state.audioUrl);
|
||||
const isPlayerVisible = !!audioUrl;
|
||||
|
||||
// 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 + generate box + gap
|
||||
const bottomPadding = hasBottomBar ? trackEditorHeight + GENERATE_BOX_HEIGHT + 24 : 0;
|
||||
|
||||
// Drag and drop sensors
|
||||
const sensors = useSensors(
|
||||
@@ -261,10 +268,8 @@ export function StoryContent() {
|
||||
{/* Content */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className={cn(
|
||||
'flex-1 min-h-0 overflow-y-auto space-y-3',
|
||||
isPlayerVisible && BOTTOM_SAFE_AREA_PADDING,
|
||||
)}
|
||||
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">
|
||||
|
||||
@@ -19,14 +19,12 @@ const DEFAULT_PIXELS_PER_SECOND = 50;
|
||||
const DEFAULT_TRACKS = [1, 0, -1]; // Default 3 tracks
|
||||
const MIN_EDITOR_HEIGHT = 120;
|
||||
const MAX_EDITOR_HEIGHT = 500;
|
||||
const DEFAULT_EDITOR_HEIGHT = 200;
|
||||
|
||||
export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
||||
const [pixelsPerSecond, setPixelsPerSecond] = useState(DEFAULT_PIXELS_PER_SECOND);
|
||||
const [draggingItem, setDraggingItem] = useState<string | null>(null);
|
||||
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
|
||||
const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 });
|
||||
const [editorHeight, setEditorHeight] = useState(DEFAULT_EDITOR_HEIGHT);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const tracksRef = useRef<HTMLDivElement>(null);
|
||||
@@ -35,6 +33,10 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
||||
const moveItem = useMoveStoryItem();
|
||||
const { toast } = useToast();
|
||||
|
||||
// Track editor height from store (shared with FloatingGenerateBox)
|
||||
const editorHeight = useStoryStore((state) => state.trackEditorHeight);
|
||||
const setEditorHeight = useStoryStore((state) => state.setTrackEditorHeight);
|
||||
|
||||
// Playback state
|
||||
const currentTimeMs = useStoryStore((state) => state.currentTimeMs);
|
||||
const playbackStoryId = useStoryStore((state) => state.playbackStoryId);
|
||||
@@ -116,7 +118,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
||||
Math.max(MIN_EDITOR_HEIGHT, resizeStartHeight.current + deltaY)
|
||||
);
|
||||
setEditorHeight(newHeight);
|
||||
}, [isResizing]);
|
||||
}, [isResizing, setEditorHeight]);
|
||||
|
||||
const handleResizeEnd = useCallback(() => {
|
||||
setIsResizing(false);
|
||||
@@ -247,15 +249,12 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
||||
const timelineContainerHeight = editorHeight - 40; // Subtract toolbar height
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="h-[200px] border rounded-lg bg-card flex items-center justify-center text-muted-foreground">
|
||||
<p className="text-sm">Add audio clips to see the track editor</p>
|
||||
</div>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border rounded-lg bg-card overflow-hidden relative" ref={containerRef}>
|
||||
<div className="fixed bottom-0 left-0 right-0 border-t bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60 z-50">
|
||||
<div className="border-t bg-card overflow-hidden relative" ref={containerRef}>
|
||||
{/* Resize handle at top */}
|
||||
<button
|
||||
type="button"
|
||||
@@ -383,6 +382,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import type { StoryItemDetail } from '@/lib/api/types';
|
||||
import { useStoryStore } from '@/stores/storyStore';
|
||||
|
||||
@@ -6,6 +6,10 @@ interface StoryPlaybackState {
|
||||
selectedStoryId: string | null;
|
||||
setSelectedStoryId: (id: string | null) => void;
|
||||
|
||||
// Track editor UI state
|
||||
trackEditorHeight: number;
|
||||
setTrackEditorHeight: (height: number) => void;
|
||||
|
||||
// Playback state
|
||||
isPlaying: boolean;
|
||||
currentTimeMs: number;
|
||||
@@ -24,11 +28,17 @@ interface StoryPlaybackState {
|
||||
setPlaybackTiming: (contextTime: number, storyTime: number) => void; // Set timing anchors for Web Audio API
|
||||
}
|
||||
|
||||
const DEFAULT_TRACK_EDITOR_HEIGHT = 200;
|
||||
|
||||
export const useStoryStore = create<StoryPlaybackState>((set, get) => ({
|
||||
// Selection
|
||||
selectedStoryId: null,
|
||||
setSelectedStoryId: (id) => set({ selectedStoryId: id }),
|
||||
|
||||
// Track editor UI state
|
||||
trackEditorHeight: DEFAULT_TRACK_EDITOR_HEIGHT,
|
||||
setTrackEditorHeight: (height) => set({ trackEditorHeight: height }),
|
||||
|
||||
// Playback state
|
||||
isPlaying: false,
|
||||
currentTimeMs: 0,
|
||||
|
||||
Reference in New Issue
Block a user