diff --git a/frontend/apps/artcraft/app/src/components/signaled/TopBar/TopBar.tsx b/frontend/apps/artcraft/app/src/components/signaled/TopBar/TopBar.tsx index 24dfb93ee0..0741de6c98 100644 --- a/frontend/apps/artcraft/app/src/components/signaled/TopBar/TopBar.tsx +++ b/frontend/apps/artcraft/app/src/components/signaled/TopBar/TopBar.tsx @@ -11,14 +11,26 @@ import { faGrid2, faImages, faCalculator, + faExclamation, + faCheck, } from "@fortawesome/pro-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { signal } from "@preact/signals-react"; import { useSignals } from "@preact/signals-react/runtime"; import { FilterMediaClasses } from "@storyteller/api"; -import { getCreatorIcon, ModelCreator, IMAGE_MODELS_BY_ID, VIDEO_MODELS_BY_ID, CommonAspectRatio, CommonResolution } from "@storyteller/model-list"; -import { useClassyModelSelectorStore, ModelPage } from "@storyteller/ui-model-selector"; -import { useCreditsState } from "@storyteller/credits"; +import { + getCreatorIcon, + ModelCreator, + IMAGE_MODELS_BY_ID, + VIDEO_MODELS_BY_ID, + CommonAspectRatio, + CommonResolution, +} from "@storyteller/model-list"; +import { + useClassyModelSelectorStore, + ModelPage, +} from "@storyteller/ui-model-selector"; +import { useCreditsState, type CreditsIconStatus } from "@storyteller/credits"; import { gtagEvent } from "@storyteller/google-analytics"; import { ProviderBillingModal } from "@storyteller/provider-billing-modal"; import { ProviderSetupModal } from "@storyteller/provider-setup-modal"; @@ -51,9 +63,14 @@ import { useCostBreakdownModalStore, CreditsModal, } from "@storyteller/ui-pricing-modal"; -import { RefImage, RefVideo, RefAudio, usePromptImageStore, usePromptVideoStore } from "@storyteller/ui-promptbox"; +import { + RefImage, + RefVideo, + RefAudio, + usePromptImageStore, + usePromptVideoStore, +} from "@storyteller/ui-promptbox"; import type { Prompts } from "@storyteller/api"; -import { LoadingSpinner } from "@storyteller/ui-loading-spinner"; import { SettingsModal } from "@storyteller/ui-settings-modal"; import { Tooltip } from "@storyteller/ui-tooltip"; import { useEffect, useRef, useState } from "react"; @@ -70,6 +87,7 @@ import { useImageTo3DStore } from "~/pages/PageImageTo3DObject/ImageTo3DStore"; import { useImageTo3DWorldStore } from "~/pages/PageImageTo3DWorld/ImageTo3DWorldStore"; import { useRemoveBackgroundStore } from "~/pages/PageRemoveBackground/RemoveBackgroundStore"; import { TabId, useTabStore } from "~/pages/Stores/TabState"; +import { AUTH_STATUS } from "~/enums"; import { authentication } from "~/signals"; import { setLogoutStates } from "~/signals/authentication/utilities"; import type { BaseSelectorImage } from "@storyteller/ui-pagedraw"; @@ -97,6 +115,7 @@ type SettingsSection = | "billing"; const SWITCHER_THROTTLE_TIME = 500; // milliseconds +const CREDITS_POLL_INTERVAL = 60_000; // milliseconds // NB: See `TabState` for the default tab. const appMenuTabs: MenuIconItem[] = [ @@ -123,6 +142,74 @@ const appMenuTabs: MenuIconItem[] = [ export const topNavMediaId = signal(""); export const topNavMediaUrl = signal(""); +const CreditsCoinWithStatus = ({ + iconStatus, +}: { + iconStatus: CreditsIconStatus; +}) => { + const showBadge = iconStatus !== "hidden"; + + const badgeColorClass = + iconStatus === "failed" + ? "bg-red text-white" + : iconStatus === "recovered" + ? "bg-emerald-500 text-white" + : "bg-amber-400 text-black"; // 'slow' + + const badgeIconDef = iconStatus === "recovered" ? faCheck : faExclamation; + + const tooltipMessage = + iconStatus === "failed" + ? "Couldn't refresh your balance." + : iconStatus === "recovered" + ? "Balance up to date." + : "Refreshing your balance — current amount may not be up to date."; + + const showRetry = iconStatus === "slow" || iconStatus === "failed"; + + const handleRetry = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + console.log("TopBar: Retrying credits fetch"); + + void useCreditsState.getState().fetchFromServer(); + }; + + return ( + + {tooltipMessage} + {showRetry && ( + + )} + + } + > + + + {showBadge && ( + + + + )} + + + ); +}; + export const TopBar = ({ pageName }: Props) => { useSignals(); @@ -157,39 +244,33 @@ export const TopBar = ({ pageName }: Props) => { const [disableSwitcher, setDisableSwitcher] = useState(false); const switcherThrottle = useRef(false); - const creditsStore = useCreditsState(); - const sumTotalCredits = creditsStore.totalCredits; + const sumTotalCredits = useCreditsState((s) => s.totalCredits); + const creditsIconStatus = useCreditsState((s) => s.iconStatus); // Just calling this function kills the app: const subscriptionStore = useSubscriptionState(); const hasPaidPlan = subscriptionStore.hasPaidPlan(); - const [isCreditsLoading, setIsCreditsLoading] = useState(true); - + // Fetch credits + subscription on entering LOGGED_IN, then poll credits every + // 60s. Reading via getState() inside the effect keeps the dep array honest + // (the only real dep is the auth status). Earlier versions had a 1s setTimeout + // band-aid to outrun intermediate auth states; gating on LOGGED_IN replaces it. + const authStatus = authentication.status.value; useEffect(() => { - const t = setTimeout(() => { - Promise.all([ - creditsStore.fetchFromServer(), - subscriptionStore.fetchFromServer(), - ]) - }, 1000); - return () => clearTimeout(t); - }, []); + if (authStatus !== AUTH_STATUS.LOGGED_IN) return; - // Re-fetch credits & subscription whenever the user logs in and out - useEffect(() => { - setIsCreditsLoading(true); - const t = setTimeout(() => { - Promise.all([ - creditsStore.fetchFromServer(), - subscriptionStore.fetchFromServer(), - ]).finally(() => setIsCreditsLoading(false)); - }, 1000); // 1 second delay before fetching to make sure credits and subscription info loads - return () => clearTimeout(t); - }, [authentication.status.value]); + void useCreditsState.getState().fetchFromServer(); + void useSubscriptionState.getState().fetchFromServer(); + + const interval = setInterval(() => { + void useCreditsState.getState().fetchFromServer(); + console.log("TopBar: Polled credits"); + }, CREDITS_POLL_INTERVAL); + return () => clearInterval(interval); + }, [authStatus]); useCreditsBalanceChangedEvent(async () => { - creditsStore.fetchFromServer(); + useCreditsState.getState().fetchFromServer(); }); useSubscriptionPlanChangedEvent(async () => { @@ -296,10 +377,18 @@ export const TopBar = ({ pageName }: Props) => { endFrameImage = { ...base, file: new File([], "recreate-ref") }; break; case "vid_ref": - vidRefs.push({ ...base, file: new File([], "recreate-ref"), duration: 0 }); + vidRefs.push({ + ...base, + file: new File([], "recreate-ref"), + duration: 0, + }); break; case "audioref": - audioRefs.push({ ...base, file: new File([], "recreate-ref"), duration: 0 }); + audioRefs.push({ + ...base, + file: new File([], "recreate-ref"), + duration: 0, + }); break; default: // imgref, imgref_character, imgref_style, imgref_bg, imgsrc, vid_start_frame, imgmask @@ -310,7 +399,8 @@ export const TopBar = ({ pageName }: Props) => { // Determine input mode from generation_mode or context image semantics const hasKeyframeSemantics = contextImages.some( - (ci) => ci.semantic === "vid_start_frame" || ci.semantic === "vid_end_frame", + (ci) => + ci.semantic === "vid_start_frame" || ci.semantic === "vid_end_frame", ); const inputMode: "keyframe" | "reference" = promptData.maybe_generation_mode === "keyframe" || hasKeyframeSemantics @@ -383,10 +473,14 @@ export const TopBar = ({ pageName }: Props) => { } if (imgRefs.length > 0) imageStore.setReferenceImages(imgRefs); if (promptData.maybe_aspect_ratio) { - imageStore.setCommonAspectRatio(promptData.maybe_aspect_ratio as CommonAspectRatio); + imageStore.setCommonAspectRatio( + promptData.maybe_aspect_ratio as CommonAspectRatio, + ); } if (promptData.maybe_resolution) { - imageStore.setCommonResolution(promptData.maybe_resolution as CommonResolution); + imageStore.setCommonResolution( + promptData.maybe_resolution as CommonResolution, + ); } if (promptData.maybe_model_type) { @@ -604,97 +698,91 @@ export const TopBar = ({ pageName }: Props) => {
- {isCreditsLoading ? ( - - ) : ( - <> - - } - triggerLabel={ - - {sumTotalCredits} Credits + + } + triggerLabel={ + + {sumTotalCredits} Credits + + } + buttonClassName="h-[30px] px-2 ps-1.5 bg-transparent hover:bg-ui-controls/30 border-0 shadow-none" + panelClassName="mt-3 bg-ui-panel border border-ui-panel-border text-base-fg" + > + {(close) => ( +
+
+ + Your credit balance - } - buttonClassName="h-[30px] px-2 ps-1.5 bg-transparent hover:bg-ui-controls/30 border-0 shadow-none" - panelClassName="mt-3 bg-ui-panel border border-ui-panel-border text-base-fg" - > - {(close) => ( -
-
- - Your credit balance - - -
-
- - {sumTotalCredits} -
+ +
+
+ + {sumTotalCredits} +
- - -
- - -
-
- )} - - - {!hasPaidPlan && ( - - )} - + + Cost calculator + + +
+ + +
+
+ )} +
+ + {!hasPaidPlan && ( + )} diff --git a/frontend/libs/state/credits/src/lib/state/credits-state.ts b/frontend/libs/state/credits/src/lib/state/credits-state.ts index 920875e383..37002cec60 100644 --- a/frontend/libs/state/credits/src/lib/state/credits-state.ts +++ b/frontend/libs/state/credits/src/lib/state/credits-state.ts @@ -1,6 +1,11 @@ import { create } from 'zustand' import { ArtcraftGetCredits } from "@storyteller/tauri-api"; +const SLOW_FETCH_MS = 3000; +const RECOVERED_HOLD_MS = 1000; + +export type CreditsIconStatus = 'hidden' | 'slow' | 'failed' | 'recovered'; + export interface CreditsState { // Daily free credits (if/when we offer them) freeCredits: number, @@ -13,6 +18,9 @@ export interface CreditsState { // Total credits available totalCredits: number, + + // Drives the status badge on the credits coin in TopBar. + iconStatus: CreditsIconStatus, } export type CreditsActions = { @@ -22,31 +30,94 @@ export type CreditsActions = { reset: () => void } -export const useCreditsState = create((set) => ({ - freeCredits: 0, - monthlyCredits: 0, - bankedCredits: 0, - totalCredits: 0, +export const useCreditsState = create((set, get) => { + let token = 0; + let slowTimer: ReturnType | null = null; + let recoveredTimer: ReturnType | null = null; - reset: () => set({ freeCredits: 0, monthlyCredits: 0, bankedCredits: 0, totalCredits: 0 }), + const clearSlowTimer = () => { + if (slowTimer) { clearTimeout(slowTimer); slowTimer = null; } + }; + const clearRecoveredTimer = () => { + if (recoveredTimer) { clearTimeout(recoveredTimer); recoveredTimer = null; } + }; - // Call to fetch credits from the server - fetchFromServer: async () => { - let data; - try { - data = await ArtcraftGetCredits(); - } catch (error) { - console.error("Error fetching credits", error); - return; - } - console.log("Fetched credits from server: ", data); - if (!!data.payload) { - set((state) => ({ + return { + freeCredits: 0, + monthlyCredits: 0, + bankedCredits: 0, + totalCredits: 0, + iconStatus: 'hidden', + + reset: () => { + token += 1; + clearSlowTimer(); + clearRecoveredTimer(); + set({ + freeCredits: 0, + monthlyCredits: 0, + bankedCredits: 0, + totalCredits: 0, + iconStatus: 'hidden', + }); + }, + + fetchFromServer: async () => { + const myToken = ++token; + clearSlowTimer(); + clearRecoveredTimer(); + + // The recovered window has served its purpose; start the new cycle clean. + // Preserve visible 'slow' / 'failed' so the icon doesn't flicker while the + // new fetch is in flight. + if (get().iconStatus === 'recovered') { + set({ iconStatus: 'hidden' }); + } + + slowTimer = setTimeout(() => { + if (myToken === token) { + set({ iconStatus: 'slow' }); + } + }, SLOW_FETCH_MS); + + let data; + try { + data = await ArtcraftGetCredits(); + } catch (error) { + console.error("Error fetching credits", error); + if (myToken !== token) return; + clearSlowTimer(); + set({ iconStatus: 'failed' }); + return; + } + + if (myToken !== token) return; + clearSlowTimer(); + + console.log("Fetched credits from server: ", data); + if (!data?.payload) { + set({ iconStatus: 'failed' }); + return; + } + + const prior = get().iconStatus; + const wasProblematic = prior === 'slow' || prior === 'failed'; + set({ freeCredits: data.payload.free_credits, monthlyCredits: data.payload.monthly_credits, bankedCredits: data.payload.banked_credits, totalCredits: data.payload.sum_total_credits, - })); - } - } -})) + iconStatus: wasProblematic ? 'recovered' : 'hidden', + }); + + if (wasProblematic) { + recoveredTimer = setTimeout(() => { + if (get().iconStatus === 'recovered') { + set({ iconStatus: 'hidden' }); + } + recoveredTimer = null; + }, RECOVERED_HOLD_MS); + } + }, + }; +});