From 7bf0984698e43b9aa2dd879f65951318ed410390 Mon Sep 17 00:00:00 2001 From: Maze <167211895+mazeincoding@users.noreply.github.com> Date: Sat, 31 Jan 2026 00:20:04 +0100 Subject: codebase overhaul (#697) --- apps/web/src/app/projects/page.tsx | 1552 +++++++++++++++++++++--------------- apps/web/src/app/projects/store.ts | 129 +++ 2 files changed, 1060 insertions(+), 621 deletions(-) create mode 100644 apps/web/src/app/projects/store.ts (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 3d66a3c5..d58c3aac 100644 --- a/apps/web/src/app/projects/page.tsx +++ b/apps/web/src/app/projects/page.tsx @@ -1,646 +1,956 @@ "use client"; -import { - Calendar, - ChevronLeft, - Loader2, - MoreHorizontal, - ArrowDown01, - Plus, - Search, - Trash2, - Video, - X, -} from "lucide-react"; import Image from "next/image"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import { useCallback, useEffect, useState } from "react"; -import { DeleteProjectDialog } from "@/components/delete-project-dialog"; -import { RenameProjectDialog } from "@/components/rename-project-dialog"; +import type { KeyboardEvent, MouseEvent } from "react"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; +import { MigrationDialog } from "@/components/editor/dialogs/migration-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 { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; import { Skeleton } from "@/components/ui/skeleton"; -import { useProjectStore } from "@/stores/project-store"; -import { useTimelineStore } from "@/stores/timeline-store"; -import type { TProject } from "@/types/project"; +import { useEditor } from "@/hooks/use-editor"; +import { useProjectsStore } from "./store"; +import type { + TProjectMetadata, + TProjectSortKey, + TProjectSortOption, +} from "@/types/project"; +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 { + 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 }); +}; export default function ProjectsPage() { - const { - savedProjects, - isLoading, - isInitialized, - deleteProject, - createNewProject, - getFilteredAndSortedProjects, - } = useProjectStore(); - const [thumbnailCache, setThumbnailCache] = useState< - Record - >({}); - const [_loadingThumbnails, setLoadingThumbnails] = useState>( - new Set() - ); - const [isSelectionMode, setIsSelectionMode] = useState(false); - const [selectedProjects, setSelectedProjects] = useState>( - new Set() - ); - const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false); - const [searchQuery, setSearchQuery] = useState(""); - const [sortOption, setSortOption] = useState("createdAt-desc"); - const router = useRouter(); - - const getProjectThumbnail = useCallback( - async (projectId: string): Promise => { - if (thumbnailCache[projectId] !== undefined) { - return thumbnailCache[projectId]; - } - - setLoadingThumbnails((prev) => new Set(prev).add(projectId)); - - try { - const thumbnail = await useTimelineStore - .getState() - .getProjectThumbnail(projectId); - setThumbnailCache((prev) => ({ ...prev, [projectId]: thumbnail })); - return thumbnail; - } finally { - setLoadingThumbnails((prev) => { - const newSet = new Set(prev); - newSet.delete(projectId); - return newSet; - }); - } - }, - [] - ); - - const handleCreateProject = async () => { - const projectId = await createNewProject("New Project"); - console.log("projectId", projectId); - router.push(`/editor/${projectId}`); - }; - - const handleSelectProject = (projectId: string, checked: boolean) => { - const newSelected = new Set(selectedProjects); - if (checked) { - newSelected.add(projectId); - } else { - newSelected.delete(projectId); - } - setSelectedProjects(newSelected); - }; - - const handleSelectAll = (checked: boolean) => { - if (checked) { - setSelectedProjects(new Set(sortedProjects.map((p) => p.id))); - } else { - setSelectedProjects(new Set()); - } - }; - - const handleCancelSelection = () => { - setIsSelectionMode(false); - setSelectedProjects(new Set()); - }; - - const handleBulkDelete = async () => { - await Promise.all( - Array.from(selectedProjects).map((projectId) => deleteProject(projectId)) - ); - setSelectedProjects(new Set()); - setIsSelectionMode(false); - setIsBulkDeleteDialogOpen(false); - }; - - const sortedProjects = getFilteredAndSortedProjects(searchQuery, sortOption); - - const allSelected = - sortedProjects.length > 0 && - selectedProjects.size === sortedProjects.length; - const someSelected = - selectedProjects.size > 0 && selectedProjects.size < sortedProjects.length; - - return ( -
-
- - - Back - -
- {isSelectionMode ? ( -
- - {selectedProjects.size > 0 && ( - - )} -
- ) : ( - - )} -
-
-
-
-
-

- Your Projects -

-

- {savedProjects.length}{" "} - {savedProjects.length === 1 ? "project" : "projects"} - {isSelectionMode && selectedProjects.size > 0 && ( - - • {selectedProjects.size} selected - - )} -

-
-
- {isSelectionMode ? ( -
- - {selectedProjects.size > 0 && ( - - )} -
- ) : ( -
- - -
- )} -
-
- -
-
- setSearchQuery(e.target.value)} - /> -
-
- - - - - - - - - - { - if (sortOption.startsWith("createdAt")) { - setSortOption( - sortOption.endsWith("asc") - ? "createdAt-desc" - : "createdAt-asc" - ); - } else { - setSortOption("createdAt-asc"); - } - }} - > - Created{" "} - {sortOption.startsWith("createdAt") && - (sortOption.endsWith("asc") ? "↑" : "↓")} - - { - if (sortOption.startsWith("name")) { - setSortOption( - sortOption.endsWith("asc") - ? "name-desc" - : "name-asc" - ); - } else { - setSortOption("name-asc"); - } - }} - > - Name{" "} - {sortOption.startsWith("name") && - (sortOption.endsWith("asc") ? "↑" : "↓")} - - - - -

