diff options
Diffstat (limited to 'apps/web/src/components')
18 files changed, 2151 insertions, 1558 deletions
diff --git a/apps/web/src/components/development-debug.tsx b/apps/web/src/components/development-debug.tsx index aef69999..3b891e38 100644 --- a/apps/web/src/components/development-debug.tsx +++ b/apps/web/src/components/development-debug.tsx @@ -1,20 +1,18 @@ "use client";
-import {
- useTimelineStore,
- type TimelineClip,
- type TimelineTrack,
-} from "@/stores/timeline-store";
+import { useTimelineStore } from "@/stores/timeline-store";
import { useMediaStore, type MediaItem } from "@/stores/media-store";
+import { TimelineTrack } from "@/types/timeline";
import { usePlaybackStore } from "@/stores/playback-store";
import { Button } from "@/components/ui/button";
import { useState } from "react";
+import type { TimelineElement } from "@/types/timeline";
// Only show in development
const SHOW_DEBUG_INFO = process.env.NODE_ENV === "development";
-interface ActiveClip {
- clip: TimelineClip;
+interface ActiveElement {
+ element: TimelineElement;
track: TimelineTrack;
mediaItem: MediaItem | null;
}
@@ -28,31 +26,32 @@ export function DevelopmentDebug() { // Don't render anything in production
if (!SHOW_DEBUG_INFO) return null;
- // Get active clips at current time
- const getActiveClips = (): ActiveClip[] => {
- const activeClips: ActiveClip[] = [];
+ // Get active elements at current time
+ const getActiveElements = (): ActiveElement[] => {
+ const activeElements: ActiveElement[] = [];
tracks.forEach((track) => {
- track.clips.forEach((clip) => {
- const clipStart = clip.startTime;
- const clipEnd =
- clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
+ track.elements.forEach((element) => {
+ const elementStart = element.startTime;
+ const elementEnd =
+ element.startTime +
+ (element.duration - element.trimStart - element.trimEnd);
- if (currentTime >= clipStart && currentTime < clipEnd) {
+ if (currentTime >= elementStart && currentTime < elementEnd) {
const mediaItem =
- clip.mediaId === "test"
- ? null // Test clips don't have a real media item
- : mediaItems.find((item) => item.id === clip.mediaId) || null;
+ element.type === "media"
+ ? mediaItems.find((item) => item.id === element.mediaId) || null
+ : null; // Text elements don't have media items
- activeClips.push({ clip, track, mediaItem });
+ activeElements.push({ element, track, mediaItem });
}
});
});
- return activeClips;
+ return activeElements;
};
- const activeClips = getActiveClips();
+ const activeElements = getActiveElements();
return (
<div className="fixed bottom-4 right-4 z-50">
@@ -71,28 +70,30 @@ export function DevelopmentDebug() { {showDebug && (
<div className="backdrop-blur-md bg-background/90 border border-border/50 rounded-lg p-3 max-w-sm">
<div className="text-xs font-medium mb-2 text-foreground">
- Active Clips ({activeClips.length})
+ Active Elements ({activeElements.length})
</div>
<div className="space-y-1 max-h-40 overflow-y-auto">
- {activeClips.map((clipData, index) => (
+ {activeElements.map((elementData, index) => (
<div
- key={clipData.clip.id}
+ key={elementData.element.id}
className="flex items-center gap-2 px-2 py-1 bg-muted/60 rounded text-xs"
>
<span className="w-4 h-4 bg-primary/20 rounded text-center text-xs leading-4 flex-shrink-0">
{index + 1}
</span>
<div className="min-w-0 flex-1">
- <div className="truncate">{clipData.clip.name}</div>
+ <div className="truncate">{elementData.element.name}</div>
<div className="text-muted-foreground text-[10px]">
- {clipData.mediaItem?.type || "test"}
+ {elementData.element.type === "media"
+ ? elementData.mediaItem?.type || "media"
+ : "text"}
</div>
</div>
</div>
))}
- {activeClips.length === 0 && (
+ {activeElements.length === 0 && (
<div className="text-muted-foreground text-xs py-2 text-center">
- No active clips
+ No active elements
</div>
)}
</div>
diff --git a/apps/web/src/components/editor-header.tsx b/apps/web/src/components/editor-header.tsx index e3d2359e..daeb14cb 100644 --- a/apps/web/src/components/editor-header.tsx +++ b/apps/web/src/components/editor-header.tsx @@ -37,7 +37,7 @@ export function EditorHeader() { const rightContent = (
<nav className="flex items-center gap-2">
- <Button size="sm" onClick={handleExport}>
+ <Button size="sm" variant="primary" onClick={handleExport}>
<Download className="h-4 w-4" />
<span className="text-sm">Export</span>
</Button>
diff --git a/apps/web/src/components/editor/media-panel/index.tsx b/apps/web/src/components/editor/media-panel/index.tsx new file mode 100644 index 00000000..51e9a264 --- /dev/null +++ b/apps/web/src/components/editor/media-panel/index.tsx @@ -0,0 +1,55 @@ +"use client";
+
+import { TabBar } from "./tabbar";
+import { MediaView } from "./views/media";
+import { useMediaPanelStore, Tab } from "./store";
+import { TextView } from "./views/text";
+
+export function MediaPanel() {
+ const { activeTab } = useMediaPanelStore();
+
+ const viewMap: Record<Tab, React.ReactNode> = {
+ media: <MediaView />,
+ audio: (
+ <div className="p-4 text-muted-foreground">Audio view coming soon...</div>
+ ),
+ text: <TextView />,
+ stickers: (
+ <div className="p-4 text-muted-foreground">
+ Stickers view coming soon...
+ </div>
+ ),
+ effects: (
+ <div className="p-4 text-muted-foreground">
+ Effects view coming soon...
+ </div>
+ ),
+ transitions: (
+ <div className="p-4 text-muted-foreground">
+ Transitions view coming soon...
+ </div>
+ ),
+ captions: (
+ <div className="p-4 text-muted-foreground">
+ Captions view coming soon...
+ </div>
+ ),
+ filters: (
+ <div className="p-4 text-muted-foreground">
+ Filters view coming soon...
+ </div>
+ ),
+ adjustment: (
+ <div className="p-4 text-muted-foreground">
+ Adjustment view coming soon...
+ </div>
+ ),
+ };
+
+ return (
+ <div className="h-full flex flex-col">
+ <TabBar />
+ <div className="flex-1">{viewMap[activeTab]}</div>
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/media-panel/store.ts b/apps/web/src/components/editor/media-panel/store.ts new file mode 100644 index 00000000..9668f285 --- /dev/null +++ b/apps/web/src/components/editor/media-panel/store.ts @@ -0,0 +1,73 @@ +import {
+ CaptionsIcon,
+ ArrowLeftRightIcon,
+ SparklesIcon,
+ StickerIcon,
+ MusicIcon,
+ VideoIcon,
+ BlendIcon,
+ SlidersHorizontalIcon,
+ LucideIcon,
+ TypeIcon,
+} from "lucide-react";
+import { create } from "zustand";
+
+export type Tab =
+ | "media"
+ | "audio"
+ | "text"
+ | "stickers"
+ | "effects"
+ | "transitions"
+ | "captions"
+ | "filters"
+ | "adjustment";
+
+export const tabs: { [key in Tab]: { icon: LucideIcon; label: string } } = {
+ media: {
+ icon: VideoIcon,
+ label: "Media",
+ },
+ audio: {
+ icon: MusicIcon,
+ label: "Audio",
+ },
+ text: {
+ icon: TypeIcon,
+ label: "Text",
+ },
+ stickers: {
+ icon: StickerIcon,
+ label: "Stickers",
+ },
+ effects: {
+ icon: SparklesIcon,
+ label: "Effects",
+ },
+ transitions: {
+ icon: ArrowLeftRightIcon,
+ label: "Transitions",
+ },
+ captions: {
+ icon: CaptionsIcon,
+ label: "Captions",
+ },
+ filters: {
+ icon: BlendIcon,
+ label: "Filters",
+ },
+ adjustment: {
+ icon: SlidersHorizontalIcon,
+ label: "Adjustment",
+ },
+};
+
+interface MediaPanelStore {
+ activeTab: Tab;
+ setActiveTab: (tab: Tab) => void;
+}
+
+export const useMediaPanelStore = create<MediaPanelStore>((set) => ({
+ activeTab: "media",
+ setActiveTab: (tab) => set({ activeTab: tab }),
+}));
diff --git a/apps/web/src/components/editor/media-panel/tabbar.tsx b/apps/web/src/components/editor/media-panel/tabbar.tsx new file mode 100644 index 00000000..580394b9 --- /dev/null +++ b/apps/web/src/components/editor/media-panel/tabbar.tsx @@ -0,0 +1,124 @@ +"use client";
+
+import { cn } from "@/lib/utils";
+import { Tab, tabs, useMediaPanelStore } from "./store";
+import { Button } from "@/components/ui/button";
+import { ChevronRight, ChevronLeft } from "lucide-react";
+import { useRef, useState, useEffect } from "react";
+
+export function TabBar() {
+ const { activeTab, setActiveTab } = useMediaPanelStore();
+ const scrollContainerRef = useRef<HTMLDivElement>(null);
+ const [isAtEnd, setIsAtEnd] = useState(false);
+ const [isAtStart, setIsAtStart] = useState(true);
+
+ const scrollToEnd = () => {
+ if (scrollContainerRef.current) {
+ scrollContainerRef.current.scrollTo({
+ left: scrollContainerRef.current.scrollWidth,
+ });
+ setIsAtEnd(true);
+ setIsAtStart(false);
+ }
+ };
+
+ const scrollToStart = () => {
+ if (scrollContainerRef.current) {
+ scrollContainerRef.current.scrollTo({
+ left: 0,
+ });
+ setIsAtStart(true);
+ setIsAtEnd(false);
+ }
+ };
+
+ const checkScrollPosition = () => {
+ if (scrollContainerRef.current) {
+ const { scrollLeft, scrollWidth, clientWidth } =
+ scrollContainerRef.current;
+ const isAtEndNow = scrollLeft + clientWidth >= scrollWidth - 1;
+ const isAtStartNow = scrollLeft <= 1;
+ setIsAtEnd(isAtEndNow);
+ setIsAtStart(isAtStartNow);
+ }
+ };
+
+ // We're using useEffect because we need to sync with external DOM scroll events
+ useEffect(() => {
+ const container = scrollContainerRef.current;
+ if (!container) return;
+
+ checkScrollPosition();
+ container.addEventListener("scroll", checkScrollPosition);
+
+ const resizeObserver = new ResizeObserver(checkScrollPosition);
+ resizeObserver.observe(container);
+
+ return () => {
+ container.removeEventListener("scroll", checkScrollPosition);
+ resizeObserver.disconnect();
+ };
+ }, []);
+
+ return (
+ <div className="flex">
+ <ScrollButton
+ direction="left"
+ onClick={scrollToStart}
+ isVisible={!isAtStart}
+ />
+ <div
+ ref={scrollContainerRef}
+ className="h-12 bg-accent/50 px-3 flex justify-start items-center gap-5 overflow-x-auto scrollbar-x-hidden relative"
+ >
+ {(Object.keys(tabs) as Tab[]).map((tabKey) => {
+ const tab = tabs[tabKey];
+ return (
+ <div
+ className={cn(
+ "flex flex-col gap-0.5 items-center cursor-pointer",
+ activeTab === tabKey ? "text-primary" : "text-muted-foreground"
+ )}
+ onClick={() => setActiveTab(tabKey)}
+ key={tabKey}
+ >
+ <tab.icon className="!size-[1.1rem]" />
+ <span className="text-[0.65rem]">{tab.label}</span>
+ </div>
+ );
+ })}
+ </div>
+ <ScrollButton
+ direction="right"
+ onClick={scrollToEnd}
+ isVisible={!isAtEnd}
+ />
+ </div>
+ );
+}
+
+function ScrollButton({
+ direction,
+ onClick,
+ isVisible,
+}: {
+ direction: "left" | "right";
+ onClick: () => void;
+ isVisible: boolean;
+}) {
+ if (!isVisible) return null;
+
+ const Icon = direction === "left" ? ChevronLeft : ChevronRight;
+
+ return (
+ <div className="bg-accent/50 w-12 h-full flex items-center justify-center">
+ <Button
+ size="icon"
+ className="rounded-[0.4rem] w-4 h-7 !bg-foreground/10"
+ onClick={onClick}
+ >
+ <Icon className="!size-4 text-foreground" />
+ </Button>
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/media-panel.tsx b/apps/web/src/components/editor/media-panel/views/media.tsx index 3d83426e..f975496e 100644 --- a/apps/web/src/components/editor/media-panel.tsx +++ b/apps/web/src/components/editor/media-panel/views/media.tsx @@ -1,354 +1,318 @@ -"use client"; - -import { useDragDrop } from "@/hooks/use-drag-drop"; -import { processMediaFiles } from "@/lib/media-processing"; -import { useMediaStore, type MediaItem } from "@/stores/media-store"; -import { useTimelineStore } from "@/stores/timeline-store"; -import { Image, Music, Plus, Upload, Video } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; -import { toast } from "sonner"; -import { AspectRatio } from "../ui/aspect-ratio"; -import { Button } from "../ui/button"; -import { DragOverlay } from "../ui/drag-overlay"; -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuTrigger, -} from "../ui/context-menu"; -import { Input } from "../ui/input"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "../ui/select"; - -// MediaPanel lets users add, view, and drag media (images, videos, audio) into the project. -// You can upload files or drag them from your computer. Dragging from here to the timeline adds them to your video project. - -export function MediaPanel() { - const { mediaItems, addMediaItem, removeMediaItem } = useMediaStore(); - 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 processFiles = async (files: FileList | File[]) => { - if (!files || files.length === 0) return; - setIsProcessing(true); - setProgress(0); - try { - // Process files (extract metadata, generate thumbnails, etc.) - const processedItems = await processMediaFiles(files, (p) => - setProgress(p) - ); - // Add each processed media item to the store - for (const item of processedItems) { - await addMediaItem(item); - } - } catch (error) { - // Show error toast if processing fails - console.error("Error processing files:", error); - toast.error("Failed to process files"); - } finally { - setIsProcessing(false); - setProgress(0); - } - }; - - const { isDragOver, dragProps } = useDragDrop({ - // When files are dropped, process them - onDrop: processFiles, - }); - - const handleFileSelect = () => fileInputRef.current?.click(); // Open file picker - - const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { - // When files are selected via file picker, process them - if (e.target.files) processFiles(e.target.files); - e.target.value = ""; // Reset input - }; - - const handleRemove = async (e: React.MouseEvent, id: string) => { - // Remove a media item from the store - e.stopPropagation(); - - // Remove tracks automatically when delete media - const { tracks, removeTrack } = useTimelineStore.getState(); - tracks.forEach((track) => { - const clipsToRemove = track.clips.filter((clip) => clip.mediaId === id); - clipsToRemove.forEach((clip) => { - useTimelineStore.getState().removeClipFromTrack(track.id, clip.id); - }); - // Only remove track if it becomes empty and has no other clips - const updatedTrack = useTimelineStore - .getState() - .tracks.find((t) => t.id === track.id); - if (updatedTrack && updatedTrack.clips.length === 0) { - removeTrack(track.id); - } - }); - await removeMediaItem(id); - }; - - const formatDuration = (duration: number) => { - // Format seconds as mm:ss - const min = Math.floor(duration / 60); - const sec = Math.floor(duration % 60); - return `${min}:${sec.toString().padStart(2, "0")}`; - }; - - const startDrag = (e: React.DragEvent, item: MediaItem) => { - // When dragging a media item, set drag data for timeline to read - e.dataTransfer.setData( - "application/x-media-item", - JSON.stringify({ - id: item.id, - type: item.type, - name: item.name, - }) - ); - e.dataTransfer.effectAllowed = "copy"; - }; - - const [filteredMediaItems, setFilteredMediaItems] = useState(mediaItems); - - useEffect(() => { - const filtered = mediaItems.filter((item) => { - if (mediaFilter && mediaFilter !== "all" && item.type !== mediaFilter) { - return false; - } - - if ( - searchQuery && - !item.name.toLowerCase().includes(searchQuery.toLowerCase()) - ) { - return false; - } - - return true; - }); - - setFilteredMediaItems(filtered); - }, [mediaItems, mediaFilter, searchQuery]); - - const renderPreview = (item: MediaItem) => { - // Render a preview for each media type (image, video, audio, unknown) - // Each preview is draggable to the timeline - const baseDragProps = { - draggable: true, - onDragStart: (e: React.DragEvent) => startDrag(e, item), - }; - - 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 rounded" - loading="lazy" - {...baseDragProps} - /> - </div> - ); - } - - if (item.type === "video") { - if (item.thumbnailUrl) { - return ( - <div className="relative w-full h-full" {...baseDragProps}> - <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> - ); - } - return ( - <div - className="w-full h-full bg-muted/30 flex flex-col items-center justify-center text-muted-foreground rounded" - {...baseDragProps} - > - <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-gradient-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" - {...baseDragProps} - > - <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> - ); - } - - return ( - <div - className="w-full h-full bg-muted/30 flex flex-col items-center justify-center text-muted-foreground rounded" - {...baseDragProps} - > - <Image className="h-6 w-6" /> - <span className="text-xs mt-1">Unknown</span> - </div> - ); - }; - - return ( - <> - {/* Hidden file input for uploading media */} - <input - ref={fileInputRef} - type="file" - accept="image/*,video/*,audio/*" - multiple - className="hidden" - onChange={handleFileChange} - /> - - <div - className={`h-full flex flex-col transition-colors relative ${isDragOver ? "bg-accent/30" : ""}`} - {...dragProps} - > - {/* Show overlay when dragging files over the panel */} - <DragOverlay isVisible={isDragOver} /> - - <div className="p-2 border-b"> - {/* Button to add/upload media */} - <div className="flex gap-2"> - {/* Search and filter controls */} - <Select value={mediaFilter} onValueChange={setMediaFilter}> - <SelectTrigger className="w-[80px] h-7 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-7 text-xs" - value={searchQuery} - onChange={(e) => setSearchQuery(e.target.value)} - /> - - {/* Add media button */} - <Button - variant="outline" - size="sm" - onClick={handleFileSelect} - disabled={isProcessing} - className="flex-none min-w-[30px] whitespace-nowrap overflow-hidden px-2 justify-center items-center" - > - {isProcessing ? ( - <> - <Upload className="h-4 w-4 animate-spin" /> - <span className="hidden md:inline ml-2">{progress}%</span> - </> - ) : ( - <> - <Plus className="h-4 w-4" /> - <span className="hidden sm:inline ml-2" aria-label="Add file"> - Add - </span> - </> - )} - </Button> - </div> - </div> - - <div className="flex-1 overflow-y-auto p-2"> - {/* Show message if no media, otherwise show media grid */} - {filteredMediaItems.length === 0 ? ( - <div className="flex flex-col items-center justify-center py-8 text-center h-full"> - <div className="w-16 h-16 rounded-full bg-muted/30 flex items-center justify-center mb-4"> - <Image className="h-8 w-8 text-muted-foreground" /> - </div> - <p className="text-sm text-muted-foreground"> - No media in project - </p> - <p className="text-xs text-muted-foreground/70 mt-1"> - Drag files here or use the button above - </p> - </div> - ) : ( - <div - className="grid gap-2" - style={{ - gridTemplateColumns: "repeat(auto-fill, 160px)", - }} - > - {/* Render each media item as a draggable button */} - {filteredMediaItems.map((item) => ( - <div key={item.id} className="relative group"> - <ContextMenu> - <ContextMenuTrigger asChild> - <Button - variant="outline" - className="flex flex-col gap-1 p-2 h-auto w-full relative border-none !bg-transparent cursor-default" - > - <AspectRatio ratio={16 / 9} className="bg-accent"> - {renderPreview(item)} - </AspectRatio> - <span - className="text-[0.7rem] text-muted-foreground truncate w-full text-left" - aria-label={item.name} - title={item.name} - > - {item.name.length > 8 - ? `${item.name.slice(0, 4)}...${item.name.slice(-3)}` - : item.name} - </span> - </Button> - </ContextMenuTrigger> - <ContextMenuContent> - <ContextMenuItem>Export clips</ContextMenuItem> - <ContextMenuItem - variant="destructive" - onClick={(e) => handleRemove(e, item.id)} - > - Delete - </ContextMenuItem> - </ContextMenuContent> - </ContextMenu> - </div> - ))} - </div> - )} - </div> - </div> - </> - ); -} +"use client";
+
+import { useDragDrop } from "@/hooks/use-drag-drop";
+import { processMediaFiles } from "@/lib/media-processing";
+import { useMediaStore, type MediaItem } from "@/stores/media-store";
+import { useTimelineStore } from "@/stores/timeline-store";
+import { Image, Music, Plus, Upload, Video } from "lucide-react";
+import { useEffect, useRef, useState } from "react";
+import { toast } from "sonner";
+import { Button } from "@/components/ui/button";
+import { DragOverlay } from "@/components/ui/drag-overlay";
+import {
+ ContextMenu,
+ ContextMenuContent,
+ ContextMenuItem,
+ ContextMenuTrigger,
+} from "@/components/ui/context-menu";
+import { Input } from "@/components/ui/input";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { DraggableMediaItem } from "@/components/ui/draggable-item";
+
+export function MediaView() {
+ const { mediaItems, addMediaItem, removeMediaItem } = useMediaStore();
+ 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 processFiles = async (files: FileList | File[]) => {
+ if (!files || files.length === 0) return;
+ setIsProcessing(true);
+ setProgress(0);
+ try {
+ // Process files (extract metadata, generate thumbnails, etc.)
+ const processedItems = await processMediaFiles(files, (p) =>
+ setProgress(p)
+ );
+ // Add each processed media item to the store
+ for (const item of processedItems) {
+ await addMediaItem(item);
+ }
+ } catch (error) {
+ // Show error toast if processing fails
+ console.error("Error processing files:", error);
+ toast.error("Failed to process files");
+ } finally {
+ setIsProcessing(false);
+ setProgress(0);
+ }
+ };
+
+ const { isDragOver, dragProps } = useDragDrop({
+ // When files are dropped, process them
+ onDrop: processFiles,
+ });
+
+ const handleFileSelect = () => fileInputRef.current?.click(); // Open file picker
+
+ const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+ // When files are selected via file picker, process them
+ if (e.target.files) processFiles(e.target.files);
+ e.target.value = ""; // Reset input
+ };
+
+ const handleRemove = async (e: React.MouseEvent, id: string) => {
+ // Remove a media item from the store
+ e.stopPropagation();
+
+ // Remove elements automatically when delete media
+ const { tracks, removeTrack } = useTimelineStore.getState();
+ tracks.forEach((track) => {
+ const elementsToRemove = track.elements.filter(
+ (element) => element.type === "media" && element.mediaId === id
+ );
+ elementsToRemove.forEach((element) => {
+ useTimelineStore
+ .getState()
+ .removeElementFromTrack(track.id, element.id);
+ });
+ // Only remove track if it becomes empty and has no other elements
+ const updatedTrack = useTimelineStore
+ .getState()
+ .tracks.find((t) => t.id === track.id);
+ if (updatedTrack && updatedTrack.elements.length === 0) {
+ removeTrack(track.id);
+ }
+ });
+ await removeMediaItem(id);
+ };
+
+ const formatDuration = (duration: number) => {
+ // Format seconds as mm:ss
+ const min = Math.floor(duration / 60);
+ const sec = Math.floor(duration % 60);
+ return `${min}:${sec.toString().padStart(2, "0")}`;
+ };
+
+ const [filteredMediaItems, setFilteredMediaItems] = useState(mediaItems);
+
+ useEffect(() => {
+ const filtered = mediaItems.filter((item) => {
+ if (mediaFilter && mediaFilter !== "all" && item.type !== mediaFilter) {
+ return false;
+ }
+
+ if (
+ searchQuery &&
+ !item.name.toLowerCase().includes(searchQuery.toLowerCase())
+ ) {
+ return false;
+ }
+
+ return true;
+ });
+
+ setFilteredMediaItems(filtered);
+ }, [mediaItems, mediaFilter, searchQuery]);
+
+ 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>
+ );
+ }
+
+ if (item.type === "video") {
+ if (item.thumbnailUrl) {
+ return (
+ <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>
+ );
+ }
+ 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-gradient-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>
+ );
+ }
+
+ 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 (
+ <>
+ {/* Hidden file input for uploading media */}
+ <input
+ ref={fileInputRef}
+ type="file"
+ accept="image/*,video/*,audio/*"
+ multiple
+ className="hidden"
+ onChange={handleFileChange}
+ />
+
+ <div
+ className={`h-full flex flex-col gap-1 transition-colors relative ${isDragOver ? "bg-accent/30" : ""}`}
+ {...dragProps}
+ >
+ {/* Show overlay when dragging files over the panel */}
+ <DragOverlay isVisible={isDragOver} />
+
+ <div className="p-3 pb-2">
+ {/* Button to add/upload media */}
+ <div className="flex gap-2">
+ {/* Search and filter controls */}
+ <Select value={mediaFilter} onValueChange={setMediaFilter}>
+ <SelectTrigger className="w-[80px] h-full text-xs">
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent className="">
+ <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-full text-xs"
+ value={searchQuery}
+ onChange={(e) => setSearchQuery(e.target.value)}
+ />
+
+ {/* Add media button */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleFileSelect}
+ disabled={isProcessing}
+ className="flex-none min-w-[30px] whitespace-nowrap overflow-hidden px-2 justify-center items-center"
+ >
+ {isProcessing ? (
+ <>
+ <Upload className="h-4 w-4 animate-spin" />
+ <span className="hidden md:inline ml-2">{progress}%</span>
+ </>
+ ) : (
+ <>
+ <Plus className="h-4 w-4" />
+ <span className="hidden sm:inline ml-2" aria-label="Add file">
+ Add
+ </span>
+ </>
+ )}
+ </Button>
+ </div>
+ </div>
+
+ <div className="flex-1 overflow-y-auto p-3 pt-0">
+ {/* Show message if no media, otherwise show media grid */}
+ {filteredMediaItems.length === 0 ? (
+ <div className="flex flex-col items-center justify-center py-8 text-center h-full">
+ <div className="w-16 h-16 rounded-full bg-muted/30 flex items-center justify-center mb-4">
+ <Image className="h-8 w-8 text-muted-foreground" />
+ </div>
+ <p className="text-sm text-muted-foreground">
+ No media in project
+ </p>
+ <p className="text-xs text-muted-foreground/70 mt-1">
+ Drag files here or use the button above
+ </p>
+ </div>
+ ) : (
+ <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 asChild>
+ <DraggableMediaItem
+ name={item.name}
+ preview={renderPreview(item)}
+ dragData={{
+ id: item.id,
+ type: item.type,
+ name: item.name,
+ }}
+ showPlusOnDrag={false}
+ rounded={false}
+ />
+ </ContextMenuTrigger>
+ <ContextMenuContent>
+ <ContextMenuItem>Export clips</ContextMenuItem>
+ <ContextMenuItem
+ variant="destructive"
+ onClick={(e) => handleRemove(e, item.id)}
+ >
+ Delete
+ </ContextMenuItem>
+ </ContextMenuContent>
+ </ContextMenu>
+ ))}
+ </div>
+ )}
+ </div>
+ </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 new file mode 100644 index 00000000..bec32c85 --- /dev/null +++ b/apps/web/src/components/editor/media-panel/views/text.tsx @@ -0,0 +1,24 @@ +import { DraggableMediaItem } from "@/components/ui/draggable-item";
+
+export function TextView() {
+ return (
+ <div className="p-4">
+ <DraggableMediaItem
+ name="Default text"
+ preview={
+ <div className="flex items-center justify-center w-full h-full bg-accent rounded">
+ <span className="text-xs select-none">Default text</span>
+ </div>
+ }
+ dragData={{
+ id: "default-text",
+ type: "text",
+ name: "Default text",
+ content: "Default text",
+ }}
+ aspectRatio={1}
+ showLabel={false}
+ />
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/preview-panel.tsx b/apps/web/src/components/editor/preview-panel.tsx index 65901365..bbd112c1 100644 --- a/apps/web/src/components/editor/preview-panel.tsx +++ b/apps/web/src/components/editor/preview-panel.tsx @@ -1,10 +1,7 @@ "use client"; -import { - useTimelineStore, - type TimelineClip, - type TimelineTrack, -} from "@/stores/timeline-store"; +import { useTimelineStore } from "@/stores/timeline-store"; +import { TimelineElement, TimelineTrack } from "@/types/timeline"; import { useMediaStore, type MediaItem, @@ -21,13 +18,13 @@ import { DropdownMenuTrigger, DropdownMenuSeparator, } from "@/components/ui/dropdown-menu"; -import { Play, Pause, Volume2, VolumeX, Plus, Square } from "lucide-react"; +import { Play, Pause } from "lucide-react"; import { useState, useRef, useEffect } from "react"; import { cn } from "@/lib/utils"; import { formatTimeCode } from "@/lib/time"; -interface ActiveClip { - clip: TimelineClip; +interface ActiveElement { + element: TimelineElement; track: TimelineTrack; mediaItem: MediaItem | null; } @@ -35,8 +32,8 @@ interface ActiveClip { export function PreviewPanel() { const { tracks } = useTimelineStore(); const { mediaItems } = useMediaStore(); - const { currentTime, muted, toggleMute, volume } = usePlaybackStore(); - const { canvasSize, canvasPresets, setCanvasSize } = useEditorStore(); + const { currentTime } = usePlaybackStore(); + const { canvasSize } = useEditorStore(); const previewRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null); const [previewDimensions, setPreviewDimensions] = useState({ @@ -104,97 +101,142 @@ export function PreviewPanel() { return () => resizeObserver.disconnect(); }, [canvasSize.width, canvasSize.height]); - // Get active clips at current time - const getActiveClips = (): ActiveClip[] => { - const activeClips: ActiveClip[] = []; + // Get active elements at current time + const getActiveElements = (): ActiveElement[] => { + const activeElements: ActiveElement[] = []; tracks.forEach((track) => { - track.clips.forEach((clip) => { - const clipStart = clip.startTime; - const clipEnd = - clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd); + track.elements.forEach((element) => { + const elementStart = element.startTime; + const elementEnd = + element.startTime + + (element.duration - element.trimStart - element.trimEnd); + + if (currentTime >= elementStart && currentTime < elementEnd) { + let mediaItem = null; - if (currentTime >= clipStart && currentTime < clipEnd) { - const mediaItem = - clip.mediaId === "test" - ? null // Test clips don't have a real media item - : mediaItems.find((item) => item.id === clip.mediaId) || null; + // Only get media item for media elements + if (element.type === "media") { + mediaItem = + element.mediaId === "test" + ? null // Test elements don't have a real media item + : mediaItems.find((item) => item.id === element.mediaId) || + null; + } - activeClips.push({ clip, track, mediaItem }); + activeElements.push({ element, track, mediaItem }); } }); }); - return activeClips; + return activeElements; }; - const activeClips = getActiveClips(); + const activeElements = getActiveElements(); - // Check if there are any clips in the timeline at all - const hasAnyClips = tracks.some((track) => track.clips.length > 0); + // Check if there are any elements in the timeline at all + const hasAnyElements = tracks.some((track) => track.elements.length > 0); - // Render a clip - const renderClip = (clipData: ActiveClip, index: number) => { - const { clip, mediaItem } = clipData; + // Render an element + const renderElement = (elementData: ActiveElement, index: number) => { + const { element, mediaItem } = elementData; - // Test clips - if (!mediaItem || clip.mediaId === "test") { + // Text elements + if (element.type === "text") { return ( <div - key={clip.id} - className="absolute inset-0 bg-gradient-to-br from-blue-500/20 to-purple-500/20 flex items-center justify-center" + key={element.id} + className="absolute flex items-center justify-center" + style={{ + left: `${50 + (element.x / canvasSize.width) * 100}%`, + top: `${50 + (element.y / canvasSize.height) * 100}%`, + transform: `translate(-50%, -50%) rotate(${element.rotation}deg)`, + opacity: element.opacity, + zIndex: 100 + index, // Text elements on top + }} > - <div className="text-center"> - <div className="text-2xl mb-2">🎬</div> - <p className="text-xs text-white">{clip.name}</p> + <div + style={{ + fontSize: `${element.fontSize}px`, + fontFamily: element.fontFamily, + color: element.color, + backgroundColor: element.backgroundColor, + textAlign: element.textAlign, + fontWeight: element.fontWeight, + fontStyle: element.fontStyle, + textDecoration: element.textDecoration, + padding: "4px 8px", + borderRadius: "2px", + whiteSpace: "pre-wrap", + }} + > + {element.content} </div> </div> ); } - // Video clips - if (mediaItem.type === "video") { - return ( - <div key={clip.id} className="absolute inset-0"> - <VideoPlayer - src={mediaItem.url} - poster={mediaItem.thumbnailUrl} - clipStartTime={clip.startTime} - trimStart={clip.trimStart} - trimEnd={clip.trimEnd} - clipDuration={clip.duration} - /> - </div> - ); - } + // Media elements + if (element.type === "media") { + // Test elements + if (!mediaItem || element.mediaId === "test") { + return ( + <div + key={element.id} + className="absolute inset-0 bg-gradient-to-br from-blue-500/20 to-purple-500/20 flex items-center justify-center" + > + <div className="text-center"> + <div className="text-2xl mb-2">🎬</div> + <p className="text-xs text-white">{element.name}</p> + </div> + </div> + ); + } - // Image clips - if (mediaItem.type === "image") { - return ( - <div key={clip.id} className="absolute inset-0"> - <img - src={mediaItem.url} - alt={mediaItem.name} - className="w-full h-full object-cover" - draggable={false} - /> - </div> - ); - } + // Video elements + if (mediaItem.type === "video") { + return ( + <div key={element.id} className="absolute inset-0"> + <VideoPlayer + src={mediaItem.url!} + poster={mediaItem.thumbnailUrl} + clipStartTime={element.startTime} + trimStart={element.trimStart} + trimEnd={element.trimEnd} + clipDuration={element.duration} + /> + </div> + ); + } - // Audio clips (visual representation) - if (mediaItem.type === "audio") { - return ( - <div - key={clip.id} - className="absolute inset-0 bg-gradient-to-br from-green-500/20 to-emerald-500/20 flex items-center justify-center" - > - <div className="text-center"> - <div className="text-2xl mb-2">🎵</div> - <p className="text-xs text-white">{mediaItem.name}</p> + // Image elements + if (mediaItem.type === "image") { + return ( + <div key={element.id} className="absolute inset-0"> + <img + src={mediaItem.url!} + alt={mediaItem.name} + className="w-full h-full object-cover" + draggable={false} + /> </div> - </div> - ); + ); + } + + // Audio elements (visual representation) + if (mediaItem.type === "audio") { + return ( + <div + key={element.id} + className="absolute inset-0 bg-gradient-to-br from-green-500/20 to-emerald-500/20 flex items-center justify-center" + > + <div className="text-center"> + <div className="text-2xl mb-2">🎵</div> + <p className="text-xs text-white">{mediaItem.name}</p> + </div> + </div> + ); + } } return null; @@ -206,7 +248,7 @@ export function PreviewPanel() { ref={containerRef} className="flex-1 flex flex-col items-center justify-center p-3 min-h-0 min-w-0 gap-4" > - {hasAnyClips ? ( + {hasAnyElements ? ( <div ref={previewRef} className="relative overflow-hidden rounded-sm bg-black border" @@ -215,12 +257,14 @@ export function PreviewPanel() { height: previewDimensions.height, }} > - {activeClips.length === 0 ? ( + {activeElements.length === 0 ? ( <div className="absolute inset-0 flex items-center justify-center text-muted-foreground"> - No clips at current time + No elements at current time </div> ) : ( - activeClips.map((clipData, index) => renderClip(clipData, index)) + activeElements.map((elementData, index) => + renderElement(elementData, index) + ) )} </div> ) : ( @@ -230,13 +274,13 @@ export function PreviewPanel() { </> )} - <PreviewToolbar hasAnyClips={hasAnyClips} /> + <PreviewToolbar hasAnyElements={hasAnyElements} /> </div> </div> ); } -function PreviewToolbar({ hasAnyClips }: { hasAnyClips: boolean }) { +function PreviewToolbar({ hasAnyElements }: { hasAnyElements: boolean }) { const { isPlaying, toggle, currentTime } = usePlaybackStore(); const { canvasSize, @@ -261,13 +305,17 @@ function PreviewToolbar({ hasAnyClips }: { hasAnyClips: boolean }) { const getOriginalAspectRatio = () => { // Find first video or image in timeline for (const track of tracks) { - for (const clip of track.clips) { - const mediaItem = mediaItems.find((item) => item.id === clip.mediaId); - if ( - mediaItem && - (mediaItem.type === "video" || mediaItem.type === "image") - ) { - return getMediaAspectRatio(mediaItem); + for (const element of track.elements) { + if (element.type === "media") { + const mediaItem = mediaItems.find( + (item) => item.id === element.mediaId + ); + if ( + mediaItem && + (mediaItem.type === "video" || mediaItem.type === "image") + ) { + return getMediaAspectRatio(mediaItem); + } } } } @@ -291,7 +339,7 @@ function PreviewToolbar({ hasAnyClips }: { hasAnyClips: boolean }) { <p className={cn( "text-xs text-muted-foreground tabular-nums", - !hasAnyClips && "opacity-50" + !hasAnyElements && "opacity-50" )} > {formatTimeCode(currentTime, "HH:MM:SS:CS")}/ @@ -302,7 +350,7 @@ function PreviewToolbar({ hasAnyClips }: { hasAnyClips: boolean }) { variant="text" size="icon" onClick={toggle} - disabled={!hasAnyClips} + disabled={!hasAnyElements} > {isPlaying ? ( <Pause className="h-3 w-3" /> @@ -315,8 +363,8 @@ function PreviewToolbar({ hasAnyClips }: { hasAnyClips: boolean }) { <DropdownMenuTrigger asChild> <Button size="sm" - className="!bg-background text-foreground text-xs h-auto rounded-none border border-foreground px-0.5 py-0 font-light" - disabled={!hasAnyClips} + className="!bg-background text-foreground/85 text-xs h-auto rounded-none border border-muted-foreground px-0.5 py-0 font-light" + disabled={!hasAnyElements} > {currentPreset?.name || "Ratio"} </Button> diff --git a/apps/web/src/components/editor/properties-panel.tsx b/apps/web/src/components/editor/properties-panel.tsx index 96ef66e5..0de78988 100644 --- a/apps/web/src/components/editor/properties-panel.tsx +++ b/apps/web/src/components/editor/properties-panel.tsx @@ -25,27 +25,29 @@ export function PropertiesPanel() { const [backgroundType, setBackgroundType] = useState<BackgroundType>("blur"); const [backgroundColor, setBackgroundColor] = useState("#000000"); - // Get the first video clip for preview (simplified) - const firstVideoClip = tracks - .flatMap((track) => track.clips) - .find((clip) => { - const mediaItem = mediaItems.find((item) => item.id === clip.mediaId); + // Get the first video element for preview (simplified) + const firstVideoElement = tracks + .flatMap((track) => track.elements) + .find((element) => { + if (element.type !== "media") return false; + const mediaItem = mediaItems.find((item) => item.id === element.mediaId); return mediaItem?.type === "video"; }); - const firstVideoItem = firstVideoClip - ? mediaItems.find((item) => item.id === firstVideoClip.mediaId) + const firstVideoItem = firstVideoElement && firstVideoElement.type === "media" + ? mediaItems.find((item) => item.id === firstVideoElement.mediaId) : null; - const firstImageClip = tracks - .flatMap((track) => track.clips) - .find((clip) => { - const mediaItem = mediaItems.find((item) => item.id === clip.mediaId); + const firstImageElement = tracks + .flatMap((track) => track.elements) + .find((element) => { + if (element.type !== "media") return false; + const mediaItem = mediaItems.find((item) => item.id === element.mediaId); return mediaItem?.type === "image"; }); - const firstImageItem = firstImageClip - ? mediaItems.find((item) => item.id === firstImageClip.mediaId) + const firstImageItem = firstImageElement && firstImageElement.type === "media" + ? mediaItems.find((item) => item.id === firstImageElement.mediaId) : null; return ( @@ -62,7 +64,7 @@ export function PropertiesPanel() { <Label>Preview</Label> <div className="w-full aspect-video max-w-48"> <ImageTimelineTreatment - src={firstImageItem.url} + src={firstImageItem.url!} alt={firstImageItem.name} targetAspectRatio={16 / 9} className="rounded-sm border" diff --git a/apps/web/src/components/editor/timeline-clip.tsx b/apps/web/src/components/editor/timeline-element.tsx index 88767a15..fe2f6bcc 100644 --- a/apps/web/src/components/editor/timeline-clip.tsx +++ b/apps/web/src/components/editor/timeline-element.tsx @@ -10,13 +10,14 @@ import { Music, ChevronRight, ChevronLeft, + Type, } from "lucide-react"; import { useMediaStore } from "@/stores/media-store"; import { useTimelineStore } from "@/stores/timeline-store"; import { usePlaybackStore } from "@/stores/playback-store"; import AudioWaveform from "./audio-waveform"; import { toast } from "sonner"; -import { TimelineClipProps, ResizeState } from "@/types/timeline"; +import { TimelineElementProps, ResizeState, TrackType } from "@/types/timeline"; import { DropdownMenu, DropdownMenuContent, @@ -27,23 +28,21 @@ import { DropdownMenuSubContent, DropdownMenuSubTrigger, } from "../ui/dropdown-menu"; -import { isDragging } from "motion/react"; -export function TimelineClip({ - clip, +export function TimelineElement({ + element, track, zoomLevel, isSelected, - onClipMouseDown, - onClipClick, -}: TimelineClipProps) { + onElementMouseDown, + onElementClick, +}: TimelineElementProps) { const { mediaItems } = useMediaStore(); const { - updateClipTrim, - addClipToTrack, - removeClipFromTrack, + updateElementTrim, + removeElementFromTrack, dragState, - splitClip, + splitElement, splitAndKeepLeft, splitAndKeepRight, separateAudio, @@ -51,47 +50,48 @@ export function TimelineClip({ const { currentTime } = usePlaybackStore(); const [resizing, setResizing] = useState<ResizeState | null>(null); - const [clipMenuOpen, setClipMenuOpen] = useState(false); + const [elementMenuOpen, setElementMenuOpen] = useState(false); - const effectiveDuration = clip.duration - clip.trimStart - clip.trimEnd; - const clipWidth = Math.max(80, effectiveDuration * 50 * zoomLevel); + const effectiveDuration = + element.duration - element.trimStart - element.trimEnd; + const elementWidth = Math.max(80, effectiveDuration * 50 * zoomLevel); // Use real-time position during drag, otherwise use stored position - const isBeingDragged = dragState.clipId === clip.id; - const clipStartTime = + const isBeingDragged = dragState.elementId === element.id; + const elementStartTime = isBeingDragged && dragState.isDragging ? dragState.currentTime - : clip.startTime; - const clipLeft = clipStartTime * 50 * zoomLevel; + : element.startTime; + const elementLeft = elementStartTime * 50 * zoomLevel; - const getTrackColor = (type: string) => { + const getTrackColor = (type: TrackType) => { switch (type) { - case "video": + case "media": return "bg-blue-500/20 border-blue-500/30"; + case "text": + return "bg-purple-500/20 border-purple-500/30"; case "audio": return "bg-green-500/20 border-green-500/30"; - case "effects": - return "bg-purple-500/20 border-purple-500/30"; default: return "bg-gray-500/20 border-gray-500/30"; } }; - // Resize handles for trimming clips + // Resize handles for trimming elements const handleResizeStart = ( e: React.MouseEvent, - clipId: string, + elementId: string, side: "left" | "right" ) => { e.stopPropagation(); e.preventDefault(); setResizing({ - clipId, + elementId, side, startX: e.clientX, - initialTrimStart: clip.trimStart, - initialTrimEnd: clip.trimEnd, + initialTrimStart: element.trimStart, + initialTrimEnd: element.trimEnd, }); }; @@ -105,20 +105,20 @@ export function TimelineClip({ const newTrimStart = Math.max( 0, Math.min( - clip.duration - clip.trimEnd - 0.1, + element.duration - element.trimEnd - 0.1, resizing.initialTrimStart + deltaTime ) ); - updateClipTrim(track.id, clip.id, newTrimStart, clip.trimEnd); + updateElementTrim(track.id, element.id, newTrimStart, element.trimEnd); } else { const newTrimEnd = Math.max( 0, Math.min( - clip.duration - clip.trimStart - 0.1, + element.duration - element.trimStart - 0.1, resizing.initialTrimEnd - deltaTime ) ); - updateClipTrim(track.id, clip.id, clip.trimStart, newTrimEnd); + updateElementTrim(track.id, element.id, element.trimStart, newTrimEnd); } }; @@ -130,96 +130,111 @@ export function TimelineClip({ setResizing(null); }; - const handleDeleteClip = () => { - removeClipFromTrack(track.id, clip.id); - setClipMenuOpen(false); - toast.success("Clip deleted"); + const handleDeleteElement = () => { + removeElementFromTrack(track.id, element.id); + setElementMenuOpen(false); }; - const handleSplitClip = () => { - const effectiveStart = clip.startTime; + const handleSplitElement = () => { + const effectiveStart = element.startTime; const effectiveEnd = - clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd); + element.startTime + + (element.duration - element.trimStart - element.trimEnd); if (currentTime <= effectiveStart || currentTime >= effectiveEnd) { - toast.error("Playhead must be within clip to split"); + toast.error("Playhead must be within element to split"); return; } - const secondClipId = splitClip(track.id, clip.id, currentTime); - if (secondClipId) { - toast.success("Clip split successfully"); - } else { - toast.error("Failed to split clip"); + const secondElementId = splitElement(track.id, element.id, currentTime); + if (!secondElementId) { + toast.error("Failed to split element"); } - setClipMenuOpen(false); + setElementMenuOpen(false); }; const handleSplitAndKeepLeft = () => { - const effectiveStart = clip.startTime; + const effectiveStart = element.startTime; const effectiveEnd = - clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd); + element.startTime + + (element.duration - element.trimStart - element.trimEnd); if (currentTime <= effectiveStart || currentTime >= effectiveEnd) { - toast.error("Playhead must be within clip"); + toast.error("Playhead must be within element"); return; } - splitAndKeepLeft(track.id, clip.id, currentTime); - toast.success("Split and kept left portion"); - setClipMenuOpen(false); + splitAndKeepLeft(track.id, element.id, currentTime); + setElementMenuOpen(false); }; const handleSplitAndKeepRight = () => { - const effectiveStart = clip.startTime; + const effectiveStart = element.startTime; const effectiveEnd = - clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd); + element.startTime + + (element.duration - element.trimStart - element.trimEnd); if (currentTime <= effectiveStart || currentTime >= effectiveEnd) { - toast.error("Playhead must be within clip"); + toast.error("Playhead must be within element"); return; } - splitAndKeepRight(track.id, clip.id, currentTime); - toast.success("Split and kept right portion"); - setClipMenuOpen(false); + splitAndKeepRight(track.id, element.id, currentTime); + setElementMenuOpen(false); }; const handleSeparateAudio = () => { - const mediaItem = mediaItems.find((item) => item.id === clip.mediaId); + if (element.type !== "media") { + toast.error("Audio separation only available for media elements"); + return; + } + const mediaItem = mediaItems.find((item) => item.id === element.mediaId); if (!mediaItem || mediaItem.type !== "video") { - toast.error("Audio separation only available for video clips"); + toast.error("Audio separation only available for video elements"); return; } - const audioClipId = separateAudio(track.id, clip.id); - if (audioClipId) { - toast.success("Audio separated to audio track"); - } else { + const audioElementId = separateAudio(track.id, element.id); + if (!audioElementId) { toast.error("Failed to separate audio"); } - setClipMenuOpen(false); + setElementMenuOpen(false); }; const canSplitAtPlayhead = () => { - const effectiveStart = clip.startTime; + const effectiveStart = element.startTime; const effectiveEnd = - clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd); + element.startTime + + (element.duration - element.trimStart - element.trimEnd); return currentTime > effectiveStart && currentTime < effectiveEnd; }; const canSeparateAudio = () => { - const mediaItem = mediaItems.find((item) => item.id === clip.mediaId); - return mediaItem?.type === "video" && track.type === "video"; + if (element.type !== "media") return false; + const mediaItem = mediaItems.find((item) => item.id === element.mediaId); + return mediaItem?.type === "video" && track.type === "media"; }; - const renderClipContent = () => { - const mediaItem = mediaItems.find((item) => item.id === clip.mediaId); + const renderElementContent = () => { + if (element.type === "text") { + return ( + <div className="w-full h-full flex items-center justify-center px-2"> + <Type className="h-4 w-4 mr-2 text-purple-400 flex-shrink-0" /> + <span className="text-xs text-foreground/80 truncate"> + {element.content} + </span> + </div> + ); + } + // Render media element -> + const mediaItem = mediaItems.find((item) => item.id === element.mediaId); if (!mediaItem) { return ( - <span className="text-xs text-foreground/80 truncate">{clip.name}</span> + <span className="text-xs text-foreground/80 truncate"> + {element.name} + </span> ); } @@ -248,18 +263,19 @@ export function TimelineClip({ /> </div> <span className="text-xs text-foreground/80 truncate flex-1"> - {clip.name} + {element.name} </span> </div> ); } + // Render audio element -> if (mediaItem.type === "audio") { return ( <div className="w-full h-full flex items-center gap-2"> <div className="flex-1 min-w-0"> <AudioWaveform - audioUrl={mediaItem.url} + audioUrl={mediaItem.url || ""} height={24} className="w-full" /> @@ -269,24 +285,26 @@ export function TimelineClip({ } return ( - <span className="text-xs text-foreground/80 truncate">{clip.name}</span> + <span className="text-xs text-foreground/80 truncate"> + {element.name} + </span> ); }; - const handleClipMouseDown = (e: React.MouseEvent) => { - if (onClipMouseDown) { - onClipMouseDown(e, clip); + const handleElementMouseDown = (e: React.MouseEvent) => { + if (onElementMouseDown) { + onElementMouseDown(e, element); } }; return ( <div - className={`absolute top-0 h-full select-none transition-all duration-75${ + className={`absolute top-0 h-full select-none timeline-element ${ isBeingDragged ? "z-50" : "z-10" }`} style={{ - left: `${clipLeft}px`, - width: `${clipWidth}px`, + left: `${elementLeft}px`, + width: `${elementWidth}px`, }} onMouseMove={resizing ? handleResizeMove : undefined} onMouseUp={resizing ? handleResizeEnd : undefined} @@ -295,32 +313,37 @@ export function TimelineClip({ <div className={`relative h-full rounded-[0.15rem] cursor-pointer overflow-hidden ${getTrackColor( track.type - )} ${isSelected ? "border-b-[0.5px] border-t-[0.5px] border-primary" : ""} ${ + )} ${isSelected ? "border-b-[0.5px] border-t-[0.5px] border-foreground" : ""} ${ isBeingDragged ? "z-50" : "z-10" }`} - onClick={(e) => onClipClick && onClipClick(e, clip)} - onMouseDown={handleClipMouseDown} - onContextMenu={(e) => onClipMouseDown && onClipMouseDown(e, clip)} + onClick={(e) => onElementClick && onElementClick(e, element)} + onMouseDown={handleElementMouseDown} + onContextMenu={(e) => + onElementMouseDown && onElementMouseDown(e, element) + } > <div className="absolute inset-1 flex items-center p-1"> - {renderClipContent()} + {renderElementContent()} </div> {isSelected && ( <> <div className="absolute left-0 top-0 bottom-0 w-1 cursor-w-resize bg-foreground z-50" - onMouseDown={(e) => handleResizeStart(e, clip.id, "left")} + 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" - onMouseDown={(e) => handleResizeStart(e, clip.id, "right")} + onMouseDown={(e) => handleResizeStart(e, element.id, "right")} /> </> )} <div className="absolute top-1 right-1"> - <DropdownMenu open={clipMenuOpen} onOpenChange={setClipMenuOpen}> + <DropdownMenu + open={elementMenuOpen} + onOpenChange={setElementMenuOpen} + > <DropdownMenuTrigger asChild> <Button variant="outline" @@ -328,21 +351,21 @@ export function TimelineClip({ className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 transition-opacity bg-background/80 hover:bg-background" onClick={(e) => { e.stopPropagation(); - setClipMenuOpen(true); + setElementMenuOpen(true); }} > <MoreVertical className="h-3 w-3" /> </Button> </DropdownMenuTrigger> <DropdownMenuContent align="end" className="w-56"> - {/* Split operations - only available when playhead is within clip */} + {/* Split operations - only available when playhead is within element */} <DropdownMenuSub> <DropdownMenuSubTrigger disabled={!canSplitAtPlayhead()}> <Scissors className="mr-2 h-4 w-4" /> Split </DropdownMenuSubTrigger> <DropdownMenuSubContent> - <DropdownMenuItem onClick={handleSplitClip}> + <DropdownMenuItem onClick={handleSplitElement}> <SplitSquareHorizontal className="mr-2 h-4 w-4" /> Split at Playhead </DropdownMenuItem> @@ -357,7 +380,7 @@ export function TimelineClip({ </DropdownMenuSubContent> </DropdownMenuSub> - {/* Audio separation - only available for video clips */} + {/* Audio separation - only available for video elements */} {canSeparateAudio() && ( <> <DropdownMenuSeparator /> @@ -370,11 +393,11 @@ export function TimelineClip({ <DropdownMenuSeparator /> <DropdownMenuItem - onClick={handleDeleteClip} + onClick={handleDeleteElement} className="text-destructive" > <Trash2 className="mr-2 h-4 w-4" /> - Delete Clip + Delete {element.type === "text" ? "text" : "clip"} </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> diff --git a/apps/web/src/components/editor/timeline-toolbar.tsx b/apps/web/src/components/editor/timeline-toolbar.tsx index 51464794..e887efa0 100644 --- a/apps/web/src/components/editor/timeline-toolbar.tsx +++ b/apps/web/src/components/editor/timeline-toolbar.tsx @@ -102,7 +102,7 @@ export function TimelineToolbar({ variant="outline"
size="sm"
onClick={() => {
- const trackId = addTrack("video");
+ const trackId = addTrack("media");
addClipToTrack(trackId, {
mediaId: "test",
name: "Test Clip",
diff --git a/apps/web/src/components/editor/timeline-track.tsx b/apps/web/src/components/editor/timeline-track.tsx index 8718d30a..eb58e1ee 100644 --- a/apps/web/src/components/editor/timeline-track.tsx +++ b/apps/web/src/components/editor/timeline-track.tsx @@ -1,692 +1,898 @@ -"use client";
-
-import { useRef, useState, useEffect } from "react";
-import { useTimelineStore } from "@/stores/timeline-store";
-import { useMediaStore } from "@/stores/media-store";
-import { toast } from "sonner";
-import { Copy, Scissors, Trash2 } from "lucide-react";
-import { TimelineClip } from "./timeline-clip";
-import {
- ContextMenu,
- ContextMenuContent,
- ContextMenuItem,
- ContextMenuSeparator,
- ContextMenuTrigger,
-} from "../ui/context-menu";
-import {
- TimelineTrack,
- TimelineClip as TypeTimelineClip,
-} from "@/stores/timeline-store";
-import { usePlaybackStore } from "@/stores/playback-store";
-
-export function TimelineTrackContent({
- track,
- zoomLevel,
-}: {
- track: TimelineTrack;
- zoomLevel: number;
-}) {
- const { mediaItems } = useMediaStore();
- const {
- tracks,
- moveClipToTrack,
- updateClipStartTime,
- addClipToTrack,
- selectedClips,
- selectClip,
- deselectClip,
- dragState,
- startDrag: startDragAction,
- updateDragTime,
- endDrag: endDragAction,
- } = useTimelineStore();
-
- const timelineRef = useRef<HTMLDivElement>(null);
- const [isDropping, setIsDropping] = useState(false);
- const [dropPosition, setDropPosition] = useState<number | null>(null);
- const [wouldOverlap, setWouldOverlap] = useState(false);
- const dragCounterRef = useRef(0);
- const [mouseDownLocation, setMouseDownLocation] = useState<{
- x: number;
- y: number;
- } | null>(null);
-
- // Set up mouse event listeners for drag
- useEffect(() => {
- if (!dragState.isDragging) return;
-
- const handleMouseMove = (e: MouseEvent) => {
- if (!timelineRef.current) return;
-
- // On first mouse move during drag, ensure the clip is selected
- if (dragState.clipId && dragState.trackId) {
- const isSelected = selectedClips.some(
- (c) =>
- c.trackId === dragState.trackId && c.clipId === dragState.clipId
- );
-
- if (!isSelected) {
- // Select this clip (replacing other selections) since we're dragging it
- selectClip(dragState.trackId, dragState.clipId, false);
- }
- }
-
- const timelineRect = timelineRef.current.getBoundingClientRect();
- const mouseX = e.clientX - timelineRect.left;
- const mouseTime = Math.max(0, mouseX / (50 * zoomLevel));
- const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime);
- const snappedTime = Math.round(adjustedTime * 10) / 10;
-
- updateDragTime(snappedTime);
- };
-
- const handleMouseUp = () => {
- if (!dragState.clipId || !dragState.trackId) return;
-
- const finalTime = dragState.currentTime;
-
- // Check for overlaps and update position
- const sourceTrack = tracks.find((t) => t.id === dragState.trackId);
- const movingClip = sourceTrack?.clips.find(
- (c) => c.id === dragState.clipId
- );
-
- if (movingClip) {
- const movingClipDuration =
- movingClip.duration - movingClip.trimStart - movingClip.trimEnd;
- const movingClipEnd = finalTime + movingClipDuration;
-
- const targetTrack = tracks.find((t) => t.id === track.id);
- const hasOverlap = targetTrack?.clips.some((existingClip) => {
- if (
- dragState.trackId === track.id &&
- existingClip.id === dragState.clipId
- ) {
- return false;
- }
- const existingStart = existingClip.startTime;
- const existingEnd =
- existingClip.startTime +
- (existingClip.duration -
- existingClip.trimStart -
- existingClip.trimEnd);
- return finalTime < existingEnd && movingClipEnd > existingStart;
- });
-
- if (!hasOverlap) {
- if (dragState.trackId === track.id) {
- updateClipStartTime(track.id, dragState.clipId, finalTime);
- } else {
- moveClipToTrack(dragState.trackId, track.id, dragState.clipId);
- requestAnimationFrame(() => {
- updateClipStartTime(track.id, dragState.clipId!, finalTime);
- });
- }
- }
- }
-
- endDragAction();
- };
-
- document.addEventListener("mousemove", handleMouseMove);
- document.addEventListener("mouseup", handleMouseUp);
-
- return () => {
- document.removeEventListener("mousemove", handleMouseMove);
- document.removeEventListener("mouseup", handleMouseUp);
- };
- }, [
- dragState.isDragging,
- dragState.clickOffsetTime,
- dragState.clipId,
- dragState.trackId,
- dragState.currentTime,
- zoomLevel,
- tracks,
- track.id,
- updateDragTime,
- updateClipStartTime,
- moveClipToTrack,
- endDragAction,
- ]);
-
- const handleClipMouseDown = (e: React.MouseEvent, clip: TypeTimelineClip) => {
- setMouseDownLocation({ x: e.clientX, y: e.clientY });
-
- // Detect right-click (button 2) and handle selection without starting drag
- const isRightClick = e.button === 2;
- const isMultiSelect = e.metaKey || e.ctrlKey || e.shiftKey;
-
- if (isRightClick) {
- // Handle right-click selection
- const isSelected = selectedClips.some(
- (c) => c.trackId === track.id && c.clipId === clip.id
- );
-
- // If clip is not selected, select it (keep other selections if multi-select)
- if (!isSelected) {
- selectClip(track.id, clip.id, isMultiSelect);
- }
- // If clip is already selected, keep it selected
-
- // Don't start drag action for right-clicks
- return;
- }
-
- // Handle multi-selection for left-click with modifiers
- if (isMultiSelect) {
- selectClip(track.id, clip.id, true);
- }
-
- // Calculate the offset from the left edge of the clip to where the user clicked
- const clipElement = e.currentTarget as HTMLElement;
- const clipRect = clipElement.getBoundingClientRect();
- const clickOffsetX = e.clientX - clipRect.left;
- const clickOffsetTime = clickOffsetX / (50 * zoomLevel);
-
- startDragAction(
- clip.id,
- track.id,
- e.clientX,
- clip.startTime,
- clickOffsetTime
- );
- };
-
- const handleClipClick = (e: React.MouseEvent, clip: TypeTimelineClip) => {
- e.stopPropagation();
-
- // Check if mouse moved significantly
- if (mouseDownLocation) {
- const deltaX = Math.abs(e.clientX - mouseDownLocation.x);
- const deltaY = Math.abs(e.clientY - mouseDownLocation.y);
- // If it moved more than a few pixels, consider it a drag and not a click.
- if (deltaX > 5 || deltaY > 5) {
- setMouseDownLocation(null); // Reset for next interaction
- return;
- }
- }
-
- // Skip selection logic for multi-selection (handled in mousedown)
- if (e.metaKey || e.ctrlKey || e.shiftKey) {
- return;
- }
-
- // Handle single selection/deselection
- const isSelected = selectedClips.some(
- (c) => c.trackId === track.id && c.clipId === clip.id
- );
-
- if (isSelected) {
- // If clip is selected, deselect it
- deselectClip(track.id, clip.id);
- } else {
- // If clip is not selected, select it (replacing other selections)
- selectClip(track.id, clip.id, false);
- }
- };
-
- const handleTrackDragOver = (e: React.DragEvent) => {
- e.preventDefault();
-
- // Handle both timeline clips and media items
- const hasTimelineClip = e.dataTransfer.types.includes(
- "application/x-timeline-clip"
- );
- const hasMediaItem = e.dataTransfer.types.includes(
- "application/x-media-item"
- );
-
- if (!hasTimelineClip && !hasMediaItem) return;
-
- if (hasMediaItem) {
- try {
- const mediaItemData = e.dataTransfer.getData(
- "application/x-media-item"
- );
- if (mediaItemData) {
- const { type } = JSON.parse(mediaItemData);
- const isCompatible =
- (track.type === "video" &&
- (type === "video" || type === "image")) ||
- (track.type === "audio" && type === "audio");
-
- if (!isCompatible) {
- e.dataTransfer.dropEffect = "none";
- return;
- }
- }
- } catch (error) {
- console.error("Error parsing dropped media item:", error);
- }
- }
-
- // Calculate drop position for overlap checking
- const trackContainer = e.currentTarget.querySelector(
- ".track-clips-container"
- ) as HTMLElement;
- let dropTime = 0;
- if (trackContainer) {
- const rect = trackContainer.getBoundingClientRect();
- const mouseX = Math.max(0, e.clientX - rect.left);
- dropTime = mouseX / (50 * zoomLevel);
- }
-
- // Check for potential overlaps and show appropriate feedback
- let wouldOverlap = false;
-
- if (hasMediaItem) {
- try {
- const mediaItemData = e.dataTransfer.getData(
- "application/x-media-item"
- );
- if (mediaItemData) {
- const { id } = JSON.parse(mediaItemData);
- const mediaItem = mediaItems.find((item) => item.id === id);
- if (mediaItem) {
- const newClipDuration = mediaItem.duration || 5;
- const snappedTime = Math.round(dropTime * 10) / 10;
- const newClipEnd = snappedTime + newClipDuration;
-
- wouldOverlap = track.clips.some((existingClip) => {
- const existingStart = existingClip.startTime;
- const existingEnd =
- existingClip.startTime +
- (existingClip.duration -
- existingClip.trimStart -
- existingClip.trimEnd);
- return snappedTime < existingEnd && newClipEnd > existingStart;
- });
- }
- }
- } catch (error) {
- // Continue with default behavior
- }
- } else if (hasTimelineClip) {
- try {
- const timelineClipData = e.dataTransfer.getData(
- "application/x-timeline-clip"
- );
- if (timelineClipData) {
- const { clipId, trackId: fromTrackId } = JSON.parse(timelineClipData);
- const sourceTrack = tracks.find(
- (t: TimelineTrack) => t.id === fromTrackId
- );
- const movingClip = sourceTrack?.clips.find(
- (c: any) => c.id === clipId
- );
-
- if (movingClip) {
- const movingClipDuration =
- movingClip.duration - movingClip.trimStart - movingClip.trimEnd;
- const snappedTime = Math.round(dropTime * 10) / 10;
- const movingClipEnd = snappedTime + movingClipDuration;
-
- wouldOverlap = track.clips.some((existingClip) => {
- if (fromTrackId === track.id && existingClip.id === clipId)
- return false;
-
- const existingStart = existingClip.startTime;
- const existingEnd =
- existingClip.startTime +
- (existingClip.duration -
- existingClip.trimStart -
- existingClip.trimEnd);
- return snappedTime < existingEnd && movingClipEnd > existingStart;
- });
- }
- }
- } catch (error) {
- // Continue with default behavior
- }
- }
-
- if (wouldOverlap) {
- e.dataTransfer.dropEffect = "none";
- setWouldOverlap(true);
- setDropPosition(Math.round(dropTime * 10) / 10);
- return;
- }
-
- e.dataTransfer.dropEffect = hasTimelineClip ? "move" : "copy";
- setWouldOverlap(false);
- setDropPosition(Math.round(dropTime * 10) / 10);
- };
-
- const handleTrackDragEnter = (e: React.DragEvent) => {
- e.preventDefault();
-
- const hasTimelineClip = e.dataTransfer.types.includes(
- "application/x-timeline-clip"
- );
- const hasMediaItem = e.dataTransfer.types.includes(
- "application/x-media-item"
- );
-
- if (!hasTimelineClip && !hasMediaItem) return;
-
- dragCounterRef.current++;
- setIsDropping(true);
- };
-
- const handleTrackDragLeave = (e: React.DragEvent) => {
- e.preventDefault();
-
- const hasTimelineClip = e.dataTransfer.types.includes(
- "application/x-timeline-clip"
- );
- const hasMediaItem = e.dataTransfer.types.includes(
- "application/x-media-item"
- );
-
- if (!hasTimelineClip && !hasMediaItem) return;
-
- dragCounterRef.current--;
-
- if (dragCounterRef.current === 0) {
- setIsDropping(false);
- setWouldOverlap(false);
- setDropPosition(null);
- }
- };
-
- const handleTrackDrop = (e: React.DragEvent) => {
- e.preventDefault();
- e.stopPropagation();
-
- // Reset all drag states
- dragCounterRef.current = 0;
- setIsDropping(false);
- setWouldOverlap(false);
- const currentDropPosition = dropPosition;
- setDropPosition(null);
-
- const hasTimelineClip = e.dataTransfer.types.includes(
- "application/x-timeline-clip"
- );
- const hasMediaItem = e.dataTransfer.types.includes(
- "application/x-media-item"
- );
-
- if (!hasTimelineClip && !hasMediaItem) return;
-
- const trackContainer = e.currentTarget.querySelector(
- ".track-clips-container"
- ) as HTMLElement;
- if (!trackContainer) return;
-
- const rect = trackContainer.getBoundingClientRect();
- const mouseX = Math.max(0, e.clientX - rect.left);
- const newStartTime = mouseX / (50 * zoomLevel);
- const snappedTime = Math.round(newStartTime * 10) / 10;
-
- try {
- if (hasTimelineClip) {
- // Handle timeline clip movement
- const timelineClipData = e.dataTransfer.getData(
- "application/x-timeline-clip"
- );
- if (!timelineClipData) return;
-
- const {
- clipId,
- trackId: fromTrackId,
- clickOffsetTime = 0,
- } = JSON.parse(timelineClipData);
-
- // Find the clip being moved
- const sourceTrack = tracks.find(
- (t: TimelineTrack) => t.id === fromTrackId
- );
- const movingClip = sourceTrack?.clips.find(
- (c: TypeTimelineClip) => c.id === clipId
- );
-
- if (!movingClip) {
- toast.error("Clip not found");
- return;
- }
-
- // Adjust position based on where user clicked on the clip
- const adjustedStartTime = snappedTime - clickOffsetTime;
- const finalStartTime = Math.max(
- 0,
- Math.round(adjustedStartTime * 10) / 10
- );
-
- // Check for overlaps with existing clips (excluding the moving clip itself)
- const movingClipDuration =
- movingClip.duration - movingClip.trimStart - movingClip.trimEnd;
- const movingClipEnd = finalStartTime + movingClipDuration;
-
- const hasOverlap = track.clips.some((existingClip) => {
- // Skip the clip being moved if it's on the same track
- if (fromTrackId === track.id && existingClip.id === clipId)
- return false;
-
- const existingStart = existingClip.startTime;
- const existingEnd =
- existingClip.startTime +
- (existingClip.duration -
- existingClip.trimStart -
- existingClip.trimEnd);
-
- // Check if clips overlap
- return finalStartTime < existingEnd && movingClipEnd > existingStart;
- });
-
- if (hasOverlap) {
- toast.error(
- "Cannot move clip here - it would overlap with existing clips"
- );
- return;
- }
-
- if (fromTrackId === track.id) {
- // Moving within same track
- updateClipStartTime(track.id, clipId, finalStartTime);
- } else {
- // Moving to different track
- moveClipToTrack(fromTrackId, track.id, clipId);
- requestAnimationFrame(() => {
- updateClipStartTime(track.id, clipId, finalStartTime);
- });
- }
- } else if (hasMediaItem) {
- // Handle media item drop
- const mediaItemData = e.dataTransfer.getData(
- "application/x-media-item"
- );
- if (!mediaItemData) return;
-
- const { id, type } = JSON.parse(mediaItemData);
- const mediaItem = mediaItems.find((item) => item.id === id);
-
- if (!mediaItem) {
- toast.error("Media item not found");
- return;
- }
-
- // Check if track type is compatible
- const isCompatible =
- (track.type === "video" && (type === "video" || type === "image")) ||
- (track.type === "audio" && type === "audio");
-
- if (!isCompatible) {
- toast.error(`Cannot add ${type} to ${track.type} track`);
- return;
- }
-
- // Check for overlaps with existing clips
- const newClipDuration = mediaItem.duration || 5;
- const newClipEnd = snappedTime + newClipDuration;
-
- const hasOverlap = track.clips.some((existingClip) => {
- const existingStart = existingClip.startTime;
- const existingEnd =
- existingClip.startTime +
- (existingClip.duration -
- existingClip.trimStart -
- existingClip.trimEnd);
-
- // Check if clips overlap
- return snappedTime < existingEnd && newClipEnd > existingStart;
- });
-
- if (hasOverlap) {
- toast.error(
- "Cannot place clip here - it would overlap with existing clips"
- );
- return;
- }
-
- addClipToTrack(track.id, {
- mediaId: mediaItem.id,
- name: mediaItem.name,
- duration: mediaItem.duration || 5,
- startTime: snappedTime,
- trimStart: 0,
- trimEnd: 0,
- });
- }
- } catch (error) {
- console.error("Error handling drop:", error);
- toast.error("Failed to add media to track");
- }
- };
-
- return (
- <div
- className="w-full h-full hover:bg-muted/20"
- onClick={(e) => {
- // If clicking empty area (not on a clip), deselect all clips
- if (!(e.target as HTMLElement).closest(".timeline-clip")) {
- const { clearSelectedClips } = useTimelineStore.getState();
- clearSelectedClips();
- }
- }}
- onDragOver={handleTrackDragOver}
- onDragEnter={handleTrackDragEnter}
- onDragLeave={handleTrackDragLeave}
- onDrop={handleTrackDrop}
- >
- <div
- ref={timelineRef}
- className="h-full relative track-clips-container min-w-full"
- >
- {track.clips.length === 0 ? (
- <div
- className={`h-full w-full rounded-sm border-2 border-dashed flex items-center justify-center text-xs text-muted-foreground transition-colors ${
- isDropping
- ? wouldOverlap
- ? "border-red-500 bg-red-500/10 text-red-600"
- : "border-blue-500 bg-blue-500/10 text-blue-600"
- : "border-muted/30"
- }`}
- >
- {isDropping
- ? wouldOverlap
- ? "Cannot drop - would overlap"
- : "Drop clip here"
- : "Drop media here"}
- </div>
- ) : (
- <>
- {track.clips.map((clip) => {
- const isSelected = selectedClips.some(
- (c) => c.trackId === track.id && c.clipId === clip.id
- );
-
- const handleClipSplit = () => {
- const { currentTime } = usePlaybackStore();
- const { updateClipTrim, addClipToTrack } = useTimelineStore();
- const splitTime = currentTime;
- const effectiveStart = clip.startTime;
- const effectiveEnd =
- clip.startTime +
- (clip.duration - clip.trimStart - clip.trimEnd);
-
- if (splitTime > effectiveStart && splitTime < effectiveEnd) {
- updateClipTrim(
- track.id,
- clip.id,
- clip.trimStart,
- clip.trimEnd + (effectiveEnd - splitTime)
- );
- addClipToTrack(track.id, {
- mediaId: clip.mediaId,
- name: clip.name + " (split)",
- duration: clip.duration,
- startTime: splitTime,
- trimStart: clip.trimStart + (splitTime - effectiveStart),
- trimEnd: clip.trimEnd,
- });
- toast.success("Clip split successfully");
- } else {
- toast.error("Playhead must be within clip to split");
- }
- };
-
- const handleClipDuplicate = () => {
- const { addClipToTrack } = useTimelineStore.getState();
- addClipToTrack(track.id, {
- mediaId: clip.mediaId,
- name: clip.name + " (copy)",
- duration: clip.duration,
- startTime:
- clip.startTime +
- (clip.duration - clip.trimStart - clip.trimEnd) +
- 0.1,
- trimStart: clip.trimStart,
- trimEnd: clip.trimEnd,
- });
- toast.success("Clip duplicated");
- };
-
- const handleClipDelete = () => {
- const { removeClipFromTrack } = useTimelineStore.getState();
- removeClipFromTrack(track.id, clip.id);
- toast.success("Clip deleted");
- };
-
- return (
- <ContextMenu key={clip.id}>
- <ContextMenuTrigger asChild>
- <div>
- <TimelineClip
- clip={clip}
- track={track}
- zoomLevel={zoomLevel}
- isSelected={isSelected}
- onClipMouseDown={handleClipMouseDown}
- onClipClick={handleClipClick}
- />
- </div>
- </ContextMenuTrigger>
- <ContextMenuContent>
- <ContextMenuItem onClick={handleClipSplit}>
- <Scissors className="h-4 w-4 mr-2" />
- Split at Playhead
- </ContextMenuItem>
- <ContextMenuItem onClick={handleClipDuplicate}>
- <Copy className="h-4 w-4 mr-2" />
- Duplicate Clip
- </ContextMenuItem>
- <ContextMenuSeparator />
- <ContextMenuItem
- onClick={handleClipDelete}
- className="text-destructive focus:text-destructive"
- >
- <Trash2 className="h-4 w-4 mr-2" />
- Delete Clip
- </ContextMenuItem>
- </ContextMenuContent>
- </ContextMenu>
- );
- })}
- </>
- )}
- </div>
- </div>
- );
-}
+"use client"; + +import { useRef, useState, useEffect } from "react"; +import { useTimelineStore } from "@/stores/timeline-store"; +import { useMediaStore } from "@/stores/media-store"; +import { toast } from "sonner"; +import { Copy, Scissors, Trash2 } from "lucide-react"; +import { TimelineElement } from "./timeline-element"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from "../ui/context-menu"; +import { + TimelineTrack, + sortTracksByOrder, + ensureMainTrack, + getMainTrack +} from "@/types/timeline"; +import { usePlaybackStore } from "@/stores/playback-store"; +import type { + TimelineElement as TimelineElementType, + DragData, +} from "@/types/timeline"; + +export function TimelineTrackContent({ + track, + zoomLevel, +}: { + track: TimelineTrack; + zoomLevel: number; +}) { + const { mediaItems } = useMediaStore(); + const { + tracks, + moveElementToTrack, + updateElementStartTime, + addElementToTrack, + selectedElements, + selectElement, + dragState, + startDrag: startDragAction, + updateDragTime, + endDrag: endDragAction, + clearSelectedElements, + insertTrackAt, + } = useTimelineStore(); + + const timelineRef = useRef<HTMLDivElement>(null); + const [isDropping, setIsDropping] = useState(false); + const [dropPosition, setDropPosition] = useState<number | null>(null); + const [wouldOverlap, setWouldOverlap] = useState(false); + const dragCounterRef = useRef(0); + const [mouseDownLocation, setMouseDownLocation] = useState<{ + x: number; + y: number; + } | null>(null); + + // Set up mouse event listeners for drag + useEffect(() => { + if (!dragState.isDragging) return; + + const handleMouseMove = (e: MouseEvent) => { + if (!timelineRef.current) return; + + // On first mouse move during drag, ensure the element is selected + if (dragState.elementId && dragState.trackId) { + const isSelected = selectedElements.some( + (c) => + c.trackId === dragState.trackId && + c.elementId === dragState.elementId + ); + + if (!isSelected) { + // Select this element (replacing other selections) since we're dragging it + selectElement(dragState.trackId, dragState.elementId, false); + } + } + + const timelineRect = timelineRef.current.getBoundingClientRect(); + const mouseX = e.clientX - timelineRect.left; + const mouseTime = Math.max(0, mouseX / (50 * zoomLevel)); + const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime); + const snappedTime = Math.round(adjustedTime * 10) / 10; + + updateDragTime(snappedTime); + }; + + const handleMouseUp = (e: MouseEvent) => { + if (!dragState.elementId || !dragState.trackId) return; + + // Check if the mouse is actually over this track + const timelineRect = timelineRef.current?.getBoundingClientRect(); + if (!timelineRect) return; + + const isMouseOverThisTrack = + e.clientY >= timelineRect.top && e.clientY <= timelineRect.bottom; + + // Only handle if mouse is over this track + if (!isMouseOverThisTrack) return; + + const finalTime = dragState.currentTime; + + // Check for overlaps and update position + const sourceTrack = tracks.find((t) => t.id === dragState.trackId); + const movingElement = sourceTrack?.elements.find( + (c) => c.id === dragState.elementId + ); + + if (movingElement) { + const movingElementDuration = + movingElement.duration - + movingElement.trimStart - + movingElement.trimEnd; + const movingElementEnd = finalTime + movingElementDuration; + + const targetTrack = tracks.find((t) => t.id === track.id); + const hasOverlap = targetTrack?.elements.some((existingElement) => { + if ( + dragState.trackId === track.id && + existingElement.id === dragState.elementId + ) { + return false; + } + const existingStart = existingElement.startTime; + const existingEnd = + existingElement.startTime + + (existingElement.duration - + existingElement.trimStart - + existingElement.trimEnd); + return finalTime < existingEnd && movingElementEnd > existingStart; + }); + + if (!hasOverlap) { + if (dragState.trackId === track.id) { + updateElementStartTime(track.id, dragState.elementId, finalTime); + } else { + moveElementToTrack( + dragState.trackId, + track.id, + dragState.elementId + ); + requestAnimationFrame(() => { + updateElementStartTime(track.id, dragState.elementId!, finalTime); + }); + } + } + } + + endDragAction(); + }; + + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + }, [ + dragState.isDragging, + dragState.clickOffsetTime, + dragState.elementId, + dragState.trackId, + dragState.currentTime, + zoomLevel, + tracks, + track.id, + updateDragTime, + updateElementStartTime, + moveElementToTrack, + endDragAction, + selectedElements, + selectElement, + ]); + + const handleElementMouseDown = ( + e: React.MouseEvent, + element: TimelineElementType + ) => { + setMouseDownLocation({ x: e.clientX, y: e.clientY }); + + // Detect right-click (button 2) and handle selection without starting drag + const isRightClick = e.button === 2; + const isMultiSelect = e.metaKey || e.ctrlKey || e.shiftKey; + + if (isRightClick) { + // Handle right-click selection + const isSelected = selectedElements.some( + (c) => c.trackId === track.id && c.elementId === element.id + ); + + // If element is not selected, select it (keep other selections if multi-select) + if (!isSelected) { + selectElement(track.id, element.id, isMultiSelect); + } + // If element is already selected, keep it selected + + // Don't start drag action for right-clicks + return; + } + + // Handle multi-selection for left-click with modifiers + if (isMultiSelect) { + selectElement(track.id, element.id, true); + } + + // Calculate the offset from the left edge of the element to where the user clicked + const elementElement = e.currentTarget as HTMLElement; + const elementRect = elementElement.getBoundingClientRect(); + const clickOffsetX = e.clientX - elementRect.left; + const clickOffsetTime = clickOffsetX / (50 * zoomLevel); + + startDragAction( + element.id, + track.id, + e.clientX, + element.startTime, + clickOffsetTime + ); + }; + + const handleElementClick = ( + e: React.MouseEvent, + element: TimelineElementType + ) => { + e.stopPropagation(); + + // Check if mouse moved significantly + if (mouseDownLocation) { + const deltaX = Math.abs(e.clientX - mouseDownLocation.x); + const deltaY = Math.abs(e.clientY - mouseDownLocation.y); + // If it moved more than a few pixels, consider it a drag and not a click. + if (deltaX > 5 || deltaY > 5) { + setMouseDownLocation(null); // Reset for next interaction + return; + } + } + + // Skip selection logic for multi-selection (handled in mousedown) + if (e.metaKey || e.ctrlKey || e.shiftKey) { + return; + } + + // Handle single selection + const isSelected = selectedElements.some( + (c) => c.trackId === track.id && c.elementId === element.id + ); + + if (!isSelected) { + // If element is not selected, select it (replacing other selections) + selectElement(track.id, element.id, false); + } + // If element is already selected, keep it selected (do nothing) + }; + + const handleTrackDragOver = (e: React.DragEvent) => { + e.preventDefault(); + + // Handle both timeline elements and media items + const hasTimelineElement = e.dataTransfer.types.includes( + "application/x-timeline-element" + ); + const hasMediaItem = e.dataTransfer.types.includes( + "application/x-media-item" + ); + + if (!hasTimelineElement && !hasMediaItem) return; + + // Calculate drop position for overlap checking + const trackContainer = e.currentTarget.querySelector( + ".track-elements-container" + ) as HTMLElement; + let dropTime = 0; + if (trackContainer) { + const rect = trackContainer.getBoundingClientRect(); + const mouseX = Math.max(0, e.clientX - rect.left); + dropTime = mouseX / (50 * zoomLevel); + } + + // Check for potential overlaps and show appropriate feedback + let wouldOverlap = false; + + if (hasMediaItem) { + try { + const mediaItemData = e.dataTransfer.getData( + "application/x-media-item" + ); + if (mediaItemData) { + const dragData: DragData = JSON.parse(mediaItemData); + + if (dragData.type === "text") { + // Text elements have default duration of 5 seconds + const newElementDuration = 5; + const snappedTime = Math.round(dropTime * 10) / 10; + const newElementEnd = snappedTime + newElementDuration; + + wouldOverlap = track.elements.some((existingElement) => { + const existingStart = existingElement.startTime; + const existingEnd = + existingElement.startTime + + (existingElement.duration - + existingElement.trimStart - + existingElement.trimEnd); + return snappedTime < existingEnd && newElementEnd > existingStart; + }); + } else { + // Media elements + const mediaItem = mediaItems.find( + (item) => item.id === dragData.id + ); + if (mediaItem) { + const newElementDuration = mediaItem.duration || 5; + const snappedTime = Math.round(dropTime * 10) / 10; + const newElementEnd = snappedTime + newElementDuration; + + wouldOverlap = track.elements.some((existingElement) => { + const existingStart = existingElement.startTime; + const existingEnd = + existingElement.startTime + + (existingElement.duration - + existingElement.trimStart - + existingElement.trimEnd); + return ( + snappedTime < existingEnd && newElementEnd > existingStart + ); + }); + } + } + } + } catch (error) { + // Continue with default behavior + } + } else if (hasTimelineElement) { + try { + const timelineElementData = e.dataTransfer.getData( + "application/x-timeline-element" + ); + if (timelineElementData) { + const { elementId, trackId: fromTrackId } = + JSON.parse(timelineElementData); + const sourceTrack = tracks.find( + (t: TimelineTrack) => t.id === fromTrackId + ); + const movingElement = sourceTrack?.elements.find( + (c: any) => c.id === elementId + ); + + if (movingElement) { + const movingElementDuration = + movingElement.duration - + movingElement.trimStart - + movingElement.trimEnd; + const snappedTime = Math.round(dropTime * 10) / 10; + const movingElementEnd = snappedTime + movingElementDuration; + + wouldOverlap = track.elements.some((existingElement) => { + if (fromTrackId === track.id && existingElement.id === elementId) + return false; + + const existingStart = existingElement.startTime; + const existingEnd = + existingElement.startTime + + (existingElement.duration - + existingElement.trimStart - + existingElement.trimEnd); + return ( + snappedTime < existingEnd && movingElementEnd > existingStart + ); + }); + } + } + } catch (error) { + // Continue with default behavior + } + } + + if (wouldOverlap) { + e.dataTransfer.dropEffect = "none"; + setWouldOverlap(true); + setDropPosition(Math.round(dropTime * 10) / 10); + return; + } + + e.dataTransfer.dropEffect = hasTimelineElement ? "move" : "copy"; + setWouldOverlap(false); + setDropPosition(Math.round(dropTime * 10) / 10); + }; + + const handleTrackDragEnter = (e: React.DragEvent) => { + e.preventDefault(); + + const hasTimelineElement = e.dataTransfer.types.includes( + "application/x-timeline-element" + ); + const hasMediaItem = e.dataTransfer.types.includes( + "application/x-media-item" + ); + + if (!hasTimelineElement && !hasMediaItem) return; + + dragCounterRef.current++; + setIsDropping(true); + }; + + const handleTrackDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + + const hasTimelineElement = e.dataTransfer.types.includes( + "application/x-timeline-element" + ); + const hasMediaItem = e.dataTransfer.types.includes( + "application/x-media-item" + ); + + if (!hasTimelineElement && !hasMediaItem) return; + + dragCounterRef.current--; + + if (dragCounterRef.current === 0) { + setIsDropping(false); + setWouldOverlap(false); + setDropPosition(null); + } + }; + + const handleTrackDrop = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + + // Reset all drag states + dragCounterRef.current = 0; + setIsDropping(false); + setWouldOverlap(false); + + const hasTimelineElement = e.dataTransfer.types.includes( + "application/x-timeline-element" + ); + const hasMediaItem = e.dataTransfer.types.includes( + "application/x-media-item" + ); + + if (!hasTimelineElement && !hasMediaItem) return; + + const trackContainer = e.currentTarget.querySelector( + ".track-elements-container" + ) as HTMLElement; + if (!trackContainer) return; + + const rect = trackContainer.getBoundingClientRect(); + const mouseX = Math.max(0, e.clientX - rect.left); + const mouseY = e.clientY - rect.top; // Get Y position relative to this track + const newStartTime = mouseX / (50 * zoomLevel); + const snappedTime = Math.round(newStartTime * 10) / 10; + + // Calculate drop position relative to tracks + const TRACK_HEIGHT = 60; + const currentTrackIndex = tracks.findIndex((t) => t.id === track.id); + + // Determine drop zone within the track (top 20px, middle 20px, bottom 20px) + let dropPosition: "above" | "on" | "below"; + if (mouseY < 20) { + dropPosition = "above"; + } else if (mouseY > 40) { + dropPosition = "below"; + } else { + dropPosition = "on"; + } + + try { + if (hasTimelineElement) { + // Handle timeline element movement + const timelineElementData = e.dataTransfer.getData( + "application/x-timeline-element" + ); + if (!timelineElementData) return; + + const { + elementId, + trackId: fromTrackId, + clickOffsetTime = 0, + } = JSON.parse(timelineElementData); + + // Find the element being moved + const sourceTrack = tracks.find( + (t: TimelineTrack) => t.id === fromTrackId + ); + const movingElement = sourceTrack?.elements.find( + (c: TimelineElementType) => c.id === elementId + ); + + if (!movingElement) { + toast.error("Element not found"); + return; + } + + // Adjust position based on where user clicked on the element + const adjustedStartTime = snappedTime - clickOffsetTime; + const finalStartTime = Math.max( + 0, + Math.round(adjustedStartTime * 10) / 10 + ); + + // Check for overlaps with existing elements (excluding the moving element itself) + const movingElementDuration = + movingElement.duration - + movingElement.trimStart - + movingElement.trimEnd; + const movingElementEnd = finalStartTime + movingElementDuration; + + const hasOverlap = track.elements.some((existingElement) => { + // Skip the element being moved if it's on the same track + if (fromTrackId === track.id && existingElement.id === elementId) + return false; + + const existingStart = existingElement.startTime; + const existingEnd = + existingElement.startTime + + (existingElement.duration - + existingElement.trimStart - + existingElement.trimEnd); + + // Check if elements overlap + return ( + finalStartTime < existingEnd && movingElementEnd > existingStart + ); + }); + + if (hasOverlap) { + toast.error( + "Cannot move element here - it would overlap with existing elements" + ); + return; + } + + if (fromTrackId === track.id) { + // Moving within same track + updateElementStartTime(track.id, elementId, finalStartTime); + } else { + // Moving to different track + moveElementToTrack(fromTrackId, track.id, elementId); + requestAnimationFrame(() => { + updateElementStartTime(track.id, elementId, finalStartTime); + }); + } + } else if (hasMediaItem) { + // Handle media item drop + const mediaItemData = e.dataTransfer.getData( + "application/x-media-item" + ); + if (!mediaItemData) return; + + const dragData: DragData = JSON.parse(mediaItemData); + + if (dragData.type === "text") { + let targetTrackId = track.id; + let targetTrack = track; + + // Handle position-aware track creation for text + if (track.type !== "text" || dropPosition !== "on") { + // Text tracks should go above the main track + const mainTrack = getMainTrack(tracks); + let insertIndex: number; + + if (dropPosition === "above") { + insertIndex = currentTrackIndex; + } else if (dropPosition === "below") { + insertIndex = currentTrackIndex + 1; + } else { + // dropPosition === "on" but track is not text type + // Insert above main track if main track exists, otherwise at top + if (mainTrack) { + const mainTrackIndex = tracks.findIndex(t => t.id === mainTrack.id); + insertIndex = mainTrackIndex; + } else { + insertIndex = 0; // Top of timeline + } + } + + targetTrackId = insertTrackAt("text", insertIndex); + // Get the updated tracks array after creating the new track + const updatedTracks = useTimelineStore.getState().tracks; + const newTargetTrack = updatedTracks.find( + (t) => t.id === targetTrackId + ); + if (!newTargetTrack) return; + targetTrack = newTargetTrack; + } + + // Check for overlaps with existing elements in target track + const newElementDuration = 5; // Default text duration + const newElementEnd = snappedTime + newElementDuration; + + const hasOverlap = targetTrack.elements.some((existingElement) => { + const existingStart = existingElement.startTime; + const existingEnd = + existingElement.startTime + + (existingElement.duration - + existingElement.trimStart - + existingElement.trimEnd); + + // Check if elements overlap + return snappedTime < existingEnd && newElementEnd > existingStart; + }); + + if (hasOverlap) { + toast.error( + "Cannot place element here - it would overlap with existing elements" + ); + return; + } + + addElementToTrack(targetTrackId, { + type: "text", + name: dragData.name || "Text", + content: dragData.content || "Default Text", + duration: 5, + startTime: snappedTime, + trimStart: 0, + trimEnd: 0, + fontSize: 48, + fontFamily: "Arial", + color: "#ffffff", + backgroundColor: "transparent", + textAlign: "center", + fontWeight: "normal", + fontStyle: "normal", + textDecoration: "none", + x: 0, + y: 0, + rotation: 0, + opacity: 1, + }); + } else { + // Handle media items + const mediaItem = mediaItems.find((item) => item.id === dragData.id); + + if (!mediaItem) { + toast.error("Media item not found"); + return; + } + + let targetTrackId = track.id; + + // Check if track type is compatible + const isVideoOrImage = + dragData.type === "video" || dragData.type === "image"; + const isAudio = dragData.type === "audio"; + const isCompatible = + (track.type === "media" && isVideoOrImage) || + (track.type === "audio" && isAudio); + + let targetTrack = tracks.find((t) => t.id === targetTrackId); + + // Handle position-aware track creation for media elements + if (!isCompatible || dropPosition !== "on") { + const needsNewTrack = !isCompatible || dropPosition !== "on"; + + if (needsNewTrack) { + if (isVideoOrImage) { + // For video/image, check if we need a main track or additional media track + const mainTrack = getMainTrack(tracks); + + if (!mainTrack) { + // No main track exists, create it + const updatedTracks = ensureMainTrack(tracks); + const newMainTrack = getMainTrack(updatedTracks); + if (newMainTrack && newMainTrack.elements.length === 0) { + targetTrackId = newMainTrack.id; + targetTrack = newMainTrack; + } else { + // Main track was created but somehow has elements, create new media track + const mainTrackIndex = updatedTracks.findIndex(t => t.id === newMainTrack?.id); + targetTrackId = insertTrackAt("media", mainTrackIndex); + const updatedTracksAfterInsert = useTimelineStore.getState().tracks; + const newTargetTrack = updatedTracksAfterInsert.find(t => t.id === targetTrackId); + if (!newTargetTrack) return; + targetTrack = newTargetTrack; + } + } else if (mainTrack.elements.length === 0 && dropPosition === "on") { + // Main track exists and is empty, use it + targetTrackId = mainTrack.id; + targetTrack = mainTrack; + } else { + // Create new media track above main track + const mainTrackIndex = tracks.findIndex(t => t.id === mainTrack.id); + let insertIndex: number; + + if (dropPosition === "above") { + insertIndex = currentTrackIndex; + } else if (dropPosition === "below") { + insertIndex = currentTrackIndex + 1; + } else { + // Insert above main track + insertIndex = mainTrackIndex; + } + + targetTrackId = insertTrackAt("media", insertIndex); + const updatedTracks = useTimelineStore.getState().tracks; + const newTargetTrack = updatedTracks.find(t => t.id === targetTrackId); + if (!newTargetTrack) return; + targetTrack = newTargetTrack; + } + } else if (isAudio) { + // Audio tracks go at the bottom + const mainTrack = getMainTrack(tracks); + let insertIndex: number; + + if (dropPosition === "above") { + insertIndex = currentTrackIndex; + } else if (dropPosition === "below") { + insertIndex = currentTrackIndex + 1; + } else { + // Insert after main track (bottom area) + if (mainTrack) { + const mainTrackIndex = tracks.findIndex(t => t.id === mainTrack.id); + insertIndex = mainTrackIndex + 1; + } else { + insertIndex = tracks.length; // Bottom of timeline + } + } + + targetTrackId = insertTrackAt("audio", insertIndex); + const updatedTracks = useTimelineStore.getState().tracks; + const newTargetTrack = updatedTracks.find(t => t.id === targetTrackId); + if (!newTargetTrack) return; + targetTrack = newTargetTrack; + } + } + } + + if (!targetTrack) return; + + // Check for overlaps with existing elements in target track + const newElementDuration = mediaItem.duration || 5; + const newElementEnd = snappedTime + newElementDuration; + + const hasOverlap = targetTrack.elements.some((existingElement) => { + const existingStart = existingElement.startTime; + const existingEnd = + existingElement.startTime + + (existingElement.duration - + existingElement.trimStart - + existingElement.trimEnd); + + // Check if elements overlap + return snappedTime < existingEnd && newElementEnd > existingStart; + }); + + if (hasOverlap) { + toast.error( + "Cannot place element here - it would overlap with existing elements" + ); + return; + } + + addElementToTrack(targetTrackId, { + type: "media", + mediaId: mediaItem.id, + name: mediaItem.name, + duration: mediaItem.duration || 5, + startTime: snappedTime, + trimStart: 0, + trimEnd: 0, + }); + } + } + } catch (error) { + console.error("Error handling drop:", error); + toast.error("Failed to add media to track"); + } + }; + + return ( + <div + className="w-full h-full hover:bg-muted/20" + onClick={(e) => { + // If clicking empty area (not on an element), deselect all elements + if (!(e.target as HTMLElement).closest(".timeline-element")) { + clearSelectedElements(); + } + }} + onDragOver={handleTrackDragOver} + onDragEnter={handleTrackDragEnter} + onDragLeave={handleTrackDragLeave} + onDrop={handleTrackDrop} + > + <div + ref={timelineRef} + className="h-full relative track-elements-container min-w-full" + > + {track.elements.length === 0 ? ( + <div + className={`h-full w-full rounded-sm border-2 border-dashed flex items-center justify-center text-xs text-muted-foreground transition-colors ${ + isDropping + ? wouldOverlap + ? "border-red-500 bg-red-500/10 text-red-600" + : "border-blue-500 bg-blue-500/10 text-blue-600" + : "border-muted/30" + }`} + > + {isDropping + ? wouldOverlap + ? "Cannot drop - would overlap" + : "Drop element here" + : "Drop media here"} + </div> + ) : ( + <> + {track.elements.map((element) => { + const isSelected = selectedElements.some( + (c) => c.trackId === track.id && c.elementId === element.id + ); + + const handleElementSplit = () => { + const { currentTime } = usePlaybackStore(); + const { splitElement } = useTimelineStore(); + const splitTime = currentTime; + const effectiveStart = element.startTime; + const effectiveEnd = + element.startTime + + (element.duration - element.trimStart - element.trimEnd); + + if (splitTime > effectiveStart && splitTime < effectiveEnd) { + const secondElementId = splitElement( + track.id, + element.id, + splitTime + ); + if (!secondElementId) { + toast.error("Failed to split element"); + } + } else { + toast.error("Playhead must be within element to split"); + } + }; + + const handleElementDuplicate = () => { + const { addElementToTrack } = useTimelineStore.getState(); + const { id, ...elementWithoutId } = element; + addElementToTrack(track.id, { + ...elementWithoutId, + name: element.name + " (copy)", + startTime: + element.startTime + + (element.duration - element.trimStart - element.trimEnd) + + 0.1, + }); + }; + + const handleElementDelete = () => { + const { removeElementFromTrack } = useTimelineStore.getState(); + removeElementFromTrack(track.id, element.id); + }; + + return ( + <ContextMenu key={element.id}> + <ContextMenuTrigger asChild> + <div> + <TimelineElement + element={element} + track={track} + zoomLevel={zoomLevel} + isSelected={isSelected} + onElementMouseDown={handleElementMouseDown} + onElementClick={handleElementClick} + /> + </div> + </ContextMenuTrigger> + <ContextMenuContent> + <ContextMenuItem onClick={handleElementSplit}> + <Scissors className="h-4 w-4 mr-2" /> + Split at playhead + </ContextMenuItem> + <ContextMenuItem onClick={handleElementDuplicate}> + <Copy className="h-4 w-4 mr-2" /> + Duplicate {element.type === "text" ? "text" : "clip"} + </ContextMenuItem> + <ContextMenuSeparator /> + <ContextMenuItem + onClick={handleElementDelete} + className="text-destructive focus:text-destructive" + > + <Trash2 className="h-4 w-4 mr-2" /> + Delete {element.type === "text" ? "text" : "clip"} + </ContextMenuItem> + </ContextMenuContent> + </ContextMenu> + ); + })} + </> + )} + </div> + </div> + ); +} diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx index e9f44e64..283f6b04 100644 --- a/apps/web/src/components/editor/timeline.tsx +++ b/apps/web/src/components/editor/timeline.tsx @@ -42,23 +42,22 @@ import { SelectValue, } from "../ui/select"; import { TimelineTrackContent } from "./timeline-track"; +import type { DragData } from "@/types/timeline"; export function Timeline() { - // Timeline shows all tracks (video, audio, effects) and their clips. + // Timeline shows all tracks (video, audio, effects) and their elements. // You can drag media here to add it to your project. - // Clips can be trimmed, deleted, and moved. + // elements can be trimmed, deleted, and moved. const { tracks, addTrack, - addClipToTrack, - removeTrack, - toggleTrackMute, - removeClipFromTrack, + addElementToTrack, + removeElementFromTrack, getTotalDuration, - selectedClips, - clearSelectedClips, - setSelectedClips, - splitClip, + selectedElements, + clearSelectedElements, + setSelectedElements, + splitElement, splitAndKeepLeft, splitAndKeepRight, separateAudio, @@ -116,36 +115,28 @@ export function Timeline() { const lastRulerSync = useRef(0); const lastTracksSync = useRef(0); - // New refs for direct playhead DOM manipulation - const rulerPlayheadRef = useRef<HTMLDivElement>(null); - const tracksPlayheadRef = useRef<HTMLDivElement>(null); - - // Refs to store initial mouse and scroll positions for drag calculations - const initialMouseXRef = useRef(0); - const initialTimelineScrollLeftRef = useRef(0); - // Update timeline duration when tracks change useEffect(() => { const totalDuration = getTotalDuration(); setDuration(Math.max(totalDuration, 10)); // Minimum 10 seconds for empty timeline }, [tracks, setDuration, getTotalDuration]); - // Keyboard event for deleting selected clips + // Keyboard event for deleting selected elements useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if ( (e.key === "Delete" || e.key === "Backspace") && - selectedClips.length > 0 + selectedElements.length > 0 ) { - selectedClips.forEach(({ trackId, clipId }) => { - removeClipFromTrack(trackId, clipId); + selectedElements.forEach(({ trackId, elementId }) => { + removeElementFromTrack(trackId, elementId); }); - clearSelectedClips(); + clearSelectedElements(); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [selectedClips, removeClipFromTrack, clearSelectedClips]); + }, [selectedElements, removeElementFromTrack, clearSelectedElements]); // Keyboard event for undo (Cmd+Z) useEffect(() => { @@ -190,9 +181,9 @@ export function Timeline() { // Add new click handler for deselection const handleTimelineClick = (e: React.MouseEvent) => { - // If clicking empty area (not on a clip) and not starting marquee, deselect all clips - if (!(e.target as HTMLElement).closest(".timeline-clip")) { - clearSelectedClips(); + // If clicking empty area (not on an element) and not starting marquee, deselect all elements + if (!(e.target as HTMLElement).closest(".timeline-element")) { + clearSelectedElements(); } }; @@ -218,7 +209,7 @@ export function Timeline() { }; }, [marquee]); - // On marquee end, select clips in box + // On marquee end, select elements in box useEffect(() => { if (!marquee || marquee.active) return; const timeline = timelineRef.current; @@ -240,56 +231,54 @@ export function Timeline() { const bx2 = clamp(x2, 0, rect.width); const by1 = clamp(y1, 0, rect.height); const by2 = clamp(y2, 0, rect.height); - let newSelection: { trackId: string; clipId: string }[] = []; + let newSelection: { trackId: string; elementId: string }[] = []; tracks.forEach((track, trackIdx) => { - track.clips.forEach((clip) => { - const effectiveDuration = clip.duration - clip.trimStart - clip.trimEnd; - const clipWidth = Math.max(80, effectiveDuration * 50 * zoomLevel); - const clipLeft = clip.startTime * 50 * zoomLevel; + track.elements.forEach((element) => { + const clipLeft = element.startTime * 50 * zoomLevel; const clipTop = trackIdx * 60; const clipBottom = clipTop + 60; - const clipRight = clipLeft + 60; // Set a fixed width for time display + const clipRight = clipLeft + 60; if ( bx1 < clipRight && bx2 > clipLeft && by1 < clipBottom && by2 > clipTop ) { - newSelection.push({ trackId: track.id, clipId: clip.id }); + newSelection.push({ trackId: track.id, elementId: element.id }); } }); }); if (newSelection.length > 0) { if (marquee.additive) { const selectedSet = new Set( - selectedClips.map((c) => c.trackId + ":" + c.clipId) + selectedElements.map((c) => c.trackId + ":" + c.elementId) ); newSelection = [ - ...selectedClips, + ...selectedElements, ...newSelection.filter( - (c) => !selectedSet.has(c.trackId + ":" + c.clipId) + (c) => !selectedSet.has(c.trackId + ":" + c.elementId) ), ]; } - setSelectedClips(newSelection); + setSelectedElements(newSelection); } else if (!marquee.additive) { - clearSelectedClips(); + clearSelectedElements(); } setMarquee(null); }, [ marquee, tracks, zoomLevel, - selectedClips, - setSelectedClips, - clearSelectedClips, + selectedElements, + setSelectedElements, + clearSelectedElements, ]); const handleDragEnter = (e: React.DragEvent) => { // When something is dragged over the timeline, show overlay e.preventDefault(); - // Don't show overlay for timeline clips - they're handled by tracks - if (e.dataTransfer.types.includes("application/x-timeline-clip")) { + // Don't show overlay for timeline elements - they're handled by tracks + if (e.dataTransfer.types.includes("application/x-timeline-element")) { return; } dragCounterRef.current += 1; @@ -305,8 +294,8 @@ export function Timeline() { const handleDragLeave = (e: React.DragEvent) => { e.preventDefault(); - // Don't update state for timeline clips - they're handled by tracks - if (e.dataTransfer.types.includes("application/x-timeline-clip")) { + // Don't update state for timeline elements - they're handled by tracks + if (e.dataTransfer.types.includes("application/x-timeline-element")) { return; } @@ -317,44 +306,74 @@ export function Timeline() { }; const handleDrop = async (e: React.DragEvent) => { - // When media is dropped, add it as a new track/clip + // When media is dropped, add it as a new track/element e.preventDefault(); setIsDragOver(false); dragCounterRef.current = 0; - // Ignore timeline clip drags - they're handled by track-specific handlers - const hasTimelineClip = e.dataTransfer.types.includes( - "application/x-timeline-clip" + // Ignore timeline element drags - they're handled by track-specific handlers + const hasTimelineElement = e.dataTransfer.types.includes( + "application/x-timeline-element" ); - if (hasTimelineClip) { + if (hasTimelineElement) { return; } - const mediaItemData = e.dataTransfer.getData("application/x-media-item"); - if (mediaItemData) { - // Handle media item drops by creating new tracks + const itemData = e.dataTransfer.getData("application/x-media-item"); + if (itemData) { try { - const { id, type } = JSON.parse(mediaItemData); - const mediaItem = mediaItems.find((item) => item.id === id); - if (!mediaItem) { - toast.error("Media item not found"); - return; + const dragData: DragData = JSON.parse(itemData); + + if (dragData.type === "text") { + // Always create new text track to avoid overlaps + const newTrackId = addTrack("text"); + + addElementToTrack(newTrackId, { + type: "text", + name: dragData.name || "Text", + content: dragData.content || "Default Text", + duration: 5, + startTime: 0, + trimStart: 0, + trimEnd: 0, + fontSize: 48, + fontFamily: "Arial", + color: "#ffffff", + backgroundColor: "transparent", + textAlign: "center", + fontWeight: "normal", + fontStyle: "normal", + textDecoration: "none", + x: 0, + y: 0, + rotation: 0, + opacity: 1, + }); + } else { + // Handle media items + const mediaItem = mediaItems.find((item) => item.id === dragData.id); + if (!mediaItem) { + toast.error("Media item not found"); + return; + } + + const trackType = dragData.type === "audio" ? "audio" : "media"; + let targetTrack = tracks.find((t) => t.type === trackType); + const newTrackId = targetTrack ? targetTrack.id : addTrack(trackType); + + addElementToTrack(newTrackId, { + type: "media", + mediaId: mediaItem.id, + name: mediaItem.name, + duration: mediaItem.duration || 5, + startTime: 0, + trimStart: 0, + trimEnd: 0, + }); } - // Add to video or audio track depending on type - const trackType = type === "audio" ? "audio" : "video"; - const newTrackId = addTrack(trackType); - addClipToTrack(newTrackId, { - mediaId: mediaItem.id, - name: mediaItem.name, - duration: mediaItem.duration || 5, - startTime: 0, - trimStart: 0, - trimEnd: 0, - }); } catch (error) { - // Show error if parsing fails - console.error("Error parsing media item data:", error); - toast.error("Failed to add media to timeline"); + console.error("Error parsing dropped item data:", error); + toast.error("Failed to add item to timeline"); } } else if (e.dataTransfer.files?.length > 0) { // Handle file drops by creating new tracks @@ -374,9 +393,10 @@ export function Timeline() { ); if (addedItem) { const trackType = - processedItem.type === "audio" ? "audio" : "video"; + processedItem.type === "audio" ? "audio" : "media"; const newTrackId = addTrack(trackType); - addClipToTrack(newTrackId, { + addElementToTrack(newTrackId, { + type: "media", mediaId: addedItem.id, name: addedItem.name, duration: addedItem.duration || 5, @@ -502,175 +522,134 @@ export function Timeline() { // Action handlers for toolbar const handleSplitSelected = () => { - if (selectedClips.length === 0) { - toast.error("No clips selected"); + if (selectedElements.length === 0) { + toast.error("No elements selected"); return; } let splitCount = 0; - selectedClips.forEach(({ trackId, clipId }) => { + selectedElements.forEach(({ trackId, elementId }) => { const track = tracks.find((t) => t.id === trackId); - const clip = track?.clips.find((c) => c.id === clipId); - if (clip && track) { - const effectiveStart = clip.startTime; + const element = track?.elements.find((c) => c.id === elementId); + if (element && track) { + const effectiveStart = element.startTime; const effectiveEnd = - clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd); + element.startTime + + (element.duration - element.trimStart - element.trimEnd); if (currentTime > effectiveStart && currentTime < effectiveEnd) { - const newClipId = splitClip(trackId, clipId, currentTime); - if (newClipId) splitCount++; + const newElementId = splitElement(trackId, elementId, currentTime); + if (newElementId) splitCount++; } } }); - if (splitCount > 0) { - toast.success(`Split ${splitCount} clip(s) at playhead`); - } else { - toast.error("Playhead must be within selected clips to split"); + if (splitCount === 0) { + toast.error("Playhead must be within selected elements to split"); } }; const handleDuplicateSelected = () => { - if (selectedClips.length === 0) { - toast.error("No clips selected"); + if (selectedElements.length === 0) { + toast.error("No elements selected"); return; } - selectedClips.forEach(({ trackId, clipId }) => { + const canDuplicate = selectedElements.length === 1; + if (!canDuplicate) return; + + const newSelections: { trackId: string; elementId: string }[] = []; + + selectedElements.forEach(({ trackId, elementId }) => { const track = tracks.find((t) => t.id === trackId); - const clip = track?.clips.find((c) => c.id === clipId); - if (clip && track) { - addClipToTrack(track.id, { - mediaId: clip.mediaId, - name: clip.name + " (copy)", - duration: clip.duration, - startTime: - clip.startTime + - (clip.duration - clip.trimStart - clip.trimEnd) + - 0.1, - trimStart: clip.trimStart, - trimEnd: clip.trimEnd, + const element = track?.elements.find((el) => el.id === elementId); + + if (element) { + const newStartTime = + element.startTime + + (element.duration - element.trimStart - element.trimEnd) + + 0.1; + + // Create element without id (will be generated by store) + const { id, ...elementWithoutId } = element; + + addElementToTrack(trackId, { + ...elementWithoutId, + startTime: newStartTime, }); + + // We can't predict the new id, so just clear selection for now + // TODO: addElementToTrack could return the new element id } }); - toast.success("Duplicated selected clip(s)"); + + clearSelectedElements(); }; const handleFreezeSelected = () => { - if (selectedClips.length === 0) { - toast.error("No clips selected"); - return; - } - selectedClips.forEach(({ trackId, clipId }) => { - const track = tracks.find((t) => t.id === trackId); - const clip = track?.clips.find((c) => c.id === clipId); - if (clip && track) { - // Add a new freeze frame clip at the playhead - addClipToTrack(track.id, { - mediaId: clip.mediaId, - name: clip.name + " (freeze)", - duration: 1, // 1 second freeze frame - startTime: currentTime, - trimStart: 0, - trimEnd: clip.duration - 1, - }); - } - }); + toast.info("Freeze frame functionality coming soon!"); }; + const handleSplitAndKeepLeft = () => { - if (selectedClips.length === 0) { - toast.error("No clips selected"); + if (selectedElements.length !== 1) { + toast.error("Select exactly one element"); return; } - - let splitCount = 0; - selectedClips.forEach(({ trackId, clipId }) => { - const track = tracks.find((t) => t.id === trackId); - const clip = track?.clips.find((c) => c.id === clipId); - if (clip && track) { - const effectiveStart = clip.startTime; - const effectiveEnd = - clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd); - - if (currentTime > effectiveStart && currentTime < effectiveEnd) { - splitAndKeepLeft(trackId, clipId, currentTime); - splitCount++; - } - } - }); - - if (splitCount > 0) { - toast.success(`Split and kept left portion of ${splitCount} clip(s)`); - } else { - toast.error("Playhead must be within selected clips"); + const { trackId, elementId } = selectedElements[0]; + const track = tracks.find((t) => t.id === trackId); + const element = track?.elements.find((c) => c.id === elementId); + if (!element) return; + const effectiveStart = element.startTime; + const effectiveEnd = + element.startTime + + (element.duration - element.trimStart - element.trimEnd); + if (currentTime <= effectiveStart || currentTime >= effectiveEnd) { + toast.error("Playhead must be within selected element"); + return; } + splitAndKeepLeft(trackId, elementId, currentTime); }; const handleSplitAndKeepRight = () => { - if (selectedClips.length === 0) { - toast.error("No clips selected"); + if (selectedElements.length !== 1) { + toast.error("Select exactly one element"); return; } - - let splitCount = 0; - selectedClips.forEach(({ trackId, clipId }) => { - const track = tracks.find((t) => t.id === trackId); - const clip = track?.clips.find((c) => c.id === clipId); - if (clip && track) { - const effectiveStart = clip.startTime; - const effectiveEnd = - clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd); - - if (currentTime > effectiveStart && currentTime < effectiveEnd) { - splitAndKeepRight(trackId, clipId, currentTime); - splitCount++; - } - } - }); - - if (splitCount > 0) { - toast.success(`Split and kept right portion of ${splitCount} clip(s)`); - } else { - toast.error("Playhead must be within selected clips"); + const { trackId, elementId } = selectedElements[0]; + const track = tracks.find((t) => t.id === trackId); + const element = track?.elements.find((c) => c.id === elementId); + if (!element) return; + const effectiveStart = element.startTime; + const effectiveEnd = + element.startTime + + (element.duration - element.trimStart - element.trimEnd); + if (currentTime <= effectiveStart || currentTime >= effectiveEnd) { + toast.error("Playhead must be within selected element"); + return; } + splitAndKeepRight(trackId, elementId, currentTime); }; const handleSeparateAudio = () => { - if (selectedClips.length === 0) { - toast.error("No clips selected"); + if (selectedElements.length !== 1) { + toast.error("Select exactly one media element to separate audio"); return; } - - let separatedCount = 0; - selectedClips.forEach(({ trackId, clipId }) => { - const track = tracks.find((t) => t.id === trackId); - const clip = track?.clips.find((c) => c.id === clipId); - const mediaItem = mediaItems.find((item) => item.id === clip?.mediaId); - - if ( - clip && - track && - mediaItem?.type === "video" && - track.type === "video" - ) { - const audioClipId = separateAudio(trackId, clipId); - if (audioClipId) separatedCount++; - } - }); - - if (separatedCount > 0) { - toast.success(`Separated audio from ${separatedCount} video clip(s)`); - } else { - toast.error("Select video clips to separate audio"); + const { trackId, elementId } = selectedElements[0]; + const track = tracks.find((t) => t.id === trackId); + if (!track || track.type !== "media") { + toast.error("Select a media element to separate audio"); + return; } + separateAudio(trackId, elementId); }; + const handleDeleteSelected = () => { - if (selectedClips.length === 0) { - toast.error("No clips selected"); + if (selectedElements.length === 0) { + toast.error("No elements selected"); return; } - selectedClips.forEach(({ trackId, clipId }) => { - removeClipFromTrack(trackId, clipId); + selectedElements.forEach(({ trackId, elementId }) => { + removeElementFromTrack(trackId, elementId); }); - clearSelectedClips(); - toast.success("Deleted selected clip(s)"); + clearSelectedElements(); }; // Prevent explorer zooming in/out when in timeline @@ -754,7 +733,7 @@ export function Timeline() { return ( <div - className={`h-full flex flex-col transition-colors duration-200 relative ${isDragOver ? "bg-accent/30 border-accent" : ""}`} + className={`h-full flex flex-col transition-colors duration-200 relative`} {...dragProps} onMouseEnter={() => setIsInTimeline(true)} onMouseLeave={() => setIsInTimeline(false)} @@ -783,9 +762,7 @@ export function Timeline() { {isPlaying ? "Pause (Space)" : "Play (Space)"} </TooltipContent> </Tooltip> - <div className="w-px h-6 bg-border mx-1" /> - {/* Time Display */} <div className="text-xs text-muted-foreground font-mono px-2" @@ -793,7 +770,6 @@ export function Timeline() { > {currentTime.toFixed(1)}s / {duration.toFixed(1)}s </div> - {/* Test Clip Button - for debugging */} {tracks.length === 0 && ( <> @@ -804,8 +780,9 @@ export function Timeline() { variant="outline" size="sm" onClick={() => { - const trackId = addTrack("video"); - addClipToTrack(trackId, { + const trackId = addTrack("media"); + addElementToTrack(trackId, { + type: "media", mediaId: "test", name: "Test Clip", duration: 5, @@ -823,18 +800,15 @@ export function Timeline() { </Tooltip> </> )} - <div className="w-px h-6 bg-border mx-1" /> - <Tooltip> <TooltipTrigger asChild> <Button variant="text" size="icon" onClick={handleSplitSelected}> <Scissors className="h-4 w-4" /> </Button> </TooltipTrigger> - <TooltipContent>Split clip (Ctrl+S)</TooltipContent> + <TooltipContent>Split element (Ctrl+S)</TooltipContent> </Tooltip> - <Tooltip> <TooltipTrigger asChild> <Button @@ -847,7 +821,6 @@ export function Timeline() { </TooltipTrigger> <TooltipContent>Split and keep left (Ctrl+Q)</TooltipContent> </Tooltip> - <Tooltip> <TooltipTrigger asChild> <Button @@ -860,7 +833,6 @@ export function Timeline() { </TooltipTrigger> <TooltipContent>Split and keep right (Ctrl+W)</TooltipContent> </Tooltip> - <Tooltip> <TooltipTrigger asChild> <Button variant="text" size="icon" onClick={handleSeparateAudio}> @@ -869,7 +841,6 @@ export function Timeline() { </TooltipTrigger> <TooltipContent>Separate audio (Ctrl+D)</TooltipContent> </Tooltip> - <Tooltip> <TooltipTrigger asChild> <Button @@ -880,9 +851,8 @@ export function Timeline() { <Copy className="h-4 w-4" /> </Button> </TooltipTrigger> - <TooltipContent>Duplicate clip (Ctrl+D)</TooltipContent> + <TooltipContent>Duplicate element (Ctrl+D)</TooltipContent> </Tooltip> - <Tooltip> <TooltipTrigger asChild> <Button variant="text" size="icon" onClick={handleFreezeSelected}> @@ -891,19 +861,15 @@ export function Timeline() { </TooltipTrigger> <TooltipContent>Freeze frame (F)</TooltipContent> </Tooltip> - <Tooltip> <TooltipTrigger asChild> <Button variant="text" size="icon" onClick={handleDeleteSelected}> <Trash2 className="h-4 w-4" /> </Button> </TooltipTrigger> - <TooltipContent>Delete clip (Delete)</TooltipContent> + <TooltipContent>Delete element (Delete)</TooltipContent> </Tooltip> - - <div className="w-px h-6 bg-border mx-1" /> - - {/* Speed Control */} + <div className="w-px h-6 bg-border mx-1" />c{/* Speed Control */} <Tooltip> <TooltipTrigger asChild> <Select @@ -935,9 +901,6 @@ export function Timeline() { <span className="text-sm font-medium text-muted-foreground"> Tracks </span> - <div className="text-xs text-muted-foreground"> - {zoomLevel.toFixed(1)}x - </div> </div> {/* Timeline Ruler */} @@ -1045,7 +1008,7 @@ export function Timeline() { <div className="flex items-center flex-1 min-w-0"> <div className={`w-3 h-3 rounded-full flex-shrink-0 ${ - track.type === "video" + track.type === "media" ? "bg-blue-500" : track.type === "audio" ? "bg-green-500" @@ -1080,16 +1043,7 @@ export function Timeline() { onMouseDown={handleTimelineMouseDown} > {tracks.length === 0 ? ( - <div className="absolute inset-0 flex items-center justify-center"> - <div className="text-center"> - <div className="w-16 h-16 rounded-full bg-muted/30 flex items-center justify-center mb-4 mx-auto"> - <SplitSquareHorizontal className="h-8 w-8 text-muted-foreground" /> - </div> - <p className="text-sm text-muted-foreground"> - Drop media here to start - </p> - </div> - </div> + <div></div> ) : ( <> {tracks.map((track, index) => ( @@ -1102,13 +1056,13 @@ export function Timeline() { height: "60px", }} onClick={(e) => { - // If clicking empty area (not on a clip), deselect all clips + // If clicking empty area (not on a element), deselect all elements if ( !(e.target as HTMLElement).closest( - ".timeline-clip" + ".timeline-element" ) ) { - clearSelectedClips(); + clearSelectedElements(); } }} > @@ -1119,33 +1073,8 @@ export function Timeline() { </div> </ContextMenuTrigger> <ContextMenuContent> - <ContextMenuItem - onClick={() => { - toggleTrackMute(track.id); - }} - > - {track.muted ? ( - <> - <Volume2 className="h-4 w-4 mr-2" /> - Unmute Track - </> - ) : ( - <> - <VolumeX className="h-4 w-4 mr-2" /> - Mute Track - </> - )} - </ContextMenuItem> - <ContextMenuSeparator /> - <ContextMenuItem - onClick={() => { - removeTrack(track.id); - toast.success("Track deleted"); - }} - className="text-destructive focus:text-destructive" - > - <Trash2 className="h-4 w-4 mr-2" /> - Delete Track + <ContextMenuItem> + Track settings (soon) </ContextMenuItem> </ContextMenuContent> </ContextMenu> @@ -1164,15 +1093,6 @@ export function Timeline() { )} </> )} - {isDragOver && ( - <div className="absolute inset-0 z-20 flex items-center justify-center pointer-events-none backdrop-blur-lg"> - <div> - {isProcessing - ? `Processing ${progress}%` - : "Drop media here to add to timeline"} - </div> - </div> - )} </div> </ScrollArea> </div> diff --git a/apps/web/src/components/footer.tsx b/apps/web/src/components/footer.tsx index ea2c33e8..10f0e119 100644 --- a/apps/web/src/components/footer.tsx +++ b/apps/web/src/components/footer.tsx @@ -4,7 +4,7 @@ import { motion } from "motion/react"; import Link from "next/link";
import { useEffect, useState } from "react";
import { RiGithubLine, RiTwitterXLine } from "react-icons/ri";
-import { getStars } from "@/lib/fetchGhStars";
+import { getStars } from "@/lib/fetch-github-stars";
import Image from "next/image";
export function Footer() {
diff --git a/apps/web/src/components/header.tsx b/apps/web/src/components/header.tsx index 56317c08..468edf16 100644 --- a/apps/web/src/components/header.tsx +++ b/apps/web/src/components/header.tsx @@ -5,7 +5,7 @@ import { Button } from "./ui/button"; import { ArrowRight } from "lucide-react";
import { HeaderBase } from "./header-base";
import { useSession } from "@opencut/auth/client";
-import { getStars } from "@/lib/fetchGhStars";
+import { getStars } from "@/lib/fetch-github-stars";
import { useEffect, useState } from "react";
import Image from "next/image";
diff --git a/apps/web/src/components/landing/hero.tsx b/apps/web/src/components/landing/hero.tsx index aac63e94..d45f33ed 100644 --- a/apps/web/src/components/landing/hero.tsx +++ b/apps/web/src/components/landing/hero.tsx @@ -4,7 +4,6 @@ import { motion } from "motion/react"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; import { ArrowRight } from "lucide-react"; -import Link from "next/link"; import { useState } from "react"; import { useToast } from "@/hooks/use-toast"; @@ -42,7 +41,7 @@ export function Hero({ signupCount }: HeroProps) { body: JSON.stringify({ email: email.trim() }), }); - const data = await response.json(); + const data = (await response.json()) as { error: string }; if (response.ok) { toast({ @@ -53,7 +52,9 @@ export function Hero({ signupCount }: HeroProps) { } else { toast({ title: "Oops!", - description: data.error || "Something went wrong. Please try again.", + description: + (data as { error: string }).error || + "Something went wrong. Please try again.", variant: "destructive", }); } @@ -132,7 +133,7 @@ export function Hero({ signupCount }: HeroProps) { <Button type="submit" size="lg" - className="px-6 h-11 text-base" + className="px-6 h-11 text-base !bg-foreground" disabled={isSubmitting} > <span className="relative z-10"> diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx index d4406d5b..234230f6 100644 --- a/apps/web/src/components/ui/button.tsx +++ b/apps/web/src/components/ui/button.tsx @@ -10,6 +10,8 @@ const buttonVariants = cva( variants: { variant: { default: + "bg-foreground text-background shadow hover:bg-foreground/90", + primary: "bg-primary text-primary-foreground shadow hover:bg-primary/90", destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", diff --git a/apps/web/src/components/ui/draggable-item.tsx b/apps/web/src/components/ui/draggable-item.tsx new file mode 100644 index 00000000..16518dde --- /dev/null +++ b/apps/web/src/components/ui/draggable-item.tsx @@ -0,0 +1,150 @@ +"use client"; + +import { AspectRatio } from "@/components/ui/aspect-ratio"; +import { Button } from "@/components/ui/button"; +import { ReactNode, useState, useRef, useEffect } from "react"; +import { createPortal } from "react-dom"; +import { Plus } from "lucide-react"; +import { cn } from "@/lib/utils"; + +export interface DraggableMediaItemProps { + name: string; + preview: ReactNode; + dragData: Record<string, any>; + onDragStart?: (e: React.DragEvent) => void; + aspectRatio?: number; + className?: string; + showPlusOnDrag?: boolean; + showLabel?: boolean; + rounded?: boolean; +} + +export function DraggableMediaItem({ + name, + preview, + dragData, + onDragStart, + aspectRatio = 16 / 9, + className = "", + showPlusOnDrag = true, + showLabel = true, + rounded = true, +}: DraggableMediaItemProps) { + const [isDragging, setIsDragging] = useState(false); + const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 }); + const dragRef = useRef<HTMLDivElement>(null); + + const emptyImg = new window.Image(); + emptyImg.src = + "data:image/gif;base64,R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs="; + + useEffect(() => { + if (!isDragging) return; + + const handleDragOver = (e: DragEvent) => { + setDragPosition({ x: e.clientX, y: e.clientY }); + }; + + document.addEventListener("dragover", handleDragOver); + + return () => { + document.removeEventListener("dragover", handleDragOver); + }; + }, [isDragging]); + + const handleDragStart = (e: React.DragEvent) => { + e.dataTransfer.setDragImage(emptyImg, 0, 0); + + // Set drag data + e.dataTransfer.setData( + "application/x-media-item", + JSON.stringify(dragData) + ); + e.dataTransfer.effectAllowed = "copy"; + + // Set initial position and show custom drag preview + setDragPosition({ x: e.clientX, y: e.clientY }); + setIsDragging(true); + + onDragStart?.(e); + }; + + const handleDragEnd = () => { + setIsDragging(false); + }; + + 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} + className={cn( + "bg-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" /> + )} + </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> + + {/* Custom drag preview */} + {isDragging && + typeof document !== "undefined" && + createPortal( + <div + className="fixed pointer-events-none z-[9999]" + style={{ + left: dragPosition.x - 40, // Center the preview (half of 80px) + top: dragPosition.y - 40, // Center the preview (half of 80px) + }} + > + <div className="w-[80px]"> + <AspectRatio + ratio={1} + className="relative rounded-md overflow-hidden shadow-2xl ring ring-primary" + > + <div className="w-full h-full [&_img]:w-full [&_img]:h-full [&_img]:object-cover [&_img]:rounded-none"> + {preview} + </div> + {showPlusOnDrag && <PlusButton />} + </AspectRatio> + </div> + </div>, + document.body + )} + </> + ); +} + +function PlusButton({ className }: { className?: string }) { + return ( + <Button + size="icon" + className={cn("absolute bottom-2 right-2 size-4", className)} + > + <Plus className="!size-3" /> + </Button> + ); +} |
