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:
Michele
2026-03-31 17:23:16 +02:00
commit 519a580679
58 changed files with 8348 additions and 0 deletions

View 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>
)
}