we're basically done
This commit is contained in:
parent
af3100950e
commit
1f8391487e
|
|
@ -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}
|
||||
|
|
@ -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 })
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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" &&
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
));
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
));
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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={() => {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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<
|
||||
|
|
|
|||
|
|
@ -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<
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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=""
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,35 @@
|
|||
import type { EditorCore } from "@/core";
|
||||
|
||||
type ElementRef = { trackId: string; elementId: string };
|
||||
|
||||
export class SelectionManager {
|
||||
private selectedElements: ElementRef[] = [];
|
||||
private listeners = new Set<() => void>();
|
||||
|
||||
constructor(editor: EditorCore) {
|
||||
void editor;
|
||||
}
|
||||
|
||||
getSelectedElements(): ElementRef[] {
|
||||
return this.selectedElements;
|
||||
}
|
||||
|
||||
setSelectedElements({ elements }: { elements: ElementRef[] }): void {
|
||||
this.selectedElements = elements;
|
||||
this.notify();
|
||||
}
|
||||
|
||||
clearSelection(): void {
|
||||
this.selectedElements = [];
|
||||
this.notify();
|
||||
}
|
||||
|
||||
subscribe(listener: () => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
private notify(): void {
|
||||
this.listeners.forEach((fn) => fn());
|
||||
}
|
||||
}
|
||||
|
|
@ -142,10 +142,10 @@ export class TimelineManager {
|
|||
elements: { trackId: string; elementId: string }[];
|
||||
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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
);
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
import { v2ToV3 } from './v2-to-v3';
|
||||
|
||||
type MigrationFn = ({ state }: { state: unknown }) => unknown;
|
||||
|
||||
/**
|
||||
* key = version we're migrating from
|
||||
* value = migration function
|
||||
*/
|
||||
const migrations: Record<number, MigrationFn> = {
|
||||
2: v2ToV3,
|
||||
};
|
||||
|
||||
export const CURRENT_VERSION = 3;
|
||||
|
||||
export function runMigrations({
|
||||
state,
|
||||
fromVersion,
|
||||
}: {
|
||||
state: unknown;
|
||||
fromVersion: number;
|
||||
}): unknown {
|
||||
let current = state;
|
||||
for (let version = fromVersion; version < CURRENT_VERSION; version++) {
|
||||
const migrate = migrations[version];
|
||||
if (migrate) current = migrate({ state: current });
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import type { KeybindingConfig, ShortcutKey } from "@/types/keybinding";
|
||||
import type { TActionWithOptionalArgs } from "@/lib/actions";
|
||||
|
||||
interface V2State {
|
||||
keybindings: KeybindingConfig;
|
||||
isCustomized: boolean;
|
||||
}
|
||||
|
||||
export function v2ToV3({ state }: { state: unknown }): unknown {
|
||||
const v2 = state as V2State;
|
||||
|
||||
const renames: Record<string, string> = {
|
||||
"split-selected": "split",
|
||||
"split-selected-left": "split-left",
|
||||
"split-selected-right": "split-right",
|
||||
};
|
||||
|
||||
const migrated = { ...v2.keybindings };
|
||||
for (const [key, action] of Object.entries(migrated)) {
|
||||
if (action && renames[action]) {
|
||||
migrated[key as ShortcutKey] = renames[action] as TActionWithOptionalArgs;
|
||||
}
|
||||
}
|
||||
|
||||
return { ...v2, keybindings: migrated };
|
||||
}
|
||||
|
|
@ -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: () => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
Loading…
Reference in New Issue