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:
227
frontend/src/components/ContentArchive.jsx
Normal file
227
frontend/src/components/ContentArchive.jsx
Normal file
@@ -0,0 +1,227 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { api } from '../api'
|
||||
|
||||
const statusLabels = {
|
||||
draft: 'Bozza',
|
||||
approved: 'Approvato',
|
||||
scheduled: 'Schedulato',
|
||||
published: 'Pubblicato',
|
||||
}
|
||||
|
||||
const statusColors = {
|
||||
draft: 'bg-amber-50 text-amber-600',
|
||||
approved: 'bg-emerald-50 text-emerald-600',
|
||||
scheduled: 'bg-blue-50 text-blue-600',
|
||||
published: 'bg-violet-50 text-violet-600',
|
||||
}
|
||||
|
||||
const platformLabels = {
|
||||
instagram: 'Instagram',
|
||||
facebook: 'Facebook',
|
||||
youtube: 'YouTube',
|
||||
tiktok: 'TikTok',
|
||||
}
|
||||
|
||||
export default function ContentArchive() {
|
||||
const [posts, setPosts] = useState([])
|
||||
const [characters, setCharacters] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [expandedId, setExpandedId] = useState(null)
|
||||
const [filterCharacter, setFilterCharacter] = useState('')
|
||||
const [filterStatus, setFilterStatus] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [])
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [postsData, charsData] = await Promise.all([
|
||||
api.get('/content/posts'),
|
||||
api.get('/characters/'),
|
||||
])
|
||||
setPosts(postsData)
|
||||
setCharacters(charsData)
|
||||
} catch {
|
||||
// silent
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getCharacterName = (id) => {
|
||||
const c = characters.find((ch) => ch.id === id)
|
||||
return c ? c.name : '—'
|
||||
}
|
||||
|
||||
const handleApprove = async (postId) => {
|
||||
try {
|
||||
await api.post(`/content/posts/${postId}/approve`)
|
||||
loadData()
|
||||
} catch {
|
||||
// silent
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (postId) => {
|
||||
if (!confirm('Eliminare questo contenuto?')) return
|
||||
try {
|
||||
await api.delete(`/content/posts/${postId}`)
|
||||
loadData()
|
||||
} catch {
|
||||
// silent
|
||||
}
|
||||
}
|
||||
|
||||
const filtered = posts.filter((p) => {
|
||||
if (filterCharacter && String(p.character_id) !== filterCharacter) return false
|
||||
if (filterStatus && p.status !== filterStatus) return false
|
||||
return true
|
||||
})
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return '—'
|
||||
const d = new Date(dateStr)
|
||||
return d.toLocaleDateString('it-IT', { day: '2-digit', month: 'short', year: 'numeric' })
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-slate-800">Archivio Contenuti</h2>
|
||||
<p className="text-slate-500 mt-1 text-sm">
|
||||
Tutti i contenuti generati
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap gap-3 mb-6">
|
||||
<select
|
||||
value={filterCharacter}
|
||||
onChange={(e) => setFilterCharacter(e.target.value)}
|
||||
className="px-3 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent text-sm bg-white"
|
||||
>
|
||||
<option value="">Tutti i personaggi</option>
|
||||
{characters.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={filterStatus}
|
||||
onChange={(e) => setFilterStatus(e.target.value)}
|
||||
className="px-3 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent text-sm bg-white"
|
||||
>
|
||||
<option value="">Tutti gli stati</option>
|
||||
{Object.entries(statusLabels).map(([val, label]) => (
|
||||
<option key={val} value={val}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<span className="flex items-center text-xs text-slate-400 ml-auto">
|
||||
{filtered.length} contenut{filtered.length === 1 ? 'o' : 'i'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-2 border-brand-500 border-t-transparent" />
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="text-center py-16 bg-white rounded-xl border border-slate-200">
|
||||
<p className="text-4xl mb-3">✦</p>
|
||||
<p className="text-slate-500 font-medium">Nessun contenuto trovato</p>
|
||||
<p className="text-slate-400 text-sm mt-1">
|
||||
{posts.length === 0
|
||||
? 'Genera il tuo primo contenuto dalla pagina Contenuti'
|
||||
: 'Prova a cambiare i filtri'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filtered.map((post) => (
|
||||
<div
|
||||
key={post.id}
|
||||
className="bg-white rounded-xl border border-slate-200 hover:border-brand-300 transition-all overflow-hidden cursor-pointer"
|
||||
onClick={() => setExpandedId(expandedId === post.id ? null : post.id)}
|
||||
>
|
||||
<div className="p-5">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${statusColors[post.status] || 'bg-slate-100 text-slate-500'}`}>
|
||||
{statusLabels[post.status] || post.status}
|
||||
</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-slate-100 text-slate-600">
|
||||
{platformLabels[post.platform] || post.platform}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Character name */}
|
||||
<p className="text-xs font-medium text-slate-500 mb-1">
|
||||
{getCharacterName(post.character_id)}
|
||||
</p>
|
||||
|
||||
{/* Text preview */}
|
||||
<p className={`text-sm text-slate-700 ${expandedId === post.id ? 'whitespace-pre-wrap' : 'line-clamp-3'}`}>
|
||||
{post.text}
|
||||
</p>
|
||||
|
||||
{/* Expanded details */}
|
||||
{expandedId === post.id && (
|
||||
<div className="mt-3 space-y-2">
|
||||
{post.hashtags && post.hashtags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{post.hashtags.map((tag, i) => (
|
||||
<span key={i} className="text-xs px-1.5 py-0.5 bg-brand-50 text-brand-700 rounded">
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between mt-3 pt-3 border-t border-slate-100">
|
||||
<span className="text-xs text-slate-400">
|
||||
{formatDate(post.created_at)}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{post.hashtags && (
|
||||
<span className="text-xs text-slate-400">
|
||||
{post.hashtags.length} hashtag
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 mt-3 pt-3 border-t border-slate-100"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{post.status === 'draft' && (
|
||||
<button
|
||||
onClick={() => handleApprove(post.id)}
|
||||
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={() => handleDelete(post.id)}
|
||||
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>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user