From a7826f8be375e0ebf75d332f3fb481ef2e66f471 Mon Sep 17 00:00:00 2001 From: BFlat Date: Wed, 29 Oct 2025 15:33:47 +0700 Subject: [PATCH] ui model selector update with provider selection (#890) sub menu inside model selector image --- .../app/src/pages/PageDraw/PageDraw.tsx | 11 +- .../app/src/pages/PageEdit/PageEdit.tsx | 11 +- .../app/src/pages/PageEnigma/PageEditor.tsx | 11 +- .../app/src/pages/PageImage/TextToImage.tsx | 11 +- .../app/src/pages/PageVideo/ImageToVideo.tsx | 11 +- .../apps/editor3d/app/src/styles/base.css | 19 +- frontend/apps/editor3d/tailwind.config.ts | 1 + .../src/lib/classy-model-selector.tsx | 224 ++++++++- .../src/lib/provider-selector.tsx | 4 + .../components/popover/src/lib/popover.tsx | 466 +++++++++++++----- .../components/tooltip/src/lib/tooltip.tsx | 6 + 11 files changed, 595 insertions(+), 180 deletions(-) diff --git a/frontend/apps/editor3d/app/src/pages/PageDraw/PageDraw.tsx b/frontend/apps/editor3d/app/src/pages/PageDraw/PageDraw.tsx index 6a43936094..d1f31cc0a3 100644 --- a/frontend/apps/editor3d/app/src/pages/PageDraw/PageDraw.tsx +++ b/frontend/apps/editor3d/app/src/pages/PageDraw/PageDraw.tsx @@ -448,20 +448,11 @@ const PageDraw = () => { items={CANVAS_2D_PAGE_MODEL_LIST} page={PAGE_ID} panelTitle="Select Model" - panelClassName="min-w-[280px]" + panelClassName="min-w-[300px]" buttonClassName="bg-transparent p-0 text-lg hover:bg-transparent text-white/80 hover:text-white" showIconsInList triggerLabel="Model" /> - {/**/}
diff --git a/frontend/apps/editor3d/app/src/pages/PageEdit/PageEdit.tsx b/frontend/apps/editor3d/app/src/pages/PageEdit/PageEdit.tsx index d74ae7412b..978f938349 100644 --- a/frontend/apps/editor3d/app/src/pages/PageEdit/PageEdit.tsx +++ b/frontend/apps/editor3d/app/src/pages/PageEdit/PageEdit.tsx @@ -470,20 +470,11 @@ const PageEdit = () => { items={IMAGE_EDITOR_PAGE_MODEL_LIST} page={PAGE_ID} panelTitle="Select Model" - panelClassName="min-w-[280px]" + panelClassName="min-w-[300px]" buttonClassName="bg-transparent p-0 text-lg hover:bg-transparent text-base-fg opacity-80 hover:opacity-100" showIconsInList triggerLabel="Model" /> - {/**/}
diff --git a/frontend/apps/editor3d/app/src/pages/PageEnigma/PageEditor.tsx b/frontend/apps/editor3d/app/src/pages/PageEnigma/PageEditor.tsx index 5514dbce3b..bbe1d8ccd0 100644 --- a/frontend/apps/editor3d/app/src/pages/PageEnigma/PageEditor.tsx +++ b/frontend/apps/editor3d/app/src/pages/PageEnigma/PageEditor.tsx @@ -548,20 +548,11 @@ export const PageEditor = () => { items={STAGE_3D_PAGE_MODEL_LIST} page={PAGE_ID} panelTitle="Select Model" - panelClassName="min-w-[280px]" + panelClassName="min-w-[300px]" buttonClassName="bg-transparent p-0 text-lg hover:bg-transparent text-white/80 hover:text-white" showIconsInList triggerLabel="Model" /> - {/**/}
diff --git a/frontend/apps/editor3d/app/src/pages/PageImage/TextToImage.tsx b/frontend/apps/editor3d/app/src/pages/PageImage/TextToImage.tsx index b2c63a1622..a333c86d28 100644 --- a/frontend/apps/editor3d/app/src/pages/PageImage/TextToImage.tsx +++ b/frontend/apps/editor3d/app/src/pages/PageImage/TextToImage.tsx @@ -233,20 +233,11 @@ const TextToImage = ({ imageMediaId, imageUrl }: TextToImageProps) => { page={PAGE_ID} mode="hoverSelect" panelTitle="Select Model" - panelClassName="min-w-[280px]" + panelClassName="min-w-[300px]" buttonClassName="bg-transparent p-0 text-lg hover:bg-transparent text-white/80 hover:text-white" showIconsInList triggerLabel="Model" /> - {/**/}
diff --git a/frontend/apps/editor3d/app/src/pages/PageVideo/ImageToVideo.tsx b/frontend/apps/editor3d/app/src/pages/PageVideo/ImageToVideo.tsx index 266b74c170..ecd8d10618 100644 --- a/frontend/apps/editor3d/app/src/pages/PageVideo/ImageToVideo.tsx +++ b/frontend/apps/editor3d/app/src/pages/PageVideo/ImageToVideo.tsx @@ -208,20 +208,11 @@ const ImageToVideo = ({ imageMediaId, imageUrl }: ImageToVideoProps) => { items={IMAGE_TO_VIDEO_PAGE_MODEL_LIST} page={PAGE_ID} panelTitle="Select Model" - panelClassName="min-w-[280px]" + panelClassName="min-w-[300px]" buttonClassName="bg-transparent p-0 text-lg hover:bg-transparent text-white/80 hover:text-white" showIconsInList triggerLabel="Model" /> - {/**/}
diff --git a/frontend/apps/editor3d/app/src/styles/base.css b/frontend/apps/editor3d/app/src/styles/base.css index 3c9a15153f..f7c227f95e 100644 --- a/frontend/apps/editor3d/app/src/styles/base.css +++ b/frontend/apps/editor3d/app/src/styles/base.css @@ -57,12 +57,15 @@ body { --st-controls-rgb: 62 62 65; --st-controls-border: #3f3f3f; --st-fg: #ffffff; + --st-fg-rgb: 255 255 255; --st-divider: #515168; --st-gallery-vignette-alpha: 0.6; /* Photo tint (default neutral) */ --st-photo-tint-rgb: 0 0 0; --st-photo-tint-alpha: 0; --st-photo-grayscale: 1; + /* Theme-aware badge alpha (default dark) */ + --st-badge-alpha: 0.4; } /* Light theme */ @@ -75,11 +78,14 @@ body { --st-controls-rgb: 238 238 239; --st-controls-border: #dcdcdf; /* requested light button border */ --st-fg: #111827; + --st-fg-rgb: 17 24 39; --st-divider: #d1d5db; --st-color-scheme: light; --st-photo-tint-rgb: 0 0 0; --st-photo-tint-alpha: 0; --st-photo-grayscale: 1; + /* lighter badge background for light themes */ + --st-badge-alpha: 0.1; } /* Gray (current) */ @@ -92,12 +98,14 @@ body { --st-controls-rgb: 62 62 65; --st-controls-border: #464646; --st-fg: #ffffff; + --st-fg-rgb: 255 255 255; --st-divider: #424242; --st-color-scheme: dark; --st-gallery-vignette-alpha: 0.3; --st-photo-tint-rgb: 0 0 0; --st-photo-tint-alpha: 0; --st-photo-grayscale: 1; + --st-badge-alpha: 0.4; } /* Full black */ @@ -110,11 +118,13 @@ body { --st-controls-rgb: 17 17 17; --st-controls-border: #242424; --st-fg: #ffffff; + --st-fg-rgb: 255 255 255; --st-divider: #303030; --st-color-scheme: dark; --st-photo-tint-rgb: 0 0 0; --st-photo-tint-alpha: 0; --st-photo-grayscale: 1; + --st-badge-alpha: 0.4; } /* Aurora gradient theme */ @@ -132,11 +142,13 @@ body { --st-controls-rgb: 26 31 53; --st-controls-border: #1e2237; --st-fg: #f8fafc; + --st-fg-rgb: 248 250 252; --st-divider: #2a3152; --st-color-scheme: dark; --st-photo-tint-rgb: 22 32 56; /* slightly darker bluish tint */ --st-photo-tint-alpha: 0.34; --st-photo-grayscale: 0; + --st-badge-alpha: 0.4; } /* Sunset gradient theme: purple to orange */ @@ -153,11 +165,13 @@ body { --st-controls-rgb: 39 25 39; --st-controls-border: #3a253a; --st-fg: #f8fafc; + --st-fg-rgb: 248 250 252; --st-divider: #4a2e3a; --st-color-scheme: dark; --st-photo-tint-rgb: 96 32 48; /* warmer tint for images */ --st-photo-tint-alpha: 0.3; --st-photo-grayscale: 0; + --st-badge-alpha: 0.4; } /* Fallback classes hooking common UI areas to variables without refactors */ @@ -179,8 +193,11 @@ body { .bg-ui-controls { background: var(--st-controls); } +.bg-ui-badge { + background-color: rgba(0, 0, 0, var(--st-badge-alpha)); +} .text-base-fg { - color: var(--st-fg); + color: rgb(var(--st-fg-rgb) / 1); } .border-ui-divider { border-color: var(--st-divider); diff --git a/frontend/apps/editor3d/tailwind.config.ts b/frontend/apps/editor3d/tailwind.config.ts index d0e6a58748..01948f10e0 100644 --- a/frontend/apps/editor3d/tailwind.config.ts +++ b/frontend/apps/editor3d/tailwind.config.ts @@ -212,6 +212,7 @@ export default { "controls-button": "rgb(var(--st-controls-rgb) / )", divider: "var(--st-divider)", }, + "base-fg": "rgb(var(--st-fg-rgb) / )", media: { "audio-tts": "#6AAA88", "audio-v2v": "#6691B9", diff --git a/frontend/libs/components/model-selector/src/lib/classy-model-selector.tsx b/frontend/libs/components/model-selector/src/lib/classy-model-selector.tsx index e4f6425989..2d4c23a986 100644 --- a/frontend/libs/components/model-selector/src/lib/classy-model-selector.tsx +++ b/frontend/libs/components/model-selector/src/lib/classy-model-selector.tsx @@ -1,7 +1,16 @@ import { PopoverMenu, type PopoverItem } from "@storyteller/ui-popover"; -import { useClassyModelSelectorStore } from "./classy-model-selector-store"; +import { + useClassyModelSelectorStore, + useSelectedProviderForModel, +} from "./classy-model-selector-store"; import { useEffect, useMemo } from "react"; import { ModelPage } from "./model-pages"; +import { Provider } from "@storyteller/tauri-api"; +import { getProviderDisplayName, getProviderIcon } from "./provider-icons"; +import { Model } from "@storyteller/model-list"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faCircleCheck, faChevronUp } from "@fortawesome/pro-solid-svg-icons"; +import { PROVIDER_LOOKUP_BY_PAGE } from "./provider-lookup"; interface ClassyModelSelectorProps { items: Omit[]; @@ -12,15 +21,98 @@ interface ClassyModelSelectorProps { panelClassName?: string; showIconsInList?: boolean; triggerLabel?: string; + providersByModel?: Partial>; + providerTooltipDelayMs?: number; +} + +const DEFAULT_PROVIDER_OPTIONS: Provider[] = [ + Provider.ArtCraft, + Provider.Fal, + Provider.Sora, +]; + +function ProviderTooltipContent({ + page, + modelId, + model, + modelLabel, + allowedProviders, + onFinished, +}: { + page: ModelPage; + modelId?: string; + model?: Model; + modelLabel?: string; + allowedProviders: Provider[]; + onFinished?: () => void; +}) { + const { setSelectedModel, setSelectedProvider } = + useClassyModelSelectorStore(); + const selectedProvider = useSelectedProviderForModel(page, modelId); + + // Initialize provider for this model if missing + useEffect(() => { + if (!modelId) return; + if (!selectedProvider && allowedProviders.length > 0) { + setSelectedProvider(page, modelId, allowedProviders[0]); + } + }, [page, modelId, selectedProvider, allowedProviders, setSelectedProvider]); + + if (!modelId) return null as any; + + return ( +
+
+ Select Provider +
+
+ {allowedProviders.map((p) => ( + + ))} +
+
+ ); } export function ClassyModelSelector({ items, page, + providersByModel, + providerTooltipDelayMs = 300, ...popoverProps }: ClassyModelSelectorProps) { - const { selectedModels, setSelectedModel } = useClassyModelSelectorStore(); + const { selectedModels, setSelectedModel, setSelectedProvider } = + useClassyModelSelectorStore(); const selectedModel = selectedModels[page] || items[0]?.model; + const selectedProvider = useSelectedProviderForModel(page, selectedModel?.id); + const selectedProvidersByModel = useClassyModelSelectorStore( + (s) => s.selectedProviders[page] ?? {} + ); // For the first mount, make sure the selected model is set for other components to listen useEffect(() => { @@ -30,6 +122,23 @@ export function ClassyModelSelector({ } }, []); + // Initialize a default provider for each model so we can render icons even when not selected + useEffect(() => { + for (const item of items) { + const modelId = item.model?.id; + if (!modelId) continue; + if (selectedProvidersByModel[modelId]) continue; + const allowed = + providersByModel?.[modelId] ?? + PROVIDER_LOOKUP_BY_PAGE[page]?.[modelId] ?? + DEFAULT_PROVIDER_OPTIONS; + if (allowed.length > 0) { + setSelectedProvider(page, modelId, allowed[0]); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [items, providersByModel, page, selectedProvidersByModel]); + const handleModelSelect = (item: PopoverItem) => { console.log(`Model selector changed on page "${page}": `, item.model); setSelectedModel(page, item.model!); @@ -37,21 +146,108 @@ export function ClassyModelSelector({ const modelList = useMemo( () => - items.map((item) => ({ - ...item, - selected: item.model === selectedModel, - })), - [items, selectedModel] + items.map((item) => { + const modelId = item.model?.id; + const allowedProviders: Provider[] = modelId + ? providersByModel?.[modelId] ?? + PROVIDER_LOOKUP_BY_PAGE[page]?.[modelId] ?? + DEFAULT_PROVIDER_OPTIONS + : DEFAULT_PROVIDER_OPTIONS; + + return { + ...item, + selected: item.model === selectedModel, + hoverTooltip: (close: () => void) => ( + + ), + tooltipDelayMs: providerTooltipDelayMs, + trailing: + item.model !== selectedModel + ? (() => { + const prov = modelId + ? selectedProvidersByModel[modelId] + : undefined; + const iconProvider = prov ?? allowedProviders[0]; + return iconProvider ? ( +
+ + {getProviderIcon(iconProvider)} + +
+ ) : undefined; + })() + : undefined, + selectedRight: + item.model === selectedModel && selectedProvider ? ( +
+ + {getProviderIcon(selectedProvider)} + +
+ ) : undefined, + } as PopoverItem; + }), + [ + items, + selectedModel, + selectedProvider, + selectedProvidersByModel, + page, + providersByModel, + providerTooltipDelayMs, + ] ); return ( - +
+ Model + { + const modelTitle = selectedItem?.label ?? ""; + const providerIcon = selectedProvider + ? getProviderIcon(selectedProvider) + : null; + return ( +
+
+
+ + {modelTitle} + +
+
+ via + {providerIcon && ( + {providerIcon} + )} + + {selectedProvider + ? getProviderDisplayName(selectedProvider) + : ""} + +
+
+ +
+ ); + }} + /> +
); } diff --git a/frontend/libs/components/model-selector/src/lib/provider-selector.tsx b/frontend/libs/components/model-selector/src/lib/provider-selector.tsx index a5149e60aa..44f5e68827 100644 --- a/frontend/libs/components/model-selector/src/lib/provider-selector.tsx +++ b/frontend/libs/components/model-selector/src/lib/provider-selector.tsx @@ -1,3 +1,7 @@ +/////////////////////////////////////////// +// DEPRECATED: Use ClassyModelSelector only +/////////////////////////////////////////// + import { useEffect, useMemo } from "react"; import { PopoverMenu, type PopoverItem } from "@storyteller/ui-popover"; import { ModelPage } from "./model-pages"; diff --git a/frontend/libs/components/popover/src/lib/popover.tsx b/frontend/libs/components/popover/src/lib/popover.tsx index dede15d6f7..5ff0872129 100644 --- a/frontend/libs/components/popover/src/lib/popover.tsx +++ b/frontend/libs/components/popover/src/lib/popover.tsx @@ -1,4 +1,4 @@ -import { ReactNode, useRef, useEffect } from "react"; +import { ReactNode, useRef, useEffect, useState } from "react"; import { Popover, Transition, @@ -14,6 +14,7 @@ import { faCircleCheck, } from "@fortawesome/pro-solid-svg-icons"; import { Model, ModelInfo } from "@storyteller/model-list"; +import { Tooltip } from "@storyteller/ui-tooltip"; // Global hover manager to debounce close across adjacent hover popovers let globalCloseTimer: NodeJS.Timeout | null = null; @@ -35,6 +36,8 @@ const scheduleGlobalClose = (fn: () => void, delayMs: number) => { const ST_POPOVER_OPEN_EVENT = "st-popover-open"; let popoverIdCounter = 0; +type HoverTooltipContent = ReactNode | ((close: () => void) => ReactNode); + export interface PopoverItem { label: string; selected: boolean; @@ -49,6 +52,14 @@ export interface PopoverItem { }>; modelInfo?: ModelInfo; model?: Model; // NB: Let's migrate to using this. + // Optional trailing content rendered on the far right of each list row + // (e.g., a settings button or tooltip trigger) + trailing?: ReactNode; + // Optional tooltip content to show when hovering the entire row + hoverTooltip?: HoverTooltipContent; + tooltipDelayMs?: number; + // Optional custom right-side node shown when item is selected + selectedRight?: ReactNode; } interface PopoverMenuProps { @@ -71,6 +82,7 @@ interface PopoverMenuProps { panelActionLabel?: string; onOpenChange?: (open: boolean) => void; closeOnUnhover?: boolean; + renderTrigger?: (selected?: PopoverItem, open?: boolean) => ReactNode; } export const PopoverMenu = ({ @@ -93,8 +105,10 @@ export const PopoverMenu = ({ panelActionLabel, onOpenChange, closeOnUnhover = false, + renderTrigger, }: PopoverMenuProps) => { const selectedItem = items.find((item) => item.selected); + const [openTooltipIdx, setOpenTooltipIdx] = useState(null); const handleItemClick = (item: PopoverItem, close: () => void) => { if (mode === "button" && item.action && onPanelAction) { @@ -260,22 +274,31 @@ export const PopoverMenu = ({ }} ref={popoverButtonRef} > - {triggerIcon} - {mode === "toggle" && selectedItem ? ( - {selectedItem.label} - ) : null} - {mode === "default" && triggerLabel ? ( - {triggerLabel} - ) : null} - {mode === "hoverSelect" && selectedItem ? ( -
- {triggerLabel} -
+ {renderTrigger ? ( + renderTrigger(selectedItem, open) + ) : ( + <> + {triggerIcon} + {mode === "toggle" && selectedItem ? ( {selectedItem.label} - -
-
- ) : null} + ) : null} + {mode === "default" && triggerLabel ? ( + {triggerLabel} + ) : null} + {mode === "hoverSelect" && selectedItem ? ( +
+ {triggerLabel} +
+ {selectedItem.label} + +
+
+ ) : null} + + )} {items.map((item, index) => (
-
{ - if (!item.disabled) { - handleItemClick(item, close); + {item.hoverTooltip ? ( + void + ) => ReactNode + )(close) + : item.hoverTooltip } - }} - className={twMerge( - "group flex cursor-pointer items-start gap-2 rounded-lg px-2 py-2 transition-all", - item.selected - ? "bg-ui-controls/70 border-l-4 border-primary" - : "hover:bg-ui-controls/50", - item.disabled - ? "!cursor-not-allowed opacity-50" - : "" - )} - style={{ minHeight: 48 }} - > -
-
- {showIconsInList && ( - - {item.icon} - + position="right" + delay={item.tooltipDelayMs ?? 300} + interactive + className="!pointer-events-auto z-50 min-w-48 rounded-lg bg-ui-panel p-1.5 shadow-lg border border-ui-panel-border" + onOpenChange={(open) => + setOpenTooltipIdx((prev) => + open ? index : prev === index ? null : prev + ) + } + > +
{ + if (!item.disabled) { + handleItemClick(item, close); + } + }} + className={twMerge( + "group flex cursor-pointer items-start gap-2 rounded-lg px-2 py-2 transition-all", + item.selected + ? "bg-ui-controls/70 border-l-4 border-primary" + : "hover:bg-ui-controls/50", + !item.selected && openTooltipIdx === index + ? "bg-ui-controls/50" + : "", + item.disabled + ? "!cursor-not-allowed opacity-50" + : "" )} -
-
- - {item.label} - + style={{ minHeight: 48 }} + > +
+
+ {showIconsInList && ( + + {item.icon} + + )} +
+
+ + {item.label} + +
+ + {item.description && ( +
+ {item.description} +
+ )} + +
+ {item.badges && + Array.isArray(item.badges) && + item.badges.map((badge, i) => ( +
+ + {badge?.icon && ( + {badge.icon} + )} + {badge?.label || ""} + +
+ ))} +
+
- {item.description && ( -
- {item.description} + {/* Optional trailing content (e.g., tooltip trigger) */} + {item.trailing && ( +
+ {item.trailing}
)} -
- {item.badges && - Array.isArray(item.badges) && - item.badges.map((badge, i) => ( -
- - {badge?.icon && ( - {badge.icon} - )} - {badge?.label || ""} - -
- ))} -
+ {item.selected && + (item.selectedRight ?? ( + + + + ))}
- - {item.selected && ( - - - + + ) : ( +
{ + if (!item.disabled) { + handleItemClick(item, close); + } + }} + className={twMerge( + "group flex cursor-pointer items-start gap-2 rounded-lg px-2 py-2 transition-all", + item.selected + ? "bg-ui-controls/70 border-l-4 border-primary" + : "hover:bg-ui-controls/50", + item.disabled + ? "!cursor-not-allowed opacity-50" + : "" )} + style={{ minHeight: 48 }} + > +
+
+ {showIconsInList && ( + + {item.icon} + + )} +
+
+ + {item.label} + +
+ + {item.description && ( +
+ {item.description} +
+ )} + +
+ {item.badges && + Array.isArray(item.badges) && + item.badges.map((badge, i) => ( +
+ + {badge?.icon && ( + {badge.icon} + )} + {badge?.label || ""} + +
+ ))} +
+
+
+ + {/* Optional trailing content (e.g., tooltip trigger) */} + {item.trailing && ( +
+ {item.trailing} +
+ )} + + {item.selected && ( + + + + )} +
-
+ )} {item.divider && (
)} @@ -428,59 +559,164 @@ export const PopoverMenu = ({
{items.map((item, index) => (
- + + ) : ( +
- {mode === "toggle" && ( - - {item.selected && ( - - )} - - )} - + + )} {item.divider && (
)} diff --git a/frontend/libs/components/tooltip/src/lib/tooltip.tsx b/frontend/libs/components/tooltip/src/lib/tooltip.tsx index 039e8d9c10..abee50dc15 100644 --- a/frontend/libs/components/tooltip/src/lib/tooltip.tsx +++ b/frontend/libs/components/tooltip/src/lib/tooltip.tsx @@ -16,6 +16,7 @@ interface TooltipProps { * immediately when the cursor leaves the trigger. Useful for menus. */ interactive?: boolean; + onOpenChange?: (open: boolean) => void; } export const Tooltip = ({ @@ -28,6 +29,7 @@ export const Tooltip = ({ imageSrc, description, interactive = false, + onOpenChange, }: TooltipProps) => { const [isShowing, setIsShowing] = useState(false); const triggerRef = useRef(null); @@ -122,6 +124,10 @@ export const Tooltip = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [isHoveringTrigger, isHoveringTooltip, interactive]); + useEffect(() => { + onOpenChange?.(isShowing); + }, [isShowing, onOpenChange]); + return (