summaryrefslogtreecommitdiff
path: root/apps/web/src
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src')
-rw-r--r--apps/web/src/app/editor/[project_id]/page.tsx6
-rw-r--r--apps/web/src/app/globals.css83
-rw-r--r--apps/web/src/app/layout.tsx2
-rw-r--r--apps/web/src/components/editor-header.tsx36
-rw-r--r--apps/web/src/components/editor/media-panel/index.tsx6
-rw-r--r--apps/web/src/components/editor/media-panel/tabbar.tsx5
-rw-r--r--apps/web/src/components/editor/media-panel/views/audio.tsx1
-rw-r--r--apps/web/src/components/editor/media-panel/views/media.tsx507
-rw-r--r--apps/web/src/components/editor/media-panel/views/text.tsx2
-rw-r--r--apps/web/src/components/editor/preview-panel.tsx157
-rw-r--r--apps/web/src/components/editor/timeline/index.tsx61
-rw-r--r--apps/web/src/components/editor/timeline/timeline-element.tsx142
-rw-r--r--apps/web/src/components/editor/timeline/timeline-playhead.tsx6
-rw-r--r--apps/web/src/components/keyboard-shortcuts-help.tsx2
-rw-r--r--apps/web/src/components/ui/button.tsx2
-rw-r--r--apps/web/src/components/ui/draggable-item.tsx85
-rw-r--r--apps/web/src/components/ui/input.tsx4
-rw-r--r--apps/web/src/constants/timeline-constants.ts16
-rw-r--r--apps/web/src/stores/panel-store.ts6
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",