Merge branch 'main' into main
This commit is contained in:
@ -54,7 +54,7 @@ const authConfig = {
|
||||
description: "Sign in to your account to continue",
|
||||
buttonText: "Sign in",
|
||||
linkText: "Don't have an account?",
|
||||
linkHref: "/auth/signup",
|
||||
linkHref: "/signup",
|
||||
linkLabel: "Sign up",
|
||||
successRedirect: "/editor",
|
||||
},
|
||||
@ -63,9 +63,9 @@ const authConfig = {
|
||||
description: "Get started with your free account today",
|
||||
buttonText: "Create account",
|
||||
linkText: "Already have an account?",
|
||||
linkHref: "/auth/login",
|
||||
linkHref: "/login",
|
||||
linkLabel: "Sign in",
|
||||
successRedirect: "/auth/login",
|
||||
successRedirect: "/login",
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
@ -38,7 +38,6 @@ export function PropertiesPanel() {
|
||||
? mediaItems.find((item) => item.id === firstVideoClip.mediaId)
|
||||
: null;
|
||||
|
||||
// Get the first image clip for preview (simplified)
|
||||
const firstImageClip = tracks
|
||||
.flatMap((track) => track.clips)
|
||||
.find((clip) => {
|
||||
|
@ -40,7 +40,7 @@ export function Timeline() {
|
||||
// Timeline shows all tracks (video, audio, effects) and their clips.
|
||||
// You can drag media here to add it to your project.
|
||||
// Clips can be trimmed, deleted, and moved.
|
||||
const { tracks, addTrack, addClipToTrack, removeTrack, toggleTrackMute, removeClipFromTrack, moveClipToTrack, getTotalDuration, selectedClips, selectClip, deselectClip, clearSelectedClips, setSelectedClips } =
|
||||
const { tracks, addTrack, addClipToTrack, removeTrack, toggleTrackMute, removeClipFromTrack, moveClipToTrack, getTotalDuration, selectedClips, selectClip, deselectClip, clearSelectedClips, setSelectedClips, updateClipTrim, undo, redo } =
|
||||
useTimelineStore();
|
||||
const { mediaItems, addMediaItem } = useMediaStore();
|
||||
const { currentTime, duration, seek, setDuration, isPlaying, play, pause, toggle, setSpeed, speed } = usePlaybackStore();
|
||||
@ -102,6 +102,33 @@ export function Timeline() {
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [selectedClips, removeClipFromTrack, clearSelectedClips]);
|
||||
|
||||
// Keyboard event for undo (Cmd+Z)
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "z" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
undo();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [undo]);
|
||||
|
||||
// Keyboard event for redo (Cmd+Shift+Z or Cmd+Y)
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "z" && e.shiftKey) {
|
||||
e.preventDefault();
|
||||
redo();
|
||||
} else if ((e.metaKey || e.ctrlKey) && e.key === "y") {
|
||||
e.preventDefault();
|
||||
redo();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [redo]);
|
||||
|
||||
// Mouse down on timeline background to start marquee
|
||||
const handleTimelineMouseDown = (e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget && e.button === 0) {
|
||||
@ -294,18 +321,32 @@ export function Timeline() {
|
||||
}
|
||||
};
|
||||
|
||||
// Deselect all clips when clicking empty timeline area
|
||||
const handleSeekToPosition = (e: React.MouseEvent) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const clickX = e.clientX - rect.left;
|
||||
const clickedTime = clickX / (50 * zoomLevel);
|
||||
const clampedTime = Math.max(0, Math.min(duration, clickedTime));
|
||||
|
||||
seek(clampedTime);
|
||||
};
|
||||
|
||||
const handleTimelineAreaClick = (e: React.MouseEvent) => {
|
||||
// Only clear selection if the click target is the timeline background (not a child/clip)
|
||||
if (e.target === e.currentTarget) {
|
||||
clearSelectedClips();
|
||||
|
||||
// Calculate the clicked time position and seek to it
|
||||
handleSeekToPosition(e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleWheel = (e: React.WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const delta = e.deltaY > 0 ? -0.05 : 0.05;
|
||||
setZoomLevel((prev) => Math.max(0.1, Math.min(10, prev + delta)));
|
||||
// Only zoom if user is using pinch gesture (ctrlKey or metaKey is true)
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault();
|
||||
const delta = e.deltaY > 0 ? -0.05 : 0.05;
|
||||
setZoomLevel((prev) => Math.max(0.1, Math.min(10, prev + delta)));
|
||||
}
|
||||
// Otherwise, allow normal scrolling
|
||||
};
|
||||
|
||||
// --- Playhead Scrubbing Handlers ---
|
||||
@ -350,6 +391,92 @@ export function Timeline() {
|
||||
onDrop: handleDrop,
|
||||
};
|
||||
|
||||
// Action handlers for toolbar
|
||||
const handleSplitSelected = () => {
|
||||
if (selectedClips.length === 0) {
|
||||
toast.error("No clips selected");
|
||||
return;
|
||||
}
|
||||
selectedClips.forEach(({ trackId, clipId }) => {
|
||||
const track = tracks.find(t => t.id === trackId);
|
||||
const clip = track?.clips.find(c => c.id === clipId);
|
||||
if (clip && track) {
|
||||
const splitTime = currentTime;
|
||||
const effectiveStart = clip.startTime;
|
||||
const effectiveEnd = clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
|
||||
if (splitTime > effectiveStart && splitTime < effectiveEnd) {
|
||||
updateClipTrim(track.id, clip.id, clip.trimStart, clip.trimEnd + (effectiveEnd - splitTime));
|
||||
addClipToTrack(track.id, {
|
||||
mediaId: clip.mediaId,
|
||||
name: clip.name + " (split)",
|
||||
duration: clip.duration,
|
||||
startTime: splitTime,
|
||||
trimStart: clip.trimStart + (splitTime - effectiveStart),
|
||||
trimEnd: clip.trimEnd,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
toast.success("Split selected clip(s)");
|
||||
};
|
||||
|
||||
const handleDuplicateSelected = () => {
|
||||
if (selectedClips.length === 0) {
|
||||
toast.error("No clips selected");
|
||||
return;
|
||||
}
|
||||
selectedClips.forEach(({ trackId, clipId }) => {
|
||||
const track = tracks.find(t => t.id === trackId);
|
||||
const clip = track?.clips.find(c => c.id === clipId);
|
||||
if (clip && track) {
|
||||
addClipToTrack(track.id, {
|
||||
mediaId: clip.mediaId,
|
||||
name: clip.name + " (copy)",
|
||||
duration: clip.duration,
|
||||
startTime: clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd) + 0.1,
|
||||
trimStart: clip.trimStart,
|
||||
trimEnd: clip.trimEnd,
|
||||
});
|
||||
}
|
||||
});
|
||||
toast.success("Duplicated selected clip(s)");
|
||||
};
|
||||
|
||||
const handleFreezeSelected = () => {
|
||||
if (selectedClips.length === 0) {
|
||||
toast.error("No clips selected");
|
||||
return;
|
||||
}
|
||||
selectedClips.forEach(({ trackId, clipId }) => {
|
||||
const track = tracks.find(t => t.id === trackId);
|
||||
const clip = track?.clips.find(c => c.id === clipId);
|
||||
if (clip && track) {
|
||||
// Add a new freeze frame clip at the playhead
|
||||
addClipToTrack(track.id, {
|
||||
mediaId: clip.mediaId,
|
||||
name: clip.name + " (freeze)",
|
||||
duration: 1, // 1 second freeze frame
|
||||
startTime: currentTime,
|
||||
trimStart: 0,
|
||||
trimEnd: clip.duration - 1,
|
||||
});
|
||||
}
|
||||
});
|
||||
toast.success("Freeze frame added for selected clip(s)");
|
||||
};
|
||||
|
||||
const handleDeleteSelected = () => {
|
||||
if (selectedClips.length === 0) {
|
||||
toast.error("No clips selected");
|
||||
return;
|
||||
}
|
||||
selectedClips.forEach(({ trackId, clipId }) => {
|
||||
removeClipFromTrack(trackId, clipId);
|
||||
});
|
||||
clearSelectedClips();
|
||||
toast.success("Deleted selected clip(s)");
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`h-full flex flex-col transition-colors duration-200 relative ${isDragOver ? "bg-accent/30 border-accent" : ""}`}
|
||||
@ -422,7 +549,7 @@ export function Timeline() {
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Button variant="ghost" size="icon" onClick={handleSplitSelected}>
|
||||
<Scissors className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
@ -458,7 +585,7 @@ export function Timeline() {
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Button variant="ghost" size="icon" onClick={handleDuplicateSelected}>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
@ -467,7 +594,7 @@ export function Timeline() {
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Button variant="ghost" size="icon" onClick={handleFreezeSelected}>
|
||||
<Snowflake className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
@ -476,7 +603,7 @@ export function Timeline() {
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Button variant="ghost" size="icon" onClick={handleDeleteSelected}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
@ -526,10 +653,14 @@ export function Timeline() {
|
||||
<div className="flex-1 relative overflow-hidden">
|
||||
<ScrollArea className="w-full">
|
||||
<div
|
||||
className="relative h-12 bg-muted/30"
|
||||
className="relative h-12 bg-muted/30 cursor-pointer"
|
||||
style={{
|
||||
width: `${Math.max(1000, duration * 50 * zoomLevel)}px`,
|
||||
}}
|
||||
onClick={(e) => {
|
||||
// Calculate the clicked time position and seek to it
|
||||
handleSeekToPosition(e);
|
||||
}}
|
||||
>
|
||||
{/* Time markers */}
|
||||
{(() => {
|
||||
@ -653,15 +784,14 @@ export function Timeline() {
|
||||
</div>
|
||||
|
||||
{/* Timeline Tracks Content */}
|
||||
<div className="flex-1 relative">
|
||||
<ScrollArea className="h-full w-full">
|
||||
<div className="flex-1 relative overflow-hidden">
|
||||
<div className="w-full h-[600px] overflow-hidden flex" ref={timelineRef} style={{ position: 'relative' }}>
|
||||
{/* Timeline grid and clips area (with left margin for sidebar) */}
|
||||
<div
|
||||
ref={timelineRef}
|
||||
className="relative cursor-pointer select-none"
|
||||
className="relative flex-1"
|
||||
style={{
|
||||
height: `${tracks.length * 60}px`,
|
||||
width: `${Math.max(1000, duration * 50 * zoomLevel)}px`,
|
||||
minHeight:
|
||||
tracks.length > 0 ? `${tracks.length * 60}px` : "200px",
|
||||
}}
|
||||
onClick={handleTimelineAreaClick}
|
||||
onMouseDown={handleTimelineMouseDown}
|
||||
@ -704,7 +834,6 @@ export function Timeline() {
|
||||
zoomLevel={zoomLevel}
|
||||
setContextMenu={setContextMenu}
|
||||
/>
|
||||
|
||||
</div>
|
||||
))}
|
||||
|
||||
@ -722,7 +851,7 @@ export function Timeline() {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -788,7 +917,7 @@ export function Timeline() {
|
||||
const effectiveEnd = clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
|
||||
|
||||
if (splitTime > effectiveStart && splitTime < effectiveEnd) {
|
||||
useTimelineStore.getState().updateClipTrim(
|
||||
updateClipTrim(
|
||||
track.id,
|
||||
clip.id,
|
||||
clip.trimStart,
|
||||
@ -1452,9 +1581,18 @@ function TimelineTrackContent({
|
||||
style={{ width: `${clipWidth}px`, left: `${clipLeft}px` }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
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);
|
||||
}
|
||||
}}
|
||||
@ -1527,8 +1665,7 @@ function TimelineTrackContent({
|
||||
{/* 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"
|
||||
}`}
|
||||
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%)",
|
||||
@ -1553,4 +1690,4 @@ function TimelineTrackContent({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -6,9 +6,27 @@ import { Button } from "./ui/button";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { HeaderBase } from "./header-base";
|
||||
import { useSession } from "@/lib/auth-client";
|
||||
import { getStars } from "@/lib/fetchGhStars";
|
||||
import { Star } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function Header() {
|
||||
const { data: session } = useSession();
|
||||
const [star, setStar] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
const fetchStars = async () => {
|
||||
try {
|
||||
const data = await getStars();
|
||||
setStar(data);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch GitHub stars", err);
|
||||
}
|
||||
};
|
||||
|
||||
fetchStars();
|
||||
}, []);
|
||||
|
||||
const leftContent = (
|
||||
<Link href="/" className="flex items-center gap-3">
|
||||
<Image src="/logo.png" alt="OpenCut Logo" width={24} height={24} />
|
||||
@ -19,11 +37,18 @@ export function Header() {
|
||||
const rightContent = (
|
||||
<nav className="flex items-center">
|
||||
<Link href="https://github.com/OpenCut-app/OpenCut" target="_blank">
|
||||
<Button variant="ghost" className="text-sm">
|
||||
GitHub
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="flex items-center text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<span className="hidden sm:inline">GitHub</span>
|
||||
<span className="text-foreground flex items-center">
|
||||
{star}+
|
||||
<Star className="w-4 h-4 ml-1" />
|
||||
</span>
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href={session ? "/editor" : "/auth/login"}>
|
||||
<Link href={session ? "/editor" : "/login"}>
|
||||
<Button size="sm" className="text-sm ml-4">
|
||||
Start editing
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
|
@ -5,18 +5,33 @@ import { Button } from "../ui/button";
|
||||
import { Input } from "../ui/input";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { getStars } from "@/lib/fetchGhStars";
|
||||
|
||||
interface HeroProps {
|
||||
signupCount: number;
|
||||
}
|
||||
|
||||
export function Hero({ signupCount }: HeroProps) {
|
||||
const [star, setStar] = useState<string>();
|
||||
const [email, setEmail] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { toast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
const fetchStars = async () => {
|
||||
try {
|
||||
const data = await getStars();
|
||||
setStar(data);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch GitHub stars", err);
|
||||
}
|
||||
};
|
||||
|
||||
fetchStars();
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
@ -67,7 +82,7 @@ export function Hero({ signupCount }: HeroProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen flex flex-col items-center justify-center text-center px-4">
|
||||
<div className="relative min-h-[calc(100vh-4rem)] flex flex-col items-center justify-center text-center px-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
@ -152,7 +167,7 @@ export function Hero({ signupCount }: HeroProps) {
|
||||
href="https://github.com/OpenCut-app/OpenCut"
|
||||
className="text-foreground underline"
|
||||
>
|
||||
GitHub
|
||||
GitHub {star}+
|
||||
</Link>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
Reference in New Issue
Block a user