diff options
Diffstat (limited to 'apps/web/src/components')
| -rw-r--r-- | apps/web/src/components/auth-form.tsx | 398 | ||||
| -rw-r--r-- | apps/web/src/components/editor/media-panel.tsx | 28 | ||||
| -rw-r--r-- | apps/web/src/components/editor/timeline-clip.tsx | 266 | ||||
| -rw-r--r-- | apps/web/src/components/editor/timeline-toolbar.tsx | 219 | ||||
| -rw-r--r-- | apps/web/src/components/editor/timeline.tsx | 820 | ||||
| -rw-r--r-- | apps/web/src/components/landing/hero.tsx | 21 |
6 files changed, 825 insertions, 927 deletions
diff --git a/apps/web/src/components/auth-form.tsx b/apps/web/src/components/auth-form.tsx deleted file mode 100644 index 2736a627..00000000 --- a/apps/web/src/components/auth-form.tsx +++ /dev/null @@ -1,398 +0,0 @@ -"use client";
-
-import { useRouter } from "next/navigation";
-import { signIn, signUp } from "@opencut/auth/client";
-import { Button } from "@/components/ui/button";
-import {
- Card,
- CardContent,
- CardDescription,
- CardHeader,
- CardTitle,
-} from "@/components/ui/card";
-import { useState } from "react";
-import { Input } from "@/components/ui/input";
-import { Separator } from "@/components/ui/separator";
-import Link from "next/link";
-import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
-import { ArrowLeft, Loader2 } from "lucide-react";
-import { GoogleIcon } from "@/components/icons";
-import { useForm } from "react-hook-form";
-import { zodResolver } from "@hookform/resolvers/zod";
-import { z } from "zod";
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@/components/ui/form";
-
-// Zod schemas
-const loginSchema = z.object({
- email: z.string().email("Please enter a valid email address"),
- password: z.string().min(1, "Password is required"),
-});
-
-const signupSchema = z.object({
- name: z.string().min(2, "Name must be at least 2 characters"),
- email: z.string().email("Please enter a valid email address"),
- password: z.string().min(8, "Password must be at least 8 characters"),
-});
-
-type LoginFormData = z.infer<typeof loginSchema>;
-type SignupFormData = z.infer<typeof signupSchema>;
-
-interface AuthFormProps {
- mode: "login" | "signup";
-}
-
-const authConfig = {
- login: {
- title: "Welcome back",
- description: "Sign in to your account to continue",
- buttonText: "Sign in",
- linkText: "Don't have an account?",
- linkHref: "/signup",
- linkLabel: "Sign up",
- successRedirect: "/editor",
- },
- signup: {
- title: "Create your account",
- description: "Get started with your free account today",
- buttonText: "Create account",
- linkText: "Already have an account?",
- linkHref: "/login",
- linkLabel: "Sign in",
- successRedirect: "/login",
- },
-} as const;
-
-interface AuthFormContentProps {
- error: string | null;
- setError: (error: string | null) => void;
- isGoogleLoading: boolean;
- config: typeof authConfig.login | typeof authConfig.signup;
- router: ReturnType<typeof useRouter>;
-}
-
-function LoginFormContent({
- error,
- setError,
- isGoogleLoading,
- config,
- router,
-}: AuthFormContentProps) {
- const form = useForm<LoginFormData>({
- resolver: zodResolver(loginSchema),
- defaultValues: { email: "", password: "" },
- });
-
- const { isSubmitting } = form.formState;
- const isAnyLoading = isSubmitting || isGoogleLoading;
-
- const onSubmit = async (data: LoginFormData) => {
- setError(null);
-
- try {
- const { error } = await signIn.email({
- email: data.email,
- password: data.password,
- });
-
- if (error) {
- setError(error.message || "An unexpected error occurred.");
- return;
- }
-
- router.push(config.successRedirect);
- } catch (error) {
- setError("An unexpected error occurred. Please try again.");
- }
- };
-
- return (
- <Form {...form}>
- <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
- <FormField
- control={form.control}
- name="email"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Email</FormLabel>
- <FormControl>
- <Input
- type="email"
- placeholder="m@example.com"
- disabled={isAnyLoading}
- className="h-11"
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <FormField
- control={form.control}
- name="password"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Password</FormLabel>
- <FormControl>
- <Input
- type="password"
- disabled={isAnyLoading}
- className="h-11"
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <Button
- type="submit"
- disabled={isAnyLoading}
- className="w-full h-11"
- size="lg"
- >
- {isSubmitting ? (
- <Loader2 className="animate-spin" />
- ) : (
- config.buttonText
- )}
- </Button>
- </form>
- </Form>
- );
-}
-
-function SignupFormContent({
- error,
- setError,
- isGoogleLoading,
- config,
- router,
-}: AuthFormContentProps) {
- const form = useForm<SignupFormData>({
- resolver: zodResolver(signupSchema),
- defaultValues: { email: "", password: "", name: "" },
- });
-
- const { isSubmitting } = form.formState;
- const isAnyLoading = isSubmitting || isGoogleLoading;
-
- const onSubmit = async (data: SignupFormData) => {
- setError(null);
-
- try {
- const { error } = await signUp.email({
- name: data.name,
- email: data.email,
- password: data.password,
- });
-
- if (error) {
- setError(error.message || "An unexpected error occurred.");
- return;
- }
-
- router.push(config.successRedirect);
- } catch (error) {
- setError("An unexpected error occurred. Please try again.");
- }
- };
-
- return (
- <Form {...form}>
- <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
- <FormField
- control={form.control}
- name="name"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Full Name</FormLabel>
- <FormControl>
- <Input
- placeholder="John Doe"
- disabled={isAnyLoading}
- className="h-11"
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <FormField
- control={form.control}
- name="email"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Email</FormLabel>
- <FormControl>
- <Input
- type="email"
- placeholder="m@example.com"
- disabled={isAnyLoading}
- className="h-11"
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <FormField
- control={form.control}
- name="password"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Password</FormLabel>
- <FormControl>
- <Input
- type="password"
- placeholder="Create a strong password"
- disabled={isAnyLoading}
- className="h-11"
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <Button
- type="submit"
- disabled={isAnyLoading}
- className="w-full h-11"
- size="lg"
- >
- {isSubmitting ? (
- <Loader2 className="animate-spin" />
- ) : (
- config.buttonText
- )}
- </Button>
- </form>
- </Form>
- );
-}
-
-export function AuthForm({ mode }: AuthFormProps) {
- const router = useRouter();
- const [error, setError] = useState<string | null>(null);
- const [isGoogleLoading, setIsGoogleLoading] = useState(false);
- const config = authConfig[mode];
-
- const handleGoogleAuth = async () => {
- setError(null);
- setIsGoogleLoading(true);
-
- try {
- await signIn.social({
- provider: "google",
- });
-
- router.push(config.successRedirect);
- } catch (error) {
- setError(
- `Failed to ${mode === "login" ? "sign in" : "sign up"} with Google. Please try again.`
- );
- setIsGoogleLoading(false);
- }
- };
-
- return (
- <div className="flex h-screen items-center justify-center relative">
- <Button
- variant="text"
- onClick={() => router.back()}
- className="absolute top-6 left-6"
- >
- <ArrowLeft className="h-5 w-5" /> Back
- </Button>
-
- <Card className="w-[400px] shadow-lg border-0">
- <CardHeader className="text-center pb-4">
- <CardTitle className="text-2xl font-semibold">
- {config.title}
- </CardTitle>
- <CardDescription className="text-base">
- {config.description}
- </CardDescription>
- </CardHeader>
- <CardContent className="pt-0">
- <div className="flex flex-col space-y-6">
- {error && (
- <Alert variant="destructive">
- <AlertTitle>Error</AlertTitle>
- <AlertDescription>{error}</AlertDescription>
- </Alert>
- )}
-
- <Button
- onClick={handleGoogleAuth}
- variant="outline"
- size="lg"
- disabled={isGoogleLoading}
- >
- {isGoogleLoading ? (
- <Loader2 className="animate-spin" />
- ) : (
- <GoogleIcon />
- )}
- Continue with Google
- </Button>
-
- <div className="relative">
- <div className="absolute inset-0 flex items-center">
- <Separator className="w-full" />
- </div>
- <div className="relative flex justify-center text-xs uppercase">
- <span className="bg-background px-2 text-muted-foreground">
- Or continue with
- </span>
- </div>
- </div>
-
- {mode === "login" ? (
- <LoginFormContent
- error={error}
- setError={setError}
- isGoogleLoading={isGoogleLoading}
- config={config}
- router={router}
- />
- ) : (
- <SignupFormContent
- error={error}
- setError={setError}
- isGoogleLoading={isGoogleLoading}
- config={config}
- router={router}
- />
- )}
- </div>
-
- <div className="mt-6 text-center text-sm">
- {config.linkText}{" "}
- <Link
- href={config.linkHref}
- className="font-medium text-primary underline-offset-4 hover:underline"
- >
- {config.linkLabel}
- </Link>
- </div>
- </CardContent>
- </Card>
- </div>
- );
-}
diff --git a/apps/web/src/components/editor/media-panel.tsx b/apps/web/src/components/editor/media-panel.tsx index dfe08365..f717234d 100644 --- a/apps/web/src/components/editor/media-panel.tsx +++ b/apps/web/src/components/editor/media-panel.tsx @@ -1,14 +1,15 @@ "use client"; -import { Button } from "../ui/button"; -import { AspectRatio } from "../ui/aspect-ratio"; -import { DragOverlay } from "../ui/drag-overlay"; -import { useMediaStore, type MediaItem } from "@/stores/media-store"; -import { processMediaFiles } from "@/lib/media-processing"; -import { Plus, Image, Video, Music, Trash2, Upload } from "lucide-react"; import { useDragDrop } from "@/hooks/use-drag-drop"; +import { processMediaFiles } from "@/lib/media-processing"; +import { useMediaStore, type MediaItem } from "@/stores/media-store"; +import { useTimelineStore } from "@/stores/timeline-store"; +import { Image, Music, Plus, Trash2, Upload, Video } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; +import { AspectRatio } from "../ui/aspect-ratio"; +import { Button } from "../ui/button"; +import { DragOverlay } from "../ui/drag-overlay"; // MediaPanel lets users add, view, and drag media (images, videos, audio) into the project. // You can upload files or drag them from your computer. Dragging from here to the timeline adds them to your video project. @@ -58,6 +59,21 @@ export function MediaPanel() { const handleRemove = (e: React.MouseEvent, id: string) => { // Remove a media item from the store e.stopPropagation(); + + + // Remove tracks 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); + }); + // Only remove track if it becomes empty and has no other clips + const updatedTrack = useTimelineStore.getState().tracks.find(t => t.id === track.id); + if (updatedTrack && updatedTrack.clips.length === 0) { + removeTrack(track.id); + } + }); removeMediaItem(id); }; diff --git a/apps/web/src/components/editor/timeline-clip.tsx b/apps/web/src/components/editor/timeline-clip.tsx index 4c0d3b77..b9d3b9a6 100644 --- a/apps/web/src/components/editor/timeline-clip.tsx +++ b/apps/web/src/components/editor/timeline-clip.tsx @@ -2,14 +2,32 @@ import { useState } from "react"; import { Button } from "../ui/button"; -import { MoreVertical, Scissors, Trash2 } from "lucide-react"; +import { + MoreVertical, + Scissors, + Trash2, + SplitSquareHorizontal, + Music, + ChevronRight, + ChevronLeft, +} from "lucide-react"; import { useMediaStore } from "@/stores/media-store"; import { useTimelineStore } from "@/stores/timeline-store"; import { usePlaybackStore } from "@/stores/playback-store"; -import { useDragClip } from "@/hooks/use-drag-clip"; import AudioWaveform from "./audio-waveform"; import { toast } from "sonner"; import { TimelineClipProps, ResizeState } from "@/types/timeline"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, +} from "../ui/dropdown-menu"; +import { isDragging } from "motion/react"; export function TimelineClip({ clip, @@ -21,8 +39,16 @@ export function TimelineClip({ onClipClick, }: TimelineClipProps) { const { mediaItems } = useMediaStore(); - const { updateClipTrim, addClipToTrack, removeClipFromTrack, dragState } = - useTimelineStore(); + const { + updateClipTrim, + addClipToTrack, + removeClipFromTrack, + dragState, + splitClip, + splitAndKeepLeft, + splitAndKeepRight, + separateAudio, + } = useTimelineStore(); const { currentTime } = usePlaybackStore(); const [resizing, setResizing] = useState<ResizeState | null>(null); @@ -52,6 +78,7 @@ export function TimelineClip({ } }; + // Resize handles for trimming clips const handleResizeStart = ( e: React.MouseEvent, clipId: string, @@ -107,44 +134,85 @@ export function TimelineClip({ const handleDeleteClip = () => { removeClipFromTrack(track.id, clip.id); setClipMenuOpen(false); + toast.success("Clip deleted"); }; const handleSplitClip = () => { - // Use current playback time as split point - const splitTime = currentTime; - // Only split if splitTime is within the clip's effective range const effectiveStart = clip.startTime; const effectiveEnd = clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd); - if (splitTime <= effectiveStart || splitTime >= effectiveEnd) { + if (currentTime <= effectiveStart || currentTime >= effectiveEnd) { toast.error("Playhead must be within clip to split"); return; } - const firstDuration = splitTime - effectiveStart; - const secondDuration = effectiveEnd - splitTime; + const secondClipId = splitClip(track.id, clip.id, currentTime); + if (secondClipId) { + toast.success("Clip split successfully"); + } else { + toast.error("Failed to split clip"); + } + setClipMenuOpen(false); + }; - // First part: adjust original clip - updateClipTrim( - track.id, - clip.id, - clip.trimStart, - clip.trimEnd + secondDuration - ); + const handleSplitAndKeepLeft = () => { + const effectiveStart = clip.startTime; + const effectiveEnd = + clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd); - // Second part: add new clip after split - addClipToTrack(track.id, { - mediaId: clip.mediaId, - name: clip.name + " (cut)", - duration: clip.duration, - startTime: splitTime, - trimStart: clip.trimStart + firstDuration, - trimEnd: clip.trimEnd, - }); + if (currentTime <= effectiveStart || currentTime >= effectiveEnd) { + toast.error("Playhead must be within clip"); + return; + } + + splitAndKeepLeft(track.id, clip.id, currentTime); + toast.success("Split and kept left portion"); + setClipMenuOpen(false); + }; + + const handleSplitAndKeepRight = () => { + const effectiveStart = clip.startTime; + const effectiveEnd = + clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd); + + if (currentTime <= effectiveStart || currentTime >= effectiveEnd) { + toast.error("Playhead must be within clip"); + return; + } + + splitAndKeepRight(track.id, clip.id, currentTime); + toast.success("Split and kept right portion"); + setClipMenuOpen(false); + }; + const handleSeparateAudio = () => { + const mediaItem = mediaItems.find((item) => item.id === clip.mediaId); + + if (!mediaItem || mediaItem.type !== "video") { + toast.error("Audio separation only available for video clips"); + return; + } + + const audioClipId = separateAudio(track.id, clip.id); + if (audioClipId) { + toast.success("Audio separated to audio track"); + } else { + toast.error("Failed to separate audio"); + } setClipMenuOpen(false); - toast.success("Clip split successfully"); + }; + + const canSplitAtPlayhead = () => { + const effectiveStart = clip.startTime; + const effectiveEnd = + clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd); + return currentTime > effectiveStart && currentTime < effectiveEnd; + }; + + const canSeparateAudio = () => { + const mediaItem = mediaItems.find((item) => item.id === clip.mediaId); + return mediaItem?.type === "video" && track.type === "video"; }; const renderClipContent = () => { @@ -201,76 +269,112 @@ export function TimelineClip({ ); } - // Fallback for videos without thumbnails return ( <span className="text-xs text-foreground/80 truncate">{clip.name}</span> ); }; + const handleClipMouseDown = (e: React.MouseEvent) => { + if (onClipMouseDown) { + onClipMouseDown(e, clip); + } + }; + return ( <div - className={`timeline-clip absolute h-full border ${getTrackColor(track.type)} flex items-center py-3 min-w-[80px] overflow-hidden group hover:shadow-lg ${isSelected ? "ring-2 ring-blue-500 z-10" : ""} ${isBeingDragged ? "shadow-lg z-20" : ""}`} - style={{ width: `${clipWidth}px`, left: `${clipLeft}px` }} - onMouseDown={(e) => onClipMouseDown(e, clip)} - onClick={(e) => onClipClick(e, clip)} - onMouseMove={handleResizeMove} - onMouseUp={handleResizeEnd} - onMouseLeave={handleResizeEnd} - tabIndex={0} - onContextMenu={(e) => onContextMenu(e, clip.id)} + className={`absolute top-0 h-full select-none transition-all duration-75 ${ + isBeingDragged ? "z-50" : "z-10" + } ${isSelected ? "ring-2 ring-primary" : ""}`} + style={{ + left: `${clipLeft}px`, + width: `${clipWidth}px`, + }} + onMouseMove={resizing ? handleResizeMove : undefined} + onMouseUp={resizing ? handleResizeEnd : undefined} + onMouseLeave={resizing ? handleResizeEnd : undefined} > - {/* Left trim handle */} <div - className={`absolute left-0 top-0 bottom-0 w-2 cursor-w-resize transition-opacity bg-blue-500/50 hover:bg-blue-500 ${isSelected ? "opacity-100" : "opacity-0"}`} - onMouseDown={(e) => handleResizeStart(e, clip.id, "left")} - /> - - {/* Clip content */} - <div className="flex-1 relative"> - {renderClipContent()} + className={`relative h-full rounded border cursor-pointer overflow-hidden ${getTrackColor( + track.type + )} ${isSelected ? "ring-2 ring-primary ring-offset-1" : ""}`} + onClick={(e) => onClipClick && onClipClick(e, clip)} + onMouseDown={handleClipMouseDown} + onContextMenu={(e) => onContextMenu && onContextMenu(e, clip.id)} + > + <div className="absolute inset-1 flex items-center p-1"> + {renderClipContent()} + </div> - {/* Clip options menu */} - <div className="absolute top-1 right-1 z-10"> - <Button - variant="text" - size="icon" - className="opacity-0 group-hover:opacity-100 transition-opacity" - onClick={(e) => { - e.stopPropagation(); - setClipMenuOpen(!clipMenuOpen); - }} - onMouseDown={(e) => e.stopPropagation()} - > - <MoreVertical className="h-4 w-4" /> - </Button> + <div + className="absolute left-0 top-0 bottom-0 w-1 cursor-w-resize hover:bg-primary/50 transition-colors" + onMouseDown={(e) => handleResizeStart(e, clip.id, "left")} + /> + <div + className="absolute right-0 top-0 bottom-0 w-1 cursor-e-resize hover:bg-primary/50 transition-colors" + onMouseDown={(e) => handleResizeStart(e, clip.id, "right")} + /> - {clipMenuOpen && ( - <div - className="absolute right-0 mt-2 w-32 bg-white border rounded shadow z-50" - onMouseDown={(e) => e.stopPropagation()} - > - <button - className="flex items-center w-full px-3 py-2 text-sm hover:bg-muted/30" - onClick={handleSplitClip} + <div className="absolute top-1 right-1"> + <DropdownMenu open={clipMenuOpen} onOpenChange={setClipMenuOpen}> + <DropdownMenuTrigger asChild> + <Button + variant="outline" + size="sm" + 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); + }} > - <Scissors className="h-4 w-4 mr-2" /> Split - </button> - <button - className="flex items-center w-full px-3 py-2 text-sm text-red-600 hover:bg-red-50" + <MoreVertical className="h-3 w-3" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-56"> + {/* Split operations - only available when playhead is within clip */} + <DropdownMenuSub> + <DropdownMenuSubTrigger disabled={!canSplitAtPlayhead()}> + <Scissors className="mr-2 h-4 w-4" /> + Split + </DropdownMenuSubTrigger> + <DropdownMenuSubContent> + <DropdownMenuItem onClick={handleSplitClip}> + <SplitSquareHorizontal className="mr-2 h-4 w-4" /> + Split at Playhead + </DropdownMenuItem> + <DropdownMenuItem onClick={handleSplitAndKeepLeft}> + <ChevronLeft className="mr-2 h-4 w-4" /> + Split and Keep Left + </DropdownMenuItem> + <DropdownMenuItem onClick={handleSplitAndKeepRight}> + <ChevronRight className="mr-2 h-4 w-4" /> + Split and Keep Right + </DropdownMenuItem> + </DropdownMenuSubContent> + </DropdownMenuSub> + + {/* Audio separation - only available for video clips */} + {canSeparateAudio() && ( + <> + <DropdownMenuSeparator /> + <DropdownMenuItem onClick={handleSeparateAudio}> + <Music className="mr-2 h-4 w-4" /> + Separate Audio + </DropdownMenuItem> + </> + )} + + <DropdownMenuSeparator /> + <DropdownMenuItem onClick={handleDeleteClip} + className="text-destructive" > - <Trash2 className="h-4 w-4 mr-2" /> Delete - </button> - </div> - )} + <Trash2 className="mr-2 h-4 w-4" /> + Delete Clip + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> </div> </div> - - {/* Right trim handle */} - <div - className={`absolute right-0 top-0 bottom-0 w-2 cursor-e-resize transition-opacity bg-blue-500/50 hover:bg-blue-500 ${isSelected ? "opacity-100" : "opacity-0"}`} - onMouseDown={(e) => handleResizeStart(e, clip.id, "right")} - /> </div> ); } diff --git a/apps/web/src/components/editor/timeline-toolbar.tsx b/apps/web/src/components/editor/timeline-toolbar.tsx new file mode 100644 index 00000000..51464794 --- /dev/null +++ b/apps/web/src/components/editor/timeline-toolbar.tsx @@ -0,0 +1,219 @@ +"use client";
+
+import type { TrackType } from "@/types/timeline";
+import {
+ ArrowLeftToLine,
+ ArrowRightToLine,
+ Copy,
+ Pause,
+ Play,
+ Scissors,
+ Snowflake,
+ SplitSquareHorizontal,
+ Trash2,
+} from "lucide-react";
+import { Button } from "../ui/button";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "../ui/select";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "../ui/tooltip";
+
+interface TimelineToolbarProps {
+ isPlaying: boolean;
+ currentTime: number;
+ duration: number;
+ speed: number;
+ tracks: any[];
+ toggle: () => void;
+ setSpeed: (speed: number) => void;
+ addTrack: (type: TrackType) => string;
+ addClipToTrack: (trackId: string, clip: any) => void;
+ handleSplitSelected: () => void;
+ handleDuplicateSelected: () => void;
+ handleFreezeSelected: () => void;
+ handleDeleteSelected: () => void;
+}
+
+export function TimelineToolbar({
+ isPlaying,
+ currentTime,
+ duration,
+ speed,
+ tracks,
+ toggle,
+ setSpeed,
+ addTrack,
+ addClipToTrack,
+ handleSplitSelected,
+ handleDuplicateSelected,
+ handleFreezeSelected,
+ handleDeleteSelected,
+}: TimelineToolbarProps) {
+ return (
+ <div className="border-b flex items-center px-2 py-1 gap-1">
+ <TooltipProvider delayDuration={500}>
+ {/* Play/Pause Button */}
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="text"
+ size="icon"
+ onClick={toggle}
+ className="mr-2"
+ >
+ {isPlaying ? (
+ <Pause className="h-4 w-4" />
+ ) : (
+ <Play className="h-4 w-4" />
+ )}
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ {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"
+ style={{ minWidth: "18ch", textAlign: "center" }}
+ >
+ {currentTime.toFixed(1)}s / {duration.toFixed(1)}s
+ </div>
+
+ {/* Test Clip Button - for debugging */}
+ {tracks.length === 0 && (
+ <>
+ <div className="w-px h-6 bg-border mx-1" />
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => {
+ const trackId = addTrack("video");
+ addClipToTrack(trackId, {
+ mediaId: "test",
+ name: "Test Clip",
+ duration: 5,
+ startTime: 0,
+ trimStart: 0,
+ trimEnd: 0,
+ });
+ }}
+ className="text-xs"
+ >
+ Add Test Clip
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>Add a test clip to try playback</TooltipContent>
+ </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 (S)</TooltipContent>
+ </Tooltip>
+
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button variant="text" size="icon">
+ <ArrowLeftToLine className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>Split and keep left (A)</TooltipContent>
+ </Tooltip>
+
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button variant="text" size="icon">
+ <ArrowRightToLine className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>Split and keep right (D)</TooltipContent>
+ </Tooltip>
+
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button variant="text" size="icon">
+ <SplitSquareHorizontal className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>Separate audio (E)</TooltipContent>
+ </Tooltip>
+
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="text"
+ size="icon"
+ onClick={handleDuplicateSelected}
+ >
+ <Copy className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>Duplicate clip (Ctrl+D)</TooltipContent>
+ </Tooltip>
+
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button variant="text" size="icon" onClick={handleFreezeSelected}>
+ <Snowflake className="h-4 w-4" />
+ </Button>
+ </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>
+ </Tooltip>
+
+ <div className="w-px h-6 bg-border mx-1" />
+
+ {/* Speed Control */}
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Select
+ value={speed.toFixed(1)}
+ onValueChange={(value) => setSpeed(parseFloat(value))}
+ >
+ <SelectTrigger className="w-[90px] h-8">
+ <SelectValue placeholder="1.0x" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="0.5">0.5x</SelectItem>
+ <SelectItem value="1.0">1.0x</SelectItem>
+ <SelectItem value="1.5">1.5x</SelectItem>
+ <SelectItem value="2.0">2.0x</SelectItem>
+ </SelectContent>
+ </Select>
+ </TooltipTrigger>
+ <TooltipContent>Playback Speed</TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx index 2d60102a..ee0a2565 100644 --- a/apps/web/src/components/editor/timeline.tsx +++ b/apps/web/src/components/editor/timeline.tsx @@ -1,27 +1,37 @@ "use client";
-import { processMediaFiles } from "@/lib/media-processing";
-import { useMediaStore } from "@/stores/media-store";
-import { usePlaybackStore } from "@/stores/playback-store";
-import { useTimelineStore, type TimelineTrack } from "@/stores/timeline-store";
+import { ScrollArea } from "../ui/scroll-area";
+import { Button } from "../ui/button";
import {
+ Scissors,
ArrowLeftToLine,
ArrowRightToLine,
- Copy,
- MoreVertical,
- Pause,
- Play,
- Scissors,
+ Trash2,
Snowflake,
+ Copy,
SplitSquareHorizontal,
- Trash2,
Volume2,
VolumeX,
+ Pause,
+ Play,
} from "lucide-react";
-import { useCallback, useEffect, useRef, useState } from "react";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+ TooltipProvider,
+} from "../ui/tooltip";
+import {
+ useTimelineStore,
+ type TimelineTrack,
+ type TimelineClip as TypeTimelineClip,
+} from "@/stores/timeline-store";
+import { useMediaStore } from "@/stores/media-store";
+import { usePlaybackStore } from "@/stores/playback-store";
+import { useDragClip } from "@/hooks/use-drag-clip";
+import { processMediaFiles } from "@/lib/media-processing";
import { toast } from "sonner";
-import { Button } from "../ui/button";
-import { ScrollArea } from "../ui/scroll-area";
+import { useState, useRef, useEffect, useCallback } from "react";
import {
Select,
SelectContent,
@@ -29,16 +39,8 @@ import { SelectTrigger,
SelectValue,
} from "../ui/select";
-
-import {
- Tooltip,
- TooltipContent,
- TooltipProvider,
- TooltipTrigger,
-} from "../ui/tooltip";
-
-import AudioWaveform from "./audio-waveform";
-
+import { TimelineClip } from "./timeline-clip";
+import { ContextMenuState } from "@/types/timeline";
export function Timeline() {
// Timeline shows all tracks (video, audio, effects) and their clips.
@@ -56,6 +58,10 @@ export function Timeline() { clearSelectedClips,
setSelectedClips,
updateClipTrim,
+ splitClip,
+ splitAndKeepLeft,
+ splitAndKeepRight,
+ separateAudio,
undo,
redo,
} = useTimelineStore();
@@ -72,19 +78,14 @@ export function Timeline() { } = usePlaybackStore();
const [isDragOver, setIsDragOver] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
+ const [progress, setProgress] = useState(0);
const [zoomLevel, setZoomLevel] = useState(1);
const dragCounterRef = useRef(0);
const timelineRef = useRef<HTMLDivElement>(null);
const [isInTimeline, setIsInTimeline] = useState(false);
// Unified context menu state
- const [contextMenu, setContextMenu] = useState<{
- type: "track" | "clip";
- trackId: string;
- clipId?: string;
- x: number;
- y: number;
- } | null>(null);
+ const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
// Marquee selection state
const [marquee, setMarquee] = useState<{
@@ -239,7 +240,7 @@ 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; clipId: string }[] = [];
tracks.forEach((track, trackIdx) => {
track.clips.forEach((clip) => {
const effectiveDuration = clip.duration - clip.trimStart - clip.trimEnd;
@@ -359,8 +360,12 @@ export function Timeline() { } else if (e.dataTransfer.files?.length > 0) {
// Handle file drops by creating new tracks
setIsProcessing(true);
+ setProgress(0);
try {
- const processedItems = await processMediaFiles(e.dataTransfer.files);
+ const processedItems = await processMediaFiles(
+ e.dataTransfer.files,
+ (p) => setProgress(p)
+ );
for (const processedItem of processedItems) {
addMediaItem(processedItem);
const currentMediaItems = useMediaStore.getState().mediaItems;
@@ -388,6 +393,7 @@ export function Timeline() { toast.error("Failed to process dropped files");
} finally {
setIsProcessing(false);
+ setProgress(0);
}
}
};
@@ -475,33 +481,26 @@ export function Timeline() { toast.error("No clips selected");
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 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,
- });
+
+ if (currentTime > effectiveStart && currentTime < effectiveEnd) {
+ const newClipId = splitClip(trackId, clipId, currentTime);
+ if (newClipId) splitCount++;
}
}
});
- toast.success("Split selected clip(s)");
+ if (splitCount > 0) {
+ toast.success(`Split ${splitCount} clip(s) at playhead`);
+ } else {
+ toast.error("Playhead must be within selected clips to split");
+ }
};
const handleDuplicateSelected = () => {
@@ -551,7 +550,86 @@ export function Timeline() { });
toast.success("Freeze frame added for selected clip(s)");
};
+ const handleSplitAndKeepLeft = () => {
+ if (selectedClips.length === 0) {
+ toast.error("No clips selected");
+ 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 handleSplitAndKeepRight = () => {
+ if (selectedClips.length === 0) {
+ toast.error("No clips selected");
+ 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 handleSeparateAudio = () => {
+ if (selectedClips.length === 0) {
+ toast.error("No clips selected");
+ 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 handleDeleteSelected = () => {
if (selectedClips.length === 0) {
toast.error("No clips selected");
@@ -670,8 +748,9 @@ export function Timeline() { <div className="w-px h-6 bg-border mx-1" />
{/* Time Display */}
- <div className="text-xs text-muted-foreground font-mono px-2"
- style={{ minWidth: '18ch', textAlign: 'center' }}
+ <div
+ className="text-xs text-muted-foreground font-mono px-2"
+ style={{ minWidth: "18ch", textAlign: "center" }}
>
{currentTime.toFixed(1)}s / {duration.toFixed(1)}s
</div>
@@ -714,34 +793,34 @@ export function Timeline() { <Scissors className="h-4 w-4" />
</Button>
</TooltipTrigger>
- <TooltipContent>Split clip (S)</TooltipContent>
+ <TooltipContent>Split clip (Ctrl+S)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
- <Button variant="text" size="icon">
+ <Button variant="text" size="icon" onClick={handleSplitAndKeepLeft}>
<ArrowLeftToLine className="h-4 w-4" />
</Button>
</TooltipTrigger>
- <TooltipContent>Split and keep left (A)</TooltipContent>
+ <TooltipContent>Split and keep left (Ctrl+Q)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
- <Button variant="text" size="icon">
+ <Button variant="text" size="icon" onClick={handleSplitAndKeepRight}>
<ArrowRightToLine className="h-4 w-4" />
</Button>
</TooltipTrigger>
- <TooltipContent>Split and keep right (D)</TooltipContent>
+ <TooltipContent>Split and keep right (Ctrl+W)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
- <Button variant="text" size="icon">
+ <Button variant="text" size="icon" onClick={handleSeparateAudio}>
<SplitSquareHorizontal className="h-4 w-4" />
</Button>
</TooltipTrigger>
- <TooltipContent>Separate audio (E)</TooltipContent>
+ <TooltipContent>Separate audio (Ctrl+D)</TooltipContent>
</Tooltip>
<Tooltip>
@@ -854,17 +933,19 @@ export function Timeline() { return (
<div
key={i}
- className={`absolute top-0 bottom-0 ${isMainMarker
- ? "border-l border-muted-foreground/40"
- : "border-l border-muted-foreground/20"
- }`}
+ className={`absolute top-0 bottom-0 ${
+ isMainMarker
+ ? "border-l border-muted-foreground/40"
+ : "border-l border-muted-foreground/20"
+ }`}
style={{ left: `${time * 50 * zoomLevel}px` }}
>
<span
- className={`absolute top-1 left-1 text-xs ${isMainMarker
- ? "text-muted-foreground font-medium"
- : "text-muted-foreground/70"
- }`}
+ className={`absolute top-1 left-1 text-xs ${
+ isMainMarker
+ ? "text-muted-foreground font-medium"
+ : "text-muted-foreground/70"
+ }`}
>
{(() => {
const formatTime = (seconds: number) => {
@@ -925,12 +1006,13 @@ 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"
- ? "bg-blue-500"
- : track.type === "audio"
- ? "bg-green-500"
- : "bg-purple-500"
- }`}
+ className={`w-3 h-3 rounded-full flex-shrink-0 ${
+ track.type === "video"
+ ? "bg-blue-500"
+ : track.type === "audio"
+ ? "bg-green-500"
+ : "bg-purple-500"
+ }`}
/>
<span className="ml-2 text-sm font-medium truncate">
{track.name}
@@ -990,6 +1072,16 @@ export function Timeline() { y: e.clientY,
});
}}
+ 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();
+ }
+ }}
>
<TimelineTrackContent
track={track}
@@ -1014,14 +1106,12 @@ export function Timeline() { </>
)}
{isDragOver && (
- <div
- className="absolute left-0 right-0 border-2 border-dashed border-accent flex items-center justify-center text-muted-foreground"
- style={{
- top: `${tracks.length * 60}px`,
- height: "60px",
- }}
- >
- <div>Drop media here to add a new track</div>
+ <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>
@@ -1050,22 +1140,17 @@ export function Timeline() { setContextMenu(null);
}}
>
- {(() => {
- const track = tracks.find(
- (t) => t.id === contextMenu.trackId
- );
- return track?.muted ? (
- <>
- <Volume2 className="h-4 w-4 mr-2" />
- Unmute Track
- </>
- ) : (
- <>
- <VolumeX className="h-4 w-4 mr-2" />
- Mute Track
- </>
- );
- })()}
+ {contextMenu.trackId ? (
+ <div className="flex items-center w-full px-3 py-2 hover:bg-accent hover:text-accent-foreground transition-colors text-left">
+ <Volume2 className="h-4 w-4 mr-2" />
+ Unmute Track
+ </div>
+ ) : (
+ <div className="flex items-center w-full px-3 py-2 hover:bg-accent hover:text-accent-foreground transition-colors text-left">
+ <VolumeX className="h-4 w-4 mr-2" />
+ Mute Track
+ </div>
+ )}
</button>
<div className="h-px bg-border mx-1 my-1" />
<button
@@ -1195,134 +1280,191 @@ function TimelineTrackContent({ }: {
track: TimelineTrack;
zoomLevel: number;
- setContextMenu: (
- menu: {
- type: "track" | "clip";
- trackId: string;
- clipId?: string;
- x: number;
- y: number;
- } | null
- ) => void;
- contextMenu: {
- type: "track" | "clip";
- trackId: string;
- clipId?: string;
- x: number;
- y: number;
- } | null;
+ setContextMenu: (menu: ContextMenuState | null) => void;
+ contextMenu: ContextMenuState | null;
}) {
const { mediaItems } = useMediaStore();
const {
tracks,
moveClipToTrack,
- updateClipTrim,
updateClipStartTime,
addClipToTrack,
- removeClipFromTrack,
- toggleTrackMute,
selectedClips,
selectClip,
deselectClip,
+ dragState,
+ startDrag: startDragAction,
+ updateDragTime,
+ endDrag: endDragAction,
} = useTimelineStore();
- const { currentTime } = usePlaybackStore();
+
+ const timelineRef = useRef<HTMLDivElement>(null);
const [isDropping, setIsDropping] = useState(false);
const [dropPosition, setDropPosition] = useState<number | null>(null);
- const [isDraggedOver, setIsDraggedOver] = useState(false);
const [wouldOverlap, setWouldOverlap] = useState(false);
- const [resizing, setResizing] = useState<{
- clipId: string;
- side: "left" | "right";
- startX: number;
- initialTrimStart: number;
- initialTrimEnd: number;
- } | null>(null);
const dragCounterRef = useRef(0);
- const [clipMenuOpen, setClipMenuOpen] = useState<string | null>(null);
-
- // Handle clip deletion
- const handleDeleteClip = (clipId: string) => {
- removeClipFromTrack(track.id, clipId);
- };
+ const [mouseDownLocation, setMouseDownLocation] = useState<{
+ x: number;
+ y: number;
+ } | null>(null);
- const handleResizeStart = (
- e: React.MouseEvent,
- clipId: string,
- side: "left" | "right"
- ) => {
- e.stopPropagation();
- e.preventDefault();
+ // Set up mouse event listeners for drag
+ useEffect(() => {
+ if (!dragState.isDragging) return;
- const clip = track.clips.find((c) => c.id === clipId);
- if (!clip) return;
+ const handleMouseMove = (e: MouseEvent) => {
+ if (!timelineRef.current) return;
- setResizing({
- clipId,
- side,
- startX: e.clientX,
- initialTrimStart: clip.trimStart,
- initialTrimEnd: clip.trimEnd,
- });
- };
+ 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;
- const updateTrimFromMouseMove = (e: { clientX: number; }) => {
- if (!resizing) return;
+ updateDragTime(snappedTime);
+ };
- const clip = track.clips.find((c) => c.id === resizing.clipId);
- if (!clip) return;
+ const handleMouseUp = () => {
+ if (!dragState.clipId || !dragState.trackId) return;
- const deltaX = e.clientX - resizing.startX;
- const deltaTime = deltaX / (50 * zoomLevel);
+ const finalTime = dragState.currentTime;
- if (resizing.side === "left") {
- const newTrimStart = Math.max(
- 0,
- Math.min(
- clip.duration - clip.trimEnd - 0.1,
- resizing.initialTrimStart + deltaTime
- )
- );
- updateClipTrim(track.id, clip.id, newTrimStart, clip.trimEnd);
- } else {
- const newTrimEnd = Math.max(
- 0,
- Math.min(
- clip.duration - clip.trimStart - 0.1,
- resizing.initialTrimEnd - deltaTime
- )
+ // 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
);
- updateClipTrim(track.id, clip.id, clip.trimStart, newTrimEnd);
+
+ 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 });
+ // Handle multi-selection only in mousedown
+ if (e.metaKey || e.ctrlKey || e.shiftKey) {
+ selectClip(track.id, clip.id, true);
}
- };
- const handleResizeMove = (e: React.MouseEvent) => {
- updateTrimFromMouseMove(e);
- };
+ // 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);
- const handleResizeEnd = () => {
- setResizing(null);
+ startDragAction(
+ clip.id,
+ track.id,
+ e.clientX,
+ clip.startTime,
+ clickOffsetTime
+ );
};
- const handleClipDragStart = (e: React.DragEvent, clip: any) => {
- const dragData = { clipId: clip.id, trackId: track.id, name: clip.name };
+ 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;
+ }
+ }
+
+ // Close context menu if it's open
+ if (contextMenu) {
+ setContextMenu(null);
+ return; // Don't handle selection when closing context menu
+ }
- e.dataTransfer.setData(
- "application/x-timeline-clip",
- JSON.stringify(dragData)
+ // Skip selection logic for multi-selection (handled in mousedown)
+ if (e.metaKey || e.ctrlKey || e.shiftKey) {
+ return;
+ }
+
+ // Handle single selection/deselection
+ const isSelected = selectedClips.some(
+ (c) => c.trackId === track.id && c.clipId === clip.id
);
- e.dataTransfer.effectAllowed = "move";
- // Add visual feedback to the dragged element
- const target = e.currentTarget.parentElement as HTMLElement;
- target.style.opacity = "0.5";
- target.style.transform = "scale(0.95)";
+ if (isSelected) {
+ // If clip is selected, deselect it
+ deselectClip(track.id, clip.id);
+ } else {
+ // If clip is not selected, select it (replacing other selections)
+ selectClip(track.id, clip.id, false);
+ }
};
- const handleClipDragEnd = (e: React.DragEvent) => {
- // Reset visual feedback
- const target = e.currentTarget.parentElement as HTMLElement;
- target.style.opacity = "";
- target.style.transform = "";
+ const handleClipContextMenu = (e: React.MouseEvent, clipId: string) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setContextMenu({
+ type: "clip",
+ trackId: track.id,
+ clipId: clipId,
+ x: e.clientX,
+ y: e.clientY,
+ });
};
const handleTrackDragOver = (e: React.DragEvent) => {
@@ -1442,14 +1584,12 @@ function TimelineTrackContent({ if (wouldOverlap) {
e.dataTransfer.dropEffect = "none";
- setIsDraggedOver(true);
setWouldOverlap(true);
setDropPosition(Math.round(dropTime * 10) / 10);
return;
}
e.dataTransfer.dropEffect = hasTimelineClip ? "move" : "copy";
- setIsDraggedOver(true);
setWouldOverlap(false);
setDropPosition(Math.round(dropTime * 10) / 10);
};
@@ -1468,7 +1608,6 @@ function TimelineTrackContent({ dragCounterRef.current++;
setIsDropping(true);
- setIsDraggedOver(true);
};
const handleTrackDragLeave = (e: React.DragEvent) => {
@@ -1487,7 +1626,6 @@ function TimelineTrackContent({ if (dragCounterRef.current === 0) {
setIsDropping(false);
- setIsDraggedOver(false);
setWouldOverlap(false);
setDropPosition(null);
}
@@ -1500,7 +1638,6 @@ function TimelineTrackContent({ // Reset all drag states
dragCounterRef.current = 0;
setIsDropping(false);
- setIsDraggedOver(false);
setWouldOverlap(false);
const currentDropPosition = dropPosition;
setDropPosition(null);
@@ -1532,23 +1669,36 @@ function TimelineTrackContent({ );
if (!timelineClipData) return;
- const { clipId, trackId: fromTrackId } = JSON.parse(timelineClipData);
+ 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: any) => c.id === clipId);
+ 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 = snappedTime + movingClipDuration;
+ const movingClipEnd = finalStartTime + movingClipDuration;
const hasOverlap = track.clips.some((existingClip) => {
// Skip the clip being moved if it's on the same track
@@ -1563,7 +1713,7 @@ function TimelineTrackContent({ existingClip.trimEnd);
// Check if clips overlap
- return snappedTime < existingEnd && movingClipEnd > existingStart;
+ return finalStartTime < existingEnd && movingClipEnd > existingStart;
});
if (hasOverlap) {
@@ -1575,12 +1725,12 @@ function TimelineTrackContent({ if (fromTrackId === track.id) {
// Moving within same track
- updateClipStartTime(track.id, clipId, snappedTime);
+ updateClipStartTime(track.id, clipId, finalStartTime);
} else {
// Moving to different track
moveClipToTrack(fromTrackId, track.id, clipId);
requestAnimationFrame(() => {
- updateClipStartTime(track.id, clipId, snappedTime);
+ updateClipStartTime(track.id, clipId, finalStartTime);
});
}
} else if (hasMediaItem) {
@@ -1648,113 +1798,9 @@ function TimelineTrackContent({ }
};
- const getTrackColor = (type: string) => {
- switch (type) {
- case "video":
- return "bg-blue-500/20 border-blue-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";
- }
- };
-
- const renderClipContent = (clip: any) => {
- const mediaItem = mediaItems.find((item) => item.id === clip.mediaId);
-
- if (!mediaItem) {
- return (
- <span className="text-xs text-foreground/80 truncate">{clip.name}</span>
- );
- }
-
- if (mediaItem.type === "image") {
- return (
- <div className="w-full h-full flex items-center justify-center">
- <img
- src={mediaItem.url}
- alt={mediaItem.name}
- className="w-full h-full object-cover"
- />
- </div>
- );
- }
-
- if (mediaItem.type === "video" && mediaItem.thumbnailUrl) {
- return (
- <div className="w-full h-full flex items-center gap-2">
- <div className="w-8 h-8 flex-shrink-0">
- <img
- src={mediaItem.thumbnailUrl}
- alt={mediaItem.name}
- className="w-full h-full object-cover rounded-sm"
- />
- </div>
- <span className="text-xs text-foreground/80 truncate flex-1">
- {clip.name}
- </span>
- </div>
- );
- }
-
- 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}
- height={24}
- className="w-full"
- />
- </div>
- </div>
- );
- }
-
- // Fallback for videos without thumbnails
- return (
- <span className="text-xs text-foreground/80 truncate">{clip.name}</span>
- );
- };
-
- const handleSplitClip = (clip: any) => {
- // Use current playback time as split point
- const splitTime = currentTime;
- // Only split if splitTime is within the clip's effective range
- const effectiveStart = clip.startTime;
- const effectiveEnd =
- clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
- if (splitTime <= effectiveStart || splitTime >= effectiveEnd) return;
- const firstDuration = splitTime - effectiveStart;
- const secondDuration = effectiveEnd - splitTime;
- // First part: adjust original clip
- updateClipTrim(
- track.id,
- clip.id,
- clip.trimStart,
- clip.trimEnd + secondDuration
- );
- // Second part: add new clip after split
- addClipToTrack(track.id, {
- mediaId: clip.mediaId,
- name: clip.name + " (cut)",
- duration: clip.duration,
- startTime: splitTime,
- trimStart: clip.trimStart + firstDuration,
- trimEnd: clip.trimEnd,
- });
- };
-
return (
<div
- className={`w-full h-full transition-all duration-150 ease-out ${isDraggedOver
- ? wouldOverlap
- ? "bg-red-500/15 border-2 border-dashed border-red-400 shadow-lg"
- : "bg-blue-500/15 border-2 border-dashed border-blue-400 shadow-lg"
- : "hover:bg-muted/20"
- }`}
+ className="w-full h-full hover:bg-muted/20"
onContextMenu={(e) => {
e.preventDefault();
// Only show track menu if we didn't click on a clip
@@ -1767,23 +1813,31 @@ function TimelineTrackContent({ });
}
}}
+ 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}
- onMouseMove={handleResizeMove}
- onMouseUp={handleResizeEnd}
- onMouseLeave={handleResizeEnd}
>
- <div className="h-full relative track-clips-container min-w-full">
+ <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"
- }`}
+ 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
@@ -1794,138 +1848,26 @@ function TimelineTrackContent({ ) : (
<>
{track.clips.map((clip) => {
- const effectiveDuration =
- clip.duration - clip.trimStart - clip.trimEnd;
- const clipWidth = Math.max(
- 80,
- effectiveDuration * 50 * zoomLevel
- );
- const clipLeft = clip.startTime * 50 * zoomLevel;
const isSelected = selectedClips.some(
(c) => c.trackId === track.id && c.clipId === clip.id
);
+
return (
- <div
+ <TimelineClip
key={clip.id}
- className={`timeline-clip absolute h-full border transition-all-200 ${getTrackColor(track.type)} flex items-center py-3 min-w-[80px] overflow-hidden group hover:shadow-lg ${isSelected ? "ring-2 ring-blue-500 z-10" : ""}`}
- style={{ width: `${clipWidth}px`, left: `${clipLeft}px` }}
- onClick={(e) => {
- e.stopPropagation();
-
- // Close context menu if it's open
- if (contextMenu) {
- setContextMenu(null);
- return; // Don't handle selection when closing context menu
- }
-
- const isSelected = selectedClips.some(
- (c) => c.trackId === track.id && c.clipId === clip.id
- );
-
- if (e.metaKey || e.ctrlKey || e.shiftKey) {
- // Multi-selection mode: toggle the clip
- selectClip(track.id, clip.id, true);
- } else if (isSelected) {
- // If clip is already selected, deselect it
- deselectClip(track.id, clip.id);
- } else {
- // If clip is not selected, select it (replacing other selections)
- selectClip(track.id, clip.id, false);
- }
- }}
- tabIndex={0}
- onContextMenu={(e) => {
- e.preventDefault();
- e.stopPropagation();
- setContextMenu({
- type: "clip",
- trackId: track.id,
- clipId: clip.id,
- x: e.clientX,
- y: e.clientY,
- });
- }}
- >
- {/* Left trim handle */}
- <div
- className="absolute left-0 top-0 bottom-0 w-2 cursor-w-resize opacity-0 group-hover:opacity-100 transition-opacity bg-blue-500/50 hover:bg-blue-500"
- onMouseDown={(e) => handleResizeStart(e, clip.id, "left")}
- />
- {/* Clip content */}
- <div
- className="flex-1 cursor-grab active:cursor-grabbing relative"
- draggable={true}
- onDragStart={(e) => handleClipDragStart(e, clip)}
- onDragEnd={handleClipDragEnd}
- >
- {renderClipContent(clip)}
- {/* Clip options menu */}
- <div className="absolute top-1 right-1 z-10">
- <Button
- variant="text"
- size="icon"
- className="opacity-0 group-hover:opacity-100 transition-opacity"
- onClick={() => setClipMenuOpen(clip.id)}
- >
- <MoreVertical className="h-4 w-4" />
- </Button>
- {clipMenuOpen === clip.id && (
- <div className="absolute right-0 mt-2 w-32 bg-white text-black border rounded shadow z-50">
- <button
- className="flex items-center w-full px-3 py-2 text-sm hover:bg-muted/30"
- onClick={() => {
- handleSplitClip(clip);
- setClipMenuOpen(null);
- }}
- >
- <Scissors className="h-4 w-4 mr-2" /> Split
- </button>
- <button
- className="flex items-center w-full px-3 py-2 text-sm text-red-600 hover:bg-red-50"
- onClick={() => handleDeleteClip(clip.id)}
- >
- <Trash2 className="h-4 w-4 mr-2" /> Delete
- </button>
- </div>
- )}
- </div>
- </div>
- {/* Right trim handle */}
- <div
- className="absolute right-0 top-0 bottom-0 w-2 cursor-e-resize opacity-0 group-hover:opacity-100 transition-opacity bg-blue-500/50 hover:bg-blue-500"
- onMouseDown={(e) => handleResizeStart(e, clip.id, "right")}
- />
- </div>
+ clip={clip}
+ track={track}
+ zoomLevel={zoomLevel}
+ isSelected={isSelected}
+ onContextMenu={handleClipContextMenu}
+ onClipMouseDown={handleClipMouseDown}
+ onClipClick={handleClipClick}
+ />
);
})}
-
- {/* Drop position indicator */}
- {isDraggedOver && dropPosition !== null && (
- <div
- className={`absolute top-0 bottom-0 w-1 pointer-events-none z-30 transition-all duration-75 ease-out ${wouldOverlap ? "bg-red-500" : "bg-blue-500"}`}
- style={{
- left: `${dropPosition * 50 * zoomLevel}px`,
- transform: "translateX(-50%)",
- }}
- >
- <div
- className={`absolute -top-2 left-1/2 transform -translate-x-1/2 w-3 h-3 rounded-full border-2 border-white shadow-md ${wouldOverlap ? "bg-red-500" : "bg-blue-500"}`}
- />
- <div
- className={`absolute -bottom-2 left-1/2 transform -translate-x-1/2 w-3 h-3 rounded-full border-2 border-white shadow-md ${wouldOverlap ? "bg-red-500" : "bg-blue-500"}`}
- />
- <div
- className={`absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 text-xs text-white px-1 py-0.5 rounded whitespace-nowrap ${wouldOverlap ? "bg-red-500" : "bg-blue-500"}`}
- >
- {wouldOverlap ? "⚠️" : ""}
- {dropPosition.toFixed(1)}s
- </div>
- </div>
- )}
</>
)}
</div>
</div>
);
}
-
diff --git a/apps/web/src/components/landing/hero.tsx b/apps/web/src/components/landing/hero.tsx index d5c5ed92..7f4a7f2b 100644 --- a/apps/web/src/components/landing/hero.tsx +++ b/apps/web/src/components/landing/hero.tsx @@ -9,6 +9,7 @@ import { useEffect, useState } from "react"; import { useToast } from "@/hooks/use-toast";
import { getStars } from "@/lib/fetchGhStars";
import Image from "next/image";
+import { RiGithubLine, RiTwitterXLine } from "react-icons/ri";
interface HeroProps {
signupCount: number;
@@ -164,7 +165,7 @@ export function Hero({ signupCount }: HeroProps) { </motion.div>
<motion.div
- className="mb-8 text-center text-sm text-muted-foreground/60"
+ className="mb-8 text-center text-sm text-muted-foreground/60 flex flex-row gap-2 items-center justify-center"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.8, duration: 0.8 }}
@@ -172,9 +173,23 @@ export function Hero({ signupCount }: HeroProps) { Currently in beta • Open source on{" "}
<Link
href="https://github.com/OpenCut-app/OpenCut"
- className="text-foreground underline"
+ className="text-foreground flex items-center gap-1"
+ target="_blank"
+ rel="noopener noreferrer"
>
- GitHub {star}+
+ Github
+ <RiGithubLine className="h-5 w-5 " />
+ {star}+
+ </Link>
+ • Follow us on
+ <Link
+ href="https://x.com/OpenCutApp"
+ className="text-foreground flex items-center gap-1"
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ Twitter
+ <RiTwitterXLine className="h-5 w-5 " />
</Link>
</motion.div>
</div>
|
