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:
Jamie Pine
2026-01-28 19:50:46 -08:00
parent 1cf90c81dd
commit 232d231788
7 changed files with 67 additions and 42 deletions
+18 -1
View File
@@ -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
+2 -19
View File
@@ -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>
); );
} }
+14 -9
View File
@@ -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 -1
View File
@@ -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';
+10
View File
@@ -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,