- Sort by{" "} - {sortOption.startsWith("createdAt") ? "date" : "name"} ( - {sortOption.endsWith("asc") ? "ascending" : "descending"}) -

-
-
-
-
-
- - {isSelectionMode && sortedProjects.length > 0 && ( - - )} - - {isLoading || !isInitialized ? ( -
- {Array.from({ length: 8 }, (_, index) => ( -
- -
- -
- - -
-
-
- ))} -
- ) : savedProjects.length === 0 ? ( - - ) : sortedProjects.length === 0 ? ( - setSearchQuery("")} - /> - ) : ( -
- {sortedProjects.map((project) => ( - - ))} -
- )} -
- - -
- ); + const { searchQuery, sortKey, sortOrder, viewMode } = useProjectsStore(); + const editor = useEditor(); + + useEffect(() => { + if (!editor.project.getIsInitialized()) { + editor.project.loadAllProjects(); + } + }, [editor.project]); + + const sortOption: TProjectSortOption = `${sortKey}-${sortOrder}`; + const projectsToDisplay = editor.project.getFilteredAndSortedProjects({ + searchQuery, + sortOption, + }); + + const isLoading = editor.project.getIsLoading(); + const isInitialized = editor.project.getIsInitialized(); + + return ( +
+ + + p.id)} /> +
+ {isLoading || !isInitialized ? ( + + ) : projectsToDisplay.length === 0 ? ( + + ) : ( +
+ {projectsToDisplay.map((project) => ( + p.id)} + /> + ))} +
+ )} +
+
+ ); } -interface ProjectCardProps { - project: TProject; - isSelectionMode?: boolean; - isSelected?: boolean; - onSelect?: (projectId: string, checked: boolean) => void; - getProjectThumbnail: (projectId: string) => Promise; +function ProjectsHeader() { + const { viewMode, isHydrated, setViewMode } = useProjectsStore(); + + return ( +
+
+
+ + + + + + Home + + + + + + + All projects + + + + + +
+ + +
+
+ +
+ + +
+
+ +
+ ); } -function ProjectCard({ - project, - isSelectionMode = false, - isSelected = false, - onSelect, - getProjectThumbnail, -}: ProjectCardProps) { - const [isDropdownOpen, setIsDropdownOpen] = useState(false); - const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); - const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false); - const [dynamicThumbnail, setDynamicThumbnail] = useState(null); - const [isLoadingThumbnail, setIsLoadingThumbnail] = useState(true); - const { deleteProject, renameProject, duplicateProject } = useProjectStore(); - - useEffect(() => { - const loadThumbnail = async () => { - setIsLoadingThumbnail(true); - try { - const thumbnail = await getProjectThumbnail(project.id); - setDynamicThumbnail(thumbnail); - } finally { - setIsLoadingThumbnail(false); - } - }; - loadThumbnail(); - }, [project.id, getProjectThumbnail]); - - const formatDate = (date: Date): string => { - return date.toLocaleDateString("en-US", { - month: "short", - day: "numeric", - year: "numeric", - }); - }; - - const handleDeleteProject = async () => { - await deleteProject(project.id); - setIsDropdownOpen(false); - }; - - const handleRenameProject = async (newName: string) => { - await renameProject(project.id, newName); - setIsRenameDialogOpen(false); - }; - - const handleDuplicateProject = async () => { - setIsDropdownOpen(false); - await duplicateProject(project.id); - }; - - const handleCardClick = (e: React.MouseEvent) => { - if (isSelectionMode) { - e.preventDefault(); - onSelect?.(project.id, !isSelected); - } - }; - - const handleCardKeyDown = (e: React.KeyboardEvent) => { - if (isSelectionMode && (e.key === "Enter" || e.key === " ")) { - e.preventDefault(); - onSelect?.(project.id, !isSelected); - } - }; - - const cardContent = ( - -
- {isSelectionMode && ( -
-
- - onSelect?.(project.id, checked as boolean) - } - onClick={(e) => e.stopPropagation()} - className="w-4 h-4" - /> -
-
- )} - -
- {isLoadingThumbnail ? ( -
- -
- ) : dynamicThumbnail ? ( - Project thumbnail - ) : ( -
-
- )} -
-
- - -
-

