Merge branch 'staging'

This commit is contained in:
Maze Winther
2025-07-17 19:51:31 +02:00
13 changed files with 2639 additions and 2610 deletions
+2 -2
View File
@@ -60,8 +60,8 @@
"zustand": "^5.0.2"
},
"devDependencies": {
"@types/pg": "^8.15.4",
"@types/bun": "latest",
"@types/pg": "^8.15.4",
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",
"cross-env": "^7.0.3",
@@ -70,6 +70,6 @@
"prettier": "^3.4.2",
"tailwindcss": "^3.4.1",
"tsx": "^4.7.1",
"typescript": "^5"
"typescript": "^5.8.3"
}
}
+3 -27
View File
@@ -245,7 +245,6 @@ export default function ProjectsPage() {
isSelectionMode={isSelectionMode}
isSelected={selectedProjects.has(project.id)}
onSelect={handleSelectProject}
searchQuery={searchQuery}
/>
))}
</div>
@@ -266,7 +265,6 @@ interface ProjectCardProps {
isSelectionMode?: boolean;
isSelected?: boolean;
onSelect?: (projectId: string, checked: boolean) => void;
searchQuery?: string;
}
function ProjectCard({
@@ -274,7 +272,6 @@ function ProjectCard({
isSelectionMode = false,
isSelected = false,
onSelect,
searchQuery,
}: ProjectCardProps) {
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
@@ -363,7 +360,7 @@ function ProjectCard({
<CardContent className="px-0 pt-5 flex flex-col gap-1">
<div className="flex items-start justify-between">
<h3 className="font-medium text-sm leading-snug group-hover:text-foreground/90 transition-colors line-clamp-2">
<Highlight text={project.name} query={searchQuery} />
{project.name}
</h3>
{!isSelectionMode && (
<DropdownMenu
@@ -470,7 +467,7 @@ function ProjectCard({
<CardContent className="px-0 pt-5 flex flex-col gap-1">
<div className="flex items-start justify-between">
<h3 className="font-medium text-sm leading-snug group-hover:text-foreground/90 transition-colors line-clamp-2">
<Highlight text={project.name} query={searchQuery} />
{project.name}
</h3>
<DropdownMenu
open={isDropdownOpen}
@@ -546,6 +543,7 @@ function ProjectCard({
isOpen={isDeleteDialogOpen}
onOpenChange={setIsDeleteDialogOpen}
onConfirm={handleDeleteProject}
projectName={project.name}
/>
<RenameProjectDialog
isOpen={isRenameDialogOpen}
@@ -557,28 +555,6 @@ function ProjectCard({
);
}
function Highlight({ text, query }: { text: string; query?: string }) {
if (!query || query.trim() === "") {
return <span>{text}</span>;
}
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const parts = text.split(new RegExp(`(${escapedQuery})`, "gi"));
return (
<span>
{parts.map((part, i) =>
part.toLowerCase() === query.toLowerCase() ? (
<mark key={i} className="bg-yellow-200 text-black">
{part}
</mark>
) : (
part
)
)}
</span>
);
}
function CreateButton({ onClick }: { onClick?: () => void }) {
return (
<Button className="flex" onClick={onClick}>
@@ -1,53 +1,68 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
export function DeleteProjectDialog({
isOpen,
onOpenChange,
onConfirm,
}: {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
}) {
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent
onOpenAutoFocus={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<DialogHeader>
<DialogTitle>Delete Project</DialogTitle>
<DialogDescription>
Are you sure you want to delete this project? This action cannot be
undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onOpenChange(false);
}}
>
Cancel
</Button>
<Button variant="destructive" onClick={onConfirm}>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
export function DeleteProjectDialog({
isOpen,
onOpenChange,
onConfirm,
projectName,
}: {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
projectName?: string;
}) {
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent
onOpenAutoFocus={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<DialogHeader>
<DialogTitle>
{projectName ? (
<>
{"Delete '"}
<span className="inline-block max-w-[300px] truncate align-bottom">
{projectName}
</span>
{"'?"}
</>
) : (
"Delete Project?"
)}
</DialogTitle>
<DialogDescription>
Are you sure you want to delete this project? This action cannot be
undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onOpenChange(false);
}}
>
Cancel
</Button>
<Button variant="destructive" onClick={onConfirm}>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -69,7 +69,7 @@ export function TabBar() {
/>
<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"
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];
@@ -1,470 +1,470 @@
"use client";
import { useState } from "react";
import { Button } from "../ui/button";
import {
MoreVertical,
Scissors,
Trash2,
SplitSquareHorizontal,
Music,
ChevronRight,
ChevronLeft,
Type,
Copy,
RefreshCw,
} from "lucide-react";
import { useMediaStore } from "@/stores/media-store";
import { useTimelineStore } from "@/stores/timeline-store";
import { usePlaybackStore } from "@/stores/playback-store";
import AudioWaveform from "./audio-waveform";
import { toast } from "sonner";
import { TimelineElementProps, TrackType } from "@/types/timeline";
import { useTimelineElementResize } from "@/hooks/use-timeline-element-resize";
import {
getTrackElementClasses,
TIMELINE_CONSTANTS,
getTrackHeight,
} from "@/constants/timeline-constants";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
} from "../ui/dropdown-menu";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "../ui/context-menu";
export function TimelineElement({
element,
track,
zoomLevel,
isSelected,
onElementMouseDown,
onElementClick,
}: TimelineElementProps) {
const { mediaItems } = useMediaStore();
const {
updateElementTrim,
updateElementDuration,
removeElementFromTrack,
dragState,
splitElement,
splitAndKeepLeft,
splitAndKeepRight,
separateAudio,
addElementToTrack,
replaceElementMedia,
} = useTimelineStore();
const { currentTime } = usePlaybackStore();
const [elementMenuOpen, setElementMenuOpen] = useState(false);
const {
resizing,
isResizing,
handleResizeStart,
handleResizeMove,
handleResizeEnd,
} = useTimelineElementResize({
element,
track,
zoomLevel,
onUpdateTrim: updateElementTrim,
onUpdateDuration: updateElementDuration,
});
const effectiveDuration =
element.duration - element.trimStart - element.trimEnd;
const elementWidth = Math.max(
TIMELINE_CONSTANTS.ELEMENT_MIN_WIDTH,
effectiveDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel
);
// Use real-time position during drag, otherwise use stored position
const isBeingDragged = dragState.elementId === element.id;
const elementStartTime =
isBeingDragged && dragState.isDragging
? dragState.currentTime
: element.startTime;
const elementLeft = elementStartTime * 50 * zoomLevel;
const handleDeleteElement = () => {
removeElementFromTrack(track.id, element.id);
setElementMenuOpen(false);
};
const handleSplitElement = () => {
const effectiveStart = element.startTime;
const effectiveEnd =
element.startTime +
(element.duration - element.trimStart - element.trimEnd);
if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
toast.error("Playhead must be within element to split");
return;
}
const secondElementId = splitElement(track.id, element.id, currentTime);
if (!secondElementId) {
toast.error("Failed to split element");
}
setElementMenuOpen(false);
};
const handleSplitAndKeepLeft = () => {
const effectiveStart = element.startTime;
const effectiveEnd =
element.startTime +
(element.duration - element.trimStart - element.trimEnd);
if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
toast.error("Playhead must be within element");
return;
}
splitAndKeepLeft(track.id, element.id, currentTime);
setElementMenuOpen(false);
};
const handleSplitAndKeepRight = () => {
const effectiveStart = element.startTime;
const effectiveEnd =
element.startTime +
(element.duration - element.trimStart - element.trimEnd);
if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
toast.error("Playhead must be within element");
return;
}
splitAndKeepRight(track.id, element.id, currentTime);
setElementMenuOpen(false);
};
const handleSeparateAudio = () => {
if (element.type !== "media") {
toast.error("Audio separation only available for media elements");
return;
}
const mediaItem = mediaItems.find((item) => item.id === element.mediaId);
if (!mediaItem || mediaItem.type !== "video") {
toast.error("Audio separation only available for video elements");
return;
}
const audioElementId = separateAudio(track.id, element.id);
if (!audioElementId) {
toast.error("Failed to separate audio");
}
setElementMenuOpen(false);
};
const canSplitAtPlayhead = () => {
const effectiveStart = element.startTime;
const effectiveEnd =
element.startTime +
(element.duration - element.trimStart - element.trimEnd);
return currentTime > effectiveStart && currentTime < effectiveEnd;
};
const canSeparateAudio = () => {
if (element.type !== "media") return false;
const mediaItem = mediaItems.find((item) => item.id === element.mediaId);
return mediaItem?.type === "video" && track.type === "media";
};
const handleElementSplitContext = () => {
const effectiveStart = element.startTime;
const effectiveEnd =
element.startTime +
(element.duration - element.trimStart - element.trimEnd);
if (currentTime > effectiveStart && currentTime < effectiveEnd) {
const secondElementId = splitElement(track.id, element.id, currentTime);
if (!secondElementId) {
toast.error("Failed to split element");
}
} else {
toast.error("Playhead must be within element to split");
}
};
const handleElementDuplicateContext = () => {
const { id, ...elementWithoutId } = element;
addElementToTrack(track.id, {
...elementWithoutId,
name: element.name + " (copy)",
startTime:
element.startTime +
(element.duration - element.trimStart - element.trimEnd) +
0.1,
});
};
const handleElementDeleteContext = () => {
removeElementFromTrack(track.id, element.id);
};
const handleReplaceClip = () => {
if (element.type !== "media") {
toast.error("Replace is only available for media clips");
return;
}
// Create a file input to select replacement media
const input = document.createElement("input");
input.type = "file";
input.accept = "video/*,audio/*,image/*";
input.onchange = async (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) return;
try {
const success = await replaceElementMedia(track.id, element.id, file);
if (success) {
toast.success("Clip replaced successfully");
} else {
toast.error("Failed to replace clip");
}
} catch (error) {
toast.error("Failed to replace clip");
console.log(
JSON.stringify({ error: "Failed to replace clip", details: error })
);
}
};
input.click();
};
const renderElementContent = () => {
if (element.type === "text") {
return (
<div className="w-full h-full flex items-center justify-start pl-2">
<span className="text-xs text-foreground/80 truncate">
{element.content}
</span>
</div>
);
}
// Render media element ->
const mediaItem = mediaItems.find((item) => item.id === element.mediaId);
if (!mediaItem) {
return (
<span className="text-xs text-foreground/80 truncate">
{element.name}
</span>
);
}
const TILE_ASPECT_RATIO = 16 / 9;
if (mediaItem.type === "image") {
// Calculate tile size based on 16:9 aspect ratio
const trackHeight = getTrackHeight(track.type);
const tileHeight = trackHeight - 8; // Account for padding
const tileWidth = tileHeight * TILE_ASPECT_RATIO;
return (
<div className="w-full h-full flex items-center justify-center">
<div className="bg-[#004D52] py-3 w-full h-full relative">
{/* Background with tiled images */}
<div
className="absolute top-3 bottom-3 left-0 right-0"
style={{
backgroundImage: mediaItem.url
? `url(${mediaItem.url})`
: "none",
backgroundRepeat: "repeat-x",
backgroundSize: `${tileWidth}px ${tileHeight}px`,
backgroundPosition: "left center",
pointerEvents: "none",
}}
aria-label={`Tiled background of ${mediaItem.name}`}
/>
{/* Overlay with vertical borders */}
<div
className="absolute top-3 bottom-3 left-0 right-0 pointer-events-none"
style={{
backgroundImage: `repeating-linear-gradient(
to right,
transparent 0px,
transparent ${tileWidth - 1}px,
rgba(255, 255, 255, 0.6) ${tileWidth - 1}px,
rgba(255, 255, 255, 0.6) ${tileWidth}px
)`,
backgroundPosition: "left center",
}}
/>
</div>
</div>
);
}
const VIDEO_TILE_PADDING = 16;
const OVERLAY_SPACE_MULTIPLIER = 1.5;
if (mediaItem.type === "video" && mediaItem.thumbnailUrl) {
const trackHeight = getTrackHeight(track.type);
const tileHeight = trackHeight - VIDEO_TILE_PADDING;
const tileWidth = tileHeight * TILE_ASPECT_RATIO;
return (
<div className="w-full h-full flex items-center gap-2">
<div className="flex-1 h-full relative overflow-hidden">
{/* Background with tiled thumbnails */}
<div
className="absolute inset-0"
style={{
backgroundImage: mediaItem.thumbnailUrl
? `url(${mediaItem.thumbnailUrl})`
: "none",
backgroundRepeat: "repeat-x",
backgroundSize: `${tileWidth}px ${tileHeight}px`,
backgroundPosition: "left center",
pointerEvents: "none",
}}
aria-label={`Tiled thumbnail of ${mediaItem.name}`}
/>
{/* Overlay with vertical borders */}
<div
className="absolute inset-0 pointer-events-none"
style={{
backgroundImage: `repeating-linear-gradient(
to right,
transparent 0px,
transparent ${tileWidth - 1}px,
rgba(255, 255, 255, 0.6) ${tileWidth - 1}px,
rgba(255, 255, 255, 0.6) ${tileWidth}px
)`,
backgroundPosition: "left center",
}}
/>
</div>
{elementWidth > tileWidth * OVERLAY_SPACE_MULTIPLIER ? (
<div className="absolute right-2 top-1/2 -translate-y-1/2 bg-black/70 text-white text-xs px-2 py-1 rounded pointer-events-none max-w-[40%] truncate">
{element.name}
</div>
) : (
<span className="text-xs text-foreground/80 truncate flex-shrink-0 max-w-[120px]">
{element.name}
</span>
)}
</div>
);
}
// Render audio element ->
if (mediaItem.type === "audio") {
return (
<div className="w-full h-full flex items-center gap-2">
<div className="flex-1 min-w-0">
<AudioWaveform
audioUrl={mediaItem.url || ""}
height={24}
className="w-full"
/>
</div>
</div>
);
}
return (
<span className="text-xs text-foreground/80 truncate">
{element.name}
</span>
);
};
const handleElementMouseDown = (e: React.MouseEvent) => {
if (onElementMouseDown) {
onElementMouseDown(e, element);
}
};
return (
<ContextMenu>
<ContextMenuTrigger asChild>
<div
className={`absolute top-0 h-full select-none timeline-element ${
isBeingDragged ? "z-50" : "z-10"
}`}
style={{
left: `${elementLeft}px`,
width: `${elementWidth}px`,
}}
data-element-id={element.id}
data-track-id={track.id}
onMouseMove={resizing ? handleResizeMove : undefined}
onMouseUp={resizing ? handleResizeEnd : undefined}
onMouseLeave={resizing ? handleResizeEnd : undefined}
>
<div
className={`relative h-full rounded-[0.15rem] cursor-pointer overflow-hidden ${getTrackElementClasses(
track.type
)} ${isSelected ? "border-b-[0.5px] border-t-[0.5px] border-foreground" : ""} ${
isBeingDragged ? "z-50" : "z-10"
}`}
onClick={(e) => onElementClick && onElementClick(e, element)}
onMouseDown={handleElementMouseDown}
onContextMenu={(e) =>
onElementMouseDown && onElementMouseDown(e, element)
}
>
<div className="absolute inset-0 flex items-center h-full">
{renderElementContent()}
</div>
{isSelected && (
<>
<div
className="absolute left-0 top-0 bottom-0 w-1 cursor-w-resize bg-foreground z-50"
onMouseDown={(e) => handleResizeStart(e, element.id, "left")}
/>
<div
className="absolute right-0 top-0 bottom-0 w-1 cursor-e-resize bg-foreground z-50"
onMouseDown={(e) => handleResizeStart(e, element.id, "right")}
/>
</>
)}
</div>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={handleElementSplitContext}>
<Scissors className="h-4 w-4 mr-2" />
Split at playhead
</ContextMenuItem>
<ContextMenuItem onClick={handleElementDuplicateContext}>
<Copy className="h-4 w-4 mr-2" />
Duplicate {element.type === "text" ? "text" : "clip"}
</ContextMenuItem>
{element.type === "media" && (
<ContextMenuItem onClick={handleReplaceClip}>
<RefreshCw className="h-4 w-4 mr-2" />
Replace clip
</ContextMenuItem>
)}
<ContextMenuSeparator />
<ContextMenuItem
onClick={handleElementDeleteContext}
className="text-destructive focus:text-destructive"
>
<Trash2 className="h-4 w-4 mr-2" />
Delete {element.type === "text" ? "text" : "clip"}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}
"use client";
import { useState } from "react";
import { Button } from "../ui/button";
import {
MoreVertical,
Scissors,
Trash2,
SplitSquareHorizontal,
Music,
ChevronRight,
ChevronLeft,
Type,
Copy,
RefreshCw,
} from "lucide-react";
import { useMediaStore } from "@/stores/media-store";
import { useTimelineStore } from "@/stores/timeline-store";
import { usePlaybackStore } from "@/stores/playback-store";
import AudioWaveform from "./audio-waveform";
import { toast } from "sonner";
import { TimelineElementProps, TrackType } from "@/types/timeline";
import { useTimelineElementResize } from "@/hooks/use-timeline-element-resize";
import {
getTrackElementClasses,
TIMELINE_CONSTANTS,
getTrackHeight,
} from "@/constants/timeline-constants";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
} from "../ui/dropdown-menu";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "../ui/context-menu";
export function TimelineElement({
element,
track,
zoomLevel,
isSelected,
onElementMouseDown,
onElementClick,
}: TimelineElementProps) {
const { mediaItems } = useMediaStore();
const {
updateElementTrim,
updateElementDuration,
removeElementFromTrack,
dragState,
splitElement,
splitAndKeepLeft,
splitAndKeepRight,
separateAudio,
addElementToTrack,
replaceElementMedia,
} = useTimelineStore();
const { currentTime } = usePlaybackStore();
const [elementMenuOpen, setElementMenuOpen] = useState(false);
const {
resizing,
isResizing,
handleResizeStart,
handleResizeMove,
handleResizeEnd,
} = useTimelineElementResize({
element,
track,
zoomLevel,
onUpdateTrim: updateElementTrim,
onUpdateDuration: updateElementDuration,
});
const effectiveDuration =
element.duration - element.trimStart - element.trimEnd;
const elementWidth = Math.max(
TIMELINE_CONSTANTS.ELEMENT_MIN_WIDTH,
effectiveDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel
);
// Use real-time position during drag, otherwise use stored position
const isBeingDragged = dragState.elementId === element.id;
const elementStartTime =
isBeingDragged && dragState.isDragging
? dragState.currentTime
: element.startTime;
const elementLeft = elementStartTime * 50 * zoomLevel;
const handleDeleteElement = () => {
removeElementFromTrack(track.id, element.id);
setElementMenuOpen(false);
};
const handleSplitElement = () => {
const effectiveStart = element.startTime;
const effectiveEnd =
element.startTime +
(element.duration - element.trimStart - element.trimEnd);
if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
toast.error("Playhead must be within element to split");
return;
}
const secondElementId = splitElement(track.id, element.id, currentTime);
if (!secondElementId) {
toast.error("Failed to split element");
}
setElementMenuOpen(false);
};
const handleSplitAndKeepLeft = () => {
const effectiveStart = element.startTime;
const effectiveEnd =
element.startTime +
(element.duration - element.trimStart - element.trimEnd);
if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
toast.error("Playhead must be within element");
return;
}
splitAndKeepLeft(track.id, element.id, currentTime);
setElementMenuOpen(false);
};
const handleSplitAndKeepRight = () => {
const effectiveStart = element.startTime;
const effectiveEnd =
element.startTime +
(element.duration - element.trimStart - element.trimEnd);
if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
toast.error("Playhead must be within element");
return;
}
splitAndKeepRight(track.id, element.id, currentTime);
setElementMenuOpen(false);
};
const handleSeparateAudio = () => {
if (element.type !== "media") {
toast.error("Audio separation only available for media elements");
return;
}
const mediaItem = mediaItems.find((item) => item.id === element.mediaId);
if (!mediaItem || mediaItem.type !== "video") {
toast.error("Audio separation only available for video elements");
return;
}
const audioElementId = separateAudio(track.id, element.id);
if (!audioElementId) {
toast.error("Failed to separate audio");
}
setElementMenuOpen(false);
};
const canSplitAtPlayhead = () => {
const effectiveStart = element.startTime;
const effectiveEnd =
element.startTime +
(element.duration - element.trimStart - element.trimEnd);
return currentTime > effectiveStart && currentTime < effectiveEnd;
};
const canSeparateAudio = () => {
if (element.type !== "media") return false;
const mediaItem = mediaItems.find((item) => item.id === element.mediaId);
return mediaItem?.type === "video" && track.type === "media";
};
const handleElementSplitContext = () => {
const effectiveStart = element.startTime;
const effectiveEnd =
element.startTime +
(element.duration - element.trimStart - element.trimEnd);
if (currentTime > effectiveStart && currentTime < effectiveEnd) {
const secondElementId = splitElement(track.id, element.id, currentTime);
if (!secondElementId) {
toast.error("Failed to split element");
}
} else {
toast.error("Playhead must be within element to split");
}
};
const handleElementDuplicateContext = () => {
const { id, ...elementWithoutId } = element;
addElementToTrack(track.id, {
...elementWithoutId,
name: element.name + " (copy)",
startTime:
element.startTime +
(element.duration - element.trimStart - element.trimEnd) +
0.1,
});
};
const handleElementDeleteContext = () => {
removeElementFromTrack(track.id, element.id);
};
const handleReplaceClip = () => {
if (element.type !== "media") {
toast.error("Replace is only available for media clips");
return;
}
// Create a file input to select replacement media
const input = document.createElement("input");
input.type = "file";
input.accept = "video/*,audio/*,image/*";
input.onchange = async (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) return;
try {
const success = await replaceElementMedia(track.id, element.id, file);
if (success) {
toast.success("Clip replaced successfully");
} else {
toast.error("Failed to replace clip");
}
} catch (error) {
toast.error("Failed to replace clip");
console.log(
JSON.stringify({ error: "Failed to replace clip", details: error })
);
}
};
input.click();
};
const renderElementContent = () => {
if (element.type === "text") {
return (
<div className="w-full h-full flex items-center justify-start pl-2">
<span className="text-xs text-foreground/80 truncate">
{element.content}
</span>
</div>
);
}
// Render media element ->
const mediaItem = mediaItems.find((item) => item.id === element.mediaId);
if (!mediaItem) {
return (
<span className="text-xs text-foreground/80 truncate">
{element.name}
</span>
);
}
const TILE_ASPECT_RATIO = 16 / 9;
if (mediaItem.type === "image") {
// Calculate tile size based on 16:9 aspect ratio
const trackHeight = getTrackHeight(track.type);
const tileHeight = trackHeight - 8; // Account for padding
const tileWidth = tileHeight * TILE_ASPECT_RATIO;
return (
<div className="w-full h-full flex items-center justify-center">
<div className="bg-[#004D52] py-3 w-full h-full relative">
{/* Background with tiled images */}
<div
className="absolute top-3 bottom-3 left-0 right-0"
style={{
backgroundImage: mediaItem.url
? `url(${mediaItem.url})`
: "none",
backgroundRepeat: "repeat-x",
backgroundSize: `${tileWidth}px ${tileHeight}px`,
backgroundPosition: "left center",
pointerEvents: "none",
}}
aria-label={`Tiled background of ${mediaItem.name}`}
/>
{/* Overlay with vertical borders */}
<div
className="absolute top-3 bottom-3 left-0 right-0 pointer-events-none"
style={{
backgroundImage: `repeating-linear-gradient(
to right,
transparent 0px,
transparent ${tileWidth - 1}px,
rgba(255, 255, 255, 0.6) ${tileWidth - 1}px,
rgba(255, 255, 255, 0.6) ${tileWidth}px
)`,
backgroundPosition: "left center",
}}
/>
</div>
</div>
);
}
const VIDEO_TILE_PADDING = 16;
const OVERLAY_SPACE_MULTIPLIER = 1.5;
if (mediaItem.type === "video" && mediaItem.thumbnailUrl) {
const trackHeight = getTrackHeight(track.type);
const tileHeight = trackHeight - VIDEO_TILE_PADDING;
const tileWidth = tileHeight * TILE_ASPECT_RATIO;
return (
<div className="w-full h-full flex items-center gap-2">
<div className="flex-1 h-full relative overflow-hidden">
{/* Background with tiled thumbnails */}
<div
className="absolute inset-0"
style={{
backgroundImage: mediaItem.thumbnailUrl
? `url(${mediaItem.thumbnailUrl})`
: "none",
backgroundRepeat: "repeat-x",
backgroundSize: `${tileWidth}px ${tileHeight}px`,
backgroundPosition: "left center",
pointerEvents: "none",
}}
aria-label={`Tiled thumbnail of ${mediaItem.name}`}
/>
{/* Overlay with vertical borders */}
<div
className="absolute inset-0 pointer-events-none"
style={{
backgroundImage: `repeating-linear-gradient(
to right,
transparent 0px,
transparent ${tileWidth - 1}px,
rgba(255, 255, 255, 0.6) ${tileWidth - 1}px,
rgba(255, 255, 255, 0.6) ${tileWidth}px
)`,
backgroundPosition: "left center",
}}
/>
</div>
{elementWidth > tileWidth * OVERLAY_SPACE_MULTIPLIER ? (
<div className="absolute right-2 top-1/2 -translate-y-1/2 bg-black/70 text-white text-xs px-2 py-1 rounded pointer-events-none max-w-[40%] truncate">
{element.name}
</div>
) : (
<span className="text-xs text-foreground/80 truncate flex-shrink-0 max-w-[120px]">
{element.name}
</span>
)}
</div>
);
}
// Render audio element ->
if (mediaItem.type === "audio") {
return (
<div className="w-full h-full flex items-center gap-2">
<div className="flex-1 min-w-0">
<AudioWaveform
audioUrl={mediaItem.url || ""}
height={24}
className="w-full"
/>
</div>
</div>
);
}
return (
<span className="text-xs text-foreground/80 truncate">
{element.name}
</span>
);
};
const handleElementMouseDown = (e: React.MouseEvent) => {
if (onElementMouseDown) {
onElementMouseDown(e, element);
}
};
return (
<ContextMenu>
<ContextMenuTrigger asChild>
<div
className={`absolute top-0 h-full select-none timeline-element ${
isBeingDragged ? "z-50" : "z-10"
}`}
style={{
left: `${elementLeft}px`,
width: `${elementWidth}px`,
}}
data-element-id={element.id}
data-track-id={track.id}
onMouseMove={resizing ? handleResizeMove : undefined}
onMouseUp={resizing ? handleResizeEnd : undefined}
onMouseLeave={resizing ? handleResizeEnd : undefined}
>
<div
className={`relative h-full rounded-[0.15rem] cursor-pointer overflow-hidden ${getTrackElementClasses(
track.type
)} ${isSelected ? "border-b-[0.5px] border-t-[0.5px] border-foreground" : ""} ${
isBeingDragged ? "z-50" : "z-10"
}`}
onClick={(e) => onElementClick && onElementClick(e, element)}
onMouseDown={handleElementMouseDown}
onContextMenu={(e) =>
onElementMouseDown && onElementMouseDown(e, element)
}
>
<div className="absolute inset-0 flex items-center h-full">
{renderElementContent()}
</div>
{isSelected && (
<>
<div
className="absolute left-0 top-0 bottom-0 w-1 cursor-w-resize bg-foreground z-50"
onMouseDown={(e) => handleResizeStart(e, element.id, "left")}
/>
<div
className="absolute right-0 top-0 bottom-0 w-1 cursor-e-resize bg-foreground z-50"
onMouseDown={(e) => handleResizeStart(e, element.id, "right")}
/>
</>
)}
</div>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={handleElementSplitContext}>
<Scissors className="h-4 w-4 mr-2" />
Split at playhead
</ContextMenuItem>
<ContextMenuItem onClick={handleElementDuplicateContext}>
<Copy className="h-4 w-4 mr-2" />
Duplicate {element.type === "text" ? "text" : "clip"}
</ContextMenuItem>
{element.type === "media" && (
<ContextMenuItem onClick={handleReplaceClip}>
<RefreshCw className="h-4 w-4 mr-2" />
Replace clip
</ContextMenuItem>
)}
<ContextMenuSeparator />
<ContextMenuItem
onClick={handleElementDeleteContext}
className="text-destructive focus:text-destructive"
>
<Trash2 className="h-4 w-4 mr-2" />
Delete {element.type === "text" ? "text" : "clip"}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}
@@ -1,219 +0,0 @@
"use client";
import type { TrackType } from "@/types/timeline";
import {
ArrowLeftToLine,
ArrowRightToLine,
Copy,
Pause,
Play,
Scissors,
Snowflake,
SplitSquareHorizontal,
Trash2,
} from "lucide-react";
import { Button } from "../ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../ui/select";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "../ui/tooltip";
interface TimelineToolbarProps {
isPlaying: boolean;
currentTime: number;
duration: number;
speed: number;
tracks: any[];
toggle: () => void;
setSpeed: (speed: number) => void;
addTrack: (type: TrackType) => string;
addClipToTrack: (trackId: string, clip: any) => void;
handleSplitSelected: () => void;
handleDuplicateSelected: () => void;
handleFreezeSelected: () => void;
handleDeleteSelected: () => void;
}
export function TimelineToolbar({
isPlaying,
currentTime,
duration,
speed,
tracks,
toggle,
setSpeed,
addTrack,
addClipToTrack,
handleSplitSelected,
handleDuplicateSelected,
handleFreezeSelected,
handleDeleteSelected,
}: TimelineToolbarProps) {
return (
<div className="border-b flex items-center px-2 py-1 gap-1">
<TooltipProvider delayDuration={500}>
{/* Play/Pause Button */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="text"
size="icon"
onClick={toggle}
className="mr-2"
>
{isPlaying ? (
<Pause className="h-4 w-4" />
) : (
<Play className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
{isPlaying ? "Pause (Space)" : "Play (Space)"}
</TooltipContent>
</Tooltip>
<div className="w-px h-6 bg-border mx-1" />
{/* Time Display */}
<div
className="text-xs text-muted-foreground font-mono px-2"
style={{ minWidth: "18ch", textAlign: "center" }}
>
{currentTime.toFixed(1)}s / {duration.toFixed(1)}s
</div>
{/* Test Clip Button - for debugging */}
{tracks.length === 0 && (
<>
<div className="w-px h-6 bg-border mx-1" />
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={() => {
const trackId = addTrack("media");
addClipToTrack(trackId, {
mediaId: "test",
name: "Test Clip",
duration: 5,
startTime: 0,
trimStart: 0,
trimEnd: 0,
});
}}
className="text-xs"
>
Add Test Clip
</Button>
</TooltipTrigger>
<TooltipContent>Add a test clip to try playback</TooltipContent>
</Tooltip>
</>
)}
<div className="w-px h-6 bg-border mx-1" />
<Tooltip>
<TooltipTrigger asChild>
<Button variant="text" size="icon" onClick={handleSplitSelected}>
<Scissors className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Split clip (S)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="text" size="icon">
<ArrowLeftToLine className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Split and keep left (A)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="text" size="icon">
<ArrowRightToLine className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Split and keep right (D)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="text" size="icon">
<SplitSquareHorizontal className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Separate audio (E)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="text"
size="icon"
onClick={handleDuplicateSelected}
>
<Copy className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Duplicate clip (Ctrl+D)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="text" size="icon" onClick={handleFreezeSelected}>
<Snowflake className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Freeze frame (F)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="text" size="icon" onClick={handleDeleteSelected}>
<Trash2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Delete clip (Delete)</TooltipContent>
</Tooltip>
<div className="w-px h-6 bg-border mx-1" />
{/* Speed Control */}
<Tooltip>
<TooltipTrigger asChild>
<Select
value={speed.toFixed(1)}
onValueChange={(value) => setSpeed(parseFloat(value))}
>
<SelectTrigger className="w-[90px] h-8">
<SelectValue placeholder="1.0x" />
</SelectTrigger>
<SelectContent>
<SelectItem value="0.5">0.5x</SelectItem>
<SelectItem value="1.0">1.0x</SelectItem>
<SelectItem value="1.5">1.5x</SelectItem>
<SelectItem value="2.0">2.0x</SelectItem>
</SelectContent>
</Select>
</TooltipTrigger>
<TooltipContent>Playback Speed</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+14 -1
View File
@@ -1,9 +1,22 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { env } from "./env";
import { getSessionCookie } from "better-auth/cookies";
export async function middleware(request: NextRequest) {
const protectedPaths = ["/editor", "/projects"];
const sessionCookie = getSessionCookie(request);
const canAccessProtectedPaths = (request: NextRequest) => {
if (env.NODE_ENV === "development") {
return true;
}
if (sessionCookie) {
return true;
}
return false;
};
// Handle fuckcapcut.com domain redirect
if (request.headers.get("host") === "fuckcapcut.com") {
@@ -12,7 +25,7 @@ export async function middleware(request: NextRequest) {
const path = request.nextUrl.pathname;
if (protectedPaths.includes(path) && env.NODE_ENV === "production") {
if (protectedPaths.includes(path) && !canAccessProtectedPaths(request)) {
const homeUrl = new URL("/", request.url);
homeUrl.searchParams.set("redirect", request.url);
return NextResponse.redirect(homeUrl);
+23 -1
View File
@@ -213,7 +213,29 @@ export const useMediaStore = create<MediaStore>((set, get) => ({
try {
const mediaItems = await storageService.loadAllMediaItems(projectId);
set({ mediaItems });
// Regenerate thumbnails for video items
const updatedMediaItems = await Promise.all(
mediaItems.map(async (item) => {
if (item.type === "video" && item.file) {
try {
const { thumbnailUrl, width, height } = await generateVideoThumbnail(item.file);
return {
...item,
thumbnailUrl,
width: width || item.width,
height: height || item.height
};
} catch (error) {
console.error(`Failed to regenerate thumbnail for video ${item.id}:`, error);
return item;
}
}
return item;
})
);
set({ mediaItems: updatedMediaItems });
} catch (error) {
console.error("Failed to load media items:", error);
} finally {