Files
leopost-full/frontend/src/components/SettingsPage.jsx
Michele 9a8b3c4e14 feat: integrate Social, Affiliati, Commenti into Settings page
- Add Social, Link Affiliati, Commenti as tabs in Settings page
- Import and render existing components directly (they have their own headers)
- Sections accessible via /settings?tab=social, ?tab=affiliati, ?tab=commenti

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 11:03:34 +02:00

887 lines
41 KiB
JavaScript

import React, { useState, useEffect, useRef } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { api } from '../api'
import { useAuth } from '../AuthContext'
import SocialAccounts from './SocialAccounts'
import AffiliateList from './AffiliateList'
import CommentsQueue from './CommentsQueue'
// ─── Provider catalogs ────────────────────────────────────────────────────────
const TEXT_PROVIDERS = [
{ value: 'claude', label: 'Claude (Anthropic)', defaultModel: 'claude-sonnet-4-20250514' }, // Sonnet 4.6
{ 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 },
]
// ─── Model catalogs per provider ─────────────────────────────────────────────
const MODELS_BY_PROVIDER = {
claude: [
{ value: 'claude-opus-4-20250514', label: 'Claude Opus 4.6' },
{ value: 'claude-sonnet-4-20250514', label: 'Claude Sonnet 4.6' },
{ value: 'claude-haiku-4-20250514', label: 'Claude Haiku 4.5' },
{ value: 'claude-4-5-sonnet-20250514', label: 'Claude 4.5 Sonnet (legacy)' },
{ value: 'claude-4-5-haiku-20250514', label: 'Claude 4.5 Haiku (legacy)' },
],
openai: [
{ value: 'gpt-4o', label: 'GPT-4o' },
{ value: 'gpt-4o-mini', label: 'GPT-4o Mini' },
{ value: 'gpt-4.1', label: 'GPT-4.1' },
{ value: 'gpt-4.1-mini', label: 'GPT-4.1 Mini' },
{ value: 'gpt-4.1-nano', label: 'GPT-4.1 Nano' },
{ value: 'o3-mini', label: 'o3 Mini' },
],
gemini: [
{ value: 'gemini-2.5-flash-preview-05-20', label: 'Gemini 2.5 Flash' },
{ value: 'gemini-2.5-pro-preview-05-06', label: 'Gemini 2.5 Pro' },
{ value: 'gemini-2.0-flash', label: 'Gemini 2.0 Flash' },
{ value: 'gemini-2.0-flash-lite', label: 'Gemini 2.0 Flash Lite' },
],
openrouter: [
{ value: 'anthropic/claude-sonnet-4', label: 'Claude Sonnet 4.6' },
{ value: 'anthropic/claude-haiku-4', label: 'Claude Haiku 4.5' },
{ value: 'openai/gpt-4o-mini', label: 'GPT-4o Mini' },
{ value: 'google/gemini-2.0-flash', label: 'Gemini 2.0 Flash' },
{ value: 'meta-llama/llama-4-maverick', label: 'Llama 4 Maverick' },
{ value: 'deepseek/deepseek-r1', label: 'DeepSeek R1' },
],
}
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: 'social', label: 'Account Social' },
{ id: 'affiliati', label: 'Link Affiliati' },
{ id: 'commenti', label: 'Commenti' },
{ id: 'sicurezza', label: 'Sicurezza' },
{ id: 'privacy', label: 'Privacy & Dati' },
]
export default function SettingsPage() {
const { user, isPro } = useAuth()
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const [activeSection, setActiveSection] = useState(searchParams.get('tab') || 'profilo')
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 (
<div style={{ animation: 'fade-up 0.5s ease-out both' }}>
{/* Page header */}
<div style={{ marginBottom: '2rem' }}>
<span className="editorial-tag">Impostazioni</span>
<div className="editorial-line" />
<h2 style={{ fontFamily: "'Fraunces', serif", fontSize: '1.75rem', fontWeight: 600, letterSpacing: '-0.02em', color: 'var(--ink)', margin: '0.5rem 0 0' }}>
Impostazioni
</h2>
</div>
<div style={{ display: 'flex', gap: '2rem', alignItems: 'flex-start' }}>
{/* Sidebar nav */}
<nav style={{ width: 180, flexShrink: 0 }}>
{SECTIONS.map(s => (
<button key={s.id} onClick={() => 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}
</button>
))}
</nav>
{/* Content */}
<div style={{ flex: 1, minWidth: 0 }}>
{activeSection === 'profilo' && (
<ProfiloSection user={user} saving={saving} success={success} errors={errors} setMsg={setMsg} setSaving={setSaving} />
)}
{activeSection === 'piano' && (
<PianoSection user={user} isPro={isPro}
saving={saving} success={success} errors={errors}
setSaving={setSaving} setMsg={setMsg} />
)}
{activeSection === 'ai' && (
<AISection values={aiValues} onChange={(k, v) => setAiValues(p => ({ ...p, [k]: v }))}
saveAI={saveAI} loading={loadingAI} saving={saving} success={success} errors={errors} />
)}
{activeSection === 'social' && <SocialAccounts />}
{activeSection === 'affiliati' && <AffiliateList />}
{activeSection === 'commenti' && <CommentsQueue />}
{activeSection === 'sicurezza' && (
<SicurezzaSection user={user} saving={saving} success={success} errors={errors} setMsg={setMsg} setSaving={setSaving} />
)}
{activeSection === 'privacy' && (
<PrivacySection user={user} navigate={navigate} saving={saving} success={success} errors={errors} setMsg={setMsg} setSaving={setSaving} />
)}
</div>
</div>
</div>
)
}
// ─── 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 (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
<SectionTitle>Profilo</SectionTitle>
<Card>
<Label>Informazioni personali</Label>
<p style={{ fontSize: '0.82rem', color: 'var(--ink-muted)', margin: '0.4rem 0 1rem', lineHeight: 1.5 }}>
Il nome viene mostrato nella sidebar. L&apos;email non è modificabile.
</p>
{errors.profilo && <Feedback type="error">{errors.profilo}</Feedback>}
{success.profilo && <Feedback type="success">Profilo aggiornato con successo.</Feedback>}
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.875rem' }}>
<Field label="Nome visualizzato">
<input
type="text"
value={displayName}
onChange={e => setDisplayName(e.target.value)}
placeholder="Il tuo nome"
style={inputStyle}
onFocus={e => e.target.style.borderColor = 'var(--ink)'}
onBlur={e => e.target.style.borderColor = 'var(--border)'}
onKeyDown={e => e.key === 'Enter' && handleSave()}
/>
</Field>
<Field label="Email">
<input
type="text"
value={user?.email || ''}
readOnly
style={{ ...inputStyle, backgroundColor: 'var(--cream-dark)', color: 'var(--ink-muted)', cursor: 'not-allowed' }}
/>
<p style={{ fontSize: '0.72rem', color: 'var(--ink-muted)', margin: '0.25rem 0 0' }}>
{user?.auth_provider === 'google'
? 'Account Google — email gestita da Google.'
: "L'email non è modificabile per motivi di sicurezza."}
</p>
</Field>
<button
onClick={handleSave}
disabled={saving.profilo || !displayName.trim()}
style={{ ...btnPrimary, alignSelf: 'flex-start', opacity: (saving.profilo || !displayName.trim()) ? 0.6 : 1 }}
>
{saving.profilo ? 'Salvataggio…' : 'Salva modifiche'}
</button>
</div>
</Card>
</div>
)
}
// ─── 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 (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
<SectionTitle>Piano & Account</SectionTitle>
{/* Current plan */}
<Card>
<Label>Piano attuale</Label>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginTop: '0.75rem' }}>
<span style={{
fontSize: '0.75rem', fontWeight: 700, letterSpacing: '0.1em', textTransform: 'uppercase',
padding: '0.3rem 0.75rem',
backgroundColor: isPro ? 'var(--success-light)' : 'var(--cream-dark)',
color: isPro ? 'var(--success)' : 'var(--ink-muted)',
}}>
{isPro ? 'PRO' : 'FREEMIUM'}
</span>
{isPro && expiryDate && (
<span style={{ fontSize: '0.82rem', color: 'var(--ink-muted)' }}>Attivo fino al {expiryDate}</span>
)}
</div>
{!isPro && (
<div style={{ marginTop: '1rem', padding: '1rem', backgroundColor: 'var(--accent-light)', borderLeft: '3px solid var(--accent)' }}>
<p style={{ fontSize: '0.85rem', color: 'var(--ink)', margin: '0 0 0.5rem', fontWeight: 600 }}>Vuoi passare a Pro?</p>
<p style={{ fontSize: '0.82rem', color: 'var(--ink-muted)', margin: '0 0 0.75rem', lineHeight: 1.5 }}>
Piano Pro Early Adopter: 14,95/mese · 39,95/3 mesi · 64,95/6 mesi · 119,95/anno
</p>
<a href="mailto:info@leopost.it?subject=Richiesta Piano Pro Early Adopter" style={{ fontSize: '0.82rem', color: 'var(--accent)', fontWeight: 600, textDecoration: 'none' }}>
Scrivici per attivarlo
</a>
</div>
)}
</Card>
{/* Redeem code */}
<Card>
<Label>Riscatta codice Early Adopter</Label>
<p style={{ fontSize: '0.82rem', color: 'var(--ink-muted)', margin: '0.4rem 0 1rem', lineHeight: 1.5 }}>
Hai ricevuto un codice di accesso anticipato? Inseriscilo qui per attivare il piano Pro.
</p>
{errors.redeem && <Feedback type="error">{errors.redeem}</Feedback>}
{success.redeem && <Feedback type="success">Codice riscattato! Piano Pro attivato.</Feedback>}
<div style={{ display: 'flex', gap: '0.5rem' }}>
<input
type="text" value={code} onChange={e => 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()}
/>
<button onClick={handleRedeem} disabled={saving.redeem || !code.trim()} style={btnPrimary}>
{saving.redeem ? 'Verifica…' : 'Riscatta'}
</button>
</div>
</Card>
</div>
)
}
// ─── AI Providers ─────────────────────────────────────────────────────────────
function AISection({ values, onChange, saveAI, loading, saving, success, errors }) {
if (loading) return <Spinner />
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 (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
<SectionTitle>Provider AI</SectionTitle>
{/* Step-by-step setup guide */}
<div style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)', borderLeft: '4px solid var(--accent)', padding: '1.25rem 1.5rem' }}>
<p style={{ fontSize: '0.7rem', fontWeight: 700, letterSpacing: '0.1em', textTransform: 'uppercase', color: 'var(--accent)', margin: '0 0 0.875rem' }}>
Come configurare Guida passo per passo
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.625rem' }}>
{[
{ 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 => (
<div key={step.n} style={{ display: 'flex', gap: '0.875rem', alignItems: 'flex-start' }}>
<div style={{ width: 22, height: 22, borderRadius: '50%', backgroundColor: 'var(--accent)', color: 'white', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '0.7rem', fontWeight: 700, flexShrink: 0, marginTop: '0.1rem' }}>
{step.n}
</div>
<div>
<p style={{ fontWeight: 600, fontSize: '0.875rem', color: 'var(--ink)', margin: '0 0 0.15rem' }}>{step.title}</p>
<p style={{ fontSize: '0.8rem', color: 'var(--ink-muted)', margin: 0, lineHeight: 1.5 }}>{step.body}</p>
</div>
</div>
))}
</div>
</div>
<ProviderCard title="Testi & Script" description="LLM per generare post, caption e script."
providers={TEXT_PROVIDERS} providerKey="llm_provider" apiKey="llm_api_key" modelKey="llm_model"
baseUrlKey={currentText.needsBaseUrl ? 'llm_base_url' : null}
currentProvider={currentText} values={values} onChange={onChange}
onSave={() => saveAI('text', ['llm_provider','llm_api_key','llm_model','llm_base_url'])}
saving={saving.text} success={success.text} error={errors.text}
/>
<ProviderCard title="Immagini" description="Generazione immagini AI."
providers={IMAGE_PROVIDERS} providerKey="image_provider" apiKey="image_api_key"
baseUrlKey={currentImage.needsBaseUrl ? 'image_base_url' : null}
currentProvider={currentImage} values={values} onChange={onChange}
onSave={() => saveAI('image', ['image_provider','image_api_key','image_base_url'])}
saving={saving.image} success={success.image} error={errors.image}
/>
<ProviderCard title="Video" description="Testo → video, immagine → video."
providers={VIDEO_PROVIDERS} providerKey="video_provider" apiKey="video_api_key" modelKey="video_model"
baseUrlKey={currentVideo.needsBaseUrl ? 'video_base_url' : null}
currentProvider={currentVideo} values={values} onChange={onChange}
onSave={() => saveAI('video', ['video_provider','video_api_key','video_model','video_base_url'])}
saving={saving.video} success={success.video} error={errors.video}
/>
<ProviderCard title="Voiceover" description="Text-to-speech per voiceover AI."
providers={VOICE_PROVIDERS} providerKey="voice_provider" apiKey="voice_api_key"
baseUrlKey={currentVoice.needsBaseUrl ? 'voice_base_url' : null}
extraKey="elevenlabs_voice_id" extraLabel="Voice ID" extraPlaceholder="ID voce ElevenLabs (opzionale)"
currentProvider={currentVoice} values={values} onChange={onChange}
onSave={() => saveAI('voice', ['voice_provider','voice_api_key','voice_base_url','elevenlabs_voice_id'])}
saving={saving.voice} success={success.voice} error={errors.voice}
/>
</div>
)
}
function ModelSelector({ providerValue, modelValue, defaultModel, onChange }) {
const models = MODELS_BY_PROVIDER[providerValue] || []
const isKnownModel = models.some(m => m.value === modelValue)
const isCustom = modelValue && !isKnownModel
const [showCustom, setShowCustom] = React.useState(isCustom)
// Reset to dropdown when provider changes and current value isn't custom
React.useEffect(() => {
const providerModels = MODELS_BY_PROVIDER[providerValue] || []
if (providerModels.length === 0) {
setShowCustom(true)
} else if (modelValue && !providerModels.some(m => m.value === modelValue)) {
// Keep custom if user had typed something not in the list
setShowCustom(true)
} else {
setShowCustom(false)
}
}, [providerValue])
if (models.length === 0) {
// No catalog for this provider — show plain input
return (
<Field label="Modello">
<input type="text" value={modelValue} onChange={e => onChange(e.target.value)}
placeholder={defaultModel || 'nome-modello'} style={{ ...inputStyle, fontFamily: 'monospace' }}
onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} />
</Field>
)
}
return (
<Field label="Modello">
{!showCustom ? (
<div>
<select value={modelValue || defaultModel || ''} onChange={e => {
if (e.target.value === '__custom__') { setShowCustom(true); onChange('') }
else onChange(e.target.value)
}} style={selectStyle}>
{models.map(m => <option key={m.value} value={m.value}>{m.label}</option>)}
<option value="__custom__">Personalizzato...</option>
</select>
</div>
) : (
<div>
<input type="text" value={modelValue} onChange={e => onChange(e.target.value)}
placeholder={defaultModel || 'nome-modello'} style={{ ...inputStyle, fontFamily: 'monospace', marginBottom: '0.35rem' }}
onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} />
<button type="button" onClick={() => { setShowCustom(false); onChange(defaultModel || models[0]?.value || '') }}
style={{ fontSize: '0.75rem', color: 'var(--accent)', background: 'none', border: 'none', cursor: 'pointer', padding: 0 }}>
Torna alla lista modelli
</button>
</div>
)}
</Field>
)
}
function ProviderCard({ title, description, providers, providerKey, apiKey, modelKey, baseUrlKey, extraKey, extraLabel, extraPlaceholder, currentProvider, values, onChange, onSave, saving, success, error }) {
return (
<Card>
<div style={{ marginBottom: '1.25rem' }}>
<Label>{title}</Label>
<p style={{ fontSize: '0.8rem', color: 'var(--ink-muted)', margin: '0.2rem 0 0' }}>{description}</p>
</div>
{error && <Feedback type="error">{error}</Feedback>}
{success && <Feedback type="success">Salvato con successo</Feedback>}
<Field label="Provider">
<select value={values[providerKey] || providers[0].value} onChange={e => onChange(providerKey, e.target.value)} style={selectStyle}>
{providers.map(p => <option key={p.value} value={p.value}>{p.label}</option>)}
</select>
</Field>
{baseUrlKey && (
<Field label="Base URL">
<input type="text" value={values[baseUrlKey] || ''} onChange={e => 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)'} />
</Field>
)}
<Field label="API Key">
<input type="password" value={values[apiKey] || ''} onChange={e => 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)'} />
</Field>
{modelKey && (
<ModelSelector
providerValue={values[providerKey]}
modelValue={values[modelKey] || ''}
defaultModel={currentProvider?.defaultModel}
onChange={v => onChange(modelKey, v)}
/>
)}
{extraKey && (
<Field label={extraLabel}>
<input type="text" value={values[extraKey] || ''} onChange={e => 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)'} />
</Field>
)}
{/* API key guide */}
{(() => {
const guide = PROVIDER_GUIDES[values[providerKey]]
if (!guide) return null
return (
<GuideAccordion guide={guide} providerName={currentProvider?.label} />
)
})()}
<button onClick={onSave} disabled={saving} style={{ ...btnPrimary, marginTop: '0.25rem', opacity: saving ? 0.6 : 1 }}>
{saving ? 'Salvataggio…' : 'Salva'}
</button>
</Card>
)
}
function GuideAccordion({ guide, providerName }) {
const [open, setOpen] = React.useState(false)
if (!guide?.steps?.length) return null
return (
<div style={{ border: '1px solid var(--border)', marginBottom: '0.875rem', overflow: 'hidden' }}>
<button type="button" onClick={() => 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",
}}>
<span style={{ fontSize: '0.78rem', fontWeight: 600, color: 'var(--ink)' }}>Come ottenere la chiave API {providerName ? `(${providerName})` : ''}</span>
<span style={{ fontSize: '0.72rem', color: 'var(--ink-muted)' }}>{open ? '▲ Chiudi' : '▼ Mostra guida'}</span>
</button>
{open && (
<div style={{ padding: '1rem', backgroundColor: 'var(--cream-dark)', borderTop: '1px solid var(--border)' }}>
<ol style={{ margin: 0, paddingLeft: '1.25rem', display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
{guide.steps.map((step, i) => (
<li key={i} style={{ fontSize: '0.82rem', color: 'var(--ink)', lineHeight: 1.6 }}>{step}</li>
))}
</ol>
{guide.link && (
<a href={guide.link} target="_blank" rel="noreferrer" style={{
display: 'inline-block', marginTop: '0.75rem',
fontSize: '0.78rem', fontWeight: 600, color: 'var(--accent)', textDecoration: 'none',
}}>
Vai alla pagina
</a>
)}
</div>
)}
</div>
)
}
// ─── 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 (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
<SectionTitle>Sicurezza</SectionTitle>
<Card>
<Label>Cambio Password</Label>
{isGoogle ? (
<div style={{ marginTop: '0.75rem', padding: '1rem', backgroundColor: 'var(--cream-dark)', borderLeft: '3px solid var(--border-strong)' }}>
<p style={{ fontSize: '0.85rem', color: 'var(--ink-muted)', margin: 0 }}>
Il tuo account è collegato tramite Google. La password è gestita da Google puoi modificarla dal tuo account Google.
</p>
</div>
) : (
<>
{errors.pwd && <Feedback type="error">{errors.pwd}</Feedback>}
{success.pwd && <Feedback type="success">Password aggiornata con successo.</Feedback>}
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.875rem', marginTop: '0.75rem' }}>
<Field label="Password attuale">
<input type="password" value={form.current_password} onChange={e => setForm(p => ({ ...p, current_password: e.target.value }))}
placeholder="Password attuale" style={inputStyle}
onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} />
</Field>
<Field label="Nuova password (min. 8 caratteri)">
<input type="password" value={form.new_password} onChange={e => setForm(p => ({ ...p, new_password: e.target.value }))}
placeholder="Nuova password" style={inputStyle}
onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} />
</Field>
<Field label="Conferma nuova password">
<input type="password" value={form.confirm_password} onChange={e => setForm(p => ({ ...p, confirm_password: e.target.value }))}
placeholder="Ripeti la nuova password" style={inputStyle}
onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'}
onKeyDown={e => e.key === 'Enter' && handleChange()} />
</Field>
<button onClick={handleChange} disabled={saving.pwd} style={{ ...btnPrimary, alignSelf: 'flex-start', opacity: saving.pwd ? 0.6 : 1 }}>
{saving.pwd ? 'Aggiornamento…' : 'Aggiorna password'}
</button>
</div>
</>
)}
</Card>
</div>
)
}
// ─── 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 (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
<SectionTitle>Privacy & Dati</SectionTitle>
{/* Export */}
<Card>
<Label>Esporta i tuoi dati</Label>
<p style={{ fontSize: '0.82rem', color: 'var(--ink-muted)', margin: '0.4rem 0 1rem', lineHeight: 1.5 }}>
Scarica tutti i tuoi dati personali in formato JSON (richiesto dal GDPR, Art. 20 Portabilità dei dati).
</p>
<button onClick={handleExport} style={btnPrimary}>Scarica dati JSON</button>
</Card>
{/* Delete */}
<Card style={{ borderColor: '#FED7D7' }}>
<Label style={{ color: 'var(--error)' }}>Elimina account</Label>
<p style={{ fontSize: '0.82rem', color: 'var(--ink-muted)', margin: '0.4rem 0 1rem', lineHeight: 1.5 }}>
L'eliminazione è permanente e irreversibile. Tutti i tuoi dati (personaggi, contenuti, impostazioni) verranno cancellati.
</p>
{!showDeleteForm ? (
<button onClick={() => setShowDeleteForm(true)} style={{ ...btnPrimary, backgroundColor: 'var(--error)' }}>
Elimina il mio account
</button>
) : (
<div>
{errors.delete && <Feedback type="error">{errors.delete}</Feedback>}
<p style={{ fontSize: '0.82rem', fontWeight: 600, color: 'var(--ink)', margin: '0 0 0.5rem' }}>
Digita <code style={{ backgroundColor: 'var(--cream-dark)', padding: '0.1rem 0.35rem' }}>ELIMINA</code> per confermare:
</p>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<input type="text" value={deleteConfirm} onChange={e => 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)'} />
<button onClick={handleDelete} disabled={saving.delete} style={{ ...btnPrimary, backgroundColor: 'var(--error)', opacity: saving.delete ? 0.6 : 1 }}>
{saving.delete ? 'Eliminazione' : 'Conferma'}
</button>
<button onClick={() => { setShowDeleteForm(false); setDeleteConfirm('') }} style={{ ...btnPrimary, backgroundColor: 'var(--cream-dark)', color: '#1A1A1A', border: '1px solid #C8C0B4' }}>
Annulla
</button>
</div>
</div>
)}
</Card>
</div>
)
}
// ─── Shared components ────────────────────────────────────────────────────────
function SectionTitle({ children }) {
return (
<div style={{ marginBottom: '0.5rem' }}>
<h3 style={{ fontFamily: "'Fraunces', serif", fontSize: '1.2rem', fontWeight: 600, color: 'var(--ink)', margin: '0 0 0.4rem' }}>{children}</h3>
<div style={{ width: 32, height: 3, backgroundColor: 'var(--accent)' }} />
</div>
)
}
function Card({ children, style }) {
return (
<div style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)', padding: '1.5rem', borderTop: '4px solid var(--accent)', ...style }}>
{children}
</div>
)
}
function Label({ children, style }) {
return (
<span style={{ display: 'block', fontSize: '0.72rem', fontWeight: 700, letterSpacing: '0.1em', textTransform: 'uppercase', color: 'var(--ink)', ...style }}>
{children}
</span>
)
}
function Field({ label, children }) {
return (
<div style={{ marginBottom: '0.875rem' }}>
<label style={{ display: 'block', fontSize: '0.72rem', fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--ink)', marginBottom: '0.4rem' }}>{label}</label>
{children}
</div>
)
}
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 (
<div style={{ padding: '0.625rem 0.875rem', backgroundColor: colors.bg, border: `1px solid ${colors.border}`, color: colors.color, fontSize: '0.85rem', marginBottom: '0.875rem' }}>
{children}
</div>
)
}
function Spinner() {
return (
<div style={{ display: 'flex', justifyContent: 'center', padding: '3rem 0' }}>
<div style={{ width: 28, height: 28, border: '2px solid var(--border)', borderTopColor: 'var(--accent)', borderRadius: '50%', animation: 'spin 0.8s linear infinite' }} />
<style>{`@keyframes spin { to { transform: rotate(360deg) } }`}</style>
</div>
)
}
function EmbeddedSection({ title, description, children }) {
return (
<div>
<h3 style={{ fontFamily: "'Fraunces', serif", fontSize: '1.35rem', fontWeight: 600, color: 'var(--ink)', margin: '0 0 0.3rem' }}>{title}</h3>
<div className="editorial-line" />
<p style={{ fontSize: '0.85rem', color: 'var(--ink-muted)', margin: '0.5rem 0 1.5rem' }}>{description}</p>
{children}
</div>
)
}
function SocialAccountsEmbed() { return <SocialAccounts /> }
function AffiliateListEmbed() { return <AffiliateList /> }
function CommentsQueueEmbed() { return <CommentsQueue /> }
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',
}