feat: add create 3D modal and image input components with supporting files

This commit is contained in:
bflatastic committed 2025-06-12 14:06:37 +07:00
1 parent df0b98ec63
commit 0f65bc8fac
30 files changed
+1150 -30

No files matched your search

@@ -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<Record<string, boolean>>({});
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 (
<>
<div className="flex justify-center">
@@ -148,10 +267,9 @@ export const Controls3D = () => {
>
<Button
icon={faMagicWandSparkles}
className="text-md h-9 w-9 rounded-[10px] bg-white/10"
className="text-md h-9 w-9 rounded-[10px] bg-white/15 transition-colors hover:bg-white/25"
variant="secondary"
disabled={true}
onClick={handleOpenModal}
onClick={handleOpenCreate3dModal}
/>
</Tooltip>
</div>
@@ -167,6 +285,16 @@ export const Controls3D = () => {
</div>
<AssetModal />
<Create3dModal />
{/* Action reminder is now handled through showActionReminder function */}
<SettingsModal
isOpen={isSettingsModalOpen}
onClose={() => setIsSettingsModalOpen(false)}
globalAccountLogoutCallback={() => setLogoutStates()}
initialSection="accounts"
/>
</>
);
};
+15 -15
View File
@@ -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"
@@ -0,0 +1,12 @@
{
"presets": [
[
"@nx/react/babel",
{
"runtime": "automatic",
"useBuiltIns": "usage"
}
]
],
"plugins": []
}
@@ -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/).
@@ -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"
}
}
}
@@ -0,0 +1,2 @@
export * from "./lib/create-3d-modal";
export * from "./lib/create-3d-modal-store";
@@ -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<Create3dModalState>((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;
}
}));
@@ -0,0 +1,7 @@
/*
* Replace this with your own classes
*
* e.g.
* .container {
* }
*/
@@ -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(<Create3dModal />);
expect(baseElement).toBeTruthy();
});
});
@@ -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<string[]>(
[]
);
const [selectedImage, setSelectedImage] = useState<ImageFile | null>(null);
// Store active toast IDs for 3D model generation
const activeToastIds = useRef<Record<string, string>>({});
// Listen for generation complete events
useEffect(() => {
let isUnmounted = false;
let unlisten: Promise<UnlistenFn>;
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 (
<>
<Modal
isOpen={isOpen}
onClose={close}
className={twMerge("max-w-xl duration-200")}
backdropClassName={twMerge("duration-200")}
childPadding={true}
>
<div className="flex h-full flex-col">
<div className="flex items-center justify-between gap-2.5 py-0.5">
<h2 className="text-[18px] font-semibold">
Create 3D Model from Image
</h2>
</div>
<div className="flex-grow space-y-6 py-4">
<div className="text-center">
{/* Image Input Component */}
<ImageInput
value={selectedImage}
onChange={setSelectedImage}
onGalleryOpen={handleOpenGallery}
placeholderText="Drag and drop an image here"
className="mb-2"
/>
</div>
</div>
<Button
disabled={!selectedImage}
onClick={handleCreate3DModel}
className="w-full"
icon={faCube}
>
Create 3D Model
</Button>
</div>
</Modal>
<GalleryModal
isOpen={isGalleryModalOpen}
onClose={handleGalleryClose}
mode="select"
selectedItemIds={selectedGalleryImages}
onSelectItem={handleImageSelect}
maxSelections={1}
onUseSelected={handleGalleryImages}
/>
</>
);
};
export default Create3dModal;
@@ -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"
}
@@ -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"
}
]
}
@@ -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/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,
}
},
}));
@@ -0,0 +1,12 @@
{
"presets": [
[
"@nx/react/babel",
{
"runtime": "automatic",
"useBuiltIns": "usage"
}
]
],
"plugins": []
}
@@ -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/).
@@ -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"
}
}
}
@@ -0,0 +1,2 @@
export * from './lib/image-input';
@@ -0,0 +1,7 @@
/*
* Replace this with your own classes
*
* e.g.
* .container {
* }
*/
@@ -0,0 +1,12 @@
import { render } from '@testing-library/react';
import ImageInput from './image-input';
describe('ImageInput', () => {
it('should render successfully', () => {
const { baseElement } = render(<ImageInput />);
expect(baseElement).toBeTruthy();
});
});
@@ -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<HTMLInputElement>(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<HTMLInputElement>) => {
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<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
}, []);
const handleDragOver = useCallback(
(e: React.DragEvent<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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 (
<div className={twMerge("relative", className)}>
{/* Drag & drop area or image preview */}
<div
className={twMerge(
"mx-auto flex h-60 w-full flex-col items-center justify-center rounded-lg border border-dashed border-white/20 bg-white/5 p-4 transition-colors",
isDragging && "border-blue-400 bg-blue-500/10"
)}
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onDragStart={(e) => e.preventDefault()}
>
{value ? (
<div className="h-full w-full overflow-hidden">
<img
src={value.url}
alt="Selected image"
className="h-full w-full object-contain"
/>
</div>
) : (
<div className="flex h-full w-full flex-col items-center justify-center text-white/60">
<FontAwesomeIcon icon={faImages} className="text-2xl mb-2" />
<p>{placeholderText}</p>
<div className="flex items-center justify-center gap-2 my-4 w-full">
<div className="h-[1px] w-12 bg-white/10" />
<p className="text-sm px-3">or</p>
<div className="h-[1px] w-12 bg-white/10" />
</div>
<div className="flex flex-col items-center justify-center gap-3 sm:flex-row">
{showGalleryButton && onGalleryOpen && (
<Button
onClick={onGalleryOpen}
variant="secondary"
className="flex items-center gap-2 px-2.5 py-1.5 text-sm bg-white/15"
>
<FontAwesomeIcon icon={faImages} />
Choose from Library
</Button>
)}
<Button
onClick={handleUploadClick}
variant="secondary"
className="flex items-center gap-2 px-2.5 py-1.5 text-sm bg-white/15"
>
<FontAwesomeIcon icon={faUpload} />
Upload Image
</Button>
</div>
</div>
)}
</div>
{/* Hidden file input */}
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleFileUpload}
className="hidden"
/>
{/* Action buttons when image is selected */}
{value && (
<div className="mt-4 flex flex-wrap items-center justify-center gap-3">
{showGalleryButton && onGalleryOpen && (
<Button
onClick={onGalleryOpen}
variant="secondary"
className="flex items-center gap-2 px-2.5 py-1.5 text-sm"
icon={faImages}
>
Choose Different Image
</Button>
)}
<Button
onClick={handleUploadClick}
variant="secondary"
className="flex items-center gap-2 px-2.5 py-1.5 text-sm"
icon={faUpload}
>
Upload Different Image
</Button>
<Button
onClick={() => {
onChange(null);
// Reset file input value to allow selecting the same file again
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}}
variant="destructive"
className="flex items-center gap-2 px-2.5 py-1.5 text-sm"
icon={faTrash}
>
Remove Image
</Button>
</div>
)}
</div>
);
}
export default ImageInput;
@@ -0,0 +1,13 @@
{
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
],
"extends": "../../../tsconfig.base.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"
]
}
@@ -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/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,
}
},
}));
@@ -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;
@@ -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<SettingsSection>("general");
useState<SettingsSection>(initialSection);
const sections = [
{ id: "general" as const, label: "General", icon: faCog },
@@ -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<GetFalApiKeyResult> => {
export const GetFalApiKey = async (): Promise<GetFalApiKeyResult> => {
let result = await invoke("get_fal_api_key_command");
return (result as GetFalApiKeyResult);
}
return result as GetFalApiKeyResult;
};
+20
View File
@@ -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
+6
View File
@@ -134,6 +134,12 @@
},
{
"path": "./libs/components/model-selector"
},
{
"path": "./libs/components/create-3d-modal"
},
{
"path": "./libs/components/image-input"
}
]
}