summaryrefslogtreecommitdiff
path: root/apps/web
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2025-07-11 03:52:39 +0200
committerMaze Winther <mazewinther@gmail.com>2025-07-11 03:52:39 +0200
commit4d67e366ad23e1d3b23b6bc33d4b4a4eefa6e7c5 (patch)
treef17eca1a15a014bfdac7376e517f18228a749578 /apps/web
parent6c19dbb6bbec9de52d1610dbdf54d81251e804b7 (diff)
downloadopencut-4d67e366ad23e1d3b23b6bc33d4b4a4eefa6e7c5.tar.gz
feat: background settings (color, blur)
Diffstat (limited to 'apps/web')
-rw-r--r--apps/web/src/components/background-settings.tsx184
-rw-r--r--apps/web/src/components/editor/preview-panel.tsx100
-rw-r--r--apps/web/src/components/icons.tsx61
-rw-r--r--apps/web/src/data/colors.ts243
-rw-r--r--apps/web/src/lib/storage/storage-service.ts6
-rw-r--r--apps/web/src/stores/project-store.ts59
-rw-r--r--apps/web/src/types/project.ts3
7 files changed, 654 insertions, 2 deletions
diff --git a/apps/web/src/components/background-settings.tsx b/apps/web/src/components/background-settings.tsx
new file mode 100644
index 00000000..bcefac1c
--- /dev/null
+++ b/apps/web/src/components/background-settings.tsx
@@ -0,0 +1,184 @@
+import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
+import { Button } from "./ui/button";
+import { BackgroundIcon } from "./icons";
+import { cn } from "@/lib/utils";
+import Image from "next/image";
+import { colors } from "@/data/colors";
+import { useProjectStore } from "@/stores/project-store";
+import { PipetteIcon } from "lucide-react";
+
+type BackgroundTab = "color" | "blur";
+
+export function BackgroundSettings() {
+ const { activeProject, updateBackgroundType } = useProjectStore();
+
+ // ✅ Good: derive activeTab from activeProject during rendering
+ const activeTab = activeProject?.backgroundType || "color";
+
+ const handleColorSelect = (color: string) => {
+ updateBackgroundType("color", { backgroundColor: color });
+ };
+
+ const handleBlurSelect = (blurIntensity: number) => {
+ updateBackgroundType("blur", { blurIntensity });
+ };
+
+ const tabs = [
+ {
+ label: "Color",
+ value: "color",
+ },
+ {
+ label: "Blur",
+ value: "blur",
+ },
+ ];
+
+ return (
+ <Popover>
+ <PopoverTrigger asChild>
+ <Button
+ variant="text"
+ size="icon"
+ className="!size-5 border border-muted-foreground"
+ >
+ <BackgroundIcon className="!size-4" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="flex flex-col items-start w-[20rem] h-[16rem] overflow-hidden p-0">
+ <div className="flex items-center justify-between w-full gap-2 z-10 bg-popover p-3">
+ <h2 className="text-sm">Background</h2>
+ <div className="flex items-center gap-2 text-sm">
+ {tabs.map((tab) => (
+ <span
+ key={tab.value}
+ onClick={() => {
+ // Switch to the background type when clicking tabs
+ if (tab.value === "color") {
+ updateBackgroundType("color", {
+ backgroundColor:
+ activeProject?.backgroundColor || "#000000",
+ });
+ } else {
+ updateBackgroundType("blur", {
+ blurIntensity: activeProject?.blurIntensity || 8,
+ });
+ }
+ }}
+ className={cn(
+ "text-muted-foreground cursor-pointer",
+ activeTab === tab.value && "text-foreground"
+ )}
+ >
+ {tab.label}
+ </span>
+ ))}
+ </div>
+ </div>
+ {activeTab === "color" ? (
+ <ColorView
+ selectedColor={activeProject?.backgroundColor || "#000000"}
+ onColorSelect={handleColorSelect}
+ />
+ ) : (
+ <BlurView
+ selectedBlur={activeProject?.blurIntensity || 8}
+ onBlurSelect={handleBlurSelect}
+ />
+ )}
+ </PopoverContent>
+ </Popover>
+ );
+}
+
+function ColorView({
+ selectedColor,
+ onColorSelect,
+}: {
+ selectedColor: string;
+ onColorSelect: (color: string) => void;
+}) {
+ return (
+ <div className="w-full h-full">
+ <div className="absolute top-8 left-0 w-[calc(100%-1rem)] h-12 bg-gradient-to-b from-popover to-transparent pointer-events-none"></div>
+ <div className="grid grid-cols-4 gap-2 w-full h-full p-3 pt-0 overflow-auto">
+ <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>
+ {colors.map((color) => (
+ <ColorItem
+ key={color}
+ color={color}
+ isSelected={color === selectedColor}
+ onClick={() => onColorSelect(color)}
+ />
+ ))}
+ </div>
+ </div>
+ );
+}
+
+function ColorItem({
+ color,
+ isSelected,
+ onClick,
+}: {
+ color: string;
+ isSelected: boolean;
+ onClick: () => void;
+}) {
+ return (
+ <div
+ className={cn(
+ "w-full aspect-square rounded-sm cursor-pointer hover:border-2 hover:border-primary",
+ isSelected && "border-2 border-primary"
+ )}
+ style={{ backgroundColor: color }}
+ onClick={onClick}
+ />
+ );
+}
+
+function BlurView({
+ selectedBlur,
+ onBlurSelect,
+}: {
+ selectedBlur: number;
+ onBlurSelect: (blurIntensity: number) => void;
+}) {
+ const blurLevels = [
+ { label: "Light", value: 4 },
+ { label: "Medium", value: 8 },
+ { label: "Heavy", value: 18 },
+ ];
+ const blurImage =
+ "https://images.unsplash.com/photo-1501785888041-af3ef285b470?q=80&w=1470&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D";
+
+ return (
+ <div className="grid grid-cols-3 gap-2 w-full p-3 pt-0">
+ {blurLevels.map((blur) => (
+ <div
+ key={blur.value}
+ className={cn(
+ "w-full aspect-square rounded-sm cursor-pointer hover:border-2 hover:border-primary relative overflow-hidden",
+ selectedBlur === blur.value && "border-2 border-primary"
+ )}
+ onClick={() => onBlurSelect(blur.value)}
+ >
+ <Image
+ src={blurImage}
+ alt={`Blur preview ${blur.label}`}
+ fill
+ className="object-cover"
+ style={{ filter: `blur(${blur.value}px)` }}
+ />
+ <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>
+ ))}
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/preview-panel.tsx b/apps/web/src/components/editor/preview-panel.tsx
index 1cc9d976..2f76af5a 100644
--- a/apps/web/src/components/editor/preview-panel.tsx
+++ b/apps/web/src/components/editor/preview-panel.tsx
@@ -21,6 +21,8 @@ import { useState, useRef, useEffect } from "react";
import { cn } from "@/lib/utils";
import { formatTimeCode } from "@/lib/time";
import { FONT_CLASS_MAP } from "@/lib/font-config";
+import { BackgroundSettings } from "../background-settings";
+import { useProjectStore } from "@/stores/project-store";
interface ActiveElement {
element: TimelineElement;
@@ -39,6 +41,7 @@ export function PreviewPanel() {
width: 0,
height: 0,
});
+ const { activeProject } = useProjectStore();
// Calculate optimal preview size that fits in container while maintaining aspect ratio
useEffect(() => {
@@ -136,6 +139,85 @@ export function PreviewPanel() {
// Check if there are any elements in the timeline at all
const hasAnyElements = tracks.some((track) => track.elements.length > 0);
+ // Get media elements for blur background (video/image only)
+ const getBlurBackgroundElements = (): ActiveElement[] => {
+ return activeElements.filter(
+ ({ element, mediaItem }) =>
+ element.type === "media" &&
+ mediaItem &&
+ (mediaItem.type === "video" || mediaItem.type === "image") &&
+ element.mediaId !== "test" // Exclude test elements
+ );
+ };
+
+ const blurBackgroundElements = getBlurBackgroundElements();
+
+ // Render blur background layer
+ const renderBlurBackground = () => {
+ if (
+ !activeProject?.backgroundType ||
+ activeProject.backgroundType !== "blur" ||
+ blurBackgroundElements.length === 0
+ ) {
+ return null;
+ }
+
+ // Use the first media element for background (could be enhanced to use primary/focused element)
+ const backgroundElement = blurBackgroundElements[0];
+ const { element, mediaItem } = backgroundElement;
+
+ if (!mediaItem) return null;
+
+ const blurIntensity = activeProject.blurIntensity || 8;
+
+ if (mediaItem.type === "video") {
+ return (
+ <div
+ key={`blur-${element.id}`}
+ className="absolute inset-0 overflow-hidden"
+ style={{
+ filter: `blur(${blurIntensity}px)`,
+ transform: "scale(1.1)", // Slightly zoom to avoid blur edge artifacts
+ transformOrigin: "center",
+ }}
+ >
+ <VideoPlayer
+ src={mediaItem.url!}
+ poster={mediaItem.thumbnailUrl}
+ clipStartTime={element.startTime}
+ trimStart={element.trimStart}
+ trimEnd={element.trimEnd}
+ clipDuration={element.duration}
+ className="w-full h-full object-cover"
+ />
+ </div>
+ );
+ }
+
+ if (mediaItem.type === "image") {
+ return (
+ <div
+ key={`blur-${element.id}`}
+ className="absolute inset-0 overflow-hidden"
+ style={{
+ filter: `blur(${blurIntensity}px)`,
+ transform: "scale(1.1)", // Slightly zoom to avoid blur edge artifacts
+ transformOrigin: "center",
+ }}
+ >
+ <img
+ src={mediaItem.url!}
+ alt={mediaItem.name}
+ className="w-full h-full object-cover"
+ draggable={false}
+ />
+ </div>
+ );
+ }
+
+ return null;
+ };
+
// Render an element
const renderElement = (elementData: ActiveElement, index: number) => {
const { element, mediaItem } = elementData;
@@ -265,12 +347,17 @@ export function PreviewPanel() {
{hasAnyElements ? (
<div
ref={previewRef}
- className="relative overflow-hidden rounded-sm bg-black border"
+ className="relative overflow-hidden rounded-sm border"
style={{
width: previewDimensions.width,
height: previewDimensions.height,
+ backgroundColor:
+ activeProject?.backgroundType === "blur"
+ ? "transparent"
+ : activeProject?.backgroundColor || "#000000",
}}
>
+ {renderBlurBackground()}
{activeElements.length === 0 ? (
<div className="absolute inset-0 flex items-center justify-center text-muted-foreground">
No elements at current time
@@ -280,6 +367,14 @@ export function PreviewPanel() {
renderElement(elementData, index)
)
)}
+ {/* Show message when blur is selected but no media available */}
+ {activeProject?.backgroundType === "blur" &&
+ blurBackgroundElements.length === 0 &&
+ activeElements.length > 0 && (
+ <div className="absolute bottom-2 left-2 right-2 bg-black/70 text-white text-xs p-2 rounded">
+ Add a video or image to use blur background
+ </div>
+ )}
</div>
) : null}
@@ -346,7 +441,8 @@ function PreviewToolbar({ hasAnyElements }: { hasAnyElements: boolean }) {
<Play className="h-3 w-3" />
)}
</Button>
- <div>
+ <div className="flex items-center gap-3">
+ <BackgroundSettings />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
diff --git a/apps/web/src/components/icons.tsx b/apps/web/src/components/icons.tsx
index 563c92a9..4f3986e3 100644
--- a/apps/web/src/components/icons.tsx
+++ b/apps/web/src/components/icons.tsx
@@ -37,3 +37,64 @@ export function GithubIcon({ className }: { className?: string }) {
</svg>
);
}
+
+export function BackgroundIcon({ className }: { className?: string }) {
+ return (
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ width="353"
+ height="353"
+ viewBox="0 0 353 353"
+ fill="none"
+ className={className}
+ >
+ <g clipPath="url(#clip0_1_3)">
+ <rect
+ x="-241.816"
+ y="233.387"
+ width="592.187"
+ height="17.765"
+ transform="rotate(-37 -241.816 233.387)"
+ fill="white"
+ />
+ <rect
+ x="-189.907"
+ y="306.804"
+ width="592.187"
+ height="17.765"
+ transform="rotate(-37 -189.907 306.804)"
+ fill="white"
+ />
+ <rect
+ x="-146.928"
+ y="389.501"
+ width="592.187"
+ height="17.765"
+ transform="rotate(-37 -146.928 389.501)"
+ fill="white"
+ />
+ <rect
+ x="-103.144"
+ y="477.904"
+ width="592.187"
+ height="17.765"
+ transform="rotate(-37 -103.144 477.904)"
+ fill="white"
+ />
+ <rect
+ x="-57.169"
+ y="570.714"
+ width="592.187"
+ height="17.765"
+ transform="rotate(-37 -57.169 570.714)"
+ fill="white"
+ />
+ </g>
+ <defs>
+ <clipPath id="clip0_1_3">
+ <rect width="353" height="353" fill="white" />
+ </clipPath>
+ </defs>
+ </svg>
+ );
+} \ No newline at end of file
diff --git a/apps/web/src/data/colors.ts b/apps/web/src/data/colors.ts
new file mode 100644
index 00000000..dd67da42
--- /dev/null
+++ b/apps/web/src/data/colors.ts
@@ -0,0 +1,243 @@
+export const colors = [
+ "#fef2f2",
+ "#ffe2e2",
+ "#ffc9c9",
+ "#ffa2a2",
+ "#ff6467",
+ "#fb2c36",
+ "#e7000b",
+ "#c10007",
+ "#9f0712",
+ "#82181a",
+ "#460809",
+ "#fff7ed",
+ "#ffedd4",
+ "#ffd6a7",
+ "#ffb86a",
+ "#ff8904",
+ "#ff6900",
+ "#f54900",
+ "#ca3500",
+ "#9f2d00",
+ "#7e2a0c",
+ "#441306",
+ "#fffbeb",
+ "#fef3c6",
+ "#fee685",
+ "#ffd230",
+ "#ffb900",
+ "#fe9a00",
+ "#e17100",
+ "#bb4d00",
+ "#973c00",
+ "#7b3306",
+ "#461901",
+ "#fefce8",
+ "#fef9c2",
+ "#fff085",
+ "#ffdf20",
+ "#fdc700",
+ "#f0b100",
+ "#d08700",
+ "#a65f00",
+ "#894b00",
+ "#733e0a",
+ "#432004",
+ "#f7fee7",
+ "#ecfcca",
+ "#d8f999",
+ "#bbf451",
+ "#9ae600",
+ "#7ccf00",
+ "#5ea500",
+ "#497d00",
+ "#3c6300",
+ "#35530e",
+ "#192e03",
+ "#f0fdf4",
+ "#dcfce7",
+ "#b9f8cf",
+ "#7bf1a8",
+ "#05df72",
+ "#00c950",
+ "#00a63e",
+ "#008236",
+ "#016630",
+ "#0d542b",
+ "#032e15",
+ "#ecfdf5",
+ "#d0fae5",
+ "#a4f4cf",
+ "#5ee9b5",
+ "#00d492",
+ "#00bc7d",
+ "#009966",
+ "#007a55",
+ "#006045",
+ "#004f3b",
+ "#002c22",
+ "#f0fdfa",
+ "#cbfbf1",
+ "#96f7e4",
+ "#46ecd5",
+ "#00d5be",
+ "#00bba7",
+ "#009689",
+ "#00786f",
+ "#005f5a",
+ "#0b4f4a",
+ "#022f2e",
+ "#ecfeff",
+ "#cefafe",
+ "#a2f4fd",
+ "#53eafd",
+ "#00d3f2",
+ "#00b8db",
+ "#0092b8",
+ "#007595",
+ "#005f78",
+ "#104e64",
+ "#053345",
+ "#f0f9ff",
+ "#dff2fe",
+ "#b8e6fe",
+ "#74d4ff",
+ "#00bcff",
+ "#00a6f4",
+ "#0084d1",
+ "#0069a8",
+ "#00598a",
+ "#024a70",
+ "#052f4a",
+ "#eff6ff",
+ "#dbeafe",
+ "#bedbff",
+ "#8ec5ff",
+ "#51a2ff",
+ "#2b7fff",
+ "#155dfc",
+ "#1447e6",
+ "#193cb8",
+ "#1c398e",
+ "#162456",
+ "#eef2ff",
+ "#e0e7ff",
+ "#c6d2ff",
+ "#a3b3ff",
+ "#7c86ff",
+ "#615fff",
+ "#4f39f6",
+ "#432dd7",
+ "#372aac",
+ "#312c85",
+ "#1e1a4d",
+ "#f5f3ff",
+ "#ede9fe",
+ "#ddd6ff",
+ "#c4b4ff",
+ "#a684ff",
+ "#8e51ff",
+ "#7f22fe",
+ "#7008e7",
+ "#5d0ec0",
+ "#4d179a",
+ "#2f0d68",
+ "#faf5ff",
+ "#f3e8ff",
+ "#e9d4ff",
+ "#dab2ff",
+ "#c27aff",
+ "#ad46ff",
+ "#9810fa",
+ "#8200db",
+ "#6e11b0",
+ "#59168b",
+ "#3c0366",
+ "#fdf4ff",
+ "#fae8ff",
+ "#f6cfff",
+ "#f4a8ff",
+ "#ed6aff",
+ "#e12afb",
+ "#c800de",
+ "#a800b7",
+ "#8a0194",
+ "#721378",
+ "#4b004f",
+ "#fdf2f8",
+ "#fce7f3",
+ "#fccee8",
+ "#fda5d5",
+ "#fb64b6",
+ "#f6339a",
+ "#e60076",
+ "#c6005c",
+ "#a3004c",
+ "#861043",
+ "#510424",
+ "#fff1f2",
+ "#ffe4e6",
+ "#ffccd3",
+ "#ffa1ad",
+ "#ff637e",
+ "#ff2056",
+ "#ec003f",
+ "#c70036",
+ "#a50036",
+ "#8b0836",
+ "#4d0218",
+ "#f8fafc",
+ "#f1f5f9",
+ "#e2e8f0",
+ "#cad5e2",
+ "#90a1b9",
+ "#62748e",
+ "#45556c",
+ "#314158",
+ "#1d293d",
+ "#0f172b",
+ "#020618",
+ "#f9fafb",
+ "#f3f4f6",
+ "#e5e7eb",
+ "#d1d5dc",
+ "#99a1af",
+ "#6a7282",
+ "#4a5565",
+ "#364153",
+ "#1e2939",
+ "#101828",
+ "#030712",
+ "#fafafa",
+ "#f4f4f5",
+ "#e4e4e7",
+ "#d4d4d8",
+ "#9f9fa9",
+ "#71717b",
+ "#52525c",
+ "#3f3f46",
+ "#27272a",
+ "#18181b",
+ "#09090b",
+ "#f5f5f5",
+ "#e5e5e5",
+ "#d4d4d4",
+ "#a1a1a1",
+ "#737373",
+ "#525252",
+ "#404040",
+ "#262626",
+ "#171717",
+ "#0a0a0a",
+ "#fafaf9",
+ "#f5f5f4",
+ "#e7e5e4",
+ "#d6d3d1",
+ "#a6a09b",
+ "#79716b",
+ "#57534d",
+ "#44403b",
+ "#292524",
+ "#1c1917",
+ "#0c0a09",
+];
diff --git a/apps/web/src/lib/storage/storage-service.ts b/apps/web/src/lib/storage/storage-service.ts
index 79e932bd..0037dbdb 100644
--- a/apps/web/src/lib/storage/storage-service.ts
+++ b/apps/web/src/lib/storage/storage-service.ts
@@ -60,6 +60,9 @@ class StorageService {
thumbnail: project.thumbnail,
createdAt: project.createdAt.toISOString(),
updatedAt: project.updatedAt.toISOString(),
+ backgroundColor: project.backgroundColor,
+ backgroundType: project.backgroundType,
+ blurIntensity: project.blurIntensity,
};
await this.projectsAdapter.set(project.id, serializedProject);
@@ -77,6 +80,9 @@ class StorageService {
thumbnail: serializedProject.thumbnail,
createdAt: new Date(serializedProject.createdAt),
updatedAt: new Date(serializedProject.updatedAt),
+ backgroundColor: serializedProject.backgroundColor,
+ backgroundType: serializedProject.backgroundType,
+ blurIntensity: serializedProject.blurIntensity,
};
}
diff --git a/apps/web/src/stores/project-store.ts b/apps/web/src/stores/project-store.ts
index 9570f4a0..913c6810 100644
--- a/apps/web/src/stores/project-store.ts
+++ b/apps/web/src/stores/project-store.ts
@@ -20,6 +20,11 @@ interface ProjectStore {
closeProject: () => void;
renameProject: (projectId: string, name: string) => Promise<void>;
duplicateProject: (projectId: string) => Promise<string>;
+ updateProjectBackground: (backgroundColor: string) => Promise<void>;
+ updateBackgroundType: (
+ type: "color" | "blur",
+ options?: { backgroundColor?: string; blurIntensity?: number }
+ ) => Promise<void>;
}
export const useProjectStore = create<ProjectStore>((set, get) => ({
@@ -35,6 +40,9 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({
thumbnail: "",
createdAt: new Date(),
updatedAt: new Date(),
+ backgroundColor: "#000000",
+ backgroundType: "color",
+ blurIntensity: 8,
};
set({ activeProject: newProject });
@@ -234,4 +242,55 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({
throw error;
}
},
+
+ updateProjectBackground: async (backgroundColor: string) => {
+ const { activeProject } = get();
+ if (!activeProject) return;
+
+ const updatedProject = {
+ ...activeProject,
+ backgroundColor,
+ updatedAt: new Date(),
+ };
+
+ try {
+ await storageService.saveProject(updatedProject);
+ set({ activeProject: updatedProject });
+ await get().loadAllProjects(); // Refresh the list
+ } catch (error) {
+ console.error("Failed to update project background:", error);
+ toast.error("Failed to update background", {
+ description: "Please try again",
+ });
+ }
+ },
+
+ updateBackgroundType: async (
+ type: "color" | "blur",
+ options?: { backgroundColor?: string; blurIntensity?: number }
+ ) => {
+ const { activeProject } = get();
+ if (!activeProject) return;
+
+ const updatedProject = {
+ ...activeProject,
+ backgroundType: type,
+ ...(options?.backgroundColor && {
+ backgroundColor: options.backgroundColor,
+ }),
+ ...(options?.blurIntensity && { blurIntensity: options.blurIntensity }),
+ updatedAt: new Date(),
+ };
+
+ try {
+ await storageService.saveProject(updatedProject);
+ set({ activeProject: updatedProject });
+ await get().loadAllProjects(); // Refresh the list
+ } catch (error) {
+ console.error("Failed to update background type:", error);
+ toast.error("Failed to update background", {
+ description: "Please try again",
+ });
+ }
+ },
}));
diff --git a/apps/web/src/types/project.ts b/apps/web/src/types/project.ts
index e208be3a..f259662e 100644
--- a/apps/web/src/types/project.ts
+++ b/apps/web/src/types/project.ts
@@ -5,4 +5,7 @@ export interface TProject {
createdAt: Date;
updatedAt: Date;
mediaItems?: string[];
+ backgroundColor?: string;
+ backgroundType?: "color" | "blur";
+ blurIntensity?: number; // in pixels (4, 8, 18)
}