feat: background settings (color, blur)
This commit is contained in:
184
apps/web/src/components/background-settings.tsx
Normal file
184
apps/web/src/components/background-settings.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
Reference in New Issue
Block a user