projects page re-design and a ton of other stuff

This commit is contained in:
Maze Winther
2026-01-26 16:10:16 +01:00
parent 954160d03b
commit 0c10bbf5ce
33 changed files with 1794 additions and 1185 deletions
+1
View File
@@ -27,6 +27,7 @@
"@opencut/env": "workspace:*",
"@opencut/ui": "workspace:*",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "^1.2.8",
+6 -6
View File
@@ -14,8 +14,8 @@
--card-foreground: hsl(0 0% 11%);
--popover: hsl(0, 0%, 100%);
--popover-foreground: hsl(0 0% 2%);
--primary: hsl(210, 91%, 49%);
--primary-foreground: hsl(0 0% 91%);
--primary: hsl(203, 100%, 50%);
--primary-foreground: hsl(0, 0%, 100%);
--secondary: hsl(216, 13%, 92%);
--secondary-foreground: hsl(0 0% 2%);
--muted: hsl(0 0% 85.1%);
@@ -26,7 +26,7 @@
--destructive-foreground: hsl(0, 0%, 100%);
--constructive: hsl(141, 71%, 48%);
--constructive-foreground: hsl(0, 0%, 100%);
--border: hsl(0 0% 83%);
--border: hsl(0 0% 88%);
--input: hsl(0 0% 85.1%);
--ring: hsl(0, 0%, 55%);
--chart-1: hsl(220 70% 50%);
@@ -42,8 +42,8 @@
--sidebar-accent-foreground: hsl(0 0% 2%);
--sidebar-border: hsl(0 0% 85.1%);
--sidebar-ring: hsl(0 0% 16.9%);
--panel-background: hsl(216 13% 92%);
--panel-accent: hsl(216, 8%, 86%);
--panel-background: hsl(216 13% 96%);
--panel-accent: hsl(216, 8%, 90%);
--radius: 1rem;
@@ -126,7 +126,7 @@
/* Font sizes */
--text-base: 0.95rem;
--text-base--line-height: calc(1.5 / 0.95);
--text-xs: 0.8rem;
--text-xs: 0.75rem;
--text-xs--line-height: calc(1 / 0.8);
/* Border radius */
-643
View File
@@ -1,643 +0,0 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
import type { KeyboardEvent, MouseEvent } from "react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { DeleteProjectDialog } from "@/components/editor/delete-project-dialog";
import { MigrationDialog } from "@/components/editor/migration-dialog";
import { RenameProjectDialog } from "@/components/rename-project-dialog";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useEditor } from "@/hooks/use-editor";
import type { TProjectMetadata } from "@/types/project";
import { formatDate } from "@/utils/date";
import { HugeiconsIcon } from "@hugeicons/react";
import {
ArrowHorizontalIcon,
ArrowLeft01Icon,
Calendar04Icon,
Delete02Icon,
MultiplicationSignIcon,
PlusSignIcon,
Search01Icon,
SortingNineOneIcon,
Video01Icon,
} from "@hugeicons/core-free-icons";
export default function ProjectsPage() {
const [isSelectionMode, setIsSelectionMode] = useState(false);
const [selectedProjects, setSelectedProjects] = useState<Set<string>>(
new Set(),
);
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [sortOption, setSortOption] = useState("createdAt-desc");
const router = useRouter();
const editor = useEditor();
useEffect(() => {
if (!editor.project.getIsInitialized()) {
editor.project.loadAllProjects();
}
}, [editor.project]);
const handleCreateProject = async () => {
try {
const projectId = await editor.project.createNewProject({
name: "New project",
});
router.push(`/editor/${projectId}`);
} catch (error) {
toast.error("Failed to create project", {
description:
error instanceof Error ? error.message : "Please try again",
});
}
};
const toggleSortOption = ({
sortField,
}: {
sortField: "createdAt" | "name";
}) => {
const isSameField = sortOption.startsWith(sortField);
const nextSortOption = isSameField
? `${sortField}-${sortOption.endsWith("asc") ? "desc" : "asc"}`
: `${sortField}-asc`;
setSortOption(nextSortOption);
};
const handleSelectProject = ({
projectId,
checked,
}: {
projectId: string;
checked: boolean;
}) => {
const newSelected = new Set(selectedProjects);
if (checked) {
newSelected.add(projectId);
} else {
newSelected.delete(projectId);
}
setSelectedProjects(newSelected);
};
const _handleSelectAll = ({ checked }: { checked: boolean }) => {
if (checked) {
setSelectedProjects(
new Set(projectsToDisplay.map((project) => project.id)),
);
} else {
setSelectedProjects(new Set());
}
};
const handleCancelSelection = () => {
setIsSelectionMode(false);
setSelectedProjects(new Set());
};
const handleBulkDelete = async () => {
try {
await Promise.all(
Array.from(selectedProjects).map((projectId) =>
editor.project.deleteProject({ id: projectId }),
),
);
} catch (error) {
toast.error("Failed to delete projects", {
description:
error instanceof Error ? error.message : "Please try again",
});
} finally {
setSelectedProjects(new Set());
setIsSelectionMode(false);
setIsBulkDeleteDialogOpen(false);
}
};
const projectsToDisplay = editor.project.getFilteredAndSortedProjects({
searchQuery,
sortOption,
});
const _isAllSelected =
projectsToDisplay.length > 0 &&
selectedProjects.size === projectsToDisplay.length;
const _hasSomeSelected =
selectedProjects.size > 0 &&
selectedProjects.size < projectsToDisplay.length;
const isLoading = editor.project.getIsLoading();
const isInitialized = editor.project.getIsInitialized();
return (
<div className="bg-background min-h-screen">
<MigrationDialog />
<div className="flex h-16 w-full items-center justify-between px-6 pt-2">
<Link
href="/"
className="hover:text-muted-foreground flex items-center gap-1 transition-colors"
>
<HugeiconsIcon icon={ArrowLeft01Icon} className="size-5! shrink-0" />
<span className="text-sm font-medium">Back</span>
</Link>
<div className="block md:hidden">
{isSelectionMode ? (
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={handleCancelSelection}
>
<HugeiconsIcon icon={MultiplicationSignIcon} />
Cancel
</Button>
{selectedProjects.size > 0 && (
<Button
variant="destructive"
size="sm"
onClick={() => setIsBulkDeleteDialogOpen(true)}
>
<HugeiconsIcon icon={Delete02Icon} />
Delete ({selectedProjects.size})
</Button>
)}
</div>
) : (
<CreateButton onClick={handleCreateProject} />
)}
</div>
</div>
<main className="mx-auto max-w-6xl px-6 pt-6 pb-6">
<div className="mb-8 flex items-center justify-between">
<div className="flex flex-col gap-3">
<h1 className="text-2xl font-bold tracking-tight md:text-3xl">
Your projects
</h1>
<p className="text-muted-foreground">
{projectsToDisplay.length}{" "}
{projectsToDisplay.length === 1 ? "project" : "projects"}
{isSelectionMode && selectedProjects.size > 0 && (
<span className="text-primary ml-2">
{selectedProjects.size} selected
</span>
)}
</p>
</div>
<div className="hidden md:block">
{isSelectionMode ? (
<div className="flex items-center gap-2">
<Button variant="outline" onClick={handleCancelSelection}>
<HugeiconsIcon icon={MultiplicationSignIcon} />
Cancel
</Button>
<Button
variant="destructive"
disabled={selectedProjects.size === 0}
onClick={() => setIsBulkDeleteDialogOpen(true)}
>
<HugeiconsIcon icon={Delete02Icon} />
Delete {selectedProjects.size} projects
</Button>
</div>
) : (
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={() => setIsSelectionMode(true)}
disabled={projectsToDisplay.length === 0}
>
Select projects
</Button>
<CreateButton onClick={handleCreateProject} />
</div>
)}
</div>
</div>
<div className="mb-4 flex items-center justify-between gap-4">
<div className="max-w-72 flex-1">
<Input
placeholder="Search projects..."
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
/>
</div>
<div className="flex items-center gap-0">
<TooltipProvider>
<Tooltip>
<DropdownMenu>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
aria-label="sort projects"
size="icon"
variant="outline"
className="size-9 items-center justify-center"
>
<HugeiconsIcon
icon={SortingNineOneIcon}
aria-hidden="true"
/>
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() =>
toggleSortOption({ sortField: "createdAt" })
}
>
Created{" "}
{sortOption.startsWith("createdAt") &&
(sortOption.endsWith("asc") ? "↑" : "↓")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => toggleSortOption({ sortField: "name" })}
>
Name{" "}
{sortOption.startsWith("name") &&
(sortOption.endsWith("asc") ? "↑" : "↓")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<TooltipContent>
<p>
Sort by{" "}
{sortOption.startsWith("createdAt") ? "date" : "name"} (
{sortOption.endsWith("asc") ? "ascending" : "descending"})
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
{isLoading || !isInitialized ? (
<ProjectsLoader />
) : projectsToDisplay.length === 0 ? (
<EmptyState
search={{
query: searchQuery,
onClearSearch: () => setSearchQuery(""),
}}
onCreateProject={handleCreateProject}
/>
) : (
<div className="xs:grid-cols-2 grid grid-cols-1 gap-6 sm:grid-cols-3 lg:grid-cols-4">
{projectsToDisplay.map((project) => (
<ProjectCard
key={project.id}
project={project}
isSelectionMode={isSelectionMode}
isSelected={selectedProjects.has(project.id)}
onSelect={handleSelectProject}
/>
))}
</div>
)}
</main>
<DeleteProjectDialog
isOpen={isBulkDeleteDialogOpen}
onOpenChange={setIsBulkDeleteDialogOpen}
onConfirm={handleBulkDelete}
/>
</div>
);
}
interface ProjectCardProps {
project: TProjectMetadata;
isSelectionMode?: boolean;
isSelected?: boolean;
onSelect?: ({
projectId,
checked,
}: {
projectId: string;
checked: boolean;
}) => void;
}
function ProjectCard({
project,
isSelectionMode = false,
isSelected = false,
onSelect,
}: ProjectCardProps) {
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
const editor = useEditor();
const handleDeleteProject = async () => {
await editor.project.deleteProject({ id: project.id });
setIsDropdownOpen(false);
};
const handleRenameProject = async ({ name }: { name: string }) => {
await editor.project.renameProject({ id: project.id, name });
setIsRenameDialogOpen(false);
};
const handleDuplicateProject = async () => {
setIsDropdownOpen(false);
await editor.project.duplicateProject({ id: project.id });
};
const handleCardClick = ({
event,
}: {
event: MouseEvent<HTMLButtonElement>;
}) => {
if (isSelectionMode) {
event.preventDefault();
onSelect?.({ projectId: project.id, checked: !isSelected });
}
};
const handleCardKeyDown = ({
event,
}: {
event: KeyboardEvent<HTMLButtonElement>;
}) => {
if (isSelectionMode && (event.key === "Enter" || event.key === " ")) {
event.preventDefault();
onSelect?.({ projectId: project.id, checked: !isSelected });
}
};
const cardContent = (
<Card
className={`bg-background overflow-hidden border-none p-0 transition-all ${
isSelectionMode && isSelected ? "ring-primary ring-2" : ""
}`}
>
<div
className={`bg-muted relative aspect-square transition-opacity ${
isDropdownOpen ? "opacity-65" : "opacity-100 group-hover:opacity-65"
}`}
>
{isSelectionMode && (
<div className="absolute top-3 left-3 z-10">
<div className="bg-background/80 flex size-5 items-center justify-center rounded-full border backdrop-blur-xs">
<Checkbox
checked={isSelected}
onCheckedChange={(checked) =>
onSelect?.({
projectId: project.id,
checked: checked === true,
})
}
onClick={(event) => event.stopPropagation()}
className="size-4"
/>
</div>
</div>
)}
<div className="absolute inset-0">
{project.thumbnail ? (
<Image
src={project.thumbnail}
alt="Project thumbnail"
fill
className="object-cover"
/>
) : (
<div className="bg-muted/50 flex size-full items-center justify-center">
<HugeiconsIcon
icon={Video01Icon}
className="text-muted-foreground size-12 shrink-0"
/>
</div>
)}
</div>
</div>
<CardContent className="flex flex-col gap-1 px-0 pt-5">
<div className="flex items-start justify-between">
<h3 className="group-hover:text-foreground/90 line-clamp-2 text-sm leading-snug font-medium transition-colors">
{project.name}
</h3>
{!isSelectionMode && (
<DropdownMenu
open={isDropdownOpen}
onOpenChange={setIsDropdownOpen}
>
<DropdownMenuTrigger asChild>
<Button
aria-label="project options"
variant="text"
size="sm"
className={`ml-2 size-6 shrink-0 p-0 transition-all ${
isDropdownOpen
? "opacity-100"
: "opacity-0 group-hover:opacity-100"
}`}
onClick={(event) => event.preventDefault()}
>
<HugeiconsIcon
icon={ArrowHorizontalIcon}
aria-hidden="true"
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
onCloseAutoFocus={(event) => {
event.preventDefault();
event.stopPropagation();
}}
>
<DropdownMenuItem
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setIsDropdownOpen(false);
setIsRenameDialogOpen(true);
}}
>
Rename
</DropdownMenuItem>
<DropdownMenuItem
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
handleDuplicateProject();
}}
>
Duplicate
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setIsDropdownOpen(false);
setIsDeleteDialogOpen(true);
}}
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<div className="space-y-1">
<div className="text-muted-foreground flex items-center gap-1.5 text-sm">
<HugeiconsIcon icon={Calendar04Icon} className="size-4" />
<span>Created {formatDate({ date: project.createdAt })}</span>
</div>
</div>
</CardContent>
</Card>
);
return (
<>
{isSelectionMode ? (
<button
type="button"
onClick={(event) => handleCardClick({ event })}
onKeyDown={(event) => handleCardKeyDown({ event })}
className="group block w-full cursor-pointer text-left"
>
{cardContent}
</button>
) : (
<Link href={`/editor/${project.id}`} className="group block">
{cardContent}
</Link>
)}
<DeleteProjectDialog
isOpen={isDeleteDialogOpen}
onOpenChange={setIsDeleteDialogOpen}
onConfirm={handleDeleteProject}
/>
<RenameProjectDialog
isOpen={isRenameDialogOpen}
onOpenChange={setIsRenameDialogOpen}
onConfirm={(name) => handleRenameProject({ name })}
projectName={project.name}
/>
</>
);
}
function ProjectsLoader() {
const skeletonIds = Array.from(
{ length: 8 },
(_, index) => `skeleton-${index}`,
);
return (
<div className="xs:grid-cols-2 grid grid-cols-1 gap-6 sm:grid-cols-3 lg:grid-cols-4">
{skeletonIds.map((skeletonId) => (
<div
key={skeletonId}
className="bg-background overflow-hidden border-none p-0"
>
<Skeleton className="bg-muted/50 aspect-square w-full" />
<div className="flex flex-col gap-1.5 px-0 pt-5">
<Skeleton className="bg-muted/50 h-4 w-3/4" />
<div className="flex items-center gap-1.5">
<Skeleton className="bg-muted/50 size-4" />
<Skeleton className="bg-muted/50 h-4 w-24" />
</div>
</div>
</div>
))}
</div>
);
}
function CreateButton({ onClick }: { onClick?: () => void }) {
return (
<Button className="flex" onClick={onClick}>
<HugeiconsIcon icon={PlusSignIcon} />
<span className="text-sm font-medium">New project</span>
</Button>
);
}
function EmptyState({
search,
onCreateProject,
}: {
search: { query: string; onClearSearch: () => void };
onCreateProject: () => void;
}) {
const editor = useEditor();
const savedProjects = editor.project.getSavedProjects();
if (savedProjects.length > 0) {
return (
<div className="flex flex-col items-center justify-center gap-6 py-16 text-center">
<div className="flex flex-col items-center gap-2">
<div className="bg-muted/30 flex size-16 items-center justify-center rounded-full">
<HugeiconsIcon
icon={Search01Icon}
className="text-muted-foreground size-8"
/>
</div>
<h3 className="text-lg font-medium">No results found</h3>
<p className="text-muted-foreground max-w-md">
Your search for "{search.query}" did not return any results.
</p>
</div>
<Button onClick={search.onClearSearch} variant="outline">
Clear search
</Button>
</div>
);
}
return (
<div className="flex flex-col items-center justify-center gap-6 py-16 text-center">
<div className="flex flex-col items-center gap-2">
<div className="bg-muted/30 flex size-16 items-center justify-center rounded-full">
<HugeiconsIcon
icon={Video01Icon}
className="text-muted-foreground size-8"
/>
</div>
<h3 className="text-lg font-medium">No projects yet</h3>
<p className="text-muted-foreground max-w-md">
Start creating your first video project. Import media, edit, and
export professional videos.
</p>
</div>
<Button size="lg" className="gap-2" onClick={onCreateProject}>
<HugeiconsIcon icon={PlusSignIcon} />
Create your first project
</Button>
</div>
);
}
File diff suppressed because it is too large Load Diff
+89
View File
@@ -0,0 +1,89 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import type { TProjectSortKey } from "@/types/project";
export type ProjectsViewMode = "grid" | "list";
interface ProjectsState {
searchQuery: string;
sortKey: TProjectSortKey;
sortOrder: "asc" | "desc";
viewMode: ProjectsViewMode;
selectedProjectIds: string[];
isHydrated: boolean;
setIsHydrated: ({ isHydrated }: { isHydrated: boolean }) => void;
setSearchQuery: ({ query }: { query: string }) => void;
setSortKey: ({ sortKey }: { sortKey: TProjectSortKey }) => void;
setSortOrder: ({ sortOrder }: { sortOrder: "asc" | "desc" }) => void;
toggleSortOrder: () => void;
setViewMode: ({ viewMode }: { viewMode: ProjectsViewMode }) => void;
setSelectedProjects: ({ projectIds }: { projectIds: string[] }) => void;
clearSelectedProjects: () => void;
setProjectSelected: ({
projectId,
isSelected,
}: {
projectId: string;
isSelected: boolean;
}) => void;
}
const getNextSelectedProjectIds = ({
selectedProjectIds,
projectId,
isSelected,
}: {
selectedProjectIds: string[];
projectId: string;
isSelected: boolean;
}): string[] => {
const selectedProjectIdSet = new Set(selectedProjectIds);
if (isSelected) {
selectedProjectIdSet.add(projectId);
return Array.from(selectedProjectIdSet);
}
selectedProjectIdSet.delete(projectId);
return Array.from(selectedProjectIdSet);
};
export const useProjectsStore = create<ProjectsState>()(
persist(
(set) => ({
searchQuery: "",
sortKey: "createdAt",
sortOrder: "desc",
viewMode: "grid",
selectedProjectIds: [],
isHydrated: false,
setIsHydrated: ({ isHydrated }) => set({ isHydrated }),
setSearchQuery: ({ query }) => set({ searchQuery: query }),
setSortKey: ({ sortKey }) => set({ sortKey }),
setSortOrder: ({ sortOrder }) => set({ sortOrder }),
toggleSortOrder: () =>
set((state) => ({
sortOrder: state.sortOrder === "asc" ? "desc" : "asc",
})),
setViewMode: ({ viewMode }) => set({ viewMode }),
setSelectedProjects: ({ projectIds }) =>
set({ selectedProjectIds: projectIds }),
clearSelectedProjects: () => set({ selectedProjectIds: [] }),
setProjectSelected: ({ projectId, isSelected }) =>
set((state) => ({
selectedProjectIds: getNextSelectedProjectIds({
selectedProjectIds: state.selectedProjectIds,
projectId,
isSelected,
}),
})),
}),
{
name: "projects-view-mode",
partialize: (state) => ({ viewMode: state.viewMode }),
onRehydrateStorage: () => (state) => {
state?.setIsHydrated({ isHydrated: true });
},
},
),
);
+5 -50
View File
@@ -31,75 +31,30 @@ const roadmapItems: RoadmapItem[] = [
{
title: "Core UI",
description:
"Built the foundation - main layout, header, sidebar, timeline container, and basic component structure. Not all functionality yet, but the UI framework that everything else builds on.",
"Build the foundation - main layout, header, sidebar, timeline container, and basic component structure. Not all functionality yet, but the UI framework that everything else builds on.",
status: {
text: "Completed",
type: "complete",
},
},
{
title: "Basic Functionality",
title: "Essential functionality",
description:
"The heart of any video editor. Timeline zoom in/out, making clips longer/shorter, dragging elements around, selection, playhead scrubbing. **This part has to be fucking perfect** because it's what users interact with 99% of the time.",
"Everything that makes a video editor **useful**. Timeline interactivity, storage, effects, transitions, etc.",
status: {
text: "In progress",
type: "pending",
},
},
{
title: "Export/preview logic",
title: "Badge (potentially)",
description:
"The foundation that enables everything else. Real-time preview, video rendering, export functionality. Once this works, we can add effects, filters, transitions - basically everything that makes a video editor powerful.",
status: {
text: "Completed",
type: "complete",
},
},
{
title: "Desktop/mobile app",
description:
"The foundation that enables everything else. Real-time preview, video rendering, export functionality. Once this works, we can add effects, filters, transitions - basically everything that makes a video editor powerful.",
status: {
text: "In progress",
type: "pending",
},
},
{
title: "Text",
description:
"After media, text is the next most important thing. Font selection with custom font imports, text stroke, colors. All the text essential text properties.",
status: {
text: "In progress",
type: "pending",
},
},
{
title: "Effects",
description:
"Adding visual effects to both text and media. Blur, brightness, contrast, saturation, filters, and all the creative tools that make videos pop. This is where the magic happens.",
'An "Edit with OpenCut" badge web apps can integrate. Shows on video players.',
status: {
text: "Not started",
type: "default",
},
},
{
title: "Transitions",
description:
"Smooth transitions between clips. Fade in/out, slide, zoom, dissolve, and custom transition effects.",
status: {
text: "Not started",
type: "default",
},
},
{
title: "Refine from here",
description:
"Once we nail the above, we have a **solid foundation** to build anything. Advanced features, performance optimizations, mobile support, desktop app.",
status: {
text: "Future",
type: "info",
},
},
];
export const metadata: Metadata = {
@@ -44,6 +44,7 @@ import {
Video01Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon, type IconSvgElement } from "@hugeicons/react";
import { OcVideoIcon } from "@opencut/ui/icons";
export function MediaView() {
const editor = useEditor();
@@ -558,19 +559,18 @@ function MediaPreview({
item: MediaAsset;
variant?: "grid" | "compact";
}) {
const shouldShowVideoOverlay = variant === "grid";
const shouldShowDurationBadge = variant === "grid";
if (item.type === "image") {
return (
<div className="flex size-full items-center justify-center">
<div className="relative flex size-full items-center justify-center">
<Image
src={item.url ?? ""}
alt={item.name}
className="max-h-full w-full object-cover"
fill
sizes="100vw"
className="object-cover"
loading="lazy"
width={item.width}
height={item.height}
unoptimized
/>
</div>
@@ -584,18 +584,12 @@ function MediaPreview({
<Image
src={item.thumbnailUrl}
alt={item.name}
className="size-full rounded object-cover"
fill
sizes="100vw"
className="rounded object-cover"
loading="lazy"
unoptimized
/>
{shouldShowVideoOverlay ? (
<div className="absolute inset-0 flex items-center justify-center rounded bg-black/20">
<HugeiconsIcon
icon={Video01Icon}
className="size-6 text-white drop-shadow-md"
/>
</div>
) : null}
{shouldShowDurationBadge ? (
<MediaDurationBadge duration={item.duration} />
) : null}
@@ -12,38 +12,43 @@ export function DeleteProjectDialog({
isOpen,
onOpenChange,
onConfirm,
projectName,
projectNames,
}: {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
projectName?: string;
projectNames: string[];
}) {
const count = projectNames.length;
const isSingle = count === 1;
const singleName = isSingle ? projectNames[0] : null;
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent
onOpenAutoFocus={(e) => {
e.preventDefault();
e.stopPropagation();
onOpenAutoFocus={(event) => {
event.preventDefault();
event.stopPropagation();
}}
>
<DialogHeader>
<DialogTitle>
{projectName ? (
{singleName ? (
<>
{"Delete '"}
<span className="inline-block max-w-[300px] truncate align-bottom">
{projectName}
{singleName}
</span>
{"'?"}
</>
) : (
"Delete Project?"
`Delete ${count} projects?`
)}
</DialogTitle>
<DialogDescription>
Are you sure you want to delete this project? This action cannot be
undone.
{isSingle
? "Are you sure you want to delete this project? This action cannot be undone."
: `Are you sure you want to delete these ${count} projects? This action cannot be undone.`}
</DialogDescription>
</DialogHeader>
<DialogFooter>
@@ -24,7 +24,7 @@ import {
ArrowLeft02Icon,
Edit03Icon,
Delete02Icon,
Keyboard,
CommandIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { ShortcutsDialog } from "./shortcuts-dialog";
@@ -92,7 +92,9 @@ function ProjectDropdown() {
const handleDeleteProject = async () => {
if (activeProject) {
try {
await editor.project.deleteProject({ id: activeProject.metadata.id });
await editor.project.deleteProjects({
ids: [activeProject.metadata.id],
});
router.push("/projects");
} catch (error) {
toast.error("Failed to delete project", {
@@ -148,7 +150,7 @@ function ProjectDropdown() {
className="flex items-center gap-1.5"
onClick={() => setOpenDialog("shortcuts")}
>
<HugeiconsIcon icon={Keyboard} className="size-4" />
<HugeiconsIcon icon={CommandIcon} className="size-4" />
Keyboard shortcuts
</DropdownMenuItem>
<DropdownMenuItem asChild>
@@ -174,7 +176,7 @@ function ProjectDropdown() {
isOpen={openDialog === "delete"}
onOpenChange={(isOpen) => setOpenDialog(isOpen ? "delete" : null)}
onConfirm={handleDeleteProject}
projectName={activeProject?.metadata.name || ""}
projectNames={[activeProject?.metadata.name || ""]}
/>
<ShortcutsDialog
isOpen={openDialog === "shortcuts"}
+8 -10
View File
@@ -1,30 +1,28 @@
"use client";
import { ArrowRightIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useState } from "react";
import ReactMarkdown from "react-markdown";
import { SOCIAL_LINKS } from "@/constants/site-constants";
import { useLocalStorage } from "@/hooks/storage/use-local-storage";
import { Button } from "../ui/button";
import { Dialog, DialogContent, DialogTitle } from "../ui/dialog";
export function Onboarding() {
const [step, setStep] = useState(0);
const [isOpen, setIsOpen] = useState(false);
const [hasSeenOnboarding, setHasSeenOnboarding] = useLocalStorage({
key: "hasSeenOnboarding",
defaultValue: false,
});
useEffect(() => {
const hasSeenOnboarding = localStorage.getItem("hasSeenOnboarding");
if (!hasSeenOnboarding) {
setIsOpen(true);
}
}, []);
const isOpen = !hasSeenOnboarding;
const handleNext = () => {
setStep(step + 1);
};
const handleClose = () => {
setIsOpen(false);
localStorage.setItem("hasSeenOnboarding", "true");
setHasSeenOnboarding({ value: true });
};
const getStepTitle = () => {
@@ -0,0 +1,74 @@
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import type { TProjectMetadata } from "@/types/project";
import { formatDate } from "@/utils/date";
import { formatTimeCode } from "@/lib/time";
function InfoRow({
label,
value,
}: {
label: string;
value: string | React.ReactNode;
}) {
return (
<div className="flex justify-between items-center py-2 last:pb-0 border-b border-border/50 last:border-b-0">
<span className="text-muted-foreground text-sm">{label}</span>
<span className="text-sm font-medium">{value}</span>
</div>
);
}
export function ProjectInfoDialog({
isOpen,
onOpenChange,
project,
}: {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
project: TProjectMetadata;
}) {
const durationFormatted =
project.duration > 0
? formatTimeCode({
timeInSeconds: project.duration,
format: project.duration >= 3600 ? "HH:MM:SS" : "MM:SS",
})
: "0:00";
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle className="truncate max-w-[350px]">
{project.name}
</DialogTitle>
</DialogHeader>
<div className="flex flex-col">
<InfoRow label="Duration" value={durationFormatted} />
<InfoRow
label="Created"
value={formatDate({ date: project.createdAt })}
/>
<InfoRow
label="Modified"
value={formatDate({ date: project.updatedAt })}
/>
<InfoRow
label="Project ID"
value={
<code className="text-xs bg-muted px-1.5 py-0.5 rounded">
{project.id.slice(0, 8)}
</code>
}
/>
</div>
</DialogContent>
</Dialog>
);
}
@@ -151,7 +151,7 @@ export function TextProperties({
<Textarea
placeholder="Name"
defaultValue={element.content}
className="bg-panel-accent min-h-18 resize-none"
className="bg-panel-accent min-h-20"
onChange={(e) =>
editor.timeline.updateTextElement({
trackId,
+1 -1
View File
@@ -65,7 +65,7 @@ const AlertDialogFooter = ({
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-4",
className,
)}
{...props}
+14 -10
View File
@@ -12,25 +12,28 @@ const buttonVariants = cva(
default:
"bg-foreground text-background shadow-sm hover:bg-foreground/90",
foreground:
"bg-background text-foreground shadow-sm hover:bg-background/80",
"bg-background text-foreground",
primary:
"bg-primary text-primary-foreground shadow-sm hover:bg-primary/90",
"bg-primary text-primary-foreground hover:bg-primary/90",
"primary-gradient":
"bg-gradient-to-r from-cyan-400 to-blue-500 text-white hover:opacity-85 transition-opacity",
destructive:
"bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/80",
"bg-destructive text-destructive-foreground hover:bg-destructive/80",
"destructive-foreground":
"border bg-background hover:bg-destructive/15 text-destructive",
outline:
"border border-input bg-transparent shadow-xs transition-colors hover:bg-accent",
"border border-border bg-transparent transition-colors hover:bg-accent",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-foreground/15 border border-input",
text: "bg-transparent p-0 rounded-none opacity-100 hover:opacity-50 transition-opacity", // Instead of ghost (matches app better)
link: "text-primary underline-offset-4 hover:underline",
"bg-secondary text-secondary-foreground hover:bg-foreground/15 border border-input",
text: "bg-transparent rounded-none !opacity-100",
link: "text-primary underline-offset-4 hover:underline !p-0 !h-auto",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-full px-3 text-xs",
lg: "h-10 rounded-full p-5",
icon: "size-7",
lg: "h-10 rounded-full p-5 px-6",
icon: "size-7 rounded-sm",
text: "p-0",
},
},
defaultVariants: {
@@ -49,9 +52,10 @@ export interface ButtonProps
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? SlotPrimitive.Slot : "button";
const effectiveSize = size ?? (variant === "text" ? "text" : "default");
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
className={cn(buttonVariants({ variant, size: effectiveSize, className }))}
ref={ref}
{...props}
/>
+1 -1
View File
@@ -13,7 +13,7 @@ const Checkbox = React.forwardRef<
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer border-primary focus-visible:ring-ring data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground size-4 shrink-0 rounded-sm border shadow-sm focus-visible:ring-1 focus-visible:outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
"cursor-pointer bg-background peer focus-visible:ring-ring data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:border-primary size-4 shrink-0 shadow-xs rounded-[0.35rem] border focus-visible:ring-1 focus-visible:outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
+1 -1
View File
@@ -75,7 +75,7 @@ const DialogFooter = ({
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-4",
className,
)}
{...props}
+3 -3
View File
@@ -21,16 +21,16 @@ export function FontPicker({
return (
<Select defaultValue={defaultValue} onValueChange={onValueChange}>
<SelectTrigger
className={`bg-panel-accent h-8 w-full text-xs ${className || ""}`}
className={`bg-panel-accent h-9 w-full text-base ${className || ""}`}
>
<SelectValue placeholder="Select a font" />
<SelectValue placeholder="Select a font" className="text-sm" />
</SelectTrigger>
<SelectContent>
{FONT_OPTIONS.map((font) => (
<SelectItem
key={font.value}
value={font.value}
className="text-xs"
className="text-sn"
style={{ fontFamily: font.value }}
>
{font.label}
+26 -6
View File
@@ -1,12 +1,32 @@
"use client";
import { Eye, EyeOff, X } from "lucide-react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/utils/ui";
import { Button } from "./button";
import { forwardRef, type ComponentProps } from "react";
import { useState } from "react";
interface InputProps extends ComponentProps<"input"> {
const inputVariants = cva(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground border-border bg-background flex w-full min-w-0 rounded-full border shadow-xs outline-none file:inline-flex file:border-0 file:bg-transparent file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 focus-visible:border-primary focus-visible:ring-4 focus-visible:ring-primary/10 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
size: {
default:
"h-9 px-3 py-1 text-base file:h-7 file:text-sm md:text-sm",
sm: "h-8 px-3 text-xs file:h-6 file:text-xs",
lg: "h-10 px-4 text-base file:h-8 file:text-sm md:text-sm",
},
},
defaultVariants: {
size: "default",
},
},
);
interface InputProps
extends Omit<ComponentProps<"input">, "size">,
VariantProps<typeof inputVariants> {
showPassword?: boolean;
onShowPasswordChange?: (show: boolean) => void;
showClearIcon?: boolean;
@@ -19,6 +39,7 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
{
className,
type,
size,
containerClassName,
showPassword,
onShowPasswordChange,
@@ -55,11 +76,10 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
<input
type={inputType}
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground border-input bg-background flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[2px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
paddingRight,
className,
inputVariants({
size,
className: cn(paddingRight, className),
}),
)}
ref={ref}
value={value}
+11 -10
View File
@@ -14,12 +14,12 @@ const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const selectItemVariants = cva(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-hidden transition-opacity data-disabled:pointer-events-none data-disabled:opacity-50",
"relative flex cursor-pointer select-none items-center gap-2 rounded-xl px-2.5 py-2 text-sm text-foreground/85 outline-hidden data-[highlighted]:bg-accent/35 data-disabled:pointer-events-none data-disabled:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "focus:opacity-65 focus:text-accent-foreground",
destructive: "text-destructive focus:text-destructive/80",
default: "",
destructive: "text-destructive data-[highlighted]:bg-destructive/5 data-[highlighted]:text-destructive",
},
},
defaultVariants: {
@@ -36,6 +36,7 @@ const SelectTrigger = React.forwardRef<
ref={ref}
className={cn(
"bg-accent ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-7 w-auto cursor-pointer items-center justify-between gap-1 rounded-md px-3 py-2 text-sm whitespace-nowrap focus:ring-1 focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
"focus:border-primary focus:ring-4 focus:ring-primary/10 border-transparent transition-none",
className,
)}
{...props}
@@ -91,9 +92,7 @@ const SelectContent = React.forwardRef<
<SelectPrimitive.Content
ref={ref}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-96 min-w-32 overflow-hidden rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
"bg-popover text-popover-foreground z-50 max-h-96 min-w-32 overflow-hidden rounded-2xl border p-2 shadow-lg",
className,
)}
position={position}
@@ -106,7 +105,6 @@ const SelectContent = React.forwardRef<
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-(--radix-select-trigger-height) w-full min-w-(--radix-select-trigger-width)",
)}
@@ -125,7 +123,10 @@ const SelectLabel = React.forwardRef<
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
className={cn(
"px-3 pb-2 pt-1 text-[11px] font-bold uppercase tracking-wider text-muted-foreground",
className,
)}
{...props}
/>
));
@@ -139,7 +140,7 @@ const SelectItem = React.forwardRef<
>(({ className, children, variant = "default", ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(selectItemVariants({ variant }), className)}
className={cn(selectItemVariants({ variant }), "pr-8 pl-2", className)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
@@ -158,7 +159,7 @@ const SelectSeparator = React.forwardRef<
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("bg-foreground/10 -mx-1 my-1 h-px", className)}
className={cn("bg-border/60 mx-1 my-2 h-px", className)}
{...props}
/>
));
+1 -1
View File
@@ -98,7 +98,7 @@ const SheetFooter = ({
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-4",
className,
)}
{...props}
+2 -2
View File
@@ -9,8 +9,8 @@ const Textarea = React.forwardRef<
return (
<textarea
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input bg-accent/50 flex min-h-[60px] w-full rounded-md border px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[2px]",
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input bg-accent/50 flex min-h-[60px] w-full rounded-md border px-3 py-2 text-base shadow-xs outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-primary focus-visible:ring-4 focus-visible:ring-primary/10",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className,
)}
+144 -70
View File
@@ -2,6 +2,8 @@ import type { EditorCore } from "@/core";
import type {
TProject,
TProjectMetadata,
TProjectSortKey,
TProjectSortOption,
TProjectSettings,
} from "@/types/project";
import type { ExportOptions, ExportResult } from "@/types/export";
@@ -15,7 +17,7 @@ import {
DEFAULT_CANVAS_SIZE,
DEFAULT_COLOR,
} from "@/constants/project-constants";
import { buildDefaultScene } from "@/lib/scenes";
import { buildDefaultScene, getProjectDurationFromScenes } from "@/lib/scenes";
import { generateThumbnail } from "@/lib/media/processing";
import {
CURRENT_STORAGE_VERSION,
@@ -90,6 +92,7 @@ export class ProjectManager {
metadata: {
id: generateUUID(),
name,
duration: getProjectDurationFromScenes({ scenes: [mainScene] }),
createdAt: new Date(),
updatedAt: new Date(),
},
@@ -178,11 +181,13 @@ export class ProjectManager {
if (!this.active) return;
try {
const scenes = this.editor.scenes.getScenes();
const updatedProject = {
...this.active,
scenes: this.editor.scenes.getScenes(),
scenes,
metadata: {
...this.active.metadata,
duration: getProjectDurationFromScenes({ scenes }),
updatedAt: new Date(),
},
};
@@ -219,25 +224,37 @@ export class ProjectManager {
}
}
async deleteProject({ id }: { id: string }): Promise<void> {
async deleteProjects({ ids }: { ids: string[] }): Promise<void> {
const uniqueIds = Array.from(new Set(ids));
if (uniqueIds.length === 0) return;
try {
await Promise.all([
storageService.deleteProjectMedia({ projectId: id }),
storageService.deleteProject({ id }),
]);
await Promise.all(
uniqueIds.map((id) =>
Promise.all([
storageService.deleteProjectMedia({ projectId: id }),
storageService.deleteProject({ id }),
]),
),
);
this.savedProjects = this.savedProjects.filter((p) => p.id !== id);
this.notify();
const idSet = new Set(uniqueIds);
this.savedProjects = this.savedProjects.filter(
(project) => !idSet.has(project.id),
);
if (this.active?.metadata.id === id) {
const shouldClearActive =
this.active && idSet.has(this.active.metadata.id);
if (shouldClearActive) {
this.active = null;
this.notify();
this.editor.media.clearAllAssets();
this.editor.scenes.clearScenes();
}
this.notify();
} catch (error) {
console.error("Failed to delete project:", error);
console.error("Failed to delete projects:", error);
}
}
@@ -291,61 +308,125 @@ export class ProjectManager {
}
}
async duplicateProject({ id }: { id: string }): Promise<string> {
async duplicateProjects({ ids }: { ids: string[] }): Promise<string[]> {
const uniqueIds = Array.from(new Set(ids));
if (uniqueIds.length === 0) return [];
try {
const result = await storageService.loadProject({ id });
if (!result) {
toast.error("Project not found", {
description: "Please try again",
});
throw new Error("Project not found");
}
const project = result.project;
const numberMatch = project.metadata.name.match(/^\((\d+)\)\s+(.+)$/);
const baseName = numberMatch ? numberMatch[2] : project.metadata.name;
const existingNumbers: number[] = [];
this.savedProjects.forEach((p) => {
const match = p.name.match(/^\((\d+)\)\s+(.+)$/);
if (match && match[2] === baseName) {
existingNumbers.push(parseInt(match[1], 10));
}
});
const nextNumber =
existingNumbers.length > 0 ? Math.max(...existingNumbers) + 1 : 1;
const newProjectId = generateUUID();
const newProject: TProject = {
...project,
metadata: {
...project.metadata,
id: newProjectId,
name: `(${nextNumber}) ${baseName}`,
createdAt: new Date(),
updatedAt: new Date(),
},
const getDuplicateBaseName = ({ name }: { name: string }) => {
const match = name.match(/^\((\d+)\)\s+(.+)$/);
const number = match ? Number.parseInt(match[1], 10) : null;
const baseName = match ? match[2] : name;
return { baseName, number };
};
await storageService.saveProject({ project: newProject });
const loadResults = await Promise.all(
uniqueIds.map(async (projectId) => {
const result = await storageService.loadProject({ id: projectId });
return { projectId, project: result?.project ?? null };
}),
);
const sourceMediaAssets = await storageService.loadAllMediaAssets({
projectId: id,
});
for (const asset of sourceMediaAssets) {
await storageService.saveMediaAsset({
projectId: newProjectId,
mediaAsset: asset,
});
const missingProjectIds = loadResults
.filter((result) => !result.project)
.map((result) => result.projectId);
if (missingProjectIds.length > 0) {
toast.error(
missingProjectIds.length === 1
? "Project not found"
: "Projects not found",
{
description:
missingProjectIds.length === 1
? "Please try again"
: "Some projects could not be found",
},
);
throw new Error(`Projects not found: ${missingProjectIds.join(", ")}`);
}
this.updateMetadata(newProject);
const projectsToDuplicate = loadResults.flatMap((result) =>
result.project ? [result.project] : [],
);
return newProjectId;
const maxNumberByBaseName = new Map<string, number>();
for (const project of this.savedProjects) {
const { baseName, number } = getDuplicateBaseName({
name: project.name,
});
if (number === null) continue;
const currentMax = maxNumberByBaseName.get(baseName);
if (currentMax === undefined || number > currentMax) {
maxNumberByBaseName.set(baseName, number);
}
}
const nextNumberByBaseName = new Map<string, number>();
for (const [baseName, maxNumber] of maxNumberByBaseName) {
nextNumberByBaseName.set(baseName, maxNumber + 1);
}
const duplicationPlans = projectsToDuplicate.map((project) => {
const { baseName } = getDuplicateBaseName({
name: project.metadata.name,
});
const nextNumber = nextNumberByBaseName.get(baseName) ?? 1;
nextNumberByBaseName.set(baseName, nextNumber + 1);
const newProjectId = generateUUID();
const newProject: TProject = {
...project,
metadata: {
...project.metadata,
id: newProjectId,
name: `(${nextNumber}) ${baseName}`,
createdAt: new Date(),
updatedAt: new Date(),
},
};
return {
newProjectId,
newProject,
sourceProjectId: project.metadata.id,
};
});
await Promise.all(
duplicationPlans.map(({ newProject }) =>
storageService.saveProject({ project: newProject }),
),
);
await Promise.all(
duplicationPlans.map(async ({ sourceProjectId, newProjectId }) => {
const sourceMediaAssets = await storageService.loadAllMediaAssets({
projectId: sourceProjectId,
});
await Promise.all(
sourceMediaAssets.map((mediaAsset) =>
storageService.saveMediaAsset({
projectId: newProjectId,
mediaAsset,
}),
),
);
}),
);
for (const { newProject } of duplicationPlans) {
this.updateMetadata(newProject);
}
return duplicationPlans.map((plan) => plan.newProjectId);
} catch (error) {
console.error("Failed to duplicate project:", error);
toast.error("Failed to duplicate project", {
console.error("Failed to duplicate projects:", error);
toast.error("Failed to duplicate projects", {
description:
error instanceof Error ? error.message : "Please try again",
});
@@ -402,25 +483,18 @@ export class ProjectManager {
sortOption,
}: {
searchQuery: string;
sortOption: string;
sortOption: TProjectSortOption;
}): TProjectMetadata[] {
const filteredProjects = this.savedProjects.filter((project) =>
project.name.toLowerCase().includes(searchQuery.toLowerCase()),
);
const [key, order] = sortOption.split("-") as [TProjectSortKey, "asc" | "desc"];
const sortedProjects = [...filteredProjects].sort((a, b) => {
const [key, order] = sortOption.split("-");
if (key !== "createdAt" && key !== "name") {
console.warn(`Invalid sort key: ${key}`);
return 0;
}
const aValue = a[key];
const bValue = b[key];
if (aValue === undefined || bValue === undefined) return 0;
if (order === "asc") {
if (aValue < bValue) return -1;
if (aValue > bValue) return 1;
@@ -0,0 +1,58 @@
import { useState, useEffect, useCallback, useRef } from "react";
export function useLocalStorage<T>({
key,
defaultValue,
}: {
key: string;
defaultValue: T;
}): [
T,
({ value }: { value: T | ((previousValue: T) => T) }) => void,
boolean,
] {
const [value, setValue] = useState<T>(defaultValue);
const [isReady, setIsReady] = useState(false);
const valueRef = useRef(defaultValue);
// avoid hydration mismatch by reading after mount
useEffect(() => {
try {
const storedValue = localStorage.getItem(key);
if (storedValue !== null) {
const parsedValue = JSON.parse(storedValue) as T;
valueRef.current = parsedValue;
setValue(parsedValue);
}
} catch {
// localstorage might be unavailable
}
setIsReady(true);
}, [key]);
// sync to localstorage after hydration
useEffect(() => {
if (!isReady) return;
try {
localStorage.setItem(key, JSON.stringify(value));
} catch {
// localstorage might be full or disabled
}
}, [key, value, isReady]);
const setValueWithCallback = useCallback(
({ value: nextValue }: { value: T | ((previousValue: T) => T) }) => {
const resolvedValue =
typeof nextValue === "function"
? (nextValue as (previousValue: T) => T)(valueRef.current)
: nextValue;
valueRef.current = resolvedValue;
setValue(resolvedValue);
},
[],
);
return [value, setValueWithCallback, isReady];
}
+14
View File
@@ -1,5 +1,6 @@
import type { TScene } from "@/types/timeline";
import { generateUUID } from "@/utils/id";
import { calculateTotalDuration } from "@/lib/timeline";
import { ensureMainTrack } from "@/lib/timeline/track-utils";
export function getMainScene({ scenes }: { scenes: TScene[] }): TScene | null {
@@ -74,6 +75,19 @@ export function findCurrentScene({
);
}
export function getProjectDurationFromScenes({
scenes,
}: {
scenes: TScene[];
}): number {
const mainScene = getMainScene({ scenes }) ?? scenes[0] ?? null;
if (!mainScene) {
return 0;
}
return calculateTotalDuration({ tracks: mainScene.tracks });
}
export function updateSceneInArray({
scenes,
sceneId,
@@ -3,7 +3,12 @@ export { StorageVersionManager } from "./version-manager";
export { runStorageMigrations } from "./runner";
import { V0toV1Migration } from "./v0-to-v1";
import { V1toV2Migration } from "./v1-to-v2";
import { V2toV3Migration } from "./v2-to-v3";
export const CURRENT_STORAGE_VERSION = 2;
export const CURRENT_STORAGE_VERSION = 3;
export const migrations = [new V0toV1Migration(), new V1toV2Migration()];
export const migrations = [
new V0toV1Migration(),
new V1toV2Migration(),
new V2toV3Migration(),
];
@@ -0,0 +1,92 @@
import { getProjectDurationFromScenes } from "@/lib/scenes";
import { IndexedDBAdapter } from "@/services/storage/indexeddb-adapter";
import type { TScene } from "@/types/timeline";
import { StorageMigration } from "./base";
type ProjectRecord = Record<string, unknown>;
export class V2toV3Migration extends StorageMigration {
from = 2;
to = 3;
async run(): Promise<void> {
const projectsAdapter = new IndexedDBAdapter<unknown>(
"video-editor-projects",
"projects",
1,
);
const projects = await projectsAdapter.getAll();
for (const project of projects) {
if (!isRecord(project)) {
continue;
}
const projectId = getProjectId({ project });
if (!projectId) {
continue;
}
if (isV3Project({ project })) {
continue;
}
const scenes = getScenes({ project });
const duration = getProjectDurationFromScenes({ scenes });
const metadataValue = project.metadata;
const metadata = isRecord(metadataValue)
? { ...metadataValue, duration }
: { duration };
const migratedProject = {
...project,
metadata,
version: 3,
};
await projectsAdapter.set(projectId, migratedProject);
}
}
}
function getProjectId({ project }: { project: ProjectRecord }): string | null {
const idValue = project.id;
if (typeof idValue === "string" && idValue.length > 0) {
return idValue;
}
const metadataValue = project.metadata;
if (!isRecord(metadataValue)) {
return null;
}
const metadataId = metadataValue.id;
if (typeof metadataId === "string" && metadataId.length > 0) {
return metadataId;
}
return null;
}
function getScenes({ project }: { project: ProjectRecord }): TScene[] {
const scenesValue = project.scenes;
if (!Array.isArray(scenesValue)) {
return [];
}
return scenesValue.filter(isRecord) as unknown as TScene[];
}
function isV3Project({ project }: { project: ProjectRecord }): boolean {
const versionValue = project.version;
if (typeof versionValue === "number" && versionValue >= 3) {
return true;
}
return isRecord(project.metadata) && typeof project.metadata.duration === "number";
}
function isRecord(value: unknown): value is ProjectRecord {
return typeof value === "object" && value !== null;
}
@@ -1,4 +1,5 @@
import type { TProject, TProjectMetadata } from "@/types/project";
import { getProjectDurationFromScenes } from "@/lib/scenes";
import type { MediaAsset } from "@/types/assets";
import { IndexedDBAdapter } from "./indexeddb-adapter";
import { OPFSAdapter } from "./opfs-adapter";
@@ -13,7 +14,7 @@ import {
migrations,
runStorageMigrations,
} from "@/services/storage/migrations";
import type { TimelineTrack } from "@/types/timeline";
import type { TimelineTrack, TScene } from "@/types/timeline";
class StorageService {
private projectsAdapter: IndexedDBAdapter<SerializedProject>;
@@ -84,6 +85,9 @@ class StorageService {
}
async saveProject({ project }: { project: TProject }): Promise<void> {
const duration =
project.metadata.duration ??
getProjectDurationFromScenes({ scenes: project.scenes });
const serializedScenes: SerializedScene[] = project.scenes.map((scene) => ({
id: scene.id,
name: scene.name,
@@ -99,6 +103,7 @@ class StorageService {
id: project.metadata.id,
name: project.metadata.name,
thumbnail: project.metadata.thumbnail,
duration,
createdAt: project.metadata.createdAt.toISOString(),
updatedAt: project.metadata.updatedAt.toISOString(),
},
@@ -141,6 +146,9 @@ class StorageService {
id: serializedProject.metadata.id,
name: serializedProject.metadata.name,
thumbnail: serializedProject.metadata.thumbnail,
duration:
serializedProject.metadata.duration ??
getProjectDurationFromScenes({ scenes }),
createdAt: new Date(serializedProject.metadata.createdAt),
updatedAt: new Date(serializedProject.metadata.updatedAt),
},
@@ -177,6 +185,11 @@ class StorageService {
id: serializedProject.metadata.id,
name: serializedProject.metadata.name,
thumbnail: serializedProject.metadata.thumbnail,
duration:
serializedProject.metadata.duration ??
getProjectDurationFromScenes({
scenes: (serializedProject.scenes ?? []) as unknown as TScene[],
}),
createdAt: new Date(serializedProject.metadata.createdAt),
updatedAt: new Date(serializedProject.metadata.updatedAt),
}));
+5
View File
@@ -19,6 +19,7 @@ export interface TProjectMetadata {
id: string;
name: string;
thumbnail?: string;
duration: number;
createdAt: Date;
updatedAt: Date;
}
@@ -37,3 +38,7 @@ export interface TProject {
settings: TProjectSettings;
version: number;
}
export type TProjectSortKey = "createdAt" | "updatedAt" | "name" | "duration";
export type TSortOrder = "asc" | "desc";
export type TProjectSortOption = `${TProjectSortKey}-${TSortOrder}`;