diff options
19 files changed, 625 insertions, 504 deletions
diff --git a/apps/web/src/app/editor/[project_id]/page.tsx b/apps/web/src/app/editor/[project_id]/page.tsx index 5e294fe7..03579144 100644 --- a/apps/web/src/app/editor/[project_id]/page.tsx +++ b/apps/web/src/app/editor/[project_id]/page.tsx @@ -167,7 +167,7 @@ export default function Editor() { {/* Main content area */} <ResizablePanelGroup direction="horizontal" - className="h-full w-full gap-[0.19rem] px-2" + className="h-full w-full gap-[0.19rem] px-3" > {/* Tools Panel */} <ResizablePanel @@ -175,7 +175,7 @@ export default function Editor() { minSize={15} maxSize={40} onResize={setToolsPanel} - className="min-w-0" + className="min-w-0 rounded-sm" > <MediaPanel /> </ResizablePanel> @@ -214,7 +214,7 @@ export default function Editor() { minSize={15} maxSize={70} onResize={setTimeline} - className="min-h-0 px-2 pb-2" + className="min-h-0 px-3 pb-3" > <Timeline /> </ResizablePanel> diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 96fef9b0..d612e27e 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -9,38 +9,40 @@ :root { /* Custom colors - light mode (default) */ - --background: hsl(0 0% 100%); - --foreground: hsl(0 0% 3.9%); - --card: hsl(0 0% 100%); - --card-foreground: hsl(0 0% 3.9%); - --popover: hsl(0 0% 100%); - --popover-foreground: hsl(0 0% 3.9%); - --primary: hsl(0 0% 9%); - --primary-foreground: hsl(0 0% 98%); - --secondary: hsl(0 0% 96.1%); - --secondary-foreground: hsl(0 0% 9%); - --muted: hsl(0 0% 96.1%); - --muted-foreground: hsl(0 0% 45.1%); - --accent: hsl(0 0% 96.1%); - --accent-foreground: hsl(0 0% 9%); - --destructive: hsl(0 84.2% 60.2%); - --destructive-foreground: hsl(0 0% 98%); - --border: hsl(0 0% 89.8%); - --input: hsl(0 0% 89.8%); - --ring: hsl(0 0% 3.9%); - --chart-1: hsl(12 76% 61%); - --chart-2: hsl(173 58% 39%); - --chart-3: hsl(197 37% 24%); - --chart-4: hsl(43 74% 66%); - --chart-5: hsl(27 87% 67%); - --sidebar-background: hsl(0 0% 100%); - --sidebar-foreground: hsl(0 0% 3.9%); - --sidebar-primary: hsl(0 0% 9%); - --sidebar-primary-foreground: hsl(0 0% 98%); - --sidebar-accent: hsl(0 0% 96.1%); - --sidebar-accent-foreground: hsl(0 0% 9%); - --sidebar-border: hsl(0 0% 89.8%); - --sidebar-ring: hsl(0 0% 3.9%); + --background: hsl(0, 0%, 100%); + --foreground: hsl(0 0% 11%); + --card: hsl(0 0% 85.1%); + --card-foreground: hsl(0 0% 2%); + --popover: hsl(0, 0%, 100%); + --popover-foreground: hsl(0 0% 2%); + --primary: hsl(205, 84%, 47%); + --primary-foreground: hsl(0 0% 91%); + --secondary: hsl(216, 13%, 92%); + --secondary-foreground: hsl(0 0% 2%); + --muted: hsl(0 0% 85.1%); + --muted-foreground: hsl(0 0% 36.1%); + --accent: hsl(216, 13%, 92%); + --accent-foreground: hsl(0 0% 2%); + --destructive: hsl(0 100% 40%); + --destructive-foreground: hsl(0, 0%, 100%); + --border: hsl(0 0% 83%); + --input: hsl(0 0% 85.1%); + --ring: hsl(0, 0%, 55%); + --chart-1: hsl(220 70% 50%); + --chart-2: hsl(160 60% 45%); + --chart-3: hsl(30 80% 55%); + --chart-4: hsl(280 65% 60%); + --chart-5: hsl(340 75% 55%); + --sidebar-background: hsl(0 0% 96.1%); + --sidebar-foreground: hsl(0 0% 2%); + --sidebar-primary: hsl(0 0% 2%); + --sidebar-primary-foreground: hsl(0 0% 91%); + --sidebar-accent: hsl(0 0% 85.1%); + --sidebar-accent-foreground: hsl(0 0% 2%); + --sidebar-border: hsl(0 0% 85.1%); + --sidebar-ring: hsl(0 0% 16.9%); + --panel-background: hsl(216 13% 92%); + --panel-accent: hsl(216, 8%, 86%); /* Radius base */ --radius: 1rem; @@ -224,3 +226,20 @@ display: none; } } + +@utility scrollbar-thin { + &::-webkit-scrollbar { + width: 8px; + height: 8px; + } + &::-webkit-scrollbar-track { + background: transparent; + } + &::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 4px; + } + &::-webkit-scrollbar-thumb:hover { + background: var(--muted-foreground); + } +} diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 5c1d7520..a64c4440 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -30,7 +30,7 @@ export default function RootLayout({ <BotIdClient protect={protectedRoutes} /> </head> <body className={`${defaultFont.className} font-sans antialiased`}> - <ThemeProvider attribute="class" forcedTheme="dark"> + <ThemeProvider attribute="class" defaultTheme="dark"> <TooltipProvider> <StorageProvider>{children}</StorageProvider> <Analytics /> diff --git a/apps/web/src/components/editor-header.tsx b/apps/web/src/components/editor-header.tsx index 99cea3e8..77202c36 100644 --- a/apps/web/src/components/editor-header.tsx +++ b/apps/web/src/components/editor-header.tsx @@ -1,7 +1,14 @@ "use client"; import { Button } from "./ui/button"; -import { ChevronDown, ArrowLeft, Download, SquarePen, Trash } from "lucide-react"; +import { + ChevronDown, + ArrowLeft, + Download, + SquarePen, + Trash, + Sun, +} from "lucide-react"; import { useTimelineStore } from "@/stores/timeline-store"; import { HeaderBase } from "./header-base"; import { formatTimeCode } from "@/lib/time"; @@ -19,14 +26,18 @@ import Link from "next/link"; import { RenameProjectDialog } from "./rename-project-dialog"; import { DeleteProjectDialog } from "./delete-project-dialog"; import { useRouter } from "next/navigation"; -import { FaDiscord, FaGithub } from "react-icons/fa6"; +import { FaDiscord } from "react-icons/fa6"; +import { useTheme } from "next-themes"; +import { usePlaybackStore } from "@/stores/playback-store"; export function EditorHeader() { const { getTotalDuration } = useTimelineStore(); + const { currentTime } = usePlaybackStore(); const { activeProject, renameProject, deleteProject } = useProjectStore(); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false); const router = useRouter(); + const { theme, setTheme } = useTheme(); const handleExport = () => { // TODO: Implement export functionality @@ -120,7 +131,11 @@ export function EditorHeader() { const centerContent = ( <div className="flex items-center gap-2 text-xs"> - <span> + <span className="text-foreground tabular-nums"> + {formatTimeCode(currentTime, "HH:MM:SS:FF", activeProject?.fps || 30)} + </span> + <span className="text-foreground/50">/</span> + <span className="text-foreground/50 tabular-nums"> {formatTimeCode( getTotalDuration(), "HH:MM:SS:FF", @@ -135,11 +150,20 @@ export function EditorHeader() { <KeyboardShortcutsHelp /> <Button size="sm" - className="h-7 text-xs !bg-linear-to-r from-cyan-400 to-blue-500 text-white hover:opacity-85 transition-opacity" + className="h-8 text-xs !bg-linear-to-r from-cyan-400 to-blue-500 text-white hover:opacity-85 transition-opacity" onClick={handleExport} > <Download className="h-4 w-4" /> - <span className="text-sm">Export</span> + <span className="text-sm pr-1">Export</span> + </Button> + <Button + size="icon" + variant="text" + className="h-7" + onClick={() => setTheme(theme === "dark" ? "light" : "dark")} + > + <Sun className="!size-[1.1rem]" /> + <span className="sr-only">{theme === "dark" ? "Light" : "Dark"}</span> </Button> </nav> ); @@ -149,7 +173,7 @@ export function EditorHeader() { leftContent={leftContent} centerContent={centerContent} rightContent={rightContent} - className="bg-background h-[3.2rem] px-4 items-center" + className="bg-background h-[3.2rem] px-3 items-center mt-0.5" /> ); } diff --git a/apps/web/src/components/editor/media-panel/index.tsx b/apps/web/src/components/editor/media-panel/index.tsx index 359612f8..a5cd6686 100644 --- a/apps/web/src/components/editor/media-panel/index.tsx +++ b/apps/web/src/components/editor/media-panel/index.tsx @@ -5,6 +5,7 @@ import { MediaView } from "./views/media"; import { useMediaPanelStore, Tab } from "./store"; import { TextView } from "./views/text"; import { AudioView } from "./views/audio"; +import { Separator } from "@/components/ui/separator"; export function MediaPanel() { const { activeTab } = useMediaPanelStore(); @@ -46,9 +47,10 @@ export function MediaPanel() { }; return ( - <div className="h-full flex flex-col bg-panel rounded-sm"> + <div className="h-full flex bg-panel"> <TabBar /> - <div className="flex-1 overflow-y-auto">{viewMap[activeTab]}</div> + <Separator orientation="vertical" /> + <div className="flex-1 overflow-hidden">{viewMap[activeTab]}</div> </div> ); } diff --git a/apps/web/src/components/editor/media-panel/tabbar.tsx b/apps/web/src/components/editor/media-panel/tabbar.tsx index 92482559..a3733e4d 100644 --- a/apps/web/src/components/editor/media-panel/tabbar.tsx +++ b/apps/web/src/components/editor/media-panel/tabbar.tsx @@ -61,7 +61,7 @@ export function TabBar() { }, []); return ( - <div className="flex"> + <div className="flex pt-4"> <ScrollButton direction="left" onClick={scrollToStart} @@ -69,7 +69,7 @@ export function TabBar() { /> <div ref={scrollContainerRef} - className="h-12 bg-panel-accent px-3 flex justify-start items-center gap-5 overflow-x-auto scrollbar-x-hidden relative w-full" + className="h-full px-4 flex flex-col justify-start items-center gap-5 overflow-x-auto scrollbar-x-hidden relative w-full" > {(Object.keys(tabs) as Tab[]).map((tabKey) => { const tab = tabs[tabKey]; @@ -83,7 +83,6 @@ export function TabBar() { key={tabKey} > <tab.icon className="size-[1.1rem]!" /> - <span className="text-[0.65rem]">{tab.label}</span> </div> ); })} diff --git a/apps/web/src/components/editor/media-panel/views/audio.tsx b/apps/web/src/components/editor/media-panel/views/audio.tsx index ba64d749..0d3c3f1e 100644 --- a/apps/web/src/components/editor/media-panel/views/audio.tsx +++ b/apps/web/src/components/editor/media-panel/views/audio.tsx @@ -9,6 +9,7 @@ export function AudioView() { <div className="h-full flex flex-col gap-2 p-4"> <Input placeholder="Search songs and artists" + className="bg-panel-accent" value={search} onChange={(e) => setSearch(e.target.value)} /> diff --git a/apps/web/src/components/editor/media-panel/views/media.tsx b/apps/web/src/components/editor/media-panel/views/media.tsx index d1533736..772ef4ab 100644 --- a/apps/web/src/components/editor/media-panel/views/media.tsx +++ b/apps/web/src/components/editor/media-panel/views/media.tsx @@ -3,8 +3,18 @@ import { useDragDrop } from "@/hooks/use-drag-drop"; import { processMediaFiles } from "@/lib/media-processing"; import { useMediaStore, type MediaItem } from "@/stores/media-store"; -import { Image, Loader2, Music, Plus, Video } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; +import { + ArrowDown01, + CloudUpload, + Grid2X2, + Image, + List, + Loader2, + Music, + Search, + Video, +} from "lucide-react"; +import { useEffect, useRef, useState, useMemo } from "react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { MediaDragOverlay } from "@/components/editor/media-panel/drag-overlay"; @@ -14,27 +24,62 @@ import { ContextMenuItem, ContextMenuTrigger, } from "@/components/ui/context-menu"; -import { Input } from "@/components/ui/input"; import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { DraggableMediaItem } from "@/components/ui/draggable-item"; import { useProjectStore } from "@/stores/project-store"; import { useTimelineStore } from "@/stores/timeline-store"; import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { usePanelStore } from "@/stores/panel-store"; + +function MediaItemWithContextMenu({ + item, + children, + onRemove, +}: { + item: MediaItem; + children: React.ReactNode; + onRemove: (e: React.MouseEvent, id: string) => Promise<void>; +}) { + return ( + <ContextMenu> + <ContextMenuTrigger>{children}</ContextMenuTrigger> + <ContextMenuContent> + <ContextMenuItem>Export clips</ContextMenuItem> + <ContextMenuItem + variant="destructive" + onClick={(e) => onRemove(e, item.id)} + > + Delete + </ContextMenuItem> + </ContextMenuContent> + </ContextMenu> + ); +} export function MediaView() { const { mediaItems, addMediaItem, removeMediaItem } = useMediaStore(); const { activeProject } = useProjectStore(); + const { mediaViewMode, setMediaViewMode } = usePanelStore(); const fileInputRef = useRef<HTMLInputElement>(null); const [isProcessing, setIsProcessing] = useState(false); const [progress, setProgress] = useState(0); const [searchQuery, setSearchQuery] = useState(""); const [mediaFilter, setMediaFilter] = useState("all"); + const [sortBy, setSortBy] = useState<"name" | "type" | "duration" | "size">( + "name" + ); + const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc"); const processFiles = async (files: FileList | File[]) => { if (!files || files.length === 0) return; @@ -100,7 +145,7 @@ export function MediaView() { const [filteredMediaItems, setFilteredMediaItems] = useState(mediaItems); useEffect(() => { - const filtered = mediaItems.filter((item) => { + let filtered = mediaItems.filter((item) => { if (mediaFilter && mediaFilter !== "all" && item.type !== mediaFilter) { return false; } @@ -115,79 +160,117 @@ export function MediaView() { return true; }); + filtered.sort((a, b) => { + let valueA: any; + let valueB: any; + + switch (sortBy) { + case "name": + valueA = a.name.toLowerCase(); + valueB = b.name.toLowerCase(); + break; + case "type": + valueA = a.type; + valueB = b.type; + break; + case "duration": + valueA = a.duration || 0; + valueB = b.duration || 0; + break; + case "size": + valueA = a.file.size; + valueB = b.file.size; + break; + default: + return 0; + } + + if (valueA < valueB) return sortOrder === "asc" ? -1 : 1; + if (valueA > valueB) return sortOrder === "asc" ? 1 : -1; + return 0; + }); + setFilteredMediaItems(filtered); - }, [mediaItems, mediaFilter, searchQuery]); + }, [mediaItems, mediaFilter, searchQuery, sortBy, sortOrder]); - const renderPreview = (item: MediaItem) => { - // Render a preview for each media type (image, video, audio, unknown) - if (item.type === "image") { - return ( - <div className="w-full h-full flex items-center justify-center"> - <img - src={item.url} - alt={item.name} - className="max-w-full max-h-full object-contain" - loading="lazy" - /> - </div> - ); - } + const previewComponents = useMemo(() => { + const previews = new Map<string, React.ReactNode>(); - if (item.type === "video") { - if (item.thumbnailUrl) { - return ( - <div className="relative w-full h-full"> + filteredMediaItems.forEach((item) => { + let preview: React.ReactNode; + + if (item.type === "image") { + preview = ( + <div className="w-full h-full flex items-center justify-center"> <img - src={item.thumbnailUrl} + src={item.url} alt={item.name} - className="w-full h-full object-cover rounded" + className="max-w-full max-h-full object-contain" loading="lazy" /> - <div className="absolute inset-0 flex items-center justify-center bg-black/20 rounded"> - <Video className="h-6 w-6 text-white drop-shadow-md" /> + </div> + ); + } else if (item.type === "video") { + if (item.thumbnailUrl) { + preview = ( + <div className="relative w-full h-full"> + <img + src={item.thumbnailUrl} + alt={item.name} + className="w-full h-full object-cover rounded" + loading="lazy" + /> + <div className="absolute inset-0 flex items-center justify-center bg-black/20 rounded"> + <Video className="h-6 w-6 text-white drop-shadow-md" /> + </div> + {item.duration && ( + <div className="absolute bottom-1 right-1 bg-black/70 text-white text-xs px-1 rounded"> + {formatDuration(item.duration)} + </div> + )} + </div> + ); + } else { + preview = ( + <div className="w-full h-full bg-muted/30 flex flex-col items-center justify-center text-muted-foreground rounded"> + <Video className="h-6 w-6 mb-1" /> + <span className="text-xs">Video</span> + {item.duration && ( + <span className="text-xs opacity-70"> + {formatDuration(item.duration)} + </span> + )} </div> + ); + } + } else if (item.type === "audio") { + preview = ( + <div className="w-full h-full bg-linear-to-br from-green-500/20 to-emerald-500/20 flex flex-col items-center justify-center text-muted-foreground rounded border border-green-500/20"> + <Music className="h-6 w-6 mb-1" /> + <span className="text-xs">Audio</span> {item.duration && ( - <div className="absolute bottom-1 right-1 bg-black/70 text-white text-xs px-1 rounded"> + <span className="text-xs opacity-70"> {formatDuration(item.duration)} - </div> + </span> )} </div> ); + } else { + preview = ( + <div className="w-full h-full bg-muted/30 flex flex-col items-center justify-center text-muted-foreground rounded"> + <Image className="h-6 w-6" /> + <span className="text-xs mt-1">Unknown</span> + </div> + ); } - return ( - <div className="w-full h-full bg-muted/30 flex flex-col items-center justify-center text-muted-foreground rounded"> - <Video className="h-6 w-6 mb-1" /> - <span className="text-xs">Video</span> - {item.duration && ( - <span className="text-xs opacity-70"> - {formatDuration(item.duration)} - </span> - )} - </div> - ); - } - if (item.type === "audio") { - return ( - <div className="w-full h-full bg-linear-to-br from-green-500/20 to-emerald-500/20 flex flex-col items-center justify-center text-muted-foreground rounded border border-green-500/20"> - <Music className="h-6 w-6 mb-1" /> - <span className="text-xs">Audio</span> - {item.duration && ( - <span className="text-xs opacity-70"> - {formatDuration(item.duration)} - </span> - )} - </div> - ); - } + previews.set(item.id, preview); + }); - return ( - <div className="w-full h-full bg-muted/30 flex flex-col items-center justify-center text-muted-foreground rounded"> - <Image className="h-6 w-6" /> - <span className="text-xs mt-1">Unknown</span> - </div> - ); - }; + return previews; + }, [filteredMediaItems]); + + const renderPreview = (item: MediaItem) => previewComponents.get(item.id); return ( <> @@ -207,43 +290,152 @@ export function MediaView() { > <div className="p-3 pb-2 bg-panel"> {/* Search and filter controls */} - <div className="flex gap-2"> - <Select value={mediaFilter} onValueChange={setMediaFilter}> - <SelectTrigger className="w-[80px] h-9 text-xs"> - <SelectValue /> - </SelectTrigger> - <SelectContent> - <SelectItem value="all">All</SelectItem> - <SelectItem value="video">Video</SelectItem> - <SelectItem value="audio">Audio</SelectItem> - <SelectItem value="image">Image</SelectItem> - </SelectContent> - </Select> - <Input - type="text" - placeholder="Search media..." - className="min-w-[60px] flex-1 h-9 text-xs" - value={searchQuery} - onChange={(e) => setSearchQuery(e.target.value)} - /> + <div className="flex items-center gap-2"> <Button variant="outline" size="lg" onClick={handleFileSelect} disabled={isProcessing} - className="flex-none bg-transparent min-w-[30px] whitespace-nowrap overflow-hidden px-2 justify-center items-center h-9" + className="!bg-background px-4 flex-1 justify-center items-center h-9 opacity-100 hover:opacity-75 transition-opacity" > {isProcessing ? ( <Loader2 className="h-4 w-4 animate-spin" /> ) : ( - <Plus className="h-4 w-4" /> + <CloudUpload className="h-4 w-4" /> )} + <span>Upload</span> </Button> + <div className="flex items-center gap-0"> + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <Button + size="icon" + variant="text" + onClick={() => + setMediaViewMode( + mediaViewMode === "grid" ? "list" : "grid" + ) + } + disabled={isProcessing} + className="justify-center items-center" + > + {mediaViewMode === "grid" ? ( + <List strokeWidth={1.5} className="!size-[1.05rem]" /> + ) : ( + <Grid2X2 + strokeWidth={1.5} + className="!size-[1.05rem]" + /> + )} + </Button> + </TooltipTrigger> + <TooltipContent> + <p> + {mediaViewMode === "grid" + ? "Switch to list view" + : "Switch to grid view"} + </p> + </TooltipContent> + <Tooltip> + <DropdownMenu> + <TooltipTrigger asChild> + <DropdownMenuTrigger asChild> + <Button + size="icon" + variant="text" + disabled={isProcessing} + className="justify-center items-center" + > + <ArrowDown01 + strokeWidth={1.5} + className="!size-[1.05rem]" + /> + </Button> + </DropdownMenuTrigger> + </TooltipTrigger> + <DropdownMenuContent align="end"> + <DropdownMenuItem + onClick={() => { + if (sortBy === "name") { + setSortOrder( + sortOrder === "asc" ? "desc" : "asc" + ); + } else { + setSortBy("name"); + setSortOrder("asc"); + } + }} + > + Name{" "} + {sortBy === "name" && + (sortOrder === "asc" ? "↑" : "↓")} + </DropdownMenuItem> + <DropdownMenuItem + onClick={() => { + if (sortBy === "type") { + setSortOrder( + sortOrder === "asc" ? "desc" : "asc" + ); + } else { + setSortBy("type"); + setSortOrder("asc"); + } + }} + > + Type{" "} + {sortBy === "type" && + (sortOrder === "asc" ? "↑" : "↓")} + </DropdownMenuItem> + <DropdownMenuItem + onClick={() => { + if (sortBy === "duration") { + setSortOrder( + sortOrder === "asc" ? "desc" : "asc" + ); + } else { + setSortBy("duration"); + setSortOrder("asc"); + } + }} + > + Duration{" "} + {sortBy === "duration" && + (sortOrder === "asc" ? "↑" : "↓")} + </DropdownMenuItem> + <DropdownMenuItem + onClick={() => { + if (sortBy === "size") { + setSortOrder( + sortOrder === "asc" ? "desc" : "asc" + ); + } else { + setSortBy("size"); + setSortOrder("asc"); + } + }} + > + File Size{" "} + {sortBy === "size" && + (sortOrder === "asc" ? "↑" : "↓")} + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + <TooltipContent> + <p> + Sort by {sortBy} ( + {sortOrder === "asc" ? "ascending" : "descending"}) + </p> + </TooltipContent> + </Tooltip> + </Tooltip> + </TooltipProvider> + </div> </div> </div> - <ScrollArea className="h-full"> - <div className="flex-1 p-3 pt-0"> + <div className="h-full w-full overflow-y-auto scrollbar-thin"> + <div className="flex-1 p-3 pt-0 w-full"> {isDragOver || filteredMediaItems.length === 0 ? ( <MediaDragOverlay isVisible={true} @@ -252,50 +444,105 @@ export function MediaView() { onClick={handleFileSelect} isEmptyState={filteredMediaItems.length === 0 && !isDragOver} /> + ) : mediaViewMode === "grid" ? ( + <GridView + filteredMediaItems={filteredMediaItems} + renderPreview={renderPreview} + handleRemove={handleRemove} + /> ) : ( - <div - className="grid gap-2" - style={{ - gridTemplateColumns: "repeat(auto-fill, 160px)", - }} - > - {/* Render each media item as a draggable button */} - {filteredMediaItems.map((item) => ( - <ContextMenu key={item.id}> - <ContextMenuTrigger> - <DraggableMediaItem - name={item.name} - preview={renderPreview(item)} - dragData={{ - id: item.id, - type: item.type, - name: item.name, - }} - showPlusOnDrag={false} - onAddToTimeline={(currentTime) => - useTimelineStore - .getState() - .addMediaAtTime(item, currentTime) - } - rounded={false} - /> - </ContextMenuTrigger> - <ContextMenuContent> - <ContextMenuItem>Export clips</ContextMenuItem> - <ContextMenuItem - variant="destructive" - onClick={(e) => handleRemove(e, item.id)} - > - Delete - </ContextMenuItem> - </ContextMenuContent> - </ContextMenu> - ))} - </div> + <ListView + filteredMediaItems={filteredMediaItems} + renderPreview={renderPreview} + handleRemove={handleRemove} + formatDuration={formatDuration} + /> )} </div> - </ScrollArea> + </div> </div> </> ); } + +function GridView({ + filteredMediaItems, + renderPreview, + handleRemove, +}: { + filteredMediaItems: MediaItem[]; + renderPreview: (item: MediaItem) => React.ReactNode; + handleRemove: (e: React.MouseEvent, id: string) => Promise<void>; +}) { + return ( + <div + className="grid gap-2" + style={{ + gridTemplateColumns: "repeat(auto-fill, 160px)", + }} + > + {filteredMediaItems.map((item) => ( + <MediaItemWithContextMenu + key={item.id} + item={item} + onRemove={handleRemove} + > + <DraggableMediaItem + name={item.name} + preview={renderPreview(item)} + dragData={{ + id: item.id, + type: item.type, + name: item.name, + }} + showPlusOnDrag={false} + onAddToTimeline={(currentTime) => + useTimelineStore.getState().addMediaAtTime(item, currentTime) + } + rounded={false} + variant="card" + /> + </MediaItemWithContextMenu> + ))} + </div> + ); +} + +function ListView({ + filteredMediaItems, + renderPreview, + handleRemove, + formatDuration, +}: { + filteredMediaItems: MediaItem[]; + renderPreview: (item: MediaItem) => React.ReactNode; + handleRemove: (e: React.MouseEvent, id: string) => Promise<void>; + formatDuration: (duration: number) => string; +}) { + return ( + <div className="space-y-1"> + {filteredMediaItems.map((item) => ( + <MediaItemWithContextMenu + key={item.id} + item={item} + onRemove={handleRemove} + > + <DraggableMediaItem + name={item.name} + preview={renderPreview(item)} + dragData={{ + id: item.id, + type: item.type, + name: item.name, + }} + showPlusOnDrag={false} + onAddToTimeline={(currentTime) => + useTimelineStore.getState().addMediaAtTime(item, currentTime) + } + variant="compact" + /> + </MediaItemWithContextMenu> + ))} + </div> + ); +} diff --git a/apps/web/src/components/editor/media-panel/views/text.tsx b/apps/web/src/components/editor/media-panel/views/text.tsx index 0686190e..ca8e88c9 100644 --- a/apps/web/src/components/editor/media-panel/views/text.tsx +++ b/apps/web/src/components/editor/media-panel/views/text.tsx @@ -32,7 +32,7 @@ export function TextView() { <DraggableMediaItem name="Default text" preview={ - <div className="flex items-center justify-center w-full h-full bg-accent rounded"> + <div className="flex items-center justify-center w-full h-full bg-panel-accent rounded"> <span className="text-xs select-none">Default text</span> </div> } diff --git a/apps/web/src/components/editor/preview-panel.tsx b/apps/web/src/components/editor/preview-panel.tsx index c6f4e159..3a962025 100644 --- a/apps/web/src/components/editor/preview-panel.tsx +++ b/apps/web/src/components/editor/preview-panel.tsx @@ -5,24 +5,15 @@ import { TimelineElement, TimelineTrack } from "@/types/timeline"; import { useMediaStore, type MediaItem } from "@/stores/media-store"; import { usePlaybackStore } from "@/stores/playback-store"; import { useEditorStore } from "@/stores/editor-store"; -import { useAspectRatio } from "@/hooks/use-aspect-ratio"; import { VideoPlayer } from "@/components/ui/video-player"; import { AudioPlayer } from "@/components/ui/audio-player"; import { Button } from "@/components/ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, - DropdownMenuSeparator, -} from "@/components/ui/dropdown-menu"; import { Play, Pause, Expand, SkipBack, SkipForward } from "lucide-react"; import { useState, useRef, useEffect, useCallback } from "react"; import { cn } from "@/lib/utils"; import { formatTimeCode } from "@/lib/time"; import { EditableTimecode } from "@/components/ui/editable-timecode"; import { FONT_CLASS_MAP } from "@/lib/font-config"; -import { BackgroundSettings } from "../background-settings"; import { useProjectStore } from "@/stores/project-store"; import { TextElementDragState } from "@/types/editor"; @@ -412,7 +403,7 @@ export function PreviewPanel() { > <div className="text-center"> <div className="text-2xl mb-2">🎬</div> - <p className="text-xs text-white">{element.name}</p> + <p className="text-xs text-foreground">{element.name}</p> </div> </div> ); @@ -476,10 +467,10 @@ export function PreviewPanel() { return ( <> - <div className="h-full w-full flex flex-col min-h-0 min-w-0 bg-panel rounded-sm"> + <div className="h-full w-full flex flex-col min-h-0 min-w-0 bg-panel rounded-sm relative"> <div ref={containerRef} - className="flex-1 flex flex-col items-center justify-center p-3 min-h-0 min-w-0" + className="flex-1 flex flex-col items-center justify-center min-h-0 min-w-0" > <div className="flex-1" /> {hasAnyElements ? ( @@ -621,9 +612,9 @@ function FullscreenToolbar({ return ( <div data-toolbar - className="flex items-center gap-2 p-1 pt-2 w-full text-white" + className="flex items-center gap-2 p-1 pt-2 w-full text-foreground relative" > - <div className="flex items-center gap-1 text-[0.70rem] tabular-nums text-white/90"> + <div className="flex items-center gap-1 text-[0.70rem] tabular-nums text-foreground/90"> <EditableTimecode time={currentTime} duration={totalDuration} @@ -631,7 +622,7 @@ function FullscreenToolbar({ fps={activeProject?.fps || 30} onTimeChange={seek} disabled={!hasAnyElements} - className="text-white/90 hover:bg-white/10" + className="text-foreground/90 hover:bg-white/10" /> <span className="opacity-50">/</span> <span> @@ -649,7 +640,7 @@ function FullscreenToolbar({ size="icon" onClick={skipBackward} disabled={!hasAnyElements} - className="h-auto p-0 text-white hover:text-white/80" + className="h-auto p-0 text-foreground" title="Skip backward 1s" > <SkipBack className="h-3 w-3" /> @@ -659,7 +650,7 @@ function FullscreenToolbar({ size="icon" onClick={toggle} disabled={!hasAnyElements} - className="h-auto p-0 text-white hover:text-white/80" + className="h-auto p-0 text-foreground hover:text-foreground/80" > {isPlaying ? ( <Pause className="h-3 w-3" /> @@ -672,7 +663,7 @@ function FullscreenToolbar({ size="icon" onClick={skipForward} disabled={!hasAnyElements} - className="h-auto p-0 text-white hover:text-white/80" + className="h-auto p-0 text-foreground hover:text-foreground/80" title="Skip forward 1s" > <SkipForward className="h-3 w-3" /> @@ -682,7 +673,7 @@ function FullscreenToolbar({ <div className="flex-1 flex items-center gap-2"> <div className={cn( - "relative h-1 rounded-full cursor-pointer flex-1 bg-white/20", + "relative h-1 rounded-full cursor-pointer flex-1 bg-foreground/20", !hasAnyElements && "opacity-50 cursor-not-allowed" )} onClick={hasAnyElements ? handleTimelineClick : undefined} @@ -691,13 +682,13 @@ function FullscreenToolbar({ > <div className={cn( - "absolute top-0 left-0 h-full rounded-full bg-white", + "absolute top-0 left-0 h-full rounded-full bg-foreground", !isDragging && "duration-100" )} style={{ width: `${progress}%` }} /> <div - className="absolute top-1/2 w-3 h-3 rounded-full -translate-y-1/2 -translate-x-1/2 shadow-xs bg-white border border-black/20" + className="absolute top-1/2 w-3 h-3 rounded-full -translate-y-1/2 -translate-x-1/2 shadow-xs bg-foreground border border-black/20" style={{ left: `${progress}%` }} /> </div> @@ -706,7 +697,7 @@ function FullscreenToolbar({ <Button variant="text" size="icon" - className="size-4! text-white/80 hover:text-white" + className="size-4! text-foreground/80 hover:text-foreground" onClick={onToggleExpanded} title="Exit fullscreen (Esc)" > @@ -776,7 +767,7 @@ function FullscreenPreview({ )} </div> </div> - <div className="p-4 bg-black"> + <div className="p-4 bg-background"> <FullscreenToolbar hasAnyElements={hasAnyElements} onToggleExpanded={toggleExpanded} @@ -807,37 +798,7 @@ function PreviewToolbar({ toggle: () => void; getTotalDuration: () => number; }) { - const { isPlaying, seek } = usePlaybackStore(); - const { setCanvasSize, setCanvasSizeToOriginal } = useEditorStore(); - const { activeProject } = useProjectStore(); - const { - currentPreset, - isOriginal, - getOriginalAspectRatio, - getDisplayName, - canvasPresets, - } = useAspectRatio(); - - const handlePresetSelect = (preset: { width: number; height: number }) => { - setCanvasSize({ width: preset.width, height: preset.height }); - }; - - const handleOriginalSelect = () => { - const aspectRatio = getOriginalAspectRatio(); - setCanvasSizeToOriginal(aspectRatio); - }; - - const totalDuration = getTotalDuration(); - - const skipBackward = () => { - const newTime = Math.max(0, currentTime - 1); - setCurrentTime(newTime); - }; - - const skipForward = () => { - const newTime = Math.min(totalDuration, currentTime + 1); - setCurrentTime(newTime); - }; + const { isPlaying } = usePlaybackStore(); if (isExpanded) { return ( @@ -857,50 +818,15 @@ function PreviewToolbar({ return ( <div data-toolbar - className="flex items-end justify-between gap-2 p-1 pt-2 w-full" + className="flex justify-between gap-2 px-1.5 pr-4 py-1.5 border border-border/50 w-auto absolute bottom-4 right-4 bg-black/20 rounded-full backdrop-blur-l text-white" > - <div> - <p - className={cn( - "text-[0.75rem] text-muted-foreground flex items-center gap-1 w-[10rem]", - !hasAnyElements && "opacity-50" - )} - > - <EditableTimecode - time={currentTime} - duration={getTotalDuration()} - format="HH:MM:SS:FF" - fps={activeProject?.fps || 30} - onTimeChange={seek} - disabled={!hasAnyElements} - /> - <span className="opacity-50">/</span> - <span className="tabular-nums"> - {formatTimeCode( - getTotalDuration(), - "HH:MM:SS:FF", - activeProject?.fps || 30 - )} - </span> - </p> - </div> - <div className="flex items-center gap-1"> - <Button - variant="text" - size="icon" - onClick={skipBackward} - disabled={!hasAnyElements} - className="h-auto p-0 text-white hover:text-white/80" - title="Skip backward 1s" - > - <SkipBack className="h-3 w-3" /> - </Button> + <div className="flex items-center gap-2"> <Button variant="text" size="icon" onClick={toggle} disabled={!hasAnyElements} - className="h-auto p-0 text-white hover:text-white/80" + className="h-auto p-0" > {isPlaying ? ( <Pause className="h-3 w-3" /> @@ -911,52 +837,7 @@ function PreviewToolbar({ <Button variant="text" size="icon" - onClick={skipForward} - disabled={!hasAnyElements} - className="h-auto p-0 text-white hover:text-white/80" - title="Skip forward 1s" - > - <SkipForward className="h-3 w-3" /> - </Button> - </div> - <div className="flex items-center gap-3"> - <BackgroundSettings /> - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button - size="sm" - className="bg-panel-accent! text-foreground/85 text-[0.70rem] h-4 rounded-none border border-muted-foreground px-0.5 py-0 font-light" - disabled={!hasAnyElements} - > - {getDisplayName()} - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent align="end"> - <DropdownMenuItem - onClick={handleOriginalSelect} - className={cn("text-xs", isOriginal && "font-semibold")} - > - Original - </DropdownMenuItem> - <DropdownMenuSeparator /> - {canvasPresets.map((preset) => ( - <DropdownMenuItem - key={preset.name} - onClick={() => handlePresetSelect(preset)} - className={cn( - "text-xs", - currentPreset?.name === preset.name && "font-semibold" - )} - > - {preset.name} - </DropdownMenuItem> - ))} - </DropdownMenuContent> - </DropdownMenu> - <Button - variant="text" - size="icon" - className="size-4! text-muted-foreground" + className="size-4!" onClick={onToggleExpanded} title="Enter fullscreen" > diff --git a/apps/web/src/components/editor/timeline/index.tsx b/apps/web/src/components/editor/timeline/index.tsx index 85d6a11e..8f41fbf3 100644 --- a/apps/web/src/components/editor/timeline/index.tsx +++ b/apps/web/src/components/editor/timeline/index.tsx @@ -20,6 +20,9 @@ import { ZoomIn, ZoomOut, Bookmark, + Eye, + MicOff, + Mic, } from "lucide-react"; import { Tooltip, @@ -544,7 +547,7 @@ export function Timeline() { {/* Timeline Header with Ruler */} <div className="flex bg-panel sticky top-0 z-10"> {/* Track Labels Header */} - <div className="w-48 shrink-0 bg-panel border-r flex items-center justify-between px-3 py-2"> + <div className="w-28 shrink-0 bg-panel border-r flex items-center justify-between px-3 py-2"> {/* Empty space */} <span className="text-sm font-medium text-muted-foreground opacity-0"> . @@ -652,14 +655,14 @@ export function Timeline() { ); }).filter(Boolean); })()} - + {/* Bookmark markers */} {(() => { const { activeProject } = useProjectStore.getState(); if (!activeProject?.bookmarks?.length) return null; - + return activeProject.bookmarks.map((bookmarkTime, i) => ( - <div + <div key={`bookmark-${i}`} className="absolute top-0 h-10 w-0.5 !bg-primary cursor-pointer" style={{ @@ -687,7 +690,7 @@ export function Timeline() { {tracks.length > 0 && ( <div ref={trackLabelsRef} - className="w-48 shrink-0 border-r border-black overflow-y-auto z-200 bg-panel" + className="w-28 shrink-0 border-r overflow-y-auto z-100 bg-panel" data-track-labels > <ScrollArea className="w-full h-full" ref={trackLabelsScrollRef}> @@ -695,17 +698,24 @@ export function Timeline() { {tracks.map((track) => ( <div key={track.id} - className="flex items-center px-3 border-b border-muted/30 group bg-foreground/5" + className="flex items-center px-3 group" style={{ height: `${getTrackHeight(track.type)}px` }} > - <div className="flex items-center flex-1 min-w-0"> + <div className="flex items-center justify-end flex-1 min-w-0 gap-2"> + {track.muted ? ( + <MicOff + className="h-4 w-4 text-destructive cursor-pointer" + onClick={() => toggleTrackMute(track.id)} + /> + ) : ( + <Mic + className="h-4 w-4 text-muted-foreground cursor-pointer" + onClick={() => toggleTrackMute(track.id)} + /> + )} + <Eye className="h-4 w-4 text-muted-foreground" /> <TrackIcon track={track} /> </div> - {track.muted && ( - <span className="ml-2 text-xs text-red-500 font-semibold shrink-0"> - Muted - </span> - )} </div> ))} </div> @@ -760,7 +770,7 @@ export function Timeline() { <ContextMenu key={track.id}> <ContextMenuTrigger asChild> <div - className="absolute left-0 right-0 border-b border-muted/30 py-[0.05rem]" + className="absolute left-0 right-0" style={{ top: `${getCumulativeHeightBefore( tracks, @@ -798,23 +808,6 @@ export function Timeline() { <ContextMenuItem onClick={(e) => e.stopPropagation()}> Track settings (soon) </ContextMenuItem> - {activeProject?.bookmarks?.length && activeProject.bookmarks.length > 0 && ( - <> - <ContextMenuItem disabled>Bookmarks</ContextMenuItem> - {activeProject.bookmarks.map((bookmarkTime, i) => ( - <ContextMenuItem - key={`bookmark-menu-${i}`} - onClick={(e) => { - e.stopPropagation(); - seek(bookmarkTime); - }} - > - <Bookmark className="h-3 w-3 mr-2 inline-block" /> - {bookmarkTime.toFixed(1)}s - </ContextMenuItem> - ))} - </> - )} </ContextMenuContent> </ContextMenu> ))} @@ -1000,11 +993,11 @@ function TimelineToolbar({ const handleZoomSliderChange = (values: number[]) => { setZoomLevel(values[0]); }; - + const handleToggleBookmark = async () => { await toggleBookmark(currentTime); }; - + // Check if the current time is bookmarked const currentBookmarked = isBookmarked(currentTime); return ( @@ -1142,7 +1135,9 @@ function TimelineToolbar({ <Tooltip> <TooltipTrigger asChild> <Button variant="text" size="icon" onClick={handleToggleBookmark}> - <Bookmark className={`h-4 w-4 ${currentBookmarked ? "fill-primary text-primary" : ""}`} /> + <Bookmark + className={`h-4 w-4 ${currentBookmarked ? "fill-primary text-primary" : ""}`} + /> </Button> </TooltipTrigger> <TooltipContent> diff --git a/apps/web/src/components/editor/timeline/timeline-element.tsx b/apps/web/src/components/editor/timeline/timeline-element.tsx index c7ed833e..b26a14bb 100644 --- a/apps/web/src/components/editor/timeline/timeline-element.tsx +++ b/apps/web/src/components/editor/timeline/timeline-element.tsx @@ -1,16 +1,8 @@ "use client"; -import { useState } from "react"; -import { Button } from "../../ui/button"; import { - MoreVertical, Scissors, Trash2, - SplitSquareHorizontal, - Music, - ChevronRight, - ChevronLeft, - Type, Copy, RefreshCw, EyeOff, @@ -23,7 +15,7 @@ import { useTimelineStore } from "@/stores/timeline-store"; import { usePlaybackStore } from "@/stores/playback-store"; import AudioWaveform from "../audio-waveform"; import { toast } from "sonner"; -import { TimelineElementProps, TrackType } from "@/types/timeline"; +import { TimelineElementProps } from "@/types/timeline"; import { useTimelineElementResize } from "@/hooks/use-timeline-element-resize"; import { getTrackElementClasses, @@ -31,16 +23,6 @@ import { getTrackHeight, } from "@/constants/timeline-constants"; import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, -} from "../../ui/dropdown-menu"; -import { ContextMenu, ContextMenuContent, ContextMenuItem, @@ -64,9 +46,6 @@ export function TimelineElement({ removeElementFromTrackWithRipple, dragState, splitElement, - splitAndKeepLeft, - splitAndKeepRight, - separateAudio, addElementToTrack, replaceElementMedia, rippleEditingEnabled, @@ -74,27 +53,20 @@ export function TimelineElement({ } = useTimelineStore(); const { currentTime } = usePlaybackStore(); - const [elementMenuOpen, setElementMenuOpen] = useState(false); - const mediaItem = element.type === "media" ? mediaItems.find((item) => item.id === element.mediaId) : null; const isAudio = mediaItem?.type === "audio"; - const { - resizing, - isResizing, - handleResizeStart, - handleResizeMove, - handleResizeEnd, - } = useTimelineElementResize({ - element, - track, - zoomLevel, - onUpdateTrim: updateElementTrim, - onUpdateDuration: updateElementDuration, - }); + const { resizing, handleResizeStart, handleResizeMove, handleResizeEnd } = + useTimelineElementResize({ + element, + track, + zoomLevel, + onUpdateTrim: updateElementTrim, + onUpdateDuration: updateElementDuration, + }); const effectiveDuration = element.duration - element.trimStart - element.trimEnd; @@ -193,9 +165,7 @@ export function TimelineElement({ if (element.type === "text") { return ( <div className="w-full h-full flex items-center justify-start pl-2"> - <span className="text-xs text-foreground/80 truncate"> - {element.content} - </span> + <span className="text-xs text-white truncate">{element.content}</span> </div> ); } @@ -212,86 +182,36 @@ export function TimelineElement({ const TILE_ASPECT_RATIO = 16 / 9; - if (mediaItem.type === "image") { + if ( + mediaItem.type === "image" || + (mediaItem.type === "video" && mediaItem.thumbnailUrl) + ) { // Calculate tile size based on 16:9 aspect ratio const trackHeight = getTrackHeight(track.type); - const tileHeight = trackHeight - 8; // Account for padding + const tileHeight = trackHeight; const tileWidth = tileHeight * TILE_ASPECT_RATIO; - return ( - <div className="w-full h-full flex items-center justify-center"> - <div className="bg-[#004D52] py-3 w-full h-full relative"> - {/* Background with tiled images */} - <div - className="absolute top-3 bottom-3 left-0 right-0" - style={{ - backgroundImage: mediaItem.url - ? `url(${mediaItem.url})` - : "none", - backgroundRepeat: "repeat-x", - backgroundSize: `${tileWidth}px ${tileHeight}px`, - backgroundPosition: "left center", - pointerEvents: "none", - }} - aria-label={`Tiled background of ${mediaItem.name}`} - /> - {/* Overlay with vertical borders */} - <div - className="absolute top-3 bottom-3 left-0 right-0 pointer-events-none" - style={{ - backgroundImage: `repeating-linear-gradient( - to right, - transparent 0px, - transparent ${tileWidth - 1}px, - rgba(255, 255, 255, 0.6) ${tileWidth - 1}px, - rgba(255, 255, 255, 0.6) ${tileWidth}px - )`, - backgroundPosition: "left center", - }} - /> - </div> - </div> - ); - } - - const VIDEO_TILE_PADDING = 16; - const OVERLAY_SPACE_MULTIPLIER = 1.5; - - if (mediaItem.type === "video" && mediaItem.thumbnailUrl) { - const trackHeight = getTrackHeight(track.type); - const tileHeight = trackHeight - 8; // Match image padding - const tileWidth = tileHeight * TILE_ASPECT_RATIO; + const imageUrl = + mediaItem.type === "image" ? mediaItem.url : mediaItem.thumbnailUrl; + const isImage = mediaItem.type === "image"; return ( <div className="w-full h-full flex items-center justify-center"> - <div className="bg-[#004D52] py-3 w-full h-full relative"> - {/* Background with tiled thumbnails */} + <div + className={`w-full h-full relative ${ + isSelected ? "bg-primary" : "bg-transparent" + }`} + > <div - className="absolute top-3 bottom-3 left-0 right-0" + className={`absolute top-[0.15rem] bottom-[0.15rem] left-0 right-0`} style={{ - backgroundImage: mediaItem.thumbnailUrl - ? `url(${mediaItem.thumbnailUrl})` - : "none", + backgroundImage: imageUrl ? `url(${imageUrl})` : "none", backgroundRepeat: "repeat-x", backgroundSize: `${tileWidth}px ${tileHeight}px`, backgroundPosition: "left center", pointerEvents: "none", }} - aria-label={`Tiled thumbnail of ${mediaItem.name}`} - /> - {/* Overlay with vertical borders */} - <div - className="absolute top-3 bottom-3 left-0 right-0 pointer-events-none" - style={{ - backgroundImage: `repeating-linear-gradient( - to right, - transparent 0px, - transparent ${tileWidth - 1}px, - rgba(255, 255, 255, 0.6) ${tileWidth - 1}px, - rgba(255, 255, 255, 0.6) ${tileWidth}px - )`, - backgroundPosition: "left center", - }} + aria-label={`Tiled ${isImage ? "background" : "thumbnail"} of ${mediaItem.name}`} /> </div> </div> @@ -346,7 +266,7 @@ export function TimelineElement({ <div className={`relative h-full rounded-[0.15rem] cursor-pointer overflow-hidden ${getTrackElementClasses( track.type - )} ${isSelected ? "border-b-[0.5px] border-t-[0.5px] border-foreground" : ""} ${ + )} ${isSelected ? "" : ""} ${ isBeingDragged ? "z-50" : "z-10" } ${element.hidden ? "opacity-50" : ""}`} onClick={(e) => onElementClick && onElementClick(e, element)} @@ -372,11 +292,11 @@ export function TimelineElement({ {isSelected && ( <> <div - className="absolute left-0 top-0 bottom-0 w-1 cursor-w-resize bg-foreground z-50" + className="absolute left-0 top-0 bottom-0 w-[0.2rem] cursor-w-resize bg-primary z-50" onMouseDown={(e) => handleResizeStart(e, element.id, "left")} /> <div - className="absolute right-0 top-0 bottom-0 w-1 cursor-e-resize bg-foreground z-50" + className="absolute right-0 top-0 bottom-0 w-[0.2rem] cursor-e-resize bg-primary z-50" onMouseDown={(e) => handleResizeStart(e, element.id, "right")} /> </> @@ -407,8 +327,8 @@ export function TimelineElement({ ? "Unmute" : "Mute" : element.hidden - ? "Show" - : "Hide"}{" "} + ? "Show" + : "Hide"}{" "} {element.type === "text" ? "text" : "clip"} </span> </ContextMenuItem> diff --git a/apps/web/src/components/editor/timeline/timeline-playhead.tsx b/apps/web/src/components/editor/timeline/timeline-playhead.tsx index ea70419b..695843a4 100644 --- a/apps/web/src/components/editor/timeline/timeline-playhead.tsx +++ b/apps/web/src/components/editor/timeline/timeline-playhead.tsx @@ -70,7 +70,7 @@ export function TimelinePlayhead({ // Use timeline container height minus a few pixels for breathing room const timelineContainerHeight = timelineRef.current?.offsetHeight || 400; - const totalHeight = timelineContainerHeight - 8; // 8px padding from edges + const totalHeight = timelineContainerHeight - 4; // Get dynamic track labels width, fallback to 0 if no tracks or no ref const trackLabelsWidth = @@ -137,12 +137,12 @@ export function TimelinePlayhead({ > {/* The playhead line spanning full height */} <div - className={`absolute left-0 w-0.5 cursor-col-resize h-full ${isSnappingToPlayhead ? "bg-primary" : "bg-foreground"}`} + className={`absolute left-0 w-0.5 cursor-col-resize h-full ${isSnappingToPlayhead ? "bg-foreground" : "bg-foreground"}`} /> {/* Playhead dot indicator at the top (in ruler area) */} <div - className={`absolute top-1 left-1/2 transform -translate-x-1/2 w-3 h-3 rounded-full border-2 shadow-xs ${isSnappingToPlayhead ? "bg-primary border-primary" : "bg-foreground border-foreground"}`} + className={`absolute top-1 left-1/2 transform -translate-x-1/2 w-3 h-3 rounded-full border-2 shadow-xs ${isSnappingToPlayhead ? "bg-foreground border-foreground" : "bg-foreground border-foreground/50"}`} /> </div> ); diff --git a/apps/web/src/components/keyboard-shortcuts-help.tsx b/apps/web/src/components/keyboard-shortcuts-help.tsx index 15b1e1d8..d4d121d9 100644 --- a/apps/web/src/components/keyboard-shortcuts-help.tsx +++ b/apps/web/src/components/keyboard-shortcuts-help.tsx @@ -96,7 +96,7 @@ const EditableShortcutKey = ({ className={`inline-flex font-sans text-xs rounded px-2 min-w-6 min-h-6 leading-none items-center justify-center shadow-xs border mr-1 cursor-pointer hover:bg-opacity-80 ${ isRecording ? "border-primary bg-primary/10" - : "border-white/10 bg-black/20" + : "border bg-accent" }`} onClick={handleClick} title={ diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx index e5c5d4cc..d3656790 100644 --- a/apps/web/src/components/ui/button.tsx +++ b/apps/web/src/components/ui/button.tsx @@ -19,7 +19,7 @@ const buttonVariants = cva( "border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground", secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", - text: "bg-transparent p-0 rounded-none hover:text-muted-foreground", // Instead of ghost (matches app better) + text: "bg-transparent p-0 rounded-none opacity-100 hover:opacity-50 transition-opacity", // Instead of ghost (matches app better) link: "text-primary underline-offset-4 hover:underline", }, size: { diff --git a/apps/web/src/components/ui/draggable-item.tsx b/apps/web/src/components/ui/draggable-item.tsx index 4c71e4b7..ff6766fd 100644 --- a/apps/web/src/components/ui/draggable-item.tsx +++ b/apps/web/src/components/ui/draggable-item.tsx @@ -24,6 +24,7 @@ export interface DraggableMediaItemProps { showPlusOnDrag?: boolean; showLabel?: boolean; rounded?: boolean; + variant?: "card" | "compact"; } export function DraggableMediaItem({ @@ -37,6 +38,7 @@ export function DraggableMediaItem({ showPlusOnDrag = true, showLabel = true, rounded = true, + variant = "card", }: DraggableMediaItemProps) { const [isDragging, setIsDragging] = useState(false); const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 }); @@ -88,42 +90,62 @@ export function DraggableMediaItem({ return ( <> - <div ref={dragRef} className="relative group w-28 h-28"> - <div - className={`flex flex-col gap-1 p-0 h-auto w-full relative cursor-default ${className}`} - > - <AspectRatio - ratio={aspectRatio} + {variant === "card" ? ( + <div ref={dragRef} className="relative group w-28 h-28"> + <div + className={`flex flex-col gap-1 p-0 h-auto w-full relative cursor-default ${className}`} + > + <AspectRatio + ratio={aspectRatio} + className={cn( + "bg-panel-accent relative overflow-hidden", + rounded && "rounded-md", + "[&::-webkit-drag-ghost]:opacity-0" // Webkit-specific ghost hiding + )} + draggable={true} + onDragStart={handleDragStart} + onDragEnd={handleDragEnd} + > + {preview} + {!isDragging && ( + <PlusButton + className="opacity-0 group-hover:opacity-100" + onClick={handleAddToTimeline} + /> + )} + </AspectRatio> + {showLabel && ( + <span + className="text-[0.7rem] text-muted-foreground truncate w-full text-left" + aria-label={name} + title={name} + > + {name.length > 8 + ? `${name.slice(0, 16)}...${name.slice(-3)}` + : name} + </span> + )} + </div> + </div> + ) : ( + <div ref={dragRef} className="relative group w-full"> + <div className={cn( - "bg-accent relative overflow-hidden", - rounded && "rounded-md", - "[&::-webkit-drag-ghost]:opacity-0" // Webkit-specific ghost hiding + "h-10 flex items-center gap-3 cursor-default w-full", + "[&::-webkit-drag-ghost]:opacity-0", + className )} draggable={true} onDragStart={handleDragStart} onDragEnd={handleDragEnd} > - {preview} - {!isDragging && ( - <PlusButton - className="opacity-0 group-hover:opacity-100" - onClick={handleAddToTimeline} - /> - )} - </AspectRatio> - {showLabel && ( - <span - className="text-[0.7rem] text-muted-foreground truncate w-full text-left" - aria-label={name} - title={name} - > - {name.length > 8 - ? `${name.slice(0, 16)}...${name.slice(-3)}` - : name} - </span> - )} + <div className="w-6 h-6 flex-shrink-0 rounded overflow-hidden"> + {preview} + </div> + <span className="text-sm truncate flex-1 w-full">{name}</span> + </div> </div> - </div> + )} {/* Custom drag preview */} {isDragging && @@ -171,7 +193,10 @@ function PlusButton({ const button = ( <Button size="icon" - className={cn("absolute bottom-2 right-2 size-4", className)} + className={cn( + "absolute bottom-2 right-2 size-4 bg-background text-foreground", + className + )} onClick={(e) => { e.preventDefault(); e.stopPropagation(); diff --git a/apps/web/src/components/ui/input.tsx b/apps/web/src/components/ui/input.tsx index 6f32885c..d4cd57a1 100644 --- a/apps/web/src/components/ui/input.tsx +++ b/apps/web/src/components/ui/input.tsx @@ -23,7 +23,9 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>( <input type={inputType} className={cn( - "flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", + "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", + "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[2px]", + "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", showPasswordToggle && "pr-10", className )} diff --git a/apps/web/src/constants/timeline-constants.ts b/apps/web/src/constants/timeline-constants.ts index 5d446d50..4516775f 100644 --- a/apps/web/src/constants/timeline-constants.ts +++ b/apps/web/src/constants/timeline-constants.ts @@ -7,18 +7,18 @@ export const TRACK_COLORS: Record< > = { media: { solid: "bg-blue-500", - background: "bg-blue-500/20", - border: "border-white/80", + background: "", + border: "", }, text: { - solid: "bg-[#9C4937]", - background: "bg-[#9C4937]", - border: "border-white/80", + solid: "bg-[#5DBAA0]", + background: "bg-[#5DBAA0]", + border: "", }, audio: { solid: "bg-green-500", - background: "bg-green-500/20", - border: "border-white/80", + background: "bg-[#915DBE]", + border: "", }, } as const; @@ -34,7 +34,7 @@ export function getTrackElementClasses(type: TrackType) { // Track height definitions export const TRACK_HEIGHTS: Record<TrackType, number> = { - media: 65, + media: 60, text: 25, audio: 50, } as const; diff --git a/apps/web/src/stores/panel-store.ts b/apps/web/src/stores/panel-store.ts index b97235fa..ebbce3c0 100644 --- a/apps/web/src/stores/panel-store.ts +++ b/apps/web/src/stores/panel-store.ts @@ -17,12 +17,15 @@ interface PanelState { mainContent: number; timeline: number; + mediaViewMode: "grid" | "list"; + // Actions setToolsPanel: (size: number) => void; setPreviewPanel: (size: number) => void; setPropertiesPanel: (size: number) => void; setMainContent: (size: number) => void; setTimeline: (size: number) => void; + setMediaViewMode: (mode: "grid" | "list") => void; } export const usePanelStore = create<PanelState>()( @@ -31,12 +34,15 @@ export const usePanelStore = create<PanelState>()( // Default sizes - optimized for responsiveness ...DEFAULT_PANEL_SIZES, + mediaViewMode: "grid" as const, + // Actions setToolsPanel: (size) => set({ toolsPanel: size }), setPreviewPanel: (size) => set({ previewPanel: size }), setPropertiesPanel: (size) => set({ propertiesPanel: size }), setMainContent: (size) => set({ mainContent: size }), setTimeline: (size) => set({ timeline: size }), + setMediaViewMode: (mode) => set({ mediaViewMode: mode }), }), { name: "panel-sizes", |
