mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-29 15:15:27 -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 { TitleBarDragRegion } from '@/components/TitleBarDragRegion';
|
||||||
import { AudioPlayer } from '@/components/AudioPlayer/AudioPlayer';
|
import { AudioPlayer } from '@/components/AudioPlayer/AudioPlayer';
|
||||||
|
import { StoryTrackEditor } from '@/components/StoriesTab/StoryTrackEditor';
|
||||||
import { TOP_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
import { TOP_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
import { useStoryStore } from '@/stores/storyStore';
|
||||||
|
import { useStory } from '@/lib/hooks/useStories';
|
||||||
|
|
||||||
interface AppFrameProps {
|
interface AppFrameProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppFrame({ children }: AppFrameProps) {
|
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 (
|
return (
|
||||||
<div className={cn('h-screen bg-background flex flex-col overflow-hidden', TOP_SAFE_AREA_PADDING)}>
|
<div className={cn('h-screen bg-background flex flex-col overflow-hidden', TOP_SAFE_AREA_PADDING)}>
|
||||||
<TitleBarDragRegion />
|
<TitleBarDragRegion />
|
||||||
{children}
|
{children}
|
||||||
<AudioPlayer />
|
{showTrackEditor ? (
|
||||||
|
<StoryTrackEditor storyId={story.id} items={story.items} />
|
||||||
|
) : (
|
||||||
|
<AudioPlayer />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,11 +22,11 @@ import { useToast } from '@/components/ui/use-toast';
|
|||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
|
||||||
interface FloatingGenerateBoxProps {
|
interface FloatingGenerateBoxProps {
|
||||||
isPlayerOpen: boolean;
|
isPlayerOpen?: boolean;
|
||||||
showVoiceSelector?: 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 selectedProfileId = useUIStore((state) => state.selectedProfileId);
|
||||||
const setSelectedProfileId = useUIStore((state) => state.setSelectedProfileId);
|
const setSelectedProfileId = useUIStore((state) => state.setSelectedProfileId);
|
||||||
const { data: selectedProfile } = useProfile(selectedProfileId || '');
|
const { data: selectedProfile } = useProfile(selectedProfileId || '');
|
||||||
@@ -37,9 +37,13 @@ export function FloatingGenerateBox({ isPlayerOpen, showVoiceSelector = false }:
|
|||||||
const matchRoute = useMatchRoute();
|
const matchRoute = useMatchRoute();
|
||||||
const isStoriesRoute = matchRoute({ to: '/stories' });
|
const isStoriesRoute = matchRoute({ to: '/stories' });
|
||||||
const selectedStoryId = useStoryStore((state) => state.selectedStoryId);
|
const selectedStoryId = useStoryStore((state) => state.selectedStoryId);
|
||||||
|
const trackEditorHeight = useStoryStore((state) => state.trackEditorHeight);
|
||||||
const { data: currentStory } = useStory(selectedStoryId);
|
const { data: currentStory } = useStory(selectedStoryId);
|
||||||
const addStoryItem = useAddStoryItem();
|
const addStoryItem = useAddStoryItem();
|
||||||
const { toast } = useToast();
|
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({
|
const { form, handleSubmit, isPending } = useGenerationForm({
|
||||||
onSuccess: async (generationId) => {
|
onSuccess: async (generationId) => {
|
||||||
@@ -110,7 +114,13 @@ export function FloatingGenerateBox({ isPlayerOpen, showVoiceSelector = false }:
|
|||||||
: 'left-[calc(5rem+2rem)] w-[calc((100%-5rem-4rem)/2-1rem)]',
|
: 'left-[calc(5rem+2rem)] w-[calc((100%-5rem-4rem)/2-1rem)]',
|
||||||
)}
|
)}
|
||||||
style={{
|
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
|
<motion.div
|
||||||
|
|||||||
@@ -1,18 +1,8 @@
|
|||||||
import { FloatingGenerateBox } from '@/components/Generation/FloatingGenerateBox';
|
import { FloatingGenerateBox } from '@/components/Generation/FloatingGenerateBox';
|
||||||
import { StoryContent } from './StoryContent';
|
import { StoryContent } from './StoryContent';
|
||||||
import { StoryList } from './StoryList';
|
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() {
|
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 (
|
return (
|
||||||
<div className="flex flex-col h-full min-h-0 overflow-hidden">
|
<div className="flex flex-col h-full min-h-0 overflow-hidden">
|
||||||
{/* Main content area */}
|
{/* Main content area */}
|
||||||
@@ -27,16 +17,9 @@ export function StoriesTab() {
|
|||||||
<StoryContent />
|
<StoryContent />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Floating Generate Box */}
|
{/* Floating Generate Box - position is managed via storyStore.trackEditorHeight */}
|
||||||
<FloatingGenerateBox isPlayerOpen={!!audioUrl || !!hasTrackEditor} showVoiceSelector />
|
<FloatingGenerateBox showVoiceSelector />
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,15 +17,15 @@ import { Download, Pause, Play } from 'lucide-react';
|
|||||||
import { useMemo, useRef } from 'react';
|
import { useMemo, useRef } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
|
||||||
import { Slider } from '@/components/ui/slider';
|
import { Slider } from '@/components/ui/slider';
|
||||||
import { useStory, useRemoveStoryItem, useExportStoryAudio, useReorderStoryItems } from '@/lib/hooks/useStories';
|
import { useStory, useRemoveStoryItem, useExportStoryAudio, useReorderStoryItems } from '@/lib/hooks/useStories';
|
||||||
import { useStoryStore } from '@/stores/storyStore';
|
import { useStoryStore } from '@/stores/storyStore';
|
||||||
import { usePlayerStore } from '@/stores/playerStore';
|
|
||||||
import { SortableStoryChatItem } from './StoryChatItem';
|
import { SortableStoryChatItem } from './StoryChatItem';
|
||||||
import { cn } from '@/lib/utils/cn';
|
|
||||||
import { useStoryPlayback } from '@/lib/hooks/useStoryPlayback';
|
import { useStoryPlayback } from '@/lib/hooks/useStoryPlayback';
|
||||||
|
|
||||||
|
// Height of the floating generate box plus some padding
|
||||||
|
const GENERATE_BOX_HEIGHT = 160;
|
||||||
|
|
||||||
export function StoryContent() {
|
export function StoryContent() {
|
||||||
const selectedStoryId = useStoryStore((state) => state.selectedStoryId);
|
const selectedStoryId = useStoryStore((state) => state.selectedStoryId);
|
||||||
const { data: story, isLoading } = useStory(selectedStoryId);
|
const { data: story, isLoading } = useStory(selectedStoryId);
|
||||||
@@ -34,8 +34,15 @@ export function StoryContent() {
|
|||||||
const exportAudio = useExportStoryAudio();
|
const exportAudio = useExportStoryAudio();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
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
|
// Drag and drop sensors
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
@@ -261,10 +268,8 @@ export function StoryContent() {
|
|||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div
|
<div
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
className={cn(
|
className="flex-1 min-h-0 overflow-y-auto space-y-3"
|
||||||
'flex-1 min-h-0 overflow-y-auto space-y-3',
|
style={{ paddingBottom: bottomPadding > 0 ? `${bottomPadding}px` : undefined }}
|
||||||
isPlayerVisible && BOTTOM_SAFE_AREA_PADDING,
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{sortedItems.length === 0 ? (
|
{sortedItems.length === 0 ? (
|
||||||
<div className="text-center py-12 px-5 border-2 border-dashed border-muted rounded-md text-muted-foreground">
|
<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 DEFAULT_TRACKS = [1, 0, -1]; // Default 3 tracks
|
||||||
const MIN_EDITOR_HEIGHT = 120;
|
const MIN_EDITOR_HEIGHT = 120;
|
||||||
const MAX_EDITOR_HEIGHT = 500;
|
const MAX_EDITOR_HEIGHT = 500;
|
||||||
const DEFAULT_EDITOR_HEIGHT = 200;
|
|
||||||
|
|
||||||
export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
||||||
const [pixelsPerSecond, setPixelsPerSecond] = useState(DEFAULT_PIXELS_PER_SECOND);
|
const [pixelsPerSecond, setPixelsPerSecond] = useState(DEFAULT_PIXELS_PER_SECOND);
|
||||||
const [draggingItem, setDraggingItem] = useState<string | null>(null);
|
const [draggingItem, setDraggingItem] = useState<string | null>(null);
|
||||||
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
|
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
|
||||||
const [dragPosition, setDragPosition] = 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 [isResizing, setIsResizing] = useState(false);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const tracksRef = useRef<HTMLDivElement>(null);
|
const tracksRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -35,6 +33,10 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
const moveItem = useMoveStoryItem();
|
const moveItem = useMoveStoryItem();
|
||||||
const { toast } = useToast();
|
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
|
// Playback state
|
||||||
const currentTimeMs = useStoryStore((state) => state.currentTimeMs);
|
const currentTimeMs = useStoryStore((state) => state.currentTimeMs);
|
||||||
const playbackStoryId = useStoryStore((state) => state.playbackStoryId);
|
const playbackStoryId = useStoryStore((state) => state.playbackStoryId);
|
||||||
@@ -116,7 +118,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
Math.max(MIN_EDITOR_HEIGHT, resizeStartHeight.current + deltaY)
|
Math.max(MIN_EDITOR_HEIGHT, resizeStartHeight.current + deltaY)
|
||||||
);
|
);
|
||||||
setEditorHeight(newHeight);
|
setEditorHeight(newHeight);
|
||||||
}, [isResizing]);
|
}, [isResizing, setEditorHeight]);
|
||||||
|
|
||||||
const handleResizeEnd = useCallback(() => {
|
const handleResizeEnd = useCallback(() => {
|
||||||
setIsResizing(false);
|
setIsResizing(false);
|
||||||
@@ -247,15 +249,12 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
const timelineContainerHeight = editorHeight - 40; // Subtract toolbar height
|
const timelineContainerHeight = editorHeight - 40; // Subtract toolbar height
|
||||||
|
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
return (
|
return null;
|
||||||
<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 (
|
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 */}
|
{/* Resize handle at top */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -383,6 +382,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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 { apiClient } from '@/lib/api/client';
|
||||||
import type { StoryItemDetail } from '@/lib/api/types';
|
import type { StoryItemDetail } from '@/lib/api/types';
|
||||||
import { useStoryStore } from '@/stores/storyStore';
|
import { useStoryStore } from '@/stores/storyStore';
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ interface StoryPlaybackState {
|
|||||||
selectedStoryId: string | null;
|
selectedStoryId: string | null;
|
||||||
setSelectedStoryId: (id: string | null) => void;
|
setSelectedStoryId: (id: string | null) => void;
|
||||||
|
|
||||||
|
// Track editor UI state
|
||||||
|
trackEditorHeight: number;
|
||||||
|
setTrackEditorHeight: (height: number) => void;
|
||||||
|
|
||||||
// Playback state
|
// Playback state
|
||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
currentTimeMs: number;
|
currentTimeMs: number;
|
||||||
@@ -24,11 +28,17 @@ interface StoryPlaybackState {
|
|||||||
setPlaybackTiming: (contextTime: number, storyTime: number) => void; // Set timing anchors for Web Audio API
|
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) => ({
|
export const useStoryStore = create<StoryPlaybackState>((set, get) => ({
|
||||||
// Selection
|
// Selection
|
||||||
selectedStoryId: null,
|
selectedStoryId: null,
|
||||||
setSelectedStoryId: (id) => set({ selectedStoryId: id }),
|
setSelectedStoryId: (id) => set({ selectedStoryId: id }),
|
||||||
|
|
||||||
|
// Track editor UI state
|
||||||
|
trackEditorHeight: DEFAULT_TRACK_EDITOR_HEIGHT,
|
||||||
|
setTrackEditorHeight: (height) => set({ trackEditorHeight: height }),
|
||||||
|
|
||||||
// Playback state
|
// Playback state
|
||||||
isPlaying: false,
|
isPlaying: false,
|
||||||
currentTimeMs: 0,
|
currentTimeMs: 0,
|
||||||
|
|||||||
Reference in New Issue
Block a user