credential selector
This commit is contained in:
parent
5ff6609981
commit
1621866cf1
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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 }),
|
||||
}));
|
||||
|
|
@ -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={
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -4,3 +4,4 @@ export {
|
|||
useExperimentalStore,
|
||||
useStoryboardPageEnabled,
|
||||
} from './lib/experimental-store';
|
||||
export * from "./lib/panes/AccountSettings/credential-helpers";
|
||||
|
|
|
|||
Loading…
Reference in New Issue