Refactor FloatingGenerateBox and StoriesTab for improved layout and interaction

- Adjusted FloatingGenerateBox positioning to align with the story list, ensuring consistent UI across different routes.
- Modified StoriesTab layout to enhance responsiveness, including setting a maximum width for the story list and adjusting the right column for better content display.
- Streamlined StoryChatItem interaction by simplifying the play functionality, allowing double-click to trigger playback directly from the text area.
- Enhanced StoryContent component by cleaning up unused playback controls and improving overall structure for better readability.
This commit is contained in:
Jamie Pine
2026-01-28 20:31:17 -08:00
parent c4884a0443
commit cd82ed0664
6 changed files with 224 additions and 199 deletions
@@ -110,7 +110,8 @@ export function FloatingGenerateBox({ isPlayerOpen = false, showVoiceSelector =
className={cn( className={cn(
'fixed right-auto', 'fixed right-auto',
isStoriesRoute isStoriesRoute
? 'left-[calc(5rem+2rem+((100%-5rem-4rem)/2)+1.5rem)] w-[calc((100%-5rem-4rem)/2-1rem)]' // Position aligned with story list: after sidebar + padding, width 360px
? 'left-[calc(5rem+2rem)] w-[360px]'
: 'left-[calc(5rem+2rem)] w-[calc((100%-5rem-4rem)/2-1rem)]', : 'left-[calc(5rem+2rem)] w-[calc((100%-5rem-4rem)/2-1rem)]',
)} )}
style={{ style={{
+3 -3
View File
@@ -6,14 +6,14 @@ export function StoriesTab() {
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 */}
<div className="flex-1 min-h-0 grid grid-cols-1 lg:grid-cols-2 gap-6 overflow-hidden relative"> <div className="flex-1 min-h-0 flex gap-6 overflow-hidden relative">
{/* Left Column - Story List */} {/* Left Column - Story List */}
<div className="flex flex-col min-h-0 overflow-hidden"> <div className="flex flex-col min-h-0 overflow-hidden w-full max-w-[360px] shrink-0">
<StoryList /> <StoryList />
</div> </div>
{/* Right Column - Story Content */} {/* Right Column - Story Content */}
<div className="flex flex-col min-h-0 overflow-hidden"> <div className="flex flex-col min-h-0 overflow-hidden flex-1">
<StoryContent /> <StoryContent />
</div> </div>
@@ -88,24 +88,12 @@ export function StoryChatItem({
{formatTime(itemStartMs)} {formatTime(itemStartMs)}
</span> </span>
</div> </div>
<button <Textarea
type="button" value={item.text}
className="w-full text-left cursor-pointer" className="flex-1 resize-none text-sm text-muted-foreground select-text bg-card cursor-text"
onClick={(e) => { readOnly
// Don't trigger play if clicking on textarea or if text is selected onDoubleClick={handlePlay}
const target = e.target as HTMLElement; />
if (target.closest('textarea') || window.getSelection()?.toString()) {
return;
}
handlePlay();
}}
>
<Textarea
value={item.text}
className="flex-1 resize-none text-sm text-muted-foreground select-text pointer-events-none bg-card"
readOnly
/>
</button>
</div> </div>
{/* Actions */} {/* Actions */}
+21 -97
View File
@@ -1,11 +1,11 @@
import { import {
DndContext,
closestCenter, closestCenter,
DndContext,
type DragEndEvent,
KeyboardSensor, KeyboardSensor,
PointerSensor, PointerSensor,
useSensor, useSensor,
useSensors, useSensors,
type DragEndEvent,
} from '@dnd-kit/core'; } from '@dnd-kit/core';
import { import {
arrayMove, arrayMove,
@@ -13,16 +13,15 @@ import {
sortableKeyboardCoordinates, sortableKeyboardCoordinates,
verticalListSortingStrategy, verticalListSortingStrategy,
} from '@dnd-kit/sortable'; } from '@dnd-kit/sortable';
import { Download, Pause, Play } from 'lucide-react'; import { Download } from 'lucide-react';
import { useEffect, useMemo, useRef } from 'react'; import { useEffect, useMemo, useRef } from 'react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Slider } from '@/components/ui/slider';
import { useToast } from '@/components/ui/use-toast'; import { useToast } from '@/components/ui/use-toast';
import { import {
useStory,
useRemoveStoryItem,
useExportStoryAudio, useExportStoryAudio,
useRemoveStoryItem,
useReorderStoryItems, useReorderStoryItems,
useStory,
} from '@/lib/hooks/useStories'; } from '@/lib/hooks/useStories';
import { useStoryPlayback } from '@/lib/hooks/useStoryPlayback'; import { useStoryPlayback } from '@/lib/hooks/useStoryPlayback';
import { useStoryStore } from '@/stores/storyStore'; import { useStoryStore } from '@/stores/storyStore';
@@ -61,15 +60,10 @@ export function StoryContent() {
}), }),
); );
// Playback state // Playback state (for auto-scroll and item highlighting)
const isPlaying = useStoryStore((state) => state.isPlaying); const isPlaying = useStoryStore((state) => state.isPlaying);
const currentTimeMs = useStoryStore((state) => state.currentTimeMs); const currentTimeMs = useStoryStore((state) => state.currentTimeMs);
const totalDurationMs = useStoryStore((state) => state.totalDurationMs);
const playbackStoryId = useStoryStore((state) => state.playbackStoryId); const playbackStoryId = useStoryStore((state) => state.playbackStoryId);
const play = useStoryStore((state) => state.play);
const pause = useStoryStore((state) => state.pause);
const stop = useStoryStore((state) => state.stop);
const seek = useStoryStore((state) => state.seek);
// Refs for auto-scrolling to playing item // Refs for auto-scrolling to playing item
const itemRefsMap = useRef<Map<string, HTMLDivElement>>(new Map()); const itemRefsMap = useRef<Map<string, HTMLDivElement>>(new Map());
@@ -169,32 +163,6 @@ export function StoryContent() {
); );
}; };
const handlePlayPause = () => {
if (!story || story.items.length === 0) return;
if (isPlaying && playbackStoryId === story.id) {
pause();
} else {
play(story.id, sortedItems);
}
};
const handleStop = () => {
stop();
};
const handleSeek = (value: number[]) => {
seek(value[0]);
};
const formatTime = (ms: number): string => {
const totalSeconds = Math.floor(ms / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
const milliseconds = Math.floor((ms % 1000) / 100);
return `${minutes}:${seconds.toString().padStart(2, '0')}.${milliseconds}`;
};
const handleExportAudio = () => { const handleExportAudio = () => {
if (!story) return; if (!story) return;
@@ -248,67 +216,23 @@ export function StoryContent() {
return ( return (
<div className="flex flex-col h-full min-h-0"> <div className="flex flex-col h-full min-h-0">
{/* Header */} {/* Header */}
<div className="flex flex-col gap-4 mb-4 px-1"> <div className="flex items-center justify-between mb-4 px-1">
<div className="flex items-center justify-between"> <div>
<div> <h2 className="text-2xl font-bold">{story.name}</h2>
<h2 className="text-2xl font-bold">{story.name}</h2> {story.description && (
{story.description && ( <p className="text-sm text-muted-foreground mt-1">{story.description}</p>
<p className="text-sm text-muted-foreground mt-1">{story.description}</p> )}
)}
</div>
<div className="flex gap-2">
{story.items.length > 0 && (
<>
<Button variant="outline" size="sm" onClick={handlePlayPause}>
{isPlaying && playbackStoryId === story.id ? (
<>
<Pause className="mr-2 h-4 w-4" />
Pause
</>
) : (
<>
<Play className="mr-2 h-4 w-4" />
Play
</>
)}
</Button>
{isPlaying && playbackStoryId === story.id && (
<Button variant="outline" size="sm" onClick={handleStop}>
Stop
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={handleExportAudio}
disabled={exportAudio.isPending}
>
<Download className="mr-2 h-4 w-4" />
Export Audio
</Button>
</>
)}
</div>
</div> </div>
{/* Playback Controls */}
{story.items.length > 0 && ( {story.items.length > 0 && (
<div className="flex items-center gap-4"> <Button
<span className="text-xs text-muted-foreground tabular-nums min-w-16"> variant="outline"
{formatTime(currentTimeMs)} size="sm"
</span> onClick={handleExportAudio}
<Slider disabled={exportAudio.isPending}
value={[currentTimeMs]} >
max={totalDurationMs || 1} <Download className="mr-2 h-4 w-4" />
step={10} Export Audio
onValueChange={handleSeek} </Button>
className="flex-1"
disabled={!isPlaying && playbackStoryId !== story.id}
/>
<span className="text-xs text-muted-foreground tabular-nums min-w-16">
{formatTime(totalDurationMs)}
</span>
</div>
)} )}
</div> </div>
@@ -1,12 +1,60 @@
import { GripHorizontal, Minus, Plus } from 'lucide-react'; import { GripHorizontal, Minus, Pause, Play, Plus, Square } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import WaveSurfer from 'wavesurfer.js';
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 { apiClient } from '@/lib/api/client';
import { useMoveStoryItem } from '@/lib/hooks/useStories'; import { useMoveStoryItem } from '@/lib/hooks/useStories';
import { useStoryStore } from '@/stores/storyStore'; import { useStoryStore } from '@/stores/storyStore';
import type { StoryItemDetail } from '@/lib/api/types'; import type { StoryItemDetail } from '@/lib/api/types';
import { cn } from '@/lib/utils/cn'; import { cn } from '@/lib/utils/cn';
// Clip waveform component
function ClipWaveform({ generationId, width }: { generationId: string; width: number }) {
const containerRef = useRef<HTMLDivElement>(null);
const wavesurferRef = useRef<WaveSurfer | null>(null);
useEffect(() => {
if (!containerRef.current || width < 20) return;
// Get CSS colors
const root = document.documentElement;
const getCSSVar = (varName: string) => {
const value = getComputedStyle(root).getPropertyValue(varName).trim();
return value ? `hsl(${value})` : '';
};
const waveColor = getCSSVar('--accent-foreground');
const wavesurfer = WaveSurfer.create({
container: containerRef.current,
waveColor,
progressColor: waveColor,
cursorWidth: 0,
barWidth: 1,
barRadius: 1,
barGap: 1,
height: 28,
normalize: true,
interact: false,
});
wavesurferRef.current = wavesurfer;
const audioUrl = apiClient.getAudioUrl(generationId);
wavesurfer.load(audioUrl).catch(() => {
// Ignore load errors
});
return () => {
wavesurfer.destroy();
wavesurferRef.current = null;
};
}, [generationId, width]);
return <div ref={containerRef} className="w-full h-full opacity-60" />;
}
interface StoryTrackEditorProps { interface StoryTrackEditorProps {
storyId: string; storyId: string;
items: StoryItemDetail[]; items: StoryItemDetail[];
@@ -39,11 +87,34 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
const setEditorHeight = useStoryStore((state) => state.setTrackEditorHeight); const setEditorHeight = useStoryStore((state) => state.setTrackEditorHeight);
// Playback state // Playback state
const isPlaying = useStoryStore((state) => state.isPlaying);
const currentTimeMs = useStoryStore((state) => state.currentTimeMs); const currentTimeMs = useStoryStore((state) => state.currentTimeMs);
const storeTotalDurationMs = useStoryStore((state) => state.totalDurationMs);
const playbackStoryId = useStoryStore((state) => state.playbackStoryId); const playbackStoryId = useStoryStore((state) => state.playbackStoryId);
const play = useStoryStore((state) => state.play);
const pause = useStoryStore((state) => state.pause);
const stop = useStoryStore((state) => state.stop);
const seek = useStoryStore((state) => state.seek); const seek = useStoryStore((state) => state.seek);
const isActiveStory = playbackStoryId === storyId; const isActiveStory = playbackStoryId === storyId;
const isCurrentlyPlaying = isPlaying && isActiveStory;
// Sort items by start time for play
const sortedItems = useMemo(() => {
return [...items].sort((a, b) => a.start_time_ms - b.start_time_ms);
}, [items]);
const handlePlayPause = () => {
if (isCurrentlyPlaying) {
pause();
} else {
play(storyId, sortedItems);
}
};
const handleStop = () => {
stop();
};
// Calculate unique tracks from items, always showing at least 3 default tracks // Calculate unique tracks from items, always showing at least 3 default tracks
const tracks = useMemo(() => { const tracks = useMemo(() => {
@@ -286,84 +357,122 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
</button> </button>
{/* Toolbar */} {/* Toolbar */}
<div className="flex items-center gap-2 px-3 py-2 border-b bg-muted/30 mt-2"> <div className="flex items-center justify-between px-3 py-2 border-b bg-muted/30 mt-2">
<span className="text-xs text-muted-foreground">Zoom:</span> {/* Play controls - left side */}
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={handleZoomOut}> <div className="flex items-center gap-2">
<Minus className="h-3 w-3" /> <Button variant="ghost" size="icon" className="h-7 w-7" onClick={handlePlayPause}>
</Button> {isCurrentlyPlaying ? (
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={handleZoomIn}> <Pause className="h-4 w-4" />
<Plus className="h-3 w-3" /> ) : (
</Button> <Play className="h-4 w-4" />
<span className="text-xs text-muted-foreground ml-2"> )}
{Math.round(pixelsPerSecond)}px/s </Button>
</span> <Button variant="ghost" size="icon" className="h-7 w-7" onClick={handleStop} disabled={!isActiveStory}>
</div> <Square className="h-3 w-3" />
</Button>
{/* Timeline container - drag handlers are intentional for drag-and-drop UX */} <span className="text-xs text-muted-foreground tabular-nums ml-2">
{/* biome-ignore lint/a11y/noStaticElementInteractions: Container handles drag events for child clips */} {formatTime(isActiveStory ? currentTimeMs : 0)} / {formatTime(isActiveStory ? storeTotalDurationMs : 0)}
<div </span>
ref={tracksRef}
className="overflow-auto relative"
style={{ height: `${timelineContainerHeight}px` }}
onMouseMove={draggingItem ? handleDragMove : undefined}
onMouseUp={draggingItem ? handleDragEnd : undefined}
onMouseLeave={draggingItem ? handleDragEnd : undefined}
>
{/* Time ruler */}
<div
className="h-6 border-b bg-muted/20 sticky top-0 z-10"
style={{ width: `${timelineWidth}px` }}
>
{timeMarkers.map((ms) => (
<div
key={ms}
className="absolute top-0 h-full flex flex-col justify-end"
style={{ left: `${msToPixels(ms)}px` }}
>
<div className="h-2 w-px bg-border" />
<span className="text-[10px] text-muted-foreground ml-1 select-none">
{formatTime(ms)}
</span>
</div>
))}
</div> </div>
{/* Tracks area */} {/* Zoom controls - right side */}
<div <div className="flex items-center gap-2">
className="relative" <span className="text-xs text-muted-foreground">Zoom:</span>
style={{ width: `${timelineWidth}px`, height: `${tracksAreaHeight}px` }} <Button variant="ghost" size="icon" className="h-6 w-6" onClick={handleZoomOut}>
> <Minus className="h-3 w-3" />
{/* Track backgrounds */} </Button>
{tracks.map((trackNumber, index) => ( <Button variant="ghost" size="icon" className="h-6 w-6" onClick={handleZoomIn}>
<div <Plus className="h-3 w-3" />
key={trackNumber} </Button>
className={cn( </div>
'absolute left-0 right-0 border-b', </div>
index % 2 === 0 ? 'bg-background' : 'bg-muted/10'
)}
style={{
top: `${index * TRACK_HEIGHT}px`,
height: `${TRACK_HEIGHT}px`,
}}
>
<span className="absolute left-2 top-1/2 -translate-y-1/2 text-[10px] text-muted-foreground select-none">
Track {trackNumber}
</span>
</div>
))}
{/* Click area for seeking - z-index lower than clips */} {/* Timeline container with track labels sidebar */}
<button <div className="flex" style={{ height: `${timelineContainerHeight}px` }}>
type="button" {/* Track labels sidebar - fixed width */}
className="absolute inset-0 z-0 cursor-pointer" <div className="w-16 shrink-0 border-r bg-muted/20 overflow-hidden">
onClick={handleTimelineClick} {/* Spacer for time ruler */}
aria-label="Seek timeline" <div className="h-6 border-b bg-muted/30" />
/> {/* Track labels */}
<div style={{ height: `${tracksAreaHeight}px` }}>
{tracks.map((trackNumber, index) => (
<div
key={trackNumber}
className={cn(
'border-b flex items-center justify-center',
index % 2 === 0 ? 'bg-background' : 'bg-muted/10'
)}
style={{ height: `${TRACK_HEIGHT}px` }}
>
<span className="text-[10px] text-muted-foreground select-none">
{trackNumber}
</span>
</div>
))}
</div>
</div>
{/* Scrollable timeline area */}
{/* biome-ignore lint/a11y/noStaticElementInteractions: Container handles drag events for child clips */}
<div
ref={tracksRef}
className="overflow-auto relative flex-1"
onMouseMove={draggingItem ? handleDragMove : undefined}
onMouseUp={draggingItem ? handleDragEnd : undefined}
onMouseLeave={draggingItem ? handleDragEnd : undefined}
>
{/* Time ruler */}
<div
className="h-6 border-b bg-muted/20 sticky top-0 z-10"
style={{ width: `${timelineWidth}px` }}
>
{timeMarkers.map((ms) => (
<div
key={ms}
className="absolute top-0 h-full flex flex-col justify-end"
style={{ left: `${msToPixels(ms)}px` }}
>
<div className="h-2 w-px bg-border" />
<span className="text-[10px] text-muted-foreground ml-1 select-none">
{formatTime(ms)}
</span>
</div>
))}
</div>
{/* Tracks area */}
<div
className="relative"
style={{ width: `${timelineWidth}px`, height: `${tracksAreaHeight}px` }}
>
{/* Track backgrounds */}
{tracks.map((trackNumber, index) => (
<div
key={trackNumber}
className={cn(
'absolute left-0 right-0 border-b',
index % 2 === 0 ? 'bg-background' : 'bg-muted/10'
)}
style={{
top: `${index * TRACK_HEIGHT}px`,
height: `${TRACK_HEIGHT}px`,
}}
/>
))}
{/* Click area for seeking - z-index lower than clips */}
<button
type="button"
className="absolute inset-0 z-0 cursor-pointer"
onClick={handleTimelineClick}
aria-label="Seek timeline"
/>
{/* Audio clips */} {/* Audio clips */}
{items.map((item) => { {items.map((item) => {
const isDragging = draggingItem === item.generation_id; const isDragging = draggingItem === item.generation_id;
const style = getClipStyle(item); const style = getClipStyle(item);
const clipWidth = msToPixels(item.duration * 1000);
return ( return (
<button <button
@@ -372,20 +481,22 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
className={cn( className={cn(
'absolute rounded cursor-move select-none overflow-hidden z-10', 'absolute rounded cursor-move select-none overflow-hidden z-10',
'bg-accent/80 hover:bg-accent border border-accent-foreground/20', 'bg-accent/80 hover:bg-accent border border-accent-foreground/20',
'flex items-center px-2 text-left', 'flex flex-col justify-center',
isDragging && 'opacity-80 shadow-lg z-20', isDragging && 'opacity-80 shadow-lg z-20',
!isDragging && 'transition-all duration-100' !isDragging && 'transition-all duration-100'
)} )}
style={style} style={style}
onMouseDown={(e) => handleDragStart(e, item)} onMouseDown={(e) => handleDragStart(e, item)}
> >
<div className="flex-1 min-w-0"> {/* Clip label */}
<p className="text-[10px] font-medium text-accent-foreground truncate"> <div className="absolute top-0 left-1 right-1 z-10">
<p className="text-[9px] font-medium text-accent-foreground truncate">
{item.profile_name} {item.profile_name}
</p> </p>
<p className="text-[9px] text-accent-foreground/70 truncate"> </div>
{item.text.substring(0, 30)}... {/* Waveform */}
</p> <div className="absolute inset-0 top-3">
<ClipWaveform generationId={item.generation_id} width={clipWidth} />
</div> </div>
</button> </button>
); );
@@ -394,13 +505,14 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
{/* Playhead */} {/* Playhead */}
{isActiveStory && ( {isActiveStory && (
<div <div
className="absolute top-0 bottom-0 w-px bg-primary z-30 pointer-events-none" className="absolute top-0 bottom-0 w-1 bg-accent z-30 pointer-events-none rounded-full"
style={{ left: `${playheadLeft}px` }} style={{ left: `${playheadLeft}px` }}
> >
<div className="absolute -top-1 left-1/2 -translate-x-1/2 w-0 h-0 border-l-4 border-r-4 border-t-4 border-l-transparent border-r-transparent border-t-primary" /> <div className="absolute -top-1 left-1/2 -translate-x-1/2 w-3 h-3 bg-accent rounded-full" />
</div> </div>
)} )}
</div> </div>
</div>
</div> </div>
</div> </div>
</div> </div>
+1 -1
View File
@@ -28,7 +28,7 @@ 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; const DEFAULT_TRACK_EDITOR_HEIGHT = 250;
export const useStoryStore = create<StoryPlaybackState>((set, get) => ({ export const useStoryStore = create<StoryPlaybackState>((set, get) => ({
// Selection // Selection