Compare commits

...
Author SHA1 Message Date
bflatastic ee4444a534 removed the asset name field 2025-07-22 20:24:40 +07:00
bflatastic 800e22ccab combine upload and add popover 2025-07-22 20:15:45 +07:00
bflatastic 5afdc85fc1 padding and gap fixes 2025-07-22 20:06:29 +07:00
bflatastic 4467a0692d center image 2025-07-22 20:04:02 +07:00
bflatastic 2d009b85f9 image upload modal 2025-07-22 19:59:35 +07:00
bflatastic 2fe8dc1db4 dropdown ux 2025-07-22 17:52:22 +07:00
bflatastic ec369675ee upload modal only uploads as objects and opens the gallery modal when done 2025-07-22 16:47:53 +07:00
9 changed files with 548 additions and 270 deletions
@@ -1,8 +1,6 @@
import { useCallback, useEffect, useState, useRef } from "react";
import { ListDropdown } from "@storyteller/ui-list-dropdown";
import { Button } from "@storyteller/ui-button";
import { Input } from "@storyteller/ui-input";
import { Label } from "@storyteller/ui-label";
import { FileUploader } from "@storyteller/ui-file-uploader";
import { loadPreviewOnCanvas, snapshotCanvasAsThumbnail } from "./utilities";
import { upload3DObjects } from "./utilities/upload3DObjects";
@@ -48,13 +46,6 @@ export const UploadFiles3D = ({
: undefined,
);
const [uploadTitle, setUploadTitle] = useState<{
value: string;
error?: string;
}>({
value: "",
});
const [assetFile, setAssetFile] = useState<{
value: File | null;
error?: string;
@@ -70,14 +61,6 @@ export const UploadFiles3D = ({
);
const handleSubmit = () => {
if (!uploadTitle.value) {
setUploadTitle((curr) => ({
...curr,
error: "Please enter a title.",
}));
return;
}
if (!assetFile.value) {
setAssetFile((curr) => ({
...curr,
@@ -86,8 +69,11 @@ export const UploadFiles3D = ({
return;
}
// Use the filename as the title
const title = assetFile.value.name.split(".")[0];
upload3DObjects({
title: uploadTitle.value,
title: title,
assetFile: assetFile.value,
thumbnailSnapshot: thumbnailFile,
engineCategory: engineCategory,
@@ -123,18 +109,7 @@ export const UploadFiles3D = ({
return (
<>
<div className="mb-4 flex flex-col gap-4">
<div className="flex flex-col">
<Label required>Asset Name</Label>
<Input
placeholder="Enter the asset name here"
errorMessage={uploadTitle.error}
value={uploadTitle.value}
onChange={(event) => setUploadTitle({ value: event.target.value })}
className={uploadTitle.error ? "mb-3" : ""}
/>
</div>
<div className="flex flex-col gap-3">
<FileUploader
fileTypes={fileTypes}
file={assetFile.value ?? undefined}
@@ -183,7 +158,7 @@ export const UploadFiles3D = ({
<Button
variant="primary"
onClick={handleSubmit}
disabled={!uploadTitle.value || !assetFile.value}
disabled={!assetFile.value}
>
Upload
</Button>
@@ -1,19 +1,19 @@
import { useEffect, useState } from "react";
import { LoadingDots } from "@storyteller/ui-loading";
import { Modal } from "@storyteller/ui-modal";
import { Label } from "@storyteller/ui-label";
import { UploadAssetError, UploadSuccess } from "@storyteller/ui-upload-modal";
import { UploadFiles3D } from "./UploadFiles3D";
import { initialUploaderState, UploaderState } from "../../../models";
import { Select } from "@storyteller/ui-select";
import {
FilterEngineCategories,
UploaderStates,
OBJECT_FILE_TYPE,
CHARACTER_MIXAMO_FILE_TYPE,
IMAGEPLANE_FILE_TYPE,
} from "../../../enums";
import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import {
galleryModalVisibleViewMode,
galleryModalVisibleDuringDrag,
} from "@storyteller/ui-gallery-modal";
interface Props {
onClose: () => void;
@@ -26,63 +26,17 @@ interface Props {
hasLength?: boolean;
hasThumbnailUpload?: boolean;
};
preselectedCategory?: FilterEngineCategories;
isSelectVisible: boolean;
}
// Map categories to their allowed file types
const categoryFileTypes: Partial<Record<FilterEngineCategories, string[]>> = {
[FilterEngineCategories.OBJECT]: Object.values(OBJECT_FILE_TYPE),
[FilterEngineCategories.CHARACTER]: [
...Object.values(CHARACTER_MIXAMO_FILE_TYPE),
],
[FilterEngineCategories.IMAGE_PLANE]: Object.values(IMAGEPLANE_FILE_TYPE),
[FilterEngineCategories.LOCATION]: Object.values(OBJECT_FILE_TYPE),
[FilterEngineCategories.CREATURE]: Object.values(OBJECT_FILE_TYPE),
};
// Excluded category options for the upload modal
const categoryOptions = Object.entries(FilterEngineCategories)
.filter(
([, value]) =>
![
"audio",
"video_plane",
"skybox",
"set_dressing",
"expression",
"animation",
"image_plane",
"scene",
].includes(value),
)
.sort(([, a], [, b]) => (a === "object" ? -1 : b === "object" ? 1 : 0))
.map(([key, value]) => ({
// This is because sets is location i guess - BFlat
label:
key === "LOCATION"
? "Set"
: key.charAt(0) + key.slice(1).toLowerCase().replace(/_/g, " "),
value: value,
}));
const objectFileTypes = Object.values(OBJECT_FILE_TYPE);
export function UploadModal3D({
isOpen,
onClose,
onSuccess,
title,
titleIcon,
options,
preselectedCategory,
isSelectVisible,
}: Props & { preselectedCategory?: FilterEngineCategories }) {
export function UploadModal3D(props: Props) {
const { isOpen, onClose, onSuccess, title, titleIcon, options } = props;
const [uploaderState, setUploaderState] =
useState<UploaderState>(initialUploaderState);
const [selectedCategory, setSelectedCategory] =
useState<FilterEngineCategories>(
preselectedCategory !== undefined
? preselectedCategory
: FilterEngineCategories.OBJECT,
);
// Category fixed to OBJECT
const selectedCategory = FilterEngineCategories.OBJECT;
const updateUploaderState = (newState: UploaderState) => {
setUploaderState(newState);
@@ -98,19 +52,12 @@ export function UploadModal3D({
}
}, [isOpen]);
useEffect(() => {
if (preselectedCategory !== undefined) {
setSelectedCategory(preselectedCategory);
}
}, [preselectedCategory]);
useEffect(() => {
console.log("Preselected category:", preselectedCategory);
console.log("Initial selected category:", selectedCategory);
}, [preselectedCategory, selectedCategory]);
useEffect(() => {
if (uploaderState.status === UploaderStates.success) {
// Automatically open the global Gallery modal after a successful upload
galleryModalVisibleViewMode.value = true;
galleryModalVisibleDuringDrag.value = true;
onSuccess(selectedCategory);
onClose();
}
@@ -122,31 +69,10 @@ export function UploadModal3D({
case UploaderStates.ready:
return (
<div className="space-y-4">
{isSelectVisible && (
<div className="flex w-full flex-col">
<Label className="mb-1">Category</Label>
<Select
id="category-select"
options={categoryOptions}
value={selectedCategory}
onChange={(value) => {
if (
typeof value === "string" &&
Object.values(FilterEngineCategories).includes(
value as FilterEngineCategories,
)
) {
setSelectedCategory(value as FilterEngineCategories);
}
}}
placeholder="Select a category"
/>
</div>
)}
<UploadFiles3D
title={title}
engineCategory={selectedCategory}
fileTypes={categoryFileTypes[selectedCategory] || []}
fileTypes={objectFileTypes}
options={options}
onClose={onClose}
onUploadProgress={updateUploaderState}
@@ -0,0 +1,81 @@
import { useState } from "react";
import { Button } from "@storyteller/ui-button";
import { FileUploader } from "@storyteller/ui-file-uploader";
import { uploadImage } from "./utilities/uploadImage";
import { UploaderState } from "../../../models";
interface Props {
title: string;
fileTypes: string[];
onClose: () => void;
onUploadProgress: (newState: UploaderState) => void;
}
export const UploadFilesImage = ({
fileTypes,
onClose,
onUploadProgress,
}: Props) => {
const [assetFile, setAssetFile] = useState<{
value: File | null;
error?: string;
}>({ value: null });
const handleSubmit = () => {
if (!assetFile.value) {
setAssetFile((curr) => ({
...curr,
error: "Please select an image to upload.",
}));
return;
}
// Use the filename as the title
const title = assetFile.value.name.split(".")[0];
uploadImage({
title: title,
assetFile: assetFile.value,
progressCallback: onUploadProgress,
});
};
return (
<>
<div className="flex flex-col gap-3">
<FileUploader
fileTypes={fileTypes}
file={assetFile.value ?? undefined}
handleChange={(file: File) => {
setAssetFile({
value: file,
});
}}
/>
{assetFile.error && (
<h6 className="z-10 text-red">{assetFile.error}</h6>
)}
{assetFile.value && (
<div className="relative m-auto flex aspect-square w-full items-center justify-center overflow-hidden rounded-lg bg-brand-secondary">
<img
alt="Preview"
className="m-auto max-h-full max-w-full object-contain"
src={URL.createObjectURL(assetFile.value)}
/>
</div>
)}
<div className="flex justify-end gap-4">
<Button variant="secondary" onClick={onClose}>
Cancel
</Button>
<Button variant="primary" onClick={handleSubmit}>
Upload
</Button>
</div>
</div>
</>
);
};
@@ -0,0 +1,134 @@
import { useEffect, useState } from "react";
import { LoadingDots } from "@storyteller/ui-loading";
import { Modal } from "@storyteller/ui-modal";
import { UploadAssetError, UploadSuccess } from "@storyteller/ui-upload-modal";
import { UploadFilesImage } from "./UploadFilesImage";
import { initialUploaderState, UploaderState } from "../../../models";
import {
FilterEngineCategories,
UploaderStates,
IMAGEPLANE_FILE_TYPE,
} from "../../../enums";
import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import {
galleryModalVisibleViewMode,
galleryModalVisibleDuringDrag,
} from "@storyteller/ui-gallery-modal";
interface Props {
onClose: () => void;
onSuccess: (category: FilterEngineCategories) => void;
isOpen: boolean;
title: string;
titleIcon: IconDefinition;
}
const imageFileTypes = Object.values(IMAGEPLANE_FILE_TYPE);
export function UploadModalImage(props: Props) {
const { isOpen, onClose, onSuccess, title, titleIcon } = props;
const [uploaderState, setUploaderState] =
useState<UploaderState>(initialUploaderState);
// Category fixed to IMAGE_PLANE
const selectedCategory = FilterEngineCategories.IMAGE_PLANE;
const updateUploaderState = (newState: UploaderState) => {
setUploaderState(newState);
};
const resetModalState = () => {
setUploaderState(initialUploaderState);
};
useEffect(() => {
if (isOpen) {
resetModalState();
}
}, [isOpen]);
useEffect(() => {
if (uploaderState.status === UploaderStates.success) {
// Automatically open the global Gallery modal after a successful upload
galleryModalVisibleViewMode.value = true;
galleryModalVisibleDuringDrag.value = true;
onSuccess(selectedCategory);
onClose();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [uploaderState.status]);
const UploaderModalContent = () => {
switch (uploaderState.status) {
case UploaderStates.ready:
return (
<div className="space-y-4">
<UploadFilesImage
title={title}
fileTypes={imageFileTypes}
onClose={onClose}
onUploadProgress={updateUploaderState}
/>
</div>
);
case UploaderStates.uploadingAsset:
case UploaderStates.uploadingCover:
case UploaderStates.settingCover:
return (
<>
<LoadingDots className="mb-1 bg-transparent" />
<div className="w-100 text-center opacity-50">Uploading...</div>
</>
);
case UploaderStates.success: {
return (
<UploadSuccess
title="Image"
onOk={() => {
onClose();
onSuccess(selectedCategory);
}}
/>
);
}
case UploaderStates.assetError:
return (
<UploadAssetError
onCancel={onClose}
onRetry={() => {
resetModalState();
}}
type={selectedCategory}
errorMessage={uploaderState.errorMessage}
/>
);
case UploaderStates.coverCreateError:
case UploaderStates.coverSetError:
return (
<UploadAssetError
onCancel={onClose}
onRetry={() => {
resetModalState();
}}
type={"Thumbnail"}
errorMessage={uploaderState.errorMessage}
/>
);
}
return undefined;
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
titleIcon={titleIcon}
title={title}
className="max-w-xl"
showClose={true}
>
<UploaderModalContent />
</Modal>
);
}
@@ -0,0 +1 @@
export { UploadModalImage } from "./UploadModalImage";
@@ -0,0 +1,56 @@
import { v4 as uuidv4 } from "uuid";
import { MediaUploadApi } from "../../../../Classes/ApiManager";
import { UploaderStates } from "../../../../enums";
import { UploaderState } from "../../../../models";
import { getFileName } from "../../../../utilities";
import { EIntermediateFile } from "~/enums/EIntermediateFile";
export const uploadImage = async ({
title,
assetFile,
progressCallback,
}: {
title: string;
assetFile: File;
progressCallback: (newState: UploaderState) => void;
}) => {
const mediaUploadApi = new MediaUploadApi();
console.log("Uploading image plane:", assetFile);
progressCallback({ status: UploaderStates.uploadingAsset });
const assetResponse = await mediaUploadApi.UploadImage({
blob: assetFile,
fileName: getFileName(assetFile),
uuid: uuidv4(),
maybe_title: title,
is_intermediate_system_file: EIntermediateFile.false,
});
console.log("Image plane response:", assetResponse);
if (assetResponse == undefined) {
console.log("Error: Could not upload image plane!");
progressCallback({
status: UploaderStates.assetError,
errorMessage: "Could not upload image plane!",
});
return;
}
if (!assetResponse.success || !assetResponse.data) {
console.log("Error:", assetResponse.errorMessage);
progressCallback({
status: UploaderStates.assetError,
errorMessage: assetResponse.errorMessage,
});
return;
}
console.log("Upload successful:", assetResponse.data);
progressCallback({
status: UploaderStates.success,
data: assetResponse.data,
});
};
@@ -10,14 +10,13 @@ import {
faChevronRight,
faMountainCity,
faDog,
faImage,
faUpFromLine,
} from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button } from "@storyteller/ui-button";
import { CloseButton } from "@storyteller/ui-close-button";
import { Input } from "@storyteller/ui-input";
import { TabSelector } from "@storyteller/ui-tab-selector";
// import { TabSelector } from "@storyteller/ui-tab-selector";
import React, {
useState,
useEffect,
@@ -306,17 +305,17 @@ export const AssetModal = () => {
icon: faSun,
items: activeLibraryTab === "library" ? demoSkyboxItems.value : [],
},
{
id: "image-planes",
label: "Image Planes",
labelSingle: "Image Plane",
icon: faImage,
engineCategory: FilterEngineCategories.IMAGE_PLANE,
items:
activeLibraryTab === "library"
? (featuredImagePlanes ?? [])
: (userImagePlanes ?? []),
},
// {
// id: "image-planes",
// label: "Image Planes",
// labelSingle: "Image Plane",
// icon: faImage,
// engineCategory: FilterEngineCategories.IMAGE_PLANE,
// items:
// activeLibraryTab === "library"
// ? (featuredImagePlanes ?? [])
// : (userImagePlanes ?? []),
// },
],
// eslint-disable-next-line react-hooks/exhaustive-deps
[
@@ -500,7 +499,7 @@ export const AssetModal = () => {
<div className="relative col-span-3 flex h-full flex-col p-3 pt-2 after:absolute after:right-0 after:top-0 after:h-full after:w-px after:bg-gray-200 after:bg-white/10">
<div className="flex items-center justify-between gap-2.5 py-0.5">
<h2 className="text-[18px] font-semibold opacity-80">
3D Assets
ArtCraft Presets
</h2>
{/* <Tooltip content="Upload model" position="top" delay={200}>
<Button
@@ -513,7 +512,7 @@ export const AssetModal = () => {
</div>
<hr className="my-2 w-full border-white/10" />
<div className="flex h-full flex-col space-y-2">
<Button
{/* <Button
variant="primary"
className={twMerge(
"w-full justify-start rounded-xl border border-white/[2%] bg-white/[4%] px-3.5 py-2.5 text-left hover:bg-white/15",
@@ -526,7 +525,7 @@ export const AssetModal = () => {
className="mr-2 text-lg opacity-70"
/>
Upload an asset
</Button>
</Button> */}
{assetTabs.map((tab) => (
<Button
key={tab.id}
@@ -581,12 +580,12 @@ export const AssetModal = () => {
<div className="flex h-full flex-col">
<div className="h-full">
<div className="flex items-center gap-4">
<TabSelector
{/* <TabSelector
tabs={libraryTabs}
activeTab={activeLibraryTab}
onTabChange={setActiveLibraryTab}
className="relative z-[51] w-auto"
/>
/> */}
<div className="relative grow">
<Input
ref={searchInputRef}
@@ -633,7 +632,7 @@ export const AssetModal = () => {
</Button>
{currentTab.label}
</div>
{activeAssetTab !== "skybox" &&
{/* {activeAssetTab !== "skybox" &&
activeAssetTab !== "all" && (
<Button
icon={faUpFromLine}
@@ -648,7 +647,7 @@ export const AssetModal = () => {
Upload {getArticle(currentTab.labelSingle)}{" "}
{currentTab.labelSingle}
</Button>
)}
)} */}
</div>
)}
{renderContent()}
@@ -5,6 +5,7 @@ import {
faPlus,
faUpRightAndDownLeftFromCenter,
faCube,
faImages,
faArrowUpFromBracket,
} from "@fortawesome/pro-solid-svg-icons";
import { ButtonIconSelect } from "@storyteller/ui-button-icon-select";
@@ -33,10 +34,17 @@ import { AssetModal } from "../AssetMenu/AssetModal";
import { selectedMode } from "../../signals/selectedMode";
import { useSignals } from "@preact/signals-react/runtime";
import { outlinerState } from "../../signals/outliner/outliner";
import { twMerge } from "tailwind-merge";
// eslint-disable-next-line import/no-unresolved
import { setLogoutStates } from "~/signals/authentication/utilities";
import { UploadModal3D } from "../../../../components/reusable/UploadModal3D/UploadModal3D";
import { PopoverMenu } from "@storyteller/ui-popover";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
galleryModalVisibleViewMode,
galleryModalVisibleDuringDrag,
} from "@storyteller/ui-gallery-modal";
import { twMerge } from "tailwind-merge";
import { UploadModalImage } from "../../../../components/reusable/UploadModalImage";
export const Controls3D = () => {
useSignals();
@@ -45,6 +53,8 @@ export const Controls3D = () => {
// Action reminder is now handled through signals
const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false);
const [upload3DIsShowing, setUpload3DIsShowing] = useState(false);
const [isAddAssetPopoverOpen, setIsAddAssetPopoverOpen] = useState(false);
const [uploadImageIsShowing, setUploadImageIsShowing] = useState(false);
// Track processed 3D models by their media token to prevent duplicates
const processedModelsRef = useRef<Record<string, boolean>>({});
@@ -53,7 +63,12 @@ export const Controls3D = () => {
// Check if scene is empty and onboarding helper is not visible
const checkSceneEmpty = () => {
const isSceneEmpty =
outlinerState.items.value.length === 0 && !assetModalVisible.value;
outlinerState.items.value.length === 0 &&
!assetModalVisible.value &&
!galleryModalVisibleViewMode.value &&
!isAddAssetPopoverOpen &&
!upload3DIsShowing &&
!uploadImageIsShowing;
setShowEmptySceneTooltip(isSceneEmpty);
};
@@ -68,7 +83,13 @@ export const Controls3D = () => {
unsubscribe();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [assetModalVisible.value]);
}, [
assetModalVisible.value,
galleryModalVisibleViewMode.value,
isAddAssetPopoverOpen,
upload3DIsShowing,
uploadImageIsShowing,
]);
const handleModeChange = (value: string) => {
selectedMode.value = value;
@@ -169,8 +190,29 @@ export const Controls3D = () => {
}
};
const handleOpenUpload3DModal = () => {
setUpload3DIsShowing(true);
// Open global Gallery modal
const handleOpenGalleryModal = () => {
galleryModalVisibleViewMode.value = true;
galleryModalVisibleDuringDrag.value = true;
};
const handleAddAssetAction = (action: string) => {
switch (action) {
case "presets":
handleOpenModal();
break;
case "library":
handleOpenGalleryModal();
break;
case "upload-3d":
setUpload3DIsShowing(true);
break;
case "upload-image":
setUploadImageIsShowing(true);
break;
default:
break;
}
};
const openSettingsModal = () => {
@@ -219,14 +261,14 @@ export const Controls3D = () => {
<div className="relative">
{showEmptySceneTooltip && (
<div className="absolute -bottom-14 left-1/2 -translate-x-1/2 transform whitespace-nowrap">
<div className="animate-bounce rounded-lg bg-brand-primary px-4 py-2 text-sm font-medium text-white shadow-lg">
<div className="animate-bounce rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white shadow-lg">
Click + to add your first 3D asset!
<div className="absolute -top-1.5 left-1/2 h-3 w-3 -translate-x-1/2 rotate-45 transform bg-brand-primary" />
<div className="absolute -top-1.5 left-1/2 h-3 w-3 -translate-x-1/2 rotate-45 transform bg-primary" />
</div>
</div>
)}
<Tooltip
content="Add 3D asset to scene (B)"
content="Add an asset to scene"
position="bottom"
delay={300}
closeOnClick
@@ -234,30 +276,68 @@ export const Controls3D = () => {
showEmptySceneTooltip ? "hidden" : "block",
)}
>
<Button
icon={faPlus}
className={`h-9 w-9 rounded-[10px] text-lg ${
<PopoverMenu
mode="button"
position="bottom"
panelTitle="Add an asset to scene"
onOpenChange={setIsAddAssetPopoverOpen}
items={[
{
label: "ArtCraft Presets (B)",
selected: false,
icon: (
<FontAwesomeIcon icon={faCube} className="h-4 w-4" />
),
action: "presets",
},
{
label: "My Library",
selected: false,
icon: (
<FontAwesomeIcon
icon={faImages}
className="h-4 w-4"
/>
),
action: "library",
divider: true,
},
{
label: "Upload 3D Model",
selected: false,
icon: (
<FontAwesomeIcon
icon={faArrowUpFromBracket}
className="h-4 w-4"
/>
),
action: "upload-3d",
},
{
label: "Upload Image",
selected: false,
icon: (
<FontAwesomeIcon
icon={faArrowUpFromBracket}
className="h-4 w-4"
/>
),
action: "upload-image",
},
]}
onPanelAction={handleAddAssetAction}
showIconsInList
buttonClassName={`h-9 w-9 rounded-[10px] text-lg ${
showEmptySceneTooltip
? "border-white/80 bg-brand-primary/90"
: ""
? "bg-primary/90 hover:bg-primary/70"
: "border-transparent bg-primary/90 hover:bg-primary/70"
}`}
onClick={handleOpenModal}
triggerIcon={
<FontAwesomeIcon icon={faPlus} className="text-xl" />
}
/>
</Tooltip>
</div>
<Tooltip
content="Upload 3D asset"
position="bottom"
delay={300}
closeOnClick
>
<Button
icon={faArrowUpFromBracket}
className="text-md h-9 w-9 rounded-[10px] bg-white/15 transition-colors hover:bg-white/25"
variant="secondary"
onClick={handleOpenUpload3DModal}
/>
</Tooltip>
<Tooltip
content="Create 3D model from image"
position="bottom"
@@ -298,9 +378,16 @@ export const Controls3D = () => {
isOpen={upload3DIsShowing}
onClose={() => setUpload3DIsShowing(false)}
onSuccess={() => setUpload3DIsShowing(false)}
title="Upload 3D Asset"
title="Upload a 3D Model"
titleIcon={faCube}
isSelectVisible={true}
/>
<UploadModalImage
isOpen={uploadImageIsShowing}
onClose={() => setUploadImageIsShowing(false)}
onSuccess={() => setUploadImageIsShowing(false)}
title="Upload an Image"
titleIcon={faImages}
/>
</>
);
@@ -21,6 +21,7 @@ export interface PopoverItem {
icon?: ReactNode;
action?: string;
disabled?: boolean;
divider?: boolean;
description?: string;
badges?: Array<{
label: string;
@@ -47,6 +48,7 @@ interface PopoverMenuProps {
position?: "top" | "bottom";
align?: "start" | "center" | "end";
panelActionLabel?: string;
onOpenChange?: (open: boolean) => void;
}
export const PopoverMenu = ({
@@ -67,6 +69,7 @@ export const PopoverMenu = ({
position = "top",
align = "start",
panelActionLabel,
onOpenChange,
}: PopoverMenuProps) => {
const selectedItem = items.find((item) => item.selected);
@@ -172,6 +175,12 @@ export const PopoverMenu = ({
<Popover>
{({ open, close }) => (
<>
{(() => {
useEffect(() => {
onOpenChange?.(open);
}, [open]);
return null;
})()}
<PopoverButton
className={className}
onMouseEnter={() =>
@@ -264,70 +273,74 @@ export const PopoverMenu = ({
) : mode === "hoverSelect" ? (
<div className="flex flex-col gap-0 text-sm text-white">
{items.map((item, index) => (
<div
key={index}
onClick={() => {
if (!item.disabled) {
handleItemClick(item, close);
}
}}
className={twMerge(
"group flex cursor-pointer items-start gap-2 rounded-lg px-2 py-2 transition-all",
item.selected
? "bg-black/40 border-l-4 border-primary"
: "hover:bg-black/20",
item.disabled
? "!cursor-not-allowed opacity-50"
: ""
)}
style={{ minHeight: 48 }}
>
<div className="flex items-center gap-2 w-full">
<div className="flex items-start gap-2 grow">
{showIconsInList && (
<span className="mt-1 flex h-5 w-5 items-center justify-center text-lg text-white/80">
{item.icon}
</span>
)}
<div className="flex flex-1 flex-col min-w-0">
<div className="flex items-center gap-2 min-w-0">
<span className="truncate font-semibold text-white text-base">
{item.label}
<div key={index}>
<div
onClick={() => {
if (!item.disabled) {
handleItemClick(item, close);
}
}}
className={twMerge(
"group flex cursor-pointer items-start gap-2 rounded-lg px-2 py-2 transition-all",
item.selected
? "bg-black/40 border-l-4 border-primary"
: "hover:bg-black/20",
item.disabled
? "!cursor-not-allowed opacity-50"
: ""
)}
style={{ minHeight: 48 }}
>
<div className="flex items-center gap-2 w-full">
<div className="flex items-start gap-2 grow">
{showIconsInList && (
<span className="mt-1 flex h-5 w-5 items-center justify-center text-lg text-white/80">
{item.icon}
</span>
</div>
{item.description && (
<div className="truncate text-xs text-white/60 mt-0.5">
{item.description}
</div>
)}
<div className="flex flex-1 flex-col min-w-0">
<div className="flex items-center gap-2 min-w-0">
<span className="truncate font-semibold text-white text-base">
{item.label}
</span>
</div>
<div className="flex flex-row gap-1 flex-wrap mt-1.5">
{item.badges &&
Array.isArray(item.badges) &&
item.badges.map((badge, i) => (
<div
key={i}
className="flex items-center gap-1 min-w-0"
>
<span className="inline-flex items-center rounded bg-black/40 px-1.5 py-0.5 text-xs font-medium text-white gap-1">
{badge?.icon && (
<span>{badge.icon}</span>
)}
{badge?.label || ""}
</span>
</div>
))}
{item.description && (
<div className="truncate text-xs text-white/60 mt-0.5">
{item.description}
</div>
)}
<div className="flex flex-row gap-1 flex-wrap mt-1.5">
{item.badges &&
Array.isArray(item.badges) &&
item.badges.map((badge, i) => (
<div
key={i}
className="flex items-center gap-1 min-w-0"
>
<span className="inline-flex items-center rounded bg-black/40 px-1.5 py-0.5 text-xs font-medium text-white gap-1">
{badge?.icon && (
<span>{badge.icon}</span>
)}
{badge?.label || ""}
</span>
</div>
))}
</div>
</div>
</div>
</div>
{item.selected && (
<span className="text-primary text-xl font-bold bg-white rounded-full p-0 h-4 w-4 flex items-center justify-center mr-1">
<FontAwesomeIcon icon={faCircleCheck} />
</span>
)}
{item.selected && (
<span className="text-primary text-xl font-bold bg-white rounded-full p-0 h-4 w-4 flex items-center justify-center mr-1">
<FontAwesomeIcon icon={faCircleCheck} />
</span>
)}
</div>
</div>
{item.divider && (
<div className="my-1 border-t border-white/10" />
)}
</div>
))}
{showAddButton && onAdd && (
@@ -349,57 +362,63 @@ export const PopoverMenu = ({
) : (
<div className="flex flex-col gap-0 text-sm text-white">
{items.map((item, index) => (
<Button
key={index}
className={twMerge(
"flex w-full items-center justify-between border-transparent bg-transparent px-1.5",
"hover:bg-[#63636B]/60",
mode === "toggle" && item.selected
? "hover:bg-[#63636B]"
: "bg-transparent",
item.disabled
? "!cursor-not-allowed opacity-50"
: ""
)}
onClick={() =>
!item.disabled && handleItemClick(item, close)
}
variant="secondary"
disabled={item.disabled}
>
<div className="flex items-center gap-2 truncate">
{showIconsInList && item.icon}
{mode === "toggle" ? (
<div key={index}>
<Button
className={twMerge(
"flex w-full items-center justify-between border-transparent bg-transparent px-1.5",
"hover:bg-[#63636B]/60",
mode === "toggle" && item.selected
? "hover:bg-[#63636B]"
: "bg-transparent",
item.disabled
? "!cursor-not-allowed opacity-50"
: ""
)}
onClick={() =>
!item.disabled && handleItemClick(item, close)
}
variant="secondary"
disabled={item.disabled}
>
<div className="flex items-center gap-2 truncate">
{showIconsInList && item.icon}
{mode === "toggle" ? (
<span
className={twMerge(
"truncate",
item.selected
? "text-white"
: "text-white/70"
)}
>
{item.label}
</span>
) : (
<span className="truncate">{item.label}</span>
)}
</div>
{mode === "toggle" && (
<span
className={twMerge(
"truncate",
item.selected ? "text-white" : "text-white/70"
"ml-2 h-5 w-5 rounded-full border flex items-center justify-center transition-colors",
item.selected
? "border-primary bg-primary"
: "border-transparent bg-transparent"
)}
>
{item.label}
{item.selected && (
<FontAwesomeIcon
icon={faCheck}
className="text-white text-xs font-bold"
/>
)}
</span>
) : (
<span className="truncate">{item.label}</span>
)}
</div>
{mode === "toggle" && (
<span
className={twMerge(
"ml-2 h-5 w-5 rounded-full border flex items-center justify-center transition-colors",
item.selected
? "border-primary bg-primary"
: "border-transparent bg-transparent"
)}
>
{item.selected && (
<FontAwesomeIcon
icon={faCheck}
className="text-white text-xs font-bold"
/>
)}
</span>
</Button>
{item.divider && (
<div className="my-1 border-t border-white/10" />
)}
</Button>
</div>
))}
{showAddButton && onAdd && (
<Button