feat: pannello Settings con 4 sezioni flessibili + provider custom

- Redesign Settings: Testi, Immagini, Video, Voiceover — sezioni separate
- Ogni sezione ha dropdown provider + API key + campo opzionale modello
- Opzione "Personalizzato" con campo Base URL libero per qualsiasi servizio
- LLM: aggiunto OpenRouter + provider custom OpenAI-compatible
- Backend: OpenAICompatibleProvider unifica OpenAI/OpenRouter/custom
- Router content: passa llm_base_url a get_llm_provider

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Michele
2026-03-31 18:34:24 +02:00
parent 94a9e4bb5e
commit 2c16407f96
3 changed files with 352 additions and 253 deletions

View File

@@ -63,7 +63,8 @@ def generate_content(request: GenerateContentRequest, db: Session = Depends(get_
}
# Create LLM provider and generate text
llm = get_llm_provider(provider_name, api_key, model)
base_url = _get_setting(db, "llm_base_url")
llm = get_llm_provider(provider_name, api_key, model, base_url=base_url)
text = generate_post_text(
character=char_dict,
llm_provider=llm,

View File

@@ -1,8 +1,8 @@
"""
Multi-LLM abstraction layer.
Supports Claude (Anthropic), OpenAI, and Gemini via direct HTTP calls using httpx.
Each provider implements the same interface for text generation.
Supports Claude (Anthropic), OpenAI, Gemini, OpenRouter, and any
OpenAI-compatible custom endpoint via direct HTTP calls using httpx.
"""
from abc import ABC, abstractmethod
@@ -14,6 +14,8 @@ DEFAULT_MODELS = {
"claude": "claude-sonnet-4-20250514",
"openai": "gpt-4o-mini",
"gemini": "gemini-2.0-flash",
"openrouter": "openai/gpt-4o-mini",
"custom": "",
}
TIMEOUT = 60.0
@@ -28,15 +30,7 @@ class LLMProvider(ABC):
@abstractmethod
def generate(self, prompt: str, system: str = "") -> str:
"""Generate text from a prompt.
Args:
prompt: The user prompt / message.
system: Optional system prompt for context and behavior.
Returns:
Generated text string.
"""
"""Generate text from a prompt."""
...
@@ -67,7 +61,6 @@ class ClaudeProvider(LLMProvider):
response = client.post(self.API_URL, headers=headers, json=payload)
response.raise_for_status()
data = response.json()
# Claude returns content as a list of content blocks
content_blocks = data.get("content", [])
return "".join(
block.get("text", "") for block in content_blocks if block.get("type") == "text"
@@ -80,15 +73,27 @@ class ClaudeProvider(LLMProvider):
raise RuntimeError(f"Claude API request failed: {e}") from e
class OpenAIProvider(LLMProvider):
"""OpenAI provider via Chat Completions API."""
class OpenAICompatibleProvider(LLMProvider):
"""OpenAI Chat Completions-compatible provider.
API_URL = "https://api.openai.com/v1/chat/completions"
Used for OpenAI, OpenRouter, and any custom OpenAI-compatible endpoint.
Set base_url to point to any compatible API.
"""
def __init__(self, api_key: str, model: str | None = None):
super().__init__(api_key, model or DEFAULT_MODELS["openai"])
DEFAULT_BASE_URL = "https://api.openai.com/v1"
def __init__(
self,
api_key: str,
model: str | None = None,
base_url: str | None = None,
default_model: str = "gpt-4o-mini",
):
super().__init__(api_key, model or default_model)
self.base_url = (base_url or self.DEFAULT_BASE_URL).rstrip("/")
def generate(self, prompt: str, system: str = "") -> str:
url = f"{self.base_url}/chat/completions"
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
@@ -106,16 +111,16 @@ class OpenAIProvider(LLMProvider):
try:
with httpx.Client(timeout=TIMEOUT) as client:
response = client.post(self.API_URL, headers=headers, json=payload)
response = client.post(url, headers=headers, json=payload)
response.raise_for_status()
data = response.json()
return data["choices"][0]["message"]["content"]
except httpx.HTTPStatusError as e:
raise RuntimeError(
f"OpenAI API error {e.response.status_code}: {e.response.text}"
f"API error {e.response.status_code}: {e.response.text}"
) from e
except httpx.RequestError as e:
raise RuntimeError(f"OpenAI API request failed: {e}") from e
raise RuntimeError(f"API request failed: {e}") from e
class GeminiProvider(LLMProvider):
@@ -131,7 +136,6 @@ class GeminiProvider(LLMProvider):
params = {"key": self.api_key}
headers = {"Content-Type": "application/json"}
# Build contents; Gemini uses a parts-based structure
parts: list[dict] = []
if system:
parts.append({"text": f"{system}\n\n{prompt}"})
@@ -140,9 +144,7 @@ class GeminiProvider(LLMProvider):
payload = {
"contents": [{"parts": parts}],
"generationConfig": {
"maxOutputTokens": 2048,
},
"generationConfig": {"maxOutputTokens": 2048},
}
try:
@@ -165,30 +167,57 @@ class GeminiProvider(LLMProvider):
def get_llm_provider(
provider_name: str, api_key: str, model: str | None = None
provider_name: str,
api_key: str,
model: str | None = None,
base_url: str | None = None,
) -> LLMProvider:
"""Factory function to get an LLM provider instance.
"""Factory: returns an LLMProvider instance.
Args:
provider_name: One of 'claude', 'openai', 'gemini'.
provider_name: 'claude', 'openai', 'gemini', 'openrouter', or 'custom'.
api_key: API key for the provider.
model: Optional model override. Uses default if not specified.
model: Optional model override.
base_url: Optional base URL override (used for 'openrouter' and 'custom').
Returns:
An LLMProvider instance.
Raises:
ValueError: If provider_name is not supported.
An LLMProvider instance ready to generate text.
"""
providers = {
"claude": ClaudeProvider,
"openai": OpenAIProvider,
"gemini": GeminiProvider,
}
provider_cls = providers.get(provider_name.lower())
if provider_cls is None:
supported = ", ".join(providers.keys())
raise ValueError(
f"Unknown LLM provider '{provider_name}'. Supported: {supported}"
name = provider_name.lower()
if name == "claude":
return ClaudeProvider(api_key=api_key, model=model)
if name == "openai":
return OpenAICompatibleProvider(
api_key=api_key,
model=model,
base_url="https://api.openai.com/v1",
default_model=DEFAULT_MODELS["openai"],
)
if name == "gemini":
return GeminiProvider(api_key=api_key, model=model)
if name == "openrouter":
return OpenAICompatibleProvider(
api_key=api_key,
model=model,
base_url=base_url or "https://openrouter.ai/api/v1",
default_model=DEFAULT_MODELS["openrouter"],
)
if name == "custom":
if not base_url:
raise ValueError("Provider 'custom' richiede un base_url configurato nelle impostazioni.")
return OpenAICompatibleProvider(
api_key=api_key,
model=model,
base_url=base_url,
default_model=model or "",
)
raise ValueError(
f"Provider LLM '{provider_name}' non supportato. "
f"Usa: claude, openai, gemini, openrouter, custom."
)
return provider_cls(api_key=api_key, model=model)

View File

@@ -1,31 +1,46 @@
import { useState, useEffect } from 'react'
import { api } from '../api'
const LLM_PROVIDERS = [
{ value: 'claude', label: 'Claude (Anthropic)' },
{ value: 'openai', label: 'OpenAI' },
{ value: 'gemini', label: 'Gemini (Google)' },
// ─── Provider catalogs ────────────────────────────────────────────────────────
const TEXT_PROVIDERS = [
{ value: 'claude', label: 'Claude (Anthropic)', defaultModel: 'claude-sonnet-4-20250514', needsBaseUrl: false },
{ value: 'openai', label: 'OpenAI', defaultModel: 'gpt-4o-mini', needsBaseUrl: false },
{ value: 'gemini', label: 'Gemini (Google)', defaultModel: 'gemini-2.0-flash', needsBaseUrl: false },
{ value: 'openrouter', label: 'OpenRouter', defaultModel: 'openai/gpt-4o-mini', needsBaseUrl: false },
{ value: 'custom', label: 'Personalizzato (custom)', defaultModel: '', needsBaseUrl: true },
]
const IMAGE_PROVIDERS = [
{ value: 'dalle', label: 'DALL-E (OpenAI)' },
{ value: 'replicate', label: 'Replicate' },
{ value: 'dalle', label: 'DALL-E (OpenAI)', needsBaseUrl: false },
{ value: 'replicate', label: 'Replicate', needsBaseUrl: false },
{ value: 'wavespeed', label: 'WaveSpeed', needsBaseUrl: false },
{ value: 'custom', label: 'Personalizzato (custom)', needsBaseUrl: true },
]
const LLM_DEFAULTS = {
claude: 'claude-sonnet-4-20250514',
openai: 'gpt-4o',
gemini: 'gemini-2.0-flash',
}
const VIDEO_PROVIDERS = [
{ value: 'wavespeed', label: 'WaveSpeed', needsBaseUrl: false },
{ value: 'replicate', label: 'Replicate', needsBaseUrl: false },
{ value: 'custom', label: 'Personalizzato (custom)', needsBaseUrl: true },
]
const cardStyle = {
const VOICE_PROVIDERS = [
{ value: 'elevenlabs', label: 'ElevenLabs', needsBaseUrl: false },
{ value: 'openai_tts', label: 'OpenAI TTS', needsBaseUrl: false },
{ value: 'wavespeed', label: 'WaveSpeed', needsBaseUrl: false },
{ value: 'custom', label: 'Personalizzato (custom)', needsBaseUrl: true },
]
// ─── Styles ───────────────────────────────────────────────────────────────────
const card = {
backgroundColor: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: '0.75rem',
padding: '1.5rem',
}
const inputStyle = {
const input = {
width: '100%',
padding: '0.625rem 1rem',
border: '1px solid var(--border)',
@@ -34,98 +49,206 @@ const inputStyle = {
color: 'var(--ink)',
backgroundColor: 'var(--cream)',
outline: 'none',
boxSizing: 'border-box',
}
export default function SettingsPage() {
const [settings, setSettings] = useState({})
const [providerStatus, setProviderStatus] = useState({})
const [loading, setLoading] = useState(true)
const [sectionSaving, setSectionSaving] = useState({})
const [sectionSuccess, setSectionSuccess] = useState({})
const [sectionError, setSectionError] = useState({})
// ─── Section component ────────────────────────────────────────────────────────
const [llmForm, setLlmForm] = useState({
function ProviderSection({ title, icon, description, providers, settingKeys, values, onChange, onSave, saving, success, error }) {
const { providerKey, apiKeyKey, modelKey, baseUrlKey, extraKey, extraLabel, extraPlaceholder } = settingKeys
const currentProvider = providers.find(p => p.value === values[providerKey]) || providers[0]
const showModel = modelKey != null
const showBaseUrl = currentProvider.needsBaseUrl && baseUrlKey
return (
<div style={card} className="space-y-4">
<div className="flex items-start gap-3">
<span className="text-2xl">{icon}</span>
<div>
<h3 className="font-semibold text-sm uppercase tracking-wider" style={{ color: 'var(--muted)' }}>
{title}
</h3>
<p className="text-xs mt-0.5" style={{ color: 'var(--muted)' }}>{description}</p>
</div>
</div>
{error && (
<div className="p-3 rounded-lg text-sm" style={{ backgroundColor: '#fef2f2', border: '1px solid #fecaca', color: '#dc2626' }}>
{error}
</div>
)}
{success && (
<div className="p-3 rounded-lg text-sm" style={{ backgroundColor: '#f0fdf4', border: '1px solid #bbf7d0', color: '#16a34a' }}>
Salvato con successo
</div>
)}
{/* Provider dropdown */}
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--ink)' }}>Provider</label>
<select
value={values[providerKey] || providers[0].value}
onChange={e => onChange(providerKey, e.target.value)}
style={input}
>
{providers.map(p => (
<option key={p.value} value={p.value}>{p.label}</option>
))}
</select>
</div>
{/* Custom base URL (shown only for 'custom' provider) */}
{showBaseUrl && (
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--ink)' }}>
Base URL <span className="font-normal ml-1" style={{ color: 'var(--muted)' }}>(es. https://mio-provider.com/v1)</span>
</label>
<input
type="text"
value={values[baseUrlKey] || ''}
onChange={e => onChange(baseUrlKey, e.target.value)}
placeholder="https://..."
style={{ ...input, fontFamily: 'monospace' }}
/>
</div>
)}
{/* API Key */}
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--ink)' }}>API Key</label>
<input
type="password"
value={values[apiKeyKey] || ''}
onChange={e => onChange(apiKeyKey, e.target.value)}
placeholder="Inserisci la tua API key..."
style={{ ...input, fontFamily: 'monospace' }}
/>
</div>
{/* Model (optional, only for text/video providers) */}
{showModel && (
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--ink)' }}>
Modello
{currentProvider.defaultModel && (
<span className="font-normal ml-1" style={{ color: 'var(--muted)' }}>
(default: {currentProvider.defaultModel})
</span>
)}
</label>
<input
type="text"
value={values[modelKey] || ''}
onChange={e => onChange(modelKey, e.target.value)}
placeholder={currentProvider.defaultModel || 'nome-modello'}
style={{ ...input, fontFamily: 'monospace' }}
/>
</div>
)}
{/* Extra field (es. Voice ID per ElevenLabs) */}
{extraKey && (
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--ink)' }}>
{extraLabel} <span className="font-normal ml-1" style={{ color: 'var(--muted)' }}>(opzionale)</span>
</label>
<input
type="text"
value={values[extraKey] || ''}
onChange={e => onChange(extraKey, e.target.value)}
placeholder={extraPlaceholder}
style={input}
/>
</div>
)}
<button
onClick={onSave}
disabled={saving}
className="px-4 py-2 text-white text-sm font-medium rounded-lg transition-opacity"
style={{ backgroundColor: 'var(--coral)', opacity: saving ? 0.7 : 1 }}
>
{saving ? 'Salvataggio...' : 'Salva'}
</button>
</div>
)
}
// ─── Main component ───────────────────────────────────────────────────────────
export default function SettingsPage() {
const [values, setValues] = useState({
// Text
llm_provider: 'claude',
llm_api_key: '',
llm_model: '',
})
const [imageForm, setImageForm] = useState({
llm_base_url: '',
// Image
image_provider: 'dalle',
image_api_key: '',
})
const [voiceForm, setVoiceForm] = useState({
elevenlabs_api_key: '',
image_base_url: '',
// Video
video_provider: 'wavespeed',
video_api_key: '',
video_model: '',
video_base_url: '',
// Voice
voice_provider: 'elevenlabs',
voice_api_key: '',
voice_base_url: '',
elevenlabs_voice_id: '',
})
useEffect(() => {
loadSettings()
}, [])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState({})
const [success, setSuccess] = useState({})
const [errors, setErrors] = useState({})
useEffect(() => { loadSettings() }, [])
const loadSettings = async () => {
setLoading(true)
try {
const [settingsData, statusData] = await Promise.all([
api.get('/settings/').catch(() => ({})),
api.get('/settings/providers/status').catch(() => ({})),
])
let normalizedSettings = {}
if (Array.isArray(settingsData)) {
settingsData.forEach((s) => { normalizedSettings[s.key] = s.value })
} else {
normalizedSettings = settingsData || {}
const data = await api.get('/settings/').catch(() => [])
const normalized = {}
if (Array.isArray(data)) {
data.forEach(s => { normalized[s.key] = s.value })
}
setSettings(normalizedSettings)
setProviderStatus(statusData || {})
setLlmForm({
llm_provider: normalizedSettings.llm_provider || 'claude',
llm_api_key: normalizedSettings.llm_api_key || '',
llm_model: normalizedSettings.llm_model || '',
})
setImageForm({
image_provider: normalizedSettings.image_provider || 'dalle',
image_api_key: normalizedSettings.image_api_key || '',
})
setVoiceForm({
elevenlabs_api_key: normalizedSettings.elevenlabs_api_key || '',
elevenlabs_voice_id: normalizedSettings.elevenlabs_voice_id || '',
})
} catch {
// silent
setValues(prev => ({ ...prev, ...normalized }))
} finally {
setLoading(false)
}
}
const saveSection = async (section, data) => {
setSectionSaving((prev) => ({ ...prev, [section]: true }))
setSectionSuccess((prev) => ({ ...prev, [section]: false }))
setSectionError((prev) => ({ ...prev, [section]: '' }))
try {
for (const [key, value] of Object.entries(data)) {
await api.put(`/settings/${key}`, { value })
const handleChange = (key, value) => {
setValues(prev => ({ ...prev, [key]: value }))
}
setSectionSuccess((prev) => ({ ...prev, [section]: true }))
setTimeout(() => {
setSectionSuccess((prev) => ({ ...prev, [section]: false }))
}, 3000)
const statusData = await api.get('/settings/providers/status').catch(() => ({}))
setProviderStatus(statusData || {})
const saveSection = async (section, keys) => {
setSaving(prev => ({ ...prev, [section]: true }))
setSuccess(prev => ({ ...prev, [section]: false }))
setErrors(prev => ({ ...prev, [section]: '' }))
try {
for (const key of keys) {
if (values[key] !== undefined) {
await api.put(`/settings/${key}`, { value: values[key] })
}
}
setSuccess(prev => ({ ...prev, [section]: true }))
setTimeout(() => setSuccess(prev => ({ ...prev, [section]: false })), 3000)
} catch (err) {
setSectionError((prev) => ({ ...prev, [section]: err.message || 'Errore nel salvataggio' }))
setErrors(prev => ({ ...prev, [section]: err.message || 'Errore nel salvataggio' }))
} finally {
setSectionSaving((prev) => ({ ...prev, [section]: false }))
setSaving(prev => ({ ...prev, [section]: false }))
}
}
if (loading) {
return (
<div>
<h2 className="text-2xl font-bold mb-1" style={{ color: 'var(--ink)' }}>Impostazioni</h2>
<h2 className="text-2xl font-bold mb-1" style={{ fontFamily: 'Fraunces, serif', color: 'var(--ink)' }}>
Impostazioni
</h2>
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-t-transparent" style={{ borderColor: 'var(--coral)' }} />
</div>
@@ -135,152 +258,98 @@ export default function SettingsPage() {
return (
<div>
<div className="mb-6">
<h2 className="text-2xl font-bold" style={{ color: 'var(--ink)' }}>Impostazioni</h2>
<div className="mb-8">
<h2 className="text-2xl font-bold" style={{ fontFamily: 'Fraunces, serif', color: 'var(--ink)' }}>
Impostazioni
</h2>
<p className="mt-1 text-sm" style={{ color: 'var(--muted)' }}>
Configurazione dei provider AI e dei servizi esterni
Scegli il provider per ogni tipo di output. Usa "Personalizzato" per collegare qualsiasi servizio compatibile.
</p>
</div>
<div className="max-w-2xl space-y-6">
{/* LLM Provider */}
<div style={cardStyle} className="space-y-4">
<h3 className="font-semibold text-sm uppercase tracking-wider" style={{ color: 'var(--muted)' }}>
Provider LLM
</h3>
{sectionError.llm && <div className="p-3 bg-red-50 border border-red-200 rounded-lg text-red-600 text-sm">{sectionError.llm}</div>}
{sectionSuccess.llm && <div className="p-3 bg-emerald-50 border border-emerald-200 rounded-lg text-emerald-600 text-sm">Salvato con successo</div>}
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--ink)' }}>Provider</label>
<select
value={llmForm.llm_provider}
onChange={(e) => setLlmForm((prev) => ({ ...prev, llm_provider: e.target.value }))}
style={inputStyle}
>
{LLM_PROVIDERS.map((p) => (
<option key={p.value} value={p.value}>{p.label}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--ink)' }}>API Key</label>
<input
type="password"
value={llmForm.llm_api_key}
onChange={(e) => setLlmForm((prev) => ({ ...prev, llm_api_key: e.target.value }))}
placeholder="sk-..."
style={{ ...inputStyle, fontFamily: 'monospace' }}
{/* TEXT */}
<ProviderSection
title="Testi & Script"
icon="✍️"
description="Provider LLM per generare post, caption, script e contenuti testuali."
providers={TEXT_PROVIDERS}
settingKeys={{
providerKey: 'llm_provider',
apiKeyKey: 'llm_api_key',
modelKey: 'llm_model',
baseUrlKey: 'llm_base_url',
}}
values={values}
onChange={handleChange}
onSave={() => saveSection('text', ['llm_provider', 'llm_api_key', 'llm_model', 'llm_base_url'])}
saving={saving.text}
success={success.text}
error={errors.text}
/>
</div>
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--ink)' }}>
Modello <span className="font-normal ml-1" style={{ color: 'var(--muted)' }}>(default: {LLM_DEFAULTS[llmForm.llm_provider]})</span>
</label>
<input
type="text"
value={llmForm.llm_model}
onChange={(e) => setLlmForm((prev) => ({ ...prev, llm_model: e.target.value }))}
placeholder={LLM_DEFAULTS[llmForm.llm_provider]}
style={{ ...inputStyle, fontFamily: 'monospace' }}
{/* IMAGE */}
<ProviderSection
title="Immagini"
icon="🖼️"
description="Provider per la generazione di immagini AI."
providers={IMAGE_PROVIDERS}
settingKeys={{
providerKey: 'image_provider',
apiKeyKey: 'image_api_key',
baseUrlKey: 'image_base_url',
}}
values={values}
onChange={handleChange}
onSave={() => saveSection('image', ['image_provider', 'image_api_key', 'image_base_url'])}
saving={saving.image}
success={success.image}
error={errors.image}
/>
</div>
<button
onClick={() => saveSection('llm', llmForm)}
disabled={sectionSaving.llm}
className="px-4 py-2 text-white text-sm font-medium rounded-lg transition-opacity"
style={{ backgroundColor: 'var(--coral)', opacity: sectionSaving.llm ? 0.7 : 1 }}
>
{sectionSaving.llm ? 'Salvataggio...' : 'Salva'}
</button>
</div>
{/* Image Provider */}
<div style={cardStyle} className="space-y-4">
<h3 className="font-semibold text-sm uppercase tracking-wider" style={{ color: 'var(--muted)' }}>
Generazione Immagini
</h3>
{sectionError.image && <div className="p-3 bg-red-50 border border-red-200 rounded-lg text-red-600 text-sm">{sectionError.image}</div>}
{sectionSuccess.image && <div className="p-3 bg-emerald-50 border border-emerald-200 rounded-lg text-emerald-600 text-sm">Salvato con successo</div>}
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--ink)' }}>Provider</label>
<select
value={imageForm.image_provider}
onChange={(e) => setImageForm((prev) => ({ ...prev, image_provider: e.target.value }))}
style={inputStyle}
>
{IMAGE_PROVIDERS.map((p) => (
<option key={p.value} value={p.value}>{p.label}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--ink)' }}>API Key</label>
<input
type="password"
value={imageForm.image_api_key}
onChange={(e) => setImageForm((prev) => ({ ...prev, image_api_key: e.target.value }))}
placeholder="API key del provider immagini"
style={{ ...inputStyle, fontFamily: 'monospace' }}
{/* VIDEO */}
<ProviderSection
title="Video"
icon="🎬"
description="Provider per la generazione di video AI (testo → video, immagine → video)."
providers={VIDEO_PROVIDERS}
settingKeys={{
providerKey: 'video_provider',
apiKeyKey: 'video_api_key',
modelKey: 'video_model',
baseUrlKey: 'video_base_url',
}}
values={values}
onChange={handleChange}
onSave={() => saveSection('video', ['video_provider', 'video_api_key', 'video_model', 'video_base_url'])}
saving={saving.video}
success={success.video}
error={errors.video}
/>
</div>
<button
onClick={() => saveSection('image', imageForm)}
disabled={sectionSaving.image}
className="px-4 py-2 text-white text-sm font-medium rounded-lg transition-opacity"
style={{ backgroundColor: 'var(--coral)', opacity: sectionSaving.image ? 0.7 : 1 }}
>
{sectionSaving.image ? 'Salvataggio...' : 'Salva'}
</button>
</div>
{/* Voiceover */}
<div style={cardStyle} className="space-y-4">
<h3 className="font-semibold text-sm uppercase tracking-wider" style={{ color: 'var(--muted)' }}>
Voiceover (ElevenLabs)
</h3>
{sectionError.voice && <div className="p-3 bg-red-50 border border-red-200 rounded-lg text-red-600 text-sm">{sectionError.voice}</div>}
{sectionSuccess.voice && <div className="p-3 bg-emerald-50 border border-emerald-200 rounded-lg text-emerald-600 text-sm">Salvato con successo</div>}
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--ink)' }}>API Key</label>
<input
type="password"
value={voiceForm.elevenlabs_api_key}
onChange={(e) => setVoiceForm((prev) => ({ ...prev, elevenlabs_api_key: e.target.value }))}
placeholder="ElevenLabs API key"
style={{ ...inputStyle, fontFamily: 'monospace' }}
{/* VOICE */}
<ProviderSection
title="Voiceover"
icon="🎙️"
description="Provider text-to-speech per generare voiceover dai tuoi contenuti."
providers={VOICE_PROVIDERS}
settingKeys={{
providerKey: 'voice_provider',
apiKeyKey: 'voice_api_key',
baseUrlKey: 'voice_base_url',
extraKey: 'elevenlabs_voice_id',
extraLabel: 'Voice ID',
extraPlaceholder: 'ID della voce (solo ElevenLabs)',
}}
values={values}
onChange={handleChange}
onSave={() => saveSection('voice', ['voice_provider', 'voice_api_key', 'voice_base_url', 'elevenlabs_voice_id'])}
saving={saving.voice}
success={success.voice}
error={errors.voice}
/>
</div>
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--ink)' }}>
Voice ID <span className="font-normal ml-1" style={{ color: 'var(--muted)' }}>(opzionale)</span>
</label>
<input
type="text"
value={voiceForm.elevenlabs_voice_id}
onChange={(e) => setVoiceForm((prev) => ({ ...prev, elevenlabs_voice_id: e.target.value }))}
placeholder="ID della voce ElevenLabs"
style={inputStyle}
/>
</div>
<button
onClick={() => saveSection('voice', voiceForm)}
disabled={sectionSaving.voice}
className="px-4 py-2 text-white text-sm font-medium rounded-lg transition-opacity"
style={{ backgroundColor: 'var(--coral)', opacity: sectionSaving.voice ? 0.7 : 1 }}
>
{sectionSaving.voice ? 'Salvataggio...' : 'Salva'}
</button>
</div>
</div>
</div>
)