"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 type { EditorCore } from "@/core"; import { MigrationDialog } from "@/components/editor/dialogs/migration-dialog"; import { StoragePersistenceDialog } from "@/components/editor/dialogs/storage-persistence-dialog"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { Skeleton } from "@/components/ui/skeleton"; import { useEditor } from "@/hooks/use-editor"; import { useProjectsStore } from "./store"; import type { TProjectMetadata, TProjectSortKey, TProjectSortOption, } from "@/lib/project/types"; import { formatTimecode, mediaTimeToSeconds } from "opencut-wasm"; import { formatDate } from "@/utils/date"; import { HugeiconsIcon } from "@hugeicons/react"; import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator, } from "@/components/ui/breadcrumb"; import { Calendar04Icon, GridViewIcon, LeftToRightListDashIcon, PlusSignIcon, Search01Icon, Video01Icon, MoreHorizontalIcon, Delete02Icon, Copy01Icon, Edit03Icon, ArrowDown02Icon, InformationCircleIcon, } from "@hugeicons/core-free-icons"; import { OcVideoIcon } from "@/components/icons"; import { Label } from "@/components/ui/label"; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger, } from "@/components/ui/context-menu"; import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { DeleteProjectDialog } from "@/components/editor/dialogs/delete-project-dialog"; import { ProjectInfoDialog } from "@/components/editor/dialogs/project-info-dialog"; import { RenameProjectDialog } from "@/components/editor/dialogs/rename-project-dialog"; import { cn } from "@/utils/ui"; import { ChangelogNotification } from "@/lib/changelog/components/changelog-notification"; const formatProjectDuration = ({ duration, }: { duration: number | undefined; }): string | null => { if (duration === undefined) { return null; } const durationSeconds = mediaTimeToSeconds({ time: duration }); const format = durationSeconds >= 3600 ? "HH:MM:SS" : "MM:SS"; return formatTimecode({ time: duration, format }) ?? ""; }; const VIEW_MODE_OPTIONS = [ { mode: "grid" as const, icon: GridViewIcon, label: "Grid view" }, { mode: "list" as const, icon: LeftToRightListDashIcon, label: "List view" }, ]; export default function ProjectsPage() { const { searchQuery, sortKey, sortOrder, viewMode } = useProjectsStore(); const editor = useEditor(); const sortOption: TProjectSortOption = `${sortKey}-${sortOrder}`; const isLoading = useEditor((e) => e.project.getIsLoading()); const isInitialized = useEditor((e) => e.project.getIsInitialized()); const projectsToDisplay = useEditor((e) => e.project.getFilteredAndSortedProjects({ searchQuery, sortOption }), ); useEffect(() => { if (!editor.project.getIsInitialized()) { editor.project.loadAllProjects(); } }, [editor.project]); return (
p.id)} />
{isLoading || !isInitialized ? ( ) : projectsToDisplay.length === 0 ? ( ) : (
{projectsToDisplay.map((project) => ( p.id)} /> ))}
)}
); } function ProjectsHeader() { const { viewMode, isHydrated, setViewMode } = useProjectsStore(); return (
Home All projects
{VIEW_MODE_OPTIONS.map(({ mode, icon, label }) => ( ))}
); } const SORT_LABELS: Record = { createdAt: "Created", updatedAt: "Modified", name: "Name", duration: "Duration", }; function ProjectsToolbar({ projectIds }: { projectIds: string[] }) { const { selectedProjectIds, sortKey, sortOrder, setSortOrder, setSelectedProjects, clearSelectedProjects, viewMode, setViewMode, } = useProjectsStore(); const selectedProjectCount = selectedProjectIds.length; const isAllSelected = projectIds.length > 0 && selectedProjectCount === projectIds.length; const hasSomeSelected = selectedProjectCount > 0 && selectedProjectCount < projectIds.length; const handleSelectAll = ({ checked }: { checked: boolean }) => { if (checked) { setSelectedProjects({ projectIds }); return; } clearSelectedProjects(); }; return (
{VIEW_MODE_OPTIONS.map(({ mode, icon, label }) => ( ))}
{selectedProjectCount > 0 ? : null}
); } function SearchBar({ className, collapsed, }: { className?: string; collapsed?: boolean; }) { const { searchQuery, setSearchQuery } = useProjectsStore(); return ( <> {collapsed ? (
) : (
)} ); } const PROJECT_ACTIONS = [ { id: "duplicate", label: "Duplicate", icon: Copy01Icon, variant: "outline" as const, }, { id: "delete", label: "Delete", icon: Delete02Icon, variant: "destructive-foreground" as const, }, ] as const; async function deleteProjects({ editor, ids, }: { editor: EditorCore; ids: string[]; }) { await editor.project.deleteProjects({ ids }); } async function duplicateProjects({ editor, ids, }: { editor: EditorCore; ids: string[]; }) { await editor.project.duplicateProjects({ ids }); } async function renameProject({ editor, id, name, }: { editor: EditorCore; id: string; name: string; }) { await editor.project.renameProject({ id, name }); } function ProjectActions() { const editor = useEditor(); const { selectedProjectIds, clearSelectedProjects } = useProjectsStore(); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const savedProjects = editor.project.getSavedProjects(); const selectedProjectNames = savedProjects .filter((project) => selectedProjectIds.includes(project.id)) .map((project) => project.name); const handleDuplicate = async () => { await duplicateProjects({ editor, ids: selectedProjectIds }); clearSelectedProjects(); }; const handleDeleteClick = () => { setIsDeleteDialogOpen(true); }; const handleDeleteConfirm = async () => { await deleteProjects({ editor, ids: selectedProjectIds }); clearSelectedProjects(); setIsDeleteDialogOpen(false); }; const actionHandlers: Record void> = { duplicate: handleDuplicate, delete: handleDeleteClick, }; return ( <>
{PROJECT_ACTIONS.map((action) => ( ))}
{PROJECT_ACTIONS.map((action) => ( {action.label} ))}
); } function SortDropdown({ children }: { children: React.ReactNode }) { const { sortKey, setSortKey } = useProjectsStore(); return ( {children} setSortKey({ sortKey: "createdAt" })} > Created setSortKey({ sortKey: "updatedAt" })} > Modified setSortKey({ sortKey: "name" })} > Name setSortKey({ sortKey: "duration" })} > Duration ); } function NewProjectButton() { const editor = useEditor(); const router = useRouter(); const handleCreateProject = async () => { const projectId = await editor.project.createNewProject({ name: "New project", }); router.push(`/editor/${projectId}`); }; return ( ); } function ProjectItem({ project, allProjectIds, }: { project: TProjectMetadata; allProjectIds: string[]; }) { const { selectedProjectIds, viewMode, setProjectSelected, selectProjectRange, } = useProjectsStore(); const selectedProjectIdSet = new Set(selectedProjectIds); const isSelected = selectedProjectIdSet.has(project.id); const selectedProjectCount = selectedProjectIds.length; const [isDropdownOpen, setIsDropdownOpen] = useState(false); const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [isInfoDialogOpen, setIsInfoDialogOpen] = useState(false); const editor = useEditor(); const durationLabel = formatProjectDuration({ duration: project.duration }); const isMultiSelect = selectedProjectCount > 1; const isGridView = viewMode === "grid"; const handleRename = () => setIsRenameDialogOpen(true); const handleDuplicate = async () => { await duplicateProjects({ editor, ids: [project.id] }); }; const handleDeleteClick = () => setIsDeleteDialogOpen(true); const handleInfoClick = () => setIsInfoDialogOpen(true); const handleDeleteConfirm = async () => { await deleteProjects({ editor, ids: [project.id] }); setIsDeleteDialogOpen(false); }; const handleCheckboxChange = ({ checked, shiftKey, }: { checked: boolean; shiftKey: boolean; }) => { if (shiftKey && checked) { selectProjectRange({ projectId: project.id, allProjectIds }); return; } setProjectSelected({ projectId: project.id, isSelected: checked }); }; const gridContent = (
{project.thumbnail ? ( Project thumbnail ) : (
)}
{durationLabel && (
{durationLabel}
)}

{project.name}

Created {formatDate({ date: project.createdAt })}
); const listRowContent = (
{project.thumbnail ? ( Project thumbnail ) : (
)}

{project.name}

{durationLabel ?? "—"} {formatDate({ date: project.createdAt })}
); const listContent = (
event.preventDefault()} onClick={(event) => { handleCheckboxChange({ checked: !isSelected, shiftKey: event.shiftKey, }); }} onCheckedChange={() => {}} className="size-5 shrink-0" /> {listRowContent} {!isMultiSelect && ( )}
); return ( <>
{isGridView ? ( <> {gridContent} event.preventDefault()} onClick={(event) => { handleCheckboxChange({ checked: !isSelected, shiftKey: event.shiftKey, }); }} onCheckedChange={() => {}} className={`absolute z-10 size-5 top-3 left-3 ${ isSelected || isDropdownOpen ? "opacity-100" : "opacity-0 group-hover:opacity-100" }`} /> {!isMultiSelect && ( )} ) : ( listContent )}
{ await renameProject({ editor, id: project.id, name: newName }); setIsRenameDialogOpen(false); }} /> ); } function ProjectContextMenuContent({ onRenameClick, onDuplicateClick, onDeleteClick, onInfoClick, }: { onRenameClick: () => void; onDuplicateClick: () => void; onDeleteClick: () => void; onInfoClick: () => void; }) { return ( } onClick={onRenameClick} > Rename } onClick={onDuplicateClick} > Duplicate } onClick={onInfoClick} > Info } onClick={onDeleteClick} > Delete ); } function ProjectMenu({ isOpen, onOpenChange, variant = "grid", onRenameClick, onDuplicateClick, onDeleteClick, onInfoClick, }: { isOpen: boolean; onOpenChange: (open: boolean) => void; variant?: "grid" | "list"; onRenameClick: () => void; onDuplicateClick: () => void; onDeleteClick: () => void; onInfoClick: () => void; }) { const handleMenuClick = ({ event, }: { event: MouseEvent; }) => { event.preventDefault(); event.stopPropagation(); }; const handleMenuKeyDown = ({ event, }: { event: KeyboardEvent; }) => { if (event.key !== "Enter" && event.key !== " ") { return; } event.preventDefault(); event.stopPropagation(); }; const handleRename = () => { onRenameClick(); onOpenChange(false); }; const handleDuplicate = () => { onDuplicateClick(); onOpenChange(false); }; const handleDeleteClick = () => { onDeleteClick(); onOpenChange(false); }; const handleInfoClick = () => { onInfoClick(); onOpenChange(false); }; const isGrid = variant === "grid"; return ( Rename Duplicate Info Delete ); } function ProjectsSkeleton() { const skeletonIds = Array.from( { length: 24 }, (_, index) => `skeleton-${index}`, ); return (
{skeletonIds.map((skeletonId) => (
))}
); } function EmptyState() { const { searchQuery, setSearchQuery } = useProjectsStore(); const router = useRouter(); const editor = useEditor(); const savedProjects = editor.project.getSavedProjects(); 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", }); } }; if (savedProjects.length > 0) { return (

No results found

Your search for "{searchQuery}" did not return any results.

); } return (

No projects yet

Start creating your first project. Import media, edit, and export your videos. All privately.

); }