summaryrefslogtreecommitdiff
path: root/apps/web/src
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src')
-rw-r--r--apps/web/src/app/projects/page.tsx451
-rw-r--r--apps/web/src/stores/timeline-store.ts37
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;