mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-15 11:38:39 -05:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2b4799cf5 | ||
|
|
1cbab057e5 | ||
|
|
c943557f85 |
@@ -12,14 +12,10 @@ import { Button } from "@storyteller/ui-button";
|
||||
import { AuthButtons } from "./AuthButtons";
|
||||
import { SceneTitleInput } from "./SceneTitleInput";
|
||||
import { Activity } from "~/pages/PageEnigma/comps/GenerateModals/Activity";
|
||||
import {
|
||||
GalleryModal,
|
||||
galleryModalVisibleViewMode,
|
||||
galleryModalVisibleDuringDrag,
|
||||
} from "@storyteller/ui-gallery-modal";
|
||||
import { useGalleryModal } from "@storyteller/ui-gallery-modal";
|
||||
import { SettingsModal } from "@storyteller/ui-settings-modal";
|
||||
import { Tooltip } from "@storyteller/ui-tooltip";
|
||||
import { downloadFileFromUrl, FilterMediaClasses } from "@storyteller/api";
|
||||
// import { downloadFileFromUrl, FilterMediaClasses } from "@storyteller/api";
|
||||
import {
|
||||
MenuIconSelector,
|
||||
MenuIconItem,
|
||||
@@ -36,7 +32,8 @@ import {
|
||||
set3DPageMounted,
|
||||
} from "~/pages/PageEnigma/Editor/editor";
|
||||
// import { usePricingModalStore } from "@storyteller/ui-pricing-modal"; - Uncomment for pricing modal - BFlat
|
||||
import toast from "react-hot-toast";
|
||||
// import toast from "react-hot-toast";
|
||||
|
||||
interface Props {
|
||||
pageName: string;
|
||||
loginSignUpPressed: () => void;
|
||||
@@ -81,9 +78,10 @@ export const TopBar = ({ pageName, loginSignUpPressed }: Props) => {
|
||||
|
||||
const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false);
|
||||
|
||||
const { openView } = useGalleryModal();
|
||||
|
||||
const handleOpenGalleryModal = () => {
|
||||
galleryModalVisibleViewMode.value = true;
|
||||
galleryModalVisibleDuringDrag.value = true;
|
||||
openView();
|
||||
};
|
||||
|
||||
const tabStore = useTabStore();
|
||||
@@ -102,19 +100,6 @@ export const TopBar = ({ pageName, loginSignUpPressed }: Props) => {
|
||||
);
|
||||
};
|
||||
|
||||
const downloadFile = async (url: string, mediaClass?: string) => {
|
||||
try {
|
||||
await downloadFileFromUrl(url);
|
||||
if (mediaClass === FilterMediaClasses.DIMENSIONAL) {
|
||||
toast.success(`Downloaded 3D model`);
|
||||
} else {
|
||||
toast.success(`Downloaded ${mediaClass}`);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("Failed to download file");
|
||||
}
|
||||
};
|
||||
|
||||
// const { toggleModal } = usePricingModalStore(); - Uncomment for pricing modal - BFlat
|
||||
|
||||
return (
|
||||
@@ -217,7 +202,7 @@ export const TopBar = ({ pageName, loginSignUpPressed }: Props) => {
|
||||
globalAccountLogoutCallback={() => setLogoutStates()}
|
||||
/>
|
||||
|
||||
<GalleryModal mode="view" onDownloadClicked={downloadFile} />
|
||||
{/* GalleryModalRoot is now mounted higher in the tree (PageEnigma). */}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -64,18 +64,18 @@ import {
|
||||
GalleryItem,
|
||||
onImageDrop,
|
||||
removeImageDropListener,
|
||||
onShapeDrop,
|
||||
removeShapeDropListener,
|
||||
} from "@storyteller/ui-gallery-modal";
|
||||
import {
|
||||
instructiveImageEditModels,
|
||||
ModelCategory,
|
||||
// ModelCategory,
|
||||
ModelSelector,
|
||||
// videoGenerationModels,
|
||||
// useModelSelectorStore,
|
||||
} from "@storyteller/ui-model-selector";
|
||||
import { LoginModal, useLoginModalStore } from "@storyteller/ui-login-modal";
|
||||
import PageDraw from "../PageDraw/PageDraw";
|
||||
import { useTabStore } from "../Stores/TabState";
|
||||
import { addShape } from "./signals/shape";
|
||||
|
||||
export const PageEditor = () => {
|
||||
useSignals();
|
||||
@@ -275,11 +275,12 @@ export const PageEditor = () => {
|
||||
// MOVE THIS don't throw this in here
|
||||
// Image drop from gallery/library modal logic
|
||||
useEffect(() => {
|
||||
let handler: unknown;
|
||||
let imageDropHandler: unknown;
|
||||
let shapeDropHandler: unknown;
|
||||
// 3D Drag and Drop Logic
|
||||
|
||||
if (tabStore.activeTabId === "3D") {
|
||||
handler = onImageDrop(
|
||||
imageDropHandler = onImageDrop(
|
||||
(item: GalleryItem, position: { x: number; y: number }) => {
|
||||
console.log("3D Drop debug (event):", {
|
||||
editorEngine,
|
||||
@@ -353,10 +354,43 @@ export const PageEditor = () => {
|
||||
})();
|
||||
},
|
||||
);
|
||||
shapeDropHandler = onShapeDrop(
|
||||
(item: GalleryItem, position: { x: number; y: number }) => {
|
||||
// Calculate world position from cursor immediately on drop
|
||||
let worldPosition = undefined;
|
||||
if (editorEngine?.camera && editorEngine?.renderer && position) {
|
||||
const rect =
|
||||
editorEngine.renderer.domElement.getBoundingClientRect();
|
||||
// Convert client coordinates to canvas coordinates
|
||||
const canvasX = position.x - rect.left;
|
||||
const canvasY = position.y - rect.top;
|
||||
const ndcX = (canvasX / rect.width) * 2 - 1;
|
||||
const ndcY = -(canvasY / rect.height) * 2 + 1;
|
||||
const vector = new THREE.Vector3(ndcX, ndcY, 0.5);
|
||||
vector.unproject(editorEngine.camera);
|
||||
worldPosition = vector;
|
||||
}
|
||||
|
||||
const mediaItem: MediaItem = {
|
||||
version: 1,
|
||||
type: AssetType.SHAPE,
|
||||
media_id: item.id, // This is "Box", "Cylinder" etc.
|
||||
name: item.name || item.label || "Shape",
|
||||
...(worldPosition && {
|
||||
position: {
|
||||
x: worldPosition.x,
|
||||
y: worldPosition.y,
|
||||
z: worldPosition.z,
|
||||
},
|
||||
}),
|
||||
};
|
||||
addShape(mediaItem);
|
||||
},
|
||||
);
|
||||
|
||||
// 2D Drag and Drop Logic
|
||||
} else if (tabStore.activeTabId === "2D") {
|
||||
handler = onImageDrop(
|
||||
imageDropHandler = onImageDrop(
|
||||
(item: GalleryItem, position: { x: number; y: number }) => {
|
||||
console.log("2D Drop debug (event):", {
|
||||
item,
|
||||
@@ -409,8 +443,10 @@ export const PageEditor = () => {
|
||||
}
|
||||
|
||||
return () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
if (handler) removeImageDropListener(handler as any);
|
||||
if (imageDropHandler)
|
||||
removeImageDropListener(imageDropHandler as (e: CustomEvent) => void);
|
||||
if (shapeDropHandler)
|
||||
removeShapeDropListener(shapeDropHandler as (e: CustomEvent) => void);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tabStore.activeTabId, editorEngine]);
|
||||
|
||||
@@ -25,7 +25,10 @@ import { useGenerationEnqueueSuccessEvent } from "./TauriEvents/generations/useG
|
||||
import { useGenerationFailedEvent } from "./TauriEvents/generations/useGenerationFailedEvent";
|
||||
import { useGenerationCompleteEvent } from "./TauriEvents/generations/useGenerationCompleteEvent";
|
||||
import { PageEditor } from "~/pages/PageEnigma/PageEditor";
|
||||
import { GalleryDragComponent } from "@storyteller/ui-gallery-modal";
|
||||
import {
|
||||
GalleryDragComponent,
|
||||
GalleryModalRoot,
|
||||
} from "@storyteller/ui-gallery-modal";
|
||||
import { LoadingDots } from "@storyteller/ui-loading";
|
||||
import { PricingModal } from "@storyteller/ui-pricing-modal";
|
||||
|
||||
@@ -103,6 +106,7 @@ export const PageEnigma = ({ sceneToken }: { sceneToken?: string }) => {
|
||||
|
||||
return (
|
||||
<EngineProvider sceneToken={sceneToken}>
|
||||
<GalleryModalRoot />
|
||||
<PageEditor />
|
||||
<DragComponent />
|
||||
<GalleryDragComponent />
|
||||
|
||||
@@ -17,11 +17,9 @@ import {
|
||||
import { SettingsModal } from "@storyteller/ui-settings-modal";
|
||||
import { EngineContext } from "../../contexts/EngineContext";
|
||||
import { useContext, useEffect, useState, useRef, useCallback } from "react";
|
||||
import { assetModalVisibleDuringDrag, assetModalVisible } from "../../signals";
|
||||
import {
|
||||
Create3dModal,
|
||||
useCreate3dModalStore,
|
||||
// eslint-disable-next-line import/no-unresolved
|
||||
} from "@storyteller/ui-create-3d-modal";
|
||||
// import { v4 as uuidv4 } from "uuid";
|
||||
// import { addObject } from "../../signals/objectGroup/addObject";
|
||||
@@ -29,13 +27,13 @@ import {
|
||||
import { GetFalApiKey } from "@storyteller/tauri-api"; // Fix import path
|
||||
// eslint-disable-next-line import/no-unresolved
|
||||
// import { AssetType } from "~/enums";
|
||||
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 { useGalleryModal } from "@storyteller/ui-gallery-modal";
|
||||
import { UploadModal3D } from "../../../../components/reusable/UploadModal3D/UploadModal3D";
|
||||
|
||||
export const Controls3D = () => {
|
||||
@@ -49,12 +47,12 @@ export const Controls3D = () => {
|
||||
// Track processed 3D models by their media token to prevent duplicates
|
||||
const processedModelsRef = useRef<Record<string, boolean>>({});
|
||||
|
||||
const { openView, isOpen: isGalleryOpen } = useGalleryModal();
|
||||
|
||||
useEffect(() => {
|
||||
// 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 && !isGalleryOpen;
|
||||
setShowEmptySceneTooltip(isSceneEmpty);
|
||||
};
|
||||
|
||||
@@ -67,8 +65,7 @@ export const Controls3D = () => {
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [assetModalVisible.value]);
|
||||
}, [isGalleryOpen]);
|
||||
|
||||
const handleModeChange = (value: string) => {
|
||||
selectedMode.value = value;
|
||||
@@ -132,8 +129,7 @@ export const Controls3D = () => {
|
||||
// }, []);
|
||||
|
||||
const handleOpenModal = () => {
|
||||
assetModalVisibleDuringDrag.value = true;
|
||||
assetModalVisible.value = true;
|
||||
openView("3d");
|
||||
};
|
||||
|
||||
const handleOpenCreate3dModal = async () => {
|
||||
@@ -283,8 +279,6 @@ export const Controls3D = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AssetModal />
|
||||
|
||||
<Create3dModal onModelComplete={handleModelComplete} />
|
||||
|
||||
<SettingsModal
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
export * from "./lib/enums/index.js";
|
||||
export * from "./lib/enums/UploaderStates.js";
|
||||
export * from "./lib/models/Users.js";
|
||||
export * from "./lib/models/MediaFile.js";
|
||||
export * from "./lib/models/UploaderState.js";
|
||||
export * from "./lib/AnalyticsApi.js";
|
||||
export * from "./lib/BillingApi.js";
|
||||
export * from "./lib/EngineApi.js";
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
export enum UploaderStates {
|
||||
ready,
|
||||
uploadingAsset,
|
||||
uploadingImage,
|
||||
uploadingCover,
|
||||
settingCover,
|
||||
success,
|
||||
assetError,
|
||||
coverCreateError,
|
||||
coverSetError,
|
||||
imageCreateError,
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { UploaderStates } from "../enums/UploaderStates";
|
||||
|
||||
export interface UploaderState {
|
||||
status: UploaderStates;
|
||||
errorMessage?: string;
|
||||
data?: string;
|
||||
}
|
||||
|
||||
export const initialUploaderState = {
|
||||
status: UploaderStates.ready,
|
||||
};
|
||||
@@ -1,4 +1,6 @@
|
||||
export * from "./lib/gallery-modal";
|
||||
export * from "./lib/galleryModalSignals";
|
||||
export * from "./lib/galleryModalStore";
|
||||
export * from "./lib/useGalleryModal";
|
||||
export * from "./lib/GalleryModalRoot";
|
||||
export * from "./lib/GalleryDragComponent";
|
||||
export * from "./lib/galleryDnd";
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { downloadFileFromUrl } from "@storyteller/api";
|
||||
import React from "react";
|
||||
import { GalleryModal } from "./gallery-modal";
|
||||
import { useGalleryModalStore } from "./galleryModalStore";
|
||||
import { LightboxModal } from "@storyteller/ui-lightbox-modal";
|
||||
|
||||
/**
|
||||
* GalleryModalRoot subscribes to the zustand store and renders the GalleryModal
|
||||
* component with the current state. Mount this once at a high level (e.g. PageEnigma)
|
||||
* so that other components can open/close the gallery via the useGalleryModal hook.
|
||||
*/
|
||||
export const GalleryModalRoot: React.FC = () => {
|
||||
const { isOpen, mode, forceFilter, close, lightbox, closeLightbox, open } =
|
||||
useGalleryModalStore();
|
||||
|
||||
return (
|
||||
<>
|
||||
<GalleryModal
|
||||
mode={mode}
|
||||
isOpen={isOpen}
|
||||
forceFilter={forceFilter}
|
||||
onClose={close}
|
||||
onDownloadClicked={downloadFileFromUrl}
|
||||
/>
|
||||
|
||||
{lightbox.isOpen && lightbox.item && (
|
||||
<LightboxModal
|
||||
isOpen={true}
|
||||
onClose={closeLightbox}
|
||||
onCloseGallery={close}
|
||||
imageUrl={lightbox.item.fullImage}
|
||||
imageAlt={lightbox.item.label}
|
||||
title={lightbox.item.label}
|
||||
createdAt={lightbox.item.createdAt}
|
||||
downloadUrl={lightbox.item.fullImage || undefined}
|
||||
mediaId={lightbox.item.id}
|
||||
mediaClass={lightbox.item.mediaClass}
|
||||
onDownloadClicked={downloadFileFromUrl}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
import { FilterEngineCategories } from "@storyteller/api";
|
||||
import { GalleryItem } from "./types";
|
||||
|
||||
const now = new Date().toISOString();
|
||||
|
||||
export const demoCharacters: GalleryItem[] = [
|
||||
{
|
||||
id: "m_gff67btr810vg3ng9szj85zskztcgy",
|
||||
label: "Story Girl",
|
||||
thumbnail: "/resources/characters/img13.png",
|
||||
fullImage: "/resources/characters/img13.png",
|
||||
name: "Story Girl",
|
||||
mediaClass: "dimensional",
|
||||
engineCategory: FilterEngineCategories.CHARACTER,
|
||||
createdAt: now,
|
||||
},
|
||||
{
|
||||
id: "m_ja13tz27cqqvy366tsed4z4kh4gg0m",
|
||||
label: "Roko",
|
||||
thumbnail: "/resources/characters/roko.png",
|
||||
fullImage: "/resources/characters/roko.png",
|
||||
name: "Roko",
|
||||
mediaClass: "dimensional",
|
||||
engineCategory: FilterEngineCategories.CHARACTER,
|
||||
createdAt: now,
|
||||
},
|
||||
];
|
||||
|
||||
export const demoShapes: GalleryItem[] = [
|
||||
{
|
||||
id: "Box",
|
||||
label: "Cube",
|
||||
thumbnail: "/resources/shapes/cube.png",
|
||||
fullImage: "/resources/shapes/cube.png",
|
||||
name: "Cube",
|
||||
mediaClass: "dimensional",
|
||||
engineCategory: FilterEngineCategories.OBJECT,
|
||||
createdAt: now,
|
||||
assetType: "shape",
|
||||
},
|
||||
{
|
||||
id: "Cylinder",
|
||||
label: "Cylinder",
|
||||
thumbnail: "/resources/shapes/cylinder.png",
|
||||
fullImage: "/resources/shapes/cylinder.png",
|
||||
name: "Cylinder",
|
||||
mediaClass: "dimensional",
|
||||
engineCategory: FilterEngineCategories.OBJECT,
|
||||
createdAt: now,
|
||||
assetType: "shape",
|
||||
},
|
||||
{
|
||||
id: "Donut",
|
||||
label: "Donut",
|
||||
thumbnail: "/resources/shapes/donut.png",
|
||||
fullImage: "/resources/shapes/donut.png",
|
||||
name: "Donut",
|
||||
mediaClass: "dimensional",
|
||||
engineCategory: FilterEngineCategories.OBJECT,
|
||||
createdAt: now,
|
||||
assetType: "shape",
|
||||
},
|
||||
{
|
||||
id: "Sphere",
|
||||
label: "Sphere",
|
||||
thumbnail: "/resources/shapes/sphere.png",
|
||||
fullImage: "/resources/shapes/sphere.png",
|
||||
name: "Sphere",
|
||||
mediaClass: "dimensional",
|
||||
engineCategory: FilterEngineCategories.OBJECT,
|
||||
createdAt: now,
|
||||
assetType: "shape",
|
||||
},
|
||||
];
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Modal } from "@storyteller/ui-modal";
|
||||
import { LightboxModal } from "@storyteller/ui-lightbox-modal";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { CloseButton } from "@storyteller/ui-close-button";
|
||||
import { LoadingSpinner } from "@storyteller/ui-loading-spinner";
|
||||
@@ -19,11 +18,7 @@ import {
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { GalleryDraggableItem } from "./GalleryDraggableItem";
|
||||
import { useSignals } from "@preact/signals-react/runtime";
|
||||
import {
|
||||
galleryModalVisibleDuringDrag,
|
||||
galleryReopenAfterDragSignal,
|
||||
galleryModalVisibleViewMode,
|
||||
} from "./galleryModalSignals";
|
||||
import { useGalleryModalStore } from "./galleryModalStore";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
faFilter,
|
||||
@@ -35,24 +30,34 @@ import {
|
||||
faExpand,
|
||||
faCompress,
|
||||
faArrowsRotate,
|
||||
faUser,
|
||||
faSun,
|
||||
faMountainCity,
|
||||
faDog,
|
||||
faUpFromLine,
|
||||
faChevronRight,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import { PopoverMenu } from "@storyteller/ui-popover";
|
||||
import { SliderV2 } from "@storyteller/ui-sliderv2";
|
||||
import { Tooltip } from "@storyteller/ui-tooltip";
|
||||
|
||||
export interface GalleryItem {
|
||||
id: string;
|
||||
label: string;
|
||||
thumbnail: string | null;
|
||||
fullImage?: string | null;
|
||||
createdAt: string;
|
||||
mediaClass?: string;
|
||||
}
|
||||
import { UploadModal3D } from "@storyteller/ui-upload-modal-3d";
|
||||
import {
|
||||
MediaFilesApi,
|
||||
FilterEngineCategories,
|
||||
MediaFile,
|
||||
} from "@storyteller/api";
|
||||
import { Switch } from "@storyteller/ui-switch";
|
||||
import { GalleryItem } from "./types";
|
||||
import { demoCharacters, demoShapes } from "./demoData";
|
||||
|
||||
interface GroupedItems {
|
||||
[date: string]: GalleryItem[];
|
||||
}
|
||||
|
||||
interface Grouped3DItems {
|
||||
[categoryName: string]: GalleryItem[];
|
||||
}
|
||||
|
||||
type ModalMode = "select" | "view";
|
||||
|
||||
interface GalleryModalProps {
|
||||
@@ -85,21 +90,48 @@ export const GalleryModal = React.memo(
|
||||
forceFilter,
|
||||
}: GalleryModalProps) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [lightboxImage, setLightboxImage] = useState<GalleryItem | null>(
|
||||
null
|
||||
);
|
||||
const [isLightboxVisible, setIsLightboxVisible] = useState(false);
|
||||
const [failedImageUrls] = useState<Set<string>>(new Set());
|
||||
const [username, setUsername] = useState<string>("");
|
||||
const forceFilterRef = useRef(forceFilter);
|
||||
const [activeFilter, setActiveFilter] = useState(forceFilter || "all");
|
||||
const { initialFilter } = useGalleryModalStore();
|
||||
|
||||
const [activeFilter, setActiveFilter] = useState(
|
||||
initialFilter || forceFilter || "all"
|
||||
);
|
||||
const [active3DCategory, setActive3DCategory] = useState("all");
|
||||
const [isUploadModalOpen, setIsUploadModalOpen] = useState(false);
|
||||
const [selectedCategory, setSelectedCategory] =
|
||||
useState<FilterEngineCategories | null>(null);
|
||||
const [isSelectVisible, setIsSelectVisible] = useState(true);
|
||||
const [showArtCraftOnly, setShowArtCraftOnly] = useState(false);
|
||||
|
||||
const {
|
||||
visibleDuringDrag,
|
||||
reopenAfterDrag,
|
||||
setReopenAfterDrag,
|
||||
openLightbox,
|
||||
} = useGalleryModalStore();
|
||||
|
||||
const [featuredItems, setFeaturedItems] = useState<GalleryItem[]>([]);
|
||||
const [grouped3dItems, setGrouped3dItems] = useState<Grouped3DItems>({});
|
||||
|
||||
const requestCounter = useRef(0);
|
||||
|
||||
const cache = useRef<{
|
||||
user: { [category: string]: GalleryItem[] };
|
||||
featured: { [category: string]: GalleryItem[] };
|
||||
userPagination: {
|
||||
[category: string]: { pageIndex: number; hasMore: boolean };
|
||||
};
|
||||
}>({ user: {}, featured: {}, userPagination: {} }).current;
|
||||
|
||||
// If forceFilter is provided, always use it
|
||||
useEffect(() => {
|
||||
if (forceFilterRef.current) {
|
||||
setActiveFilter(forceFilterRef.current);
|
||||
if (initialFilter) {
|
||||
setActiveFilter(initialFilter);
|
||||
} else if (forceFilter) {
|
||||
setActiveFilter(forceFilter);
|
||||
}
|
||||
}, [forceFilterRef]);
|
||||
}, [initialFilter, forceFilter]);
|
||||
|
||||
const minColumns = 3;
|
||||
const maxColumns = 12;
|
||||
// Default gridColumns to 5
|
||||
@@ -114,9 +146,8 @@ export const GalleryModal = React.memo(
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const pageSize = 25;
|
||||
|
||||
const imageUrl = lightboxImage?.fullImage || "";
|
||||
|
||||
const api = useMemo(() => new GalleryModalApi(), []);
|
||||
const mediaFilesApi = useMemo(() => new MediaFilesApi(), []);
|
||||
const usersApi = useMemo(() => new UsersApi(), []);
|
||||
|
||||
const formatDate = useCallback((date: string) => {
|
||||
@@ -166,10 +197,10 @@ export const GalleryModal = React.memo(
|
||||
setUsername(session.data.user.username);
|
||||
}
|
||||
};
|
||||
if (isOpen || (mode === "view" && galleryModalVisibleViewMode.value)) {
|
||||
if (typeof isOpen === "boolean" ? isOpen : true) {
|
||||
getUsername();
|
||||
}
|
||||
}, [usersApi, mode, galleryModalVisibleViewMode.value, isOpen]);
|
||||
}, [usersApi, isOpen]);
|
||||
|
||||
// filter state
|
||||
const FILTERS = [
|
||||
@@ -204,9 +235,201 @@ export const GalleryModal = React.memo(
|
||||
}
|
||||
};
|
||||
|
||||
const asset3DCategories = useMemo(
|
||||
() => [
|
||||
{ id: "all", label: "All", icon: faBorderAll },
|
||||
{
|
||||
id: "character",
|
||||
label: "Characters",
|
||||
icon: faUser,
|
||||
engineCategory: FilterEngineCategories.CHARACTER,
|
||||
},
|
||||
{
|
||||
id: "objects",
|
||||
label: "Objects",
|
||||
icon: faCube,
|
||||
engineCategory: FilterEngineCategories.OBJECT,
|
||||
},
|
||||
{
|
||||
id: "sets",
|
||||
label: "Sets",
|
||||
icon: faMountainCity,
|
||||
engineCategory: FilterEngineCategories.LOCATION,
|
||||
},
|
||||
{
|
||||
id: "creatures",
|
||||
label: "Creatures",
|
||||
icon: faDog,
|
||||
engineCategory: FilterEngineCategories.CREATURE,
|
||||
},
|
||||
{
|
||||
id: "skybox",
|
||||
label: "Skybox",
|
||||
icon: faSun,
|
||||
engineCategory: FilterEngineCategories.SKYBOX,
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
const loadItems = async (reset = false) => {
|
||||
const currentRequest = ++requestCounter.current;
|
||||
if (!username) return;
|
||||
|
||||
setLoading(true);
|
||||
|
||||
if (activeFilter === "3d") {
|
||||
const categoryId = active3DCategory;
|
||||
const isAllTab = categoryId === "all";
|
||||
|
||||
if (reset) {
|
||||
setAllItems([]);
|
||||
setPageIndex(0);
|
||||
setHasMore(true);
|
||||
if (isAllTab) {
|
||||
setGrouped3dItems({});
|
||||
}
|
||||
}
|
||||
|
||||
const category = asset3DCategories.find((c) => c.id === categoryId);
|
||||
|
||||
// Step 1: Fetch featured items
|
||||
let featured: GalleryItem[] = [];
|
||||
if (reset) {
|
||||
if (isAllTab) {
|
||||
const categoriesToFetch = asset3DCategories.filter(
|
||||
(c) => c.id !== "all" && c.engineCategory
|
||||
);
|
||||
|
||||
const promises = categoriesToFetch.map(async (cat) => {
|
||||
const response = await mediaFilesApi.ListFeaturedMediaFiles({
|
||||
filter_engine_categories: [cat.engineCategory!],
|
||||
filter_media_classes: [FilterMediaClasses.DIMENSIONAL],
|
||||
page_size: 4,
|
||||
});
|
||||
return {
|
||||
category: cat.label,
|
||||
items:
|
||||
response.success && response.data
|
||||
? response.data
|
||||
.filter(
|
||||
(item) => !item.media_links.cdn_url?.endsWith(".pmx")
|
||||
)
|
||||
.map((item) => mapToGalleryItem(item, true))
|
||||
: [],
|
||||
};
|
||||
});
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
|
||||
if (currentRequest === requestCounter.current) {
|
||||
const newGroupedItems: Grouped3DItems = {};
|
||||
results.forEach((result) => {
|
||||
if (result.items.length > 0) {
|
||||
newGroupedItems[result.category] = result.items;
|
||||
}
|
||||
});
|
||||
|
||||
// Special handling for demo items
|
||||
newGroupedItems["Characters"] = [
|
||||
...demoCharacters,
|
||||
...(newGroupedItems["Characters"] || []),
|
||||
].slice(0, 4);
|
||||
newGroupedItems["Objects"] = [
|
||||
...demoShapes,
|
||||
...(newGroupedItems["Objects"] || []),
|
||||
].slice(0, 4);
|
||||
|
||||
setGrouped3dItems(newGroupedItems);
|
||||
}
|
||||
// For the "All" tab, we still need to proceed to fetch user items below.
|
||||
} else {
|
||||
const featuredResponse = await mediaFilesApi.ListFeaturedMediaFiles(
|
||||
{
|
||||
filter_engine_categories: category?.engineCategory
|
||||
? [category.engineCategory]
|
||||
: undefined,
|
||||
filter_media_classes: [FilterMediaClasses.DIMENSIONAL],
|
||||
}
|
||||
);
|
||||
if (
|
||||
currentRequest === requestCounter.current &&
|
||||
featuredResponse.success &&
|
||||
featuredResponse.data
|
||||
) {
|
||||
featured = featuredResponse.data
|
||||
.filter((item) => !item.media_links.cdn_url?.endsWith(".pmx"))
|
||||
.map((item) => mapToGalleryItem(item, true));
|
||||
}
|
||||
}
|
||||
if (!isAllTab) setFeaturedItems(featured);
|
||||
} else {
|
||||
featured = featuredItems;
|
||||
}
|
||||
|
||||
// Step 2: Fetch user items (paginated)
|
||||
const userResponse = showArtCraftOnly
|
||||
? { success: false, data: [], pagination: undefined }
|
||||
: await mediaFilesApi.ListUserMediaFiles({
|
||||
username,
|
||||
include_user_uploads: true,
|
||||
filter_engine_categories:
|
||||
!isAllTab && category?.engineCategory
|
||||
? [category.engineCategory]
|
||||
: undefined,
|
||||
filter_media_classes: [FilterMediaClasses.DIMENSIONAL],
|
||||
page_index: reset ? 0 : pageIndex,
|
||||
page_size: pageSize,
|
||||
});
|
||||
|
||||
if (currentRequest !== requestCounter.current) return setLoading(false);
|
||||
|
||||
let user: GalleryItem[] = [];
|
||||
if (userResponse.success && userResponse.data) {
|
||||
user = userResponse.data
|
||||
.filter((item) => !item.media_links.cdn_url?.endsWith(".pmx"))
|
||||
.map((item) => mapToGalleryItem(item, false));
|
||||
}
|
||||
|
||||
// Step 3: Combine, sort, and set state
|
||||
setAllItems((prev) => {
|
||||
if (isAllTab) {
|
||||
// For 'All' tab, allItems is only user items.
|
||||
return reset ? user : [...prev, ...user];
|
||||
}
|
||||
|
||||
// For specific category tabs:
|
||||
let initialItems: GalleryItem[] = [];
|
||||
if (reset) {
|
||||
if (categoryId === "character")
|
||||
initialItems.push(...demoCharacters);
|
||||
if (categoryId === "objects") initialItems.push(...demoShapes);
|
||||
initialItems.push(...featured);
|
||||
}
|
||||
|
||||
const combined = reset
|
||||
? [...initialItems, ...user]
|
||||
: [...prev, ...user];
|
||||
|
||||
// De-duplicate
|
||||
const seen = new Set();
|
||||
return combined.filter((item) => {
|
||||
if (seen.has(item.id)) return false;
|
||||
seen.add(item.id);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
const pagination = (userResponse as any).pagination;
|
||||
setPageIndex((pagination?.current ?? 0) + 1);
|
||||
setHasMore(
|
||||
!showArtCraftOnly &&
|
||||
(pagination?.current ?? 0) + 1 < (pagination?.total_page_count ?? 1)
|
||||
);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let response = null;
|
||||
const filterMediaClasses = getFilterMediaClass(activeFilter);
|
||||
@@ -228,7 +451,9 @@ export const GalleryModal = React.memo(
|
||||
const thumbnail_size = 250;
|
||||
const newItems = response.data
|
||||
.filter(
|
||||
(item: any) => item.media_type !== FilterMediaType.SCENE_JSON
|
||||
(item: any) =>
|
||||
item.media_type !== FilterMediaType.SCENE_JSON &&
|
||||
!item.media_links.cdn_url?.endsWith(".pmx")
|
||||
)
|
||||
.map((item: any) => ({
|
||||
id: item.token,
|
||||
@@ -237,7 +462,13 @@ export const GalleryModal = React.memo(
|
||||
item.media_class === "video"
|
||||
? item.media_links.maybe_video_previews.animated
|
||||
: item.media_class === "dimensional"
|
||||
? item.cover_image?.maybe_cover_image_public_bucket_url
|
||||
? (item.cover_image as any)
|
||||
?.maybe_cover_image_public_bucket_url ||
|
||||
item.cover_image?.maybe_cover_image_public_bucket_path ||
|
||||
item.media_links.maybe_thumbnail_template?.replace(
|
||||
"{WIDTH}",
|
||||
thumbnail_size.toString()
|
||||
)
|
||||
: item.media_links.maybe_thumbnail_template?.replace(
|
||||
"{WIDTH}",
|
||||
thumbnail_size.toString()
|
||||
@@ -250,12 +481,21 @@ export const GalleryModal = React.memo(
|
||||
? item.filter_media_classes[0]
|
||||
: "image"),
|
||||
}));
|
||||
setAllItems((prev) => (reset ? newItems : [...prev, ...newItems]));
|
||||
setAllItems((prev) => {
|
||||
if (reset) return newItems;
|
||||
const existingIds = new Set(prev.map((i) => i.id));
|
||||
const deduped = newItems.filter((i) => !existingIds.has(i.id));
|
||||
return [...prev, ...deduped];
|
||||
});
|
||||
// Pagination logic
|
||||
const current = response.pagination?.current ?? 0;
|
||||
const total = response.pagination?.total_page_count ?? 1;
|
||||
setPageIndex(current + 1);
|
||||
setHasMore(current + 1 < total);
|
||||
if (showArtCraftOnly) {
|
||||
setHasMore(false);
|
||||
} else {
|
||||
setHasMore(current + 1 < total);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch library items:", error);
|
||||
@@ -263,52 +503,72 @@ export const GalleryModal = React.memo(
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
// Helper function to be defined inside the component
|
||||
const mapToGalleryItem = (
|
||||
item: MediaFile,
|
||||
isFeatured: boolean
|
||||
): GalleryItem => ({
|
||||
id: item.token,
|
||||
label: item.maybe_title || "3D Asset",
|
||||
thumbnail:
|
||||
(item.cover_image as any)?.maybe_cover_image_public_bucket_url ||
|
||||
item.cover_image?.maybe_cover_image_public_bucket_path ||
|
||||
item.media_links.thumbnail_template?.replace("{WIDTH}", "250"),
|
||||
fullImage: item.media_links.cdn_url,
|
||||
createdAt: item.created_at,
|
||||
mediaClass: "dimensional",
|
||||
engineCategory: item.origin_category as FilterEngineCategories,
|
||||
name: item.maybe_title ?? undefined,
|
||||
assetType: "object",
|
||||
isFeatured,
|
||||
});
|
||||
|
||||
// refresh logic
|
||||
const refreshGallery = useCallback(() => {
|
||||
cache.user = {};
|
||||
cache.featured = {};
|
||||
cache.userPagination = {};
|
||||
setAllItems([]);
|
||||
setGrouped3dItems({});
|
||||
setPageIndex(0);
|
||||
setHasMore(true);
|
||||
loadItems(true);
|
||||
}, [setAllItems, setPageIndex, setHasMore, loadItems]);
|
||||
}, [loadItems]);
|
||||
|
||||
useEffect(() => {
|
||||
// Refresh every time the modal is opened
|
||||
const modalIsOpen =
|
||||
mode === "view"
|
||||
? galleryModalVisibleViewMode.value
|
||||
: typeof isOpen === "boolean"
|
||||
? isOpen
|
||||
: true;
|
||||
const modalIsOpen = typeof isOpen === "boolean" ? isOpen : true;
|
||||
if (modalIsOpen && username) {
|
||||
refreshGallery();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
mode,
|
||||
isOpen,
|
||||
galleryModalVisibleViewMode.value,
|
||||
username,
|
||||
activeFilter,
|
||||
]);
|
||||
}, [mode, isOpen, username, activeFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
// Refresh when active 3D category changes
|
||||
if (activeFilter === "3d") {
|
||||
refreshGallery();
|
||||
}
|
||||
}, [active3DCategory]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeFilter === "3d") {
|
||||
refreshGallery();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [showArtCraftOnly]);
|
||||
|
||||
const handleItemClick = useCallback(
|
||||
(item: GalleryItem) => {
|
||||
if (mode === "select" && onSelectItem) {
|
||||
onSelectItem(item.id);
|
||||
} else {
|
||||
setLightboxImage(item);
|
||||
setIsLightboxVisible(true);
|
||||
openLightbox(item);
|
||||
}
|
||||
},
|
||||
[mode, onSelectItem]
|
||||
[mode, onSelectItem, openLightbox]
|
||||
);
|
||||
|
||||
const handleCloseLightbox = useCallback(() => {
|
||||
setIsLightboxVisible(false);
|
||||
// Wait for animation to complete before removing the image
|
||||
setTimeout(() => setLightboxImage(null), 300);
|
||||
}, []);
|
||||
|
||||
const handleDeselectAll = useCallback(() => {
|
||||
selectedItemIds.forEach((id: any) => onSelectItem?.(id));
|
||||
}, [selectedItemIds, onSelectItem]);
|
||||
@@ -338,24 +598,40 @@ export const GalleryModal = React.memo(
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddAsset = () => {
|
||||
setIsUploadModalOpen(true);
|
||||
setSelectedCategory(null);
|
||||
setIsSelectVisible(true);
|
||||
};
|
||||
|
||||
const handleUploadSuccess = (category: FilterEngineCategories) => {
|
||||
setIsUploadModalOpen(false);
|
||||
// Optional: set the category filter to the newly uploaded type
|
||||
const categoryId = asset3DCategories.find(
|
||||
(c) => c.engineCategory === category
|
||||
)?.id;
|
||||
if (categoryId) {
|
||||
setActive3DCategory(categoryId);
|
||||
} else {
|
||||
setActive3DCategory("all");
|
||||
}
|
||||
refreshGallery();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
resizable={mode === "view"}
|
||||
isOpen={
|
||||
mode === "view"
|
||||
? galleryModalVisibleViewMode.value &&
|
||||
galleryModalVisibleDuringDrag.value
|
||||
? (typeof isOpen === "boolean" ? isOpen : true) &&
|
||||
visibleDuringDrag
|
||||
: typeof isOpen === "boolean"
|
||||
? isOpen
|
||||
: true
|
||||
}
|
||||
onClose={() => {
|
||||
if (mode === "view") {
|
||||
onClose?.() || (galleryModalVisibleViewMode.value = false);
|
||||
} else {
|
||||
onClose?.();
|
||||
}
|
||||
onClose?.();
|
||||
}}
|
||||
className={twMerge(
|
||||
"h-[620px] max-w-4xl",
|
||||
@@ -385,11 +661,8 @@ export const GalleryModal = React.memo(
|
||||
<input
|
||||
type="checkbox"
|
||||
id="gallery-reopen-after-drag"
|
||||
checked={galleryReopenAfterDragSignal.value}
|
||||
onChange={(e) =>
|
||||
(galleryReopenAfterDragSignal.value =
|
||||
e.target.checked)
|
||||
}
|
||||
checked={reopenAfterDrag}
|
||||
onChange={(e) => setReopenAfterDrag(e.target.checked)}
|
||||
className="h-4 w-4 cursor-pointer rounded-lg border-gray-300 bg-gray-700 text-primary focus:ring-primary"
|
||||
/>
|
||||
<label
|
||||
@@ -402,7 +675,20 @@ export const GalleryModal = React.memo(
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 items-center">
|
||||
{/* Refresh button */}
|
||||
{activeFilter === "3d" && (
|
||||
<Tooltip position="top" content="Upload 3D Asset">
|
||||
<Button
|
||||
variant="action"
|
||||
onClick={handleAddAsset}
|
||||
className="relative z-[51] h-9 w-9 bg-primary/80 hover:bg-primary"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faUpFromLine}
|
||||
className="text-lg text-white"
|
||||
/>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip
|
||||
position="top"
|
||||
content="Refresh list"
|
||||
@@ -421,7 +707,6 @@ export const GalleryModal = React.memo(
|
||||
/>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
{/* Image fit toggle button */}
|
||||
<Tooltip
|
||||
position="top"
|
||||
content="Toggle image fit"
|
||||
@@ -443,7 +728,6 @@ export const GalleryModal = React.memo(
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
{/* Slider */}
|
||||
<div className="w-48 mx-3 relative z-[51] flex items-center gap-2">
|
||||
<SliderV2
|
||||
min={minColumns}
|
||||
@@ -460,12 +744,9 @@ export const GalleryModal = React.memo(
|
||||
} columns`}
|
||||
/>
|
||||
</div>
|
||||
{/* Filter popover */}
|
||||
<Tooltip
|
||||
position="top"
|
||||
content={
|
||||
forceFilterRef.current ? "Filter locked" : "Filter"
|
||||
}
|
||||
content={forceFilter ? "Filter locked" : "Filter"}
|
||||
closeOnClick={true}
|
||||
>
|
||||
<PopoverMenu
|
||||
@@ -473,23 +754,19 @@ export const GalleryModal = React.memo(
|
||||
position="bottom"
|
||||
align="end"
|
||||
buttonClassName={`relative z-[51] mr-3 ${
|
||||
forceFilterRef.current
|
||||
? "opacity-70 pointer-events-none"
|
||||
: ""
|
||||
forceFilter ? "opacity-70 pointer-events-none" : ""
|
||||
}`}
|
||||
panelClassName="min-w-36"
|
||||
items={FILTERS.map((f) => ({
|
||||
label: f.label,
|
||||
selected: activeFilter === f.id,
|
||||
icon: f.icon,
|
||||
// Use a custom property that will be passed through but not cause type errors
|
||||
customProps: {
|
||||
disabled: forceFilterRef.current !== undefined,
|
||||
disabled: forceFilter !== undefined,
|
||||
},
|
||||
}))}
|
||||
onSelect={(item) => {
|
||||
// Only allow filter changes if no forceFilter was provided
|
||||
if (!forceFilterRef.current) {
|
||||
if (!forceFilter) {
|
||||
const filter = FILTERS.find(
|
||||
(f) => f.label === item.label
|
||||
);
|
||||
@@ -507,11 +784,7 @@ export const GalleryModal = React.memo(
|
||||
{mode === "view" && <Modal.ExpandButton />}
|
||||
<CloseButton
|
||||
onClick={() => {
|
||||
if (mode === "view") {
|
||||
galleryModalVisibleViewMode.value = false;
|
||||
} else {
|
||||
onClose?.();
|
||||
}
|
||||
onClose?.();
|
||||
}}
|
||||
className="relative z-[51]"
|
||||
/>
|
||||
@@ -519,69 +792,204 @@ export const GalleryModal = React.memo(
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeFilter === "3d" && (
|
||||
<div className="px-4 py-2.5 flex items-center gap-2 border-white/10">
|
||||
{asset3DCategories.map((cat) => (
|
||||
<Button
|
||||
key={cat.id}
|
||||
variant={
|
||||
active3DCategory === cat.id ? "primary" : "secondary"
|
||||
}
|
||||
onClick={() => setActive3DCategory(cat.id)}
|
||||
className="px-3 py-1.5 text-xs"
|
||||
>
|
||||
<FontAwesomeIcon icon={cat.icon} />
|
||||
{cat.label}
|
||||
</Button>
|
||||
))}
|
||||
<div className="ml-auto flex items-center">
|
||||
<Tooltip content="ArtCraft assets only" position="top">
|
||||
<Switch
|
||||
enabled={showArtCraftOnly}
|
||||
setEnabled={(v) => setShowArtCraftOnly(v)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto" onScroll={handleScroll}>
|
||||
{loading && allItems.length === 0 ? (
|
||||
{loading &&
|
||||
allItems.length === 0 &&
|
||||
!Object.keys(grouped3dItems).length ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-white/60">
|
||||
<LoadingSpinner className="h-12 w-12" />
|
||||
</div>
|
||||
</div>
|
||||
) : activeFilter === "3d" && active3DCategory === "all" ? (
|
||||
<div className="space-y-6 p-4">
|
||||
{Object.entries(grouped3dItems).map(
|
||||
([categoryName, items]) => (
|
||||
<div key={categoryName}>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="font-semibold text-lg">
|
||||
{categoryName}
|
||||
</h3>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
const catId = asset3DCategories.find(
|
||||
(c) => c.label === categoryName
|
||||
)?.id;
|
||||
if (catId) setActive3DCategory(catId);
|
||||
}}
|
||||
className="text-sm text-white/60 hover:text-white bg-transparent hover:bg-white/10"
|
||||
>
|
||||
View all{" "}
|
||||
<FontAwesomeIcon
|
||||
icon={faChevronRight}
|
||||
className="ml-2"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
className={twMerge("grid", gapClass)}
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${gridColumns}, minmax(0, 1fr))`,
|
||||
}}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<GalleryDraggableItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
mode={mode}
|
||||
activeFilter={activeFilter}
|
||||
selected={selectedItemIds.includes(item.id)}
|
||||
onClick={() => handleItemClick(item)}
|
||||
onImageError={() =>
|
||||
handleImageError(item.thumbnail!)
|
||||
}
|
||||
disableTooltipAndBadge={mode === "select"}
|
||||
imageFit={imageFit}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* User's Library Section */}
|
||||
{allItems.length > 0 && (
|
||||
<div>
|
||||
<div className="mb-2 mt-8 flex items-center justify-between border-t border-white/10 pt-4">
|
||||
<h3 className="font-semibold text-lg">Your Library</h3>
|
||||
</div>
|
||||
<div
|
||||
className={twMerge("grid", gapClass)}
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${gridColumns}, minmax(0, 1fr))`,
|
||||
}}
|
||||
>
|
||||
{allItems.map((item) => (
|
||||
<GalleryDraggableItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
mode={mode}
|
||||
activeFilter={activeFilter}
|
||||
selected={selectedItemIds.includes(item.id)}
|
||||
onClick={() => handleItemClick(item)}
|
||||
onImageError={() =>
|
||||
handleImageError(item.thumbnail!)
|
||||
}
|
||||
disableTooltipAndBadge={mode === "select"}
|
||||
imageFit={imageFit}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && allItems.length > 0 && (
|
||||
<div className="flex justify-center py-4">
|
||||
<LoadingSpinner className="h-8 w-8" />
|
||||
</div>
|
||||
)}
|
||||
{!hasMore && allItems.length > 0 && (
|
||||
<div className="flex justify-center py-4 text-white/40 text-xs">
|
||||
No more items
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : activeFilter === "3d" && active3DCategory !== "all" ? (
|
||||
<div className="space-y-6 p-4">
|
||||
<div className="mb-2 flex items-center justify-between font-semibold">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setActive3DCategory("all")}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<h3>
|
||||
{
|
||||
asset3DCategories.find((c) => c.id === active3DCategory)
|
||||
?.label
|
||||
}
|
||||
</h3>
|
||||
</div>
|
||||
<div
|
||||
className={twMerge("grid", gapClass)}
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${gridColumns}, minmax(0, 1fr))`,
|
||||
}}
|
||||
>
|
||||
{allItems.map((item) => (
|
||||
<GalleryDraggableItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
mode={mode}
|
||||
activeFilter={activeFilter}
|
||||
selected={selectedItemIds.includes(item.id)}
|
||||
onClick={() => handleItemClick(item)}
|
||||
onImageError={() => handleImageError(item.thumbnail!)}
|
||||
disableTooltipAndBadge={mode === "select"}
|
||||
imageFit={imageFit}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{loading && allItems.length > 0 && (
|
||||
<div className="flex justify-center py-4">
|
||||
<LoadingSpinner className="h-8 w-8" />
|
||||
</div>
|
||||
)}
|
||||
{!hasMore && allItems.length > 0 && (
|
||||
<div className="flex justify-center py-4 text-white/40 text-xs">
|
||||
No more items
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6 p-4">
|
||||
{Object.entries(groupItemsByDate(allItems)).map(
|
||||
([date, dateItems]) => {
|
||||
const filteredItems = dateItems.filter((item) => {
|
||||
if ((item as any).mediaType === "scene_json")
|
||||
return false;
|
||||
if (activeFilter === "3d") {
|
||||
return item.mediaClass === "dimensional";
|
||||
}
|
||||
if (activeFilter === "image") {
|
||||
return item.mediaClass === "image";
|
||||
}
|
||||
if (activeFilter === "video") {
|
||||
return item.mediaClass === "video";
|
||||
}
|
||||
if (activeFilter === "all") {
|
||||
return (
|
||||
item.mediaClass !== "audio" &&
|
||||
(item as any).mediaType !== "scene_json"
|
||||
);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
if (filteredItems.length === 0) return null;
|
||||
return (
|
||||
<div key={date}>
|
||||
<h3 className="text-md mb-2 font-medium text-white/60">
|
||||
{date}
|
||||
</h3>
|
||||
<div
|
||||
className={twMerge("grid", gapClass)}
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${gridColumns}, minmax(0, 1fr))`,
|
||||
}}
|
||||
>
|
||||
{filteredItems.map((item) => (
|
||||
<GalleryDraggableItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
mode={mode}
|
||||
activeFilter={activeFilter}
|
||||
selected={selectedItemIds.includes(item.id)}
|
||||
onClick={() => handleItemClick(item)}
|
||||
onImageError={() =>
|
||||
handleImageError(item.thumbnail!)
|
||||
}
|
||||
disableTooltipAndBadge={mode === "select"}
|
||||
imageFit={imageFit}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
)}
|
||||
<div
|
||||
className={twMerge("grid", gapClass)}
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${gridColumns}, minmax(0, 1fr))`,
|
||||
}}
|
||||
>
|
||||
{allItems.map((item) => (
|
||||
<GalleryDraggableItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
mode={mode}
|
||||
activeFilter={activeFilter}
|
||||
selected={selectedItemIds.includes(item.id)}
|
||||
onClick={() => handleItemClick(item)}
|
||||
onImageError={() => handleImageError(item.thumbnail!)}
|
||||
disableTooltipAndBadge={mode === "select"}
|
||||
imageFit={imageFit}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{loading && allItems.length > 0 && (
|
||||
<div className="flex justify-center py-4">
|
||||
<LoadingSpinner className="h-8 w-8" />
|
||||
@@ -625,24 +1033,20 @@ export const GalleryModal = React.memo(
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<LightboxModal
|
||||
isOpen={isLightboxVisible}
|
||||
onClose={handleCloseLightbox}
|
||||
onCloseGallery={() =>
|
||||
mode === "view"
|
||||
? (galleryModalVisibleViewMode.value = false)
|
||||
: onClose?.()
|
||||
<UploadModal3D
|
||||
onClose={() => {
|
||||
setIsUploadModalOpen(false);
|
||||
}}
|
||||
onSuccess={(category: FilterEngineCategories) =>
|
||||
handleUploadSuccess(category)
|
||||
}
|
||||
imageUrl={imageUrl}
|
||||
imageAlt={lightboxImage?.label || ""}
|
||||
onImageError={() => imageUrl && handleImageError(imageUrl)}
|
||||
title={lightboxImage?.label}
|
||||
createdAt={lightboxImage?.createdAt}
|
||||
downloadUrl={imageUrl}
|
||||
mediaId={lightboxImage?.id}
|
||||
onDownloadClicked={onDownloadClicked}
|
||||
onAddToSceneClicked={onAddToSceneClicked}
|
||||
mediaClass={lightboxImage?.mediaClass}
|
||||
isOpen={isUploadModalOpen}
|
||||
title={`Upload 3D Asset`}
|
||||
titleIcon={faUpFromLine}
|
||||
preselectedCategory={
|
||||
selectedCategory || FilterEngineCategories.OBJECT
|
||||
}
|
||||
isSelectVisible={isSelectVisible}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { GalleryItem } from "./gallery-modal";
|
||||
import {
|
||||
galleryModalVisibleDuringDrag,
|
||||
galleryReopenAfterDragSignal,
|
||||
} from "./galleryModalSignals";
|
||||
import { useGalleryModalStore } from "./galleryModalStore";
|
||||
|
||||
interface DragState {
|
||||
item: GalleryItem | null;
|
||||
@@ -32,7 +29,7 @@ function onPointerDown(event: React.PointerEvent, item: GalleryItem) {
|
||||
dragState.currX = event.pageX;
|
||||
dragState.currY = event.pageY;
|
||||
dragState.isDragging = false;
|
||||
galleryModalVisibleDuringDrag.value = false;
|
||||
useGalleryModalStore.setState({ visibleDuringDrag: false });
|
||||
document.body.style.cursor = "grabbing";
|
||||
window.addEventListener("pointermove", onPointerMove);
|
||||
window.addEventListener("pointerup", onPointerUp);
|
||||
@@ -53,6 +50,7 @@ function onPointerMove(event: PointerEvent) {
|
||||
}
|
||||
|
||||
export const IMAGE_DROP_EVENT = "gallery-image-drop";
|
||||
export const SHAPE_DROP_EVENT = "gallery-shape-drop";
|
||||
|
||||
export function emitImageDrop(
|
||||
item: GalleryItem,
|
||||
@@ -63,6 +61,15 @@ export function emitImageDrop(
|
||||
);
|
||||
}
|
||||
|
||||
export function emitShapeDrop(
|
||||
item: GalleryItem,
|
||||
position: { x: number; y: number }
|
||||
) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(SHAPE_DROP_EVENT, { detail: { item, position } })
|
||||
);
|
||||
}
|
||||
|
||||
export function onImageDrop(
|
||||
callback: (item: GalleryItem, position: { x: number; y: number }) => void
|
||||
) {
|
||||
@@ -73,22 +80,40 @@ export function onImageDrop(
|
||||
return handler;
|
||||
}
|
||||
|
||||
export function onShapeDrop(
|
||||
callback: (item: GalleryItem, position: { x: number; y: number }) => void
|
||||
) {
|
||||
const handler = (e: any) => {
|
||||
callback(e.detail.item, e.detail.position);
|
||||
};
|
||||
window.addEventListener(SHAPE_DROP_EVENT, handler);
|
||||
return handler;
|
||||
}
|
||||
|
||||
export function removeImageDropListener(handler: (e: any) => void) {
|
||||
window.removeEventListener(IMAGE_DROP_EVENT, handler);
|
||||
}
|
||||
|
||||
export function removeShapeDropListener(handler: (e: any) => void) {
|
||||
window.removeEventListener(SHAPE_DROP_EVENT, handler);
|
||||
}
|
||||
|
||||
function onPointerUp(event: PointerEvent) {
|
||||
if (
|
||||
dragState.item &&
|
||||
dragState.isDragging &&
|
||||
(dragState.item.mediaClass === "image" ||
|
||||
dragState.item.mediaClass === "dimensional")
|
||||
) {
|
||||
emitImageDrop(dragState.item, { x: event.pageX, y: event.pageY });
|
||||
if (dragState.item && dragState.isDragging) {
|
||||
if (dragState.item.assetType === "shape") {
|
||||
emitShapeDrop(dragState.item, { x: event.pageX, y: event.pageY });
|
||||
} else if (
|
||||
dragState.item.mediaClass === "image" ||
|
||||
dragState.item.mediaClass === "dimensional"
|
||||
) {
|
||||
emitImageDrop(dragState.item, { x: event.pageX, y: event.pageY });
|
||||
}
|
||||
}
|
||||
|
||||
dragState.item = null;
|
||||
dragState.isDragging = false;
|
||||
galleryModalVisibleDuringDrag.value = galleryReopenAfterDragSignal.value;
|
||||
const { reopenAfterDrag } = useGalleryModalStore.getState();
|
||||
useGalleryModalStore.setState({ visibleDuringDrag: reopenAfterDrag });
|
||||
document.body.style.cursor = "";
|
||||
window.removeEventListener("pointermove", onPointerMove);
|
||||
window.removeEventListener("pointerup", onPointerUp);
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { create } from "zustand";
|
||||
import { GalleryItem } from "./gallery-modal";
|
||||
|
||||
export type GalleryModalMode = "view" | "select";
|
||||
|
||||
interface GalleryModalState {
|
||||
isOpen: boolean;
|
||||
mode: GalleryModalMode;
|
||||
/**
|
||||
* When set, the gallery will lock the filter to this value.
|
||||
* Possible values: "all", "image", "video", "3d", "uploaded"
|
||||
*/
|
||||
forceFilter?: string;
|
||||
initialFilter?: string;
|
||||
/** internal flag used to temporarily hide modal during drag */
|
||||
visibleDuringDrag: boolean;
|
||||
/** user preference: whether modal should re-appear after a drag */
|
||||
reopenAfterDrag: boolean;
|
||||
/** Open the gallery modal. */
|
||||
open: (options?: {
|
||||
mode?: GalleryModalMode;
|
||||
forceFilter?: string;
|
||||
initialFilter?: string;
|
||||
}) => void;
|
||||
/** Close the gallery modal. */
|
||||
close: () => void;
|
||||
/** Set temporary visibility during drag */
|
||||
setVisibleDuringDrag: (visible: boolean) => void;
|
||||
/** Toggle reopen preference */
|
||||
setReopenAfterDrag: (value: boolean) => void;
|
||||
/** Lightbox state */
|
||||
lightbox: { isOpen: boolean; item: GalleryItem | null };
|
||||
openLightbox: (item: GalleryItem) => void;
|
||||
closeLightbox: () => void;
|
||||
}
|
||||
|
||||
export const useGalleryModalStore = create<GalleryModalState>((set) => ({
|
||||
isOpen: false,
|
||||
mode: "view",
|
||||
forceFilter: undefined,
|
||||
initialFilter: undefined,
|
||||
visibleDuringDrag: true,
|
||||
reopenAfterDrag: false,
|
||||
lightbox: { isOpen: false, item: null },
|
||||
open: (options) => {
|
||||
const { mode = "view", forceFilter, initialFilter } = options || {};
|
||||
set({
|
||||
isOpen: true,
|
||||
mode,
|
||||
forceFilter,
|
||||
initialFilter,
|
||||
visibleDuringDrag: true,
|
||||
});
|
||||
},
|
||||
close: () =>
|
||||
set({ isOpen: false, visibleDuringDrag: false, initialFilter: undefined }),
|
||||
setVisibleDuringDrag: (visible) => set({ visibleDuringDrag: visible }),
|
||||
setReopenAfterDrag: (value) => set({ reopenAfterDrag: value }),
|
||||
openLightbox: (item) => set({ lightbox: { isOpen: true, item } }),
|
||||
closeLightbox: () =>
|
||||
set((state) => ({ lightbox: { isOpen: false, item: null } })),
|
||||
}));
|
||||
@@ -0,0 +1,15 @@
|
||||
import { FilterEngineCategories } from "@storyteller/api";
|
||||
|
||||
export interface GalleryItem {
|
||||
id: string;
|
||||
label: string;
|
||||
thumbnail: string | null;
|
||||
fullImage?: string | null;
|
||||
createdAt: string;
|
||||
mediaClass?: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
engineCategory?: FilterEngineCategories;
|
||||
assetType?: "shape" | "object";
|
||||
isFeatured?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useCallback } from "react";
|
||||
import { useGalleryModalStore, GalleryModalMode } from "./galleryModalStore";
|
||||
|
||||
export const useGalleryModal = () => {
|
||||
const { isOpen, mode, forceFilter, open, close, setVisibleDuringDrag } =
|
||||
useGalleryModalStore();
|
||||
|
||||
const openView = useCallback(
|
||||
(filter?: string, lock = false) => {
|
||||
setVisibleDuringDrag(true);
|
||||
if (lock && filter) {
|
||||
open({ mode: "view", forceFilter: filter });
|
||||
} else if (filter) {
|
||||
open({ mode: "view", initialFilter: filter });
|
||||
} else {
|
||||
open({ mode: "view" });
|
||||
}
|
||||
},
|
||||
[open, setVisibleDuringDrag]
|
||||
);
|
||||
|
||||
const openSelect = useCallback(
|
||||
(forceFilter?: string) => {
|
||||
setVisibleDuringDrag(true);
|
||||
open({ mode: "select", forceFilter });
|
||||
},
|
||||
[open, setVisibleDuringDrag]
|
||||
);
|
||||
|
||||
const safeClose = useCallback(() => {
|
||||
setVisibleDuringDrag(false);
|
||||
close();
|
||||
}, [close, setVisibleDuringDrag]);
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
mode,
|
||||
forceFilter,
|
||||
open,
|
||||
close: safeClose,
|
||||
openView,
|
||||
openSelect,
|
||||
} as const;
|
||||
};
|
||||
@@ -2,6 +2,9 @@
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "../upload-modal-3d"
|
||||
},
|
||||
{
|
||||
"path": "../slider-v2"
|
||||
},
|
||||
|
||||
@@ -43,6 +43,9 @@
|
||||
"src/**/*.tsx"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../upload-modal-3d/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../slider-v2/tsconfig.lib.json"
|
||||
},
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
{
|
||||
"path": "../camera-settings-modal"
|
||||
},
|
||||
{
|
||||
"path": "../action-reminder-modal"
|
||||
},
|
||||
{
|
||||
"path": "../../tauri-api"
|
||||
},
|
||||
|
||||
@@ -49,6 +49,9 @@
|
||||
{
|
||||
"path": "../camera-settings-modal/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../action-reminder-modal/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../tauri-api/tsconfig.lib.json"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"presets": [
|
||||
[
|
||||
"@nx/react/babel",
|
||||
{
|
||||
"runtime": "automatic",
|
||||
"useBuiltIns": "usage"
|
||||
}
|
||||
]
|
||||
],
|
||||
"plugins": []
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
# @storyteller/ui-upload-modal-3d
|
||||
|
||||
This library was generated with [Nx](https://nx.dev).
|
||||
|
||||
## Running unit tests
|
||||
|
||||
Run `nx test @storyteller/ui-upload-modal-3d` to execute the unit tests via [Vitest](https://vitest.dev/).
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "@storyteller/ui-upload-modal-3d",
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"development": "./src/index.ts",
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./lib/upload-modal-3d";
|
||||
@@ -0,0 +1,195 @@
|
||||
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";
|
||||
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;
|
||||
fileTypes: string[];
|
||||
engineCategory: FilterEngineCategories;
|
||||
options?: {
|
||||
fileSubtypes?: { [key: string]: string }[];
|
||||
hasLength?: boolean;
|
||||
hasThumbnailUpload?: boolean;
|
||||
};
|
||||
onClose: () => void;
|
||||
onUploadProgress: (newState: UploaderState) => void;
|
||||
}
|
||||
|
||||
export const UploadFiles3D = ({
|
||||
fileTypes,
|
||||
engineCategory,
|
||||
options,
|
||||
onClose,
|
||||
onUploadProgress,
|
||||
}: Props) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement | undefined>(undefined);
|
||||
const canvasCallbackRef = useCallback((node: HTMLCanvasElement) => {
|
||||
if (node !== null) {
|
||||
canvasRef.current = node;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fileSubtypes = options?.fileSubtypes;
|
||||
// const hasLength = options?.hasLength;
|
||||
// const hasThumbnailUpload = options?.hasThumbnailUpload;
|
||||
|
||||
const [subtype, setSubtype] = useState<MediaFileAnimationType | undefined>(
|
||||
fileSubtypes
|
||||
? (Object.values(fileSubtypes[0])[0] as MediaFileAnimationType)
|
||||
: undefined
|
||||
);
|
||||
|
||||
const [uploadTitle, setUploadTitle] = useState<{
|
||||
value: string;
|
||||
error?: string;
|
||||
}>({
|
||||
value: "",
|
||||
});
|
||||
|
||||
const [assetFile, setAssetFile] = useState<{
|
||||
value: File | null;
|
||||
error?: string;
|
||||
}>({ value: null });
|
||||
|
||||
const [previewStatus, setPreviewStatus] = useState<{
|
||||
type: string;
|
||||
message?: string;
|
||||
}>({ type: "init" });
|
||||
|
||||
const [thumbnailFile, setThumbnailFile] = useState<Blob | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!uploadTitle.value) {
|
||||
setUploadTitle((curr) => ({
|
||||
...curr,
|
||||
error: "Please enter a title.",
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!assetFile.value) {
|
||||
setAssetFile((curr) => ({
|
||||
...curr,
|
||||
error: "Please select a file to upload.",
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
upload3DObjects({
|
||||
title: uploadTitle.value,
|
||||
assetFile: assetFile.value,
|
||||
thumbnailSnapshot: thumbnailFile,
|
||||
engineCategory: engineCategory,
|
||||
animationType: subtype,
|
||||
progressCallback: onUploadProgress,
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (canvasRef.current && assetFile.value) {
|
||||
loadPreviewOnCanvas({
|
||||
file: assetFile.value,
|
||||
canvas: canvasRef.current,
|
||||
statusCallback: (statusObject: { type: string; message?: string }) => {
|
||||
setPreviewStatus(statusObject);
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [assetFile.value]);
|
||||
|
||||
useEffect(() => {
|
||||
if (previewStatus.type === "OK" && canvasRef.current) {
|
||||
snapshotCanvasAsThumbnail({
|
||||
targetNode: canvasRef.current!,
|
||||
resultCallback: (snapshotBlob) => {
|
||||
if (snapshotBlob) {
|
||||
setThumbnailFile(snapshotBlob);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [previewStatus]);
|
||||
|
||||
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>
|
||||
|
||||
<FileUploader
|
||||
title="Upload 3D Model"
|
||||
fileTypes={fileTypes}
|
||||
file={assetFile.value}
|
||||
setFile={(file: File | null) => {
|
||||
setAssetFile({
|
||||
value: file,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{fileSubtypes && fileSubtypes.length > 1 && (
|
||||
<ListDropdown
|
||||
list={fileSubtypes}
|
||||
onSelect={(value) => setSubtype(value as MediaFileAnimationType)}
|
||||
/>
|
||||
)}
|
||||
{assetFile.error && (
|
||||
<h6 className="z-10 text-red">{assetFile.error}</h6>
|
||||
)}
|
||||
|
||||
<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="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="pointer-events-auto absolute left-0 top-1/2 -mt-5 w-full text-center">
|
||||
{previewStatus.type}
|
||||
{previewStatus.message && <br />}
|
||||
{previewStatus.message}
|
||||
</h6>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
/*
|
||||
* Replace this with your own classes
|
||||
*
|
||||
* e.g.
|
||||
* .container {
|
||||
* }
|
||||
*/
|
||||
@@ -0,0 +1,12 @@
|
||||
import { render } from '@testing-library/react';
|
||||
|
||||
import UploadModal3d from './upload-modal-3d';
|
||||
|
||||
describe('UploadModal3d', () => {
|
||||
|
||||
it('should render successfully', () => {
|
||||
const { baseElement } = render(<UploadModal3d />);
|
||||
expect(baseElement).toBeTruthy();
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
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 "./upload-files-3d";
|
||||
import {
|
||||
initialUploaderState,
|
||||
UploaderState,
|
||||
} from "../../../../../apps/editor3d/app/src/models";
|
||||
import { Select } from "@storyteller/ui-select";
|
||||
import {
|
||||
FilterEngineCategories,
|
||||
UploaderStates,
|
||||
OBJECT_FILE_TYPE,
|
||||
CHARACTER_MIXAMO_FILE_TYPE,
|
||||
IMAGEPLANE_FILE_TYPE,
|
||||
} from "../../../../../apps/editor3d/app/src/enums";
|
||||
import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
onSuccess: (category: FilterEngineCategories) => void;
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
titleIcon: IconDefinition;
|
||||
options?: {
|
||||
fileSubtypes?: { [key: string]: string }[];
|
||||
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",
|
||||
"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,
|
||||
titleIcon,
|
||||
options,
|
||||
preselectedCategory,
|
||||
isSelectVisible,
|
||||
}: Props & { preselectedCategory?: FilterEngineCategories }) {
|
||||
const [uploaderState, setUploaderState] =
|
||||
useState<UploaderState>(initialUploaderState);
|
||||
const [selectedCategory, setSelectedCategory] =
|
||||
useState<FilterEngineCategories>(
|
||||
preselectedCategory !== undefined
|
||||
? preselectedCategory
|
||||
: FilterEngineCategories.OBJECT
|
||||
);
|
||||
|
||||
const updateUploaderState = (newState: UploaderState) => {
|
||||
setUploaderState(newState);
|
||||
};
|
||||
|
||||
const resetModalState = () => {
|
||||
setUploaderState(initialUploaderState);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
resetModalState();
|
||||
}
|
||||
}, [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) {
|
||||
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">
|
||||
{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] || []}
|
||||
options={options}
|
||||
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="3D model"
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
export default UploadModal3D;
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./loadPreviewOnCanvas";
|
||||
export * from "./thumbnailHelpers";
|
||||
export * from "./uploadAsset";
|
||||
@@ -0,0 +1,229 @@
|
||||
import * as THREE from "three";
|
||||
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
|
||||
import { FontLoader } from "three/addons/loaders/FontLoader.js";
|
||||
import { TextGeometry } from "three/addons/geometries/TextGeometry.js";
|
||||
|
||||
interface LoaderInterface {
|
||||
file: File;
|
||||
camera: THREE.PerspectiveCamera;
|
||||
scene: THREE.Scene;
|
||||
renderer: THREE.WebGLRenderer;
|
||||
statusCallback: (statusObject: { type: string; message?: string }) => void;
|
||||
}
|
||||
|
||||
export const loadPreviewOnCanvas = ({
|
||||
file,
|
||||
canvas,
|
||||
statusCallback,
|
||||
}: {
|
||||
file: File;
|
||||
canvas: HTMLCanvasElement;
|
||||
statusCallback: (error: { type: string; message?: string }) => void;
|
||||
}) => {
|
||||
// Setup of scene, camera, and renderer in the canvas
|
||||
const scene = new THREE.Scene();
|
||||
|
||||
// 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({
|
||||
alpha: true,
|
||||
antialias: true,
|
||||
canvas: canvas,
|
||||
preserveDrawingBuffer: true,
|
||||
});
|
||||
|
||||
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
|
||||
if (file.name.includes(".glb")) {
|
||||
glbLoader({ file, scene, camera, renderer, statusCallback });
|
||||
} else if (file.name.includes(".pmd")) {
|
||||
pmdLoader({ file, scene, camera, renderer, statusCallback });
|
||||
} else if (
|
||||
file.name.includes(".png") ||
|
||||
file.name.includes(".jpg") ||
|
||||
file.name.includes(".jpeg") ||
|
||||
file.name.includes(".gif")
|
||||
) {
|
||||
imagePlaneLoader({ file, scene, camera, renderer, statusCallback });
|
||||
} else if (file.name.includes(".vmd")) {
|
||||
statusCallback({
|
||||
type: "Preview Error",
|
||||
message: "Sorry, Preview is not available to VMD files yet",
|
||||
});
|
||||
} else {
|
||||
statusCallback({
|
||||
type: "Preview Error",
|
||||
message: "Unknown file type for loader",
|
||||
});
|
||||
}
|
||||
|
||||
// Render the loaded data
|
||||
const animate = function () {
|
||||
renderer.render(scene, camera);
|
||||
};
|
||||
renderer.setAnimationLoop(animate);
|
||||
};
|
||||
|
||||
const glbLoader = ({
|
||||
file,
|
||||
camera,
|
||||
scene,
|
||||
renderer,
|
||||
statusCallback,
|
||||
}: LoaderInterface) => {
|
||||
const loader = new GLTFLoader();
|
||||
loader.load(
|
||||
URL.createObjectURL(file),
|
||||
(data) => {
|
||||
data.scene.children.forEach((child) => {
|
||||
child.userData["color"] = "#FFFFFF";
|
||||
scene.add(child);
|
||||
|
||||
// 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",
|
||||
message: "Preview should be available",
|
||||
});
|
||||
});
|
||||
},
|
||||
undefined, // nothing to-do onProgress
|
||||
(loaderError) => {
|
||||
/*onError*/
|
||||
statusCallback({
|
||||
type: "GLB Loader Error",
|
||||
message: String(loaderError),
|
||||
});
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const pmdLoader = ({
|
||||
camera,
|
||||
scene,
|
||||
renderer,
|
||||
statusCallback,
|
||||
}: LoaderInterface) => {
|
||||
camera.position.z = 30;
|
||||
const loader = new FontLoader();
|
||||
loader.load(
|
||||
"https://threejs.org/examples/fonts/helvetiker_regular.typeface.json",
|
||||
(font) => {
|
||||
const textGeometry = new TextGeometry("MMD", {
|
||||
font: font,
|
||||
size: 100,
|
||||
depth: 5,
|
||||
curveSegments: 12,
|
||||
bevelEnabled: true,
|
||||
bevelThickness: 1,
|
||||
bevelSize: 1,
|
||||
bevelOffset: 0,
|
||||
bevelSegments: 5,
|
||||
});
|
||||
textGeometry.computeBoundingBox();
|
||||
const textMaterial = new THREE.MeshPhongMaterial({
|
||||
color: 0xffffff,
|
||||
});
|
||||
const textMesh = new THREE.Mesh(textGeometry, textMaterial);
|
||||
textMesh.scale.set(0.15, 0.15, 0.01);
|
||||
textMesh.position.set(-22, -5, 0);
|
||||
scene.add(textMesh);
|
||||
renderer.render(scene, camera);
|
||||
statusCallback({
|
||||
type: "OK",
|
||||
message: "Preview should be available",
|
||||
});
|
||||
},
|
||||
undefined, // nothing on Progress
|
||||
(loaderError) => {
|
||||
/*onError*/
|
||||
statusCallback({
|
||||
type: "PMD Loader Error",
|
||||
message: String(loaderError),
|
||||
});
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const imagePlaneLoader = ({ file, scene, statusCallback }: LoaderInterface) => {
|
||||
const geometry = new THREE.PlaneGeometry(1, 1);
|
||||
const loader = new THREE.TextureLoader();
|
||||
const texture = loader.load(
|
||||
URL.createObjectURL(file),
|
||||
undefined, // nothing to-do onLoad
|
||||
undefined, // nothing to-do onProgress
|
||||
(loaderError) => {
|
||||
/*onError*/
|
||||
statusCallback({
|
||||
type: "Image Plane Loader Error",
|
||||
message: String(loaderError),
|
||||
});
|
||||
},
|
||||
);
|
||||
texture.colorSpace = THREE.SRGBColorSpace;
|
||||
|
||||
const image_material = new THREE.MeshBasicMaterial({
|
||||
color: 0xffffff,
|
||||
map: texture,
|
||||
});
|
||||
const obj = new THREE.Mesh(geometry, image_material);
|
||||
obj.receiveShadow = true;
|
||||
obj.castShadow = true;
|
||||
scene.add(obj);
|
||||
statusCallback({
|
||||
type: "OK",
|
||||
message: "Preview should be available",
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import { MediaFilesApi, MediaUploadApi } from "@storyteller/api";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import { getFileName } from "./utilities";
|
||||
|
||||
export const snapshotCanvasAsThumbnail = async ({
|
||||
targetNode,
|
||||
resultCallback,
|
||||
}: {
|
||||
targetNode: HTMLCanvasElement | undefined;
|
||||
resultCallback: (blob: Blob | undefined) => void;
|
||||
}) => {
|
||||
if (!targetNode) {
|
||||
//TODO: Verbose Error
|
||||
return undefined;
|
||||
}
|
||||
targetNode.toBlob((blob: Blob | null) => {
|
||||
resultCallback(blob || undefined);
|
||||
});
|
||||
};
|
||||
|
||||
export const uploadSnapshotAsThumbnail = async ({
|
||||
assetTitle = "unknown_asset",
|
||||
blob,
|
||||
}: {
|
||||
assetTitle?: string;
|
||||
blob: Blob;
|
||||
}) => {
|
||||
const thumbnailFile = new File([blob], "storyteller-cap.png");
|
||||
|
||||
const thumbnailTitle = "thumbnail_" + assetTitle;
|
||||
|
||||
const mediaUploadApi = new MediaUploadApi();
|
||||
return await mediaUploadApi.UploadImage({
|
||||
uuid: uuidv4(),
|
||||
blob: thumbnailFile,
|
||||
fileName: getFileName(thumbnailFile),
|
||||
maybe_title: thumbnailTitle,
|
||||
});
|
||||
};
|
||||
|
||||
export const setThumbnail = async ({
|
||||
assetToken,
|
||||
thumbnailToken,
|
||||
}: {
|
||||
assetToken: string;
|
||||
thumbnailToken: string;
|
||||
}) => {
|
||||
const mediaFilesApi = new MediaFilesApi();
|
||||
return await mediaFilesApi.UpdateCoverImage({
|
||||
mediaFileToken: assetToken,
|
||||
imageToken: thumbnailToken,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
FilterEngineCategories,
|
||||
UploaderState,
|
||||
UploaderStates,
|
||||
} from "@storyteller/api";
|
||||
import { setThumbnail, uploadSnapshotAsThumbnail } from "./thumbnailHelpers";
|
||||
import { uploadAsset } from "./uploadAsset";
|
||||
|
||||
export const upload3DObjects = async ({
|
||||
title,
|
||||
assetFile,
|
||||
engineCategory,
|
||||
thumbnailSnapshot,
|
||||
progressCallback,
|
||||
}: {
|
||||
title: string;
|
||||
assetFile: File;
|
||||
engineCategory: FilterEngineCategories;
|
||||
thumbnailSnapshot: Blob | undefined;
|
||||
progressCallback: (newState: UploaderState) => void;
|
||||
}) => {
|
||||
progressCallback({ status: UploaderStates.uploadingAsset });
|
||||
|
||||
const assetReponse = await uploadAsset({
|
||||
file: assetFile,
|
||||
title: title,
|
||||
engineCategory: engineCategory,
|
||||
});
|
||||
|
||||
if (!assetReponse.success || !assetReponse.data) {
|
||||
progressCallback({
|
||||
status: UploaderStates.assetError,
|
||||
errorMessage: assetReponse.errorMessage,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const assetToken = assetReponse.data;
|
||||
|
||||
if (!thumbnailSnapshot) {
|
||||
progressCallback({ status: UploaderStates.success });
|
||||
return;
|
||||
}
|
||||
|
||||
progressCallback({ status: UploaderStates.uploadingCover });
|
||||
const thumbnailResponse = await uploadSnapshotAsThumbnail({
|
||||
assetTitle: title,
|
||||
blob: thumbnailSnapshot,
|
||||
});
|
||||
if (!thumbnailResponse.success || !thumbnailResponse.data) {
|
||||
progressCallback({
|
||||
status: UploaderStates.coverCreateError,
|
||||
errorMessage: thumbnailResponse.errorMessage,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
progressCallback({ status: UploaderStates.settingCover });
|
||||
const thumbnailToken = thumbnailResponse.data;
|
||||
const setThumbnailResponse = await setThumbnail({
|
||||
assetToken: assetToken,
|
||||
thumbnailToken: thumbnailToken,
|
||||
});
|
||||
if (!setThumbnailResponse.success) {
|
||||
progressCallback({
|
||||
status: UploaderStates.coverSetError,
|
||||
errorMessage: setThumbnailResponse.errorMessage,
|
||||
});
|
||||
return;
|
||||
}
|
||||
progressCallback({ status: UploaderStates.success });
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import { MediaUploadApi } from "@storyteller/api";
|
||||
import { FilterEngineCategories } from "@storyteller/api";
|
||||
import { getFileExtension } from "./utilities";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
export const uploadAsset = async ({
|
||||
file,
|
||||
title,
|
||||
engineCategory,
|
||||
}: {
|
||||
file: File;
|
||||
title: string;
|
||||
engineCategory: FilterEngineCategories;
|
||||
}) => {
|
||||
const mediaUploadApi = new MediaUploadApi();
|
||||
const fileExtension = getFileExtension(file);
|
||||
const assetReponse = await (async () => {
|
||||
switch (fileExtension) {
|
||||
case ".zip":
|
||||
return mediaUploadApi.UploadPmx({
|
||||
file: file,
|
||||
fileName: file.name,
|
||||
engine_category: engineCategory,
|
||||
maybe_title: title,
|
||||
uuid: uuidv4(),
|
||||
});
|
||||
default:
|
||||
return mediaUploadApi.UploadNewEngineAsset({
|
||||
file: file,
|
||||
fileName: file.name,
|
||||
engine_category: engineCategory,
|
||||
maybe_title: title,
|
||||
uuid: uuidv4(),
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
return assetReponse;
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
export const getFileName = (file: File) => {
|
||||
return file.name.substring(0, file.name.lastIndexOf("."));
|
||||
};
|
||||
export const getFileExtension = (file: File) => {
|
||||
return file.name.substring(file.name.lastIndexOf("."));
|
||||
};
|
||||
|
||||
export const getFileTypesFromEnum = (enumOfTypes: object) => {
|
||||
return Object.keys(enumOfTypes);
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../api"
|
||||
},
|
||||
{
|
||||
"path": "../select"
|
||||
},
|
||||
{
|
||||
"path": "../upload-modal"
|
||||
},
|
||||
{
|
||||
"path": "../modal"
|
||||
},
|
||||
{
|
||||
"path": "../loading"
|
||||
},
|
||||
{
|
||||
"path": "../../../apps/editor3d"
|
||||
},
|
||||
{
|
||||
"path": "../file-uploader"
|
||||
},
|
||||
{
|
||||
"path": "../label"
|
||||
},
|
||||
{
|
||||
"path": "../input"
|
||||
},
|
||||
{
|
||||
"path": "../button"
|
||||
},
|
||||
{
|
||||
"path": "../list-dropdown"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
],
|
||||
"extends": "../../../tsconfig.base.json"
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"types": [
|
||||
"node",
|
||||
"@nx/react/typings/cssmodule.d.ts",
|
||||
"@nx/react/typings/image.d.ts",
|
||||
"vite/client"
|
||||
],
|
||||
"rootDir": "src",
|
||||
"jsx": "react-jsx",
|
||||
"tsBuildInfoFile": "dist/tsconfig.lib.tsbuildinfo"
|
||||
},
|
||||
"exclude": [
|
||||
"out-tsc",
|
||||
"dist",
|
||||
"**/*.spec.ts",
|
||||
"**/*.test.ts",
|
||||
"**/*.spec.tsx",
|
||||
"**/*.test.tsx",
|
||||
"**/*.spec.js",
|
||||
"**/*.test.js",
|
||||
"**/*.spec.jsx",
|
||||
"**/*.test.jsx",
|
||||
"vite.config.ts",
|
||||
"vite.config.mts",
|
||||
"vitest.config.ts",
|
||||
"vitest.config.mts",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.test.tsx",
|
||||
"src/**/*.spec.tsx",
|
||||
"src/**/*.test.js",
|
||||
"src/**/*.spec.js",
|
||||
"src/**/*.test.jsx",
|
||||
"src/**/*.spec.jsx"
|
||||
],
|
||||
"include": [
|
||||
"src/**/*.js",
|
||||
"src/**/*.jsx",
|
||||
"src/**/*.ts",
|
||||
"src/**/*.tsx"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../api/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../select/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../upload-modal/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../modal/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../loading/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../../apps/editor3d"
|
||||
},
|
||||
{
|
||||
"path": "../file-uploader/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../label/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../input/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../button/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../list-dropdown/tsconfig.lib.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./out-tsc/vitest",
|
||||
"types": [
|
||||
"vitest/globals",
|
||||
"vitest/importMeta",
|
||||
"vite/client",
|
||||
"node",
|
||||
"vitest"
|
||||
],
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": [
|
||||
"vite.config.ts",
|
||||
"vite.config.mts",
|
||||
"vitest.config.ts",
|
||||
"vitest.config.mts",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.test.tsx",
|
||||
"src/**/*.spec.tsx",
|
||||
"src/**/*.test.js",
|
||||
"src/**/*.spec.js",
|
||||
"src/**/*.test.jsx",
|
||||
"src/**/*.spec.jsx",
|
||||
"src/**/*.d.ts"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/// <reference types='vitest' />
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import dts from 'vite-plugin-dts';
|
||||
import * as path from 'path';
|
||||
|
||||
export default defineConfig(() => ({
|
||||
root: __dirname,
|
||||
cacheDir: '../../../node_modules/.vite/libs/components/upload-modal-3d',
|
||||
plugins: [react(), dts({ entryRoot: 'src', tsconfigPath: path.join(__dirname, 'tsconfig.lib.json') })],
|
||||
// Uncomment this if you are using workers.
|
||||
// worker: {
|
||||
// plugins: [ nxViteTsPaths() ],
|
||||
// },
|
||||
// Configuration for building your library.
|
||||
// See: https://vitejs.dev/guide/build.html#library-mode
|
||||
build: {
|
||||
outDir: './dist',
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
commonjsOptions: {
|
||||
transformMixedEsModules: true,
|
||||
},
|
||||
lib: {
|
||||
// Could also be a dictionary or array of multiple entry points.
|
||||
entry: 'src/index.ts',
|
||||
name: '@storyteller/ui-upload-modal-3d',
|
||||
fileName: 'index',
|
||||
// Change this to the formats you want to support.
|
||||
// Don't forget to update your package.json as well.
|
||||
formats: ['es' as const]
|
||||
},
|
||||
rollupOptions: {
|
||||
// External packages that should not be bundled into your library.
|
||||
external: ['react','react-dom','react/jsx-runtime']
|
||||
},
|
||||
},
|
||||
test: {
|
||||
watch: false,
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
|
||||
reporters: ['default'],
|
||||
coverage: {
|
||||
reportsDirectory: './test-output/vitest/coverage',
|
||||
provider: 'v8' as const,
|
||||
}
|
||||
},
|
||||
}));
|
||||
Generated
+8
@@ -1341,6 +1341,10 @@
|
||||
"name": "@storyteller/ui-upload-modal",
|
||||
"version": "0.0.1"
|
||||
},
|
||||
"libs/components/upload-modal-3d": {
|
||||
"name": "@storyteller/ui-upload-modal-3d",
|
||||
"version": "0.0.1"
|
||||
},
|
||||
"libs/login": {
|
||||
"name": "@frontend/login",
|
||||
"version": "0.0.1"
|
||||
@@ -9581,6 +9585,10 @@
|
||||
"resolved": "libs/components/upload-modal",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@storyteller/ui-upload-modal-3d": {
|
||||
"resolved": "libs/components/upload-modal-3d",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@svgr/babel-plugin-add-jsx-attribute": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz",
|
||||
|
||||
@@ -146,6 +146,9 @@
|
||||
},
|
||||
{
|
||||
"path": "./libs/model-list"
|
||||
},
|
||||
{
|
||||
"path": "./libs/components/upload-modal-3d"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user