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,306 @@
import { useState, useEffect } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { api } from '../api'
const SUGGESTED_NETWORKS = ['Amazon', 'ClickBank', 'ShareASale', 'CJ', 'Impact']
const EMPTY_FORM = {
character_id: '',
network: '',
name: '',
url: '',
tag: '',
topics: [],
is_active: true,
}
export default function AffiliateForm() {
const { id } = useParams()
const isEdit = Boolean(id)
const navigate = useNavigate()
const [form, setForm] = useState(EMPTY_FORM)
const [characters, setCharacters] = useState([])
const [topicInput, setTopicInput] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [loading, setLoading] = useState(isEdit)
useEffect(() => {
api.get('/characters/')
.then(setCharacters)
.catch(() => {})
}, [])
useEffect(() => {
if (isEdit) {
api.get(`/affiliates/${id}`)
.then((data) => {
setForm({
character_id: data.character_id ? String(data.character_id) : '',
network: data.network || '',
name: data.name || '',
url: data.url || '',
tag: data.tag || '',
topics: data.topics || [],
is_active: data.is_active ?? true,
})
})
.catch(() => setError('Link affiliato non trovato'))
.finally(() => setLoading(false))
}
}, [id, isEdit])
const handleChange = (field, value) => {
setForm((prev) => ({ ...prev, [field]: value }))
}
const addTopic = () => {
const topic = topicInput.trim()
if (topic && !form.topics.includes(topic)) {
setForm((prev) => ({ ...prev, topics: [...prev.topics, topic] }))
}
setTopicInput('')
}
const removeTopic = (topic) => {
setForm((prev) => ({
...prev,
topics: prev.topics.filter((t) => t !== topic),
}))
}
const handleTopicKeyDown = (e) => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault()
addTopic()
}
}
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
setSaving(true)
try {
const payload = {
...form,
character_id: form.character_id ? parseInt(form.character_id) : null,
}
if (isEdit) {
await api.put(`/affiliates/${id}`, payload)
} else {
await api.post('/affiliates/', payload)
}
navigate('/affiliates')
} catch (err) {
setError(err.message || 'Errore nel salvataggio')
} finally {
setSaving(false)
}
}
if (loading) {
return (
<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>
)
}
return (
<div>
<div className="mb-6">
<h2 className="text-2xl font-bold text-slate-800">
{isEdit ? 'Modifica link affiliato' : 'Nuovo link affiliato'}
</h2>
<p className="text-slate-500 mt-1 text-sm">
{isEdit ? 'Aggiorna le informazioni del link' : 'Aggiungi un nuovo link affiliato'}
</p>
</div>
<form onSubmit={handleSubmit} className="max-w-2xl space-y-6">
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-red-600 text-sm">
{error}
</div>
)}
{/* Main info */}
<div className="bg-white rounded-xl border border-slate-200 p-6 space-y-4">
<h3 className="font-semibold text-slate-700 text-sm uppercase tracking-wider">
Informazioni link
</h3>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Personaggio
<span className="text-slate-400 font-normal ml-1">(lascia vuoto per globale)</span>
</label>
<select
value={form.character_id}
onChange={(e) => handleChange('character_id', 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"
>
<option value="">Globale (tutti i personaggi)</option>
{characters.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Network
</label>
<div className="space-y-2">
<input
type="text"
value={form.network}
onChange={(e) => handleChange('network', e.target.value)}
placeholder="Es. Amazon, ClickBank..."
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 className="flex flex-wrap gap-1.5">
{SUGGESTED_NETWORKS.map((net) => (
<button
key={net}
type="button"
onClick={() => handleChange('network', net)}
className={`text-xs px-2 py-1 rounded-lg transition-colors ${
form.network === net
? 'bg-brand-100 text-brand-700 border border-brand-200'
: 'bg-slate-100 text-slate-500 hover:bg-slate-200 border border-transparent'
}`}
>
{net}
</button>
))}
</div>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Nome
</label>
<input
type="text"
value={form.name}
onChange={(e) => handleChange('name', e.target.value)}
placeholder="Es. Corso Python, Hosting Premium..."
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">
URL completo
</label>
<input
type="url"
value={form.url}
onChange={(e) => handleChange('url', e.target.value)}
placeholder="https://example.com/ref/..."
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">
Tag di tracciamento
</label>
<input
type="text"
value={form.tag}
onChange={(e) => handleChange('tag', e.target.value)}
placeholder="Es. ref-luigi, tag-2026..."
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>
<div className="flex items-center gap-3">
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={form.is_active}
onChange={(e) => handleChange('is_active', e.target.checked)}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-slate-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white 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-brand-500"></div>
</label>
<span className="text-sm text-slate-700">Attivo</span>
</div>
</div>
{/* Topics */}
<div className="bg-white rounded-xl border border-slate-200 p-6 space-y-4">
<h3 className="font-semibold text-slate-700 text-sm uppercase tracking-wider">
Topic correlati
</h3>
<p className="text-xs text-slate-400 -mt-2">
I topic aiutano l'AI a scegliere il link giusto per ogni contenuto
</p>
<div className="flex gap-2">
<input
type="text"
value={topicInput}
onChange={(e) => setTopicInput(e.target.value)}
onKeyDown={handleTopicKeyDown}
placeholder="Scrivi un topic e premi Invio"
className="flex-1 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"
/>
<button
type="button"
onClick={addTopic}
className="px-4 py-2.5 bg-slate-100 hover:bg-slate-200 text-slate-600 rounded-lg text-sm font-medium transition-colors"
>
Aggiungi
</button>
</div>
{form.topics.length > 0 && (
<div className="flex flex-wrap gap-2">
{form.topics.map((topic) => (
<span
key={topic}
className="inline-flex items-center gap-1.5 px-3 py-1 bg-brand-50 text-brand-700 rounded-lg text-sm"
>
{topic}
<button
type="button"
onClick={() => removeTopic(topic)}
className="text-brand-400 hover:text-brand-600"
>
×
</button>
</span>
))}
</div>
)}
</div>
{/* Actions */}
<div className="flex items-center gap-3">
<button
type="submit"
disabled={saving}
className="px-6 py-2.5 bg-brand-600 hover:bg-brand-700 disabled:bg-brand-300 text-white font-medium rounded-lg transition-colors text-sm"
>
{saving ? 'Salvataggio...' : isEdit ? 'Salva modifiche' : 'Crea link'}
</button>
<button
type="button"
onClick={() => navigate('/affiliates')}
className="px-6 py-2.5 bg-white hover:bg-slate-50 text-slate-600 font-medium rounded-lg border border-slate-200 transition-colors text-sm"
>
Annulla
</button>
</div>
</form>
</div>
)
}

View File

@@ -0,0 +1,218 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { api } from '../api'
const networkColors = {
Amazon: 'bg-amber-50 text-amber-700',
ClickBank: 'bg-emerald-50 text-emerald-700',
ShareASale: 'bg-blue-50 text-blue-700',
CJ: 'bg-violet-50 text-violet-700',
Impact: 'bg-rose-50 text-rose-700',
}
export default function AffiliateList() {
const [links, setLinks] = useState([])
const [characters, setCharacters] = useState([])
const [loading, setLoading] = useState(true)
const [filterCharacter, setFilterCharacter] = useState('')
useEffect(() => {
loadData()
}, [])
const loadData = async () => {
setLoading(true)
try {
const [linksData, charsData] = await Promise.all([
api.get('/affiliates/'),
api.get('/characters/'),
])
setLinks(linksData)
setCharacters(charsData)
} catch {
// silent
} finally {
setLoading(false)
}
}
const getCharacterName = (id) => {
if (!id) return 'Globale'
const c = characters.find((ch) => ch.id === id)
return c ? c.name : '—'
}
const getNetworkColor = (network) => {
return networkColors[network] || 'bg-slate-100 text-slate-600'
}
const handleToggle = async (link) => {
try {
await api.put(`/affiliates/${link.id}`, { is_active: !link.is_active })
loadData()
} catch {
// silent
}
}
const handleDelete = async (id, name) => {
if (!confirm(`Eliminare "${name}"?`)) return
try {
await api.delete(`/affiliates/${id}`)
loadData()
} catch {
// silent
}
}
const truncateUrl = (url) => {
if (!url) return '—'
if (url.length <= 50) return url
return url.substring(0, 50) + '...'
}
const filtered = links.filter((l) => {
if (filterCharacter === '') return true
if (filterCharacter === 'global') return !l.character_id
return String(l.character_id) === filterCharacter
})
return (
<div>
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-2xl font-bold text-slate-800">Link Affiliati</h2>
<p className="text-slate-500 mt-1 text-sm">
Gestisci i link affiliati per la monetizzazione
</p>
</div>
<Link
to="/affiliates/new"
className="px-4 py-2 bg-brand-600 hover:bg-brand-700 text-white text-sm font-medium rounded-lg transition-colors"
>
+ Nuovo Link
</Link>
</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</option>
<option value="global">Globale</option>
{characters.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
<span className="flex items-center text-xs text-slate-400 ml-auto">
{filtered.length} link
</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 link affiliato</p>
<p className="text-slate-400 text-sm mt-1">
Aggiungi i tuoi primi link affiliati per monetizzare i contenuti
</p>
<Link
to="/affiliates/new"
className="inline-block mt-4 px-4 py-2 bg-brand-600 hover:bg-brand-700 text-white text-sm font-medium rounded-lg transition-colors"
>
+ Crea link affiliato
</Link>
</div>
) : (
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-100">
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-500 uppercase tracking-wider">Network</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-500 uppercase tracking-wider">Nome</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-500 uppercase tracking-wider hidden md:table-cell">URL</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-500 uppercase tracking-wider hidden lg:table-cell">Tag</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-500 uppercase tracking-wider hidden lg:table-cell">Topic</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-500 uppercase tracking-wider">Personaggio</th>
<th className="text-center px-4 py-3 text-xs font-semibold text-slate-500 uppercase tracking-wider">Stato</th>
<th className="text-center px-4 py-3 text-xs font-semibold text-slate-500 uppercase tracking-wider">Click</th>
<th className="text-right px-4 py-3 text-xs font-semibold text-slate-500 uppercase tracking-wider">Azioni</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-50">
{filtered.map((link) => (
<tr key={link.id} className="hover:bg-slate-50 transition-colors">
<td className="px-4 py-3">
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${getNetworkColor(link.network)}`}>
{link.network || '—'}
</span>
</td>
<td className="px-4 py-3 font-medium text-slate-700">{link.name}</td>
<td className="px-4 py-3 text-slate-500 hidden md:table-cell">
<span className="font-mono text-xs">{truncateUrl(link.url)}</span>
</td>
<td className="px-4 py-3 text-slate-500 hidden lg:table-cell">
<span className="font-mono text-xs">{link.tag || '—'}</span>
</td>
<td className="px-4 py-3 hidden lg:table-cell">
<div className="flex flex-wrap gap-1">
{link.topics && link.topics.slice(0, 2).map((t, i) => (
<span key={i} className="text-xs px-1.5 py-0.5 bg-slate-100 text-slate-600 rounded">
{t}
</span>
))}
{link.topics && link.topics.length > 2 && (
<span className="text-xs text-slate-400">+{link.topics.length - 2}</span>
)}
</div>
</td>
<td className="px-4 py-3 text-slate-500 text-xs">
{getCharacterName(link.character_id)}
</td>
<td className="px-4 py-3 text-center">
<span className={`inline-block w-2 h-2 rounded-full ${link.is_active ? 'bg-emerald-500' : 'bg-slate-300'}`} />
</td>
<td className="px-4 py-3 text-center text-slate-500">
{link.click_count ?? 0}
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-1">
<Link
to={`/affiliates/${link.id}/edit`}
className="text-xs px-2 py-1 bg-slate-100 hover:bg-slate-200 text-slate-600 rounded transition-colors"
>
Modifica
</Link>
<button
onClick={() => handleToggle(link)}
className="text-xs px-2 py-1 bg-slate-100 hover:bg-slate-200 text-slate-600 rounded transition-colors"
>
{link.is_active ? 'Disattiva' : 'Attiva'}
</button>
<button
onClick={() => handleDelete(link.id, link.name)}
className="text-xs px-2 py-1 text-red-500 hover:bg-red-50 rounded transition-colors"
>
Elimina
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,331 @@
import { useState, useEffect } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { api } from '../api'
const EMPTY_FORM = {
name: '',
niche: '',
topics: [],
tone: '',
visual_style: { primary_color: '#f97316', secondary_color: '#1e293b', font: '' },
is_active: true,
}
export default function CharacterForm() {
const { id } = useParams()
const isEdit = Boolean(id)
const navigate = useNavigate()
const [form, setForm] = useState(EMPTY_FORM)
const [topicInput, setTopicInput] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [loading, setLoading] = useState(isEdit)
useEffect(() => {
if (isEdit) {
api.get(`/characters/${id}`)
.then((data) => {
setForm({
name: data.name || '',
niche: data.niche || '',
topics: data.topics || [],
tone: data.tone || '',
visual_style: {
primary_color: data.visual_style?.primary_color || '#f97316',
secondary_color: data.visual_style?.secondary_color || '#1e293b',
font: data.visual_style?.font || '',
},
is_active: data.is_active ?? true,
})
})
.catch(() => setError('Personaggio non trovato'))
.finally(() => setLoading(false))
}
}, [id, isEdit])
const handleChange = (field, value) => {
setForm((prev) => ({ ...prev, [field]: value }))
}
const handleStyleChange = (field, value) => {
setForm((prev) => ({
...prev,
visual_style: { ...prev.visual_style, [field]: value },
}))
}
const addTopic = () => {
const topic = topicInput.trim()
if (topic && !form.topics.includes(topic)) {
setForm((prev) => ({ ...prev, topics: [...prev.topics, topic] }))
}
setTopicInput('')
}
const removeTopic = (topic) => {
setForm((prev) => ({
...prev,
topics: prev.topics.filter((t) => t !== topic),
}))
}
const handleTopicKeyDown = (e) => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault()
addTopic()
}
}
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
setSaving(true)
try {
if (isEdit) {
await api.put(`/characters/${id}`, form)
} else {
await api.post('/characters/', form)
}
navigate('/characters')
} catch (err) {
setError(err.message || 'Errore nel salvataggio')
} finally {
setSaving(false)
}
}
if (loading) {
return (
<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>
)
}
return (
<div>
<div className="mb-6">
<h2 className="text-2xl font-bold text-slate-800">
{isEdit ? 'Modifica personaggio' : 'Nuovo personaggio'}
</h2>
<p className="text-slate-500 mt-1 text-sm">
{isEdit ? 'Aggiorna il profilo editoriale' : 'Crea un nuovo profilo editoriale'}
</p>
</div>
<form onSubmit={handleSubmit} className="max-w-2xl space-y-6">
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-red-600 text-sm">
{error}
</div>
)}
{/* Basic info */}
<div className="bg-white rounded-xl border border-slate-200 p-6 space-y-4">
<h3 className="font-semibold text-slate-700 text-sm uppercase tracking-wider">
Informazioni base
</h3>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Nome personaggio
</label>
<input
type="text"
value={form.name}
onChange={(e) => handleChange('name', e.target.value)}
placeholder="Es. TechGuru, FoodBlogger..."
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">
Niche / Settore
</label>
<input
type="text"
value={form.niche}
onChange={(e) => handleChange('niche', e.target.value)}
placeholder="Es. Tecnologia, Food, Fitness..."
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">
Tono di comunicazione
</label>
<textarea
value={form.tone}
onChange={(e) => handleChange('tone', e.target.value)}
placeholder="Descrivi lo stile di comunicazione: informale, professionale, ironico, motivazionale..."
rows={3}
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 resize-none"
/>
</div>
<div className="flex items-center gap-3">
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={form.is_active}
onChange={(e) => handleChange('is_active', e.target.checked)}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-slate-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white 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-brand-500"></div>
</label>
<span className="text-sm text-slate-700">Attivo</span>
</div>
</div>
{/* Topics */}
<div className="bg-white rounded-xl border border-slate-200 p-6 space-y-4">
<h3 className="font-semibold text-slate-700 text-sm uppercase tracking-wider">
Topic ricorrenti
</h3>
<div className="flex gap-2">
<input
type="text"
value={topicInput}
onChange={(e) => setTopicInput(e.target.value)}
onKeyDown={handleTopicKeyDown}
placeholder="Scrivi un topic e premi Invio"
className="flex-1 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"
/>
<button
type="button"
onClick={addTopic}
className="px-4 py-2.5 bg-slate-100 hover:bg-slate-200 text-slate-600 rounded-lg text-sm font-medium transition-colors"
>
Aggiungi
</button>
</div>
{form.topics.length > 0 && (
<div className="flex flex-wrap gap-2">
{form.topics.map((topic) => (
<span
key={topic}
className="inline-flex items-center gap-1.5 px-3 py-1 bg-brand-50 text-brand-700 rounded-lg text-sm"
>
{topic}
<button
type="button"
onClick={() => removeTopic(topic)}
className="text-brand-400 hover:text-brand-600"
>
×
</button>
</span>
))}
</div>
)}
</div>
{/* Visual style */}
<div className="bg-white rounded-xl border border-slate-200 p-6 space-y-4">
<h3 className="font-semibold text-slate-700 text-sm uppercase tracking-wider">
Stile visivo
</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Colore primario
</label>
<div className="flex items-center gap-2">
<input
type="color"
value={form.visual_style.primary_color}
onChange={(e) => handleStyleChange('primary_color', e.target.value)}
className="w-10 h-10 rounded border border-slate-200 cursor-pointer"
/>
<input
type="text"
value={form.visual_style.primary_color}
onChange={(e) => handleStyleChange('primary_color', e.target.value)}
className="flex-1 px-3 py-2 border border-slate-200 rounded-lg text-sm font-mono"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Colore secondario
</label>
<div className="flex items-center gap-2">
<input
type="color"
value={form.visual_style.secondary_color}
onChange={(e) => handleStyleChange('secondary_color', e.target.value)}
className="w-10 h-10 rounded border border-slate-200 cursor-pointer"
/>
<input
type="text"
value={form.visual_style.secondary_color}
onChange={(e) => handleStyleChange('secondary_color', e.target.value)}
className="flex-1 px-3 py-2 border border-slate-200 rounded-lg text-sm font-mono"
/>
</div>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Font preferito
</label>
<input
type="text"
value={form.visual_style.font}
onChange={(e) => handleStyleChange('font', e.target.value)}
placeholder="Es. Montserrat, Poppins, Inter..."
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"
/>
</div>
{/* Preview */}
<div className="mt-4 p-4 rounded-lg border border-dashed border-slate-300">
<p className="text-xs text-slate-400 mb-2">Anteprima</p>
<div className="flex items-center gap-3">
<div
className="w-10 h-10 rounded-full flex items-center justify-center text-white font-bold"
style={{ backgroundColor: form.visual_style.primary_color }}
>
{form.name?.charAt(0)?.toUpperCase() || '?'}
</div>
<div>
<p className="font-semibold text-sm" style={{ color: form.visual_style.secondary_color }}>
{form.name || 'Nome personaggio'}
</p>
<p className="text-xs text-slate-400">{form.niche || 'Niche'}</p>
</div>
</div>
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-3">
<button
type="submit"
disabled={saving}
className="px-6 py-2.5 bg-brand-600 hover:bg-brand-700 disabled:bg-brand-300 text-white font-medium rounded-lg transition-colors text-sm"
>
{saving ? 'Salvataggio...' : isEdit ? 'Salva modifiche' : 'Crea personaggio'}
</button>
<button
type="button"
onClick={() => navigate('/characters')}
className="px-6 py-2.5 bg-white hover:bg-slate-50 text-slate-600 font-medium rounded-lg border border-slate-200 transition-colors text-sm"
>
Annulla
</button>
</div>
</form>
</div>
)
}

View File

@@ -0,0 +1,161 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { api } from '../api'
export default function CharacterList() {
const [characters, setCharacters] = useState([])
const [loading, setLoading] = useState(true)
useEffect(() => {
loadCharacters()
}, [])
const loadCharacters = () => {
setLoading(true)
api.get('/characters/')
.then(setCharacters)
.catch(() => {})
.finally(() => setLoading(false))
}
const handleDelete = async (id, name) => {
if (!confirm(`Eliminare "${name}"?`)) return
await api.delete(`/characters/${id}`)
loadCharacters()
}
const handleToggle = async (character) => {
await api.put(`/characters/${character.id}`, { is_active: !character.is_active })
loadCharacters()
}
return (
<div>
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-2xl font-bold text-slate-800">Personaggi</h2>
<p className="text-slate-500 mt-1 text-sm">
Gestisci i tuoi profili editoriali
</p>
</div>
<Link
to="/characters/new"
className="px-4 py-2 bg-brand-600 hover:bg-brand-700 text-white text-sm font-medium rounded-lg transition-colors"
>
+ Nuovo
</Link>
</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>
) : 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 il tuo primo profilo editoriale
</p>
<Link
to="/characters/new"
className="inline-block mt-4 px-4 py-2 bg-brand-600 hover:bg-brand-700 text-white text-sm font-medium rounded-lg transition-colors"
>
+ Crea personaggio
</Link>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{characters.map((c) => (
<div
key={c.id}
className="bg-white rounded-xl border border-slate-200 hover:border-brand-300 transition-all overflow-hidden"
>
{/* Card header with color */}
<div
className="h-2"
style={{
backgroundColor: c.visual_style?.primary_color || '#f97316',
}}
/>
<div className="p-5">
<div className="flex items-start gap-3">
<div
className="w-12 h-12 rounded-full flex items-center justify-center text-white font-bold text-lg shrink-0"
style={{
backgroundColor: c.visual_style?.primary_color || '#f97316',
}}
>
{c.name?.charAt(0).toUpperCase()}
</div>
<div className="min-w-0 flex-1">
<h3 className="font-semibold text-slate-800 truncate">{c.name}</h3>
<p className="text-sm text-slate-500 truncate">{c.niche}</p>
</div>
<span
className={`text-xs px-2 py-0.5 rounded-full shrink-0 ${
c.is_active
? 'bg-emerald-50 text-emerald-600'
: 'bg-slate-100 text-slate-400'
}`}
>
{c.is_active ? 'Attivo' : 'Off'}
</span>
</div>
{/* Topics */}
{c.topics?.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-3">
{c.topics.slice(0, 4).map((t) => (
<span
key={t}
className="text-xs px-2 py-0.5 bg-slate-100 text-slate-600 rounded"
>
{t}
</span>
))}
{c.topics.length > 4 && (
<span className="text-xs text-slate-400">
+{c.topics.length - 4}
</span>
)}
</div>
)}
{/* Tone preview */}
{c.tone && (
<p className="text-xs text-slate-400 mt-3 line-clamp-2 italic">
"{c.tone}"
</p>
)}
{/* Actions */}
<div className="flex items-center gap-2 mt-4 pt-3 border-t border-slate-100">
<Link
to={`/characters/${c.id}/edit`}
className="text-xs px-3 py-1.5 bg-slate-100 hover:bg-slate-200 text-slate-600 rounded-lg transition-colors"
>
Modifica
</Link>
<button
onClick={() => handleToggle(c)}
className="text-xs px-3 py-1.5 bg-slate-100 hover:bg-slate-200 text-slate-600 rounded-lg transition-colors"
>
{c.is_active ? 'Disattiva' : 'Attiva'}
</button>
<button
onClick={() => handleDelete(c.id, c.name)}
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>
)
}

View File

@@ -0,0 +1,292 @@
import { useState, useEffect } from 'react'
import { api } from '../api'
const TAB_OPTIONS = [
{ value: 'pending', label: 'In Attesa' },
{ value: 'approved', label: 'Approvati' },
{ value: 'replied', label: 'Risposti' },
{ value: 'ignored', label: 'Ignorati' },
]
const platformColors = {
instagram: 'bg-pink-50 text-pink-600',
facebook: 'bg-blue-50 text-blue-600',
youtube: 'bg-red-50 text-red-600',
tiktok: 'bg-slate-800 text-white',
}
const platformLabels = {
instagram: 'Instagram',
facebook: 'Facebook',
youtube: 'YouTube',
tiktok: 'TikTok',
}
export default function CommentsQueue() {
const [comments, setComments] = useState([])
const [loading, setLoading] = useState(true)
const [activeTab, setActiveTab] = useState('pending')
const [counts, setCounts] = useState({})
const [editingReply, setEditingReply] = useState(null)
const [replyText, setReplyText] = useState('')
const [fetching, setFetching] = useState(false)
const [actionLoading, setActionLoading] = useState(null)
useEffect(() => {
loadComments()
}, [activeTab])
const loadComments = async () => {
setLoading(true)
try {
const data = await api.get(`/comments/?reply_status=${activeTab}`)
setComments(data)
// Also load counts for all tabs
loadCounts()
} catch {
setComments([])
} finally {
setLoading(false)
}
}
const loadCounts = async () => {
try {
const results = await Promise.all(
TAB_OPTIONS.map(async (tab) => {
const data = await api.get(`/comments/?reply_status=${tab.value}`)
return [tab.value, Array.isArray(data) ? data.length : 0]
})
)
setCounts(Object.fromEntries(results))
} catch {
// silent
}
}
const handleAction = async (commentId, action, body = null) => {
setActionLoading(commentId)
try {
if (action === 'approve') {
await api.post(`/comments/${commentId}/approve`)
} else if (action === 'ignore') {
await api.post(`/comments/${commentId}/ignore`)
} else if (action === 'edit') {
await api.put(`/comments/${commentId}`, { ai_reply: body })
setEditingReply(null)
} else if (action === 'reply') {
await api.post(`/comments/${commentId}/reply`)
}
loadComments()
} catch {
// silent
} finally {
setActionLoading(null)
}
}
const handleFetchComments = async () => {
setFetching(true)
try {
const platforms = ['instagram', 'facebook', 'youtube', 'tiktok']
await Promise.allSettled(
platforms.map((p) => api.post(`/comments/fetch/${p}`))
)
loadComments()
} catch {
// silent
} finally {
setFetching(false)
}
}
const startEditReply = (comment) => {
setEditingReply(comment.id)
setReplyText(comment.ai_reply || '')
}
return (
<div>
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-2xl font-bold text-slate-800">Commenti</h2>
<p className="text-slate-500 mt-1 text-sm">
Gestisci i commenti e le risposte AI
</p>
</div>
<button
onClick={handleFetchComments}
disabled={fetching}
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"
>
{fetching ? (
<span className="flex items-center gap-2">
<span className="animate-spin rounded-full h-3.5 w-3.5 border-2 border-white border-t-transparent" />
Aggiornamento...
</span>
) : (
'Aggiorna Commenti'
)}
</button>
</div>
{/* Tab bar */}
<div className="flex gap-1 mb-6 bg-slate-100 rounded-lg p-1 w-fit">
{TAB_OPTIONS.map((tab) => (
<button
key={tab.value}
onClick={() => setActiveTab(tab.value)}
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors flex items-center gap-1.5 ${
activeTab === tab.value
? 'bg-white text-slate-800 shadow-sm'
: 'text-slate-500 hover:text-slate-700'
}`}
>
{tab.label}
{counts[tab.value] > 0 && (
<span className={`text-[10px] px-1.5 py-0.5 rounded-full ${
activeTab === tab.value
? 'bg-brand-100 text-brand-700'
: 'bg-slate-200 text-slate-500'
}`}>
{counts[tab.value]}
</span>
)}
</button>
))}
</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>
) : comments.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 commento {TAB_OPTIONS.find(t => t.value === activeTab)?.label.toLowerCase()}</p>
<p className="text-slate-400 text-sm mt-1">
{activeTab === 'pending'
? 'Clicca "Aggiorna Commenti" per recuperare nuovi commenti'
: 'I commenti appariranno qui quando cambieranno stato'}
</p>
</div>
) : (
<div className="space-y-3">
{comments.map((comment) => (
<div
key={comment.id}
className="bg-white rounded-xl border border-slate-200 overflow-hidden"
>
<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 font-medium ${platformColors[comment.platform] || 'bg-slate-100 text-slate-600'}`}>
{platformLabels[comment.platform] || comment.platform}
</span>
<span className="text-xs text-slate-400">da</span>
<span className="text-xs font-medium text-slate-700">
{comment.author_name || 'Utente'}
</span>
{comment.post_reference && (
<span className="text-xs text-slate-400 ml-auto truncate max-w-xs">
su: {comment.post_reference}
</span>
)}
</div>
{/* Comment text */}
<div className="p-3 bg-slate-50 rounded-lg border border-slate-100 mb-3">
<p className="text-sm text-slate-700">
{comment.text}
</p>
</div>
{/* AI suggested reply */}
{comment.ai_reply && (
<div className="mb-3">
<p className="text-xs font-medium text-slate-400 mb-1">Risposta AI suggerita</p>
{editingReply === comment.id ? (
<div className="space-y-2">
<textarea
value={replyText}
onChange={(e) => setReplyText(e.target.value)}
rows={3}
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 resize-none"
/>
<div className="flex gap-2">
<button
onClick={() => handleAction(comment.id, 'edit', replyText)}
disabled={actionLoading === comment.id}
className="text-xs px-3 py-1.5 bg-brand-600 hover:bg-brand-700 text-white rounded-lg transition-colors disabled:opacity-50"
>
Salva
</button>
<button
onClick={() => setEditingReply(null)}
className="text-xs px-3 py-1.5 bg-slate-100 hover:bg-slate-200 text-slate-600 rounded-lg transition-colors"
>
Annulla
</button>
</div>
</div>
) : (
<div className="p-3 bg-brand-50 rounded-lg border border-brand-100">
<p className="text-sm text-brand-800 italic">
{comment.ai_reply}
</p>
</div>
)}
</div>
)}
{/* Actions */}
<div className="flex items-center gap-2 pt-3 border-t border-slate-100">
{activeTab === 'pending' && (
<>
<button
onClick={() => handleAction(comment.id, 'approve')}
disabled={actionLoading === comment.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 disabled:opacity-50"
>
Approva Risposta AI
</button>
<button
onClick={() => startEditReply(comment)}
className="text-xs px-3 py-1.5 bg-slate-100 hover:bg-slate-200 text-slate-600 rounded-lg transition-colors"
>
Modifica Risposta
</button>
<button
onClick={() => handleAction(comment.id, 'ignore')}
disabled={actionLoading === comment.id}
className="text-xs px-3 py-1.5 text-slate-400 hover:bg-slate-100 rounded-lg transition-colors ml-auto disabled:opacity-50"
>
Ignora
</button>
</>
)}
{activeTab === 'approved' && (
<button
onClick={() => handleAction(comment.id, 'reply')}
disabled={actionLoading === comment.id}
className="text-xs px-3 py-1.5 bg-brand-600 hover:bg-brand-700 text-white rounded-lg transition-colors font-medium disabled:opacity-50"
>
{actionLoading === comment.id ? 'Invio...' : 'Invia Risposta'}
</button>
)}
{(activeTab === 'replied' || activeTab === 'ignored') && (
<span className="text-xs text-slate-400 italic">
{activeTab === 'replied' ? 'Risposta inviata' : 'Commento ignorato'}
</span>
)}
</div>
</div>
</div>
))}
</div>
)}
</div>
)
}

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

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

View File

@@ -0,0 +1,201 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { api } from '../api'
export default function Dashboard() {
const [stats, setStats] = useState({
characters: 0,
active: 0,
posts: 0,
scheduled: 0,
pendingComments: 0,
affiliates: 0,
plans: 0,
})
const [recentPosts, setRecentPosts] = useState([])
const [providerStatus, setProviderStatus] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
Promise.all([
api.get('/characters/').catch(() => []),
api.get('/content/posts').catch(() => []),
api.get('/plans/scheduled').catch(() => []),
api.get('/comments/pending').catch(() => []),
api.get('/affiliates/').catch(() => []),
api.get('/plans/').catch(() => []),
api.get('/settings/providers/status').catch(() => null),
]).then(([chars, posts, scheduled, comments, affiliates, plans, providers]) => {
setStats({
characters: chars.length,
active: chars.filter((c) => c.is_active).length,
posts: posts.length,
scheduled: scheduled.filter((s) => s.status === 'pending').length,
pendingComments: comments.length,
affiliates: affiliates.length,
plans: plans.filter((p) => p.is_active).length,
})
setRecentPosts(posts.slice(0, 5))
setProviderStatus(providers)
setLoading(false)
})
}, [])
return (
<div>
<h2 className="text-2xl font-bold mb-1" style={{ color: 'var(--ink)' }}>
Dashboard
</h2>
<p className="text-sm mb-5" style={{ color: 'var(--muted)' }}>
Panoramica Leopost Full
</p>
{/* Stats grid */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mt-5">
<StatCard label="Personaggi" value={loading ? '—' : stats.characters} sub={`${stats.active} attivi`} accentColor="var(--coral)" />
<StatCard label="Post generati" value={loading ? '—' : stats.posts} accentColor="#3B82F6" />
<StatCard label="Schedulati" value={loading ? '—' : stats.scheduled} sub="in coda" accentColor="#10B981" />
<StatCard label="Commenti" value={loading ? '—' : stats.pendingComments} sub="in attesa" accentColor="#8B5CF6" />
<StatCard label="Link Affiliati" value={loading ? '—' : stats.affiliates} accentColor="#F59E0B" />
<StatCard label="Piani Attivi" value={loading ? '—' : stats.plans} accentColor="#14B8A6" />
</div>
{/* Provider status */}
{providerStatus && (
<div
className="mt-6 rounded-xl p-5"
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
>
<h3 className="text-sm font-semibold uppercase tracking-wider mb-3" style={{ color: 'var(--muted)' }}>
Stato Provider
</h3>
<div className="flex flex-wrap gap-3">
<ProviderBadge name="LLM" ok={providerStatus.llm?.configured} detail={providerStatus.llm?.provider} />
<ProviderBadge name="Immagini" ok={providerStatus.image?.configured} detail={providerStatus.image?.provider} />
<ProviderBadge name="Voiceover" ok={providerStatus.voice?.configured} />
{providerStatus.social && Object.entries(providerStatus.social).map(([k, v]) => (
<ProviderBadge key={k} name={k} ok={v} />
))}
</div>
{!providerStatus.llm?.configured && (
<p className="text-xs mt-2" style={{ color: 'var(--muted)' }}>
Configura le API key in{' '}
<Link to="/settings" style={{ color: 'var(--coral)' }} className="hover:underline">
Impostazioni
</Link>
</p>
)}
</div>
)}
{/* Quick actions */}
<div className="mt-6">
<h3 className="text-sm font-semibold uppercase tracking-wider mb-3" style={{ color: 'var(--muted)' }}>
Azioni rapide
</h3>
<div className="flex flex-wrap gap-2">
<Link
to="/content"
className="px-4 py-2 text-white text-sm font-medium rounded-lg transition-opacity hover:opacity-90"
style={{ backgroundColor: 'var(--coral)' }}
>
Genera contenuto
</Link>
<Link
to="/editorial"
className="px-4 py-2 text-sm font-medium rounded-lg transition-colors"
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)', color: 'var(--ink)' }}
>
Calendario AI
</Link>
<Link
to="/characters/new"
className="px-4 py-2 text-sm font-medium rounded-lg transition-colors"
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)', color: 'var(--ink)' }}
>
Nuovo personaggio
</Link>
<Link
to="/plans/new"
className="px-4 py-2 text-sm font-medium rounded-lg transition-colors"
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)', color: 'var(--ink)' }}
>
Nuovo piano
</Link>
</div>
</div>
{/* Recent posts */}
{recentPosts.length > 0 && (
<div className="mt-6">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold uppercase tracking-wider" style={{ color: 'var(--muted)' }}>
Post recenti
</h3>
<Link to="/content/archive" style={{ color: 'var(--coral)' }} className="text-xs hover:underline">
Vedi tutti
</Link>
</div>
<div className="space-y-2">
{recentPosts.map((p) => (
<div
key={p.id}
className="flex items-center gap-3 p-3 rounded-lg"
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
>
<span className={`text-[10px] px-2 py-0.5 rounded-full font-medium ${statusColor(p.status)}`}>
{p.status}
</span>
<span className="text-xs" style={{ color: 'var(--muted)' }}>{p.platform_hint}</span>
<p className="text-sm truncate flex-1" style={{ color: 'var(--ink)' }}>
{p.text_content?.slice(0, 80)}...
</p>
</div>
))}
</div>
</div>
)}
</div>
)
}
function StatCard({ label, value, sub, accentColor }) {
return (
<div
className="rounded-xl p-4"
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
>
<div className="flex items-center gap-2 mb-1.5">
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: accentColor }} />
<span className="text-[11px] font-medium uppercase tracking-wider" style={{ color: 'var(--muted)' }}>
{label}
</span>
</div>
<p className="text-2xl font-bold" style={{ color: 'var(--ink)' }}>{value}</p>
{sub && <p className="text-[11px] mt-0.5" style={{ color: 'var(--muted)' }}>{sub}</p>}
</div>
)
}
function ProviderBadge({ name, ok, detail }) {
return (
<div className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium ${
ok ? 'bg-emerald-50 text-emerald-700' : 'bg-slate-100 text-slate-400'
}`}>
<div className={`w-1.5 h-1.5 rounded-full ${ok ? 'bg-emerald-500' : 'bg-slate-300'}`} />
{name}
{detail && <span className="text-[10px] opacity-60">({detail})</span>}
</div>
)
}
function statusColor(s) {
const map = {
draft: 'bg-slate-100 text-slate-500',
approved: 'bg-blue-50 text-blue-600',
scheduled: 'bg-amber-50 text-amber-600',
published: 'bg-emerald-50 text-emerald-600',
failed: 'bg-red-50 text-red-600',
}
return map[s] || 'bg-slate-100 text-slate-500'
}

View File

@@ -0,0 +1,403 @@
import { useState, useEffect } from 'react'
import { api } from '../api'
const BASE_URL = '/leopost-full/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',
}
const AWARENESS_LABELS = {
1: '1 — Unaware',
2: '2 — Problem Aware',
3: '3 — Solution Aware',
4: '4 — Product Aware',
5: '5 — Most Aware',
}
const FORMATO_COLORS = {
PAS: { bg: '#FFF0EC', color: 'var(--coral)' },
AIDA: { bg: '#EFF6FF', color: '#3B82F6' },
BAB: { bg: '#F0FDF4', color: '#16A34A' },
Storytelling: { bg: '#FDF4FF', color: '#9333EA' },
Listicle: { bg: '#FFFBEB', color: '#D97706' },
Dato_Implicazione: { bg: '#F0F9FF', color: '#0284C7' },
}
const AWARENESS_COLORS = {
1: { bg: '#FEF2F2', color: '#DC2626' },
2: { bg: '#FFF7ED', color: '#EA580C' },
3: { bg: '#FFFBEB', color: '#D97706' },
4: { bg: '#F0FDF4', color: '#16A34A' },
5: { bg: '#EFF6FF', color: '#2563EB' },
}
export default function EditorialCalendar() {
const [formats, setFormats] = useState([])
const [awarenessLevels, setAwarenessLevels] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [calendar, setCalendar] = useState(null)
const [exporting, setExporting] = useState(false)
const [form, setForm] = useState({
topics: '',
format_narrativo: '',
awareness_level: '',
num_posts: 7,
start_date: new Date().toISOString().split('T')[0],
})
useEffect(() => {
api.get('/editorial/formats')
.then((data) => {
setFormats(data.formats || [])
setAwarenessLevels(data.awareness_levels || [])
})
.catch(() => {})
}, [])
const handleChange = (field, value) => {
setForm((prev) => ({ ...prev, [field]: value }))
}
const handleGenerate = async (e) => {
e.preventDefault()
const topicsList = form.topics
.split(',')
.map((t) => t.trim())
.filter(Boolean)
if (topicsList.length === 0) {
setError('Inserisci almeno un topic/keyword')
return
}
setError('')
setLoading(true)
setCalendar(null)
try {
const payload = {
topics: topicsList,
num_posts: parseInt(form.num_posts) || 7,
start_date: form.start_date || null,
}
if (form.format_narrativo) payload.format_narrativo = form.format_narrativo
if (form.awareness_level) payload.awareness_level = parseInt(form.awareness_level)
const data = await api.post('/editorial/generate-calendar', payload)
setCalendar(data)
} catch (err) {
setError(err.message || 'Errore nella generazione del calendario')
} finally {
setLoading(false)
}
}
const handleExportCsv = async () => {
if (!calendar?.slots?.length) return
setExporting(true)
try {
const token = localStorage.getItem('token')
const res = await fetch(`${BASE_URL}/editorial/export-csv`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ slots: calendar.slots }),
})
if (!res.ok) throw new Error('Export fallito')
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'calendario_editoriale.csv'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
} catch (err) {
setError(err.message || 'Errore nell\'export CSV')
} finally {
setExporting(false)
}
}
return (
<div>
<div className="mb-6">
<h2 className="text-2xl font-bold" style={{ color: 'var(--ink)' }}>
Calendario Editoriale AI
</h2>
<p className="mt-1 text-sm" style={{ color: 'var(--muted)' }}>
Genera un piano editoriale con format narrativi e awareness levels (Schwartz)
</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-3 gap-6">
{/* Form */}
<div style={cardStyle}>
<h3 className="font-semibold text-sm uppercase tracking-wider mb-4" style={{ color: 'var(--muted)' }}>
Parametri
</h3>
<form onSubmit={handleGenerate} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--ink)' }}>
Topics / Keywords
</label>
<textarea
value={form.topics}
onChange={(e) => handleChange('topics', e.target.value)}
placeholder="Es. marketing digitale, social media, content strategy"
rows={3}
style={{ ...inputStyle, resize: 'vertical' }}
/>
<p className="text-xs mt-1" style={{ color: 'var(--muted)' }}>
Separati da virgola
</p>
</div>
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--ink)' }}>
Formato Narrativo
<span className="font-normal ml-1" style={{ color: 'var(--muted)' }}>(opzionale)</span>
</label>
<select
value={form.format_narrativo}
onChange={(e) => handleChange('format_narrativo', e.target.value)}
style={inputStyle}
>
<option value="">Distribuzione automatica</option>
{formats.map((f) => (
<option key={f.value} value={f.value}>
{f.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--ink)' }}>
Awareness Level
<span className="font-normal ml-1" style={{ color: 'var(--muted)' }}>(opzionale)</span>
</label>
<select
value={form.awareness_level}
onChange={(e) => handleChange('awareness_level', e.target.value)}
style={inputStyle}
>
<option value="">Distribuzione automatica</option>
{awarenessLevels.map((l) => (
<option key={l.value} value={l.value}>
{l.value} {l.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--ink)' }}>
Numero di post
</label>
<input
type="number"
min={1}
max={30}
value={form.num_posts}
onChange={(e) => handleChange('num_posts', e.target.value)}
style={inputStyle}
/>
</div>
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--ink)' }}>
Data di inizio
</label>
<input
type="date"
value={form.start_date}
onChange={(e) => handleChange('start_date', e.target.value)}
style={inputStyle}
/>
</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...
</span>
) : 'Genera Calendario'}
</button>
</form>
</div>
{/* Results */}
<div className="lg:col-span-2">
{calendar ? (
<div>
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="font-semibold text-sm uppercase tracking-wider" style={{ color: 'var(--muted)' }}>
Calendario Generato
</h3>
<p className="text-xs mt-0.5" style={{ color: 'var(--muted)' }}>
{calendar.totale_post} post pianificati
</p>
</div>
<button
onClick={handleExportCsv}
disabled={exporting}
className="px-4 py-2 text-sm font-medium rounded-lg transition-opacity"
style={{
backgroundColor: 'var(--ink)',
color: '#fff',
opacity: exporting ? 0.7 : 1,
}}
>
{exporting ? 'Export...' : 'Esporta CSV per Canva'}
</button>
</div>
<div className="space-y-3">
{calendar.slots.map((slot) => {
const fmtColor = FORMATO_COLORS[slot.formato_narrativo] || { bg: '#F8F8F8', color: 'var(--ink)' }
const awColor = AWARENESS_COLORS[slot.awareness_level] || { bg: '#F8F8F8', color: 'var(--ink)' }
return (
<div
key={slot.indice}
className="flex gap-4 p-4 rounded-xl"
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
>
{/* Index */}
<div
className="w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold shrink-0"
style={{ backgroundColor: 'var(--coral)', color: '#fff' }}
>
{slot.indice + 1}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-2 mb-2">
{/* Date */}
<span
className="text-xs font-medium px-2 py-0.5 rounded"
style={{ backgroundColor: 'var(--cream)', color: 'var(--muted)', border: '1px solid var(--border)' }}
>
{new Date(slot.data_pubblicazione).toLocaleDateString('it-IT', {
weekday: 'short', day: '2-digit', month: 'short'
})}
</span>
{/* Format */}
<span
className="text-xs font-medium px-2 py-0.5 rounded"
style={{ backgroundColor: fmtColor.bg, color: fmtColor.color }}
>
{slot.formato_narrativo.replace('_', ' ')}
</span>
{/* Awareness */}
<span
className="text-xs font-medium px-2 py-0.5 rounded"
style={{ backgroundColor: awColor.bg, color: awColor.color }}
>
L{slot.awareness_level} {slot.awareness_label}
</span>
</div>
{/* Topic */}
<p className="text-sm font-medium" style={{ color: 'var(--ink)' }}>
{slot.topic}
</p>
{/* Note */}
{slot.note && (
<p className="text-xs mt-1" style={{ color: 'var(--muted)' }}>
{slot.note}
</p>
)}
</div>
</div>
)
})}
</div>
</div>
) : (
<div
className="flex flex-col items-center justify-center py-20 rounded-xl text-center"
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
>
<p className="text-5xl mb-4"></p>
<p className="font-semibold text-lg font-serif" style={{ color: 'var(--ink)' }}>
Nessun calendario generato
</p>
<p className="text-sm mt-2 max-w-xs" style={{ color: 'var(--muted)' }}>
Inserisci i topic e scegli le impostazioni, poi clicca "Genera Calendario"
</p>
{/* Info boxes */}
<div className="grid grid-cols-2 gap-3 mt-8 text-left max-w-sm">
<InfoBox title="Format narrativi" items={['PAS', 'AIDA', 'BAB', 'Storytelling', 'Listicle', 'Dato Implicazione']} />
<InfoBox title="Awareness levels" items={['1 — Unaware', '2 — Problem', '3 — Solution', '4 — Product', '5 — Most Aware']} />
</div>
</div>
)}
</div>
</div>
</div>
)
}
function InfoBox({ title, items }) {
return (
<div
className="p-3 rounded-lg"
style={{ backgroundColor: 'var(--cream)', border: '1px solid var(--border)' }}
>
<p className="text-xs font-semibold uppercase tracking-wider mb-2" style={{ color: 'var(--muted)' }}>
{title}
</p>
<ul className="space-y-1">
{items.map((item) => (
<li key={item} className="text-xs" style={{ color: 'var(--ink)' }}>
{item}
</li>
))}
</ul>
</div>
)
}

View File

@@ -0,0 +1,80 @@
import { NavLink, Outlet } from 'react-router-dom'
import { useAuth } from '../AuthContext'
const nav = [
{ to: '/', label: 'Dashboard', icon: '◉' },
{ to: '/characters', label: 'Personaggi', icon: '◎' },
{ to: '/content', label: 'Contenuti', icon: '✦' },
{ to: '/affiliates', label: 'Link Affiliati', icon: '⟁' },
{ to: '/plans', label: 'Piano Editoriale', icon: '▦' },
{ to: '/schedule', label: 'Schedulazione', icon: '◈' },
{ to: '/social', label: 'Social', icon: '◇' },
{ to: '/comments', label: 'Commenti', icon: '◌' },
{ to: '/editorial', label: 'Calendario AI', icon: '◰' },
{ to: '/settings', label: 'Impostazioni', icon: '⚙' },
]
export default function Layout() {
const { user, logout } = useAuth()
return (
<div className="min-h-screen flex" style={{ backgroundColor: 'var(--cream)' }}>
{/* Sidebar */}
<aside className="w-60 flex flex-col shrink-0" style={{ backgroundColor: 'var(--ink)' }}>
<div className="p-5 border-b" style={{ borderColor: 'rgba(255,255,255,0.08)' }}>
<h1 className="text-lg font-bold tracking-tight text-white font-serif">
Leopost <span style={{ color: 'var(--coral)' }}>Full</span>
</h1>
<p className="text-[10px] mt-0.5" style={{ color: 'var(--muted)' }}>
Content Automation
</p>
</div>
<nav className="flex-1 p-3 space-y-0.5 overflow-y-auto">
{nav.map(({ to, label, icon }) => (
<NavLink
key={to}
to={to}
end={to === '/'}
className={({ isActive }) =>
`flex items-center gap-2.5 px-3 py-2 rounded text-[13px] font-medium transition-colors ${
isActive
? 'text-white'
: 'text-slate-400 hover:text-white'
}`
}
style={({ isActive }) =>
isActive ? { backgroundColor: 'var(--coral)' } : {}
}
>
<span className="text-base w-5 text-center">{icon}</span>
{label}
</NavLink>
))}
</nav>
<div className="p-3 border-t" style={{ borderColor: 'rgba(255,255,255,0.08)' }}>
<div className="flex items-center justify-between px-2">
<span className="text-xs" style={{ color: 'var(--muted)' }}>
{user?.username}
</span>
<button
onClick={logout}
className="text-[11px] transition-colors hover:text-white"
style={{ color: 'var(--muted)' }}
>
Logout
</button>
</div>
</div>
</aside>
{/* Main content */}
<main className="flex-1 overflow-auto">
<div className="max-w-6xl mx-auto p-6">
<Outlet />
</div>
</main>
</div>
)
}

View File

@@ -0,0 +1,109 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../AuthContext'
export default function LoginPage() {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const { login } = useAuth()
const navigate = useNavigate()
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
setLoading(true)
try {
await login(username, password)
navigate('/')
} catch (err) {
setError(err.message || 'Login failed')
} finally {
setLoading(false)
}
}
return (
<div
className="min-h-screen flex items-center justify-center px-4"
style={{ backgroundColor: 'var(--ink)' }}
>
<div className="w-full max-w-sm">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-white tracking-tight font-serif">
Leopost <span style={{ color: 'var(--coral)' }}>Full</span>
</h1>
<p className="mt-2 text-sm" style={{ color: 'var(--muted)' }}>
Content Automation Platform
</p>
</div>
<form
onSubmit={handleSubmit}
className="rounded-xl p-8 shadow-xl"
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
>
{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="space-y-4">
<div>
<label
className="block text-sm font-medium mb-1"
style={{ color: 'var(--ink)' }}
>
Username
</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full px-4 py-2.5 rounded-lg text-sm focus:outline-none"
style={{
border: '1px solid var(--border)',
color: 'var(--ink)',
backgroundColor: 'var(--cream)',
}}
required
/>
</div>
<div>
<label
className="block text-sm font-medium mb-1"
style={{ color: 'var(--ink)' }}
>
Password
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-4 py-2.5 rounded-lg text-sm focus:outline-none"
style={{
border: '1px solid var(--border)',
color: 'var(--ink)',
backgroundColor: 'var(--cream)',
}}
required
/>
</div>
</div>
<button
type="submit"
disabled={loading}
className="mt-6 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 ? 'Accesso...' : 'Accedi'}
</button>
</form>
</div>
</div>
)
}

View File

@@ -0,0 +1,408 @@
import { useState, useEffect } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { api } from '../api'
const FREQUENCY_OPTIONS = [
{ value: 'daily', label: 'Giornaliero' },
{ value: 'twice_daily', label: 'Due volte al giorno' },
{ value: 'weekly', label: 'Settimanale' },
{ value: 'custom', label: 'Personalizzato' },
]
const PLATFORM_OPTIONS = [
{ value: 'instagram', label: 'Instagram' },
{ value: 'facebook', label: 'Facebook' },
{ value: 'youtube', label: 'YouTube' },
{ value: 'tiktok', label: 'TikTok' },
]
const CONTENT_TYPE_OPTIONS = [
{ value: 'text', label: 'Testo' },
{ value: 'image', label: 'Immagine' },
{ value: 'video', label: 'Video' },
]
const EMPTY_FORM = {
character_id: '',
name: '',
frequency: 'daily',
posts_per_day: 1,
platforms: [],
content_types: [],
posting_times: ['09:00'],
start_date: '',
end_date: '',
is_active: true,
}
export default function PlanForm() {
const { id } = useParams()
const isEdit = Boolean(id)
const navigate = useNavigate()
const [form, setForm] = useState(EMPTY_FORM)
const [characters, setCharacters] = useState([])
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [loading, setLoading] = useState(isEdit)
useEffect(() => {
api.get('/characters/')
.then(setCharacters)
.catch(() => {})
}, [])
useEffect(() => {
if (isEdit) {
api.get(`/plans/${id}`)
.then((data) => {
setForm({
character_id: data.character_id ? String(data.character_id) : '',
name: data.name || '',
frequency: data.frequency || 'daily',
posts_per_day: data.posts_per_day || 1,
platforms: data.platforms || [],
content_types: data.content_types || [],
posting_times: data.posting_times && data.posting_times.length > 0
? data.posting_times
: ['09:00'],
start_date: data.start_date ? data.start_date.split('T')[0] : '',
end_date: data.end_date ? data.end_date.split('T')[0] : '',
is_active: data.is_active ?? true,
})
})
.catch(() => setError('Piano non trovato'))
.finally(() => setLoading(false))
}
}, [id, isEdit])
const handleChange = (field, value) => {
setForm((prev) => ({ ...prev, [field]: value }))
}
const toggleArrayItem = (field, value) => {
setForm((prev) => {
const arr = prev[field] || []
return {
...prev,
[field]: arr.includes(value)
? arr.filter((v) => v !== value)
: [...arr, value],
}
})
}
const addPostingTime = () => {
setForm((prev) => ({
...prev,
posting_times: [...prev.posting_times, '12:00'],
}))
}
const updatePostingTime = (index, value) => {
setForm((prev) => {
const times = [...prev.posting_times]
times[index] = value
return { ...prev, posting_times: times }
})
}
const removePostingTime = (index) => {
setForm((prev) => ({
...prev,
posting_times: prev.posting_times.filter((_, i) => i !== index),
}))
}
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
if (!form.character_id) {
setError('Seleziona un personaggio')
return
}
if (form.platforms.length === 0) {
setError('Seleziona almeno una piattaforma')
return
}
if (form.content_types.length === 0) {
setError('Seleziona almeno un tipo di contenuto')
return
}
setSaving(true)
try {
const payload = {
...form,
character_id: parseInt(form.character_id),
posts_per_day: form.frequency === 'custom' ? parseInt(form.posts_per_day) : null,
start_date: form.start_date || null,
end_date: form.end_date || null,
}
if (isEdit) {
await api.put(`/plans/${id}`, payload)
} else {
await api.post('/plans/', payload)
}
navigate('/plans')
} catch (err) {
setError(err.message || 'Errore nel salvataggio')
} finally {
setSaving(false)
}
}
if (loading) {
return (
<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>
)
}
return (
<div>
<div className="mb-6">
<h2 className="text-2xl font-bold text-slate-800">
{isEdit ? 'Modifica piano editoriale' : 'Nuovo piano editoriale'}
</h2>
<p className="text-slate-500 mt-1 text-sm">
{isEdit ? 'Aggiorna la configurazione del piano' : 'Configura un nuovo piano di pubblicazione'}
</p>
</div>
<form onSubmit={handleSubmit} className="max-w-2xl space-y-6">
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-red-600 text-sm">
{error}
</div>
)}
{/* Basic info */}
<div className="bg-white rounded-xl border border-slate-200 p-6 space-y-4">
<h3 className="font-semibold text-slate-700 text-sm uppercase tracking-wider">
Informazioni base
</h3>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Personaggio
</label>
<select
value={form.character_id}
onChange={(e) => handleChange('character_id', 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"
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 text-slate-700 mb-1">
Nome piano
</label>
<input
type="text"
value={form.name}
onChange={(e) => handleChange('name', e.target.value)}
placeholder="Es. Piano Instagram Giornaliero..."
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 className="flex items-center gap-3">
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={form.is_active}
onChange={(e) => handleChange('is_active', e.target.checked)}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-slate-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white 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-brand-500"></div>
</label>
<span className="text-sm text-slate-700">Attivo</span>
</div>
</div>
{/* Frequency */}
<div className="bg-white rounded-xl border border-slate-200 p-6 space-y-4">
<h3 className="font-semibold text-slate-700 text-sm uppercase tracking-wider">
Frequenza pubblicazione
</h3>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Frequenza
</label>
<select
value={form.frequency}
onChange={(e) => handleChange('frequency', 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"
>
{FREQUENCY_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
{form.frequency === 'custom' && (
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Post al giorno
</label>
<input
type="number"
min="1"
max="20"
value={form.posts_per_day}
onChange={(e) => handleChange('posts_per_day', e.target.value)}
className="w-32 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"
/>
</div>
)}
</div>
{/* Platforms & Content Types */}
<div className="bg-white rounded-xl border border-slate-200 p-6 space-y-4">
<h3 className="font-semibold text-slate-700 text-sm uppercase tracking-wider">
Piattaforme e tipi di contenuto
</h3>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Piattaforme
</label>
<div className="flex flex-wrap gap-3">
{PLATFORM_OPTIONS.map((opt) => (
<label key={opt.value} className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={form.platforms.includes(opt.value)}
onChange={() => toggleArrayItem('platforms', opt.value)}
className="w-4 h-4 rounded border-slate-300 text-brand-600 focus:ring-brand-500"
/>
<span className="text-sm text-slate-700">{opt.label}</span>
</label>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Tipi di contenuto
</label>
<div className="flex flex-wrap gap-3">
{CONTENT_TYPE_OPTIONS.map((opt) => (
<label key={opt.value} className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={form.content_types.includes(opt.value)}
onChange={() => toggleArrayItem('content_types', opt.value)}
className="w-4 h-4 rounded border-slate-300 text-brand-600 focus:ring-brand-500"
/>
<span className="text-sm text-slate-700">{opt.label}</span>
</label>
))}
</div>
</div>
</div>
{/* Posting times */}
<div className="bg-white rounded-xl border border-slate-200 p-6 space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-slate-700 text-sm uppercase tracking-wider">
Orari di pubblicazione
</h3>
<button
type="button"
onClick={addPostingTime}
className="text-xs px-2.5 py-1 bg-slate-100 hover:bg-slate-200 text-slate-600 rounded-lg transition-colors"
>
+ Aggiungi orario
</button>
</div>
<div className="space-y-2">
{form.posting_times.map((time, i) => (
<div key={i} className="flex items-center gap-2">
<input
type="time"
value={time}
onChange={(e) => updatePostingTime(i, e.target.value)}
className="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"
/>
{form.posting_times.length > 1 && (
<button
type="button"
onClick={() => removePostingTime(i)}
className="text-xs px-2 py-1 text-red-500 hover:bg-red-50 rounded transition-colors"
>
Rimuovi
</button>
)}
</div>
))}
</div>
</div>
{/* Date range */}
<div className="bg-white rounded-xl border border-slate-200 p-6 space-y-4">
<h3 className="font-semibold text-slate-700 text-sm uppercase tracking-wider">
Periodo
</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Data inizio
</label>
<input
type="date"
value={form.start_date}
onChange={(e) => handleChange('start_date', 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"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Data fine
<span className="text-slate-400 font-normal ml-1">(opzionale)</span>
</label>
<input
type="date"
value={form.end_date}
onChange={(e) => handleChange('end_date', 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"
/>
</div>
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-3">
<button
type="submit"
disabled={saving}
className="px-6 py-2.5 bg-brand-600 hover:bg-brand-700 disabled:bg-brand-300 text-white font-medium rounded-lg transition-colors text-sm"
>
{saving ? 'Salvataggio...' : isEdit ? 'Salva modifiche' : 'Crea piano'}
</button>
<button
type="button"
onClick={() => navigate('/plans')}
className="px-6 py-2.5 bg-white hover:bg-slate-50 text-slate-600 font-medium rounded-lg border border-slate-200 transition-colors text-sm"
>
Annulla
</button>
</div>
</form>
</div>
)
}

View File

@@ -0,0 +1,213 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { api } from '../api'
const frequencyLabels = {
daily: 'Giornaliero',
twice_daily: 'Due volte al giorno',
weekly: 'Settimanale',
custom: 'Personalizzato',
}
const platformLabels = {
instagram: 'Instagram',
facebook: 'Facebook',
youtube: 'YouTube',
tiktok: 'TikTok',
}
export default function PlanList() {
const [plans, setPlans] = useState([])
const [characters, setCharacters] = useState([])
const [loading, setLoading] = useState(true)
useEffect(() => {
loadData()
}, [])
const loadData = async () => {
setLoading(true)
try {
const [plansData, charsData] = await Promise.all([
api.get('/plans/'),
api.get('/characters/'),
])
setPlans(plansData)
setCharacters(charsData)
} catch {
// silent
} finally {
setLoading(false)
}
}
const getCharacterName = (id) => {
const c = characters.find((ch) => ch.id === id)
return c ? c.name : '—'
}
const handleToggle = async (plan) => {
try {
await api.post(`/plans/${plan.id}/toggle`)
loadData()
} catch {
// silent
}
}
const handleDelete = async (id, name) => {
if (!confirm(`Eliminare il piano "${name}"?`)) return
try {
await api.delete(`/plans/${id}`)
loadData()
} catch {
// silent
}
}
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">Piano Editoriale</h2>
<p className="text-slate-500 mt-1 text-sm">
Gestisci i piani di pubblicazione automatica
</p>
</div>
<Link
to="/plans/new"
className="px-4 py-2 bg-brand-600 hover:bg-brand-700 text-white text-sm font-medium rounded-lg transition-colors"
>
+ Nuovo Piano
</Link>
</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>
) : plans.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 piano editoriale</p>
<p className="text-slate-400 text-sm mt-1">
Crea un piano per automatizzare la pubblicazione dei contenuti
</p>
<Link
to="/plans/new"
className="inline-block mt-4 px-4 py-2 bg-brand-600 hover:bg-brand-700 text-white text-sm font-medium rounded-lg transition-colors"
>
+ Crea piano
</Link>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{plans.map((plan) => (
<div
key={plan.id}
className="bg-white rounded-xl border border-slate-200 hover:border-brand-300 transition-all overflow-hidden"
>
<div className="p-5">
{/* Header */}
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2">
<span className={`w-2.5 h-2.5 rounded-full shrink-0 ${plan.is_active ? 'bg-emerald-500' : 'bg-slate-300'}`} />
<h3 className="font-semibold text-slate-800">{plan.name}</h3>
</div>
<button
onClick={() => handleToggle(plan)}
className={`text-xs px-2.5 py-1 rounded-full font-medium transition-colors ${
plan.is_active
? 'bg-emerald-50 text-emerald-600 hover:bg-emerald-100'
: 'bg-slate-100 text-slate-500 hover:bg-slate-200'
}`}
>
{plan.is_active ? 'Attivo' : 'Inattivo'}
</button>
</div>
{/* Character */}
<p className="text-sm text-slate-500 mb-3">
{getCharacterName(plan.character_id)}
</p>
{/* Info grid */}
<div className="space-y-2">
{/* Frequency */}
<div className="flex items-center gap-2">
<span className="text-xs text-slate-400 w-20 shrink-0">Frequenza</span>
<span className="text-xs font-medium text-slate-600">
{frequencyLabels[plan.frequency] || plan.frequency}
{plan.frequency === 'custom' && plan.posts_per_day && (
<span className="text-slate-400 font-normal ml-1">
({plan.posts_per_day} post/giorno)
</span>
)}
</span>
</div>
{/* Platforms */}
<div className="flex items-center gap-2">
<span className="text-xs text-slate-400 w-20 shrink-0">Piattaforme</span>
<div className="flex flex-wrap gap-1">
{plan.platforms && plan.platforms.map((p) => (
<span key={p} className="text-xs px-1.5 py-0.5 bg-slate-100 text-slate-600 rounded">
{platformLabels[p] || p}
</span>
))}
</div>
</div>
{/* Posting times */}
{plan.posting_times && plan.posting_times.length > 0 && (
<div className="flex items-center gap-2">
<span className="text-xs text-slate-400 w-20 shrink-0">Orari</span>
<div className="flex flex-wrap gap-1">
{plan.posting_times.map((t, i) => (
<span key={i} className="text-xs px-1.5 py-0.5 bg-brand-50 text-brand-700 rounded font-mono">
{t}
</span>
))}
</div>
</div>
)}
{/* Date range */}
<div className="flex items-center gap-2">
<span className="text-xs text-slate-400 w-20 shrink-0">Periodo</span>
<span className="text-xs text-slate-600">
{formatDate(plan.start_date)}
{plan.end_date ? `${formatDate(plan.end_date)}` : ' — In corso'}
</span>
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2 mt-4 pt-3 border-t border-slate-100">
<Link
to={`/plans/${plan.id}/edit`}
className="text-xs px-3 py-1.5 bg-slate-100 hover:bg-slate-200 text-slate-600 rounded-lg transition-colors"
>
Modifica
</Link>
<button
onClick={() => handleDelete(plan.id, plan.name)}
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>
)
}

View File

@@ -0,0 +1,16 @@
import { Navigate, Outlet } from 'react-router-dom'
import { useAuth } from '../AuthContext'
export default function ProtectedRoute() {
const { user, loading } = useAuth()
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-slate-50">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-brand-500 border-t-transparent" />
</div>
)
}
return user ? <Outlet /> : <Navigate to="/login" />
}

View File

@@ -0,0 +1,263 @@
import { useState, useEffect } from 'react'
import { api } from '../api'
const statusLabels = {
pending: 'In attesa',
publishing: 'Pubblicazione...',
published: 'Pubblicato',
failed: 'Fallito',
}
const statusColors = {
pending: 'bg-amber-50 text-amber-600',
publishing: 'bg-blue-50 text-blue-600',
published: 'bg-emerald-50 text-emerald-600',
failed: 'bg-red-50 text-red-600',
}
const statusDotColors = {
pending: 'bg-amber-400',
publishing: 'bg-blue-400',
published: 'bg-emerald-400',
failed: 'bg-red-400',
}
const platformLabels = {
instagram: 'Instagram',
facebook: 'Facebook',
youtube: 'YouTube',
tiktok: 'TikTok',
}
export default function ScheduleView() {
const [scheduled, setScheduled] = useState([])
const [loading, setLoading] = useState(true)
const [filterPlatform, setFilterPlatform] = useState('')
const [filterStatus, setFilterStatus] = useState('')
const [publishing, setPublishing] = useState(null)
useEffect(() => {
loadScheduled()
}, [])
const loadScheduled = async () => {
setLoading(true)
try {
const data = await api.get('/plans/scheduled')
setScheduled(data)
} catch {
// silent
} finally {
setLoading(false)
}
}
const handlePublishNow = async (id) => {
setPublishing(id)
try {
await api.post(`/social/publish/${id}`)
loadScheduled()
} catch {
// silent
} finally {
setPublishing(null)
}
}
const handleDelete = async (id) => {
if (!confirm('Rimuovere questo post schedulato?')) return
try {
await api.delete(`/plans/scheduled/${id}`)
loadScheduled()
} catch {
// silent
}
}
const filtered = scheduled.filter((s) => {
if (filterPlatform && s.platform !== filterPlatform) return false
if (filterStatus && s.status !== filterStatus) return false
return true
})
// Group by date
const groupByDate = (items) => {
const groups = {}
const today = new Date()
today.setHours(0, 0, 0, 0)
const tomorrow = new Date(today)
tomorrow.setDate(tomorrow.getDate() + 1)
items.forEach((item) => {
const date = new Date(item.scheduled_at || item.publish_at)
date.setHours(0, 0, 0, 0)
let label
if (date.getTime() === today.getTime()) {
label = 'Oggi'
} else if (date.getTime() === tomorrow.getTime()) {
label = 'Domani'
} else {
label = date.toLocaleDateString('it-IT', {
day: 'numeric',
month: 'long',
year: 'numeric',
})
}
if (!groups[label]) groups[label] = []
groups[label].push(item)
})
return groups
}
const formatTime = (dateStr) => {
if (!dateStr) return '—'
const d = new Date(dateStr)
return d.toLocaleTimeString('it-IT', { hour: '2-digit', minute: '2-digit' })
}
const grouped = groupByDate(filtered)
return (
<div>
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-2xl font-bold text-slate-800">Schedulazione</h2>
<p className="text-slate-500 mt-1 text-sm">
Post programmati e in attesa di pubblicazione
</p>
</div>
<button
onClick={loadScheduled}
className="px-4 py-2 bg-white hover:bg-slate-50 text-slate-700 text-sm font-medium rounded-lg border border-slate-200 transition-colors"
>
Aggiorna
</button>
</div>
{/* Filters */}
<div className="flex flex-wrap gap-3 mb-6">
<select
value={filterPlatform}
onChange={(e) => setFilterPlatform(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="">Tutte le piattaforme</option>
{Object.entries(platformLabels).map(([val, label]) => (
<option key={val} value={val}>{label}</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} post programmati
</span>
</div>
{/* View toggle - for future */}
<div className="flex gap-1 mb-4">
<button className="px-3 py-1.5 text-xs font-medium bg-brand-600 text-white rounded-lg">
Lista
</button>
<button className="px-3 py-1.5 text-xs font-medium bg-slate-100 text-slate-500 rounded-lg cursor-not-allowed" title="Disponibile prossimamente">
Calendario
</button>
</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 post schedulato</p>
<p className="text-slate-400 text-sm mt-1">
I post verranno schedulati automaticamente dai piani editoriali attivi
</p>
</div>
) : (
<div className="space-y-6">
{Object.entries(grouped).map(([dateLabel, items]) => (
<div key={dateLabel}>
{/* Date header */}
<h3 className="text-sm font-semibold text-slate-500 uppercase tracking-wider mb-3 flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-slate-400" />
{dateLabel}
<span className="text-xs font-normal text-slate-400 lowercase">
({items.length} post)
</span>
</h3>
{/* Items */}
<div className="space-y-2">
{items.map((item) => (
<div
key={item.id}
className="bg-white rounded-xl border border-slate-200 hover:border-brand-300 transition-all p-4"
>
<div className="flex items-start gap-3">
{/* Time + status dot */}
<div className="flex flex-col items-center gap-1 shrink-0 w-14">
<span className="text-sm font-mono font-medium text-slate-700">
{formatTime(item.scheduled_at || item.publish_at)}
</span>
<span className={`w-2 h-2 rounded-full ${statusDotColors[item.status] || 'bg-slate-300'}`} />
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs px-2 py-0.5 rounded-full bg-slate-100 text-slate-600">
{platformLabels[item.platform] || item.platform}
</span>
<span className={`text-xs px-2 py-0.5 rounded-full ${statusColors[item.status] || 'bg-slate-100 text-slate-500'}`}>
{statusLabels[item.status] || item.status}
</span>
</div>
<p className="text-sm text-slate-700 line-clamp-2">
{item.text || item.post_text || 'Contenuto in fase di generazione...'}
</p>
</div>
{/* Actions */}
<div className="flex items-center gap-1 shrink-0">
{(item.status === 'pending' || item.status === 'failed') && (
<button
onClick={() => handlePublishNow(item.id)}
disabled={publishing === item.id}
className="text-xs px-2.5 py-1.5 bg-brand-50 hover:bg-brand-100 text-brand-600 rounded-lg transition-colors font-medium disabled:opacity-50"
>
{publishing === item.id ? 'Invio...' : 'Pubblica ora'}
</button>
)}
<button
onClick={() => handleDelete(item.id)}
className="text-xs px-2 py-1.5 text-red-500 hover:bg-red-50 rounded-lg transition-colors"
>
Rimuovi
</button>
</div>
</div>
</div>
))}
</div>
</div>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,287 @@
import { useState, useEffect } from 'react'
import { api } from '../api'
const LLM_PROVIDERS = [
{ value: 'claude', label: 'Claude (Anthropic)' },
{ value: 'openai', label: 'OpenAI' },
{ value: 'gemini', label: 'Gemini (Google)' },
]
const IMAGE_PROVIDERS = [
{ value: 'dalle', label: 'DALL-E (OpenAI)' },
{ value: 'replicate', label: 'Replicate' },
]
const LLM_DEFAULTS = {
claude: 'claude-sonnet-4-20250514',
openai: 'gpt-4o',
gemini: 'gemini-2.0-flash',
}
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 SettingsPage() {
const [settings, setSettings] = useState({})
const [providerStatus, setProviderStatus] = useState({})
const [loading, setLoading] = useState(true)
const [sectionSaving, setSectionSaving] = useState({})
const [sectionSuccess, setSectionSuccess] = useState({})
const [sectionError, setSectionError] = useState({})
const [llmForm, setLlmForm] = useState({
llm_provider: 'claude',
llm_api_key: '',
llm_model: '',
})
const [imageForm, setImageForm] = useState({
image_provider: 'dalle',
image_api_key: '',
})
const [voiceForm, setVoiceForm] = useState({
elevenlabs_api_key: '',
elevenlabs_voice_id: '',
})
useEffect(() => {
loadSettings()
}, [])
const loadSettings = async () => {
setLoading(true)
try {
const [settingsData, statusData] = await Promise.all([
api.get('/settings/').catch(() => ({})),
api.get('/settings/providers/status').catch(() => ({})),
])
let normalizedSettings = {}
if (Array.isArray(settingsData)) {
settingsData.forEach((s) => { normalizedSettings[s.key] = s.value })
} else {
normalizedSettings = settingsData || {}
}
setSettings(normalizedSettings)
setProviderStatus(statusData || {})
setLlmForm({
llm_provider: normalizedSettings.llm_provider || 'claude',
llm_api_key: normalizedSettings.llm_api_key || '',
llm_model: normalizedSettings.llm_model || '',
})
setImageForm({
image_provider: normalizedSettings.image_provider || 'dalle',
image_api_key: normalizedSettings.image_api_key || '',
})
setVoiceForm({
elevenlabs_api_key: normalizedSettings.elevenlabs_api_key || '',
elevenlabs_voice_id: normalizedSettings.elevenlabs_voice_id || '',
})
} catch {
// silent
} finally {
setLoading(false)
}
}
const saveSection = async (section, data) => {
setSectionSaving((prev) => ({ ...prev, [section]: true }))
setSectionSuccess((prev) => ({ ...prev, [section]: false }))
setSectionError((prev) => ({ ...prev, [section]: '' }))
try {
for (const [key, value] of Object.entries(data)) {
await api.put(`/settings/${key}`, { value })
}
setSectionSuccess((prev) => ({ ...prev, [section]: true }))
setTimeout(() => {
setSectionSuccess((prev) => ({ ...prev, [section]: false }))
}, 3000)
const statusData = await api.get('/settings/providers/status').catch(() => ({}))
setProviderStatus(statusData || {})
} catch (err) {
setSectionError((prev) => ({ ...prev, [section]: err.message || 'Errore nel salvataggio' }))
} finally {
setSectionSaving((prev) => ({ ...prev, [section]: false }))
}
}
if (loading) {
return (
<div>
<h2 className="text-2xl font-bold mb-1" style={{ color: 'var(--ink)' }}>Impostazioni</h2>
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-t-transparent" style={{ borderColor: 'var(--coral)' }} />
</div>
</div>
)
}
return (
<div>
<div className="mb-6">
<h2 className="text-2xl font-bold" style={{ color: 'var(--ink)' }}>Impostazioni</h2>
<p className="mt-1 text-sm" style={{ color: 'var(--muted)' }}>
Configurazione dei provider AI e dei servizi esterni
</p>
</div>
<div className="max-w-2xl space-y-6">
{/* LLM Provider */}
<div style={cardStyle} className="space-y-4">
<h3 className="font-semibold text-sm uppercase tracking-wider" style={{ color: 'var(--muted)' }}>
Provider LLM
</h3>
{sectionError.llm && <div className="p-3 bg-red-50 border border-red-200 rounded-lg text-red-600 text-sm">{sectionError.llm}</div>}
{sectionSuccess.llm && <div className="p-3 bg-emerald-50 border border-emerald-200 rounded-lg text-emerald-600 text-sm">Salvato con successo</div>}
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--ink)' }}>Provider</label>
<select
value={llmForm.llm_provider}
onChange={(e) => setLlmForm((prev) => ({ ...prev, llm_provider: e.target.value }))}
style={inputStyle}
>
{LLM_PROVIDERS.map((p) => (
<option key={p.value} value={p.value}>{p.label}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--ink)' }}>API Key</label>
<input
type="password"
value={llmForm.llm_api_key}
onChange={(e) => setLlmForm((prev) => ({ ...prev, llm_api_key: e.target.value }))}
placeholder="sk-..."
style={{ ...inputStyle, fontFamily: 'monospace' }}
/>
</div>
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--ink)' }}>
Modello <span className="font-normal ml-1" style={{ color: 'var(--muted)' }}>(default: {LLM_DEFAULTS[llmForm.llm_provider]})</span>
</label>
<input
type="text"
value={llmForm.llm_model}
onChange={(e) => setLlmForm((prev) => ({ ...prev, llm_model: e.target.value }))}
placeholder={LLM_DEFAULTS[llmForm.llm_provider]}
style={{ ...inputStyle, fontFamily: 'monospace' }}
/>
</div>
<button
onClick={() => saveSection('llm', llmForm)}
disabled={sectionSaving.llm}
className="px-4 py-2 text-white text-sm font-medium rounded-lg transition-opacity"
style={{ backgroundColor: 'var(--coral)', opacity: sectionSaving.llm ? 0.7 : 1 }}
>
{sectionSaving.llm ? 'Salvataggio...' : 'Salva'}
</button>
</div>
{/* Image Provider */}
<div style={cardStyle} className="space-y-4">
<h3 className="font-semibold text-sm uppercase tracking-wider" style={{ color: 'var(--muted)' }}>
Generazione Immagini
</h3>
{sectionError.image && <div className="p-3 bg-red-50 border border-red-200 rounded-lg text-red-600 text-sm">{sectionError.image}</div>}
{sectionSuccess.image && <div className="p-3 bg-emerald-50 border border-emerald-200 rounded-lg text-emerald-600 text-sm">Salvato con successo</div>}
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--ink)' }}>Provider</label>
<select
value={imageForm.image_provider}
onChange={(e) => setImageForm((prev) => ({ ...prev, image_provider: e.target.value }))}
style={inputStyle}
>
{IMAGE_PROVIDERS.map((p) => (
<option key={p.value} value={p.value}>{p.label}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--ink)' }}>API Key</label>
<input
type="password"
value={imageForm.image_api_key}
onChange={(e) => setImageForm((prev) => ({ ...prev, image_api_key: e.target.value }))}
placeholder="API key del provider immagini"
style={{ ...inputStyle, fontFamily: 'monospace' }}
/>
</div>
<button
onClick={() => saveSection('image', imageForm)}
disabled={sectionSaving.image}
className="px-4 py-2 text-white text-sm font-medium rounded-lg transition-opacity"
style={{ backgroundColor: 'var(--coral)', opacity: sectionSaving.image ? 0.7 : 1 }}
>
{sectionSaving.image ? 'Salvataggio...' : 'Salva'}
</button>
</div>
{/* Voiceover */}
<div style={cardStyle} className="space-y-4">
<h3 className="font-semibold text-sm uppercase tracking-wider" style={{ color: 'var(--muted)' }}>
Voiceover (ElevenLabs)
</h3>
{sectionError.voice && <div className="p-3 bg-red-50 border border-red-200 rounded-lg text-red-600 text-sm">{sectionError.voice}</div>}
{sectionSuccess.voice && <div className="p-3 bg-emerald-50 border border-emerald-200 rounded-lg text-emerald-600 text-sm">Salvato con successo</div>}
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--ink)' }}>API Key</label>
<input
type="password"
value={voiceForm.elevenlabs_api_key}
onChange={(e) => setVoiceForm((prev) => ({ ...prev, elevenlabs_api_key: e.target.value }))}
placeholder="ElevenLabs API key"
style={{ ...inputStyle, fontFamily: 'monospace' }}
/>
</div>
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--ink)' }}>
Voice ID <span className="font-normal ml-1" style={{ color: 'var(--muted)' }}>(opzionale)</span>
</label>
<input
type="text"
value={voiceForm.elevenlabs_voice_id}
onChange={(e) => setVoiceForm((prev) => ({ ...prev, elevenlabs_voice_id: e.target.value }))}
placeholder="ID della voce ElevenLabs"
style={inputStyle}
/>
</div>
<button
onClick={() => saveSection('voice', voiceForm)}
disabled={sectionSaving.voice}
className="px-4 py-2 text-white text-sm font-medium rounded-lg transition-opacity"
style={{ backgroundColor: 'var(--coral)', opacity: sectionSaving.voice ? 0.7 : 1 }}
>
{sectionSaving.voice ? 'Salvataggio...' : 'Salva'}
</button>
</div>
</div>
</div>
)
}

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