Files
openmaic/OpenMAIC/components/generation/media-popover.tsx
2026-08-16 14:58:47 +08:00

301 lines
10 KiB
TypeScript

'use client';
import { useState } from 'react';
import type { LucideIcon } from 'lucide-react';
import {
Image as ImageIcon,
Video,
Volume2,
Mic,
Globe2,
SlidersHorizontal,
ChevronRight,
} from 'lucide-react';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Switch } from '@/components/ui/switch';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/hooks/use-i18n';
import { useSettingsStore } from '@/lib/store/settings';
import type { SettingsSection } from '@/lib/types/settings';
import { isSettingsManagementEnabled } from '@/lib/config/deployment-role';
import {
WEB_SEARCH_PROVIDERS,
getWebSearchProviderDisplayName,
isWebSearchProviderConfigured,
} from '@/lib/web-search/constants';
interface MediaPopoverProps {
onSettingsOpen?: (section: SettingsSection) => void;
webSearch: boolean;
onWebSearchChange: (enabled: boolean) => void;
capabilityOverrides?: Partial<Record<MediaCapabilityId, MediaCapabilityOverride>>;
}
export type MediaCapabilityId = 'image' | 'video' | 'tts' | 'asr';
export interface MediaCapabilityOverride {
enabled: boolean;
onToggle?: (enabled: boolean) => void;
disabled?: boolean;
}
type TabId = MediaCapabilityId | 'web-search';
const TABS: Array<{ id: TabId; icon: LucideIcon; labelKey: string }> = [
{ id: 'image', icon: ImageIcon, labelKey: 'media.imageShort' },
{ id: 'video', icon: Video, labelKey: 'media.videoShort' },
{ id: 'tts', icon: Volume2, labelKey: 'media.ttsShort' },
{ id: 'asr', icon: Mic, labelKey: 'media.asrShort' },
{ id: 'web-search', icon: Globe2, labelKey: 'media.webSearchShort' },
];
export function MediaPopover({
onSettingsOpen,
webSearch,
onWebSearchChange,
capabilityOverrides,
}: MediaPopoverProps) {
const { t } = useI18n();
const [open, setOpen] = useState(false);
const [activeTab, setActiveTab] = useState<TabId>('image');
const settingsManagementEnabled = isSettingsManagementEnabled();
// ─── Store ───
const imageGenerationEnabled = useSettingsStore((s) => s.imageGenerationEnabled);
const videoGenerationEnabled = useSettingsStore((s) => s.videoGenerationEnabled);
const ttsEnabled = useSettingsStore((s) => s.ttsEnabled);
const asrEnabled = useSettingsStore((s) => s.asrEnabled);
const setImageGenerationEnabled = useSettingsStore((s) => s.setImageGenerationEnabled);
const setVideoGenerationEnabled = useSettingsStore((s) => s.setVideoGenerationEnabled);
const setTTSEnabled = useSettingsStore((s) => s.setTTSEnabled);
const setASREnabled = useSettingsStore((s) => s.setASREnabled);
const webSearchProviderId = useSettingsStore((s) => s.webSearchProviderId);
const webSearchProvidersConfig = useSettingsStore((s) => s.webSearchProvidersConfig);
const webSearchProvider = WEB_SEARCH_PROVIDERS[webSearchProviderId];
const selectedWebSearchAvailable = webSearchProvider
? isWebSearchProviderConfigured(
webSearchProvider,
webSearchProvidersConfig[webSearchProviderId],
)
: false;
const webSearchAvailable = Object.values(WEB_SEARCH_PROVIDERS).some((provider) =>
isWebSearchProviderConfigured(provider, webSearchProvidersConfig[provider.id]),
);
const capabilities: Record<MediaCapabilityId, MediaCapabilityOverride> = {
image: capabilityOverrides?.image ?? {
enabled: imageGenerationEnabled,
onToggle: setImageGenerationEnabled,
},
video: capabilityOverrides?.video ?? {
enabled: videoGenerationEnabled,
onToggle: setVideoGenerationEnabled,
},
tts: capabilityOverrides?.tts ?? { enabled: ttsEnabled, onToggle: setTTSEnabled },
asr: capabilityOverrides?.asr ?? { enabled: asrEnabled, onToggle: setASREnabled },
};
const enabledMap: Record<TabId, boolean> = {
image: capabilities.image.enabled,
video: capabilities.video.enabled,
tts: capabilities.tts.enabled,
asr: capabilities.asr.enabled,
'web-search': webSearch,
};
const enabledCount = Object.values(enabledMap).filter(Boolean).length;
// Auto-select first enabled tab on open
const handleOpenChange = (isOpen: boolean) => {
setOpen(isOpen);
if (isOpen) {
const first = (['image', 'video', 'tts', 'asr', 'web-search'] as TabId[]).find(
(id) => enabledMap[id],
);
setActiveTab(first || 'image');
}
};
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<button
className={cn(
'inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium transition-all cursor-pointer select-none whitespace-nowrap border',
enabledCount > 0
? 'bg-violet-100 dark:bg-violet-900/30 text-violet-700 dark:text-violet-300 border-violet-200/60 dark:border-violet-700/50'
: 'text-muted-foreground/70 hover:text-foreground hover:bg-muted/60 border-border/50',
)}
>
<SlidersHorizontal className="size-3.5" />
{capabilities.image.enabled && <ImageIcon className="size-3.5" />}
{capabilities.video.enabled && <Video className="size-3.5" />}
{capabilities.tts.enabled && <Volume2 className="size-3.5" />}
{capabilities.asr.enabled && <Mic className="size-3.5" />}
{webSearch && <Globe2 className="size-3.5" />}
</button>
</PopoverTrigger>
<PopoverContent
align="start"
side="bottom"
avoidCollisions={false}
className="w-96 max-w-[calc(100vw-2rem)] p-0"
>
{/* ── Tab bar (segmented control) ── */}
<div className="p-2 pb-0">
<div className="flex gap-0.5 p-0.5 bg-muted/60 rounded-lg">
{TABS.map((tab) => {
const isActive = activeTab === tab.id;
const isEnabled = enabledMap[tab.id];
const Icon = tab.icon;
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={cn(
'min-w-0 flex-1 flex items-center justify-center gap-1.5 whitespace-nowrap py-1.5 rounded-md text-[11px] font-medium transition-all relative',
isActive
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground/80',
)}
>
<Icon className="size-3.5" />
<span className="hidden sm:inline">{t(tab.labelKey)}</span>
{isEnabled && !isActive && (
<span className="absolute top-1 right-1 size-1.5 rounded-full bg-violet-500" />
)}
</button>
);
})}
</div>
</div>
{/* ── Tab content ── */}
<div className="p-3 pt-2.5">
{activeTab === 'image' && (
<TabPanel
icon={ImageIcon}
label={t('media.imageCapability')}
enabled={capabilities.image.enabled}
onToggle={capabilities.image.onToggle}
disabled={capabilities.image.disabled}
/>
)}
{activeTab === 'video' && (
<TabPanel
icon={Video}
label={t('media.videoCapability')}
enabled={capabilities.video.enabled}
onToggle={capabilities.video.onToggle}
disabled={capabilities.video.disabled}
/>
)}
{activeTab === 'tts' && (
<TabPanel
icon={Volume2}
label={t('media.ttsCapability')}
enabled={capabilities.tts.enabled}
onToggle={capabilities.tts.onToggle}
disabled={capabilities.tts.disabled}
/>
)}
{activeTab === 'asr' && (
<TabPanel
icon={Mic}
label={t('media.asrCapability')}
enabled={capabilities.asr.enabled}
onToggle={capabilities.asr.onToggle}
disabled={capabilities.asr.disabled}
/>
)}
{activeTab === 'web-search' && (
<div className="space-y-2.5">
<TabPanel
icon={Globe2}
label={t('settings.webSearchSettings')}
enabled={webSearch}
onToggle={onWebSearchChange}
disabled={!webSearchAvailable || !selectedWebSearchAvailable}
/>
<p className="text-[10px] leading-relaxed text-muted-foreground/70">
{selectedWebSearchAvailable
? t('toolbar.webSearchDesc')
: t('toolbar.webSearchNoProvider')}
</p>
{selectedWebSearchAvailable && (
<p className="text-[10px] text-muted-foreground/55">
{t('toolbar.webSearchProvider')}:{' '}
{getWebSearchProviderDisplayName(webSearchProviderId, t)}
</p>
)}
</div>
)}
</div>
{/* ── Footer ── */}
{settingsManagementEnabled && onSettingsOpen && (
<div className="border-t border-border/40">
<button
onClick={() => {
setOpen(false);
onSettingsOpen?.(activeTab);
}}
className="w-full flex items-center justify-between px-3.5 py-2.5 text-[11px] text-muted-foreground/60 hover:text-muted-foreground transition-colors"
>
<span>{t('toolbar.advancedSettings')}</span>
<ChevronRight className="size-3" />
</button>
</div>
)}
</PopoverContent>
</Popover>
);
}
// ─── Tab panel: header (label + switch) ───
function TabPanel({
icon: Icon,
label,
enabled,
onToggle,
disabled,
}: {
icon: LucideIcon;
label: string;
enabled: boolean;
onToggle?: (v: boolean) => void;
disabled?: boolean;
}) {
return (
<div className="space-y-2.5">
<div className="flex items-center gap-2.5">
<Icon
className={cn(
'size-4 shrink-0 transition-colors',
enabled ? 'text-violet-600 dark:text-violet-400' : 'text-muted-foreground/50',
)}
/>
<span
className={cn(
'flex-1 text-sm font-medium transition-colors',
!enabled && 'text-muted-foreground',
)}
>
{label}
</span>
<Switch
checked={enabled}
onCheckedChange={onToggle}
disabled={disabled || !onToggle}
className="scale-[0.85] origin-right"
/>
</div>
</div>
);
}