This commit is contained in:
Maze Winther 2025-07-22 12:36:17 +02:00
parent a7c0b92051
commit 87a29963c3
2 changed files with 286 additions and 264 deletions

View File

@ -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<string, string | null>
>({});
const [_loadingThumbnails, setLoadingThumbnails] = useState<Set<string>>(
new Set()
);
const [isSelectionMode, setIsSelectionMode] = useState(false);
const [selectedProjects, setSelectedProjects] = useState<Set<string>>(
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<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 +242,45 @@ 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>
<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>
)}
{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 }, (_, 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} />
@ -245,6 +298,7 @@ export default function ProjectsPage() {
isSelectionMode={isSelectionMode}
isSelected={selectedProjects.has(project.id)}
onSelect={handleSelectProject}
getProjectThumbnail={getProjectThumbnail}
/>
))}
</div>
@ -265,6 +319,7 @@ interface ProjectCardProps {
isSelectionMode?: boolean;
isSelected?: boolean;
onSelect?: (projectId: string, checked: boolean) => void;
getProjectThumbnail: (projectId: string) => Promise<string | null>;
}
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<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, 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 = (
<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>
)}
<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>
<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">
<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>
)}
{/* 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>
{!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>
<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">
<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}

View File

@ -161,6 +161,7 @@ interface TimelineStore {
// Computed values
getTotalDuration: () => number;
getProjectThumbnail: (projectId: string) => Promise<string | null>;
// History actions
undo: () => void;
@ -1178,6 +1179,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;