diff options
Diffstat (limited to 'apps/web/src')
| -rw-r--r-- | apps/web/src/app/projects/page.tsx | 451 | ||||
| -rw-r--r-- | apps/web/src/stores/timeline-store.ts | 37 |
2 files changed, 247 insertions, 241 deletions
diff --git a/apps/web/src/app/projects/page.tsx b/apps/web/src/app/projects/page.tsx index 4dd15ee7..f4df5882 100644 --- a/apps/web/src/app/projects/page.tsx +++ b/apps/web/src/app/projects/page.tsx @@ -1,7 +1,7 @@ "use client"; import Link from "next/link"; -import { useState } from "react"; +import { useState, useEffect, useCallback } from "react"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { Checkbox } from "@/components/ui/checkbox"; @@ -30,6 +30,7 @@ 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 { useTimelineStore } from "@/stores/timeline-store"; import { Select, SelectContent, @@ -37,17 +38,27 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { Skeleton } from "@/components/ui/skeleton"; export default function ProjectsPage() { const { - createNewProject, savedProjects, isLoading, isInitialized, deleteProject, + createNewProject, getFilteredAndSortedProjects, } = useProjectStore(); - const router = useRouter(); + const [thumbnailCache, setThumbnailCache] = useState< + Record<string, string | null> + >({}); + const [loadingThumbnails, setLoadingThumbnails] = useState<Set<string>>( + new Set(), + ); + const [selectedProject, setSelectedProject] = useState<TProject | null>(null); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false); + const [sortBy, setSortBy] = useState<"name" | "date">("date"); const [isSelectionMode, setIsSelectionMode] = useState(false); const [selectedProjects, setSelectedProjects] = useState<Set<string>>( new Set(), @@ -55,6 +66,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<string | null> => { + 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 +246,37 @@ export default function ProjectsPage() { </div> {isSelectionMode && sortedProjects.length > 0 && ( - <div className="mb-6 p-4 bg-muted/30 rounded-lg border"> - <div className="flex items-center gap-3"> - <Checkbox - checked={someSelected ? "indeterminate" : allSelected} - onCheckedChange={(value) => handleSelectAll(!!value)} - /> - <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> - </div> + <div + onClick={() => handleSelectAll(!allSelected)} + className="hover:cursor-pointer gap-2 mb-6 p-4 bg-muted/30 rounded-lg border items-center flex" + > + <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> </div> )} {isLoading || !isInitialized ? ( - <div className="flex items-center justify-center py-16"> - <Loader2 className="h-8 w-8 text-muted-foreground animate-spin" /> + <div className="grid grid-cols-1 xs:grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-6"> + {Array.from({ length: 8 }).map((_, index) => ( + <div + key={index} + 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} /> @@ -245,6 +294,7 @@ export default function ProjectsPage() { isSelectionMode={isSelectionMode} isSelected={selectedProjects.has(project.id)} onSelect={handleSelectProject} + getProjectThumbnail={getProjectThumbnail} /> ))} </div> @@ -265,6 +315,7 @@ interface ProjectCardProps { isSelectionMode?: boolean; isSelected?: boolean; onSelect?: (projectId: string, checked: boolean) => void; + getProjectThumbnail: (projectId: string) => Promise<string | null>; } function ProjectCard({ @@ -272,12 +323,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<string | null>(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]); + const formatDate = (date: Date): string => { return date.toLocaleDateString("en-US", { month: "short", @@ -308,242 +375,144 @@ function ProjectCard({ } }; - return ( - <> - {isSelectionMode ? ( - <div onClick={handleCardClick} className="block group cursor-pointer"> - <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" - }`} - > - {/* Selection checkbox */} - {isSelectionMode && ( - <div className="absolute top-3 left-3 z-10"> - <div className="w-5 h-5 rounded bg-background/80 backdrop-blur-sm 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 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 bg-background/80 backdrop-blur-sm 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> + )} - {/* Thumbnail preview or placeholder */} - <div className="absolute inset-0"> - {project.thumbnail ? ( - <Image - src={project.thumbnail} - 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 flex-shrink-0 text-muted-foreground" /> - </div> - )} - </div> + <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 flex-shrink-0 text-muted-foreground" /> </div> + )} + </div> + </div> - <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> + <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> - <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> + <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> + ); + + return ( + <> + {isSelectionMode ? ( + <div onClick={handleCardClick} className="block group cursor-pointer"> + {cardContent} </div> ) : ( <Link href={`/editor/${project.id}`} className="block group"> - <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" - }`} - > - {/* Thumbnail preview or placeholder */} - <div className="absolute inset-0"> - {project.thumbnail ? ( - <Image - src={project.thumbnail} - 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 flex-shrink-0 text-muted-foreground" /> - </div> - )} - </div> - </div> - - <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> - <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> - - <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> + {cardContent} </Link> )} <DeleteProjectDialog isOpen={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen} onConfirm={handleDeleteProject} - projectName={project.name} /> <RenameProjectDialog isOpen={isRenameDialogOpen} diff --git a/apps/web/src/stores/timeline-store.ts b/apps/web/src/stores/timeline-store.ts index 6e48f7cd..25a56943 100644 --- a/apps/web/src/stores/timeline-store.ts +++ b/apps/web/src/stores/timeline-store.ts @@ -158,6 +158,7 @@ interface TimelineStore { // Computed values getTotalDuration: () => number; + getProjectThumbnail: (projectId: string) => Promise<string | null>; // History actions undo: () => void; @@ -1159,6 +1160,42 @@ export const useTimelineStore = create<TimelineStore>((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; |
