From 6ee16f9df8aeabfef0ca533f9f5b13daab3a3758 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Sun, 22 Jun 2025 19:28:03 +0200 Subject: feat: so much stuff --- apps/web/src/components/editor/media-panel.tsx | 282 +++++++++++--- apps/web/src/components/editor/preview-panel.tsx | 106 +++++- .../web/src/components/editor/properties-panel.tsx | 97 +++++ apps/web/src/components/editor/timeline.tsx | 409 +++++++++++++++++++-- .../src/components/ui/image-timeline-treatment.tsx | 101 +++++ 5 files changed, 915 insertions(+), 80 deletions(-) create mode 100644 apps/web/src/components/ui/image-timeline-treatment.tsx (limited to 'apps/web/src/components') 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(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) => { + 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() { } }; - return ( -
- - -
- {/* Media Grid */} - {mediaItems.length === 0 ? ( - + const renderMediaPreview = (item: any) => { + switch (item.type) { + case "image": + return ( + {item.name} handleDragStart(e, item)} + /> + ); + case "video": + return item.thumbnailUrl ? ( +
+ {item.name} handleDragStart(e, item)} + /> +
+
+ {item.duration && ( +
+ {formatDuration(item.duration)} +
+ )} +
) : ( -
- {mediaItems.map((item) => ( - + + {/* Remove button - positioned outside the button container */} +
e.preventDefault()} + onDrag={(e) => e.preventDefault()} + > +
- - - ))} -
- )} +
+ ))} +
+ )} + - + ); } -function EmptyMedia({ onAddSample }: { onAddSample: () => void }) { +function EmptyMedia({ + onFileSelect, + isProcessing, +}: { + onFileSelect: () => void; + isProcessing: boolean; +}) { return (
- + {isProcessing ? ( +
+ +
+ ) : ( + + )}
-

No media in project

+

+ {isProcessing ? "Processing files..." : "No media in project"} +

- Drag files or click to add media + {isProcessing + ? "Please wait while files are being processed" + : "Drag files or click to add media"}

- + {!isProcessing && ( + + )}
); } 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 ( -
-
+ 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 (
Drop media here or click to import
+ ); + } + + if (firstMediaItem.type === "image") { + return ( + + ); + } + + if (firstMediaItem.type === "video") { + return firstMediaItem.thumbnailUrl ? ( + {firstMediaItem.name} + ) : ( +
+ Video Preview +
+ ); + } + + if (firstMediaItem.type === "audio") { + return ( +
+
+
🎵
+

{firstMediaItem.name}

+
+
+ ); + } + + return null; + }; + + return ( +
+
+ {renderPreviewContent()} + + {/* Playback Controls Overlay */} + {firstMediaItem && ( +
+
+ + + {firstClip?.name || "No clip selected"} + +
+
+ )}
+ + {/* Preview Info */} + {firstMediaItem && ( +
+

+ Preview: {firstMediaItem.name} + {firstMediaItem.type === "image" && + " (with CapCut-style treatment)"} +

+
+ )}
); } 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 (
+ {/* Image Treatment - only show if an image is selected */} + {firstImageItem && ( + <> +
+

Image Treatment

+
+ {/* Preview */} +
+ +
+ +
+
+ + {/* Background Type */} +
+ + +
+ + {/* Background Color - only show for color type */} + {backgroundType === "color" && ( +
+ +
+ setBackgroundColor(e.target.value)} + className="w-16 h-10 p-1" + /> + setBackgroundColor(e.target.value)} + placeholder="#000000" + className="flex-1" + /> +
+
+ )} +
+
+ + + + )} + {/* Transform */}

Transform

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 (
{/* 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 ( + {clip.name} + ); + } + + if (mediaItem.type === "image") { + return ( +
+
+ +
+ + {clip.name} + +
+ ); + } + + if (mediaItem.type === "video" && mediaItem.thumbnailUrl) { + return ( +
+
+ {mediaItem.name} +
+ + {clip.name} + +
+ ); + } + + // Fallback for audio or videos without thumbnails + return ( + {clip.name} + ); + }; + return (
{track.name}
-
- {track.clips.length === 0 ? ( -
- Drop media here -
- ) : ( -
- - {track.clips.length} clip{track.clips.length !== 1 ? "s" : ""} - -
- )} +
+
+ {track.clips.length === 0 ? ( +
+ Drop media here +
+ ) : ( + track.clips.map((clip, index) => ( +
handleClipDragStart(e, clip)} + > + {renderClipContent(clip)} +
+ )) + )} +
); diff --git a/apps/web/src/components/ui/image-timeline-treatment.tsx b/apps/web/src/components/ui/image-timeline-treatment.tsx new file mode 100644 index 00000000..600dd243 --- /dev/null +++ b/apps/web/src/components/ui/image-timeline-treatment.tsx @@ -0,0 +1,101 @@ +"use client"; + +import { useState } from "react"; +import { cn } from "@/lib/utils"; + +interface ImageTimelineTreatmentProps { + src: string; + alt: string; + targetAspectRatio?: number; // Default to 16:9 for video + className?: string; + backgroundType?: "blur" | "mirror" | "color"; + backgroundColor?: string; +} + +export function ImageTimelineTreatment({ + src, + alt, + targetAspectRatio = 16 / 9, + className, + backgroundType = "blur", + backgroundColor = "#000000", +}: ImageTimelineTreatmentProps) { + const [imageLoaded, setImageLoaded] = useState(false); + const [imageDimensions, setImageDimensions] = useState<{ + width: number; + height: number; + } | null>(null); + + const handleImageLoad = (e: React.SyntheticEvent) => { + const img = e.currentTarget; + setImageDimensions({ + width: img.naturalWidth, + height: img.naturalHeight, + }); + setImageLoaded(true); + }; + + const imageAspectRatio = imageDimensions + ? imageDimensions.width / imageDimensions.height + : 1; + + const needsAspectRatioTreatment = imageAspectRatio !== targetAspectRatio; + + return ( +
+ {/* Background Layer */} + {needsAspectRatioTreatment && imageLoaded && ( + <> + {backgroundType === "blur" && ( +
+ +
+
+ )} + + {backgroundType === "mirror" && ( +
+ +
+ )} + + {backgroundType === "color" && ( +
+ )} + + )} + + {/* Main Image Layer */} +
+ {alt} +
+ + {/* Loading state */} + {!imageLoaded && ( +
+
+ Loading... +
+
+ )} +
+ ); +} -- cgit v1.3.1