From 87a29963c3220e82f5d8512b178fd3d14e52ca63 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Tue, 22 Jul 2025 12:36:17 +0200 Subject: Merge pull request [#366](https://github.com/mazeincoding/AppCut/issues/366) --- apps/web/src/app/projects/page.tsx | 503 ++++++++++++++++++------------------- 1 file changed, 244 insertions(+), 259 deletions(-) (limited to 'apps/web/src/app') diff --git a/apps/web/src/app/projects/page.tsx b/apps/web/src/app/projects/page.tsx index 7ac08107..4b68791b 100644 --- a/apps/web/src/app/projects/page.tsx +++ b/apps/web/src/app/projects/page.tsx @@ -1,35 +1,33 @@ "use client"; -import Link from "next/link"; -import { useState } from "react"; -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 { - ChevronLeft, - Plus, Calendar, + ChevronLeft, + Loader2, MoreHorizontal, + Plus, + Search, + Trash2, Video, - Loader2, X, - Trash2, - Search, } from "lucide-react"; -import { TProject } from "@/types/project"; 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 { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; import { DropdownMenu, - DropdownMenuItem, DropdownMenuContent, - DropdownMenuTrigger, + DropdownMenuItem, DropdownMenuSeparator, + DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { useProjectStore } from "@/stores/project-store"; -import { useRouter } from "next/navigation"; -import { DeleteProjectDialog } from "@/components/delete-project-dialog"; -import { RenameProjectDialog } from "@/components/rename-project-dialog"; +import { Input } from "@/components/ui/input"; import { Select, SelectContent, @@ -37,17 +35,26 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useProjectStore } from "@/stores/project-store"; +import { useTimelineStore } from "@/stores/timeline-store"; +import type { TProject } from "@/types/project"; export default function ProjectsPage() { const { - createNewProject, savedProjects, isLoading, isInitialized, deleteProject, + createNewProject, getFilteredAndSortedProjects, } = useProjectStore(); - const router = useRouter(); + const [thumbnailCache, setThumbnailCache] = useState< + Record + >({}); + const [_loadingThumbnails, setLoadingThumbnails] = useState>( + new Set() + ); const [isSelectionMode, setIsSelectionMode] = useState(false); const [selectedProjects, setSelectedProjects] = useState>( new Set() @@ -55,6 +62,32 @@ export default function ProjectsPage() { 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; + }); + } + }, + [thumbnailCache] + ); const handleCreateProject = async () => { const projectId = await createNewProject("New Project"); @@ -209,25 +242,45 @@ export default function ProjectsPage() { {isSelectionMode && sortedProjects.length > 0 && ( -
-
- handleSelectAll(!!value)} - /> - - {allSelected ? "Deselect All" : "Select All"} - - - ({selectedProjects.size} of {sortedProjects.length} selected) - -
-
+ )} {isLoading || !isInitialized ? ( -
- +
+ {Array.from({ length: 8 }, (_, index) => ( +
+ +
+ +
+ + +
+
+
+ ))}
) : savedProjects.length === 0 ? ( @@ -245,6 +298,7 @@ export default function ProjectsPage() { isSelectionMode={isSelectionMode} isSelected={selectedProjects.has(project.id)} onSelect={handleSelectProject} + getProjectThumbnail={getProjectThumbnail} /> ))}
@@ -265,6 +319,7 @@ interface ProjectCardProps { isSelectionMode?: boolean; isSelected?: boolean; onSelect?: (projectId: string, checked: boolean) => void; + getProjectThumbnail: (projectId: string) => Promise; } function ProjectCard({ @@ -272,12 +327,28 @@ function ProjectCard({ 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", @@ -308,242 +379,156 @@ function ProjectCard({ } }; - return ( - <> - {isSelectionMode ? ( -
- -
- {/* Selection checkbox */} - {isSelectionMode && ( -
-
- - onSelect?.(project.id, checked as boolean) - } - onClick={(e) => e.stopPropagation()} - className="w-4 h-4" - /> -
-
- )} + const handleCardKeyDown = (e: React.KeyboardEvent) => { + if (isSelectionMode && (e.key === "Enter" || e.key === " ")) { + e.preventDefault(); + onSelect?.(project.id, !isSelected); + } + }; - {/* Thumbnail preview or placeholder */} -
- {project.thumbnail ? ( - Project thumbnail - ) : ( -
-
- )} -
+ const cardContent = ( + +
+ {isSelectionMode && ( +
+
+ + onSelect?.(project.id, checked as boolean) + } + onClick={(e) => e.stopPropagation()} + className="w-4 h-4" + />
+
+ )} - -
-

- {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)} -
-
-
- -
- ) : ( - - -
- {/* Thumbnail preview or placeholder */} -
- {project.thumbnail ? ( - Project thumbnail - ) : ( -
-
- )} -
+
+ {isLoadingThumbnail ? ( +
+ +
+ ) : dynamicThumbnail ? ( + Project thumbnail + ) : ( +
+
+ )} +
+
- -
-

- {project.name} -

- +
+

+ {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 - - - -
+ + + + { + 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)} -
-
-
-
+
+
+ + Created {formatDate(project.createdAt)} +
+
+ +
+ ); + + return ( + <> + {isSelectionMode ? ( + + ) : ( + + {cardContent} )}