mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-10-02 16:45:15 -07:00
feat(ui): theme settings, stories polish, track editor restructure
- add dark/light/system theme with persisted choice + OS change listener - restyle stories sidebar (search, item layout, border) to match captures - move floating generate box to right column of stories, add top fade mask - story track editor: sticky track labels aligned via flex rows, custom scrollbar with left/right zoom handles - capture pill light mode pass, fix inline waveform progress color - pull mcp_server hidden imports into the pyinstaller spec - notarization doc draft
This commit is contained in:
@@ -135,8 +135,9 @@ export function CapturePill({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex items-center gap-3 px-4 h-10 rounded-full',
|
||||
'bg-black/55 backdrop-blur-md text-accent',
|
||||
'inline-flex items-center gap-3 px-4 h-10 rounded-full text-accent',
|
||||
'bg-white/80 ring-1 ring-black/5 shadow-lg backdrop-blur-xl',
|
||||
'dark:bg-black/55 dark:ring-0 dark:shadow-none dark:backdrop-blur-md',
|
||||
completedStroke,
|
||||
'transition-opacity duration-300 ease-out',
|
||||
visible ? 'opacity-100' : 'opacity-0 pointer-events-none',
|
||||
@@ -182,8 +183,9 @@ function ErrorPill({
|
||||
title={t('captures.pill.errorCopyTooltip')}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2.5 px-4 h-10 rounded-full',
|
||||
'bg-black/65 backdrop-blur-md text-red-300',
|
||||
'max-w-[380px] hover:bg-black/80 transition-colors',
|
||||
'bg-white/85 ring-1 ring-destructive/25 shadow-lg backdrop-blur-xl text-red-600 hover:bg-white',
|
||||
'dark:bg-black/65 dark:ring-0 dark:shadow-none dark:backdrop-blur-md dark:text-red-300 dark:hover:bg-black/80',
|
||||
'max-w-[380px] transition-colors',
|
||||
'focus:outline-none focus:ring-2 focus:ring-red-400/50',
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -37,12 +37,15 @@ export function CaptureInlinePlayer({
|
||||
const root = document.documentElement;
|
||||
const cssHsla = (varName: string, alpha: number) => {
|
||||
const value = getComputedStyle(root).getPropertyValue(varName).trim();
|
||||
return value ? `hsl(${value} / ${alpha})` : '';
|
||||
if (!value) return '';
|
||||
const [h, s, l] = value.split(/\s+/);
|
||||
if (!h || !s || !l) return '';
|
||||
return `hsla(${h}, ${s}, ${l}, ${alpha})`;
|
||||
};
|
||||
|
||||
const ws = WaveSurfer.create({
|
||||
container,
|
||||
waveColor: cssHsla('--foreground', 0.25),
|
||||
waveColor: cssHsla('--muted-foreground', 1),
|
||||
progressColor: cssHsla('--accent', 1),
|
||||
cursorColor: 'transparent',
|
||||
barWidth: 2,
|
||||
|
||||
@@ -255,10 +255,10 @@ export function FloatingGenerateBox({
|
||||
<motion.div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
'fixed right-auto',
|
||||
'fixed',
|
||||
isStoriesRoute
|
||||
? // Position aligned with story list: after sidebar + padding, width 360px
|
||||
'left-[calc(5rem+2rem)] w-[360px]'
|
||||
? // Aligned with StoryContent: sidebar + list width + gap (tab bleeds with -mx-8)
|
||||
'left-[calc(5rem+360px+1.5rem)] right-8'
|
||||
: 'left-[calc(5rem+2rem)] right-8 lg:right-auto lg:w-[calc((100%-5rem-4rem)/2-1rem)]',
|
||||
)}
|
||||
style={{
|
||||
|
||||
@@ -16,6 +16,7 @@ import { usePlatform } from '@/platform/PlatformContext';
|
||||
import { useServerStore } from '@/stores/serverStore';
|
||||
import { LanguageSelect } from './LanguageSelect';
|
||||
import { SettingRow, SettingSection } from './SettingRow';
|
||||
import { ThemeSelect } from './ThemeSelect';
|
||||
|
||||
function makeConnectionSchema(invalidUrl: string) {
|
||||
return z.object({
|
||||
@@ -198,6 +199,12 @@ export function GeneralPage() {
|
||||
description={t('settings.language.description')}
|
||||
action={<LanguageSelect />}
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
title={t('settings.theme.label')}
|
||||
description={t('settings.theme.description')}
|
||||
action={<ThemeSelect />}
|
||||
/>
|
||||
</SettingSection>
|
||||
|
||||
<ApiReferenceCard serverUrl={serverUrl} />
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { type Theme, useUIStore } from '@/stores/uiStore';
|
||||
|
||||
export function ThemeSelect() {
|
||||
const { t } = useTranslation();
|
||||
const theme = useUIStore((s) => s.theme);
|
||||
const setTheme = useUIStore((s) => s.setTheme);
|
||||
|
||||
return (
|
||||
<Select value={theme} onValueChange={(value) => setTheme(value as Theme)}>
|
||||
<SelectTrigger className="h-9 w-[180px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="system">{t('settings.theme.options.system')}</SelectItem>
|
||||
<SelectItem value="light">{t('settings.theme.options.light')}</SelectItem>
|
||||
<SelectItem value="dark">{t('settings.theme.options.dark')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
@@ -47,15 +47,7 @@ export function Sidebar({ isMacOS }: SidebarProps) {
|
||||
>
|
||||
{/* Logo */}
|
||||
<div className="mb-2">
|
||||
<img
|
||||
src={voiceboxLogo}
|
||||
alt="Voicebox"
|
||||
className="w-12 h-12 object-contain"
|
||||
style={{
|
||||
filter:
|
||||
'drop-shadow(0 0 6px hsl(var(--accent) / 0.5)) drop-shadow(0 0 14px hsl(var(--accent) / 0.35)) drop-shadow(0 0 28px hsl(var(--accent) / 0.2))',
|
||||
}}
|
||||
/>
|
||||
<img src={voiceboxLogo} alt="Voicebox" className="sidebar-logo w-12 h-12 object-contain" />
|
||||
</div>
|
||||
|
||||
{/* Navigation Buttons */}
|
||||
|
||||
@@ -7,7 +7,7 @@ export function StoriesTab() {
|
||||
const audioUrl = usePlayerStore((state) => state.audioUrl);
|
||||
|
||||
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 -mx-8">
|
||||
{/* Main content area */}
|
||||
<div className="flex-1 min-h-0 flex gap-6 overflow-hidden relative">
|
||||
{/* Left Column - Story List */}
|
||||
@@ -16,7 +16,7 @@ export function StoriesTab() {
|
||||
</div>
|
||||
|
||||
{/* Right Column - Story Content */}
|
||||
<div className="flex flex-col min-h-0 overflow-hidden flex-1">
|
||||
<div className="flex flex-col min-h-0 overflow-hidden flex-1 pr-8">
|
||||
<StoryContent />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -72,8 +72,12 @@ export function StoryContent() {
|
||||
// Track editor is shown when story has items
|
||||
const hasBottomBar = story && story.items.length > 0;
|
||||
|
||||
// Calculate dynamic bottom padding: track editor + gap
|
||||
const bottomPadding = hasBottomBar ? trackEditorHeight + 24 : 0;
|
||||
// Clear the floating generate box (always visible on this route) and the
|
||||
// track editor bar when it's showing.
|
||||
const FLOATING_BOX_CLEARANCE = 140;
|
||||
const bottomPadding = hasBottomBar
|
||||
? trackEditorHeight + FLOATING_BOX_CLEARANCE
|
||||
: FLOATING_BOX_CLEARANCE;
|
||||
|
||||
// Drag and drop sensors
|
||||
const sensors = useSensors(
|
||||
@@ -265,9 +269,12 @@ export function StoryContent() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<div className="flex flex-col h-full min-h-0 relative overflow-hidden">
|
||||
{/* Scroll Mask */}
|
||||
<div className="absolute top-0 left-0 right-0 h-20 bg-gradient-to-b from-background to-transparent z-10 pointer-events-none" />
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4 px-1">
|
||||
<div className="absolute top-0 left-0 right-0 z-20 flex items-center justify-between px-1">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">{story.name}</h2>
|
||||
{story.description && (
|
||||
@@ -357,7 +364,7 @@ export function StoryContent() {
|
||||
{/* Content */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex-1 min-h-0 overflow-y-auto space-y-3"
|
||||
className="flex-1 min-h-0 overflow-y-auto space-y-3 pt-14 scroll-pt-14 relative z-0"
|
||||
style={{ paddingBottom: bottomPadding > 0 ? `${bottomPadding}px` : undefined }}
|
||||
>
|
||||
{sortedItems.length === 0 ? (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BookOpen, MoreHorizontal, Pencil, Plus, Trash2 } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -62,6 +63,7 @@ export function StoryList() {
|
||||
const [deletingStoryId, setDeletingStoryId] = useState<string | null>(null);
|
||||
const [newStoryName, setNewStoryName] = useState('');
|
||||
const [newStoryDescription, setNewStoryDescription] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const { toast } = useToast();
|
||||
|
||||
// Auto-select the first story when the list loads with no selection
|
||||
@@ -178,6 +180,19 @@ export function StoryList() {
|
||||
});
|
||||
};
|
||||
|
||||
const storyList = stories || [];
|
||||
const hasTrackEditor = selectedStoryId && selectedStory && selectedStory.items.length > 0;
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return storyList;
|
||||
return storyList.filter((s) => {
|
||||
const name = (s.name || '').toLowerCase();
|
||||
const description = (s.description || '').toLowerCase();
|
||||
return name.includes(q) || description.includes(q);
|
||||
});
|
||||
}, [search, storyList]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
@@ -186,77 +201,91 @@ export function StoryList() {
|
||||
);
|
||||
}
|
||||
|
||||
const storyList = stories || [];
|
||||
const hasTrackEditor = selectedStoryId && selectedStory && selectedStory.items.length > 0;
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col relative overflow-hidden">
|
||||
<div className="h-full flex flex-col relative overflow-hidden border-r border-border">
|
||||
{/* Scroll Mask */}
|
||||
<div className="absolute top-0 left-0 right-0 h-16 bg-gradient-to-b from-background to-transparent z-10 pointer-events-none" />
|
||||
<div className="absolute top-0 left-0 right-0 h-20 bg-gradient-to-b from-background to-transparent z-10 pointer-events-none" />
|
||||
|
||||
{/* Fixed Header */}
|
||||
<div className="absolute top-0 left-0 right-0 z-20">
|
||||
<div className="flex items-center justify-between mb-4 px-1">
|
||||
<h2 className="text-2xl font-bold">{t('stories.title')}</h2>
|
||||
<div className="absolute top-0 left-0 right-0 z-20 pl-4 pr-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h2 className="text-2xl px-4 font-bold">{t('stories.title')}</h2>
|
||||
<Button onClick={() => setCreateDialogOpen(true)} size="sm">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t('stories.newStory')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Input
|
||||
placeholder={t('stories.searchPlaceholder')}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="h-9 text-sm rounded-full focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrollable Story List */}
|
||||
<div
|
||||
className="flex-1 overflow-y-auto pt-14 relative z-0"
|
||||
className="flex-1 overflow-y-auto pt-24 relative z-0"
|
||||
style={{ paddingBottom: hasTrackEditor ? `${trackEditorHeight + 140}px` : '170px' }}
|
||||
>
|
||||
{storyList.length === 0 ? (
|
||||
<div className="text-center py-12 px-5 border-2 border-dashed border-muted rounded-2xl text-muted-foreground">
|
||||
<div className="mx-4 text-center py-12 px-5 border-2 border-dashed border-muted rounded-2xl text-muted-foreground">
|
||||
<BookOpen className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<p className="text-sm">{t('stories.empty.title')}</p>
|
||||
<p className="text-xs mt-2">{t('stories.empty.hint')}</p>
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="px-4 py-12 text-center text-sm text-muted-foreground">
|
||||
<p>{t('stories.empty.noMatches', { query: search })}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{storyList.map((story) => (
|
||||
<div
|
||||
key={story.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
'px-5 py-3 rounded-lg transition-colors group flex items-center cursor-pointer',
|
||||
selectedStoryId === story.id ? 'bg-muted' : 'hover:bg-muted/50',
|
||||
)}
|
||||
aria-label={t('stories.row.ariaLabel', {
|
||||
name: story.name,
|
||||
count: story.item_count,
|
||||
updated: formatDate(story.updated_at),
|
||||
})}
|
||||
aria-pressed={selectedStoryId === story.id}
|
||||
onClick={() => setSelectedStoryId(story.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.target !== e.currentTarget) return;
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setSelectedStoryId(story.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2 w-full min-w-0">
|
||||
<div className="flex-1 min-w-0 text-left overflow-hidden">
|
||||
<h3 className="text-sm font-medium truncate">{story.name}</h3>
|
||||
<div className="flex items-center gap-2 mt-1 text-xs text-muted-foreground">
|
||||
<span>{t('stories.row.itemCount', { count: story.item_count })}</span>
|
||||
<span>·</span>
|
||||
<span>{formatDate(story.updated_at)}</span>
|
||||
<div className="px-4 pb-6 space-y-1">
|
||||
{filtered.map((story) => {
|
||||
const isActive = selectedStoryId === story.id;
|
||||
return (
|
||||
<div key={story.id} className="relative group">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedStoryId(story.id)}
|
||||
aria-label={t('stories.row.ariaLabel', {
|
||||
name: story.name,
|
||||
count: story.item_count,
|
||||
updated: formatDate(story.updated_at),
|
||||
})}
|
||||
aria-pressed={isActive}
|
||||
className={cn(
|
||||
'w-full text-left p-3 rounded-lg transition-colors block',
|
||||
isActive
|
||||
? 'bg-muted/70 border border-border'
|
||||
: 'border border-transparent hover:bg-muted/30',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<span className="text-[11px] text-muted-foreground font-medium">
|
||||
{formatDate(story.updated_at)}
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[13px] text-foreground/90 line-clamp-2 leading-snug mb-2">
|
||||
{story.name}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-5 px-1.5 text-[10px] gap-1 font-medium bg-muted/60 text-muted-foreground"
|
||||
>
|
||||
{t('stories.row.itemCount', { count: story.item_count })}
|
||||
</Badge>
|
||||
</div>
|
||||
</button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
className="absolute top-2 right-2 h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label={t('stories.row.actionsLabel', { name: story.name })}
|
||||
>
|
||||
@@ -278,8 +307,8 @@ export function StoryList() {
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -125,6 +125,8 @@ interface StoryTrackEditorProps {
|
||||
|
||||
const TRACK_HEIGHT = 48;
|
||||
const TIME_RULER_HEIGHT = 24; // h-6 = 1.5rem = 24px
|
||||
const SCRUB_BAR_HEIGHT = 16;
|
||||
const LABEL_COL_WIDTH = 64; // w-16 = 4rem = 64px
|
||||
const MIN_PIXELS_PER_SECOND = 10;
|
||||
const MAX_PIXELS_PER_SECOND = 200;
|
||||
const DEFAULT_PIXELS_PER_SECOND = 50;
|
||||
@@ -282,6 +284,44 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
// Horizontal scrollbar state
|
||||
const [timelineScrollLeft, setTimelineScrollLeft] = useState(0);
|
||||
const [scrollbarTrackWidth, setScrollbarTrackWidth] = useState(0);
|
||||
const scrollbarTrackRef = useRef<HTMLDivElement>(null);
|
||||
const scrollbarDragRef = useRef<{
|
||||
mode: 'pan' | 'left' | 'right';
|
||||
startX: number;
|
||||
startScrollLeft: number;
|
||||
startPixelsPerSecond: number;
|
||||
} | null>(null);
|
||||
// Anchor the visible left/right edge time during a zoom drag so the edge
|
||||
// the user isn't dragging stays pinned in place across pixelsPerSecond changes.
|
||||
const zoomAnchorRef = useRef<{ type: 'left' | 'right'; timeMs: number } | null>(null);
|
||||
|
||||
// Mirror the timeline's scrollLeft into state so the scrollbar thumb tracks it
|
||||
useEffect(() => {
|
||||
const el = tracksRef.current;
|
||||
if (!el) return;
|
||||
const onScroll = () => setTimelineScrollLeft(el.scrollLeft);
|
||||
el.addEventListener('scroll', onScroll);
|
||||
setTimelineScrollLeft(el.scrollLeft);
|
||||
return () => el.removeEventListener('scroll', onScroll);
|
||||
}, []);
|
||||
|
||||
// Track scrollbar track width for thumb sizing
|
||||
useEffect(() => {
|
||||
const el = scrollbarTrackRef.current;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
setScrollbarTrackWidth(entry.contentRect.width);
|
||||
}
|
||||
});
|
||||
ro.observe(el);
|
||||
setScrollbarTrackWidth(el.clientWidth);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
// Calculate effective duration (accounting for trims)
|
||||
const getEffectiveDuration = (item: StoryItemDetail) => {
|
||||
return item.duration * 1000 - (item.trim_start_ms || 0) - (item.trim_end_ms || 0);
|
||||
@@ -374,7 +414,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
||||
const handleTimelineClick = (e: React.MouseEvent<HTMLElement>) => {
|
||||
if (!tracksRef.current || draggingItem || trimmingItem) return;
|
||||
const rect = tracksRef.current.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left + tracksRef.current.scrollLeft;
|
||||
const x = e.clientX - rect.left + tracksRef.current.scrollLeft - LABEL_COL_WIDTH;
|
||||
const timeMs = Math.max(0, pixelsToMs(x));
|
||||
seek(timeMs);
|
||||
// Deselect clip when clicking on timeline
|
||||
@@ -654,7 +694,13 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
||||
y: e.clientY - rect.top,
|
||||
});
|
||||
setDragPosition({
|
||||
x: rect.left - tracksRef.current.getBoundingClientRect().left + tracksRef.current.scrollLeft,
|
||||
// Subtract label column width because clips live in a sub-container offset
|
||||
// by LABEL_COL_WIDTH, so dragPosition.x is stored in timeline-local coords.
|
||||
x:
|
||||
rect.left -
|
||||
tracksRef.current.getBoundingClientRect().left +
|
||||
tracksRef.current.scrollLeft -
|
||||
LABEL_COL_WIDTH,
|
||||
// Subtract ruler height since clips are positioned relative to tracks area, not the scrollable container
|
||||
y: rect.top - tracksRef.current.getBoundingClientRect().top - TIME_RULER_HEIGHT,
|
||||
});
|
||||
@@ -666,7 +712,12 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
||||
if (!draggingItem || !tracksRef.current) return;
|
||||
|
||||
const rect = tracksRef.current.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left + tracksRef.current.scrollLeft - dragOffset.x;
|
||||
const x =
|
||||
e.clientX -
|
||||
rect.left +
|
||||
tracksRef.current.scrollLeft -
|
||||
dragOffset.x -
|
||||
LABEL_COL_WIDTH;
|
||||
// Subtract ruler height since clips are positioned relative to tracks area
|
||||
const y = e.clientY - rect.top - dragOffset.y - TIME_RULER_HEIGHT;
|
||||
|
||||
@@ -762,7 +813,109 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
||||
|
||||
// Calculate tracks area height
|
||||
const tracksAreaHeight = tracks.length * TRACK_HEIGHT;
|
||||
const timelineContainerHeight = editorHeight - 40; // Subtract toolbar height
|
||||
const timelineContainerHeight = editorHeight - 40 - SCRUB_BAR_HEIGHT;
|
||||
|
||||
// Scrollbar thumb geometry
|
||||
const maxTimelineScroll = Math.max(0, timelineWidth - containerWidth);
|
||||
const visibleRatio = timelineWidth > 0 ? Math.min(1, containerWidth / timelineWidth) : 1;
|
||||
const thumbWidth = Math.max(24, visibleRatio * scrollbarTrackWidth);
|
||||
const thumbRange = Math.max(0, scrollbarTrackWidth - thumbWidth);
|
||||
const thumbLeft =
|
||||
maxTimelineScroll > 0 && thumbRange > 0
|
||||
? (timelineScrollLeft / maxTimelineScroll) * thumbRange
|
||||
: 0;
|
||||
const canScrollHorizontally = maxTimelineScroll > 0;
|
||||
|
||||
const handleScrollbarMouseDown = useCallback(
|
||||
(mode: 'pan' | 'left' | 'right') => (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
scrollbarDragRef.current = {
|
||||
mode,
|
||||
startX: e.clientX,
|
||||
startScrollLeft: timelineScrollLeft,
|
||||
startPixelsPerSecond: pixelsPerSecond,
|
||||
};
|
||||
},
|
||||
[timelineScrollLeft, pixelsPerSecond],
|
||||
);
|
||||
|
||||
// After a zoom drag updates pixelsPerSecond, snap scrollLeft so the anchored
|
||||
// edge (left or right of the visible window) stays at the same time.
|
||||
useEffect(() => {
|
||||
const anchor = zoomAnchorRef.current;
|
||||
if (!anchor || !tracksRef.current) return;
|
||||
const timePx = (anchor.timeMs / 1000) * pixelsPerSecond;
|
||||
tracksRef.current.scrollLeft =
|
||||
anchor.type === 'left' ? Math.max(0, timePx) : Math.max(0, timePx - containerWidth);
|
||||
}, [pixelsPerSecond, containerWidth]);
|
||||
|
||||
useEffect(() => {
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
const drag = scrollbarDragRef.current;
|
||||
if (!drag || !tracksRef.current) return;
|
||||
const deltaX = e.clientX - drag.startX;
|
||||
|
||||
if (drag.mode === 'pan') {
|
||||
if (thumbRange <= 0) return;
|
||||
const deltaScroll = (deltaX / thumbRange) * maxTimelineScroll;
|
||||
tracksRef.current.scrollLeft = Math.max(
|
||||
0,
|
||||
Math.min(maxTimelineScroll, drag.startScrollLeft + deltaScroll),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (scrollbarTrackWidth <= 0 || containerWidth <= 0) return;
|
||||
|
||||
// Recompute the thumb width that corresponded to the drag start, then
|
||||
// apply the mouse delta to the dragged edge.
|
||||
const startTimelinePx =
|
||||
(totalDurationMs / 1000) * drag.startPixelsPerSecond + 200;
|
||||
const startThumbWidth = Math.max(
|
||||
30,
|
||||
Math.min(scrollbarTrackWidth, (containerWidth / startTimelinePx) * scrollbarTrackWidth),
|
||||
);
|
||||
const newThumbWidth = Math.max(
|
||||
30,
|
||||
Math.min(
|
||||
scrollbarTrackWidth,
|
||||
drag.mode === 'right' ? startThumbWidth + deltaX : startThumbWidth - deltaX,
|
||||
),
|
||||
);
|
||||
|
||||
const newTimelinePx = (containerWidth / newThumbWidth) * scrollbarTrackWidth;
|
||||
const rawPps = (newTimelinePx - 200) / (totalDurationMs / 1000);
|
||||
const newPps = Math.max(
|
||||
MIN_PIXELS_PER_SECOND,
|
||||
Math.min(MAX_PIXELS_PER_SECOND, rawPps),
|
||||
);
|
||||
|
||||
zoomAnchorRef.current =
|
||||
drag.mode === 'right'
|
||||
? {
|
||||
type: 'left',
|
||||
timeMs: (drag.startScrollLeft / drag.startPixelsPerSecond) * 1000,
|
||||
}
|
||||
: {
|
||||
type: 'right',
|
||||
timeMs:
|
||||
((drag.startScrollLeft + containerWidth) / drag.startPixelsPerSecond) * 1000,
|
||||
};
|
||||
|
||||
setPixelsPerSecond(newPps);
|
||||
};
|
||||
const onMouseUp = () => {
|
||||
scrollbarDragRef.current = null;
|
||||
zoomAnchorRef.current = null;
|
||||
};
|
||||
window.addEventListener('mousemove', onMouseMove);
|
||||
window.addEventListener('mouseup', onMouseUp);
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', onMouseMove);
|
||||
window.removeEventListener('mouseup', onMouseUp);
|
||||
};
|
||||
}, [maxTimelineScroll, thumbRange, scrollbarTrackWidth, containerWidth, totalDurationMs]);
|
||||
|
||||
if (items.length === 0) {
|
||||
return null;
|
||||
@@ -916,44 +1069,25 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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 */}
|
||||
{/* Timeline scroll container */}
|
||||
{/* 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}
|
||||
>
|
||||
{/* Ruler row: corner spacer + time ruler, sticky to top */}
|
||||
<div
|
||||
ref={tracksRef}
|
||||
className="overflow-auto relative flex-1"
|
||||
onMouseMove={draggingItem ? handleDragMove : undefined}
|
||||
onMouseUp={draggingItem ? handleDragEnd : undefined}
|
||||
onMouseLeave={draggingItem ? handleDragEnd : undefined}
|
||||
className="flex sticky top-0 z-30"
|
||||
style={{ width: `${timelineWidth + LABEL_COL_WIDTH}px` }}
|
||||
>
|
||||
{/* Time ruler - clickable to seek */}
|
||||
<div className="w-16 h-6 shrink-0 border-b border-r bg-muted/30 sticky left-0 z-40" />
|
||||
<button
|
||||
type="button"
|
||||
className="h-6 border-b bg-muted/20 sticky top-0 z-10 cursor-pointer text-left"
|
||||
className="h-6 border-b bg-muted/20 cursor-pointer text-left relative"
|
||||
style={{ width: `${timelineWidth}px` }}
|
||||
onClick={handleTimelineClick}
|
||||
aria-label="Seek timeline"
|
||||
@@ -971,27 +1105,46 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
||||
</div>
|
||||
))}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tracks area */}
|
||||
<div
|
||||
className="relative"
|
||||
style={{ width: `${timelineWidth}px`, height: `${tracksAreaHeight}px` }}
|
||||
>
|
||||
{/* Track backgrounds - pointer-events-none to allow clicks to pass through */}
|
||||
{tracks.map((trackNumber, index) => (
|
||||
{/* Tracks area (rows with sticky labels + clips sub-container) */}
|
||||
<div
|
||||
className="relative"
|
||||
style={{
|
||||
width: `${timelineWidth + LABEL_COL_WIDTH}px`,
|
||||
height: `${tracksAreaHeight}px`,
|
||||
}}
|
||||
>
|
||||
{/* Per-track rows: label and background as flex siblings guarantee alignment */}
|
||||
{tracks.map((trackNumber, index) => (
|
||||
<div
|
||||
key={trackNumber}
|
||||
className="absolute left-0 right-0 flex"
|
||||
style={{
|
||||
top: `${index * TRACK_HEIGHT}px`,
|
||||
height: `${TRACK_HEIGHT}px`,
|
||||
}}
|
||||
>
|
||||
<div className="w-16 shrink-0 border-b border-r flex items-center justify-center sticky left-0 z-20 h-full bg-background">
|
||||
<div className="absolute inset-0 bg-muted/20 pointer-events-none" />
|
||||
<span className="relative text-[10px] text-muted-foreground select-none">
|
||||
{trackNumber}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
key={trackNumber}
|
||||
className={cn(
|
||||
'absolute left-0 right-0 border-b pointer-events-none',
|
||||
'border-b flex-1 pointer-events-none',
|
||||
index % 2 === 0 ? 'bg-background' : 'bg-muted/10',
|
||||
)}
|
||||
style={{
|
||||
top: `${index * TRACK_HEIGHT}px`,
|
||||
height: `${TRACK_HEIGHT}px`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Clip/playhead/seek layer offset past the label column */}
|
||||
<div
|
||||
className="absolute top-0 bottom-0"
|
||||
style={{ left: `${LABEL_COL_WIDTH}px`, width: `${timelineWidth}px` }}
|
||||
>
|
||||
{/* Click area for seeking - z-index lower than clips */}
|
||||
<button
|
||||
type="button"
|
||||
@@ -1101,6 +1254,55 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Horizontal timeline scrollbar + zoom handles */}
|
||||
<div
|
||||
className="flex border-t bg-background/40"
|
||||
style={{ height: `${SCRUB_BAR_HEIGHT}px` }}
|
||||
>
|
||||
<div className="w-16 shrink-0 border-r" />
|
||||
<div
|
||||
ref={scrollbarTrackRef}
|
||||
className="relative flex-1 overflow-hidden select-none px-1"
|
||||
>
|
||||
<div
|
||||
className="absolute top-1 bottom-1 bg-foreground/10 hover:bg-foreground/15 transition-colors group rounded-full"
|
||||
style={{ width: `${thumbWidth}px`, left: `${thumbLeft}px` }}
|
||||
>
|
||||
{/* Left zoom handle */}
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: mouse-driven edge handle */}
|
||||
<div
|
||||
role="slider"
|
||||
aria-label="Zoom from left edge"
|
||||
aria-valuenow={Math.round(pixelsPerSecond)}
|
||||
aria-valuemin={MIN_PIXELS_PER_SECOND}
|
||||
aria-valuemax={MAX_PIXELS_PER_SECOND}
|
||||
className="absolute top-0 bottom-0 left-0 w-1.5 cursor-ew-resize bg-foreground/25 hover:bg-foreground/40 transition-colors rounded-l-full"
|
||||
onMouseDown={handleScrollbarMouseDown('left')}
|
||||
/>
|
||||
{/* Pan area */}
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: mouse-driven drag area */}
|
||||
<div
|
||||
className={cn(
|
||||
'absolute top-0 bottom-0 left-1.5 right-1.5',
|
||||
canScrollHorizontally ? 'cursor-grab active:cursor-grabbing' : 'cursor-default',
|
||||
)}
|
||||
onMouseDown={canScrollHorizontally ? handleScrollbarMouseDown('pan') : undefined}
|
||||
/>
|
||||
{/* Right zoom handle */}
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: mouse-driven edge handle */}
|
||||
<div
|
||||
role="slider"
|
||||
aria-label="Zoom from right edge"
|
||||
aria-valuenow={Math.round(pixelsPerSecond)}
|
||||
aria-valuemin={MIN_PIXELS_PER_SECOND}
|
||||
aria-valuemax={MAX_PIXELS_PER_SECOND}
|
||||
className="absolute top-0 bottom-0 right-0 w-1.5 cursor-ew-resize bg-foreground/25 hover:bg-foreground/40 transition-colors rounded-r-full"
|
||||
onMouseDown={handleScrollbarMouseDown('right')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -91,7 +91,7 @@ export function ProfileCard({ profile, disabled }: ProfileCardProps) {
|
||||
className={cn(
|
||||
'cursor-pointer transition-all flex flex-col h-[162px]',
|
||||
disabled ? 'opacity-40 hover:opacity-60' : 'hover:shadow-md',
|
||||
isSelected && !disabled && 'ring-2 ring-accent shadow-md',
|
||||
isSelected && !disabled && 'ring-2 border-transparent ring-accent shadow-md',
|
||||
)}
|
||||
onClick={handleSelect}
|
||||
tabIndex={0}
|
||||
|
||||
@@ -9,7 +9,7 @@ const badgeVariants = cva(
|
||||
variant: {
|
||||
default: 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
|
||||
secondary:
|
||||
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
'border-border bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
destructive:
|
||||
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
|
||||
outline: 'text-foreground',
|
||||
|
||||
Reference in New Issue
Block a user