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 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, - }); - }} - /> -
- -
- -
- -
- {`Thumbnail -
- {duration} -
-
- -
-
-

- {name} -

-
-
-
-
-
- ); - })} -
-
+ )}
- - +
+ ); +} + +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 ? ( +
+ +
+ ) : ( +
+
+ )} + + ); +} + +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 ? ( + Project thumbnail + ) : ( +
+ +
+ )} +
+ + {durationLabel && ( +
+ {durationLabel} +
+ )} +
+ + +

+ {project.name} +

+
+ + Created {formatDate({ date: project.createdAt })} +
+
+
+ ); + + const listRowContent = ( +
+
+ {project.thumbnail ? ( + 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 ( -
+
{item.name}
@@ -584,18 +584,12 @@ function MediaPreview({ {item.name} - {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 ( { - e.preventDefault(); - e.stopPropagation(); + onOpenAutoFocus={(event) => { + event.preventDefault(); + event.stopPropagation(); }} > - {projectName ? ( + {singleName ? ( <> {"Delete '"} - {projectName} + {singleName} {"'?"} ) : ( - "Delete Project?" + `Delete ${count} projects?` )} - Are you sure you want to delete this project? This action cannot be - undone. + {isSingle + ? "Are you sure you want to delete this project? This action cannot be undone." + : `Are you sure you want to delete these ${count} projects? This action cannot be undone.`} diff --git a/apps/web/src/components/editor/editor-header.tsx b/apps/web/src/components/editor/editor-header.tsx index 4f9da7ca..212ebfff 100644 --- a/apps/web/src/components/editor/editor-header.tsx +++ b/apps/web/src/components/editor/editor-header.tsx @@ -24,7 +24,7 @@ import { ArrowLeft02Icon, Edit03Icon, Delete02Icon, - Keyboard, + CommandIcon, } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { ShortcutsDialog } from "./shortcuts-dialog"; @@ -92,7 +92,9 @@ function ProjectDropdown() { const handleDeleteProject = async () => { if (activeProject) { try { - await editor.project.deleteProject({ id: activeProject.metadata.id }); + await editor.project.deleteProjects({ + ids: [activeProject.metadata.id], + }); router.push("/projects"); } catch (error) { toast.error("Failed to delete project", { @@ -148,7 +150,7 @@ function ProjectDropdown() { className="flex items-center gap-1.5" onClick={() => setOpenDialog("shortcuts")} > - + Keyboard shortcuts @@ -174,7 +176,7 @@ function ProjectDropdown() { isOpen={openDialog === "delete"} onOpenChange={(isOpen) => setOpenDialog(isOpen ? "delete" : null)} onConfirm={handleDeleteProject} - projectName={activeProject?.metadata.name || ""} + projectNames={[activeProject?.metadata.name || ""]} /> { - const hasSeenOnboarding = localStorage.getItem("hasSeenOnboarding"); - if (!hasSeenOnboarding) { - setIsOpen(true); - } - }, []); + const isOpen = !hasSeenOnboarding; const handleNext = () => { setStep(step + 1); }; const handleClose = () => { - setIsOpen(false); - localStorage.setItem("hasSeenOnboarding", "true"); + setHasSeenOnboarding({ value: true }); }; const getStepTitle = () => { diff --git a/apps/web/src/components/editor/project-info-dialog.tsx b/apps/web/src/components/editor/project-info-dialog.tsx new file mode 100644 index 00000000..5b944a76 --- /dev/null +++ b/apps/web/src/components/editor/project-info-dialog.tsx @@ -0,0 +1,74 @@ +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import type { TProjectMetadata } from "@/types/project"; +import { formatDate } from "@/utils/date"; +import { formatTimeCode } from "@/lib/time"; + +function InfoRow({ + label, + value, +}: { + label: string; + value: string | React.ReactNode; +}) { + return ( +
+ {label} + {value} +
+ ); +} + +export function ProjectInfoDialog({ + isOpen, + onOpenChange, + project, +}: { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + project: TProjectMetadata; +}) { + const durationFormatted = + project.duration > 0 + ? formatTimeCode({ + timeInSeconds: project.duration, + format: project.duration >= 3600 ? "HH:MM:SS" : "MM:SS", + }) + : "0:00"; + + return ( + + + + + {project.name} + + + +
+ + + + + {project.id.slice(0, 8)} + + } + /> +
+
+
+ ); +} diff --git a/apps/web/src/components/editor/properties-panel/text-properties.tsx b/apps/web/src/components/editor/properties-panel/text-properties.tsx index 82aa88af..34ad30bb 100644 --- a/apps/web/src/components/editor/properties-panel/text-properties.tsx +++ b/apps/web/src/components/editor/properties-panel/text-properties.tsx @@ -151,7 +151,7 @@ export function TextProperties({