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(
'fixed right-auto',
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)]',
)}
style={{
+3 -3
View File
@@ -6,14 +6,14 @@ export function StoriesTab() {
return (
<div className="flex flex-col h-full min-h-0 overflow-hidden">
{/* 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 */}
<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 />
</div>
{/* 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 />
</div>
@@ -88,24 +88,12 @@ export function StoryChatItem({
{formatTime(itemStartMs)}
</span>
</div>
<button
type="button"
className="w-full text-left cursor-pointer"
onClick={(e) => {
// Don't trigger play if clicking on textarea or if text is selected
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>
<Textarea
value={item.text}
className="flex-1 resize-none text-sm text-muted-foreground select-text bg-card cursor-text"
readOnly
onDoubleClick={handlePlay}
/>
</div>
{/* Actions */}
+21 -97
View File
@@ -1,11 +1,11 @@
import {
DndContext,
closestCenter,
DndContext,
type DragEndEvent,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core';
import {
arrayMove,
@@ -13,16 +13,15 @@ import {
sortableKeyboardCoordinates,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { Download, Pause, Play } from 'lucide-react';
import { Download } from 'lucide-react';
import { useEffect, useMemo, useRef } from 'react';
import { Button } from '@/components/ui/button';
import { Slider } from '@/components/ui/slider';
import { useToast } from '@/components/ui/use-toast';
import {
useStory,
useRemoveStoryItem,
useExportStoryAudio,
useRemoveStoryItem,
useReorderStoryItems,
useStory,
} from '@/lib/hooks/useStories';
import { useStoryPlayback } from '@/lib/hooks/useStoryPlayback';
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 currentTimeMs = useStoryStore((state) => state.currentTimeMs);
const totalDurationMs = useStoryStore((state) => state.totalDurationMs);
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
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 = () => {
if (!story) return;
@@ -248,67 +216,23 @@ export function StoryContent() {
return (
<div className="flex flex-col h-full min-h-0">
{/* Header */}
<div className="flex flex-col gap-4 mb-4 px-1">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold">{story.name}</h2>
{story.description && (
<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 className="flex items-center justify-between mb-4 px-1">
<div>
<h2 className="text-2xl font-bold">{story.name}</h2>
{story.description && (
<p className="text-sm text-muted-foreground mt-1">{story.description}</p>
)}
</div>
{/* Playback Controls */}
{story.items.length > 0 && (
<div className="flex items-center gap-4">
<span className="text-xs text-muted-foreground tabular-nums min-w-16">
{formatTime(currentTimeMs)}
</span>
<Slider
value={[currentTimeMs]}
max={totalDurationMs || 1}
step={10}
onValueChange={handleSeek}
className="flex-1"
disabled={!isPlaying && playbackStoryId !== story.id}
/>
<span className="text-xs text-muted-foreground tabular-nums min-w-16">
{formatTime(totalDurationMs)}
</span>
</div>
<Button
variant="outline"
size="sm"
onClick={handleExportAudio}
disabled={exportAudio.isPending}
>
<Download className="mr-2 h-4 w-4" />
Export Audio
</Button>
)}
</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 WaveSurfer from 'wavesurfer.js';
import { Button } from '@/components/ui/button';
import { useToast } from '@/components/ui/use-toast';
import { apiClient } from '@/lib/api/client';
import { useMoveStoryItem } from '@/lib/hooks/useStories';
import { useStoryStore } from '@/stores/storyStore';
import type { StoryItemDetail } from '@/lib/api/types';
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 {
storyId: string;
items: StoryItemDetail[];
@@ -39,11 +87,34 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
const setEditorHeight = useStoryStore((state) => state.setTrackEditorHeight);
// Playback state
const isPlaying = useStoryStore((state) => state.isPlaying);
const currentTimeMs = useStoryStore((state) => state.currentTimeMs);
const storeTotalDurationMs = useStoryStore((state) => state.totalDurationMs);
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 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
const tracks = useMemo(() => {
@@ -286,84 +357,122 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
</button>
{/* Toolbar */}
<div className="flex items-center gap-2 px-3 py-2 border-b bg-muted/30 mt-2">
<span className="text-xs text-muted-foreground">Zoom:</span>
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={handleZoomOut}>
<Minus className="h-3 w-3" />
</Button>
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={handleZoomIn}>
<Plus className="h-3 w-3" />
</Button>
<span className="text-xs text-muted-foreground ml-2">
{Math.round(pixelsPerSecond)}px/s
</span>
</div>
{/* Timeline container - drag handlers are intentional for drag-and-drop UX */}
{/* biome-ignore lint/a11y/noStaticElementInteractions: Container handles drag events for child clips */}
<div
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 className="flex items-center justify-between px-3 py-2 border-b bg-muted/30 mt-2">
{/* Play controls - left side */}
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={handlePlayPause}>
{isCurrentlyPlaying ? (
<Pause className="h-4 w-4" />
) : (
<Play className="h-4 w-4" />
)}
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={handleStop} disabled={!isActiveStory}>
<Square className="h-3 w-3" />
</Button>
<span className="text-xs text-muted-foreground tabular-nums ml-2">
{formatTime(isActiveStory ? currentTimeMs : 0)} / {formatTime(isActiveStory ? storeTotalDurationMs : 0)}
</span>
</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`,
}}
>
<span className="absolute left-2 top-1/2 -translate-y-1/2 text-[10px] text-muted-foreground select-none">
Track {trackNumber}
</span>
</div>
))}
{/* Zoom controls - right side */}
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">Zoom:</span>
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={handleZoomOut}>
<Minus className="h-3 w-3" />
</Button>
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={handleZoomIn}>
<Plus className="h-3 w-3" />
</Button>
</div>
</div>
{/* 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"
/>
{/* Timeline container with track labels sidebar */}
<div className="flex" style={{ height: `${timelineContainerHeight}px` }}>
{/* Track labels sidebar - fixed width */}
<div className="w-16 shrink-0 border-r bg-muted/20 overflow-hidden">
{/* Spacer for time ruler */}
<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 */}
{items.map((item) => {
const isDragging = draggingItem === item.generation_id;
const style = getClipStyle(item);
const clipWidth = msToPixels(item.duration * 1000);
return (
<button
@@ -372,20 +481,22 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
className={cn(
'absolute rounded cursor-move select-none overflow-hidden z-10',
'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 && 'transition-all duration-100'
)}
style={style}
onMouseDown={(e) => handleDragStart(e, item)}
>
<div className="flex-1 min-w-0">
<p className="text-[10px] font-medium text-accent-foreground truncate">
{/* Clip label */}
<div className="absolute top-0 left-1 right-1 z-10">
<p className="text-[9px] font-medium text-accent-foreground truncate">
{item.profile_name}
</p>
<p className="text-[9px] text-accent-foreground/70 truncate">
{item.text.substring(0, 30)}...
</p>
</div>
{/* Waveform */}
<div className="absolute inset-0 top-3">
<ClipWaveform generationId={item.generation_id} width={clipWidth} />
</div>
</button>
);
@@ -394,13 +505,14 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
{/* Playhead */}
{isActiveStory && (
<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` }}
>
<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>
+1 -1
View File
@@ -28,7 +28,7 @@ interface StoryPlaybackState {
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) => ({
// Selection