301 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|