diff --git a/frontend/apps/editor3d/app/src/pages/PageEnigma/comps/Controls3D/Controls3D.tsx b/frontend/apps/editor3d/app/src/pages/PageEnigma/comps/Controls3D/Controls3D.tsx index 88289baeb8..44b944cc11 100644 --- a/frontend/apps/editor3d/app/src/pages/PageEnigma/comps/Controls3D/Controls3D.tsx +++ b/frontend/apps/editor3d/app/src/pages/PageEnigma/comps/Controls3D/Controls3D.tsx @@ -8,19 +8,42 @@ import { import { ButtonIconSelect } from "@storyteller/ui-button-icon-select"; import { Button } from "@storyteller/ui-button"; import { Tooltip } from "@storyteller/ui-tooltip"; +import { + showActionReminder, + isActionReminderOpen, +} from "@storyteller/ui-action-reminder-modal"; +import { SettingsModal } from "@storyteller/ui-settings-modal"; import { EngineContext } from "../../contexts/EngineContext"; -import { useContext, useEffect, useState } from "react"; +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"; +import { MediaItem } from "../../models/assets"; +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"; export const Controls3D = () => { useSignals(); const editorEngine = useContext(EngineContext); const [showEmptySceneTooltip, setShowEmptySceneTooltip] = useState(false); + // Action reminder is now handled through signals + const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false); + + // Track processed 3D models by their media token to prevent duplicates + const processedModelsRef = useRef>({}); useEffect(() => { // Check if scene is empty and onboarding helper is not visible @@ -79,6 +102,107 @@ export const Controls3D = () => { editorEngine.change_mode("scale"); }; + // Function to add the generated 3D model to the scene + const addGeneratedModelToScene = useCallback( + (mediaToken: string) => { + if (!editorEngine) { + console.error("Engine not available to add 3D model to scene"); + return; + } + + try { + // Create a MediaItem object for the 3D model + const mediaItem: MediaItem = { + version: 1, + type: AssetType.OBJECT, + media_id: mediaToken, + name: "Generated 3D Model", + object_uuid: uuidv4(), + }; + + // Add the object to the scene + addObject(mediaItem); + } catch (error) { + console.error("Error adding generated 3D model to scene:", error); + } + }, + [editorEngine], + ); + + const handleOpenModal = () => { + assetModalVisibleDuringDrag.value = true; + assetModalVisible.value = true; + }; + + const handleOpenCreate3dModal = async () => { + try { + const falApiKeyResult = await GetFalApiKey(); + + if ( + falApiKeyResult.status === "success" && + "payload" in falApiKeyResult && + falApiKeyResult.payload?.key + ) { + // API key exists, open the create 3D modal + useCreate3dModalStore.getState().open(); + } else { + // No API key, show the action reminder modal + showActionReminder({ + reminderType: "default", + title: "FAL API Key Required", + message: + "To generate 3D models, you need to add a valid FAL API key in your settings.", + primaryActionText: "Add now", + secondaryActionText: "Cancel", + onPrimaryAction: () => { + // Close the action reminder modal first + isActionReminderOpen.value = false; + // Then open the settings modal + openSettingsModal(); + }, + }); + } + } catch (error) { + console.error("Error checking FAL API key:", error); + } + }; + + const openSettingsModal = () => { + setIsSettingsModalOpen(true); + }; + + // Check for completed models from the Create3dModal component + useEffect(() => { + // Set up an interval to check for completed models + const intervalId = setInterval(() => { + const completedModels = useCreate3dModalStore + .getState() + .getAndClearCompletedModels(); + + if (completedModels.length > 0) { + console.log("Found completed models:", completedModels); + + // Process each completed model + completedModels.forEach((model) => { + const { mediaToken } = model; + + // Check if we've already processed this model + if (mediaToken && !processedModelsRef.current[mediaToken]) { + // Mark this model as processed to avoid duplicates + processedModelsRef.current[mediaToken] = true; + + // Add the generated 3D model to the scene + addGeneratedModelToScene(mediaToken); + } + }); + } + }, 1000); // Check every second + + return () => { + clearInterval(intervalId); + }; + }, [addGeneratedModelToScene]); + const modes = [ { value: "move", @@ -100,11 +224,6 @@ export const Controls3D = () => { }, ]; - const handleOpenModal = () => { - assetModalVisibleDuringDrag.value = true; - assetModalVisible.value = true; - }; - return ( <>
@@ -148,10 +267,9 @@ export const Controls3D = () => { >
@@ -167,6 +285,16 @@ export const Controls3D = () => { + + + {/* Action reminder is now handled through showActionReminder function */} + + setIsSettingsModalOpen(false)} + globalAccountLogoutCallback={() => setLogoutStates()} + initialSection="accounts" + /> ); }; diff --git a/frontend/apps/editor3d/tsconfig.json b/frontend/apps/editor3d/tsconfig.json index 64e0db4ed9..c3f9f42489 100644 --- a/frontend/apps/editor3d/tsconfig.json +++ b/frontend/apps/editor3d/tsconfig.json @@ -28,9 +28,6 @@ "noEmit": true }, "references": [ - { - "path": "../../libs/components/model-selector" - }, { "path": "../../libs/components/pagination" }, @@ -49,9 +46,15 @@ { "path": "../../libs/components/loading-spinner" }, + { + "path": "../../libs/components/create-3d-modal" + }, { "path": "../../libs/components/button-icon-select" }, + { + "path": "../../libs/components/slider-v2" + }, { "path": "../../libs/components/tab-selector" }, @@ -61,32 +64,29 @@ { "path": "../../libs/components/button-dropdown" }, + { + "path": "../../libs/tauri-api" + }, { "path": "../../libs/components/action-reminder-modal" }, - { - "path": "../../libs/components/login-modal" - }, - { - "path": "../../libs/soundboard" - }, - { - "path": "../../libs/components/slider-v2" - }, { "path": "../../libs/components/toaster" }, { - "path": "../../libs/components/popover" + "path": "../../libs/components/model-selector" + }, + { + "path": "../../libs/soundboard" }, { "path": "../../libs/components/promptbox" }, { - "path": "../../libs/common" + "path": "../../libs/components/popover" }, { - "path": "../../libs/tauri-api" + "path": "../../libs/common" }, { "path": "../../libs/components/menu-icon-selector" diff --git a/frontend/libs/components/create-3d-modal/.babelrc b/frontend/libs/components/create-3d-modal/.babelrc new file mode 100644 index 0000000000..1ea870ead4 --- /dev/null +++ b/frontend/libs/components/create-3d-modal/.babelrc @@ -0,0 +1,12 @@ +{ + "presets": [ + [ + "@nx/react/babel", + { + "runtime": "automatic", + "useBuiltIns": "usage" + } + ] + ], + "plugins": [] +} diff --git a/frontend/libs/components/create-3d-modal/README.md b/frontend/libs/components/create-3d-modal/README.md new file mode 100644 index 0000000000..194c0f41e8 --- /dev/null +++ b/frontend/libs/components/create-3d-modal/README.md @@ -0,0 +1,7 @@ +# @storyteller/ui-create-3d-modal + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test @storyteller/ui-create-3d-modal` to execute the unit tests via [Vitest](https://vitest.dev/). diff --git a/frontend/libs/components/create-3d-modal/package.json b/frontend/libs/components/create-3d-modal/package.json new file mode 100644 index 0000000000..3c04d0e0c2 --- /dev/null +++ b/frontend/libs/components/create-3d-modal/package.json @@ -0,0 +1,17 @@ +{ + "name": "@storyteller/ui-create-3d-modal", + "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" + } + } +} diff --git a/frontend/libs/components/create-3d-modal/src/index.ts b/frontend/libs/components/create-3d-modal/src/index.ts new file mode 100644 index 0000000000..b4aeea55a5 --- /dev/null +++ b/frontend/libs/components/create-3d-modal/src/index.ts @@ -0,0 +1,2 @@ +export * from "./lib/create-3d-modal"; +export * from "./lib/create-3d-modal-store"; diff --git a/frontend/libs/components/create-3d-modal/src/lib/create-3d-modal-store.ts b/frontend/libs/components/create-3d-modal/src/lib/create-3d-modal-store.ts new file mode 100644 index 0000000000..c89f9ca378 --- /dev/null +++ b/frontend/libs/components/create-3d-modal/src/lib/create-3d-modal-store.ts @@ -0,0 +1,31 @@ +import { create } from 'zustand'; + +interface CompletedModel { + mediaToken: string; +} + +interface Create3dModalState { + isOpen: boolean; + completedModels: CompletedModel[]; + open: () => void; + close: () => void; + toggle: () => void; + addCompletedModel: (model: CompletedModel) => void; + getAndClearCompletedModels: () => CompletedModel[]; +} + +export const useCreate3dModalStore = create((set, get) => ({ + isOpen: false, + completedModels: [], + open: () => set({ isOpen: true }), + close: () => set({ isOpen: false }), + toggle: () => set((state) => ({ isOpen: !state.isOpen })), + addCompletedModel: (model) => set((state) => ({ + completedModels: [...state.completedModels, model] + })), + getAndClearCompletedModels: () => { + const models = get().completedModels; + set({ completedModels: [] }); + return models; + } +})); diff --git a/frontend/libs/components/create-3d-modal/src/lib/create-3d-modal.module.css b/frontend/libs/components/create-3d-modal/src/lib/create-3d-modal.module.css new file mode 100644 index 0000000000..d0246c74b9 --- /dev/null +++ b/frontend/libs/components/create-3d-modal/src/lib/create-3d-modal.module.css @@ -0,0 +1,7 @@ +/* + * Replace this with your own classes + * + * e.g. + * .container { + * } +*/ \ No newline at end of file diff --git a/frontend/libs/components/create-3d-modal/src/lib/create-3d-modal.spec.tsx b/frontend/libs/components/create-3d-modal/src/lib/create-3d-modal.spec.tsx new file mode 100644 index 0000000000..3401f92c24 --- /dev/null +++ b/frontend/libs/components/create-3d-modal/src/lib/create-3d-modal.spec.tsx @@ -0,0 +1,12 @@ +import { render } from '@testing-library/react'; + +import Create3dModal from './create-3d-modal'; + +describe('Create3dModal', () => { + + it('should render successfully', () => { + const { baseElement } = render(); + expect(baseElement).toBeTruthy(); + }); + +}); diff --git a/frontend/libs/components/create-3d-modal/src/lib/create-3d-modal.tsx b/frontend/libs/components/create-3d-modal/src/lib/create-3d-modal.tsx new file mode 100644 index 0000000000..41b117056a --- /dev/null +++ b/frontend/libs/components/create-3d-modal/src/lib/create-3d-modal.tsx @@ -0,0 +1,252 @@ +import { Modal } from "@storyteller/ui-modal"; +import { Button } from "@storyteller/ui-button"; +import { twMerge } from "tailwind-merge"; +import { useCreate3dModalStore } from "./create-3d-modal-store"; +import { useState, useCallback, useEffect, useRef } from "react"; +import { GalleryModal, GalleryItem } from "@storyteller/ui-gallery-modal"; +import { ImageInput, ImageFile } from "@storyteller/ui-image-input"; +import { faCube } from "@fortawesome/pro-solid-svg-icons"; +import { MediaUploadApi } from "@storyteller/api"; +import { + EnqueueImageTo3dObject, + EnqueueImageTo3dObjectModel, +} from "@storyteller/tauri-api"; +import { toast } from "react-hot-toast"; +import { v4 as uuidv4 } from "uuid"; +import { listen } from "@tauri-apps/api/event"; +import type { UnlistenFn } from "@tauri-apps/api/event"; + +enum GenerationAction { + ImageTo3d = "image_to_3d", +} + +export const Create3dModal = () => { + const { isOpen, close } = useCreate3dModalStore(); + const [isGalleryModalOpen, setIsGalleryModalOpen] = useState(false); + const [selectedGalleryImages, setSelectedGalleryImages] = useState( + [] + ); + const [selectedImage, setSelectedImage] = useState(null); + + // Store active toast IDs for 3D model generation + const activeToastIds = useRef>({}); + + // Listen for generation complete events + useEffect(() => { + let isUnmounted = false; + let unlisten: Promise; + + const setup = async () => { + unlisten = listen("generation-complete-event", (event: any) => { + console.log("Generation complete event received:", event); + + // Check if this is an ImageTo3d completion event + const eventData = event.payload?.data; + if (eventData?.action === GenerationAction.ImageTo3d) { + // Find any active toasts for 3D model generation and update them + Object.entries(activeToastIds.current).forEach( + ([mediaToken, toastId]) => { + // Dismiss the loading toast + toast.dismiss(toastId); + + // Add the completed model to the Zustand store + useCreate3dModalStore.getState().addCompletedModel({ + mediaToken: mediaToken, + }); + + // Remove from active toasts + delete activeToastIds.current[mediaToken]; + } + ); + } + }); + + if (isUnmounted) { + unlisten.then((f) => f()); // Unsubscribe if unmounted early. + } + }; + + setup(); + + return () => { + isUnmounted = true; + if (unlisten) { + unlisten.then((f) => f()); + } + }; + }, []); + + const handleOpenGallery = () => { + setIsGalleryModalOpen(true); + }; + + const handleGalleryClose = () => { + setIsGalleryModalOpen(false); + setSelectedGalleryImages([]); + }; + + const handleImageSelect = (id: string) => { + setSelectedGalleryImages((prev) => { + if (prev.includes(id)) { + return prev.filter((imageId) => imageId !== id); + } + // Only allow single selection for 3D model creation + return [id]; + }); + }; + + const handleGalleryImages = (selectedItems: GalleryItem[]) => { + if (selectedItems && selectedItems.length > 0) { + const item = selectedItems[0]; + if (!item.fullImage) return; + + const newImage: ImageFile = { + id: item.id, + url: item.fullImage, + file: new File([], "gallery-image"), + mediaToken: item.id, + }; + setSelectedImage(newImage); + } + setIsGalleryModalOpen(false); + setSelectedGalleryImages([]); + }; + + // Process image for 3D model creation + const processImageFor3D = useCallback(async (imageToProcess: ImageFile) => { + let mediaToken = imageToProcess.mediaToken; + let toastId: string; + + try { + // toast that won't disappear until we manually dismiss it + toastId = toast.loading("Generating 3D model...", { + duration: 300000, // max five minutes + }); + + // If the image is from a local file and doesn't have a media token yet, upload it first + if (!mediaToken && imageToProcess.file) { + const mediaUploadApi = new MediaUploadApi(); + const uuid = uuidv4(); + + const uploadResult = await mediaUploadApi.UploadImage({ + blob: imageToProcess.file, + fileName: imageToProcess.file.name, + uuid: uuid, + }); + + if (!uploadResult.success || !uploadResult.data) { + throw new Error("Failed to upload image"); + } + + mediaToken = uploadResult.data; + } + + if (!mediaToken) { + throw new Error("No media token available"); + } + + // Update toast message to show progress + toast.loading("Uploading and preparing image...", { id: toastId }); + + // Enqueue the image for 3D model creation using the Hunyuan 3D 2 model + const result = await EnqueueImageTo3dObject({ + image_media_token: mediaToken, + model: EnqueueImageTo3dObjectModel.Hunyuan3d2, + }); + + if ("error_type" in result) { + throw new Error(result.error_message || result.error_type); + } + + toast.loading("Generating 3D model...", { + id: toastId, + }); + + // Store the toast ID with the media token so we can update it when generation completes + if (mediaToken) { + activeToastIds.current[mediaToken] = toastId; + } + + return true; + } catch (error) { + // Handle errors + const errorMessage = + error instanceof Error ? error.message : "An unexpected error occurred"; + toast.error(`Failed to create 3D model: ${errorMessage}`, { + id: toastId!, // Use the non-null assertion as we know toastId is defined + duration: 5000, + }); + return false; + } + }, []); + + const handleCreate3DModel = () => { + if (!selectedImage) return; + + // Store the selected image information before closing the modal + const imageToProcess = selectedImage; + + // Clear the image field after starting the process + setSelectedImage(null); + + // Close the modal immediately + close(); + + // Start the processing in the background + processImageFor3D(imageToProcess); + }; + + return ( + <> + +
+
+

+ Create 3D Model from Image +

+
+ +
+
+ {/* Image Input Component */} + +
+
+ + +
+
+ + + + ); +}; + +export default Create3dModal; diff --git a/frontend/libs/components/create-3d-modal/tsconfig.json b/frontend/libs/components/create-3d-modal/tsconfig.json new file mode 100644 index 0000000000..2631ca286a --- /dev/null +++ b/frontend/libs/components/create-3d-modal/tsconfig.json @@ -0,0 +1,22 @@ +{ + "files": [], + "include": [], + "references": [ + { + "path": "../gallery-modal" + }, + { + "path": "../button" + }, + { + "path": "../modal" + }, + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ], + "extends": "../../../tsconfig.base.json" +} diff --git a/frontend/libs/components/create-3d-modal/tsconfig.lib.json b/frontend/libs/components/create-3d-modal/tsconfig.lib.json new file mode 100644 index 0000000000..47c5c26bc7 --- /dev/null +++ b/frontend/libs/components/create-3d-modal/tsconfig.lib.json @@ -0,0 +1,56 @@ +{ + "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": "../gallery-modal/tsconfig.lib.json" + }, + { + "path": "../button/tsconfig.lib.json" + }, + { + "path": "../modal/tsconfig.lib.json" + } + ] +} diff --git a/frontend/libs/components/create-3d-modal/tsconfig.spec.json b/frontend/libs/components/create-3d-modal/tsconfig.spec.json new file mode 100644 index 0000000000..18b7de51b8 --- /dev/null +++ b/frontend/libs/components/create-3d-modal/tsconfig.spec.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" + } + ] +} diff --git a/frontend/libs/components/create-3d-modal/vite.config.ts b/frontend/libs/components/create-3d-modal/vite.config.ts new file mode 100644 index 0000000000..e3996531a9 --- /dev/null +++ b/frontend/libs/components/create-3d-modal/vite.config.ts @@ -0,0 +1,49 @@ +/// +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/create-3d-modal', + 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-create-3d-modal', + 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, + } + }, +})); diff --git a/frontend/libs/components/image-input/.babelrc b/frontend/libs/components/image-input/.babelrc new file mode 100644 index 0000000000..1ea870ead4 --- /dev/null +++ b/frontend/libs/components/image-input/.babelrc @@ -0,0 +1,12 @@ +{ + "presets": [ + [ + "@nx/react/babel", + { + "runtime": "automatic", + "useBuiltIns": "usage" + } + ] + ], + "plugins": [] +} diff --git a/frontend/libs/components/image-input/README.md b/frontend/libs/components/image-input/README.md new file mode 100644 index 0000000000..338186d753 --- /dev/null +++ b/frontend/libs/components/image-input/README.md @@ -0,0 +1,7 @@ +# @storyteller/ui-image-input + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test @storyteller/ui-image-input` to execute the unit tests via [Vitest](https://vitest.dev/). diff --git a/frontend/libs/components/image-input/package.json b/frontend/libs/components/image-input/package.json new file mode 100644 index 0000000000..fb4a6c3ddc --- /dev/null +++ b/frontend/libs/components/image-input/package.json @@ -0,0 +1,17 @@ +{ + "name": "@storyteller/ui-image-input", + "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" + } + } +} diff --git a/frontend/libs/components/image-input/src/index.ts b/frontend/libs/components/image-input/src/index.ts new file mode 100644 index 0000000000..3bf29a9ec8 --- /dev/null +++ b/frontend/libs/components/image-input/src/index.ts @@ -0,0 +1,2 @@ + +export * from './lib/image-input'; diff --git a/frontend/libs/components/image-input/src/lib/image-input.module.css b/frontend/libs/components/image-input/src/lib/image-input.module.css new file mode 100644 index 0000000000..d0246c74b9 --- /dev/null +++ b/frontend/libs/components/image-input/src/lib/image-input.module.css @@ -0,0 +1,7 @@ +/* + * Replace this with your own classes + * + * e.g. + * .container { + * } +*/ \ No newline at end of file diff --git a/frontend/libs/components/image-input/src/lib/image-input.spec.tsx b/frontend/libs/components/image-input/src/lib/image-input.spec.tsx new file mode 100644 index 0000000000..b67b432d59 --- /dev/null +++ b/frontend/libs/components/image-input/src/lib/image-input.spec.tsx @@ -0,0 +1,12 @@ +import { render } from '@testing-library/react'; + +import ImageInput from './image-input'; + +describe('ImageInput', () => { + + it('should render successfully', () => { + const { baseElement } = render(); + expect(baseElement).toBeTruthy(); + }); + +}); diff --git a/frontend/libs/components/image-input/src/lib/image-input.tsx b/frontend/libs/components/image-input/src/lib/image-input.tsx new file mode 100644 index 0000000000..949ea87197 --- /dev/null +++ b/frontend/libs/components/image-input/src/lib/image-input.tsx @@ -0,0 +1,265 @@ +import { useState, useRef, useCallback } from "react"; +import { Button } from "@storyteller/ui-button"; +import { twMerge } from "tailwind-merge"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faUpload, faImages, faTrash } from "@fortawesome/pro-solid-svg-icons"; +import { toast } from "react-hot-toast"; + +export interface ImageFile { + id: string; + url: string; + file: File; + mediaToken?: string; +} + +export interface ImageInputProps { + /** Selected image */ + value?: ImageFile | null; + /** Callback when image is selected or removed */ + onChange: (image: ImageFile | null) => void; + /** Handler for opening gallery modal */ + onGalleryOpen?: () => void; + /** Show gallery button */ + showGalleryButton?: boolean; + /** Custom placeholder text */ + placeholderText?: string; + /** Additional class names */ + className?: string; +} + +export function ImageInput({ + value, + onChange, + onGalleryOpen, + showGalleryButton = true, + placeholderText = "Drag and drop an image here", + className, +}: ImageInputProps) { + const [isDragging, setIsDragging] = useState(false); + const fileInputRef = useRef(null); + + const processImageFile = (file: File) => { + // Only accept image files + if (!file.type.startsWith("image/")) { + toast.error("Please upload an image file"); + return; + } + + const reader = new FileReader(); + reader.onloadend = () => { + const newImage: ImageFile = { + id: Math.random().toString(36).substring(7), + url: reader.result as string, + file: file, + }; + onChange(newImage); + }; + reader.readAsDataURL(file); + }; + + const handleFileUpload = (event: React.ChangeEvent) => { + const files = event.target.files; + if (files && files[0]) { + const file = files[0]; + processImageFile(file); + } + }; + + const handleUploadClick = () => { + fileInputRef.current?.click(); + }; + + // Drag and drop handlers + // Enhanced drag and drop handlers for better desktop compatibility + const handleDragEnter = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(true); + }, []); + + const handleDragOver = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (!isDragging) setIsDragging(true); + + // Explicitly set effectAllowed to make sure the browser shows the correct drop effect + if (e.dataTransfer) { + e.dataTransfer.dropEffect = "copy"; + } + }, + [isDragging] + ); + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + + // Only set isDragging to false if we're leaving the drop target + // and not entering a child element + const rect = (e.currentTarget as HTMLDivElement).getBoundingClientRect(); + const x = e.clientX; + const y = e.clientY; + + if (x < rect.left || x >= rect.right || y < rect.top || y >= rect.bottom) { + setIsDragging(false); + } + }, []); + + const handleDrop = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); + + // Debug logging + console.log("Files dropped:", e.dataTransfer.files); + console.log("DataTransfer types:", e.dataTransfer.types); + + try { + // First try the files collection + if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { + const file = e.dataTransfer.files[0]; // Just take the first file + console.log("Processing file:", file.name, file.type, file.size); + processImageFile(file); + return; + } + + // Then try the items collection + const items = e.dataTransfer.items; + if (items && items.length > 0) { + for (let i = 0; i < items.length; i++) { + if (items[i].kind === "file") { + const file = items[i].getAsFile(); + if (file) { + console.log("Processing item as file:", file.name); + processImageFile(file); + return; + } + } + } + } + + // If we got here, we couldn't process the drop + console.log("Could not process dropped content"); + toast.error( + "Could not process the dropped file. Please try uploading instead." + ); + } catch (error) { + console.error("Error processing drop:", error); + toast.error( + "Error processing the dropped file. Please try uploading instead." + ); + } + }, []); + + return ( +
+ {/* Drag & drop area or image preview */} +
e.preventDefault()} + > + {value ? ( +
+ Selected image +
+ ) : ( +
+ +

{placeholderText}

+ +
+
+

or

+
+
+ +
+ {showGalleryButton && onGalleryOpen && ( + + )} + + +
+
+ )} +
+ + {/* Hidden file input */} + + + {/* Action buttons when image is selected */} + {value && ( +
+ {showGalleryButton && onGalleryOpen && ( + + )} + + + + +
+ )} +
+ ); +} + +export default ImageInput; diff --git a/frontend/libs/components/image-input/tsconfig.json b/frontend/libs/components/image-input/tsconfig.json new file mode 100644 index 0000000000..b3d19d8f5e --- /dev/null +++ b/frontend/libs/components/image-input/tsconfig.json @@ -0,0 +1,13 @@ +{ + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ], + "extends": "../../../tsconfig.base.json" +} diff --git a/frontend/libs/components/image-input/tsconfig.lib.json b/frontend/libs/components/image-input/tsconfig.lib.json new file mode 100644 index 0000000000..f0782e3dd8 --- /dev/null +++ b/frontend/libs/components/image-input/tsconfig.lib.json @@ -0,0 +1,45 @@ +{ + "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" + ] +} diff --git a/frontend/libs/components/image-input/tsconfig.spec.json b/frontend/libs/components/image-input/tsconfig.spec.json new file mode 100644 index 0000000000..18b7de51b8 --- /dev/null +++ b/frontend/libs/components/image-input/tsconfig.spec.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" + } + ] +} diff --git a/frontend/libs/components/image-input/vite.config.ts b/frontend/libs/components/image-input/vite.config.ts new file mode 100644 index 0000000000..db5b596de3 --- /dev/null +++ b/frontend/libs/components/image-input/vite.config.ts @@ -0,0 +1,49 @@ +/// +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/image-input', + 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-image-input', + 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, + } + }, +})); diff --git a/frontend/libs/components/promptbox/src/lib/PromptBox3D.tsx b/frontend/libs/components/promptbox/src/lib/PromptBox3D.tsx index 18973e63cb..c5232362fb 100644 --- a/frontend/libs/components/promptbox/src/lib/PromptBox3D.tsx +++ b/frontend/libs/components/promptbox/src/lib/PromptBox3D.tsx @@ -58,7 +58,7 @@ import { SoraSessionState, waitForSoraLogin, } from "@storyteller/tauri-api"; -import { showActionReminder } from "libs/components/action-reminder-modal/src/lib/action-reminder-modal-signals"; +import { showActionReminder } from "@storyteller/ui-action-reminder-modal"; interface ReferenceImage { id: string; diff --git a/frontend/libs/components/settings-modal/src/lib/settings-modal.tsx b/frontend/libs/components/settings-modal/src/lib/settings-modal.tsx index 149e41e444..089066a832 100644 --- a/frontend/libs/components/settings-modal/src/lib/settings-modal.tsx +++ b/frontend/libs/components/settings-modal/src/lib/settings-modal.tsx @@ -17,6 +17,7 @@ interface SettingsModalProps { isOpen: boolean; onClose: () => void; globalAccountLogoutCallback: () => void; + initialSection?: SettingsSection; } //type SettingsSection = "misc" | "audio" | "accounts" | "video" | "image"; @@ -26,9 +27,10 @@ export const SettingsModal = ({ isOpen, onClose, globalAccountLogoutCallback, + initialSection = "general", }: SettingsModalProps) => { const [selectedSection, setSelectedSection] = - useState("general"); + useState(initialSection); const sections = [ { id: "general" as const, label: "General", icon: faCog }, diff --git a/frontend/libs/tauri-api/src/lib/fal/GetFalApiKey.ts b/frontend/libs/tauri-api/src/lib/fal/GetFalApiKey.ts index c1b22e7ea0..581dedf8be 100644 --- a/frontend/libs/tauri-api/src/lib/fal/GetFalApiKey.ts +++ b/frontend/libs/tauri-api/src/lib/fal/GetFalApiKey.ts @@ -4,12 +4,12 @@ import { CommandResult } from "../common/CommandStatus"; export interface GetFalApiKeySuccessResult extends CommandResult { payload: { key: string; - } + }; } export type GetFalApiKeyResult = GetFalApiKeySuccessResult | CommandResult; -export const GetFalApiKey = async () : Promise => { +export const GetFalApiKey = async (): Promise => { let result = await invoke("get_fal_api_key_command"); - return (result as GetFalApiKeyResult); -} + return result as GetFalApiKeyResult; +}; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 59d94dc0a2..bb7a6679b5 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1390,6 +1390,10 @@ "name": "@storyteller/ui-close-button", "version": "0.0.1" }, + "libs/components/create-3d-modal": { + "name": "@storyteller/ui-create-3d-modal", + "version": "0.0.1" + }, "libs/components/demo-modal": { "name": "@storyteller/ui-demo-modal", "version": "0.0.1" @@ -1406,6 +1410,14 @@ "name": "@storyteller/ui-gravatar", "version": "0.0.1" }, + "libs/components/image-field": { + "version": "0.0.1", + "extraneous": true + }, + "libs/components/image-input": { + "name": "@storyteller/ui-image-input", + "version": "0.0.1" + }, "libs/components/input": { "name": "@storyteller/ui-input", "version": "0.0.1" @@ -9681,6 +9693,10 @@ "resolved": "libs/components/close-button", "link": true }, + "node_modules/@storyteller/ui-create-3d-modal": { + "resolved": "libs/components/create-3d-modal", + "link": true + }, "node_modules/@storyteller/ui-demo-modal": { "resolved": "libs/components/demo-modal", "link": true @@ -9697,6 +9713,10 @@ "resolved": "libs/components/gravatar", "link": true }, + "node_modules/@storyteller/ui-image-input": { + "resolved": "libs/components/image-input", + "link": true + }, "node_modules/@storyteller/ui-input": { "resolved": "libs/components/input", "link": true diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index a373b2d78e..aad900be48 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -134,6 +134,12 @@ }, { "path": "./libs/components/model-selector" + }, + { + "path": "./libs/components/create-3d-modal" + }, + { + "path": "./libs/components/image-input" } ] }