credential selector

This commit is contained in:
Hanashi 2026-08-03 18:32:23 -04:00
parent 5ff6609981
commit 1621866cf1
10 changed files with 177 additions and 0 deletions

View File

@ -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<CredentialPayload[]>([]);
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: (
<img
src={getServiceLogoPath(credential.service)}
alt=""
className="h-4 w-4 object-contain icon-auto-contrast"
/>
),
}))
: [
{
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 (
<Tooltip content="Account" position="top" className="z-50" closeOnClick>
<PopoverMenu
items={items}
onSelect={handleSelect}
mode="toggle"
richList
panelTitle="Select Account"
panelClassName="w-[300px]"
buttonClassName="max-w-48"
triggerIcon={
<span className="flex h-4 w-4 shrink-0 items-center justify-center">
{selectedCredential ? (
<img
src={getServiceLogoPath(selectedCredential.service)}
alt=""
className="h-4 w-4 object-contain icon-auto-contrast"
/>
) : (
<FontAwesomeIcon icon={faUser} className="text-xs" />
)}
</span>
}
/>
</Tooltip>
);
};
/** 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;
};

View File

@ -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<AccountSelectorState>((set) => ({
selectedAccountId: null,
setSelectedAccountId: (id) => set({ selectedAccountId: id }),
}));

View File

@ -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 */}
<div className="mt-3 flex shrink-0 flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap items-center gap-2">
<AccountSelector />
<ClassyModelSelector
variant="embedded"
items={

View File

@ -12,6 +12,7 @@ import {
PromptShell,
type CompletedFile,
} from "~/components/PromptShell";
import { AccountSelector } from "~/components/account-selector/AccountSelector";
// The whole page is the composer: no feed, no gallery — results are written
// straight to disk and the PromptShell shows the progress bar + receipt.
@ -64,6 +65,7 @@ const CreateAudio = () => {
uploadAudio={UploadAudioMedia}
uploadImage={UploadImageMedia}
credits={audioCredits}
accountSelector={<AccountSelector />}
onEnqueuePressed={async () => {
// Nudge the shared jobs poller so the in-flight state appears
// immediately (one request can create several job tokens).

View File

@ -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={<AccountSelector />}
modelSelector={
<ClassyModelSelector
variant="embedded"

View File

@ -25,6 +25,7 @@ import { useCostBreakdownModalStore } from "@storyteller/ui-pricing-modal";
import { GenerationProvider } from "@storyteller/api-enums";
import { Clapperboard } from "lucide-react";
import { PromptShell, useComposerTasks } from "~/components/PromptShell";
import { AccountSelector } from "~/components/account-selector/AccountSelector";
const PAGE_ID: ModelPage = ModelPage.ImageToVideo;
@ -91,6 +92,7 @@ const ImageToVideo = ({ imageMediaId, imageUrl }: ImageToVideoProps) => {
uploadVideo={UploadVideoMedia}
uploadAudio={UploadAudioMedia}
credits={videoCredits}
accountSelector={<AccountSelector />}
modelSelector={
<ClassyModelSelector
variant="embedded"

View File

@ -80,6 +80,9 @@ interface PromptBoxAudioProps {
// request can create several Suno clips).
onEnqueuePressed?: (jobTokens: string[]) => void | Promise<void>;
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 = ({
<div className="shrink-0 pt-2">{referenceRow}</div>
<div className="mt-3 flex shrink-0 flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap items-center gap-2">
{accountSelector}
{modelSelector}
{toggleButtons}
{settingsPopovers}
@ -658,6 +663,7 @@ export const PromptBoxAudio = ({
<div className="mt-2 flex items-center justify-between gap-2">
<div className="flex flex-wrap items-center gap-2">
{accountSelector}
{modelSelector}
{toggleButtons}
{settingsPopovers}

View File

@ -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() && (
<AspectRatioPicker

View File

@ -129,6 +129,9 @@ interface PromptBoxVideoProps {
uploadVideo?: UploadImageFn;
uploadAudio?: UploadImageFn;
credits?: number | null;
/** Optional account-picker slot rendered at the very start of the toolbar
* (left of the model selector). */
accountSelector?: React.ReactNode;
/** Optional model-picker slot rendered at the start of the toolbar
* (left of the aspect-ratio picker). */
modelSelector?: React.ReactNode;
@ -148,6 +151,7 @@ export const PromptBoxVideo = ({
uploadVideo,
uploadAudio,
credits,
accountSelector,
modelSelector,
fullBleed = false,
}: PromptBoxVideoProps) => {
@ -1318,6 +1322,7 @@ export const PromptBoxVideo = ({
// Toolbar clusters shared by the floating and full-bleed layouts.
const settingsPickers = (
<>
{accountSelector}
{modelSelector}
<Tooltip
content="Aspect Ratio"

View File

@ -4,3 +4,4 @@ export {
useExperimentalStore,
useStoryboardPageEnabled,
} from './lib/experimental-store';
export * from "./lib/panes/AccountSettings/credential-helpers";