From 387422c2c8cdb34d712366e002ac807a2fdb1330 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 18 Apr 2025 14:57:07 +0700 Subject: [PATCH] shared toaster component --- .../app/pages/PageEnigma/PageEditor.tsx | 17 +- .../app/pages/PageEnigma/PageEnigma.tsx | 11 +- .../comps/GenerateModals/Activity.tsx | 221 ++++++++++-------- .../PageEnigma/comps/PromptBox/PromptBox.tsx | 4 +- frontend/apps/editor3d/tsconfig.json | 3 + frontend/libs/components/toaster/.babelrc | 12 + frontend/libs/components/toaster/README.md | 7 + frontend/libs/components/toaster/package.json | 17 ++ frontend/libs/components/toaster/src/index.ts | 2 + .../toaster/src/lib/toaster.module.css | 7 + .../toaster/src/lib/toaster.spec.tsx | 12 + .../components/toaster/src/lib/toaster.tsx | 49 ++++ .../libs/components/toaster/tsconfig.json | 13 ++ .../libs/components/toaster/tsconfig.lib.json | 45 ++++ .../components/toaster/tsconfig.spec.json | 34 +++ .../libs/components/toaster/vite.config.ts | 49 ++++ frontend/package.json | 1 + frontend/tsconfig.json | 3 + 18 files changed, 386 insertions(+), 121 deletions(-) create mode 100644 frontend/libs/components/toaster/.babelrc create mode 100644 frontend/libs/components/toaster/README.md create mode 100644 frontend/libs/components/toaster/package.json create mode 100644 frontend/libs/components/toaster/src/index.ts create mode 100644 frontend/libs/components/toaster/src/lib/toaster.module.css create mode 100644 frontend/libs/components/toaster/src/lib/toaster.spec.tsx create mode 100644 frontend/libs/components/toaster/src/lib/toaster.tsx create mode 100644 frontend/libs/components/toaster/tsconfig.json create mode 100644 frontend/libs/components/toaster/tsconfig.lib.json create mode 100644 frontend/libs/components/toaster/tsconfig.spec.json create mode 100644 frontend/libs/components/toaster/vite.config.ts diff --git a/frontend/apps/editor3d/app/pages/PageEnigma/PageEditor.tsx b/frontend/apps/editor3d/app/pages/PageEnigma/PageEditor.tsx index 3b7411472f..0e556c67d1 100644 --- a/frontend/apps/editor3d/app/pages/PageEnigma/PageEditor.tsx +++ b/frontend/apps/editor3d/app/pages/PageEnigma/PageEditor.tsx @@ -1,6 +1,6 @@ import React, { useEffect } from "react"; import { useSignals } from "@preact/signals-react/runtime"; -import { LoadingDots, Toaster, TopBar } from "~/components"; +import { LoadingDots, TopBar } from "~/components"; import { Controls3D } from "./comps/Controls3D"; import { ControlsTopButtons } from "./comps/ControlsTopButtons"; import { ControlPanelSceneObject } from "./comps/ControlPanelSceneObject"; @@ -8,12 +8,9 @@ import { PreviewEngineCamera } from "./comps/PreviewEngineCamera"; import { PreviewFrameImage } from "./comps/PreviewFrameImage"; import { pageHeight, pageWidth } from "~/signals"; import { PoseModeSelector } from "./comps/PoseModeSelector"; - import { timelineHeight, sidePanelWidth, - sidePanelVisible, - dndSidePanelWidth, dndTimelineHeight, editorLoader, cameraAspectRatio, @@ -26,12 +23,9 @@ import { CameraAspectRatio } from "./enums"; import { PromptBox } from "./comps/PromptBox"; import { OnboardingHelper } from "./comps/OnboardingHelper"; import { FocalLengthDisplay } from "./comps/FocalLengthDisplay/FocalLengthDisplay"; - -import { Login } from "@frontend/login"; // import { api } from "@storyteller/api"; // import { AnalyticsApi } from "@storyteller/api"; - export const PageEditor = () => { useSignals(); //console.log(api()); @@ -48,13 +42,6 @@ export const PageEditor = () => { }; }, []); - const dndWidth = - dndSidePanelWidth.value > -1 - ? dndSidePanelWidth.value - : sidePanelWidth.value; - const width = sidePanelVisible.value - ? pageWidth.value - dndWidth - 84 - : pageWidth.value - 84; const height = dndTimelineHeight.value > -1 ? pageHeight.value - dndTimelineHeight.value - 56 @@ -88,7 +75,6 @@ export const PageEditor = () => { return (
- {/* */}
{ >
- diff --git a/frontend/apps/editor3d/app/pages/PageEnigma/PageEnigma.tsx b/frontend/apps/editor3d/app/pages/PageEnigma/PageEnigma.tsx index f1ce26316e..4bc27e6825 100644 --- a/frontend/apps/editor3d/app/pages/PageEnigma/PageEnigma.tsx +++ b/frontend/apps/editor3d/app/pages/PageEnigma/PageEnigma.tsx @@ -6,9 +6,8 @@ import { useBackgroundLoadingMedia } from "~/hooks/useBackgroundLoadingMedia"; import { useQueueHandler } from "./hooks/useQueueHandler"; import { ErrorDialog, LoadingDots } from "~/components"; import { GenerateModals } from "~/pages/PageEnigma/comps/GenerateModals/GenerateModals"; - +import { Toaster } from "@storyteller/ui-toaster"; import { EditorLoadingBar } from "./comps/EditorLoadingBar"; - import { Wizard } from "~/pages/PageEnigma/Wizard/Wizard"; import { useSignals } from "@preact/signals-react/runtime"; import { useEffect, useState } from "react"; @@ -17,7 +16,12 @@ import { UsersApi } from "~/Classes/ApiManager"; import { PageEnigmaComponent } from "~/pages/PageEnigma/PageEnigmaComponent"; import { TurnOnGpu } from "~/pages/PageEnigma/TurnOnGpu"; import { PrecisionSelector } from "./comps/PrecisionSelector/PrecisionSelector"; -import { precisionSelectedValue, precisionSelectorMenuCoords, precisionSelectorValues, showPrecisionSelector } from "./signals/precisionSelectorMenu"; +import { + precisionSelectedValue, + precisionSelectorMenuCoords, + precisionSelectorValues, + showPrecisionSelector, +} from "./signals/precisionSelectorMenu"; export const PageEnigma = ({ sceneToken }: { sceneToken?: string }) => { useSignals(); @@ -106,6 +110,7 @@ export const PageEnigma = ({ sceneToken }: { sceneToken?: string }) => { + ); }; diff --git a/frontend/apps/editor3d/app/pages/PageEnigma/comps/GenerateModals/Activity.tsx b/frontend/apps/editor3d/app/pages/PageEnigma/comps/GenerateModals/Activity.tsx index 485b3c13f7..5bb2ea6375 100644 --- a/frontend/apps/editor3d/app/pages/PageEnigma/comps/GenerateModals/Activity.tsx +++ b/frontend/apps/editor3d/app/pages/PageEnigma/comps/GenerateModals/Activity.tsx @@ -4,10 +4,10 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { PopoverMenu } from "~/components/reusable/Popover/Popover"; import { CompletedCard } from "./CompletedCard"; import { InProgressCard } from "./InProgressCard"; -import { Tooltip } from "~/components"; -import { useEffect, useState } from "react"; +import { H3, Tooltip } from "~/components"; +import { useEffect, useState, useRef, useCallback } from "react"; import { JobsApi } from "~/Classes/ApiManager/JobsApi"; -import { toast } from "sonner"; +import { toast } from "@storyteller/ui-toaster"; import { ActiveJob } from "~/pages/PageEnigma/models"; // TODO ensure we de-dupe all this extra code. interface CompletedItem { @@ -59,26 +59,49 @@ interface CompletedItem { export function Activity() { useSignals(); const [completedItems, setCompletedItems] = useState([]); - const [loading, setLoading] = useState(true); const [jobs, setJobs] = useState([]); + const processedJobs = useRef(new Set()); + const isFirstLoad = useRef(true); + const toastTimeoutRef = useRef(null); + + // Debounced toast function + const showToast = useCallback((items: CompletedItem[]) => { + if (toastTimeoutRef.current) { + clearTimeout(toastTimeoutRef.current); + } + + toastTimeoutRef.current = setTimeout(() => { + const count = items.length; + toast.success( + count === 1 + ? `${items[0].maybe_title} completed successfully` + : `${count} images completed successfully`, + ); + toastTimeoutRef.current = null; + }, 100); // Small delay to catch potential double calls + }, []); useEffect(() => { - const pollData = async () => { - const fetchJobs = async () => { - try { - const jobsApi = new JobsApi(); - const jobsResponse = await jobsApi.ListRecentJobs(); + let mounted = true; - if (jobsResponse.success && jobsResponse.data) { - // Process active jobs - const activeJobs = jobsResponse.data - .filter((job) => { - const status = job.status?.status?.toLowerCase(); - return ( - status && status !== "complete_success" && status !== "failed" - ); - }) - .map((job) => ({ + async function fetchJobs() { + try { + const jobsApi = new JobsApi(); + const response = await jobsApi.ListRecentJobs(); + + if (!mounted || !response.success || !response.data) return; + + // Handle active jobs + const activeJobs = response.data + .filter((job) => { + const status = job.status?.status?.toLowerCase(); + return ( + status && status !== "complete_success" && status !== "failed" + ); + }) + .map( + (job) => + ({ job_token: job.job_token, request: { maybe_model_title: @@ -88,80 +111,78 @@ export function Activity() { status: job.status?.status?.toUpperCase() || "", progress_percentage: job.status?.progress_percentage || 0, }, - })); - - // Process completed items - // Get completed items and filter out any that are already in our list - const successfulJobs = jobsResponse.data.filter( - (job) => job.status?.status?.toLowerCase() === "complete_success", - ); - - // Create a set of existing tokens for faster lookup - const existingTokens = new Set( - completedItems.map((item) => item.token), - ); - - // Filter out jobs we already have in our list - const newCompletedItems = successfulJobs - .filter((job) => !existingTokens.has(job.job_token)) - .map((job) => ({ - token: job.job_token, - maybe_title: - job.request?.maybe_model_title || "Image Generation", - public_bucket_path: - job.maybe_result?.maybe_public_bucket_media_path || "", + created_at: job.created_at || new Date().toISOString(), updated_at: job.updated_at || new Date().toISOString(), - })); + maybe_result: job.maybe_result || null, + }) as ActiveJob, + ); - // Show toast notification for newly completed items - if (newCompletedItems.length > 0) { - const count = newCompletedItems.length; - const message = - count === 1 - ? `${newCompletedItems[0].maybe_title} completed successfully` - : `${count} images completed successfully`; - toast.success(message); - } + // Handle completed jobs + const newlyCompleted = response.data.filter( + (job) => + job.status?.status?.toLowerCase() === "complete_success" && + !processedJobs.current.has(job.job_token), + ); - // Update completed items with a maximum limit of 50 items - setCompletedItems((prev) => { - const combined = [...newCompletedItems, ...prev]; - // Remove duplicates based on token - const uniqueItems = Array.from( - new Map(combined.map((item) => [item.token, item])).values(), - ); - // Sort by updated_at in descending order and limit to 50 items - return uniqueItems - .sort( - (a, b) => - new Date(b.updated_at).getTime() - - new Date(a.updated_at).getTime(), - ) - .slice(0, 50); - }); - setJobs(activeJobs as ActiveJob[]); - setLoading(false); + if (newlyCompleted.length > 0) { + // Add to processed set + newlyCompleted.forEach((job) => + processedJobs.current.add(job.job_token), + ); + + // Create completed items + const newItems = newlyCompleted.map((job) => ({ + token: job.job_token, + maybe_title: job.request?.maybe_model_title || "Image Generation", + public_bucket_path: + job.maybe_result?.maybe_public_bucket_media_path || "", + updated_at: job.updated_at || new Date().toISOString(), + })); + + // Show toast only if not first load + if (!isFirstLoad.current) { + showToast(newItems); } - } catch (error) { - toast.error("Error Fetching Recent Jobs"); - console.error("Failed to fetch recent jobs:", error); - setLoading(false); + + // Update state + setCompletedItems((prev) => { + const combined = [...newItems, ...prev]; + return combined + .filter( + (item, index, self) => + index === self.findIndex((t) => t.token === item.token), + ) + .sort( + (a, b) => + new Date(b.updated_at).getTime() - + new Date(a.updated_at).getTime(), + ) + .slice(0, 50); + }); } - }; - await fetchJobs(); + + setJobs(activeJobs); + isFirstLoad.current = false; + } catch (error) { + console.error("Failed to fetch jobs:", error); + } + } + + // Initial fetch + fetchJobs(); + + // Set up polling + const interval = setInterval(fetchJobs, 5000); + + // Cleanup + return () => { + mounted = false; + clearInterval(interval); + if (toastTimeoutRef.current) { + clearTimeout(toastTimeoutRef.current); + } }; - - // Initial load - // Initial call to pollData - pollData(); - // Set up polling interval (every 5 seconds) - const interval = setInterval(async () => { - await pollData(); - }, 5000); - - return () => clearInterval(interval); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [showToast]); return ( @@ -185,8 +206,7 @@ export function Activity() { } >
- {/* In progress */} - {jobs && jobs.length > 0 && ( + {jobs.length > 0 && (
{jobs.map((job) => ( @@ -194,23 +214,22 @@ export function Activity() {
)} - {loading ? ( -
- -

- Loading activities... -

+ {isFirstLoad.current ? ( +
+ +

Retrieving Activities

) : jobs.length === 0 && completedItems.length === 0 ? ( -
- -

- No activities yet -

+
+

No activities yet

) : (
- {/* Completed */}
{completedItems.map((item) => ( diff --git a/frontend/apps/editor3d/app/pages/PageEnigma/comps/PromptBox/PromptBox.tsx b/frontend/apps/editor3d/app/pages/PageEnigma/comps/PromptBox/PromptBox.tsx index 3644b9e996..84bf473958 100644 --- a/frontend/apps/editor3d/app/pages/PageEnigma/comps/PromptBox/PromptBox.tsx +++ b/frontend/apps/editor3d/app/pages/PageEnigma/comps/PromptBox/PromptBox.tsx @@ -48,6 +48,7 @@ import { updateCamera, focalLengthDragging, } from "~/pages/PageEnigma/signals/camera"; +import { toast } from "@storyteller/ui-toaster"; interface ReferenceImage { id: string; @@ -430,7 +431,7 @@ export const PromptBox = () => { screenshot: snapshot.file, sceneMediaToken: "", }); - + console.log("useSystemPrompt", useSystemPrompt); await engineApi.enqueueImageGeneration({ disableSystemPrompt: !useSystemPrompt, @@ -451,6 +452,7 @@ export const PromptBox = () => { await new Promise((resolve) => setTimeout(resolve, 2000)); } finally { setisEnqueueing(false); + toast.success("Image added to queue"); } } setisEnqueueing(false); diff --git a/frontend/apps/editor3d/tsconfig.json b/frontend/apps/editor3d/tsconfig.json index 490787456a..df44f96589 100644 --- a/frontend/apps/editor3d/tsconfig.json +++ b/frontend/apps/editor3d/tsconfig.json @@ -23,6 +23,9 @@ "references": [ { "path": "../../libs/login" + }, + { + "path": "../../libs/components/toaster" } ] } diff --git a/frontend/libs/components/toaster/.babelrc b/frontend/libs/components/toaster/.babelrc new file mode 100644 index 0000000000..1ea870ead4 --- /dev/null +++ b/frontend/libs/components/toaster/.babelrc @@ -0,0 +1,12 @@ +{ + "presets": [ + [ + "@nx/react/babel", + { + "runtime": "automatic", + "useBuiltIns": "usage" + } + ] + ], + "plugins": [] +} diff --git a/frontend/libs/components/toaster/README.md b/frontend/libs/components/toaster/README.md new file mode 100644 index 0000000000..e0f957ed25 --- /dev/null +++ b/frontend/libs/components/toaster/README.md @@ -0,0 +1,7 @@ +# @storyteller/ui-toaster + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test @storyteller/ui-toaster` to execute the unit tests via [Vitest](https://vitest.dev/). diff --git a/frontend/libs/components/toaster/package.json b/frontend/libs/components/toaster/package.json new file mode 100644 index 0000000000..9912d73084 --- /dev/null +++ b/frontend/libs/components/toaster/package.json @@ -0,0 +1,17 @@ +{ + "name": "@storyteller/ui-toaster", + "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/toaster/src/index.ts b/frontend/libs/components/toaster/src/index.ts new file mode 100644 index 0000000000..6405a47fbe --- /dev/null +++ b/frontend/libs/components/toaster/src/index.ts @@ -0,0 +1,2 @@ + +export * from './lib/toaster'; diff --git a/frontend/libs/components/toaster/src/lib/toaster.module.css b/frontend/libs/components/toaster/src/lib/toaster.module.css new file mode 100644 index 0000000000..d0246c74b9 --- /dev/null +++ b/frontend/libs/components/toaster/src/lib/toaster.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/toaster/src/lib/toaster.spec.tsx b/frontend/libs/components/toaster/src/lib/toaster.spec.tsx new file mode 100644 index 0000000000..6ff83ed6e1 --- /dev/null +++ b/frontend/libs/components/toaster/src/lib/toaster.spec.tsx @@ -0,0 +1,12 @@ +import { render } from '@testing-library/react'; + +import Toaster from './toaster'; + +describe('Toaster', () => { + + it('should render successfully', () => { + const { baseElement } = render(); + expect(baseElement).toBeTruthy(); + }); + +}); diff --git a/frontend/libs/components/toaster/src/lib/toaster.tsx b/frontend/libs/components/toaster/src/lib/toaster.tsx new file mode 100644 index 0000000000..f18da7150a --- /dev/null +++ b/frontend/libs/components/toaster/src/lib/toaster.tsx @@ -0,0 +1,49 @@ +import toast, { Toaster as RHToaster } from "react-hot-toast"; + +interface ToasterProps { + position?: "top-right" | "top-left" | "bottom-right" | "bottom-left"; + offsetTop?: number; + offsetBottom?: number; + offsetLeft?: number; + offsetRight?: number; + zIndex?: number; +} + +export function Toaster({ + position = "top-right", + offsetTop = 12, + offsetBottom = 12, + offsetLeft = 12, + offsetRight = 12, + zIndex = 9, +}: ToasterProps) { + return ( + + ); +} + +export default Toaster; + +export { toast }; diff --git a/frontend/libs/components/toaster/tsconfig.json b/frontend/libs/components/toaster/tsconfig.json new file mode 100644 index 0000000000..b3d19d8f5e --- /dev/null +++ b/frontend/libs/components/toaster/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/toaster/tsconfig.lib.json b/frontend/libs/components/toaster/tsconfig.lib.json new file mode 100644 index 0000000000..f0782e3dd8 --- /dev/null +++ b/frontend/libs/components/toaster/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/toaster/tsconfig.spec.json b/frontend/libs/components/toaster/tsconfig.spec.json new file mode 100644 index 0000000000..18b7de51b8 --- /dev/null +++ b/frontend/libs/components/toaster/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/toaster/vite.config.ts b/frontend/libs/components/toaster/vite.config.ts new file mode 100644 index 0000000000..a0453aa36a --- /dev/null +++ b/frontend/libs/components/toaster/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/toaster', + 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-toaster', + 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/package.json b/frontend/package.json index aae035da80..424e8d0f7d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,6 +10,7 @@ "pnpm": "^10.8.1", "react": "18.3.1", "react-dom": "18.3.1", + "react-hot-toast": "^2.5.2", "react-router-dom": "6.29.0" }, "devDependencies": { diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 752d3150e4..0ae454dda8 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -14,6 +14,9 @@ }, { "path": "./apps/my-app" + }, + { + "path": "./libs/components/toaster" } ] }