mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-02 16:38:14 +00:00
ui model selector update with provider selection (#890)
sub menu inside model selector <img width="646" height="705" alt="image" src="https://github.com/user-attachments/assets/f946c8ea-d48d-4d0f-8dd6-95772b0161c1" />
This commit is contained in:
1 parent
dc0e59b8ce
commit
a7826f8be3
11 files changed
+595
-180
No files matched your search
@@ -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"
|
||||
/>
|
||||
{/*<ProviderSelector
|
||||
page={PAGE_ID}
|
||||
model={selectedImageModel}
|
||||
providersByModel={PROVIDER_LOOKUP_BY_PAGE[PAGE_ID]}
|
||||
panelTitle="Select Provider"
|
||||
panelClassName="min-w-[220px]"
|
||||
buttonClassName="bg-transparent p-0 text-lg hover:bg-transparent text-white/80 hover:text-white"
|
||||
triggerLabel="Provider"
|
||||
/>*/}
|
||||
</div>
|
||||
<div className="absolute bottom-6 right-6 z-20 flex items-center gap-2">
|
||||
<TutorialModalButton />
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
{/*<ProviderSelector
|
||||
page={PAGE_ID}
|
||||
model={selectedImageModel}
|
||||
providersByModel={PROVIDER_LOOKUP_BY_PAGE[PAGE_ID]}
|
||||
panelTitle="Select Provider"
|
||||
panelClassName="min-w-[220px]"
|
||||
buttonClassName="bg-transparent p-0 text-lg hover:bg-transparent text-base-fg opacity-80 hover:opacity-100"
|
||||
triggerLabel="Provider"
|
||||
/>*/}
|
||||
</div>
|
||||
<div className="absolute bottom-6 right-6 z-20 flex items-center gap-2">
|
||||
<TutorialModalButton />
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
{/*<ProviderSelector
|
||||
page={PAGE_ID}
|
||||
model={selectedImageModel}
|
||||
providersByModel={PROVIDER_LOOKUP_BY_PAGE[PAGE_ID]}
|
||||
panelTitle="Select Provider"
|
||||
panelClassName="min-w-[220px]"
|
||||
buttonClassName="bg-transparent p-0 text-lg hover:bg-transparent text-white/80 hover:text-white"
|
||||
triggerLabel="Provider"
|
||||
/>*/}
|
||||
</div>
|
||||
<div className="absolute bottom-6 right-6 z-20 flex items-center gap-2">
|
||||
<TutorialModalButton />
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
{/*<ProviderSelector
|
||||
page={PAGE_ID}
|
||||
model={selectedImageModel}
|
||||
providersByModel={PROVIDER_LOOKUP_BY_PAGE[PAGE_ID]}
|
||||
panelTitle="Select Provider"
|
||||
panelClassName="min-w-[220px]"
|
||||
buttonClassName="bg-transparent p-0 text-lg hover:bg-transparent text-white/80 hover:text-white"
|
||||
triggerLabel="Provider"
|
||||
/>*/}
|
||||
</div>
|
||||
<div className="absolute bottom-6 right-6 z-20 flex items-center gap-2">
|
||||
<TutorialModalButton />
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
{/*<ProviderSelector
|
||||
page={PAGE_ID}
|
||||
model={selectedVideoModel}
|
||||
providersByModel={PROVIDER_LOOKUP_BY_PAGE[PAGE_ID]}
|
||||
panelTitle="Select Provider"
|
||||
panelClassName="min-w-[220px]"
|
||||
buttonClassName="bg-transparent p-0 text-lg hover:bg-transparent text-white/80 hover:text-white"
|
||||
triggerLabel="Provider"
|
||||
/>*/}
|
||||
</div>
|
||||
<div className="absolute bottom-6 right-6 z-20 flex items-center gap-2">
|
||||
<TutorialModalButton />
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -212,6 +212,7 @@ export default {
|
||||
"controls-button": "rgb(var(--st-controls-rgb) / <alpha-value>)",
|
||||
divider: "var(--st-divider)",
|
||||
},
|
||||
"base-fg": "rgb(var(--st-fg-rgb) / <alpha-value>)",
|
||||
media: {
|
||||
"audio-tts": "#6AAA88",
|
||||
"audio-v2v": "#6691B9",
|
||||
|
||||
@@ -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<PopoverItem, "selected">[];
|
||||
@@ -12,15 +21,98 @@ interface ClassyModelSelectorProps {
|
||||
panelClassName?: string;
|
||||
showIconsInList?: boolean;
|
||||
triggerLabel?: string;
|
||||
providersByModel?: Partial<Record<string, Provider[]>>;
|
||||
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 (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="mb-1 mt-0.5 px-1.5 text-sm font-normal text-base-fg opacity-70">
|
||||
Select Provider
|
||||
</div>
|
||||
<div className="flex flex-col gap-0">
|
||||
{allowedProviders.map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => {
|
||||
if (model) {
|
||||
setSelectedModel(page, model);
|
||||
}
|
||||
setSelectedProvider(page, modelId, p);
|
||||
onFinished?.();
|
||||
}}
|
||||
type="button"
|
||||
className={`group flex cursor-pointer items-center justify-between rounded-lg px-2 py-2 transition-all ${
|
||||
selectedProvider === p
|
||||
? "bg-ui-controls/70 border-l-4 border-primary"
|
||||
: "hover:bg-ui-controls/50"
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-2 text-sm text-base-fg">
|
||||
<span className="text-lg">{getProviderIcon(p)}</span>
|
||||
{getProviderDisplayName(p)}
|
||||
</span>
|
||||
{selectedProvider === p && (
|
||||
<span className="text-primary text-xl font-bold bg-white rounded-full p-0 h-4 w-4 flex items-center justify-center">
|
||||
<FontAwesomeIcon icon={faCircleCheck} />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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) => (
|
||||
<ProviderTooltipContent
|
||||
page={page}
|
||||
modelId={modelId}
|
||||
model={item.model as Model | undefined}
|
||||
modelLabel={item.label}
|
||||
allowedProviders={allowedProviders}
|
||||
onFinished={close}
|
||||
/>
|
||||
),
|
||||
tooltipDelayMs: providerTooltipDelayMs,
|
||||
trailing:
|
||||
item.model !== selectedModel
|
||||
? (() => {
|
||||
const prov = modelId
|
||||
? selectedProvidersByModel[modelId]
|
||||
: undefined;
|
||||
const iconProvider = prov ?? allowedProviders[0];
|
||||
return iconProvider ? (
|
||||
<div className="mr-1 rounded-md p-1.5 bg-ui-controls/60 group-hover:bg-ui-controls/80 transition-colors">
|
||||
<span className="text-base-fg/70 group-hover:text-base-fg/90 text-lg">
|
||||
{getProviderIcon(iconProvider)}
|
||||
</span>
|
||||
</div>
|
||||
) : undefined;
|
||||
})()
|
||||
: undefined,
|
||||
selectedRight:
|
||||
item.model === selectedModel && selectedProvider ? (
|
||||
<div className="mr-1 rounded-md p-1.5 bg-primary/60 group-hover:bg-primary/80 transition-colors">
|
||||
<span className="text-base-fg/70 group-hover:text-base-fg/90 text-lg">
|
||||
{getProviderIcon(selectedProvider)}
|
||||
</span>
|
||||
</div>
|
||||
) : undefined,
|
||||
} as PopoverItem;
|
||||
}),
|
||||
[
|
||||
items,
|
||||
selectedModel,
|
||||
selectedProvider,
|
||||
selectedProvidersByModel,
|
||||
page,
|
||||
providersByModel,
|
||||
providerTooltipDelayMs,
|
||||
]
|
||||
);
|
||||
|
||||
return (
|
||||
<PopoverMenu
|
||||
items={modelList}
|
||||
onSelect={handleModelSelect}
|
||||
mode="hoverSelect"
|
||||
{...popoverProps}
|
||||
buttonClassName="border-0 bg-transparent p-0 hover:bg-transparent text-lg hover:opacity-80 shadow-none"
|
||||
/>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-base-fg/90 text-base font-semibold">Model</span>
|
||||
<PopoverMenu
|
||||
items={modelList}
|
||||
onSelect={handleModelSelect}
|
||||
mode="hoverSelect"
|
||||
{...popoverProps}
|
||||
buttonClassName="rounded-xl bg-ui-controls/90 hover:bg-ui-controls text-left shadow-sm px-3 py-1 gap-3 border border-ui-controls-border"
|
||||
renderTrigger={(selectedItem) => {
|
||||
const modelTitle = selectedItem?.label ?? "";
|
||||
const providerIcon = selectedProvider
|
||||
? getProviderIcon(selectedProvider)
|
||||
: null;
|
||||
return (
|
||||
<div className="flex items-center justify-between w-full gap-3">
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="truncate text-base font-semibold text-base-fg">
|
||||
{modelTitle}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-base-fg/60 text-[13px] -mt-[1px]">
|
||||
<span>via</span>
|
||||
{providerIcon && (
|
||||
<span className="opacity-70">{providerIcon}</span>
|
||||
)}
|
||||
<span className="truncate">
|
||||
{selectedProvider
|
||||
? getProviderDisplayName(selectedProvider)
|
||||
: ""}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<FontAwesomeIcon
|
||||
icon={faChevronUp}
|
||||
className="text-base text-base-fg/70 self-center"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<number | null>(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 ? (
|
||||
<span className="truncate">{selectedItem.label}</span>
|
||||
) : null}
|
||||
{mode === "default" && triggerLabel ? (
|
||||
<span className="truncate">{triggerLabel}</span>
|
||||
) : null}
|
||||
{mode === "hoverSelect" && selectedItem ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="opacity-70">{triggerLabel}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{renderTrigger ? (
|
||||
renderTrigger(selectedItem, open)
|
||||
) : (
|
||||
<>
|
||||
{triggerIcon}
|
||||
{mode === "toggle" && selectedItem ? (
|
||||
<span className="truncate">{selectedItem.label}</span>
|
||||
<FontAwesomeIcon icon={faChevronUp} className="text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
) : null}
|
||||
{mode === "default" && triggerLabel ? (
|
||||
<span className="truncate">{triggerLabel}</span>
|
||||
) : null}
|
||||
{mode === "hoverSelect" && selectedItem ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="opacity-70">{triggerLabel}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate">{selectedItem.label}</span>
|
||||
<FontAwesomeIcon
|
||||
icon={faChevronUp}
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</PopoverButton>
|
||||
|
||||
<Transition
|
||||
@@ -339,70 +362,178 @@ export const PopoverMenu = ({
|
||||
<div className="flex flex-col gap-0 text-sm text-base-fg">
|
||||
{items.map((item, index) => (
|
||||
<div key={index}>
|
||||
<div
|
||||
onClick={() => {
|
||||
if (!item.disabled) {
|
||||
handleItemClick(item, close);
|
||||
{item.hoverTooltip ? (
|
||||
<Tooltip
|
||||
content={
|
||||
typeof item.hoverTooltip === "function"
|
||||
? (
|
||||
item.hoverTooltip as (
|
||||
close: () => 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 }}
|
||||
>
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<div className="flex items-start gap-2 grow">
|
||||
{showIconsInList && (
|
||||
<span className="mt-1 flex h-5 w-5 items-center justify-center text-lg text-base-fg/80">
|
||||
{item.icon}
|
||||
</span>
|
||||
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
|
||||
)
|
||||
}
|
||||
>
|
||||
<div
|
||||
onClick={() => {
|
||||
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"
|
||||
: ""
|
||||
)}
|
||||
<div className="flex flex-1 flex-col min-w-0">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="truncate font-semibold text-base-fg text-base">
|
||||
{item.label}
|
||||
</span>
|
||||
style={{ minHeight: 48 }}
|
||||
>
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<div className="flex items-start gap-2 grow">
|
||||
{showIconsInList && (
|
||||
<span className="mt-1 flex h-5 w-5 items-center justify-center text-lg text-base-fg/80">
|
||||
{item.icon}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex flex-1 flex-col min-w-0">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="truncate font-semibold text-base-fg text-base">
|
||||
{item.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{item.description && (
|
||||
<div className="truncate text-xs text-base-fg/60 mt-0.5">
|
||||
{item.description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-row gap-1 flex-wrap mt-1.5">
|
||||
{item.badges &&
|
||||
Array.isArray(item.badges) &&
|
||||
item.badges.map((badge, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-1 min-w-0"
|
||||
>
|
||||
<span className="inline-flex items-center rounded bg-ui-badge px-1.5 py-0.5 text-xs font-medium text-base-fg gap-1">
|
||||
{badge?.icon && (
|
||||
<span>{badge.icon}</span>
|
||||
)}
|
||||
{badge?.label || ""}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{item.description && (
|
||||
<div className="truncate text-xs text-base-fg/60 mt-0.5">
|
||||
{item.description}
|
||||
{/* Optional trailing content (e.g., tooltip trigger) */}
|
||||
{item.trailing && (
|
||||
<div className="ml-2 mr-1 flex items-center">
|
||||
{item.trailing}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-row gap-1 flex-wrap mt-1.5">
|
||||
{item.badges &&
|
||||
Array.isArray(item.badges) &&
|
||||
item.badges.map((badge, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-1 min-w-0"
|
||||
>
|
||||
<span className="inline-flex items-center rounded bg-black/40 px-1.5 py-0.5 text-xs font-medium text-base-fg gap-1">
|
||||
{badge?.icon && (
|
||||
<span>{badge.icon}</span>
|
||||
)}
|
||||
{badge?.label || ""}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{item.selected &&
|
||||
(item.selectedRight ?? (
|
||||
<span className="text-primary text-xl font-bold bg-white rounded-full p-0 h-4 w-4 flex items-center justify-center mr-1">
|
||||
<FontAwesomeIcon
|
||||
icon={faCircleCheck}
|
||||
/>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{item.selected && (
|
||||
<span className="text-primary text-xl font-bold bg-white rounded-full p-0 h-4 w-4 flex items-center justify-center mr-1">
|
||||
<FontAwesomeIcon icon={faCircleCheck} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<div
|
||||
onClick={() => {
|
||||
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 }}
|
||||
>
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<div className="flex items-start gap-2 grow">
|
||||
{showIconsInList && (
|
||||
<span className="mt-1 flex h-5 w-5 items-center justify-center text-lg text-base-fg/80">
|
||||
{item.icon}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex flex-1 flex-col min-w-0">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="truncate font-semibold text-base-fg text-base">
|
||||
{item.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{item.description && (
|
||||
<div className="truncate text-xs text-base-fg/60 mt-0.5">
|
||||
{item.description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-row gap-1 flex-wrap mt-1.5">
|
||||
{item.badges &&
|
||||
Array.isArray(item.badges) &&
|
||||
item.badges.map((badge, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-1 min-w-0"
|
||||
>
|
||||
<span className="inline-flex items-center rounded bg-ui-badge px-1.5 py-0.5 text-xs font-medium text-base-fg gap-1">
|
||||
{badge?.icon && (
|
||||
<span>{badge.icon}</span>
|
||||
)}
|
||||
{badge?.label || ""}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Optional trailing content (e.g., tooltip trigger) */}
|
||||
{item.trailing && (
|
||||
<div className="ml-2 mr-1 flex items-center">
|
||||
{item.trailing}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.selected && (
|
||||
<span className="text-primary text-xl font-bold bg-white rounded-full p-0 h-4 w-4 flex items-center justify-center mr-1">
|
||||
<FontAwesomeIcon icon={faCircleCheck} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{item.divider && (
|
||||
<div className="my-1 border-t border-white/10" />
|
||||
)}
|
||||
@@ -428,59 +559,164 @@ export const PopoverMenu = ({
|
||||
<div className="flex flex-col gap-0 text-sm text-base-fg">
|
||||
{items.map((item, index) => (
|
||||
<div key={index}>
|
||||
<Button
|
||||
className={twMerge(
|
||||
"flex w-full items-center shadow-none justify-between px-1.5",
|
||||
"bg-transparent hover:bg-ui-controls/60",
|
||||
mode === "toggle" && item.selected
|
||||
? "hover:bg-ui-controls/80"
|
||||
: "",
|
||||
item.disabled
|
||||
? "!cursor-not-allowed opacity-50"
|
||||
: "",
|
||||
"border-0"
|
||||
)}
|
||||
onClick={() =>
|
||||
!item.disabled && handleItemClick(item, close)
|
||||
}
|
||||
variant="secondary"
|
||||
disabled={item.disabled}
|
||||
>
|
||||
<div className="flex items-center gap-2 truncate">
|
||||
{showIconsInList && item.icon}
|
||||
{mode === "toggle" ? (
|
||||
{item.hoverTooltip ? (
|
||||
<Tooltip
|
||||
content={
|
||||
typeof item.hoverTooltip === "function"
|
||||
? (
|
||||
item.hoverTooltip as (
|
||||
close: () => void
|
||||
) => ReactNode
|
||||
)(close)
|
||||
: item.hoverTooltip
|
||||
}
|
||||
position="right"
|
||||
delay={item.tooltipDelayMs ?? 1000}
|
||||
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
|
||||
)
|
||||
}
|
||||
>
|
||||
<Button
|
||||
className={twMerge(
|
||||
"flex w-full items-center shadow-none justify-between px-1.5",
|
||||
"bg-transparent hover:bg-ui-controls/60",
|
||||
openTooltipIdx === index
|
||||
? "bg-ui-controls/60"
|
||||
: "",
|
||||
mode === "toggle" && item.selected
|
||||
? "hover:bg-ui-controls/80"
|
||||
: "",
|
||||
item.disabled
|
||||
? "!cursor-not-allowed opacity-50"
|
||||
: "",
|
||||
"border-0"
|
||||
)}
|
||||
onClick={() =>
|
||||
!item.disabled &&
|
||||
handleItemClick(item, close)
|
||||
}
|
||||
variant="secondary"
|
||||
disabled={item.disabled}
|
||||
>
|
||||
<div className="flex items-center gap-2 truncate">
|
||||
{showIconsInList && item.icon}
|
||||
{mode === "toggle" ? (
|
||||
<span
|
||||
className={twMerge(
|
||||
"truncate",
|
||||
item.selected
|
||||
? "text-base-fg"
|
||||
: "text-base-fg/70"
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
) : (
|
||||
<span className="truncate">
|
||||
{item.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Optional trailing content on the right */}
|
||||
{item.trailing && (
|
||||
<div className="ml-2 flex items-center">
|
||||
{item.trailing}
|
||||
</div>
|
||||
)}
|
||||
{/* Optional trailing content on the right */}
|
||||
{item.trailing && (
|
||||
<div className="ml-2 flex items-center">
|
||||
{item.trailing}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === "toggle" && (
|
||||
<span
|
||||
className={twMerge(
|
||||
"ml-2 h-5 w-5 rounded-full border flex items-center justify-center transition-colors",
|
||||
item.selected
|
||||
? "border-primary bg-primary"
|
||||
: "border-transparent bg-transparent"
|
||||
)}
|
||||
>
|
||||
{item.selected && (
|
||||
<FontAwesomeIcon
|
||||
icon={faCheck}
|
||||
className="text-base-fg text-xs font-bold"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Button
|
||||
className={twMerge(
|
||||
"flex w-full items-center shadow-none justify-between px-1.5",
|
||||
"bg-transparent hover:bg-ui-controls/60",
|
||||
mode === "toggle" && item.selected
|
||||
? "hover:bg-ui-controls/80"
|
||||
: "",
|
||||
item.disabled
|
||||
? "!cursor-not-allowed opacity-50"
|
||||
: "",
|
||||
"border-0"
|
||||
)}
|
||||
onClick={() =>
|
||||
!item.disabled && handleItemClick(item, close)
|
||||
}
|
||||
variant="secondary"
|
||||
disabled={item.disabled}
|
||||
>
|
||||
<div className="flex items-center gap-2 truncate">
|
||||
{showIconsInList && item.icon}
|
||||
{mode === "toggle" ? (
|
||||
<span
|
||||
className={twMerge(
|
||||
"truncate",
|
||||
item.selected
|
||||
? "text-base-fg"
|
||||
: "text-base-fg/70"
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
) : (
|
||||
<span className="truncate">
|
||||
{item.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Optional trailing content on the right */}
|
||||
{item.trailing && (
|
||||
<div className="ml-2 flex items-center">
|
||||
{item.trailing}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === "toggle" && (
|
||||
<span
|
||||
className={twMerge(
|
||||
"truncate",
|
||||
"ml-2 h-5 w-5 rounded-full border flex items-center justify-center transition-colors",
|
||||
item.selected
|
||||
? "text-base-fg"
|
||||
: "text-base-fg/70"
|
||||
? "border-primary bg-primary"
|
||||
: "border-transparent bg-transparent"
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
{item.selected && (
|
||||
<FontAwesomeIcon
|
||||
icon={faCheck}
|
||||
className="text-base-fg text-xs font-bold"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="truncate">{item.label}</span>
|
||||
)}
|
||||
</div>
|
||||
{mode === "toggle" && (
|
||||
<span
|
||||
className={twMerge(
|
||||
"ml-2 h-5 w-5 rounded-full border flex items-center justify-center transition-colors",
|
||||
item.selected
|
||||
? "border-primary bg-primary"
|
||||
: "border-transparent bg-transparent"
|
||||
)}
|
||||
>
|
||||
{item.selected && (
|
||||
<FontAwesomeIcon
|
||||
icon={faCheck}
|
||||
className="text-base-fg text-xs font-bold"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</Button>
|
||||
)}
|
||||
{item.divider && (
|
||||
<div className="my-1 border-t border-white/10" />
|
||||
)}
|
||||
|
||||
@@ -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<HTMLDivElement>(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 (
|
||||
<div
|
||||
ref={triggerRef}
|
||||
|
||||
Reference in new issue
Block a user