Add action reminder modal component and integrate it into the editor3d app

This commit is contained in:
bflatastic committed 2025-06-02 22:33:19 +07:00
1 parent bc90084b37
commit 4a183fe6f3
21 files changed
+511 -23

No files matched your search

@@ -1,4 +1,3 @@
import { TrackProvider } from "~/pages/PageEnigma/contexts/TrackContext/TrackProvider";
import { DragComponent } from "~/pages/PageEnigma/comps/DragComponent/DragComponent";
import { EngineProvider } from "./contexts/EngineContext";
import { useActiveJobs } from "~/hooks/useActiveJobs";
@@ -28,12 +27,16 @@ import { useGenerationCompleteEvent } from "./TauriEvents/generations/useGenerat
import { PageEditor } from "~/pages/PageEnigma/PageEditor";
import { GalleryDragComponent } from "@storyteller/ui-gallery-modal";
import { LoadingDots } from "@storyteller/ui-loading";
import {
isActionReminderOpen,
actionReminderProps,
ActionReminderModal,
} from "@storyteller/ui-action-reminder-modal";
export const PageEnigma = ({ sceneToken }: { sceneToken?: string }) => {
useSignals();
useActiveJobs();
useBackgroundLoadingMedia();
// implement the code to handle incoming messages from the Engine
useQueueHandler();
const [validGpu, setValidGpu] = useState("unknown");
@@ -110,8 +113,9 @@ export const PageEnigma = ({ sceneToken }: { sceneToken?: string }) => {
return <TurnOnGpu />;
}
return (
const currentReminderModalProps = actionReminderProps.value;
return (
<EngineProvider sceneToken={sceneToken}>
<PageEditor />
<DragComponent />
@@ -127,7 +131,22 @@ export const PageEnigma = ({ sceneToken }: { sceneToken?: string }) => {
<EditorLoadingBar />
<Toaster offsetTop={70} offsetRight={12} />
</EngineProvider>
{currentReminderModalProps && (
<ActionReminderModal
isOpen={isActionReminderOpen.value}
onClose={currentReminderModalProps.onClose}
reminderType={currentReminderModalProps.reminderType}
onPrimaryAction={currentReminderModalProps.onPrimaryAction}
title={currentReminderModalProps.title}
message={currentReminderModalProps.message}
primaryActionText={currentReminderModalProps.primaryActionText}
secondaryActionText={currentReminderModalProps.secondaryActionText}
onSecondaryAction={currentReminderModalProps.onSecondaryAction}
isLoading={currentReminderModalProps.isLoading}
openAiLogo={currentReminderModalProps.openAiLogo}
/>
)}
</EngineProvider>
);
};
+3
View File
@@ -58,6 +58,9 @@
{
"path": "../../libs/components/button-dropdown"
},
{
"path": "../../libs/components/action-reminder-modal"
},
{
"path": "../../libs/components/login-modal"
},
@@ -0,0 +1,12 @@
{
"presets": [
[
"@nx/react/babel",
{
"runtime": "automatic",
"useBuiltIns": "usage"
}
]
],
"plugins": []
}
@@ -0,0 +1,7 @@
# @storyteller/ui-action-reminder-modal
This library was generated with [Nx](https://nx.dev).
## Running unit tests
Run `nx test @storyteller/ui-action-reminder-modal` to execute the unit tests via [Vitest](https://vitest.dev/).
@@ -0,0 +1,17 @@
{
"name": "@storyteller/ui-action-reminder-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/action-reminder-modal-signals";
export * from "./lib/action-reminder-modal";
@@ -0,0 +1,48 @@
// frontend/apps/editor3d/app/src/signals/actionReminderSignals.ts
import { signal } from "@preact/signals-react";
import { ReactNode } from "react";
import { ReminderType } from "@storyteller/ui-action-reminder-modal";
export interface ShowActionReminderOptions {
reminderType: ReminderType;
onPrimaryAction: () => void;
title?: string;
message?: ReactNode;
primaryActionText?: string;
secondaryActionText?: string;
onSecondaryAction?: () => void;
isLoading?: boolean;
openAiLogo?: string;
}
interface ActionReminderModalFullProps extends ShowActionReminderOptions {
onClose: () => void;
}
export const isActionReminderOpen = signal(false);
export const actionReminderProps = signal<ActionReminderModalFullProps | null>(
null
);
export function showActionReminder(options: ShowActionReminderOptions): void {
actionReminderProps.value = {
...options,
onClose: () => {
isActionReminderOpen.value = false;
},
};
isActionReminderOpen.value = true;
}
// Example of how to trigger it - BFlat:
// import { showGlobalActionReminder } from "@storyteller/ui-action-reminder-modal";
// import openAiLogo from 'path/to/logo.svg';
//
// function someAction() {
// showActionReminder({
// reminderType: 'soraLogin',
// onPrimaryAction: () => console.log('Login with Sora!'),
// openAiLogo: openAiLogo,
// title: 'Sora Login Needed'
// });
// }
@@ -0,0 +1,7 @@
/*
* Replace this with your own classes
*
* e.g.
* .container {
* }
*/
@@ -0,0 +1,12 @@
import { render } from '@testing-library/react';
import ActionReminderModal from './action-reminder-modal';
describe('ActionReminderModal', () => {
it('should render successfully', () => {
const { baseElement } = render(<ActionReminderModal />);
expect(baseElement).toBeTruthy();
});
});
@@ -0,0 +1,140 @@
import { Button } from "@storyteller/ui-button";
import { Modal } from "@storyteller/ui-modal";
import { ReactNode } from "react";
import { faRightToBracket } from "@fortawesome/pro-solid-svg-icons";
export type ReminderType = "default" | "soraLogin" | "artcraftLogin";
interface ActionReminderModalProps {
isOpen: boolean;
onClose: () => void;
reminderType?: ReminderType;
onPrimaryAction: () => void;
title?: string;
hideTitle?: boolean;
message?: ReactNode;
children?: ReactNode;
primaryActionText?: string;
secondaryActionText?: string;
onSecondaryAction?: () => void;
isLoading?: boolean;
openAiLogo?: string;
modalClassName?: string;
}
export function ActionReminderModal({
isOpen,
onClose,
reminderType = "default",
onPrimaryAction,
title: customTitle,
hideTitle = false,
message: customMessage,
children: customChildren,
primaryActionText: customPrimaryActionText,
secondaryActionText: customSecondaryActionText,
onSecondaryAction,
isLoading = false,
openAiLogo,
modalClassName: customModalClassName = "",
}: ActionReminderModalProps) {
let titleText: string | undefined;
let messageContent: ReactNode;
let primaryActionBtnText: string;
let primaryActionIcon = faRightToBracket;
if (!hideTitle) {
switch (reminderType) {
case "soraLogin":
titleText = customTitle || "Link Your OpenAI Sora Account";
break;
case "artcraftLogin":
titleText = customTitle || "Login to ArtCraft";
break;
default:
titleText = customTitle || "Action Required";
break;
}
} else {
titleText = undefined;
}
switch (reminderType) {
case "soraLogin":
titleText = customTitle || "Link Your OpenAI Account";
messageContent = customMessage || (
<p>
To use this feature, please connect your OpenAI account. This allows
us to leverage your existing subscription.
</p>
);
primaryActionBtnText = customPrimaryActionText || "Login with OpenAI";
break;
case "artcraftLogin":
messageContent = customMessage || (
<p className="text-sm text-white/70">
Please log in or sign up to ArtCraft to proceed. This will allow you
to save your work and access all features.
</p>
);
primaryActionBtnText = customPrimaryActionText || "Login / Sign Up";
break;
default:
messageContent = customMessage || (
<p className="text-sm text-white/70">
Please complete the required action.
</p>
);
primaryActionBtnText = customPrimaryActionText || "Proceed";
break;
}
const effectiveSecondaryAction = onSecondaryAction || onClose;
const effectiveSecondaryActionText = customSecondaryActionText || "Cancel";
let modalSpecificClasses = "";
const finalModalClassName =
`${customModalClassName} ${modalSpecificClasses}`.trim();
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={titleText}
className={finalModalClassName}
>
<div className="pt-2">
{customChildren ? (
<div className="space-y-4">{customChildren}</div>
) : (
<div className="space-y-4">{messageContent}</div>
)}
<div className="mt-6 flex flex-col sm:flex-row-reverse gap-3">
<Button
onClick={onPrimaryAction}
loading={isLoading}
disabled={isLoading}
icon={primaryActionIcon}
className="w-full sm:w-auto"
>
{primaryActionBtnText}
</Button>
{(onSecondaryAction || customSecondaryActionText) && (
<Button
variant="secondary"
onClick={effectiveSecondaryAction}
disabled={isLoading}
className="w-full sm:w-auto"
>
{effectiveSecondaryActionText}
</Button>
)}
</div>
</div>
</Modal>
);
}
export default ActionReminderModal;
@@ -0,0 +1,19 @@
{
"files": [],
"include": [],
"references": [
{
"path": "../modal"
},
{
"path": "../button"
},
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
],
"extends": "../../../tsconfig.base.json"
}
@@ -0,0 +1,53 @@
{
"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": "../modal/tsconfig.lib.json"
},
{
"path": "../button/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/action-reminder-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-action-reminder-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,
}
},
}));
@@ -14,7 +14,7 @@ import {
useSoraLoginListener,
} from "@storyteller/tauri-api";
import { invoke } from "@tauri-apps/api/core";
import { ArtCraftSignUp } from "./ArtCraftSignUp";
import { ArtCraftSignUp } from "./artcraft-signup";
import { UsersApi } from "@storyteller/api";
interface LoginModalProps {
onClose: () => void;
@@ -42,11 +42,21 @@ export function LoginModal({
const artCraftFormRef = useRef<HTMLFormElement>(null);
const [initialArtCraftLoginStatusForUI, setInitialArtCraftLoginStatusForUI] =
useState<boolean | null>(null);
const [initialSoraSessionStatusForUI, setInitialSoraSessionStatusForUI] =
useState<boolean | null>(null);
const uiTotalSteps = initialArtCraftLoginStatusForUI ? 3 : 4;
// Determine total steps and current step for UI
let uiTotalSteps = 4;
let uiCurrentStep = step;
if (initialArtCraftLoginStatusForUI) {
if (initialSoraSessionStatusForUI) {
// If Sora session exists, remove the Sora step (step 3)
uiTotalSteps = 3;
if (step === 1) uiCurrentStep = 1;
else if (step === 2) uiCurrentStep = 2;
else if (step === 4) uiCurrentStep = 3;
} else if (initialArtCraftLoginStatusForUI) {
// If ArtCraft is logged in but not Sora, keep 4 steps
uiTotalSteps = 4;
if (step === 1) uiCurrentStep = 1;
else if (step === 3) uiCurrentStep = 2;
else if (step === 4) uiCurrentStep = 3;
@@ -67,11 +77,7 @@ export function LoginModal({
useSoraLoginListener((payload: any) => {
console.log("Login success!", payload);
if (isLoggedInArtCraft) {
setStep(4);
} else {
setStep(2);
}
setStep(4); // Always go to the final step after Sora login
});
// Check session on component mount
@@ -80,8 +86,13 @@ export function LoginModal({
Promise.all([initSession(), checkArtCraftLogin()]).then(
([soraSessionExists, loggedIn]) => {
setInitialArtCraftLoginStatusForUI(loggedIn || false); // Set initial status for UI step counting
setInitialSoraSessionStatusForUI(soraSessionExists || false); // Set initial Sora status for UI step counting
if (soraSessionExists && !loggedIn) {
if (soraSessionExists && loggedIn) {
setIsLoggedInArtCraft(true);
setIsOpen(false); // Close the modal if fully logged in
return;
} else if (soraSessionExists && !loggedIn) {
// Sora session exists, but not logged in to ArtCraft
setIsOpen(true);
setStep(2);
@@ -96,14 +107,10 @@ export function LoginModal({
setIsOpen(true);
setStep(1);
setIsLoggedInArtCraft(false); // Ensure this is also set
} else if (loggedIn) {
// Already logged in, no need to show modal
setIsLoggedInArtCraft(true);
// setIsOpen(false); // This would prevent modal from showing if fully logged in
}
}
);
}, []); // Empty dependency array ensures this runs once on mount
}, []);
useEffect(() => {
if (onOpenChange) onOpenChange(isOpen);
@@ -124,14 +131,14 @@ export function LoginModal({
const result = await CheckSoraSession();
const sessionExists = result.state === SoraSessionState.Valid;
if (sessionExists) {
setStep(step + 1);
setStep(4); // Always go to the final step after Sora login
}
} else {
alert("Please open the desktop app to login");
await new Promise((resolve) => setTimeout(resolve, 3000));
const sessionExists = true;
if (sessionExists) {
setStep(step + 1);
setStep(4); // Always go to the final step after Sora login
}
}
} catch (error) {
@@ -279,7 +286,13 @@ export function LoginModal({
const userInfo = session.data?.user;
if (userInfo) onArtCraftAuthSuccess(userInfo);
}
setStep(3);
// Check for Sora session and skip to step 4 if present
const soraSessionExists = await initSession();
if (soraSessionExists) {
setStep(4);
} else {
setStep(3);
}
} catch (e) {
console.error(e);
setErrorMessage(
@@ -37,8 +37,13 @@ import {
SoraImageRemix,
SoraImageRemixErrorType,
SoraImageRemixAspectRatio,
CheckSoraSession,
SoraSessionState,
waitForSoraLogin,
} from "@storyteller/tauri-api";
import { SoundRegistry } from "@storyteller/soundboard";
import { showActionReminder } from "@storyteller/ui-action-reminder-modal";
import { invoke } from "@tauri-apps/api/core";
interface ReferenceImage {
id: string;
@@ -243,7 +248,30 @@ export const PromptBox2D = ({
setPrompt(e.target.value);
};
// Helper to show Sora login reminder and wait for login
const handleSoraLoginReminder = async () => {
return new Promise<void>((resolve) => {
showActionReminder({
reminderType: "soraLogin",
onPrimaryAction: async () => {
await invoke("open_sora_login_command");
await waitForSoraLogin();
toast.success("Logged in to Sora!");
resolve();
},
});
});
};
const handleTauriEnqueue = async () => {
// Check if the Sora session is valid
const soraSession = await CheckSoraSession();
if (soraSession.state !== SoraSessionState.Valid) {
setIsEnqueueing(false);
await handleSoraLoginReminder();
return;
}
const api = new PromptsApi();
let image = getCanvasRenderBitmap();
if (image === undefined) {
@@ -403,7 +431,7 @@ export const PromptBox2D = ({
const getCurrentAspectRatioIcon = () => {
const selected = aspectRatioList.find((item) => item.selected);
if (!selected || !selected.icon) return faRectangle;
const iconElement = selected.icon as React.ReactElement;
const iconElement = selected.icon as React.ReactElement<any, any>;
return iconElement.props.icon;
};
+1
View File
@@ -12,3 +12,4 @@ export * from "./lib/sora/LogoutSoraSession.js";
export * from "./lib/sora/SoraImageRemix.js";
export * from "./lib/sora/useSoraLoginListener.js";
export * from "./lib/system/GetBuildInfo.js";
export * from "./lib/sora/waitForSoraLogin.js";
@@ -0,0 +1,13 @@
import { listen } from "@tauri-apps/api/event";
export function waitForSoraLogin(): Promise<void> {
return new Promise((resolve) => {
let unlisten: () => void;
listen("sora-login-success", () => {
if (unlisten) unlisten();
resolve();
}).then((stop) => {
unlisten = stop;
});
});
}
+8
View File
@@ -1297,6 +1297,10 @@
"version": "0.0.1",
"extraneous": true
},
"libs/components/action-reminder-modal": {
"name": "@storyteller/ui-action-reminder-modal",
"version": "0.0.1"
},
"libs/components/badge": {
"name": "@storyteller/ui-badge",
"version": "0.0.1"
@@ -9171,6 +9175,10 @@
"resolved": "libs/tauri-utils",
"link": true
},
"node_modules/@storyteller/ui-action-reminder-modal": {
"resolved": "libs/components/action-reminder-modal",
"link": true
},
"node_modules/@storyteller/ui-badge": {
"resolved": "libs/components/badge",
"link": true
+3
View File
@@ -128,6 +128,9 @@
},
{
"path": "./libs/components/upload-modal"
},
{
"path": "./libs/components/action-reminder-modal"
}
]
}