Merge branch 'main' into feature/optimize-timeline-ruler
This commit is contained in:
commit
faf5a121ac
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:**
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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]">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue