mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
feat: add project settings
This commit is contained in:
@@ -6,6 +6,7 @@ import { useMediaPanelStore, Tab } from "./store";
|
|||||||
import { TextView } from "./views/text";
|
import { TextView } from "./views/text";
|
||||||
import { AudioView } from "./views/audio";
|
import { AudioView } from "./views/audio";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { SettingsView } from "./views/settings";
|
||||||
|
|
||||||
export function MediaPanel() {
|
export function MediaPanel() {
|
||||||
const { activeTab } = useMediaPanelStore();
|
const { activeTab } = useMediaPanelStore();
|
||||||
@@ -44,6 +45,7 @@ export function MediaPanel() {
|
|||||||
Adjustment view coming soon...
|
Adjustment view coming soon...
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
|
settings: <SettingsView />,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
SlidersHorizontalIcon,
|
SlidersHorizontalIcon,
|
||||||
LucideIcon,
|
LucideIcon,
|
||||||
TypeIcon,
|
TypeIcon,
|
||||||
|
SettingsIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
|
|
||||||
@@ -21,7 +22,8 @@ export type Tab =
|
|||||||
| "transitions"
|
| "transitions"
|
||||||
| "captions"
|
| "captions"
|
||||||
| "filters"
|
| "filters"
|
||||||
| "adjustment";
|
| "adjustment"
|
||||||
|
| "settings";
|
||||||
|
|
||||||
export const tabs: { [key in Tab]: { icon: LucideIcon; label: string } } = {
|
export const tabs: { [key in Tab]: { icon: LucideIcon; label: string } } = {
|
||||||
media: {
|
media: {
|
||||||
@@ -60,6 +62,10 @@ export const tabs: { [key in Tab]: { icon: LucideIcon; label: string } } = {
|
|||||||
icon: SlidersHorizontalIcon,
|
icon: SlidersHorizontalIcon,
|
||||||
label: "Adjustment",
|
label: "Adjustment",
|
||||||
},
|
},
|
||||||
|
settings: {
|
||||||
|
icon: SettingsIcon,
|
||||||
|
label: "Settings",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
interface MediaPanelStore {
|
interface MediaPanelStore {
|
||||||
|
|||||||
@@ -0,0 +1,246 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import {
|
||||||
|
PropertyItem,
|
||||||
|
PropertyItemLabel,
|
||||||
|
PropertyItemValue,
|
||||||
|
} from "../../properties-panel/property-item";
|
||||||
|
import { FPS_PRESETS } from "@/constants/timeline-constants";
|
||||||
|
import { useProjectStore } from "@/stores/project-store";
|
||||||
|
import { useEditorStore } from "@/stores/editor-store";
|
||||||
|
import { useAspectRatio } from "@/hooks/use-aspect-ratio";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { colors } from "@/data/colors";
|
||||||
|
import { PipetteIcon } from "lucide-react";
|
||||||
|
import { useMemo, memo, useCallback } from "react";
|
||||||
|
|
||||||
|
export function SettingsView() {
|
||||||
|
return <ProjectSettingsTabs />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProjectSettingsTabs() {
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col">
|
||||||
|
<Tabs defaultValue="project-info" className="flex flex-col h-full">
|
||||||
|
<div className="px-3 pt-4 pb-0">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="project-info">Project info</TabsTrigger>
|
||||||
|
<TabsTrigger value="background">Background</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</div>
|
||||||
|
<Separator className="my-4" />
|
||||||
|
<ScrollArea className="flex-1">
|
||||||
|
<TabsContent value="project-info" className="p-5 pt-0 mt-0">
|
||||||
|
<ProjectInfoView />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="background" className="p-4 pt-0">
|
||||||
|
<BackgroundView />
|
||||||
|
</TabsContent>
|
||||||
|
</ScrollArea>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProjectInfoView() {
|
||||||
|
const { activeProject, updateProjectFps } = useProjectStore();
|
||||||
|
const { canvasSize, canvasPresets, setCanvasSize } = useEditorStore();
|
||||||
|
const { getDisplayName } = useAspectRatio();
|
||||||
|
|
||||||
|
const handleAspectRatioChange = (value: string) => {
|
||||||
|
const preset = canvasPresets.find((p) => p.name === value);
|
||||||
|
if (preset) {
|
||||||
|
setCanvasSize({ width: preset.width, height: preset.height });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFpsChange = (value: string) => {
|
||||||
|
const fps = parseFloat(value);
|
||||||
|
updateProjectFps(fps);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<PropertyItem direction="column">
|
||||||
|
<PropertyItemLabel>Name</PropertyItemLabel>
|
||||||
|
<PropertyItemValue>
|
||||||
|
{activeProject?.name || "Untitled project"}
|
||||||
|
</PropertyItemValue>
|
||||||
|
</PropertyItem>
|
||||||
|
|
||||||
|
<PropertyItem direction="column">
|
||||||
|
<PropertyItemLabel>Aspect ratio</PropertyItemLabel>
|
||||||
|
<PropertyItemValue>
|
||||||
|
<Select
|
||||||
|
value={getDisplayName()}
|
||||||
|
onValueChange={handleAspectRatioChange}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="bg-panel-accent">
|
||||||
|
<SelectValue placeholder="Select an aspect ratio" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{canvasPresets.map((preset) => (
|
||||||
|
<SelectItem key={preset.name} value={preset.name}>
|
||||||
|
{preset.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</PropertyItemValue>
|
||||||
|
</PropertyItem>
|
||||||
|
|
||||||
|
<PropertyItem direction="column">
|
||||||
|
<PropertyItemLabel>Frame rate</PropertyItemLabel>
|
||||||
|
<PropertyItemValue>
|
||||||
|
<Select
|
||||||
|
value={(activeProject?.fps || 30).toString()}
|
||||||
|
onValueChange={handleFpsChange}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="bg-panel-accent">
|
||||||
|
<SelectValue placeholder="Select a frame rate" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{FPS_PRESETS.map((preset) => (
|
||||||
|
<SelectItem key={preset.value} value={preset.value}>
|
||||||
|
{preset.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</PropertyItemValue>
|
||||||
|
</PropertyItem>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const BlurPreview = memo(
|
||||||
|
({
|
||||||
|
blur,
|
||||||
|
isSelected,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
blur: { label: string; value: number };
|
||||||
|
isSelected: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
}) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"w-full aspect-square rounded-sm cursor-pointer hover:outline-2 hover:outline-primary relative overflow-hidden",
|
||||||
|
isSelected && "outline-2 outline-primary"
|
||||||
|
)}
|
||||||
|
onClick={onSelect}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src="https://images.unsplash.com/photo-1501785888041-af3ef285b470?q=80&w=1470&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"
|
||||||
|
alt={`Blur preview ${blur.label}`}
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
style={{ filter: `blur(${blur.value}px)` }}
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
<div className="absolute bottom-1 left-1 right-1 text-center">
|
||||||
|
<span className="text-xs text-white bg-black/50 px-1 rounded">
|
||||||
|
{blur.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
BlurPreview.displayName = "BlurPreview";
|
||||||
|
|
||||||
|
function BackgroundView() {
|
||||||
|
const { activeProject, updateBackgroundType } = useProjectStore();
|
||||||
|
|
||||||
|
const blurLevels = useMemo(
|
||||||
|
() => [
|
||||||
|
{ label: "Light", value: 4 },
|
||||||
|
{ label: "Medium", value: 8 },
|
||||||
|
{ label: "Heavy", value: 18 },
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleBlurSelect = useCallback(
|
||||||
|
async (blurIntensity: number) => {
|
||||||
|
await updateBackgroundType("blur", { blurIntensity });
|
||||||
|
},
|
||||||
|
[updateBackgroundType]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleColorSelect = useCallback(
|
||||||
|
async (color: string) => {
|
||||||
|
await updateBackgroundType("color", { backgroundColor: color });
|
||||||
|
},
|
||||||
|
[updateBackgroundType]
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentBlurIntensity = activeProject?.blurIntensity || 8;
|
||||||
|
const isBlurBackground = activeProject?.backgroundType === "blur";
|
||||||
|
const currentBackgroundColor = activeProject?.backgroundColor || "#000000";
|
||||||
|
const isColorBackground = activeProject?.backgroundType === "color";
|
||||||
|
|
||||||
|
const blurPreviews = useMemo(
|
||||||
|
() =>
|
||||||
|
blurLevels.map((blur) => (
|
||||||
|
<BlurPreview
|
||||||
|
key={blur.value}
|
||||||
|
blur={blur}
|
||||||
|
isSelected={isBlurBackground && currentBlurIntensity === blur.value}
|
||||||
|
onSelect={() => handleBlurSelect(blur.value)}
|
||||||
|
/>
|
||||||
|
)),
|
||||||
|
[blurLevels, isBlurBackground, currentBlurIntensity, handleBlurSelect]
|
||||||
|
);
|
||||||
|
|
||||||
|
const colorPreviews = useMemo(
|
||||||
|
() =>
|
||||||
|
colors.map((color) => (
|
||||||
|
<div
|
||||||
|
key={color}
|
||||||
|
className={cn(
|
||||||
|
"w-full aspect-square rounded-sm cursor-pointer hover:border-2 hover:border-primary",
|
||||||
|
isColorBackground &&
|
||||||
|
color === currentBackgroundColor &&
|
||||||
|
"border-2 border-primary"
|
||||||
|
)}
|
||||||
|
style={{ backgroundColor: color }}
|
||||||
|
onClick={() => handleColorSelect(color)}
|
||||||
|
/>
|
||||||
|
)),
|
||||||
|
[isColorBackground, currentBackgroundColor, handleColorSelect]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<PropertyItem direction="column" className="gap-2">
|
||||||
|
<PropertyItemLabel>Blur</PropertyItemLabel>
|
||||||
|
<PropertyItemValue>
|
||||||
|
<div className="grid grid-cols-4 gap-2 w-full">{blurPreviews}</div>
|
||||||
|
</PropertyItemValue>
|
||||||
|
</PropertyItem>
|
||||||
|
<PropertyItem direction="column" className="gap-2">
|
||||||
|
<PropertyItemLabel>Color</PropertyItemLabel>
|
||||||
|
<PropertyItemValue>
|
||||||
|
<div className="grid grid-cols-4 gap-2 w-full">
|
||||||
|
<div className="w-full aspect-square rounded-sm cursor-pointer border border-foreground/15 hover:border-primary flex items-center justify-center">
|
||||||
|
<PipetteIcon className="size-4" />
|
||||||
|
</div>
|
||||||
|
{colorPreviews}
|
||||||
|
</div>
|
||||||
|
</PropertyItemValue>
|
||||||
|
</PropertyItem>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,7 +17,7 @@ export function PropertyItem({
|
|||||||
"flex gap-2",
|
"flex gap-2",
|
||||||
direction === "row"
|
direction === "row"
|
||||||
? "items-center justify-between gap-6"
|
? "items-center justify-between gap-6"
|
||||||
: "flex-col gap-1",
|
: "flex-col gap-1.5",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -33,7 +33,11 @@ export function PropertyItemLabel({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
return <label className={cn("text-xs", className)}>{children}</label>;
|
return (
|
||||||
|
<label className={cn("text-xs text-muted-foreground", className)}>
|
||||||
|
{children}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PropertyItemValue({
|
export function PropertyItemValue({
|
||||||
@@ -43,5 +47,5 @@ export function PropertyItemValue({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
return <div className={cn("flex-1", className)}>{children}</div>;
|
return <div className={cn("flex-1 text-sm", className)}>{children}</div>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -163,3 +163,25 @@ export function DataBuddyIcon({
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SquareSlashIcon({
|
||||||
|
className,
|
||||||
|
size = 24,
|
||||||
|
}: {
|
||||||
|
className?: string;
|
||||||
|
size?: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
className={className}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 256 256"
|
||||||
|
>
|
||||||
|
<rect width="18" height="18" x="3" y="3" rx="2" />
|
||||||
|
<line x1="9" x2="15" y1="15" y2="9" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,14 +35,14 @@ const SelectTrigger = React.forwardRef<
|
|||||||
<SelectPrimitive.Trigger
|
<SelectPrimitive.Trigger
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs ring-offset-background placeholder:text-muted-foreground focus:outline-hidden focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
"flex gap-1 h-7 cursor-pointer w-auto items-center justify-between whitespace-nowrap rounded-md bg-accent px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-hidden focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<SelectPrimitive.Icon asChild>
|
<SelectPrimitive.Icon asChild>
|
||||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
<ChevronDown className="size-3 opacity-50" />
|
||||||
</SelectPrimitive.Icon>
|
</SelectPrimitive.Icon>
|
||||||
</SelectPrimitive.Trigger>
|
</SelectPrimitive.Trigger>
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const TabsList = React.forwardRef<
|
|||||||
<TabsPrimitive.List
|
<TabsPrimitive.List
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
|
"inline-flex h-auto items-center justify-center gap-2 rounded-lg bg-transparent p-0 text-muted-foreground",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -29,7 +29,7 @@ const TabsTrigger = React.forwardRef<
|
|||||||
<TabsPrimitive.Trigger
|
<TabsPrimitive.Trigger
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
"inline-flex items-center cursor-pointer justify-center whitespace-nowrap rounded-lg px-3 py-1 text-sm font-medium ring-offset-background focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-card data-[state=active]:text-foreground",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
Reference in New Issue
Block a user