feat: background settings (color, blur)

This commit is contained in:
Maze Winther
2025-07-11 03:52:39 +02:00
parent 6c19dbb6bb
commit 4d67e366ad
7 changed files with 654 additions and 2 deletions

View File

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

View File

@ -21,6 +21,8 @@ import { useState, useRef, useEffect } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { formatTimeCode } from "@/lib/time"; import { formatTimeCode } from "@/lib/time";
import { FONT_CLASS_MAP } from "@/lib/font-config"; import { FONT_CLASS_MAP } from "@/lib/font-config";
import { BackgroundSettings } from "../background-settings";
import { useProjectStore } from "@/stores/project-store";
interface ActiveElement { interface ActiveElement {
element: TimelineElement; element: TimelineElement;
@ -39,6 +41,7 @@ export function PreviewPanel() {
width: 0, width: 0,
height: 0, height: 0,
}); });
const { activeProject } = useProjectStore();
// Calculate optimal preview size that fits in container while maintaining aspect ratio // Calculate optimal preview size that fits in container while maintaining aspect ratio
useEffect(() => { useEffect(() => {
@ -136,6 +139,85 @@ export function PreviewPanel() {
// Check if there are any elements in the timeline at all // Check if there are any elements in the timeline at all
const hasAnyElements = tracks.some((track) => track.elements.length > 0); 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 // Render an element
const renderElement = (elementData: ActiveElement, index: number) => { const renderElement = (elementData: ActiveElement, index: number) => {
const { element, mediaItem } = elementData; const { element, mediaItem } = elementData;
@ -265,12 +347,17 @@ export function PreviewPanel() {
{hasAnyElements ? ( {hasAnyElements ? (
<div <div
ref={previewRef} ref={previewRef}
className="relative overflow-hidden rounded-sm bg-black border" className="relative overflow-hidden rounded-sm border"
style={{ style={{
width: previewDimensions.width, width: previewDimensions.width,
height: previewDimensions.height, height: previewDimensions.height,
backgroundColor:
activeProject?.backgroundType === "blur"
? "transparent"
: activeProject?.backgroundColor || "#000000",
}} }}
> >
{renderBlurBackground()}
{activeElements.length === 0 ? ( {activeElements.length === 0 ? (
<div className="absolute inset-0 flex items-center justify-center text-muted-foreground"> <div className="absolute inset-0 flex items-center justify-center text-muted-foreground">
No elements at current time No elements at current time
@ -280,6 +367,14 @@ export function PreviewPanel() {
renderElement(elementData, index) 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> </div>
) : null} ) : null}
@ -346,7 +441,8 @@ function PreviewToolbar({ hasAnyElements }: { hasAnyElements: boolean }) {
<Play className="h-3 w-3" /> <Play className="h-3 w-3" />
)} )}
</Button> </Button>
<div> <div className="flex items-center gap-3">
<BackgroundSettings />
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button

View File

@ -37,3 +37,64 @@ export function GithubIcon({ className }: { className?: string }) {
</svg> </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>
);
}

243
apps/web/src/data/colors.ts Normal file
View File

@ -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",
];

View File

@ -60,6 +60,9 @@ class StorageService {
thumbnail: project.thumbnail, thumbnail: project.thumbnail,
createdAt: project.createdAt.toISOString(), createdAt: project.createdAt.toISOString(),
updatedAt: project.updatedAt.toISOString(), updatedAt: project.updatedAt.toISOString(),
backgroundColor: project.backgroundColor,
backgroundType: project.backgroundType,
blurIntensity: project.blurIntensity,
}; };
await this.projectsAdapter.set(project.id, serializedProject); await this.projectsAdapter.set(project.id, serializedProject);
@ -77,6 +80,9 @@ class StorageService {
thumbnail: serializedProject.thumbnail, thumbnail: serializedProject.thumbnail,
createdAt: new Date(serializedProject.createdAt), createdAt: new Date(serializedProject.createdAt),
updatedAt: new Date(serializedProject.updatedAt), updatedAt: new Date(serializedProject.updatedAt),
backgroundColor: serializedProject.backgroundColor,
backgroundType: serializedProject.backgroundType,
blurIntensity: serializedProject.blurIntensity,
}; };
} }

View File

@ -20,6 +20,11 @@ interface ProjectStore {
closeProject: () => void; closeProject: () => void;
renameProject: (projectId: string, name: string) => Promise<void>; renameProject: (projectId: string, name: string) => Promise<void>;
duplicateProject: (projectId: string) => Promise<string>; 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) => ({ export const useProjectStore = create<ProjectStore>((set, get) => ({
@ -35,6 +40,9 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({
thumbnail: "", thumbnail: "",
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
backgroundColor: "#000000",
backgroundType: "color",
blurIntensity: 8,
}; };
set({ activeProject: newProject }); set({ activeProject: newProject });
@ -234,4 +242,55 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({
throw error; 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",
});
}
},
})); }));

View File

@ -5,4 +5,7 @@ export interface TProject {
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
mediaItems?: string[]; mediaItems?: string[];
backgroundColor?: string;
backgroundType?: "color" | "blur";
blurIntensity?: number; // in pixels (4, 8, 18)
} }