Merge branch 'main' into feature/optimize-timeline-ruler

This commit is contained in:
Khắc Tuấn 2025-07-17 20:32:08 +07:00 committed by GitHub
commit faf5a121ac
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 335 additions and 101 deletions

View File

@ -15,6 +15,27 @@ Thank you for your interest in contributing to OpenCut! This document provides g
> 1. Upgrade to a recent npm version (v9 or later), which has full workspace protocol support.
> 2. Use an alternative package manager such as **bun** or **pnpm**.
## What to Focus On
**🎯 Good Areas to Contribute:**
- Timeline functionality and UI improvements
- Project management features
- Performance optimizations
- Bug fixes in existing functionality
- UI/UX improvements
- Documentation and testing
**⚠️ Areas to Avoid:**
- Preview panel enhancements (text fonts, stickers, effects)
- Export functionality improvements
- Preview rendering optimizations
**Why?** We're currently planning a major refactor of the preview system. The current preview renders DOM elements (HTML), but we're moving to a binary rendering approach similar to CapCut. This new system will ensure consistency between preview and export, and provide much better performance and quality.
The current HTML-based preview is essentially a prototype - the binary approach will be the "real deal." To avoid wasted effort, please focus on other areas of the application until this refactor is complete.
If you're unsure whether your idea falls into the preview category, feel free to ask us [directly in discord](https://discord.gg/zmR9N35cjK) or create a GitHub issue!
## Development Setup
### Prerequisites

View File

@ -23,6 +23,10 @@ jobs:
env:
DATABASE_URL: "postgresql://opencut:opencutthegoat@localhost:5432/opencut"
BETTER_AUTH_SECRET: "supersecret"
NEXT_PUBLIC_BETTER_AUTH_URL: "http://localhost:3000"
UPSTASH_REDIS_REST_URL: "https://your-upstash-redis-url"
UPSTASH_REDIS_REST_TOKEN: "your-upstash-redis-token"
steps:
- name: Checkout repository

View File

