- Add OnboardingWizard, BetaBanner, CookieBanner components - Add legal pages (Privacy, Terms, Cookies) - Update Layout with mobile topbar, sidebar drawer, plan banner - Update SettingsPage with profile, API config, security - Update CharacterForm with topic suggestions, niche chips - Update EditorialCalendar with shared strategy card - Update ContentPage with narrative technique + brief - Update SocialAccounts with 4 platforms and token guides - Fix CSS button color inheritance, mobile responsive - Add backup script - Update .gitignore for pgdata and backups Co-Authored-By: Claude (BRAIN/StackOS) <noreply@anthropic.com>
771 lines
36 KiB
JavaScript
771 lines
36 KiB
JavaScript
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 (
|
|
<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 === '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'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 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 && (
|
|
<Field label={`Modello${currentProvider?.defaultModel ? ` (default: ${currentProvider.defaultModel})` : ''}`}>
|
|
<input type="text" value={values[modelKey] || ''} onChange={e => 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)'} />
|
|
</Field>
|
|
)}
|
|
{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>
|
|
)
|
|
}
|
|
|
|
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',
|
|
}
|