projects page re-design and a ton of other stuff

This commit is contained in:
Maze Winther
2026-01-26 16:10:16 +01:00
parent 954160d03b
commit 0c10bbf5ce
33 changed files with 1794 additions and 1185 deletions
+6 -6
View File
@@ -14,8 +14,8 @@
--card-foreground: hsl(0 0% 11%);
--popover: hsl(0, 0%, 100%);
--popover-foreground: hsl(0 0% 2%);
--primary: hsl(210, 91%, 49%);
--primary-foreground: hsl(0 0% 91%);
--primary: hsl(203, 100%, 50%);
--primary-foreground: hsl(0, 0%, 100%);
--secondary: hsl(216, 13%, 92%);
--secondary-foreground: hsl(0 0% 2%);
--muted: hsl(0 0% 85.1%);
@@ -26,7 +26,7 @@
--destructive-foreground: hsl(0, 0%, 100%);
--constructive: hsl(141, 71%, 48%);
--constructive-foreground: hsl(0, 0%, 100%);
--border: hsl(0 0% 83%);
--border: hsl(0 0% 88%);
--input: hsl(0 0% 85.1%);
--ring: hsl(0, 0%, 55%);
--chart-1: hsl(220 70% 50%);
@@ -42,8 +42,8 @@
--sidebar-accent-foreground: hsl(0 0% 2%);
--sidebar-border: hsl(0 0% 85.1%);
--sidebar-ring: hsl(0 0% 16.9%);
--panel-background: hsl(216 13% 92%);
--panel-accent: hsl(216, 8%, 86%);
--panel-background: hsl(216 13% 96%);
--panel-accent: hsl(216, 8%, 90%);
--radius: 1rem;
@@ -126,7 +126,7 @@
/* Font sizes */
--text-base: 0.95rem;
--text-base--line-height: calc(1.5 / 0.95);
--text-xs: 0.8rem;
--text-xs: 0.75rem;
--text-xs--line-height: calc(1 / 0.8);
/* Border radius */
-643
View File
@@ -1,643 +0,0 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
import type { KeyboardEvent, MouseEvent } from "react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { DeleteProjectDialog } from "@/components/editor/delete-project-dialog";
import { MigrationDialog } from "@/components/editor/migration-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,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useEditor } from "@/hooks/use-editor";
import type { TProjectMetadata } from "@/types/project";
import { formatDate } from "@/utils/date";
import { HugeiconsIcon } from "@hugeicons/react";
import {
ArrowHorizontalIcon,
ArrowLeft01Icon,
Calendar04Icon,
Delete02Icon,
MultiplicationSignIcon,
PlusSignIcon,
Search01Icon,
SortingNineOneIcon,
Video01Icon,
} from "@hugeicons/core-free-icons";
export default function ProjectsPage() {
const [isSelectionMode, setIsSelectionMode] = useState(false);
const [selectedProjects, setSelectedProjects] = useState<Set<string>>(
new Set(),
);
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [sortOption, setSortOption] = useState("createdAt-desc");
const router = useRouter();
const editor = useEditor();
useEffect(() => {
if (!editor.project.getIsInitialized()) {
editor.project.loadAllProjects();
}
}, [editor.project]);
const handleCreateProject = async () => {
try {
const projectId = await editor.project.createNewProject({
name: "New project",
});
router.push(`/editor/${projectId}`);
} catch (error) {
toast.error("Failed to create project", {
description:
error instanceof Error ? error.message : "Please try again",
});
}
};
const toggleSortOption = ({
sortField,
}: {
sortField: "createdAt" | "name";
}) => {
const isSameField = sortOption.startsWith(sortField);
const nextSortOption = isSameField
? `${sortField}-${sortOption.endsWith("asc") ? "desc" : "asc"}`
: `${sortField}-asc`;
setSortOption(nextSortOption);
};
const handleSelectProject = ({
projectId,
checked,
}: {
projectId: string;
checked: boolean;
}) => {
const newSelected = new Set(selectedProjects);
if (checked) {
newSelected.add(projectId);
} else {
newSelected.delete(projectId);
}
setSelectedProjects(newSelected);
};
const _handleSelectAll = ({ checked }: { checked: boolean }) => {
if (checked) {
setSelectedProjects(
new Set(projectsToDisplay.map((project) => project.id)),
);
} else {
setSelectedProjects(new Set());
}
};
const handleCancelSelection = () => {
setIsSelectionMode(false);
setSelectedProjects(new Set());
};
const handleBulkDelete = async () => {
try {
await Promise.all(
Array.from(selectedProjects).map((projectId) =>
editor.project.deleteProject({ id: projectId }),
),
);
} catch (error) {
toast.error("Failed to delete projects", {
description:
error instanceof Error ? error.message : "Please try again",
});
} finally {
setSelectedProjects(new Set());
setIsSelectionMode(false);
setIsBulkDeleteDialogOpen(false);
}
};
const projectsToDisplay = editor.project.getFilteredAndSortedProjects({
searchQuery,
sortOption,
});
const _isAllSelected =
projectsToDisplay.length > 0 &&
selectedProjects.size === projectsToDisplay.length;
const _hasSomeSelected =
selectedProjects.size > 0 &&
selectedProjects.size < projectsToDisplay.length;
const isLoading = editor.project.getIsLoading();
const isInitialized = editor.project.getIsInitialized();
return (
<div className="bg-background min-h-screen">
<MigrationDialog />
<div className="flex h-16 w-full items-center justify-between px-6 pt-2">
<Link
href="/"
className="hover:text-muted-foreground flex items-center gap-1 transition-colors"
>
<HugeiconsIcon icon={ArrowLeft01Icon} className="size-5! shrink-0" />
<span className="text-sm font-medium">Back</span>
</Link>
<div className="block md:hidden">
{isSelectionMode ? (
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={handleCancelSelection}
>
<HugeiconsIcon icon={MultiplicationSignIcon} />
Cancel
</Button>
{selectedProjects.size > 0 && (
<Button
variant="destructive"
size="sm"
onClick={() => setIsBulkDeleteDialogOpen(true)}
>
<HugeiconsIcon icon={Delete02Icon} />
Delete ({selectedProjects.size})
</Button>
)}
</div>
) : (
<CreateButton onClick={handleCreateProject} />
)}
</div>
</div>
<main className="mx-auto max-w-6xl px-6 pt-6 pb-6">
<div className="mb-8 flex items-center justify-between">
<div className="flex flex-col gap-3">
<h1 className="text-2xl font-bold tracking-tight md:text-3xl">
Your projects
</h1>
<p className="text-muted-foreground">
{projectsToDisplay.length}{" "}
{projectsToDisplay.length === 1 ? "project" : "projects"}
{isSelectionMode && selectedProjects.size > 0 && (
<span className="text-primary ml-2">
{selectedProjects.size} selected
</span>
)}
</p>
</div>
<div className="hidden md:block">
{isSelectionMode ? (
<div className="flex items-center gap-2">
<Button variant="outline" onClick={handleCancelSelection}>
<HugeiconsIcon icon={MultiplicationSignIcon} />
Cancel
</Button>
<Button
variant="destructive"
disabled={selectedProjects.size === 0}
onClick={() => setIsBulkDeleteDialogOpen(true)}
>
<HugeiconsIcon icon={Delete02Icon} />
Delete {selectedProjects.size} projects
</Button>
</div>
) : (
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={() => setIsSelectionMode(true)}
disabled={projectsToDisplay.length === 0}
>
Select projects
</Button>
<CreateButton onClick={handleCreateProject} />
</div>
)}
</div>
</div>
<div className="mb-4 flex items-center justify-between gap-4">
<div className="max-w-72 flex-1">
<Input
placeholder="Search projects..."
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
/>
</div>
<div className="flex items-center gap-0">
<TooltipProvider>
<Tooltip>
<DropdownMenu>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
aria-label="sort projects"
size="icon"
variant="outline"
className="size-9 items-center justify-center"
>
<HugeiconsIcon
icon={SortingNineOneIcon}
aria-hidden="true"
/>
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() =>
toggleSortOption({ sortField: "createdAt" })
}
>
Created{" "}
{sortOption.startsWith("createdAt") &&
(sortOption.endsWith("asc") ? "↑" : "↓")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => toggleSortOption({ sortField: "name" })}
>
Name{" "}
{sortOption.startsWith("name") &&
(sortOption.endsWith("asc") ? "↑" : "↓")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<TooltipContent>
<p>
Sort by{" "}
{sortOption.startsWith("createdAt") ? "date" : "name"} (
{sortOption.endsWith("asc") ? "ascending" : "descending"})
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
{isLoading || !isInitialized ? (
<ProjectsLoader />
) : projectsToDisplay.length === 0 ? (
<EmptyState
search={{
query: searchQuery,
onClearSearch: () => setSearchQuery(""),
}}
onCreateProject={handleCreateProject}
/>
) : (
<div className="xs:grid-cols-2 grid grid-cols-1 gap-6 sm:grid-cols-3 lg:grid-cols-4">
{projectsToDisplay.map((project) => (
<ProjectCard
key={project.id}
project={project}
isSelectionMode={isSelectionMode}
isSelected={selectedProjects.has(project.id)}
onSelect={handleSelectProject}
/>
))}
</div>
)}
</main>
<DeleteProjectDialog
isOpen={isBulkDeleteDialogOpen}
onOpenChange={setIsBulkDeleteDialogOpen}
onConfirm={handleBulkDelete}
/>
</div>
);
}
interface ProjectCardProps {
project: TProjectMetadata;
isSelectionMode?: boolean;
isSelected?: boolean;
onSelect?: ({
projectId,
checked,
}: {
projectId: string;
checked: boolean;
}) => void;
}
function ProjectCard({
project,
isSelectionMode = false,
isSelected = false,
onSelect,
}: ProjectCardProps) {
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
const editor = useEditor();
const handleDeleteProject = async () => {
await editor.project.deleteProject({ id: project.id });
setIsDropdownOpen(false);
};
const handleRenameProject = async ({ name }: { name: string }) => {
await editor.project.renameProject({ id: project.id, name });
setIsRenameDialogOpen(false);
};
const handleDuplicateProject = async () => {
setIsDropdownOpen(false);
await editor.project.duplicateProject({ id: project.id });
};
const handleCardClick = ({
event,
}: {
event: MouseEvent<HTMLButtonElement>;
}) => {
if (isSelectionMode) {
event.preventDefault();
onSelect?.({ projectId: project.id, checked: !isSelected });
}
};
const handleCardKeyDown = ({
event,
}: {
event: KeyboardEvent<HTMLButtonElement>;
}) => {
if (isSelectionMode && (event.key === "Enter" || event.key === " ")) {
event.preventDefault();
onSelect?.({ projectId: project.id, checked: !isSelected });
}
};
const cardContent = (
<Card
className={`bg-background overflow-hidden border-none p-0 transition-all ${
isSelectionMode && isSelected ? "ring-primary ring-2" : ""
}`}
>
<div
className={`bg-muted relative aspect-square transition-opacity ${
isDropdownOpen ? "opacity-65" : "opacity-100 group-hover:opacity-65"
}`}
>
{isSelectionMode && (
<div className="absolute top-3 left-3 z-10">
<div className="bg-background/80 flex size-5 items-center justify-center rounded-full border backdrop-blur-xs">
<Checkbox
checked={isSelected}
onCheckedChange={(checked) =>
onSelect?.({
projectId: project.id,
checked: checked === true,
})
}
onClick={(event) => event.stopPropagation()}
className="size-4"
/>
</div>
</div>
)}
<div className="absolute inset-0">
{project.thumbnail ? (
<Image
src={project.thumbnail}
alt="Project thumbnail"
fill
className="object-cover"
/>
) : (
<div className="bg-muted/50 flex size-full items-center justify-center">
<HugeiconsIcon
icon={Video01Icon}
className="text-muted-foreground size-12 shrink-0"
/>
</div>
)}
</div>
</div>
<CardContent className="flex flex-col gap-1 px-0 pt-5">
<div className="flex items-start justify-between">
<h3 className="group-hover:text-foreground/90 line-clamp-2 text-sm leading-snug font-medium transition-colors">
{project.name}
</h3>
{!isSelectionMode && (
<DropdownMenu
open={isDropdownOpen}
onOpenChange={setIsDropdownOpen}
>
<DropdownMenuTrigger asChild>
<Button
aria-label="project options"
variant="text"
size="sm"
className={`ml-2 size-6 shrink-0 p-0 transition-all ${
isDropdownOpen
? "opacity-100"
: "opacity-0 group-hover:opacity-100"
}`}
onClick={(event) => event.preventDefault()}
>
<HugeiconsIcon
icon={ArrowHorizontalIcon}
aria-hidden="true"
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
onCloseAutoFocus={(event) => {
event.preventDefault();
event.stopPropagation();
}}
>
<DropdownMenuItem
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setIsDropdownOpen(false);
setIsRenameDialogOpen(true);
}}
>
Rename
</DropdownMenuItem>
<DropdownMenuItem
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
handleDuplicateProject();
}}
>
Duplicate
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setIsDropdownOpen(false);
setIsDeleteDialogOpen(true);
}}
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<div className="space-y-1">
<div className="text-muted-foreground flex items-center gap-1.5 text-sm">
<HugeiconsIcon icon={Calendar04Icon} className="size-4" />
<span>Created {formatDate({ date: project.createdAt })}</span>
</div>
</div>
</CardContent>
</Card>
);
return (
<>
{isSelectionMode ? (
<button
type="button"
onClick={(event) => handleCardClick({ event })}
onKeyDown={(event) => handleCardKeyDown({ event })}
className="group block w-full cursor-pointer text-left"
>
{cardContent}
</button>
) : (
<Link href={`/editor/${project.id}`} className="group block">
{cardContent}
</Link>
)}
<DeleteProjectDialog
isOpen={isDeleteDialogOpen}
onOpenChange={setIsDeleteDialogOpen}
onConfirm={handleDeleteProject}
/>
<RenameProjectDialog
isOpen={isRenameDialogOpen}
onOpenChange={setIsRenameDialogOpen}
onConfirm={(name) => handleRenameProject({ name })}
projectName={project.name}
/>
</>
);
}
function ProjectsLoader() {
const skeletonIds = Array.from(
{ length: 8 },
(_, index) => `skeleton-${index}`,
);
return (
<div className="xs:grid-cols-2 grid grid-cols-1 gap-6 sm:grid-cols-3 lg:grid-cols-4">
{skeletonIds.map((skeletonId) => (
<div
key={skeletonId}
className="bg-background overflow-hidden border-none p-0"
>
<Skeleton className="bg-muted/50 aspect-square w-full" />
<div className="flex flex-col gap-1.5 px-0 pt-5">
<Skeleton className="bg-muted/50 h-4 w-3/4" />
<div className="flex items-center gap-1.5">
<Skeleton className="bg-muted/50 size-4" />
<Skeleton className="bg-muted/50 h-4 w-24" />
</div>
</div>
</div>
))}
</div>
);
}
function CreateButton({ onClick }: { onClick?: () => void }) {
return (
<Button className="flex" onClick={onClick}>
<HugeiconsIcon icon={PlusSignIcon} />
<span className="text-sm font-medium">New project</span>
</Button>
);
}
function EmptyState({
search,
onCreateProject,
}: {
search: { query: string; onClearSearch: () => void };
onCreateProject: () => void;
}) {
const editor = useEditor();
const savedProjects = editor.project.getSavedProjects();
if (savedProjects.length > 0) {
return (
<div className="flex flex-col items-center justify-center gap-6 py-16 text-center">
<div className="flex flex-col items-center gap-2">
<div className="bg-muted/30 flex size-16 items-center justify-center rounded-full">
<HugeiconsIcon
icon={Search01Icon}
className="text-muted-foreground size-8"
/>
</div>
<h3 className="text-lg font-medium">No results found</h3>
<p className="text-muted-foreground max-w-md">
Your search for "{search.query}" did not return any results.
</p>
</div>
<Button onClick={search.onClearSearch} variant="outline">
Clear search
</Button>
</div>
);
}
return (
<div className="flex flex-col items-center justify-center gap-6 py-16 text-center">
<div className="flex flex-col items-center gap-2">
<div className="bg-muted/30 flex size-16 items-center justify-center rounded-full">
<HugeiconsIcon
icon={Video01Icon}
className="text-muted-foreground size-8"
/>
</div>
<h3 className="text-lg font-medium">No projects yet</h3>
<p className="text-muted-foreground max-w-md">
Start creating your first video project. Import media, edit, and
export professional videos.
</p>
</div>
<Button size="lg" className="gap-2" onClick={onCreateProject}>
<HugeiconsIcon icon={PlusSignIcon} />
Create your first project
</Button>
</div>
);
}
File diff suppressed because it is too large Load Diff
+89
View File
@@ -0,0 +1,89 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import type { TProjectSortKey } from "@/types/project";
export type ProjectsViewMode = "grid" | "list";
interface ProjectsState {
searchQuery: string;
sortKey: TProjectSortKey;
sortOrder: "asc" | "desc";
viewMode: ProjectsViewMode;
selectedProjectIds: string[];
isHydrated: boolean;
setIsHydrated: ({ isHydrated }: { isHydrated: boolean }) => void;
setSearchQuery: ({ query }: { query: string }) => void;
setSortKey: ({ sortKey }: { sortKey: TProjectSortKey }) => void;
setSortOrder: ({ sortOrder }: { sortOrder: "asc" | "desc" }) => void;
toggleSortOrder: () => void;
setViewMode: ({ viewMode }: { viewMode: ProjectsViewMode }) => void;
setSelectedProjects: ({ projectIds }: { projectIds: string[] }) => void;
clearSelectedProjects: () => void;
setProjectSelected: ({
projectId,
isSelected,
}: {
projectId: string;
isSelected: boolean;
}) => void;
}
const getNextSelectedProjectIds = ({
selectedProjectIds,
projectId,
isSelected,
}: {
selectedProjectIds: string[];
projectId: string;
isSelected: boolean;
}): string[] => {
const selectedProjectIdSet = new Set(selectedProjectIds);
if (isSelected) {
selectedProjectIdSet.add(projectId);
return Array.from(selectedProjectIdSet);
}
selectedProjectIdSet.delete(projectId);
return Array.from(selectedProjectIdSet);
};
export const useProjectsStore = create<ProjectsState>()(
persist(
(set) => ({
searchQuery: "",
sortKey: "createdAt",
sortOrder: "desc",
viewMode: "grid",
selectedProjectIds: [],
isHydrated: false,
setIsHydrated: ({ isHydrated }) => set({ isHydrated }),
setSearchQuery: ({ query }) => set({ searchQuery: query }),
setSortKey: ({ sortKey }) => set({ sortKey }),
setSortOrder: ({ sortOrder }) => set({ sortOrder }),
toggleSortOrder: () =>
set((state) => ({
sortOrder: state.sortOrder === "asc" ? "desc" : "asc",
})),
setViewMode: ({ viewMode }) => set({ viewMode }),
setSelectedProjects: ({ projectIds }) =>
set({ selectedProjectIds: projectIds }),
clearSelectedProjects: () => set({ selectedProjectIds: [] }),
setProjectSelected: ({ projectId, isSelected }) =>
set((state) => ({
selectedProjectIds: getNextSelectedProjectIds({
selectedProjectIds: state.selectedProjectIds,
projectId,
isSelected,
}),
})),
}),
{
name: "projects-view-mode",
partialize: (state) => ({ viewMode: state.viewMode }),
onRehydrateStorage: () => (state) => {
state?.setIsHydrated({ isHydrated: true });
},
},
),
);
+5 -50
View File
@@ -31,75 +31,30 @@ const roadmapItems: RoadmapItem[] = [
{
title: "Core UI",
description:
"Built the foundation - main layout, header, sidebar, timeline container, and basic component structure. Not all functionality yet, but the UI framework that everything else builds on.",
"Build the foundation - main layout, header, sidebar, timeline container, and basic component structure. Not all functionality yet, but the UI framework that everything else builds on.",
status: {
text: "Completed",
type: "complete",
},
},
{
title: "Basic Functionality",
title: "Essential functionality",
description:
"The heart of any video editor. Timeline zoom in/out, making clips longer/shorter, dragging elements around, selection, playhead scrubbing. **This part has to be fucking perfect** because it's what users interact with 99% of the time.",
"Everything that makes a video editor **useful**. Timeline interactivity, storage, effects, transitions, etc.",
status: {
text: "In progress",
type: "pending",
},
},
{
title: "Export/preview logic",
title: "Badge (potentially)",
description:
"The foundation that enables everything else. Real-time preview, video rendering, export functionality. Once this works, we can add effects, filters, transitions - basically everything that makes a video editor powerful.",
status: {
text: "Completed",
type: "complete",
},
},
{
title: "Desktop/mobile app",
description:
"The foundation that enables everything else. Real-time preview, video rendering, export functionality. Once this works, we can add effects, filters, transitions - basically everything that makes a video editor powerful.",
status: {
text: "In progress",
type: "pending",
},
},
{
title: "Text",
description:
"After media, text is the next most important thing. Font selection with custom font imports, text stroke, colors. All the text essential text properties.",
status: {
text: "In progress",
type: "pending",
},
},
{
title: "Effects",
description:
"Adding visual effects to both text and media. Blur, brightness, contrast, saturation, filters, and all the creative tools that make videos pop. This is where the magic happens.",
'An "Edit with OpenCut" badge web apps can integrate. Shows on video players.',
status: {
text: "Not started",
type: "default",
},
},
{
title: "Transitions",
description:
"Smooth transitions between clips. Fade in/out, slide, zoom, dissolve, and custom transition effects.",
status: {
text: "Not started",
type: "default",
},
},
{
title: "Refine from here",
description:
"Once we nail the above, we have a **solid foundation** to build anything. Advanced features, performance optimizations, mobile support, desktop app.",
status: {
text: "Future",
type: "info",
},
},
];
export const metadata: Metadata = {