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

This commit is contained in:
Maze Winther
2025-07-31 13:12:02 +02:00
15 changed files with 488 additions and 48 deletions
+1 -1
View File
@@ -43,7 +43,7 @@
"input-otp": "^1.4.1",
"lucide-react": "^0.468.0",
"motion": "^12.18.1",
"next": "^15.4.3",
"next": "^15.4.5",
"next-themes": "^0.4.4",
"pg": "^8.16.2",
"radix-ui": "^1.4.2",
+55 -2
View File
@@ -5,8 +5,14 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { ExternalLink } from "lucide-react";
import Link from "next/link";
import { GithubIcon } from "@/components/icons";
import {
GithubIcon,
MarbleIcon,
VercelIcon,
DataBuddyIcon,
} from "@/components/icons";
import { Badge } from "@/components/ui/badge";
import { EXTERNAL_TOOLS } from "@/constants/site";
export const metadata: Metadata = {
title: "Contributors - OpenCut",
@@ -39,7 +45,7 @@ async function getContributors(): Promise<Contributor[]> {
"User-Agent": "OpenCut-Web-App",
},
next: { revalidate: 600 }, // 10 minutes
}
} as RequestInit
);
if (!response.ok) {
@@ -236,6 +242,53 @@ export default async function ContributorsPage() {
</div>
)}
<div className="mt-6 mb-20">
<div className="text-center mb-12">
<h2 className="text-2xl font-semibold mb-2">External Tools</h2>
<p className="text-muted-foreground">
Tools we use to build OpenCut
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6 max-w-4xl mx-auto">
{EXTERNAL_TOOLS.map((tool, index) => {
const IconComponent = {
MarbleIcon,
VercelIcon,
DataBuddyIcon,
}[tool.icon];
return (
<Link
key={tool.name}
href={tool.url}
target="_blank"
className="group block"
style={{
animationDelay: `${index * 100}ms`,
}}
>
<Card className="h-full bg-background/80 backdrop-blur-sm border-2 group-hover:border-muted-foreground/20 transition-all duration-300 group-hover:shadow-xl">
<CardContent className="p-6 text-center h-full flex flex-col">
<div className="mb-4">
<div className="w-12 h-12 mx-auto rounded-full bg-muted/50 flex items-center justify-center group-hover:bg-muted/70 transition-colors">
<IconComponent className="h-6 w-6" size={24} />
</div>
</div>
<h3 className="font-semibold text-lg mb-2 group-hover:text-foreground/80 transition-colors">
{tool.name}
</h3>
<p className="text-sm text-muted-foreground flex-1">
{tool.description}
</p>
</CardContent>
</Card>
</Link>
);
})}
</div>
</div>
<div className="mt-32 text-center">
<div className="max-w-2xl mx-auto">
<h2 className="text-3xl font-bold mb-4">Join the community</h2>
@@ -19,22 +19,21 @@ const AudioWaveform: React.FC<AudioWaveformProps> = ({
useEffect(() => {
let mounted = true;
let ws = wavesurfer.current;
const initWaveSurfer = async () => {
if (!waveformRef.current || !audioUrl) return;
try {
// Clean up any existing instance
if (wavesurfer.current) {
try {
wavesurfer.current.destroy();
} catch (e) {
// Silently ignore destroy errors
}
// Clear any existing instance safely
if (ws) {
// Instead of immediately destroying, just set to null
// We'll destroy it outside this function
wavesurfer.current = null;
}
wavesurfer.current = WaveSurfer.create({
// Create a fresh instance
const newWaveSurfer = WaveSurfer.create({
container: waveformRef.current,
waveColor: "rgba(255, 255, 255, 0.6)",
progressColor: "rgba(255, 255, 255, 0.9)",
@@ -46,15 +45,28 @@ const AudioWaveform: React.FC<AudioWaveformProps> = ({
interact: false,
});
// Assign to ref only if component is still mounted
if (mounted) {
wavesurfer.current = newWaveSurfer;
} else {
// Component unmounted during initialization, clean up
try {
newWaveSurfer.destroy();
} catch (e) {
// Ignore destroy errors
}
return;
}
// Event listeners
wavesurfer.current.on("ready", () => {
newWaveSurfer.on("ready", () => {
if (mounted) {
setIsLoading(false);
setError(false);
}
});
wavesurfer.current.on("error", (err) => {
newWaveSurfer.on("error", (err) => {
console.error("WaveSurfer error:", err);
if (mounted) {
setError(true);
@@ -62,7 +74,7 @@ const AudioWaveform: React.FC<AudioWaveformProps> = ({
}
});
await wavesurfer.current.load(audioUrl);
await newWaveSurfer.load(audioUrl);
} catch (err) {
console.error("Failed to initialize WaveSurfer:", err);
if (mounted) {
@@ -72,17 +84,50 @@ const AudioWaveform: React.FC<AudioWaveformProps> = ({
}
};
initWaveSurfer();
// First safely destroy previous instance if it exists
if (ws) {
// Use this pattern to safely destroy the previous instance
const wsToDestroy = ws;
// Detach from ref immediately
wavesurfer.current = null;
// Wait a tick to destroy so any pending operations can complete
requestAnimationFrame(() => {
try {
wsToDestroy.destroy();
} catch (e) {
// Ignore errors during destroy
}
// Only initialize new instance after destroying the old one
if (mounted) {
initWaveSurfer();
}
});
} else {
// No previous instance to clean up, initialize directly
initWaveSurfer();
}
return () => {
// Mark component as unmounted
mounted = false;
if (wavesurfer.current) {
try {
wavesurfer.current.destroy();
} catch (e) {
// Silently ignore destroy errors
}
wavesurfer.current = null;
// Store reference to current wavesurfer instance
const wsToDestroy = wavesurfer.current;
// Immediately clear the ref to prevent accessing it after unmount
wavesurfer.current = null;
// If we have an instance to clean up, do it safely
if (wsToDestroy) {
// Delay destruction to avoid race conditions
requestAnimationFrame(() => {
try {
wsToDestroy.destroy();
} catch (e) {
// Ignore destroy errors - they're expected
}
});
}
};
}, [audioUrl, height]);
@@ -234,6 +234,7 @@ export function PreviewPanel() {
tracks.forEach((track) => {
track.elements.forEach((element) => {
if (element.hidden) return;
const elementStart = element.startTime;
const elementEnd =
element.startTime +
@@ -826,6 +827,18 @@ function PreviewToolbar({
setCanvasSizeToOriginal(aspectRatio);
};
const totalDuration = getTotalDuration();
const skipBackward = () => {
const newTime = Math.max(0, currentTime - 1);
setCurrentTime(newTime);
};
const skipForward = () => {
const newTime = Math.min(totalDuration, currentTime + 1);
setCurrentTime(newTime);
};
if (isExpanded) {
return (
<FullscreenToolbar
@@ -871,19 +884,41 @@ function PreviewToolbar({
</span>
</p>
</div>
<Button
variant="text"
size="icon"
onClick={toggle}
disabled={!hasAnyElements}
className="h-auto p-0"
>
{isPlaying ? (
<Pause className="h-3 w-3" />
) : (
<Play className="h-3 w-3" />
)}
</Button>
<div className="flex items-center gap-1">
<Button
variant="text"
size="icon"
onClick={skipBackward}
disabled={!hasAnyElements}
className="h-auto p-0 text-white hover:text-white/80"
title="Skip backward 1s"
>
<SkipBack className="h-3 w-3" />
</Button>
<Button
variant="text"
size="icon"
onClick={toggle}
disabled={!hasAnyElements}
className="h-auto p-0 text-white hover:text-white/80"
>
{isPlaying ? (
<Pause className="h-3 w-3" />
) : (
<Play className="h-3 w-3" />
)}
</Button>
<Button
variant="text"
size="icon"
onClick={skipForward}
disabled={!hasAnyElements}
className="h-auto p-0 text-white hover:text-white/80"
title="Skip forward 1s"
>
<SkipForward className="h-3 w-3" />
</Button>
</div>
<div className="flex items-center gap-3">
<BackgroundSettings />
<DropdownMenu>
@@ -19,6 +19,7 @@ import {
Link,
ZoomIn,
ZoomOut,
Bookmark,
} from "lucide-react";
import {
Tooltip,
@@ -651,6 +652,30 @@ export function Timeline() {
);
}).filter(Boolean);
})()}
{/* Bookmark markers */}
{(() => {
const { activeProject } = useProjectStore.getState();
if (!activeProject?.bookmarks?.length) return null;
return activeProject.bookmarks.map((bookmarkTime, i) => (
<div
key={`bookmark-${i}`}
className="absolute top-0 h-10 w-0.5 !bg-primary cursor-pointer"
style={{
left: `${bookmarkTime * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel}px`,
}}
onClick={(e) => {
e.stopPropagation();
usePlaybackStore.getState().seek(bookmarkTime);
}}
>
<div className="absolute top-[-1px] left-[-5px] text-primary">
<Bookmark className="h-3 w-3 fill-primary" />
</div>
</div>
));
})()}
</div>
</ScrollArea>
</div>
@@ -773,6 +798,23 @@ export function Timeline() {
<ContextMenuItem onClick={(e) => e.stopPropagation()}>
Track settings (soon)
</ContextMenuItem>
{activeProject?.bookmarks?.length && activeProject.bookmarks.length > 0 && (
<>
<ContextMenuItem disabled>Bookmarks</ContextMenuItem>
{activeProject.bookmarks.map((bookmarkTime, i) => (
<ContextMenuItem
key={`bookmark-menu-${i}`}
onClick={(e) => {
e.stopPropagation();
seek(bookmarkTime);
}}
>
<Bookmark className="h-3 w-3 mr-2 inline-block" />
{bookmarkTime.toFixed(1)}s
</ContextMenuItem>
))}
</>
)}
</ContextMenuContent>
</ContextMenu>
))}
@@ -828,6 +870,7 @@ function TimelineToolbar({
toggleRippleEditing,
} = useTimelineStore();
const { currentTime, duration, isPlaying, toggle } = usePlaybackStore();
const { toggleBookmark, isBookmarked } = useProjectStore();
// Action handlers
const handleSplitSelected = () => {
@@ -957,6 +1000,13 @@ function TimelineToolbar({
const handleZoomSliderChange = (values: number[]) => {
setZoomLevel(values[0]);
};
const handleToggleBookmark = async () => {
await toggleBookmark(currentTime);
};
// Check if the current time is bookmarked
const currentBookmarked = isBookmarked(currentTime);
return (
<div className="border-b flex items-center justify-between px-2 py-1">
<div className="flex items-center gap-1 w-full">
@@ -1088,6 +1138,17 @@ function TimelineToolbar({
</TooltipTrigger>
<TooltipContent>Delete element (Delete)</TooltipContent>
</Tooltip>
<div className="w-px h-6 bg-border mx-1" />
<Tooltip>
<TooltipTrigger asChild>
<Button variant="text" size="icon" onClick={handleToggleBookmark}>
<Bookmark className={`h-4 w-4 ${currentBookmarked ? "fill-primary text-primary" : ""}`} />
</Button>
</TooltipTrigger>
<TooltipContent>
{currentBookmarked ? "Remove bookmark" : "Add bookmark"}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="flex items-center gap-1">
@@ -1121,6 +1182,8 @@ function TimelineToolbar({
</TooltipContent>
</Tooltip>
</TooltipProvider>
<div className="h-6 w-px bg-border mx-1" />
<div className="flex items-center gap-1">
<Button variant="text" size="icon" onClick={handleZoomOut}>
<ZoomOut className="h-4 w-4" />
@@ -13,6 +13,10 @@ import {
Type,
Copy,
RefreshCw,
EyeOff,
Eye,
Volume2,
VolumeX,
} from "lucide-react";
import { useMediaStore } from "@/stores/media-store";
import { useTimelineStore } from "@/stores/timeline-store";
@@ -66,11 +70,18 @@ export function TimelineElement({
addElementToTrack,
replaceElementMedia,
rippleEditingEnabled,
toggleElementHidden,
} = useTimelineStore();
const { currentTime } = usePlaybackStore();
const [elementMenuOpen, setElementMenuOpen] = useState(false);
const mediaItem =
element.type === "media"
? mediaItems.find((item) => item.id === element.mediaId)
: null;
const isAudio = mediaItem?.type === "audio";
const {
resizing,
isResizing,
@@ -141,6 +152,11 @@ export function TimelineElement({
}
};
const handleToggleElementHidden = (e: React.MouseEvent) => {
e.stopPropagation();
toggleElementHidden(track.id, element.id);
};
const handleReplaceClip = (e: React.MouseEvent) => {
e.stopPropagation();
if (element.type !== "media") {
@@ -332,7 +348,7 @@ export function TimelineElement({
track.type
)} ${isSelected ? "border-b-[0.5px] border-t-[0.5px] border-foreground" : ""} ${
isBeingDragged ? "z-50" : "z-10"
}`}
} ${element.hidden ? "opacity-50" : ""}`}
onClick={(e) => onElementClick && onElementClick(e, element)}
onMouseDown={handleElementMouseDown}
onContextMenu={(e) =>
@@ -343,6 +359,16 @@ export function TimelineElement({
{renderElementContent()}
</div>
{element.hidden && (
<div className="absolute inset-0 bg-black bg-opacity-50 flex items-center justify-center pointer-events-none">
{isAudio ? (
<VolumeX className="h-6 w-6 text-white" />
) : (
<EyeOff className="h-6 w-6 text-white" />
)}
</div>
)}
{isSelected && (
<>
<div
@@ -363,6 +389,29 @@ export function TimelineElement({
<Scissors className="h-4 w-4 mr-2" />
Split at playhead
</ContextMenuItem>
<ContextMenuItem onClick={handleToggleElementHidden}>
{isAudio ? (
element.hidden ? (
<Volume2 className="h-4 w-4 mr-2" />
) : (
<VolumeX className="h-4 w-4 mr-2" />
)
) : element.hidden ? (
<Eye className="h-4 w-4 mr-2" />
) : (
<EyeOff className="h-4 w-4 mr-2" />
)}
<span>
{isAudio
? element.hidden
? "Unmute"
: "Mute"
: element.hidden
? "Show"
: "Hide"}{" "}
{element.type === "text" ? "text" : "clip"}
</span>
</ContextMenuItem>
<ContextMenuItem onClick={handleElementDuplicateContext}>
<Copy className="h-4 w-4 mr-2" />
Duplicate {element.type === "text" ? "text" : "clip"}
+50
View File
@@ -113,3 +113,53 @@ export function BackgroundIcon({ className }: { className?: string }) {
</svg>
);
}
export function MarbleIcon({
className = "",
size = 32,
}: {
className?: string;
size?: number;
}) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
className={className}
fill="none"
viewBox="0 0 256 256"
>
<path fill="#202027" d="M0 0h256v256H0z" />
<path
fill="#fff"
d="M116.032 94.016q1.408.256 6.272 9.856 4.856 9.472 11.904 24.576l4.096 8.576a42.4 42.4 0 0 0 4.992-6.784q2.304-3.96 5.76-10.624 3.96-7.56 5.504-9.728 6.144-9.344 13.312-19.84.896-1.408 3.2-1.92a4.8 4.8 0 0 1 1.28-.128q3.072 0 6.784 2.56 3.84 2.56 4.992 5.248a4.8 4.8 0 0 1 .256 1.92q0 1.28-.128 1.92l-.64 2.944q-.256 1.664-.512 3.968a3200 3200 0 0 0-5.888 46.848 56 56 0 0 0-.512 8.064l-.256 4.096a32 32 0 0 0-.128 2.944q0 1.672-.384 2.304-.384.64-1.408.64-.768 0-2.304-.384-4.096-.896-6.272-3.2-2.176-2.432-2.432-6.656-.128-2.176-.128-6.784 0-4.736.256-14.464l.128-7.04.256-10.112q-1.152 1.024-5.888 8.064a328 328 0 0 0-9.088 14.464q-4.48 7.56-5.888 11.648-1.28 3.2-2.56 4.736-1.152 1.536-2.816 1.536-1.536 0-3.968-1.408-6.912-4.224-8.448-11.648a336 336 0 0 0-6.016-23.68 40 40 0 0 0-2.56-6.272q-1.792-3.968-2.304-4.992l-1.664-3.328q-18.68 24.832-25.344 45.568-1.28 4.352-2.432 6.272-1.152 1.792-2.688 1.792-2.432 0-6.912-4.352Q72 158.144 72 154.304q0-2.04.768-4.096a108 108 0 0 1 16-30.08l3.968-5.248a432 432 0 0 0 12.16-16.64q3.072-4.48 8.192-4.48.896 0 2.944.256"
/>
</svg>
);
}
export function DataBuddyIcon({
className = "",
size = 32,
}: {
className?: string;
size?: number;
}) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
className={className}
viewBox="0 0 8 8"
shapeRendering="crispEdges"
>
<path d="M0 0h8v8H0z" />
<path
fill="#fff"
d="M1 1h1v6H1zm1 0h4v1H2zm4 1h1v1H6zm0 1h1v1H6zm0 1h1v1H6zm0 1h1v1H6zM2 6h4v1H2zm1-3h1v1H3zm1 1h1v1H4z"
/>
</svg>
);
}
+22
View File
@@ -9,3 +9,25 @@ export const SITE_INFO = {
twitterImage: "/open-graph/default.jpg",
favicon: "/favicon.ico",
};
export const EXTERNAL_TOOLS = [
{
name: "Marble",
description:
"Modern headless CMS for content management and the blog for OpenCut",
url: "https://marblecms.com?utm_source=opencut",
icon: "MarbleIcon" as const,
},
{
name: "Vercel",
description: "Platform where we deploy and host OpenCut",
url: "https://vercel.com?utm_source=opencut",
icon: "VercelIcon" as const,
},
{
name: "Databuddy",
description: "GDPR compliant analytics and user insights for OpenCut",
url: "https://databuddy.cc?utm_source=opencut",
icon: "DataBuddyIcon" as const,
},
];
@@ -63,6 +63,8 @@ class StorageService {
backgroundColor: project.backgroundColor,
backgroundType: project.backgroundType,
blurIntensity: project.blurIntensity,
bookmarks: project.bookmarks,
fps: project.fps,
};
await this.projectsAdapter.set(project.id, serializedProject);
@@ -83,6 +85,8 @@ class StorageService {
backgroundColor: serializedProject.backgroundColor,
backgroundType: serializedProject.backgroundType,
blurIntensity: serializedProject.blurIntensity,
bookmarks: serializedProject.bookmarks,
fps: serializedProject.fps,
};
}
+1
View File
@@ -37,6 +37,7 @@ export interface StorageConfig {
export type SerializedProject = Omit<TProject, "createdAt" | "updatedAt"> & {
createdAt: string;
updatedAt: string;
bookmarks?: number[];
};
// Extend FileSystemDirectoryHandle with missing async iterator methods
+97
View File
@@ -29,6 +29,11 @@ interface ProjectStore {
) => Promise<void>;
updateProjectFps: (fps: number) => Promise<void>;
// Bookmark methods
toggleBookmark: (time: number) => Promise<void>;
isBookmarked: (time: number) => boolean;
removeBookmark: (time: number) => Promise<void>;
getFilteredAndSortedProjects: (
searchQuery: string,
sortOption: string
@@ -47,6 +52,97 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({
isInitialized: false,
invalidProjectIds: new Set<string>(),
// Implementation of bookmark methods
toggleBookmark: async (time: number) => {
const { activeProject } = get();
if (!activeProject) return;
// Round time to the nearest frame
const fps = activeProject.fps || 30;
const frameTime = Math.round(time * fps) / fps;
const bookmarks = activeProject.bookmarks || [];
let updatedBookmarks: number[];
// Check if already bookmarked
const bookmarkIndex = bookmarks.findIndex(
(bookmark) => Math.abs(bookmark - frameTime) < 0.001
);
if (bookmarkIndex !== -1) {
// Remove bookmark
updatedBookmarks = bookmarks.filter((_, i) => i !== bookmarkIndex);
} else {
// Add bookmark
updatedBookmarks = [...bookmarks, frameTime].sort((a, b) => a - b);
}
const updatedProject = {
...activeProject,
bookmarks: updatedBookmarks,
updatedAt: new Date(),
};
try {
await storageService.saveProject(updatedProject);
set({ activeProject: updatedProject });
await get().loadAllProjects(); // Refresh the list
} catch (error) {
console.error("Failed to update project bookmarks:", error);
toast.error("Failed to update bookmarks", {
description: "Please try again",
});
}
},
isBookmarked: (time: number) => {
const { activeProject } = get();
if (!activeProject || !activeProject.bookmarks) return false;
// Round time to the nearest frame
const fps = activeProject.fps || 30;
const frameTime = Math.round(time * fps) / fps;
return activeProject.bookmarks.some(
(bookmark) => Math.abs(bookmark - frameTime) < 0.001
);
},
removeBookmark: async (time: number) => {
const { activeProject } = get();
if (!activeProject || !activeProject.bookmarks) return;
// Round time to the nearest frame
const fps = activeProject.fps || 30;
const frameTime = Math.round(time * fps) / fps;
const updatedBookmarks = activeProject.bookmarks.filter(
(bookmark) => Math.abs(bookmark - frameTime) >= 0.001
);
if (updatedBookmarks.length === activeProject.bookmarks.length) {
// No bookmark found to remove
return;
}
const updatedProject = {
...activeProject,
bookmarks: updatedBookmarks,
updatedAt: new Date(),
};
try {
await storageService.saveProject(updatedProject);
set({ activeProject: updatedProject });
await get().loadAllProjects(); // Refresh the list
} catch (error) {
console.error("Failed to update project bookmarks:", error);
toast.error("Failed to remove bookmark", {
description: "Please try again",
});
}
},
createNewProject: async (name: string) => {
const newProject: TProject = {
id: generateUUID(),
@@ -57,6 +153,7 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({
backgroundColor: "#000000",
backgroundType: "color",
blurIntensity: 8,
bookmarks: [],
};
set({ activeProject: newProject });
+19
View File
@@ -126,6 +126,7 @@ interface TimelineStore {
pushHistory?: boolean
) => void;
toggleTrackMute: (trackId: string) => void;
toggleElementHidden: (trackId: string, elementId: string) => void;
// Split operations for elements
splitElement: (
@@ -868,6 +869,24 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
);
},
toggleElementHidden: (trackId, elementId) => {
get().pushHistory();
updateTracksAndSave(
get()._tracks.map((track) =>
track.id === trackId
? {
...track,
elements: track.elements.map((element) =>
element.id === elementId
? { ...element, hidden: !element.hidden }
: element
),
}
: track
)
);
},
updateTextElement: (trackId, elementId, updates) => {
get().pushHistory();
updateTracksAndSave(
+1
View File
@@ -9,4 +9,5 @@ export interface TProject {
backgroundType?: "color" | "blur";
blurIntensity?: number; // in pixels (4, 8, 18)
fps?: number;
bookmarks?: number[];
}
+1
View File
@@ -11,6 +11,7 @@ interface BaseTimelineElement {
startTime: number;
trimStart: number;
trimEnd: number;
hidden?: boolean;
}
// Media element that references MediaStore