Initial commit: Leopost Full — merge di Leopost, Post Generator e Autopilot OS
- Backend FastAPI con multi-LLM (Claude/OpenAI/Gemini) - Publishing su Facebook, Instagram, YouTube, TikTok - Calendario editoriale con awareness levels (PAS, AIDA, BAB...) - Design system Editorial Fresh (Fraunces + DM Sans) - Scheduler automatico, gestione commenti AI, affiliate links Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
334
frontend/src/components/ContentPage.jsx
Normal file
334
frontend/src/components/ContentPage.jsx
Normal file
@@ -0,0 +1,334 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { api } from '../api'
|
||||
|
||||
const cardStyle = {
|
||||
backgroundColor: 'var(--surface)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '0.75rem',
|
||||
padding: '1.5rem',
|
||||
}
|
||||
|
||||
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',
|
||||
}
|
||||
|
||||
export default function ContentPage() {
|
||||
const [characters, setCharacters] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [generated, setGenerated] = useState(null)
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [editText, setEditText] = useState('')
|
||||
|
||||
const [form, setForm] = useState({
|
||||
character_id: '',
|
||||
platform: 'instagram',
|
||||
content_type: 'text',
|
||||
topic_hint: '',
|
||||
include_affiliates: false,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/characters/').then(setCharacters).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const handleChange = (field, value) => {
|
||||
setForm((prev) => ({ ...prev, [field]: value }))
|
||||
}
|
||||
|
||||
const handleGenerate = async (e) => {
|
||||
e.preventDefault()
|
||||
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,
|
||||
topic_hint: form.topic_hint || null,
|
||||
include_affiliates: form.include_affiliates,
|
||||
})
|
||||
setGenerated(data)
|
||||
setEditText(data.text_content || '')
|
||||
} catch (err) {
|
||||
setError(err.message || 'Errore nella generazione')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
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')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveEdit = async () => {
|
||||
if (!generated) return
|
||||
try {
|
||||
await api.put(`/content/posts/${generated.id}`, { text_content: editText })
|
||||
setGenerated((prev) => ({ ...prev, text_content: editText }))
|
||||
setEditing(false)
|
||||
} 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',
|
||||
}
|
||||
|
||||
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
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-600 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Generation form */}
|
||||
<div style={cardStyle}>
|
||||
<h3 className="font-semibold text-sm uppercase tracking-wider mb-4" style={{ color: 'var(--muted)' }}>
|
||||
Genera Contenuto
|
||||
</h3>
|
||||
|
||||
<form onSubmit={handleGenerate} className="space-y-4">
|
||||
<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>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<input
|
||||
type="text"
|
||||
value={form.topic_hint}
|
||||
onChange={(e) => handleChange('topic_hint', e.target.value)}
|
||||
placeholder="Es. ultimi trend, tutorial..."
|
||||
style={inputStyle}
|
||||
/>
|
||||
</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>
|
||||
</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 }}
|
||||
>
|
||||
{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'}
|
||||
</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>
|
||||
|
||||
{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>
|
||||
) : 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>
|
||||
|
||||
{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>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 rounded-lg" style={{ backgroundColor: 'var(--cream)' }}>
|
||||
<p className="text-sm whitespace-pre-wrap leading-relaxed" style={{ color: 'var(--ink)' }}>
|
||||
{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.map((tag, i) => (
|
||||
<span key={i} className="text-xs px-2 py-0.5 rounded" style={{ backgroundColor: '#FFF0EC', color: 'var(--coral)' }}>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 pt-3 border-t" style={{ borderColor: 'var(--border)' }}>
|
||||
{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>
|
||||
)}
|
||||
{!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>
|
||||
</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"
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user