@ -129,11 +129,13 @@ The application will be available at [http://localhost:3000](http://localhost:30
## Contributing
**Note**: We're currently moving at an extremely fast pace with rapid development and breaking changes. While we appreciate the interest, it's recommended to wait until the project stabilizes before contributing to avoid conflicts and wasted effort.
We welcome contributions! While we're actively developing and refactoring certain areas, there are plenty of opportunities to contribute effectively.
## Visit [CONTRIBUTING.md](.github/CONTRIBUTING.md)
**🎯 Focus areas:** Timeline functionality, project management, performance, bug fixes, and UI improvements outside the preview panel.
We welcome contributions! Please see our [Contributing Guide](.github/CONTRIBUTING.md) for detailed setup instructions and development guidelines.
**⚠️ Avoid for now:** Preview panel enhancements (fonts, stickers, effects) and export functionality - we're refactoring these with a new binary rendering approach.
See our [Contributing Guide](.github/CONTRIBUTING.md) for detailed setup instructions, development guidelines, and complete focus area guidance.
**Quick start for contributors:**

View File

@ -1,6 +1,6 @@
"use client";
import { useEffect, useRef } from "react";
import { useEffect, useRef, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import {
ResizablePanelGroup,
@ -17,6 +17,7 @@ import { useProjectStore } from "@/stores/project-store";
import { EditorProvider } from "@/components/editor-provider";
import { usePlaybackControls } from "@/hooks/use-playback-controls";
import { useDisableBrowserZoom } from "@/hooks/use-disable-browser-zoom";
import { Onboarding } from "@/components/onboarding";
export default function Editor() {
const {
@ -37,13 +38,13 @@ export default function Editor() {
const router = useRouter();
const projectId = params.project_id as string;
const handledProjectIds = useRef<Set<string>>(new Set());
const [isOnboardingOpen, setIsOnboardingOpen] = useState(true);
useDisableBrowserZoom();
usePlaybackControls();
useEffect(() => {
const initProject = async () => {
if (!projectId) return;
if (activeProject?.id === projectId) {

View File

@ -347,7 +347,7 @@ export function PreviewPanel() {
{hasAnyElements ? (
<div
ref={previewRef}
className="relative overflow-hidden rounded-sm border"
className="relative overflow-hidden border"
style={{
width: previewDimensions.width,
height: previewDimensions.height,

View File

@ -54,12 +54,59 @@ export function TimelineTrackContent({
const { currentTime } = usePlaybackStore();
// Initialize snapping hook
const { snapElementPosition } = useTimelineSnapping({
const { snapElementPosition, snapElementEdge } = useTimelineSnapping({
snapThreshold: 10,
enableElementSnapping: snappingEnabled,
enablePlayheadSnapping: snappingEnabled,
});
// Helper function for drop snapping that tries both edges
const getDropSnappedTime = (
dropTime: number,
elementDuration: number,
excludeElementId?: string
) => {
if (!snappingEnabled) {
// Use frame snapping if project has FPS, otherwise use decimal snapping
const projectStore = useProjectStore.getState();
const projectFps = projectStore.activeProject?.fps || 30;
return snapTimeToFrame(dropTime, projectFps);
}
// Try snapping both start and end edges for drops
const startSnapResult = snapElementEdge(
dropTime,
elementDuration,
tracks,
currentTime,
zoomLevel,
excludeElementId,
true // snap to start edge
);
const endSnapResult = snapElementEdge(
dropTime,
elementDuration,
tracks,
currentTime,
zoomLevel,
excludeElementId,
false // snap to end edge
);
// Choose the snap result with the smaller distance (closer snap)
let bestSnapResult = startSnapResult;
if (
endSnapResult.snapPoint &&
(!startSnapResult.snapPoint ||
endSnapResult.snapDistance < startSnapResult.snapDistance)
) {
bestSnapResult = endSnapResult;
}
return bestSnapResult.snappedTime;
};
const timelineRef = useRef<HTMLDivElement>(null);
const [isDropping, setIsDropping] = useState(false);
const [dropPosition, setDropPosition] = useState<number | null>(null);
@ -103,15 +150,52 @@ export function TimelineTrackContent({
let finalTime = adjustedTime;
let snapPoint = null;
if (snappingEnabled) {
const snapResult = snapElementPosition(
// Find the element being dragged to get its duration
let elementDuration = 5; // fallback duration
if (dragState.elementId && dragState.trackId) {
const sourceTrack = tracks.find((t) => t.id === dragState.trackId);
const element = sourceTrack?.elements.find(
(e) => e.id === dragState.elementId
);
if (element) {
elementDuration =
element.duration - element.trimStart - element.trimEnd;
}
}
// Try snapping both start and end edges
const startSnapResult = snapElementEdge(
adjustedTime,
elementDuration,
tracks,
currentTime,
zoomLevel,
dragState.elementId || undefined
dragState.elementId || undefined,
true // snap to start edge
);
finalTime = snapResult.snappedTime;
snapPoint = snapResult.snapPoint;
const endSnapResult = snapElementEdge(
adjustedTime,
elementDuration,
tracks,
currentTime,
zoomLevel,
dragState.elementId || undefined,
false // snap to end edge
);
// Choose the snap result with the smaller distance (closer snap)
let bestSnapResult = startSnapResult;
if (
endSnapResult.snapPoint &&
(!startSnapResult.snapPoint ||
endSnapResult.snapDistance < startSnapResult.snapDistance)
) {
bestSnapResult = endSnapResult;
}
finalTime = bestSnapResult.snappedTime;
snapPoint = bestSnapResult.snapPoint;
// Notify parent component about snap point change
onSnapPointChange?.(snapPoint);
@ -390,9 +474,10 @@ export function TimelineTrackContent({
if (dragData.type === "text") {
// Text elements have default duration of 5 seconds
const newElementDuration = 5;
const projectStore = useProjectStore.getState();
const projectFps = projectStore.activeProject?.fps || 30;
const snappedTime = snapTimeToFrame(dropTime, projectFps);
const snappedTime = getDropSnappedTime(
dropTime,
newElementDuration
);
const newElementEnd = snappedTime + newElementDuration;
wouldOverlap = track.elements.some((existingElement) => {
@ -411,9 +496,10 @@ export function TimelineTrackContent({
);
if (mediaItem) {
const newElementDuration = mediaItem.duration || 5;
const projectStore = useProjectStore.getState();
const projectFps = projectStore.activeProject?.fps || 30;
const snappedTime = snapTimeToFrame(dropTime, projectFps);
const snappedTime = getDropSnappedTime(
dropTime,
newElementDuration
);
const newElementEnd = snappedTime + newElementDuration;
wouldOverlap = track.elements.some((existingElement) => {
@ -453,9 +539,11 @@ export function TimelineTrackContent({
movingElement.duration -
movingElement.trimStart -
movingElement.trimEnd;
const projectStore = useProjectStore.getState();
const projectFps = projectStore.activeProject?.fps || 30;
const snappedTime = snapTimeToFrame(dropTime, projectFps);
const snappedTime = getDropSnappedTime(
dropTime,
movingElementDuration,
elementId
);
const movingElementEnd = snappedTime + movingElementDuration;
wouldOverlap = track.elements.some((existingElement) => {
@ -482,17 +570,15 @@ export function TimelineTrackContent({
if (wouldOverlap) {
e.dataTransfer.dropEffect = "none";
setWouldOverlap(true);
const projectStore = useProjectStore.getState();
const projectFps = projectStore.activeProject?.fps || 30;
setDropPosition(snapTimeToFrame(dropTime, projectFps));
// Use default duration for position indicator
setDropPosition(getDropSnappedTime(dropTime, 5));
return;
}
e.dataTransfer.dropEffect = hasTimelineElement ? "move" : "copy";
setWouldOverlap(false);
const projectStore = useProjectStore.getState();
const projectFps = projectStore.activeProject?.fps || 30;
setDropPosition(snapTimeToFrame(dropTime, projectFps));
// Use default duration for position indicator
setDropPosition(getDropSnappedTime(dropTime, 5));
};
const handleTrackDragEnter = (e: React.DragEvent) => {
@ -614,18 +700,20 @@ export function TimelineTrackContent({
return;
}
// Adjust position based on where user clicked on the element
const adjustedStartTime = snappedTime - clickOffsetTime;
const finalStartTime = Math.max(
0,
snapTimeToFrame(adjustedStartTime, projectFps)
);
// Check for overlaps with existing elements (excluding the moving element itself)
const movingElementDuration =
movingElement.duration -
movingElement.trimStart -
movingElement.trimEnd;
// Adjust position based on where user clicked on the element
const adjustedStartTime = newStartTime - clickOffsetTime;
const snappedStartTime = getDropSnappedTime(
adjustedStartTime,
movingElementDuration,
elementId
);
const finalStartTime = Math.max(0, snappedStartTime);
const movingElementEnd = finalStartTime + movingElementDuration;
const hasOverlap = track.elements.some((existingElement) => {
@ -711,7 +799,11 @@ export function TimelineTrackContent({
// Check for overlaps with existing elements in target track
const newElementDuration = 5; // Default text duration
const newElementEnd = snappedTime + newElementDuration;
const textSnappedTime = getDropSnappedTime(
newStartTime,
newElementDuration
);
const newElementEnd = textSnappedTime + newElementDuration;
const hasOverlap = targetTrack.elements.some((existingElement) => {
const existingStart = existingElement.startTime;
@ -722,7 +814,9 @@ export function TimelineTrackContent({
existingElement.trimEnd);
// Check if elements overlap
return snappedTime < existingEnd && newElementEnd > existingStart;
return (
textSnappedTime < existingEnd && newElementEnd > existingStart
);
});
if (hasOverlap) {
@ -737,7 +831,7 @@ export function TimelineTrackContent({
name: dragData.name || "Text",
content: dragData.content || "Default Text",
duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
startTime: snappedTime,
startTime: textSnappedTime,
trimStart: 0,
trimEnd: 0,
fontSize: 48,
@ -857,7 +951,11 @@ export function TimelineTrackContent({
// Check for overlaps with existing elements in target track
const newElementDuration = mediaItem.duration || 5;
const newElementEnd = snappedTime + newElementDuration;
const mediaSnappedTime = getDropSnappedTime(
newStartTime,
newElementDuration
);
const newElementEnd = mediaSnappedTime + newElementDuration;
const hasOverlap = targetTrack.elements.some((existingElement) => {
const existingStart = existingElement.startTime;
@ -868,7 +966,9 @@ export function TimelineTrackContent({
existingElement.trimEnd);
// Check if elements overlap
return snappedTime < existingEnd && newElementEnd > existingStart;
return (
mediaSnappedTime < existingEnd && newElementEnd > existingStart
);
});
if (hasOverlap) {
@ -883,7 +983,7 @@ export function TimelineTrackContent({
mediaId: mediaItem.id,
name: mediaItem.name,
duration: mediaItem.duration || 5,
startTime: snappedTime,
startTime: mediaSnappedTime,
trimStart: 0,
trimEnd: 0,
});

View File

@ -34,15 +34,15 @@ export function Footer() {
<div className="grid grid-cols-1 md:grid-cols-2 gap-12 mb-8">
{/* Brand Section */}
<div className="md:col-span-1 max-w-sm">
<div className="flex items-center gap-2 mb-4">
<div className="flex justify-start items-center gap-2 mb-4">
<Image src="/logo.svg" alt="OpenCut" width={24} height={24} />
<span className="font-bold text-lg">OpenCut</span>
</div>
<p className="text-sm text-muted-foreground mb-5">
<p className="text-sm md:text-left text-muted-foreground mb-5">
The open source video editor that gets the job done. Simple,
powerful, and works on any platform.
</p>
<div className="flex gap-3">
<div className="flex justify-start gap-3">
<Link
href="https://github.com/OpenCut-app/OpenCut"
className="text-muted-foreground hover:text-foreground transition-colors"
@ -70,7 +70,7 @@ export function Footer() {
</div>
</div>
<div className="flex gap-12 justify-end items-start py-2">
<div className="flex gap-12 justify-start items-start py-2">
<div>
<h3 className="font-semibold text-foreground mb-4">Resources</h3>
<ul className="space-y-2 text-sm">
@ -129,7 +129,7 @@ export function Footer() {
</div>
{/* Bottom Section */}
<div className="pt-2 flex flex-col md:flex-row justify-between items-center gap-4">
<div className="pt-2 flex flex-col md:flex-row justify-between items-start gap-4">
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<span>© 2025 OpenCut, All Rights Reserved</span>
</div>

View File

@ -38,6 +38,14 @@ export function GithubIcon({ className }: { className?: string }) {
);
}
export function VercelIcon({ className }: { className?: string }) {
return (
<svg className={className} width="20" height="18" viewBox="0 0 76 65" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M37.5274 0L75.0548 65H0L37.5274 0Z" fill="currentColor"/>
</svg>
);
}
export function BackgroundIcon({ className }: { className?: string }) {
return (
<svg

View File

@ -15,41 +15,73 @@ import { Keyboard } from "lucide-react";
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
const KeyBadge = ({ keyName }: { keyName: string }) => {
// Replace common key names with symbols
// Replace common key names with symbols or friendly names
const displayKey = keyName
.replace("Cmd", "⌘")
.replace("Shift", "⇧")
.replace("Shift", "Shift")
.replace("ArrowLeft", "Arrow Left")
.replace("ArrowRight", "Arrow Right")
.replace("ArrowUp", "Arrow Up")
.replace("ArrowDown", "Arrow Down")
.replace("←", "◀")
.replace("→", "▶")
.replace("Space", "⎵");
.replace("Space", "Space");
return (
<Badge variant="secondary" className="font-mono text-xs px-2 py-1">
<Badge variant="secondary" className="font-mono text-xs px-1 py-1">
{displayKey}
</Badge>
);
};
const ShortcutItem = ({ shortcut }: { shortcut: any }) => (
<div className="flex items-center justify-between py-2 px-3 rounded-lg hover:bg-muted/50">
<div className="flex items-center gap-3">
{shortcut.icon && (
<div className="text-muted-foreground">{shortcut.icon}</div>
)}
<span className="text-sm">{shortcut.description}</span>
const ShortcutItem = ({ shortcut }: { shortcut: any }) => {
// Filter out lowercase duplicates for display - if both "j" and "J" exist, only show "J"
const displayKeys = shortcut.keys.filter((key: string) => {
const lowerKey = key.toLowerCase();
const upperKey = key.toUpperCase();
// If this is a lowercase letter and the uppercase version exists, skip it
if (
key === lowerKey &&
key !== upperKey &&
shortcut.keys.includes(upperKey)
) {
return false;
}
return true;
});
return (
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{shortcut.icon && (
<div className="text-muted-foreground">{shortcut.icon}</div>
)}
<span className="text-sm">{shortcut.description}</span>
</div>
<div className="flex items-center gap-1">
{displayKeys.map((key: string, index: number) => (
<div key={index} className="flex items-center gap-1">
<div className="flex items-center">
{key.split("+").map((keyPart: string, partIndex: number) => (
<div key={partIndex} className="flex items-center gap-1">
<KeyBadge keyName={keyPart} />
{partIndex < key.split("+").length - 1 && (
<span className="text-xs text-muted-foreground">+</span>
)}
</div>
))}
</div>
{index < displayKeys.length - 1 && (
<span className="text-xs text-muted-foreground">or</span>
)}
</div>
))}
</div>
</div>
<div className="flex items-center gap-1">
{shortcut.keys.map((key: string, index: number) => (
<div key={index} className="flex items-center gap-1">
<KeyBadge keyName={key} />
{index < shortcut.keys.length - 1 && (
<span className="text-xs text-muted-foreground">+</span>
)}
</div>
))}
</div>
</div>
);
);
};
export const KeyboardShortcutsHelp = () => {
const [open, setOpen] = useState(false);
@ -67,7 +99,7 @@ export const KeyboardShortcutsHelp = () => {
Shortcuts
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-hidden flex">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Keyboard className="w-5 h-5" />
@ -81,11 +113,11 @@ export const KeyboardShortcutsHelp = () => {
<div className="space-y-6">
{categories.map((category) => (
<div key={category}>
<h3 className="font-semibold text-sm text-muted-foreground uppercase tracking-wide mb-3">
<div key={category} className="flex flex-col gap-1">
<h3 className="text-xs text-muted-foreground uppercase tracking-wide font-medium">
{category}
</h3>
<div className="space-y-1">
<div className="space-y-0.5">
{shortcuts
.filter((shortcut) => shortcut.category === category)
.map((shortcut, index) => (
@ -95,18 +127,6 @@ export const KeyboardShortcutsHelp = () => {
</div>
))}
</div>
<div className="mt-6 p-4 bg-muted/30 rounded-lg">
<h4 className="font-medium text-sm mb-2">Tips:</h4>
<ul className="text-sm text-muted-foreground space-y-1">
<li>
Shortcuts work when the editor is focused (not typing in inputs)
</li>
<li> J/K/L are industry-standard video editing shortcuts</li>
<li> Use arrow keys for frame-perfect positioning</li>
<li> Hold Shift with arrow keys for larger jumps</li>
</ul>
</div>
</DialogContent>
</Dialog>
);

View File

@ -3,6 +3,8 @@
import { motion } from "motion/react";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
import { SponsorButton } from "../ui/sponsor-button";
import { VercelIcon } from "../icons";
import { ArrowRight } from "lucide-react";
import { useState, useEffect } from "react";
import { toast } from "sonner";
@ -113,6 +115,18 @@ export function Hero() {
transition={{ duration: 1 }}
className="max-w-3xl mx-auto w-full flex-1 flex flex-col justify-center"
>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.6, duration: 0.8 }}
className="mb-8 flex justify-center"
>
<SponsorButton
href="https://vercel.com/?utm_source=opencut"
logo={VercelIcon}
companyName="Vercel"
/>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}

View File

@ -0,0 +1,29 @@
"use client";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "./ui/dialog";
interface OnboardingProps {
isOpen: boolean;
onClose: () => void;
}
export function Onboarding({ isOpen, onClose }: OnboardingProps) {
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent>
<DialogHeader>
<DialogTitle>Welcome to Clipture</DialogTitle>
<DialogDescription>
Let's get you started with the basics.
</DialogDescription>
</DialogHeader>
</DialogContent>
</Dialog>
);
}

View File

@ -22,7 +22,7 @@ const DialogOverlay = React.forwardRef<
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"fixed inset-0 z-[100] bg-black/20 backdrop-blur-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
@ -39,9 +39,17 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-[calc(100%-2rem)] max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
"fixed left-[50%] top-[50%] z-[150] grid w-[calc(100%-2rem)] max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-popover shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
onCloseAutoFocus={(e) => {
e.stopPropagation();
e.preventDefault();
}}
onOpenAutoFocus={(e) => {
e.stopPropagation();
e.preventDefault();
}}
{...props}
>
<ScrollArea className="max-h-[85vh]">

View File

@ -0,0 +1,40 @@
import { motion } from "motion/react";
import { ComponentType } from "react";
interface SponsorButtonProps {
href: string;
logo: ComponentType<{ className?: string }>;
companyName: string;
className?: string;
}
export function SponsorButton({
href,
logo: Logo,
companyName,
className = "",
}: SponsorButtonProps) {
return (
<motion.a
href={href}
target="_blank"
rel="noopener noreferrer"
className={`inline-flex items-center gap-2 px-3 py-2 rounded-full border border-white/10 bg-white/5 backdrop-blur-sm hover:bg-white/10 hover:border-white/20 transition-all duration-200 group shadow-lg ${className}`}
>
<span className="text-xs font-medium text-zinc-400 group-hover:text-zinc-300 transition-colors">
Sponsored by
</span>
<div className="flex items-center gap-1.5">
<div className="text-zinc-100 group-hover:text-white transition-colors">
<Logo className="w-4 h-4" />
</div>
<span className="text-xs font-medium text-zinc-100 group-hover:text-white transition-colors">
{companyName}
</span>
</div>
</motion.a>
);
}

View File

@ -66,7 +66,6 @@ export const useKeyboardShortcuts = (
category: "Playback",
action: () => {
toggle();
toast.info(isPlaying ? "Paused" : "Playing", { duration: 1000 });
},
},
{
@ -76,7 +75,6 @@ export const useKeyboardShortcuts = (
category: "Playback",
action: () => {
seek(Math.max(0, currentTime - 1));
toast.info("Rewind 1s", { duration: 1000 });
},
},
{
@ -86,7 +84,6 @@ export const useKeyboardShortcuts = (
category: "Playback",
action: () => {
toggle();
toast.info(isPlaying ? "Paused" : "Playing", { duration: 1000 });
},
},
{
@ -96,7 +93,6 @@ export const useKeyboardShortcuts = (
category: "Playback",
action: () => {
seek(Math.min(duration, currentTime + 1));
toast.info("Forward 1s", { duration: 1000 });
},
},
@ -146,7 +142,6 @@ export const useKeyboardShortcuts = (
category: "Navigation",
action: () => {
seek(0);
toast.info("Start of timeline", { duration: 1000 });
},
},
{
@ -156,7 +151,6 @@ export const useKeyboardShortcuts = (
category: "Navigation",
action: () => {
seek(duration);
toast.info("End of timeline", { duration: 1000 });
},
},
@ -185,7 +179,6 @@ export const useKeyboardShortcuts = (
if (currentTime > effectiveStart && currentTime < effectiveEnd) {
splitElement(trackId, elementId, currentTime);
toast.success("Element split at playhead");
} else {
toast.error("Playhead must be within selected element");
}
@ -218,9 +211,6 @@ export const useKeyboardShortcuts = (
category: "Editing",
action: () => {
toggleSnapping();
toast.info(`Snapping ${snappingEnabled ? "disabled" : "enabled"}`, {
duration: 1000,
});
},
},
@ -238,9 +228,6 @@ export const useKeyboardShortcuts = (
}))
);
setSelectedElements(allElements);
toast.info(`Selected ${allElements.length} elements`, {
duration: 1000,
});
},
},
{
@ -270,8 +257,6 @@ export const useKeyboardShortcuts = (
...elementWithoutId,
startTime: newStartTime,
});
toast.success("Element duplicated");
}
},
},

View File

@ -3,6 +3,8 @@ import type { NextRequest } from "next/server";
import { env } from "./env";
export async function middleware(request: NextRequest) {
const protectedPaths = ["/editor", "/projects"];
// Handle fuckcapcut.com domain redirect
if (request.headers.get("host") === "fuckcapcut.com") {
return NextResponse.redirect("https://opencut.app/why-not-capcut", 301);
@ -10,7 +12,7 @@ export async function middleware(request: NextRequest) {
const path = request.nextUrl.pathname;
if (path.startsWith("/editor") && env.NODE_ENV === "production") {
if (protectedPaths.includes(path) && env.NODE_ENV === "production") {
const homeUrl = new URL("/", request.url);
homeUrl.searchParams.set("redirect", request.url);
return NextResponse.redirect(homeUrl);