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 { + Calendar, + ChevronLeft, + Loader2, + MoreHorizontal, + Plus, + Search, + Trash2, + Video, + X, +} from "lucide-react"; +import Image from "next/image"; import Link from "next/link"; -import { useState } from "react"; +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 { Input } from "@/components/ui/input"; -import { - ChevronLeft, - Plus, - Calendar, - MoreHorizontal, - Video, - Loader2, - X, - Trash2, - Search, -} from "lucide-react"; -import { TProject } from "@/types/project"; -import Image from "next/image"; 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({ } }; + 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 ? ( -
- -
- {/* Selection checkbox */} - {isSelectionMode && ( -
-
- - onSelect?.(project.id, checked as boolean) - } - onClick={(e) => e.stopPropagation()} - className="w-4 h-4" - /> -
-
- )} - - {/* Thumbnail preview or placeholder */} -
- {project.thumbnail ? ( - 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)} -
-
-
-
-
+ ) : ( - -
- {/* Thumbnail preview or placeholder */} -
- {project.thumbnail ? ( - Project thumbnail - ) : ( -
-
- )} -
-
- - -
-

- {project.name} -

- - - - - { - 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)} -
-
-
-
+ {cardContent} )} number; + getProjectThumbnail: (projectId: string) => Promise; // History actions undo: () => void; @@ -1178,6 +1179,42 @@ export const useTimelineStore = create((set, get) => { return Math.max(...trackEndTimes, 0); }, + getProjectThumbnail: async (projectId) => { + try { + const tracks = await storageService.loadTimeline(projectId); + const mediaItems = await storageService.loadAllMediaItems(projectId); + + if (!tracks || !mediaItems.length) return null; + + const firstMediaElement = tracks + .flatMap((track) => track.elements) + .filter((element) => element.type === "media") + .sort((a, b) => a.startTime - b.startTime)[0]; + + if (!firstMediaElement) return null; + + const mediaItem = mediaItems.find( + (item) => item.id === firstMediaElement.mediaId + ); + if (!mediaItem) return null; + + if (mediaItem.type === "video" && mediaItem.file) { + const { generateVideoThumbnail } = await import( + "@/stores/media-store" + ); + const { thumbnailUrl } = await generateVideoThumbnail(mediaItem.file); + return thumbnailUrl; + } else if (mediaItem.type === "image" && mediaItem.url) { + return mediaItem.url; + } + + return null; + } catch (error) { + console.error("Failed to get project thumbnail:", error); + return null; + } + }, + redo: () => { const { redoStack } = get(); if (redoStack.length === 0) return;