Compare commits

...
Author SHA1 Message Date
bflatastic be5d60d98f camera modal ui 2025-04-04 20:56:01 +07:00
bflatastic d3d5e29fae camera settings modal 2025-04-04 20:37:27 +07:00
9 changed files with 441 additions and 9 deletions
@@ -51,7 +51,7 @@ export const Input = React.forwardRef(
ref={ref}
id={id ? id : label ? kebabCase(label) : undefined}
className={twMerge(
"h-10 w-full rounded-lg bg-brand-secondary/80 px-3 py-2.5 text-white placeholder-white/50 outline-none",
"h-10 w-full rounded-lg bg-[#242424] px-3 py-2.5 text-white placeholder-white/50 outline-none",
"border border-[#363636] transition-all duration-150 ease-in-out hover:border-brand-primary/60 focus:border-brand-primary focus:!outline-none",
icon && "pl-10",
isError && "outline-red focus:outline-red",
@@ -23,10 +23,11 @@ interface PopoverMenuProps {
children?: ReactNode;
buttonClassName?: string;
panelClassName?: string;
onAction?: (action: string) => void;
onPanelAction?: (action: string) => void;
panelTitle?: string;
position?: "top" | "bottom";
align?: "start" | "center" | "end";
panelActionLabel?: string;
}
export const PopoverMenu = ({
@@ -41,16 +42,17 @@ export const PopoverMenu = ({
children,
buttonClassName,
panelClassName,
onAction,
onPanelAction,
panelTitle,
position = "top",
align = "start",
panelActionLabel,
}: PopoverMenuProps) => {
const selectedItem = items.find((item) => item.selected);
const handleItemClick = (item: PopoverItem, close: () => void) => {
if (mode === "button" && item.action && onAction) {
onAction(item.action);
if (mode === "button" && item.action && onPanelAction) {
onPanelAction(item.action);
close();
} else {
onSelect?.(item);
@@ -124,8 +126,19 @@ export const PopoverMenu = ({
)}
>
{panelTitle && (
<div className="mb-2 mt-0.5 px-1.5 text-sm font-normal text-white opacity-70">
<div className="mb-2 mt-0.5 flex justify-between px-1.5 text-sm font-normal text-white opacity-70">
{panelTitle}
{panelActionLabel && (
<button
onClick={() => {
onPanelAction?.(panelActionLabel);
close();
}}
className="text-end text-sm text-white/85 hover:underline"
>
{panelActionLabel}
</button>
)}
</div>
)}
{mode === "default" && children ? (
@@ -0,0 +1,184 @@
import { Transition } from "@headlessui/react";
import React, { useState, useRef, useEffect, ReactNode } from "react";
import { twMerge } from "tailwind-merge";
import { Button } from "../Button";
import { faMinus, faPlus } from "@fortawesome/pro-solid-svg-icons";
interface SliderProps {
min: number;
max: number;
value: number;
onChange: (value: number) => void;
step: number;
className?: string;
showTooltip?: boolean;
tooltipContent?: ReactNode;
suffix?: string;
innerLabel?: string;
showDecrement?: boolean;
showIncrement?: boolean;
}
export const SliderV2 = ({
min,
max,
value,
onChange,
step,
className,
showTooltip,
tooltipContent,
suffix,
innerLabel,
showDecrement,
showIncrement,
}: SliderProps) => {
const [localValue, setLocalValue] = useState(value);
const sliderRef = useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [showHandleTooltip, setShowHandleTooltip] = useState(false);
useEffect(() => {
setLocalValue(value);
}, [value]);
const setClampedValue = (value: number) => {
const roundedValue = Math.round(value / step) * step;
const clampedValue = Math.min(Math.max(roundedValue, min), max);
setLocalValue(clampedValue);
onChange(clampedValue);
};
const updatePosition = (x: number) => {
if (!sliderRef.current) return;
const rect = sliderRef.current.getBoundingClientRect();
const fraction = (x - rect.left) / rect.width;
const newValue = fraction * (max - min) + min;
setClampedValue(newValue);
};
const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragging(true);
setShowHandleTooltip(true);
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
updatePosition(e.clientX);
};
const handleMouseMove = (e: MouseEvent) => {
e.preventDefault();
updatePosition(e.clientX);
};
const handleMouseUp = (e: MouseEvent) => {
e.preventDefault();
setIsDragging(false);
setShowHandleTooltip(false);
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
const handleMouseEnter = () => {
if (!isDragging) {
setShowHandleTooltip(true);
}
};
const handleMouseLeave = () => {
if (!isDragging) {
setShowHandleTooltip(false);
}
};
const handleDecrement = () => {
setClampedValue(localValue - step);
};
const handleIncrement = () => {
setClampedValue(localValue + step);
};
const percentage = ((localValue - min) / (max - min)) * 100;
const displayValue =
localValue.toFixed(step.toString().split(".")[1]?.length || 0) +
(suffix || "");
return (
<div className="flex w-full">
{showDecrement && (
<Button
icon={faMinus}
className="focus-visible:outline-primary my-auto mr-1 size-6 rounded-full bg-transparent text-white/80 hover:bg-white/10 active:bg-brand-primary/30"
onClick={handleDecrement}
/>
)}
<div
ref={sliderRef}
className={twMerge(
"glass border-ui-border group relative h-7 w-full cursor-pointer overflow-hidden rounded-lg border",
isDragging && "!bg-ui-controls/90",
className,
)}
onMouseDown={handleMouseDown}
>
<div
className={twMerge(
"absolute h-full bg-brand-primary/30 transition-colors duration-300 group-hover:bg-brand-primary/50",
isDragging && "!bg-brand-primary/50",
)}
style={{ width: `${percentage}%` }}
>
{innerLabel && (
<span
className={twMerge(
"absolute top-1/2 ml-2.5 -translate-y-1/2 text-nowrap text-sm font-medium text-white/60 transition-colors duration-300 group-hover:text-white",
isDragging && "!text-white",
)}
>
{innerLabel}
</span>
)}
<div
className={twMerge(
"absolute right-0 top-1/2 mr-1 h-4 w-0.5 -translate-y-1/2 rounded-full",
isDragging ? "bg-white" : "bg-white/50",
)}
onMouseDown={handleMouseDown}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
/>
</div>
</div>
{showIncrement && (
<Button
icon={faPlus}
className="focus-visible:outline-primary my-auto ml-1 size-6 rounded-full bg-transparent text-white/80 hover:bg-white/10 active:bg-brand-primary/30"
onClick={handleIncrement}
/>
)}
{showTooltip && (
<Transition
as={"div"}
show={showHandleTooltip}
enter="transition ease-out duration-200"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0"
>
<div
className="absolute -top-8 z-10 rounded-md bg-ui-panel px-2 py-1 text-xs text-black shadow-lg"
style={{ left: `${percentage}%`, transform: "translateX(-50%)" }}
>
{tooltipContent ? tooltipContent : displayValue}
</div>
</Transition>
)}
</div>
);
};
@@ -0,0 +1 @@
export * from "./SliderV2";
@@ -79,7 +79,7 @@ export const TransitionDialogue = ({
<Dialog.Panel
className={twMerge(
"w-full max-w-lg transform rounded-xl",
"border border-ui-panel-border bg-ui-panel",
"border border-ui-panel-border bg-[#2C2C2C]",
"text-left align-middle shadow-xl transition-all",
className,
)}
@@ -130,7 +130,9 @@ export const TransitionDialogue = ({
)}
</Dialog.Title>
)}
<div className={`${childPadding ? "p-5 pt-0" : ""}`.trim()}>
<div
className={`h-full ${childPadding ? "p-5 pt-0" : ""}`.trim()}
>
{children}
</div>
</Dialog.Panel>
@@ -0,0 +1 @@
export { CameraSettingsModal } from "./CameraSettingsModal";
@@ -0,0 +1,160 @@
import { TransitionDialogue } from "~/components/reusable/TransitionDialogue";
import { faPlus, faTrashAlt, faXmark } from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { PopoverItem } from "~/components/reusable/Popover";
import { Button, Input, Label, Tooltip } from "~/components";
import { SliderV2 } from "~/components/reusable/SliderV2/SliderV2";
interface ExtendedPopoverItem extends PopoverItem {
id: string;
focalLength: number;
}
interface CameraSettingsModalProps {
isOpen: boolean;
onClose: () => void;
cameras: ExtendedPopoverItem[];
onCameraNameChange: (id: string, newName: string) => void;
onCameraFocalLengthChange: (id: string, value: number) => void;
onAddCamera: () => void;
selectedCameraId: string;
onSelectCamera: (id: string) => void;
onDeleteCamera: (id: string) => void;
}
export const CameraSettingsModal = ({
isOpen,
onClose,
cameras,
onCameraNameChange,
onCameraFocalLengthChange,
onAddCamera,
selectedCameraId,
onSelectCamera,
onDeleteCamera,
}: CameraSettingsModalProps) => {
const selectedCamera = cameras.find((cam) => cam.id === selectedCameraId);
return (
<TransitionDialogue
isOpen={isOpen}
onClose={onClose}
className="h-[500px] max-w-3xl"
childPadding={false}
>
<div className="grid h-full grid-cols-12 gap-3">
<div className="relative col-span-4 p-3 pt-2 after:absolute after:right-0 after:top-0 after:h-full after:w-px after:bg-gray-200 after:dark:bg-white/10">
<div className="flex items-center justify-between gap-2.5 py-0.5">
<h2 className="text-[18px] font-semibold opacity-80">Camera</h2>
<Tooltip content="Add camera" position="top" delay={200}>
<button
className="h-6 w-6 rounded-full text-white/70 transition-colors hover:text-white/100"
onClick={onAddCamera}
>
<FontAwesomeIcon icon={faPlus} className="text-xl" />
</button>
</Tooltip>
</div>
<hr className="my-2 w-full border-white/10" />
<div className="space-y-1">
{cameras.map((camera) => (
<button
key={camera.id}
className={`w-full rounded-lg p-2 text-left transition-colors duration-100 hover:bg-[#63636B]/40 ${
camera.id === selectedCameraId ? "bg-[#63636B]/40" : ""
}`}
onClick={() => onSelectCamera(camera.id)}
>
<div className="flex items-center gap-2.5 text-sm">
{camera.icon}
{camera.label}
</div>
</button>
))}
</div>
</div>
<div className="col-span-8 p-3 ps-0 pt-2">
<div className="flex h-full flex-col">
<div>
<div className="flex items-center justify-between gap-2.5 py-0.5 opacity-100">
<h2 className="text-[18px] font-semibold">
{selectedCamera?.label || "Camera"}
</h2>
<Tooltip
content={
selectedCamera?.id === "main"
? "Cannot delete the main camera"
: "Delete camera"
}
position="top"
delay={200}
>
<button
className={`h-6 w-6 rounded-lg transition-colors ${
selectedCamera?.id === "main"
? "cursor-not-allowed text-white/30"
: "text-white/60 hover:text-white/100"
}`}
onClick={() =>
selectedCamera &&
selectedCamera.id !== "main" &&
onDeleteCamera(selectedCamera.id)
}
disabled={selectedCamera?.id === "main"}
>
<FontAwesomeIcon icon={faTrashAlt} className="text-lg" />
</button>
</Tooltip>
</div>
<hr className="my-2 w-full border-white/10" />
<div className="space-y-4">
<div className="space-y-1">
<Label htmlFor="camera-name" className="text-sm opacity-70">
Name
</Label>
<Input
id="camera-name"
type="text"
value={selectedCamera?.label || ""}
onChange={(e) =>
selectedCamera &&
onCameraNameChange(selectedCamera.id, e.target.value)
}
className="text-sm"
/>
</div>
<div className="space-y-2">
<Label htmlFor="focal-length" className="text-sm opacity-70">
Focal Length
</Label>
<div className="mt-1 flex items-center gap-4">
<SliderV2
min={10}
max={200}
value={selectedCamera?.focalLength || 35}
onChange={(value) =>
selectedCamera &&
onCameraFocalLengthChange(selectedCamera.id, value)
}
step={1}
suffix="mm"
showDecrement={true}
showIncrement={true}
className="w-full"
/>
<span className="min-w-[60px] text-sm">
{selectedCamera?.focalLength || 35}mm
</span>
</div>
</div>
</div>
</div>
<div className="mt-auto flex justify-end pt-4">
<Button onClick={onClose}>Done</Button>
</div>
</div>
</div>
</div>
</TransitionDialogue>
);
};
@@ -48,6 +48,12 @@ interface ReferenceImage {
import { EngineApi } from "~/Classes/ApiManager/EngineApi";
import { scene } from "~/signals/scene";
import { UploaderStates } from "~/enums/UploaderStates";
import { CameraSettingsModal } from "./CameraSettingsModal";
interface ExtendedPopoverItem extends PopoverItem {
id: string;
focalLength: number;
}
export const PromptBox = () => {
useSignals();
@@ -78,20 +84,26 @@ export const PromptBox = () => {
icon: <FontAwesomeIcon icon={faRectangleWide} className="h-4 w-4" />,
},
]);
const [cameraList, setCameraList] = useState<PopoverItem[]>([
const [cameraList, setCameraList] = useState<ExtendedPopoverItem[]>([
{
id: "main",
label: "Main View",
selected: true,
icon: <FontAwesomeIcon icon={faCamera} className="h-4 w-4" />,
focalLength: 35,
},
{
id: "cam2",
label: "Camera 2",
selected: false,
icon: <FontAwesomeIcon icon={faCamera} className="h-4 w-4" />,
focalLength: 35,
},
]);
const [selectedCameraId, setSelectedCameraId] = useState("main");
const fileInputRef = useRef<HTMLInputElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [isCameraSettingsOpen, setIsCameraSettingsOpen] = useState(false);
useEffect(() => {
if (textareaRef.current) {
@@ -126,20 +138,41 @@ export const PromptBox = () => {
selected: item.label === selectedItem.label,
})),
);
const selectedCamera = cameraList.find(
(cam) => cam.label === selectedItem.label,
);
if (selectedCamera) {
setSelectedCameraId(selectedCamera.id);
}
};
const handleAddCamera = () => {
const newIndex = cameraList.length + 1;
const newId = `cam${newIndex}`;
setCameraList((prev) => [
...prev,
{
id: newId,
label: `Camera ${newIndex}`,
selected: false,
icon: <FontAwesomeIcon icon={faCamera} className="h-4 w-4" />,
focalLength: 35,
},
]);
};
const handleCameraNameChange = (id: string, newName: string) => {
setCameraList((prev) =>
prev.map((cam) => (cam.id === id ? { ...cam, label: newName } : cam)),
);
};
const handleCameraFocalLengthChange = (id: string, value: number) => {
setCameraList((prev) =>
prev.map((cam) => (cam.id === id ? { ...cam, focalLength: value } : cam)),
);
};
const handleAspectRatioSelect = (selectedItem: PopoverItem) => {
setAspectRatioList((prev) =>
prev.map((item) => ({
@@ -326,6 +359,31 @@ export const PromptBox = () => {
}
};
const handleDeleteCamera = (id: string) => {
// Don't allow deleting the main camera or if it's the last camera
if (id === "main" || cameraList.length <= 1) return;
// Update camera list
setCameraList((prev) => prev.filter((cam) => cam.id !== id));
// If we're deleting the selected camera, select the first remaining camera
if (id === selectedCameraId) {
const remainingCameras = cameraList.filter((cam) => cam.id !== id);
if (remainingCameras.length > 0) {
setSelectedCameraId(remainingCameras[0].id);
// Update selection state
setCameraList((prev) =>
prev
.filter((cam) => cam.id !== id)
.map((cam, idx) => ({
...cam,
selected: idx === 0,
})),
);
}
}
};
return (
<div className="absolute bottom-4 left-1/2 flex -translate-x-1/2 flex-col gap-3">
{referenceImages.length > 0 && (
@@ -429,6 +487,8 @@ export const PromptBox = () => {
showIconsInList
mode="toggle"
panelTitle="Camera"
panelActionLabel="Settings"
onPanelAction={() => setIsCameraSettingsOpen(true)}
/>
<Tooltip
content={
@@ -489,6 +549,17 @@ export const PromptBox = () => {
</div>
</div>
</div>
<CameraSettingsModal
isOpen={isCameraSettingsOpen}
onClose={() => setIsCameraSettingsOpen(false)}
cameras={cameraList}
onCameraNameChange={handleCameraNameChange}
onCameraFocalLengthChange={handleCameraFocalLengthChange}
onAddCamera={handleAddCamera}
selectedCameraId={selectedCameraId}
onSelectCamera={setSelectedCameraId}
onDeleteCamera={handleDeleteCamera}
/>
</div>
);
};