Merge branch 'staging' of https://github.com/mazeincoding/AppCut into staging

This commit is contained in:
Maze Winther
2025-07-25 15:34:53 +02:00
120 changed files with 8212 additions and 7086 deletions
+184 -184
View File
@@ -1,184 +1,184 @@
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
import { Button } from "./ui/button";
import { BackgroundIcon } from "./icons";
import { cn } from "@/lib/utils";
import Image from "next/image";
import { colors } from "@/data/colors";
import { useProjectStore } from "@/stores/project-store";
import { PipetteIcon } from "lucide-react";
type BackgroundTab = "color" | "blur";
export function BackgroundSettings() {
const { activeProject, updateBackgroundType } = useProjectStore();
// ✅ Good: derive activeTab from activeProject during rendering
const activeTab = activeProject?.backgroundType || "color";
const handleColorSelect = (color: string) => {
updateBackgroundType("color", { backgroundColor: color });
};
const handleBlurSelect = (blurIntensity: number) => {
updateBackgroundType("blur", { blurIntensity });
};
const tabs = [
{
label: "Color",
value: "color",
},
{
label: "Blur",
value: "blur",
},
];
return (
<Popover>
<PopoverTrigger asChild>
<Button
variant="text"
size="icon"
className="!size-4 border border-muted-foreground"
>
<BackgroundIcon className="!size-3" />
</Button>
</PopoverTrigger>
<PopoverContent className="flex flex-col items-start w-[20rem] h-[16rem] overflow-hidden p-0">
<div className="flex items-center justify-between w-full gap-2 z-10 bg-popover p-3">
<h2 className="text-sm">Background</h2>
<div className="flex items-center gap-2 text-sm">
{tabs.map((tab) => (
<span
key={tab.value}
onClick={() => {
// Switch to the background type when clicking tabs
if (tab.value === "color") {
updateBackgroundType("color", {
backgroundColor:
activeProject?.backgroundColor || "#000000",
});
} else {
updateBackgroundType("blur", {
blurIntensity: activeProject?.blurIntensity || 8,
});
}
}}
className={cn(
"text-muted-foreground cursor-pointer",
activeTab === tab.value && "text-foreground"
)}
>
{tab.label}
</span>
))}
</div>
</div>
{activeTab === "color" ? (
<ColorView
selectedColor={activeProject?.backgroundColor || "#000000"}
onColorSelect={handleColorSelect}
/>
) : (
<BlurView
selectedBlur={activeProject?.blurIntensity || 8}
onBlurSelect={handleBlurSelect}
/>
)}
</PopoverContent>
</Popover>
);
}
function ColorView({
selectedColor,
onColorSelect,
}: {
selectedColor: string;
onColorSelect: (color: string) => void;
}) {
return (
<div className="w-full h-full">
<div className="absolute top-8 left-0 w-[calc(100%-1rem)] h-12 bg-gradient-to-b from-popover to-transparent pointer-events-none"></div>
<div className="grid grid-cols-4 gap-2 w-full h-full p-3 pt-0 overflow-auto">
<div className="w-full aspect-square rounded-sm cursor-pointer border border-foreground/15 hover:border-primary flex items-center justify-center">
<PipetteIcon className="size-4" />
</div>
{colors.map((color) => (
<ColorItem
key={color}
color={color}
isSelected={color === selectedColor}
onClick={() => onColorSelect(color)}
/>
))}
</div>
</div>
);
}
function ColorItem({
color,
isSelected,
onClick,
}: {
color: string;
isSelected: boolean;
onClick: () => void;
}) {
return (
<div
className={cn(
"w-full aspect-square rounded-sm cursor-pointer hover:border-2 hover:border-primary",
isSelected && "border-2 border-primary"
)}
style={{ backgroundColor: color }}
onClick={onClick}
/>
);
}
function BlurView({
selectedBlur,
onBlurSelect,
}: {
selectedBlur: number;
onBlurSelect: (blurIntensity: number) => void;
}) {
const blurLevels = [
{ label: "Light", value: 4 },
{ label: "Medium", value: 8 },
{ label: "Heavy", value: 18 },
];
const blurImage =
"https://images.unsplash.com/photo-1501785888041-af3ef285b470?q=80&w=1470&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D";
return (
<div className="grid grid-cols-3 gap-2 w-full p-3 pt-0">
{blurLevels.map((blur) => (
<div
key={blur.value}
className={cn(
"w-full aspect-square rounded-sm cursor-pointer hover:border-2 hover:border-primary relative overflow-hidden",
selectedBlur === blur.value && "border-2 border-primary"
)}
onClick={() => onBlurSelect(blur.value)}
>
<Image
src={blurImage}
alt={`Blur preview ${blur.label}`}
fill
className="object-cover"
style={{ filter: `blur(${blur.value}px)` }}
/>
<div className="absolute bottom-1 left-1 right-1 text-center">
<span className="text-xs text-white bg-black/50 px-1 rounded">
{blur.label}
</span>
</div>
</div>
))}
</div>
);
}
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
import { Button } from "./ui/button";
import { BackgroundIcon } from "./icons";
import { cn } from "@/lib/utils";
import Image from "next/image";
import { colors } from "@/data/colors";
import { useProjectStore } from "@/stores/project-store";
import { PipetteIcon } from "lucide-react";
type BackgroundTab = "color" | "blur";
export function BackgroundSettings() {
const { activeProject, updateBackgroundType } = useProjectStore();
// ✅ Good: derive activeTab from activeProject during rendering
const activeTab = activeProject?.backgroundType || "color";
const handleColorSelect = (color: string) => {
updateBackgroundType("color", { backgroundColor: color });
};
const handleBlurSelect = (blurIntensity: number) => {
updateBackgroundType("blur", { blurIntensity });
};
const tabs = [
{
label: "Color",
value: "color",
},
{
label: "Blur",
value: "blur",
},
];
return (
<Popover>
<PopoverTrigger asChild>
<Button
variant="text"
size="icon"
className="!size-4 border border-muted-foreground"
>
<BackgroundIcon className="!size-3" />
</Button>
</PopoverTrigger>
<PopoverContent className="flex flex-col items-start w-[20rem] h-[16rem] overflow-hidden p-0">
<div className="flex items-center justify-between w-full gap-2 z-10 bg-popover p-3">
<h2 className="text-sm">Background</h2>
<div className="flex items-center gap-2 text-sm">
{tabs.map((tab) => (
<span
key={tab.value}
onClick={() => {
// Switch to the background type when clicking tabs
if (tab.value === "color") {
updateBackgroundType("color", {
backgroundColor:
activeProject?.backgroundColor || "#000000",
});
} else {
updateBackgroundType("blur", {
blurIntensity: activeProject?.blurIntensity || 8,
});
}
}}
className={cn(
"text-muted-foreground cursor-pointer",
activeTab === tab.value && "text-foreground"
)}
>
{tab.label}
</span>
))}
</div>
</div>
{activeTab === "color" ? (
<ColorView
selectedColor={activeProject?.backgroundColor || "#000000"}
onColorSelect={handleColorSelect}
/>
) : (
<BlurView
selectedBlur={activeProject?.blurIntensity || 8}
onBlurSelect={handleBlurSelect}
/>
)}
</PopoverContent>
</Popover>
);
}
function ColorView({
selectedColor,
onColorSelect,
}: {
selectedColor: string;
onColorSelect: (color: string) => void;
}) {
return (
<div className="w-full h-full">
<div className="absolute top-8 left-0 w-[calc(100%-1rem)] h-12 bg-gradient-to-b from-popover to-transparent pointer-events-none" />
<div className="grid grid-cols-4 gap-2 w-full h-full p-3 pt-0 overflow-auto">
<div className="w-full aspect-square rounded-sm cursor-pointer border border-foreground/15 hover:border-primary flex items-center justify-center">
<PipetteIcon className="size-4" />
</div>
{colors.map((color) => (
<ColorItem
key={color}
color={color}
isSelected={color === selectedColor}
onClick={() => onColorSelect(color)}
/>
))}
</div>
</div>
);
}
function ColorItem({
color,
isSelected,
onClick,
}: {
color: string;
isSelected: boolean;
onClick: () => void;
}) {
return (
<div
className={cn(
"w-full aspect-square rounded-sm cursor-pointer hover:border-2 hover:border-primary",
isSelected && "border-2 border-primary"
)}
style={{ backgroundColor: color }}
onClick={onClick}
/>
);
}
function BlurView({
selectedBlur,
onBlurSelect,
}: {
selectedBlur: number;
onBlurSelect: (blurIntensity: number) => void;
}) {
const blurLevels = [
{ label: "Light", value: 4 },
{ label: "Medium", value: 8 },
{ label: "Heavy", value: 18 },
];
const blurImage =
"https://images.unsplash.com/photo-1501785888041-af3ef285b470?q=80&w=1470&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D";
return (
<div className="grid grid-cols-3 gap-2 w-full p-3 pt-0">
{blurLevels.map((blur) => (
<div
key={blur.value}
className={cn(
"w-full aspect-square rounded-sm cursor-pointer hover:border-2 hover:border-primary relative overflow-hidden",
selectedBlur === blur.value && "border-2 border-primary"
)}
onClick={() => onBlurSelect(blur.value)}
>
<Image
src={blurImage}
alt={`Blur preview ${blur.label}`}
fill
className="object-cover"
style={{ filter: `blur(${blur.value}px)` }}
/>
<div className="absolute bottom-1 left-1 right-1 text-center">
<span className="text-xs text-white bg-black/50 px-1 rounded">
{blur.label}
</span>
</div>
</div>
))}
</div>
);
}
+127 -127
View File
@@ -1,127 +1,127 @@
"use client";
import Link from "next/link";
import { Button } from "./ui/button";
import { ChevronLeft, Download } from "lucide-react";
import { useTimelineStore } from "@/stores/timeline-store";
import { HeaderBase } from "./header-base";
import { formatTimeCode } from "@/lib/time";
import { useProjectStore } from "@/stores/project-store";
import { KeyboardShortcutsHelp } from "./keyboard-shortcuts-help";
import { useState, useRef } from "react";
import { Input } from "@/components/ui/input";
import { toast } from "sonner";
export function EditorHeader() {
const { getTotalDuration } = useTimelineStore();
const { activeProject, renameProject } = useProjectStore();
const [isEditing, setIsEditing] = useState(false);
const [newName, setNewName] = useState(activeProject?.name || "");
const inputRef = useRef<HTMLInputElement>(null);
const handleExport = () => {
// TODO: Implement export functionality
// NOTE: This is already being worked on
console.log("Export project");
toast.warning("Export project is being worked on.");
};
const handleNameClick = () => {
if (!activeProject) return;
setNewName(activeProject.name);
setIsEditing(true);
};
const handleNameSave = async () => {
if (activeProject && newName.trim() && newName !== activeProject.name) {
try {
await renameProject(activeProject.id, newName.trim());
} catch (error) {
console.error("Failed to rename project:", error);
setNewName(activeProject.name);
}
}
setIsEditing(false);
};
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") handleNameSave();
else if (e.key === "Escape") setIsEditing(false);
};
const leftContent = (
<div className="flex items-center gap-2">
<Link
href="/projects"
className="font-medium tracking-tight flex items-center gap-2 hover:opacity-80 transition-opacity"
>
<ChevronLeft className="h-4 w-4" />
</Link>
<div className="w-[14rem] h-9 flex items-center">
{isEditing ? (
<Input
ref={inputRef}
className="text-sm font-medium px-2 py-1 h-9 truncate"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onBlur={handleNameSave}
onKeyDown={handleInputKeyDown}
onFocus={(e) => e.target.select()}
maxLength={64}
aria-label="Project name"
autoFocus
/>
) : (
<span
className="text-sm font-medium cursor-pointer hover:underline"
title="Click to rename"
role="button"
tabIndex={0}
onClick={handleNameClick}
onKeyDown={(e) => e.key === "Enter" && handleNameClick()}
>
<div className="truncate text-ellipsis overflow-clip w-40">
{activeProject?.name}
</div>
</span>
)}
</div>
</div>
);
const centerContent = (
<div className="flex items-center gap-2 text-xs">
<span>
{formatTimeCode(
getTotalDuration(),
"HH:MM:SS:FF",
activeProject?.fps || 30
)}
</span>
</div>
);
const rightContent = (
<nav className="flex items-center gap-2">
<KeyboardShortcutsHelp />
<Button
size="sm"
variant="primary"
className="h-7 text-xs"
onClick={handleExport}
>
<Download className="h-4 w-4" />
<span className="text-sm">Export</span>
</Button>
</nav>
);
return (
<HeaderBase
leftContent={leftContent}
centerContent={centerContent}
rightContent={rightContent}
className="bg-background h-[3.2rem] px-4 items-center"
/>
);
}
"use client";
import Link from "next/link";
import { Button } from "./ui/button";
import { ChevronLeft, Download } from "lucide-react";
import { useTimelineStore } from "@/stores/timeline-store";
import { HeaderBase } from "./header-base";
import { formatTimeCode } from "@/lib/time";
import { useProjectStore } from "@/stores/project-store";
import { KeyboardShortcutsHelp } from "./keyboard-shortcuts-help";
import { useState, useRef } from "react";
import { Input } from "@/components/ui/input";
import { toast } from "sonner";
export function EditorHeader() {
const { getTotalDuration } = useTimelineStore();
const { activeProject, renameProject } = useProjectStore();
const [isEditing, setIsEditing] = useState(false);
const [newName, setNewName] = useState(activeProject?.name || "");
const inputRef = useRef<HTMLInputElement>(null);
const handleExport = () => {
// TODO: Implement export functionality
// NOTE: This is already being worked on
console.log("Export project");
toast.warning("Export project is being worked on.");
};
const handleNameClick = () => {
if (!activeProject) return;
setNewName(activeProject.name);
setIsEditing(true);
};
const handleNameSave = async () => {
if (activeProject && newName.trim() && newName !== activeProject.name) {
try {
await renameProject(activeProject.id, newName.trim());
} catch (error) {
console.error("Failed to rename project:", error);
setNewName(activeProject.name);
}
}
setIsEditing(false);
};
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") handleNameSave();
else if (e.key === "Escape") setIsEditing(false);
};
const leftContent = (
<div className="flex items-center gap-2">
<Link
href="/projects"
className="font-medium tracking-tight flex items-center gap-2 hover:opacity-80 transition-opacity"
>
<ChevronLeft className="h-4 w-4" />
</Link>
<div className="w-[14rem] h-9 flex items-center">
{isEditing ? (
<Input
ref={inputRef}
className="text-sm font-medium px-2 py-1 h-9 truncate"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onBlur={handleNameSave}
onKeyDown={handleInputKeyDown}
onFocus={(e) => e.target.select()}
maxLength={64}
aria-label="Project name"
autoFocus
/>
) : (
<span
className="text-sm font-medium cursor-pointer hover:underline"
title="Click to rename"
role="button"
tabIndex={0}
onClick={handleNameClick}
onKeyDown={(e) => e.key === "Enter" && handleNameClick()}
>
<div className="truncate text-ellipsis overflow-clip w-40">
{activeProject?.name}
</div>
</span>
)}
</div>
</div>
);
const centerContent = (
<div className="flex items-center gap-2 text-xs">
<span>
{formatTimeCode(
getTotalDuration(),
"HH:MM:SS:FF",
activeProject?.fps || 30
)}
</span>
</div>
);
const rightContent = (
<nav className="flex items-center gap-2">
<KeyboardShortcutsHelp />
<Button
size="sm"
variant="primary"
className="h-7 text-xs"
onClick={handleExport}
>
<Download className="h-4 w-4" />
<span className="text-sm">Export</span>
</Button>
</nav>
);
return (
<HeaderBase
leftContent={leftContent}
centerContent={centerContent}
rightContent={rightContent}
className="bg-background h-[3.2rem] px-4 items-center"
/>
);
}
+53 -53
View File
@@ -1,53 +1,53 @@
"use client";
import { useEffect } from "react";
import { Loader2 } from "lucide-react";
import { useEditorStore } from "@/stores/editor-store";
import {
useKeybindingsListener,
useKeybindingDisabler,
} from "@/hooks/use-keybindings";
import { useEditorActions } from "@/hooks/use-editor-actions";
interface EditorProviderProps {
children: React.ReactNode;
}
export function EditorProvider({ children }: EditorProviderProps) {
const { isInitializing, isPanelsReady, initializeApp } = useEditorStore();
const { disableKeybindings, enableKeybindings } = useKeybindingDisabler();
// Set up action handlers
useEditorActions();
// Set up keybinding listener
useKeybindingsListener();
// Disable keybindings when initializing
useEffect(() => {
if (isInitializing || !isPanelsReady) {
disableKeybindings();
} else {
enableKeybindings();
}
}, [isInitializing, isPanelsReady, disableKeybindings, enableKeybindings]);
useEffect(() => {
initializeApp();
}, [initializeApp]);
// Show loading screen while initializing
if (isInitializing || !isPanelsReady) {
return (
<div className="h-screen w-screen flex items-center justify-center bg-background">
<div className="flex flex-col items-center gap-4">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<p className="text-sm text-muted-foreground">Loading editor...</p>
</div>
</div>
);
}
// App is ready, render children
return <>{children}</>;
}
"use client";
import { useEffect } from "react";
import { Loader2 } from "lucide-react";
import { useEditorStore } from "@/stores/editor-store";
import {
useKeybindingsListener,
useKeybindingDisabler,
} from "@/hooks/use-keybindings";
import { useEditorActions } from "@/hooks/use-editor-actions";
interface EditorProviderProps {
children: React.ReactNode;
}
export function EditorProvider({ children }: EditorProviderProps) {
const { isInitializing, isPanelsReady, initializeApp } = useEditorStore();
const { disableKeybindings, enableKeybindings } = useKeybindingDisabler();
// Set up action handlers
useEditorActions();
// Set up keybinding listener
useKeybindingsListener();
// Disable keybindings when initializing
useEffect(() => {
if (isInitializing || !isPanelsReady) {
disableKeybindings();
} else {
enableKeybindings();
}
}, [isInitializing, isPanelsReady, disableKeybindings, enableKeybindings]);
useEffect(() => {
initializeApp();
}, [initializeApp]);
// Show loading screen while initializing
if (isInitializing || !isPanelsReady) {
return (
<div className="h-screen w-screen flex items-center justify-center bg-background">
<div className="flex flex-col items-center gap-4">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<p className="text-sm text-muted-foreground">Loading editor...</p>
</div>
</div>
);
}
// App is ready, render children
return <>{children}</>;
}
@@ -1,5 +1,5 @@
import React, { useEffect, useRef, useState } from 'react';
import WaveSurfer from 'wavesurfer.js';
import React, { useEffect, useRef, useState } from "react";
import WaveSurfer from "wavesurfer.js";
interface AudioWaveformProps {
audioUrl: string;
@@ -7,10 +7,10 @@ interface AudioWaveformProps {
className?: string;
}
const AudioWaveform: React.FC<AudioWaveformProps> = ({
audioUrl,
height = 32,
className = ''
const AudioWaveform: React.FC<AudioWaveformProps> = ({
audioUrl,
height = 32,
className = "",
}) => {
const waveformRef = useRef<HTMLDivElement>(null);
const wavesurfer = useRef<WaveSurfer | null>(null);
@@ -36,26 +36,26 @@ const AudioWaveform: React.FC<AudioWaveformProps> = ({
wavesurfer.current = WaveSurfer.create({
container: waveformRef.current,
waveColor: 'rgba(255, 255, 255, 0.6)',
progressColor: 'rgba(255, 255, 255, 0.9)',
cursorColor: 'transparent',
waveColor: "rgba(255, 255, 255, 0.6)",
progressColor: "rgba(255, 255, 255, 0.9)",
cursorColor: "transparent",
barWidth: 2,
barGap: 1,
height: height,
height,
normalize: true,
interact: false,
});
// Event listeners
wavesurfer.current.on('ready', () => {
wavesurfer.current.on("ready", () => {
if (mounted) {
setIsLoading(false);
setError(false);
}
});
wavesurfer.current.on('error', (err) => {
console.error('WaveSurfer error:', err);
wavesurfer.current.on("error", (err) => {
console.error("WaveSurfer error:", err);
if (mounted) {
setError(true);
setIsLoading(false);
@@ -63,9 +63,8 @@ const AudioWaveform: React.FC<AudioWaveformProps> = ({
});
await wavesurfer.current.load(audioUrl);
} catch (err) {
console.error('Failed to initialize WaveSurfer:', err);
console.error("Failed to initialize WaveSurfer:", err);
if (mounted) {
setError(true);
setIsLoading(false);
@@ -90,7 +89,10 @@ const AudioWaveform: React.FC<AudioWaveformProps> = ({
if (error) {
return (
<div className={`flex items-center justify-center ${className}`} style={{ height }}>
<div
className={`flex items-center justify-center ${className}`}
style={{ height }}
>
<span className="text-xs text-foreground/60">Audio unavailable</span>
</div>
);
@@ -103,13 +105,13 @@ const AudioWaveform: React.FC<AudioWaveformProps> = ({
<span className="text-xs text-foreground/60">Loading...</span>
</div>
)}
<div
ref={waveformRef}
className={`w-full transition-opacity duration-200 ${isLoading ? 'opacity-0' : 'opacity-100'}`}
<div
ref={waveformRef}
className={`w-full transition-opacity duration-200 ${isLoading ? "opacity-0" : "opacity-100"}`}
style={{ height }}
/>
</div>
);
};
export default AudioWaveform;
export default AudioWaveform;
@@ -1,57 +1,57 @@
import { Upload, Plus, Image } from "lucide-react";
import { Button } from "@/components/ui/button";
interface MediaDragOverlayProps {
isVisible: boolean;
isProcessing?: boolean;
progress?: number;
onClick?: () => void;
isEmptyState?: boolean;
}
export function MediaDragOverlay({
isVisible,
isProcessing = false,
progress = 0,
onClick,
isEmptyState = false,
}: MediaDragOverlayProps) {
if (!isVisible) return null;
const handleClick = (e: React.MouseEvent) => {
if (isProcessing || !onClick) return;
e.preventDefault();
e.stopPropagation();
onClick();
};
return (
<div
className="flex flex-col items-center justify-center gap-4 h-full text-center rounded-lg bg-foreground/5 hover:bg-foreground/10 transition-all duration-200 p-8"
onClick={handleClick}
>
<div className="flex items-center justify-center">
<Upload className="h-10 w-10 text-foreground" />
</div>
<div className="space-y-2">
<p className="text-xs text-muted-foreground max-w-sm">
{isProcessing
? `Processing your files (${progress}%)`
: "Drag and drop videos, photos, and audio files here"}
</p>
</div>
{isProcessing && (
<div className="w-full max-w-xs">
<div className="w-full bg-muted/50 rounded-full h-2">
<div
className="bg-primary h-2 rounded-full transition-all duration-300"
style={{ width: `${progress}%` }}
/>
</div>
</div>
)}
</div>
);
}
import { Upload, Plus, Image } from "lucide-react";
import { Button } from "@/components/ui/button";
interface MediaDragOverlayProps {
isVisible: boolean;
isProcessing?: boolean;
progress?: number;
onClick?: () => void;
isEmptyState?: boolean;
}
export function MediaDragOverlay({
isVisible,
isProcessing = false,
progress = 0,
onClick,
isEmptyState = false,
}: MediaDragOverlayProps) {
if (!isVisible) return null;
const handleClick = (e: React.MouseEvent) => {
if (isProcessing || !onClick) return;
e.preventDefault();
e.stopPropagation();
onClick();
};
return (
<div
className="flex flex-col items-center justify-center gap-4 h-full text-center rounded-lg bg-foreground/5 hover:bg-foreground/10 transition-all duration-200 p-8"
onClick={handleClick}
>
<div className="flex items-center justify-center">
<Upload className="h-10 w-10 text-foreground" />
</div>
<div className="space-y-2">
<p className="text-xs text-muted-foreground max-w-sm">
{isProcessing
? `Processing your files (${progress}%)`
: "Drag and drop videos, photos, and audio files here"}
</p>
</div>
{isProcessing && (
<div className="w-full max-w-xs">
<div className="w-full bg-muted/50 rounded-full h-2">
<div
className="bg-primary h-2 rounded-full transition-all duration-300"
style={{ width: `${progress}%` }}
/>
</div>
</div>
)}
</div>
);
}
@@ -1,54 +1,54 @@
"use client";
import { TabBar } from "./tabbar";
import { MediaView } from "./views/media";
import { useMediaPanelStore, Tab } from "./store";
import { TextView } from "./views/text";
import { AudioView } from "./views/audio";
export function MediaPanel() {
const { activeTab } = useMediaPanelStore();
const viewMap: Record<Tab, React.ReactNode> = {
media: <MediaView />,
audio: <AudioView />,
text: <TextView />,
stickers: (
<div className="p-4 text-muted-foreground">
Stickers view coming soon...
</div>
),
effects: (
<div className="p-4 text-muted-foreground">
Effects view coming soon...
</div>
),
transitions: (
<div className="p-4 text-muted-foreground">
Transitions view coming soon...
</div>
),
captions: (
<div className="p-4 text-muted-foreground">
Captions view coming soon...
</div>
),
filters: (
<div className="p-4 text-muted-foreground">
Filters view coming soon...
</div>
),
adjustment: (
<div className="p-4 text-muted-foreground">
Adjustment view coming soon...
</div>
),
};
return (
<div className="h-full flex flex-col bg-panel rounded-sm overflow-hidden">
<TabBar />
<div className="flex-1">{viewMap[activeTab]}</div>
</div>
);
}
"use client";
import { TabBar } from "./tabbar";
import { MediaView } from "./views/media";
import { useMediaPanelStore, Tab } from "./store";
import { TextView } from "./views/text";
import { AudioView } from "./views/audio";
export function MediaPanel() {
const { activeTab } = useMediaPanelStore();
const viewMap: Record<Tab, React.ReactNode> = {
media: <MediaView />,
audio: <AudioView />,
text: <TextView />,
stickers: (
<div className="p-4 text-muted-foreground">
Stickers view coming soon...
</div>
),
effects: (
<div className="p-4 text-muted-foreground">
Effects view coming soon...
</div>
),
transitions: (
<div className="p-4 text-muted-foreground">
Transitions view coming soon...
</div>
),
captions: (
<div className="p-4 text-muted-foreground">
Captions view coming soon...
</div>
),
filters: (
<div className="p-4 text-muted-foreground">
Filters view coming soon...
</div>
),
adjustment: (
<div className="p-4 text-muted-foreground">
Adjustment view coming soon...
</div>
),
};
return (
<div className="h-full flex flex-col bg-panel rounded-sm overflow-hidden">
<TabBar />
<div className="flex-1">{viewMap[activeTab]}</div>
</div>
);
}
@@ -1,73 +1,73 @@
import {
CaptionsIcon,
ArrowLeftRightIcon,
SparklesIcon,
StickerIcon,
MusicIcon,
VideoIcon,
BlendIcon,
SlidersHorizontalIcon,
LucideIcon,
TypeIcon,
} from "lucide-react";
import { create } from "zustand";
export type Tab =
| "media"
| "audio"
| "text"
| "stickers"
| "effects"
| "transitions"
| "captions"
| "filters"
| "adjustment";
export const tabs: { [key in Tab]: { icon: LucideIcon; label: string } } = {
media: {
icon: VideoIcon,
label: "Media",
},
audio: {
icon: MusicIcon,
label: "Audio",
},
text: {
icon: TypeIcon,
label: "Text",
},
stickers: {
icon: StickerIcon,
label: "Stickers",
},
effects: {
icon: SparklesIcon,
label: "Effects",
},
transitions: {
icon: ArrowLeftRightIcon,
label: "Transitions",
},
captions: {
icon: CaptionsIcon,
label: "Captions",
},
filters: {
icon: BlendIcon,
label: "Filters",
},
adjustment: {
icon: SlidersHorizontalIcon,
label: "Adjustment",
},
};
interface MediaPanelStore {
activeTab: Tab;
setActiveTab: (tab: Tab) => void;
}
export const useMediaPanelStore = create<MediaPanelStore>((set) => ({
activeTab: "media",
setActiveTab: (tab) => set({ activeTab: tab }),
}));
import {
CaptionsIcon,
ArrowLeftRightIcon,
SparklesIcon,
StickerIcon,
MusicIcon,
VideoIcon,
BlendIcon,
SlidersHorizontalIcon,
LucideIcon,
TypeIcon,
} from "lucide-react";
import { create } from "zustand";
export type Tab =
| "media"
| "audio"
| "text"
| "stickers"
| "effects"
| "transitions"
| "captions"
| "filters"
| "adjustment";
export const tabs: { [key in Tab]: { icon: LucideIcon; label: string } } = {
media: {
icon: VideoIcon,
label: "Media",
},
audio: {
icon: MusicIcon,
label: "Audio",
},
text: {
icon: TypeIcon,
label: "Text",
},
stickers: {
icon: StickerIcon,
label: "Stickers",
},
effects: {
icon: SparklesIcon,
label: "Effects",
},
transitions: {
icon: ArrowLeftRightIcon,
label: "Transitions",
},
captions: {
icon: CaptionsIcon,
label: "Captions",
},
filters: {
icon: BlendIcon,
label: "Filters",
},
adjustment: {
icon: SlidersHorizontalIcon,
label: "Adjustment",
},
};
interface MediaPanelStore {
activeTab: Tab;
setActiveTab: (tab: Tab) => void;
}
export const useMediaPanelStore = create<MediaPanelStore>((set) => ({
activeTab: "media",
setActiveTab: (tab) => set({ activeTab: tab }),
}));
@@ -1,124 +1,124 @@
"use client";
import { cn } from "@/lib/utils";
import { Tab, tabs, useMediaPanelStore } from "./store";
import { Button } from "@/components/ui/button";
import { ChevronRight, ChevronLeft } from "lucide-react";
import { useRef, useState, useEffect } from "react";
export function TabBar() {
const { activeTab, setActiveTab } = useMediaPanelStore();
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [isAtEnd, setIsAtEnd] = useState(false);
const [isAtStart, setIsAtStart] = useState(true);
const scrollToEnd = () => {
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollTo({
left: scrollContainerRef.current.scrollWidth,
});
setIsAtEnd(true);
setIsAtStart(false);
}
};
const scrollToStart = () => {
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollTo({
left: 0,
});
setIsAtStart(true);
setIsAtEnd(false);
}
};
const checkScrollPosition = () => {
if (scrollContainerRef.current) {
const { scrollLeft, scrollWidth, clientWidth } =
scrollContainerRef.current;
const isAtEndNow = scrollLeft + clientWidth >= scrollWidth - 1;
const isAtStartNow = scrollLeft <= 1;
setIsAtEnd(isAtEndNow);
setIsAtStart(isAtStartNow);
}
};
// We're using useEffect because we need to sync with external DOM scroll events
useEffect(() => {
const container = scrollContainerRef.current;
if (!container) return;
checkScrollPosition();
container.addEventListener("scroll", checkScrollPosition);
const resizeObserver = new ResizeObserver(checkScrollPosition);
resizeObserver.observe(container);
return () => {
container.removeEventListener("scroll", checkScrollPosition);
resizeObserver.disconnect();
};
}, []);
return (
<div className="flex">
<ScrollButton
direction="left"
onClick={scrollToStart}
isVisible={!isAtStart}
/>
<div
ref={scrollContainerRef}
className="h-12 bg-panel-accent px-3 flex justify-start items-center gap-5 overflow-x-auto scrollbar-x-hidden relative w-full"
>
{(Object.keys(tabs) as Tab[]).map((tabKey) => {
const tab = tabs[tabKey];
return (
<div
className={cn(
"flex flex-col gap-0.5 items-center cursor-pointer",
activeTab === tabKey ? "text-primary" : "text-muted-foreground"
)}
onClick={() => setActiveTab(tabKey)}
key={tabKey}
>
<tab.icon className="!size-[1.1rem]" />
<span className="text-[0.65rem]">{tab.label}</span>
</div>
);
})}
</div>
<ScrollButton
direction="right"
onClick={scrollToEnd}
isVisible={!isAtEnd}
/>
</div>
);
}
function ScrollButton({
direction,
onClick,
isVisible,
}: {
direction: "left" | "right";
onClick: () => void;
isVisible: boolean;
}) {
if (!isVisible) return null;
const Icon = direction === "left" ? ChevronLeft : ChevronRight;
return (
<div className="bg-panel-accent w-12 h-full flex items-center justify-center">
<Button
size="icon"
className="rounded-[0.4rem] w-4 h-7 !bg-foreground/10"
onClick={onClick}
>
<Icon className="!size-4 text-foreground" />
</Button>
</div>
);
}
"use client";
import { cn } from "@/lib/utils";
import { Tab, tabs, useMediaPanelStore } from "./store";
import { Button } from "@/components/ui/button";
import { ChevronRight, ChevronLeft } from "lucide-react";
import { useRef, useState, useEffect } from "react";
export function TabBar() {
const { activeTab, setActiveTab } = useMediaPanelStore();
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [isAtEnd, setIsAtEnd] = useState(false);
const [isAtStart, setIsAtStart] = useState(true);
const scrollToEnd = () => {
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollTo({
left: scrollContainerRef.current.scrollWidth,
});
setIsAtEnd(true);
setIsAtStart(false);
}
};
const scrollToStart = () => {
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollTo({
left: 0,
});
setIsAtStart(true);
setIsAtEnd(false);
}
};
const checkScrollPosition = () => {
if (scrollContainerRef.current) {
const { scrollLeft, scrollWidth, clientWidth } =
scrollContainerRef.current;
const isAtEndNow = scrollLeft + clientWidth >= scrollWidth - 1;
const isAtStartNow = scrollLeft <= 1;
setIsAtEnd(isAtEndNow);
setIsAtStart(isAtStartNow);
}
};
// We're using useEffect because we need to sync with external DOM scroll events
useEffect(() => {
const container = scrollContainerRef.current;
if (!container) return;
checkScrollPosition();
container.addEventListener("scroll", checkScrollPosition);
const resizeObserver = new ResizeObserver(checkScrollPosition);
resizeObserver.observe(container);
return () => {
container.removeEventListener("scroll", checkScrollPosition);
resizeObserver.disconnect();
};
}, []);
return (
<div className="flex">
<ScrollButton
direction="left"
onClick={scrollToStart}
isVisible={!isAtStart}
/>
<div
ref={scrollContainerRef}
className="h-12 bg-panel-accent px-3 flex justify-start items-center gap-5 overflow-x-auto scrollbar-x-hidden relative w-full"
>
{(Object.keys(tabs) as Tab[]).map((tabKey) => {
const tab = tabs[tabKey];
return (
<div
className={cn(
"flex flex-col gap-0.5 items-center cursor-pointer",
activeTab === tabKey ? "text-primary" : "text-muted-foreground"
)}
onClick={() => setActiveTab(tabKey)}
key={tabKey}
>
<tab.icon className="!size-[1.1rem]" />
<span className="text-[0.65rem]">{tab.label}</span>
</div>
);
})}
</div>
<ScrollButton
direction="right"
onClick={scrollToEnd}
isVisible={!isAtEnd}
/>
</div>
);
}
function ScrollButton({
direction,
onClick,
isVisible,
}: {
direction: "left" | "right";
onClick: () => void;
isVisible: boolean;
}) {
if (!isVisible) return null;
const Icon = direction === "left" ? ChevronLeft : ChevronRight;
return (
<div className="bg-panel-accent w-12 h-full flex items-center justify-center">
<Button
size="icon"
className="rounded-[0.4rem] w-4 h-7 !bg-foreground/10"
onClick={onClick}
>
<Icon className="!size-4 text-foreground" />
</Button>
</div>
);
}
@@ -1,18 +1,18 @@
"use client";
import { Input } from "@/components/ui/input";
import { useState } from "react";
export function AudioView() {
const [search, setSearch] = useState("");
return (
<div className="h-full flex flex-col gap-2 p-4">
<Input
placeholder="Search songs and artists"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<div className="flex flex-col gap-2"></div>
</div>
);
}
"use client";
import { Input } from "@/components/ui/input";
import { useState } from "react";
export function AudioView() {
const [search, setSearch] = useState("");
return (
<div className="h-full flex flex-col gap-2 p-4">
<Input
placeholder="Search songs and artists"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<div className="flex flex-col gap-2" />
</div>
);
}
@@ -1,298 +1,298 @@
"use client";
import { useDragDrop } from "@/hooks/use-drag-drop";
import { processMediaFiles } from "@/lib/media-processing";
import { useMediaStore, type MediaItem } from "@/stores/media-store";
import { Image, Loader2, Music, Plus, Video } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { MediaDragOverlay } from "@/components/editor/media-panel/drag-overlay";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { DraggableMediaItem } from "@/components/ui/draggable-item";
import { useProjectStore } from "@/stores/project-store";
import { useTimelineStore } from "@/stores/timeline-store";
export function MediaView() {
const { mediaItems, addMediaItem, removeMediaItem } = useMediaStore();
const { activeProject } = useProjectStore();
const fileInputRef = useRef<HTMLInputElement>(null);
const [isProcessing, setIsProcessing] = useState(false);
const [progress, setProgress] = useState(0);
const [searchQuery, setSearchQuery] = useState("");
const [mediaFilter, setMediaFilter] = useState("all");
const processFiles = async (files: FileList | File[]) => {
if (!files || files.length === 0) return;
if (!activeProject) {
toast.error("No active project");
return;
}
setIsProcessing(true);
setProgress(0);
try {
// Process files (extract metadata, generate thumbnails, etc.)
const processedItems = await processMediaFiles(files, (p) =>
setProgress(p)
);
// Add each processed media item to the store
for (const item of processedItems) {
await addMediaItem(activeProject.id, item);
}
} catch (error) {
// Show error toast if processing fails
console.error("Error processing files:", error);
toast.error("Failed to process files");
} finally {
setIsProcessing(false);
setProgress(0);
}
};
const { isDragOver, dragProps } = useDragDrop({
// When files are dropped, process them
onDrop: processFiles,
});
const handleFileSelect = () => fileInputRef.current?.click(); // Open file picker
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
// When files are selected via file picker, process them
if (e.target.files) processFiles(e.target.files);
e.target.value = ""; // Reset input
};
const handleRemove = async (e: React.MouseEvent, id: string) => {
// Remove a media item from the store
e.stopPropagation();
if (!activeProject) {
toast.error("No active project");
return;
}
// Media store now handles cascade deletion automatically
await removeMediaItem(activeProject.id, id);
};
const formatDuration = (duration: number) => {
// Format seconds as mm:ss
const min = Math.floor(duration / 60);
const sec = Math.floor(duration % 60);
return `${min}:${sec.toString().padStart(2, "0")}`;
};
const [filteredMediaItems, setFilteredMediaItems] = useState(mediaItems);
useEffect(() => {
const filtered = mediaItems.filter((item) => {
if (mediaFilter && mediaFilter !== "all" && item.type !== mediaFilter) {
return false;
}
if (
searchQuery &&
!item.name.toLowerCase().includes(searchQuery.toLowerCase())
) {
return false;
}
return true;
});
setFilteredMediaItems(filtered);
}, [mediaItems, mediaFilter, searchQuery]);
const renderPreview = (item: MediaItem) => {
// Render a preview for each media type (image, video, audio, unknown)
if (item.type === "image") {
return (
<div className="w-full h-full flex items-center justify-center">
<img
src={item.url}
alt={item.name}
className="max-w-full max-h-full object-contain"
loading="lazy"
/>
</div>
);
}
if (item.type === "video") {
if (item.thumbnailUrl) {
return (
<div className="relative w-full h-full">
<img
src={item.thumbnailUrl}
alt={item.name}
className="w-full h-full object-cover rounded"
loading="lazy"
/>
<div className="absolute inset-0 flex items-center justify-center bg-black/20 rounded">
<Video className="h-6 w-6 text-white drop-shadow-md" />
</div>
{item.duration && (
<div className="absolute bottom-1 right-1 bg-black/70 text-white text-xs px-1 rounded">
{formatDuration(item.duration)}
</div>
)}
</div>
);
}
return (
<div className="w-full h-full bg-muted/30 flex flex-col items-center justify-center text-muted-foreground rounded">
<Video className="h-6 w-6 mb-1" />
<span className="text-xs">Video</span>
{item.duration && (
<span className="text-xs opacity-70">
{formatDuration(item.duration)}
</span>
)}
</div>
);
}
if (item.type === "audio") {
return (
<div className="w-full h-full bg-gradient-to-br from-green-500/20 to-emerald-500/20 flex flex-col items-center justify-center text-muted-foreground rounded border border-green-500/20">
<Music className="h-6 w-6 mb-1" />
<span className="text-xs">Audio</span>
{item.duration && (
<span className="text-xs opacity-70">
{formatDuration(item.duration)}
</span>
)}
</div>
);
}
return (
<div className="w-full h-full bg-muted/30 flex flex-col items-center justify-center text-muted-foreground rounded">
<Image className="h-6 w-6" />
<span className="text-xs mt-1">Unknown</span>
</div>
);
};
return (
<>
{/* Hidden file input for uploading media */}
<input
ref={fileInputRef}
type="file"
accept="image/*,video/*,audio/*"
multiple
className="hidden"
onChange={handleFileChange}
/>
<div
className={`h-full flex flex-col gap-1 transition-colors relative ${isDragOver ? "bg-accent/30" : ""}`}
{...dragProps}
>
<div className="p-3 pb-2">
{/* Search and filter controls */}
<div className="flex gap-2">
<Select value={mediaFilter} onValueChange={setMediaFilter}>
<SelectTrigger className="w-[80px] h-9 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All</SelectItem>
<SelectItem value="video">Video</SelectItem>
<SelectItem value="audio">Audio</SelectItem>
<SelectItem value="image">Image</SelectItem>
</SelectContent>
</Select>
<Input
type="text"
placeholder="Search media..."
className="min-w-[60px] flex-1 h-9 text-xs"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
<Button
variant="outline"
size="lg"
onClick={handleFileSelect}
disabled={isProcessing}
className="flex-none bg-transparent min-w-[30px] whitespace-nowrap overflow-hidden px-2 justify-center items-center h-9"
>
{isProcessing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Plus className="h-4 w-4" />
)}
</Button>
</div>
</div>
<div className="flex-1 overflow-y-auto p-3 pt-0">
{isDragOver || filteredMediaItems.length === 0 ? (
<MediaDragOverlay
isVisible={true}
isProcessing={isProcessing}
progress={progress}
onClick={handleFileSelect}
isEmptyState={filteredMediaItems.length === 0 && !isDragOver}
/>
) : (
<div
className="grid gap-2"
style={{
gridTemplateColumns: "repeat(auto-fill, 160px)",
}}
>
{/* Render each media item as a draggable button */}
{filteredMediaItems.map((item) => (
<ContextMenu key={item.id}>
<ContextMenuTrigger>
<DraggableMediaItem
name={item.name}
preview={renderPreview(item)}
dragData={{
id: item.id,
type: item.type,
name: item.name,
}}
showPlusOnDrag={false}
onAddToTimeline={(currentTime) =>
useTimelineStore
.getState()
.addMediaAtTime(item, currentTime)
}
rounded={false}
/>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem>Export clips</ContextMenuItem>
<ContextMenuItem
variant="destructive"
onClick={(e) => handleRemove(e, item.id)}
>
Delete
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
))}
</div>
)}
</div>
</div>
</>
);
}
"use client";
import { useDragDrop } from "@/hooks/use-drag-drop";
import { processMediaFiles } from "@/lib/media-processing";
import { useMediaStore, type MediaItem } from "@/stores/media-store";
import { Image, Loader2, Music, Plus, Video } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { MediaDragOverlay } from "@/components/editor/media-panel/drag-overlay";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { DraggableMediaItem } from "@/components/ui/draggable-item";
import { useProjectStore } from "@/stores/project-store";
import { useTimelineStore } from "@/stores/timeline-store";
export function MediaView() {
const { mediaItems, addMediaItem, removeMediaItem } = useMediaStore();
const { activeProject } = useProjectStore();
const fileInputRef = useRef<HTMLInputElement>(null);
const [isProcessing, setIsProcessing] = useState(false);
const [progress, setProgress] = useState(0);
const [searchQuery, setSearchQuery] = useState("");
const [mediaFilter, setMediaFilter] = useState("all");
const processFiles = async (files: FileList | File[]) => {
if (!files || files.length === 0) return;
if (!activeProject) {
toast.error("No active project");
return;
}
setIsProcessing(true);
setProgress(0);
try {
// Process files (extract metadata, generate thumbnails, etc.)
const processedItems = await processMediaFiles(files, (p) =>
setProgress(p)
);
// Add each processed media item to the store
for (const item of processedItems) {
await addMediaItem(activeProject.id, item);
}
} catch (error) {
// Show error toast if processing fails
console.error("Error processing files:", error);
toast.error("Failed to process files");
} finally {
setIsProcessing(false);
setProgress(0);
}
};
const { isDragOver, dragProps } = useDragDrop({
// When files are dropped, process them
onDrop: processFiles,
});
const handleFileSelect = () => fileInputRef.current?.click(); // Open file picker
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
// When files are selected via file picker, process them
if (e.target.files) processFiles(e.target.files);
e.target.value = ""; // Reset input
};
const handleRemove = async (e: React.MouseEvent, id: string) => {
// Remove a media item from the store
e.stopPropagation();
if (!activeProject) {
toast.error("No active project");
return;
}
// Media store now handles cascade deletion automatically
await removeMediaItem(activeProject.id, id);
};
const formatDuration = (duration: number) => {
// Format seconds as mm:ss
const min = Math.floor(duration / 60);
const sec = Math.floor(duration % 60);
return `${min}:${sec.toString().padStart(2, "0")}`;
};
const [filteredMediaItems, setFilteredMediaItems] = useState(mediaItems);
useEffect(() => {
const filtered = mediaItems.filter((item) => {
if (mediaFilter && mediaFilter !== "all" && item.type !== mediaFilter) {
return false;
}
if (
searchQuery &&
!item.name.toLowerCase().includes(searchQuery.toLowerCase())
) {
return false;
}
return true;
});
setFilteredMediaItems(filtered);
}, [mediaItems, mediaFilter, searchQuery]);
const renderPreview = (item: MediaItem) => {
// Render a preview for each media type (image, video, audio, unknown)
if (item.type === "image") {
return (
<div className="w-full h-full flex items-center justify-center">
<img
src={item.url}
alt={item.name}
className="max-w-full max-h-full object-contain"
loading="lazy"
/>
</div>
);
}
if (item.type === "video") {
if (item.thumbnailUrl) {
return (
<div className="relative w-full h-full">
<img
src={item.thumbnailUrl}
alt={item.name}
className="w-full h-full object-cover rounded"
loading="lazy"
/>
<div className="absolute inset-0 flex items-center justify-center bg-black/20 rounded">
<Video className="h-6 w-6 text-white drop-shadow-md" />
</div>
{item.duration && (
<div className="absolute bottom-1 right-1 bg-black/70 text-white text-xs px-1 rounded">
{formatDuration(item.duration)}
</div>
)}
</div>
);
}
return (
<div className="w-full h-full bg-muted/30 flex flex-col items-center justify-center text-muted-foreground rounded">
<Video className="h-6 w-6 mb-1" />
<span className="text-xs">Video</span>
{item.duration && (
<span className="text-xs opacity-70">
{formatDuration(item.duration)}
</span>
)}
</div>
);
}
if (item.type === "audio") {
return (
<div className="w-full h-full bg-gradient-to-br from-green-500/20 to-emerald-500/20 flex flex-col items-center justify-center text-muted-foreground rounded border border-green-500/20">
<Music className="h-6 w-6 mb-1" />
<span className="text-xs">Audio</span>
{item.duration && (
<span className="text-xs opacity-70">
{formatDuration(item.duration)}
</span>
)}
</div>
);
}
return (
<div className="w-full h-full bg-muted/30 flex flex-col items-center justify-center text-muted-foreground rounded">
<Image className="h-6 w-6" />
<span className="text-xs mt-1">Unknown</span>
</div>
);
};
return (
<>
{/* Hidden file input for uploading media */}
<input
ref={fileInputRef}
type="file"
accept="image/*,video/*,audio/*"
multiple
className="hidden"
onChange={handleFileChange}
/>
<div
className={`h-full flex flex-col gap-1 transition-colors relative ${isDragOver ? "bg-accent/30" : ""}`}
{...dragProps}
>
<div className="p-3 pb-2">
{/* Search and filter controls */}
<div className="flex gap-2">
<Select value={mediaFilter} onValueChange={setMediaFilter}>
<SelectTrigger className="w-[80px] h-9 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All</SelectItem>
<SelectItem value="video">Video</SelectItem>
<SelectItem value="audio">Audio</SelectItem>
<SelectItem value="image">Image</SelectItem>
</SelectContent>
</Select>
<Input
type="text"
placeholder="Search media..."
className="min-w-[60px] flex-1 h-9 text-xs"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
<Button
variant="outline"
size="lg"
onClick={handleFileSelect}
disabled={isProcessing}
className="flex-none bg-transparent min-w-[30px] whitespace-nowrap overflow-hidden px-2 justify-center items-center h-9"
>
{isProcessing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Plus className="h-4 w-4" />
)}
</Button>
</div>
</div>
<div className="flex-1 overflow-y-auto p-3 pt-0">
{isDragOver || filteredMediaItems.length === 0 ? (
<MediaDragOverlay
isVisible={true}
isProcessing={isProcessing}
progress={progress}
onClick={handleFileSelect}
isEmptyState={filteredMediaItems.length === 0 && !isDragOver}
/>
) : (
<div
className="grid gap-2"
style={{
gridTemplateColumns: "repeat(auto-fill, 160px)",
}}
>
{/* Render each media item as a draggable button */}
{filteredMediaItems.map((item) => (
<ContextMenu key={item.id}>
<ContextMenuTrigger>
<DraggableMediaItem
name={item.name}
preview={renderPreview(item)}
dragData={{
id: item.id,
type: item.type,
name: item.name,
}}
showPlusOnDrag={false}
onAddToTimeline={(currentTime) =>
useTimelineStore
.getState()
.addMediaAtTime(item, currentTime)
}
rounded={false}
/>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem>Export clips</ContextMenuItem>
<ContextMenuItem
variant="destructive"
onClick={(e) => handleRemove(e, item.id)}
>
Delete
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
))}
</div>
)}
</div>
</div>
</>
);
}
@@ -1,53 +1,53 @@
import { DraggableMediaItem } from "@/components/ui/draggable-item";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { useTimelineStore } from "@/stores/timeline-store";
import { type TextElement } from "@/types/timeline";
let textData: TextElement = {
id: "default-text",
type: "text",
name: "Default text",
content: "Default text",
fontSize: 48,
fontFamily: "Arial",
color: "#ffffff",
backgroundColor: "transparent",
textAlign: "center" as const,
fontWeight: "normal" as const,
fontStyle: "normal" as const,
textDecoration: "none" as const,
x: 0,
y: 0,
rotation: 0,
opacity: 1,
duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
startTime: 0,
trimStart: 0,
trimEnd: 0,
};
export function TextView() {
return (
<div className="p-4">
<DraggableMediaItem
name="Default text"
preview={
<div className="flex items-center justify-center w-full h-full bg-accent rounded">
<span className="text-xs select-none">Default text</span>
</div>
}
dragData={{
id: textData.id,
type: textData.type,
name: textData.name,
content: textData.content,
}}
aspectRatio={1}
onAddToTimeline={(currentTime) =>
useTimelineStore.getState().addTextAtTime(textData, currentTime)
}
showLabel={false}
/>
</div>
);
}
import { DraggableMediaItem } from "@/components/ui/draggable-item";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { useTimelineStore } from "@/stores/timeline-store";
import { type TextElement } from "@/types/timeline";
const textData: TextElement = {
id: "default-text",
type: "text",
name: "Default text",
content: "Default text",
fontSize: 48,
fontFamily: "Arial",
color: "#ffffff",
backgroundColor: "transparent",
textAlign: "center" as const,
fontWeight: "normal" as const,
fontStyle: "normal" as const,
textDecoration: "none" as const,
x: 0,
y: 0,
rotation: 0,
opacity: 1,
duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
startTime: 0,
trimStart: 0,
trimEnd: 0,
};
export function TextView() {
return (
<div className="p-4">
<DraggableMediaItem
name="Default text"
preview={
<div className="flex items-center justify-center w-full h-full bg-accent rounded">
<span className="text-xs select-none">Default text</span>
</div>
}
dragData={{
id: textData.id,
type: textData.type,
name: textData.name,
content: textData.content,
}}
aspectRatio={1}
onAddToTimeline={(currentTime) =>
useTimelineStore.getState().addTextAtTime(textData, currentTime)
}
showLabel={false}
/>
</div>
);
}
@@ -368,7 +368,7 @@ export function PreviewPanel() {
ref={containerRef}
className="flex-1 flex flex-col items-center justify-center p-3 min-h-0 min-w-0"
>
<div className="flex-1"></div>
<div className="flex-1" />
{hasAnyElements ? (
<div
ref={previewRef}
@@ -402,7 +402,7 @@ export function PreviewPanel() {
</div>
) : null}
<div className="flex-1"></div>
<div className="flex-1" />
<PreviewToolbar
hasAnyElements={hasAnyElements}
@@ -1,5 +1,5 @@
import { MediaElement } from "@/types/timeline";
export function AudioProperties({ element }: { element: MediaElement }) {
return <div className="space-y-4 p-5">Audio properties</div>;
}
import { MediaElement } from "@/types/timeline";
export function AudioProperties({ element }: { element: MediaElement }) {
return <div className="space-y-4 p-5">Audio properties</div>;
}
@@ -1,5 +1,5 @@
import { MediaElement } from "@/types/timeline";
export function MediaProperties({ element }: { element: MediaElement }) {
return <div className="space-y-4 p-5">Media properties</div>;
}
import { MediaElement } from "@/types/timeline";
export function MediaProperties({ element }: { element: MediaElement }) {
return <div className="space-y-4 p-5">Media properties</div>;
}
@@ -1,47 +1,47 @@
import { cn } from "@/lib/utils";
interface PropertyItemProps {
direction?: "row" | "column";
children: React.ReactNode;
className?: string;
}
export function PropertyItem({
direction = "row",
children,
className,
}: PropertyItemProps) {
return (
<div
className={cn(
"flex gap-2",
direction === "row"
? "items-center justify-between gap-6"
: "flex-col gap-1",
className
)}
>
{children}
</div>
);
}
export function PropertyItemLabel({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return <label className={cn("text-xs", className)}>{children}</label>;
}
export function PropertyItemValue({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return <div className={cn("flex-1", className)}>{children}</div>;
}
import { cn } from "@/lib/utils";
interface PropertyItemProps {
direction?: "row" | "column";
children: React.ReactNode;
className?: string;
}
export function PropertyItem({
direction = "row",
children,
className,
}: PropertyItemProps) {
return (
<div
className={cn(
"flex gap-2",
direction === "row"
? "items-center justify-between gap-6"
: "flex-col gap-1",
className
)}
>
{children}
</div>
);
}
export function PropertyItemLabel({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return <label className={cn("text-xs", className)}>{children}</label>;
}
export function PropertyItemValue({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return <div className={cn("flex-1", className)}>{children}</div>;
}
@@ -1,76 +1,76 @@
import { Textarea } from "@/components/ui/textarea";
import { FontPicker } from "@/components/ui/font-picker";
import { FontFamily } from "@/constants/font-constants";
import { TextElement } from "@/types/timeline";
import { useTimelineStore } from "@/stores/timeline-store";
import { Slider } from "@/components/ui/slider";
import { Input } from "@/components/ui/input";
import {
PropertyItem,
PropertyItemLabel,
PropertyItemValue,
} from "./property-item";
export function TextProperties({
element,
trackId,
}: {
element: TextElement;
trackId: string;
}) {
const { updateTextElement } = useTimelineStore();
return (
<div className="space-y-6 p-5">
<Textarea
placeholder="Name"
defaultValue={element.content}
className="min-h-[4.5rem] resize-none bg-background/50"
onChange={(e) =>
updateTextElement(trackId, element.id, { content: e.target.value })
}
/>
<PropertyItem direction="row">
<PropertyItemLabel>Font</PropertyItemLabel>
<PropertyItemValue>
<FontPicker
defaultValue={element.fontFamily}
onValueChange={(value: FontFamily) =>
updateTextElement(trackId, element.id, { fontFamily: value })
}
/>
</PropertyItemValue>
</PropertyItem>
<PropertyItem direction="column">
<PropertyItemLabel>Font size</PropertyItemLabel>
<PropertyItemValue>
<div className="flex items-center gap-2">
<Slider
defaultValue={[element.fontSize]}
min={8}
max={300}
step={1}
onValueChange={([value]) =>
updateTextElement(trackId, element.id, { fontSize: value })
}
className="w-full"
/>
<Input
type="number"
value={element.fontSize}
onChange={(e) =>
updateTextElement(trackId, element.id, {
fontSize: parseInt(e.target.value),
})
}
className="w-12 !text-xs h-7 rounded-sm text-center
[appearance:textfield]
[&::-webkit-outer-spin-button]:appearance-none
[&::-webkit-inner-spin-button]:appearance-none"
/>
</div>
</PropertyItemValue>
</PropertyItem>
</div>
);
}
import { Textarea } from "@/components/ui/textarea";
import { FontPicker } from "@/components/ui/font-picker";
import { FontFamily } from "@/constants/font-constants";
import { TextElement } from "@/types/timeline";
import { useTimelineStore } from "@/stores/timeline-store";
import { Slider } from "@/components/ui/slider";
import { Input } from "@/components/ui/input";
import {
PropertyItem,
PropertyItemLabel,
PropertyItemValue,
} from "./property-item";
export function TextProperties({
element,
trackId,
}: {
element: TextElement;
trackId: string;
}) {
const { updateTextElement } = useTimelineStore();
return (
<div className="space-y-6 p-5">
<Textarea
placeholder="Name"
defaultValue={element.content}
className="min-h-[4.5rem] resize-none bg-background/50"
onChange={(e) =>
updateTextElement(trackId, element.id, { content: e.target.value })
}
/>
<PropertyItem direction="row">
<PropertyItemLabel>Font</PropertyItemLabel>
<PropertyItemValue>
<FontPicker
defaultValue={element.fontFamily}
onValueChange={(value: FontFamily) =>
updateTextElement(trackId, element.id, { fontFamily: value })
}
/>
</PropertyItemValue>
</PropertyItem>
<PropertyItem direction="column">
<PropertyItemLabel>Font size</PropertyItemLabel>
<PropertyItemValue>
<div className="flex items-center gap-2">
<Slider
defaultValue={[element.fontSize]}
min={8}
max={300}
step={1}
onValueChange={([value]) =>
updateTextElement(trackId, element.id, { fontSize: value })
}
className="w-full"
/>
<Input
type="number"
value={element.fontSize}
onChange={(e) =>
updateTextElement(trackId, element.id, {
fontSize: parseInt(e.target.value),
})
}
className="w-12 !text-xs h-7 rounded-sm text-center
[appearance:textfield]
[&::-webkit-outer-spin-button]:appearance-none
[&::-webkit-inner-spin-button]:appearance-none"
/>
</div>
</PropertyItemValue>
</PropertyItem>
</div>
);
}
@@ -1,58 +1,58 @@
"use client";
import { useEffect, useRef } from "react";
interface SelectionBoxProps {
startPos: { x: number; y: number } | null;
currentPos: { x: number; y: number } | null;
containerRef: React.RefObject<HTMLElement>;
isActive: boolean;
}
export function SelectionBox({
startPos,
currentPos,
containerRef,
isActive,
}: SelectionBoxProps) {
const selectionBoxRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!isActive || !startPos || !currentPos || !containerRef.current) return;
const container = containerRef.current;
const containerRect = container.getBoundingClientRect();
// Calculate relative positions within the container
const startX = startPos.x - containerRect.left;
const startY = startPos.y - containerRect.top;
const currentX = currentPos.x - containerRect.left;
const currentY = currentPos.y - containerRect.top;
// Calculate the selection rectangle bounds
const left = Math.min(startX, currentX);
const top = Math.min(startY, currentY);
const width = Math.abs(currentX - startX);
const height = Math.abs(currentY - startY);
// Update the selection box position and size
if (selectionBoxRef.current) {
selectionBoxRef.current.style.left = `${left}px`;
selectionBoxRef.current.style.top = `${top}px`;
selectionBoxRef.current.style.width = `${width}px`;
selectionBoxRef.current.style.height = `${height}px`;
}
}, [startPos, currentPos, isActive, containerRef]);
if (!isActive || !startPos || !currentPos) return null;
return (
<div
ref={selectionBoxRef}
className="absolute pointer-events-none z-50"
style={{
backgroundColor: "hsl(var(--foreground) / 0.1)",
}}
/>
);
}
"use client";
import { useEffect, useRef } from "react";
interface SelectionBoxProps {
startPos: { x: number; y: number } | null;
currentPos: { x: number; y: number } | null;
containerRef: React.RefObject<HTMLElement>;
isActive: boolean;
}
export function SelectionBox({
startPos,
currentPos,
containerRef,
isActive,
}: SelectionBoxProps) {
const selectionBoxRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!isActive || !startPos || !currentPos || !containerRef.current) return;
const container = containerRef.current;
const containerRect = container.getBoundingClientRect();
// Calculate relative positions within the container
const startX = startPos.x - containerRect.left;
const startY = startPos.y - containerRect.top;
const currentX = currentPos.x - containerRect.left;
const currentY = currentPos.y - containerRect.top;
// Calculate the selection rectangle bounds
const left = Math.min(startX, currentX);
const top = Math.min(startY, currentY);
const width = Math.abs(currentX - startX);
const height = Math.abs(currentY - startY);
// Update the selection box position and size
if (selectionBoxRef.current) {
selectionBoxRef.current.style.left = `${left}px`;
selectionBoxRef.current.style.top = `${top}px`;
selectionBoxRef.current.style.width = `${width}px`;
selectionBoxRef.current.style.height = `${height}px`;
}
}, [startPos, currentPos, isActive, containerRef]);
if (!isActive || !startPos || !currentPos) return null;
return (
<div
ref={selectionBoxRef}
className="absolute pointer-events-none z-50"
style={{
backgroundColor: "hsl(var(--foreground) / 0.1)",
}}
/>
);
}
@@ -48,7 +48,7 @@ export function SnapIndicator({
width: "2px",
}}
>
<div className={`w-0.5 h-full bg-primary/40 opacity-80`} />
<div className={"w-0.5 h-full bg-primary/40 opacity-80"} />
</div>
);
}
@@ -43,4 +43,4 @@ export function SpeedControl() {
</div>
</div>
);
}
}
@@ -278,7 +278,7 @@ export function Timeline() {
Math.min(
duration,
(mouseX + scrollLeft) /
(TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel)
(TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel)
)
);
@@ -501,7 +501,9 @@ export function Timeline() {
return (
<div
className={`h-full flex flex-col transition-colors duration-200 relative bg-panel rounded-sm overflow-hidden`}
className={
"h-full flex flex-col transition-colors duration-200 relative bg-panel rounded-sm overflow-hidden"
}
{...dragProps}
onMouseEnter={() => setIsInTimeline(true)}
onMouseLeave={() => setIsInTimeline(false)}
@@ -598,19 +600,21 @@ export function Timeline() {
return (
<div
key={i}
className={`absolute top-0 bottom-0 ${isMainMarker
className={`absolute top-0 bottom-0 ${
isMainMarker
? "border-l border-muted-foreground/40"
: "border-l border-muted-foreground/20"
}`}
}`}
style={{
left: `${time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel}px`,
}}
>
<span
className={`absolute top-1 left-1 text-[0.6rem] ${isMainMarker
className={`absolute top-1 left-1 text-[0.6rem] ${
isMainMarker
? "text-muted-foreground font-medium"
: "text-muted-foreground/70"
}`}
}`}
>
{(() => {
const formatTime = (seconds: number) => {
@@ -620,13 +624,14 @@ export function Timeline() {
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, "0")}:${Math.floor(secs).toString().padStart(2, "0")}`;
} else if (minutes > 0) {
return `${minutes}:${Math.floor(secs).toString().padStart(2, "0")}`;
} else if (interval >= 1) {
return `${Math.floor(secs)}s`;
} else {
return `${secs.toFixed(1)}s`;
}
if (minutes > 0) {
return `${minutes}:${Math.floor(secs).toString().padStart(2, "0")}`;
}
if (interval >= 1) {
return `${Math.floor(secs)}s`;
}
return `${secs.toFixed(1)}s`;
};
return formatTime(time);
})()}
@@ -709,7 +714,7 @@ export function Timeline() {
}}
>
{tracks.length === 0 ? (
<div></div>
<div />
) : (
<>
{tracks.map((track, index) => (
@@ -1,116 +1,116 @@
"use client";
import { useRef } from "react";
import { TimelineTrack } from "@/types/timeline";
import {
TIMELINE_CONSTANTS,
getTotalTracksHeight,
} from "@/constants/timeline-constants";
import { useTimelinePlayhead } from "@/hooks/use-timeline-playhead";
interface TimelinePlayheadProps {
currentTime: number;
duration: number;
zoomLevel: number;
tracks: TimelineTrack[];
seek: (time: number) => void;
rulerRef: React.RefObject<HTMLDivElement>;
rulerScrollRef: React.RefObject<HTMLDivElement>;
tracksScrollRef: React.RefObject<HTMLDivElement>;
trackLabelsRef?: React.RefObject<HTMLDivElement>;
timelineRef: React.RefObject<HTMLDivElement>;
playheadRef?: React.RefObject<HTMLDivElement>;
isSnappingToPlayhead?: boolean;
}
export function TimelinePlayhead({
currentTime,
duration,
zoomLevel,
tracks,
seek,
rulerRef,
rulerScrollRef,
tracksScrollRef,
trackLabelsRef,
timelineRef,
playheadRef: externalPlayheadRef,
isSnappingToPlayhead = false,
}: TimelinePlayheadProps) {
const internalPlayheadRef = useRef<HTMLDivElement>(null);
const playheadRef = externalPlayheadRef || internalPlayheadRef;
const { playheadPosition, handlePlayheadMouseDown } = useTimelinePlayhead({
currentTime,
duration,
zoomLevel,
seek,
rulerRef,
rulerScrollRef,
tracksScrollRef,
playheadRef,
});
// Use timeline container height minus a few pixels for breathing room
const timelineContainerHeight = timelineRef.current?.offsetHeight || 400;
const totalHeight = timelineContainerHeight - 8; // 8px padding from edges
// Get dynamic track labels width, fallback to 0 if no tracks or no ref
const trackLabelsWidth =
tracks.length > 0 && trackLabelsRef?.current
? trackLabelsRef.current.offsetWidth
: 0;
const leftPosition =
trackLabelsWidth +
playheadPosition * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
return (
<div
ref={playheadRef}
className="absolute pointer-events-auto z-[150]"
style={{
left: `${leftPosition}px`,
top: 0,
height: `${totalHeight}px`,
width: "2px", // Slightly wider for better click target
}}
onMouseDown={handlePlayheadMouseDown}
>
{/* The playhead line spanning full height */}
<div
className={`absolute left-0 w-0.5 cursor-col-resize h-full ${isSnappingToPlayhead ? "bg-primary" : "bg-foreground"}`}
/>
{/* Playhead dot indicator at the top (in ruler area) */}
<div
className={`absolute top-1 left-1/2 transform -translate-x-1/2 w-3 h-3 rounded-full border-2 shadow-sm ${isSnappingToPlayhead ? "bg-primary border-primary" : "bg-foreground border-foreground"}`}
/>
</div>
);
}
// Also export a hook for getting ruler handlers
export function useTimelinePlayheadRuler({
currentTime,
duration,
zoomLevel,
seek,
rulerRef,
rulerScrollRef,
tracksScrollRef,
playheadRef,
}: Omit<TimelinePlayheadProps, "tracks" | "trackLabelsRef" | "timelineRef">) {
const { handleRulerMouseDown, isDraggingRuler } = useTimelinePlayhead({
currentTime,
duration,
zoomLevel,
seek,
rulerRef,
rulerScrollRef,
tracksScrollRef,
playheadRef,
});
return { handleRulerMouseDown, isDraggingRuler };
}
export { TimelinePlayhead as default };
"use client";
import { useRef } from "react";
import { TimelineTrack } from "@/types/timeline";
import {
TIMELINE_CONSTANTS,
getTotalTracksHeight,
} from "@/constants/timeline-constants";
import { useTimelinePlayhead } from "@/hooks/use-timeline-playhead";
interface TimelinePlayheadProps {
currentTime: number;
duration: number;
zoomLevel: number;
tracks: TimelineTrack[];
seek: (time: number) => void;
rulerRef: React.RefObject<HTMLDivElement>;
rulerScrollRef: React.RefObject<HTMLDivElement>;
tracksScrollRef: React.RefObject<HTMLDivElement>;
trackLabelsRef?: React.RefObject<HTMLDivElement>;
timelineRef: React.RefObject<HTMLDivElement>;
playheadRef?: React.RefObject<HTMLDivElement>;
isSnappingToPlayhead?: boolean;
}
export function TimelinePlayhead({
currentTime,
duration,
zoomLevel,
tracks,
seek,
rulerRef,
rulerScrollRef,
tracksScrollRef,
trackLabelsRef,
timelineRef,
playheadRef: externalPlayheadRef,
isSnappingToPlayhead = false,
}: TimelinePlayheadProps) {
const internalPlayheadRef = useRef<HTMLDivElement>(null);
const playheadRef = externalPlayheadRef || internalPlayheadRef;
const { playheadPosition, handlePlayheadMouseDown } = useTimelinePlayhead({
currentTime,
duration,
zoomLevel,
seek,
rulerRef,
rulerScrollRef,
tracksScrollRef,
playheadRef,
});
// Use timeline container height minus a few pixels for breathing room
const timelineContainerHeight = timelineRef.current?.offsetHeight || 400;
const totalHeight = timelineContainerHeight - 8; // 8px padding from edges
// Get dynamic track labels width, fallback to 0 if no tracks or no ref
const trackLabelsWidth =
tracks.length > 0 && trackLabelsRef?.current
? trackLabelsRef.current.offsetWidth
: 0;
const leftPosition =
trackLabelsWidth +
playheadPosition * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
return (
<div
ref={playheadRef}
className="absolute pointer-events-auto z-[150]"
style={{
left: `${leftPosition}px`,
top: 0,
height: `${totalHeight}px`,
width: "2px", // Slightly wider for better click target
}}
onMouseDown={handlePlayheadMouseDown}
>
{/* The playhead line spanning full height */}
<div
className={`absolute left-0 w-0.5 cursor-col-resize h-full ${isSnappingToPlayhead ? "bg-primary" : "bg-foreground"}`}
/>
{/* Playhead dot indicator at the top (in ruler area) */}
<div
className={`absolute top-1 left-1/2 transform -translate-x-1/2 w-3 h-3 rounded-full border-2 shadow-sm ${isSnappingToPlayhead ? "bg-primary border-primary" : "bg-foreground border-foreground"}`}
/>
</div>
);
}
// Also export a hook for getting ruler handlers
export function useTimelinePlayheadRuler({
currentTime,
duration,
zoomLevel,
seek,
rulerRef,
rulerScrollRef,
tracksScrollRef,
playheadRef,
}: Omit<TimelinePlayheadProps, "tracks" | "trackLabelsRef" | "timelineRef">) {
const { handleRulerMouseDown, isDraggingRuler } = useTimelinePlayhead({
currentTime,
duration,
zoomLevel,
seek,
rulerRef,
rulerScrollRef,
tracksScrollRef,
playheadRef,
});
return { handleRulerMouseDown, isDraggingRuler };
}
export { TimelinePlayhead as default };
File diff suppressed because it is too large Load Diff
+41 -41
View File
@@ -1,41 +1,41 @@
"use client";
import { cn } from "@/lib/utils";
import { ReactNode } from "react";
interface HeaderBaseProps {
leftContent?: ReactNode;
centerContent?: ReactNode;
rightContent?: ReactNode;
className?: string;
children?: ReactNode;
}
export function HeaderBase({
leftContent,
centerContent,
rightContent,
className,
children,
}: HeaderBaseProps) {
// If children is provided, render it directly without the grid layout
if (children) {
return (
<header className={cn("px-6 h-16 flex items-center", className)}>
{children}
</header>
);
}
return (
<header
className={cn("px-6 h-14 flex justify-between items-center", className)}
>
{leftContent && <div className="flex items-center">{leftContent}</div>}
{centerContent && (
<div className="flex items-center">{centerContent}</div>
)}
{rightContent && <div className="flex items-center">{rightContent}</div>}
</header>
);
}
"use client";
import { cn } from "@/lib/utils";
import { ReactNode } from "react";
interface HeaderBaseProps {
leftContent?: ReactNode;
centerContent?: ReactNode;
rightContent?: ReactNode;
className?: string;
children?: ReactNode;
}
export function HeaderBase({
leftContent,
centerContent,
rightContent,
className,
children,
}: HeaderBaseProps) {
// If children is provided, render it directly without the grid layout
if (children) {
return (
<header className={cn("px-6 h-16 flex items-center", className)}>
{children}
</header>
);
}
return (
<header
className={cn("px-6 h-14 flex justify-between items-center", className)}
>
{leftContent && <div className="flex items-center">{leftContent}</div>}
{centerContent && (
<div className="flex items-center">{centerContent}</div>
)}
{rightContent && <div className="flex items-center">{rightContent}</div>}
</header>
);
}
+47 -47
View File
@@ -1,47 +1,47 @@
"use client";
import Link from "next/link";
import { Button } from "./ui/button";
import { ArrowRight } from "lucide-react";
import { HeaderBase } from "./header-base";
import Image from "next/image";
export function Header() {
const leftContent = (
<Link href="/" className="flex items-center gap-3">
<Image src="/logo.svg" alt="OpenCut Logo" width={32} height={32} />
<span className="text-xl font-medium hidden md:block">OpenCut</span>
</Link>
);
const rightContent = (
<nav className="flex items-center gap-3">
<Link href="/blog">
<Button variant="text" className="text-sm p-0">
Blog
</Button>
</Link>
<Link href="/contributors">
<Button variant="text" className="text-sm p-0">
Contributors
</Button>
</Link>
<Link href="/projects">
<Button size="sm" className="text-sm ml-4">
Projects
<ArrowRight className="h-4 w-4" />
</Button>
</Link>
</nav>
);
return (
<div className="mx-4 md:mx-0">
<HeaderBase
className="bg-accent border rounded-2xl max-w-3xl mx-auto mt-4 pl-4 pr-[14px]"
leftContent={leftContent}
rightContent={rightContent}
/>
</div>
);
}
"use client";
import Link from "next/link";
import { Button } from "./ui/button";
import { ArrowRight } from "lucide-react";
import { HeaderBase } from "./header-base";
import Image from "next/image";
export function Header() {
const leftContent = (
<Link href="/" className="flex items-center gap-3">
<Image src="/logo.svg" alt="OpenCut Logo" width={32} height={32} />
<span className="text-xl font-medium hidden md:block">OpenCut</span>
</Link>
);
const rightContent = (
<nav className="flex items-center gap-3">
<Link href="/blog">
<Button variant="text" className="text-sm p-0">
Blog
</Button>
</Link>
<Link href="/contributors">
<Button variant="text" className="text-sm p-0">
Contributors
</Button>
</Link>
<Link href="/projects">
<Button size="sm" className="text-sm ml-4">
Projects
<ArrowRight className="h-4 w-4" />
</Button>
</Link>
</nav>
);
return (
<div className="mx-4 md:mx-0">
<HeaderBase
className="bg-accent border rounded-2xl max-w-3xl mx-auto mt-4 pl-4 pr-[14px]"
leftContent={leftContent}
rightContent={rightContent}
/>
</div>
);
}
+10 -3
View File
@@ -40,8 +40,15 @@ 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
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>
);
}
@@ -105,4 +112,4 @@ export function BackgroundIcon({ className }: { className?: string }) {
</defs>
</svg>
);
}
}
@@ -112,7 +112,7 @@ export function Handlebars({
whileDrag={{ scale: 1.1 }}
transition={{ type: "spring", stiffness: 400, damping: 30 }}
>
<div className="w-2 h-8 rounded-full bg-yellow-500"></div>
<div className="w-2 h-8 rounded-full bg-yellow-500" />
</motion.div>
<motion.div
@@ -135,7 +135,7 @@ export function Handlebars({
whileDrag={{ scale: 1.1 }}
transition={{ type: "spring", stiffness: 400, damping: 30 }}
>
<div className="w-2 h-8 rounded-full bg-yellow-500"></div>
<div className="w-2 h-8 rounded-full bg-yellow-500" />
</motion.div>
</div>
@@ -161,4 +161,4 @@ export function Handlebars({
</div>
</div>
);
};
}
+121 -121
View File
@@ -1,121 +1,121 @@
"use client";
import { Dialog, DialogContent } from "./ui/dialog";
import { Button } from "./ui/button";
import { ArrowRightIcon } from "lucide-react";
import { useState, useEffect } from "react";
import ReactMarkdown from "react-markdown";
export function Onboarding() {
const [step, setStep] = useState(0);
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
const hasSeenOnboarding = localStorage.getItem("hasSeenOnboarding");
if (!hasSeenOnboarding) {
setIsOpen(true);
}
}, []);
const handleNext = () => {
setStep(step + 1);
};
const handleClose = () => {
setIsOpen(false);
localStorage.setItem("hasSeenOnboarding", "true");
};
const renderStepContent = () => {
switch (step) {
case 0:
return (
<div className="space-y-5">
<div className="space-y-3">
<Title title="Welcome to OpenCut Beta! 🎉" />
<Description description="You're among the first to try OpenCut - the fully open source CapCut alternative." />
</div>
<NextButton onClick={handleNext}>Next</NextButton>
</div>
);
case 1:
return (
<div className="space-y-5">
<div className="space-y-3">
<Title title="⚠️ This is a super early beta!" />
<Description description="OpenCut started just one month ago. There's still a ton of things to do to make this editor amazing." />
<Description description="If you're curious, check out our roadmap [here](https://opencut.app/roadmap)" />
</div>
<NextButton onClick={handleNext}>Next</NextButton>
</div>
);
case 2:
return (
<div className="space-y-5">
<div className="space-y-3">
<Title title="🦋 Have fun testing!" />
<Description description="Join our [Discord](https://discord.gg/zmR9N35cjK), chat with cool people and share feedback to help make OpenCut the best editor ever." />
</div>
<NextButton onClick={handleClose}>Finish</NextButton>
</div>
);
default:
return null;
}
};
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[425px] !outline-none">
{renderStepContent()}
</DialogContent>
</Dialog>
);
}
function Title({ title }: { title: string }) {
return <h2 className="text-lg md:text-xl font-bold">{title}</h2>;
}
function Subtitle({ subtitle }: { subtitle: string }) {
return <h3 className="text-lg font-medium">{subtitle}</h3>;
}
function Description({ description }: { description: string }) {
return (
<div className="text-muted-foreground">
<ReactMarkdown
components={{
p: ({ children }) => <p className="mb-0">{children}</p>,
a: ({ href, children }) => (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-foreground hover:text-foreground/80 underline"
>
{children}
</a>
),
}}
>
{description}
</ReactMarkdown>
</div>
);
}
function NextButton({
children,
onClick,
}: {
children: React.ReactNode;
onClick: () => void;
}) {
return (
<Button onClick={onClick} variant="default" className="w-full">
{children}
<ArrowRightIcon className="w-4 h-4" />
</Button>
);
}
"use client";
import { Dialog, DialogContent } from "./ui/dialog";
import { Button } from "./ui/button";
import { ArrowRightIcon } from "lucide-react";
import { useState, useEffect } from "react";
import ReactMarkdown from "react-markdown";
export function Onboarding() {
const [step, setStep] = useState(0);
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
const hasSeenOnboarding = localStorage.getItem("hasSeenOnboarding");
if (!hasSeenOnboarding) {
setIsOpen(true);
}
}, []);
const handleNext = () => {
setStep(step + 1);
};
const handleClose = () => {
setIsOpen(false);
localStorage.setItem("hasSeenOnboarding", "true");
};
const renderStepContent = () => {
switch (step) {
case 0:
return (
<div className="space-y-5">
<div className="space-y-3">
<Title title="Welcome to OpenCut Beta! 🎉" />
<Description description="You're among the first to try OpenCut - the fully open source CapCut alternative." />
</div>
<NextButton onClick={handleNext}>Next</NextButton>
</div>
);
case 1:
return (
<div className="space-y-5">
<div className="space-y-3">
<Title title="⚠️ This is a super early beta!" />
<Description description="OpenCut started just one month ago. There's still a ton of things to do to make this editor amazing." />
<Description description="If you're curious, check out our roadmap [here](https://opencut.app/roadmap)" />
</div>
<NextButton onClick={handleNext}>Next</NextButton>
</div>
);
case 2:
return (
<div className="space-y-5">
<div className="space-y-3">
<Title title="🦋 Have fun testing!" />
<Description description="Join our [Discord](https://discord.gg/zmR9N35cjK), chat with cool people and share feedback to help make OpenCut the best editor ever." />
</div>
<NextButton onClick={handleClose}>Finish</NextButton>
</div>
);
default:
return null;
}
};
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[425px] !outline-none">
{renderStepContent()}
</DialogContent>
</Dialog>
);
}
function Title({ title }: { title: string }) {
return <h2 className="text-lg md:text-xl font-bold">{title}</h2>;
}
function Subtitle({ subtitle }: { subtitle: string }) {
return <h3 className="text-lg font-medium">{subtitle}</h3>;
}
function Description({ description }: { description: string }) {
return (
<div className="text-muted-foreground">
<ReactMarkdown
components={{
p: ({ children }) => <p className="mb-0">{children}</p>,
a: ({ href, children }) => (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-foreground hover:text-foreground/80 underline"
>
{children}
</a>
),
}}
>
{description}
</ReactMarkdown>
</div>
);
}
function NextButton({
children,
onClick,
}: {
children: React.ReactNode;
onClick: () => void;
}) {
return (
<Button onClick={onClick} variant="default" className="w-full">
{children}
<ArrowRightIcon className="w-4 h-4" />
</Button>
);
}
@@ -1,73 +1,73 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { useState } from "react";
export function RenameProjectDialog({
isOpen,
onOpenChange,
onConfirm,
projectName,
}: {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: (name: string) => void;
projectName: string;
}) {
const [name, setName] = useState(projectName);
// Reset the name when dialog opens - this is better UX than syncing with every prop change
const handleOpenChange = (open: boolean) => {
if (open) {
setName(projectName);
}
onOpenChange(open);
};
return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Rename Project</DialogTitle>
<DialogDescription>
Enter a new name for your project.
</DialogDescription>
</DialogHeader>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
onConfirm(name);
}
}}
placeholder="Enter a new name"
className="mt-4"
/>
<DialogFooter>
<Button
variant="outline"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onOpenChange(false);
}}
>
Cancel
</Button>
<Button onClick={() => onConfirm(name)}>Rename</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { useState } from "react";
export function RenameProjectDialog({
isOpen,
onOpenChange,
onConfirm,
projectName,
}: {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: (name: string) => void;
projectName: string;
}) {
const [name, setName] = useState(projectName);
// Reset the name when dialog opens - this is better UX than syncing with every prop change
const handleOpenChange = (open: boolean) => {
if (open) {
setName(projectName);
}
onOpenChange(open);
};
return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Rename Project</DialogTitle>
<DialogDescription>
Enter a new name for your project.
</DialogDescription>
</DialogHeader>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
onConfirm(name);
}
}}
placeholder="Enter a new name"
className="mt-4"
/>
<DialogFooter>
<Button
variant="outline"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onOpenChange(false);
}}
>
Cancel
</Button>
<Button onClick={() => onConfirm(name)}>Rename</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+80 -80
View File
@@ -1,80 +1,80 @@
"use client";
import { createContext, useContext, useEffect, useState } from "react";
import { useProjectStore } from "@/stores/project-store";
import { useMediaStore } from "@/stores/media-store";
import { storageService } from "@/lib/storage/storage-service";
import { toast } from "sonner";
interface StorageContextType {
isInitialized: boolean;
isLoading: boolean;
hasSupport: boolean;
error: string | null;
}
const StorageContext = createContext<StorageContextType | null>(null);
export function useStorage() {
const context = useContext(StorageContext);
if (!context) {
throw new Error("useStorage must be used within StorageProvider");
}
return context;
}
interface StorageProviderProps {
children: React.ReactNode;
}
export function StorageProvider({ children }: StorageProviderProps) {
const [status, setStatus] = useState<StorageContextType>({
isInitialized: false,
isLoading: true,
hasSupport: false,
error: null,
});
const loadAllProjects = useProjectStore((state) => state.loadAllProjects);
useEffect(() => {
const initializeStorage = async () => {
setStatus((prev) => ({ ...prev, isLoading: true }));
try {
// Check browser support
const hasSupport = storageService.isFullySupported();
if (!hasSupport) {
toast.warning(
"Storage not fully supported. Some features may not work."
);
}
// Load saved projects (media will be loaded when a project is loaded)
await loadAllProjects();
setStatus({
isInitialized: true,
isLoading: false,
hasSupport,
error: null,
});
} catch (error) {
console.error("Failed to initialize storage:", error);
setStatus({
isInitialized: false,
isLoading: false,
hasSupport: storageService.isFullySupported(),
error: error instanceof Error ? error.message : "Unknown error",
});
}
};
initializeStorage();
}, [loadAllProjects]);
return (
<StorageContext.Provider value={status}>{children}</StorageContext.Provider>
);
}
"use client";
import { createContext, useContext, useEffect, useState } from "react";
import { useProjectStore } from "@/stores/project-store";
import { useMediaStore } from "@/stores/media-store";
import { storageService } from "@/lib/storage/storage-service";
import { toast } from "sonner";
interface StorageContextType {
isInitialized: boolean;
isLoading: boolean;
hasSupport: boolean;
error: string | null;
}
const StorageContext = createContext<StorageContextType | null>(null);
export function useStorage() {
const context = useContext(StorageContext);
if (!context) {
throw new Error("useStorage must be used within StorageProvider");
}
return context;
}
interface StorageProviderProps {
children: React.ReactNode;
}
export function StorageProvider({ children }: StorageProviderProps) {
const [status, setStatus] = useState<StorageContextType>({
isInitialized: false,
isLoading: true,
hasSupport: false,
error: null,
});
const loadAllProjects = useProjectStore((state) => state.loadAllProjects);
useEffect(() => {
const initializeStorage = async () => {
setStatus((prev) => ({ ...prev, isLoading: true }));
try {
// Check browser support
const hasSupport = storageService.isFullySupported();
if (!hasSupport) {
toast.warning(
"Storage not fully supported. Some features may not work."
);
}
// Load saved projects (media will be loaded when a project is loaded)
await loadAllProjects();
setStatus({
isInitialized: true,
isLoading: false,
hasSupport,
error: null,
});
} catch (error) {
console.error("Failed to initialize storage:", error);
setStatus({
isInitialized: false,
isLoading: false,
hasSupport: storageService.isFullySupported(),
error: error instanceof Error ? error.message : "Unknown error",
});
}
};
initializeStorage();
}, [loadAllProjects]);
return (
<StorageContext.Provider value={status}>{children}</StorageContext.Provider>
);
}
+4 -4
View File
@@ -1,7 +1,7 @@
"use client"
"use client";
import { AspectRatio as AspectRatioPrimitive } from "radix-ui"
import { AspectRatio as AspectRatioPrimitive } from "radix-ui";
const AspectRatio = AspectRatioPrimitive.Root
const AspectRatio = AspectRatioPrimitive.Root;
export { AspectRatio }
export { AspectRatio };
+127 -127
View File
@@ -1,127 +1,127 @@
"use client";
import { useRef, useEffect } from "react";
import { usePlaybackStore } from "@/stores/playback-store";
interface AudioPlayerProps {
src: string;
className?: string;
clipStartTime: number;
trimStart: number;
trimEnd: number;
clipDuration: number;
trackMuted?: boolean;
}
export function AudioPlayer({
src,
className = "",
clipStartTime,
trimStart,
trimEnd,
clipDuration,
trackMuted = false,
}: AudioPlayerProps) {
const audioRef = useRef<HTMLAudioElement>(null);
const { isPlaying, currentTime, volume, speed, muted } = usePlaybackStore();
// Calculate if we're within this clip's timeline range
const clipEndTime = clipStartTime + (clipDuration - trimStart - trimEnd);
const isInClipRange =
currentTime >= clipStartTime && currentTime < clipEndTime;
// Sync playback events
useEffect(() => {
const audio = audioRef.current;
if (!audio || !isInClipRange) return;
const handleSeekEvent = (e: CustomEvent) => {
// Always update audio time, even if outside clip range
const timelineTime = e.detail.time;
const audioTime = Math.max(
trimStart,
Math.min(
clipDuration - trimEnd,
timelineTime - clipStartTime + trimStart
)
);
audio.currentTime = audioTime;
};
const handleUpdateEvent = (e: CustomEvent) => {
// Always update audio time, even if outside clip range
const timelineTime = e.detail.time;
const targetTime = Math.max(
trimStart,
Math.min(
clipDuration - trimEnd,
timelineTime - clipStartTime + trimStart
)
);
if (Math.abs(audio.currentTime - targetTime) > 0.5) {
audio.currentTime = targetTime;
}
};
const handleSpeed = (e: CustomEvent) => {
audio.playbackRate = e.detail.speed;
};
window.addEventListener("playback-seek", handleSeekEvent as EventListener);
window.addEventListener(
"playback-update",
handleUpdateEvent as EventListener
);
window.addEventListener("playback-speed", handleSpeed as EventListener);
return () => {
window.removeEventListener(
"playback-seek",
handleSeekEvent as EventListener
);
window.removeEventListener(
"playback-update",
handleUpdateEvent as EventListener
);
window.removeEventListener(
"playback-speed",
handleSpeed as EventListener
);
};
}, [clipStartTime, trimStart, trimEnd, clipDuration, isInClipRange]);
// Sync playback state
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
if (isPlaying && isInClipRange && !trackMuted) {
audio.play().catch(() => {});
} else {
audio.pause();
}
}, [isPlaying, isInClipRange, trackMuted]);
// Sync volume and speed
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
audio.volume = volume;
audio.muted = muted || trackMuted;
audio.playbackRate = speed;
}, [volume, speed, muted, trackMuted]);
return (
<audio
ref={audioRef}
src={src}
className={className}
preload="auto"
controls={false}
style={{ display: "none" }} // Audio elements don't need visual representation
onContextMenu={(e) => e.preventDefault()}
/>
);
}
"use client";
import { useRef, useEffect } from "react";
import { usePlaybackStore } from "@/stores/playback-store";
interface AudioPlayerProps {
src: string;
className?: string;
clipStartTime: number;
trimStart: number;
trimEnd: number;
clipDuration: number;
trackMuted?: boolean;
}
export function AudioPlayer({
src,
className = "",
clipStartTime,
trimStart,
trimEnd,
clipDuration,
trackMuted = false,
}: AudioPlayerProps) {
const audioRef = useRef<HTMLAudioElement>(null);
const { isPlaying, currentTime, volume, speed, muted } = usePlaybackStore();
// Calculate if we're within this clip's timeline range
const clipEndTime = clipStartTime + (clipDuration - trimStart - trimEnd);
const isInClipRange =
currentTime >= clipStartTime && currentTime < clipEndTime;
// Sync playback events
useEffect(() => {
const audio = audioRef.current;
if (!audio || !isInClipRange) return;
const handleSeekEvent = (e: CustomEvent) => {
// Always update audio time, even if outside clip range
const timelineTime = e.detail.time;
const audioTime = Math.max(
trimStart,
Math.min(
clipDuration - trimEnd,
timelineTime - clipStartTime + trimStart
)
);
audio.currentTime = audioTime;
};
const handleUpdateEvent = (e: CustomEvent) => {
// Always update audio time, even if outside clip range
const timelineTime = e.detail.time;
const targetTime = Math.max(
trimStart,
Math.min(
clipDuration - trimEnd,
timelineTime - clipStartTime + trimStart
)
);
if (Math.abs(audio.currentTime - targetTime) > 0.5) {
audio.currentTime = targetTime;
}
};
const handleSpeed = (e: CustomEvent) => {
audio.playbackRate = e.detail.speed;
};
window.addEventListener("playback-seek", handleSeekEvent as EventListener);
window.addEventListener(
"playback-update",
handleUpdateEvent as EventListener
);
window.addEventListener("playback-speed", handleSpeed as EventListener);
return () => {
window.removeEventListener(
"playback-seek",
handleSeekEvent as EventListener
);
window.removeEventListener(
"playback-update",
handleUpdateEvent as EventListener
);
window.removeEventListener(
"playback-speed",
handleSpeed as EventListener
);
};
}, [clipStartTime, trimStart, trimEnd, clipDuration, isInClipRange]);
// Sync playback state
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
if (isPlaying && isInClipRange && !trackMuted) {
audio.play().catch(() => {});
} else {
audio.pause();
}
}, [isPlaying, isInClipRange, trackMuted]);
// Sync volume and speed
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
audio.volume = volume;
audio.muted = muted || trackMuted;
audio.playbackRate = speed;
}, [volume, speed, muted, trackMuted]);
return (
<audio
ref={audioRef}
src={src}
className={className}
preload="auto"
controls={false}
style={{ display: "none" }} // Audio elements don't need visual representation
onContextMenu={(e) => e.preventDefault()}
/>
);
}
+1 -2
View File
@@ -9,8 +9,7 @@ const buttonVariants = cva(
{
variants: {
variant: {
default:
"bg-foreground text-background shadow hover:bg-foreground/90",
default: "bg-foreground text-background shadow hover:bg-foreground/90",
primary:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
+1 -4
View File
@@ -8,10 +8,7 @@ const Card = React.forwardRef<
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-xl border bg-card text-card-foreground",
className
)}
className={cn("rounded-xl border bg-card text-card-foreground", className)}
{...props}
/>
));
+1 -1
View File
@@ -124,7 +124,7 @@ const Carousel = React.forwardRef<
<CarouselContext.Provider
value={{
carouselRef,
api: api,
api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
+2 -2
View File
@@ -188,7 +188,7 @@ const ChartTooltipContent = React.forwardRef<
className
)}
>
{!nestLabel ? tooltipLabel : null}
{nestLabel ? null : tooltipLabel}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`;
@@ -328,7 +328,7 @@ function getPayloadConfigFromPayload(
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined;
return;
}
const payloadPayload =
+6 -6
View File
@@ -1,11 +1,11 @@
"use client"
"use client";
import { Collapsible as CollapsiblePrimitive } from "radix-ui"
import { Collapsible as CollapsiblePrimitive } from "radix-ui";
const Collapsible = CollapsiblePrimitive.Root
const Collapsible = CollapsiblePrimitive.Root;
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
+40 -40
View File
@@ -1,40 +1,40 @@
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { FONT_OPTIONS, FontFamily } from "@/constants/font-constants";
interface FontPickerProps {
defaultValue?: FontFamily;
onValueChange?: (value: FontFamily) => void;
className?: string;
}
export function FontPicker({
defaultValue,
onValueChange,
className,
}: FontPickerProps) {
return (
<Select defaultValue={defaultValue} onValueChange={onValueChange}>
<SelectTrigger className={`w-full text-xs ${className || ""}`}>
<SelectValue placeholder="Select a font" />
</SelectTrigger>
<SelectContent>
{FONT_OPTIONS.map((font) => (
<SelectItem
key={font.value}
value={font.value}
className="text-xs"
style={{ fontFamily: font.value }}
>
{font.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { FONT_OPTIONS, FontFamily } from "@/constants/font-constants";
interface FontPickerProps {
defaultValue?: FontFamily;
onValueChange?: (value: FontFamily) => void;
className?: string;
}
export function FontPicker({
defaultValue,
onValueChange,
className,
}: FontPickerProps) {
return (
<Select defaultValue={defaultValue} onValueChange={onValueChange}>
<SelectTrigger className={`w-full text-xs ${className || ""}`}>
<SelectValue placeholder="Select a font" />
</SelectTrigger>
<SelectContent>
{FONT_OPTIONS.map((font) => (
<SelectItem
key={font.value}
value={font.value}
className="text-xs"
style={{ fontFamily: font.value }}
>
{font.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
+1 -3
View File
@@ -115,9 +115,7 @@ const FormControl = React.forwardRef<
ref={ref}
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
error ? `${formDescriptionId} ${formMessageId}` : `${formDescriptionId}`
}
aria-invalid={!!error}
{...props}
@@ -1,102 +1,102 @@
"use client";
import { useState } from "react";
import { cn } from "@/lib/utils";
import type { BackgroundType } from "@/types/editor";
interface ImageTimelineTreatmentProps {
src: string;
alt: string;
targetAspectRatio?: number; // Default to 16:9 for video
className?: string;
backgroundType?: BackgroundType;
backgroundColor?: string;
}
export function ImageTimelineTreatment({
src,
alt,
targetAspectRatio = 16 / 9,
className,
backgroundType = "blur",
backgroundColor = "#000000",
}: ImageTimelineTreatmentProps) {
const [imageLoaded, setImageLoaded] = useState(false);
const [imageDimensions, setImageDimensions] = useState<{
width: number;
height: number;
} | null>(null);
const handleImageLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {
const img = e.currentTarget;
setImageDimensions({
width: img.naturalWidth,
height: img.naturalHeight,
});
setImageLoaded(true);
};
const imageAspectRatio = imageDimensions
? imageDimensions.width / imageDimensions.height
: 1;
const needsAspectRatioTreatment = imageAspectRatio !== targetAspectRatio;
return (
<div
className={cn("relative overflow-hidden", className)}
style={{ aspectRatio: targetAspectRatio }}
>
{/* Background Layer */}
{needsAspectRatioTreatment && imageLoaded && (
<>
{backgroundType === "blur" && (
<div className="absolute inset-0">
<img
src={src}
alt=""
className="w-full h-full object-cover filter blur-xl scale-110 opacity-60"
aria-hidden="true"
/>
<div className="absolute inset-0 bg-black/20" />
</div>
)}
{backgroundType === "mirror" && (
<div className="absolute inset-0">
<img
src={src}
alt=""
className="w-full h-full object-cover opacity-30"
aria-hidden="true"
/>
</div>
)}
{backgroundType === "color" && (
<div className="absolute inset-0" style={{ backgroundColor }} />
)}
</>
)}
{/* Main Image Layer */}
<div className="absolute inset-0">
<img
src={src}
alt={alt}
className="w-full h-full object-cover"
onLoad={handleImageLoad}
/>
</div>
{/* Loading state */}
{!imageLoaded && (
<div className="absolute inset-0 flex items-center justify-center bg-muted/30">
<div className="animate-pulse text-xs text-muted-foreground">
Loading...
</div>
</div>
)}
</div>
);
}
"use client";
import { useState } from "react";
import { cn } from "@/lib/utils";
import type { BackgroundType } from "@/types/editor";
interface ImageTimelineTreatmentProps {
src: string;
alt: string;
targetAspectRatio?: number; // Default to 16:9 for video
className?: string;
backgroundType?: BackgroundType;
backgroundColor?: string;
}
export function ImageTimelineTreatment({
src,
alt,
targetAspectRatio = 16 / 9,
className,
backgroundType = "blur",
backgroundColor = "#000000",
}: ImageTimelineTreatmentProps) {
const [imageLoaded, setImageLoaded] = useState(false);
const [imageDimensions, setImageDimensions] = useState<{
width: number;
height: number;
} | null>(null);
const handleImageLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {
const img = e.currentTarget;
setImageDimensions({
width: img.naturalWidth,
height: img.naturalHeight,
});
setImageLoaded(true);
};
const imageAspectRatio = imageDimensions
? imageDimensions.width / imageDimensions.height
: 1;
const needsAspectRatioTreatment = imageAspectRatio !== targetAspectRatio;
return (
<div
className={cn("relative overflow-hidden", className)}
style={{ aspectRatio: targetAspectRatio }}
>
{/* Background Layer */}
{needsAspectRatioTreatment && imageLoaded && (
<>
{backgroundType === "blur" && (
<div className="absolute inset-0">
<img
src={src}
alt=""
className="w-full h-full object-cover filter blur-xl scale-110 opacity-60"
aria-hidden="true"
/>
<div className="absolute inset-0 bg-black/20" />
</div>
)}
{backgroundType === "mirror" && (
<div className="absolute inset-0">
<img
src={src}
alt=""
className="w-full h-full object-cover opacity-30"
aria-hidden="true"
/>
</div>
)}
{backgroundType === "color" && (
<div className="absolute inset-0" style={{ backgroundColor }} />
)}
</>
)}
{/* Main Image Layer */}
<div className="absolute inset-0">
<img
src={src}
alt={alt}
className="w-full h-full object-cover"
onLoad={handleImageLoad}
/>
</div>
{/* Loading state */}
{!imageLoaded && (
<div className="absolute inset-0 flex items-center justify-center bg-muted/30">
<div className="animate-pulse text-xs text-muted-foreground">
Loading...
</div>
</div>
)}
</div>
);
}
+167 -167
View File
@@ -1,167 +1,167 @@
import * as React from "react";
import { CheckIcon, ChevronsUpDown } from "lucide-react";
import * as RPNInput from "react-phone-number-input";
import flags from "react-phone-number-input/flags";
import { Button } from "./button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "./command";
import { Input } from "./input";
import { Popover, PopoverContent, PopoverTrigger } from "./popover";
import { ScrollArea } from "./scroll-area";
import { cn } from "../../lib/utils";
type PhoneInputProps = Omit<
React.ComponentProps<"input">,
"onChange" | "value" | "ref"
> &
Omit<RPNInput.Props<typeof RPNInput.default>, "onChange"> & {
onChange?: (value: RPNInput.Value) => void;
};
const PhoneInput: React.ForwardRefExoticComponent<PhoneInputProps> =
React.forwardRef<React.ElementRef<typeof RPNInput.default>, PhoneInputProps>(
({ className, onChange, defaultCountry = "US", ...props }, ref) => {
return (
<RPNInput.default
ref={ref}
className={cn("flex", className)}
flagComponent={FlagComponent}
countrySelectComponent={CountrySelect}
inputComponent={InputComponent}
defaultCountry={defaultCountry}
international
addInternationalOption
smartCaret={false}
/**
* Handles the onChange event.
*
* react-phone-number-input might trigger the onChange event as undefined
* when a valid phone number is not entered. To prevent this,
* the value is coerced to an empty string.
*
* @param {E164Number | undefined} value - The entered value
*/
onChange={(value) => onChange?.(value || ("" as RPNInput.Value))}
{...props}
/>
);
}
);
PhoneInput.displayName = "PhoneInput";
const InputComponent = React.forwardRef<
HTMLInputElement,
React.ComponentProps<"input">
>(({ className, ...props }, ref) => (
<Input
className={cn("rounded-e-lg rounded-s-none", className)}
{...props}
ref={ref}
/>
));
InputComponent.displayName = "InputComponent";
type CountryEntry = { label: string; value: RPNInput.Country | undefined };
type CountrySelectProps = {
disabled?: boolean;
value: RPNInput.Country;
options: CountryEntry[];
onChange: (country: RPNInput.Country) => void;
};
const CountrySelect = ({
disabled,
value: selectedCountry,
options: countryList,
onChange,
}: CountrySelectProps) => {
return (
<Popover>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
className="flex gap-1 rounded-e-none rounded-s-lg border-r-0 px-3 focus:z-10"
disabled={disabled}
>
<FlagComponent
country={selectedCountry}
countryName={selectedCountry}
/>
<ChevronsUpDown
className={cn(
"-mr-2 size-4 opacity-50",
disabled ? "hidden" : "opacity-100"
)}
/>
</Button>
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0">
<Command>
<CommandInput placeholder="Search country..." />
<CommandList>
<ScrollArea className="h-72">
<CommandEmpty>No country found.</CommandEmpty>
<CommandGroup>
{countryList.map(({ value, label }) =>
value ? (
<CountrySelectOption
key={value}
country={value}
countryName={label}
selectedCountry={selectedCountry}
onChange={onChange}
/>
) : null
)}
</CommandGroup>
</ScrollArea>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
};
interface CountrySelectOptionProps extends RPNInput.FlagProps {
selectedCountry: RPNInput.Country;
onChange: (country: RPNInput.Country) => void;
}
const CountrySelectOption = ({
country,
countryName,
selectedCountry,
onChange,
}: CountrySelectOptionProps) => {
return (
<CommandItem className="gap-2" onSelect={() => onChange(country)}>
<FlagComponent country={country} countryName={countryName} />
<span className="flex-1 text-sm">{countryName}</span>
<span className="text-sm text-foreground/50">{`+${RPNInput.getCountryCallingCode(country)}`}</span>
<CheckIcon
className={`ml-auto size-4 ${country === selectedCountry ? "opacity-100" : "opacity-0"}`}
/>
</CommandItem>
);
};
const FlagComponent = ({ country, countryName }: RPNInput.FlagProps) => {
const Flag = flags[country];
return (
<span className="flex h-4 w-6 overflow-hidden rounded-sm bg-foreground/20 [&_svg]:size-full">
{Flag && <Flag title={countryName} />}
</span>
);
};
export { PhoneInput };
import * as React from "react";
import { CheckIcon, ChevronsUpDown } from "lucide-react";
import * as RPNInput from "react-phone-number-input";
import flags from "react-phone-number-input/flags";
import { Button } from "./button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "./command";
import { Input } from "./input";
import { Popover, PopoverContent, PopoverTrigger } from "./popover";
import { ScrollArea } from "./scroll-area";
import { cn } from "../../lib/utils";
type PhoneInputProps = Omit<
React.ComponentProps<"input">,
"onChange" | "value" | "ref"
> &
Omit<RPNInput.Props<typeof RPNInput.default>, "onChange"> & {
onChange?: (value: RPNInput.Value) => void;
};
const PhoneInput: React.ForwardRefExoticComponent<PhoneInputProps> =
React.forwardRef<React.ElementRef<typeof RPNInput.default>, PhoneInputProps>(
({ className, onChange, defaultCountry = "US", ...props }, ref) => {
return (
<RPNInput.default
ref={ref}
className={cn("flex", className)}
flagComponent={FlagComponent}
countrySelectComponent={CountrySelect}
inputComponent={InputComponent}
defaultCountry={defaultCountry}
international
addInternationalOption
smartCaret={false}
/**
* Handles the onChange event.
*
* react-phone-number-input might trigger the onChange event as undefined
* when a valid phone number is not entered. To prevent this,
* the value is coerced to an empty string.
*
* @param {E164Number | undefined} value - The entered value
*/
onChange={(value) => onChange?.(value || ("" as RPNInput.Value))}
{...props}
/>
);
}
);
PhoneInput.displayName = "PhoneInput";
const InputComponent = React.forwardRef<
HTMLInputElement,
React.ComponentProps<"input">
>(({ className, ...props }, ref) => (
<Input
className={cn("rounded-e-lg rounded-s-none", className)}
{...props}
ref={ref}
/>
));
InputComponent.displayName = "InputComponent";
type CountryEntry = { label: string; value: RPNInput.Country | undefined };
type CountrySelectProps = {
disabled?: boolean;
value: RPNInput.Country;
options: CountryEntry[];
onChange: (country: RPNInput.Country) => void;
};
const CountrySelect = ({
disabled,
value: selectedCountry,
options: countryList,
onChange,
}: CountrySelectProps) => {
return (
<Popover>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
className="flex gap-1 rounded-e-none rounded-s-lg border-r-0 px-3 focus:z-10"
disabled={disabled}
>
<FlagComponent
country={selectedCountry}
countryName={selectedCountry}
/>
<ChevronsUpDown
className={cn(
"-mr-2 size-4 opacity-50",
disabled ? "hidden" : "opacity-100"
)}
/>
</Button>
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0">
<Command>
<CommandInput placeholder="Search country..." />
<CommandList>
<ScrollArea className="h-72">
<CommandEmpty>No country found.</CommandEmpty>
<CommandGroup>
{countryList.map(({ value, label }) =>
value ? (
<CountrySelectOption
key={value}
country={value}
countryName={label}
selectedCountry={selectedCountry}
onChange={onChange}
/>
) : null
)}
</CommandGroup>
</ScrollArea>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
};
interface CountrySelectOptionProps extends RPNInput.FlagProps {
selectedCountry: RPNInput.Country;
onChange: (country: RPNInput.Country) => void;
}
const CountrySelectOption = ({
country,
countryName,
selectedCountry,
onChange,
}: CountrySelectOptionProps) => {
return (
<CommandItem className="gap-2" onSelect={() => onChange(country)}>
<FlagComponent country={country} countryName={countryName} />
<span className="flex-1 text-sm">{countryName}</span>
<span className="text-sm text-foreground/50">{`+${RPNInput.getCountryCallingCode(country)}`}</span>
<CheckIcon
className={`ml-auto size-4 ${country === selectedCountry ? "opacity-100" : "opacity-0"}`}
/>
</CommandItem>
);
};
const FlagComponent = ({ country, countryName }: RPNInput.FlagProps) => {
const Flag = flags[country];
return (
<span className="flex h-4 w-6 overflow-hidden rounded-sm bg-foreground/20 [&_svg]:size-full">
{Flag && <Flag title={countryName} />}
</span>
);
};
export { PhoneInput };
+7 -7
View File
@@ -1,9 +1,9 @@
"use client"
"use client";
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import * as React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
@@ -25,7 +25,7 @@ const Separator = React.forwardRef<
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
);
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator }
export { Separator };
+8 -8
View File
@@ -1,12 +1,12 @@
"use client"
"use client";
import { useTheme } from "next-themes"
import { Toaster as Sonner } from "sonner"
import { useTheme } from "next-themes";
import { Toaster as Sonner } from "sonner";
type ToasterProps = React.ComponentProps<typeof Sonner>
type ToasterProps = React.ComponentProps<typeof Sonner>;
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
const { theme = "system" } = useTheme();
return (
<Sonner
@@ -25,7 +25,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
}}
{...props}
/>
)
}
);
};
export { Toaster }
export { Toaster };
@@ -14,7 +14,6 @@ export function SponsorButton({
companyName,
className = "",
}: SponsorButtonProps) {
return (
<motion.a
href={href}
+10 -14
View File
@@ -15,20 +15,16 @@ export function Toaster() {
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
);
})}
{toasts.map(({ id, title, description, action, ...props }) => (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && <ToastDescription>{description}</ToastDescription>}
</div>
{action}
<ToastClose />
</Toast>
))}
<ToastViewport />
</ToastProvider>
);