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 { 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
+2 -19
View File
@@ -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>
);
}
+14 -9
View File
@@ -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 -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 type { StoryItemDetail } from '@/lib/api/types';
import { useStoryStore } from '@/stores/storyStore';
+10
View File
@@ -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,