summaryrefslogtreecommitdiff
path: root/apps/web/src/components/editor
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2025-06-22 19:28:03 +0200
committerMaze Winther <mazewinther@gmail.com>2025-06-22 19:28:03 +0200
commit6ee16f9df8aeabfef0ca533f9f5b13daab3a3758 (patch)
tree75a9db32a8c4e4a60c54e35c914444b00d1a76d9 /apps/web/src/components/editor
parente22aa6620c5cdf97c71947e96e894313ac3e895e (diff)
downloadopencut-6ee16f9df8aeabfef0ca533f9f5b13daab3a3758.tar.gz
feat: so much stuff
Diffstat (limited to 'apps/web/src/components/editor')
-rw-r--r--apps/web/src/components/editor/media-panel.tsx280
-rw-r--r--apps/web/src/components/editor/preview-panel.tsx106
-rw-r--r--apps/web/src/components/editor/properties-panel.tsx97
-rw-r--r--apps/web/src/components/editor/timeline.tsx409
4 files changed, 813 insertions, 79 deletions
diff --git a/apps/web/src/components/editor/media-panel.tsx b/apps/web/src/components/editor/media-panel.tsx
index 565a4e7d..17fe0417 100644
--- a/apps/web/src/components/editor/media-panel.tsx
+++ b/apps/web/src/components/editor/media-panel.tsx
@@ -4,25 +4,76 @@ import { Button } from "../ui/button";
import { AspectRatio } from "../ui/aspect-ratio";
import { DragOverlay } from "../ui/drag-overlay";
import { useMediaStore } from "@/stores/media-store";
-import { Plus, Image, Video, Music } from "lucide-react";
+import { processMediaFiles } from "@/lib/media-processing";
+import { Plus, Image, Video, Music, Trash2, Upload } from "lucide-react";
import { useDragDrop } from "@/hooks/use-drag-drop";
+import { useRef, useState } from "react";
+import { toast } from "sonner";
export function MediaPanel() {
- const { mediaItems, addMediaItem } = useMediaStore();
+ const { mediaItems, addMediaItem, removeMediaItem } = useMediaStore();
+ const fileInputRef = useRef<HTMLInputElement>(null);
+ const [isProcessing, setIsProcessing] = useState(false);
+
+ const processFiles = async (files: FileList | File[]) => {
+ setIsProcessing(true);
+
+ try {
+ const processedItems = await processMediaFiles(files);
+
+ for (const processedItem of processedItems) {
+ addMediaItem(processedItem);
+ toast.success(`Added ${processedItem.name} to project`);
+ }
+ } catch (error) {
+ console.error("Error processing files:", error);
+ toast.error("Failed to process files");
+ } finally {
+ setIsProcessing(false);
+ }
+ };
const { isDragOver, dragProps } = useDragDrop({
onDrop: (files) => {
- // TODO: Handle file drop functionality
- console.log("Files dropped on media panel:", files);
+ processFiles(files);
},
});
- const handleAddSampleMedia = () => {
- // Just for testing - add a sample media item
- addMediaItem({
- name: `Sample ${mediaItems.length + 1}`,
- type: "image",
- });
+ const handleFileSelect = () => {
+ fileInputRef.current?.click();
+ };
+
+ const handleFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+ if (e.target.files && e.target.files.length > 0) {
+ processFiles(e.target.files);
+ // Reset the input so the same file can be selected again
+ e.target.value = "";
+ }
+ };
+
+ const handleRemoveItem = (e: React.MouseEvent, itemId: string) => {
+ e.stopPropagation();
+ removeMediaItem(itemId);
+ toast.success("Media removed from project");
+ };
+
+ const handleDragStart = (e: React.DragEvent, item: any) => {
+ // Mark this as an internal app drag
+ e.dataTransfer.setData(
+ "application/x-media-item",
+ JSON.stringify({
+ id: item.id,
+ type: item.type,
+ name: item.name,
+ })
+ );
+ e.dataTransfer.effectAllowed = "copy";
+ };
+
+ const formatDuration = (duration: number) => {
+ const minutes = Math.floor(duration / 60);
+ const seconds = Math.floor(duration % 60);
+ return `${minutes}:${seconds.toString().padStart(2, "0")}`;
};
const getMediaIcon = (type: string) => {
@@ -36,63 +87,188 @@ export function MediaPanel() {
}
};
+ const renderMediaPreview = (item: any) => {
+ switch (item.type) {
+ case "image":
+ return (
+ <img
+ src={item.url}
+ alt={item.name}
+ className="w-full h-full object-cover rounded cursor-grab active:cursor-grabbing"
+ loading="lazy"
+ draggable={true}
+ onDragStart={(e) => handleDragStart(e, item)}
+ />
+ );
+ case "video":
+ return item.thumbnailUrl ? (
+ <div className="relative w-full h-full">
+ <img
+ src={item.thumbnailUrl}
+ alt={item.name}
+ className="w-full h-full object-cover rounded cursor-grab active:cursor-grabbing"
+ loading="lazy"
+ draggable={true}
+ onDragStart={(e) => handleDragStart(e, item)}
+ />
+ <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>
+ ) : (
+ <div
+ className="w-full h-full bg-muted/30 flex flex-col items-center justify-center text-muted-foreground rounded cursor-grab active:cursor-grabbing"
+ draggable={true}
+ onDragStart={(e) => handleDragStart(e, item)}
+ >
+ <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>
+ );
+ case "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 cursor-grab active:cursor-grabbing"
+ draggable={true}
+ onDragStart={(e) => handleDragStart(e, item)}
+ >
+ <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>
+ );
+ default:
+ return (
+ <div
+ className="w-full h-full bg-muted/30 flex flex-col items-center justify-center text-muted-foreground rounded cursor-grab active:cursor-grabbing"
+ draggable={true}
+ onDragStart={(e) => handleDragStart(e, item)}
+ >
+ {getMediaIcon(item.type)}
+ <span className="text-xs mt-1">Unknown</span>
+ </div>
+ );
+ }
+ };
+
return (
- <div
- className={`h-full overflow-y-auto transition-colors duration-200 relative ${
- isDragOver ? "bg-accent/30 border-accent" : ""
- }`}
- {...dragProps}
- >
- <DragOverlay isVisible={isDragOver} />
+ <>
+ <input
+ ref={fileInputRef}
+ type="file"
+ accept="image/*,video/*,audio/*"
+ multiple
+ className="hidden"
+ onChange={handleFileInputChange}
+ />
- <div className="space-y-4 p-2 h-full">
- {/* Media Grid */}
- {mediaItems.length === 0 ? (
- <EmptyMedia onAddSample={handleAddSampleMedia} />
- ) : (
- <div className="grid grid-cols-2 gap-2">
- {mediaItems.map((item) => (
- <Button
- key={item.id}
- variant="outline"
- className="flex flex-col gap-2 p-2 h-auto overflow-hidden"
- >
- <AspectRatio ratio={16 / 9} className="w-full">
- <div className="w-full h-full bg-muted/30 flex flex-col items-center justify-center text-muted-foreground">
- {getMediaIcon(item.type)}
- <span className="text-xs mt-1 truncate max-w-full px-1">
+ <div
+ className={`h-full overflow-y-auto transition-colors duration-200 relative ${
+ isDragOver ? "bg-accent/30 border-accent" : ""
+ }`}
+ {...dragProps}
+ >
+ <DragOverlay isVisible={isDragOver} />
+
+ <div className="space-y-4 p-2 h-full">
+ {/* Media Grid */}
+ {mediaItems.length === 0 ? (
+ <EmptyMedia
+ onFileSelect={handleFileSelect}
+ isProcessing={isProcessing}
+ />
+ ) : (
+ <div className="grid grid-cols-2 gap-2">
+ {mediaItems.map((item) => (
+ <div key={item.id} className="relative group">
+ <Button
+ variant="outline"
+ className="flex flex-col gap-2 p-2 h-auto overflow-hidden w-full relative"
+ >
+ <AspectRatio ratio={item.aspectRatio} className="w-full">
+ {renderMediaPreview(item)}
+ </AspectRatio>
+ <span className="text-xs truncate max-w-full px-1">
{item.name}
</span>
+ </Button>
+
+ {/* Remove button - positioned outside the button container */}
+ <div
+ className="absolute -top-2 -right-2 opacity-0 group-hover:opacity-100 transition-opacity z-20"
+ onDragStart={(e) => e.preventDefault()}
+ onDrag={(e) => e.preventDefault()}
+ >
+ <Button
+ variant="destructive"
+ size="icon"
+ className="h-6 w-6 pointer-events-auto"
+ onClick={(e) => handleRemoveItem(e, item.id)}
+ >
+ <Trash2 className="h-3 w-3" />
+ </Button>
</div>
- </AspectRatio>
- </Button>
- ))}
- </div>
- )}
+ </div>
+ ))}
+ </div>
+ )}
+ </div>
</div>
- </div>
+ </>
);
}
-function EmptyMedia({ onAddSample }: { onAddSample: () => void }) {
+function EmptyMedia({
+ onFileSelect,
+ isProcessing,
+}: {
+ onFileSelect: () => void;
+ isProcessing: boolean;
+}) {
return (
<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" />
+ {isProcessing ? (
+ <div className="animate-spin">
+ <Upload className="h-8 w-8 text-muted-foreground" />
+ </div>
+ ) : (
+ <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-sm text-muted-foreground">
+ {isProcessing ? "Processing files..." : "No media in project"}
+ </p>
<p className="text-xs text-muted-foreground/70 mt-1">
- Drag files or click to add media
+ {isProcessing
+ ? "Please wait while files are being processed"
+ : "Drag files or click to add media"}
</p>
- <Button
- variant="outline"
- size="sm"
- className="mt-4"
- onClick={onAddSample}
- >
- <Plus className="h-4 w-4 mr-2" />
- Add Sample
- </Button>
+ {!isProcessing && (
+ <Button
+ variant="outline"
+ size="sm"
+ className="mt-4"
+ onClick={onFileSelect}
+ >
+ <Plus className="h-4 w-4 mr-2" />
+ Add Media
+ </Button>
+ )}
</div>
);
}
diff --git a/apps/web/src/components/editor/preview-panel.tsx b/apps/web/src/components/editor/preview-panel.tsx
index 4361e967..488e1fc9 100644
--- a/apps/web/src/components/editor/preview-panel.tsx
+++ b/apps/web/src/components/editor/preview-panel.tsx
@@ -1,11 +1,111 @@
+"use client";
+
+import { useTimelineStore } from "@/stores/timeline-store";
+import { useMediaStore } from "@/stores/media-store";
+import { ImageTimelineTreatment } from "@/components/ui/image-timeline-treatment";
+import { Button } from "@/components/ui/button";
+import { Play, Pause } from "lucide-react";
+import { useState } from "react";
+
export function PreviewPanel() {
- return (
- <div className="h-full flex items-center justify-center">
- <div className="aspect-video bg-black/90 w-full max-w-4xl mx-4 rounded-lg shadow-lg relative group">
+ const { tracks } = useTimelineStore();
+ const { mediaItems } = useMediaStore();
+ const [isPlaying, setIsPlaying] = useState(false);
+
+ // Get the first clip from the first track for preview (simplified for now)
+ const firstClip = tracks[0]?.clips[0];
+ const firstMediaItem = firstClip
+ ? mediaItems.find((item) => item.id === firstClip.mediaId)
+ : null;
+
+ const renderPreviewContent = () => {
+ if (!firstMediaItem) {
+ return (
<div className="absolute inset-0 flex items-center justify-center text-muted-foreground/50 group-hover:text-muted-foreground/80 transition-colors">
Drop media here or click to import
</div>
+ );
+ }
+
+ if (firstMediaItem.type === "image") {
+ return (
+ <ImageTimelineTreatment
+ src={firstMediaItem.url}
+ alt={firstMediaItem.name}
+ targetAspectRatio={16 / 9}
+ className="w-full h-full rounded-lg"
+ backgroundType="blur"
+ />
+ );
+ }
+
+ if (firstMediaItem.type === "video") {
+ return firstMediaItem.thumbnailUrl ? (
+ <img
+ src={firstMediaItem.thumbnailUrl}
+ alt={firstMediaItem.name}
+ className="w-full h-full object-cover rounded-lg"
+ />
+ ) : (
+ <div className="absolute inset-0 flex items-center justify-center text-muted-foreground/50">
+ Video Preview
+ </div>
+ );
+ }
+
+ if (firstMediaItem.type === "audio") {
+ return (
+ <div className="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-green-500/20 to-emerald-500/20">
+ <div className="text-center">
+ <div className="text-6xl mb-4">🎵</div>
+ <p className="text-muted-foreground">{firstMediaItem.name}</p>
+ </div>
+ </div>
+ );
+ }
+
+ return null;
+ };
+
+ return (
+ <div className="h-full flex flex-col items-center justify-center p-4">
+ <div className="aspect-video bg-black/90 w-full max-w-4xl rounded-lg shadow-lg relative group overflow-hidden">
+ {renderPreviewContent()}
+
+ {/* Playback Controls Overlay */}
+ {firstMediaItem && (
+ <div className="absolute bottom-4 left-1/2 transform -translate-x-1/2 opacity-0 group-hover:opacity-100 transition-opacity">
+ <div className="flex items-center gap-2 bg-black/80 rounded-lg px-4 py-2">
+ <Button
+ variant="ghost"
+ size="icon"
+ className="text-white hover:bg-white/20"
+ onClick={() => setIsPlaying(!isPlaying)}
+ >
+ {isPlaying ? (
+ <Pause className="h-5 w-5" />
+ ) : (
+ <Play className="h-5 w-5" />
+ )}
+ </Button>
+ <span className="text-white text-sm">
+ {firstClip?.name || "No clip selected"}
+ </span>
+ </div>
+ </div>
+ )}
</div>
+
+ {/* Preview Info */}
+ {firstMediaItem && (
+ <div className="mt-4 text-center">
+ <p className="text-sm text-muted-foreground">
+ Preview: {firstMediaItem.name}
+ {firstMediaItem.type === "image" &&
+ " (with CapCut-style treatment)"}
+ </p>
+ </div>
+ )}
</div>
);
}
diff --git a/apps/web/src/components/editor/properties-panel.tsx b/apps/web/src/components/editor/properties-panel.tsx
index 280b6dad..af80f464 100644
--- a/apps/web/src/components/editor/properties-panel.tsx
+++ b/apps/web/src/components/editor/properties-panel.tsx
@@ -5,11 +5,108 @@ import { Label } from "../ui/label";
import { Slider } from "../ui/slider";
import { ScrollArea } from "../ui/scroll-area";
import { Separator } from "../ui/separator";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "../ui/select";
+import { useTimelineStore } from "@/stores/timeline-store";
+import { useMediaStore } from "@/stores/media-store";
+import { ImageTimelineTreatment } from "@/components/ui/image-timeline-treatment";
+import { useState } from "react";
export function PropertiesPanel() {
+ const { tracks } = useTimelineStore();
+ const { mediaItems } = useMediaStore();
+ const [backgroundType, setBackgroundType] = useState<
+ "blur" | "mirror" | "color"
+ >("blur");
+ const [backgroundColor, setBackgroundColor] = useState("#000000");
+
+ // Get the first image clip for preview (simplified)
+ const firstImageClip = tracks
+ .flatMap((track) => track.clips)
+ .find((clip) => {
+ const mediaItem = mediaItems.find((item) => item.id === clip.mediaId);
+ return mediaItem?.type === "image";
+ });
+
+ const firstImageItem = firstImageClip
+ ? mediaItems.find((item) => item.id === firstImageClip.mediaId)
+ : null;
+
return (
<ScrollArea className="h-full">
<div className="space-y-6 p-5">
+ {/* Image Treatment - only show if an image is selected */}
+ {firstImageItem && (
+ <>
+ <div className="space-y-4">
+ <h3 className="text-sm font-medium">Image Treatment</h3>
+ <div className="space-y-4">
+ {/* Preview */}
+ <div className="space-y-2">
+ <Label>Preview</Label>
+ <div className="w-full aspect-video max-w-48">
+ <ImageTimelineTreatment
+ src={firstImageItem.url}
+ alt={firstImageItem.name}
+ targetAspectRatio={16 / 9}
+ className="rounded-sm border"
+ backgroundType={backgroundType}
+ backgroundColor={backgroundColor}
+ />
+ </div>
+ </div>
+
+ {/* Background Type */}
+ <div className="space-y-2">
+ <Label htmlFor="bg-type">Background Type</Label>
+ <Select
+ value={backgroundType}
+ onValueChange={(value: any) => setBackgroundType(value)}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="Select background type" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="blur">Blur</SelectItem>
+ <SelectItem value="mirror">Mirror</SelectItem>
+ <SelectItem value="color">Solid Color</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+
+ {/* Background Color - only show for color type */}
+ {backgroundType === "color" && (
+ <div className="space-y-2">
+ <Label htmlFor="bg-color">Background Color</Label>
+ <div className="flex gap-2">
+ <Input
+ id="bg-color"
+ type="color"
+ value={backgroundColor}
+ onChange={(e) => setBackgroundColor(e.target.value)}
+ className="w-16 h-10 p-1"
+ />
+ <Input
+ value={backgroundColor}
+ onChange={(e) => setBackgroundColor(e.target.value)}
+ placeholder="#000000"
+ className="flex-1"
+ />
+ </div>
+ </div>
+ )}
+ </div>
+ </div>
+
+ <Separator />
+ </>
+ )}
+
{/* Transform */}
<div className="space-y-4">
<h3 className="text-sm font-medium">Transform</h3>
diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx
index dd312764..2e23bfba 100644
--- a/apps/web/src/components/editor/timeline.tsx
+++ b/apps/web/src/components/editor/timeline.tsx
@@ -19,17 +19,164 @@ import {
} from "../ui/tooltip";
import { DragOverlay } from "../ui/drag-overlay";
import { useTimelineStore, type TimelineTrack } from "@/stores/timeline-store";
-import { useDragDrop } from "@/hooks/use-drag-drop";
+import { useMediaStore } from "@/stores/media-store";
+import { processMediaFiles } from "@/lib/media-processing";
+import { ImageTimelineTreatment } from "@/components/ui/image-timeline-treatment";
+import { toast } from "sonner";
+import { useState, useRef } from "react";
export function Timeline() {
- const { tracks, addTrack } = useTimelineStore();
+ const { tracks, addTrack, addClipToTrack } = useTimelineStore();
+ const { mediaItems, addMediaItem } = useMediaStore();
+ const [isDragOver, setIsDragOver] = useState(false);
+ const [isProcessing, setIsProcessing] = useState(false);
+ const dragCounterRef = useRef(0);
- const { isDragOver, dragProps } = useDragDrop({
- onDrop: (files) => {
- // TODO: Handle file drop functionality for timeline
- console.log("Files dropped on timeline:", files);
- },
- });
+ const handleDragEnter = (e: React.DragEvent) => {
+ e.preventDefault();
+
+ // Don't show overlay for timeline clips or other internal drags
+ if (e.dataTransfer.types.includes("application/x-timeline-clip")) {
+ return;
+ }
+
+ dragCounterRef.current += 1;
+ if (!isDragOver) {
+ setIsDragOver(true);
+ }
+ };
+
+ const handleDragOver = (e: React.DragEvent) => {
+ e.preventDefault();
+ };
+
+ const handleDragLeave = (e: React.DragEvent) => {
+ e.preventDefault();
+
+ // Don't update state for timeline clips
+ if (e.dataTransfer.types.includes("application/x-timeline-clip")) {
+ return;
+ }
+
+ dragCounterRef.current -= 1;
+ if (dragCounterRef.current === 0) {
+ setIsDragOver(false);
+ }
+ };
+
+ const handleDrop = async (e: React.DragEvent) => {
+ e.preventDefault();
+ setIsDragOver(false);
+ dragCounterRef.current = 0;
+
+ // Check if this is a timeline clip drop - now we'll handle it!
+ const timelineClipData = e.dataTransfer.getData(
+ "application/x-timeline-clip"
+ );
+ if (timelineClipData) {
+ // Timeline clips dropped on the main timeline area (not on a specific track)
+ // For now, we'll just ignore these - clips should be dropped on specific tracks
+ return;
+ }
+
+ // Check if this is an internal media item drop
+ const mediaItemData = e.dataTransfer.getData("application/x-media-item");
+ if (mediaItemData) {
+ try {
+ const { id, type, name } = JSON.parse(mediaItemData);
+
+ // Find the full media item from the store
+ const mediaItem = mediaItems.find((item) => item.id === id);
+ if (!mediaItem) {
+ toast.error("Media item not found");
+ return;
+ }
+
+ // Determine track type based on media type
+ let trackType: "video" | "audio" | "effects";
+ if (type === "video") {
+ trackType = "video";
+ } else if (type === "audio") {
+ trackType = "audio";
+ } else {
+ // For images, we'll put them on video tracks
+ trackType = "video";
+ }
+
+ // Create a new track and get its ID
+ const newTrackId = addTrack(trackType);
+
+ // Add the clip to the new track
+ addClipToTrack(newTrackId, {
+ mediaId: mediaItem.id,
+ name: mediaItem.name,
+ duration: mediaItem.duration || 5, // Default 5 seconds for images
+ });
+
+ toast.success(`Added ${name} to ${trackType} track`);
+ } catch (error) {
+ console.error("Error parsing media item data:", error);
+ toast.error("Failed to add media to timeline");
+ }
+ } else if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
+ // Handle external file drops
+ setIsProcessing(true);
+
+ try {
+ const processedItems = await processMediaFiles(e.dataTransfer.files);
+
+ for (const processedItem of processedItems) {
+ // Add to media store first
+ addMediaItem(processedItem);
+
+ // The media item now has an ID, let's get it from the latest state
+ // Since addMediaItem is synchronous, we can get the latest item
+ const currentMediaItems = useMediaStore.getState().mediaItems;
+ const addedItem = currentMediaItems.find(
+ (item) =>
+ item.name === processedItem.name && item.url === processedItem.url
+ );
+
+ if (addedItem) {
+ // Determine track type based on media type
+ let trackType: "video" | "audio" | "effects";
+ if (processedItem.type === "video") {
+ trackType = "video";
+ } else if (processedItem.type === "audio") {
+ trackType = "audio";
+ } else {
+ // For images, we'll put them on video tracks
+ trackType = "video";
+ }
+
+ // Create a new track and get its ID
+ const newTrackId = addTrack(trackType);
+
+ // Add the clip to the new track
+ addClipToTrack(newTrackId, {
+ mediaId: addedItem.id,
+ name: addedItem.name,
+ duration: addedItem.duration || 5, // Default 5 seconds for images
+ });
+
+ toast.success(`Added ${processedItem.name} to timeline`);
+ }
+ }
+ } catch (error) {
+ console.error("Error processing external files:", error);
+ toast.error("Failed to process dropped files");
+ } finally {
+ setIsProcessing(false);
+ }
+ }
+ };
+
+ const dragProps = {
+ onDragEnter: handleDragEnter,
+ onDragOver: handleDragOver,
+ onDragLeave: handleDragLeave,
+ onDrop: handleDrop,
+ };
return (
<div
@@ -40,8 +187,12 @@ export function Timeline() {
>
<DragOverlay
isVisible={isDragOver}
- title="Drop files here"
- description="Add media to timeline tracks"
+ title={isProcessing ? "Processing files..." : "Drop media here"}
+ description={
+ isProcessing
+ ? "Please wait while files are being processed"
+ : "Add media to timeline tracks"
+ }
/>
{/* Toolbar */}
@@ -154,6 +305,149 @@ export function Timeline() {
}
function TimelineTrackComponent({ track }: { track: TimelineTrack }) {
+ const { mediaItems } = useMediaStore();
+ const { moveClipToTrack, reorderClipInTrack } = useTimelineStore();
+ const [isDropping, setIsDropping] = useState(false);
+
+ const handleClipDragStart = (e: React.DragEvent, clip: any) => {
+ // Mark this as an timeline clip drag to differentiate from media items
+ const dragData = {
+ clipId: clip.id,
+ trackId: track.id,
+ name: clip.name,
+ };
+
+ e.dataTransfer.setData(
+ "application/x-timeline-clip",
+ JSON.stringify(dragData)
+ );
+ e.dataTransfer.effectAllowed = "move";
+
+ // Use the entire clip container as the drag image instead of just the content
+ const target = e.currentTarget as HTMLElement;
+ e.dataTransfer.setDragImage(
+ target,
+ target.offsetWidth / 2,
+ target.offsetHeight / 2
+ );
+ };
+
+ const handleTrackDragOver = (e: React.DragEvent) => {
+ e.preventDefault();
+
+ // Only handle timeline clip drags
+ if (!e.dataTransfer.types.includes("application/x-timeline-clip")) {
+ return;
+ }
+
+ e.dataTransfer.dropEffect = "move";
+ };
+
+ const handleTrackDragEnter = (e: React.DragEvent) => {
+ e.preventDefault();
+
+ // Only handle timeline clip drags
+ if (!e.dataTransfer.types.includes("application/x-timeline-clip")) {
+ return;
+ }
+
+ setIsDropping(true);
+ };
+
+ const handleTrackDragLeave = (e: React.DragEvent) => {
+ e.preventDefault();
+
+ // Only handle timeline clip drags
+ if (!e.dataTransfer.types.includes("application/x-timeline-clip")) {
+ return;
+ }
+
+ // Check if we're actually leaving the track area
+ const rect = e.currentTarget.getBoundingClientRect();
+ const x = e.clientX;
+ const y = e.clientY;
+
+ const isActuallyLeaving =
+ x < rect.left || x > rect.right || y < rect.top || y > rect.bottom;
+
+ if (isActuallyLeaving) {
+ setIsDropping(false);
+ }
+ };
+
+ const handleTrackDrop = (e: React.DragEvent) => {
+ e.preventDefault();
+ setIsDropping(false);
+
+ // Only handle timeline clip drags
+ if (!e.dataTransfer.types.includes("application/x-timeline-clip")) {
+ return;
+ }
+
+ const timelineClipData = e.dataTransfer.getData(
+ "application/x-timeline-clip"
+ );
+
+ if (!timelineClipData) {
+ return;
+ }
+
+ try {
+ const parsedData = JSON.parse(timelineClipData);
+ const { clipId, trackId: fromTrackId } = parsedData;
+
+ // Calculate where to insert the clip based on mouse position
+ const trackContainer = e.currentTarget.querySelector(
+ ".track-clips-container"
+ ) as HTMLElement;
+
+ if (!trackContainer) {
+ return;
+ }
+
+ const rect = trackContainer.getBoundingClientRect();
+ const mouseX = e.clientX - rect.left;
+
+ // Calculate insertion index based on position
+ let insertIndex = 0;
+ const clipElements = trackContainer.querySelectorAll(".timeline-clip");
+
+ for (let i = 0; i < clipElements.length; i++) {
+ const clipRect = clipElements[i].getBoundingClientRect();
+ const clipCenterX = clipRect.left + clipRect.width / 2 - rect.left;
+
+ if (mouseX > clipCenterX) {
+ insertIndex = i + 1;
+ } else {
+ break;
+ }
+ }
+
+ if (fromTrackId === track.id) {
+ // Moving within the same track - reorder
+ const currentIndex = track.clips.findIndex(
+ (clip) => clip.id === clipId
+ );
+
+ if (currentIndex !== -1 && currentIndex !== insertIndex) {
+ // Adjust index if we're moving to a position after the current one
+ const adjustedIndex =
+ insertIndex > currentIndex ? insertIndex - 1 : insertIndex;
+
+ reorderClipInTrack(track.id, clipId, adjustedIndex);
+ toast.success("Clip reordered");
+ }
+ } else {
+ // Moving between different tracks
+ moveClipToTrack(fromTrackId, track.id, clipId, insertIndex);
+ toast.success("Clip moved to different track");
+ }
+ } catch (error) {
+ console.error("Error moving clip:", error);
+ toast.error("Failed to move clip");
+ }
+ };
+
const getTrackColor = (type: string) => {
switch (type) {
case "video":
@@ -167,26 +461,93 @@ function TimelineTrackComponent({ track }: { track: TimelineTrack }) {
}
};
+ const renderClipContent = (clip: any) => {
+ const mediaItem = mediaItems.find((item) => item.id === clip.mediaId);
+
+ if (!mediaItem) {
+ return (
+ <span className="text-xs text-foreground/80 truncate">{clip.name}</span>
+ );
+ }
+
+ if (mediaItem.type === "image") {
+ return (
+ <div className="w-full h-full flex items-center gap-2">
+ <div className="w-16 h-12 flex-shrink-0">
+ <ImageTimelineTreatment
+ src={mediaItem.url}
+ alt={mediaItem.name}
+ targetAspectRatio={16 / 9}
+ className="rounded-sm"
+ backgroundType="mirror"
+ />
+ </div>
+ <span className="text-xs text-foreground/80 truncate flex-1">
+ {clip.name}
+ </span>
+ </div>
+ );
+ }
+
+ if (mediaItem.type === "video" && mediaItem.thumbnailUrl) {
+ return (
+ <div className="w-full h-full flex items-center gap-2">
+ <div className="w-8 h-8 flex-shrink-0">
+ <img
+ src={mediaItem.thumbnailUrl}
+ alt={mediaItem.name}
+ className="w-full h-full object-cover rounded-sm"
+ />
+ </div>
+ <span className="text-xs text-foreground/80 truncate flex-1">
+ {clip.name}
+ </span>
+ </div>
+ );
+ }
+
+ // Fallback for audio or videos without thumbnails
+ return (
+ <span className="text-xs text-foreground/80 truncate">{clip.name}</span>
+ );
+ };
+
return (
<div className="flex items-center px-2">
<div className="w-24 text-xs text-muted-foreground flex-shrink-0 mr-2">
{track.name}
</div>
- <div className="flex-1 h-[60px]">
- {track.clips.length === 0 ? (
- <div className="h-full rounded-sm border-2 border-dashed border-muted/30 flex items-center justify-center text-xs text-muted-foreground">
- Drop media here
- </div>
- ) : (
- <div
- className={`h-full rounded-sm border cursor-pointer transition-colors ${getTrackColor(track.type)} flex items-center px-2`}
- >
- <span className="text-xs text-foreground/80">
- {track.clips.length} clip{track.clips.length !== 1 ? "s" : ""}
- </span>
- </div>
- )}
+ <div
+ className={`flex-1 h-[60px] transition-colors ${
+ isDropping ? "bg-accent/50 border-2 border-dashed border-accent" : ""
+ }`}
+ onDragOver={handleTrackDragOver}
+ onDragEnter={handleTrackDragEnter}
+ onDragLeave={handleTrackDragLeave}
+ onDrop={handleTrackDrop}
+ >
+ <div className="h-full flex gap-1 track-clips-container">
+ {track.clips.length === 0 ? (
+ <div className="h-full w-full rounded-sm border-2 border-dashed border-muted/30 flex items-center justify-center text-xs text-muted-foreground">
+ Drop media here
+ </div>
+ ) : (
+ track.clips.map((clip, index) => (
+ <div
+ key={clip.id}
+ className={`timeline-clip h-full rounded-sm border cursor-grab active:cursor-grabbing transition-colors ${getTrackColor(track.type)} flex items-center px-2 min-w-[80px] overflow-hidden`}
+ style={{
+ width: `${Math.max(80, (clip.duration / 30) * 400)}px`,
+ }}
+ draggable={true}
+ onDragStart={(e) => handleClipDragStart(e, clip)}
+ >
+ {renderClipContent(clip)}
+ </div>
+ ))
+ )}
+ </div>
</div>
</div>
);