summaryrefslogtreecommitdiff
path: root/apps/web/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/components')
-rw-r--r--apps/web/src/components/editor/media-panel/drag-overlay.tsx57
-rw-r--r--apps/web/src/components/editor/media-panel/views/media.tsx71
-rw-r--r--apps/web/src/components/editor/media-panel/views/text.tsx37
-rw-r--r--apps/web/src/components/editor/timeline-element.tsx95
-rw-r--r--apps/web/src/components/editor/timeline.tsx58
-rw-r--r--apps/web/src/components/landing/hero.tsx100
-rw-r--r--apps/web/src/components/ui/drag-overlay.tsx25
-rw-r--r--apps/web/src/components/ui/draggable-item.tsx22
-rw-r--r--apps/web/src/components/ui/tooltip.tsx2
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 ca5bb672..6775e845 100644
--- a/apps/web/src/components/editor/timeline.tsx
+++ b/apps/web/src/components/editor/timeline.tsx
@@ -18,6 +18,7 @@ import {
TypeIcon,
Magnet,
Lock,
+ LockOpen,
} from "lucide-react";
import {
Tooltip,
@@ -571,29 +572,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: any) => item.id === dragData.id);
@@ -602,19 +581,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);
@@ -642,18 +609,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) {
@@ -1048,7 +1004,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}