import React, { useState, useEffect, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { api } from '../api'
import { useAuth } from '../AuthContext'
// ─── Provider catalogs ────────────────────────────────────────────────────────
const TEXT_PROVIDERS = [
{ value: 'claude', label: 'Claude (Anthropic)', defaultModel: 'claude-sonnet-4-20250514' },
{ value: 'openai', label: 'OpenAI', defaultModel: 'gpt-4o-mini' },
{ value: 'gemini', label: 'Gemini (Google)', defaultModel: 'gemini-2.0-flash' },
{ value: 'openrouter', label: 'OpenRouter', defaultModel: 'openai/gpt-4o-mini' },
{ value: 'custom', label: 'Personalizzato', defaultModel: '', needsBaseUrl: true },
]
const IMAGE_PROVIDERS = [
{ value: 'dalle', label: 'DALL-E (OpenAI)' },
{ value: 'replicate', label: 'Replicate' },
{ value: 'wavespeed', label: 'WaveSpeed' },
{ value: 'custom', label: 'Personalizzato', needsBaseUrl: true },
]
const VIDEO_PROVIDERS = [
{ value: 'wavespeed', label: 'WaveSpeed' },
{ value: 'replicate', label: 'Replicate' },
{ value: 'custom', label: 'Personalizzato', needsBaseUrl: true },
]
const VOICE_PROVIDERS = [
{ value: 'elevenlabs', label: 'ElevenLabs' },
{ value: 'openai_tts', label: 'OpenAI TTS' },
{ value: 'wavespeed', label: 'WaveSpeed' },
{ value: 'custom', label: 'Personalizzato', needsBaseUrl: true },
]
const PROVIDER_GUIDES = {
claude: {
steps: [
'Vai su console.anthropic.com e crea un account (o accedi).',
'Dal menu laterale, clicca su "API Keys".',
'Clicca "+ Create Key", assegna un nome (es. "Leopost").',
'Copia la chiave generata — non sarà più visibile dopo.',
],
link: 'https://console.anthropic.com/account/keys',
},
openai: {
steps: [
'Vai su platform.openai.com e accedi.',
'In alto a destra clicca il tuo account → "API keys".',
'Clicca "+ Create new secret key".',
'Copia la chiave (inizia con "sk-").',
],
link: 'https://platform.openai.com/api-keys',
},
gemini: {
steps: [
'Vai su aistudio.google.com e accedi con Google.',
'Clicca "Get API Key" → "+ Create API key in new project".',
'Copia la chiave generata.',
],
link: 'https://aistudio.google.com/app/apikey',
},
openrouter: {
steps: [
'Vai su openrouter.ai e registrati.',
'In alto a destra: account → "API Keys" → "+ Create Key".',
'Copia la chiave (inizia con "sk-or-").',
],
link: 'https://openrouter.ai/keys',
},
dalle: {
steps: [
'DALL-E usa le stesse chiavi di OpenAI.',
'Vai su platform.openai.com e crea una API key.',
'Assicurati di avere crediti disponibili nel tuo account.',
],
link: 'https://platform.openai.com/api-keys',
},
replicate: {
steps: [
'Vai su replicate.com e crea un account.',
'Avatar in alto a destra → "API tokens" → "Create token".',
'Copia il token (inizia con "r8_").',
],
link: 'https://replicate.com/account/api-tokens',
},
wavespeed: {
steps: [
'Vai su wavespeed.ai e registrati.',
'Dal pannello account, vai alla sezione API Keys.',
'Genera una nuova chiave e copiala.',
],
link: 'https://wavespeed.ai',
},
elevenlabs: {
steps: [
'Vai su elevenlabs.io e accedi.',
'In basso a sinistra: profilo → "Profile + API key".',
'Copia la chiave API nella sezione "API Key".',
"Per il Voice ID: vai su \"Voices\" → seleziona voce → copia l'ID dalla sidebar.",
],
link: 'https://elevenlabs.io/app/settings/api-keys',
},
openai_tts: {
steps: [
'OpenAI TTS usa le stesse chiavi di OpenAI (GPT).',
'Vai su platform.openai.com e crea una API key.',
],
link: 'https://platform.openai.com/api-keys',
},
custom: {
steps: [
'Inserisci la Base URL del provider personalizzato.',
"Inserisci l'API key fornita dal provider.",
'Il provider deve essere compatibile con il formato API OpenAI.',
],
link: null,
},
}
const SECTIONS = [
{ id: 'profilo', label: 'Profilo' },
{ id: 'piano', label: 'Piano & Account' },
{ id: 'ai', label: 'Provider AI' },
{ id: 'sicurezza',label: 'Sicurezza' },
{ id: 'privacy', label: 'Privacy & Dati' },
]
export default function SettingsPage() {
const { user, isPro } = useAuth()
const navigate = useNavigate()
const [activeSection, setActiveSection] = useState('piano')
const [aiValues, setAiValues] = useState({
llm_provider: 'claude', llm_api_key: '', llm_model: '', llm_base_url: '',
image_provider: 'dalle', image_api_key: '', image_base_url: '',
video_provider: 'wavespeed', video_api_key: '', video_model: '', video_base_url: '',
voice_provider: 'elevenlabs', voice_api_key: '', voice_base_url: '', elevenlabs_voice_id: '',
})
const [loadingAI, setLoadingAI] = useState(true)
const [saving, setSaving] = useState({})
const [success, setSuccess] = useState({})
const [errors, setErrors] = useState({})
useEffect(() => {
api.get('/settings/').then(data => {
if (Array.isArray(data)) {
const n = {}
data.forEach(s => { n[s.key] = s.value })
setAiValues(prev => ({ ...prev, ...n }))
}
}).catch(() => {}).finally(() => setLoadingAI(false))
}, [])
const setMsg = (key, type, msg) => {
if (type === 'success') {
setSuccess(p => ({ ...p, [key]: true }))
setErrors(p => ({ ...p, [key]: '' }))
setTimeout(() => setSuccess(p => ({ ...p, [key]: false })), 3000)
} else {
setErrors(p => ({ ...p, [key]: msg }))
setSuccess(p => ({ ...p, [key]: false }))
}
}
const saveAI = async (section, keys) => {
setSaving(p => ({ ...p, [section]: true }))
try {
for (const key of keys) {
if (aiValues[key] !== undefined) await api.put(`/settings/${key}`, { value: aiValues[key] })
}
setMsg(section, 'success')
} catch (e) {
setMsg(section, 'error', e.message || 'Errore nel salvataggio')
} finally {
setSaving(p => ({ ...p, [section]: false }))
}
}
return (
{/* Page header */}
Impostazioni
Impostazioni
{/* Sidebar nav */}
{SECTIONS.map(s => (
setActiveSection(s.id)} style={{
display: 'block', width: '100%', textAlign: 'left',
padding: '0.625rem 0.875rem',
fontSize: '0.85rem', fontWeight: activeSection === s.id ? 600 : 400,
fontFamily: "'DM Sans', sans-serif",
color: activeSection === s.id ? 'var(--accent)' : 'var(--ink-light)',
backgroundColor: 'transparent',
borderLeft: activeSection === s.id ? '3px solid var(--accent)' : '3px solid transparent',
border: 'none', borderLeft: activeSection === s.id ? '3px solid var(--accent)' : '3px solid transparent',
cursor: 'pointer', marginBottom: '0.1rem',
transition: 'color 0.15s, background-color 0.15s',
}}
onMouseEnter={e => { if (activeSection !== s.id) e.currentTarget.style.color = 'var(--ink)' }}
onMouseLeave={e => { if (activeSection !== s.id) e.currentTarget.style.color = 'var(--ink-light)' }}
>
{s.label}
))}
{/* Content */}
{activeSection === 'profilo' && (
)}
{activeSection === 'piano' && (
)}
{activeSection === 'ai' && (
setAiValues(p => ({ ...p, [k]: v }))}
saveAI={saveAI} loading={loadingAI} saving={saving} success={success} errors={errors} />
)}
{activeSection === 'sicurezza' && (
)}
{activeSection === 'privacy' && (
)}
)
}
// ─── Profilo ─────────────────────────────────────────────────────────────────
function ProfiloSection({ user, saving, success, errors, setSaving, setMsg }) {
const [displayName, setDisplayName] = React.useState(user?.display_name || '')
React.useEffect(() => { setDisplayName(user?.display_name || '') }, [user?.display_name])
const handleSave = async () => {
const name = displayName.trim()
if (!name) return
setSaving(p => ({ ...p, profilo: true }))
try {
await api.put('/auth/me', { display_name: name })
setMsg('profilo', 'success')
} catch (e) {
setMsg('profilo', 'error', e.message || 'Errore nel salvataggio')
} finally {
setSaving(p => ({ ...p, profilo: false }))
}
}
return (
Profilo
Informazioni personali
Il nome viene mostrato nella sidebar. L'email non è modificabile.
{errors.profilo && {errors.profilo} }
{success.profilo && Profilo aggiornato con successo. }
)
}
// ─── Piano & Account ─────────────────────────────────────────────────────────
function PianoSection({ user, isPro, saving, success, errors, setSaving, setMsg }) {
const [code, setCode] = useState('')
const handleRedeem = async () => {
if (!code.trim()) return
setSaving(p => ({ ...p, redeem: true }))
try {
const data = await api.post('/auth/redeem', { code: code.trim() })
setMsg('redeem', 'success')
setCode('')
// Refresh the page to update plan badge
setTimeout(() => window.location.reload(), 1500)
} catch (e) {
setMsg('redeem', 'error', e.message || 'Codice non valido')
} finally {
setSaving(p => ({ ...p, redeem: false }))
}
}
const expiry = user?.subscription_expires_at
const expiryDate = expiry ? new Date(expiry).toLocaleDateString('it-IT', { day: 'numeric', month: 'long', year: 'numeric' }) : null
return (
Piano & Account
{/* Current plan */}
Piano attuale
{isPro ? 'PRO' : 'FREEMIUM'}
{isPro && expiryDate && (
Attivo fino al {expiryDate}
)}
{!isPro && (
Vuoi passare a Pro?
Piano Pro Early Adopter: €14,95/mese · €39,95/3 mesi · €64,95/6 mesi · €119,95/anno
Scrivici per attivarlo →
)}
{/* Redeem code */}
Riscatta codice Early Adopter
Hai ricevuto un codice di accesso anticipato? Inseriscilo qui per attivare il piano Pro.
{errors.redeem && {errors.redeem} }
{success.redeem && Codice riscattato! Piano Pro attivato. }
setCode(e.target.value.toUpperCase())}
placeholder="LEOPOST-XXXXX" maxLength={20}
style={{ ...inputStyle, flex: 1, fontFamily: 'monospace', letterSpacing: '0.08em' }}
onFocus={e => e.target.style.borderColor = 'var(--ink)'}
onBlur={e => e.target.style.borderColor = 'var(--border)'}
onKeyDown={e => e.key === 'Enter' && handleRedeem()}
/>
{saving.redeem ? 'Verifica…' : 'Riscatta'}
)
}
// ─── AI Providers ─────────────────────────────────────────────────────────────
function AISection({ values, onChange, saveAI, loading, saving, success, errors }) {
if (loading) return
const currentText = TEXT_PROVIDERS.find(p => p.value === values.llm_provider) || TEXT_PROVIDERS[0]
const currentImage = IMAGE_PROVIDERS.find(p => p.value === values.image_provider) || IMAGE_PROVIDERS[0]
const currentVideo = VIDEO_PROVIDERS.find(p => p.value === values.video_provider) || VIDEO_PROVIDERS[0]
const currentVoice = VOICE_PROVIDERS.find(p => p.value === values.voice_provider) || VOICE_PROVIDERS[0]
return (
Provider AI
{/* Step-by-step setup guide */}
Come configurare — Guida passo per passo
{[
{ n: 1, title: 'Scegli un provider LLM (per i testi)', body: 'Il più semplice da iniziare: Claude (Anthropic) o OpenAI. Serve una API key gratuita o a pagamento dal sito del provider.' },
{ n: 2, title: 'Ottieni la tua API key', body: 'Clicca su "▼ Mostra guida" sotto il campo API Key — trovi le istruzioni specifiche per ogni provider, con link diretto alla pagina giusta.' },
{ n: 3, title: 'Incolla la chiave e salva', body: 'Copia la chiave dal sito del provider, incollala nel campo apposito e clicca "Salva". La chiave viene cifrata e non è più visibile dopo il salvataggio.' },
{ n: 4, title: 'Configura immagini (opzionale)', body: 'Per generare immagini AI aggiungi DALL-E (OpenAI) o Replicate. Non obbligatorio per generare solo testi.' },
{ n: 5, title: 'Torna alla Dashboard e verifica', body: 'Il pannello "Stato Provider" in Dashboard mostra ✓ o ✗ per ogni servizio configurato.' },
].map(step => (
))}
saveAI('text', ['llm_provider','llm_api_key','llm_model','llm_base_url'])}
saving={saving.text} success={success.text} error={errors.text}
/>
saveAI('image', ['image_provider','image_api_key','image_base_url'])}
saving={saving.image} success={success.image} error={errors.image}
/>
saveAI('video', ['video_provider','video_api_key','video_model','video_base_url'])}
saving={saving.video} success={success.video} error={errors.video}
/>
saveAI('voice', ['voice_provider','voice_api_key','voice_base_url','elevenlabs_voice_id'])}
saving={saving.voice} success={success.voice} error={errors.voice}
/>
)
}
function ProviderCard({ title, description, providers, providerKey, apiKey, modelKey, baseUrlKey, extraKey, extraLabel, extraPlaceholder, currentProvider, values, onChange, onSave, saving, success, error }) {
return (
{error && {error} }
{success && Salvato con successo }
onChange(providerKey, e.target.value)} style={selectStyle}>
{providers.map(p => {p.label} )}
{baseUrlKey && (
onChange(baseUrlKey, e.target.value)}
placeholder="https://..." style={{ ...inputStyle, fontFamily: 'monospace' }}
onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} />
)}
onChange(apiKey, e.target.value)}
placeholder="Inserisci la tua API key…" style={{ ...inputStyle, fontFamily: 'monospace' }}
onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} />
{modelKey && (
onChange(modelKey, e.target.value)}
placeholder={currentProvider?.defaultModel || 'nome-modello'} style={{ ...inputStyle, fontFamily: 'monospace' }}
onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} />
)}
{extraKey && (
onChange(extraKey, e.target.value)}
placeholder={extraPlaceholder} style={inputStyle}
onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} />
)}
{/* API key guide */}
{(() => {
const guide = PROVIDER_GUIDES[values[providerKey]]
if (!guide) return null
return (
)
})()}
{saving ? 'Salvataggio…' : 'Salva'}
)
}
function GuideAccordion({ guide, providerName }) {
const [open, setOpen] = React.useState(false)
if (!guide?.steps?.length) return null
return (
setOpen(o => !o)} style={{
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '0.625rem 0.875rem', backgroundColor: 'var(--cream)', border: 'none', cursor: 'pointer',
fontFamily: "'DM Sans', sans-serif",
}}>
Come ottenere la chiave API {providerName ? `(${providerName})` : ''}
{open ? '▲ Chiudi' : '▼ Mostra guida'}
{open && (
)}
)
}
// ─── Sicurezza ────────────────────────────────────────────────────────────────
function SicurezzaSection({ user, saving, success, errors, setMsg, setSaving }) {
const [form, setForm] = useState({ current_password: '', new_password: '', confirm_password: '' })
const isGoogle = user?.auth_provider === 'google'
const handleChange = async () => {
if (form.new_password !== form.confirm_password) {
setMsg('pwd', 'error', 'Le password non coincidono.')
return
}
if (form.new_password.length < 8) {
setMsg('pwd', 'error', 'La nuova password deve essere di almeno 8 caratteri.')
return
}
setSaving(p => ({ ...p, pwd: true }))
try {
await api.post('/auth/change-password', {
current_password: form.current_password,
new_password: form.new_password,
})
setMsg('pwd', 'success')
setForm({ current_password: '', new_password: '', confirm_password: '' })
} catch (e) {
setMsg('pwd', 'error', e.message || 'Errore nel cambio password')
} finally {
setSaving(p => ({ ...p, pwd: false }))
}
}
return (
)
}
// ─── Privacy & Dati ───────────────────────────────────────────────────────────
function PrivacySection({ user, navigate, saving, success, errors, setMsg, setSaving }) {
const [deleteConfirm, setDeleteConfirm] = useState('')
const [showDeleteForm, setShowDeleteForm] = useState(false)
const handleExport = () => {
window.location.href = '/api/auth/export-data'
}
const handleDelete = async () => {
if (deleteConfirm !== 'ELIMINA') {
setMsg('delete', 'error', 'Digita ELIMINA (in maiuscolo) per confermare.')
return
}
setSaving(p => ({ ...p, delete: true }))
try {
await api.delete('/auth/account', { confirmation: 'ELIMINA' })
localStorage.clear()
navigate('/login')
} catch (e) {
setMsg('delete', 'error', e.message || 'Errore durante la cancellazione')
} finally {
setSaving(p => ({ ...p, delete: false }))
}
}
return (
Privacy & Dati
{/* Export */}
Esporta i tuoi dati
Scarica tutti i tuoi dati personali in formato JSON (richiesto dal GDPR, Art. 20 — Portabilità dei dati).
Scarica dati JSON
{/* Delete */}
Elimina account
L'eliminazione è permanente e irreversibile. Tutti i tuoi dati (personaggi, contenuti, impostazioni) verranno cancellati.
{!showDeleteForm ? (
setShowDeleteForm(true)} style={{ ...btnPrimary, backgroundColor: 'var(--error)' }}>
Elimina il mio account
) : (
{errors.delete &&
{errors.delete} }
Digita ELIMINA per confermare:
setDeleteConfirm(e.target.value)}
placeholder="ELIMINA" style={{ ...inputStyle, flex: 1 }}
onFocus={e => e.target.style.borderColor = 'var(--error)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} />
{saving.delete ? 'Eliminazione…' : 'Conferma'}
{ setShowDeleteForm(false); setDeleteConfirm('') }} style={{ ...btnPrimary, backgroundColor: 'var(--cream-dark)', color: '#1A1A1A', border: '1px solid #C8C0B4' }}>
Annulla
)}
)
}
// ─── Shared components ────────────────────────────────────────────────────────
function SectionTitle({ children }) {
return (
)
}
function Card({ children, style }) {
return (
{children}
)
}
function Label({ children, style }) {
return (
{children}
)
}
function Field({ label, children }) {
return (
{label}
{children}
)
}
function Feedback({ type, children }) {
const colors = type === 'error'
? { bg: 'var(--error-light)', border: '#FED7D7', color: 'var(--error)' }
: { bg: 'var(--success-light)', border: '#86EFAC', color: 'var(--success)' }
return (
{children}
)
}
function Spinner() {
return (
)
}
const inputStyle = {
width: '100%', padding: '0.625rem 0.875rem',
border: '1px solid var(--border)', borderRadius: 0,
fontSize: '0.875rem', color: 'var(--ink)',
backgroundColor: 'var(--surface)', outline: 'none',
boxSizing: 'border-box', transition: 'border-color 0.15s',
fontFamily: "'DM Sans', sans-serif",
}
const selectStyle = { ...inputStyle, cursor: 'pointer', appearance: 'auto' }
const btnPrimary = {
display: 'inline-block', padding: '0.6rem 1.25rem',
backgroundColor: 'var(--ink)', color: 'white',
fontFamily: "'DM Sans', sans-serif", fontWeight: 600,
fontSize: '0.875rem', textDecoration: 'none',
border: 'none', cursor: 'pointer', whiteSpace: 'nowrap',
}