diff --git a/frontend/apps/artcraft/app/src/components/account-selector/AccountSelector.tsx b/frontend/apps/artcraft/app/src/components/account-selector/AccountSelector.tsx new file mode 100644 index 00000000..d4763212 --- /dev/null +++ b/frontend/apps/artcraft/app/src/components/account-selector/AccountSelector.tsx @@ -0,0 +1,135 @@ +import { useCallback, useEffect, useState } from "react"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faUser } from "@fortawesome/pro-solid-svg-icons"; +import { PopoverMenu, type PopoverItem } from "@storyteller/ui-popover"; +import { Tooltip } from "@storyteller/ui-tooltip"; +import { useRefreshAccountStateEvent } from "@storyteller/tauri-events"; +import { + CredentialPayload, + getServiceLogoPath, + getServiceMeta, + listCredentials, +} from "@storyteller/ui-settings-modal"; +import { useAccountSelectorStore } from "./accountSelectorStore"; + +/** + * Toolbar account picker: lists every stored credential (API keys and + * website/ArtCraft logins) and remembers the pick in a shared store. Rendered + * to the left of the model selector on each generation page. + * + * Fetches the credential list on every mount for now — caching and cache + * busting come later. + */ +export const AccountSelector = () => { + const [credentials, setCredentials] = useState([]); + const selectedAccountId = useAccountSelectorStore( + (state) => state.selectedAccountId + ); + const setSelectedAccountId = useAccountSelectorStore( + (state) => state.setSelectedAccountId + ); + + const refresh = useCallback(async () => { + try { + setCredentials(await listCredentials()); + } catch (e) { + console.error("Failed to list accounts", e); + } + }, []); + + useEffect(() => { + refresh(); + }, [refresh]); + + // Logins and credential edits emit this event; reload so the list is fresh. + useRefreshAccountStateEvent(async () => { + await refresh(); + }); + + // Keep the selection valid: default to the first account, and re-point (or + // clear) when the selected credential is deleted. + useEffect(() => { + if (credentials.length === 0) { + if (selectedAccountId !== null) setSelectedAccountId(null); + return; + } + if (!credentials.some((credential) => credential.id === selectedAccountId)) { + setSelectedAccountId(credentials[0].id); + } + }, [credentials, selectedAccountId, setSelectedAccountId]); + + const items: PopoverItem[] = + credentials.length > 0 + ? credentials.map((credential) => ({ + label: accountLabel(credential), + description: accountDescription(credential), + selected: credential.id === selectedAccountId, + action: credential.id, + icon: ( + + ), + })) + : [ + { + label: "No accounts yet", + description: "Add one in Settings → Accounts", + selected: false, + disabled: true, + }, + ]; + + const handleSelect = (item: PopoverItem) => { + if (item.action) setSelectedAccountId(item.action); + }; + + const selectedCredential = credentials.find( + (credential) => credential.id === selectedAccountId + ); + + return ( + + + {selectedCredential ? ( + + ) : ( + + )} + + } + /> + + ); +}; + +/** Row title: the most personal identity we have for the account. */ +const accountLabel = (credential: CredentialPayload): string => + credential.name || + credential.username || + credential.email || + getServiceMeta(credential.service).label; + +/** Row subtitle: the service, plus a key preview for API-key accounts. */ +const accountDescription = (credential: CredentialPayload): string => { + const serviceLabel = getServiceMeta(credential.service).label; + if (credential.kind === "api_key") { + return `${serviceLabel} · ${credential.api_key_preview ?? ""}${"*".repeat(8)}`; + } + return serviceLabel; +}; diff --git a/frontend/apps/artcraft/app/src/components/account-selector/accountSelectorStore.ts b/frontend/apps/artcraft/app/src/components/account-selector/accountSelectorStore.ts new file mode 100644 index 00000000..fe06e13e --- /dev/null +++ b/frontend/apps/artcraft/app/src/components/account-selector/accountSelectorStore.ts @@ -0,0 +1,17 @@ +import { create } from "zustand"; + +interface AccountSelectorState { + /** + * Credential id (the credential's file name, e.g. `artcraft_2.toml`) of + * the account selected in the toolbar account pickers. Shared across all + * generation pages. `null` until accounts load (the selector defaults to + * the first account) or when no accounts exist. + */ + selectedAccountId: string | null; + setSelectedAccountId: (id: string | null) => void; +} + +export const useAccountSelectorStore = create((set) => ({ + selectedAccountId: null, + setSelectedAccountId: (id) => set({ selectedAccountId: id }), +})); diff --git a/frontend/apps/artcraft/app/src/components/experiences/ImageTo3DComposer.tsx b/frontend/apps/artcraft/app/src/components/experiences/ImageTo3DComposer.tsx index 75c9b045..915ef16a 100644 --- a/frontend/apps/artcraft/app/src/components/experiences/ImageTo3DComposer.tsx +++ b/frontend/apps/artcraft/app/src/components/experiences/ImageTo3DComposer.tsx @@ -21,6 +21,7 @@ import { useSelectedProviderForModel, } from "@storyteller/ui-model-selector"; import { PromptShell, useComposerTasks } from "~/components/PromptShell"; +import { AccountSelector } from "~/components/account-selector/AccountSelector"; // Minimal image→3D composer in the marketing-site style: reference tray + // optional prompt + generate. Replaces the old ImageTo3DExperience page UI @@ -285,6 +286,7 @@ export const ImageTo3DComposer = ({ variant }: ImageTo3DComposerProps) => { {/* control bar */}
+ { uploadAudio={UploadAudioMedia} uploadImage={UploadImageMedia} credits={audioCredits} + accountSelector={} onEnqueuePressed={async () => { // Nudge the shared jobs poller so the in-flight state appears // immediately (one request can create several job tokens). diff --git a/frontend/apps/artcraft/app/src/pages/PageImage/TextToImage.tsx b/frontend/apps/artcraft/app/src/pages/PageImage/TextToImage.tsx index 8d6d963d..38a90913 100644 --- a/frontend/apps/artcraft/app/src/pages/PageImage/TextToImage.tsx +++ b/frontend/apps/artcraft/app/src/pages/PageImage/TextToImage.tsx @@ -14,6 +14,7 @@ import { useCostBreakdownModalStore } from "@storyteller/ui-pricing-modal"; import { GenerationProvider } from "@storyteller/api-enums"; import { Image as ImageIcon } from "lucide-react"; import { PromptShell, useComposerTasks } from "~/components/PromptShell"; +import { AccountSelector } from "~/components/account-selector/AccountSelector"; const PAGE_ID: ModelPage = ModelPage.TextToImage; @@ -64,6 +65,7 @@ const TextToImage = ({ imageMediaId, imageUrl }: TextToImageProps) => { imageMediaId={imageMediaId} url={imageUrl ?? undefined} credits={imageCredits} + accountSelector={} modelSelector={ { uploadVideo={UploadVideoMedia} uploadAudio={UploadAudioMedia} credits={videoCredits} + accountSelector={} modelSelector={ void | Promise; credits?: number | null; + /** Optional account-picker slot rendered at the very start of the toolbar + * (left of the model selector). */ + accountSelector?: ReactNode; /** Render the focus-mode layout inline, filling the parent's height, * instead of the floating card + fullscreen modal. */ fullBleed?: boolean; @@ -91,6 +94,7 @@ export const PromptBoxAudio = ({ uploadImage, onEnqueuePressed, credits, + accountSelector, fullBleed = false, }: PromptBoxAudioProps) => { const prompt = usePromptAudioStore((s) => s.prompt); @@ -585,6 +589,7 @@ export const PromptBoxAudio = ({
{referenceRow}
+ {accountSelector} {modelSelector} {toggleButtons} {settingsPopovers} @@ -658,6 +663,7 @@ export const PromptBoxAudio = ({
+ {accountSelector} {modelSelector} {toggleButtons} {settingsPopovers} diff --git a/frontend/libs/components/promptbox/src/lib/PromptBoxImage.tsx b/frontend/libs/components/promptbox/src/lib/PromptBoxImage.tsx index 8e167d89..571e8bba 100644 --- a/frontend/libs/components/promptbox/src/lib/PromptBoxImage.tsx +++ b/frontend/libs/components/promptbox/src/lib/PromptBoxImage.tsx @@ -66,6 +66,9 @@ interface PromptBoxImageProps { url?: string; onImageRowVisibilityChange?: (visible: boolean) => void; credits?: number | null; + /** Optional account-picker slot rendered at the very start of the toolbar + * (left of the model selector). */ + accountSelector?: ReactNode; /** Optional model-picker slot rendered at the start of the toolbar * (left of the aspect-ratio picker). */ modelSelector?: ReactNode; @@ -83,6 +86,7 @@ export const PromptBoxImage = ({ imageMediaId, url, credits, + accountSelector, modelSelector, fullBleed = false, }: PromptBoxImageProps) => { @@ -455,6 +459,7 @@ export const PromptBoxImage = ({ // Toolbar clusters shared by the floating and full-bleed layouts. const settingsPickers = ( <> + {accountSelector} {modelSelector} {selectedModel?.supportsNewAspectRatio() && ( { @@ -1318,6 +1322,7 @@ export const PromptBoxVideo = ({ // Toolbar clusters shared by the floating and full-bleed layouts. const settingsPickers = ( <> + {accountSelector} {modelSelector}