Compare commits

...
Author SHA1 Message Date
bflatastic 3db889cdbc after ok is clicked open the mine tab and category of uploaded object 2025-04-21 12:22:02 +07:00
bflatastic 2f52ccf05c working upload modal 2025-04-21 12:15:55 +07:00
8 changed files with 607 additions and 305 deletions
@@ -1,76 +1,95 @@
import { Fragment } from "react";
import { Listbox, Transition } from "@headlessui/react";
import { faCheck, faChevronDown } from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { MediaFileAnimationType } from "~/enums";
import { twMerge } from "tailwind-merge";
export type SelectValue = string | number;
export type SelectOption = { label: string; value: SelectValue };
interface ListDropdownProps {
options: SelectOption[];
onChange: (val: MediaFileAnimationType) => void;
onChange: (val: SelectValue) => void;
placeholder?: string;
value?: SelectValue;
id?: string;
}
export const Select = ({
onChange,
options,
placeholder,
value,
id,
}: ListDropdownProps) => {
const selectedOption = options.find(
(option: SelectOption) => option.value === value,
) || { label: placeholder || "", value: "" };
return (
<Listbox value={value} onChange={onChange}>
<div className="relative mt-1">
<Listbox.Button className="relative h-10 w-full cursor-pointer rounded-md bg-brand-secondary py-2 pl-3 pr-10 text-left outline-none outline-offset-0 transition-all duration-150 ease-in-out focus:outline-brand-primary sm:text-sm">
<span className={`block truncate${value ? "" : " opacity-50"}`}>
{selectedOption.label}
</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<FontAwesomeIcon icon={faChevronDown} aria-hidden="true" />
</span>
</Listbox.Button>
<Transition
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Listbox.Options className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-brand-secondary py-1 text-base shadow-lg focus:outline-none sm:text-sm">
{options.map((option, itemIdx) => (
<Listbox.Option
key={itemIdx}
className={({ active }) =>
`relative cursor-pointer select-none py-2 pl-10 pr-4 text-white ${
active ? "text-white" : "text-gray-400"
}`
}
value={option.value}
<div className="relative mt-1">
<Listbox value={value || ""} onChange={onChange}>
{({ open }) => (
<>
<Listbox.Button
id={id}
className="relative h-10 w-full cursor-pointer rounded-md bg-brand-secondary py-2 pl-3 pr-10 text-left outline-none outline-offset-0 transition-all duration-150 ease-in-out focus:outline-brand-primary sm:text-sm"
>
<span
className={twMerge("block truncate", !value && "opacity-50")}
>
{({ selected }) => (
<>
<span
className={`block truncate ${
selected ? "font-medium" : "font-normal"
}`}
>
{option.label}
</span>
{selected ? (
<span className="absolute inset-y-0 left-0 flex items-center pl-3">
<FontAwesomeIcon icon={faCheck} aria-hidden="true" />
</span>
) : null}
</>
)}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
</Listbox>
{selectedOption.label}
</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2.5">
<FontAwesomeIcon icon={faChevronDown} aria-hidden="true" />
</span>
</Listbox.Button>
<Transition
show={open}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Listbox.Options className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-brand-secondary py-1 text-base shadow-xl focus:outline-none sm:text-sm">
{options.map((option, itemIdx) => (
<Listbox.Option
key={itemIdx}
className={({ active, selected }) =>
twMerge(
"relative cursor-pointer select-none py-2.5 pl-8 pr-2 text-white transition-all duration-150 ease-in-out",
active && "bg-brand-primary/20 text-white",
selected && "bg-brand-primary/40",
!selected && "text-white/90",
)
}
value={option.value}
>
{({ selected }) => (
<>
<span
className={`block truncate ${
selected ? "font-medium" : "font-normal"
}`}
>
{option.label}
</span>
{selected ? (
<span className="absolute inset-y-0 left-0 flex items-center pl-2">
<FontAwesomeIcon
icon={faCheck}
aria-hidden="true"
/>
</span>
) : null}
</>
)}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</>
)}
</Listbox>
</div>
);
};
@@ -27,11 +27,17 @@ const DialogBackdrop = ({ className }: { className?: string }) => {
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leave="ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className={twMerge("fixed inset-0 bg-black/60", className)} />
<div
className={twMerge("fixed inset-0 bg-black/60", className)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
/>
</Transition.Child>
);
};
@@ -71,7 +77,7 @@ export const TransitionDialogue = ({
<div className="flex min-h-full items-center justify-center p-4 text-center">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enter="ease-out duration-200"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
@@ -86,6 +92,10 @@ export const TransitionDialogue = ({
className,
)}
style={{ minWidth: width }}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
{title && (
<Dialog.Title
@@ -35,13 +35,15 @@ export const H4 = ({ className, children }: TypoProps) => (
export const Label = ({
className,
children,
required,
...rest
}: LabelHTMLAttributes<HTMLLabelElement>) => (
}: LabelHTMLAttributes<HTMLLabelElement> & { required?: boolean }) => (
<label
className={twMerge(baseTypo, "mb-2 text-[15px] font-medium", className)}
{...rest}
>
{children}
{required && <span className="ml-0.5 text-[#ff6467]">*</span>}
</label>
);
@@ -1,5 +1,6 @@
import { FileUploader as DragDropFilesUploader } from "react-drag-drop-files";
import { DragAndDropZone } from "./DragAndDropZone";
import { Label } from "../Typography";
interface Props {
title: string;
@@ -10,8 +11,8 @@ interface Props {
export const FileUploader = ({ fileTypes, title, file, setFile }: Props) => {
return (
<div className="flex w-full flex-col gap-3">
{title}
<div className="flex w-full flex-col">
<Label required>{title}</Label>
<DragDropFilesUploader
handleChange={(file: File | null) => {
setFile(file);
@@ -1,11 +1,12 @@
import { useCallback, useEffect, useState, useRef } from "react";
import { Button, H6, Input, ListDropdown } from "~/components";
import { Button, H6, Input, Label, ListDropdown } from "~/components";
import { FileUploader } from "../UploadModal/FileUploader";
import { loadPreviewOnCanvas, snapshotCanvasAsThumbnail } from "./utilities";
import { upload3DObjects } from "./utilities/upload3DObjects";
import { UploaderState } from "~/models";
import { FilterEngineCategories, MediaFileAnimationType } from "~/enums";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCube } from "@fortawesome/pro-solid-svg-icons";
interface Props {
title: string;
@@ -21,7 +22,6 @@ interface Props {
}
export const UploadFiles3D = ({
title,
fileTypes,
engineCategory,
options,
@@ -121,16 +121,19 @@ export const UploadFiles3D = ({
return (
<>
<div className="mb-4 flex flex-col gap-4">
<Input
placeholder="Enter the title here"
errorMessage={uploadTitle.error}
value={uploadTitle.value}
onChange={(event) => setUploadTitle({ value: event.target.value })}
className={uploadTitle.error ? "mb-3" : ""}
/>
<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>
<FileUploader
title={title}
title="Upload 3D Model"
fileTypes={fileTypes}
file={assetFile.value}
setFile={(file: File | null) => {
@@ -149,16 +152,20 @@ export const UploadFiles3D = ({
<H6 className="z-10 text-red">{assetFile.error}</H6>
)}
<div className="relative m-auto aspect-square w-full overflow-hidden rounded-lg bg-brand-secondary">
<canvas className="h-full w-full" ref={canvasCallbackRef} />
<div className="relative m-auto w-full overflow-hidden rounded-lg bg-brand-secondary">
<canvas
className="pointer-events-none h-full w-full"
ref={canvasCallbackRef}
/>
{!assetFile.value && (
<H6 className="absolute left-0 top-1/2 -mt-5 w-full text-center">
File Preview
<H6 className="pointer-events-auto absolute left-0 top-1/2 -mt-5 flex w-full items-center justify-center gap-2.5 text-center opacity-50">
<FontAwesomeIcon icon={faCube} />
Your model preview will appear here
</H6>
)}
{previewStatus.type.includes("Error") && (
<>
<H6 className="absolute left-0 top-1/2 -mt-5 w-full text-center">
<H6 className="pointer-events-auto absolute left-0 top-1/2 -mt-5 w-full text-center">
{previewStatus.type}
{previewStatus.message && <br />}
{previewStatus.message}
@@ -167,13 +174,17 @@ export const UploadFiles3D = ({
)}
</div>
<div className="flex justify-end gap-4">
<Button variant="primary" onClick={handleSubmit}>
Upload
</Button>
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={onClose}>
Cancel
</Button>
<Button
variant="primary"
onClick={handleSubmit}
disabled={!uploadTitle.value || !assetFile.value}
>
Upload
</Button>
</div>
</div>
</>
@@ -1,20 +1,31 @@
import { useEffect, useState } from "react";
import { TransitionDialogue, LoadingDots } from "~/components";
import {
TransitionDialogue,
LoadingDots,
Label,
CloseButton,
} from "~/components";
import { UploadAssetError } from "../UploadModal/UploadAssetError";
import { UploadSuccess } from "../UploadModal/UploadSuccess";
import { UploadFiles3D } from "./UploadFiles3D";
import { FilterEngineCategories, UploaderStates } from "~/enums";
import { initialUploaderState, UploaderState } from "~/models";
import { Select } from "../Select";
import {
FilterEngineCategories,
UploaderStates,
OBJECT_FILE_TYPE,
CHARACTER_MIXAMO_FILE_TYPE,
IMAGEPLANE_FILE_TYPE,
} from "~/enums";
import { assetModalVisible } from "~/pages/PageEnigma/signals";
interface Props {
onClose: () => void;
onSuccess: () => void;
isOpen: boolean;
title: string;
fileTypes: string[];
engineCategory: FilterEngineCategories;
options?: {
fileSubtypes?: { [key: string]: string }[];
hasLength?: boolean;
@@ -22,17 +33,61 @@ interface Props {
};
}
// 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),
};
// Map engine categories to asset modal tabs
const categoryToAssetTab: Partial<Record<FilterEngineCategories, string>> = {
[FilterEngineCategories.OBJECT]: "objects",
[FilterEngineCategories.CHARACTER]: "character",
[FilterEngineCategories.IMAGE_PLANE]: "image-planes",
[FilterEngineCategories.LOCATION]: "sets",
[FilterEngineCategories.CREATURE]: "creatures",
};
// Excluded category options for the upload modal
const categoryOptions = Object.entries(FilterEngineCategories)
.filter(
([, value]) =>
![
"audio",
"video_plane",
"skybox",
"set_dressing",
"expression",
"animation",
"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,
}));
export function UploadModal3D({
isOpen,
onClose,
onSuccess,
title,
fileTypes,
engineCategory,
options,
}: Props) {
const [uploaderState, setUploaderState] =
useState<UploaderState>(initialUploaderState);
const [selectedCategory, setSelectedCategory] =
useState<FilterEngineCategories>(FilterEngineCategories.OBJECT);
const updateUploaderState = (newState: UploaderState) => {
setUploaderState(newState);
@@ -52,14 +107,35 @@ export function UploadModal3D({
switch (uploaderState.status) {
case UploaderStates.ready:
return (
<UploadFiles3D
title={title}
engineCategory={engineCategory}
fileTypes={fileTypes}
options={options}
onClose={onClose}
onUploadProgress={updateUploaderState}
/>
<div className="space-y-4">
<div className="flex w-full flex-col">
<Label>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] || []}
options={options}
onClose={onClose}
onUploadProgress={updateUploaderState}
/>
</div>
);
case UploaderStates.uploadingAsset:
case UploaderStates.uploadingCover:
@@ -73,10 +149,25 @@ export function UploadModal3D({
case UploaderStates.success:
return (
<UploadSuccess
title={title}
title="3D model"
onOk={() => {
onSuccess();
onClose();
// Store the selected category and tab in sessionStorage
const assetTab = categoryToAssetTab[selectedCategory];
if (assetTab) {
sessionStorage.setItem(
"lastUploadedCategory",
selectedCategory,
);
sessionStorage.setItem("lastUploadedTab", assetTab);
// Reopen the asset modal
assetModalVisible.value = true;
} else {
// Clear any existing stored values if we don't have a valid tab
sessionStorage.removeItem("lastUploadedCategory");
sessionStorage.removeItem("lastUploadedTab");
}
}}
/>
);
@@ -87,7 +178,7 @@ export function UploadModal3D({
onRetry={() => {
resetModalState();
}}
type={FilterEngineCategories.OBJECT}
type={selectedCategory}
errorMessage={uploaderState.errorMessage}
/>
);
@@ -104,10 +195,18 @@ export function UploadModal3D({
/>
);
}
return undefined;
};
return (
<TransitionDialogue isOpen={isOpen} onClose={onClose} title={title}>
<TransitionDialogue
isOpen={isOpen}
onClose={onClose}
title={title}
className="max-w-xl"
showClose={false}
>
<CloseButton className="absolute right-4 top-4" onClick={onClose} />
<UploaderModalContent />
</TransitionDialogue>
);
@@ -22,7 +22,14 @@ export const loadPreviewOnCanvas = ({
}) => {
// Setup of scene, camera, and renderer in the canvas
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, 1, 0.1, 1000);
// Calculate proper aspect ratio from canvas dimensions
const width = canvas.getBoundingClientRect().width || 0;
const height = canvas.getBoundingClientRect().height || 0;
const aspectRatio = width / height;
// Reduce FOV to make objects appear larger
const camera = new THREE.PerspectiveCamera(35, aspectRatio, 0.1, 1000);
camera.position.z = 2;
const renderer = new THREE.WebGLRenderer({
@@ -32,14 +39,16 @@ export const loadPreviewOnCanvas = ({
preserveDrawingBuffer: true,
});
renderer.setSize(
canvas.getBoundingClientRect().width || 0,
canvas.getBoundingClientRect().height || 0,
);
renderer.setSize(width, height);
const color = 0xfcece7;
const light = new THREE.HemisphereLight(color, 0x8d8d8d, 3.0);
// Add some additional lighting to better show the model
const frontLight = new THREE.DirectionalLight(0xffffff, 2);
frontLight.position.set(0, 0, 10);
scene.add(frontLight);
scene.add(light);
// load the file into the preview mini-scene depending of the file's type
@@ -88,33 +97,41 @@ const glbLoader = ({
child.userData["color"] = "#FFFFFF";
scene.add(child);
let maxSize = 2;
if (scene.children.length > 0) {
scene.children.forEach((child) => {
child.traverse((object: THREE.Object3D | THREE.Mesh) => {
// Assuming `object` is your Three.js object and you know it's a Mesh
if (object instanceof THREE.Mesh) {
object.geometry.computeBoundingBox();
const boundingBox = object.geometry.boundingBox;
const center = new THREE.Vector3();
boundingBox.getCenter(center);
const dimensions = new THREE.Vector3();
boundingBox.getSize(dimensions);
const maxDim = Math.max(
dimensions.x,
dimensions.y,
dimensions.z,
);
if (maxSize < maxDim) {
maxSize = maxDim;
camera.position.set(-maxDim, maxDim, maxDim);
camera.lookAt(center);
camera.updateProjectionMatrix();
}
}
});
});
}
// Calculate bounding box for all objects in the scene
const box = new THREE.Box3();
scene.traverse((object) => {
if (object instanceof THREE.Mesh) {
object.geometry.computeBoundingBox();
box.expandByObject(object);
}
});
// Get the center and size of the bounding box
const center = new THREE.Vector3();
const size = new THREE.Vector3();
box.getCenter(center);
box.getSize(size);
// Calculate the radius of the bounding sphere
const radius = Math.max(size.x, size.y, size.z) * 0.5;
// Position camera to fit the object in view with minimal padding
const fov = camera.fov * (Math.PI / 180);
const distance = (radius * 1.2) / Math.tan(fov * 0.5);
// Position camera at a better angle for preview
camera.position.set(
center.x + distance * 0.6,
center.y + distance * 0.4,
center.z + distance * 0.6,
);
camera.lookAt(center);
// Adjust the camera's near and far planes to ensure the model is visible
camera.near = distance * 0.01;
camera.far = distance * 100;
camera.updateProjectionMatrix();
renderer.render(scene, camera);
statusCallback({
type: "OK",
@@ -1,4 +1,5 @@
import { TransitionDialogue } from "~/components/reusable/TransitionDialogue";
import { UploadModal3D } from "~/components/reusable/UploadModal3D";
import {
faPlus,
faSearch,
@@ -10,6 +11,7 @@ import {
faChevronRight,
faMountainCity,
faDog,
faImage,
} from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button, CloseButton, Input, Tooltip } from "~/components";
@@ -26,14 +28,10 @@ import {
} from "../../signals";
import { MediaItem } from "../../models";
import { useUserObjects, useFeaturedObjects } from "../SidePanelTabs/hooks";
import { FilterEngineCategories } from "~/enums";
import { FilterEngineCategories, FilterMediaType } from "~/enums";
import { twMerge } from "tailwind-merge";
import { useSignals } from "@preact/signals-react/runtime";
interface AssetModalProps {
isOpen: boolean;
onClose: () => void;
}
import { isAnyStatusFetching } from "../SidePanelTabs/utilities";
type AssetTab = {
id: string;
@@ -80,6 +78,7 @@ export const AssetModal = () => {
const [activeAssetTab, setActiveAssetTab] = useState("all");
const [searchTerm, setSearchTerm] = useState("");
const [reopenAfterAdd, setReopenAfterAdd] = useState(false);
const [isUploadModalOpen, setIsUploadModalOpen] = useState(false);
const searchInputRef = useRef<HTMLInputElement>(null);
const handleClose = () => {
@@ -93,116 +92,219 @@ export const AssetModal = () => {
useEffect(() => {
if (assetModalVisible.value) {
// Check for stored category and tab from upload
const lastUploadedTab = sessionStorage.getItem("lastUploadedTab");
if (lastUploadedTab) {
setActiveLibraryTab("mine");
setActiveAssetTab(lastUploadedTab);
// Clear the stored values
sessionStorage.removeItem("lastUploadedTab");
sessionStorage.removeItem("lastUploadedCategory");
}
// Small delay to ensure the modal is fully mounted
const timer = setTimeout(() => {
searchInputRef.current?.focus();
}, 100);
return () => clearTimeout(timer);
}
return undefined;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [assetModalVisible.value]);
// Fetch objects for different categories
const { userObjects: userCharacters } = useUserObjects({
const {
userObjects: userCharacters,
userFetchStatus: userCharactersFetchStatus,
fetchUserObjects: fetchUserCharacters,
} = useUserObjects({
filterEngineCategories: [FilterEngineCategories.CHARACTER],
defaultErrorMessage: "Error fetching user characters",
});
const { userObjects: userObjects } = useUserObjects({
const {
userObjects: userObjects,
userFetchStatus: userObjectsFetchStatus,
fetchUserObjects: fetchUserObjects,
} = useUserObjects({
filterEngineCategories: [FilterEngineCategories.OBJECT],
defaultErrorMessage: "Error fetching user objects",
});
const { featuredObjects: featuredCharacters } = useFeaturedObjects({
filterEngineCategories: [FilterEngineCategories.CHARACTER],
defaultErrorMessage: "Error fetching featured characters",
});
const { featuredObjects: featuredObjects } = useFeaturedObjects({
filterEngineCategories: [FilterEngineCategories.OBJECT],
defaultErrorMessage: "Error fetching featured objects",
});
const { userObjects: userSets } = useUserObjects({
const {
userObjects: userSets,
userFetchStatus: userSetsFetchStatus,
fetchUserObjects: fetchUserSets,
} = useUserObjects({
filterEngineCategories: [FilterEngineCategories.LOCATION],
defaultErrorMessage: "Error fetching user sets",
});
const { featuredObjects: featuredSets } = useFeaturedObjects({
filterEngineCategories: [FilterEngineCategories.LOCATION],
defaultErrorMessage: "Error fetching featured sets",
});
const { userObjects: userCreatures } = useUserObjects({
const {
userObjects: userCreatures,
userFetchStatus: userCreaturesFetchStatus,
fetchUserObjects: fetchUserCreatures,
} = useUserObjects({
filterEngineCategories: [FilterEngineCategories.CREATURE],
defaultErrorMessage: "Error fetching user creatures",
});
const { featuredObjects: featuredCreatures } = useFeaturedObjects({
const {
userObjects: userImagePlanes,
userFetchStatus: userImagePlanesFetchStatus,
fetchUserObjects: fetchUserImagePlanes,
} = useUserObjects({
filterEngineCategories: [FilterEngineCategories.IMAGE_PLANE],
defaultErrorMessage: "Error fetching user image planes",
});
const {
featuredObjects: featuredCharacters,
featuredFetchStatus: featuredCharactersFetchStatus,
} = useFeaturedObjects({
filterEngineCategories: [FilterEngineCategories.CHARACTER],
filterMediaTypes: [FilterMediaType.GLB],
defaultErrorMessage: "Error fetching featured characters",
});
const {
featuredObjects: featuredObjects,
featuredFetchStatus: featuredObjectsFetchStatus,
} = useFeaturedObjects({
filterEngineCategories: [FilterEngineCategories.OBJECT],
defaultErrorMessage: "Error fetching featured objects",
});
const {
featuredObjects: featuredSets,
featuredFetchStatus: featuredSetsFetchStatus,
} = useFeaturedObjects({
filterEngineCategories: [FilterEngineCategories.LOCATION],
defaultErrorMessage: "Error fetching featured sets",
});
const {
featuredObjects: featuredCreatures,
featuredFetchStatus: featuredCreaturesFetchStatus,
} = useFeaturedObjects({
filterEngineCategories: [FilterEngineCategories.CREATURE],
defaultErrorMessage: "Error fetching featured creatures",
});
const {
featuredObjects: featuredImagePlanes,
featuredFetchStatus: featuredImagePlanesFetchStatus,
} = useFeaturedObjects({
filterEngineCategories: [FilterEngineCategories.IMAGE_PLANE],
defaultErrorMessage: "Error fetching featured image planes",
});
const isFetching = isAnyStatusFetching([
userCharactersFetchStatus,
userObjectsFetchStatus,
userSetsFetchStatus,
userCreaturesFetchStatus,
userImagePlanesFetchStatus,
featuredCharactersFetchStatus,
featuredObjectsFetchStatus,
featuredSetsFetchStatus,
featuredCreaturesFetchStatus,
featuredImagePlanesFetchStatus,
]);
const libraryTabs = [
{ id: "library", label: "Library" },
{ id: "mine", label: "Mine" },
];
// eslint-disable-next-line react-hooks/exhaustive-deps
const assetTabs: AssetTab[] = [
{ id: "all", label: "All", icon: faLayerGroup, items: [] },
{
id: "character",
label: "Character",
icon: faUser,
engineCategory: FilterEngineCategories.CHARACTER,
items:
activeLibraryTab === "library"
? [...demoCharacterItems.value, ...(featuredCharacters ?? [])]
: (userCharacters ?? []),
},
{
id: "objects",
label: "Objects",
icon: faCube,
engineCategory: FilterEngineCategories.OBJECT,
items:
activeLibraryTab === "library"
? [...demoShapeItems.value, ...(featuredObjects ?? [])]
: (userObjects ?? []),
},
{
id: "sets",
label: "Sets",
icon: faMountainCity,
engineCategory: FilterEngineCategories.LOCATION,
items:
activeLibraryTab === "library"
? (featuredSets ?? [])
: (userSets ?? []),
},
{
id: "creatures",
label: "Creatures",
icon: faDog,
engineCategory: FilterEngineCategories.CREATURE,
items:
activeLibraryTab === "library"
? (featuredCreatures ?? [])
: (userCreatures ?? []),
},
{
id: "skybox",
label: "Skybox",
icon: faSun,
items: demoSkyboxItems.value,
},
];
const assetTabs = useMemo<AssetTab[]>(
() => [
{ id: "all", label: "All", icon: faLayerGroup, items: [] },
{
id: "character",
label: "Character",
icon: faUser,
engineCategory: FilterEngineCategories.CHARACTER,
items:
activeLibraryTab === "library"
? [...demoCharacterItems.value, ...(featuredCharacters ?? [])]
: (userCharacters ?? []),
},
{
id: "objects",
label: "Objects",
icon: faCube,
engineCategory: FilterEngineCategories.OBJECT,
items:
activeLibraryTab === "library"
? [...demoShapeItems.value, ...(featuredObjects ?? [])]
: (userObjects ?? []),
},
{
id: "sets",
label: "Sets",
icon: faMountainCity,
engineCategory: FilterEngineCategories.LOCATION,
items:
activeLibraryTab === "library"
? (featuredSets ?? [])
: (userSets ?? []),
},
{
id: "creatures",
label: "Creatures",
icon: faDog,
engineCategory: FilterEngineCategories.CREATURE,
items:
activeLibraryTab === "library"
? (featuredCreatures ?? [])
: (userCreatures ?? []),
},
{
id: "skybox",
label: "Skybox",
icon: faSun,
items: activeLibraryTab === "library" ? demoSkyboxItems.value : [],
},
{
id: "image-planes",
label: "Image Planes",
icon: faImage,
engineCategory: FilterEngineCategories.IMAGE_PLANE,
items:
activeLibraryTab === "library"
? (featuredImagePlanes ?? [])
: (userImagePlanes ?? []),
},
],
// eslint-disable-next-line react-hooks/exhaustive-deps
[
activeLibraryTab,
demoCharacterItems.value,
demoShapeItems.value,
demoSkyboxItems.value,
featuredCharacters,
featuredCreatures,
featuredObjects,
featuredSets,
featuredImagePlanes,
userCharacters,
userCreatures,
userObjects,
userSets,
userImagePlanes,
],
);
// Combine all items for the "All" tab
const allItems = useMemo(() => {
return assetTabs
const items = assetTabs
.filter((tab) => tab.id !== "all")
.flatMap((tab) => tab.items);
// console.log("All items:", items);
// console.log("Active library tab:", activeLibraryTab);
// console.log("User creatures:", userCreatures);
// console.log("User objects:", userObjects);
return items;
}, [assetTabs]);
// Update the items array for the "All" tab
@@ -238,11 +340,31 @@ export const AssetModal = () => {
setSearchTerm("");
}, [activeAssetTab]);
// Refresh user content when switching to mine tab
useEffect(() => {
if (activeLibraryTab === "mine") {
// Trigger a refetch of user content
fetchUserCharacters();
fetchUserObjects();
fetchUserSets();
fetchUserCreatures();
fetchUserImagePlanes();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeLibraryTab]);
const renderContent = () => {
if (activeAssetTab === "all" && !searchTerm) {
// Filter out empty categories when in "mine" tab
const tabsToShow = assetTabs
.slice(1)
.filter(
(tab) => activeLibraryTab === "library" || tab.items.length > 0,
);
return (
<div className="h-full space-y-2 overflow-y-auto">
{assetTabs.slice(1).map((tab) => (
{tabsToShow.map((tab) => (
<AllTabSection
key={tab.id}
label={tab.label}
@@ -257,13 +379,18 @@ export const AssetModal = () => {
return (
<ItemElements
items={displayedItems}
busy={false}
busy={isFetching}
debug={`asset-modal-${currentTab.id}`}
/>
);
};
const handleAddAsset = () => {
assetModalVisible.value = false;
setIsUploadModalOpen(true);
};
const handleUploadSuccess = () => {
if (reopenAfterAdd) {
// Small delay to allow the modal to close and reopen
setTimeout(() => {
@@ -277,113 +404,129 @@ export const AssetModal = () => {
const clearSearch = () => setSearchTerm("");
return (
<TransitionDialogue
isOpen={assetModalVisible.value && assetModalVisibleDuringDrag.value}
onClose={handleClose}
className="h-[640px] max-w-4xl"
childPadding={false}
>
<div className="grid h-full grid-cols-12 gap-3">
<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: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">3D Assets</h2>
<Tooltip content="Upload model" position="top" delay={200}>
<Button
className="h-6 w-6 rounded-full border-none bg-transparent text-white/70 transition-colors hover:bg-transparent hover:text-white/100"
onClick={handleAddAsset}
>
<FontAwesomeIcon icon={faPlus} className="text-xl" />
</Button>
</Tooltip>
</div>
<hr className="my-2 w-full border-white/10" />
<div className="space-y-2">
{assetTabs.map((tab) => (
<Button
key={tab.id}
variant={activeAssetTab === tab.id ? "primary" : "secondary"}
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",
activeAssetTab === tab.id &&
"border-brand-primary bg-brand-primary/10 hover:bg-brand-primary/10",
)}
onClick={() => setActiveAssetTab(tab.id)}
>
<FontAwesomeIcon icon={tab.icon} className="mr-2 opacity-70" />
{tab.label}
</Button>
))}
</div>
<div className="mt-auto flex items-center gap-2 pt-3">
<input
type="checkbox"
id="reopen-after-add"
checked={reopenAfterAdd}
onChange={(e) => setReopenAfterAdd(e.target.checked)}
className="h-4 w-4 rounded-lg border-gray-300 bg-gray-700 text-brand-primary focus:ring-brand-primary"
/>
<label htmlFor="reopen-after-add" className="text-sm text-white/70">
Reopen after adding
</label>
</div>
</div>
<div className="col-span-9 p-3 pb-0 ps-0 pt-2">
<div className="flex h-full flex-col">
<div className="h-full">
<div className="flex items-center gap-4">
<TabSelector
tabs={libraryTabs}
activeTab={activeLibraryTab}
onTabChange={setActiveLibraryTab}
className="w-auto"
/>
<div className="relative grow">
<Input
ref={searchInputRef}
placeholder="Search"
className="grow"
inputClassName="pr-2.5"
icon={faSearch}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
iconClassName="text-white/60"
/>
{searchTerm && (
<CloseButton
onClick={clearSearch}
className="absolute right-2.5 top-1/2 h-4 w-4 -translate-y-1/2 bg-white/10 text-[10px] hover:bg-white/20"
/>
<>
<TransitionDialogue
isOpen={assetModalVisible.value && assetModalVisibleDuringDrag.value}
onClose={handleClose}
className="h-[640px] max-w-4xl"
childPadding={false}
>
<div className="grid h-full grid-cols-12 gap-3">
<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: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">
3D Assets
</h2>
<Tooltip content="Upload model" position="top" delay={200}>
<Button
className="h-6 w-6 rounded-full border-none bg-transparent text-white/70 transition-colors hover:bg-transparent hover:text-white/100"
onClick={handleAddAsset}
>
<FontAwesomeIcon icon={faPlus} className="text-xl" />
</Button>
</Tooltip>
</div>
<hr className="my-2 w-full border-white/10" />
<div className="space-y-2">
{assetTabs.map((tab) => (
<Button
key={tab.id}
variant={activeAssetTab === tab.id ? "primary" : "secondary"}
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",
activeAssetTab === tab.id &&
"border-brand-primary bg-brand-primary/10 hover:bg-brand-primary/10",
)}
</div>
<CloseButton onClick={handleClose} />
</div>
<div
className={twMerge(
"overflow-auto-y mt-4 h-[574px]",
activeAssetTab !== "all" && "h-[552px]",
)}
onClick={() => setActiveAssetTab(tab.id)}
>
<FontAwesomeIcon
icon={tab.icon}
className="mr-2 opacity-70"
/>
{tab.label}
</Button>
))}
</div>
<div className="mt-auto flex items-center gap-2 pt-3">
<input
type="checkbox"
id="reopen-after-add"
checked={reopenAfterAdd}
onChange={(e) => setReopenAfterAdd(e.target.checked)}
className="h-4 w-4 rounded-lg border-gray-300 bg-gray-700 text-brand-primary focus:ring-brand-primary"
/>
<label
htmlFor="reopen-after-add"
className="text-sm text-white/70"
>
{activeAssetTab !== "all" && !searchTerm && (
<div className="mb-2 flex items-center font-semibold">
<Button
variant="secondary"
className="flex items-center gap-2 border-none bg-transparent px-3 py-1.5 text-sm text-white/70 hover:bg-transparent hover:text-white/100"
onClick={() => setActiveAssetTab("all")}
>
<FontAwesomeIcon
icon={faChevronLeft}
className="text-sm font-semibold opacity-70"
Reopen after adding
</label>
</div>
</div>
<div className="col-span-9 p-3 pb-0 ps-0 pt-2">
<div className="flex h-full flex-col">
<div className="h-full">
<div className="flex items-center gap-4">
<TabSelector
tabs={libraryTabs}
activeTab={activeLibraryTab}
onTabChange={setActiveLibraryTab}
className="w-auto"
/>
<div className="relative grow">
<Input
ref={searchInputRef}
placeholder="Search"
className="grow"
inputClassName="pr-2.5"
icon={faSearch}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
iconClassName="text-white/60"
/>
{searchTerm && (
<CloseButton
onClick={clearSearch}
className="absolute right-2.5 top-1/2 h-4 w-4 -translate-y-1/2 bg-white/10 text-[10px] hover:bg-white/20"
/>
</Button>
{currentTab.label}
)}
</div>
)}
{renderContent()}
<CloseButton onClick={handleClose} />
</div>
<div
className={twMerge(
"overflow-auto-y mt-4 h-[574px]",
activeAssetTab !== "all" && "h-[552px]",
)}
>
{activeAssetTab !== "all" && !searchTerm && (
<div className="mb-2 flex items-center font-semibold">
<Button
variant="secondary"
className="flex items-center gap-2 border-none bg-transparent px-3 py-1.5 text-sm text-white/70 hover:bg-transparent hover:text-white/100"
onClick={() => setActiveAssetTab("all")}
>
<FontAwesomeIcon
icon={faChevronLeft}
className="text-sm font-semibold opacity-70"
/>
</Button>
{currentTab.label}
</div>
)}
{renderContent()}
</div>
</div>
</div>
</div>
</div>
</div>
</TransitionDialogue>
</TransitionDialogue>
<UploadModal3D
onClose={() => setIsUploadModalOpen(false)}
onSuccess={handleUploadSuccess}
isOpen={isUploadModalOpen}
title="Upload 3D Asset"
/>
</>
);
};