projects page re-design and a ton of other stuff

This commit is contained in:
Maze Winther
2026-01-26 16:10:16 +01:00
parent 954160d03b
commit 0c10bbf5ce
33 changed files with 1794 additions and 1185 deletions
+25 -8
View File
@@ -187,6 +187,21 @@ use-action-handler.ts
use-editor-actions.ts use-editor-actions.ts
export function useEditorActions() 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 ## apps/web/src/hooks/timeline
use-edge-auto-scroll.ts use-edge-auto-scroll.ts
@@ -457,6 +472,11 @@ scenes.ts
scenes: TScene[]; scenes: TScene[];
currentSceneId: string; currentSceneId: string;
}): TScene | null }): TScene | null
export function getProjectDurationFromScenes({
scenes,
}: {
scenes: TScene[];
}): number
export function updateSceneInArray({ export function updateSceneInArray({
scenes, scenes,
sceneId, sceneId,
@@ -1383,6 +1403,7 @@ project.ts
id: string id: string
name: string name: string
thumbnail?: string thumbnail?: string
duration: number
createdAt: Date createdAt: Date
updatedAt: Date updatedAt: Date
} }
@@ -1399,6 +1420,9 @@ project.ts
settings: TProjectSettings settings: TProjectSettings
version: number version: number
} }
export type TProjectSortKey = "createdAt" | "updatedAt" | "name" | "duration"
export type TSortOrder = "asc" | "desc"
export type TProjectSortOption = `${TProjectSortKey}-${TSortOrder}`
sounds.ts sounds.ts
export interface SoundEffect { export interface SoundEffect {
@@ -1695,14 +1719,7 @@ brand.tsx
}) })
ui.tsx ui.tsx
export function OcSplitKeepLeftIcon({␍ export function OcVideoIcon({␍
className = "",␍
size = 32,␍
}: {␍
className?: string;␍
size?: number;␍
})
export function OcSplitKeepRightIcon({␍
className = "",␍ className = "",␍
size = 32,␍ size = 32,␍
}: {␍ }: {␍
+1
View File
@@ -27,6 +27,7 @@
"@opencut/env": "workspace:*", "@opencut/env": "workspace:*",
"@opencut/ui": "workspace:*", "@opencut/ui": "workspace:*",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
+6 -6
View File
@@ -14,8 +14,8 @@
--card-foreground: hsl(0 0% 11%); --card-foreground: hsl(0 0% 11%);
--popover: hsl(0, 0%, 100%); --popover: hsl(0, 0%, 100%);
--popover-foreground: hsl(0 0% 2%); --popover-foreground: hsl(0 0% 2%);
--primary: hsl(210, 91%, 49%); --primary: hsl(203, 100%, 50%);
--primary-foreground: hsl(0 0% 91%); --primary-foreground: hsl(0, 0%, 100%);
--secondary: hsl(216, 13%, 92%); --secondary: hsl(216, 13%, 92%);
--secondary-foreground: hsl(0 0% 2%); --secondary-foreground: hsl(0 0% 2%);
--muted: hsl(0 0% 85.1%); --muted: hsl(0 0% 85.1%);
@@ -26,7 +26,7 @@
--destructive-foreground: hsl(0, 0%, 100%); --destructive-foreground: hsl(0, 0%, 100%);
--constructive: hsl(141, 71%, 48%); --constructive: hsl(141, 71%, 48%);
--constructive-foreground: hsl(0, 0%, 100%); --constructive-foreground: hsl(0, 0%, 100%);
--border: hsl(0 0% 83%); --border: hsl(0 0% 88%);
--input: hsl(0 0% 85.1%); --input: hsl(0 0% 85.1%);
--ring: hsl(0, 0%, 55%); --ring: hsl(0, 0%, 55%);
--chart-1: hsl(220 70% 50%); --chart-1: hsl(220 70% 50%);
@@ -42,8 +42,8 @@
--sidebar-accent-foreground: hsl(0 0% 2%); --sidebar-accent-foreground: hsl(0 0% 2%);
--sidebar-border: hsl(0 0% 85.1%); --sidebar-border: hsl(0 0% 85.1%);
--sidebar-ring: hsl(0 0% 16.9%); --sidebar-ring: hsl(0 0% 16.9%);
--panel-background: hsl(216 13% 92%); --panel-background: hsl(216 13% 96%);
--panel-accent: hsl(216, 8%, 86%); --panel-accent: hsl(216, 8%, 90%);
--radius: 1rem; --radius: 1rem;
@@ -126,7 +126,7 @@
/* Font sizes */ /* Font sizes */
--text-base: 0.95rem; --text-base: 0.95rem;
--text-base--line-height: calc(1.5 / 0.95); --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); --text-xs--line-height: calc(1 / 0.8);
/* Border radius */ /* Border radius */
-643
View File
@@ -1,643 +0,0 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
import type { KeyboardEvent, MouseEvent } from "react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { DeleteProjectDialog } from "@/components/editor/delete-project-dialog";
import { MigrationDialog } from "@/components/editor/migration-dialog";
import { RenameProjectDialog } from "@/components/rename-project-dialog";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useEditor } from "@/hooks/use-editor";
import type { TProjectMetadata } from "@/types/project";
import { formatDate } from "@/utils/date";
import { HugeiconsIcon } from "@hugeicons/react";
import {
ArrowHorizontalIcon,
ArrowLeft01Icon,
Calendar04Icon,
Delete02Icon,
MultiplicationSignIcon,
PlusSignIcon,
Search01Icon,
SortingNineOneIcon,
Video01Icon,
} from "@hugeicons/core-free-icons";
export default function ProjectsPage() {
const [isSelectionMode, setIsSelectionMode] = useState(false);
const [selectedProjects, setSelectedProjects] = useState<Set<string>>(
new Set(),
);
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [sortOption, setSortOption] = useState("createdAt-desc");
const router = useRouter();
const editor = useEditor();
useEffect(() => {
if (!editor.project.getIsInitialized()) {
editor.project.loadAllProjects();
}
}, [editor.project]);
const handleCreateProject = async () => {
try {
const projectId = await editor.project.createNewProject({
name: "New project",
});
router.push(`/editor/${projectId}`);
} catch (error) {
toast.error("Failed to create project", {
description:
error instanceof Error ? error.message : "Please try again",
});
}
};
const toggleSortOption = ({
sortField,
}: {
sortField: "createdAt" | "name";
}) => {
const isSameField = sortOption.startsWith(sortField);
const nextSortOption = isSameField
? `${sortField}-${sortOption.endsWith("asc") ? "desc" : "asc"}`
: `${sortField}-asc`;
setSortOption(nextSortOption);
};
const handleSelectProject = ({
projectId,
checked,
}: {
projectId: string;
checked: boolean;
}) => {
const newSelected = new Set(selectedProjects);
if (checked) {
newSelected.add(projectId);
} else {
newSelected.delete(projectId);
}
setSelectedProjects(newSelected);
};
const _handleSelectAll = ({ checked }: { checked: boolean }) => {
if (checked) {
setSelectedProjects(
new Set(projectsToDisplay.map((project) => project.id)),
);
} else {
setSelectedProjects(new Set());
}
};
const handleCancelSelection = () => {
setIsSelectionMode(false);
setSelectedProjects(new Set());
};
const handleBulkDelete = async () => {
try {
await Promise.all(
Array.from(selectedProjects).map((projectId) =>
editor.project.deleteProject({ id: projectId }),
),
);
} catch (error) {
toast.error("Failed to delete projects", {
description:
error instanceof Error ? error.message : "Please try again",
});
} finally {
setSelectedProjects(new Set());
setIsSelectionMode(false);
setIsBulkDeleteDialogOpen(false);
}
};
const projectsToDisplay = editor.project.getFilteredAndSortedProjects({
searchQuery,
sortOption,
});
const _isAllSelected =
projectsToDisplay.length > 0 &&
selectedProjects.size === projectsToDisplay.length;
const _hasSomeSelected =
selectedProjects.size > 0 &&
selectedProjects.size < projectsToDisplay.length;
const isLoading = editor.project.getIsLoading();
const isInitialized = editor.project.getIsInitialized();
return (
<div className="bg-background min-h-screen">
<MigrationDialog />
<div className="flex h-16 w-full items-center justify-between px-6 pt-2">
<Link
href="/"
className="hover:text-muted-foreground flex items-center gap-1 transition-colors"
>
<HugeiconsIcon icon={ArrowLeft01Icon} className="size-5! shrink-0" />
<span className="text-sm font-medium">Back</span>
</Link>
<div className="block md:hidden">
{isSelectionMode ? (
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={handleCancelSelection}
>
<HugeiconsIcon icon={MultiplicationSignIcon} />
Cancel
</Button>
{selectedProjects.size > 0 && (
<Button
variant="destructive"
size="sm"
onClick={() => setIsBulkDeleteDialogOpen(true)}
>
<HugeiconsIcon icon={Delete02Icon} />
Delete ({selectedProjects.size})
</Button>
)}
</div>
) : (
<CreateButton onClick={handleCreateProject} />
)}
</div>
</div>
<main className="mx-auto max-w-6xl px-6 pt-6 pb-6">
<div className="mb-8 flex items-center justify-between">
<div className="flex flex-col gap-3">
<h1 className="text-2xl font-bold tracking-tight md:text-3xl">
Your projects
</h1>
<p className="text-muted-foreground">
{projectsToDisplay.length}{" "}
{projectsToDisplay.length === 1 ? "project" : "projects"}
{isSelectionMode && selectedProjects.size > 0 && (
<span className="text-primary ml-2">
{selectedProjects.size} selected
</span>
)}
</p>
</div>
<div className="hidden md:block">
{isSelectionMode ? (
<div className="flex items-center gap-2">
<Button variant="outline" onClick={handleCancelSelection}>
<HugeiconsIcon icon={MultiplicationSignIcon} />
Cancel
</Button>
<Button
variant="destructive"
disabled={selectedProjects.size === 0}
onClick={() => setIsBulkDeleteDialogOpen(true)}
>
<HugeiconsIcon icon={Delete02Icon} />
Delete {selectedProjects.size} projects
</Button>
</div>
) : (
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={() => setIsSelectionMode(true)}
disabled={projectsToDisplay.length === 0}
>
Select projects
</Button>
<CreateButton onClick={handleCreateProject} />
</div>
)}
</div>
</div>
<div className="mb-4 flex items-center justify-between gap-4">
<div className="max-w-72 flex-1">
<Input
placeholder="Search projects..."
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
/>
</div>
<div className="flex items-center gap-0">
<TooltipProvider>
<Tooltip>
<DropdownMenu>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
aria-label="sort projects"
size="icon"
variant="outline"
className="size-9 items-center justify-center"
>
<HugeiconsIcon
icon={SortingNineOneIcon}
aria-hidden="true"
/>
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() =>
toggleSortOption({ sortField: "createdAt" })
}
>
Created{" "}
{sortOption.startsWith("createdAt") &&
(sortOption.endsWith("asc") ? "↑" : "↓")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => toggleSortOption({ sortField: "name" })}
>
Name{" "}
{sortOption.startsWith("name") &&
(sortOption.endsWith("asc") ? "↑" : "↓")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<TooltipContent>
<p>
Sort by{" "}
{sortOption.startsWith("createdAt") ? "date" : "name"} (
{sortOption.endsWith("asc") ? "ascending" : "descending"})
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
{isLoading || !isInitialized ? (
<ProjectsLoader />
) : projectsToDisplay.length === 0 ? (
<EmptyState
search={{
query: searchQuery,
onClearSearch: () => setSearchQuery(""),
}}
onCreateProject={handleCreateProject}
/>
) : (
<div className="xs:grid-cols-2 grid grid-cols-1 gap-6 sm:grid-cols-3 lg:grid-cols-4">
{projectsToDisplay.map((project) => (
<ProjectCard
key={project.id}
project={project}
isSelectionMode={isSelectionMode}
isSelected={selectedProjects.has(project.id)}
onSelect={handleSelectProject}
/>
))}
</div>
)}
</main>
<DeleteProjectDialog
isOpen={isBulkDeleteDialogOpen}
onOpenChange={setIsBulkDeleteDialogOpen}
onConfirm={handleBulkDelete}
/>
</div>
);
}
interface ProjectCardProps {
project: TProjectMetadata;
isSelectionMode?: boolean;
isSelected?: boolean;
onSelect?: ({
projectId,
checked,
}: {
projectId: string;
checked: boolean;
}) => void;
}
function ProjectCard({
project,
isSelectionMode = false,
isSelected = false,
onSelect,
}: ProjectCardProps) {
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
const editor = useEditor();
const handleDeleteProject = async () => {
await editor.project.deleteProject({ id: project.id });
setIsDropdownOpen(false);
};
const handleRenameProject = async ({ name }: { name: string }) => {
await editor.project.renameProject({ id: project.id, name });
setIsRenameDialogOpen(false);
};
const handleDuplicateProject = async () => {
setIsDropdownOpen(false);
await editor.project.duplicateProject({ id: project.id });
};
const handleCardClick = ({
event,
}: {
event: MouseEvent<HTMLButtonElement>;
}) => {
if (isSelectionMode) {
event.preventDefault();
onSelect?.({ projectId: project.id, checked: !isSelected });
}
};
const handleCardKeyDown = ({
event,
}: {
event: KeyboardEvent<HTMLButtonElement>;
}) => {
if (isSelectionMode && (event.key === "Enter" || event.key === " ")) {
event.preventDefault();
onSelect?.({ projectId: project.id, checked: !isSelected });
}
};
const cardContent = (
<Card
className={`bg-background overflow-hidden border-none p-0 transition-all ${
isSelectionMode && isSelected ? "ring-primary ring-2" : ""
}`}
>
<div
className={`bg-muted relative aspect-square transition-opacity ${
isDropdownOpen ? "opacity-65" : "opacity-100 group-hover:opacity-65"
}`}
>
{isSelectionMode && (
<div className="absolute top-3 left-3 z-10">
<div className="bg-background/80 flex size-5 items-center justify-center rounded-full border backdrop-blur-xs">
<Checkbox
checked={isSelected}
onCheckedChange={(checked) =>
onSelect?.({
projectId: project.id,
checked: checked === true,
})
}
onClick={(event) => event.stopPropagation()}
className="size-4"
/>
</div>
</div>
)}
<div className="absolute inset-0">
{project.thumbnail ? (
<Image
src={project.thumbnail}
alt="Project thumbnail"
fill
className="object-cover"
/>
) : (
<div className="bg-muted/50 flex size-full items-center justify-center">
<HugeiconsIcon
icon={Video01Icon}
className="text-muted-foreground size-12 shrink-0"
/>
</div>
)}
</div>
</div>
<CardContent className="flex flex-col gap-1 px-0 pt-5">
<div className="flex items-start justify-between">
<h3 className="group-hover:text-foreground/90 line-clamp-2 text-sm leading-snug font-medium transition-colors">
{project.name}
</h3>
{!isSelectionMode && (
<DropdownMenu
open={isDropdownOpen}
onOpenChange={setIsDropdownOpen}
>
<DropdownMenuTrigger asChild>
<Button
aria-label="project options"
variant="text"
size="sm"
className={`ml-2 size-6 shrink-0 p-0 transition-all ${
isDropdownOpen
? "opacity-100"
: "opacity-0 group-hover:opacity-100"
}`}
onClick={(event) => event.preventDefault()}
>
<HugeiconsIcon
icon={ArrowHorizontalIcon}
aria-hidden="true"
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
onCloseAutoFocus={(event) => {
event.preventDefault();
event.stopPropagation();
}}
>
<DropdownMenuItem
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setIsDropdownOpen(false);
setIsRenameDialogOpen(true);
}}
>
Rename
</DropdownMenuItem>
<DropdownMenuItem
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
handleDuplicateProject();
}}
>
Duplicate
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setIsDropdownOpen(false);
setIsDeleteDialogOpen(true);
}}
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<div className="space-y-1">
<div className="text-muted-foreground flex items-center gap-1.5 text-sm">
<HugeiconsIcon icon={Calendar04Icon} className="size-4" />
<span>Created {formatDate({ date: project.createdAt })}</span>
</div>
</div>
</CardContent>
</Card>
);
return (
<>
{isSelectionMode ? (
<button
type="button"
onClick={(event) => handleCardClick({ event })}
onKeyDown={(event) => handleCardKeyDown({ event })}
className="group block w-full cursor-pointer text-left"
>
{cardContent}
</button>
) : (
<Link href={`/editor/${project.id}`} className="group block">
{cardContent}
</Link>
)}
<DeleteProjectDialog
isOpen={isDeleteDialogOpen}
onOpenChange={setIsDeleteDialogOpen}
onConfirm={handleDeleteProject}
/>
<RenameProjectDialog
isOpen={isRenameDialogOpen}
onOpenChange={setIsRenameDialogOpen}
onConfirm={(name) => handleRenameProject({ name })}
projectName={project.name}
/>
</>
);
}
function ProjectsLoader() {
const skeletonIds = Array.from(
{ length: 8 },
(_, index) => `skeleton-${index}`,
);
return (
<div className="xs:grid-cols-2 grid grid-cols-1 gap-6 sm:grid-cols-3 lg:grid-cols-4">
{skeletonIds.map((skeletonId) => (
<div
key={skeletonId}
className="bg-background overflow-hidden border-none p-0"
>
<Skeleton className="bg-muted/50 aspect-square w-full" />
<div className="flex flex-col gap-1.5 px-0 pt-5">
<Skeleton className="bg-muted/50 h-4 w-3/4" />
<div className="flex items-center gap-1.5">
<Skeleton className="bg-muted/50 size-4" />
<Skeleton className="bg-muted/50 h-4 w-24" />
</div>
</div>
</div>
))}
</div>
);
}
function CreateButton({ onClick }: { onClick?: () => void }) {
return (
<Button className="flex" onClick={onClick}>
<HugeiconsIcon icon={PlusSignIcon} />
<span className="text-sm font-medium">New project</span>
</Button>
);
}
function EmptyState({
search,
onCreateProject,
}: {
search: { query: string; onClearSearch: () => void };
onCreateProject: () => void;
}) {
const editor = useEditor();
const savedProjects = editor.project.getSavedProjects();
if (savedProjects.length > 0) {
return (
<div className="flex flex-col items-center justify-center gap-6 py-16 text-center">
<div className="flex flex-col items-center gap-2">
<div className="bg-muted/30 flex size-16 items-center justify-center rounded-full">
<HugeiconsIcon
icon={Search01Icon}
className="text-muted-foreground size-8"
/>
</div>
<h3 className="text-lg font-medium">No results found</h3>
<p className="text-muted-foreground max-w-md">
Your search for "{search.query}" did not return any results.
</p>
</div>
<Button onClick={search.onClearSearch} variant="outline">
Clear search
</Button>
</div>
);
}
return (
<div className="flex flex-col items-center justify-center gap-6 py-16 text-center">
<div className="flex flex-col items-center gap-2">
<div className="bg-muted/30 flex size-16 items-center justify-center rounded-full">
<HugeiconsIcon
icon={Video01Icon}
className="text-muted-foreground size-8"
/>
</div>
<h3 className="text-lg font-medium">No projects yet</h3>
<p className="text-muted-foreground max-w-md">
Start creating your first video project. Import media, edit, and
export professional videos.
</p>
</div>
<Button size="lg" className="gap-2" onClick={onCreateProject}>
<HugeiconsIcon icon={PlusSignIcon} />
Create your first project
</Button>
</div>
);
}
File diff suppressed because it is too large Load Diff
+89
View File
@@ -0,0 +1,89 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import type { TProjectSortKey } from "@/types/project";
export type ProjectsViewMode = "grid" | "list";
interface ProjectsState {
searchQuery: string;
sortKey: TProjectSortKey;
sortOrder: "asc" | "desc";
viewMode: ProjectsViewMode;
selectedProjectIds: string[];
isHydrated: boolean;
setIsHydrated: ({ isHydrated }: { isHydrated: boolean }) => void;
setSearchQuery: ({ query }: { query: string }) => void;
setSortKey: ({ sortKey }: { sortKey: TProjectSortKey }) => void;
setSortOrder: ({ sortOrder }: { sortOrder: "asc" | "desc" }) => void;
toggleSortOrder: () => void;
setViewMode: ({ viewMode }: { viewMode: ProjectsViewMode }) => void;
setSelectedProjects: ({ projectIds }: { projectIds: string[] }) => void;
clearSelectedProjects: () => void;
setProjectSelected: ({
projectId,
isSelected,
}: {
projectId: string;
isSelected: boolean;
}) => void;
}
const getNextSelectedProjectIds = ({
selectedProjectIds,
projectId,
isSelected,
}: {
selectedProjectIds: string[];
projectId: string;
isSelected: boolean;
}): string[] => {
const selectedProjectIdSet = new Set(selectedProjectIds);
if (isSelected) {
selectedProjectIdSet.add(projectId);
return Array.from(selectedProjectIdSet);
}
selectedProjectIdSet.delete(projectId);
return Array.from(selectedProjectIdSet);
};
export const useProjectsStore = create<ProjectsState>()(
persist(
(set) => ({
searchQuery: "",
sortKey: "createdAt",
sortOrder: "desc",
viewMode: "grid",
selectedProjectIds: [],
isHydrated: false,
setIsHydrated: ({ isHydrated }) => set({ isHydrated }),
setSearchQuery: ({ query }) => set({ searchQuery: query }),
setSortKey: ({ sortKey }) => set({ sortKey }),
setSortOrder: ({ sortOrder }) => set({ sortOrder }),
toggleSortOrder: () =>
set((state) => ({
sortOrder: state.sortOrder === "asc" ? "desc" : "asc",
})),
setViewMode: ({ viewMode }) => set({ viewMode }),
setSelectedProjects: ({ projectIds }) =>
set({ selectedProjectIds: projectIds }),
clearSelectedProjects: () => set({ selectedProjectIds: [] }),
setProjectSelected: ({ projectId, isSelected }) =>
set((state) => ({
selectedProjectIds: getNextSelectedProjectIds({
selectedProjectIds: state.selectedProjectIds,
projectId,
isSelected,
}),
})),
}),
{
name: "projects-view-mode",
partialize: (state) => ({ viewMode: state.viewMode }),
onRehydrateStorage: () => (state) => {
state?.setIsHydrated({ isHydrated: true });
},
},
),
);
+5 -50
View File
@@ -31,75 +31,30 @@ const roadmapItems: RoadmapItem[] = [
{ {
title: "Core UI", title: "Core UI",
description: 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: { status: {
text: "Completed", text: "Completed",
type: "complete", type: "complete",
}, },
}, },
{ {
title: "Basic Functionality", title: "Essential functionality",
description: 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: { status: {
text: "In progress", text: "In progress",
type: "pending", type: "pending",
}, },
}, },
{ {
title: "Export/preview logic", title: "Badge (potentially)",
description: 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.", 'An "Edit with OpenCut" badge web apps can integrate. Shows on video players.',
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.",
status: { status: {
text: "Not started", text: "Not started",
type: "default", 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 = { export const metadata: Metadata = {
@@ -44,6 +44,7 @@ import {
Video01Icon, Video01Icon,
} from "@hugeicons/core-free-icons"; } from "@hugeicons/core-free-icons";
import { HugeiconsIcon, type IconSvgElement } from "@hugeicons/react"; import { HugeiconsIcon, type IconSvgElement } from "@hugeicons/react";
import { OcVideoIcon } from "@opencut/ui/icons";
export function MediaView() { export function MediaView() {
const editor = useEditor(); const editor = useEditor();
@@ -558,19 +559,18 @@ function MediaPreview({
item: MediaAsset; item: MediaAsset;
variant?: "grid" | "compact"; variant?: "grid" | "compact";
}) { }) {
const shouldShowVideoOverlay = variant === "grid";
const shouldShowDurationBadge = variant === "grid"; const shouldShowDurationBadge = variant === "grid";
if (item.type === "image") { if (item.type === "image") {
return ( return (
<div className="flex size-full items-center justify-center"> <div className="relative flex size-full items-center justify-center">
<Image <Image
src={item.url ?? ""} src={item.url ?? ""}
alt={item.name} alt={item.name}
className="max-h-full w-full object-cover" fill
sizes="100vw"
className="object-cover"
loading="lazy" loading="lazy"
width={item.width}
height={item.height}
unoptimized unoptimized
/> />
</div> </div>
@@ -584,18 +584,12 @@ function MediaPreview({
<Image <Image
src={item.thumbnailUrl} src={item.thumbnailUrl}
alt={item.name} alt={item.name}
className="size-full rounded object-cover" fill
sizes="100vw"
className="rounded object-cover"
loading="lazy" loading="lazy"
unoptimized 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 ? ( {shouldShowDurationBadge ? (
<MediaDurationBadge duration={item.duration} /> <MediaDurationBadge duration={item.duration} />
) : null} ) : null}
@@ -12,38 +12,43 @@ export function DeleteProjectDialog({
isOpen, isOpen,
onOpenChange, onOpenChange,
onConfirm, onConfirm,
projectName, projectNames,
}: { }: {
isOpen: boolean; isOpen: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
onConfirm: () => void; onConfirm: () => void;
projectName?: string; projectNames: string[];
}) { }) {
const count = projectNames.length;
const isSingle = count === 1;
const singleName = isSingle ? projectNames[0] : null;
return ( return (
<Dialog open={isOpen} onOpenChange={onOpenChange}> <Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent <DialogContent
onOpenAutoFocus={(e) => { onOpenAutoFocus={(event) => {
e.preventDefault(); event.preventDefault();
e.stopPropagation(); event.stopPropagation();
}} }}
> >
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
{projectName ? ( {singleName ? (
<> <>
{"Delete '"} {"Delete '"}
<span className="inline-block max-w-[300px] truncate align-bottom"> <span className="inline-block max-w-[300px] truncate align-bottom">
{projectName} {singleName}
</span> </span>
{"'?"} {"'?"}
</> </>
) : ( ) : (
"Delete Project?" `Delete ${count} projects?`
)} )}
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
Are you sure you want to delete this project? This action cannot be {isSingle
undone. ? "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> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
@@ -24,7 +24,7 @@ import {
ArrowLeft02Icon, ArrowLeft02Icon,
Edit03Icon, Edit03Icon,
Delete02Icon, Delete02Icon,
Keyboard, CommandIcon,
} from "@hugeicons/core-free-icons"; } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react"; import { HugeiconsIcon } from "@hugeicons/react";
import { ShortcutsDialog } from "./shortcuts-dialog"; import { ShortcutsDialog } from "./shortcuts-dialog";
@@ -92,7 +92,9 @@ function ProjectDropdown() {
const handleDeleteProject = async () => { const handleDeleteProject = async () => {
if (activeProject) { if (activeProject) {
try { try {
await editor.project.deleteProject({ id: activeProject.metadata.id }); await editor.project.deleteProjects({
ids: [activeProject.metadata.id],
});
router.push("/projects"); router.push("/projects");
} catch (error) { } catch (error) {
toast.error("Failed to delete project", { toast.error("Failed to delete project", {
@@ -148,7 +150,7 @@ function ProjectDropdown() {
className="flex items-center gap-1.5" className="flex items-center gap-1.5"
onClick={() => setOpenDialog("shortcuts")} onClick={() => setOpenDialog("shortcuts")}
> >
<HugeiconsIcon icon={Keyboard} className="size-4" /> <HugeiconsIcon icon={CommandIcon} className="size-4" />
Keyboard shortcuts Keyboard shortcuts
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
@@ -174,7 +176,7 @@ function ProjectDropdown() {
isOpen={openDialog === "delete"} isOpen={openDialog === "delete"}
onOpenChange={(isOpen) => setOpenDialog(isOpen ? "delete" : null)} onOpenChange={(isOpen) => setOpenDialog(isOpen ? "delete" : null)}
onConfirm={handleDeleteProject} onConfirm={handleDeleteProject}
projectName={activeProject?.metadata.name || ""} projectNames={[activeProject?.metadata.name || ""]}
/> />
<ShortcutsDialog <ShortcutsDialog
isOpen={openDialog === "shortcuts"} isOpen={openDialog === "shortcuts"}
+8 -10
View File
@@ -1,30 +1,28 @@
"use client"; "use client";
import { ArrowRightIcon } from "lucide-react"; import { ArrowRightIcon } from "lucide-react";
import { useEffect, useState } from "react"; import { useState } from "react";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import { SOCIAL_LINKS } from "@/constants/site-constants"; import { SOCIAL_LINKS } from "@/constants/site-constants";
import { useLocalStorage } from "@/hooks/storage/use-local-storage";
import { Button } from "../ui/button"; import { Button } from "../ui/button";
import { Dialog, DialogContent, DialogTitle } from "../ui/dialog"; import { Dialog, DialogContent, DialogTitle } from "../ui/dialog";
export function Onboarding() { export function Onboarding() {
const [step, setStep] = useState(0); const [step, setStep] = useState(0);
const [isOpen, setIsOpen] = useState(false); const [hasSeenOnboarding, setHasSeenOnboarding] = useLocalStorage({
key: "hasSeenOnboarding",
defaultValue: false,
});
useEffect(() => { const isOpen = !hasSeenOnboarding;
const hasSeenOnboarding = localStorage.getItem("hasSeenOnboarding");
if (!hasSeenOnboarding) {
setIsOpen(true);
}
}, []);
const handleNext = () => { const handleNext = () => {
setStep(step + 1); setStep(step + 1);
}; };
const handleClose = () => { const handleClose = () => {
setIsOpen(false); setHasSeenOnboarding({ value: true });
localStorage.setItem("hasSeenOnboarding", "true");
}; };
const getStepTitle = () => { 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 <Textarea
placeholder="Name" placeholder="Name"
defaultValue={element.content} defaultValue={element.content}
className="bg-panel-accent min-h-18 resize-none" className="bg-panel-accent min-h-20"
onChange={(e) => onChange={(e) =>
editor.timeline.updateTextElement({ editor.timeline.updateTextElement({
trackId, trackId,
+1 -1
View File
@@ -65,7 +65,7 @@ const AlertDialogFooter = ({
}: React.HTMLAttributes<HTMLDivElement>) => ( }: React.HTMLAttributes<HTMLDivElement>) => (
<div <div
className={cn( 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, className,
)} )}
{...props} {...props}
+14 -10
View File
@@ -12,25 +12,28 @@ const buttonVariants = cva(
default: default:
"bg-foreground text-background shadow-sm hover:bg-foreground/90", "bg-foreground text-background shadow-sm hover:bg-foreground/90",
foreground: foreground:
"bg-background text-foreground shadow-sm hover:bg-background/80", "bg-background text-foreground",
primary: primary:
"bg-primary text-primary-foreground shadow-sm hover:bg-primary/90", "bg-primary text-primary-foreground hover:bg-primary/90",
"primary-gradient": "primary-gradient":
"bg-gradient-to-r from-cyan-400 to-blue-500 text-white hover:opacity-85 transition-opacity", "bg-gradient-to-r from-cyan-400 to-blue-500 text-white hover:opacity-85 transition-opacity",
destructive: 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: outline:
"border border-input bg-transparent shadow-xs transition-colors hover:bg-accent", "border border-border bg-transparent transition-colors hover:bg-accent",
secondary: secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-foreground/15 border border-input", "bg-secondary text-secondary-foreground 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) text: "bg-transparent rounded-none !opacity-100",
link: "text-primary underline-offset-4 hover:underline", link: "text-primary underline-offset-4 hover:underline !p-0 !h-auto",
}, },
size: { size: {
default: "h-9 px-4 py-2", default: "h-9 px-4 py-2",
sm: "h-8 rounded-full px-3 text-xs", sm: "h-8 rounded-full px-3 text-xs",
lg: "h-10 rounded-full p-5", lg: "h-10 rounded-full p-5 px-6",
icon: "size-7", icon: "size-7 rounded-sm",
text: "p-0",
}, },
}, },
defaultVariants: { defaultVariants: {
@@ -49,9 +52,10 @@ export interface ButtonProps
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>( const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => { ({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? SlotPrimitive.Slot : "button"; const Comp = asChild ? SlotPrimitive.Slot : "button";
const effectiveSize = size ?? (variant === "text" ? "text" : "default");
return ( return (
<Comp <Comp
className={cn(buttonVariants({ variant, size, className }))} className={cn(buttonVariants({ variant, size: effectiveSize, className }))}
ref={ref} ref={ref}
{...props} {...props}
/> />
+1 -1
View File
@@ -13,7 +13,7 @@ const Checkbox = React.forwardRef<
<CheckboxPrimitive.Root <CheckboxPrimitive.Root
ref={ref} ref={ref}
className={cn( 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, className,
)} )}
{...props} {...props}
+1 -1
View File
@@ -75,7 +75,7 @@ const DialogFooter = ({
}: React.HTMLAttributes<HTMLDivElement>) => ( }: React.HTMLAttributes<HTMLDivElement>) => (
<div <div
className={cn( 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, className,
)} )}
{...props} {...props}
+3 -3
View File
@@ -21,16 +21,16 @@ export function FontPicker({
return ( return (
<Select defaultValue={defaultValue} onValueChange={onValueChange}> <Select defaultValue={defaultValue} onValueChange={onValueChange}>
<SelectTrigger <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> </SelectTrigger>
<SelectContent> <SelectContent>
{FONT_OPTIONS.map((font) => ( {FONT_OPTIONS.map((font) => (
<SelectItem <SelectItem
key={font.value} key={font.value}
value={font.value} value={font.value}
className="text-xs" className="text-sn"
style={{ fontFamily: font.value }} style={{ fontFamily: font.value }}
> >
{font.label} {font.label}
+26 -6
View File
@@ -1,12 +1,32 @@
"use client"; "use client";
import { Eye, EyeOff, X } from "lucide-react"; import { Eye, EyeOff, X } from "lucide-react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/utils/ui"; import { cn } from "@/utils/ui";
import { Button } from "./button"; import { Button } from "./button";
import { forwardRef, type ComponentProps } from "react"; import { forwardRef, type ComponentProps } from "react";
import { useState } 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; showPassword?: boolean;
onShowPasswordChange?: (show: boolean) => void; onShowPasswordChange?: (show: boolean) => void;
showClearIcon?: boolean; showClearIcon?: boolean;
@@ -19,6 +39,7 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
{ {
className, className,
type, type,
size,
containerClassName, containerClassName,
showPassword, showPassword,
onShowPasswordChange, onShowPasswordChange,
@@ -55,11 +76,10 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
<input <input
type={inputType} type={inputType}
className={cn( 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", inputVariants({
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[2px]", size,
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", className: cn(paddingRight, className),
paddingRight, }),
className,
)} )}
ref={ref} ref={ref}
value={value} value={value}
+11 -10
View File
@@ -14,12 +14,12 @@ const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value; const SelectValue = SelectPrimitive.Value;
const selectItemVariants = cva( 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: { variants: {
variant: { variant: {
default: "focus:opacity-65 focus:text-accent-foreground", default: "",
destructive: "text-destructive focus:text-destructive/80", destructive: "text-destructive data-[highlighted]:bg-destructive/5 data-[highlighted]:text-destructive",
}, },
}, },
defaultVariants: { defaultVariants: {
@@ -36,6 +36,7 @@ const SelectTrigger = React.forwardRef<
ref={ref} ref={ref}
className={cn( 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", "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, className,
)} )}
{...props} {...props}
@@ -91,9 +92,7 @@ const SelectContent = React.forwardRef<
<SelectPrimitive.Content <SelectPrimitive.Content
ref={ref} ref={ref}
className={cn( 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", "bg-popover text-popover-foreground z-50 max-h-96 min-w-32 overflow-hidden rounded-2xl border p-2 shadow-lg",
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",
className, className,
)} )}
position={position} position={position}
@@ -106,7 +105,6 @@ const SelectContent = React.forwardRef<
<SelectScrollUpButton /> <SelectScrollUpButton />
<SelectPrimitive.Viewport <SelectPrimitive.Viewport
className={cn( className={cn(
"p-1",
position === "popper" && position === "popper" &&
"h-(--radix-select-trigger-height) w-full min-w-(--radix-select-trigger-width)", "h-(--radix-select-trigger-height) w-full min-w-(--radix-select-trigger-width)",
)} )}
@@ -125,7 +123,10 @@ const SelectLabel = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<SelectPrimitive.Label <SelectPrimitive.Label
ref={ref} 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} {...props}
/> />
)); ));
@@ -139,7 +140,7 @@ const SelectItem = React.forwardRef<
>(({ className, children, variant = "default", ...props }, ref) => ( >(({ className, children, variant = "default", ...props }, ref) => (
<SelectPrimitive.Item <SelectPrimitive.Item
ref={ref} ref={ref}
className={cn(selectItemVariants({ variant }), className)} className={cn(selectItemVariants({ variant }), "pr-8 pl-2", className)}
{...props} {...props}
> >
<span className="absolute right-2 flex size-3.5 items-center justify-center"> <span className="absolute right-2 flex size-3.5 items-center justify-center">
@@ -158,7 +159,7 @@ const SelectSeparator = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<SelectPrimitive.Separator <SelectPrimitive.Separator
ref={ref} 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} {...props}
/> />
)); ));
+1 -1
View File
@@ -98,7 +98,7 @@ const SheetFooter = ({
}: React.HTMLAttributes<HTMLDivElement>) => ( }: React.HTMLAttributes<HTMLDivElement>) => (
<div <div
className={cn( 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, className,
)} )}
{...props} {...props}
+2 -2
View File
@@ -9,8 +9,8 @@ const Textarea = React.forwardRef<
return ( return (
<textarea <textarea
className={cn( 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", "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-ring focus-visible:ring-ring/50 focus-visible:ring-[2px]", "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", "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className, className,
)} )}
+124 -50
View File
@@ -2,6 +2,8 @@ import type { EditorCore } from "@/core";
import type { import type {
TProject, TProject,
TProjectMetadata, TProjectMetadata,
TProjectSortKey,
TProjectSortOption,
TProjectSettings, TProjectSettings,
} from "@/types/project"; } from "@/types/project";
import type { ExportOptions, ExportResult } from "@/types/export"; import type { ExportOptions, ExportResult } from "@/types/export";
@@ -15,7 +17,7 @@ import {
DEFAULT_CANVAS_SIZE, DEFAULT_CANVAS_SIZE,
DEFAULT_COLOR, DEFAULT_COLOR,
} from "@/constants/project-constants"; } from "@/constants/project-constants";
import { buildDefaultScene } from "@/lib/scenes"; import { buildDefaultScene, getProjectDurationFromScenes } from "@/lib/scenes";
import { generateThumbnail } from "@/lib/media/processing"; import { generateThumbnail } from "@/lib/media/processing";
import { import {
CURRENT_STORAGE_VERSION, CURRENT_STORAGE_VERSION,
@@ -90,6 +92,7 @@ export class ProjectManager {
metadata: { metadata: {
id: generateUUID(), id: generateUUID(),
name, name,
duration: getProjectDurationFromScenes({ scenes: [mainScene] }),
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
}, },
@@ -178,11 +181,13 @@ export class ProjectManager {
if (!this.active) return; if (!this.active) return;
try { try {
const scenes = this.editor.scenes.getScenes();
const updatedProject = { const updatedProject = {
...this.active, ...this.active,
scenes: this.editor.scenes.getScenes(), scenes,
metadata: { metadata: {
...this.active.metadata, ...this.active.metadata,
duration: getProjectDurationFromScenes({ scenes }),
updatedAt: new Date(), 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 { try {
await Promise.all([ await Promise.all(
uniqueIds.map((id) =>
Promise.all([
storageService.deleteProjectMedia({ projectId: id }), storageService.deleteProjectMedia({ projectId: id }),
storageService.deleteProject({ id }), storageService.deleteProject({ id }),
]); ]),
),
);
this.savedProjects = this.savedProjects.filter((p) => p.id !== id); const idSet = new Set(uniqueIds);
this.notify(); 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.active = null;
this.notify();
this.editor.media.clearAllAssets(); this.editor.media.clearAllAssets();
this.editor.scenes.clearScenes(); this.editor.scenes.clearScenes();
} }
this.notify();
} catch (error) { } catch (error) {
console.error("Failed to delete project:", error); console.error("Failed to delete projects:", error);
} }
} }
@@ -291,30 +308,74 @@ 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 { try {
const result = await storageService.loadProject({ id }); const getDuplicateBaseName = ({ name }: { name: string }) => {
if (!result) { const match = name.match(/^\((\d+)\)\s+(.+)$/);
toast.error("Project not found", { const number = match ? Number.parseInt(match[1], 10) : null;
description: "Please try again", const baseName = match ? match[2] : name;
}); return { baseName, number };
throw new Error("Project not found"); };
const loadResults = await Promise.all(
uniqueIds.map(async (projectId) => {
const result = await storageService.loadProject({ id: projectId });
return { projectId, project: result?.project ?? null };
}),
);
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(", ")}`);
} }
const project = result.project; const projectsToDuplicate = loadResults.flatMap((result) =>
const numberMatch = project.metadata.name.match(/^\((\d+)\)\s+(.+)$/); result.project ? [result.project] : [],
const baseName = numberMatch ? numberMatch[2] : project.metadata.name; );
const existingNumbers: number[] = [];
this.savedProjects.forEach((p) => { const maxNumberByBaseName = new Map<string, number>();
const match = p.name.match(/^\((\d+)\)\s+(.+)$/);
if (match && match[2] === baseName) { for (const project of this.savedProjects) {
existingNumbers.push(parseInt(match[1], 10)); const { baseName, number } = getDuplicateBaseName({
} name: project.name,
}); });
const nextNumber = if (number === null) continue;
existingNumbers.length > 0 ? Math.max(...existingNumbers) + 1 : 1;
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 newProjectId = generateUUID();
const newProject: TProject = { const newProject: TProject = {
@@ -328,24 +389,44 @@ export class ProjectManager {
}, },
}; };
await storageService.saveProject({ project: newProject }); 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({ const sourceMediaAssets = await storageService.loadAllMediaAssets({
projectId: id, projectId: sourceProjectId,
}); });
for (const asset of sourceMediaAssets) {
await storageService.saveMediaAsset({ await Promise.all(
sourceMediaAssets.map((mediaAsset) =>
storageService.saveMediaAsset({
projectId: newProjectId, projectId: newProjectId,
mediaAsset: asset, mediaAsset,
}); }),
),
);
}),
);
for (const { newProject } of duplicationPlans) {
this.updateMetadata(newProject);
} }
this.updateMetadata(newProject); return duplicationPlans.map((plan) => plan.newProjectId);
return newProjectId;
} catch (error) { } catch (error) {
console.error("Failed to duplicate project:", error); console.error("Failed to duplicate projects:", error);
toast.error("Failed to duplicate project", { toast.error("Failed to duplicate projects", {
description: description:
error instanceof Error ? error.message : "Please try again", error instanceof Error ? error.message : "Please try again",
}); });
@@ -402,25 +483,18 @@ export class ProjectManager {
sortOption, sortOption,
}: { }: {
searchQuery: string; searchQuery: string;
sortOption: string; sortOption: TProjectSortOption;
}): TProjectMetadata[] { }): TProjectMetadata[] {
const filteredProjects = this.savedProjects.filter((project) => const filteredProjects = this.savedProjects.filter((project) =>
project.name.toLowerCase().includes(searchQuery.toLowerCase()), project.name.toLowerCase().includes(searchQuery.toLowerCase()),
); );
const [key, order] = sortOption.split("-") as [TProjectSortKey, "asc" | "desc"];
const sortedProjects = [...filteredProjects].sort((a, b) => { 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 aValue = a[key];
const bValue = b[key]; const bValue = b[key];
if (aValue === undefined || bValue === undefined) return 0;
if (order === "asc") { if (order === "asc") {
if (aValue < bValue) return -1; if (aValue < bValue) return -1;
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];
}
+14
View File
@@ -1,5 +1,6 @@
import type { TScene } from "@/types/timeline"; import type { TScene } from "@/types/timeline";
import { generateUUID } from "@/utils/id"; import { generateUUID } from "@/utils/id";
import { calculateTotalDuration } from "@/lib/timeline";
import { ensureMainTrack } from "@/lib/timeline/track-utils"; import { ensureMainTrack } from "@/lib/timeline/track-utils";
export function getMainScene({ scenes }: { scenes: TScene[] }): TScene | null { 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({ export function updateSceneInArray({
scenes, scenes,
sceneId, sceneId,
@@ -3,7 +3,12 @@ export { StorageVersionManager } from "./version-manager";
export { runStorageMigrations } from "./runner"; export { runStorageMigrations } from "./runner";
import { V0toV1Migration } from "./v0-to-v1"; import { V0toV1Migration } from "./v0-to-v1";
import { V1toV2Migration } from "./v1-to-v2"; 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 type { TProject, TProjectMetadata } from "@/types/project";
import { getProjectDurationFromScenes } from "@/lib/scenes";
import type { MediaAsset } from "@/types/assets"; import type { MediaAsset } from "@/types/assets";
import { IndexedDBAdapter } from "./indexeddb-adapter"; import { IndexedDBAdapter } from "./indexeddb-adapter";
import { OPFSAdapter } from "./opfs-adapter"; import { OPFSAdapter } from "./opfs-adapter";
@@ -13,7 +14,7 @@ import {
migrations, migrations,
runStorageMigrations, runStorageMigrations,
} from "@/services/storage/migrations"; } from "@/services/storage/migrations";
import type { TimelineTrack } from "@/types/timeline"; import type { TimelineTrack, TScene } from "@/types/timeline";
class StorageService { class StorageService {
private projectsAdapter: IndexedDBAdapter<SerializedProject>; private projectsAdapter: IndexedDBAdapter<SerializedProject>;
@@ -84,6 +85,9 @@ class StorageService {
} }
async saveProject({ project }: { project: TProject }): Promise<void> { async saveProject({ project }: { project: TProject }): Promise<void> {
const duration =
project.metadata.duration ??
getProjectDurationFromScenes({ scenes: project.scenes });
const serializedScenes: SerializedScene[] = project.scenes.map((scene) => ({ const serializedScenes: SerializedScene[] = project.scenes.map((scene) => ({
id: scene.id, id: scene.id,
name: scene.name, name: scene.name,
@@ -99,6 +103,7 @@ class StorageService {
id: project.metadata.id, id: project.metadata.id,
name: project.metadata.name, name: project.metadata.name,
thumbnail: project.metadata.thumbnail, thumbnail: project.metadata.thumbnail,
duration,
createdAt: project.metadata.createdAt.toISOString(), createdAt: project.metadata.createdAt.toISOString(),
updatedAt: project.metadata.updatedAt.toISOString(), updatedAt: project.metadata.updatedAt.toISOString(),
}, },
@@ -141,6 +146,9 @@ class StorageService {
id: serializedProject.metadata.id, id: serializedProject.metadata.id,
name: serializedProject.metadata.name, name: serializedProject.metadata.name,
thumbnail: serializedProject.metadata.thumbnail, thumbnail: serializedProject.metadata.thumbnail,
duration:
serializedProject.metadata.duration ??
getProjectDurationFromScenes({ scenes }),
createdAt: new Date(serializedProject.metadata.createdAt), createdAt: new Date(serializedProject.metadata.createdAt),
updatedAt: new Date(serializedProject.metadata.updatedAt), updatedAt: new Date(serializedProject.metadata.updatedAt),
}, },
@@ -177,6 +185,11 @@ class StorageService {
id: serializedProject.metadata.id, id: serializedProject.metadata.id,
name: serializedProject.metadata.name, name: serializedProject.metadata.name,
thumbnail: serializedProject.metadata.thumbnail, thumbnail: serializedProject.metadata.thumbnail,
duration:
serializedProject.metadata.duration ??
getProjectDurationFromScenes({
scenes: (serializedProject.scenes ?? []) as unknown as TScene[],
}),
createdAt: new Date(serializedProject.metadata.createdAt), createdAt: new Date(serializedProject.metadata.createdAt),
updatedAt: new Date(serializedProject.metadata.updatedAt), updatedAt: new Date(serializedProject.metadata.updatedAt),
})); }));
+5
View File
@@ -19,6 +19,7 @@ export interface TProjectMetadata {
id: string; id: string;
name: string; name: string;
thumbnail?: string; thumbnail?: string;
duration: number;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
} }
@@ -37,3 +38,7 @@ export interface TProject {
settings: TProjectSettings; settings: TProjectSettings;
version: number; version: number;
} }
export type TProjectSortKey = "createdAt" | "updatedAt" | "name" | "duration";
export type TSortOrder = "asc" | "desc";
export type TProjectSortOption = `${TProjectSortKey}-${TSortOrder}`;
+4 -9
View File
@@ -27,6 +27,7 @@
"@opencut/env": "workspace:*", "@opencut/env": "workspace:*",
"@opencut/ui": "workspace:*", "@opencut/ui": "workspace:*",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "^1.2.8", "@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-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=="], "@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-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-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=="], "@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-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-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=="], "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=="],
+66 -2
View File
@@ -85,7 +85,7 @@
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<div class="relative group"> <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> <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>
<div class="flex bg-white rounded-full border border-slate-200 p-1 shadow-sm"> <div class="flex bg-white rounded-full border border-slate-200 p-1 shadow-sm">
@@ -123,7 +123,7 @@
</div> </div>
<!-- Grid --> <!-- 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 --> <!-- Project Card 1 -->
<div class="project-card group rounded-[24px] overflow-hidden relative h-auto flex flex-col"> <div class="project-card group rounded-[24px] overflow-hidden relative h-auto flex flex-col">
@@ -232,6 +232,20 @@
</div> </div>
</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> </div>
</main> </main>
@@ -313,6 +327,56 @@
}); });
} }
syncSelectionState(); 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> </script>
</body> </body>
+216
View File
@@ -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>
+2 -2
View File
@@ -17,13 +17,13 @@ export function OcVideoIcon({
<title>Video</title> <title>Video</title>
<path <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" 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" strokeWidth="1.5"
strokeLinecap="round" strokeLinecap="round"
/> />
<path <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" 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" strokeWidth="1.5"
strokeLinecap="round" strokeLinecap="round"
/> />