mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-22 01:29:31 -04:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee4444a534 | ||
|
|
800e22ccab | ||
|
|
5afdc85fc1 | ||
|
|
4467a0692d | ||
|
|
2d009b85f9 | ||
|
|
2fe8dc1db4 | ||
|
|
ec369675ee |
@@ -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>
|
||||
|
||||
+15
-89
@@ -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}
|
||||
|
||||
+81
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+134
@@ -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";
|
||||
+56
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user