From 4d127a038bf768eded68aae3cfa5c1b12241bdac Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Fri, 27 Mar 2026 00:55:15 +0100 Subject: chore: normalize line endings --- apps/web/src/app/projects/page.tsx | 2030 ++++++++++++++++++------------------ apps/web/src/app/projects/store.ts | 270 ++--- 2 files changed, 1150 insertions(+), 1150 deletions(-) (limited to 'apps/web/src/app/projects') diff --git a/apps/web/src/app/projects/page.tsx b/apps/web/src/app/projects/page.tsx index f32ce2b9..8c5d9d04 100644 --- a/apps/web/src/app/projects/page.tsx +++ b/apps/web/src/app/projects/page.tsx @@ -1,1015 +1,1015 @@ -"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 } from "@/lib/time"; -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 "@opencut/ui/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"; - -const formatProjectDuration = ({ - duration, -}: { - duration: number | undefined; -}): string | null => { - if (duration === undefined) { - return null; - } - - const format = duration >= 3600 ? "HH:MM:SS" : "MM:SS"; - return formatTimeCode({ timeInSeconds: 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. -

-
- -
- ); -} +"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 } from "@/lib/time"; +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 "@opencut/ui/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"; + +const formatProjectDuration = ({ + duration, +}: { + duration: number | undefined; +}): string | null => { + if (duration === undefined) { + return null; + } + + const format = duration >= 3600 ? "HH:MM:SS" : "MM:SS"; + return formatTimeCode({ timeInSeconds: 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. +

+
+ +
+ ); +} diff --git a/apps/web/src/app/projects/store.ts b/apps/web/src/app/projects/store.ts index 2c79aa0a..71f89b4a 100644 --- a/apps/web/src/app/projects/store.ts +++ b/apps/web/src/app/projects/store.ts @@ -1,135 +1,135 @@ -import { create } from "zustand"; -import { persist } from "zustand/middleware"; -import type { TProjectSortKey } from "@/lib/project/types"; - -export type ProjectsViewMode = "grid" | "list"; - -interface ProjectsState { - searchQuery: string; - sortKey: TProjectSortKey; - sortOrder: "asc" | "desc"; - viewMode: ProjectsViewMode; - selectedProjectIds: string[]; - lastSelectedProjectId: string | null; - 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; - selectProjectRange: ({ - projectId, - allProjectIds, - }: { - projectId: string; - allProjectIds: string[]; - }) => 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()( - persist( - (set) => ({ - searchQuery: "", - sortKey: "updatedAt", - sortOrder: "desc", - viewMode: "grid", - selectedProjectIds: [], - lastSelectedProjectId: null, - 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: [], lastSelectedProjectId: null }), - setProjectSelected: ({ projectId, isSelected }) => - set((state) => ({ - selectedProjectIds: getNextSelectedProjectIds({ - selectedProjectIds: state.selectedProjectIds, - projectId, - isSelected, - }), - lastSelectedProjectId: isSelected - ? projectId - : state.lastSelectedProjectId, - })), - selectProjectRange: ({ projectId, allProjectIds }) => - set((state) => { - const anchorId = state.lastSelectedProjectId; - if (!anchorId) { - return { - selectedProjectIds: [projectId], - lastSelectedProjectId: projectId, - }; - } - - const anchorIndex = allProjectIds.indexOf(anchorId); - const targetIndex = allProjectIds.indexOf(projectId); - - if (anchorIndex === -1 || targetIndex === -1) { - return { - selectedProjectIds: [projectId], - lastSelectedProjectId: projectId, - }; - } - - const startIndex = Math.min(anchorIndex, targetIndex); - const endIndex = Math.max(anchorIndex, targetIndex); - const rangeIds = allProjectIds.slice(startIndex, endIndex + 1); - - const merged = new Set([...state.selectedProjectIds, ...rangeIds]); - return { - selectedProjectIds: Array.from(merged), - }; - }), - }), - { - name: "projects-view-mode", - partialize: (state) => ({ - viewMode: state.viewMode, - sortKey: state.sortKey, - sortOrder: state.sortOrder, - }), - onRehydrateStorage: () => (state) => { - state?.setIsHydrated({ isHydrated: true }); - }, - }, - ), -); +import { create } from "zustand"; +import { persist } from "zustand/middleware"; +import type { TProjectSortKey } from "@/lib/project/types"; + +export type ProjectsViewMode = "grid" | "list"; + +interface ProjectsState { + searchQuery: string; + sortKey: TProjectSortKey; + sortOrder: "asc" | "desc"; + viewMode: ProjectsViewMode; + selectedProjectIds: string[]; + lastSelectedProjectId: string | null; + 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; + selectProjectRange: ({ + projectId, + allProjectIds, + }: { + projectId: string; + allProjectIds: string[]; + }) => 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()( + persist( + (set) => ({ + searchQuery: "", + sortKey: "updatedAt", + sortOrder: "desc", + viewMode: "grid", + selectedProjectIds: [], + lastSelectedProjectId: null, + 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: [], lastSelectedProjectId: null }), + setProjectSelected: ({ projectId, isSelected }) => + set((state) => ({ + selectedProjectIds: getNextSelectedProjectIds({ + selectedProjectIds: state.selectedProjectIds, + projectId, + isSelected, + }), + lastSelectedProjectId: isSelected + ? projectId + : state.lastSelectedProjectId, + })), + selectProjectRange: ({ projectId, allProjectIds }) => + set((state) => { + const anchorId = state.lastSelectedProjectId; + if (!anchorId) { + return { + selectedProjectIds: [projectId], + lastSelectedProjectId: projectId, + }; + } + + const anchorIndex = allProjectIds.indexOf(anchorId); + const targetIndex = allProjectIds.indexOf(projectId); + + if (anchorIndex === -1 || targetIndex === -1) { + return { + selectedProjectIds: [projectId], + lastSelectedProjectId: projectId, + }; + } + + const startIndex = Math.min(anchorIndex, targetIndex); + const endIndex = Math.max(anchorIndex, targetIndex); + const rangeIds = allProjectIds.slice(startIndex, endIndex + 1); + + const merged = new Set([...state.selectedProjectIds, ...rangeIds]); + return { + selectedProjectIds: Array.from(merged), + }; + }), + }), + { + name: "projects-view-mode", + partialize: (state) => ({ + viewMode: state.viewMode, + sortKey: state.sortKey, + sortOrder: state.sortOrder, + }), + onRehydrateStorage: () => (state) => { + state?.setIsHydrated({ isHydrated: true }); + }, + }, + ), +); -- cgit v1.3.1