projects page re-design and a ton of other stuff
This commit is contained in:
parent
954160d03b
commit
0c10bbf5ce
|
|
@ -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,
|
||||
}: {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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 });
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="flex size-full items-center justify-center">
|
||||
<div className="relative flex size-full items-center justify-center">
|
||||
<Image
|
||||
src={item.url ?? ""}
|
||||
alt={item.name}
|
||||
className="max-h-full w-full object-cover"
|
||||
fill
|
||||
sizes="100vw"
|
||||
className="object-cover"
|
||||
loading="lazy"
|
||||
width={item.width}
|
||||
height={item.height}
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -584,18 +584,12 @@ function MediaPreview({
|
|||
<Image
|
||||
src={item.thumbnailUrl}
|
||||
alt={item.name}
|
||||
className="size-full rounded object-cover"
|
||||
fill
|
||||
sizes="100vw"
|
||||
className="rounded object-cover"
|
||||
loading="lazy"
|
||||
unoptimized
|
||||
/>
|
||||
{shouldShowVideoOverlay ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center rounded bg-black/20">
|
||||
<HugeiconsIcon
|
||||
icon={Video01Icon}
|
||||
className="size-6 text-white drop-shadow-md"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{shouldShowDurationBadge ? (
|
||||
<MediaDurationBadge duration={item.duration} />
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -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 open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
onOpenAutoFocus={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onOpenAutoFocus={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{projectName ? (
|
||||
{singleName ? (
|
||||
<>
|
||||
{"Delete '"}
|
||||
<span className="inline-block max-w-[300px] truncate align-bottom">
|
||||
{projectName}
|
||||
{singleName}
|
||||
</span>
|
||||
{"'?"}
|
||||
</>
|
||||
) : (
|
||||
"Delete Project?"
|
||||
`Delete ${count} projects?`
|
||||
)}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
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.`}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
>
|
||||
<HugeiconsIcon icon={Keyboard} className="size-4" />
|
||||
<HugeiconsIcon icon={CommandIcon} className="size-4" />
|
||||
Keyboard shortcuts
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
|
|
@ -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 || ""]}
|
||||
/>
|
||||
<ShortcutsDialog
|
||||
isOpen={openDialog === "shortcuts"}
|
||||
|
|
|
|||
|
|
@ -1,30 +1,28 @@
|
|||
"use client";
|
||||
|
||||
import { ArrowRightIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { SOCIAL_LINKS } from "@/constants/site-constants";
|
||||
import { useLocalStorage } from "@/hooks/storage/use-local-storage";
|
||||
import { Button } from "../ui/button";
|
||||
import { Dialog, DialogContent, DialogTitle } from "../ui/dialog";
|
||||
|
||||
export function Onboarding() {
|
||||
const [step, setStep] = useState(0);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [hasSeenOnboarding, setHasSeenOnboarding] = useLocalStorage({
|
||||
key: "hasSeenOnboarding",
|
||||
defaultValue: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
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 = () => {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="flex justify-between items-center py-2 last:pb-0 border-b border-border/50 last:border-b-0">
|
||||
<span className="text-muted-foreground text-sm">{label}</span>
|
||||
<span className="text-sm font-medium">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="truncate max-w-[350px]">
|
||||
{project.name}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<InfoRow label="Duration" value={durationFormatted} />
|
||||
<InfoRow
|
||||
label="Created"
|
||||
value={formatDate({ date: project.createdAt })}
|
||||
/>
|
||||
<InfoRow
|
||||
label="Modified"
|
||||
value={formatDate({ date: project.updatedAt })}
|
||||
/>
|
||||
<InfoRow
|
||||
label="Project ID"
|
||||
value={
|
||||
<code className="text-xs bg-muted px-1.5 py-0.5 rounded">
|
||||
{project.id.slice(0, 8)}
|
||||
</code>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -151,7 +151,7 @@ export function TextProperties({
|
|||
<Textarea
|
||||
placeholder="Name"
|
||||
defaultValue={element.content}
|
||||
className="bg-panel-accent min-h-18 resize-none"
|
||||
className="bg-panel-accent min-h-20"
|
||||
onChange={(e) =>
|
||||
editor.timeline.updateTextElement({
|
||||
trackId,
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ const AlertDialogFooter = ({
|
|||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -12,25 +12,28 @@ const buttonVariants = cva(
|
|||
default:
|
||||
"bg-foreground text-background shadow-sm hover:bg-foreground/90",
|
||||
foreground:
|
||||
"bg-background text-foreground shadow-sm hover:bg-background/80",
|
||||
"bg-background text-foreground",
|
||||
primary:
|
||||
"bg-primary text-primary-foreground shadow-sm hover:bg-primary/90",
|
||||
"bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
"primary-gradient":
|
||||
"bg-gradient-to-r from-cyan-400 to-blue-500 text-white hover:opacity-85 transition-opacity",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/80",
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
"destructive-foreground":
|
||||
"border bg-background hover:bg-destructive/15 text-destructive",
|
||||
outline:
|
||||
"border border-input bg-transparent shadow-xs transition-colors hover:bg-accent",
|
||||
"border border-border bg-transparent transition-colors hover:bg-accent",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-xs hover:bg-foreground/15 border border-input",
|
||||
text: "bg-transparent p-0 rounded-none opacity-100 hover:opacity-50 transition-opacity", // Instead of ghost (matches app better)
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
"bg-secondary text-secondary-foreground hover:bg-foreground/15 border border-input",
|
||||
text: "bg-transparent rounded-none !opacity-100",
|
||||
link: "text-primary underline-offset-4 hover:underline !p-0 !h-auto",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-full px-3 text-xs",
|
||||
lg: "h-10 rounded-full p-5",
|
||||
icon: "size-7",
|
||||
lg: "h-10 rounded-full p-5 px-6",
|
||||
icon: "size-7 rounded-sm",
|
||||
text: "p-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
|
@ -49,9 +52,10 @@ export interface ButtonProps
|
|||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? SlotPrimitive.Slot : "button";
|
||||
const effectiveSize = size ?? (variant === "text" ? "text" : "default");
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
className={cn(buttonVariants({ variant, size: effectiveSize, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ const Checkbox = React.forwardRef<
|
|||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"peer border-primary focus-visible:ring-ring data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground size-4 shrink-0 rounded-sm border shadow-sm focus-visible:ring-1 focus-visible:outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"cursor-pointer bg-background peer focus-visible:ring-ring data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:border-primary size-4 shrink-0 shadow-xs rounded-[0.35rem] border focus-visible:ring-1 focus-visible:outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ const DialogFooter = ({
|
|||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -21,16 +21,16 @@ export function FontPicker({
|
|||
return (
|
||||
<Select defaultValue={defaultValue} onValueChange={onValueChange}>
|
||||
<SelectTrigger
|
||||
className={`bg-panel-accent h-8 w-full text-xs ${className || ""}`}
|
||||
className={`bg-panel-accent h-9 w-full text-base ${className || ""}`}
|
||||
>
|
||||
<SelectValue placeholder="Select a font" />
|
||||
<SelectValue placeholder="Select a font" className="text-sm" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FONT_OPTIONS.map((font) => (
|
||||
<SelectItem
|
||||
key={font.value}
|
||||
value={font.value}
|
||||
className="text-xs"
|
||||
className="text-sn"
|
||||
style={{ fontFamily: font.value }}
|
||||
>
|
||||
{font.label}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,32 @@
|
|||
"use client";
|
||||
|
||||
import { Eye, EyeOff, X } from "lucide-react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/utils/ui";
|
||||
import { Button } from "./button";
|
||||
import { forwardRef, type ComponentProps } from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
interface InputProps extends ComponentProps<"input"> {
|
||||
const inputVariants = cva(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground border-border bg-background flex w-full min-w-0 rounded-full border shadow-xs outline-none file:inline-flex file:border-0 file:bg-transparent file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 focus-visible:border-primary focus-visible:ring-4 focus-visible:ring-primary/10 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
default:
|
||||
"h-9 px-3 py-1 text-base file:h-7 file:text-sm md:text-sm",
|
||||
sm: "h-8 px-3 text-xs file:h-6 file:text-xs",
|
||||
lg: "h-10 px-4 text-base file:h-8 file:text-sm md:text-sm",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
interface InputProps
|
||||
extends Omit<ComponentProps<"input">, "size">,
|
||||
VariantProps<typeof inputVariants> {
|
||||
showPassword?: boolean;
|
||||
onShowPasswordChange?: (show: boolean) => void;
|
||||
showClearIcon?: boolean;
|
||||
|
|
@ -19,6 +39,7 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
|
|||
{
|
||||
className,
|
||||
type,
|
||||
size,
|
||||
containerClassName,
|
||||
showPassword,
|
||||
onShowPasswordChange,
|
||||
|
|
@ -55,11 +76,10 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
|
|||
<input
|
||||
type={inputType}
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground border-input bg-background flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[2px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
paddingRight,
|
||||
className,
|
||||
inputVariants({
|
||||
size,
|
||||
className: cn(paddingRight, className),
|
||||
}),
|
||||
)}
|
||||
ref={ref}
|
||||
value={value}
|
||||
|
|
|
|||
|
|
@ -14,12 +14,12 @@ const SelectGroup = SelectPrimitive.Group;
|
|||
const SelectValue = SelectPrimitive.Value;
|
||||
|
||||
const selectItemVariants = cva(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-hidden transition-opacity data-disabled:pointer-events-none data-disabled:opacity-50",
|
||||
"relative flex cursor-pointer select-none items-center gap-2 rounded-xl px-2.5 py-2 text-sm text-foreground/85 outline-hidden data-[highlighted]:bg-accent/35 data-disabled:pointer-events-none data-disabled:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "focus:opacity-65 focus:text-accent-foreground",
|
||||
destructive: "text-destructive focus:text-destructive/80",
|
||||
default: "",
|
||||
destructive: "text-destructive data-[highlighted]:bg-destructive/5 data-[highlighted]:text-destructive",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
|
@ -36,6 +36,7 @@ const SelectTrigger = React.forwardRef<
|
|||
ref={ref}
|
||||
className={cn(
|
||||
"bg-accent ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-7 w-auto cursor-pointer items-center justify-between gap-1 rounded-md px-3 py-2 text-sm whitespace-nowrap focus:ring-1 focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
"focus:border-primary focus:ring-4 focus:ring-primary/10 border-transparent transition-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -91,9 +92,7 @@ const SelectContent = React.forwardRef<
|
|||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-96 min-w-32 overflow-hidden rounded-md border shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
"bg-popover text-popover-foreground z-50 max-h-96 min-w-32 overflow-hidden rounded-2xl border p-2 shadow-lg",
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
|
|
@ -106,7 +105,6 @@ const SelectContent = React.forwardRef<
|
|||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-(--radix-select-trigger-height) w-full min-w-(--radix-select-trigger-width)",
|
||||
)}
|
||||
|
|
@ -125,7 +123,10 @@ const SelectLabel = React.forwardRef<
|
|||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
|
||||
className={cn(
|
||||
"px-3 pb-2 pt-1 text-[11px] font-bold uppercase tracking-wider text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
|
@ -139,7 +140,7 @@ const SelectItem = React.forwardRef<
|
|||
>(({ className, children, variant = "default", ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(selectItemVariants({ variant }), className)}
|
||||
className={cn(selectItemVariants({ variant }), "pr-8 pl-2", className)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||
|
|
@ -158,7 +159,7 @@ const SelectSeparator = React.forwardRef<
|
|||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("bg-foreground/10 -mx-1 my-1 h-px", className)}
|
||||
className={cn("bg-border/60 mx-1 my-2 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
|
|
|||
|
|
@ -98,7 +98,7 @@ const SheetFooter = ({
|
|||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@ const Textarea = React.forwardRef<
|
|||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input bg-accent/50 flex min-h-[60px] w-full rounded-md border px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[2px]",
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input bg-accent/50 flex min-h-[60px] w-full rounded-md border px-3 py-2 text-base shadow-xs outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-primary focus-visible:ring-4 focus-visible:ring-primary/10",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className,
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import type { EditorCore } from "@/core";
|
|||
import type {
|
||||
TProject,
|
||||
TProjectMetadata,
|
||||
TProjectSortKey,
|
||||
TProjectSortOption,
|
||||
TProjectSettings,
|
||||
} from "@/types/project";
|
||||
import type { ExportOptions, ExportResult } from "@/types/export";
|
||||
|
|
@ -15,7 +17,7 @@ import {
|
|||
DEFAULT_CANVAS_SIZE,
|
||||
DEFAULT_COLOR,
|
||||
} from "@/constants/project-constants";
|
||||
import { buildDefaultScene } from "@/lib/scenes";
|
||||
import { buildDefaultScene, getProjectDurationFromScenes } from "@/lib/scenes";
|
||||
import { generateThumbnail } from "@/lib/media/processing";
|
||||
import {
|
||||
CURRENT_STORAGE_VERSION,
|
||||
|
|
@ -90,6 +92,7 @@ export class ProjectManager {
|
|||
metadata: {
|
||||
id: generateUUID(),
|
||||
name,
|
||||
duration: getProjectDurationFromScenes({ scenes: [mainScene] }),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
|
|
@ -178,11 +181,13 @@ export class ProjectManager {
|
|||
if (!this.active) return;
|
||||
|
||||
try {
|
||||
const scenes = this.editor.scenes.getScenes();
|
||||
const updatedProject = {
|
||||
...this.active,
|
||||
scenes: this.editor.scenes.getScenes(),
|
||||
scenes,
|
||||
metadata: {
|
||||
...this.active.metadata,
|
||||
duration: getProjectDurationFromScenes({ scenes }),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
};
|
||||
|
|
@ -219,25 +224,37 @@ export class ProjectManager {
|
|||
}
|
||||
}
|
||||
|
||||
async deleteProject({ id }: { id: string }): Promise<void> {
|
||||
async deleteProjects({ ids }: { ids: string[] }): Promise<void> {
|
||||
const uniqueIds = Array.from(new Set(ids));
|
||||
if (uniqueIds.length === 0) return;
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
storageService.deleteProjectMedia({ projectId: id }),
|
||||
storageService.deleteProject({ id }),
|
||||
]);
|
||||
await Promise.all(
|
||||
uniqueIds.map((id) =>
|
||||
Promise.all([
|
||||
storageService.deleteProjectMedia({ projectId: id }),
|
||||
storageService.deleteProject({ id }),
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
this.savedProjects = this.savedProjects.filter((p) => p.id !== id);
|
||||
this.notify();
|
||||
const idSet = new Set(uniqueIds);
|
||||
this.savedProjects = this.savedProjects.filter(
|
||||
(project) => !idSet.has(project.id),
|
||||
);
|
||||
|
||||
if (this.active?.metadata.id === id) {
|
||||
const shouldClearActive =
|
||||
this.active && idSet.has(this.active.metadata.id);
|
||||
|
||||
if (shouldClearActive) {
|
||||
this.active = null;
|
||||
this.notify();
|
||||
|
||||
this.editor.media.clearAllAssets();
|
||||
this.editor.scenes.clearScenes();
|
||||
}
|
||||
|
||||
this.notify();
|
||||
} catch (error) {
|
||||
console.error("Failed to delete project:", error);
|
||||
console.error("Failed to delete projects:", error);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -291,61 +308,125 @@ export class ProjectManager {
|
|||
}
|
||||
}
|
||||
|
||||
async duplicateProject({ id }: { id: string }): Promise<string> {
|
||||
async duplicateProjects({ ids }: { ids: string[] }): Promise<string[]> {
|
||||
const uniqueIds = Array.from(new Set(ids));
|
||||
if (uniqueIds.length === 0) return [];
|
||||
|
||||
try {
|
||||
const result = await storageService.loadProject({ id });
|
||||
if (!result) {
|
||||
toast.error("Project not found", {
|
||||
description: "Please try again",
|
||||
});
|
||||
throw new Error("Project not found");
|
||||
}
|
||||
|
||||
const project = result.project;
|
||||
const numberMatch = project.metadata.name.match(/^\((\d+)\)\s+(.+)$/);
|
||||
const baseName = numberMatch ? numberMatch[2] : project.metadata.name;
|
||||
const existingNumbers: number[] = [];
|
||||
|
||||
this.savedProjects.forEach((p) => {
|
||||
const match = p.name.match(/^\((\d+)\)\s+(.+)$/);
|
||||
if (match && match[2] === baseName) {
|
||||
existingNumbers.push(parseInt(match[1], 10));
|
||||
}
|
||||
});
|
||||
|
||||
const nextNumber =
|
||||
existingNumbers.length > 0 ? Math.max(...existingNumbers) + 1 : 1;
|
||||
|
||||
const newProjectId = generateUUID();
|
||||
const newProject: TProject = {
|
||||
...project,
|
||||
metadata: {
|
||||
...project.metadata,
|
||||
id: newProjectId,
|
||||
name: `(${nextNumber}) ${baseName}`,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
const getDuplicateBaseName = ({ name }: { name: string }) => {
|
||||
const match = name.match(/^\((\d+)\)\s+(.+)$/);
|
||||
const number = match ? Number.parseInt(match[1], 10) : null;
|
||||
const baseName = match ? match[2] : name;
|
||||
return { baseName, number };
|
||||
};
|
||||
|
||||
await storageService.saveProject({ project: newProject });
|
||||
const loadResults = await Promise.all(
|
||||
uniqueIds.map(async (projectId) => {
|
||||
const result = await storageService.loadProject({ id: projectId });
|
||||
return { projectId, project: result?.project ?? null };
|
||||
}),
|
||||
);
|
||||
|
||||
const sourceMediaAssets = await storageService.loadAllMediaAssets({
|
||||
projectId: id,
|
||||
});
|
||||
for (const asset of sourceMediaAssets) {
|
||||
await storageService.saveMediaAsset({
|
||||
projectId: newProjectId,
|
||||
mediaAsset: asset,
|
||||
});
|
||||
const missingProjectIds = loadResults
|
||||
.filter((result) => !result.project)
|
||||
.map((result) => result.projectId);
|
||||
|
||||
if (missingProjectIds.length > 0) {
|
||||
toast.error(
|
||||
missingProjectIds.length === 1
|
||||
? "Project not found"
|
||||
: "Projects not found",
|
||||
{
|
||||
description:
|
||||
missingProjectIds.length === 1
|
||||
? "Please try again"
|
||||
: "Some projects could not be found",
|
||||
},
|
||||
);
|
||||
throw new Error(`Projects not found: ${missingProjectIds.join(", ")}`);
|
||||
}
|
||||
|
||||
this.updateMetadata(newProject);
|
||||
const projectsToDuplicate = loadResults.flatMap((result) =>
|
||||
result.project ? [result.project] : [],
|
||||
);
|
||||
|
||||
return newProjectId;
|
||||
const maxNumberByBaseName = new Map<string, number>();
|
||||
|
||||
for (const project of this.savedProjects) {
|
||||
const { baseName, number } = getDuplicateBaseName({
|
||||
name: project.name,
|
||||
});
|
||||
|
||||
if (number === null) continue;
|
||||
|
||||
const currentMax = maxNumberByBaseName.get(baseName);
|
||||
if (currentMax === undefined || number > currentMax) {
|
||||
maxNumberByBaseName.set(baseName, number);
|
||||
}
|
||||
}
|
||||
|
||||
const nextNumberByBaseName = new Map<string, number>();
|
||||
for (const [baseName, maxNumber] of maxNumberByBaseName) {
|
||||
nextNumberByBaseName.set(baseName, maxNumber + 1);
|
||||
}
|
||||
|
||||
const duplicationPlans = projectsToDuplicate.map((project) => {
|
||||
const { baseName } = getDuplicateBaseName({
|
||||
name: project.metadata.name,
|
||||
});
|
||||
const nextNumber = nextNumberByBaseName.get(baseName) ?? 1;
|
||||
nextNumberByBaseName.set(baseName, nextNumber + 1);
|
||||
|
||||
const newProjectId = generateUUID();
|
||||
const newProject: TProject = {
|
||||
...project,
|
||||
metadata: {
|
||||
...project.metadata,
|
||||
id: newProjectId,
|
||||
name: `(${nextNumber}) ${baseName}`,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
newProjectId,
|
||||
newProject,
|
||||
sourceProjectId: project.metadata.id,
|
||||
};
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
duplicationPlans.map(({ newProject }) =>
|
||||
storageService.saveProject({ project: newProject }),
|
||||
),
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
duplicationPlans.map(async ({ sourceProjectId, newProjectId }) => {
|
||||
const sourceMediaAssets = await storageService.loadAllMediaAssets({
|
||||
projectId: sourceProjectId,
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
sourceMediaAssets.map((mediaAsset) =>
|
||||
storageService.saveMediaAsset({
|
||||
projectId: newProjectId,
|
||||
mediaAsset,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
for (const { newProject } of duplicationPlans) {
|
||||
this.updateMetadata(newProject);
|
||||
}
|
||||
|
||||
return duplicationPlans.map((plan) => plan.newProjectId);
|
||||
} catch (error) {
|
||||
console.error("Failed to duplicate project:", error);
|
||||
toast.error("Failed to duplicate project", {
|
||||
console.error("Failed to duplicate projects:", error);
|
||||
toast.error("Failed to duplicate projects", {
|
||||
description:
|
||||
error instanceof Error ? error.message : "Please try again",
|
||||
});
|
||||
|
|
@ -402,25 +483,18 @@ export class ProjectManager {
|
|||
sortOption,
|
||||
}: {
|
||||
searchQuery: string;
|
||||
sortOption: string;
|
||||
sortOption: TProjectSortOption;
|
||||
}): TProjectMetadata[] {
|
||||
const filteredProjects = this.savedProjects.filter((project) =>
|
||||
project.name.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
);
|
||||
|
||||
const [key, order] = sortOption.split("-") as [TProjectSortKey, "asc" | "desc"];
|
||||
|
||||
const sortedProjects = [...filteredProjects].sort((a, b) => {
|
||||
const [key, order] = sortOption.split("-");
|
||||
|
||||
if (key !== "createdAt" && key !== "name") {
|
||||
console.warn(`Invalid sort key: ${key}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const aValue = a[key];
|
||||
const bValue = b[key];
|
||||
|
||||
if (aValue === undefined || bValue === undefined) return 0;
|
||||
|
||||
if (order === "asc") {
|
||||
if (aValue < bValue) return -1;
|
||||
if (aValue > bValue) return 1;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,58 @@
|
|||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
|
||||
export function useLocalStorage<T>({
|
||||
key,
|
||||
defaultValue,
|
||||
}: {
|
||||
key: string;
|
||||
defaultValue: T;
|
||||
}): [
|
||||
T,
|
||||
({ value }: { value: T | ((previousValue: T) => T) }) => void,
|
||||
boolean,
|
||||
] {
|
||||
const [value, setValue] = useState<T>(defaultValue);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const valueRef = useRef(defaultValue);
|
||||
|
||||
// avoid hydration mismatch by reading after mount
|
||||
useEffect(() => {
|
||||
try {
|
||||
const storedValue = localStorage.getItem(key);
|
||||
if (storedValue !== null) {
|
||||
const parsedValue = JSON.parse(storedValue) as T;
|
||||
valueRef.current = parsedValue;
|
||||
setValue(parsedValue);
|
||||
}
|
||||
} catch {
|
||||
// localstorage might be unavailable
|
||||
}
|
||||
setIsReady(true);
|
||||
}, [key]);
|
||||
|
||||
// sync to localstorage after hydration
|
||||
useEffect(() => {
|
||||
if (!isReady) return;
|
||||
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
} catch {
|
||||
// localstorage might be full or disabled
|
||||
}
|
||||
}, [key, value, isReady]);
|
||||
|
||||
const setValueWithCallback = useCallback(
|
||||
({ value: nextValue }: { value: T | ((previousValue: T) => T) }) => {
|
||||
const resolvedValue =
|
||||
typeof nextValue === "function"
|
||||
? (nextValue as (previousValue: T) => T)(valueRef.current)
|
||||
: nextValue;
|
||||
|
||||
valueRef.current = resolvedValue;
|
||||
setValue(resolvedValue);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return [value, setValueWithCallback, isReady];
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import type { TScene } from "@/types/timeline";
|
||||
import { generateUUID } from "@/utils/id";
|
||||
import { calculateTotalDuration } from "@/lib/timeline";
|
||||
import { ensureMainTrack } from "@/lib/timeline/track-utils";
|
||||
|
||||
export function getMainScene({ scenes }: { scenes: TScene[] }): TScene | null {
|
||||
|
|
@ -74,6 +75,19 @@ export function findCurrentScene({
|
|||
);
|
||||
}
|
||||
|
||||
export function getProjectDurationFromScenes({
|
||||
scenes,
|
||||
}: {
|
||||
scenes: TScene[];
|
||||
}): number {
|
||||
const mainScene = getMainScene({ scenes }) ?? scenes[0] ?? null;
|
||||
if (!mainScene) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return calculateTotalDuration({ tracks: mainScene.tracks });
|
||||
}
|
||||
|
||||
export function updateSceneInArray({
|
||||
scenes,
|
||||
sceneId,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,12 @@ export { StorageVersionManager } from "./version-manager";
|
|||
export { runStorageMigrations } from "./runner";
|
||||
import { V0toV1Migration } from "./v0-to-v1";
|
||||
import { V1toV2Migration } from "./v1-to-v2";
|
||||
import { V2toV3Migration } from "./v2-to-v3";
|
||||
|
||||
export const CURRENT_STORAGE_VERSION = 2;
|
||||
export const CURRENT_STORAGE_VERSION = 3;
|
||||
|
||||
export const migrations = [new V0toV1Migration(), new V1toV2Migration()];
|
||||
export const migrations = [
|
||||
new V0toV1Migration(),
|
||||
new V1toV2Migration(),
|
||||
new V2toV3Migration(),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,92 @@
|
|||
import { getProjectDurationFromScenes } from "@/lib/scenes";
|
||||
import { IndexedDBAdapter } from "@/services/storage/indexeddb-adapter";
|
||||
import type { TScene } from "@/types/timeline";
|
||||
import { StorageMigration } from "./base";
|
||||
|
||||
type ProjectRecord = Record<string, unknown>;
|
||||
|
||||
export class V2toV3Migration extends StorageMigration {
|
||||
from = 2;
|
||||
to = 3;
|
||||
|
||||
async run(): Promise<void> {
|
||||
const projectsAdapter = new IndexedDBAdapter<unknown>(
|
||||
"video-editor-projects",
|
||||
"projects",
|
||||
1,
|
||||
);
|
||||
const projects = await projectsAdapter.getAll();
|
||||
|
||||
for (const project of projects) {
|
||||
if (!isRecord(project)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const projectId = getProjectId({ project });
|
||||
if (!projectId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isV3Project({ project })) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const scenes = getScenes({ project });
|
||||
const duration = getProjectDurationFromScenes({ scenes });
|
||||
|
||||
const metadataValue = project.metadata;
|
||||
const metadata = isRecord(metadataValue)
|
||||
? { ...metadataValue, duration }
|
||||
: { duration };
|
||||
|
||||
const migratedProject = {
|
||||
...project,
|
||||
metadata,
|
||||
version: 3,
|
||||
};
|
||||
|
||||
await projectsAdapter.set(projectId, migratedProject);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getProjectId({ project }: { project: ProjectRecord }): string | null {
|
||||
const idValue = project.id;
|
||||
if (typeof idValue === "string" && idValue.length > 0) {
|
||||
return idValue;
|
||||
}
|
||||
|
||||
const metadataValue = project.metadata;
|
||||
if (!isRecord(metadataValue)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const metadataId = metadataValue.id;
|
||||
if (typeof metadataId === "string" && metadataId.length > 0) {
|
||||
return metadataId;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getScenes({ project }: { project: ProjectRecord }): TScene[] {
|
||||
const scenesValue = project.scenes;
|
||||
if (!Array.isArray(scenesValue)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return scenesValue.filter(isRecord) as unknown as TScene[];
|
||||
}
|
||||
|
||||
function isV3Project({ project }: { project: ProjectRecord }): boolean {
|
||||
const versionValue = project.version;
|
||||
if (typeof versionValue === "number" && versionValue >= 3) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return isRecord(project.metadata) && typeof project.metadata.duration === "number";
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is ProjectRecord {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import type { TProject, TProjectMetadata } from "@/types/project";
|
||||
import { getProjectDurationFromScenes } from "@/lib/scenes";
|
||||
import type { MediaAsset } from "@/types/assets";
|
||||
import { IndexedDBAdapter } from "./indexeddb-adapter";
|
||||
import { OPFSAdapter } from "./opfs-adapter";
|
||||
|
|
@ -13,7 +14,7 @@ import {
|
|||
migrations,
|
||||
runStorageMigrations,
|
||||
} from "@/services/storage/migrations";
|
||||
import type { TimelineTrack } from "@/types/timeline";
|
||||
import type { TimelineTrack, TScene } from "@/types/timeline";
|
||||
|
||||
class StorageService {
|
||||
private projectsAdapter: IndexedDBAdapter<SerializedProject>;
|
||||
|
|
@ -84,6 +85,9 @@ class StorageService {
|
|||
}
|
||||
|
||||
async saveProject({ project }: { project: TProject }): Promise<void> {
|
||||
const duration =
|
||||
project.metadata.duration ??
|
||||
getProjectDurationFromScenes({ scenes: project.scenes });
|
||||
const serializedScenes: SerializedScene[] = project.scenes.map((scene) => ({
|
||||
id: scene.id,
|
||||
name: scene.name,
|
||||
|
|
@ -99,6 +103,7 @@ class StorageService {
|
|||
id: project.metadata.id,
|
||||
name: project.metadata.name,
|
||||
thumbnail: project.metadata.thumbnail,
|
||||
duration,
|
||||
createdAt: project.metadata.createdAt.toISOString(),
|
||||
updatedAt: project.metadata.updatedAt.toISOString(),
|
||||
},
|
||||
|
|
@ -141,6 +146,9 @@ class StorageService {
|
|||
id: serializedProject.metadata.id,
|
||||
name: serializedProject.metadata.name,
|
||||
thumbnail: serializedProject.metadata.thumbnail,
|
||||
duration:
|
||||
serializedProject.metadata.duration ??
|
||||
getProjectDurationFromScenes({ scenes }),
|
||||
createdAt: new Date(serializedProject.metadata.createdAt),
|
||||
updatedAt: new Date(serializedProject.metadata.updatedAt),
|
||||
},
|
||||
|
|
@ -177,6 +185,11 @@ class StorageService {
|
|||
id: serializedProject.metadata.id,
|
||||
name: serializedProject.metadata.name,
|
||||
thumbnail: serializedProject.metadata.thumbnail,
|
||||
duration:
|
||||
serializedProject.metadata.duration ??
|
||||
getProjectDurationFromScenes({
|
||||
scenes: (serializedProject.scenes ?? []) as unknown as TScene[],
|
||||
}),
|
||||
createdAt: new Date(serializedProject.metadata.createdAt),
|
||||
updatedAt: new Date(serializedProject.metadata.updatedAt),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ export interface TProjectMetadata {
|
|||
id: string;
|
||||
name: string;
|
||||
thumbnail?: string;
|
||||
duration: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
|
@ -37,3 +38,7 @@ export interface TProject {
|
|||
settings: TProjectSettings;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export type TProjectSortKey = "createdAt" | "updatedAt" | "name" | "duration";
|
||||
export type TSortOrder = "asc" | "desc";
|
||||
export type TProjectSortOption = `${TProjectSortKey}-${TSortOrder}`;
|
||||
|
|
|
|||
13
bun.lock
13
bun.lock
|
|
@ -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",
|
||||
|
|
@ -416,7 +417,7 @@
|
|||
|
||||
"@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.9", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YSjEfBXnhUELsO2VzjdtYYD4CfQjvao+lhhrX5XsHD7/cyUNzljF1FHEbgTPN7LH2MClfwRMIsYlqTYpKTTe2A=="],
|
||||
|
||||
"@radix-ui/react-select": ["@radix-ui/react-select@2.2.5", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.7", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-HnMTdXEVuuyzx63ME0ut4+sEMYW6oouHWNGUZc7ddvUWIcfCva/AMoqEW/3wnEllriMWBa0RHspCYnfCWJQYmA=="],
|
||||
"@radix-ui/react-select": ["@radix-ui/react-select@2.2.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ=="],
|
||||
|
||||
"@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g=="],
|
||||
|
||||
|
|
@ -1246,14 +1247,6 @@
|
|||
|
||||
"@radix-ui/react-scroll-area/@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA=="],
|
||||
|
||||
"@radix-ui/react-select/@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="],
|
||||
|
||||
"@radix-ui/react-select/@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ=="],
|
||||
|
||||
"@radix-ui/react-select/@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA=="],
|
||||
|
||||
"@radix-ui/react-select/@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.7", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-IUFAccz1JyKcf/RjB552PlWwxjeCJB8/4KxT7EhBHOJM+mN7LdW+B3kacJXILm32xawcMMjb2i0cIZpo+f9kiQ=="],
|
||||
|
||||
"@radix-ui/react-select/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-separator/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
|
||||
|
|
@ -1330,6 +1323,8 @@
|
|||
|
||||
"radix-ui/@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA=="],
|
||||
|
||||
"radix-ui/@radix-ui/react-select": ["@radix-ui/react-select@2.2.5", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.7", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-HnMTdXEVuuyzx63ME0ut4+sEMYW6oouHWNGUZc7ddvUWIcfCva/AMoqEW/3wnEllriMWBa0RHspCYnfCWJQYmA=="],
|
||||
|
||||
"radix-ui/@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA=="],
|
||||
|
||||
"radix-ui/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@
|
|||
<div class="flex items-center gap-4">
|
||||
<div class="relative group">
|
||||
<i data-lucide="search" class="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400"></i>
|
||||
<input type="text" placeholder="Search..." class="w-64 bg-white border border-slate-200 text-slate-900 text-sm rounded-full pl-10 pr-4 py-2.5 focus:outline-none focus:border-[#00A3FF] focus:ring-4 focus:ring-blue-500/10 placeholder:text-slate-400 shadow-sm">
|
||||
<input id="search-input" type="text" placeholder="Search..." class="w-64 bg-white border border-slate-200 text-slate-900 text-sm rounded-full pl-10 pr-4 py-2.5 focus:outline-none focus:border-[#00A3FF] focus:ring-4 focus:ring-blue-500/10 placeholder:text-slate-400 shadow-sm">
|
||||
</div>
|
||||
|
||||
<div class="flex bg-white rounded-full border border-slate-200 p-1 shadow-sm">
|
||||
|
|
@ -123,7 +123,7 @@
|
|||
</div>
|
||||
|
||||
<!-- Grid -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8">
|
||||
<div id="projects-grid" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8">
|
||||
|
||||
<!-- Project Card 1 -->
|
||||
<div class="project-card group rounded-[24px] overflow-hidden relative h-auto flex flex-col">
|
||||
|
|
@ -232,6 +232,20 @@
|
|||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div id="empty-state" class="hidden flex flex-col items-center justify-center py-24 text-center">
|
||||
<div class="w-16 h-16 bg-slate-50 rounded-2xl flex items-center justify-center mb-6 ring-1 ring-slate-900/5">
|
||||
<i data-lucide="search" class="w-8 h-8 text-slate-400"></i>
|
||||
</div>
|
||||
<h3 class="text-slate-900 font-semibold text-lg mb-2">No projects found</h3>
|
||||
<p class="text-slate-500 text-sm max-w-[280px] mx-auto leading-relaxed mb-8">
|
||||
We couldn't find any projects matching your search. Try adjusting your terms.
|
||||
</p>
|
||||
<button onclick="clearSearch()" class="inline-flex items-center justify-center px-6 py-2.5 text-sm font-medium text-slate-700 bg-white border border-slate-200 rounded-full hover:bg-slate-50 hover:text-slate-900 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-slate-200">
|
||||
Clear search
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
|
|
@ -313,6 +327,56 @@
|
|||
});
|
||||
}
|
||||
syncSelectionState();
|
||||
|
||||
// Search functionality
|
||||
const searchInput = document.getElementById('search-input');
|
||||
const emptyState = document.getElementById('empty-state');
|
||||
const projectsGrid = document.getElementById('projects-grid');
|
||||
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('input', (e) => {
|
||||
const query = e.target.value.toLowerCase().trim();
|
||||
const cards = document.querySelectorAll('.project-card');
|
||||
let hasVisibleProjects = false;
|
||||
|
||||
cards.forEach(card => {
|
||||
const titleElement = card.querySelector('h3');
|
||||
if (!titleElement) return;
|
||||
|
||||
const title = titleElement.textContent.toLowerCase();
|
||||
const isMatch = title.includes(query);
|
||||
|
||||
if (isMatch) {
|
||||
card.classList.remove('hidden');
|
||||
hasVisibleProjects = true;
|
||||
} else {
|
||||
card.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// Toggle empty state
|
||||
if (hasVisibleProjects) {
|
||||
emptyState.classList.add('hidden');
|
||||
projectsGrid.classList.remove('hidden');
|
||||
} else {
|
||||
emptyState.classList.remove('hidden');
|
||||
projectsGrid.classList.add('hidden');
|
||||
}
|
||||
|
||||
// Re-initialize icons for new elements (if any dynamic ones were added, though here we just hide/show)
|
||||
// But importantly, the empty state icon needs to be rendered if it wasn't visible initially
|
||||
lucide.createIcons();
|
||||
});
|
||||
}
|
||||
|
||||
// Expose clearSearch globally
|
||||
window.clearSearch = function() {
|
||||
if (searchInput) {
|
||||
searchInput.value = '';
|
||||
searchInput.dispatchEvent(new Event('input'));
|
||||
searchInput.focus();
|
||||
}
|
||||
};
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,216 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Dialog Designs</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&display=swap');
|
||||
body { font-family: 'Inter', sans-serif; }
|
||||
|
||||
:root {
|
||||
--brand-blue: #00A3FF;
|
||||
}
|
||||
|
||||
/* Utility to hide/show */
|
||||
.dialog-overlay { display: none; }
|
||||
.dialog-overlay.open { display: flex; }
|
||||
|
||||
/* Stop body scroll when open */
|
||||
body.dialog-open { overflow: hidden; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-[#ffffff] text-slate-900 min-h-screen p-12">
|
||||
|
||||
<div class="max-w-4xl mx-auto space-y-4 mb-12 text-center">
|
||||
<h1 class="text-2xl font-bold">Dialog Design Explorations</h1>
|
||||
<p class="text-slate-500">Three interactions for the same brand language. Click buttons to open.</p>
|
||||
|
||||
<div class="flex items-center justify-center gap-4 mt-8">
|
||||
<button onclick="openDialog(1)" class="px-5 py-2.5 bg-white border border-slate-200 rounded-xl font-medium text-slate-700 hover:border-[#00A3FF] hover:text-[#00A3FF] shadow-sm">
|
||||
Option 1: Soft & Friendly
|
||||
</button>
|
||||
<button onclick="openDialog(2)" class="px-5 py-2.5 bg-white border border-slate-200 rounded-xl font-medium text-slate-700 hover:border-[#00A3FF] hover:text-[#00A3FF] shadow-sm">
|
||||
Option 2: Structured
|
||||
</button>
|
||||
<button onclick="openDialog(3)" class="px-5 py-2.5 bg-white border border-slate-200 rounded-xl font-medium text-slate-700 hover:border-[#00A3FF] hover:text-[#00A3FF] shadow-sm">
|
||||
Option 3: Edge Accent
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Overlay (Shared) -->
|
||||
<div id="overlay" class="dialog-overlay fixed inset-0 bg-slate-900/20 backdrop-blur-sm z-50 items-center justify-center p-4">
|
||||
|
||||
<!-- Option 1: Soft & Friendly -->
|
||||
<!-- Characteristics: Very rounded, generous padding, minimal dividers -->
|
||||
<div id="dialog-1" class="hidden w-full max-w-md bg-white rounded-[28px] shadow-xl p-8 relative">
|
||||
<button onclick="closeAll()" class="absolute top-6 right-6 p-2 text-slate-400 hover:text-slate-600 rounded-full hover:bg-slate-50">
|
||||
<i data-lucide="x" class="w-5 h-5"></i>
|
||||
</button>
|
||||
|
||||
<div class="mb-6">
|
||||
<div class="w-12 h-12 bg-blue-50 rounded-2xl flex items-center justify-center mb-4 text-[#00A3FF]">
|
||||
<i data-lucide="share-2" class="w-6 h-6"></i>
|
||||
</div>
|
||||
<h2 class="text-xl font-bold text-slate-900 mb-2">Share Project</h2>
|
||||
<p class="text-slate-500 text-[15px] leading-relaxed">
|
||||
Invite collaborators to <strong>Summer Vlog 2025</strong>. They will receive an email invitation to edit.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 mb-8">
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-2">Email address</label>
|
||||
<input type="email" placeholder="colleague@example.com" class="w-full bg-slate-50 border border-slate-200 rounded-xl px-4 py-3 text-sm focus:outline-none focus:border-[#00A3FF] focus:bg-white focus:ring-4 focus:ring-blue-500/10 placeholder:text-slate-400">
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between p-3 border border-slate-100 rounded-xl">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 rounded-full bg-emerald-100 flex items-center justify-center text-emerald-600 text-xs font-bold">JM</div>
|
||||
<span class="text-sm font-medium text-slate-700">John Miller</span>
|
||||
</div>
|
||||
<span class="text-xs font-medium text-slate-400 px-2 py-1 bg-slate-50 rounded-md">Owner</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<button onclick="closeAll()" class="flex-1 px-5 py-3 text-sm font-medium text-slate-600 hover:text-slate-900 hover:bg-slate-50 rounded-xl">
|
||||
Cancel
|
||||
</button>
|
||||
<button class="flex-1 px-5 py-3 text-sm font-bold text-white bg-[#00A3FF] hover:bg-[#008BE0] rounded-xl shadow-lg shadow-blue-500/20">
|
||||
Send Invite
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Option 2: Structured -->
|
||||
<!-- Characteristics: Clear header/footer separation, solid pill buttons, highly organized -->
|
||||
<div id="dialog-2" class="hidden w-full max-w-lg bg-white rounded-[20px] shadow-xl overflow-hidden flex flex-col">
|
||||
<!-- Header -->
|
||||
<div class="px-6 py-5 border-b border-slate-100 flex items-center justify-between bg-white">
|
||||
<h2 class="text-lg font-bold text-slate-900">Export Video</h2>
|
||||
<button onclick="closeAll()" class="text-slate-400 hover:text-slate-600">
|
||||
<i data-lucide="x" class="w-5 h-5"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="p-6 space-y-6">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-xs font-semibold text-slate-500">Resolution</label>
|
||||
<button class="w-full flex items-center justify-between px-3 py-2.5 bg-white border border-slate-200 rounded-lg text-sm font-medium text-slate-700 hover:border-[#00A3FF]">
|
||||
<span>1080p HD</span>
|
||||
<i data-lucide="chevron-down" class="w-4 h-4 text-slate-400"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-xs font-semibold text-slate-500">Format</label>
|
||||
<button class="w-full flex items-center justify-between px-3 py-2.5 bg-white border border-slate-200 rounded-lg text-sm font-medium text-slate-700 hover:border-[#00A3FF]">
|
||||
<span>MP4 (H.264)</span>
|
||||
<i data-lucide="chevron-down" class="w-4 h-4 text-slate-400"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 bg-slate-50 rounded-xl border border-slate-100 flex items-start gap-3">
|
||||
<i data-lucide="info" class="w-5 h-5 text-[#00A3FF] mt-0.5 flex-shrink-0"></i>
|
||||
<div class="text-sm text-slate-600">
|
||||
<p class="font-medium text-slate-900 mb-1">Estimated file size: 245 MB</p>
|
||||
<p>Exporting may take a few minutes depending on your hardware.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="px-6 py-4 bg-slate-50 border-t border-slate-100 flex items-center justify-end gap-3">
|
||||
<button onclick="closeAll()" class="px-4 py-2 text-sm font-medium text-slate-600 hover:text-slate-900 bg-white border border-slate-200 rounded-lg hover:bg-slate-50">
|
||||
Cancel
|
||||
</button>
|
||||
<button class="px-4 py-2 text-sm font-bold text-white bg-[#00A3FF] hover:bg-[#008BE0] rounded-lg shadow-md shadow-blue-500/10">
|
||||
Export Video
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Option 3: Edge Accent -->
|
||||
<!-- Characteristics: Minimalist, blue left border accent, very clean interactions -->
|
||||
<div id="dialog-3" class="hidden w-full max-w-md bg-white rounded-2xl shadow-xl overflow-hidden relative">
|
||||
<!-- Accent Bar -->
|
||||
<div class="absolute left-0 top-0 bottom-0 w-1.5 bg-[#00A3FF]"></div>
|
||||
|
||||
<div class="pl-8 pr-6 py-6">
|
||||
<div class="flex items-start justify-between mb-2">
|
||||
<h2 class="text-lg font-bold text-slate-900">Delete Project?</h2>
|
||||
<button onclick="closeAll()" class="text-slate-400 hover:text-slate-600 -mt-1 -mr-2 p-2">
|
||||
<i data-lucide="x" class="w-5 h-5"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="text-slate-500 text-sm mb-6">
|
||||
This will permanently delete <span class="text-slate-900 font-medium">"Summer Vlog 2025"</span> and all its associated media files. This action cannot be undone.
|
||||
</p>
|
||||
|
||||
<div class="flex items-center gap-3 pt-2">
|
||||
<button class="flex-1 px-4 py-2.5 text-sm font-bold text-white bg-red-500 hover:bg-red-600 rounded-lg shadow-sm">
|
||||
Yes, delete it
|
||||
</button>
|
||||
<button onclick="closeAll()" class="px-4 py-2.5 text-sm font-medium text-slate-600 hover:text-slate-900 border border-slate-200 rounded-lg hover:bg-slate-50">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
lucide.createIcons();
|
||||
|
||||
const overlay = document.getElementById('overlay');
|
||||
const dialogs = [
|
||||
document.getElementById('dialog-1'),
|
||||
document.getElementById('dialog-2'),
|
||||
document.getElementById('dialog-3')
|
||||
];
|
||||
|
||||
function openDialog(id) {
|
||||
// Hide all first
|
||||
dialogs.forEach(d => d.classList.add('hidden'));
|
||||
|
||||
// Show overlay
|
||||
overlay.classList.add('open');
|
||||
document.body.classList.add('dialog-open');
|
||||
|
||||
// Show specific dialog
|
||||
const target = document.getElementById(`dialog-${id}`);
|
||||
if (target) {
|
||||
target.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function closeAll() {
|
||||
overlay.classList.remove('open');
|
||||
document.body.classList.remove('dialog-open');
|
||||
dialogs.forEach(d => d.classList.add('hidden'));
|
||||
}
|
||||
|
||||
// Close on backdrop click
|
||||
overlay.addEventListener('click', (e) => {
|
||||
if (e.target === overlay) {
|
||||
closeAll();
|
||||
}
|
||||
});
|
||||
|
||||
// Escape key support
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') closeAll();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -17,13 +17,13 @@ export function OcVideoIcon({
|
|||
<title>Video</title>
|
||||
<path
|
||||
d="M2.41667 11C2.41667 7.70017 2.41667 6.05025 3.65537 5.02513C4.89405 4 6.88771 4 10.875 4H12.0833C16.0706 4 18.0642 4 19.303 5.02513C20.5417 6.05025 20.5417 7.70017 20.5417 11V13C20.5417 16.2998 20.5417 17.9497 19.303 18.9749C18.0642 20 16.0706 20 12.0833 20H10.875C6.88771 20 4.89405 20 3.65537 18.9749C2.41667 17.9497 2.41667 16.2998 2.41667 13V11Z"
|
||||
stroke="#141B34"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M20.5417 8.90585L20.6938 8.80196C23.2504 7.05623 24.5287 6.18336 25.556 6.60482C26.5833 7.02628 26.5833 8.42355 26.5833 11.2181V12.7819C26.5833 15.5765 26.5833 16.9737 25.556 17.3952C24.5287 17.8166 23.2504 16.9438 20.6938 15.198L20.5417 15.0941"
|
||||
stroke="#141B34"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
|
|
|
|||
Loading…
Reference in New Issue