mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-09 03:21:23 +00:00
first class provider selector next to model selector
This commit is contained in:
1 parent
2d44b92bfc
commit
eb0db3d67d
15 files changed
+350
-75
No files matched your search
@@ -16,9 +16,11 @@ import {
|
||||
CANVAS_2D_PAGE_MODEL_LIST,
|
||||
ClassyModelSelector,
|
||||
ModelPage,
|
||||
ProviderSelector,
|
||||
PROVIDER_LOOKUP_BY_PAGE,
|
||||
useSelectedImageModel,
|
||||
} from "@storyteller/ui-model-selector";
|
||||
import { useCanvasBgRemovedEvent } from "@storyteller/tauri-api";
|
||||
import { getSelectedImageModel } from "@storyteller/ui-model-selector";
|
||||
import { TutorialModalButton } from "@storyteller/ui-tutorial-modal";
|
||||
|
||||
const PAGE_ID: ModelPage = ModelPage.Canvas2D;
|
||||
@@ -57,7 +59,7 @@ const PageDraw = () => {
|
||||
const store = useSceneStore();
|
||||
|
||||
const selectedImageModel: ImageModel | undefined =
|
||||
getSelectedImageModel(PAGE_ID);
|
||||
useSelectedImageModel(PAGE_ID);
|
||||
|
||||
useDeleteHotkeys({ onDelete: store.deleteSelectedItems });
|
||||
useUndoRedoHotkeys({ undo: store.undo, redo: store.redo });
|
||||
@@ -441,7 +443,7 @@ const PageDraw = () => {
|
||||
/>
|
||||
</ContextMenuContainer>
|
||||
</div>
|
||||
<div className="absolute bottom-6 left-6 z-20 flex items-center gap-2">
|
||||
<div className="absolute bottom-6 left-6 z-20 flex items-center gap-5">
|
||||
<ClassyModelSelector
|
||||
items={CANVAS_2D_PAGE_MODEL_LIST}
|
||||
page={PAGE_ID}
|
||||
@@ -451,6 +453,15 @@ const PageDraw = () => {
|
||||
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 />
|
||||
|
||||
@@ -13,9 +13,11 @@ import { BaseImageSelector, BaseSelectorImage } from "./BaseImageSelector";
|
||||
import DrawToolControlBar from "./DrawToolControlBar";
|
||||
import {
|
||||
ClassyModelSelector,
|
||||
getSelectedImageModel,
|
||||
useSelectedImageModel,
|
||||
IMAGE_EDITOR_PAGE_MODEL_LIST,
|
||||
ModelPage,
|
||||
ProviderSelector,
|
||||
PROVIDER_LOOKUP_BY_PAGE,
|
||||
} from "@storyteller/ui-model-selector";
|
||||
import { ImageModel } from "@storyteller/model-list";
|
||||
import { HistoryStack, ImageBundle } from "./HistoryStack";
|
||||
@@ -27,7 +29,7 @@ const PageEdit = () => {
|
||||
//useStateSceneLoader();
|
||||
|
||||
const selectedImageModel: ImageModel | undefined =
|
||||
getSelectedImageModel(PAGE_ID);
|
||||
useSelectedImageModel(PAGE_ID);
|
||||
|
||||
// State for canvas dimensions
|
||||
const canvasWidth = useRef<number>(1024);
|
||||
@@ -463,7 +465,7 @@ const PageEdit = () => {
|
||||
/>
|
||||
</ContextMenuContainer>
|
||||
</div>
|
||||
<div className="absolute bottom-6 left-6 z-20 flex items-center gap-2">
|
||||
<div className="absolute bottom-6 left-6 z-20 flex items-center gap-5">
|
||||
<ClassyModelSelector
|
||||
items={IMAGE_EDITOR_PAGE_MODEL_LIST}
|
||||
page={PAGE_ID}
|
||||
@@ -473,6 +475,15 @@ const PageEdit = () => {
|
||||
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 />
|
||||
|
||||
@@ -69,8 +69,10 @@ import {
|
||||
import {
|
||||
STAGE_3D_PAGE_MODEL_LIST,
|
||||
ModelPage,
|
||||
getSelectedImageModel,
|
||||
useSelectedImageModel,
|
||||
ClassyModelSelector,
|
||||
ProviderSelector,
|
||||
PROVIDER_LOOKUP_BY_PAGE,
|
||||
} from "@storyteller/ui-model-selector";
|
||||
import { LoginModal, useLoginModalStore } from "@storyteller/ui-login-modal";
|
||||
import PageDraw from "../PageDraw/PageDraw";
|
||||
@@ -106,7 +108,7 @@ export const PageEditor = () => {
|
||||
}, []);
|
||||
|
||||
const selectedImageModel: ImageModel | undefined =
|
||||
getSelectedImageModel(PAGE_ID);
|
||||
useSelectedImageModel(PAGE_ID);
|
||||
|
||||
const height =
|
||||
dndTimelineHeight.value > -1
|
||||
@@ -541,7 +543,7 @@ export const PageEditor = () => {
|
||||
message={editorLoader.value.message}
|
||||
/>
|
||||
|
||||
<div className="absolute bottom-6 left-6 z-20 flex items-center gap-2">
|
||||
<div className="absolute bottom-6 left-6 z-20 flex items-center gap-3">
|
||||
<ClassyModelSelector
|
||||
items={STAGE_3D_PAGE_MODEL_LIST}
|
||||
page={PAGE_ID}
|
||||
@@ -551,6 +553,15 @@ export const PageEditor = () => {
|
||||
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 />
|
||||
|
||||
@@ -7,7 +7,9 @@ import {
|
||||
TEXT_TO_IMAGE_PAGE_MODEL_LIST,
|
||||
ModelPage,
|
||||
ClassyModelSelector,
|
||||
getSelectedImageModel,
|
||||
useSelectedImageModel,
|
||||
ProviderSelector,
|
||||
PROVIDER_LOOKUP_BY_PAGE,
|
||||
} from "@storyteller/ui-model-selector";
|
||||
import { ImageModel } from "@storyteller/model-list";
|
||||
interface TextToImageProps {
|
||||
@@ -38,7 +40,7 @@ const TextToImage = ({ imageMediaId, imageUrl }: TextToImageProps) => {
|
||||
const [promptHeight, setPromptHeight] = useState<number>(138);
|
||||
|
||||
const selectedImageModel: ImageModel | undefined =
|
||||
getSelectedImageModel(PAGE_ID);
|
||||
useSelectedImageModel(PAGE_ID);
|
||||
|
||||
const jobContext: JobContextType = {
|
||||
jobTokens: [],
|
||||
@@ -213,7 +215,7 @@ const TextToImage = ({ imageMediaId, imageUrl }: TextToImageProps) => {
|
||||
|
||||
{!showPromptAtBottom && <BackgroundGallery />}
|
||||
|
||||
<div className="absolute bottom-6 left-6 z-20 flex items-center gap-2">
|
||||
<div className="absolute bottom-6 left-6 z-20 flex items-center gap-5">
|
||||
<ClassyModelSelector
|
||||
items={TEXT_TO_IMAGE_PAGE_MODEL_LIST}
|
||||
page={PAGE_ID}
|
||||
@@ -224,6 +226,15 @@ const TextToImage = ({ imageMediaId, imageUrl }: TextToImageProps) => {
|
||||
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 />
|
||||
|
||||
@@ -6,7 +6,9 @@ import {
|
||||
ClassyModelSelector,
|
||||
IMAGE_TO_VIDEO_PAGE_MODEL_LIST,
|
||||
ModelPage,
|
||||
getSelectedVideoModel,
|
||||
useSelectedVideoModel,
|
||||
ProviderSelector,
|
||||
PROVIDER_LOOKUP_BY_PAGE,
|
||||
} from "@storyteller/ui-model-selector";
|
||||
import { VideoModel } from "@storyteller/model-list";
|
||||
import { animated, useSpring } from "@react-spring/web";
|
||||
@@ -38,7 +40,7 @@ const ImageToVideo = ({ imageMediaId, imageUrl }: ImageToVideoProps) => {
|
||||
const [promptHeight, setPromptHeight] = useState<number>(138);
|
||||
|
||||
const selectedVideoModel: VideoModel | undefined =
|
||||
getSelectedVideoModel(PAGE_ID);
|
||||
useSelectedVideoModel(PAGE_ID);
|
||||
|
||||
const jobContext: JobContextType = {
|
||||
jobTokens: [],
|
||||
@@ -201,7 +203,7 @@ const ImageToVideo = ({ imageMediaId, imageUrl }: ImageToVideoProps) => {
|
||||
<BackgroundGallery />
|
||||
{/* } */}
|
||||
|
||||
<div className="absolute bottom-6 left-6 z-20 flex items-center gap-2">
|
||||
<div className="absolute bottom-6 left-6 z-20 flex items-center gap-5">
|
||||
<ClassyModelSelector
|
||||
items={IMAGE_TO_VIDEO_PAGE_MODEL_LIST}
|
||||
page={PAGE_ID}
|
||||
@@ -211,6 +213,15 @@ const ImageToVideo = ({ imageMediaId, imageUrl }: ImageToVideoProps) => {
|
||||
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 />
|
||||
|
||||
@@ -208,6 +208,14 @@ img {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Icon auto-contrast: invert only in dark themes */
|
||||
.icon-auto-contrast {
|
||||
filter: invert(1);
|
||||
}
|
||||
.theme-light .icon-auto-contrast {
|
||||
filter: none;
|
||||
}
|
||||
|
||||
/* Hide native scrollbars while preserving scrollability */
|
||||
.scrollbar-hidden {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
|
||||
@@ -2,4 +2,6 @@ export * from "./lib/model-selectors-for-pages";
|
||||
export * from "./lib/model-pages";
|
||||
export * from "./lib/classy-model-selector";
|
||||
export * from "./lib/classy-model-selector-store";
|
||||
|
||||
export * from "./lib/provider-selector";
|
||||
export * from "./lib/provider-icons";
|
||||
export * from "./lib/provider-lookup";
|
||||
@@ -1,25 +1,47 @@
|
||||
import { create } from "zustand";
|
||||
import { ModelPage } from "./model-pages";
|
||||
import { ImageModel, Model, VideoModel } from "@storyteller/model-list";
|
||||
import { Provider } from "@storyteller/tauri-api";
|
||||
|
||||
interface ClassyModelSelectorState {
|
||||
selectedModels: { [page in ModelPage]?: Model };
|
||||
selectedProviders: { [page in ModelPage]?: { [modelId: string]: Provider } };
|
||||
setSelectedModel: (page: ModelPage, model: Model) => void;
|
||||
setSelectedProvider: (
|
||||
page: ModelPage,
|
||||
modelId: string,
|
||||
provider: Provider
|
||||
) => void;
|
||||
}
|
||||
|
||||
export const useClassyModelSelectorStore = create<ClassyModelSelectorState>((set) => ({
|
||||
selectedModels: {},
|
||||
setSelectedModel: (page, model) =>
|
||||
set((state) => ({
|
||||
selectedModels: {
|
||||
...state.selectedModels,
|
||||
[page]: model,
|
||||
},
|
||||
})),
|
||||
}));
|
||||
export const useClassyModelSelectorStore = create<ClassyModelSelectorState>(
|
||||
(set) => ({
|
||||
selectedModels: {},
|
||||
selectedProviders: {},
|
||||
setSelectedModel: (page, model) =>
|
||||
set((state) => ({
|
||||
selectedModels: {
|
||||
...state.selectedModels,
|
||||
[page]: model,
|
||||
},
|
||||
})),
|
||||
setSelectedProvider: (page, modelId, provider) =>
|
||||
set((state) => ({
|
||||
selectedProviders: {
|
||||
...state.selectedProviders,
|
||||
[page]: {
|
||||
...(state.selectedProviders[page] ?? {}),
|
||||
[modelId]: provider,
|
||||
},
|
||||
},
|
||||
})),
|
||||
})
|
||||
);
|
||||
|
||||
export const getSelectedImageModel = (page: ModelPage) : ImageModel | undefined => {
|
||||
const { selectedModels } = useClassyModelSelectorStore();
|
||||
export const getSelectedImageModel = (
|
||||
page: ModelPage
|
||||
): ImageModel | undefined => {
|
||||
const { selectedModels } = useClassyModelSelectorStore.getState();
|
||||
const maybeModel = selectedModels[page];
|
||||
if (!maybeModel) {
|
||||
return undefined;
|
||||
@@ -32,8 +54,10 @@ export const getSelectedImageModel = (page: ModelPage) : ImageModel | undefined
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const getSelectedVideoModel = (page: ModelPage) : VideoModel | undefined => {
|
||||
const { selectedModels } = useClassyModelSelectorStore();
|
||||
export const getSelectedVideoModel = (
|
||||
page: ModelPage
|
||||
): VideoModel | undefined => {
|
||||
const { selectedModels } = useClassyModelSelectorStore.getState();
|
||||
const maybeModel = selectedModels[page];
|
||||
if (!maybeModel) {
|
||||
return undefined;
|
||||
@@ -45,3 +69,45 @@ export const getSelectedVideoModel = (page: ModelPage) : VideoModel | undefined
|
||||
}
|
||||
return maybeModel as VideoModel;
|
||||
};
|
||||
|
||||
export const getSelectedProviderForModel = (
|
||||
page: ModelPage,
|
||||
modelId: string
|
||||
): Provider | undefined => {
|
||||
const { selectedProviders } = useClassyModelSelectorStore.getState();
|
||||
const byPage = selectedProviders[page];
|
||||
if (!byPage) return undefined;
|
||||
return byPage[modelId];
|
||||
};
|
||||
|
||||
// Reactive hooks for UI subscriptions
|
||||
export const useSelectedModel = (page: ModelPage): Model | undefined =>
|
||||
useClassyModelSelectorStore((s) => s.selectedModels[page]);
|
||||
|
||||
export const useSelectedImageModel = (
|
||||
page: ModelPage
|
||||
): ImageModel | undefined => {
|
||||
const maybeModel = useSelectedModel(page);
|
||||
if (!maybeModel) return undefined;
|
||||
return maybeModel.kind === "image_model"
|
||||
? (maybeModel as ImageModel)
|
||||
: undefined;
|
||||
};
|
||||
|
||||
export const useSelectedVideoModel = (
|
||||
page: ModelPage
|
||||
): VideoModel | undefined => {
|
||||
const maybeModel = useSelectedModel(page);
|
||||
if (!maybeModel) return undefined;
|
||||
return maybeModel.kind === "video_model"
|
||||
? (maybeModel as VideoModel)
|
||||
: undefined;
|
||||
};
|
||||
|
||||
export const useSelectedProviderForModel = (
|
||||
page: ModelPage,
|
||||
modelId: string | undefined
|
||||
): Provider | undefined =>
|
||||
useClassyModelSelectorStore((s) =>
|
||||
modelId ? s.selectedProviders[page]?.[modelId] : undefined
|
||||
);
|
||||
@@ -0,0 +1,37 @@
|
||||
import { getCreatorIcon, ModelCreator } from "@storyteller/model-list";
|
||||
import { Provider } from "@storyteller/tauri-api";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
const PROVIDER_TO_CREATOR: Partial<Record<Provider, ModelCreator>> = {
|
||||
[Provider.ArtCraft]: ModelCreator.ArtCraft,
|
||||
[Provider.Fal]: ModelCreator.Fal,
|
||||
[Provider.Sora]: ModelCreator.OpenAi,
|
||||
};
|
||||
|
||||
export const getProviderIcon = (
|
||||
provider: Provider,
|
||||
className = "h-4 w-4 icon-auto-contrast"
|
||||
): ReactNode => {
|
||||
const creator = PROVIDER_TO_CREATOR[provider];
|
||||
if (creator) return getCreatorIcon(creator, className);
|
||||
return (
|
||||
<img
|
||||
src="/resources/images/services/generic.svg"
|
||||
alt="generic logo"
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const getProviderDisplayName = (provider: Provider): string => {
|
||||
switch (provider) {
|
||||
case Provider.ArtCraft:
|
||||
return "ArtCraft";
|
||||
case Provider.Fal:
|
||||
return "Fal";
|
||||
case Provider.Sora:
|
||||
return "Sora / ChatGPT";
|
||||
default:
|
||||
return provider;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import { ModelPage } from "./model-pages";
|
||||
import { Provider } from "@storyteller/tauri-api";
|
||||
|
||||
export type ProvidersByModel = Partial<Record<string, Provider[]>>;
|
||||
|
||||
// Centralized lookup: page (product/tab) → modelId → allowed providers
|
||||
export const PROVIDER_LOOKUP_BY_PAGE: Record<ModelPage, ProvidersByModel> = {
|
||||
[ModelPage.TextToImage]: {
|
||||
midjourney: [Provider.ArtCraft],
|
||||
flux_pro_1_1: [Provider.ArtCraft, Provider.Fal],
|
||||
gpt_image_1: [Provider.Sora],
|
||||
},
|
||||
[ModelPage.ImageToVideo]: {},
|
||||
[ModelPage.Canvas2D]: {
|
||||
gpt_image_1: [Provider.Sora],
|
||||
flux_pro_1_1: [Provider.ArtCraft, Provider.Fal],
|
||||
midjourney: [Provider.ArtCraft],
|
||||
},
|
||||
[ModelPage.Stage3D]: {},
|
||||
[ModelPage.ImageEditor]: {
|
||||
gpt_image_1: [Provider.Sora],
|
||||
flux_pro_1_1: [Provider.ArtCraft, Provider.Fal],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { PopoverMenu, type PopoverItem } from "@storyteller/ui-popover";
|
||||
import { ModelPage } from "./model-pages";
|
||||
import { Model } from "@storyteller/model-list";
|
||||
import { Provider } from "@storyteller/tauri-api";
|
||||
import { getProviderDisplayName, getProviderIcon } from "./provider-icons";
|
||||
import {
|
||||
useClassyModelSelectorStore,
|
||||
useSelectedProviderForModel,
|
||||
} from "./classy-model-selector-store";
|
||||
|
||||
interface ProviderSelectorProps {
|
||||
page: ModelPage;
|
||||
model: Model | undefined;
|
||||
providersByModel?: Partial<Record<string, Provider[]>>;
|
||||
panelTitle?: string;
|
||||
buttonClassName?: string;
|
||||
panelClassName?: string;
|
||||
triggerLabel?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_PROVIDER_OPTIONS: Provider[] = [
|
||||
Provider.ArtCraft,
|
||||
Provider.Fal,
|
||||
Provider.Sora,
|
||||
];
|
||||
|
||||
export function ProviderSelector({
|
||||
page,
|
||||
model,
|
||||
providersByModel,
|
||||
...popoverProps
|
||||
}: ProviderSelectorProps) {
|
||||
const { setSelectedProvider } = useClassyModelSelectorStore();
|
||||
|
||||
const modelId = model?.id;
|
||||
const allowedProviders: Provider[] = useMemo(() => {
|
||||
if (!modelId) return DEFAULT_PROVIDER_OPTIONS;
|
||||
return providersByModel?.[modelId] ?? DEFAULT_PROVIDER_OPTIONS;
|
||||
}, [providersByModel, modelId]);
|
||||
|
||||
const selectedProvider = useSelectedProviderForModel(page, modelId);
|
||||
|
||||
useEffect(() => {
|
||||
if (!modelId) return;
|
||||
if (!selectedProvider && allowedProviders.length > 0) {
|
||||
setSelectedProvider(page, modelId, allowedProviders[0]);
|
||||
}
|
||||
}, [page, modelId, selectedProvider, allowedProviders, setSelectedProvider]);
|
||||
|
||||
const items: Omit<PopoverItem, "selected">[] = useMemo(
|
||||
() =>
|
||||
allowedProviders.map((p) => ({
|
||||
label: getProviderDisplayName(p),
|
||||
icon: getProviderIcon(p),
|
||||
model: undefined,
|
||||
provider: p,
|
||||
})),
|
||||
[allowedProviders]
|
||||
);
|
||||
|
||||
const list = useMemo(
|
||||
() =>
|
||||
items.map((item) => ({
|
||||
...item,
|
||||
selected: (item as any).provider === selectedProvider,
|
||||
})),
|
||||
[items, selectedProvider]
|
||||
);
|
||||
|
||||
const handleSelect = (item: PopoverItem) => {
|
||||
if (!modelId) return;
|
||||
const prov: Provider | undefined = (item as any).provider as
|
||||
| Provider
|
||||
| undefined;
|
||||
if (!prov) return;
|
||||
setSelectedProvider(page, modelId, prov);
|
||||
};
|
||||
|
||||
return (
|
||||
<PopoverMenu
|
||||
items={list}
|
||||
onSelect={handleSelect}
|
||||
mode="hoverSelect"
|
||||
showIconsInList
|
||||
{...popoverProps}
|
||||
buttonClassName="border-0 bg-transparent p-0 hover:bg-transparent text-lg hover:opacity-80 shadow-none"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default ProviderSelector;
|
||||
@@ -1,2 +1 @@
|
||||
|
||||
export * from './lib/popover';
|
||||
export * from "./lib/popover";
|
||||
@@ -106,77 +106,64 @@ export const PopoverMenu = ({
|
||||
end: "right-0",
|
||||
};
|
||||
|
||||
// Track hover state and refs
|
||||
const [isHovering, setIsHovering] = useState(false);
|
||||
const hoverTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
// Hover timers and refs
|
||||
const openTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const closeTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const popoverButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
// Cleanup timeout on unmount
|
||||
// Cleanup timeouts on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (hoverTimeoutRef.current) {
|
||||
clearTimeout(hoverTimeoutRef.current);
|
||||
}
|
||||
if (openTimeoutRef.current) clearTimeout(openTimeoutRef.current);
|
||||
if (closeTimeoutRef.current) clearTimeout(closeTimeoutRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleButtonMouseEnter = (open: boolean, openFn: () => void) => {
|
||||
if (!(mode === "hoverSelect" || closeOnUnhover)) return;
|
||||
|
||||
setIsHovering(true);
|
||||
if (hoverTimeoutRef.current) {
|
||||
clearTimeout(hoverTimeoutRef.current);
|
||||
hoverTimeoutRef.current = null;
|
||||
if (closeTimeoutRef.current) {
|
||||
clearTimeout(closeTimeoutRef.current);
|
||||
closeTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
if (!open && mode === "hoverSelect") {
|
||||
hoverTimeoutRef.current = setTimeout(() => {
|
||||
if (openTimeoutRef.current) clearTimeout(openTimeoutRef.current);
|
||||
openTimeoutRef.current = setTimeout(() => {
|
||||
openFn();
|
||||
}, 100);
|
||||
}, 120);
|
||||
}
|
||||
};
|
||||
|
||||
const handleButtonMouseLeave = (closeFn: () => void) => {
|
||||
if (!(mode === "hoverSelect" || closeOnUnhover)) return;
|
||||
|
||||
setIsHovering(false);
|
||||
|
||||
if (hoverTimeoutRef.current) {
|
||||
clearTimeout(hoverTimeoutRef.current);
|
||||
if (openTimeoutRef.current) {
|
||||
clearTimeout(openTimeoutRef.current);
|
||||
openTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
hoverTimeoutRef.current = setTimeout(
|
||||
if (closeTimeoutRef.current) clearTimeout(closeTimeoutRef.current);
|
||||
closeTimeoutRef.current = setTimeout(
|
||||
() => {
|
||||
if (!isHovering) {
|
||||
closeFn();
|
||||
}
|
||||
closeFn();
|
||||
},
|
||||
mode === "hoverSelect" ? 300 : 150
|
||||
mode === "hoverSelect" ? 200 : 120
|
||||
);
|
||||
};
|
||||
|
||||
const handlePanelMouseEnter = () => {
|
||||
if (!(mode === "hoverSelect" || closeOnUnhover)) return;
|
||||
setIsHovering(true);
|
||||
if (hoverTimeoutRef.current) {
|
||||
clearTimeout(hoverTimeoutRef.current);
|
||||
hoverTimeoutRef.current = null;
|
||||
if (closeTimeoutRef.current) {
|
||||
clearTimeout(closeTimeoutRef.current);
|
||||
closeTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handlePanelMouseLeave = (closeFn: () => void) => {
|
||||
if (!(mode === "hoverSelect" || closeOnUnhover)) return;
|
||||
setIsHovering(false);
|
||||
|
||||
if (hoverTimeoutRef.current) {
|
||||
clearTimeout(hoverTimeoutRef.current);
|
||||
}
|
||||
|
||||
hoverTimeoutRef.current = setTimeout(
|
||||
if (closeTimeoutRef.current) clearTimeout(closeTimeoutRef.current);
|
||||
closeTimeoutRef.current = setTimeout(
|
||||
() => {
|
||||
closeFn();
|
||||
},
|
||||
mode === "hoverSelect" ? 300 : 150
|
||||
mode === "hoverSelect" ? 200 : 120
|
||||
);
|
||||
};
|
||||
|
||||
@@ -297,8 +284,8 @@ export const PopoverMenu = ({
|
||||
className={twMerge(
|
||||
"group flex cursor-pointer items-start gap-2 rounded-lg px-2 py-2 transition-all",
|
||||
item.selected
|
||||
? "bg-black/40 border-l-4 border-primary"
|
||||
: "hover:bg-black/20",
|
||||
? "bg-ui-controls/70 border-l-4 border-primary"
|
||||
: "hover:bg-ui-controls/50",
|
||||
item.disabled
|
||||
? "!cursor-not-allowed opacity-50"
|
||||
: ""
|
||||
|
||||
@@ -14,7 +14,7 @@ export class Model {
|
||||
// A unique frontend-only string for the model
|
||||
readonly id: string;
|
||||
|
||||
// A unique identifier that Tauri uses for the model (this
|
||||
// A unique identifier that Tauri uses for the model (this
|
||||
// might differ from our backend or other systems)
|
||||
readonly tauriId: string;
|
||||
|
||||
@@ -40,6 +40,9 @@ export class Model {
|
||||
// A list of filterable "capabilities" that can be used to filter models.
|
||||
readonly tags: ModelTag[];
|
||||
|
||||
// Optional provider preferences; two-to-many via page and providers.
|
||||
readonly preferredProvidersByPage?: Partial<Record<string, string[]>>;
|
||||
|
||||
protected constructor(args: {
|
||||
id: string;
|
||||
tauriId: string;
|
||||
@@ -50,6 +53,7 @@ export class Model {
|
||||
selectorDescription: string;
|
||||
selectorBadges: string[];
|
||||
tags?: ModelTag[];
|
||||
preferredProvidersByPage?: Partial<Record<string, string[]>>;
|
||||
}) {
|
||||
this.id = args.id;
|
||||
this.tauriId = args.tauriId;
|
||||
@@ -60,9 +64,10 @@ export class Model {
|
||||
this.selectorDescription = args.selectorDescription;
|
||||
this.selectorBadges = args.selectorBadges;
|
||||
this.tags = args.tags ?? [];
|
||||
this.preferredProvidersByPage = args.preferredProvidersByPage;
|
||||
}
|
||||
|
||||
toLegacyBadges() : { label: string }[] {
|
||||
toLegacyBadges(): { label: string }[] {
|
||||
return this.selectorBadges.map((b) => ({ label: b }));
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ export const getCreatorIconPath = (creator: ModelCreator): string | undefined =>
|
||||
|
||||
export const getCreatorIcon = (
|
||||
creator: ModelCreator,
|
||||
className = "h-4 w-4 invert"
|
||||
className = "h-4 w-4 icon-auto-contrast"
|
||||
): ReactNode | null => {
|
||||
const path = getCreatorIconPath(creator) ?? DEFAULT_ICON_PATH;
|
||||
return React.createElement("img", {
|
||||
|
||||
Reference in new issue
Block a user