we're basically done

This commit is contained in:
Maze Winther
2026-01-27 16:39:59 +01:00
parent af3100950e
commit 1f8391487e
70 changed files with 1230 additions and 699 deletions
+11
View File
@@ -0,0 +1,11 @@
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"D","location":"use-keybindings.ts:16","message":"keybindings_listener_mount","data":{"keybindingsEnabled":false,"isRecording":false,"keybindingsCount":24},"timestamp":1769522187702}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"D","location":"use-keybindings.ts:16","message":"keybindings_listener_mount","data":{"keybindingsEnabled":false,"isRecording":false,"keybindingsCount":24},"timestamp":1769522187709}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"D","location":"use-keybindings.ts:16","message":"keybindings_listener_mount","data":{"keybindingsEnabled":true,"isRecording":false,"keybindingsCount":24},"timestamp":1769522187729}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"E","location":"use-keybindings.ts:23","message":"keydown_received","data":{"key":" ","code":"Space","ctrlKey":false,"metaKey":false,"altKey":false,"shiftKey":false,"targetTag":"BODY"},"timestamp":1769522205815}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"D","location":"use-keybindings.ts:107","message":"invoking_action","data":{"boundAction":"toggle-play","binding":"space"},"timestamp":1769522205816}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"B","location":"use-keybindings.ts:70","message":"binding_computed","data":{"binding":"space"},"timestamp":1769522205816}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"D","location":"registry.ts:63","message":"invoke_action_dispatch","data":{"action":"toggle-play","handlersCount":1,"hasHandlers":true,"trigger":"keypress"},"timestamp":1769522205817}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"E","location":"use-keybindings.ts:23","message":"keydown_received","data":{"key":" ","code":"Space","ctrlKey":false,"metaKey":false,"altKey":false,"shiftKey":false,"targetTag":"BODY"},"timestamp":1769522206526}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"D","location":"use-keybindings.ts:107","message":"invoking_action","data":{"boundAction":"toggle-play","binding":"space"},"timestamp":1769522206526}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"D","location":"registry.ts:63","message":"invoke_action_dispatch","data":{"action":"toggle-play","handlersCount":1,"hasHandlers":true,"trigger":"keypress"},"timestamp":1769522206526}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"B","location":"use-keybindings.ts:70","message":"binding_computed","data":{"binding":"space"},"timestamp":1769522206526}
+9 -1
View File
@@ -113,6 +113,7 @@ index.ts
renderer: RendererManager renderer: RendererManager
save: SaveManager save: SaveManager
audio: AudioManager audio: AudioManager
selection: SelectionManager
static getInstance(): EditorCore static getInstance(): EditorCore
static reset(): void static reset(): void
} }
@@ -925,6 +926,13 @@ element-utils.ts
startTime: number; startTime: number;
buffer?: AudioBuffer; buffer?: AudioBuffer;
}): CreateLibraryAudioElement }): CreateLibraryAudioElement
export function getElementsAtTime({
tracks,
time,
}: {
tracks: TimelineTrack[];
time: number;
}): { trackId: string; elementId: string }[]
index.ts index.ts
export function calculateTotalDuration({ export function calculateTotalDuration({
@@ -1692,7 +1700,7 @@ platform.ts
export function getPlatformAlternateKey(): string export function getPlatformAlternateKey(): string
export function isAppleDevice(): boolean export function isAppleDevice(): boolean
strings.ts string.ts
export function capitalizeFirstLetter({ string }: { string: string }) export function capitalizeFirstLetter({ string }: { string: string })
export function uppercase({ string }: { string: string }) export function uppercase({ string }: { string: string })
+1 -1
View File
@@ -42,7 +42,7 @@ export default async function BlogPage() {
function BlogPostItem({ post }: { post: Post }) { function BlogPostItem({ post }: { post: Post }) {
return ( return (
<Link href={`/blog/${post.slug}`}> <Link href={`/blog/${post.slug}`}>
<div className="flex h-auto w-full items-center justify-between py-6 opacity-100 transition-opacity hover:opacity-75"> <div className="flex h-auto w-full items-center justify-between py-6 opacity-100 hover:opacity-75">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<h2 className="text-xl font-semibold">{post.title}</h2> <h2 className="text-xl font-semibold">{post.title}</h2>
<p className="text-muted-foreground">{post.description}</p> <p className="text-muted-foreground">{post.description}</p>
+4 -6
View File
@@ -180,7 +180,7 @@ function AllContributorsSection({
href={contributor.html_url} href={contributor.html_url}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="opacity-100 transition-opacity hover:opacity-70" className="opacity-100 hover:opacity-70"
> >
<div className="flex flex-col items-center gap-2 p-2"> <div className="flex flex-col items-center gap-2 p-2">
<Avatar className="size-16"> <Avatar className="size-16">
@@ -214,7 +214,7 @@ function ExternalToolsSection() {
<p className="text-muted-foreground">Tools we use to build OpenCut</p> <p className="text-muted-foreground">Tools we use to build OpenCut</p>
</div> </div>
<div className="mx-auto grid max-w-4xl grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3"> <div className="mx-auto grid max-w-4xl grid-cols-1 gap-6 sm:grid-cols-2">
{EXTERNAL_TOOLS.map((tool, index) => ( {EXTERNAL_TOOLS.map((tool, index) => (
<Link <Link
key={tool.url} key={tool.url}
@@ -224,10 +224,8 @@ function ExternalToolsSection() {
style={{ animationDelay: `${index * 100}ms` }} style={{ animationDelay: `${index * 100}ms` }}
> >
<Card className="h-full"> <Card className="h-full">
<CardContent className="flex h-full flex-col gap-4 p-6 text-center"> <CardContent className="flex items-center justify-center h-full flex-col gap-4 p-6 text-center">
<div className="bg-muted/50 mx-auto flex size-12 items-center justify-center rounded-full"> <tool.icon className="size-8" />
<tool.icon className="size-6" />
</div>
<div className="flex flex-1 flex-col gap-2"> <div className="flex flex-1 flex-col gap-2">
<h3 className="text-lg font-semibold">{tool.name}</h3> <h3 className="text-lg font-semibold">{tool.name}</h3>
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-sm">
+9 -9
View File
@@ -15,6 +15,7 @@
--popover: hsl(0, 0%, 100%); --popover: hsl(0, 0%, 100%);
--popover-foreground: hsl(0 0% 2%); --popover-foreground: hsl(0 0% 2%);
--primary: hsl(203, 100%, 50%); --primary: hsl(203, 100%, 50%);
--primary-hover: hsl(203, 100%, 45%);
--primary-foreground: hsl(0, 0%, 100%); --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%);
@@ -44,9 +45,6 @@
--sidebar-ring: hsl(0 0% 16.9%); --sidebar-ring: hsl(0 0% 16.9%);
--panel-background: hsl(216 13% 96%); --panel-background: hsl(216 13% 96%);
--panel-accent: hsl(216, 8%, 90%); --panel-accent: hsl(216, 8%, 90%);
--radius: 1rem;
--sidebar: hsl(0 0% 98%); --sidebar: hsl(0 0% 98%);
} }
.dark { .dark {
@@ -56,7 +54,8 @@
--card-foreground: hsl(0 0% 89%); --card-foreground: hsl(0 0% 89%);
--popover: hsl(0 0% 14.9%); --popover: hsl(0 0% 14.9%);
--popover-foreground: hsl(0 0% 98%); --popover-foreground: hsl(0 0% 98%);
--primary: hsl(205, 84%, 53%); --primary: hsl(203, 100%, 50%);
--primary-hover: hsl(203, 100%, 45%);
--primary-foreground: hsl(0 0% 9%); --primary-foreground: hsl(0 0% 9%);
--secondary: hsl(0 0% 14.9%); --secondary: hsl(0 0% 14.9%);
--secondary-foreground: hsl(0 0% 98%); --secondary-foreground: hsl(0 0% 98%);
@@ -124,15 +123,16 @@
--font-sans: var(--font-inter), sans-serif; --font-sans: var(--font-inter), sans-serif;
/* Font sizes */ /* Font sizes */
--text-base: 0.95rem; --text-xl: 1.20rem;
--text-base: 0.92rem;
--text-base--line-height: calc(1.5 / 0.95); --text-base--line-height: calc(1.5 / 0.95);
--text-xs: 0.75rem; --text-xs: 0.75rem;
--text-xs--line-height: calc(1 / 0.8); --text-xs--line-height: calc(1 / 0.8);
/* Border radius */ /* Border radius */
--radius-lg: var(--radius); --radius-lg: 0.82rem;
--radius-md: calc(var(--radius) - 2px); --radius-md: 0.65rem;
--radius-sm: calc(var(--radius) - 8px); --radius-sm: 0.35rem;
/* Palette mapped to root design tokens */ /* Palette mapped to root design tokens */
--color-background: var(--background); --color-background: var(--background);
@@ -146,7 +146,7 @@
--color-primary: var(--primary); --color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground); --color-primary-foreground: var(--primary-foreground);
--color-primary-hover: var(--primary-hover);
--color-secondary: var(--secondary); --color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground); --color-secondary-foreground: var(--secondary-foreground);
+50 -24
View File
@@ -110,7 +110,11 @@ export default function ProjectsPage() {
} }
> >
{projectsToDisplay.map((project) => ( {projectsToDisplay.map((project) => (
<ProjectItem key={project.id} project={project} /> <ProjectItem
key={project.id}
project={project}
allProjectIds={projectsToDisplay.map((p) => p.id)}
/>
))} ))}
</div> </div>
)} )}
@@ -144,10 +148,10 @@ function ProjectsHeader() {
</BreadcrumbList> </BreadcrumbList>
</Breadcrumb> </Breadcrumb>
<div className="hidden md:flex rounded-full border p-1 h-10"> <div className="hidden md:flex rounded-md border p-1 h-10">
<button <button
type="button" type="button"
className={`p-2 rounded-full cursor-pointer ${isHydrated && viewMode === "grid" ? "bg-accent/75" : ""}`} className={`p-2 rounded-sm cursor-pointer ${isHydrated && viewMode === "grid" ? "bg-accent/75" : ""}`}
onClick={() => setViewMode({ viewMode: "grid" })} onClick={() => setViewMode({ viewMode: "grid" })}
onKeyDown={(event) => onKeyDown={(event) =>
event.key === "Enter" && setViewMode({ viewMode: "grid" }) event.key === "Enter" && setViewMode({ viewMode: "grid" })
@@ -159,7 +163,7 @@ function ProjectsHeader() {
</button> </button>
<button <button
type="button" type="button"
className={`p-2 rounded-full cursor-pointer ${isHydrated && viewMode === "list" ? "bg-accent/75" : ""}`} className={`p-2 rounded-sm cursor-pointer ${isHydrated && viewMode === "list" ? "bg-accent/75" : ""}`}
onClick={() => setViewMode({ viewMode: "list" })} onClick={() => setViewMode({ viewMode: "list" })}
onKeyDown={(event) => onKeyDown={(event) =>
event.key === "Enter" && setViewMode({ viewMode: "list" }) event.key === "Enter" && setViewMode({ viewMode: "list" })
@@ -219,7 +223,7 @@ function ProjectsToolbar({ projectIds }: { projectIds: string[] }) {
}; };
return ( return (
<div className="sticky top-16 z-10 flex items-center justify-between px-6 h-14 bg-background"> <div className="sticky top-16 z-10 flex items-center justify-between px-6 h-14 pt-2 bg-background">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Label <Label
className="flex items-center gap-3 cursor-pointer px-2" className="flex items-center gap-3 cursor-pointer px-2"
@@ -298,7 +302,7 @@ function ProjectsToolbar({ projectIds }: { projectIds: string[] }) {
</Button> </Button>
</div> </div>
</div> </div>
{selectedProjectCount > 1 ? <ProjectActions /> : null} {selectedProjectCount > 0 ? <ProjectActions /> : null}
</div> </div>
); );
} }
@@ -518,20 +522,24 @@ function NewProjectButton() {
return ( return (
<Button <Button
variant="primary"
size="lg" size="lg"
className="flex px-5 md:px-6" className="flex px-5 md:px-6"
onClick={handleCreateProject} onClick={handleCreateProject}
> >
<HugeiconsIcon icon={PlusSignIcon} />
<span className="text-sm font-medium hidden md:block">New project</span> <span className="text-sm font-medium hidden md:block">New project</span>
<span className="text-sm font-medium block md:hidden">New</span> <span className="text-sm font-medium block md:hidden">New</span>
</Button> </Button>
); );
} }
function ProjectItem({ project }: { project: TProjectMetadata }) { function ProjectItem({
const { selectedProjectIds, viewMode, setProjectSelected } = project,
allProjectIds,
}: {
project: TProjectMetadata;
allProjectIds: string[];
}) {
const { selectedProjectIds, viewMode, setProjectSelected, selectProjectRange } =
useProjectsStore(); useProjectsStore();
const selectedProjectIdSet = new Set(selectedProjectIds); const selectedProjectIdSet = new Set(selectedProjectIds);
const isSelected = selectedProjectIdSet.has(project.id); const isSelected = selectedProjectIdSet.has(project.id);
@@ -541,6 +549,20 @@ function ProjectItem({ project }: { project: TProjectMetadata }) {
const isMultiSelect = selectedProjectCount > 1; const isMultiSelect = selectedProjectCount > 1;
const isGridView = viewMode === "grid"; const isGridView = viewMode === "grid";
const handleCheckboxChange = ({
checked,
shiftKey,
}: {
checked: boolean;
shiftKey: boolean;
}) => {
if (shiftKey && checked) {
selectProjectRange({ projectId: project.id, allProjectIds });
return;
}
setProjectSelected({ projectId: project.id, isSelected: checked });
};
const gridContent = ( const gridContent = (
<Card className="bg-background overflow-hidden border-none p-0"> <Card className="bg-background overflow-hidden border-none p-0">
<div className="bg-muted relative aspect-video"> <div className="bg-muted relative aspect-video">
@@ -567,7 +589,7 @@ function ProjectItem({ project }: { project: TProjectMetadata }) {
</div> </div>
<CardContent className="flex flex-col gap-2 px-0 pt-4"> <CardContent className="flex flex-col gap-2 px-0 pt-4">
<h3 className="group-hover:text-foreground/90 line-clamp-2 text-sm leading-snug font-medium transition-colors"> <h3 className="group-hover:text-foreground/90 line-clamp-2 text-sm leading-snug font-medium">
{project.name} {project.name}
</h3> </h3>
<div className="text-muted-foreground flex items-center gap-1.5 text-sm"> <div className="text-muted-foreground flex items-center gap-1.5 text-sm">
@@ -595,7 +617,7 @@ function ProjectItem({ project }: { project: TProjectMetadata }) {
)} )}
</div> </div>
<h3 className="group-hover:text-foreground/90 text-sm font-medium truncate flex-1 min-w-0 transition-colors"> <h3 className="group-hover:text-foreground/90 text-sm font-medium truncate flex-1 min-w-0">
{project.name} {project.name}
</h3> </h3>
@@ -617,12 +639,14 @@ function ProjectItem({ project }: { project: TProjectMetadata }) {
> >
<Checkbox <Checkbox
checked={isSelected} checked={isSelected}
onCheckedChange={(checked) => onMouseDown={(event) => event.preventDefault()}
setProjectSelected({ onClick={(event) => {
projectId: project.id, handleCheckboxChange({
isSelected: checked === true, checked: !isSelected,
}) shiftKey: event.shiftKey,
} });
}}
onCheckedChange={() => {}}
className="size-5 shrink-0" className="size-5 shrink-0"
/> />
@@ -657,12 +681,14 @@ function ProjectItem({ project }: { project: TProjectMetadata }) {
<> <>
<Checkbox <Checkbox
checked={isSelected} checked={isSelected}
onCheckedChange={(checked) => onMouseDown={(event) => event.preventDefault()}
setProjectSelected({ onClick={(event) => {
projectId: project.id, handleCheckboxChange({
isSelected: checked === true, checked: !isSelected,
}) shiftKey: event.shiftKey,
} });
}}
onCheckedChange={() => {}}
className={`absolute z-10 size-5 top-3 left-3 ${ className={`absolute z-10 size-5 top-3 left-3 ${
isSelected || isDropdownOpen isSelected || isDropdownOpen
? "opacity-100" ? "opacity-100"
+41 -1
View File
@@ -10,6 +10,7 @@ interface ProjectsState {
sortOrder: "asc" | "desc"; sortOrder: "asc" | "desc";
viewMode: ProjectsViewMode; viewMode: ProjectsViewMode;
selectedProjectIds: string[]; selectedProjectIds: string[];
lastSelectedProjectId: string | null;
isHydrated: boolean; isHydrated: boolean;
setIsHydrated: ({ isHydrated }: { isHydrated: boolean }) => void; setIsHydrated: ({ isHydrated }: { isHydrated: boolean }) => void;
setSearchQuery: ({ query }: { query: string }) => void; setSearchQuery: ({ query }: { query: string }) => void;
@@ -26,6 +27,13 @@ interface ProjectsState {
projectId: string; projectId: string;
isSelected: boolean; isSelected: boolean;
}) => void; }) => void;
selectProjectRange: ({
projectId,
allProjectIds,
}: {
projectId: string;
allProjectIds: string[];
}) => void;
} }
const getNextSelectedProjectIds = ({ const getNextSelectedProjectIds = ({
@@ -56,6 +64,7 @@ export const useProjectsStore = create<ProjectsState>()(
sortOrder: "desc", sortOrder: "desc",
viewMode: "grid", viewMode: "grid",
selectedProjectIds: [], selectedProjectIds: [],
lastSelectedProjectId: null,
isHydrated: false, isHydrated: false,
setIsHydrated: ({ isHydrated }) => set({ isHydrated }), setIsHydrated: ({ isHydrated }) => set({ isHydrated }),
setSearchQuery: ({ query }) => set({ searchQuery: query }), setSearchQuery: ({ query }) => set({ searchQuery: query }),
@@ -68,7 +77,8 @@ export const useProjectsStore = create<ProjectsState>()(
setViewMode: ({ viewMode }) => set({ viewMode }), setViewMode: ({ viewMode }) => set({ viewMode }),
setSelectedProjects: ({ projectIds }) => setSelectedProjects: ({ projectIds }) =>
set({ selectedProjectIds: projectIds }), set({ selectedProjectIds: projectIds }),
clearSelectedProjects: () => set({ selectedProjectIds: [] }), clearSelectedProjects: () =>
set({ selectedProjectIds: [], lastSelectedProjectId: null }),
setProjectSelected: ({ projectId, isSelected }) => setProjectSelected: ({ projectId, isSelected }) =>
set((state) => ({ set((state) => ({
selectedProjectIds: getNextSelectedProjectIds({ selectedProjectIds: getNextSelectedProjectIds({
@@ -76,7 +86,37 @@ export const useProjectsStore = create<ProjectsState>()(
projectId, projectId,
isSelected, isSelected,
}), }),
lastSelectedProjectId: isSelected ? projectId : state.lastSelectedProjectId,
})), })),
selectProjectRange: ({ projectId, allProjectIds }) =>
set((state) => {
const anchorId = state.lastSelectedProjectId;
if (!anchorId) {
return {
selectedProjectIds: [projectId],
lastSelectedProjectId: projectId,
};
}
const anchorIndex = allProjectIds.indexOf(anchorId);
const targetIndex = allProjectIds.indexOf(projectId);
if (anchorIndex === -1 || targetIndex === -1) {
return {
selectedProjectIds: [projectId],
lastSelectedProjectId: projectId,
};
}
const startIndex = Math.min(anchorIndex, targetIndex);
const endIndex = Math.max(anchorIndex, targetIndex);
const rangeIds = allProjectIds.slice(startIndex, endIndex + 1);
const merged = new Set([...state.selectedProjectIds, ...rangeIds]);
return {
selectedProjectIds: Array.from(merged),
};
}),
}), }),
{ {
name: "projects-view-mode", name: "projects-view-mode",
@@ -29,7 +29,7 @@ export function MediaDragOverlay({
return ( return (
<button <button
className="bg-foreground/5 hover:bg-foreground/10 flex size-full flex-col items-center justify-center gap-4 rounded-lg p-8 text-center transition-all duration-200" className="bg-foreground/5 hover:bg-foreground/10 flex size-full flex-col items-center justify-center gap-4 rounded-lg p-8 text-center"
type="button" type="button"
disabled={isProcessing || !onClick} disabled={isProcessing || !onClick}
onClick={(event) => handleClick({ event })} onClick={(event) => handleClick({ event })}
@@ -50,7 +50,7 @@ export function MediaDragOverlay({
<div className="w-full max-w-xs"> <div className="w-full max-w-xs">
<div className="bg-muted/50 h-2 w-full rounded-full"> <div className="bg-muted/50 h-2 w-full rounded-full">
<div <div
className="bg-primary h-2 rounded-full transition-all duration-300" className="bg-primary h-2 rounded-full"
style={{ width: `${progress}%` }} style={{ width: `${progress}%` }}
/> />
</div> </div>
@@ -106,7 +106,7 @@ function FadeOverlay({
return ( return (
<div <div
className={cn( className={cn(
"pointer-events-none absolute right-0 left-0 h-6 transition-opacity duration-200", "pointer-events-none absolute right-0 left-0 h-6",
direction === "top" && show direction === "top" && show
? "from-panel top-0 bg-gradient-to-b to-transparent" ? "from-panel top-0 bg-gradient-to-b to-transparent"
: "from-panel bottom-0 bg-gradient-to-t to-transparent", : "from-panel bottom-0 bg-gradient-to-t to-transparent",
@@ -196,24 +196,11 @@ export function MediaView() {
<input {...fileInputProps} /> <input {...fileInputProps} />
<div <div
className={`relative flex h-full flex-col gap-1 transition-colors ${isDragOver ? "bg-accent/30" : ""}`} className={`relative flex h-full flex-col gap-1 ${isDragOver ? "bg-accent/30" : ""}`}
{...dragProps} {...dragProps}
> >
<div className="bg-panel p-3 pb-2"> <div className="bg-panel py-2 px-4 flex items-center justify-between border-b">
<div className="flex items-center gap-2"> <span className="text-muted-foreground text-sm">Assets</span>
<Button
variant="foreground"
onClick={openFilePicker}
disabled={isProcessing}
className="w-full"
>
{isProcessing ? (
<HugeiconsIcon icon={Loading03Icon} className="animate-spin" />
) : (
<HugeiconsIcon icon={CloudUploadIcon} />
)}
<span>Upload</span>
</Button>
<div className="flex items-center gap-0"> <div className="flex items-center gap-0">
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
@@ -265,9 +252,7 @@ export function MediaView() {
currentSortOrder={sortOrder} currentSortOrder={sortOrder}
onSort={({ key }) => { onSort={({ key }) => {
if (sortBy === key) { if (sortBy === key) {
setSortOrder( setSortOrder(sortOrder === "asc" ? "desc" : "asc");
sortOrder === "asc" ? "desc" : "asc",
);
} else { } else {
setSortBy(key); setSortBy(key);
setSortOrder("asc"); setSortOrder("asc");
@@ -281,9 +266,7 @@ export function MediaView() {
currentSortOrder={sortOrder} currentSortOrder={sortOrder}
onSort={({ key }) => { onSort={({ key }) => {
if (sortBy === key) { if (sortBy === key) {
setSortOrder( setSortOrder(sortOrder === "asc" ? "desc" : "asc");
sortOrder === "asc" ? "desc" : "asc",
);
} else { } else {
setSortBy(key); setSortBy(key);
setSortOrder("asc"); setSortOrder("asc");
@@ -297,9 +280,7 @@ export function MediaView() {
currentSortOrder={sortOrder} currentSortOrder={sortOrder}
onSort={({ key }) => { onSort={({ key }) => {
if (sortBy === key) { if (sortBy === key) {
setSortOrder( setSortOrder(sortOrder === "asc" ? "desc" : "asc");
sortOrder === "asc" ? "desc" : "asc",
);
} else { } else {
setSortBy(key); setSortBy(key);
setSortOrder("asc"); setSortOrder("asc");
@@ -313,9 +294,7 @@ export function MediaView() {
currentSortOrder={sortOrder} currentSortOrder={sortOrder}
onSort={({ key }) => { onSort={({ key }) => {
if (sortBy === key) { if (sortBy === key) {
setSortOrder( setSortOrder(sortOrder === "asc" ? "desc" : "asc");
sortOrder === "asc" ? "desc" : "asc",
);
} else { } else {
setSortBy(key); setSortBy(key);
setSortOrder("asc"); setSortOrder("asc");
@@ -333,7 +312,16 @@ export function MediaView() {
</Tooltip> </Tooltip>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
</div> <Button
variant="outline"
onClick={openFilePicker}
disabled={isProcessing}
size="sm"
className="items-center justify-center gap-1.5 ml-1.5"
>
<HugeiconsIcon icon={CloudUploadIcon} />
Upload
</Button>
</div> </div>
</div> </div>
@@ -519,7 +519,7 @@ function AudioItem({ sound, isPlaying, onPlay }: AudioItemProps) {
}; };
return ( return (
<div className="group flex items-center gap-3 opacity-100 transition-opacity hover:opacity-75"> <div className="group flex items-center gap-3 opacity-100 hover:opacity-75">
<button <button
type="button" type="button"
className="flex min-w-0 flex-1 items-center gap-3 text-left" className="flex min-w-0 flex-1 items-center gap-3 text-left"
@@ -1,12 +1,16 @@
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
DialogBody,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Alert, AlertDescription, AlertTitle } from "../ui/alert";
import { Label } from "../ui/label";
import { Input } from "../ui/input";
export function DeleteProjectDialog({ export function DeleteProjectDialog({
isOpen, isOpen,
@@ -45,25 +49,34 @@ export function DeleteProjectDialog({
`Delete ${count} projects?` `Delete ${count} projects?`
)} )}
</DialogTitle> </DialogTitle>
<DialogDescription>
{isSingle
? "Are you sure you want to delete this project? This action cannot be undone."
: `Are you sure you want to delete these ${count} projects? This action cannot be undone.`}
</DialogDescription>
</DialogHeader> </DialogHeader>
<DialogBody>
<Alert variant="destructive">
<AlertTitle>Warning</AlertTitle>
<AlertDescription>
This will permanently delete{" "}
{singleName ? `"${singleName}"` : `${count} projects`} and all
associated files.
</AlertDescription>
</Alert>
<div className="flex flex-col gap-3">
<Label className="text-xs font-semibold text-slate-500">
Type "DELETE" to confirm
</Label>
<Input
type="text"
placeholder="DELETE"
size="lg"
variant="destructive"
/>
</div>
</DialogBody>
<DialogFooter> <DialogFooter>
<Button <Button variant="outline" onClick={() => onOpenChange(false)}>
variant="text"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onOpenChange(false);
}}
>
Cancel Cancel
</Button> </Button>
<Button variant="destructive" onClick={onConfirm}> <Button variant="destructive" onClick={onConfirm}>
Delete Delete project
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -163,7 +163,7 @@ export function DraggableItem({
<div className="size-6 flex-shrink-0 overflow-hidden rounded-[0.35rem]"> <div className="size-6 flex-shrink-0 overflow-hidden rounded-[0.35rem]">
{preview} {preview}
</div> </div>
<span className="w-full flex-1 truncate text-sm">{name}</span> <span className="w-full flex-1 truncate text-sm text-left">{name}</span>
</button> </button>
</div> </div>
)} )}
@@ -142,7 +142,7 @@ export function EditableTimecode({
disabled={disabled} disabled={disabled}
className={cn( className={cn(
"text-primary cursor-pointer font-mono text-xs tabular-nums", "text-primary cursor-pointer font-mono text-xs tabular-nums",
"hover:bg-muted/50 -mx-1 px-1 transition-colors hover:rounded", "hover:bg-muted/50 -mx-1 px-1 hover:rounded",
disabled && "cursor-default hover:bg-transparent", disabled && "cursor-default hover:bg-transparent",
className, className,
)} )}
@@ -39,9 +39,9 @@ export function ExportButton() {
<button <button
type="button" type="button"
className={cn( className={cn(
"flex items-center gap-1.5 rounded-md bg-[#38BDF8] px-[0.12rem] py-[0.12rem] text-white transition-all duration-200", "flex items-center gap-1.5 rounded-md bg-[#38BDF8] px-[0.12rem] py-[0.12rem] text-white",
hasProject hasProject
? "cursor-pointer hover:brightness-95" ? "cursor-pointer hover:brightness-105"
: "cursor-not-allowed opacity-50", : "cursor-not-allowed opacity-50",
)} )}
onClick={hasProject ? handleExport : undefined} onClick={hasProject ? handleExport : undefined}
@@ -53,11 +53,11 @@ export function ExportButton() {
} }
}} }}
> >
<div className="relative flex items-center gap-1.5 rounded-[0.8rem] bg-linear-270 from-[#2567EC] to-[#37B6F7] px-4 py-1 shadow-[0_1px_3px_0px_rgba(0,0,0,0.65)]"> <div className="relative flex items-center gap-1.5 rounded-[0.6rem] bg-linear-270 from-[#2567EC] to-[#37B6F7] px-4 py-1 shadow-[0_1px_3px_0px_rgba(0,0,0,0.65)]">
<HugeiconsIcon icon={TransitionTopIcon} className="z-50 size-4" /> <HugeiconsIcon icon={TransitionTopIcon} className="z-50 size-4" />
<span className="z-50 text-[0.875rem]">Export</span> <span className="z-50 text-[0.875rem]">Export</span>
<div className="absolute top-0 left-0 z-10 flex size-full items-center justify-center rounded-[0.8rem] bg-linear-to-t from-white/0 to-white/50"> <div className="absolute top-0 left-0 z-10 flex size-full items-center justify-center rounded-[0.6rem] bg-linear-to-t from-white/0 to-white/50">
<div className="absolute top-[0.08rem] z-50 h-[calc(100%-2px)] w-[calc(100%-2px)] rounded-[0.8rem] bg-linear-270 from-[#2567EC] to-[#37B6F7]"></div> <div className="absolute top-[0.08rem] z-50 h-[calc(100%-2px)] w-[calc(100%-2px)] rounded-[0.6rem] bg-linear-270 from-[#2567EC] to-[#37B6F7]"></div>
</div> </div>
</div> </div>
</button> </button>
@@ -1,12 +1,15 @@
import { import {
Dialog, Dialog,
DialogBody,
DialogContent, DialogContent,
DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import type { TProjectMetadata } from "@/types/project"; import type { TProjectMetadata } from "@/types/project";
import { formatDate } from "@/utils/date"; import { formatDate } from "@/utils/date";
import { formatTimeCode } from "@/lib/time"; import { formatTimeCode } from "@/lib/time";
import { Button } from "../ui/button";
function InfoRow({ function InfoRow({
label, label,
@@ -16,7 +19,7 @@ function InfoRow({
value: string | React.ReactNode; value: string | React.ReactNode;
}) { }) {
return ( return (
<div className="flex justify-between items-center py-2 last:pb-0 border-b border-border/50 last:border-b-0"> <div className="flex justify-between items-center py-0 last:pb-0">
<span className="text-muted-foreground text-sm">{label}</span> <span className="text-muted-foreground text-sm">{label}</span>
<span className="text-sm font-medium">{value}</span> <span className="text-sm font-medium">{value}</span>
</div> </div>
@@ -42,14 +45,14 @@ export function ProjectInfoDialog({
return ( return (
<Dialog open={isOpen} onOpenChange={onOpenChange}> <Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent> <DialogContent onOpenAutoFocus={(event) => event.preventDefault()}>
<DialogHeader> <DialogHeader>
<DialogTitle className="truncate max-w-[350px]"> <DialogTitle className="truncate max-w-[350px]">
{project.name} {project.name}
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
<div className="flex flex-col"> <DialogBody className="flex flex-col">
<InfoRow label="Duration" value={durationFormatted} /> <InfoRow label="Duration" value={durationFormatted} />
<InfoRow <InfoRow
label="Created" label="Created"
@@ -67,7 +70,13 @@ export function ProjectInfoDialog({
</code> </code>
} }
/> />
</div> </DialogBody>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Close
</Button>
<Button onClick={() => onOpenChange(false)}>Done</Button>
</DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );
@@ -74,10 +74,10 @@ export function PropertyGroup({
<PropertyItem direction="column" className={cn("gap-3", className)}> <PropertyItem direction="column" className={cn("gap-3", className)}>
<button <button
type="button" type="button"
className="flex items-center gap-1.5" className="flex items-center gap-1.5 cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)} onClick={() => setIsExpanded(!isExpanded)}
> >
<PropertyItemLabel className={cn("cursor-pointer", titleClassName)}> <PropertyItemLabel className={cn(titleClassName)}>
{title} {title}
</PropertyItemLabel> </PropertyItemLabel>
<HugeiconsIcon <HugeiconsIcon
@@ -19,7 +19,7 @@ import {
} from "./property-item"; } from "./property-item";
import { ColorPicker } from "@/components/ui/color-picker"; import { ColorPicker } from "@/components/ui/color-picker";
import { cn } from "@/utils/ui"; import { cn } from "@/utils/ui";
import { capitalizeFirstLetter, uppercase } from "@/utils/strings"; import { capitalizeFirstLetter, uppercase } from "@/utils/string";
import { clamp } from "@/utils/math"; import { clamp } from "@/utils/math";
import { HugeiconsIcon } from "@hugeicons/react"; import { HugeiconsIcon } from "@hugeicons/react";
import { LayoutGridIcon } from "@hugeicons/core-free-icons"; import { LayoutGridIcon } from "@hugeicons/core-free-icons";
@@ -387,7 +387,8 @@ export function TextProperties({
} }
className="bg-panel-accent size-9 overflow-hidden rounded-full p-0" className="bg-panel-accent size-9 overflow-hidden rounded-full p-0"
> >
<HugeiconsIcon icon={LayoutGridIcon} <HugeiconsIcon
icon={LayoutGridIcon}
className={cn( className={cn(
"text-foreground", "text-foreground",
element.backgroundColor === "transparent" && element.backgroundColor === "transparent" &&
@@ -1,6 +1,5 @@
"use client"; "use client";
import { Keyboard } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
@@ -11,8 +10,8 @@ import { useKeybindingsStore } from "@/stores/keybindings-store";
import { Button } from "../ui/button"; import { Button } from "../ui/button";
import { import {
Dialog, Dialog,
DialogBody,
DialogContent, DialogContent,
DialogDescription,
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
@@ -107,19 +106,12 @@ export function ShortcutsDialog({
return ( return (
<Dialog open={isOpen} onOpenChange={onOpenChange}> <Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[80vh] max-w-2xl flex-col p-0"> <DialogContent className="flex max-h-[80vh] max-w-2xl flex-col p-0">
<DialogHeader className="flex-shrink-0 gap-2 p-6 pb-0"> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle>Keyboard shortcuts</DialogTitle>
<Keyboard className="size-5" />
Keyboard shortcuts
</DialogTitle>
<DialogDescription>
Speed up your video editing workflow with these keyboard shortcuts.
Click any shortcut key to edit it.
</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="scrollbar-thin flex-grow overflow-y-auto"> <DialogBody className="scrollbar-thin flex-grow overflow-y-auto">
<div className="flex flex-col gap-6 p-6 pt-2"> <div className="flex flex-col gap-6">
{categories.map((category) => ( {categories.map((category) => (
<div key={category} className="flex flex-col gap-1"> <div key={category} className="flex flex-col gap-1">
<h3 className="text-muted-foreground text-xs font-medium tracking-wide uppercase"> <h3 className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
@@ -142,9 +134,9 @@ export function ShortcutsDialog({
</div> </div>
))} ))}
</div> </div>
</div> </DialogBody>
<DialogFooter className="p-4 pt-0"> <DialogFooter>
<Button size="sm" variant="destructive" onClick={resetToDefaults}> <Button variant="destructive" onClick={resetToDefaults}>
Reset to default Reset to default
</Button> </Button>
</DialogFooter> </DialogFooter>
@@ -166,7 +166,7 @@ export function AudioWaveform({
)} )}
<div <div
ref={waveformRef} ref={waveformRef}
className={`w-full transition-opacity duration-200 ${isLoading ? "opacity-0" : "opacity-100"}`} className={`w-full ${isLoading ? "opacity-0" : "opacity-100"}`}
style={{ height }} style={{ height }}
/> />
</div> </div>
@@ -3,6 +3,7 @@
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { import {
Delete02Icon, Delete02Icon,
TaskAdd02Icon,
ViewIcon, ViewIcon,
ViewOffSlashIcon, ViewOffSlashIcon,
VolumeHighIcon, VolumeHighIcon,
@@ -36,6 +37,7 @@ import {
canTracktHaveAudio, canTracktHaveAudio,
canTrackBeHidden, canTrackBeHidden,
getTimelineZoomMin, getTimelineZoomMin,
isMainTrack,
} from "@/lib/timeline"; } from "@/lib/timeline";
import { TimelineToolbar } from "./timeline-toolbar"; import { TimelineToolbar } from "./timeline-toolbar";
import { useScrollSync } from "@/hooks/timeline/use-scroll-sync"; import { useScrollSync } from "@/hooks/timeline/use-scroll-sync";
@@ -48,6 +50,7 @@ import { useTimelineStore } from "@/stores/timeline-store";
import { useEditor } from "@/hooks/use-editor"; import { useEditor } from "@/hooks/use-editor";
import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead"; import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead";
import { DragLine } from "./drag-line"; import { DragLine } from "./drag-line";
import { invokeAction } from "@/lib/actions";
export function Timeline() { export function Timeline() {
const tracksContainerHeight = { min: 0, max: 800 }; const tracksContainerHeight = { min: 0, max: 800 };
@@ -181,7 +184,7 @@ export function Timeline() {
return ( return (
<section <section
className={ className={
"bg-panel relative flex h-full flex-col overflow-hidden rounded-sm transition-colors duration-200" "bg-panel relative flex h-full flex-col overflow-hidden rounded-sm"
} }
{...dragProps} {...dragProps}
aria-label="Timeline" aria-label="Timeline"
@@ -267,6 +270,10 @@ export function Timeline() {
}} }}
> >
<div className="flex min-w-0 flex-1 items-center justify-end gap-2"> <div className="flex min-w-0 flex-1 items-center justify-end gap-2">
{process.env.NODE_ENV === "development" &&
isMainTrack(track) && (
<div className="bg-red-500 size-1.5 rounded-full" />
)}
{canTracktHaveAudio(track) && ( {canTracktHaveAudio(track) && (
<TrackToggleIcon <TrackToggleIcon
isOff={track.muted} isOff={track.muted}
@@ -281,7 +288,6 @@ export function Timeline() {
} }
/> />
)} )}
{canTrackBeHidden(track) && ( {canTrackBeHidden(track) && (
<TrackToggleIcon <TrackToggleIcon
isOff={track.hidden} isOff={track.hidden}
@@ -296,7 +302,6 @@ export function Timeline() {
} }
/> />
)} )}
<TrackIcon track={track} /> <TrackIcon track={track} />
</div> </div>
</div> </div>
@@ -397,6 +402,15 @@ export function Timeline() {
</div> </div>
</ContextMenuTrigger> </ContextMenuTrigger>
<ContextMenuContent className="z-200 w-40"> <ContextMenuContent className="z-200 w-40">
<ContextMenuItem
icon={<HugeiconsIcon icon={TaskAdd02Icon} />}
onClick={(e) => {
e.stopPropagation();
invokeAction("paste-copied");
}}
>
Paste elements
</ContextMenuItem>
<ContextMenuItem <ContextMenuItem
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
@@ -1,17 +1,5 @@
"use client"; "use client";
import {
Scissors,
Trash2,
Copy,
Search,
RefreshCw,
EyeOff,
Eye,
Volume2,
VolumeX,
ArrowUpDown,
} from "lucide-react";
import { useEditor } from "@/hooks/use-editor"; import { useEditor } from "@/hooks/use-editor";
import { useAssetsPanelStore } from "@/stores/assets-panel-store"; import { useAssetsPanelStore } from "@/stores/assets-panel-store";
import AudioWaveform from "./audio-waveform"; import AudioWaveform from "./audio-waveform";
@@ -39,9 +27,35 @@ import type {
} from "@/types/timeline"; } from "@/types/timeline";
import type { MediaAsset } from "@/types/assets"; import type { MediaAsset } from "@/types/assets";
import { mediaSupportsAudio } from "@/lib/media/media-utils"; import { mediaSupportsAudio } from "@/lib/media/media-utils";
import { type TAction, invokeAction } from "@/lib/actions"; import { getActionDefinition, type TAction, invokeAction } from "@/lib/actions";
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection"; import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
import Image from "next/image"; import Image from "next/image";
import {
ScissorIcon,
Delete02Icon,
Copy01Icon,
ViewIcon,
ViewOffSlashIcon,
VolumeHighIcon,
VolumeOffIcon,
VolumeMute02Icon,
Search01Icon,
Exchange01Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { uppercase } from "@/utils/string";
import type { ComponentProps } from "react";
function getDisplayShortcut(action: TAction) {
const { defaultShortcuts } = getActionDefinition(action);
if (!defaultShortcuts?.length) {
return "";
}
return uppercase({
string: defaultShortcuts[0].replace("+", " "),
});
}
interface TimelineElementProps { interface TimelineElementProps {
element: TimelineElementType; element: TimelineElementType;
@@ -112,17 +126,6 @@ export function TimelineElement({
displayedDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; displayedDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
const elementLeft = displayedStartTime * 50 * zoomLevel; const elementLeft = displayedStartTime * 50 * zoomLevel;
const handleAction = ({
action,
event,
}: {
action: TAction;
event: React.MouseEvent;
}) => {
event.stopPropagation();
invokeAction(action);
};
const handleRevealInMedia = ({ event }: { event: React.MouseEvent }) => { const handleRevealInMedia = ({ event }: { event: React.MouseEvent }) => {
event.stopPropagation(); event.stopPropagation();
if (hasMediaId(element)) { if (hasMediaId(element)) {
@@ -162,31 +165,16 @@ export function TimelineElement({
/> />
</div> </div>
</ContextMenuTrigger> </ContextMenuTrigger>
<ContextMenuContent className="z-200"> <ContextMenuContent className="z-200 w-64">
<ContextMenuItem <ActionMenuItem action="split" icon={<HugeiconsIcon icon={ScissorIcon} />}>
onClick={(event) => handleAction({ action: "split-selected", event })} Split
> </ActionMenuItem>
<Scissors className="mr-2 size-4" /> <CopyMenuItem />
{selectedElements.length > 1 && isCurrentElementSelected
? `Split ${selectedElements.length} elements at playhead`
: "Split at playhead"}
</ContextMenuItem>
<CopyMenuItem
isMultipleSelected={selectedElements.length > 1}
isCurrentElementSelected={isCurrentElementSelected}
selectedCount={selectedElements.length}
onClick={(event) => handleAction({ action: "copy-selected", event })}
/>
{canElementHaveAudio(element) && hasAudio && ( {canElementHaveAudio(element) && hasAudio && (
<MuteMenuItem <MuteMenuItem
element={element}
isMultipleSelected={selectedElements.length > 1} isMultipleSelected={selectedElements.length > 1}
isCurrentElementSelected={isCurrentElementSelected} isCurrentElementSelected={isCurrentElementSelected}
isMuted={isMuted} isMuted={isMuted}
selectedCount={selectedElements.length}
onClick={(event) =>
handleAction({ action: "toggle-elements-muted-selected", event })
}
/> />
)} )}
{canElementBeHidden(element) && ( {canElementBeHidden(element) && (
@@ -194,36 +182,26 @@ export function TimelineElement({
element={element} element={element}
isMultipleSelected={selectedElements.length > 1} isMultipleSelected={selectedElements.length > 1}
isCurrentElementSelected={isCurrentElementSelected} isCurrentElementSelected={isCurrentElementSelected}
selectedCount={selectedElements.length}
onClick={(event) =>
handleAction({
action: "toggle-elements-visibility-selected",
event,
})
}
/> />
)} )}
{selectedElements.length === 1 && ( {selectedElements.length === 1 && (
<ContextMenuItem <ActionMenuItem action="duplicate-selected" icon={<HugeiconsIcon icon={Copy01Icon} />}>
onClick={(event) => Duplicate
handleAction({ action: "duplicate-selected", event }) </ActionMenuItem>
}
>
<Copy className="mr-2 size-4" />
Duplicate {element.type === "text" ? "text" : "clip"}
</ContextMenuItem>
)} )}
{selectedElements.length === 1 && hasMediaId(element) && ( {selectedElements.length === 1 && hasMediaId(element) && (
<> <>
<ContextMenuItem <ContextMenuItem
icon={<HugeiconsIcon icon={Search01Icon} />}
onClick={(event) => handleRevealInMedia({ event })} onClick={(event) => handleRevealInMedia({ event })}
> >
<Search className="mr-2 size-4" /> Reveal media
Reveal in media
</ContextMenuItem> </ContextMenuItem>
<ContextMenuItem disabled> <ContextMenuItem
<RefreshCw className="mr-2 size-4" /> icon={<HugeiconsIcon icon={Exchange01Icon} />}
Replace clip (Coming soon) disabled
>
Replace media
</ContextMenuItem> </ContextMenuItem>
</> </>
)} )}
@@ -233,9 +211,6 @@ export function TimelineElement({
isCurrentElementSelected={isCurrentElementSelected} isCurrentElementSelected={isCurrentElementSelected}
elementType={element.type} elementType={element.type}
selectedCount={selectedElements.length} selectedCount={selectedElements.length}
onClick={(event) =>
handleAction({ action: "delete-selected", event })
}
/> />
</ContextMenuContent> </ContextMenuContent>
</ContextMenu> </ContextMenu>
@@ -300,9 +275,15 @@ function ElementInner({
: canElementBeHidden(element) && element.hidden) && ( : canElementBeHidden(element) && element.hidden) && (
<div className="bg-opacity-50 pointer-events-none absolute inset-0 flex items-center justify-center bg-black"> <div className="bg-opacity-50 pointer-events-none absolute inset-0 flex items-center justify-center bg-black">
{hasAudio ? ( {hasAudio ? (
<VolumeX className="size-6 text-white" /> <HugeiconsIcon
icon={VolumeHighIcon}
className="size-6 text-white"
/>
) : ( ) : (
<EyeOff className="size-6 text-white" /> <HugeiconsIcon
icon={VolumeOffIcon}
className="size-6 text-white"
/>
)} )}
</div> </div>
)} )}
@@ -463,66 +444,38 @@ function ElementContent({
); );
} }
function CopyMenuItem({ function CopyMenuItem() {
isMultipleSelected,
isCurrentElementSelected,
selectedCount,
onClick,
}: {
isMultipleSelected: boolean;
isCurrentElementSelected: boolean;
selectedCount: number;
onClick: (e: React.MouseEvent) => void;
}) {
return ( return (
<ContextMenuItem onClick={onClick}> <ActionMenuItem action="copy-selected" icon={<HugeiconsIcon icon={Copy01Icon} />}>
<Copy className="mr-2 size-4" /> Copy
{isMultipleSelected && isCurrentElementSelected </ActionMenuItem>
? `Copy ${selectedCount} elements`
: "Copy element"}
</ContextMenuItem>
); );
} }
function MuteMenuItem({ function MuteMenuItem({
element,
isMultipleSelected, isMultipleSelected,
isCurrentElementSelected, isCurrentElementSelected,
isMuted, isMuted,
selectedCount,
onClick,
}: { }: {
element: TimelineElementType;
isMultipleSelected: boolean; isMultipleSelected: boolean;
isCurrentElementSelected: boolean; isCurrentElementSelected: boolean;
isMuted: boolean; isMuted: boolean;
selectedCount: number;
onClick: (e: React.MouseEvent) => void;
}) { }) {
const getIcon = () => { const getIcon = () => {
if (isMultipleSelected && isCurrentElementSelected) { if (isMultipleSelected && isCurrentElementSelected) {
return <VolumeX className="mr-2 size-4" />; return <HugeiconsIcon icon={VolumeMute02Icon} />;
} }
return isMuted ? ( return isMuted ? (
<Volume2 className="mr-2 size-4" /> <HugeiconsIcon icon={VolumeHighIcon} />
) : ( ) : (
<VolumeX className="mr-2 size-4" /> <HugeiconsIcon icon={VolumeOffIcon} />
); );
}; };
const getLabel = () => {
if (isMultipleSelected && isCurrentElementSelected) {
return `Toggle mute ${selectedCount} elements`;
}
const suffix = element.type === "text" ? "text" : "clip";
return isMuted ? `Unmute ${suffix}` : `Mute ${suffix}`;
};
return ( return (
<ContextMenuItem onClick={onClick}> <ActionMenuItem action="toggle-elements-muted-selected" icon={getIcon()}>
{getIcon()} {isMuted ? "Unmute" : "Mute"}
<span>{getLabel()}</span> </ActionMenuItem>
</ContextMenuItem>
); );
} }
@@ -530,41 +483,28 @@ function VisibilityMenuItem({
element, element,
isMultipleSelected, isMultipleSelected,
isCurrentElementSelected, isCurrentElementSelected,
selectedCount,
onClick,
}: { }: {
element: TimelineElementType; element: TimelineElementType;
isMultipleSelected: boolean; isMultipleSelected: boolean;
isCurrentElementSelected: boolean; isCurrentElementSelected: boolean;
selectedCount: number;
onClick: (e: React.MouseEvent) => void;
}) { }) {
const isHidden = canElementBeHidden(element) && element.hidden; const isHidden = canElementBeHidden(element) && element.hidden;
const getIcon = () => { const getIcon = () => {
if (isMultipleSelected && isCurrentElementSelected) { if (isMultipleSelected && isCurrentElementSelected) {
return <EyeOff className="mr-2 size-4" />; return <HugeiconsIcon icon={ViewOffSlashIcon} />;
} }
return isHidden ? ( return isHidden ? (
<Eye className="mr-2 size-4" /> <HugeiconsIcon icon={ViewIcon} />
) : ( ) : (
<EyeOff className="mr-2 size-4" /> <HugeiconsIcon icon={ViewOffSlashIcon} />
); );
}; };
const getLabel = () => {
if (isMultipleSelected && isCurrentElementSelected) {
return `Toggle visibility ${selectedCount} elements`;
}
const suffix = element.type === "text" ? "text" : "clip";
return isHidden ? `Show ${suffix}` : `Hide ${suffix}`;
};
return ( return (
<ContextMenuItem onClick={onClick}> <ActionMenuItem action="toggle-elements-visibility-selected" icon={getIcon()}>
{getIcon()} {isHidden ? "Show" : "Hide"}
<span>{getLabel()}</span> </ActionMenuItem>
</ContextMenuItem>
); );
} }
@@ -573,23 +513,42 @@ function DeleteMenuItem({
isCurrentElementSelected, isCurrentElementSelected,
elementType, elementType,
selectedCount, selectedCount,
onClick,
}: { }: {
isMultipleSelected: boolean; isMultipleSelected: boolean;
isCurrentElementSelected: boolean; isCurrentElementSelected: boolean;
elementType: TimelineElementType["type"]; elementType: TimelineElementType["type"];
selectedCount: number; selectedCount: number;
onClick: (e: React.MouseEvent) => void;
}) { }) {
return ( return (
<ContextMenuItem <ActionMenuItem
onClick={onClick} action="delete-selected"
className="text-destructive focus:text-destructive" variant="destructive"
icon={<HugeiconsIcon icon={Delete02Icon} />}
> >
<Trash2 className="mr-2 size-4" />
{isMultipleSelected && isCurrentElementSelected {isMultipleSelected && isCurrentElementSelected
? `Delete ${selectedCount} elements` ? `Delete ${selectedCount} elements`
: `Delete ${elementType === "text" ? "text" : "clip"}`} : `Delete ${elementType === "text" ? "text" : "clip"}`}
</ActionMenuItem>
);
}
function ActionMenuItem({
action,
children,
...props
}: Omit<ComponentProps<typeof ContextMenuItem>, "onClick" | "textRight"> & {
action: TAction;
}) {
return (
<ContextMenuItem
onClick={(event) => {
event.stopPropagation();
invokeAction(action);
}}
textRight={getDisplayShortcut(action)}
{...props}
>
{children}
</ContextMenuItem> </ContextMenuItem>
); );
} }
@@ -6,11 +6,7 @@ import {
TooltipContent, TooltipContent,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import { SkipBack, SplitSquareHorizontal } from "lucide-react";
SkipBack,
SplitSquareHorizontal,
LayersIcon,
} from "lucide-react";
import { import {
SplitButton, SplitButton,
SplitButtonLeft, SplitButtonLeft,
@@ -38,10 +34,6 @@ import {
PauseIcon, PauseIcon,
PlayIcon, PlayIcon,
Copy01Icon, Copy01Icon,
ArrowLeft03Icon,
ArrowRight03Icon,
LayoutAlignLeftIcon,
LayoutAlignRightIcon,
AlignLeftIcon, AlignLeftIcon,
AlignRightIcon, AlignRightIcon,
Layers01Icon, Layers01Icon,
@@ -136,7 +128,7 @@ function ToolbarLeftSection() {
icon={<HugeiconsIcon icon={ScissorIcon} />} icon={<HugeiconsIcon icon={ScissorIcon} />}
tooltip="Split element" tooltip="Split element"
onClick={({ event }) => onClick={({ event }) =>
handleAction({ action: "split-selected", event }) handleAction({ action: "split", event })
} }
/> />
@@ -144,7 +136,7 @@ function ToolbarLeftSection() {
icon={<HugeiconsIcon icon={AlignLeftIcon} />} icon={<HugeiconsIcon icon={AlignLeftIcon} />}
tooltip="Split left" tooltip="Split left"
onClick={({ event }) => onClick={({ event }) =>
handleAction({ action: "split-selected-right", event }) handleAction({ action: "split-left", event })
} }
/> />
@@ -152,7 +144,7 @@ function ToolbarLeftSection() {
icon={<HugeiconsIcon icon={AlignRightIcon} />} icon={<HugeiconsIcon icon={AlignRightIcon} />}
tooltip="Split right" tooltip="Split right"
onClick={({ event }) => onClick={({ event }) =>
handleAction({ action: "split-selected-left", event }) handleAction({ action: "split-right", event })
} }
/> />
+1 -1
View File
@@ -3,7 +3,7 @@ import { RiDiscordFill, RiTwitterXLine } from "react-icons/ri";
import { FaGithub } from "react-icons/fa6"; import { FaGithub } from "react-icons/fa6";
import Image from "next/image"; import Image from "next/image";
import { DEFAULT_LOGO_URL, SOCIAL_LINKS } from "@/constants/site-constants"; import { DEFAULT_LOGO_URL, SOCIAL_LINKS } from "@/constants/site-constants";
import { capitalizeFirstLetter } from "@/utils/strings"; import { capitalizeFirstLetter } from "@/utils/string";
type Category = "resources" | "company"; type Category = "resources" | "company";
+1 -1
View File
@@ -74,7 +74,7 @@ export function Header() {
</Button> </Button>
</Link> </Link>
<Link href="/projects"> <Link href="/projects">
<Button className="text-sm"> <Button variant="foreground" className="text-sm">
Projects Projects
<ArrowRight className="size-4" /> <ArrowRight className="size-4" />
</Button> </Button>
+2 -1
View File
@@ -30,9 +30,10 @@ export function Hero() {
<div className="mt-8 flex justify-center gap-8"> <div className="mt-8 flex justify-center gap-8">
<Link href="/projects"> <Link href="/projects">
<Button <Button
variant="foreground"
type="submit" type="submit"
size="lg" size="lg"
className="bg-foreground h-11 px-6 text-base" className="h-11 text-base"
> >
Try early beta Try early beta
<ArrowRight className="ml-0.5" /> <ArrowRight className="ml-0.5" />
@@ -1,6 +1,7 @@
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
DialogBody,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogFooter, DialogFooter,
@@ -9,6 +10,7 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { useState } from "react"; import { useState } from "react";
import { Label } from "./ui/label";
export function RenameProjectDialog({ export function RenameProjectDialog({
isOpen, isOpen,
@@ -34,12 +36,11 @@ export function RenameProjectDialog({
<Dialog open={isOpen} onOpenChange={handleOpenChange}> <Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Rename Project</DialogTitle> <DialogTitle>Rename project</DialogTitle>
<DialogDescription>
Enter a new name for your project.
</DialogDescription>
</DialogHeader> </DialogHeader>
<DialogBody className="gap-3">
<Label>New name</Label>
<Input <Input
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
@@ -50,8 +51,8 @@ export function RenameProjectDialog({
} }
}} }}
placeholder="Enter a new name" placeholder="Enter a new name"
className="bg-background border-border mt-0 border-2"
/> />
</DialogBody>
<DialogFooter> <DialogFooter>
<Button <Button
+2 -2
View File
@@ -28,13 +28,13 @@ const AccordionTrigger = React.forwardRef<
<AccordionPrimitive.Trigger <AccordionPrimitive.Trigger
ref={ref} ref={ref}
className={cn( className={cn(
"flex flex-1 cursor-pointer items-center justify-between py-4 text-left text-sm font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180", "flex flex-1 cursor-pointer items-center justify-between py-4 text-left text-sm font-medium hover:underline [&[data-state=open]>svg]:rotate-180",
className, className,
)} )}
{...props} {...props}
> >
{children} {children}
<ChevronDown className="text-muted-foreground size-4 shrink-0 transition-transform duration-200" /> <ChevronDown className="text-muted-foreground size-4 shrink-0" />
</AccordionPrimitive.Trigger> </AccordionPrimitive.Trigger>
</AccordionPrimitive.Header> </AccordionPrimitive.Header>
)); ));
+13 -6
View File
@@ -1,16 +1,18 @@
import * as React from "react"; import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority"; import { cva, type VariantProps } from "class-variance-authority";
import { AlertTriangle } from "lucide-react";
import { cn } from "@/utils/ui"; import { cn } from "@/utils/ui";
import { HugeiconsIcon } from "@hugeicons/react";
import { Alert02Icon } from "@hugeicons/core-free-icons";
const alertVariants = cva( const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7", "relative w-full rounded-lg border p-5 py-4.5 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-destructive [&>svg~*]:pl-7",
{ {
variants: { variants: {
variant: { variant: {
default: "bg-background text-foreground", default: "bg-background text-foreground",
destructive: destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", "bg-destructive/5 border-destructive/10 text-destructive dark:border-destructive [&>svg]:text-destructive",
}, },
}, },
defaultVariants: { defaultVariants: {
@@ -22,13 +24,18 @@ const alertVariants = cva(
const Alert = React.forwardRef< const Alert = React.forwardRef<
HTMLDivElement, HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants> React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => ( >(({ className, variant, children, ...props }, ref) => (
<div <div
ref={ref} ref={ref}
role="alert" role="alert"
className={cn(alertVariants({ variant }), className)} className={cn(alertVariants({ variant }), className)}
{...props} {...props}
/> >
{variant === "destructive" && (
<HugeiconsIcon icon={Alert02Icon} className="size-5 text-destructive mt-0.5" />
)}
{children}
</div>
)); ));
Alert.displayName = "Alert"; Alert.displayName = "Alert";
@@ -38,7 +45,7 @@ const AlertTitle = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<h5 <h5
ref={ref} ref={ref}
className={cn("mb-1 leading-none font-medium tracking-tight", className)} className={cn("mb-2 text-base leading-none font-semibold tracking-tight", className)}
{...props} {...props}
/> />
)); ));
+1 -1
View File
@@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/utils/ui"; import { cn } from "@/utils/ui";
const badgeVariants = cva( const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2", "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2",
{ {
variants: { variants: {
variant: { variant: {
+1 -1
View File
@@ -50,7 +50,7 @@ const BreadcrumbLink = React.forwardRef<
return ( return (
<Comp <Comp
ref={ref} ref={ref}
className={cn("hover:text-foreground transition-colors", className)} className={cn("hover:text-foreground", className)}
{...props} {...props}
/> />
); );
+10 -12
View File
@@ -5,34 +5,32 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/utils/ui"; import { cn } from "@/utils/ui";
const buttonVariants = cva( const buttonVariants = cva(
"inline-flex items-center cursor-pointer justify-center gap-2 whitespace-nowrap rounded-full text-sm font-medium transition-colors focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", "inline-flex items-center cursor-pointer justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{ {
variants: { variants: {
variant: { variant: {
default: default:
"bg-foreground text-background shadow-sm hover:bg-foreground/90", "bg-primary text-primary-foreground hover:bg-primary-hover",
foreground: foreground:
"bg-background text-foreground", "bg-foreground text-background hover:bg-foreground/90",
primary:
"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",
destructive: destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/80", "bg-destructive text-destructive-foreground hover:bg-destructive/80",
"destructive-foreground": "destructive-foreground":
"border bg-background hover:bg-destructive/15 text-destructive", "border bg-background hover:bg-destructive/15 text-destructive",
outline: outline:
"border border-border bg-transparent transition-colors hover:bg-accent", "border border-border bg-transparent hover:bg-accent/50",
secondary: secondary:
"bg-secondary text-secondary-foreground hover:bg-foreground/15 border border-input", "bg-secondary text-secondary-foreground hover:bg-foreground/15 border border-input",
text: "bg-transparent rounded-none !opacity-100", text: "bg-transparent rounded-none opacity-100 hover:opacity-75",
link: "text-primary underline-offset-4 hover:underline !p-0 !h-auto", link: "text-primary underline-offset-4 hover:underline !p-0 !h-auto",
}, },
size: { size: {
default: "h-9 px-4 py-2", default: "h-9.5 px-4 py-2",
sm: "h-8 rounded-full px-3 text-xs", sm: "h-8 px-3 text-xs",
lg: "h-10 rounded-full p-5 px-6", lg: "h-10 p-5 px-6",
icon: "size-7 rounded-sm", icon: "size-7",
text: "p-0", text: "p-0",
}, },
}, },
+1 -1
View File
@@ -13,7 +13,7 @@ const Checkbox = React.forwardRef<
<CheckboxPrimitive.Root <CheckboxPrimitive.Root
ref={ref} ref={ref}
className={cn( className={cn(
"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", "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-sm border focus-visible:ring-1 focus-visible:outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className, className,
)} )}
{...props} {...props}
+1 -1
View File
@@ -242,7 +242,7 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
> >
<button <button
ref={triggerRef} ref={triggerRef}
className="size-6 cursor-pointer rounded-full transition-all hover:ring-2 hover:ring-white/20" className="size-6 cursor-pointer rounded-full hover:ring-2 hover:ring-white/20"
style={{ backgroundColor: `#${value}` }} style={{ backgroundColor: `#${value}` }}
type="button" type="button"
onClick={() => { onClick={() => {
+41 -22
View File
@@ -2,10 +2,10 @@
import * as React from "react"; import * as React from "react";
import { ContextMenu as ContextMenuPrimitive } from "radix-ui"; import { ContextMenu as ContextMenuPrimitive } from "radix-ui";
import { Check, ChevronRight, Circle } from "lucide-react";
import { cva, type VariantProps } from "class-variance-authority"; import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/utils/ui"; import { cn } from "@/utils/ui";
import { HugeiconsIcon } from "@hugeicons/react";
import { Tick02Icon, ArrowRightIcon, CircleIcon } from "@hugeicons/core-free-icons"
const ContextMenu = ContextMenuPrimitive.Root; const ContextMenu = ContextMenuPrimitive.Root;
@@ -20,12 +20,12 @@ const ContextMenuSub = ContextMenuPrimitive.Sub;
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup; const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
const contextMenuItemVariants = cva( const contextMenuItemVariants = cva(
"relative flex cursor-pointer select-none items-center gap-2 px-2 py-1.5 text-sm outline-hidden transition-opacity data-disabled:pointer-events-none data-disabled:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0", "relative flex cursor-pointer select-none items-center gap-2.5 px-4 py-1.5 last:pb-1 text-base outline-hidden 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: "focus:bg-accent/35 focus:text-accent-foreground [&_svg]:text-muted-foreground",
destructive: "text-destructive focus:text-destructive/80", destructive: "text-destructive focus:bg-destructive/5 focus:text-destructive [&_svg]:text-destructive",
}, },
}, },
defaultVariants: { defaultVariants: {
@@ -39,20 +39,22 @@ const ContextMenuSubTrigger = React.forwardRef<
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & { React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean; inset?: boolean;
variant?: VariantProps<typeof contextMenuItemVariants>["variant"]; variant?: VariantProps<typeof contextMenuItemVariants>["variant"];
icon?: React.ReactNode;
} }
>(({ className, inset, children, variant = "default", ...props }, ref) => ( >(({ className, inset, children, variant = "default", icon, ...props }, ref) => (
<ContextMenuPrimitive.SubTrigger <ContextMenuPrimitive.SubTrigger
ref={ref} ref={ref}
className={cn( className={cn(
contextMenuItemVariants({ variant }), contextMenuItemVariants({ variant }),
"data-[state=open]:bg-accent data-[state=open]:opacity-65", "data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
inset && "pl-8", inset && "pl-8",
className, className,
)} )}
{...props} {...props}
> >
{icon && <span className="size-4 shrink-0 text-muted-foreground">{icon}</span>}
{children} {children}
<ChevronRight className="ml-auto" /> <HugeiconsIcon icon={ArrowRightIcon} className="ml-auto text-muted-foreground/80" />
</ContextMenuPrimitive.SubTrigger> </ContextMenuPrimitive.SubTrigger>
)); ));
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName; ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
@@ -64,7 +66,7 @@ const ContextMenuSubContent = React.forwardRef<
<ContextMenuPrimitive.SubContent <ContextMenuPrimitive.SubContent
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 z-50 min-w-32 overflow-hidden rounded-md border p-1 shadow-lg", "bg-popover text-popover-foreground z-50 min-w-48 overflow-hidden rounded-lg border shadow-xl py-2.5",
className, className,
)} )}
{...props} {...props}
@@ -80,8 +82,7 @@ const ContextMenuContent = React.forwardRef<
<ContextMenuPrimitive.Content <ContextMenuPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(
"bg-popover text-popover-foreground z-50 min-w-32 overflow-hidden rounded-md border p-1 shadow-md", "bg-popover text-popover-foreground z-50 min-w-48 overflow-hidden rounded-lg border shadow-xl py-2.5",
"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",
className, className,
)} )}
{...props} {...props}
@@ -95,8 +96,10 @@ const ContextMenuItem = React.forwardRef<
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & { React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
inset?: boolean; inset?: boolean;
variant?: VariantProps<typeof contextMenuItemVariants>["variant"]; variant?: VariantProps<typeof contextMenuItemVariants>["variant"];
icon?: React.ReactNode;
textRight?: string;
} }
>(({ className, inset, variant = "default", ...props }, ref) => ( >(({ className, inset, variant = "default", icon, children, textRight, ...props }, ref) => (
<ContextMenuPrimitive.Item <ContextMenuPrimitive.Item
ref={ref} ref={ref}
className={cn( className={cn(
@@ -105,7 +108,15 @@ const ContextMenuItem = React.forwardRef<
className, className,
)} )}
{...props} {...props}
/> >
{icon && <span className="[&_svg]:size-4 [&_svg]:shrink-0">{icon}</span>}
{children}
{textRight && (
<span className="ml-auto text-[0.60rem] tracking-widest text-muted-foreground/80 mb-0.5">
{textRight}
</span>
)}
</ContextMenuPrimitive.Item>
)); ));
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName; ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName;
@@ -113,8 +124,9 @@ const ContextMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>, React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem> & { React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem> & {
variant?: VariantProps<typeof contextMenuItemVariants>["variant"]; variant?: VariantProps<typeof contextMenuItemVariants>["variant"];
icon?: React.ReactNode;
} }
>(({ className, children, checked, variant = "default", ...props }, ref) => ( >(({ className, children, checked, variant = "default", icon, ...props }, ref) => (
<ContextMenuPrimitive.CheckboxItem <ContextMenuPrimitive.CheckboxItem
ref={ref} ref={ref}
className={cn(contextMenuItemVariants({ variant }), "pr-2 pl-8", className)} className={cn(contextMenuItemVariants({ variant }), "pr-2 pl-8", className)}
@@ -123,9 +135,10 @@ const ContextMenuCheckboxItem = React.forwardRef<
> >
<span className="absolute left-2 flex size-3.5 items-center justify-center"> <span className="absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator> <ContextMenuPrimitive.ItemIndicator>
<Check className="size-4" /> <HugeiconsIcon icon={Tick02Icon} className="size-4" />
</ContextMenuPrimitive.ItemIndicator> </ContextMenuPrimitive.ItemIndicator>
</span> </span>
{icon && <span className="size-4 shrink-0 text-muted-foreground">{icon}</span>}
{children} {children}
</ContextMenuPrimitive.CheckboxItem> </ContextMenuPrimitive.CheckboxItem>
)); ));
@@ -136,8 +149,9 @@ const ContextMenuRadioItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>, React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem> & { React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem> & {
variant?: VariantProps<typeof contextMenuItemVariants>["variant"]; variant?: VariantProps<typeof contextMenuItemVariants>["variant"];
icon?: React.ReactNode;
} }
>(({ className, children, variant = "default", ...props }, ref) => ( >(({ className, children, variant = "default", icon, ...props }, ref) => (
<ContextMenuPrimitive.RadioItem <ContextMenuPrimitive.RadioItem
ref={ref} ref={ref}
className={cn(contextMenuItemVariants({ variant }), "pr-2 pl-8", className)} className={cn(contextMenuItemVariants({ variant }), "pr-2 pl-8", className)}
@@ -145,9 +159,10 @@ const ContextMenuRadioItem = React.forwardRef<
> >
<span className="absolute left-2 flex size-3.5 items-center justify-center"> <span className="absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator> <ContextMenuPrimitive.ItemIndicator>
<Circle className="size-2 fill-current" /> <HugeiconsIcon icon={CircleIcon} className="size-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator> </ContextMenuPrimitive.ItemIndicator>
</span> </span>
{icon && <span className="size-4 shrink-0 text-muted-foreground">{icon}</span>}
{children} {children}
</ContextMenuPrimitive.RadioItem> </ContextMenuPrimitive.RadioItem>
)); ));
@@ -157,17 +172,21 @@ const ContextMenuLabel = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Label>, React.ElementRef<typeof ContextMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & { React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
inset?: boolean; inset?: boolean;
icon?: React.ReactNode;
} }
>(({ className, inset, ...props }, ref) => ( >(({ className, inset, icon, children, ...props }, ref) => (
<ContextMenuPrimitive.Label <ContextMenuPrimitive.Label
ref={ref} ref={ref}
className={cn( className={cn(
"px-2 py-1.5 text-sm font-semibold", "flex items-center gap-2.5 px-4 py-1.5 text-sm font-semibold text-foreground",
inset && "pl-8", inset && "pl-8",
className, className,
)} )}
{...props} {...props}
/> >
{icon && <span className="size-4 shrink-0 text-muted-foreground">{icon}</span>}
{children}
</ContextMenuPrimitive.Label>
)); ));
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName; ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName;
@@ -177,7 +196,7 @@ const ContextMenuSeparator = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Separator <ContextMenuPrimitive.Separator
ref={ref} ref={ref}
className={cn("bg-foreground/10 -mx-1 my-1 h-px", className)} className={cn("bg-border my-2 h-px", className)}
{...props} {...props}
/> />
)); ));
@@ -189,7 +208,7 @@ const ContextMenuShortcut = ({
}: React.HTMLAttributes<HTMLSpanElement>) => { }: React.HTMLAttributes<HTMLSpanElement>) => {
return ( return (
<span <span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)} className={cn("ml-auto text-xs tracking-widest text-muted-foreground opacity-60", className)}
{...props} {...props}
/> />
); );
+15 -8
View File
@@ -3,9 +3,7 @@
import * as React from "react"; import * as React from "react";
import { Dialog as DialogPrimitive } from "radix-ui"; import { Dialog as DialogPrimitive } from "radix-ui";
import { X } from "lucide-react"; import { X } from "lucide-react";
import { cn } from "@/utils/ui"; import { cn } from "@/utils/ui";
import { ScrollArea } from "./scroll-area";
const Dialog = DialogPrimitive.Root; const Dialog = DialogPrimitive.Root;
@@ -22,7 +20,7 @@ const DialogOverlay = React.forwardRef<
<DialogPrimitive.Overlay <DialogPrimitive.Overlay
ref={ref} ref={ref}
className={cn( className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-250 bg-black/50", "fixed inset-0 z-250 backdrop-blur-sm bg-black/10",
className, className,
)} )}
{...props} {...props}
@@ -39,7 +37,7 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content <DialogPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(
"bg-popover 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 fixed top-[50%] left-[50%] z-250 grid w-[calc(100%-2rem)] max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200", "bg-popover fixed top-[50%] left-[50%] z-250 grid w-[calc(100%-2rem)] max-w-lg translate-x-[-50%] translate-y-[-50%] rounded-lg border shadow-lg duration-200",
className, className,
)} )}
onCloseAutoFocus={(e) => { onCloseAutoFocus={(e) => {
@@ -49,8 +47,8 @@ const DialogContent = React.forwardRef<
{...props} {...props}
> >
{children} {children}
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 cursor-pointer opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none"> <DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-6 right-6 cursor-pointer opacity-70 hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<X className="size-4" /> <X className="size-5 text-muted-foreground" />
<span className="sr-only">Close</span> <span className="sr-only">Close</span>
</DialogPrimitive.Close> </DialogPrimitive.Close>
</DialogPrimitive.Content> </DialogPrimitive.Content>
@@ -63,19 +61,27 @@ const DialogHeader = ({
...props ...props
}: React.HTMLAttributes<HTMLDivElement>) => ( }: React.HTMLAttributes<HTMLDivElement>) => (
<div <div
className={cn("flex flex-col space-y-2 text-left", className)} className={cn("flex flex-col space-y-2 text-left border-b p-6", className)}
{...props} {...props}
/> />
); );
DialogHeader.displayName = "DialogHeader"; DialogHeader.displayName = "DialogHeader";
const DialogBody = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("p-6 flex flex-col gap-6", className)} {...props} />
);
DialogBody.displayName = "DialogBody";
const DialogFooter = ({ const DialogFooter = ({
className, className,
...props ...props
}: 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-4", "flex gap-3 flex-col-reverse sm:flex-row sm:justify-end p-6 py-5 border-t",
className, className,
)} )}
{...props} {...props}
@@ -118,6 +124,7 @@ export {
DialogClose, DialogClose,
DialogContent, DialogContent,
DialogHeader, DialogHeader,
DialogBody,
DialogFooter, DialogFooter,
DialogTitle, DialogTitle,
DialogDescription, DialogDescription,
+2 -2
View File
@@ -20,7 +20,7 @@ const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const dropdownMenuItemVariants = cva( const dropdownMenuItemVariants = cva(
"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", "relative flex cursor-pointer select-none items-center gap-2 rounded-lg 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: {
@@ -88,7 +88,7 @@ const DropdownMenuContent = React.forwardRef<
e.preventDefault(); e.preventDefault();
}} }}
className={cn( className={cn(
"bg-popover text-popover-foreground z-50 min-w-32 overflow-hidden rounded-2xl border p-2 shadow-lg", "bg-popover text-popover-foreground z-50 min-w-32 overflow-hidden rounded-lg border p-2 shadow-lg",
className, className,
)} )}
{...props} {...props}
+1 -1
View File
@@ -45,7 +45,7 @@ const InputOTPSlot = React.forwardRef<
<div <div
ref={ref} ref={ref}
className={cn( className={cn(
"border-input relative flex aspect-square min-w-[36px] flex-1 items-center justify-center border text-lg shadow-xs transition-all first:rounded-l-md first:border-l last:rounded-r-md", "border-input relative flex aspect-square min-w-[36px] flex-1 items-center justify-center border text-lg shadow-xs first:rounded-l-md first:border-l last:rounded-r-md",
isActive && "ring-ring z-10 ring-1", isActive && "ring-ring z-10 ring-1",
className, className,
)} )}
@@ -41,7 +41,7 @@ export function InputWithBack({
return ( return (
<div ref={setContainerRef} className="relative w-full"> <div ref={setContainerRef} className="relative w-full">
<motion.div <motion.div
className="absolute top-1/2 left-0 z-10 -translate-y-1/2 cursor-pointer transition-opacity hover:opacity-75" className="absolute top-1/2 left-0 z-10 -translate-y-1/2 cursor-pointer hover:opacity-75"
initial={{ initial={{
x: isExpanded ? 0 : buttonOffset, x: isExpanded ? 0 : buttonOffset,
opacity: isExpanded ? 1 : 0.5, opacity: isExpanded ? 1 : 0.5,
+8 -1
View File
@@ -8,9 +8,13 @@ import { forwardRef, type ComponentProps } from "react";
import { useState } from "react"; import { useState } from "react";
const inputVariants = cva( 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", "file:text-foreground placeholder:text-muted-foreground border-border bg-background flex w-full min-w-0 rounded-md 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:ring-4 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{ {
variants: { variants: {
variant: {
default: "selection:bg-primary selection:text-primary-foreground focus-visible:border-primary focus-visible:ring-primary/10",
destructive: "selection:bg-destructive selection:text-destructive-foreground focus-visible:border-destructive focus-visible:ring-destructive/10",
},
size: { size: {
default: default:
"h-9 px-3 py-1 text-base file:h-7 file:text-sm md:text-sm", "h-9 px-3 py-1 text-base file:h-7 file:text-sm md:text-sm",
@@ -20,6 +24,7 @@ const inputVariants = cva(
}, },
defaultVariants: { defaultVariants: {
size: "default", size: "default",
variant: "default",
}, },
}, },
); );
@@ -40,6 +45,7 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
className, className,
type, type,
size, size,
variant,
containerClassName, containerClassName,
showPassword, showPassword,
onShowPasswordChange, onShowPasswordChange,
@@ -79,6 +85,7 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
inputVariants({ inputVariants({
size, size,
className: cn(paddingRight, className), className: cn(paddingRight, className),
variant,
}), }),
)} )}
ref={ref} ref={ref}
+1 -1
View File
@@ -7,7 +7,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/utils/ui"; import { cn } from "@/utils/ui";
const labelVariants = cva( const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70", "text-sm text-muted-foreground font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
); );
const Label = React.forwardRef< const Label = React.forwardRef<
@@ -41,7 +41,7 @@ NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName;
const NavigationMenuItem = NavigationMenuPrimitive.Item; const NavigationMenuItem = NavigationMenuPrimitive.Item;
const navigationMenuTriggerStyle = cva( const navigationMenuTriggerStyle = cva(
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-hidden disabled:pointer-events-none disabled:opacity-50 data-active:bg-accent/50 data-[state=open]:bg-accent/50", "group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-hidden disabled:pointer-events-none disabled:opacity-50 data-active:bg-accent/50 data-[state=open]:bg-accent/50",
); );
const NavigationMenuTrigger = React.forwardRef< const NavigationMenuTrigger = React.forwardRef<
+1 -1
View File
@@ -18,7 +18,7 @@ const Progress = React.forwardRef<
{...props} {...props}
> >
<ProgressPrimitive.Indicator <ProgressPrimitive.Indicator
className="bg-foreground size-full flex-1 transition-all" className="bg-foreground size-full flex-1"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }} style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/> />
</ProgressPrimitive.Root> </ProgressPrimitive.Root>
+1 -1
View File
@@ -20,7 +20,7 @@ const Slider = React.forwardRef<
<SliderPrimitive.Track className="bg-primary/20 relative h-1.5 w-full grow overflow-hidden rounded-full"> <SliderPrimitive.Track className="bg-primary/20 relative h-1.5 w-full grow overflow-hidden rounded-full">
<SliderPrimitive.Range className="bg-primary absolute h-full" /> <SliderPrimitive.Range className="bg-primary absolute h-full" />
</SliderPrimitive.Track> </SliderPrimitive.Track>
<SliderPrimitive.Thumb className="border-primary/50 bg-background focus-visible:ring-ring block size-4 rounded-full border shadow-sm transition-colors focus-visible:ring-1 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50" /> <SliderPrimitive.Thumb className="border-primary/50 bg-background focus-visible:ring-ring block size-4 rounded-full border shadow-sm focus-visible:ring-1 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root> </SliderPrimitive.Root>
)); ));
Slider.displayName = SliderPrimitive.Root.displayName; Slider.displayName = SliderPrimitive.Root.displayName;
+2 -2
View File
@@ -18,7 +18,7 @@ const SplitButton = forwardRef<HTMLDivElement, SplitButtonProps>(
<div <div
ref={ref} ref={ref}
className={cn( className={cn(
"border-input bg-panel-accent inline-flex h-7 overflow-hidden rounded-full border", "border-input bg-panel-accent inline-flex h-7 overflow-hidden rounded-lg border",
className, className,
)} )}
{...props} {...props}
@@ -39,7 +39,7 @@ const SplitButtonSide = forwardRef<
ref={ref} ref={ref}
variant="text" variant="text"
className={cn( className={cn(
"bg-panel-accent disabled:text-muted-foreground h-full gap-0 rounded-none border-0 font-normal !opacity-100 transition-colors", "bg-panel-accent disabled:text-muted-foreground h-full gap-0 rounded-none border-0 font-normal !opacity-100",
onClick onClick
? "hover:bg-foreground/10 cursor-pointer hover:opacity-100" ? "hover:bg-foreground/10 cursor-pointer hover:opacity-100"
: "cursor-default select-text", : "cursor-default select-text",
+2 -2
View File
@@ -11,7 +11,7 @@ const Switch = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<SwitchPrimitives.Root <SwitchPrimitives.Root
className={cn( className={cn(
"peer focus-visible:ring-ring focus-visible:ring-offset-background data-[state=checked]:bg-primary data-[state=unchecked]:bg-input inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-xs transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:cursor-not-allowed disabled:opacity-50", "peer focus-visible:ring-ring focus-visible:ring-offset-background data-[state=checked]:bg-primary data-[state=unchecked]:bg-input inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-xs focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className, className,
)} )}
{...props} {...props}
@@ -19,7 +19,7 @@ const Switch = React.forwardRef<
> >
<SwitchPrimitives.Thumb <SwitchPrimitives.Thumb
className={cn( className={cn(
"bg-background pointer-events-none block size-4 rounded-full shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0", "bg-background pointer-events-none block size-4 rounded-full shadow-lg ring-0 data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
)} )}
/> />
</SwitchPrimitives.Root> </SwitchPrimitives.Root>
+1 -1
View File
@@ -58,7 +58,7 @@ const TableRow = React.forwardRef<
<tr <tr
ref={ref} ref={ref}
className={cn( className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors", "hover:bg-muted/50 data-[state=selected]:bg-muted border-b",
className, className,
)} )}
{...props} {...props}
+3 -3
View File
@@ -25,7 +25,7 @@ const ToastViewport = React.forwardRef<
ToastViewport.displayName = ToastPrimitives.Viewport.displayName; ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
const toastVariants = cva( const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-(--radix-toast-swipe-end-x) data-[swipe=move]:translate-x-(--radix-toast-swipe-move-x) data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full sm:data-[state=open]:slide-in-from-bottom-full", "group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-(--radix-toast-swipe-end-x) data-[swipe=move]:translate-x-(--radix-toast-swipe-move-x) data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full sm:data-[state=open]:slide-in-from-bottom-full",
{ {
variants: { variants: {
variant: { variant: {
@@ -62,7 +62,7 @@ const ToastAction = React.forwardRef<
<ToastPrimitives.Action <ToastPrimitives.Action
ref={ref} ref={ref}
className={cn( className={cn(
"hover:bg-secondary focus:ring-ring group-[.destructive]:border-muted/40 hover:group-[.destructive]:border-destructive/30 hover:group-[.destructive]:bg-destructive hover:group-[.destructive]:text-destructive-foreground group-[.destructive]:focus-visible::ring-destructive inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors focus-visible:ring-1 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50", "hover:bg-secondary focus:ring-ring group-[.destructive]:border-muted/40 hover:group-[.destructive]:border-destructive/30 hover:group-[.destructive]:bg-destructive hover:group-[.destructive]:text-destructive-foreground group-[.destructive]:focus-visible::ring-destructive inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium focus-visible:ring-1 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50",
className, className,
)} )}
{...props} {...props}
@@ -77,7 +77,7 @@ const ToastClose = React.forwardRef<
<ToastPrimitives.Close <ToastPrimitives.Close
ref={ref} ref={ref}
className={cn( className={cn(
"text-foreground/50 hover:text-foreground focus-visible::ring-1 group-[.destructive]:text-muted-foreground absolute top-1 right-1 rounded-md p-1 opacity-0 transition-opacity group-hover:opacity-100 hover:group-[.destructive]:text-red-50 focus:opacity-100 focus:outline-hidden focus-visible:group-[.destructive]:ring-red-400 focus-visible:group-[.destructive]:ring-offset-red-600", "text-foreground/50 hover:text-foreground focus-visible::ring-1 group-[.destructive]:text-muted-foreground absolute top-1 right-1 rounded-md p-1 opacity-0 group-hover:opacity-100 hover:group-[.destructive]:text-red-50 focus:opacity-100 focus:outline-hidden focus-visible:group-[.destructive]:ring-red-400 focus-visible:group-[.destructive]:ring-offset-red-600",
className, className,
)} )}
toast-close="" toast-close=""
+1 -1
View File
@@ -7,7 +7,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/utils/ui"; import { cn } from "@/utils/ui";
const toggleVariants = cva( const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", "inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{ {
variants: { variants: {
variant: { variant: {
-6
View File
@@ -27,12 +27,6 @@ export const EXTERNAL_TOOLS: ExternalTool[] = [
url: "https://marblecms.com?utm_source=opencut", url: "https://marblecms.com?utm_source=opencut",
icon: OcMarbleIcon, icon: OcMarbleIcon,
}, },
{
name: "Vercel",
description: "Platform where we deploy and host OpenCut",
url: "https://vercel.com?utm_source=opencut",
icon: OcVercelIcon,
},
{ {
name: "Databuddy", name: "Databuddy",
description: "GDPR compliant analytics and user insights for OpenCut", description: "GDPR compliant analytics and user insights for OpenCut",
+3
View File
@@ -7,6 +7,7 @@ import { RendererManager } from "./managers/renderer-manager";
import { CommandManager } from "./managers/commands"; import { CommandManager } from "./managers/commands";
import { SaveManager } from "./managers/save-manager"; import { SaveManager } from "./managers/save-manager";
import { AudioManager } from "./managers/audio-manager"; import { AudioManager } from "./managers/audio-manager";
import { SelectionManager } from "./managers/selection-manager";
export class EditorCore { export class EditorCore {
private static instance: EditorCore | null = null; private static instance: EditorCore | null = null;
@@ -20,6 +21,7 @@ export class EditorCore {
public readonly renderer: RendererManager; public readonly renderer: RendererManager;
public readonly save: SaveManager; public readonly save: SaveManager;
public readonly audio: AudioManager; public readonly audio: AudioManager;
public readonly selection: SelectionManager;
private constructor() { private constructor() {
this.command = new CommandManager(); this.command = new CommandManager();
@@ -31,6 +33,7 @@ export class EditorCore {
this.renderer = new RendererManager(this); this.renderer = new RendererManager(this);
this.save = new SaveManager(this); this.save = new SaveManager(this);
this.audio = new AudioManager(this); this.audio = new AudioManager(this);
this.selection = new SelectionManager(this);
this.save.start(); this.save.start();
} }
@@ -0,0 +1,35 @@
import type { EditorCore } from "@/core";
type ElementRef = { trackId: string; elementId: string };
export class SelectionManager {
private selectedElements: ElementRef[] = [];
private listeners = new Set<() => void>();
constructor(editor: EditorCore) {
void editor;
}
getSelectedElements(): ElementRef[] {
return this.selectedElements;
}
setSelectedElements({ elements }: { elements: ElementRef[] }): void {
this.selectedElements = elements;
this.notify();
}
clearSelection(): void {
this.selectedElements = [];
this.notify();
}
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
private notify(): void {
this.listeners.forEach((fn) => fn());
}
}
@@ -142,10 +142,10 @@ export class TimelineManager {
elements: { trackId: string; elementId: string }[]; elements: { trackId: string; elementId: string }[];
splitTime: number; splitTime: number;
retainSide?: "both" | "left" | "right"; retainSide?: "both" | "left" | "right";
}): string[] { }): { trackId: string; elementId: string }[] {
const command = new SplitElementsCommand(elements, splitTime, retainSide); const command = new SplitElementsCommand(elements, splitTime, retainSide);
this.editor.command.execute({ command }); this.editor.command.execute({ command });
return command.splitElementIds; return command.getRightSideElements();
} }
getTotalDuration(): number { getTotalDuration(): number {
@@ -184,9 +184,10 @@ export class TimelineManager {
}: { }: {
time: number; time: number;
clipboardItems: ClipboardItem[]; clipboardItems: ClipboardItem[];
}): void { }): { trackId: string; elementId: string }[] {
const command = new PasteCommand(time, clipboardItems); const command = new PasteCommand(time, clipboardItems);
this.editor.command.execute({ command }); this.editor.command.execute({ command });
return command.getPastedElements();
} }
deleteElements({ deleteElements({
@@ -230,9 +231,10 @@ export class TimelineManager {
elements, elements,
}: { }: {
elements: { trackId: string; elementId: string }[]; elements: { trackId: string; elementId: string }[];
}): void { }): { trackId: string; elementId: string }[] {
const command = new DuplicateElementsCommand({ elements }); const command = new DuplicateElementsCommand({ elements });
this.editor.command.execute({ command }); this.editor.command.execute({ command });
return command.getDuplicatedElements();
} }
toggleElementsVisibility({ toggleElementsVisibility({
@@ -4,6 +4,7 @@ import { useTimelineStore } from "@/stores/timeline-store";
import { useActionHandler } from "@/hooks/actions/use-action-handler"; import { useActionHandler } from "@/hooks/actions/use-action-handler";
import { useEditor } from "../use-editor"; import { useEditor } from "../use-editor";
import { useElementSelection } from "../timeline/element/use-element-selection"; import { useElementSelection } from "../timeline/element/use-element-selection";
import { getElementsAtTime } from "@/lib/timeline";
export function useEditorActions() { export function useEditorActions() {
const editor = useEditor(); const editor = useEditor();
@@ -122,40 +123,73 @@ export function useEditorActions() {
); );
useActionHandler( useActionHandler(
"split-selected", "split",
() => { () => {
const currentTime = editor.playback.getCurrentTime();
const elementsToSplit =
selectedElements.length > 0
? selectedElements
: getElementsAtTime({
tracks: editor.timeline.getTracks(),
time: currentTime,
});
if (elementsToSplit.length === 0) return;
editor.timeline.splitElements({ editor.timeline.splitElements({
elements: selectedElements, elements: elementsToSplit,
splitTime: editor.playback.getCurrentTime(), splitTime: currentTime,
}); });
}, },
undefined, undefined,
); );
useActionHandler( useActionHandler(
"split-selected-left", "split-left",
() => { () => {
editor.timeline.splitElements({ const currentTime = editor.playback.getCurrentTime();
elements: selectedElements, const elementsToSplit =
splitTime: editor.playback.getCurrentTime(), selectedElements.length > 0
retainSide: "left", ? selectedElements
: getElementsAtTime({
tracks: editor.timeline.getTracks(),
time: currentTime,
}); });
},
undefined,
);
useActionHandler( if (elementsToSplit.length === 0) return;
"split-selected-right",
() => {
editor.timeline.splitElements({ editor.timeline.splitElements({
elements: selectedElements, elements: elementsToSplit,
splitTime: editor.playback.getCurrentTime(), splitTime: currentTime,
retainSide: "right", retainSide: "right",
}); });
}, },
undefined, undefined,
); );
useActionHandler(
"split-right",
() => {
const currentTime = editor.playback.getCurrentTime();
const elementsToSplit =
selectedElements.length > 0
? selectedElements
: getElementsAtTime({
tracks: editor.timeline.getTracks(),
time: currentTime,
});
if (elementsToSplit.length === 0) return;
editor.timeline.splitElements({
elements: elementsToSplit,
splitTime: currentTime,
retainSide: "left",
});
},
undefined,
);
useActionHandler( useActionHandler(
"delete-selected", "delete-selected",
() => { () => {
@@ -186,7 +220,10 @@ export function useEditorActions() {
useActionHandler( useActionHandler(
"duplicate-selected", "duplicate-selected",
() => { () => {
editor.timeline.duplicateElements({ elements: selectedElements }); const duplicatedElements = editor.timeline.duplicateElements({
elements: selectedElements,
});
setElementSelection({ elements: duplicatedElements });
}, },
undefined, undefined,
); );
@@ -238,14 +275,16 @@ export function useEditorActions() {
); );
useActionHandler( useActionHandler(
"paste-selected", "paste-copied",
() => { () => {
if (!clipboard?.items.length) return; if (!clipboard?.items.length) return;
const pastedElements =
editor.timeline.pasteAtTime({ editor.timeline.pasteAtTime({
time: editor.playback.getCurrentTime(), time: editor.playback.getCurrentTime(),
clipboardItems: clipboard.items, clipboardItems: clipboard.items,
}); }) ?? [];
setElementSelection({ elements: pastedElements });
}, },
undefined, undefined,
); );
@@ -1,10 +1,14 @@
import { useCallback } from "react"; import { useCallback, useSyncExternalStore } from "react";
import { useTimelineStore } from "@/stores/timeline-store"; import { useEditor } from "@/hooks/use-editor";
type ElementRef = { trackId: string; elementId: string }; type ElementRef = { trackId: string; elementId: string };
export function useElementSelection() { export function useElementSelection() {
const { selectedElements, setSelectedElements } = useTimelineStore(); const editor = useEditor();
const selectedElements = useSyncExternalStore(
(listener) => editor.selection.subscribe(listener),
() => editor.selection.getSelectedElements(),
);
const isElementSelected = useCallback( const isElementSelected = useCallback(
({ trackId, elementId }: ElementRef) => ({ trackId, elementId }: ElementRef) =>
@@ -17,9 +21,9 @@ export function useElementSelection() {
const selectElement = useCallback( const selectElement = useCallback(
({ trackId, elementId }: ElementRef) => { ({ trackId, elementId }: ElementRef) => {
setSelectedElements({ elements: [{ trackId, elementId }] }); editor.selection.setSelectedElements({ elements: [{ trackId, elementId }] });
}, },
[setSelectedElements], [editor],
); );
const addElementToSelection = useCallback( const addElementToSelection = useCallback(
@@ -30,23 +34,23 @@ export function useElementSelection() {
); );
if (alreadySelected) return; if (alreadySelected) return;
setSelectedElements({ editor.selection.setSelectedElements({
elements: [...selectedElements, { trackId, elementId }], elements: [...selectedElements, { trackId, elementId }],
}); });
}, },
[selectedElements, setSelectedElements], [selectedElements, editor],
); );
const removeElementFromSelection = useCallback( const removeElementFromSelection = useCallback(
({ trackId, elementId }: ElementRef) => { ({ trackId, elementId }: ElementRef) => {
setSelectedElements({ editor.selection.setSelectedElements({
elements: selectedElements.filter( elements: selectedElements.filter(
(element) => (element) =>
!(element.trackId === trackId && element.elementId === elementId), !(element.trackId === trackId && element.elementId === elementId),
), ),
}); });
}, },
[selectedElements, setSelectedElements], [selectedElements, editor],
); );
const toggleElementSelection = useCallback( const toggleElementSelection = useCallback(
@@ -66,14 +70,14 @@ export function useElementSelection() {
); );
const clearElementSelection = useCallback(() => { const clearElementSelection = useCallback(() => {
setSelectedElements({ elements: [] }); editor.selection.clearSelection();
}, [setSelectedElements]); }, [editor]);
const setElementSelection = useCallback( const setElementSelection = useCallback(
({ elements }: { elements: ElementRef[] }) => { ({ elements }: { elements: ElementRef[] }) => {
setSelectedElements({ elements }); editor.selection.setSelectedElements({ elements });
}, },
[setSelectedElements], [editor],
); );
/** /**
+1
View File
@@ -19,6 +19,7 @@ export function useEditor(): EditorCore {
editor.project.subscribe(handleStoreChange), editor.project.subscribe(handleStoreChange),
editor.media.subscribe(handleStoreChange), editor.media.subscribe(handleStoreChange),
editor.renderer.subscribe(handleStoreChange), editor.renderer.subscribe(handleStoreChange),
editor.selection.subscribe(handleStoreChange),
]; ];
return () => { return () => {
+3 -2
View File
@@ -12,6 +12,7 @@ export function useKeybindingsListener() {
useKeybindingsStore(); useKeybindingsStore();
useEffect(() => { useEffect(() => {
const eventOptions: AddEventListenerOptions = { capture: true };
const handleKeyDown = (ev: KeyboardEvent) => { const handleKeyDown = (ev: KeyboardEvent) => {
// do not check keybinds if the mode is disabled // do not check keybinds if the mode is disabled
if (!keybindingsEnabled) return; if (!keybindingsEnabled) return;
@@ -53,10 +54,10 @@ export function useKeybindingsListener() {
} }
}; };
document.addEventListener("keydown", handleKeyDown); document.addEventListener("keydown", handleKeyDown, eventOptions);
return () => { return () => {
document.removeEventListener("keydown", handleKeyDown); document.removeEventListener("keydown", handleKeyDown, eventOptions);
}; };
}, [keybindings, getKeybindingString, keybindingsEnabled, isRecording]); }, [keybindings, getKeybindingString, keybindingsEnabled, isRecording]);
} }
+8 -8
View File
@@ -70,32 +70,32 @@ export const ACTIONS = {
category: "navigation", category: "navigation",
defaultShortcuts: ["end"], defaultShortcuts: ["end"],
}, },
"split-selected": { split: {
description: "Split element at playhead", description: "Split elements at playhead",
category: "editing", category: "editing",
defaultShortcuts: ["s"], defaultShortcuts: ["s"],
}, },
"split-selected-left": { "split-left": {
description: "Split element at playhead and keep left", description: "Split and remove left",
category: "editing", category: "editing",
defaultShortcuts: ["q"], defaultShortcuts: ["q"],
}, },
"split-selected-right": { "split-right": {
description: "Split element at playhead and keep right", description: "Split and remove right",
category: "editing", category: "editing",
defaultShortcuts: ["w"], defaultShortcuts: ["w"],
}, },
"delete-selected": { "delete-selected": {
description: "Delete selected elements", description: "Delete selected elements",
category: "editing", category: "editing",
defaultShortcuts: ["delete", "backspace"], defaultShortcuts: ["backspace", "delete"],
}, },
"copy-selected": { "copy-selected": {
description: "Copy selected elements", description: "Copy selected elements",
category: "editing", category: "editing",
defaultShortcuts: ["ctrl+c"], defaultShortcuts: ["ctrl+c"],
}, },
"paste-selected": { "paste-copied": {
description: "Paste elements at playhead", description: "Paste elements at playhead",
category: "editing", category: "editing",
defaultShortcuts: ["ctrl+v"], defaultShortcuts: ["ctrl+v"],
@@ -14,7 +14,7 @@ import {
export class PasteCommand extends Command { export class PasteCommand extends Command {
private savedState: TimelineTrack[] | null = null; private savedState: TimelineTrack[] | null = null;
private pastedElementIds: string[] = []; private pastedElements: { trackId: string; elementId: string }[] = [];
constructor( constructor(
private time: number, private time: number,
@@ -28,7 +28,7 @@ export class PasteCommand extends Command {
const editor = EditorCore.getInstance(); const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks(); this.savedState = editor.timeline.getTracks();
this.pastedElementIds = []; this.pastedElements = [];
const minStart = Math.min( const minStart = Math.min(
...this.clipboardItems.map((item) => item.element.startTime), ...this.clipboardItems.map((item) => item.element.startTime),
@@ -44,7 +44,6 @@ export class PasteCommand extends Command {
items, items,
minStart, minStart,
time: this.time, time: this.time,
pastedElementIds: this.pastedElementIds,
}); });
if (elementsToAdd.length === 0) { if (elementsToAdd.length === 0) {
@@ -68,6 +67,12 @@ export class PasteCommand extends Command {
...targetTrack, ...targetTrack,
elements: [...targetTrack.elements, ...elementsToAdd], elements: [...targetTrack.elements, ...elementsToAdd],
} as TimelineTrack; } as TimelineTrack;
for (const element of elementsToAdd) {
this.pastedElements.push({
trackId: targetTrack.id,
elementId: element.id,
});
}
continue; continue;
} }
@@ -81,6 +86,12 @@ export class PasteCommand extends Command {
elements: elementsToAdd, elements: elementsToAdd,
}); });
updatedTracks.splice(insertIndex, 0, newTrack); updatedTracks.splice(insertIndex, 0, newTrack);
for (const element of elementsToAdd) {
this.pastedElements.push({
trackId: newTrack.id,
elementId: element.id,
});
}
} }
editor.timeline.updateTracks(updatedTracks); editor.timeline.updateTracks(updatedTracks);
@@ -92,6 +103,10 @@ export class PasteCommand extends Command {
editor.timeline.updateTracks(this.savedState); editor.timeline.updateTracks(this.savedState);
} }
} }
getPastedElements(): { trackId: string; elementId: string }[] {
return this.pastedElements;
}
} }
function groupClipboardItemsByTrackId({ function groupClipboardItemsByTrackId({
@@ -113,12 +128,10 @@ function buildPastedElements({
items, items,
minStart, minStart,
time, time,
pastedElementIds,
}: { }: {
items: ClipboardItem[]; items: ClipboardItem[];
minStart: number; minStart: number;
time: number; time: number;
pastedElementIds: string[];
}): TimelineElement[] { }): TimelineElement[] {
const elementsToAdd: TimelineElement[] = []; const elementsToAdd: TimelineElement[] = [];
@@ -127,7 +140,6 @@ function buildPastedElements({
const startTime = Math.max(0, time + relativeOffset); const startTime = Math.max(0, time + relativeOffset);
const newElementId = generateUUID(); const newElementId = generateUUID();
pastedElementIds.push(newElementId);
elementsToAdd.push({ elementsToAdd.push({
...item.element, ...item.element,
id: newElementId, id: newElementId,
@@ -12,7 +12,7 @@ interface DuplicateElementsParams {
} }
export class DuplicateElementsCommand extends Command { export class DuplicateElementsCommand extends Command {
private duplicatedIds: string[] = []; private duplicatedElements: { trackId: string; elementId: string }[] = [];
private savedState: TimelineTrack[] | null = null; private savedState: TimelineTrack[] | null = null;
private elements: DuplicateElementsParams["elements"]; private elements: DuplicateElementsParams["elements"];
@@ -24,7 +24,7 @@ export class DuplicateElementsCommand extends Command {
execute(): void { execute(): void {
const editor = EditorCore.getInstance(); const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks(); this.savedState = editor.timeline.getTracks();
this.duplicatedIds = []; this.duplicatedElements = [];
const updatedTracks = [...this.savedState]; const updatedTracks = [...this.savedState];
@@ -42,13 +42,22 @@ export class DuplicateElementsCommand extends Command {
); );
const newTrackElements: TimelineElement[] = []; const newTrackElements: TimelineElement[] = [];
const newTrackId = generateUUID();
const newTrackBase = buildEmptyTrack({
id: newTrackId,
type: track.type,
});
for (const element of track.elements) { for (const element of track.elements) {
if (!elementIdsToDuplicate.has(element.id)) { if (!elementIdsToDuplicate.has(element.id)) {
continue; continue;
} }
const newId = generateUUID(); const newId = generateUUID();
this.duplicatedIds.push(newId); this.duplicatedElements.push({
trackId: newTrackId,
elementId: newId,
});
newTrackElements.push( newTrackElements.push(
buildDuplicateElement({ buildDuplicateElement({
element, element,
@@ -58,11 +67,6 @@ export class DuplicateElementsCommand extends Command {
); );
} }
const newTrackId = generateUUID();
const newTrackBase = buildEmptyTrack({
id: newTrackId,
type: track.type,
});
const newTrack = { const newTrack = {
...newTrackBase, ...newTrackBase,
elements: newTrackElements, elements: newTrackElements,
@@ -84,6 +88,10 @@ export class DuplicateElementsCommand extends Command {
editor.timeline.updateTracks(this.savedState); editor.timeline.updateTracks(this.savedState);
} }
} }
getDuplicatedElements(): { trackId: string; elementId: string }[] {
return this.duplicatedElements;
}
} }
function buildDuplicateElement({ function buildDuplicateElement({
@@ -5,8 +5,8 @@ import { EditorCore } from "@/core";
export class SplitElementsCommand extends Command { export class SplitElementsCommand extends Command {
private savedState: TimelineTrack[] | null = null; private savedState: TimelineTrack[] | null = null;
private newElementIds: string[] = []; private rightSideElements: { trackId: string; elementId: string }[] = [];
private affectedElementIds: Set<string> = new Set(); private previousSelection: { trackId: string; elementId: string }[] = [];
constructor( constructor(
private elements: { trackId: string; elementId: string }[], private elements: { trackId: string; elementId: string }[],
@@ -16,15 +16,15 @@ export class SplitElementsCommand extends Command {
super(); super();
} }
get splitElementIds(): string[] { getRightSideElements(): { trackId: string; elementId: string }[] {
return Array.from(this.affectedElementIds); return this.rightSideElements;
} }
execute(): void { execute(): void {
const editor = EditorCore.getInstance(); const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks(); this.savedState = editor.timeline.getTracks();
this.newElementIds = []; this.previousSelection = editor.selection.getSelectedElements();
this.affectedElementIds.clear(); this.rightSideElements = [];
const updatedTracks = this.savedState.map((track) => { const updatedTracks = this.savedState.map((track) => {
const elementsToSplit = this.elements.filter( const elementsToSplit = this.elements.filter(
@@ -61,7 +61,6 @@ export class SplitElementsCommand extends Command {
const rightVisibleDuration = element.duration - relativeTime; const rightVisibleDuration = element.duration - relativeTime;
if (this.retainSide === "left") { if (this.retainSide === "left") {
this.affectedElementIds.add(element.id);
return [ return [
{ {
...element, ...element,
@@ -74,7 +73,10 @@ export class SplitElementsCommand extends Command {
if (this.retainSide === "right") { if (this.retainSide === "right") {
const newId = generateUUID(); const newId = generateUUID();
this.affectedElementIds.add(newId); this.rightSideElements.push({
trackId: track.id,
elementId: newId,
});
return [ return [
{ {
...element, ...element,
@@ -89,9 +91,10 @@ export class SplitElementsCommand extends Command {
// "both" - split into two pieces // "both" - split into two pieces
const secondElementId = generateUUID(); const secondElementId = generateUUID();
this.affectedElementIds.add(element.id); this.rightSideElements.push({
this.affectedElementIds.add(secondElementId); trackId: track.id,
this.newElementIds.push(secondElementId); elementId: secondElementId,
});
return [ return [
{ {
@@ -114,12 +117,17 @@ export class SplitElementsCommand extends Command {
}); });
editor.timeline.updateTracks(updatedTracks); editor.timeline.updateTracks(updatedTracks);
if (this.rightSideElements.length > 0) {
editor.selection.setSelectedElements({ elements: this.rightSideElements });
}
} }
undo(): void { undo(): void {
if (this.savedState) { if (this.savedState) {
const editor = EditorCore.getInstance(); const editor = EditorCore.getInstance();
editor.timeline.updateTracks(this.savedState); editor.timeline.updateTracks(this.savedState);
editor.selection.setSelectedElements({ elements: this.previousSelection });
} }
} }
} }
@@ -7,6 +7,7 @@ import type {
CreateLibraryAudioElement, CreateLibraryAudioElement,
TextElement, TextElement,
TimelineElement, TimelineElement,
TimelineTrack,
AudioElement, AudioElement,
VideoElement, VideoElement,
ImageElement, ImageElement,
@@ -225,3 +226,26 @@ export function buildLibraryAudioElement({
} }
return element; return element;
} }
export function getElementsAtTime({
tracks,
time,
}: {
tracks: TimelineTrack[];
time: number;
}): { trackId: string; elementId: string }[] {
const result: { trackId: string; elementId: string }[] = [];
for (const track of tracks) {
for (const element of track.elements) {
const elementStart = element.startTime;
const elementEnd = element.startTime + element.duration;
if (time > elementStart && time < elementEnd) {
result.push({ trackId: track.id, elementId: element.id });
}
}
}
return result;
}
+11 -3
View File
@@ -7,6 +7,10 @@ import { getDefaultShortcuts } from "@/lib/actions";
import { isTypableDOMElement } from "@/utils/browser"; import { isTypableDOMElement } from "@/utils/browser";
import { isAppleDevice } from "@/utils/platform"; import { isAppleDevice } from "@/utils/platform";
import type { KeybindingConfig, ShortcutKey } from "@/types/keybinding"; import type { KeybindingConfig, ShortcutKey } from "@/types/keybinding";
import {
runMigrations,
CURRENT_VERSION,
} from "./keybindings/migrations";
export const defaultKeybindings: KeybindingConfig = getDefaultShortcuts(); export const defaultKeybindings: KeybindingConfig = getDefaultShortcuts();
@@ -35,8 +39,6 @@ interface KeybindingsState {
action: TActionWithOptionalArgs, action: TActionWithOptionalArgs,
) => KeybindingConflict | null; ) => KeybindingConflict | null;
getKeybindingsForAction: (action: TActionWithOptionalArgs) => ShortcutKey[]; getKeybindingsForAction: (action: TActionWithOptionalArgs) => ShortcutKey[];
// Utility
getKeybindingString: (ev: KeyboardEvent) => ShortcutKey | null; getKeybindingString: (ev: KeyboardEvent) => ShortcutKey | null;
} }
@@ -143,7 +145,13 @@ export const useKeybindingsStore = create<KeybindingsState>()(
}), }),
{ {
name: "opencut-keybindings", name: "opencut-keybindings",
version: 2, version: CURRENT_VERSION,
partialize: (state) => ({
keybindings: state.keybindings,
isCustomized: state.isCustomized,
}),
migrate: (persisted, version) =>
runMigrations({ state: persisted, fromVersion: version }),
}, },
), ),
); );
@@ -0,0 +1,28 @@
import { v2ToV3 } from './v2-to-v3';
type MigrationFn = ({ state }: { state: unknown }) => unknown;
/**
* key = version we're migrating from
* value = migration function
*/
const migrations: Record<number, MigrationFn> = {
2: v2ToV3,
};
export const CURRENT_VERSION = 3;
export function runMigrations({
state,
fromVersion,
}: {
state: unknown;
fromVersion: number;
}): unknown {
let current = state;
for (let version = fromVersion; version < CURRENT_VERSION; version++) {
const migrate = migrations[version];
if (migrate) current = migrate({ state: current });
}
return current;
}
@@ -0,0 +1,26 @@
import type { KeybindingConfig, ShortcutKey } from "@/types/keybinding";
import type { TActionWithOptionalArgs } from "@/lib/actions";
interface V2State {
keybindings: KeybindingConfig;
isCustomized: boolean;
}
export function v2ToV3({ state }: { state: unknown }): unknown {
const v2 = state as V2State;
const renames: Record<string, string> = {
"split-selected": "split",
"split-selected-left": "split-left",
"split-selected-right": "split-right",
};
const migrated = { ...v2.keybindings };
for (const [key, action] of Object.entries(migrated)) {
if (action && renames[action]) {
migrated[key as ShortcutKey] = renames[action] as TActionWithOptionalArgs;
}
}
return { ...v2, keybindings: migrated };
}
-12
View File
@@ -7,12 +7,6 @@ import { create } from "zustand";
import type { ClipboardItem } from "@/types/timeline"; import type { ClipboardItem } from "@/types/timeline";
interface TimelineStore { interface TimelineStore {
selectedElements: { trackId: string; elementId: string }[];
setSelectedElements: ({
elements,
}: {
elements: { trackId: string; elementId: string }[];
}) => void;
snappingEnabled: boolean; snappingEnabled: boolean;
toggleSnapping: () => void; toggleSnapping: () => void;
rippleEditingEnabled: boolean; rippleEditingEnabled: boolean;
@@ -28,12 +22,6 @@ interface TimelineStore {
} }
export const useTimelineStore = create<TimelineStore>((set) => ({ export const useTimelineStore = create<TimelineStore>((set) => ({
selectedElements: [],
setSelectedElements: ({ elements }) => {
set({ selectedElements: elements });
},
snappingEnabled: true, snappingEnabled: true,
toggleSnapping: () => { toggleSnapping: () => {
+243
View File
@@ -0,0 +1,243 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Context Menu 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 */
.context-menu { display: none; }
.context-menu.open { display: block; }
</style>
</head>
<body class="bg-[#ffffff] text-slate-900 min-h-screen p-12">
<div class="max-w-6xl mx-auto space-y-4 mb-12 text-center">
<h1 class="text-2xl font-bold">Context Menu Explorations</h1>
<p class="text-slate-500">Right-click simulations (right-click buttons to toggle).</p>
<div class="flex items-center justify-center gap-4 mt-8">
<button oncontextmenu="event.preventDefault(); toggle(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 transition-colors">
Option 1: Standard
</button>
<button oncontextmenu="event.preventDefault(); toggle(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 transition-colors">
Option 2: Rich with Shortcuts
</button>
<button oncontextmenu="event.preventDefault(); toggle(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 transition-colors">
Option 3: Dense & Technical
</button>
<button oncontextmenu="event.preventDefault(); toggle(4)" 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 transition-colors">
Option 4: Dense & Technical (Icons)
</button>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 items-start max-w-7xl mx-auto relative h-[500px]">
<!-- Option 1: Standard -->
<!-- Characteristics: Simple, clean, standard spacing -->
<div id="menu-1" class="context-menu absolute top-0 left-[15%] -translate-x-1/2 w-64 bg-white rounded-[20px] shadow-xl border border-slate-200 overflow-hidden py-2 z-10">
<div class="flex flex-col gap-0.5">
<a href="#" class="block px-4 py-2 mx-2 text-sm text-slate-600 rounded-xl hover:bg-blue-50 hover:text-[#00A3FF] flex items-center gap-3 group">
<i data-lucide="folder-open" class="w-4 h-4 text-slate-400 group-hover:text-[#00A3FF]"></i>
<span>Open</span>
</a>
<a href="#" class="block px-4 py-2 mx-2 text-sm text-slate-600 rounded-xl hover:bg-blue-50 hover:text-[#00A3FF] flex items-center gap-3 group">
<i data-lucide="edit-2" class="w-4 h-4 text-slate-400 group-hover:text-[#00A3FF]"></i>
<span>Rename</span>
</a>
<a href="#" class="block px-4 py-2 mx-2 text-sm text-slate-600 rounded-xl hover:bg-blue-50 hover:text-[#00A3FF] flex items-center gap-3 group">
<i data-lucide="copy" class="w-4 h-4 text-slate-400 group-hover:text-[#00A3FF]"></i>
<span>Duplicate</span>
</a>
<div class="h-px bg-slate-100 my-1 mx-2"></div>
<a href="#" class="block px-4 py-2 mx-2 text-sm text-slate-600 rounded-xl hover:bg-blue-50 hover:text-[#00A3FF] flex items-center gap-3 group">
<i data-lucide="share" class="w-4 h-4 text-slate-400 group-hover:text-[#00A3FF]"></i>
<span>Share...</span>
</a>
<a href="#" class="block px-4 py-2 mx-2 text-sm text-slate-600 rounded-xl hover:bg-blue-50 hover:text-[#00A3FF] flex items-center gap-3 group">
<i data-lucide="download" class="w-4 h-4 text-slate-400 group-hover:text-[#00A3FF]"></i>
<span>Download</span>
</a>
<div class="h-px bg-slate-100 my-1 mx-2"></div>
<a href="#" class="block px-4 py-2 mx-2 text-sm text-red-500 rounded-xl hover:bg-red-50 flex items-center gap-3 group">
<i data-lucide="trash-2" class="w-4 h-4 group-hover:text-red-600"></i>
<span>Delete</span>
</a>
</div>
</div>
<!-- Option 2: Rich with Shortcuts -->
<!-- Characteristics: Wider, right-aligned shortcuts, grouping -->
<div id="menu-2" class="context-menu absolute top-0 left-[38%] -translate-x-1/2 w-72 bg-white rounded-[20px] shadow-xl border border-slate-200 overflow-hidden py-2 z-10">
<div class="flex flex-col gap-0.5">
<div class="px-4 py-1.5 text-[11px] font-bold uppercase tracking-wider text-slate-400 select-none">
Actions
</div>
<a href="#" class="block px-4 py-2 mx-2 text-sm text-slate-700 rounded-xl hover:bg-blue-50 hover:text-[#00A3FF] flex items-center justify-between group">
<div class="flex items-center gap-3">
<i data-lucide="scissors" class="w-4 h-4 text-slate-400 group-hover:text-[#00A3FF]"></i>
<span>Cut</span>
</div>
<span class="text-xs text-slate-400 group-hover:text-blue-300">⌘X</span>
</a>
<a href="#" class="block px-4 py-2 mx-2 text-sm text-slate-700 rounded-xl hover:bg-blue-50 hover:text-[#00A3FF] flex items-center justify-between group">
<div class="flex items-center gap-3">
<i data-lucide="copy" class="w-4 h-4 text-slate-400 group-hover:text-[#00A3FF]"></i>
<span>Copy</span>
</div>
<span class="text-xs text-slate-400 group-hover:text-blue-300">⌘C</span>
</a>
<a href="#" class="block px-4 py-2 mx-2 text-sm text-slate-700 rounded-xl hover:bg-blue-50 hover:text-[#00A3FF] flex items-center justify-between group">
<div class="flex items-center gap-3">
<i data-lucide="clipboard" class="w-4 h-4 text-slate-400 group-hover:text-[#00A3FF]"></i>
<span>Paste</span>
</div>
<span class="text-xs text-slate-400 group-hover:text-blue-300">⌘V</span>
</a>
<div class="h-px bg-slate-100 my-1 mx-2"></div>
<a href="#" class="block px-4 py-2 mx-2 text-sm text-slate-700 rounded-xl hover:bg-blue-50 hover:text-[#00A3FF] flex items-center justify-between group">
<div class="flex items-center gap-3">
<i data-lucide="maximize" class="w-4 h-4 text-slate-400 group-hover:text-[#00A3FF]"></i>
<span>Properties</span>
</div>
<span class="text-xs text-slate-400 group-hover:text-blue-300">⌘I</span>
</a>
</div>
</div>
<!-- Option 3: Dense & Technical -->
<!-- Characteristics: Smaller text, smaller padding, minimal radius, efficient -->
<div id="menu-3" class="context-menu absolute top-0 left-[62%] -translate-x-1/2 w-60 bg-white rounded-xl shadow-xl border border-slate-200 overflow-hidden py-2.5 z-10">
<div class="flex flex-col">
<a href="#" class="block px-4 py-1.5 text-sm font-medium text-slate-700 hover:bg-slate-50 flex items-center justify-between group">
<span>New Folder</span>
</a>
<a href="#" class="block px-4 py-1.5 text-sm font-medium text-slate-700 hover:bg-slate-50 flex items-center justify-between group">
<span>New File</span>
</a>
<div class="h-px bg-slate-100 my-2"></div>
<div class="relative group/submenu">
<a href="#" class="block px-4 py-1.5 text-sm font-medium text-slate-700 hover:bg-slate-50 flex items-center justify-between">
<span>Sort By</span>
<i data-lucide="chevron-right" class="w-3.5 h-3.5 text-slate-400"></i>
</a>
</div>
<div class="relative group/submenu">
<a href="#" class="block px-4 py-1.5 text-sm font-medium text-slate-700 hover:bg-slate-50 flex items-center justify-between">
<span>View As</span>
<i data-lucide="chevron-right" class="w-3.5 h-3.5 text-slate-400"></i>
</a>
</div>
<div class="h-px bg-slate-100 my-2"></div>
<a href="#" class="block px-4 py-1.5 text-sm font-medium text-slate-700 hover:bg-slate-50 flex items-center justify-between group">
<span>Refresh</span>
<span class="text-xs text-slate-400">R</span>
</a>
</div>
</div>
<!-- Option 4: Dense & Technical (Icons) -->
<div id="menu-4" class="context-menu absolute top-0 left-[85%] -translate-x-1/2 w-60 bg-white rounded-xl shadow-xl border border-slate-200 overflow-hidden py-2.5 z-10">
<div class="flex flex-col">
<a href="#" class="block px-4 py-1.5 text-sm font-medium text-slate-700 hover:bg-slate-50 flex items-center justify-between group">
<div class="flex items-center gap-2.5">
<i data-lucide="folder-plus" class="w-4 h-4 text-slate-400 group-hover:text-slate-600"></i>
<span>New Folder</span>
</div>
</a>
<a href="#" class="block px-4 py-1.5 text-sm font-medium text-slate-700 hover:bg-slate-50 flex items-center justify-between group">
<div class="flex items-center gap-2.5">
<i data-lucide="file-plus" class="w-4 h-4 text-slate-400 group-hover:text-slate-600"></i>
<span>New File</span>
</div>
</a>
<div class="h-px bg-slate-100 my-2"></div>
<div class="relative group/submenu">
<a href="#" class="block px-4 py-1.5 text-sm font-medium text-slate-700 hover:bg-slate-50 flex items-center justify-between">
<div class="flex items-center gap-2.5">
<i data-lucide="arrow-up-down" class="w-4 h-4 text-slate-400"></i>
<span>Sort By</span>
</div>
<i data-lucide="chevron-right" class="w-3.5 h-3.5 text-slate-400"></i>
</a>
</div>
<div class="relative group/submenu">
<a href="#" class="block px-4 py-1.5 text-sm font-medium text-slate-700 hover:bg-slate-50 flex items-center justify-between">
<div class="flex items-center gap-2.5">
<i data-lucide="layout-grid" class="w-4 h-4 text-slate-400"></i>
<span>View As</span>
</div>
<i data-lucide="chevron-right" class="w-3.5 h-3.5 text-slate-400"></i>
</a>
</div>
<div class="h-px bg-slate-100 my-2"></div>
<a href="#" class="block px-4 py-1.5 text-sm font-medium text-slate-700 hover:bg-slate-50 flex items-center justify-between group">
<div class="flex items-center gap-2.5">
<i data-lucide="rotate-cw" class="w-4 h-4 text-slate-400 group-hover:text-slate-600"></i>
<span>Refresh</span>
</div>
<span class="text-xs text-slate-400">R</span>
</a>
</div>
</div>
</div>
<script>
lucide.createIcons();
function toggle(id) {
// Close others
[1, 2, 3, 4].forEach(num => {
if (num !== id) {
document.getElementById(`menu-${num}`).classList.remove('open');
}
});
// Toggle current
const menu = document.getElementById(`menu-${id}`);
if (menu) {
menu.classList.toggle('open');
}
}
// Close when clicking outside
document.addEventListener('click', (e) => {
if (!e.target.closest('button') && !e.target.closest('.context-menu')) {
document.querySelectorAll('.context-menu').forEach(el => el.classList.remove('open'));
}
});
// Open option 2 by default
toggle(2);
</script>
</body>
</html>
+113 -97
View File
@@ -25,18 +25,15 @@
<body class="bg-[#ffffff] text-slate-900 min-h-screen p-12"> <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"> <div class="max-w-4xl mx-auto space-y-4 mb-12 text-center">
<h1 class="text-2xl font-bold">Dialog Design Explorations</h1> <h1 class="text-2xl font-bold">Project Info Explorations</h1>
<p class="text-slate-500">Three interactions for the same brand language. Click buttons to open.</p> <p class="text-slate-500">Two layout approaches using the same "Structured" brand style.</p>
<div class="flex items-center justify-center gap-4 mt-8"> <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"> <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 transition-colors">
Option 1: Soft & Friendly Layout 1: Cards & Grid
</button> </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"> <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 transition-colors">
Option 2: Structured Layout 2: Clean Rows
</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> </button>
</div> </div>
</div> </div>
@@ -44,125 +41,145 @@
<!-- Overlay (Shared) --> <!-- 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"> <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 --> <!-- Option 1: Cards & Grid -->
<!-- Characteristics: Very rounded, generous padding, minimal dividers --> <!-- Focus: High visibility, dashboard feel, grouping info into boxes -->
<div id="dialog-1" class="hidden w-full max-w-md bg-white rounded-[28px] shadow-xl p-8 relative"> <div id="dialog-1" class="hidden w-full max-w-lg bg-white rounded-[20px] shadow-xl overflow-hidden flex flex-col">
<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 --> <!-- Header -->
<div class="px-6 py-5 border-b border-slate-100 flex items-center justify-between bg-white"> <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> <h2 class="text-lg font-bold text-slate-900">Project Info</h2>
<button onclick="closeAll()" class="text-slate-400 hover:text-slate-600"> <button onclick="closeAll()" class="text-slate-400 hover:text-slate-600 transition-colors">
<i data-lucide="x" class="w-5 h-5"></i> <i data-lucide="x" class="w-5 h-5"></i>
</button> </button>
</div> </div>
<!-- Body --> <!-- Body -->
<div class="p-6 space-y-6"> <div class="p-6 space-y-6">
<div class="grid grid-cols-2 gap-4"> <!-- Name Input -->
<div class="space-y-1.5"> <div class="space-y-1.5">
<label class="text-xs font-semibold text-slate-500">Resolution</label> <label class="text-xs font-bold text-slate-500 uppercase tracking-wider">Project Name</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]"> <div class="relative">
<span>1080p HD</span> <input type="text" value="Summer Vlog 2025" class="w-full bg-white border border-slate-200 rounded-xl px-4 py-2.5 text-sm font-medium text-slate-900 focus:outline-none focus:border-[#00A3FF] focus:ring-4 focus:ring-blue-500/10 shadow-sm transition-shadow">
<i data-lucide="chevron-down" class="w-4 h-4 text-slate-400"></i> <div class="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none">
</button> <i data-lucide="pencil" class="w-3.5 h-3.5"></i>
</div> </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> </div>
<div class="p-4 bg-slate-50 rounded-xl border border-slate-100 flex items-start gap-3"> <!-- Stats Grid -->
<i data-lucide="info" class="w-5 h-5 text-[#00A3FF] mt-0.5 flex-shrink-0"></i> <div class="grid grid-cols-3 gap-3">
<div class="text-sm text-slate-600"> <div class="p-3 bg-slate-50 border border-slate-100 rounded-xl text-center">
<p class="font-medium text-slate-900 mb-1">Estimated file size: 245 MB</p> <p class="text-[10px] uppercase tracking-wide font-bold text-slate-400 mb-1">Duration</p>
<p>Exporting may take a few minutes depending on your hardware.</p> <p class="text-sm font-semibold text-slate-900">03:45</p>
</div>
<div class="p-3 bg-slate-50 border border-slate-100 rounded-xl text-center">
<p class="text-[10px] uppercase tracking-wide font-bold text-slate-400 mb-1">Format</p>
<p class="text-sm font-semibold text-slate-900">1080p</p>
</div>
<div class="p-3 bg-slate-50 border border-slate-100 rounded-xl text-center">
<p class="text-[10px] uppercase tracking-wide font-bold text-slate-400 mb-1">FPS</p>
<p class="text-sm font-semibold text-slate-900">60</p>
</div>
</div>
<!-- Storage Info Card -->
<div class="p-4 bg-blue-50/50 rounded-xl border border-blue-100 flex gap-4">
<div class="w-10 h-10 rounded-full bg-blue-100 text-[#00A3FF] flex items-center justify-center flex-shrink-0">
<i data-lucide="hard-drive" class="w-5 h-5"></i>
</div>
<div class="flex-1">
<div class="flex justify-between items-center mb-1.5">
<span class="text-xs font-bold text-slate-700">Storage</span>
<span class="text-xs font-medium text-slate-500">1.2 GB</span>
</div>
<div class="h-2 w-full bg-blue-100 rounded-full overflow-hidden">
<div class="h-full bg-[#00A3FF] w-[35%] rounded-full"></div>
</div>
<p class="text-[11px] text-slate-400 mt-2">Last modified Jan 22, 2026</p>
</div> </div>
</div> </div>
</div> </div>
<!-- Footer --> <!-- Footer -->
<div class="px-6 py-4 bg-slate-50 border-t border-slate-100 flex items-center justify-end gap-3"> <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"> <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 transition-colors">
Cancel Close
</button> </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"> <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 transition-colors">
Export Video Save Changes
</button> </button>
</div> </div>
</div> </div>
<!-- Option 3: Edge Accent --> <!-- Option 2: Clean Rows -->
<!-- Characteristics: Minimalist, blue left border accent, very clean interactions --> <!-- Focus: Scannability, list view, technical details, minimal containers -->
<div id="dialog-3" class="hidden w-full max-w-md bg-white rounded-2xl shadow-xl overflow-hidden relative"> <div id="dialog-2" class="hidden w-full max-w-lg bg-white rounded-[20px] shadow-xl overflow-hidden flex flex-col">
<!-- Accent Bar --> <!-- Header -->
<div class="absolute left-0 top-0 bottom-0 w-1.5 bg-[#00A3FF]"></div> <div class="px-6 py-5 border-b border-slate-100 flex items-center justify-between bg-white">
<div class="flex items-center gap-3">
<div class="pl-8 pr-6 py-6"> <div class="w-8 h-8 rounded-lg bg-gradient-to-br from-slate-100 to-slate-200 border border-slate-200"></div>
<div class="flex items-start justify-between mb-2"> <h2 class="text-lg font-bold text-slate-900">Project Details</h2>
<h2 class="text-lg font-bold text-slate-900">Delete Project?</h2> </div>
<button onclick="closeAll()" class="text-slate-400 hover:text-slate-600 -mt-1 -mr-2 p-2"> <button onclick="closeAll()" class="text-slate-400 hover:text-slate-600 transition-colors">
<i data-lucide="x" class="w-5 h-5"></i> <i data-lucide="x" class="w-5 h-5"></i>
</button> </button>
</div> </div>
<p class="text-slate-500 text-sm mb-6"> <!-- Body -->
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. <div class="px-6 py-2">
</p> <!-- Row 1: Name -->
<div class="py-4 flex items-center justify-between border-b border-slate-50">
<div class="flex items-center gap-3 text-slate-500">
<i data-lucide="type" class="w-4 h-4"></i>
<span class="text-sm font-medium">Name</span>
</div>
<input type="text" value="Summer Vlog 2025" class="text-right text-sm font-semibold text-slate-900 bg-transparent border-none focus:ring-0 w-48 p-0 placeholder-slate-300">
</div>
<div class="flex items-center gap-3 pt-2"> <!-- Row 2: Created -->
<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"> <div class="py-4 flex items-center justify-between border-b border-slate-50">
Yes, delete it <div class="flex items-center gap-3 text-slate-500">
<i data-lucide="calendar" class="w-4 h-4"></i>
<span class="text-sm font-medium">Created</span>
</div>
<span class="text-sm font-medium text-slate-900">Jan 22, 2026</span>
</div>
<!-- Row 3: Technical -->
<div class="py-4 flex items-center justify-between border-b border-slate-50">
<div class="flex items-center gap-3 text-slate-500">
<i data-lucide="monitor" class="w-4 h-4"></i>
<span class="text-sm font-medium">Resolution</span>
</div>
<div class="flex items-center gap-2">
<span class="px-2 py-1 bg-slate-100 rounded-md text-xs font-bold text-slate-600">1080p</span>
<span class="px-2 py-1 bg-slate-100 rounded-md text-xs font-bold text-slate-600">60fps</span>
</div>
</div>
<!-- Row 4: Size -->
<div class="py-4 flex items-center justify-between">
<div class="flex items-center gap-3 text-slate-500">
<i data-lucide="hard-drive" class="w-4 h-4"></i>
<span class="text-sm font-medium">Size</span>
</div>
<span class="text-sm font-medium text-slate-900">1.2 GB</span>
</div>
</div>
<!-- Footer -->
<div class="px-6 py-4 bg-slate-50 border-t border-slate-100 flex items-center justify-between">
<button class="text-xs font-medium text-red-500 hover:text-red-600 transition-colors flex items-center gap-1.5">
<i data-lucide="trash-2" class="w-3.5 h-3.5"></i>
Delete Project
</button> </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"> <div class="flex gap-3">
<button onclick="closeAll()" class="px-4 py-2 text-sm font-medium text-slate-600 hover:text-slate-900 transition-colors">
Cancel Cancel
</button> </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 transition-colors">
Done
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -175,8 +192,7 @@
const overlay = document.getElementById('overlay'); const overlay = document.getElementById('overlay');
const dialogs = [ const dialogs = [
document.getElementById('dialog-1'), document.getElementById('dialog-1'),
document.getElementById('dialog-2'), document.getElementById('dialog-2')
document.getElementById('dialog-3')
]; ];
function openDialog(id) { function openDialog(id) {