arena-learning / studyArena /components /settings /image-settings.tsx
Nitish kumar
Upload folder using huggingface_hub
c20f20c verified
'use client';
import { useState, useCallback, useMemo } from 'react';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import { useI18n } from '@/lib/hooks/use-i18n';
import { useSettingsStore } from '@/lib/store/settings';
import { IMAGE_PROVIDERS } from '@/lib/media/image-providers';
import {
Loader2,
CheckCircle2,
XCircle,
Eye,
EyeOff,
Zap,
Plus,
Settings2,
Trash2,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import type { ImageProviderId } from '@/lib/media/types';
interface ImageSettingsProps {
selectedProviderId: ImageProviderId;
}
export function ImageSettings({ selectedProviderId }: ImageSettingsProps) {
const { t } = useI18n();
const imageModelId = useSettingsStore((state) => state.imageModelId);
const imageProvidersConfig = useSettingsStore((state) => state.imageProvidersConfig);
const _setImageModelId = useSettingsStore((state) => state.setImageModelId);
const setImageProviderConfig = useSettingsStore((state) => state.setImageProviderConfig);
const [showApiKey, setShowApiKey] = useState(false);
const [testLoading, setTestLoading] = useState(false);
const [testStatus, setTestStatus] = useState<'idle' | 'success' | 'error'>('idle');
const [testMessage, setTestMessage] = useState('');
// Model dialog state
const [showModelDialog, setShowModelDialog] = useState(false);
const [editingModelIndex, setEditingModelIndex] = useState<number | null>(null);
const [modelForm, setModelForm] = useState({ id: '', name: '' });
// Reset test state when provider changes (derived state pattern)
const [prevSelectedProviderId, setPrevSelectedProviderId] = useState(selectedProviderId);
if (selectedProviderId !== prevSelectedProviderId) {
setPrevSelectedProviderId(selectedProviderId);
setTestStatus('idle');
setTestMessage('');
}
const currentConfig = imageProvidersConfig[selectedProviderId];
const currentProvider = IMAGE_PROVIDERS[selectedProviderId];
const builtInModels = currentProvider?.models || [];
const customModels = useMemo(
() => currentConfig?.customModels || [],
[currentConfig?.customModels],
);
const isServerConfigured = !!currentConfig?.isServerConfigured;
const handleApiKeyChange = (apiKey: string) => {
setImageProviderConfig(selectedProviderId, { apiKey });
};
const handleBaseUrlChange = (baseUrl: string) => {
setImageProviderConfig(selectedProviderId, { baseUrl });
};
const handleTest = async () => {
setTestLoading(true);
setTestStatus('idle');
setTestMessage('');
try {
const response = await fetch('/api/verify-image-provider', {
method: 'POST',
headers: {
'x-image-provider': selectedProviderId,
'x-image-model': imageModelId || '',
'x-api-key': currentConfig?.apiKey || '',
'x-base-url': currentConfig?.baseUrl || '',
},
});
const data = await response.json();
if (data.success) {
setTestStatus('success');
setTestMessage(t('settings.imageConnectivitySuccess'));
} else {
setTestStatus('error');
setTestMessage(`${t('settings.imageConnectivityFailed')}: ${data.message}`);
}
} catch (err) {
setTestStatus('error');
setTestMessage(`${t('settings.imageConnectivityFailed')}: ${err}`);
} finally {
setTestLoading(false);
}
};
// Model CRUD
const handleOpenAddModel = () => {
setEditingModelIndex(null);
setModelForm({ id: '', name: '' });
setShowModelDialog(true);
};
const handleOpenEditModel = (index: number) => {
setEditingModelIndex(index);
setModelForm({ ...customModels[index] });
setShowModelDialog(true);
};
const handleSaveModel = useCallback(() => {
if (!modelForm.id.trim()) return;
const newCustomModels = [...customModels];
if (editingModelIndex !== null) {
newCustomModels[editingModelIndex] = {
id: modelForm.id.trim(),
name: modelForm.name.trim() || modelForm.id.trim(),
};
} else {
newCustomModels.push({
id: modelForm.id.trim(),
name: modelForm.name.trim() || modelForm.id.trim(),
});
}
setImageProviderConfig(selectedProviderId, {
customModels: newCustomModels,
});
setShowModelDialog(false);
}, [modelForm, editingModelIndex, customModels, selectedProviderId, setImageProviderConfig]);
const handleDeleteModel = (index: number) => {
const newCustomModels = customModels.filter((_, i) => i !== index);
setImageProviderConfig(selectedProviderId, {
customModels: newCustomModels,
});
};
return (
<div className="space-y-6 max-w-3xl">
{/* Server-configured notice */}
{isServerConfigured && (
<div className="rounded-lg border border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-950/30 p-3 text-sm text-blue-700 dark:text-blue-300">
{t('settings.serverConfiguredNotice')}
</div>
)}
{/* API Key + Test inline */}
<div className="space-y-2">
<Label>API Key</Label>
<div className="flex gap-2">
<div className="relative flex-1">
<Input
name={`image-api-key-${selectedProviderId}`}
type={showApiKey ? 'text' : 'password'}
autoComplete="new-password"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
placeholder={
isServerConfigured ? t('settings.optionalOverride') : t('settings.enterApiKey')
}
value={currentConfig?.apiKey || ''}
onChange={(e) => handleApiKeyChange(e.target.value)}
className="h-8 pr-8"
/>
<button
type="button"
onClick={() => setShowApiKey(!showApiKey)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
<Button
variant="outline"
size="sm"
onClick={handleTest}
disabled={testLoading || (!currentConfig?.apiKey && !isServerConfigured)}
className="gap-1.5"
>
{testLoading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<>
<Zap className="h-3.5 w-3.5" />
{t('settings.testConnection')}
</>
)}
</Button>
</div>
{testMessage && (
<div
className={cn(
'rounded-lg p-3 text-sm overflow-hidden',
testStatus === 'success' &&
'bg-green-50 text-green-700 border border-green-200 dark:bg-green-950/50 dark:text-green-400 dark:border-green-800',
testStatus === 'error' &&
'bg-red-50 text-red-700 border border-red-200 dark:bg-red-950/50 dark:text-red-400 dark:border-red-800',
)}
>
<div className="flex items-start gap-2 min-w-0">
{testStatus === 'success' && <CheckCircle2 className="h-4 w-4 mt-0.5 shrink-0" />}
{testStatus === 'error' && <XCircle className="h-4 w-4 mt-0.5 shrink-0" />}
<p className="flex-1 min-w-0 break-all">{testMessage}</p>
</div>
</div>
)}
</div>
{/* Base URL */}
<div className="space-y-2">
<Label>Base URL</Label>
<Input
name={`image-base-url-${selectedProviderId}`}
type="url"
autoComplete="off"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
value={currentConfig?.baseUrl || ''}
onChange={(e) => handleBaseUrlChange(e.target.value)}
placeholder={
currentConfig?.serverBaseUrl ||
currentProvider?.defaultBaseUrl ||
t('settings.enterCustomBaseUrl')
}
className="h-8"
/>
{(() => {
const effectiveBaseUrl =
currentConfig?.baseUrl ||
currentConfig?.serverBaseUrl ||
currentProvider?.defaultBaseUrl ||
'';
if (!effectiveBaseUrl) return null;
return (
<p className="text-xs text-muted-foreground break-all">
{t('settings.requestUrl')}: {effectiveBaseUrl}
</p>
);
})()}
</div>
{/* Model list */}
<div className="space-y-3">
<div className="flex items-center justify-between flex-wrap gap-2">
<Label className="text-base">{t('settings.models')}</Label>
<Button variant="outline" size="sm" onClick={handleOpenAddModel} className="gap-1.5">
<Plus className="h-3.5 w-3.5" />
{t('settings.addNewModel')}
</Button>
</div>
<div className="space-y-1.5">
{/* Built-in models */}
{builtInModels.map((model) => (
<div
key={model.id}
className="flex items-center justify-between p-3 rounded-lg border border-border/50 bg-card"
>
<div className="flex-1 min-w-0">
<div className="font-mono text-sm font-medium">{model.name}</div>
<div className="text-xs text-muted-foreground font-mono mt-0.5">{model.id}</div>
</div>
</div>
))}
{/* Custom models */}
{customModels.map((model, index) => (
<div
key={`custom-${index}`}
className="flex items-center justify-between p-3 rounded-lg border border-border/50 bg-card"
>
<div className="flex-1 min-w-0">
<div className="font-mono text-sm font-medium">{model.name}</div>
<div className="text-xs text-muted-foreground font-mono mt-0.5">{model.id}</div>
</div>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
className="h-8 px-2"
onClick={() => handleOpenEditModel(index)}
title={t('settings.editModel')}
>
<Settings2 className="h-3.5 w-3.5" />
</Button>
<Button
variant="outline"
size="sm"
className="h-8 px-2 text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => handleDeleteModel(index)}
title={t('settings.deleteModel')}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
))}
</div>
</div>
{/* Add/Edit Model Dialog */}
<Dialog open={showModelDialog} onOpenChange={setShowModelDialog}>
<DialogContent className="sm:max-w-md">
<DialogTitle>
{editingModelIndex !== null ? t('settings.editModel') : t('settings.addNewModel')}
</DialogTitle>
<DialogDescription className="sr-only">
{editingModelIndex !== null ? t('settings.editModel') : t('settings.addNewModel')}
</DialogDescription>
<div className="space-y-4 pt-2">
<div className="space-y-2">
<Label>{t('settings.modelId')}</Label>
<Input
value={modelForm.id}
onChange={(e) => setModelForm((prev) => ({ ...prev, id: e.target.value }))}
placeholder="e.g. my-custom-model-v1"
className="h-8 font-mono text-sm"
/>
</div>
<div className="space-y-2">
<Label>{t('settings.modelName')}</Label>
<Input
value={modelForm.name}
onChange={(e) => setModelForm((prev) => ({ ...prev, name: e.target.value }))}
placeholder="e.g. My Custom Model"
className="h-8 text-sm"
/>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" size="sm" onClick={() => setShowModelDialog(false)}>
{t('common.cancel')}
</Button>
<Button size="sm" onClick={handleSaveModel} disabled={!modelForm.id.trim()}>
{t('common.save')}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}