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 { 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

View File

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