diff options
Diffstat (limited to 'apps/web/src/components')
| -rw-r--r-- | apps/web/src/components/development-debug.tsx | 56 | ||||
| -rw-r--r-- | apps/web/src/components/editor/media-panel/views/media.tsx | 16 | ||||
| -rw-r--r-- | apps/web/src/components/editor/media-panel/views/text.tsx | 1 | ||||
| -rw-r--r-- | apps/web/src/components/editor/preview-panel.tsx | 222 | ||||
| -rw-r--r-- | apps/web/src/components/editor/properties-panel.tsx | 30 | ||||
| -rw-r--r-- | apps/web/src/components/editor/timeline-element.tsx (renamed from apps/web/src/components/editor/timeline-clip.tsx) | 209 | ||||
| -rw-r--r-- | apps/web/src/components/editor/timeline-toolbar.tsx | 2 | ||||
| -rw-r--r-- | apps/web/src/components/editor/timeline-track.tsx | 1545 | ||||
| -rw-r--r-- | apps/web/src/components/editor/timeline.tsx | 468 | ||||
| -rw-r--r-- | apps/web/src/components/footer.tsx | 2 | ||||
| -rw-r--r-- | apps/web/src/components/header.tsx | 2 | ||||
| -rw-r--r-- | apps/web/src/components/landing/hero.tsx | 7 | ||||
| -rw-r--r-- | apps/web/src/components/ui/draggable-item.tsx | 18 |
13 files changed, 1367 insertions, 1211 deletions
diff --git a/apps/web/src/components/development-debug.tsx b/apps/web/src/components/development-debug.tsx index aef69999..f407e2e0 100644 --- a/apps/web/src/components/development-debug.tsx +++ b/apps/web/src/components/development-debug.tsx @@ -1,20 +1,17 @@ "use client";
-import {
- useTimelineStore,
- type TimelineClip,
- type TimelineTrack,
-} from "@/stores/timeline-store";
+import { useTimelineStore, type TimelineTrack } from "@/stores/timeline-store";
import { useMediaStore, type MediaItem } from "@/stores/media-store";
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 +25,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 +69,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/media-panel/views/media.tsx b/apps/web/src/components/editor/media-panel/views/media.tsx index 50fb6fa6..f975496e 100644 --- a/apps/web/src/components/editor/media-panel/views/media.tsx +++ b/apps/web/src/components/editor/media-panel/views/media.tsx @@ -73,18 +73,22 @@ export function MediaView() { // Remove a media item from the store
e.stopPropagation();
- // Remove tracks automatically when delete media
+ // Remove elements 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);
+ 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 clips
+ // 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.clips.length === 0) {
+ if (updatedTrack && updatedTrack.elements.length === 0) {
removeTrack(track.id);
}
});
diff --git a/apps/web/src/components/editor/media-panel/views/text.tsx b/apps/web/src/components/editor/media-panel/views/text.tsx index ab698528..bec32c85 100644 --- a/apps/web/src/components/editor/media-panel/views/text.tsx +++ b/apps/web/src/components/editor/media-panel/views/text.tsx @@ -17,7 +17,6 @@ export function TextView() { content: "Default text",
}}
aspectRatio={1}
- className="w-24"
showLabel={false}
/>
</div>
diff --git a/apps/web/src/components/editor/preview-panel.tsx b/apps/web/src/components/editor/preview-panel.tsx index eba8ac04..9a02560b 100644 --- a/apps/web/src/components/editor/preview-panel.tsx +++ b/apps/web/src/components/editor/preview-panel.tsx @@ -2,9 +2,9 @@ import { useTimelineStore, - type TimelineClip, type TimelineTrack, } from "@/stores/timeline-store"; +import { TimelineElement } from "@/types/timeline"; import { useMediaStore, type MediaItem, @@ -21,13 +21,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 +35,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 +104,139 @@ 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 >= 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; + if (currentTime >= elementStart && currentTime < elementEnd) { + let mediaItem = 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,12 @@ 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 +272,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 +303,15 @@ 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 +335,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 +346,7 @@ function PreviewToolbar({ hasAnyClips }: { hasAnyClips: boolean }) { variant="text" size="icon" onClick={toggle} - disabled={!hasAnyClips} + disabled={!hasAnyElements} > {isPlaying ? ( <Pause className="h-3 w-3" /> @@ -316,7 +360,7 @@ function PreviewToolbar({ hasAnyClips }: { hasAnyClips: boolean }) { <Button size="sm" className="!bg-background text-foreground/85 text-xs h-auto rounded-none border border-muted-foreground px-0.5 py-0 font-light" - disabled={!hasAnyClips} + 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 ddc58f8d..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} @@ -298,29 +316,34 @@ export function TimelineClip({ )} ${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 4d32511f..dd4219f9 100644 --- a/apps/web/src/components/editor/timeline-track.tsx +++ b/apps/web/src/components/editor/timeline-track.tsx @@ -1,690 +1,855 @@ -"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
- const isSelected = selectedClips.some(
- (c) => c.trackId === track.id && c.clipId === clip.id
- );
-
- if (!isSelected) {
- // If clip is not selected, select it (replacing other selections)
- selectClip(track.id, clip.id, false);
- }
- // If clip is already selected, keep it selected (do nothing)
- };
-
- 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 } from "@/stores/timeline-store"; +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") { + // Determine where to insert the new text track + let insertIndex: number; + if (dropPosition === "above") { + insertIndex = currentTrackIndex; + } else if (dropPosition === "below") { + insertIndex = currentTrackIndex + 1; + } else { + // dropPosition === "on" but track is not text type + insertIndex = currentTrackIndex + 1; + } + + 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) { + // Determine where to insert the new track + let insertIndex: number; + if (dropPosition === "above") { + insertIndex = currentTrackIndex; + } else if (dropPosition === "below") { + insertIndex = currentTrackIndex + 1; + } else { + // dropPosition === "on" but track is incompatible + insertIndex = currentTrackIndex + 1; + } + + if (isVideoOrImage) { + // For video/image, check if main media track is empty and at the right position + const mainMediaTrack = tracks.find((t) => t.type === "media"); + if ( + mainMediaTrack && + mainMediaTrack.elements.length === 0 && + dropPosition === "on" + ) { + targetTrackId = mainMediaTrack.id; + targetTrack = mainMediaTrack; + } else { + 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) { + 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..113d7579 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", }); } diff --git a/apps/web/src/components/ui/draggable-item.tsx b/apps/web/src/components/ui/draggable-item.tsx index 4ec72d12..16518dde 100644 --- a/apps/web/src/components/ui/draggable-item.tsx +++ b/apps/web/src/components/ui/draggable-item.tsx @@ -7,11 +7,6 @@ import { createPortal } from "react-dom"; import { Plus } from "lucide-react"; import { cn } from "@/lib/utils"; -// Create the empty image once, outside the component -const emptyImg = new Image(); -emptyImg.src = - "data:image/gif;base64,R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs="; - export interface DraggableMediaItemProps { name: string; preview: ReactNode; @@ -39,6 +34,10 @@ export function DraggableMediaItem({ 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; @@ -76,10 +75,9 @@ export function DraggableMediaItem({ return ( <> - <div ref={dragRef} className="relative group"> - <Button - variant="outline" - className={`flex flex-col gap-1 p-0 h-auto w-full relative border-none !bg-transparent cursor-default ${className}`} + <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} @@ -108,7 +106,7 @@ export function DraggableMediaItem({ : name} </span> )} - </Button> + </div> </div> {/* Custom drag preview */} |
