feat: sync all BRAIN mobile changes - onboarding, cookies, legal, mobile UX, settings
- 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>
This commit is contained in:
@@ -1,27 +1,43 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { api } from '../api'
|
||||
|
||||
const cardStyle = {
|
||||
backgroundColor: 'var(--surface)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '0.75rem',
|
||||
padding: '1.5rem',
|
||||
}
|
||||
const PLATFORMS = [
|
||||
{ value: 'instagram', label: 'Instagram' },
|
||||
{ value: 'facebook', label: 'Facebook' },
|
||||
{ value: 'youtube', label: 'YouTube' },
|
||||
{ value: 'tiktok', label: 'TikTok' },
|
||||
]
|
||||
|
||||
const inputStyle = {
|
||||
width: '100%',
|
||||
padding: '0.625rem 1rem',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '0.5rem',
|
||||
fontSize: '0.875rem',
|
||||
color: 'var(--ink)',
|
||||
backgroundColor: 'var(--cream)',
|
||||
outline: 'none',
|
||||
const CONTENT_TYPES = [
|
||||
{ value: 'text', label: 'Testo' },
|
||||
{ value: 'image', label: 'Immagine' },
|
||||
{ value: 'video', label: 'Video' },
|
||||
{ value: 'reel', label: 'Reel/Short' },
|
||||
{ value: 'story', label: 'Story' },
|
||||
]
|
||||
|
||||
const TECNICHE_NARRATIVE = [
|
||||
{ value: 'PAS', label: 'PAS', desc: 'Problema → Agitazione → Soluzione' },
|
||||
{ value: 'AIDA', label: 'AIDA', desc: 'Attenzione → Interesse → Desiderio → Azione' },
|
||||
{ value: 'Storytelling', label: 'Storytelling', desc: 'Narrazione con arco emotivo' },
|
||||
{ value: 'Tutorial', label: 'Tutorial', desc: 'Step-by-step educativo' },
|
||||
{ value: 'Listicle', label: 'Listicle', desc: 'Lista di consigli/esempi' },
|
||||
{ value: 'Social_proof', label: 'Social Proof', desc: 'Risultati, testimonianze, numeri' },
|
||||
{ value: 'Hook_domanda', label: 'Hook + Domanda', desc: 'Apertura con domanda provocatoria' },
|
||||
]
|
||||
|
||||
const STATUS_LABELS = { approved: 'Approvato', published: 'Pubblicato', draft: 'Bozza' }
|
||||
const STATUS_COLORS = {
|
||||
approved: { bg: 'var(--success-light)', color: 'var(--success)' },
|
||||
published: { bg: '#EFF6FF', color: '#1D4ED8' },
|
||||
draft: { bg: '#FFFBEB', color: '#B45309' },
|
||||
}
|
||||
|
||||
export default function ContentPage() {
|
||||
const [characters, setCharacters] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [charsLoading, setCharsLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [generated, setGenerated] = useState(null)
|
||||
const [editing, setEditing] = useState(false)
|
||||
@@ -29,35 +45,41 @@ export default function ContentPage() {
|
||||
|
||||
const [form, setForm] = useState({
|
||||
character_id: '',
|
||||
platform: 'instagram',
|
||||
content_type: 'text',
|
||||
brief: '',
|
||||
platforms: ['instagram'],
|
||||
content_types: ['text'],
|
||||
topic_hint: '',
|
||||
tecnica: '',
|
||||
include_affiliates: false,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/characters/').then(setCharacters).catch(() => {})
|
||||
api.get('/characters/').then(d => { setCharacters(d); setCharsLoading(false) }).catch(() => setCharsLoading(false))
|
||||
}, [])
|
||||
|
||||
const handleChange = (field, value) => {
|
||||
setForm((prev) => ({ ...prev, [field]: value }))
|
||||
const toggleChip = (field, value) => {
|
||||
setForm(prev => {
|
||||
const arr = prev[field]
|
||||
return { ...prev, [field]: arr.includes(value) ? (arr.length > 1 ? arr.filter(v => v !== value) : arr) : [...arr, value] }
|
||||
})
|
||||
}
|
||||
|
||||
const handleGenerate = async (e) => {
|
||||
e.preventDefault()
|
||||
if (!form.character_id) {
|
||||
setError('Seleziona un personaggio')
|
||||
return
|
||||
}
|
||||
if (!form.character_id) { setError('Seleziona un personaggio'); return }
|
||||
setError('')
|
||||
setLoading(true)
|
||||
setGenerated(null)
|
||||
try {
|
||||
const data = await api.post('/content/generate', {
|
||||
character_id: parseInt(form.character_id),
|
||||
platform: form.platform,
|
||||
content_type: form.content_type,
|
||||
platforms: form.platforms,
|
||||
content_types: form.content_types,
|
||||
topic_hint: form.topic_hint || null,
|
||||
brief: [
|
||||
form.tecnica ? `Tecnica narrativa: ${form.tecnica}` : '',
|
||||
form.brief,
|
||||
].filter(Boolean).join('. ') || null,
|
||||
include_affiliates: form.include_affiliates,
|
||||
})
|
||||
setGenerated(data)
|
||||
@@ -70,222 +92,247 @@ export default function ContentPage() {
|
||||
}
|
||||
|
||||
const handleApprove = async () => {
|
||||
if (!generated) return
|
||||
try {
|
||||
await api.post(`/content/posts/${generated.id}/approve`)
|
||||
setGenerated((prev) => ({ ...prev, status: 'approved' }))
|
||||
} catch (err) {
|
||||
setError(err.message || 'Errore approvazione')
|
||||
}
|
||||
setGenerated(prev => ({ ...prev, status: 'approved' }))
|
||||
} catch (err) { setError(err.message || 'Errore approvazione') }
|
||||
}
|
||||
|
||||
const handleSaveEdit = async () => {
|
||||
if (!generated) return
|
||||
try {
|
||||
await api.put(`/content/posts/${generated.id}`, { text_content: editText })
|
||||
setGenerated((prev) => ({ ...prev, text_content: editText }))
|
||||
setGenerated(prev => ({ ...prev, text_content: editText }))
|
||||
setEditing(false)
|
||||
} catch (err) {
|
||||
setError(err.message || 'Errore salvataggio')
|
||||
}
|
||||
} catch (err) { setError(err.message || 'Errore salvataggio') }
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!generated) return
|
||||
if (!confirm('Eliminare questo contenuto?')) return
|
||||
try {
|
||||
await api.delete(`/content/posts/${generated.id}`)
|
||||
setGenerated(null)
|
||||
} catch (err) {
|
||||
setError(err.message || 'Errore eliminazione')
|
||||
}
|
||||
}
|
||||
|
||||
const platformLabels = {
|
||||
instagram: 'Instagram',
|
||||
facebook: 'Facebook',
|
||||
youtube: 'YouTube',
|
||||
tiktok: 'TikTok',
|
||||
}
|
||||
|
||||
const contentTypeLabels = {
|
||||
text: 'Testo',
|
||||
image: 'Immagine',
|
||||
video: 'Video',
|
||||
} catch (err) { setError(err.message || 'Errore eliminazione') }
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h2 className="text-2xl font-bold" style={{ color: 'var(--ink)' }}>Contenuti</h2>
|
||||
<p className="mt-1 text-sm" style={{ color: 'var(--muted)' }}>
|
||||
Genera e gestisci contenuti per i tuoi personaggi
|
||||
<div style={{ animation: 'fade-up 0.5s ease-out both' }}>
|
||||
{/* Header */}
|
||||
<div style={{ marginBottom: '2rem' }}>
|
||||
<span className="editorial-tag">Contenuti</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.3rem' }}>
|
||||
Genera Contenuti
|
||||
</h2>
|
||||
<p style={{ fontSize: '0.875rem', color: 'var(--ink-muted)', margin: 0 }}>
|
||||
Definisci un brief editoriale, scegli piattaforma e tipo, poi genera. L'AI terrà conto del tono e dei topic del personaggio selezionato.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* No characters → gate */}
|
||||
{!charsLoading && characters.length === 0 && (
|
||||
<div style={{ padding: '3rem 2rem', textAlign: 'center', backgroundColor: 'var(--surface)', border: '1px solid var(--border)', borderTop: '4px solid var(--accent)', marginBottom: '1.5rem' }}>
|
||||
<div style={{ fontSize: '2rem', color: 'var(--accent)', marginBottom: '1rem' }}>◎</div>
|
||||
<h3 style={{ fontFamily: "'Fraunces', serif", fontSize: '1.1rem', fontWeight: 600, color: 'var(--ink)', margin: '0 0 0.5rem' }}>Prima crea un Personaggio</h3>
|
||||
<p style={{ fontSize: '0.875rem', color: 'var(--ink-muted)', maxWidth: 360, margin: '0 auto 1.25rem', lineHeight: 1.6 }}>
|
||||
Per generare contenuti serve almeno un personaggio che definisca la voce editoriale (tono, nicchia, pubblico).
|
||||
</p>
|
||||
<Link to="/characters/new" style={btnPrimary}>Crea il tuo primo Personaggio →</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-600 text-sm">
|
||||
<div style={{ padding: '0.75rem 1rem', backgroundColor: 'var(--error-light)', border: '1px solid #FED7D7', color: 'var(--error)', fontSize: '0.875rem', marginBottom: '1rem' }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))', gap: '1.25rem' }}>
|
||||
{/* Generation form */}
|
||||
<div style={cardStyle}>
|
||||
<h3 className="font-semibold text-sm uppercase tracking-wider mb-4" style={{ color: 'var(--muted)' }}>
|
||||
Genera Contenuto
|
||||
</h3>
|
||||
<div style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)', borderTop: '4px solid var(--accent)', padding: '1.5rem' }}>
|
||||
<div style={{ marginBottom: '1.25rem' }}>
|
||||
<span style={labelStyle}>Genera Contenuto</span>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleGenerate} className="space-y-4">
|
||||
<form onSubmit={handleGenerate} style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
|
||||
{/* Character select */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--ink)' }}>Personaggio</label>
|
||||
<select
|
||||
value={form.character_id}
|
||||
onChange={(e) => handleChange('character_id', e.target.value)}
|
||||
style={inputStyle}
|
||||
required
|
||||
>
|
||||
<option value="">Seleziona personaggio...</option>
|
||||
{characters.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
<label style={labelStyle}>Personaggio</label>
|
||||
<select value={form.character_id} onChange={e => setForm(p => ({ ...p, character_id: e.target.value }))} style={inputStyle} required>
|
||||
<option value="">Seleziona personaggio…</option>
|
||||
{characters.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Prompt / Brief strategico */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--ink)' }}>Piattaforma</label>
|
||||
<select
|
||||
value={form.platform}
|
||||
onChange={(e) => handleChange('platform', e.target.value)}
|
||||
style={inputStyle}
|
||||
>
|
||||
{Object.entries(platformLabels).map(([val, label]) => (
|
||||
<option key={val} value={val}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--ink)' }}>Tipo contenuto</label>
|
||||
<select
|
||||
value={form.content_type}
|
||||
onChange={(e) => handleChange('content_type', e.target.value)}
|
||||
style={inputStyle}
|
||||
>
|
||||
{Object.entries(contentTypeLabels).map(([val, label]) => (
|
||||
<option key={val} value={val}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--ink)' }}>
|
||||
Suggerimento tema <span className="font-normal" style={{ color: 'var(--muted)' }}>(opzionale)</span>
|
||||
<label style={{ ...labelStyle, marginBottom: '0.5rem' }}>
|
||||
Prompt & Strategia del Post
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.topic_hint}
|
||||
onChange={(e) => handleChange('topic_hint', e.target.value)}
|
||||
placeholder="Es. ultimi trend, tutorial..."
|
||||
style={inputStyle}
|
||||
/>
|
||||
|
||||
{/* Tecnica narrativa chips */}
|
||||
<div style={{ marginBottom: '0.75rem' }}>
|
||||
<p style={{ fontSize: '0.7rem', fontWeight: 600, letterSpacing: '0.06em', textTransform: 'uppercase', color: 'var(--ink-muted)', margin: '0 0 0.4rem' }}>Tecnica narrativa</p>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.35rem' }}>
|
||||
{TECNICHE_NARRATIVE.map(t => (
|
||||
<button key={t.value} type="button" onClick={() => setForm(p => ({ ...p, tecnica: p.tecnica === t.value ? '' : t.value }))} title={t.desc} style={{
|
||||
padding: '0.3rem 0.75rem', fontSize: '0.78rem', fontFamily: "'DM Sans', sans-serif",
|
||||
border: 'none', cursor: 'pointer',
|
||||
backgroundColor: form.tecnica === t.value ? '#1A1A1A' : 'var(--cream-dark)',
|
||||
color: form.tecnica === t.value ? 'white' : 'var(--ink-light)',
|
||||
fontWeight: form.tecnica === t.value ? 600 : 400, transition: 'background-color 0.15s',
|
||||
}}>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{form.tecnica && (
|
||||
<p style={{ fontSize: '0.7rem', color: 'var(--ink-muted)', margin: '0.3rem 0 0' }}>
|
||||
{TECNICHE_NARRATIVE.find(t => t.value === form.tecnica)?.desc}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Brief textarea */}
|
||||
<textarea value={form.brief} onChange={e => setForm(p => ({ ...p, brief: e.target.value }))}
|
||||
placeholder="Descrivi in dettaglio cosa vuoi comunicare: obiettivo del post, a chi ti rivolgi, angolo narrativo, call to action, cosa deve provare chi legge. Es: 'Tutorial per principianti su sourdough — tono incoraggiante, mostra che è più facile del previsto, CTA: salva la ricetta e taggami nel risultato'"
|
||||
rows={4} style={{ ...inputStyle, resize: 'vertical', lineHeight: 1.6 }}
|
||||
onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} />
|
||||
<p style={{ fontSize: '0.72rem', color: 'var(--ink-muted)', margin: '0.3rem 0 0', lineHeight: 1.5 }}>
|
||||
Più sei specifico, più il contenuto sarà preciso e pubblicabile senza revisioni.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.include_affiliates}
|
||||
onChange={(e) => handleChange('include_affiliates', e.target.checked)}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-9 h-5 bg-slate-200 rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-coral"></div>
|
||||
</label>
|
||||
<span className="text-sm" style={{ color: 'var(--ink)' }}>Includi link affiliati</span>
|
||||
{/* Platform chips */}
|
||||
<div>
|
||||
<label style={labelStyle}>Piattaforme <span style={{ fontWeight: 400, textTransform: 'none', letterSpacing: 0, color: 'var(--ink-muted)', fontSize: '0.75rem' }}>(seleziona una o più)</span></label>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem', marginTop: '0.4rem' }}>
|
||||
{PLATFORMS.map(p => {
|
||||
const active = form.platforms.includes(p.value)
|
||||
return (
|
||||
<button key={p.value} type="button" onClick={() => toggleChip('platforms', p.value)} style={{
|
||||
padding: '0.35rem 0.875rem', fontSize: '0.82rem', fontWeight: active ? 600 : 400,
|
||||
fontFamily: "'DM Sans', sans-serif", border: 'none', cursor: 'pointer',
|
||||
backgroundColor: active ? 'var(--ink)' : 'var(--cream-dark)',
|
||||
color: active ? 'white' : 'var(--ink-light)',
|
||||
transition: 'background-color 0.15s, color 0.15s',
|
||||
}}>
|
||||
{p.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-2.5 text-white font-medium rounded-lg transition-opacity text-sm"
|
||||
style={{ backgroundColor: 'var(--coral)', opacity: loading ? 0.7 : 1 }}
|
||||
>
|
||||
{/* Content type chips */}
|
||||
<div>
|
||||
<label style={labelStyle}>Tipo di contenuto <span style={{ fontWeight: 400, textTransform: 'none', letterSpacing: 0, color: 'var(--ink-muted)', fontSize: '0.75rem' }}>(seleziona uno o più)</span></label>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem', marginTop: '0.4rem' }}>
|
||||
{CONTENT_TYPES.map(t => {
|
||||
const active = form.content_types.includes(t.value)
|
||||
return (
|
||||
<button key={t.value} type="button" onClick={() => toggleChip('content_types', t.value)} style={{
|
||||
padding: '0.35rem 0.875rem', fontSize: '0.82rem', fontWeight: active ? 600 : 400,
|
||||
fontFamily: "'DM Sans', sans-serif", border: 'none', cursor: 'pointer',
|
||||
backgroundColor: active ? 'var(--accent)' : 'var(--cream-dark)',
|
||||
color: active ? 'white' : 'var(--ink-light)',
|
||||
transition: 'background-color 0.15s, color 0.15s',
|
||||
}}>
|
||||
{t.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Topic hint */}
|
||||
<div>
|
||||
<label style={labelStyle}>Parola chiave / Topic <span style={{ fontWeight: 400, textTransform: 'none', letterSpacing: 0, color: 'var(--ink-muted)', fontSize: '0.75rem' }}>(opzionale)</span></label>
|
||||
<input type="text" value={form.topic_hint} onChange={e => setForm(p => ({ ...p, topic_hint: e.target.value }))}
|
||||
placeholder="Es. ricetta pasta, trend primavera, lancio prodotto…" style={inputStyle}
|
||||
onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} />
|
||||
</div>
|
||||
|
||||
{/* Affiliates toggle */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
<button type="button" onClick={() => setForm(p => ({ ...p, include_affiliates: !p.include_affiliates }))} style={{
|
||||
width: 44, height: 24, borderRadius: 12, border: 'none', cursor: 'pointer',
|
||||
backgroundColor: form.include_affiliates ? 'var(--accent)' : 'var(--border-strong)',
|
||||
position: 'relative', transition: 'background-color 0.2s',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<span style={{
|
||||
position: 'absolute', top: 2, left: form.include_affiliates ? 22 : 2,
|
||||
width: 20, height: 20, borderRadius: '50%', backgroundColor: 'white',
|
||||
transition: 'left 0.2s',
|
||||
}} />
|
||||
</button>
|
||||
<span style={{ fontSize: '0.875rem', color: 'var(--ink)' }}>Includi link affiliati</span>
|
||||
</div>
|
||||
|
||||
<button type="submit" disabled={loading || characters.length === 0} style={{
|
||||
...btnPrimary, width: '100%', justifyContent: 'center', display: 'flex', alignItems: 'center', gap: '0.5rem',
|
||||
opacity: (loading || characters.length === 0) ? 0.6 : 1,
|
||||
padding: '0.75rem',
|
||||
}}>
|
||||
{loading ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<span className="animate-spin rounded-full h-4 w-4 border-2 border-white border-t-transparent" />
|
||||
Generazione in corso...
|
||||
</span>
|
||||
) : 'Genera'}
|
||||
<>
|
||||
<span style={{ width: 16, height: 16, border: '2px solid rgba(255,255,255,0.3)', borderTopColor: 'white', borderRadius: '50%', animation: 'spin 0.8s linear infinite', display: 'inline-block' }} />
|
||||
Generazione in corso…
|
||||
</>
|
||||
) : '✦ Genera'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
<div style={cardStyle}>
|
||||
<h3 className="font-semibold text-sm uppercase tracking-wider mb-4" style={{ color: 'var(--muted)' }}>
|
||||
Ultimo Contenuto Generato
|
||||
</h3>
|
||||
{/* Preview panel */}
|
||||
<div style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)', borderTop: '4px solid var(--border-strong)', padding: '1.5rem' }}>
|
||||
<span style={labelStyle}>Contenuto Generato</span>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex flex-col items-center justify-center py-16">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-2 border-t-transparent" style={{ borderColor: 'var(--coral)' }} />
|
||||
<p className="text-sm mt-3" style={{ color: 'var(--muted)' }}>Generazione in corso...</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '4rem 0' }}>
|
||||
<div style={{ width: 32, height: 32, border: '2px solid var(--border)', borderTopColor: 'var(--accent)', borderRadius: '50%', animation: 'spin 0.8s linear infinite', marginBottom: '1rem' }} />
|
||||
<p style={{ fontSize: '0.875rem', color: 'var(--ink-muted)' }}>Generazione in corso…</p>
|
||||
</div>
|
||||
) : generated ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${
|
||||
generated.status === 'approved' ? 'bg-emerald-50 text-emerald-600' :
|
||||
generated.status === 'published' ? 'bg-blue-50 text-blue-600' :
|
||||
'bg-amber-50 text-amber-600'
|
||||
}`}>
|
||||
{generated.status === 'approved' ? 'Approvato' :
|
||||
generated.status === 'published' ? 'Pubblicato' : 'Bozza'}
|
||||
</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-slate-100 text-slate-600">
|
||||
{platformLabels[generated.platform_hint] || generated.platform_hint}
|
||||
</span>
|
||||
<div style={{ marginTop: '1rem' }}>
|
||||
{/* Status + platform badges */}
|
||||
<div style={{ display: 'flex', gap: '0.4rem', flexWrap: 'wrap', marginBottom: '1rem' }}>
|
||||
{(() => { const sc = STATUS_COLORS[generated.status] || STATUS_COLORS.draft; return (
|
||||
<span style={{ fontSize: '0.72rem', fontWeight: 700, padding: '0.2rem 0.5rem', backgroundColor: sc.bg, color: sc.color }}>
|
||||
{STATUS_LABELS[generated.status] || generated.status}
|
||||
</span>
|
||||
)})()}
|
||||
{generated.platform_hint && (
|
||||
<span style={{ fontSize: '0.72rem', fontWeight: 600, padding: '0.2rem 0.5rem', backgroundColor: 'var(--cream-dark)', color: 'var(--ink-muted)' }}>
|
||||
{generated.platform_hint}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{editing ? (
|
||||
<div className="space-y-2">
|
||||
<textarea
|
||||
value={editText}
|
||||
onChange={(e) => setEditText(e.target.value)}
|
||||
rows={8}
|
||||
className="w-full px-4 py-2.5 rounded-lg text-sm resize-none focus:outline-none"
|
||||
style={{ border: '1px solid var(--border)', color: 'var(--ink)' }}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleSaveEdit}
|
||||
className="px-3 py-1.5 text-white text-xs rounded-lg"
|
||||
style={{ backgroundColor: 'var(--coral)' }}
|
||||
>
|
||||
Salva
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setEditing(false); setEditText(generated.text_content || '') }}
|
||||
className="px-3 py-1.5 bg-slate-100 text-slate-600 text-xs rounded-lg"
|
||||
>
|
||||
Annulla
|
||||
</button>
|
||||
<div>
|
||||
<textarea value={editText} onChange={e => setEditText(e.target.value)} rows={8}
|
||||
style={{ ...inputStyle, resize: 'vertical', lineHeight: 1.6 }}
|
||||
onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} />
|
||||
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.5rem' }}>
|
||||
<button onClick={handleSaveEdit} style={btnPrimary}>Salva</button>
|
||||
<button onClick={() => { setEditing(false); setEditText(generated.text_content || '') }} style={btnSecondary}>Annulla</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 rounded-lg" style={{ backgroundColor: 'var(--cream)' }}>
|
||||
<p className="text-sm whitespace-pre-wrap leading-relaxed" style={{ color: 'var(--ink)' }}>
|
||||
<div style={{ padding: '1rem', backgroundColor: 'var(--cream)', marginBottom: '1rem' }}>
|
||||
<p style={{ fontSize: '0.875rem', whiteSpace: 'pre-wrap', lineHeight: 1.7, color: 'var(--ink)', margin: 0 }}>
|
||||
{generated.text_content}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{generated.hashtags && generated.hashtags.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-medium mb-1.5" style={{ color: 'var(--muted)' }}>Hashtag</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{generated.hashtags?.length > 0 && (
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<span style={{ ...labelStyle, display: 'block', marginBottom: '0.4rem' }}>Hashtag</span>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.35rem' }}>
|
||||
{generated.hashtags.map((tag, i) => (
|
||||
<span key={i} className="text-xs px-2 py-0.5 rounded" style={{ backgroundColor: '#FFF0EC', color: 'var(--coral)' }}>
|
||||
<span key={i} style={{ fontSize: '0.78rem', padding: '0.15rem 0.5rem', backgroundColor: 'var(--accent-light)', color: 'var(--accent)' }}>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
@@ -293,42 +340,49 @@ export default function ContentPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 pt-3 border-t" style={{ borderColor: 'var(--border)' }}>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', paddingTop: '1rem', borderTop: '1px solid var(--border)', flexWrap: 'wrap' }}>
|
||||
{generated.status !== 'approved' && (
|
||||
<button
|
||||
onClick={handleApprove}
|
||||
className="text-xs px-3 py-1.5 bg-emerald-50 hover:bg-emerald-100 text-emerald-600 rounded-lg transition-colors font-medium"
|
||||
>
|
||||
Approva
|
||||
</button>
|
||||
<button onClick={handleApprove} style={{ ...btnPrimary, backgroundColor: 'var(--success)' }}>Approva</button>
|
||||
)}
|
||||
{!editing && (
|
||||
<button
|
||||
onClick={() => setEditing(true)}
|
||||
className="text-xs px-3 py-1.5 bg-slate-100 hover:bg-slate-200 text-slate-600 rounded-lg transition-colors"
|
||||
>
|
||||
Modifica
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="text-xs px-3 py-1.5 text-red-500 hover:bg-red-50 rounded-lg transition-colors ml-auto"
|
||||
>
|
||||
Elimina
|
||||
</button>
|
||||
{!editing && <button onClick={() => setEditing(true)} style={btnSecondary}>Modifica</button>}
|
||||
<button onClick={handleDelete} style={{ ...btnSecondary, marginLeft: 'auto', color: 'var(--error)' }}>Elimina</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<p className="text-4xl mb-3">✦</p>
|
||||
<p className="font-medium" style={{ color: 'var(--ink)' }}>Nessun contenuto generato</p>
|
||||
<p className="text-sm mt-1" style={{ color: 'var(--muted)' }}>
|
||||
Compila il form e clicca "Genera"
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '4rem 1rem', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '2.5rem', color: 'var(--border-strong)', marginBottom: '1rem' }}>✦</div>
|
||||
<p style={{ fontFamily: "'Fraunces', serif", fontSize: '1rem', color: 'var(--ink)', margin: '0 0 0.5rem' }}>Nessun contenuto ancora</p>
|
||||
<p style={{ fontSize: '0.82rem', color: 'var(--ink-muted)', margin: 0 }}>
|
||||
Scrivi un brief, seleziona personaggio e piattaforma, poi clicca "Genera"
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<style>{`@keyframes spin { to { transform: rotate(360deg) } }`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const labelStyle = {
|
||||
fontSize: '0.72rem', fontWeight: 700, letterSpacing: '0.1em',
|
||||
textTransform: 'uppercase', color: 'var(--ink)',
|
||||
}
|
||||
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 btnPrimary = {
|
||||
display: 'inline-block', padding: '0.55rem 1.1rem',
|
||||
backgroundColor: 'var(--ink)', color: 'white',
|
||||
fontFamily: "'DM Sans', sans-serif", fontWeight: 600,
|
||||
fontSize: '0.875rem', border: 'none', cursor: 'pointer',
|
||||
}
|
||||
const btnSecondary = {
|
||||
...btnPrimary,
|
||||
backgroundColor: 'var(--cream-dark)', color: 'var(--ink-light)',
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user