mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
projects page re-design and a ton of other stuff
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+874
-318
File diff suppressed because it is too large
Load Diff
@@ -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 });
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -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"}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
));
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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),
|
||||
}));
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
Reference in New Issue
Block a user