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,331 @@
import { useState, useEffect } from 'react'
import { api } from '../api'
const platformLabels = {
instagram: 'Instagram',
facebook: 'Facebook',
youtube: 'YouTube',
tiktok: 'TikTok',
}
const platformColors = {
instagram: 'bg-pink-50 text-pink-600 border-pink-200',
facebook: 'bg-blue-50 text-blue-600 border-blue-200',
youtube: 'bg-red-50 text-red-600 border-red-200',
tiktok: 'bg-slate-900 text-white border-slate-700',
}
const EMPTY_ACCOUNT = {
platform: 'instagram',
account_name: '',
access_token: '',
page_id: '',
}
export default function SocialAccounts() {
const [characters, setCharacters] = useState([])
const [accounts, setAccounts] = useState([])
const [loading, setLoading] = useState(true)
const [showForm, setShowForm] = useState(null) // character_id or null
const [form, setForm] = useState(EMPTY_ACCOUNT)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [testing, setTesting] = useState(null)
const [testResult, setTestResult] = useState({})
useEffect(() => {
loadData()
}, [])
const loadData = async () => {
setLoading(true)
try {
const [charsData, accsData] = await Promise.all([
api.get('/characters/'),
api.get('/social/accounts'),
])
setCharacters(charsData)
setAccounts(accsData)
} catch {
// silent
} finally {
setLoading(false)
}
}
const getAccountsForCharacter = (characterId) => {
return accounts.filter((a) => a.character_id === characterId)
}
const handleFormChange = (field, value) => {
setForm((prev) => ({ ...prev, [field]: value }))
}
const handleAddAccount = async (characterId) => {
setError('')
setSaving(true)
try {
await api.post('/social/accounts', {
character_id: characterId,
platform: form.platform,
account_name: form.account_name,
access_token: form.access_token,
page_id: form.page_id || null,
})
setShowForm(null)
setForm(EMPTY_ACCOUNT)
loadData()
} catch (err) {
setError(err.message || 'Errore nel salvataggio')
} finally {
setSaving(false)
}
}
const handleTest = async (accountId) => {
setTesting(accountId)
setTestResult((prev) => ({ ...prev, [accountId]: null }))
try {
const result = await api.post(`/social/accounts/${accountId}/test`)
setTestResult((prev) => ({ ...prev, [accountId]: { success: true, message: result.message || 'Connessione OK' } }))
} catch (err) {
setTestResult((prev) => ({ ...prev, [accountId]: { success: false, message: err.message || 'Test fallito' } }))
} finally {
setTesting(null)
}
}
const handleToggle = async (account) => {
try {
await api.put(`/social/accounts/${account.id}`, { is_active: !account.is_active })
loadData()
} catch {
// silent
}
}
const handleRemove = async (accountId) => {
if (!confirm('Rimuovere questo account social?')) return
try {
await api.delete(`/social/accounts/${accountId}`)
loadData()
} catch {
// silent
}
}
if (loading) {
return (
<div>
<h2 className="text-2xl font-bold text-slate-800 mb-1">Account Social</h2>
<p className="text-slate-500 text-sm mb-6">Gestisci le connessioni ai social network</p>
<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>
</div>
)
}
return (
<div>
<div className="mb-6">
<h2 className="text-2xl font-bold text-slate-800">Account Social</h2>
<p className="text-slate-500 mt-1 text-sm">
Gestisci le connessioni ai social network per ogni personaggio
</p>
</div>
{/* Info box */}
<div className="mb-6 p-4 bg-blue-50 border border-blue-200 rounded-xl">
<div className="flex gap-3">
<span className="text-blue-500 text-lg shrink-0">i</span>
<div>
<p className="text-sm text-blue-700 font-medium">Configurazione OAuth</p>
<p className="text-xs text-blue-600 mt-0.5">
Per la pubblicazione automatica, ogni piattaforma richiede la configurazione di un'app
developer con le relative credenziali OAuth. Inserisci access token e page ID ottenuti
dalla console developer di ciascuna piattaforma.
</p>
</div>
</div>
</div>
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-600 text-sm">
{error}
</div>
)}
{characters.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 personaggio</p>
<p className="text-slate-400 text-sm mt-1">
Crea un personaggio per poi collegare gli account social
</p>
</div>
) : (
<div className="space-y-6">
{characters.map((character) => {
const charAccounts = getAccountsForCharacter(character.id)
const isFormOpen = showForm === character.id
return (
<div key={character.id} className="bg-white rounded-xl border border-slate-200 overflow-hidden">
{/* Character header */}
<div className="p-5 border-b border-slate-100">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div
className="w-10 h-10 rounded-full flex items-center justify-center text-white font-bold text-sm shrink-0"
style={{ backgroundColor: character.visual_style?.primary_color || '#f97316' }}
>
{character.name?.charAt(0).toUpperCase()}
</div>
<div>
<h3 className="font-semibold text-slate-800">{character.name}</h3>
<p className="text-xs text-slate-400">{character.niche}</p>
</div>
</div>
<button
onClick={() => {
setShowForm(isFormOpen ? null : character.id)
setForm(EMPTY_ACCOUNT)
setError('')
}}
className="text-xs px-3 py-1.5 bg-brand-50 hover:bg-brand-100 text-brand-600 rounded-lg transition-colors font-medium"
>
{isFormOpen ? 'Annulla' : '+ Connetti Account'}
</button>
</div>
</div>
{/* Inline form */}
{isFormOpen && (
<div className="p-5 bg-slate-50 border-b border-slate-100">
<div className="max-w-md space-y-3">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Piattaforma</label>
<select
value={form.platform}
onChange={(e) => handleFormChange('platform', e.target.value)}
className="w-full px-4 py-2.5 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent text-sm bg-white"
>
{Object.entries(platformLabels).map(([val, label]) => (
<option key={val} value={val}>{label}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Nome account</label>
<input
type="text"
value={form.account_name}
onChange={(e) => handleFormChange('account_name', e.target.value)}
placeholder="Es. @mio_profilo"
className="w-full px-4 py-2.5 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent text-sm"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Access Token</label>
<input
type="password"
value={form.access_token}
onChange={(e) => handleFormChange('access_token', e.target.value)}
placeholder="Token di accesso dalla piattaforma"
className="w-full px-4 py-2.5 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent text-sm font-mono"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Page ID
<span className="text-slate-400 font-normal ml-1">(opzionale)</span>
</label>
<input
type="text"
value={form.page_id}
onChange={(e) => handleFormChange('page_id', e.target.value)}
placeholder="ID pagina (per Facebook/YouTube)"
className="w-full px-4 py-2.5 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent text-sm font-mono"
/>
</div>
<button
onClick={() => handleAddAccount(character.id)}
disabled={saving || !form.account_name || !form.access_token}
className="px-4 py-2 bg-brand-600 hover:bg-brand-700 disabled:bg-brand-300 text-white text-sm font-medium rounded-lg transition-colors"
>
{saving ? 'Salvataggio...' : 'Salva Account'}
</button>
</div>
</div>
)}
{/* Accounts list */}
<div className="divide-y divide-slate-50">
{charAccounts.length === 0 ? (
<div className="px-5 py-8 text-center">
<p className="text-sm text-slate-400">Nessun account collegato</p>
</div>
) : (
charAccounts.map((account) => (
<div key={account.id} className="px-5 py-3 flex items-center gap-3">
{/* Platform badge */}
<span className={`text-xs px-2 py-0.5 rounded-full font-medium border ${platformColors[account.platform] || 'bg-slate-100 text-slate-600 border-slate-200'}`}>
{platformLabels[account.platform] || account.platform}
</span>
{/* Account name */}
<span className="text-sm font-medium text-slate-700 flex-1 min-w-0 truncate">
{account.account_name}
</span>
{/* Status */}
<span className={`w-2 h-2 rounded-full shrink-0 ${account.is_active ? 'bg-emerald-500' : 'bg-slate-300'}`} />
{/* Test result */}
{testResult[account.id] && (
<span className={`text-xs ${testResult[account.id].success ? 'text-emerald-600' : 'text-red-500'}`}>
{testResult[account.id].message}
</span>
)}
{/* Actions */}
<div className="flex items-center gap-1 shrink-0">
<button
onClick={() => handleTest(account.id)}
disabled={testing === account.id}
className="text-xs px-2 py-1 bg-slate-100 hover:bg-slate-200 text-slate-600 rounded transition-colors disabled:opacity-50"
>
{testing === account.id ? 'Test...' : 'Test'}
</button>
<button
onClick={() => handleToggle(account)}
className="text-xs px-2 py-1 bg-slate-100 hover:bg-slate-200 text-slate-600 rounded transition-colors"
>
{account.is_active ? 'Disattiva' : 'Attiva'}
</button>
<button
onClick={() => handleRemove(account.id)}
className="text-xs px-2 py-1 text-red-500 hover:bg-red-50 rounded transition-colors"
>
Rimuovi
</button>
</div>
</div>
))
)}
</div>
</div>
)
})}
</div>
)}
</div>
)
}