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
.cursor/debug.log Normal file
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}

View File

@ -113,6 +113,7 @@ index.ts
renderer: RendererManager
save: SaveManager
audio: AudioManager
selection: SelectionManager
static getInstance(): EditorCore
static reset(): void
}
@ -925,6 +926,13 @@ element-utils.ts
startTime: number;
buffer?: AudioBuffer;
}): CreateLibraryAudioElement
export function getElementsAtTime({
tracks,
time,
}: {
tracks: TimelineTrack[];
time: number;
}): { trackId: string; elementId: string }[]
index.ts
export function calculateTotalDuration({
@ -1692,7 +1700,7 @@ platform.ts
export function getPlatformAlternateKey(): string
export function isAppleDevice(): boolean
strings.ts
string.ts
export function capitalizeFirstLetter({ string }: { string: string })
export function uppercase({ string }: { string: string })

View File

@ -42,7 +42,7 @@ export default async function BlogPage() {
function BlogPostItem({ post }: { post: Post }) {
return (
<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">
<h2 className="text-xl font-semibold">{post.title}</h2>
<p className="text-muted-foreground">{post.description}</p>

View File

@ -180,7 +180,7 @@ function AllContributorsSection({
href={contributor.html_url}
target="_blank"
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">
<Avatar className="size-16">
@ -214,7 +214,7 @@ function ExternalToolsSection() {
<p className="text-muted-foreground">Tools we use to build OpenCut</p>
</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) => (
<Link
key={tool.url}
@ -224,10 +224,8 @@ function ExternalToolsSection() {
style={{ animationDelay: `${index * 100}ms` }}
>
<Card className="h-full">
<CardContent className="flex 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-6" />
</div>
<CardContent className="flex items-center justify-center h-full flex-col gap-4 p-6 text-center">
<tool.icon className="size-8" />
<div className="flex flex-1 flex-col gap-2">
<h3 className="text-lg font-semibold">{tool.name}</h3>
<p className="text-muted-foreground text-sm">

View File

@ -15,6 +15,7 @@
--popover: hsl(0, 0%, 100%);
--popover-foreground: hsl(0 0% 2%);
--primary: hsl(203, 100%, 50%);
--primary-hover: hsl(203, 100%, 45%);
--primary-foreground: hsl(0, 0%, 100%);
--secondary: hsl(216, 13%, 92%);
--secondary-foreground: hsl(0 0% 2%);
@ -44,9 +45,6 @@
--sidebar-ring: hsl(0 0% 16.9%);
--panel-background: hsl(216 13% 96%);
--panel-accent: hsl(216, 8%, 90%);
--radius: 1rem;
--sidebar: hsl(0 0% 98%);
}
.dark {
@ -56,7 +54,8 @@
--card-foreground: hsl(0 0% 89%);
--popover: hsl(0 0% 14.9%);
--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%);
--secondary: hsl(0 0% 14.9%);
--secondary-foreground: hsl(0 0% 98%);
@ -124,15 +123,16 @@
--font-sans: var(--font-inter), sans-serif;
/* Font sizes */
--text-base: 0.95rem;
--text-xl: 1.20rem;
--text-base: 0.92rem;
--text-base--line-height: calc(1.5 / 0.95);
--text-xs: 0.75rem;
--text-xs--line-height: calc(1 / 0.8);
/* Border radius */
--radius-lg: var(--radius);
--radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) - 8px);
--radius-lg: 0.82rem;
--radius-md: 0.65rem;
--radius-sm: 0.35rem;
/* Palette mapped to root design tokens */
--color-background: var(--background);
@ -146,7 +146,7 @@
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-primary-hover: var(--primary-hover);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);

View File

@ -110,7 +110,11 @@ export default function ProjectsPage() {
}
>
{projectsToDisplay.map((project) => (
<ProjectItem key={project.id} project={project} />
<ProjectItem
key={project.id}
project={project}
allProjectIds={projectsToDisplay.map((p) => p.id)}
/>
))}
</div>
)}
@ -144,10 +148,10 @@ function ProjectsHeader() {
</BreadcrumbList>
</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
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" })}
onKeyDown={(event) =>
event.key === "Enter" && setViewMode({ viewMode: "grid" })
@ -159,7 +163,7 @@ function ProjectsHeader() {
</button>
<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" })}
onKeyDown={(event) =>
event.key === "Enter" && setViewMode({ viewMode: "list" })
@ -219,7 +223,7 @@ function ProjectsToolbar({ projectIds }: { projectIds: string[] }) {
};
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">
<Label
className="flex items-center gap-3 cursor-pointer px-2"
@ -298,7 +302,7 @@ function ProjectsToolbar({ projectIds }: { projectIds: string[] }) {
</Button>
</div>
</div>
{selectedProjectCount > 1 ? <ProjectActions /> : null}
{selectedProjectCount > 0 ? <ProjectActions /> : null}
</div>
);
}
@ -518,20 +522,24 @@ function NewProjectButton() {
return (
<Button
variant="primary"
size="lg"
className="flex px-5 md:px-6"
onClick={handleCreateProject}
>
<HugeiconsIcon icon={PlusSignIcon} />
<span className="text-sm font-medium hidden md:block">New project</span>
<span className="text-sm font-medium block md:hidden">New</span>
</Button>
);
}
function ProjectItem({ project }: { project: TProjectMetadata }) {
const { selectedProjectIds, viewMode, setProjectSelected } =
function ProjectItem({
project,
allProjectIds,
}: {
project: TProjectMetadata;
allProjectIds: string[];
}) {
const { selectedProjectIds, viewMode, setProjectSelected, selectProjectRange } =
useProjectsStore();
const selectedProjectIdSet = new Set(selectedProjectIds);
const isSelected = selectedProjectIdSet.has(project.id);
@ -541,6 +549,20 @@ function ProjectItem({ project }: { project: TProjectMetadata }) {
const isMultiSelect = selectedProjectCount > 1;
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 = (
<Card className="bg-background overflow-hidden border-none p-0">
<div className="bg-muted relative aspect-video">
@ -567,7 +589,7 @@ function ProjectItem({ project }: { project: TProjectMetadata }) {
</div>
<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}
</h3>
<div className="text-muted-foreground flex items-center gap-1.5 text-sm">
@ -595,7 +617,7 @@ function ProjectItem({ project }: { project: TProjectMetadata }) {
)}
</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}
</h3>
@ -617,12 +639,14 @@ function ProjectItem({ project }: { project: TProjectMetadata }) {
>
<Checkbox
checked={isSelected}
onCheckedChange={(checked) =>
setProjectSelected({
projectId: project.id,
isSelected: checked === true,
})
}
onMouseDown={(event) => event.preventDefault()}
onClick={(event) => {
handleCheckboxChange({
checked: !isSelected,
shiftKey: event.shiftKey,
});
}}
onCheckedChange={() => {}}
className="size-5 shrink-0"
/>
@ -657,12 +681,14 @@ function ProjectItem({ project }: { project: TProjectMetadata }) {
<>
<Checkbox
checked={isSelected}
onCheckedChange={(checked) =>
setProjectSelected({
projectId: project.id,
isSelected: checked === true,
})
}
onMouseDown={(event) => event.preventDefault()}
onClick={(event) => {
handleCheckboxChange({
checked: !isSelected,
shiftKey: event.shiftKey,
});
}}
onCheckedChange={() => {}}
className={`absolute z-10 size-5 top-3 left-3 ${
isSelected || isDropdownOpen
? "opacity-100"

View File

@ -10,6 +10,7 @@ interface ProjectsState {
sortOrder: "asc" | "desc";
viewMode: ProjectsViewMode;
selectedProjectIds: string[];
lastSelectedProjectId: string | null;
isHydrated: boolean;
setIsHydrated: ({ isHydrated }: { isHydrated: boolean }) => void;
setSearchQuery: ({ query }: { query: string }) => void;
@ -26,6 +27,13 @@ interface ProjectsState {
projectId: string;
isSelected: boolean;
}) => void;
selectProjectRange: ({
projectId,
allProjectIds,
}: {
projectId: string;
allProjectIds: string[];
}) => void;
}
const getNextSelectedProjectIds = ({
@ -56,6 +64,7 @@ export const useProjectsStore = create<ProjectsState>()(
sortOrder: "desc",
viewMode: "grid",
selectedProjectIds: [],
lastSelectedProjectId: null,
isHydrated: false,
setIsHydrated: ({ isHydrated }) => set({ isHydrated }),
setSearchQuery: ({ query }) => set({ searchQuery: query }),
@ -68,7 +77,8 @@ export const useProjectsStore = create<ProjectsState>()(
setViewMode: ({ viewMode }) => set({ viewMode }),
setSelectedProjects: ({ projectIds }) =>
set({ selectedProjectIds: projectIds }),
clearSelectedProjects: () => set({ selectedProjectIds: [] }),
clearSelectedProjects: () =>
set({ selectedProjectIds: [], lastSelectedProjectId: null }),
setProjectSelected: ({ projectId, isSelected }) =>
set((state) => ({
selectedProjectIds: getNextSelectedProjectIds({
@ -76,7 +86,37 @@ export const useProjectsStore = create<ProjectsState>()(
projectId,
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",

View File

@ -29,7 +29,7 @@ export function MediaDragOverlay({
return (
<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"
disabled={isProcessing || !onClick}
onClick={(event) => handleClick({ event })}
@ -50,7 +50,7 @@ export function MediaDragOverlay({
<div className="w-full max-w-xs">
<div className="bg-muted/50 h-2 w-full rounded-full">
<div
className="bg-primary h-2 rounded-full transition-all duration-300"
className="bg-primary h-2 rounded-full"
style={{ width: `${progress}%` }}
/>
</div>

View File

@ -106,7 +106,7 @@ function FadeOverlay({
return (
<div
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
? "from-panel top-0 bg-gradient-to-b to-transparent"
: "from-panel bottom-0 bg-gradient-to-t to-transparent",

View File

@ -196,144 +196,132 @@ export function MediaView() {
<input {...fileInputProps} />
<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}
>
<div className="bg-panel p-3 pb-2">
<div className="flex items-center gap-2">
<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">
<TooltipProvider>
<div className="bg-panel py-2 px-4 flex items-center justify-between border-b">
<span className="text-muted-foreground text-sm">Assets</span>
<div className="flex items-center gap-0">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="text"
onClick={() =>
setMediaViewMode(
mediaViewMode === "grid" ? "list" : "grid",
)
}
disabled={isProcessing}
className="items-center justify-center"
>
{mediaViewMode === "grid" ? (
<HugeiconsIcon icon={LeftToRightListDashIcon} />
) : (
<HugeiconsIcon icon={GridViewIcon} />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>
{mediaViewMode === "grid"
? "Switch to list view"
: "Switch to grid view"}
</p>
</TooltipContent>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="text"
onClick={() =>
setMediaViewMode(
mediaViewMode === "grid" ? "list" : "grid",
)
}
disabled={isProcessing}
className="items-center justify-center"
>
{mediaViewMode === "grid" ? (
<HugeiconsIcon icon={LeftToRightListDashIcon} />
) : (
<HugeiconsIcon icon={GridViewIcon} />
)}
</Button>
</TooltipTrigger>
<DropdownMenu>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="text"
disabled={isProcessing}
className="items-center justify-center"
>
<HugeiconsIcon icon={SortingOneNineIcon} />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<DropdownMenuContent align="end">
<SortMenuItem
label="Name"
sortKey="name"
currentSortBy={sortBy}
currentSortOrder={sortOrder}
onSort={({ key }) => {
if (sortBy === key) {
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
} else {
setSortBy(key);
setSortOrder("asc");
}
}}
/>
<SortMenuItem
label="Type"
sortKey="type"
currentSortBy={sortBy}
currentSortOrder={sortOrder}
onSort={({ key }) => {
if (sortBy === key) {
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
} else {
setSortBy(key);
setSortOrder("asc");
}
}}
/>
<SortMenuItem
label="Duration"
sortKey="duration"
currentSortBy={sortBy}
currentSortOrder={sortOrder}
onSort={({ key }) => {
if (sortBy === key) {
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
} else {
setSortBy(key);
setSortOrder("asc");
}
}}
/>
<SortMenuItem
label="File size"
sortKey="size"
currentSortBy={sortBy}
currentSortOrder={sortOrder}
onSort={({ key }) => {
if (sortBy === key) {
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
} else {
setSortBy(key);
setSortOrder("asc");
}
}}
/>
</DropdownMenuContent>
</DropdownMenu>
<TooltipContent>
<p>
{mediaViewMode === "grid"
? "Switch to list view"
: "Switch to grid view"}
Sort by {sortBy} (
{sortOrder === "asc" ? "ascending" : "descending"})
</p>
</TooltipContent>
<Tooltip>
<DropdownMenu>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="text"
disabled={isProcessing}
className="items-center justify-center"
>
<HugeiconsIcon icon={SortingOneNineIcon} />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<DropdownMenuContent align="end">
<SortMenuItem
label="Name"
sortKey="name"
currentSortBy={sortBy}
currentSortOrder={sortOrder}
onSort={({ key }) => {
if (sortBy === key) {
setSortOrder(
sortOrder === "asc" ? "desc" : "asc",
);
} else {
setSortBy(key);
setSortOrder("asc");
}
}}
/>
<SortMenuItem
label="Type"
sortKey="type"
currentSortBy={sortBy}
currentSortOrder={sortOrder}
onSort={({ key }) => {
if (sortBy === key) {
setSortOrder(
sortOrder === "asc" ? "desc" : "asc",
);
} else {
setSortBy(key);
setSortOrder("asc");
}
}}
/>
<SortMenuItem
label="Duration"
sortKey="duration"
currentSortBy={sortBy}
currentSortOrder={sortOrder}
onSort={({ key }) => {
if (sortBy === key) {
setSortOrder(
sortOrder === "asc" ? "desc" : "asc",
);
} else {
setSortBy(key);
setSortOrder("asc");
}
}}
/>
<SortMenuItem
label="File size"
sortKey="size"
currentSortBy={sortBy}
currentSortOrder={sortOrder}
onSort={({ key }) => {
if (sortBy === key) {
setSortOrder(
sortOrder === "asc" ? "desc" : "asc",
);
} else {
setSortBy(key);
setSortOrder("asc");
}
}}
/>
</DropdownMenuContent>
</DropdownMenu>
<TooltipContent>
<p>
Sort by {sortBy} (
{sortOrder === "asc" ? "ascending" : "descending"})
</p>
</TooltipContent>
</Tooltip>
</Tooltip>
</TooltipProvider>
</div>
</Tooltip>
</TooltipProvider>
<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>

View File

@ -519,7 +519,7 @@ function AudioItem({ sound, isPlaying, onPlay }: AudioItemProps) {
};
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
type="button"
className="flex min-w-0 flex-1 items-center gap-3 text-left"

View File

@ -1,12 +1,16 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Alert, AlertDescription, AlertTitle } from "../ui/alert";
import { Label } from "../ui/label";
import { Input } from "../ui/input";
export function DeleteProjectDialog({
isOpen,
@ -45,25 +49,34 @@ export function DeleteProjectDialog({
`Delete ${count} projects?`
)}
</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>
<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>
<Button
variant="text"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onOpenChange(false);
}}
>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button variant="destructive" onClick={onConfirm}>
Delete
Delete project
</Button>
</DialogFooter>
</DialogContent>

View File

@ -163,7 +163,7 @@ export function DraggableItem({
<div className="size-6 flex-shrink-0 overflow-hidden rounded-[0.35rem]">
{preview}
</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>
</div>
)}

View File

@ -142,7 +142,7 @@ export function EditableTimecode({
disabled={disabled}
className={cn(
"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",
className,
)}

View File

@ -39,9 +39,9 @@ export function ExportButton() {
<button
type="button"
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
? "cursor-pointer hover:brightness-95"
? "cursor-pointer hover:brightness-105"
: "cursor-not-allowed opacity-50",
)}
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" />
<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.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 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.6rem] bg-linear-270 from-[#2567EC] to-[#37B6F7]"></div>
</div>
</div>
</button>

View File

@ -1,12 +1,15 @@
import {
Dialog,
DialogBody,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import type { TProjectMetadata } from "@/types/project";
import { formatDate } from "@/utils/date";
import { formatTimeCode } from "@/lib/time";
import { Button } from "../ui/button";
function InfoRow({
label,
@ -16,7 +19,7 @@ function InfoRow({
value: string | React.ReactNode;
}) {
return (
<div className="flex justify-between items-center py-2 last:pb-0 border-b border-border/50 last:border-b-0">
<div className="flex justify-between items-center py-0 last:pb-0">
<span className="text-muted-foreground text-sm">{label}</span>
<span className="text-sm font-medium">{value}</span>
</div>
@ -42,14 +45,14 @@ export function ProjectInfoDialog({
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent>
<DialogContent onOpenAutoFocus={(event) => event.preventDefault()}>
<DialogHeader>
<DialogTitle className="truncate max-w-[350px]">
{project.name}
</DialogTitle>
</DialogHeader>
<div className="flex flex-col">
<DialogBody className="flex flex-col">
<InfoRow label="Duration" value={durationFormatted} />
<InfoRow
label="Created"
@ -67,7 +70,13 @@ export function ProjectInfoDialog({
</code>
}
/>
</div>
</DialogBody>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Close
</Button>
<Button onClick={() => onOpenChange(false)}>Done</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);

View File

@ -74,10 +74,10 @@ export function PropertyGroup({
<PropertyItem direction="column" className={cn("gap-3", className)}>
<button
type="button"
className="flex items-center gap-1.5"
className="flex items-center gap-1.5 cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
>
<PropertyItemLabel className={cn("cursor-pointer", titleClassName)}>
<PropertyItemLabel className={cn(titleClassName)}>
{title}
</PropertyItemLabel>
<HugeiconsIcon

View File

@ -19,7 +19,7 @@ import {
} from "./property-item";
import { ColorPicker } from "@/components/ui/color-picker";
import { cn } from "@/utils/ui";
import { capitalizeFirstLetter, uppercase } from "@/utils/strings";
import { capitalizeFirstLetter, uppercase } from "@/utils/string";
import { clamp } from "@/utils/math";
import { HugeiconsIcon } from "@hugeicons/react";
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"
>
<HugeiconsIcon icon={LayoutGridIcon}
<HugeiconsIcon
icon={LayoutGridIcon}
className={cn(
"text-foreground",
element.backgroundColor === "transparent" &&

View File

@ -1,6 +1,5 @@
"use client";
import { Keyboard } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import {
@ -11,8 +10,8 @@ import { useKeybindingsStore } from "@/stores/keybindings-store";
import { Button } from "../ui/button";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
@ -107,19 +106,12 @@ export function ShortcutsDialog({
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[80vh] max-w-2xl flex-col p-0">
<DialogHeader className="flex-shrink-0 gap-2 p-6 pb-0">
<DialogTitle className="flex items-center gap-2">
<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>
<DialogTitle>Keyboard shortcuts</DialogTitle>
</DialogHeader>
<div className="scrollbar-thin flex-grow overflow-y-auto">
<div className="flex flex-col gap-6 p-6 pt-2">
<DialogBody className="scrollbar-thin flex-grow overflow-y-auto">
<div className="flex flex-col gap-6">
{categories.map((category) => (
<div key={category} className="flex flex-col gap-1">
<h3 className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
@ -142,9 +134,9 @@ export function ShortcutsDialog({
</div>
))}
</div>
</div>
<DialogFooter className="p-4 pt-0">
<Button size="sm" variant="destructive" onClick={resetToDefaults}>
</DialogBody>
<DialogFooter>
<Button variant="destructive" onClick={resetToDefaults}>
Reset to default
</Button>
</DialogFooter>

View File

@ -166,7 +166,7 @@ export function AudioWaveform({
)}
<div
ref={waveformRef}
className={`w-full transition-opacity duration-200 ${isLoading ? "opacity-0" : "opacity-100"}`}
className={`w-full ${isLoading ? "opacity-0" : "opacity-100"}`}
style={{ height }}
/>
</div>

View File

@ -3,6 +3,7 @@
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Delete02Icon,
TaskAdd02Icon,
ViewIcon,
ViewOffSlashIcon,
VolumeHighIcon,
@ -36,6 +37,7 @@ import {
canTracktHaveAudio,
canTrackBeHidden,
getTimelineZoomMin,
isMainTrack,
} from "@/lib/timeline";
import { TimelineToolbar } from "./timeline-toolbar";
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 { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead";
import { DragLine } from "./drag-line";
import { invokeAction } from "@/lib/actions";
export function Timeline() {
const tracksContainerHeight = { min: 0, max: 800 };
@ -181,7 +184,7 @@ export function Timeline() {
return (
<section
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}
aria-label="Timeline"
@ -267,6 +270,10 @@ export function Timeline() {
}}
>
<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) && (
<TrackToggleIcon
isOff={track.muted}
@ -281,7 +288,6 @@ export function Timeline() {
}
/>
)}
{canTrackBeHidden(track) && (
<TrackToggleIcon
isOff={track.hidden}
@ -296,7 +302,6 @@ export function Timeline() {
}
/>
)}
<TrackIcon track={track} />
</div>
</div>
@ -397,6 +402,15 @@ export function Timeline() {
</div>
</ContextMenuTrigger>
<ContextMenuContent className="z-200 w-40">
<ContextMenuItem
icon={<HugeiconsIcon icon={TaskAdd02Icon} />}
onClick={(e) => {
e.stopPropagation();
invokeAction("paste-copied");
}}
>
Paste elements
</ContextMenuItem>
<ContextMenuItem
onClick={(e) => {
e.stopPropagation();

View File

@ -1,17 +1,5 @@
"use client";
import {
Scissors,
Trash2,
Copy,
Search,
RefreshCw,
EyeOff,
Eye,
Volume2,
VolumeX,
ArrowUpDown,
} from "lucide-react";
import { useEditor } from "@/hooks/use-editor";
import { useAssetsPanelStore } from "@/stores/assets-panel-store";
import AudioWaveform from "./audio-waveform";
@ -39,9 +27,35 @@ import type {
} from "@/types/timeline";
import type { MediaAsset } from "@/types/assets";
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 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 {
element: TimelineElementType;
@ -112,17 +126,6 @@ export function TimelineElement({
displayedDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * 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 }) => {
event.stopPropagation();
if (hasMediaId(element)) {
@ -162,31 +165,16 @@ export function TimelineElement({
/>
</div>
</ContextMenuTrigger>
<ContextMenuContent className="z-200">
<ContextMenuItem
onClick={(event) => handleAction({ action: "split-selected", event })}
>
<Scissors className="mr-2 size-4" />
{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 })}
/>
<ContextMenuContent className="z-200 w-64">
<ActionMenuItem action="split" icon={<HugeiconsIcon icon={ScissorIcon} />}>
Split
</ActionMenuItem>
<CopyMenuItem />
{canElementHaveAudio(element) && hasAudio && (
<MuteMenuItem
element={element}
isMultipleSelected={selectedElements.length > 1}
isCurrentElementSelected={isCurrentElementSelected}
isMuted={isMuted}
selectedCount={selectedElements.length}
onClick={(event) =>
handleAction({ action: "toggle-elements-muted-selected", event })
}
/>
)}
{canElementBeHidden(element) && (
@ -194,36 +182,26 @@ export function TimelineElement({
element={element}
isMultipleSelected={selectedElements.length > 1}
isCurrentElementSelected={isCurrentElementSelected}
selectedCount={selectedElements.length}
onClick={(event) =>
handleAction({
action: "toggle-elements-visibility-selected",
event,
})
}
/>
)}
{selectedElements.length === 1 && (
<ContextMenuItem
onClick={(event) =>
handleAction({ action: "duplicate-selected", event })
}
>
<Copy className="mr-2 size-4" />
Duplicate {element.type === "text" ? "text" : "clip"}
</ContextMenuItem>
<ActionMenuItem action="duplicate-selected" icon={<HugeiconsIcon icon={Copy01Icon} />}>
Duplicate
</ActionMenuItem>
)}
{selectedElements.length === 1 && hasMediaId(element) && (
<>
<ContextMenuItem
icon={<HugeiconsIcon icon={Search01Icon} />}
onClick={(event) => handleRevealInMedia({ event })}
>
<Search className="mr-2 size-4" />
Reveal in media
Reveal media
</ContextMenuItem>
<ContextMenuItem disabled>
<RefreshCw className="mr-2 size-4" />
Replace clip (Coming soon)
<ContextMenuItem
icon={<HugeiconsIcon icon={Exchange01Icon} />}
disabled
>
Replace media
</ContextMenuItem>
</>
)}
@ -233,9 +211,6 @@ export function TimelineElement({
isCurrentElementSelected={isCurrentElementSelected}
elementType={element.type}
selectedCount={selectedElements.length}
onClick={(event) =>
handleAction({ action: "delete-selected", event })
}
/>
</ContextMenuContent>
</ContextMenu>
@ -300,9 +275,15 @@ function ElementInner({
: canElementBeHidden(element) && element.hidden) && (
<div className="bg-opacity-50 pointer-events-none absolute inset-0 flex items-center justify-center bg-black">
{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>
)}
@ -463,66 +444,38 @@ function ElementContent({
);
}
function CopyMenuItem({
isMultipleSelected,
isCurrentElementSelected,
selectedCount,
onClick,
}: {
isMultipleSelected: boolean;
isCurrentElementSelected: boolean;
selectedCount: number;
onClick: (e: React.MouseEvent) => void;
}) {
function CopyMenuItem() {
return (
<ContextMenuItem onClick={onClick}>
<Copy className="mr-2 size-4" />
{isMultipleSelected && isCurrentElementSelected
? `Copy ${selectedCount} elements`
: "Copy element"}
</ContextMenuItem>
<ActionMenuItem action="copy-selected" icon={<HugeiconsIcon icon={Copy01Icon} />}>
Copy
</ActionMenuItem>
);
}
function MuteMenuItem({
element,
isMultipleSelected,
isCurrentElementSelected,
isMuted,
selectedCount,
onClick,
}: {
element: TimelineElementType;
isMultipleSelected: boolean;
isCurrentElementSelected: boolean;
isMuted: boolean;
selectedCount: number;
onClick: (e: React.MouseEvent) => void;
}) {
const getIcon = () => {
if (isMultipleSelected && isCurrentElementSelected) {
return <VolumeX className="mr-2 size-4" />;
return <HugeiconsIcon icon={VolumeMute02Icon} />;
}
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 (
<ContextMenuItem onClick={onClick}>
{getIcon()}
<span>{getLabel()}</span>
</ContextMenuItem>
<ActionMenuItem action="toggle-elements-muted-selected" icon={getIcon()}>
{isMuted ? "Unmute" : "Mute"}
</ActionMenuItem>
);
}
@ -530,41 +483,28 @@ function VisibilityMenuItem({
element,
isMultipleSelected,
isCurrentElementSelected,
selectedCount,
onClick,
}: {
element: TimelineElementType;
isMultipleSelected: boolean;
isCurrentElementSelected: boolean;
selectedCount: number;
onClick: (e: React.MouseEvent) => void;
}) {
const isHidden = canElementBeHidden(element) && element.hidden;
const getIcon = () => {
if (isMultipleSelected && isCurrentElementSelected) {
return <EyeOff className="mr-2 size-4" />;
return <HugeiconsIcon icon={ViewOffSlashIcon} />;
}
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 (
<ContextMenuItem onClick={onClick}>
{getIcon()}
<span>{getLabel()}</span>
</ContextMenuItem>
<ActionMenuItem action="toggle-elements-visibility-selected" icon={getIcon()}>
{isHidden ? "Show" : "Hide"}
</ActionMenuItem>
);
}
@ -573,23 +513,42 @@ function DeleteMenuItem({
isCurrentElementSelected,
elementType,
selectedCount,
onClick,
}: {
isMultipleSelected: boolean;
isCurrentElementSelected: boolean;
elementType: TimelineElementType["type"];
selectedCount: number;
onClick: (e: React.MouseEvent) => void;
}) {
return (
<ContextMenuItem
onClick={onClick}
className="text-destructive focus:text-destructive"
<ActionMenuItem
action="delete-selected"
variant="destructive"
icon={<HugeiconsIcon icon={Delete02Icon} />}
>
<Trash2 className="mr-2 size-4" />
{isMultipleSelected && isCurrentElementSelected
? `Delete ${selectedCount} elements`
: `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>
);
}

View File

@ -6,11 +6,7 @@ import {
TooltipContent,
} from "@/components/ui/tooltip";
import { Button } from "@/components/ui/button";
import {
SkipBack,
SplitSquareHorizontal,
LayersIcon,
} from "lucide-react";
import { SkipBack, SplitSquareHorizontal } from "lucide-react";
import {
SplitButton,
SplitButtonLeft,
@ -38,10 +34,6 @@ import {
PauseIcon,
PlayIcon,
Copy01Icon,
ArrowLeft03Icon,
ArrowRight03Icon,
LayoutAlignLeftIcon,
LayoutAlignRightIcon,
AlignLeftIcon,
AlignRightIcon,
Layers01Icon,
@ -136,7 +128,7 @@ function ToolbarLeftSection() {
icon={<HugeiconsIcon icon={ScissorIcon} />}
tooltip="Split element"
onClick={({ event }) =>
handleAction({ action: "split-selected", event })
handleAction({ action: "split", event })
}
/>
@ -144,7 +136,7 @@ function ToolbarLeftSection() {
icon={<HugeiconsIcon icon={AlignLeftIcon} />}
tooltip="Split left"
onClick={({ event }) =>
handleAction({ action: "split-selected-right", event })
handleAction({ action: "split-left", event })
}
/>
@ -152,7 +144,7 @@ function ToolbarLeftSection() {
icon={<HugeiconsIcon icon={AlignRightIcon} />}
tooltip="Split right"
onClick={({ event }) =>
handleAction({ action: "split-selected-left", event })
handleAction({ action: "split-right", event })
}
/>

View File

@ -3,7 +3,7 @@ import { RiDiscordFill, RiTwitterXLine } from "react-icons/ri";
import { FaGithub } from "react-icons/fa6";
import Image from "next/image";
import { DEFAULT_LOGO_URL, SOCIAL_LINKS } from "@/constants/site-constants";
import { capitalizeFirstLetter } from "@/utils/strings";
import { capitalizeFirstLetter } from "@/utils/string";
type Category = "resources" | "company";

View File

@ -74,7 +74,7 @@ export function Header() {
</Button>
</Link>
<Link href="/projects">
<Button className="text-sm">
<Button variant="foreground" className="text-sm">
Projects
<ArrowRight className="size-4" />
</Button>

View File

@ -30,9 +30,10 @@ export function Hero() {
<div className="mt-8 flex justify-center gap-8">
<Link href="/projects">
<Button
variant="foreground"
type="submit"
size="lg"
className="bg-foreground h-11 px-6 text-base"
className="h-11 text-base"
>
Try early beta
<ArrowRight className="ml-0.5" />

View File

@ -1,6 +1,7 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
@ -9,6 +10,7 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { useState } from "react";
import { Label } from "./ui/label";
export function RenameProjectDialog({
isOpen,
@ -34,24 +36,23 @@ export function RenameProjectDialog({
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Rename Project</DialogTitle>
<DialogDescription>
Enter a new name for your project.
</DialogDescription>
<DialogTitle>Rename project</DialogTitle>
</DialogHeader>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
onConfirm(name);
}
}}
placeholder="Enter a new name"
className="bg-background border-border mt-0 border-2"
/>
<DialogBody className="gap-3">
<Label>New name</Label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
onConfirm(name);
}
}}
placeholder="Enter a new name"
/>
</DialogBody>
<DialogFooter>
<Button

View File

@ -28,13 +28,13 @@ const AccordionTrigger = React.forwardRef<
<AccordionPrimitive.Trigger
ref={ref}
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,
)}
{...props}
>
{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.Header>
));

View File

@ -1,16 +1,18 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { AlertTriangle } from "lucide-react";
import { cn } from "@/utils/ui";
import { HugeiconsIcon } from "@hugeicons/react";
import { Alert02Icon } from "@hugeicons/core-free-icons";
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: {
variant: {
default: "bg-background text-foreground",
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: {
@ -22,13 +24,18 @@ const alertVariants = cva(
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
>(({ className, variant, children, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
>
{variant === "destructive" && (
<HugeiconsIcon icon={Alert02Icon} className="size-5 text-destructive mt-0.5" />
)}
{children}
</div>
));
Alert.displayName = "Alert";
@ -38,7 +45,7 @@ const AlertTitle = React.forwardRef<
>(({ className, ...props }, ref) => (
<h5
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}
/>
));

View File

@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/utils/ui";
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: {
variant: {

View File

@ -50,7 +50,7 @@ const BreadcrumbLink = React.forwardRef<
return (
<Comp
ref={ref}
className={cn("hover:text-foreground transition-colors", className)}
className={cn("hover:text-foreground", className)}
{...props}
/>
);

View File

@ -5,34 +5,32 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/utils/ui";
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: {
variant: {
default:
"bg-foreground text-background shadow-sm hover:bg-foreground/90",
"bg-primary text-primary-foreground hover:bg-primary-hover",
foreground:
"bg-background text-foreground",
primary:
"bg-primary text-primary-foreground hover:bg-primary/90",
"bg-foreground text-background hover:bg-foreground/90",
"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:
"bg-destructive text-destructive-foreground hover:bg-destructive/80",
"destructive-foreground":
"border bg-background hover:bg-destructive/15 text-destructive",
outline:
"border border-border bg-transparent transition-colors hover:bg-accent",
"border border-border bg-transparent hover:bg-accent/50",
secondary:
"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",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-full px-3 text-xs",
lg: "h-10 rounded-full p-5 px-6",
icon: "size-7 rounded-sm",
default: "h-9.5 px-4 py-2",
sm: "h-8 px-3 text-xs",
lg: "h-10 p-5 px-6",
icon: "size-7",
text: "p-0",
},
},

View File

@ -13,7 +13,7 @@ const Checkbox = React.forwardRef<
<CheckboxPrimitive.Root
ref={ref}
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,
)}
{...props}

View File

@ -242,7 +242,7 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
>
<button
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}` }}
type="button"
onClick={() => {

View File

@ -2,10 +2,10 @@
import * as React from "react";
import { ContextMenu as ContextMenuPrimitive } from "radix-ui";
import { Check, ChevronRight, Circle } from "lucide-react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/utils/ui";
import { HugeiconsIcon } from "@hugeicons/react";
import { Tick02Icon, ArrowRightIcon, CircleIcon } from "@hugeicons/core-free-icons"
const ContextMenu = ContextMenuPrimitive.Root;
@ -20,12 +20,12 @@ const ContextMenuSub = ContextMenuPrimitive.Sub;
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
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: {
variant: {
default: "focus:opacity-65 focus:text-accent-foreground",
destructive: "text-destructive focus:text-destructive/80",
default: "focus:bg-accent/35 focus:text-accent-foreground [&_svg]:text-muted-foreground",
destructive: "text-destructive focus:bg-destructive/5 focus:text-destructive [&_svg]:text-destructive",
},
},
defaultVariants: {
@ -39,20 +39,22 @@ const ContextMenuSubTrigger = React.forwardRef<
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean;
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
ref={ref}
className={cn(
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",
className,
)}
{...props}
>
{icon && <span className="size-4 shrink-0 text-muted-foreground">{icon}</span>}
{children}
<ChevronRight className="ml-auto" />
<HugeiconsIcon icon={ArrowRightIcon} className="ml-auto text-muted-foreground/80" />
</ContextMenuPrimitive.SubTrigger>
));
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
@ -64,7 +66,7 @@ const ContextMenuSubContent = React.forwardRef<
<ContextMenuPrimitive.SubContent
ref={ref}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 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,
)}
{...props}
@ -80,8 +82,7 @@ const ContextMenuContent = React.forwardRef<
<ContextMenuPrimitive.Content
ref={ref}
className={cn(
"bg-popover text-popover-foreground z-50 min-w-32 overflow-hidden rounded-md border p-1 shadow-md",
"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",
"bg-popover text-popover-foreground z-50 min-w-48 overflow-hidden rounded-lg border shadow-xl py-2.5",
className,
)}
{...props}
@ -95,8 +96,10 @@ const ContextMenuItem = React.forwardRef<
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
inset?: boolean;
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
ref={ref}
className={cn(
@ -105,7 +108,15 @@ const ContextMenuItem = React.forwardRef<
className,
)}
{...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;
@ -113,8 +124,9 @@ const ContextMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem> & {
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
ref={ref}
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">
<ContextMenuPrimitive.ItemIndicator>
<Check className="size-4" />
<HugeiconsIcon icon={Tick02Icon} className="size-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{icon && <span className="size-4 shrink-0 text-muted-foreground">{icon}</span>}
{children}
</ContextMenuPrimitive.CheckboxItem>
));
@ -136,8 +149,9 @@ const ContextMenuRadioItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem> & {
variant?: VariantProps<typeof contextMenuItemVariants>["variant"];
icon?: React.ReactNode;
}
>(({ className, children, variant = "default", ...props }, ref) => (
>(({ className, children, variant = "default", icon, ...props }, ref) => (
<ContextMenuPrimitive.RadioItem
ref={ref}
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">
<ContextMenuPrimitive.ItemIndicator>
<Circle className="size-2 fill-current" />
<HugeiconsIcon icon={CircleIcon} className="size-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{icon && <span className="size-4 shrink-0 text-muted-foreground">{icon}</span>}
{children}
</ContextMenuPrimitive.RadioItem>
));
@ -157,17 +172,21 @@ const ContextMenuLabel = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
inset?: boolean;
icon?: React.ReactNode;
}
>(({ className, inset, ...props }, ref) => (
>(({ className, inset, icon, children, ...props }, ref) => (
<ContextMenuPrimitive.Label
ref={ref}
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",
className,
)}
{...props}
/>
>
{icon && <span className="size-4 shrink-0 text-muted-foreground">{icon}</span>}
{children}
</ContextMenuPrimitive.Label>
));
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName;
@ -177,7 +196,7 @@ const ContextMenuSeparator = React.forwardRef<
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Separator
ref={ref}
className={cn("bg-foreground/10 -mx-1 my-1 h-px", className)}
className={cn("bg-border my-2 h-px", className)}
{...props}
/>
));
@ -189,7 +208,7 @@ const ContextMenuShortcut = ({
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<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}
/>
);

View File

@ -3,9 +3,7 @@
import * as React from "react";
import { Dialog as DialogPrimitive } from "radix-ui";
import { X } from "lucide-react";
import { cn } from "@/utils/ui";
import { ScrollArea } from "./scroll-area";
const Dialog = DialogPrimitive.Root;
@ -22,7 +20,7 @@ const DialogOverlay = React.forwardRef<
<DialogPrimitive.Overlay
ref={ref}
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,
)}
{...props}
@ -39,7 +37,7 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
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,
)}
onCloseAutoFocus={(e) => {
@ -49,8 +47,8 @@ const DialogContent = React.forwardRef<
{...props}
>
{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">
<X className="size-4" />
<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-5 text-muted-foreground" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
@ -63,19 +61,27 @@ const DialogHeader = ({
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<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}
/>
);
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 = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
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,
)}
{...props}
@ -118,6 +124,7 @@ export {
DialogClose,
DialogContent,
DialogHeader,
DialogBody,
DialogFooter,
DialogTitle,
DialogDescription,

View File

@ -20,7 +20,7 @@ const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
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: {
variant: {
@ -88,7 +88,7 @@ const DropdownMenuContent = React.forwardRef<
e.preventDefault();
}}
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,
)}
{...props}

View File

@ -45,7 +45,7 @@ const InputOTPSlot = React.forwardRef<
<div
ref={ref}
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",
className,
)}

View File

@ -41,7 +41,7 @@ export function InputWithBack({
return (
<div ref={setContainerRef} className="relative w-full">
<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={{
x: isExpanded ? 0 : buttonOffset,
opacity: isExpanded ? 1 : 0.5,

View File

@ -8,9 +8,13 @@ import { forwardRef, type ComponentProps } from "react";
import { useState } from "react";
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: {
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: {
default:
"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: {
size: "default",
variant: "default",
},
},
);
@ -40,6 +45,7 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
className,
type,
size,
variant,
containerClassName,
showPassword,
onShowPasswordChange,
@ -79,6 +85,7 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
inputVariants({
size,
className: cn(paddingRight, className),
variant,
}),
)}
ref={ref}

View File

@ -7,7 +7,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/utils/ui";
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<

View File

@ -41,7 +41,7 @@ NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName;
const NavigationMenuItem = NavigationMenuPrimitive.Item;
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<

View File

@ -18,7 +18,7 @@ const Progress = React.forwardRef<
{...props}
>
<ProgressPrimitive.Indicator
className="bg-foreground size-full flex-1 transition-all"
className="bg-foreground size-full flex-1"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>

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.Range className="bg-primary absolute h-full" />
</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>
));
Slider.displayName = SliderPrimitive.Root.displayName;

View File

@ -18,7 +18,7 @@ const SplitButton = forwardRef<HTMLDivElement, SplitButtonProps>(
<div
ref={ref}
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,
)}
{...props}
@ -39,7 +39,7 @@ const SplitButtonSide = forwardRef<
ref={ref}
variant="text"
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
? "hover:bg-foreground/10 cursor-pointer hover:opacity-100"
: "cursor-default select-text",

View File

@ -11,7 +11,7 @@ const Switch = React.forwardRef<
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
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,
)}
{...props}
@ -19,7 +19,7 @@ const Switch = React.forwardRef<
>
<SwitchPrimitives.Thumb
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>

View File

@ -58,7 +58,7 @@ const TableRow = React.forwardRef<
<tr
ref={ref}
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,
)}
{...props}

View File

@ -25,7 +25,7 @@ const ToastViewport = React.forwardRef<
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
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: {
variant: {
@ -62,7 +62,7 @@ const ToastAction = React.forwardRef<
<ToastPrimitives.Action
ref={ref}
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,
)}
{...props}
@ -77,7 +77,7 @@ const ToastClose = React.forwardRef<
<ToastPrimitives.Close
ref={ref}
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,
)}
toast-close=""

View File

@ -7,7 +7,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/utils/ui";
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: {
variant: {

View File

@ -27,12 +27,6 @@ export const EXTERNAL_TOOLS: ExternalTool[] = [
url: "https://marblecms.com?utm_source=opencut",
icon: OcMarbleIcon,
},
{
name: "Vercel",
description: "Platform where we deploy and host OpenCut",
url: "https://vercel.com?utm_source=opencut",
icon: OcVercelIcon,
},
{
name: "Databuddy",
description: "GDPR compliant analytics and user insights for OpenCut",

View File

@ -7,6 +7,7 @@ import { RendererManager } from "./managers/renderer-manager";
import { CommandManager } from "./managers/commands";
import { SaveManager } from "./managers/save-manager";
import { AudioManager } from "./managers/audio-manager";
import { SelectionManager } from "./managers/selection-manager";
export class EditorCore {
private static instance: EditorCore | null = null;
@ -20,6 +21,7 @@ export class EditorCore {
public readonly renderer: RendererManager;
public readonly save: SaveManager;
public readonly audio: AudioManager;
public readonly selection: SelectionManager;
private constructor() {
this.command = new CommandManager();
@ -31,6 +33,7 @@ export class EditorCore {
this.renderer = new RendererManager(this);
this.save = new SaveManager(this);
this.audio = new AudioManager(this);
this.selection = new SelectionManager(this);
this.save.start();
}

View File

@ -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());
}
}

View File

@ -142,10 +142,10 @@ export class TimelineManager {
elements: { trackId: string; elementId: string }[];
splitTime: number;
retainSide?: "both" | "left" | "right";
}): string[] {
}): { trackId: string; elementId: string }[] {
const command = new SplitElementsCommand(elements, splitTime, retainSide);
this.editor.command.execute({ command });
return command.splitElementIds;
return command.getRightSideElements();
}
getTotalDuration(): number {
@ -184,9 +184,10 @@ export class TimelineManager {
}: {
time: number;
clipboardItems: ClipboardItem[];
}): void {
}): { trackId: string; elementId: string }[] {
const command = new PasteCommand(time, clipboardItems);
this.editor.command.execute({ command });
return command.getPastedElements();
}
deleteElements({
@ -230,9 +231,10 @@ export class TimelineManager {
elements,
}: {
elements: { trackId: string; elementId: string }[];
}): void {
}): { trackId: string; elementId: string }[] {
const command = new DuplicateElementsCommand({ elements });
this.editor.command.execute({ command });
return command.getDuplicatedElements();
}
toggleElementsVisibility({

View File

@ -4,6 +4,7 @@ import { useTimelineStore } from "@/stores/timeline-store";
import { useActionHandler } from "@/hooks/actions/use-action-handler";
import { useEditor } from "../use-editor";
import { useElementSelection } from "../timeline/element/use-element-selection";
import { getElementsAtTime } from "@/lib/timeline";
export function useEditorActions() {
const editor = useEditor();
@ -122,40 +123,73 @@ export function useEditorActions() {
);
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({
elements: selectedElements,
splitTime: editor.playback.getCurrentTime(),
elements: elementsToSplit,
splitTime: currentTime,
});
},
undefined,
);
useActionHandler(
"split-selected-left",
"split-left",
() => {
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: "right",
});
},
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: selectedElements,
splitTime: editor.playback.getCurrentTime(),
elements: elementsToSplit,
splitTime: currentTime,
retainSide: "left",
});
},
undefined,
);
useActionHandler(
"split-selected-right",
() => {
editor.timeline.splitElements({
elements: selectedElements,
splitTime: editor.playback.getCurrentTime(),
retainSide: "right",
});
},
undefined,
);
useActionHandler(
"delete-selected",
() => {
@ -186,7 +220,10 @@ export function useEditorActions() {
useActionHandler(
"duplicate-selected",
() => {
editor.timeline.duplicateElements({ elements: selectedElements });
const duplicatedElements = editor.timeline.duplicateElements({
elements: selectedElements,
});
setElementSelection({ elements: duplicatedElements });
},
undefined,
);
@ -238,14 +275,16 @@ export function useEditorActions() {
);
useActionHandler(
"paste-selected",
"paste-copied",
() => {
if (!clipboard?.items.length) return;
editor.timeline.pasteAtTime({
time: editor.playback.getCurrentTime(),
clipboardItems: clipboard.items,
});
const pastedElements =
editor.timeline.pasteAtTime({
time: editor.playback.getCurrentTime(),
clipboardItems: clipboard.items,
}) ?? [];
setElementSelection({ elements: pastedElements });
},
undefined,
);

View File

@ -1,10 +1,14 @@
import { useCallback } from "react";
import { useTimelineStore } from "@/stores/timeline-store";
import { useCallback, useSyncExternalStore } from "react";
import { useEditor } from "@/hooks/use-editor";
type ElementRef = { trackId: string; elementId: string };
export function useElementSelection() {
const { selectedElements, setSelectedElements } = useTimelineStore();
const editor = useEditor();
const selectedElements = useSyncExternalStore(
(listener) => editor.selection.subscribe(listener),
() => editor.selection.getSelectedElements(),
);
const isElementSelected = useCallback(
({ trackId, elementId }: ElementRef) =>
@ -17,9 +21,9 @@ export function useElementSelection() {
const selectElement = useCallback(
({ trackId, elementId }: ElementRef) => {
setSelectedElements({ elements: [{ trackId, elementId }] });
editor.selection.setSelectedElements({ elements: [{ trackId, elementId }] });
},
[setSelectedElements],
[editor],
);
const addElementToSelection = useCallback(
@ -30,23 +34,23 @@ export function useElementSelection() {
);
if (alreadySelected) return;
setSelectedElements({
editor.selection.setSelectedElements({
elements: [...selectedElements, { trackId, elementId }],
});
},
[selectedElements, setSelectedElements],
[selectedElements, editor],
);
const removeElementFromSelection = useCallback(
({ trackId, elementId }: ElementRef) => {
setSelectedElements({
editor.selection.setSelectedElements({
elements: selectedElements.filter(
(element) =>
!(element.trackId === trackId && element.elementId === elementId),
),
});
},
[selectedElements, setSelectedElements],
[selectedElements, editor],
);
const toggleElementSelection = useCallback(
@ -66,14 +70,14 @@ export function useElementSelection() {
);
const clearElementSelection = useCallback(() => {
setSelectedElements({ elements: [] });
}, [setSelectedElements]);
editor.selection.clearSelection();
}, [editor]);
const setElementSelection = useCallback(
({ elements }: { elements: ElementRef[] }) => {
setSelectedElements({ elements });
editor.selection.setSelectedElements({ elements });
},
[setSelectedElements],
[editor],
);
/**

View File

@ -19,6 +19,7 @@ export function useEditor(): EditorCore {
editor.project.subscribe(handleStoreChange),
editor.media.subscribe(handleStoreChange),
editor.renderer.subscribe(handleStoreChange),
editor.selection.subscribe(handleStoreChange),
];
return () => {

View File

@ -12,6 +12,7 @@ export function useKeybindingsListener() {
useKeybindingsStore();
useEffect(() => {
const eventOptions: AddEventListenerOptions = { capture: true };
const handleKeyDown = (ev: KeyboardEvent) => {
// do not check keybinds if the mode is disabled
if (!keybindingsEnabled) return;
@ -35,28 +36,28 @@ export function useKeybindingsListener() {
ev.preventDefault();
switch (boundAction) {
case "seek-forward":
invokeAction("seek-forward", { seconds: 1 }, "keypress");
break;
case "seek-backward":
invokeAction("seek-backward", { seconds: 1 }, "keypress");
break;
case "jump-forward":
invokeAction("jump-forward", { seconds: 5 }, "keypress");
break;
case "jump-backward":
invokeAction("jump-backward", { seconds: 5 }, "keypress");
break;
default:
invokeAction(boundAction, undefined, "keypress");
}
switch (boundAction) {
case "seek-forward":
invokeAction("seek-forward", { seconds: 1 }, "keypress");
break;
case "seek-backward":
invokeAction("seek-backward", { seconds: 1 }, "keypress");
break;
case "jump-forward":
invokeAction("jump-forward", { seconds: 5 }, "keypress");
break;
case "jump-backward":
invokeAction("jump-backward", { seconds: 5 }, "keypress");
break;
default:
invokeAction(boundAction, undefined, "keypress");
}
};
document.addEventListener("keydown", handleKeyDown);
document.addEventListener("keydown", handleKeyDown, eventOptions);
return () => {
document.removeEventListener("keydown", handleKeyDown);
document.removeEventListener("keydown", handleKeyDown, eventOptions);
};
}, [keybindings, getKeybindingString, keybindingsEnabled, isRecording]);
}

View File

@ -70,32 +70,32 @@ export const ACTIONS = {
category: "navigation",
defaultShortcuts: ["end"],
},
"split-selected": {
description: "Split element at playhead",
split: {
description: "Split elements at playhead",
category: "editing",
defaultShortcuts: ["s"],
},
"split-selected-left": {
description: "Split element at playhead and keep left",
"split-left": {
description: "Split and remove left",
category: "editing",
defaultShortcuts: ["q"],
},
"split-selected-right": {
description: "Split element at playhead and keep right",
"split-right": {
description: "Split and remove right",
category: "editing",
defaultShortcuts: ["w"],
},
"delete-selected": {
description: "Delete selected elements",
category: "editing",
defaultShortcuts: ["delete", "backspace"],
defaultShortcuts: ["backspace", "delete"],
},
"copy-selected": {
description: "Copy selected elements",
category: "editing",
defaultShortcuts: ["ctrl+c"],
},
"paste-selected": {
"paste-copied": {
description: "Paste elements at playhead",
category: "editing",
defaultShortcuts: ["ctrl+v"],

View File

@ -14,7 +14,7 @@ import {
export class PasteCommand extends Command {
private savedState: TimelineTrack[] | null = null;
private pastedElementIds: string[] = [];
private pastedElements: { trackId: string; elementId: string }[] = [];
constructor(
private time: number,
@ -28,7 +28,7 @@ export class PasteCommand extends Command {
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();
this.pastedElementIds = [];
this.pastedElements = [];
const minStart = Math.min(
...this.clipboardItems.map((item) => item.element.startTime),
@ -44,7 +44,6 @@ export class PasteCommand extends Command {
items,
minStart,
time: this.time,
pastedElementIds: this.pastedElementIds,
});
if (elementsToAdd.length === 0) {
@ -68,6 +67,12 @@ export class PasteCommand extends Command {
...targetTrack,
elements: [...targetTrack.elements, ...elementsToAdd],
} as TimelineTrack;
for (const element of elementsToAdd) {
this.pastedElements.push({
trackId: targetTrack.id,
elementId: element.id,
});
}
continue;
}
@ -81,6 +86,12 @@ export class PasteCommand extends Command {
elements: elementsToAdd,
});
updatedTracks.splice(insertIndex, 0, newTrack);
for (const element of elementsToAdd) {
this.pastedElements.push({
trackId: newTrack.id,
elementId: element.id,
});
}
}
editor.timeline.updateTracks(updatedTracks);
@ -92,6 +103,10 @@ export class PasteCommand extends Command {
editor.timeline.updateTracks(this.savedState);
}
}
getPastedElements(): { trackId: string; elementId: string }[] {
return this.pastedElements;
}
}
function groupClipboardItemsByTrackId({
@ -113,12 +128,10 @@ function buildPastedElements({
items,
minStart,
time,
pastedElementIds,
}: {
items: ClipboardItem[];
minStart: number;
time: number;
pastedElementIds: string[];
}): TimelineElement[] {
const elementsToAdd: TimelineElement[] = [];
@ -127,7 +140,6 @@ function buildPastedElements({
const startTime = Math.max(0, time + relativeOffset);
const newElementId = generateUUID();
pastedElementIds.push(newElementId);
elementsToAdd.push({
...item.element,
id: newElementId,

View File

@ -12,7 +12,7 @@ interface DuplicateElementsParams {
}
export class DuplicateElementsCommand extends Command {
private duplicatedIds: string[] = [];
private duplicatedElements: { trackId: string; elementId: string }[] = [];
private savedState: TimelineTrack[] | null = null;
private elements: DuplicateElementsParams["elements"];
@ -24,7 +24,7 @@ export class DuplicateElementsCommand extends Command {
execute(): void {
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();
this.duplicatedIds = [];
this.duplicatedElements = [];
const updatedTracks = [...this.savedState];
@ -42,13 +42,22 @@ export class DuplicateElementsCommand extends Command {
);
const newTrackElements: TimelineElement[] = [];
const newTrackId = generateUUID();
const newTrackBase = buildEmptyTrack({
id: newTrackId,
type: track.type,
});
for (const element of track.elements) {
if (!elementIdsToDuplicate.has(element.id)) {
continue;
}
const newId = generateUUID();
this.duplicatedIds.push(newId);
this.duplicatedElements.push({
trackId: newTrackId,
elementId: newId,
});
newTrackElements.push(
buildDuplicateElement({
element,
@ -58,11 +67,6 @@ export class DuplicateElementsCommand extends Command {
);
}
const newTrackId = generateUUID();
const newTrackBase = buildEmptyTrack({
id: newTrackId,
type: track.type,
});
const newTrack = {
...newTrackBase,
elements: newTrackElements,
@ -84,6 +88,10 @@ export class DuplicateElementsCommand extends Command {
editor.timeline.updateTracks(this.savedState);
}
}
getDuplicatedElements(): { trackId: string; elementId: string }[] {
return this.duplicatedElements;
}
}
function buildDuplicateElement({

View File

@ -5,8 +5,8 @@ import { EditorCore } from "@/core";
export class SplitElementsCommand extends Command {
private savedState: TimelineTrack[] | null = null;
private newElementIds: string[] = [];
private affectedElementIds: Set<string> = new Set();
private rightSideElements: { trackId: string; elementId: string }[] = [];
private previousSelection: { trackId: string; elementId: string }[] = [];
constructor(
private elements: { trackId: string; elementId: string }[],
@ -16,15 +16,15 @@ export class SplitElementsCommand extends Command {
super();
}
get splitElementIds(): string[] {
return Array.from(this.affectedElementIds);
getRightSideElements(): { trackId: string; elementId: string }[] {
return this.rightSideElements;
}
execute(): void {
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();
this.newElementIds = [];
this.affectedElementIds.clear();
this.previousSelection = editor.selection.getSelectedElements();
this.rightSideElements = [];
const updatedTracks = this.savedState.map((track) => {
const elementsToSplit = this.elements.filter(
@ -61,7 +61,6 @@ export class SplitElementsCommand extends Command {
const rightVisibleDuration = element.duration - relativeTime;
if (this.retainSide === "left") {
this.affectedElementIds.add(element.id);
return [
{
...element,
@ -74,7 +73,10 @@ export class SplitElementsCommand extends Command {
if (this.retainSide === "right") {
const newId = generateUUID();
this.affectedElementIds.add(newId);
this.rightSideElements.push({
trackId: track.id,
elementId: newId,
});
return [
{
...element,
@ -89,9 +91,10 @@ export class SplitElementsCommand extends Command {
// "both" - split into two pieces
const secondElementId = generateUUID();
this.affectedElementIds.add(element.id);
this.affectedElementIds.add(secondElementId);
this.newElementIds.push(secondElementId);
this.rightSideElements.push({
trackId: track.id,
elementId: secondElementId,
});
return [
{
@ -114,12 +117,17 @@ export class SplitElementsCommand extends Command {
});
editor.timeline.updateTracks(updatedTracks);
if (this.rightSideElements.length > 0) {
editor.selection.setSelectedElements({ elements: this.rightSideElements });
}
}
undo(): void {
if (this.savedState) {
const editor = EditorCore.getInstance();
editor.timeline.updateTracks(this.savedState);
editor.selection.setSelectedElements({ elements: this.previousSelection });
}
}
}

View File

@ -7,6 +7,7 @@ import type {
CreateLibraryAudioElement,
TextElement,
TimelineElement,
TimelineTrack,
AudioElement,
VideoElement,
ImageElement,
@ -225,3 +226,26 @@ export function buildLibraryAudioElement({
}
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;
}

View File

@ -7,6 +7,10 @@ import { getDefaultShortcuts } from "@/lib/actions";
import { isTypableDOMElement } from "@/utils/browser";
import { isAppleDevice } from "@/utils/platform";
import type { KeybindingConfig, ShortcutKey } from "@/types/keybinding";
import {
runMigrations,
CURRENT_VERSION,
} from "./keybindings/migrations";
export const defaultKeybindings: KeybindingConfig = getDefaultShortcuts();
@ -35,8 +39,6 @@ interface KeybindingsState {
action: TActionWithOptionalArgs,
) => KeybindingConflict | null;
getKeybindingsForAction: (action: TActionWithOptionalArgs) => ShortcutKey[];
// Utility
getKeybindingString: (ev: KeyboardEvent) => ShortcutKey | null;
}
@ -143,7 +145,13 @@ export const useKeybindingsStore = create<KeybindingsState>()(
}),
{
name: "opencut-keybindings",
version: 2,
version: CURRENT_VERSION,
partialize: (state) => ({
keybindings: state.keybindings,
isCustomized: state.isCustomized,
}),
migrate: (persisted, version) =>
runMigrations({ state: persisted, fromVersion: version }),
},
),
);

View File

@ -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;
}

View File

@ -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 };
}

View File

@ -7,12 +7,6 @@ import { create } from "zustand";
import type { ClipboardItem } from "@/types/timeline";
interface TimelineStore {
selectedElements: { trackId: string; elementId: string }[];
setSelectedElements: ({
elements,
}: {
elements: { trackId: string; elementId: string }[];
}) => void;
snappingEnabled: boolean;
toggleSnapping: () => void;
rippleEditingEnabled: boolean;
@ -28,12 +22,6 @@ interface TimelineStore {
}
export const useTimelineStore = create<TimelineStore>((set) => ({
selectedElements: [],
setSelectedElements: ({ elements }) => {
set({ selectedElements: elements });
},
snappingEnabled: true,
toggleSnapping: () => {

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>

View File

@ -25,18 +25,15 @@
<body class="bg-[#ffffff] text-slate-900 min-h-screen p-12">
<div class="max-w-4xl mx-auto space-y-4 mb-12 text-center">
<h1 class="text-2xl font-bold">Dialog Design Explorations</h1>
<p class="text-slate-500">Three interactions for the same brand language. Click buttons to open.</p>
<h1 class="text-2xl font-bold">Project Info Explorations</h1>
<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">
<button onclick="openDialog(1)" class="px-5 py-2.5 bg-white border border-slate-200 rounded-xl font-medium text-slate-700 hover:border-[#00A3FF] hover:text-[#00A3FF] shadow-sm">
Option 1: Soft & Friendly
<button 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">
Layout 1: Cards & Grid
</button>
<button onclick="openDialog(2)" class="px-5 py-2.5 bg-white border border-slate-200 rounded-xl font-medium text-slate-700 hover:border-[#00A3FF] hover:text-[#00A3FF] shadow-sm">
Option 2: Structured
</button>
<button onclick="openDialog(3)" class="px-5 py-2.5 bg-white border border-slate-200 rounded-xl font-medium text-slate-700 hover:border-[#00A3FF] hover:text-[#00A3FF] shadow-sm">
Option 3: Edge Accent
<button 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">
Layout 2: Clean Rows
</button>
</div>
</div>
@ -44,125 +41,145 @@
<!-- Overlay (Shared) -->
<div id="overlay" class="dialog-overlay fixed inset-0 bg-slate-900/20 backdrop-blur-sm z-50 items-center justify-center p-4">
<!-- Option 1: Soft & Friendly -->
<!-- Characteristics: Very rounded, generous padding, minimal dividers -->
<div id="dialog-1" class="hidden w-full max-w-md bg-white rounded-[28px] shadow-xl p-8 relative">
<button onclick="closeAll()" class="absolute top-6 right-6 p-2 text-slate-400 hover:text-slate-600 rounded-full hover:bg-slate-50">
<i data-lucide="x" class="w-5 h-5"></i>
</button>
<div class="mb-6">
<div class="w-12 h-12 bg-blue-50 rounded-2xl flex items-center justify-center mb-4 text-[#00A3FF]">
<i data-lucide="share-2" class="w-6 h-6"></i>
</div>
<h2 class="text-xl font-bold text-slate-900 mb-2">Share Project</h2>
<p class="text-slate-500 text-[15px] leading-relaxed">
Invite collaborators to <strong>Summer Vlog 2025</strong>. They will receive an email invitation to edit.
</p>
</div>
<div class="space-y-4 mb-8">
<div>
<label class="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-2">Email address</label>
<input type="email" placeholder="colleague@example.com" class="w-full bg-slate-50 border border-slate-200 rounded-xl px-4 py-3 text-sm focus:outline-none focus:border-[#00A3FF] focus:bg-white focus:ring-4 focus:ring-blue-500/10 placeholder:text-slate-400">
</div>
<div class="flex items-center justify-between p-3 border border-slate-100 rounded-xl">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-full bg-emerald-100 flex items-center justify-center text-emerald-600 text-xs font-bold">JM</div>
<span class="text-sm font-medium text-slate-700">John Miller</span>
</div>
<span class="text-xs font-medium text-slate-400 px-2 py-1 bg-slate-50 rounded-md">Owner</span>
</div>
</div>
<div class="flex items-center gap-3">
<button onclick="closeAll()" class="flex-1 px-5 py-3 text-sm font-medium text-slate-600 hover:text-slate-900 hover:bg-slate-50 rounded-xl">
Cancel
</button>
<button class="flex-1 px-5 py-3 text-sm font-bold text-white bg-[#00A3FF] hover:bg-[#008BE0] rounded-xl shadow-lg shadow-blue-500/20">
Send Invite
</button>
</div>
</div>
<!-- Option 2: Structured -->
<!-- Characteristics: Clear header/footer separation, solid pill buttons, highly organized -->
<div id="dialog-2" class="hidden w-full max-w-lg bg-white rounded-[20px] shadow-xl overflow-hidden flex flex-col">
<!-- Option 1: Cards & Grid -->
<!-- Focus: High visibility, dashboard feel, grouping info into boxes -->
<div id="dialog-1" class="hidden w-full max-w-lg bg-white rounded-[20px] shadow-xl overflow-hidden flex flex-col">
<!-- Header -->
<div class="px-6 py-5 border-b border-slate-100 flex items-center justify-between bg-white">
<h2 class="text-lg font-bold text-slate-900">Export Video</h2>
<button onclick="closeAll()" class="text-slate-400 hover:text-slate-600">
<h2 class="text-lg font-bold text-slate-900">Project Info</h2>
<button onclick="closeAll()" class="text-slate-400 hover:text-slate-600 transition-colors">
<i data-lucide="x" class="w-5 h-5"></i>
</button>
</div>
<!-- Body -->
<div class="p-6 space-y-6">
<div class="grid grid-cols-2 gap-4">
<div class="space-y-1.5">
<label class="text-xs font-semibold text-slate-500">Resolution</label>
<button class="w-full flex items-center justify-between px-3 py-2.5 bg-white border border-slate-200 rounded-lg text-sm font-medium text-slate-700 hover:border-[#00A3FF]">
<span>1080p HD</span>
<i data-lucide="chevron-down" class="w-4 h-4 text-slate-400"></i>
</button>
</div>
<div class="space-y-1.5">
<label class="text-xs font-semibold text-slate-500">Format</label>
<button class="w-full flex items-center justify-between px-3 py-2.5 bg-white border border-slate-200 rounded-lg text-sm font-medium text-slate-700 hover:border-[#00A3FF]">
<span>MP4 (H.264)</span>
<i data-lucide="chevron-down" class="w-4 h-4 text-slate-400"></i>
</button>
<!-- Name Input -->
<div class="space-y-1.5">
<label class="text-xs font-bold text-slate-500 uppercase tracking-wider">Project Name</label>
<div class="relative">
<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">
<div class="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none">
<i data-lucide="pencil" class="w-3.5 h-3.5"></i>
</div>
</div>
</div>
<div class="p-4 bg-slate-50 rounded-xl border border-slate-100 flex items-start gap-3">
<i data-lucide="info" class="w-5 h-5 text-[#00A3FF] mt-0.5 flex-shrink-0"></i>
<div class="text-sm text-slate-600">
<p class="font-medium text-slate-900 mb-1">Estimated file size: 245 MB</p>
<p>Exporting may take a few minutes depending on your hardware.</p>
<!-- Stats Grid -->
<div class="grid grid-cols-3 gap-3">
<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">Duration</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>
<!-- Footer -->
<div class="px-6 py-4 bg-slate-50 border-t border-slate-100 flex items-center justify-end gap-3">
<button onclick="closeAll()" class="px-4 py-2 text-sm font-medium text-slate-600 hover:text-slate-900 bg-white border border-slate-200 rounded-lg hover:bg-slate-50">
Cancel
<button 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">
Close
</button>
<button class="px-4 py-2 text-sm font-bold text-white bg-[#00A3FF] hover:bg-[#008BE0] rounded-lg shadow-md shadow-blue-500/10">
Export Video
<button 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">
Save Changes
</button>
</div>
</div>
<!-- Option 3: Edge Accent -->
<!-- Characteristics: Minimalist, blue left border accent, very clean interactions -->
<div id="dialog-3" class="hidden w-full max-w-md bg-white rounded-2xl shadow-xl overflow-hidden relative">
<!-- Accent Bar -->
<div class="absolute left-0 top-0 bottom-0 w-1.5 bg-[#00A3FF]"></div>
<div class="pl-8 pr-6 py-6">
<div class="flex items-start justify-between mb-2">
<h2 class="text-lg font-bold text-slate-900">Delete Project?</h2>
<button onclick="closeAll()" class="text-slate-400 hover:text-slate-600 -mt-1 -mr-2 p-2">
<i data-lucide="x" class="w-5 h-5"></i>
</button>
<!-- Option 2: Clean Rows -->
<!-- Focus: Scannability, list view, technical details, minimal containers -->
<div id="dialog-2" class="hidden w-full max-w-lg bg-white rounded-[20px] shadow-xl overflow-hidden flex flex-col">
<!-- Header -->
<div class="px-6 py-5 border-b border-slate-100 flex items-center justify-between bg-white">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-lg bg-gradient-to-br from-slate-100 to-slate-200 border border-slate-200"></div>
<h2 class="text-lg font-bold text-slate-900">Project Details</h2>
</div>
<p class="text-slate-500 text-sm mb-6">
This will permanently delete <span class="text-slate-900 font-medium">"Summer Vlog 2025"</span> and all its associated media files. This action cannot be undone.
</p>
<button onclick="closeAll()" class="text-slate-400 hover:text-slate-600 transition-colors">
<i data-lucide="x" class="w-5 h-5"></i>
</button>
</div>
<div class="flex items-center gap-3 pt-2">
<button class="flex-1 px-4 py-2.5 text-sm font-bold text-white bg-red-500 hover:bg-red-600 rounded-lg shadow-sm">
Yes, delete it
</button>
<button onclick="closeAll()" class="px-4 py-2.5 text-sm font-medium text-slate-600 hover:text-slate-900 border border-slate-200 rounded-lg hover:bg-slate-50">
<!-- Body -->
<div class="px-6 py-2">
<!-- 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>
<!-- Row 2: Created -->
<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="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>
<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
</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>
@ -175,8 +192,7 @@
const overlay = document.getElementById('overlay');
const dialogs = [
document.getElementById('dialog-1'),
document.getElementById('dialog-2'),
document.getElementById('dialog-3')
document.getElementById('dialog-2')
];
function openDialog(id) {

View File

@ -1,32 +1,32 @@
import { z } from "zod";
const webEnvSchema = z.object({
// Node
NODE_ENV: z.enum(["development", "production", "test"]),
ANALYZE: z.string().optional(),
NEXT_RUNTIME: z.enum(["nodejs", "edge"]).optional(),
// Node
NODE_ENV: z.enum(["development", "production", "test"]),
ANALYZE: z.string().optional(),
NEXT_RUNTIME: z.enum(["nodejs", "edge"]).optional(),
// Public
NEXT_PUBLIC_SITE_URL: z.url().default("http://localhost:3000"),
NEXT_PUBLIC_MARBLE_API_URL: z.url(),
// Public
NEXT_PUBLIC_SITE_URL: z.url().default("http://localhost:3000"),
NEXT_PUBLIC_MARBLE_API_URL: z.url(),
// Server
DATABASE_URL: z
.string()
.startsWith("postgres://")
.or(z.string().startsWith("postgresql://")),
// Server
DATABASE_URL: z
.string()
.startsWith("postgres://")
.or(z.string().startsWith("postgresql://")),
BETTER_AUTH_SECRET: z.string(),
UPSTASH_REDIS_REST_URL: z.url(),
UPSTASH_REDIS_REST_TOKEN: z.string(),
MARBLE_WORKSPACE_KEY: z.string(),
FREESOUND_CLIENT_ID: z.string(),
FREESOUND_API_KEY: z.string(),
CLOUDFLARE_ACCOUNT_ID: z.string(),
R2_ACCESS_KEY_ID: z.string(),
R2_SECRET_ACCESS_KEY: z.string(),
R2_BUCKET_NAME: z.string(),
MODAL_TRANSCRIPTION_URL: z.url(),
BETTER_AUTH_SECRET: z.string(),
UPSTASH_REDIS_REST_URL: z.url(),
UPSTASH_REDIS_REST_TOKEN: z.string(),
MARBLE_WORKSPACE_KEY: z.string(),
FREESOUND_CLIENT_ID: z.string(),
FREESOUND_API_KEY: z.string(),
CLOUDFLARE_ACCOUNT_ID: z.string(),
R2_ACCESS_KEY_ID: z.string(),
R2_SECRET_ACCESS_KEY: z.string(),
R2_BUCKET_NAME: z.string(),
MODAL_TRANSCRIPTION_URL: z.url(),
});
export type WebEnv = z.infer<typeof webEnvSchema>;