diff options
| author | Khắc Tuấn <khtuankendy@gmail.com> | 2025-07-16 20:04:27 +0700 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-07-16 20:04:27 +0700 |
| commit | 2974fd89a942f4e3173642b165f228e2af604ae7 (patch) | |
| tree | 179811835f69429d6fc4b7917c8ece2615a74a3b /apps/web/src/components | |
| parent | faf317cd20756b72974b67aaacab1ccf49ec8a86 (diff) | |
| parent | 0554a031c2d8cc8d736172aeaf503d33b0764c04 (diff) | |
| download | opencut-2974fd89a942f4e3173642b165f228e2af604ae7.tar.gz | |
Merge branch 'main' into feature/optimize-timeline-ruler
Diffstat (limited to 'apps/web/src/components')
| -rw-r--r-- | apps/web/src/components/editor/media-panel/drag-overlay.tsx | 57 | ||||
| -rw-r--r-- | apps/web/src/components/editor/media-panel/views/media.tsx | 71 | ||||
| -rw-r--r-- | apps/web/src/components/editor/media-panel/views/text.tsx | 37 | ||||
| -rw-r--r-- | apps/web/src/components/editor/timeline-element.tsx | 95 | ||||
| -rw-r--r-- | apps/web/src/components/editor/timeline.tsx | 58 | ||||
| -rw-r--r-- | apps/web/src/components/landing/hero.tsx | 100 | ||||
| -rw-r--r-- | apps/web/src/components/ui/drag-overlay.tsx | 25 | ||||
| -rw-r--r-- | apps/web/src/components/ui/draggable-item.tsx | 22 | ||||
| -rw-r--r-- | apps/web/src/components/ui/tooltip.tsx | 2 |
9 files changed, 278 insertions, 189 deletions
diff --git a/apps/web/src/components/editor/media-panel/drag-overlay.tsx b/apps/web/src/components/editor/media-panel/drag-overlay.tsx new file mode 100644 index 00000000..3f3820db --- /dev/null +++ b/apps/web/src/components/editor/media-panel/drag-overlay.tsx @@ -0,0 +1,57 @@ +import { Upload, Plus, Image } from "lucide-react";
+import { Button } from "@/components/ui/button";
+
+interface MediaDragOverlayProps {
+ isVisible: boolean;
+ isProcessing?: boolean;
+ progress?: number;
+ onClick?: () => void;
+ isEmptyState?: boolean;
+}
+
+export function MediaDragOverlay({
+ isVisible,
+ isProcessing = false,
+ progress = 0,
+ onClick,
+ isEmptyState = false,
+}: MediaDragOverlayProps) {
+ if (!isVisible) return null;
+
+ const handleClick = (e: React.MouseEvent) => {
+ if (isProcessing || !onClick) return;
+ e.preventDefault();
+ e.stopPropagation();
+ onClick();
+ };
+
+ return (
+ <div
+ className="flex flex-col items-center justify-center gap-4 h-full text-center rounded-lg bg-foreground/5 hover:bg-foreground/10 transition-all duration-200 p-8"
+ onClick={handleClick}
+ >
+ <div className="flex items-center justify-center">
+ <Upload className="h-10 w-10 text-foreground" />
+ </div>
+
+ <div className="space-y-2">
+ <p className="text-xs text-muted-foreground max-w-sm">
+ {isProcessing
+ ? `Processing your files (${progress}%)`
+ : "Drag and drop videos, photos, and audio files here"}
+ </p>
+ </div>
+
+ {isProcessing && (
+ <div className="w-full max-w-xs">
+ <div className="w-full bg-muted/50 rounded-full h-2">
+ <div
+ className="bg-primary h-2 rounded-full transition-all duration-300"
+ style={{ width: `${progress}%` }}
+ />
+ </div>
+ </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 5438737f..631fe39a 100644 --- a/apps/web/src/components/editor/media-panel/views/media.tsx +++ b/apps/web/src/components/editor/media-panel/views/media.tsx @@ -3,11 +3,11 @@ import { useDragDrop } from "@/hooks/use-drag-drop";
import { processMediaFiles } from "@/lib/media-processing";
import { useMediaStore, type MediaItem } from "@/stores/media-store";
-import { Image, Music, Plus, Upload, Video } from "lucide-react";
+import { Image, Loader2, Music, Plus, Video } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
-import { DragOverlay } from "@/components/ui/drag-overlay";
+import { MediaDragOverlay } from "@/components/editor/media-panel/drag-overlay";
import {
ContextMenu,
ContextMenuContent,
@@ -24,6 +24,7 @@ import { } from "@/components/ui/select";
import { DraggableMediaItem } from "@/components/ui/draggable-item";
import { useProjectStore } from "@/stores/project-store";
+import { useTimelineStore } from "@/stores/timeline-store";
export function MediaView() {
const { mediaItems, addMediaItem, removeMediaItem } = useMediaStore();
@@ -203,9 +204,6 @@ export function MediaView() { className={`h-full flex flex-col gap-1 transition-colors relative ${isDragOver ? "bg-accent/30" : ""}`}
{...dragProps}
>
- {/* Show overlay when dragging files over the panel */}
- <DragOverlay isVisible={isDragOver} />
-
<div className="p-3 pb-2">
{/* Search and filter controls */}
<div className="flex gap-2">
@@ -227,47 +225,31 @@ export function MediaView() { value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
+ <Button
+ variant="outline"
+ size="lg"
+ onClick={handleFileSelect}
+ disabled={isProcessing}
+ className="flex-none bg-transparent min-w-[30px] whitespace-nowrap overflow-hidden px-2 justify-center items-center"
+ >
+ {isProcessing ? (
+ <Loader2 className="h-4 w-4 animate-spin" />
+ ) : (
+ <Plus className="h-4 w-4" />
+ )}
+ </Button>
</div>
</div>
<div className="flex-1 overflow-y-auto p-3 pt-0">
- {/* Show message if no media, otherwise show media grid */}
- {filteredMediaItems.length === 0 ? (
- <div className="flex flex-col items-center justify-center py-8 text-center h-full">
- <div className="w-16 h-16 rounded-full bg-muted/30 flex items-center justify-center mb-4">
- <Image className="h-8 w-8 text-muted-foreground" />
- </div>
- <p className="text-sm text-muted-foreground">
- No media in project
- </p>
- <p className="text-xs text-muted-foreground/70 mt-1">
- Drag files here or use the button below
- </p>
- <Button
- variant="outline"
- size="sm"
- onClick={handleFileSelect}
- disabled={isProcessing}
- className="flex-none bg-transparent min-w-[30px] whitespace-nowrap overflow-hidden px-2 justify-center items-center mt-2"
- >
- {isProcessing ? (
- <>
- <Upload className="h-4 w-4 animate-spin" />
- <span className="hidden md:inline ml-2">{progress}%</span>
- </>
- ) : (
- <>
- <Plus className="h-4 w-4" />
- <span
- className="hidden sm:inline ml-2"
- aria-label="Add file"
- >
- Add
- </span>
- </>
- )}
- </Button>
- </div>
+ {isDragOver || filteredMediaItems.length === 0 ? (
+ <MediaDragOverlay
+ isVisible={true}
+ isProcessing={isProcessing}
+ progress={progress}
+ onClick={handleFileSelect}
+ isEmptyState={filteredMediaItems.length === 0 && !isDragOver}
+ />
) : (
<div
className="grid gap-2"
@@ -288,6 +270,11 @@ export function MediaView() { name: item.name,
}}
showPlusOnDrag={false}
+ onAddToTimeline={(currentTime) =>
+ useTimelineStore
+ .getState()
+ .addMediaAtTime(item, currentTime)
+ }
rounded={false}
/>
</ContextMenuTrigger>
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 bec32c85..6d0d310b 100644 --- a/apps/web/src/components/editor/media-panel/views/text.tsx +++ b/apps/web/src/components/editor/media-panel/views/text.tsx @@ -1,4 +1,30 @@ import { DraggableMediaItem } from "@/components/ui/draggable-item";
+import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import { useTimelineStore } from "@/stores/timeline-store";
+import { type TextElement } from "@/types/timeline";
+
+let textData: TextElement = {
+ id: "default-text",
+ type: "text",
+ name: "Default text",
+ content: "Default text",
+ fontSize: 48,
+ fontFamily: "Arial",
+ color: "#ffffff",
+ backgroundColor: "transparent",
+ textAlign: "center" as const,
+ fontWeight: "normal" as const,
+ fontStyle: "normal" as const,
+ textDecoration: "none" as const,
+ x: 0,
+ y: 0,
+ rotation: 0,
+ opacity: 1,
+ duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
+ startTime: 0,
+ trimStart: 0,
+ trimEnd: 0,
+};
export function TextView() {
return (
@@ -11,12 +37,15 @@ export function TextView() { </div>
}
dragData={{
- id: "default-text",
- type: "text",
- name: "Default text",
- content: "Default text",
+ id: textData.id,
+ type: textData.type,
+ name: textData.name,
+ content: textData.content,
}}
aspectRatio={1}
+ onAddToTimeline={(currentTime) =>
+ useTimelineStore.getState().addTextAtTime(textData, currentTime)
+ }
showLabel={false}
/>
</div>
diff --git a/apps/web/src/components/editor/timeline-element.tsx b/apps/web/src/components/editor/timeline-element.tsx index ee9445f3..8cccd0e4 100644 --- a/apps/web/src/components/editor/timeline-element.tsx +++ b/apps/web/src/components/editor/timeline-element.tsx @@ -24,6 +24,7 @@ import { useTimelineElementResize } from "@/hooks/use-timeline-element-resize"; import { getTrackElementClasses, TIMELINE_CONSTANTS, + getTrackHeight, } from "@/constants/timeline-constants"; import { DropdownMenu, @@ -267,35 +268,99 @@ export function TimelineElement({ ); } + const TILE_ASPECT_RATIO = 16 / 9; + if (mediaItem.type === "image") { + // Calculate tile size based on 16:9 aspect ratio + const trackHeight = getTrackHeight(track.type); + const tileHeight = trackHeight - 8; // Account for padding + const tileWidth = tileHeight * TILE_ASPECT_RATIO; + return ( <div className="w-full h-full flex items-center justify-center"> - <div className="bg-[#004D52] py-3 w-full h-full"> - <img - src={mediaItem.url} - alt={mediaItem.name} - className="w-full h-full object-cover" - draggable={false} + <div className="bg-[#004D52] py-3 w-full h-full relative"> + {/* Background with tiled images */} + <div + className="absolute top-3 bottom-3 left-0 right-0" + style={{ + backgroundImage: mediaItem.url + ? `url(${mediaItem.url})` + : "none", + backgroundRepeat: "repeat-x", + backgroundSize: `${tileWidth}px ${tileHeight}px`, + backgroundPosition: "left center", + pointerEvents: "none", + }} + aria-label={`Tiled background of ${mediaItem.name}`} + /> + {/* Overlay with vertical borders */} + <div + className="absolute top-3 bottom-3 left-0 right-0 pointer-events-none" + style={{ + backgroundImage: `repeating-linear-gradient( + to right, + transparent 0px, + transparent ${tileWidth - 1}px, + rgba(255, 255, 255, 0.6) ${tileWidth - 1}px, + rgba(255, 255, 255, 0.6) ${tileWidth}px + )`, + backgroundPosition: "left center", + }} /> </div> </div> ); } + const VIDEO_TILE_PADDING = 16; + const OVERLAY_SPACE_MULTIPLIER = 1.5; + if (mediaItem.type === "video" && mediaItem.thumbnailUrl) { + const trackHeight = getTrackHeight(track.type); + const tileHeight = trackHeight - VIDEO_TILE_PADDING; + const tileWidth = tileHeight * TILE_ASPECT_RATIO; + return ( <div className="w-full h-full flex items-center gap-2"> - <div className="w-8 h-8 flex-shrink-0"> - <img - src={mediaItem.thumbnailUrl} - alt={mediaItem.name} - className="w-full h-full object-cover rounded-sm" - draggable={false} + <div className="flex-1 h-full relative overflow-hidden"> + {/* Background with tiled thumbnails */} + <div + className="absolute inset-0" + style={{ + backgroundImage: mediaItem.thumbnailUrl + ? `url(${mediaItem.thumbnailUrl})` + : "none", + backgroundRepeat: "repeat-x", + backgroundSize: `${tileWidth}px ${tileHeight}px`, + backgroundPosition: "left center", + pointerEvents: "none", + }} + aria-label={`Tiled thumbnail of ${mediaItem.name}`} + /> + {/* Overlay with vertical borders */} + <div + className="absolute inset-0 pointer-events-none" + style={{ + backgroundImage: `repeating-linear-gradient( + to right, + transparent 0px, + transparent ${tileWidth - 1}px, + rgba(255, 255, 255, 0.6) ${tileWidth - 1}px, + rgba(255, 255, 255, 0.6) ${tileWidth}px + )`, + backgroundPosition: "left center", + }} /> </div> - <span className="text-xs text-foreground/80 truncate flex-1"> - {element.name} - </span> + {elementWidth > tileWidth * OVERLAY_SPACE_MULTIPLIER ? ( + <div className="absolute right-2 top-1/2 -translate-y-1/2 bg-black/70 text-white text-xs px-2 py-1 rounded pointer-events-none max-w-[40%] truncate"> + {element.name} + </div> + ) : ( + <span className="text-xs text-foreground/80 truncate flex-shrink-0 max-w-[120px]"> + {element.name} + </span> + )} </div> ); } diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx index 7b7455e0..c6e6a331 100644 --- a/apps/web/src/components/editor/timeline.tsx +++ b/apps/web/src/components/editor/timeline.tsx @@ -18,6 +18,7 @@ import { Lock, ZoomIn, ZoomOut, + LockOpen, } from "lucide-react"; import { Tooltip, @@ -372,29 +373,7 @@ export function Timeline() { 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: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION, - 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, - }); + useTimelineStore.getState().addTextToNewTrack(dragData); } else { // Handle media items const mediaItem = mediaItems.find((item) => item.id === dragData.id); @@ -403,19 +382,7 @@ export function Timeline() { 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, - }); + useTimelineStore.getState().addMediaToNewTrack(mediaItem); } } catch (error) { console.error("Error parsing dropped item data:", error); @@ -443,18 +410,7 @@ export function Timeline() { item.name === processedItem.name && item.url === processedItem.url ); if (addedItem) { - const trackType = - processedItem.type === "audio" ? "audio" : "media"; - const newTrackId = addTrack(trackType); - addElementToTrack(newTrackId, { - type: "media", - mediaId: addedItem.id, - name: addedItem.name, - duration: addedItem.duration || 5, - startTime: 0, - trimStart: 0, - trimEnd: 0, - }); + useTimelineStore.getState().addMediaToNewTrack(addedItem); } } } catch (error) { @@ -867,7 +823,11 @@ export function Timeline() { <Tooltip> <TooltipTrigger asChild> <Button variant="text" size="icon" onClick={toggleSnapping}> - <Lock className="h-4 w-4" /> + {snappingEnabled ? ( + <Lock className="h-4 w-4" /> + ) : ( + <LockOpen className="h-4 w-4 text-primary" /> + )} </Button> </TooltipTrigger> <TooltipContent>Auto snapping</TooltipContent> diff --git a/apps/web/src/components/landing/hero.tsx b/apps/web/src/components/landing/hero.tsx index 01ad7e7e..b1910dc5 100644 --- a/apps/web/src/components/landing/hero.tsx +++ b/apps/web/src/components/landing/hero.tsx @@ -4,19 +4,37 @@ import { motion } from "motion/react"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; import { ArrowRight } from "lucide-react"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import { toast } from "sonner"; import Image from "next/image"; import { Handlebars } from "./handlebars"; -interface HeroProps { - signupCount: number; -} - -export function Hero({ signupCount }: HeroProps) { +export function Hero() { const [email, setEmail] = useState(""); const [isSubmitting, setIsSubmitting] = useState(false); + const [csrfToken, setCsrfToken] = useState<string | null>(null); + + useEffect(() => { + let isMounted = true; + fetch("/api/waitlist/token", { + credentials: "include", + }) + .then((res) => res.json()) + .then((data) => { + if (isMounted && data.token) { + setCsrfToken(data.token); + } + }) + .catch((err) => { + console.error("Failed to fetch CSRF token:", err); + if (isMounted) { + toast.error("Security initialization failed", { + description: "Please refresh the page to continue.", + }); + } + }); + }, []); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -28,6 +46,13 @@ export function Hero({ signupCount }: HeroProps) { return; } + if (!csrfToken) { + toast.error("Security error", { + description: "Please refresh the page and try again.", + }); + return; + } + setIsSubmitting(true); try { @@ -35,7 +60,9 @@ export function Hero({ signupCount }: HeroProps) { method: "POST", headers: { "Content-Type": "application/json", + "X-CSRF-Token": csrfToken, }, + credentials: "include", body: JSON.stringify({ email: email.trim() }), }); @@ -46,11 +73,18 @@ export function Hero({ signupCount }: HeroProps) { description: "You'll be notified when we launch.", }); setEmail(""); + + fetch("/api/waitlist/token", { credentials: "include" }) + .then((res) => res.json()) + .then((data) => { + if (data.token) setCsrfToken(data.token); + }) + .catch((err) => { + console.error("Failed to refresh CSRF token:", err); + }); } else { toast.error("Oops!", { - description: - (data as { error: string }).error || - "Something went wrong. Please try again.", + description: (data as { error: string }).error || "Something went wrong. Please try again.", }); } } catch (error) { @@ -64,13 +98,7 @@ export function Hero({ signupCount }: HeroProps) { return ( <div className="min-h-[calc(100vh-4.5rem)] supports-[height:100dvh]:min-h-[calc(100dvh-4.5rem)] flex flex-col justify-between items-center text-center px-4"> - <Image - className="absolute top-0 left-0 -z-50 size-full object-cover" - src="/landing-page-bg.png" - height={1903.5} - width={1269} - alt="landing-page.bg" - /> + <Image className="absolute top-0 left-0 -z-50 size-full object-cover" src="/landing-page-bg.png" height={1903.5} width={1269} alt="landing-page.bg" /> <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} @@ -93,20 +121,11 @@ export function Hero({ signupCount }: HeroProps) { animate={{ opacity: 1 }} transition={{ delay: 0.4, duration: 0.8 }} > - A simple but powerful video editor that gets the job done. Works on - any platform. + A simple but powerful video editor that gets the job done. Works on any platform. </motion.p> - <motion.div - className="mt-12 flex gap-8 justify-center" - initial={{ opacity: 0 }} - animate={{ opacity: 1 }} - transition={{ delay: 0.6, duration: 0.8 }} - > - <form - onSubmit={handleSubmit} - className="flex gap-3 w-full max-w-lg flex-col sm:flex-row" - > + <motion.div className="mt-12 flex gap-8 justify-center" initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.6, duration: 0.8 }}> + <form onSubmit={handleSubmit} className="flex gap-3 w-full max-w-lg flex-col sm:flex-row"> <div className="relative w-full"> <Input type="email" @@ -114,35 +133,16 @@ export function Hero({ signupCount }: HeroProps) { className="h-11 text-base flex-1" value={email} onChange={(e) => setEmail(e.target.value)} - disabled={isSubmitting} + disabled={isSubmitting || !csrfToken} required /> </div> - <Button - type="submit" - size="lg" - className="px-6 h-11 text-base !bg-foreground" - disabled={isSubmitting} - > - <span className="relative z-10"> - {isSubmitting ? "Joining..." : "Join waitlist"} - </span> + <Button type="submit" size="lg" className="px-6 h-11 text-base !bg-foreground" disabled={isSubmitting || !csrfToken}> + <span className="relative z-10">{isSubmitting ? "Joining..." : "Join waitlist"}</span> <ArrowRight className="relative z-10 ml-0.5 h-4 w-4 inline-block" /> </Button> </form> </motion.div> - - {signupCount > 0 && ( - <motion.div - initial={{ opacity: 0, y: 10 }} - animate={{ opacity: 1, y: 0 }} - transition={{ delay: 0.8, duration: 0.6 }} - className="mt-8 inline-flex items-center gap-2 text-sm text-muted-foreground justify-center" - > - <div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" /> - <span>{signupCount.toLocaleString()} people already joined</span> - </motion.div> - )} </motion.div> </div> ); diff --git a/apps/web/src/components/ui/drag-overlay.tsx b/apps/web/src/components/ui/drag-overlay.tsx deleted file mode 100644 index ba6fe08f..00000000 --- a/apps/web/src/components/ui/drag-overlay.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { Upload } from "lucide-react";
-
-interface DragOverlayProps {
- isVisible: boolean;
- title?: string;
- description?: string;
-}
-
-export function DragOverlay({
- isVisible,
- title = "Drop files here",
- description = "Images, videos, and audio files",
-}: DragOverlayProps) {
- if (!isVisible) return null;
-
- return (
- <div className="absolute inset-0 bg-accent/20 backdrop-blur-lg border-2 border-dashed border-accent flex items-center justify-center z-10 pointer-events-none">
- <div className="text-center">
- <Upload className="h-8 w-8 text-accent mx-auto mb-2" />
- <p className="text-sm font-medium text-accent">{title}</p>
- <p className="text-xs text-muted-foreground">{description}</p>
- </div>
- </div>
- );
-}
diff --git a/apps/web/src/components/ui/draggable-item.tsx b/apps/web/src/components/ui/draggable-item.tsx index 16518dde..c1f81e16 100644 --- a/apps/web/src/components/ui/draggable-item.tsx +++ b/apps/web/src/components/ui/draggable-item.tsx @@ -6,12 +6,14 @@ import { ReactNode, useState, useRef, useEffect } from "react"; import { createPortal } from "react-dom"; import { Plus } from "lucide-react"; import { cn } from "@/lib/utils"; +import { usePlaybackStore } from "@/stores/playback-store"; export interface DraggableMediaItemProps { name: string; preview: ReactNode; dragData: Record<string, any>; onDragStart?: (e: React.DragEvent) => void; + onAddToTimeline?: (currentTime: number) => void; aspectRatio?: number; className?: string; showPlusOnDrag?: boolean; @@ -24,6 +26,7 @@ export function DraggableMediaItem({ preview, dragData, onDragStart, + onAddToTimeline, aspectRatio = 16 / 9, className = "", showPlusOnDrag = true, @@ -33,6 +36,11 @@ export function DraggableMediaItem({ const [isDragging, setIsDragging] = useState(false); const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 }); const dragRef = useRef<HTMLDivElement>(null); + const currentTime = usePlaybackStore((state) => state.currentTime); + + const handleAddToTimeline = () => { + onAddToTimeline?.(currentTime); + }; const emptyImg = new window.Image(); emptyImg.src = @@ -92,7 +100,10 @@ export function DraggableMediaItem({ > {preview} {!isDragging && ( - <PlusButton className="opacity-0 group-hover:opacity-100" /> + <PlusButton + className="opacity-0 group-hover:opacity-100" + onClick={handleAddToTimeline} + /> )} </AspectRatio> {showLabel && ( @@ -128,7 +139,7 @@ export function DraggableMediaItem({ <div className="w-full h-full [&_img]:w-full [&_img]:h-full [&_img]:object-cover [&_img]:rounded-none"> {preview} </div> - {showPlusOnDrag && <PlusButton />} + {showPlusOnDrag && <PlusButton onClick={handleAddToTimeline} tooltipText="Add to timeline or drag to position" />} </AspectRatio> </div> </div>, @@ -138,11 +149,16 @@ export function DraggableMediaItem({ ); } -function PlusButton({ className }: { className?: string }) { +function PlusButton({ className, onClick }: { className?: string; onClick?: () => void }) { return ( <Button size="icon" className={cn("absolute bottom-2 right-2 size-4", className)} + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + onClick?.(); + }} > <Plus className="!size-3" /> </Button> diff --git a/apps/web/src/components/ui/tooltip.tsx b/apps/web/src/components/ui/tooltip.tsx index 0da023e6..4971e262 100644 --- a/apps/web/src/components/ui/tooltip.tsx +++ b/apps/web/src/components/ui/tooltip.tsx @@ -20,7 +20,7 @@ const TooltipContent = React.forwardRef< ref={ref} sideOffset={sideOffset} className={cn( - "z-50 overflow-hidden rounded-md bg-foreground/10 px-3 py-1.5 text-xs text-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + "z-50 overflow-hidden rounded-md bg-foreground px-3 py-1.5 text-xs text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", className )} {...props} |
