From 0c10bbf5ce6e99ff2034f9da2b7673dbd3b89a7e Mon Sep 17 00:00:00 2001
From: Maze Winther
Date: Mon, 26 Jan 2026 16:10:16 +0100
Subject: [PATCH] projects page re-design and a ton of other stuff
---
.cursor/rules/codebase-index.mdc | 33 +-
apps/web/package.json | 1 +
apps/web/src/app/globals.css | 12 +-
apps/web/src/app/projects/page-legacy.tsx | 643 ---------
apps/web/src/app/projects/page.tsx | 1194 ++++++++++++-----
apps/web/src/app/projects/store.ts | 89 ++
apps/web/src/app/roadmap/page.tsx | 55 +-
.../editor/assets-panel/views/media.tsx | 22 +-
.../editor/delete-project-dialog.tsx | 25 +-
.../src/components/editor/editor-header.tsx | 10 +-
apps/web/src/components/editor/onboarding.tsx | 18 +-
.../components/editor/project-info-dialog.tsx | 74 +
.../properties-panel/text-properties.tsx | 2 +-
apps/web/src/components/ui/alert-dialog.tsx | 2 +-
apps/web/src/components/ui/button.tsx | 24 +-
apps/web/src/components/ui/checkbox.tsx | 2 +-
apps/web/src/components/ui/dialog.tsx | 2 +-
apps/web/src/components/ui/font-picker.tsx | 6 +-
apps/web/src/components/ui/input.tsx | 32 +-
apps/web/src/components/ui/select.tsx | 21 +-
apps/web/src/components/ui/sheet.tsx | 2 +-
apps/web/src/components/ui/textarea.tsx | 4 +-
apps/web/src/core/managers/project-manager.ts | 214 ++-
.../src/hooks/storage/use-local-storage.ts | 58 +
apps/web/src/lib/scenes.ts | 14 +
.../src/services/storage/migrations/index.ts | 9 +-
.../services/storage/migrations/v2-to-v3.ts | 92 ++
.../src/services/storage/storage-service.ts | 15 +-
apps/web/src/types/project.ts | 5 +
bun.lock | 13 +-
html/design1.html | 68 +-
html/dialog_designs.html | 216 +++
packages/ui/src/icons/ui.tsx | 4 +-
33 files changed, 1795 insertions(+), 1186 deletions(-)
delete mode 100644 apps/web/src/app/projects/page-legacy.tsx
create mode 100644 apps/web/src/app/projects/store.ts
create mode 100644 apps/web/src/components/editor/project-info-dialog.tsx
create mode 100644 apps/web/src/hooks/storage/use-local-storage.ts
create mode 100644 apps/web/src/services/storage/migrations/v2-to-v3.ts
create mode 100644 html/dialog_designs.html
diff --git a/.cursor/rules/codebase-index.mdc b/.cursor/rules/codebase-index.mdc
index 9d0d3d85..ce51e890 100644
--- a/.cursor/rules/codebase-index.mdc
+++ b/.cursor/rules/codebase-index.mdc
@@ -187,6 +187,21 @@ use-action-handler.ts
use-editor-actions.ts
export function useEditorActions()
+## apps/web/src/hooks/storage
+
+use-local-storage.ts
+ export function useLocalStorage({
+ key,
+ defaultValue,
+ }: {
+ key: string;
+ defaultValue: T;
+ }): [
+ T,
+ ({ value }: { value: T | ((previousValue: T) => T) }) => void,
+ boolean,
+ ]
+
## apps/web/src/hooks/timeline
use-edge-auto-scroll.ts
@@ -457,6 +472,11 @@ scenes.ts
scenes: TScene[];
currentSceneId: string;
}): TScene | null
+ export function getProjectDurationFromScenes({
+ scenes,
+ }: {
+ scenes: TScene[];
+ }): number
export function updateSceneInArray({
scenes,
sceneId,
@@ -1383,6 +1403,7 @@ project.ts
id: string
name: string
thumbnail?: string
+ duration: number
createdAt: Date
updatedAt: Date
}
@@ -1399,6 +1420,9 @@ project.ts
settings: TProjectSettings
version: number
}
+ export type TProjectSortKey = "createdAt" | "updatedAt" | "name" | "duration"
+ export type TSortOrder = "asc" | "desc"
+ export type TProjectSortOption = `${TProjectSortKey}-${TSortOrder}`
sounds.ts
export interface SoundEffect {
@@ -1695,14 +1719,7 @@ brand.tsx
})
ui.tsx
- export function OcSplitKeepLeftIcon({
- className = "",
- size = 32,
- }: {
- className?: string;
- size?: number;
- })
- export function OcSplitKeepRightIcon({
+ export function OcVideoIcon({
className = "",
size = 32,
}: {
diff --git a/apps/web/package.json b/apps/web/package.json
index 09e8d7ca..aa752a81 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -27,6 +27,7 @@
"@opencut/env": "workspace:*",
"@opencut/ui": "workspace:*",
"@radix-ui/react-dialog": "^1.1.15",
+ "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "^1.2.8",
diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css
index 9b547211..da2523fc 100644
--- a/apps/web/src/app/globals.css
+++ b/apps/web/src/app/globals.css
@@ -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 */
diff --git a/apps/web/src/app/projects/page-legacy.tsx b/apps/web/src/app/projects/page-legacy.tsx
deleted file mode 100644
index 7aeb9523..00000000
--- a/apps/web/src/app/projects/page-legacy.tsx
+++ /dev/null
@@ -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>(
- 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 (
-
-
-
-
-
-
Back
-
-
- {isSelectionMode ? (
-
-
- {selectedProjects.size > 0 && (
-
- )}
-
- ) : (
-
- )}
-
-
-
-
-
-
- Your projects
-
-
- {projectsToDisplay.length}{" "}
- {projectsToDisplay.length === 1 ? "project" : "projects"}
- {isSelectionMode && selectedProjects.size > 0 && (
-
- • {selectedProjects.size} selected
-
- )}
-
-
-
- {isSelectionMode ? (
-
-
-
-
- ) : (
-
-
-
-
- )}
-
-
-
-
-
- setSearchQuery(event.target.value)}
- />
-
-
-
-
-
-
-
-
-
-
-
-
- toggleSortOption({ sortField: "createdAt" })
- }
- >
- Created{" "}
- {sortOption.startsWith("createdAt") &&
- (sortOption.endsWith("asc") ? "↑" : "↓")}
-
- toggleSortOption({ sortField: "name" })}
- >
- Name{" "}
- {sortOption.startsWith("name") &&
- (sortOption.endsWith("asc") ? "↑" : "↓")}
-
-
-
-
-
- Sort by{" "}
- {sortOption.startsWith("createdAt") ? "date" : "name"} (
- {sortOption.endsWith("asc") ? "ascending" : "descending"})
-
-
-
-
-
-
-
- {isLoading || !isInitialized ? (
-
- ) : projectsToDisplay.length === 0 ? (
- setSearchQuery(""),
- }}
- onCreateProject={handleCreateProject}
- />
- ) : (
-
- {projectsToDisplay.map((project) => (
-
- ))}
-
- )}
-
-
-
-
- );
-}
-
-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;
- }) => {
- if (isSelectionMode) {
- event.preventDefault();
- onSelect?.({ projectId: project.id, checked: !isSelected });
- }
- };
-
- const handleCardKeyDown = ({
- event,
- }: {
- event: KeyboardEvent;
- }) => {
- if (isSelectionMode && (event.key === "Enter" || event.key === " ")) {
- event.preventDefault();
- onSelect?.({ projectId: project.id, checked: !isSelected });
- }
- };
-
- const cardContent = (
-
-
- {isSelectionMode && (
-
-
-
- onSelect?.({
- projectId: project.id,
- checked: checked === true,
- })
- }
- onClick={(event) => event.stopPropagation()}
- className="size-4"
- />
-
-
- )}
-
-
- {project.thumbnail ? (
-
- ) : (
-
-
-
- )}
-
-
-
-
-
-
- {project.name}
-
- {!isSelectionMode && (
-
-
-
-
- {
- event.preventDefault();
- event.stopPropagation();
- }}
- >
- {
- event.preventDefault();
- event.stopPropagation();
- setIsDropdownOpen(false);
- setIsRenameDialogOpen(true);
- }}
- >
- Rename
-
- {
- event.preventDefault();
- event.stopPropagation();
- handleDuplicateProject();
- }}
- >
- Duplicate
-
-
- {
- event.preventDefault();
- event.stopPropagation();
- setIsDropdownOpen(false);
- setIsDeleteDialogOpen(true);
- }}
- >
- Delete
-
-
-
- )}
-
-
-
-
-
- Created {formatDate({ date: project.createdAt })}
-
-
-
-
- );
-
- return (
- <>
- {isSelectionMode ? (
-
- ) : (
-
- {cardContent}
-
- )}
-
- handleRenameProject({ name })}
- projectName={project.name}
- />
- >
- );
-}
-
-function ProjectsLoader() {
- const skeletonIds = Array.from(
- { length: 8 },
- (_, index) => `skeleton-${index}`,
- );
-
- return (
-
- {skeletonIds.map((skeletonId) => (
-
- ))}
-
- );
-}
-
-function CreateButton({ onClick }: { onClick?: () => void }) {
- return (
-
- );
-}
-
-function EmptyState({
- search,
- onCreateProject,
-}: {
- search: { query: string; onClearSearch: () => void };
- onCreateProject: () => void;
-}) {
- const editor = useEditor();
- const savedProjects = editor.project.getSavedProjects();
-
- if (savedProjects.length > 0) {
- return (
-
-
-
-
-
-
No results found
-
- Your search for "{search.query}" did not return any results.
-
-
-
-
- );
- }
-
- return (
-
-
-
-
-
-
No projects yet
-
- Start creating your first video project. Import media, edit, and
- export professional videos.
-
-
-
-
- );
-}
diff --git a/apps/web/src/app/projects/page.tsx b/apps/web/src/app/projects/page.tsx
index 39a5b018..788f4b8a 100644
--- a/apps/web/src/app/projects/page.tsx
+++ b/apps/web/src/app/projects/page.tsx
@@ -1,346 +1,902 @@
"use client";
import Image from "next/image";
-import { useEffect, useMemo, useRef, useState } from "react";
+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 { MigrationDialog } from "@/components/editor/migration-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 { Skeleton } from "@/components/ui/skeleton";
+import { useEditor } from "@/hooks/use-editor";
+import { useProjectsStore } from "./store";
+import type {
+ TProjectMetadata,
+ TProjectSortKey,
+ TProjectSortOption,
+} from "@/types/project";
+import { formatTimeCode } from "@/lib/time";
+import { formatDate } from "@/utils/date";
import { HugeiconsIcon } from "@hugeicons/react";
import {
- ArrowHorizontalIcon,
- Clock03Icon,
- Folder03Icon,
+ Calendar04Icon,
GridViewIcon,
LeftToRightListDashIcon,
PlusSignIcon,
- SearchIcon,
- SortingNineOneIcon,
+ Search01Icon,
+ Video01Icon,
+ MoreHorizontalIcon,
+ Delete02Icon,
+ Copy01Icon,
+ Edit03Icon,
+ ArrowDown02Icon,
+ InformationCircleIcon,
} from "@hugeicons/core-free-icons";
+import { OcVideoIcon } from "@opencut/ui/icons";
+import { Label } from "@/components/ui/label";
+import {
+ DropdownMenu,
+ DropdownMenuCheckboxItem,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { DeleteProjectDialog } from "@/components/editor/delete-project-dialog";
+import { ProjectInfoDialog } from "@/components/editor/project-info-dialog";
+import { RenameProjectDialog } from "@/components/rename-project-dialog";
+import { cn } from "@/utils/ui";
-const projects = [
- {
- id: "project-1",
- name: "Summer Vlog 2025",
- createdAtLabel: "Created Jan 20, 2026",
- duration: "02:14",
- },
- {
- id: "project-2",
- name: "Product Launch Teaser",
- createdAtLabel: "Created Jan 20, 2026",
- duration: "00:45",
- },
- {
- id: "project-3",
- name: "Podcast Ep. 4",
- createdAtLabel: "Created Jan 18, 2026",
- duration: "03:30",
- },
-];
+const formatProjectDuration = ({
+ duration,
+}: {
+ duration: number | undefined;
+}): string | null => {
+ if (duration === undefined) {
+ return null;
+ }
-const thumbnailSrc = "/open-graph/default.jpg";
+ const format = duration >= 3600 ? "HH:MM:SS" : "MM:SS";
+ return formatTimeCode({ timeInSeconds: duration, format });
+};
export default function ProjectsPage() {
- const [selectedProjectIds, setSelectedProjectIds] = useState>(
- [],
- );
- const selectAllRef = useRef(null);
-
- const selectedProjectIdSet = useMemo(() => {
- return new Set(selectedProjectIds);
- }, [selectedProjectIds]);
-
- const selectedCount = selectedProjectIds.length;
- const totalCount = projects.length;
- const isAllSelected = selectedCount > 0 && selectedCount === totalCount;
- const isIndeterminate = selectedCount > 0 && selectedCount < totalCount;
+ const { searchQuery, sortKey, sortOrder, viewMode } = useProjectsStore();
+ const editor = useEditor();
useEffect(() => {
- if (!selectAllRef.current) {
- return;
+ if (!editor.project.getIsInitialized()) {
+ editor.project.loadAllProjects();
}
- selectAllRef.current.indeterminate = isIndeterminate;
- }, [isIndeterminate]);
+ }, [editor.project]);
- const toggleProjectSelection = ({
- projectId,
- isSelected,
- }: {
- projectId: string;
- isSelected: boolean;
- }) => {
- setSelectedProjectIds((previousIds) => {
- if (isSelected) {
- if (previousIds.includes(projectId)) {
- return previousIds;
- }
- return [...previousIds, projectId];
- }
- return previousIds.filter((id) => id !== projectId);
- });
- };
+ const sortOption: TProjectSortOption = `${sortKey}-${sortOrder}`;
+ const projectsToDisplay = editor.project.getFilteredAndSortedProjects({
+ searchQuery,
+ sortOption,
+ });
- const toggleSelectAll = ({ isSelected }: { isSelected: boolean }) => {
- if (!isSelected) {
- setSelectedProjectIds([]);
- return;
- }
- setSelectedProjectIds(projects.map(({ id }) => id));
- };
+ const isLoading = editor.project.getIsLoading();
+ const isInitialized = editor.project.getIsInitialized();
return (
- 1 ? "multi-select" : ""
- }`}
- style={{ "--brand-blue": "#00A3FF" } as React.CSSProperties}
- >
-
-
-
-
-
-
All projects
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
p.id)} />
+
+ {isLoading || !isInitialized ? (
+
+ ) : projectsToDisplay.length === 0 ? (
+
+ ) : (
-
-
-
-
-
+ {projectsToDisplay.map((project) => (
+
+ ))}
-
-
- {projects.map(({ createdAtLabel, duration, id, name }) => {
- const isSelected = selectedProjectIdSet.has(id);
- return (
-
-
- {
- toggleProjectSelection({
- projectId: id,
- isSelected: currentTarget.checked,
- });
- }}
- />
-
-
-
-
-
-
-
-
-
-
- );
- })}
-
-
+ )}
-
-
+
+ );
+}
+
+function ProjectsHeader() {
+ const { viewMode, isHydrated, setViewMode } = useProjectsStore();
+
+ return (
+
+
+
+
All projects
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+const SORT_LABELS: Record = {
+ createdAt: "Created",
+ updatedAt: "Modified",
+ name: "Name",
+ duration: "Duration",
+};
+
+function ProjectsToolbar({ projectIds }: { projectIds: string[] }) {
+ const {
+ selectedProjectIds,
+ sortKey,
+ sortOrder,
+ setSortOrder,
+ setSelectedProjects,
+ clearSelectedProjects,
+ viewMode,
+ setViewMode,
+ } = useProjectsStore();
+
+ const selectedProjectCount = selectedProjectIds.length;
+ const isAllSelected =
+ projectIds.length > 0 && selectedProjectCount === projectIds.length;
+ const hasSomeSelected =
+ selectedProjectCount > 0 && selectedProjectCount < projectIds.length;
+
+ const handleSelectAll = ({ checked }: { checked: boolean }) => {
+ if (checked) {
+ setSelectedProjects({ projectIds });
+ return;
+ }
+ clearSelectedProjects();
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {selectedProjectCount > 1 ?
: null}
+
+ );
+}
+
+function SearchBar({
+ className,
+ collapsed,
+}: {
+ className?: string;
+ collapsed?: boolean;
+}) {
+ const { searchQuery, setSearchQuery } = useProjectsStore();
+
+ return (
+ <>
+ {collapsed ? (
+
+
+
+ ) : (
+
+
+ setSearchQuery({ query: event.target.value })}
+ size="lg"
+ className="pl-9"
+ />
+
+ )}
+ >
+ );
+}
+
+const PROJECT_ACTIONS = [
+ {
+ id: "duplicate",
+ label: "Duplicate",
+ icon: Copy01Icon,
+ variant: "outline" as const,
+ },
+ {
+ id: "delete",
+ label: "Delete",
+ icon: Delete02Icon,
+ variant: "destructive-foreground" as const,
+ },
+] as const;
+
+async function deleteProjects({
+ editor,
+ ids,
+}: {
+ editor: ReturnType;
+ ids: string[];
+}) {
+ await editor.project.deleteProjects({ ids });
+}
+
+async function duplicateProjects({
+ editor,
+ ids,
+}: {
+ editor: ReturnType;
+ ids: string[];
+}) {
+ await editor.project.duplicateProjects({ ids });
+}
+
+async function renameProject({
+ editor,
+ id,
+ name,
+}: {
+ editor: ReturnType;
+ id: string;
+ name: string;
+}) {
+ await editor.project.renameProject({ id, name });
+}
+
+function ProjectActions() {
+ const editor = useEditor();
+ const { selectedProjectIds, clearSelectedProjects } = useProjectsStore();
+ const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
+
+ const savedProjects = editor.project.getSavedProjects();
+ const selectedProjectNames = savedProjects
+ .filter((project) => selectedProjectIds.includes(project.id))
+ .map((project) => project.name);
+
+ const handleDuplicate = async () => {
+ await duplicateProjects({ editor, ids: selectedProjectIds });
+ clearSelectedProjects();
+ };
+
+ const handleDeleteClick = () => {
+ setIsDeleteDialogOpen(true);
+ };
+
+ const handleDeleteConfirm = async () => {
+ await deleteProjects({ editor, ids: selectedProjectIds });
+ clearSelectedProjects();
+ setIsDeleteDialogOpen(false);
+ };
+
+ const actionHandlers: Record void> = {
+ duplicate: handleDuplicate,
+ delete: handleDeleteClick,
+ };
+
+ return (
+ <>
+
+
+ {PROJECT_ACTIONS.map((action) => (
+
+ ))}
+
+
+
+
+
+
+
+ {PROJECT_ACTIONS.map((action) => (
+
+
+ {action.label}
+
+ ))}
+
+
+
+
+
+ >
+ );
+}
+
+function SortDropdown({ children }: { children: React.ReactNode }) {
+ const { sortKey, setSortKey } = useProjectsStore();
+
+ return (
+
+ {children}
+
+ setSortKey({ sortKey: "createdAt" })}
+ >
+ Created
+
+ setSortKey({ sortKey: "updatedAt" })}
+ >
+ Modified
+
+ setSortKey({ sortKey: "name" })}
+ >
+ Name
+
+ setSortKey({ sortKey: "duration" })}
+ >
+ Duration
+
+
+
+ );
+}
+
+function NewProjectButton() {
+ const editor = useEditor();
+ const router = useRouter();
+
+ const handleCreateProject = async () => {
+ const projectId = await editor.project.createNewProject({
+ name: "New project",
+ });
+ router.push(`/editor/${projectId}`);
+ };
+
+ return (
+
+ );
+}
+
+function ProjectItem({ project }: { project: TProjectMetadata }) {
+ const { selectedProjectIds, viewMode, setProjectSelected } =
+ useProjectsStore();
+ const selectedProjectIdSet = new Set(selectedProjectIds);
+ const isSelected = selectedProjectIdSet.has(project.id);
+ const selectedProjectCount = selectedProjectIds.length;
+ const [isDropdownOpen, setIsDropdownOpen] = useState(false);
+ const durationLabel = formatProjectDuration({ duration: project.duration });
+ const isMultiSelect = selectedProjectCount > 1;
+ const isGridView = viewMode === "grid";
+
+ const gridContent = (
+
+
+
+ {project.thumbnail ? (
+
+ ) : (
+
+
+
+ )}
+
+
+ {durationLabel && (
+
+ {durationLabel}
+
+ )}
+
+
+
+
+ {project.name}
+
+
+
+ Created {formatDate({ date: project.createdAt })}
+
+
+
+ );
+
+ const listRowContent = (
+
+
+ {project.thumbnail ? (
+
+ ) : (
+
+
+
+ )}
+
+
+
+ {project.name}
+
+
+
+ {durationLabel ?? "—"}
+
+
+
+ {formatDate({ date: project.createdAt })}
+
+
+ );
+
+ const listContent = (
+
+
+ setProjectSelected({
+ projectId: project.id,
+ isSelected: checked === true,
+ })
+ }
+ className="size-5 shrink-0"
+ />
+
+
+ {listRowContent}
+
+
+ {!isMultiSelect && (
+
+ )}
+
+ );
+
+ const cardContent = isGridView ? gridContent : listContent;
+
+ if (!isGridView) {
+ return {listContent}
;
+ }
+
+ return (
+
+
+ {cardContent}
+
+
+ {isGridView && (
+ <>
+
+ setProjectSelected({
+ projectId: project.id,
+ isSelected: checked === true,
+ })
+ }
+ className={`absolute z-10 size-5 top-3 left-3 ${
+ isSelected || isDropdownOpen
+ ? "opacity-100"
+ : "opacity-0 group-hover:opacity-100"
+ }`}
+ />
+
+ {!isMultiSelect && (
+
+ )}
+ >
+ )}
+
+ );
+}
+
+function ProjectMenu({
+ isOpen,
+ onOpenChange,
+ project,
+ variant = "grid",
+}: {
+ isOpen: boolean;
+ onOpenChange: (open: boolean) => void;
+ project: TProjectMetadata;
+ variant?: "grid" | "list";
+}) {
+ const editor = useEditor();
+ const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
+ const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
+ const [isInfoDialogOpen, setIsInfoDialogOpen] = useState(false);
+
+ const handleMenuClick = ({
+ event,
+ }: {
+ event: MouseEvent;
+ }) => {
+ event.preventDefault();
+ event.stopPropagation();
+ };
+
+ const handleMenuKeyDown = ({
+ event,
+ }: {
+ event: KeyboardEvent;
+ }) => {
+ if (event.key !== "Enter" && event.key !== " ") {
+ return;
+ }
+ event.preventDefault();
+ event.stopPropagation();
+ };
+
+ const handleRename = () => {
+ setIsRenameDialogOpen(true);
+ onOpenChange(false);
+ };
+
+ const handleDuplicate = async () => {
+ await duplicateProjects({ editor, ids: [project.id] });
+ onOpenChange(false);
+ };
+
+ const handleDeleteClick = () => {
+ setIsDeleteDialogOpen(true);
+ onOpenChange(false);
+ };
+
+ const handleDeleteConfirm = async () => {
+ await deleteProjects({ editor, ids: [project.id] });
+ setIsDeleteDialogOpen(false);
+ };
+
+ const handleInfoClick = () => {
+ setIsInfoDialogOpen(true);
+ onOpenChange(false);
+ };
+
+ const isGrid = variant === "grid";
+
+ return (
+ <>
+
+
+
+
+
+
+
+ Rename
+
+
+
+ Duplicate
+
+
+
+ Info
+
+
+
+ Delete
+
+
+
+
+ {
+ await renameProject({ editor, id: project.id, name: newName });
+ setIsRenameDialogOpen(false);
+ }}
+ />
+
+
+
+
+ >
+ );
+}
+
+function ProjectsSkeleton() {
+ const skeletonIds = Array.from(
+ { length: 24 },
+ (_, index) => `skeleton-${index}`,
+ );
+
+ return (
+
+ {skeletonIds.map((skeletonId) => (
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+ );
+}
+
+function EmptyState() {
+ const { searchQuery, setSearchQuery } = useProjectsStore();
+ const router = useRouter();
+ const editor = useEditor();
+ const savedProjects = editor.project.getSavedProjects();
+
+ 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",
+ });
+ }
+ };
+
+ if (savedProjects.length > 0) {
+ return (
+
+
+
+
+
No results found
+
+ Your search for "{searchQuery}" did not return any results.
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
No projects yet
+
+ Start creating your first video project. Import media, edit, and
+ export professional videos.
+
+
+
);
}
diff --git a/apps/web/src/app/projects/store.ts b/apps/web/src/app/projects/store.ts
new file mode 100644
index 00000000..0eb149a1
--- /dev/null
+++ b/apps/web/src/app/projects/store.ts
@@ -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()(
+ 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 });
+ },
+ },
+ ),
+);
diff --git a/apps/web/src/app/roadmap/page.tsx b/apps/web/src/app/roadmap/page.tsx
index ca23d34f..5227f808 100644
--- a/apps/web/src/app/roadmap/page.tsx
+++ b/apps/web/src/app/roadmap/page.tsx
@@ -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 = {
diff --git a/apps/web/src/components/editor/assets-panel/views/media.tsx b/apps/web/src/components/editor/assets-panel/views/media.tsx
index 7be18614..db238db9 100644
--- a/apps/web/src/components/editor/assets-panel/views/media.tsx
+++ b/apps/web/src/components/editor/assets-panel/views/media.tsx
@@ -44,6 +44,7 @@ import {
Video01Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon, type IconSvgElement } from "@hugeicons/react";
+import { OcVideoIcon } from "@opencut/ui/icons";
export function MediaView() {
const editor = useEditor();
@@ -558,19 +559,18 @@ function MediaPreview({
item: MediaAsset;
variant?: "grid" | "compact";
}) {
- const shouldShowVideoOverlay = variant === "grid";
const shouldShowDurationBadge = variant === "grid";
if (item.type === "image") {
return (
-
+
@@ -584,18 +584,12 @@ function MediaPreview({
- {shouldShowVideoOverlay ? (
-
-
-
- ) : null}
{shouldShowDurationBadge ? (
) : null}
diff --git a/apps/web/src/components/editor/delete-project-dialog.tsx b/apps/web/src/components/editor/delete-project-dialog.tsx
index a482a910..f29fd6e6 100644
--- a/apps/web/src/components/editor/delete-project-dialog.tsx
+++ b/apps/web/src/components/editor/delete-project-dialog.tsx
@@ -12,38 +12,43 @@ export function DeleteProjectDialog({
isOpen,
onOpenChange,
onConfirm,
- projectName,
+ projectNames,
}: {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
- projectName?: string;
+ projectNames: string[];
}) {
+ const count = projectNames.length;
+ const isSingle = count === 1;
+ const singleName = isSingle ? projectNames[0] : null;
+
return (
+
+
+
Dialog Design Explorations
+
Three interactions for the same brand language. Click buttons to open.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Share Project
+
+ Invite collaborators to Summer Vlog 2025. They will receive an email invitation to edit.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Export Video
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Estimated file size: 245 MB
+
Exporting may take a few minutes depending on your hardware.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Delete Project?
+
+
+
+
+ This will permanently delete "Summer Vlog 2025" and all its associated media files. This action cannot be undone.
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/html/dialog_designs.html b/html/dialog_designs.html
new file mode 100644
index 00000000..6b5be497
--- /dev/null
+++ b/html/dialog_designs.html
@@ -0,0 +1,216 @@
+
+
+