- {project.name} -

- {!isSelectionMode && ( - - - - - { - e.preventDefault(); - e.stopPropagation(); - }} - > - { - e.preventDefault(); - e.stopPropagation(); - setIsDropdownOpen(false); - setIsRenameDialogOpen(true); - }} - > - Rename - - { - e.preventDefault(); - e.stopPropagation(); - handleDuplicateProject(); - }} - > - Duplicate - - - { - e.preventDefault(); - e.stopPropagation(); - setIsDropdownOpen(false); - setIsDeleteDialogOpen(true); - }} - > - Delete - - - - )} -
- -
-
- - Created {formatDate(project.createdAt)} -
-
-
-
- ); - - return ( - <> - {isSelectionMode ? ( - - ) : ( - - {cardContent} - - )} - - - - ); +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 ( +
+
+ + +
+ + + + + + +
+ +
+ + +
+
+ {selectedProjectCount > 0 ? : null} +
+ ); } -function CreateButton({ onClick }: { onClick?: () => void }) { - return ( - - ); +function SearchBar({ + className, + collapsed, +}: { + className?: string; + collapsed?: boolean; +}) { + const { searchQuery, setSearchQuery } = useProjectsStore(); + + return ( + <> + {collapsed ? ( +
+ +
+ ) : ( +
+
+ )} + + ); } -function NoProjects({ onCreateProject }: { onCreateProject: () => void }) { - return ( -
-
-
-

No projects yet

-

- Start creating your first video project. Import media, edit, and export - professional videos. -

- -
- ); +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: ReturnType; + ids: string[]; +}) { + await editor.project.deleteProjects({ ids }); +} + +async function duplicateProjects({ + editor, + ids, +}: { + editor: ReturnType; + ids: string[]; +}) { + await editor.project.duplicateProjects({ ids }); } -function NoResults({ - searchQuery, - onClearSearch, +async function renameProject({ + editor, + id, + name, }: { - searchQuery: string; - onClearSearch: () => void; + editor: ReturnType; + id: string; + name: string; }) { - return ( -
-
- -
-

No results found

-

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

- -
- ); + 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 durationLabel = formatProjectDuration({ duration: project.duration }); + const isMultiSelect = selectedProjectCount > 1; + const isGridView = viewMode === "grid"; + + 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 && ( + + )} +
+ ); + + const cardContent = isGridView ? gridContent : listContent; + + if (!isGridView) { + return
{listContent}
; + } + + return ( +
+ + {cardContent} + + + {isGridView && ( + <> + 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 && ( + + )} + + )} +
+ ); +} + +function ProjectMenu({ + isOpen, + onOpenChange, + project, + variant = "grid", +}: { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + project: TProjectMetadata; + variant?: "grid" | "list"; +}) { + const editor = useEditor(); + const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [isInfoDialogOpen, setIsInfoDialogOpen] = useState(false); + + 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 = () => { + setIsRenameDialogOpen(true); + onOpenChange(false); + }; + + const handleDuplicate = async () => { + await duplicateProjects({ editor, ids: [project.id] }); + onOpenChange(false); + }; + + const handleDeleteClick = () => { + setIsDeleteDialogOpen(true); + onOpenChange(false); + }; + + const handleDeleteConfirm = async () => { + await deleteProjects({ editor, ids: [project.id] }); + setIsDeleteDialogOpen(false); + }; + + const handleInfoClick = () => { + setIsInfoDialogOpen(true); + onOpenChange(false); + }; + + const isGrid = variant === "grid"; + + return ( + <> + + + + + + + + Rename + + + + Duplicate + + + + Info + + + + Delete + + + + + { + await renameProject({ editor, id: project.id, name: newName }); + setIsRenameDialogOpen(false); + }} + /> + + + + + + ); +} + +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 new file mode 100644 index 00000000..eaf8f947 --- /dev/null +++ b/apps/web/src/app/projects/store.ts @@ -0,0 +1,129 @@ +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[]; + 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: "createdAt", + 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 }), + onRehydrateStorage: () => (state) => { + state?.setIsHydrated({ isHydrated: true }); + }, + }, + ), +); -- cgit v1.3.1