summaryrefslogtreecommitdiff
path: root/apps/web/src/app/projects
diff options
context:
space:
mode:
authorMaze <167211895+mazeincoding@users.noreply.github.com>2026-01-31 00:20:04 +0100
committerGitHub <noreply@github.com>2026-01-31 00:20:04 +0100
commit7bf0984698e43b9aa2dd879f65951318ed410390 (patch)
treeccf2cd590a47d550152afc07129cb55c71a9ec35 /apps/web/src/app/projects
parent0173db99448d1c4c6415dcf15272e17551b48b04 (diff)
downloadopencut-7bf0984698e43b9aa2dd879f65951318ed410390.tar.gz
codebase overhaul (#697)
Diffstat (limited to 'apps/web/src/app/projects')
-rw-r--r--apps/web/src/app/projects/page.tsx1498
-rw-r--r--apps/web/src/app/projects/store.ts129
2 files changed, 1033 insertions, 594 deletions
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<string, string | null>
- >({});
- const [_loadingThumbnails, setLoadingThumbnails] = useState<Set<string>>(
- new Set()
- );
- 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 { searchQuery, sortKey, sortOrder, viewMode } = useProjectsStore();
+ const editor = useEditor();
- const getProjectThumbnail = useCallback(
- async (projectId: string): Promise<string | null> => {
- if (thumbnailCache[projectId] !== undefined) {
- return thumbnailCache[projectId];
- }
+ useEffect(() => {
+ if (!editor.project.getIsInitialized()) {
+ editor.project.loadAllProjects();
+ }
+ }, [editor.project]);
- setLoadingThumbnails((prev) => new Set(prev).add(projectId));
+ const sortOption: TProjectSortOption = `${sortKey}-${sortOrder}`;
+ const projectsToDisplay = editor.project.getFilteredAndSortedProjects({
+ searchQuery,
+ sortOption,
+ });
- 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 isLoading = editor.project.getIsLoading();
+ const isInitialized = editor.project.getIsInitialized();
- const handleCreateProject = async () => {
- const projectId = await createNewProject("New Project");
- console.log("projectId", projectId);
- router.push(`/editor/${projectId}`);
- };
+ return (
+ <div className="bg-background min-h-screen">
+ <MigrationDialog />
+ <ProjectsHeader />
+ <ProjectsToolbar projectIds={projectsToDisplay.map((p) => p.id)} />
+ <main className="mx-auto px-4 pt-2 pb-6 flex flex-col gap-4">
+ {isLoading || !isInitialized ? (
+ <ProjectsSkeleton />
+ ) : projectsToDisplay.length === 0 ? (
+ <EmptyState />
+ ) : (
+ <div
+ className={
+ viewMode === "grid"
+ ? "xs:grid-cols-2 grid grid-cols-1 gap-6 sm:grid-cols-3 lg:grid-cols-4 px-4"
+ : "flex flex-col"
+ }
+ >
+ {projectsToDisplay.map((project) => (
+ <ProjectItem
+ key={project.id}
+ project={project}
+ allProjectIds={projectsToDisplay.map((p) => p.id)}
+ />
+ ))}
+ </div>
+ )}
+ </main>
+ </div>
+ );
+}
- const handleSelectProject = (projectId: string, checked: boolean) => {
- const newSelected = new Set(selectedProjects);
- if (checked) {
- newSelected.add(projectId);
- } else {
- newSelected.delete(projectId);
- }
- setSelectedProjects(newSelected);
- };
+function ProjectsHeader() {
+ const { viewMode, isHydrated, setViewMode } = useProjectsStore();
- const handleSelectAll = (checked: boolean) => {
- if (checked) {
- setSelectedProjects(new Set(sortedProjects.map((p) => p.id)));
- } else {
- setSelectedProjects(new Set());
- }
- };
+ return (
+ <header className="sticky top-0 z-20 px-8 bg-background flex flex-col gap-2">
+ <div className="flex items-center justify-between h-16 pt-2">
+ <div className="flex items-center gap-5">
+ <Breadcrumb>
+ <BreadcrumbList>
+ <BreadcrumbItem>
+ <BreadcrumbLink asChild>
+ <Link href="/" className="text-sm sm:text-base">
+ Home
+ </Link>
+ </BreadcrumbLink>
+ </BreadcrumbItem>
+ <BreadcrumbSeparator />
+ <BreadcrumbItem>
+ <BreadcrumbPage className="text-sm sm:text-base font-medium">
+ All projects
+ </BreadcrumbPage>
+ </BreadcrumbItem>
+ </BreadcrumbList>
+ </Breadcrumb>
- const handleCancelSelection = () => {
- setIsSelectionMode(false);
- setSelectedProjects(new Set());
- };
+ <div className="hidden md:flex rounded-md border p-1 h-10">
+ <button
+ type="button"
+ className={`p-2 rounded-sm cursor-pointer ${isHydrated && viewMode === "grid" ? "bg-accent/75" : ""}`}
+ onClick={() => setViewMode({ viewMode: "grid" })}
+ onKeyDown={(event) =>
+ event.key === "Enter" && setViewMode({ viewMode: "grid" })
+ }
+ aria-label="Grid view"
+ aria-pressed={isHydrated && viewMode === "grid"}
+ >
+ <HugeiconsIcon icon={GridViewIcon} className="size-4" />
+ </button>
+ <button
+ type="button"
+ className={`p-2 rounded-sm cursor-pointer ${isHydrated && viewMode === "list" ? "bg-accent/75" : ""}`}
+ onClick={() => setViewMode({ viewMode: "list" })}
+ onKeyDown={(event) =>
+ event.key === "Enter" && setViewMode({ viewMode: "list" })
+ }
+ aria-label="List view"
+ aria-pressed={isHydrated && viewMode === "list"}
+ >
+ <HugeiconsIcon
+ icon={LeftToRightListDashIcon}
+ className="size-4"
+ />
+ </button>
+ </div>
+ </div>
+
+ <div className="flex items-center gap-3 md:gap-4">
+ <SearchBar className="hidden md:block" />
+ <NewProjectButton />
+ </div>
+ </div>
+ <SearchBar className="block md:hidden mb-4" />
+ </header>
+ );
+}
- const handleBulkDelete = async () => {
- await Promise.all(
- Array.from(selectedProjects).map((projectId) => deleteProject(projectId))
- );
- setSelectedProjects(new Set());
- setIsSelectionMode(false);
- setIsBulkDeleteDialogOpen(false);
- };
+const SORT_LABELS: Record<TProjectSortKey, string> = {
+ createdAt: "Created",
+ updatedAt: "Modified",
+ name: "Name",
+ duration: "Duration",
+};
- const sortedProjects = getFilteredAndSortedProjects(searchQuery, sortOption);
+function ProjectsToolbar({ projectIds }: { projectIds: string[] }) {
+ const {
+ selectedProjectIds,
+ sortKey,
+ sortOrder,
+ setSortOrder,
+ setSelectedProjects,
+ clearSelectedProjects,
+ viewMode,
+ setViewMode,
+ } = useProjectsStore();
- const allSelected =
- sortedProjects.length > 0 &&
- selectedProjects.size === sortedProjects.length;
- const someSelected =
- selectedProjects.size > 0 && selectedProjects.size < sortedProjects.length;
+ const selectedProjectCount = selectedProjectIds.length;
+ const isAllSelected =
+ projectIds.length > 0 && selectedProjectCount === projectIds.length;
+ const hasSomeSelected =
+ selectedProjectCount > 0 && selectedProjectCount < projectIds.length;
- return (
- <div className="min-h-screen bg-background">
- <div className="pt-6 px-6 flex items-center justify-between w-full h-16">
- <Link
- href="/"
- className="flex items-center gap-1 hover:text-muted-foreground transition-colors"
- >
- <ChevronLeft 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}
- >
- <X className="size-4!" />
- Cancel
- </Button>
- {selectedProjects.size > 0 && (
- <Button
- variant="destructive"
- size="sm"
- onClick={() => setIsBulkDeleteDialogOpen(true)}
- >
- <Trash2 className="size-4!" />
- Delete ({selectedProjects.size})
- </Button>
- )}
- </div>
- ) : (
- <CreateButton onClick={handleCreateProject} />
- )}
- </div>
- </div>
- <main className="max-w-6xl mx-auto 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 md:text-3xl font-bold tracking-tight">
- Your Projects
- </h1>
- <p className="text-muted-foreground">
- {savedProjects.length}{" "}
- {savedProjects.length === 1 ? "project" : "projects"}
- {isSelectionMode && selectedProjects.size > 0 && (
- <span className="ml-2 text-primary">
- • {selectedProjects.size} selected
- </span>
- )}
- </p>
- </div>
- <div className="hidden md:block">
- {isSelectionMode ? (
- <div className="flex items-center gap-2">
- <Button variant="outline" onClick={handleCancelSelection}>
- <X className="size-4!" />
- Cancel
- </Button>
- {selectedProjects.size > 0 && (
- <Button
- variant="destructive"
- onClick={() => setIsBulkDeleteDialogOpen(true)}
- >
- <Trash2 className="size-4!" />
- Delete Selected ({selectedProjects.size})
- </Button>
- )}
- </div>
- ) : (
- <div className="flex items-center gap-2">
- <Button
- variant="outline"
- onClick={() => setIsSelectionMode(true)}
- disabled={savedProjects.length === 0}
- >
- Select Projects
- </Button>
- <CreateButton onClick={handleCreateProject} />
- </div>
- )}
- </div>
- </div>
+ const handleSelectAll = ({ checked }: { checked: boolean }) => {
+ if (checked) {
+ setSelectedProjects({ projectIds });
+ return;
+ }
+ clearSelectedProjects();
+ };
- <div className="mb-4 flex items-center justify-between gap-4">
- <div className="flex-1 max-w-72">
- <Input
- placeholder="Search projects..."
- value={searchQuery}
- onChange={(e) => setSearchQuery(e.target.value)}
- />
- </div>
- <div className="flex items-center gap-0">
- <TooltipProvider>
- <Tooltip>
- <DropdownMenu>
- <TooltipTrigger asChild>
- <DropdownMenuTrigger asChild>
- <Button
- size="icon"
- variant="secondary"
- className="justify-center items-center w-9 h-9"
- >
- <ArrowDown01
- strokeWidth={1.5}
- className="!size-[1.05rem]"
- />
- </Button>
- </DropdownMenuTrigger>
- </TooltipTrigger>
- <DropdownMenuContent align="end">
- <DropdownMenuItem
- onClick={() => {
- if (sortOption.startsWith("createdAt")) {
- setSortOption(
- sortOption.endsWith("asc")
- ? "createdAt-desc"
- : "createdAt-asc"
- );
- } else {
- setSortOption("createdAt-asc");
- }
- }}
- >
- Created{" "}
- {sortOption.startsWith("createdAt") &&
- (sortOption.endsWith("asc") ? "↑" : "↓")}
- </DropdownMenuItem>
- <DropdownMenuItem
- onClick={() => {
- if (sortOption.startsWith("name")) {
- setSortOption(
- sortOption.endsWith("asc")
- ? "name-desc"
- : "name-asc"
- );
- } else {
- setSortOption("name-asc");
- }
- }}
- >
- 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>
+ return (
+ <div className="sticky top-16 z-10 flex items-center justify-between px-6 h-14 pt-2 bg-background">
+ <div className="flex items-center gap-2">
+ <Label
+ className="flex items-center gap-3 cursor-pointer px-2"
+ htmlFor="select-all-projects"
+ >
+ <Checkbox
+ className="size-5"
+ id="select-all-projects"
+ checked={
+ isAllSelected ? true : hasSomeSelected ? "indeterminate" : false
+ }
+ onCheckedChange={(checked) =>
+ handleSelectAll({ checked: checked === true })
+ }
+ />
+ <span className="text-muted-foreground hidden md:block">
+ Select all
+ </span>
+ </Label>
- {isSelectionMode && sortedProjects.length > 0 && (
- <button
- type="button"
- onClick={() => handleSelectAll(!allSelected)}
- onKeyDown={(e) => {
- if (e.key === "Enter" || e.key === " ") {
- e.preventDefault();
- handleSelectAll(!allSelected);
- }
- }}
- className="w-full hover:cursor-pointer gap-2 mb-6 p-4 bg-muted/30 rounded-lg border items-center flex"
- tabIndex={0}
- >
- <Checkbox checked={someSelected ? "indeterminate" : allSelected} />
- <span className="text-sm font-medium">
- {allSelected ? "Deselect All" : "Select All"}
- </span>
- <span className="text-sm text-muted-foreground">
- ({selectedProjects.size} of {sortedProjects.length} selected)
- </span>
- </button>
- )}
+ <div className="h-4 w-px bg-border/50" />
- {isLoading || !isInitialized ? (
- <div className="grid grid-cols-1 xs:grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-6">
- {Array.from({ length: 8 }, (_, index) => (
- <div
- key={`skeleton-${index}-${Date.now()}`}
- className="overflow-hidden bg-background border-none p-0"
- >
- <Skeleton className="aspect-square w-full bg-muted/50" />
- <div className="px-0 pt-5 flex flex-col gap-1">
- <Skeleton className="h-4 w-3/4 bg-muted/50" />
- <div className="flex items-center gap-1.5">
- <Skeleton className="h-4 w-4 bg-muted/50" />
- <Skeleton className="h-4 w-24 bg-muted/50" />
- </div>
- </div>
- </div>
- ))}
- </div>
- ) : savedProjects.length === 0 ? (
- <NoProjects onCreateProject={handleCreateProject} />
- ) : sortedProjects.length === 0 ? (
- <NoResults
- searchQuery={searchQuery}
- onClearSearch={() => setSearchQuery("")}
- />
- ) : (
- <div className="grid grid-cols-1 xs:grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-6">
- {sortedProjects.map((project) => (
- <ProjectCard
- key={project.id}
- project={project}
- isSelectionMode={isSelectionMode}
- isSelected={selectedProjects.has(project.id)}
- onSelect={handleSelectProject}
- getProjectThumbnail={getProjectThumbnail}
- />
- ))}
- </div>
- )}
- </main>
+ <SortDropdown>
+ <Button variant="text" className="text-muted-foreground pl-2">
+ {SORT_LABELS[sortKey]}
+ </Button>
+ </SortDropdown>
+ <Button
+ type="button"
+ variant="text"
+ className="text-muted-foreground"
+ onClick={() =>
+ setSortOrder({
+ sortOrder: sortOrder === "asc" ? "desc" : "asc",
+ })
+ }
+ onKeyDown={(event) => {
+ if (event.key === "Enter" || event.key === " ") {
+ setSortOrder({
+ sortOrder: sortOrder === "asc" ? "desc" : "asc",
+ });
+ }
+ }}
+ aria-label={`Sort ${sortOrder === "asc" ? "ascending" : "descending"}`}
+ >
+ <HugeiconsIcon
+ icon={ArrowDown02Icon}
+ className={sortOrder === "asc" ? "rotate-180" : ""}
+ />
+ </Button>
- <DeleteProjectDialog
- isOpen={isBulkDeleteDialogOpen}
- onOpenChange={setIsBulkDeleteDialogOpen}
- onConfirm={handleBulkDelete}
- />
- </div>
- );
+ <div className="h-4 w-px bg-border/50 block md:hidden" />
+
+ <div className="flex md:hidden items-center gap-4">
+ <Button
+ variant="text"
+ onClick={() => setViewMode({ viewMode: "grid" })}
+ >
+ <HugeiconsIcon
+ icon={GridViewIcon}
+ className={cn(
+ viewMode === "grid" ? "text-primary" : "text-muted-foreground",
+ )}
+ />
+ </Button>
+ <Button
+ variant="text"
+ onClick={() => setViewMode({ viewMode: "list" })}
+ >
+ <HugeiconsIcon
+ icon={LeftToRightListDashIcon}
+ className={cn(
+ viewMode === "list" ? "text-primary" : "text-muted-foreground",
+ )}
+ />
+ </Button>
+ </div>
+ </div>
+ {selectedProjectCount > 0 ? <ProjectActions /> : null}
+ </div>
+ );
}
-interface ProjectCardProps {
- project: TProject;
- isSelectionMode?: boolean;
- isSelected?: boolean;
- onSelect?: (projectId: string, checked: boolean) => void;
- getProjectThumbnail: (projectId: string) => Promise<string | null>;
+function SearchBar({
+ className,
+ collapsed,
+}: {
+ className?: string;
+ collapsed?: boolean;
+}) {
+ const { searchQuery, setSearchQuery } = useProjectsStore();
+
+ return (
+ <>
+ {collapsed ? (
+ <div className="block md:hidden">
+ <Button
+ size="icon"
+ variant="outline"
+ className="size-10.5 rounded-full"
+ >
+ <HugeiconsIcon icon={Search01Icon} />
+ </Button>
+ </div>
+ ) : (
+ <div className={cn("relative", className)}>
+ <HugeiconsIcon
+ icon={Search01Icon}
+ className="text-muted-foreground pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2"
+ aria-hidden="true"
+ />
+ <Input
+ placeholder="Search..."
+ value={searchQuery}
+ onChange={(event) => setSearchQuery({ query: event.target.value })}
+ size="lg"
+ className="pl-9"
+ />
+ </div>
+ )}
+ </>
+ );
}
-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<string | null>(null);
- const [isLoadingThumbnail, setIsLoadingThumbnail] = useState(true);
- const { deleteProject, renameProject, duplicateProject } = useProjectStore();
+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;
- useEffect(() => {
- const loadThumbnail = async () => {
- setIsLoadingThumbnail(true);
- try {
- const thumbnail = await getProjectThumbnail(project.id);
- setDynamicThumbnail(thumbnail);
- } finally {
- setIsLoadingThumbnail(false);
- }
- };
- loadThumbnail();
- }, [project.id, getProjectThumbnail]);
+async function deleteProjects({
+ editor,
+ ids,
+}: {
+ editor: ReturnType<typeof useEditor>;
+ ids: string[];
+}) {
+ await editor.project.deleteProjects({ ids });
+}
- const formatDate = (date: Date): string => {
- return date.toLocaleDateString("en-US", {
- month: "short",
- day: "numeric",
- year: "numeric",
- });
- };
+async function duplicateProjects({
+ editor,
+ ids,
+}: {
+ editor: ReturnType<typeof useEditor>;
+ ids: string[];
+}) {
+ await editor.project.duplicateProjects({ ids });
+}
- const handleDeleteProject = async () => {
- await deleteProject(project.id);
- setIsDropdownOpen(false);
- };
+async function renameProject({
+ editor,
+ id,
+ name,
+}: {
+ editor: ReturnType<typeof useEditor>;
+ id: string;
+ name: string;
+}) {
+ await editor.project.renameProject({ id, name });
+}
- const handleRenameProject = async (newName: string) => {
- await renameProject(project.id, newName);
- setIsRenameDialogOpen(false);
- };
+function ProjectActions() {
+ const editor = useEditor();
+ const { selectedProjectIds, clearSelectedProjects } = useProjectsStore();
+ const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
- const handleDuplicateProject = async () => {
- setIsDropdownOpen(false);
- await duplicateProject(project.id);
- };
+ const savedProjects = editor.project.getSavedProjects();
+ const selectedProjectNames = savedProjects
+ .filter((project) => selectedProjectIds.includes(project.id))
+ .map((project) => project.name);
- const handleCardClick = (e: React.MouseEvent) => {
- if (isSelectionMode) {
- e.preventDefault();
- onSelect?.(project.id, !isSelected);
- }
- };
+ const handleDuplicate = async () => {
+ await duplicateProjects({ editor, ids: selectedProjectIds });
+ clearSelectedProjects();
+ };
- const handleCardKeyDown = (e: React.KeyboardEvent) => {
- if (isSelectionMode && (e.key === "Enter" || e.key === " ")) {
- e.preventDefault();
- onSelect?.(project.id, !isSelected);
- }
- };
+ const handleDeleteClick = () => {
+ setIsDeleteDialogOpen(true);
+ };
- const cardContent = (
- <Card
- className={`overflow-hidden bg-background border-none p-0 transition-all ${
- isSelectionMode && isSelected ? "ring-2 ring-primary" : ""
- }`}
- >
- <div
- className={`relative aspect-square bg-muted transition-opacity ${
- isDropdownOpen ? "opacity-65" : "opacity-100 group-hover:opacity-65"
- }`}
- >
- {isSelectionMode && (
- <div className="absolute top-3 left-3 z-10">
- <div className="w-5 h-5 rounded-full bg-background/80 backdrop-blur-xs border flex items-center justify-center">
- <Checkbox
- checked={isSelected}
- onCheckedChange={(checked) =>
- onSelect?.(project.id, checked as boolean)
- }
- onClick={(e) => e.stopPropagation()}
- className="w-4 h-4"
- />
- </div>
- </div>
- )}
+ const handleDeleteConfirm = async () => {
+ await deleteProjects({ editor, ids: selectedProjectIds });
+ clearSelectedProjects();
+ setIsDeleteDialogOpen(false);
+ };
- <div className="absolute inset-0">
- {isLoadingThumbnail ? (
- <div className="w-full h-full bg-muted/50 flex items-center justify-center">
- <Loader2 className="h-12 w-12 text-muted-foreground animate-spin" />
- </div>
- ) : dynamicThumbnail ? (
- <Image
- src={dynamicThumbnail}
- alt="Project thumbnail"
- fill
- className="object-cover"
- />
- ) : (
- <div className="w-full h-full bg-muted/50 flex items-center justify-center">
- <Video className="h-12 w-12 shrink-0 text-muted-foreground" />
- </div>
- )}
- </div>
- </div>
+ const actionHandlers: Record<string, () => void> = {
+ duplicate: handleDuplicate,
+ delete: handleDeleteClick,
+ };
- <CardContent className="px-0 pt-5 flex flex-col gap-1">
- <div className="flex items-start justify-between">
- <h3 className="font-medium text-sm leading-snug group-hover:text-foreground/90 transition-colors line-clamp-2">
- {project.name}
- </h3>
- {!isSelectionMode && (
- <DropdownMenu
- open={isDropdownOpen}
- onOpenChange={setIsDropdownOpen}
- >
- <DropdownMenuTrigger asChild>
- <Button
- variant="text"
- size="sm"
- className={`size-6 p-0 transition-all shrink-0 ml-2 ${
- isDropdownOpen
- ? "opacity-100"
- : "opacity-0 group-hover:opacity-100"
- }`}
- onClick={(e) => e.preventDefault()}
- >
- <MoreHorizontal />
- </Button>
- </DropdownMenuTrigger>
- <DropdownMenuContent
- align="end"
- onCloseAutoFocus={(e) => {
- e.preventDefault();
- e.stopPropagation();
- }}
- >
- <DropdownMenuItem
- onClick={(e) => {
- e.preventDefault();
- e.stopPropagation();
- setIsDropdownOpen(false);
- setIsRenameDialogOpen(true);
- }}
- >
- Rename
- </DropdownMenuItem>
- <DropdownMenuItem
- onClick={(e) => {
- e.preventDefault();
- e.stopPropagation();
- handleDuplicateProject();
- }}
- >
- Duplicate
- </DropdownMenuItem>
- <DropdownMenuSeparator />
- <DropdownMenuItem
- variant="destructive"
- onClick={(e) => {
- e.preventDefault();
- e.stopPropagation();
- setIsDropdownOpen(false);
- setIsDeleteDialogOpen(true);
- }}
- >
- Delete
- </DropdownMenuItem>
- </DropdownMenuContent>
- </DropdownMenu>
- )}
- </div>
+ return (
+ <>
+ <div className="flex items-center gap-2.5 px-3">
+ <div className="hidden sm:flex items-center gap-2.5">
+ {PROJECT_ACTIONS.map((action) => (
+ <Button
+ key={action.id}
+ size="icon"
+ variant={action.variant}
+ className="size-9"
+ onClick={actionHandlers[action.id]}
+ >
+ <HugeiconsIcon icon={action.icon} />
+ </Button>
+ ))}
+ </div>
- <div className="space-y-1">
- <div className="flex items-center gap-1.5 text-sm text-muted-foreground">
- <Calendar className="size-4!" />
- <span>Created {formatDate(project.createdAt)}</span>
- </div>
- </div>
- </CardContent>
- </Card>
- );
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild className="sm:hidden">
+ <Button size="icon" variant="outline" className="size-9">
+ <HugeiconsIcon icon={MoreHorizontalIcon} />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end">
+ {PROJECT_ACTIONS.map((action) => (
+ <DropdownMenuItem
+ key={action.id}
+ variant={action.id === "delete" ? "destructive" : undefined}
+ onClick={actionHandlers[action.id]}
+ >
+ <HugeiconsIcon icon={action.icon} />
+ {action.label}
+ </DropdownMenuItem>
+ ))}
+ </DropdownMenuContent>
+ </DropdownMenu>
+ </div>
- return (
- <>
- {isSelectionMode ? (
- <button
- type="button"
- onClick={handleCardClick}
- onKeyDown={handleCardKeyDown}
- className="block group cursor-pointer w-full text-left"
- >
- {cardContent}
- </button>
- ) : (
- <Link href={`/editor/${project.id}`} className="block group">
- {cardContent}
- </Link>
- )}
- <DeleteProjectDialog
- isOpen={isDeleteDialogOpen}
- onOpenChange={setIsDeleteDialogOpen}
- onConfirm={handleDeleteProject}
- />
- <RenameProjectDialog
- isOpen={isRenameDialogOpen}
- onOpenChange={setIsRenameDialogOpen}
- onConfirm={handleRenameProject}
- projectName={project.name}
- />
- </>
- );
+ <DeleteProjectDialog
+ isOpen={isDeleteDialogOpen}
+ onOpenChange={setIsDeleteDialogOpen}
+ projectNames={selectedProjectNames}
+ onConfirm={handleDeleteConfirm}
+ />
+ </>
+ );
}
-function CreateButton({ onClick }: { onClick?: () => void }) {
- return (
- <Button className="flex" onClick={onClick}>
- <Plus className="size-4!" />
- <span className="text-sm font-medium">New project</span>
- </Button>
- );
+function SortDropdown({ children }: { children: React.ReactNode }) {
+ const { sortKey, setSortKey } = useProjectsStore();
+
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
+ <DropdownMenuContent className="w-48" align="center">
+ <DropdownMenuCheckboxItem
+ checked={sortKey === "createdAt"}
+ onCheckedChange={() => setSortKey({ sortKey: "createdAt" })}
+ >
+ Created
+ </DropdownMenuCheckboxItem>
+ <DropdownMenuCheckboxItem
+ checked={sortKey === "updatedAt"}
+ onCheckedChange={() => setSortKey({ sortKey: "updatedAt" })}
+ >
+ Modified
+ </DropdownMenuCheckboxItem>
+ <DropdownMenuCheckboxItem
+ checked={sortKey === "name"}
+ onCheckedChange={() => setSortKey({ sortKey: "name" })}
+ >
+ Name
+ </DropdownMenuCheckboxItem>
+ <DropdownMenuCheckboxItem
+ checked={sortKey === "duration"}
+ onCheckedChange={() => setSortKey({ sortKey: "duration" })}
+ >
+ Duration
+ </DropdownMenuCheckboxItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ );
+}
+
+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 (
+ <Button
+ size="lg"
+ className="flex px-5 md:px-6"
+ onClick={handleCreateProject}
+ >
+ <span className="text-sm font-medium hidden md:block">New project</span>
+ <span className="text-sm font-medium block md:hidden">New</span>
+ </Button>
+ );
}
-function NoProjects({ onCreateProject }: { onCreateProject: () => void }) {
- return (
- <div className="flex flex-col items-center justify-center py-16 text-center">
- <div className="w-16 h-16 rounded-full bg-muted/30 flex items-center justify-center mb-4">
- <Video className="h-8 w-8 text-muted-foreground" />
- </div>
- <h3 className="text-lg font-medium mb-2">No projects yet</h3>
- <p className="text-muted-foreground mb-6 max-w-md">
- Start creating your first video project. Import media, edit, and export
- professional videos.
- </p>
- <Button size="lg" className="gap-2" onClick={onCreateProject}>
- <Plus className="h-4 w-4" />
- Create Your First Project
- </Button>
- </div>
- );
+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 = (
+ <Card className="bg-background overflow-hidden border-none p-0">
+ <div className="bg-muted relative aspect-video">
+ <div className="absolute inset-0">
+ {project.thumbnail ? (
+ <Image
+ src={project.thumbnail}
+ alt="Project thumbnail"
+ fill
+ className="object-cover"
+ />
+ ) : (
+ <div className="flex size-full items-center justify-center">
+ <OcVideoIcon className="text-muted-foreground size-12 shrink-0" />
+ </div>
+ )}
+ </div>
+
+ {durationLabel && (
+ <div className="absolute bottom-2 right-2 bg-black/60 text-white text-xs font-semibold px-2 py-1 rounded-lg">
+ {durationLabel}
+ </div>
+ )}
+ </div>
+
+ <CardContent className="flex flex-col gap-2 px-0 pt-4">
+ <h3 className="group-hover:text-foreground/90 line-clamp-2 text-sm leading-snug font-medium">
+ {project.name}
+ </h3>
+ <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>
+ </CardContent>
+ </Card>
+ );
+
+ const listRowContent = (
+ <div className="flex items-center gap-3 flex-1 min-w-0">
+ <div className="bg-muted relative size-10 rounded overflow-hidden shrink-0">
+ {project.thumbnail ? (
+ <Image
+ src={project.thumbnail}
+ alt="Project thumbnail"
+ fill
+ className="object-cover"
+ />
+ ) : (
+ <div className="flex size-full items-center justify-center">
+ <OcVideoIcon className="text-muted-foreground size-5 shrink-0" />
+ </div>
+ )}
+ </div>
+
+ <h3 className="group-hover:text-foreground/90 text-sm font-medium truncate flex-1 min-w-0">
+ {project.name}
+ </h3>
+
+ <span className="text-muted-foreground text-sm shrink-0 hidden sm:block">
+ {durationLabel ?? "—"}
+ </span>
+
+ <span className="text-muted-foreground text-sm shrink-0 w-auto pl-8 text-right hidden xs:block">
+ {formatDate({ date: project.createdAt })}
+ </span>
+ </div>
+ );
+
+ const listContent = (
+ <div
+ className={`flex items-center gap-4 py-2 px-4 border-b border-border/50 ${
+ isSelected ? "bg-primary/5" : ""
+ }`}
+ >
+ <Checkbox
+ checked={isSelected}
+ onMouseDown={(event) => event.preventDefault()}
+ onClick={(event) => {
+ handleCheckboxChange({
+ checked: !isSelected,
+ shiftKey: event.shiftKey,
+ });
+ }}
+ onCheckedChange={() => {}}
+ className="size-5 shrink-0"
+ />
+
+ <Link href={`/editor/${project.id}`} className="flex-1 min-w-0">
+ {listRowContent}
+ </Link>
+
+ {!isMultiSelect && (
+ <ProjectMenu
+ isOpen={isDropdownOpen}
+ onOpenChange={setIsDropdownOpen}
+ project={project}
+ variant="list"
+ />
+ )}
+ </div>
+ );
+
+ const cardContent = isGridView ? gridContent : listContent;
+
+ if (!isGridView) {
+ return <div className="group relative">{listContent}</div>;
+ }
+
+ return (
+ <div className="group relative">
+ <Link href={`/editor/${project.id}`} className="block">
+ {cardContent}
+ </Link>
+
+ {isGridView && (
+ <>
+ <Checkbox
+ checked={isSelected}
+ onMouseDown={(event) => 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 && (
+ <ProjectMenu
+ isOpen={isDropdownOpen}
+ onOpenChange={setIsDropdownOpen}
+ project={project}
+ />
+ )}
+ </>
+ )}
+ </div>
+ );
}
-function NoResults({
- searchQuery,
- onClearSearch,
+function ProjectMenu({
+ isOpen,
+ onOpenChange,
+ project,
+ variant = "grid",
}: {
- searchQuery: string;
- onClearSearch: () => void;
+ isOpen: boolean;
+ onOpenChange: (open: boolean) => void;
+ project: TProjectMetadata;
+ variant?: "grid" | "list";
}) {
- return (
- <div className="flex flex-col items-center justify-center py-16 text-center">
- <div className="w-16 h-16 rounded-full bg-muted/30 flex items-center justify-center mb-4">
- <Search className="h-8 w-8 text-muted-foreground" />
- </div>
- <h3 className="text-lg font-medium mb-2">No results found</h3>
- <p className="text-muted-foreground mb-6 max-w-md">
- Your search for "{searchQuery}" did not return any results.
- </p>
- <Button onClick={onClearSearch} variant="outline">
- Clear Search
- </Button>
- </div>
- );
+ const editor = useEditor();
+ const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
+ const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
+ const [isInfoDialogOpen, setIsInfoDialogOpen] = useState(false);
+
+ const handleMenuClick = ({
+ event,
+ }: {
+ event: MouseEvent<HTMLButtonElement>;
+ }) => {
+ event.preventDefault();
+ event.stopPropagation();
+ };
+
+ const handleMenuKeyDown = ({
+ event,
+ }: {
+ event: KeyboardEvent<HTMLButtonElement>;
+ }) => {
+ 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 (
+ <>
+ <DropdownMenu open={isOpen} onOpenChange={onOpenChange}>
+ <DropdownMenuTrigger asChild>
+ <Button
+ type="button"
+ variant="background"
+ className={
+ isGrid
+ ? `absolute z-10 top-3 right-3 ${isOpen ? "opacity-100" : "opacity-0 group-hover:opacity-100"}`
+ : "!bg-transparent !shadow-none"
+ }
+ size="icon"
+ aria-label="Project menu"
+ onClick={(event) =>
+ handleMenuClick({
+ event: event as unknown as MouseEvent<HTMLButtonElement>,
+ })
+ }
+ onMouseDown={(event) => event.stopPropagation()}
+ onKeyDown={(event) =>
+ handleMenuKeyDown({
+ event: event as unknown as KeyboardEvent<HTMLButtonElement>,
+ })
+ }
+ >
+ <HugeiconsIcon
+ icon={MoreHorizontalIcon}
+ className="text-foreground"
+ aria-hidden="true"
+ />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent className="w-48" align="end">
+ <DropdownMenuItem onClick={handleRename}>
+ <HugeiconsIcon icon={Edit03Icon} />
+ Rename
+ </DropdownMenuItem>
+ <DropdownMenuItem onClick={handleDuplicate}>
+ <HugeiconsIcon icon={Copy01Icon} />
+ Duplicate
+ </DropdownMenuItem>
+ <DropdownMenuItem onClick={handleInfoClick}>
+ <HugeiconsIcon icon={InformationCircleIcon} />
+ Info
+ </DropdownMenuItem>
+ <DropdownMenuItem variant="destructive" onClick={handleDeleteClick}>
+ <HugeiconsIcon icon={Delete02Icon} />
+ Delete
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+
+ <RenameProjectDialog
+ isOpen={isRenameDialogOpen}
+ onOpenChange={setIsRenameDialogOpen}
+ projectName={project.name}
+ onConfirm={async (newName) => {
+ await renameProject({ editor, id: project.id, name: newName });
+ setIsRenameDialogOpen(false);
+ }}
+ />
+
+ <DeleteProjectDialog
+ isOpen={isDeleteDialogOpen}
+ onOpenChange={setIsDeleteDialogOpen}
+ projectNames={[project.name]}
+ onConfirm={handleDeleteConfirm}
+ />
+
+ <ProjectInfoDialog
+ isOpen={isInfoDialogOpen}
+ onOpenChange={setIsInfoDialogOpen}
+ project={project}
+ />
+ </>
+ );
+}
+
+function ProjectsSkeleton() {
+ const skeletonIds = Array.from(
+ { length: 24 },
+ (_, 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) => (
+ <Card
+ key={skeletonId}
+ className="bg-background overflow-hidden border-none p-0"
+ >
+ <div className="bg-muted relative aspect-video">
+ <div className="absolute inset-0">
+ <Skeleton className="bg-muted/50 size-full" />
+ </div>
+ </div>
+ <CardContent className="flex flex-col gap-2 px-0 pt-4">
+ <Skeleton className="bg-muted/50 h-4 w-3/4" />
+ <div className="text-muted-foreground flex items-center gap-1.5">
+ <Skeleton className="bg-muted/50 size-4" />
+ <Skeleton className="bg-muted/50 h-4 w-24" />
+ </div>
+ </CardContent>
+ </Card>
+ ))}
+ </div>
+ );
+}
+
+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 (
+ <div className="flex flex-col items-center justify-center gap-5 py-16 text-center">
+ <div className="flex flex-col items-center gap-8">
+ <HugeiconsIcon
+ icon={Search01Icon}
+ className="text-muted-foreground size-16 bg-accent/35 border rounded-md p-4"
+ />
+ <div className="flex flex-col items-center gap-3">
+ <h3 className="text-lg font-medium">No results found</h3>
+ <p className="text-muted-foreground max-w-md">
+ Your search for "{searchQuery}" did not return any results.
+ </p>
+ </div>
+ </div>
+ <Button
+ onClick={() => setSearchQuery({ query: "" })}
+ variant="outline"
+ size="lg"
+ >
+ 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 project. Import media, edit, and export your
+ videos. All privately.
+ </p>
+ </div>
+ <Button size="lg" className="gap-2" onClick={handleCreateProject}>
+ <HugeiconsIcon icon={PlusSignIcon} />
+ Create your first project
+ </Button>
+ </div>
+ );
}
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<ProjectsState>()(
+ 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 });
+ },
+ },
+ ),
+);