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:
15
frontend/index.html
Normal file
15
frontend/index.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Leopost Full</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,wght@0,400;0,600;0,700;1,400&family=DM+Sans:wght@400;500;600&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
23
frontend/package.json
Normal file
23
frontend/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "leopost-full",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.41",
|
||||
"tailwindcss": "^3.4.10",
|
||||
"vite": "^5.4.0"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
53
frontend/src/App.jsx
Normal file
53
frontend/src/App.jsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { AuthProvider } from './AuthContext'
|
||||
import ProtectedRoute from './components/ProtectedRoute'
|
||||
import Layout from './components/Layout'
|
||||
import LoginPage from './components/LoginPage'
|
||||
import Dashboard from './components/Dashboard'
|
||||
import CharacterList from './components/CharacterList'
|
||||
import CharacterForm from './components/CharacterForm'
|
||||
import ContentPage from './components/ContentPage'
|
||||
import ContentArchive from './components/ContentArchive'
|
||||
import AffiliateList from './components/AffiliateList'
|
||||
import AffiliateForm from './components/AffiliateForm'
|
||||
import PlanList from './components/PlanList'
|
||||
import PlanForm from './components/PlanForm'
|
||||
import ScheduleView from './components/ScheduleView'
|
||||
import SocialAccounts from './components/SocialAccounts'
|
||||
import CommentsQueue from './components/CommentsQueue'
|
||||
import SettingsPage from './components/SettingsPage'
|
||||
import EditorialCalendar from './components/EditorialCalendar'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter basename="/leopost-full">
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route element={<Layout />}>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/characters" element={<CharacterList />} />
|
||||
<Route path="/characters/new" element={<CharacterForm />} />
|
||||
<Route path="/characters/:id/edit" element={<CharacterForm />} />
|
||||
<Route path="/content" element={<ContentPage />} />
|
||||
<Route path="/content/archive" element={<ContentArchive />} />
|
||||
<Route path="/affiliates" element={<AffiliateList />} />
|
||||
<Route path="/affiliates/new" element={<AffiliateForm />} />
|
||||
<Route path="/affiliates/:id/edit" element={<AffiliateForm />} />
|
||||
<Route path="/plans" element={<PlanList />} />
|
||||
<Route path="/plans/new" element={<PlanForm />} />
|
||||
<Route path="/plans/:id/edit" element={<PlanForm />} />
|
||||
<Route path="/schedule" element={<ScheduleView />} />
|
||||
<Route path="/social" element={<SocialAccounts />} />
|
||||
<Route path="/comments" element={<CommentsQueue />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/editorial" element={<EditorialCalendar />} />
|
||||
</Route>
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" />} />
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
41
frontend/src/AuthContext.jsx
Normal file
41
frontend/src/AuthContext.jsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { createContext, useContext, useState, useEffect } from 'react'
|
||||
import { api } from './api'
|
||||
|
||||
const AuthContext = createContext(null)
|
||||
|
||||
export function AuthProvider({ children }) {
|
||||
const [user, setUser] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) {
|
||||
api.get('/auth/me')
|
||||
.then((data) => setUser(data))
|
||||
.catch(() => localStorage.removeItem('token'))
|
||||
.finally(() => setLoading(false))
|
||||
} else {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const login = async (username, password) => {
|
||||
const data = await api.post('/auth/login', { username, password })
|
||||
localStorage.setItem('token', data.access_token)
|
||||
const me = await api.get('/auth/me')
|
||||
setUser(me)
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('token')
|
||||
setUser(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, login, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useAuth = () => useContext(AuthContext)
|
||||
34
frontend/src/api.js
Normal file
34
frontend/src/api.js
Normal file
@@ -0,0 +1,34 @@
|
||||
const BASE_URL = '/leopost-full/api'
|
||||
|
||||
async function request(method, path, body = null) {
|
||||
const headers = { 'Content-Type': 'application/json' }
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`
|
||||
|
||||
const res = await fetch(`${BASE_URL}${path}`, {
|
||||
method,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : null,
|
||||
})
|
||||
|
||||
if (res.status === 401) {
|
||||
localStorage.removeItem('token')
|
||||
window.location.href = '/leopost-full/login'
|
||||
return
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json().catch(() => ({ detail: 'Request failed' }))
|
||||
throw new Error(error.detail || 'Request failed')
|
||||
}
|
||||
|
||||
if (res.status === 204) return null
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: (path) => request('GET', path),
|
||||
post: (path, body) => request('POST', path, body),
|
||||
put: (path, body) => request('PUT', path, body),
|
||||
delete: (path) => request('DELETE', path),
|
||||
}
|
||||
306
frontend/src/components/AffiliateForm.jsx
Normal file
306
frontend/src/components/AffiliateForm.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
218
frontend/src/components/AffiliateList.jsx
Normal file
218
frontend/src/components/AffiliateList.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
331
frontend/src/components/CharacterForm.jsx
Normal file
331
frontend/src/components/CharacterForm.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
161
frontend/src/components/CharacterList.jsx
Normal file
161
frontend/src/components/CharacterList.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
292
frontend/src/components/CommentsQueue.jsx
Normal file
292
frontend/src/components/CommentsQueue.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
227
frontend/src/components/ContentArchive.jsx
Normal file
227
frontend/src/components/ContentArchive.jsx
Normal file
@@ -0,0 +1,227 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { api } from '../api'
|
||||
|
||||
const statusLabels = {
|
||||
draft: 'Bozza',
|
||||
approved: 'Approvato',
|
||||
scheduled: 'Schedulato',
|
||||
published: 'Pubblicato',
|
||||
}
|
||||
|
||||
const statusColors = {
|
||||
draft: 'bg-amber-50 text-amber-600',
|
||||
approved: 'bg-emerald-50 text-emerald-600',
|
||||
scheduled: 'bg-blue-50 text-blue-600',
|
||||
published: 'bg-violet-50 text-violet-600',
|
||||
}
|
||||
|
||||
const platformLabels = {
|
||||
instagram: 'Instagram',
|
||||
facebook: 'Facebook',
|
||||
youtube: 'YouTube',
|
||||
tiktok: 'TikTok',
|
||||
}
|
||||
|
||||
export default function ContentArchive() {
|
||||
const [posts, setPosts] = useState([])
|
||||
const [characters, setCharacters] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [expandedId, setExpandedId] = useState(null)
|
||||
const [filterCharacter, setFilterCharacter] = useState('')
|
||||
const [filterStatus, setFilterStatus] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [])
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [postsData, charsData] = await Promise.all([
|
||||
api.get('/content/posts'),
|
||||
api.get('/characters/'),
|
||||
])
|
||||
setPosts(postsData)
|
||||
setCharacters(charsData)
|
||||
} catch {
|
||||
// silent
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getCharacterName = (id) => {
|
||||
const c = characters.find((ch) => ch.id === id)
|
||||
return c ? c.name : '—'
|
||||
}
|
||||
|
||||
const handleApprove = async (postId) => {
|
||||
try {
|
||||
await api.post(`/content/posts/${postId}/approve`)
|
||||
loadData()
|
||||
} catch {
|
||||
// silent
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (postId) => {
|
||||
if (!confirm('Eliminare questo contenuto?')) return
|
||||
try {
|
||||
await api.delete(`/content/posts/${postId}`)
|
||||
loadData()
|
||||
} catch {
|
||||
// silent
|
||||
}
|
||||
}
|
||||
|
||||
const filtered = posts.filter((p) => {
|
||||
if (filterCharacter && String(p.character_id) !== filterCharacter) return false
|
||||
if (filterStatus && p.status !== filterStatus) return false
|
||||
return true
|
||||
})
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return '—'
|
||||
const d = new Date(dateStr)
|
||||
return d.toLocaleDateString('it-IT', { day: '2-digit', month: 'short', year: 'numeric' })
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-slate-800">Archivio Contenuti</h2>
|
||||
<p className="text-slate-500 mt-1 text-sm">
|
||||
Tutti i contenuti generati
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap gap-3 mb-6">
|
||||
<select
|
||||
value={filterCharacter}
|
||||
onChange={(e) => setFilterCharacter(e.target.value)}
|
||||
className="px-3 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent text-sm bg-white"
|
||||
>
|
||||
<option value="">Tutti i personaggi</option>
|
||||
{characters.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={filterStatus}
|
||||
onChange={(e) => setFilterStatus(e.target.value)}
|
||||
className="px-3 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent text-sm bg-white"
|
||||
>
|
||||
<option value="">Tutti gli stati</option>
|
||||
{Object.entries(statusLabels).map(([val, label]) => (
|
||||
<option key={val} value={val}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<span className="flex items-center text-xs text-slate-400 ml-auto">
|
||||
{filtered.length} contenut{filtered.length === 1 ? 'o' : 'i'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-2 border-brand-500 border-t-transparent" />
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="text-center py-16 bg-white rounded-xl border border-slate-200">
|
||||
<p className="text-4xl mb-3">✦</p>
|
||||
<p className="text-slate-500 font-medium">Nessun contenuto trovato</p>
|
||||
<p className="text-slate-400 text-sm mt-1">
|
||||
{posts.length === 0
|
||||
? 'Genera il tuo primo contenuto dalla pagina Contenuti'
|
||||
: 'Prova a cambiare i filtri'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filtered.map((post) => (
|
||||
<div
|
||||
key={post.id}
|
||||
className="bg-white rounded-xl border border-slate-200 hover:border-brand-300 transition-all overflow-hidden cursor-pointer"
|
||||
onClick={() => setExpandedId(expandedId === post.id ? null : post.id)}
|
||||
>
|
||||
<div className="p-5">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${statusColors[post.status] || 'bg-slate-100 text-slate-500'}`}>
|
||||
{statusLabels[post.status] || post.status}
|
||||
</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-slate-100 text-slate-600">
|
||||
{platformLabels[post.platform] || post.platform}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Character name */}
|
||||
<p className="text-xs font-medium text-slate-500 mb-1">
|
||||
{getCharacterName(post.character_id)}
|
||||
</p>
|
||||
|
||||
{/* Text preview */}
|
||||
<p className={`text-sm text-slate-700 ${expandedId === post.id ? 'whitespace-pre-wrap' : 'line-clamp-3'}`}>
|
||||
{post.text}
|
||||
</p>
|
||||
|
||||
{/* Expanded details */}
|
||||
{expandedId === post.id && (
|
||||
<div className="mt-3 space-y-2">
|
||||
{post.hashtags && post.hashtags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{post.hashtags.map((tag, i) => (
|
||||
<span key={i} className="text-xs px-1.5 py-0.5 bg-brand-50 text-brand-700 rounded">
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between mt-3 pt-3 border-t border-slate-100">
|
||||
<span className="text-xs text-slate-400">
|
||||
{formatDate(post.created_at)}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{post.hashtags && (
|
||||
<span className="text-xs text-slate-400">
|
||||
{post.hashtags.length} hashtag
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 mt-3 pt-3 border-t border-slate-100"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{post.status === 'draft' && (
|
||||
<button
|
||||
onClick={() => handleApprove(post.id)}
|
||||
className="text-xs px-3 py-1.5 bg-emerald-50 hover:bg-emerald-100 text-emerald-600 rounded-lg transition-colors font-medium"
|
||||
>
|
||||
Approva
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleDelete(post.id)}
|
||||
className="text-xs px-3 py-1.5 text-red-500 hover:bg-red-50 rounded-lg transition-colors ml-auto"
|
||||
>
|
||||
Elimina
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
334
frontend/src/components/ContentPage.jsx
Normal file
334
frontend/src/components/ContentPage.jsx
Normal file
@@ -0,0 +1,334 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { api } from '../api'
|
||||
|
||||
const cardStyle = {
|
||||
backgroundColor: 'var(--surface)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '0.75rem',
|
||||
padding: '1.5rem',
|
||||
}
|
||||
|
||||
const inputStyle = {
|
||||
width: '100%',
|
||||
padding: '0.625rem 1rem',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '0.5rem',
|
||||
fontSize: '0.875rem',
|
||||
color: 'var(--ink)',
|
||||
backgroundColor: 'var(--cream)',
|
||||
outline: 'none',
|
||||
}
|
||||
|
||||
export default function ContentPage() {
|
||||
const [characters, setCharacters] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [generated, setGenerated] = useState(null)
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [editText, setEditText] = useState('')
|
||||
|
||||
const [form, setForm] = useState({
|
||||
character_id: '',
|
||||
platform: 'instagram',
|
||||
content_type: 'text',
|
||||
topic_hint: '',
|
||||
include_affiliates: false,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/characters/').then(setCharacters).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const handleChange = (field, value) => {
|
||||
setForm((prev) => ({ ...prev, [field]: value }))
|
||||
}
|
||||
|
||||
const handleGenerate = async (e) => {
|
||||
e.preventDefault()
|
||||
if (!form.character_id) {
|
||||
setError('Seleziona un personaggio')
|
||||
return
|
||||
}
|
||||
setError('')
|
||||
setLoading(true)
|
||||
setGenerated(null)
|
||||
try {
|
||||
const data = await api.post('/content/generate', {
|
||||
character_id: parseInt(form.character_id),
|
||||
platform: form.platform,
|
||||
content_type: form.content_type,
|
||||
topic_hint: form.topic_hint || null,
|
||||
include_affiliates: form.include_affiliates,
|
||||
})
|
||||
setGenerated(data)
|
||||
setEditText(data.text_content || '')
|
||||
} catch (err) {
|
||||
setError(err.message || 'Errore nella generazione')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleApprove = async () => {
|
||||
if (!generated) return
|
||||
try {
|
||||
await api.post(`/content/posts/${generated.id}/approve`)
|
||||
setGenerated((prev) => ({ ...prev, status: 'approved' }))
|
||||
} catch (err) {
|
||||
setError(err.message || 'Errore approvazione')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveEdit = async () => {
|
||||
if (!generated) return
|
||||
try {
|
||||
await api.put(`/content/posts/${generated.id}`, { text_content: editText })
|
||||
setGenerated((prev) => ({ ...prev, text_content: editText }))
|
||||
setEditing(false)
|
||||
} catch (err) {
|
||||
setError(err.message || 'Errore salvataggio')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!generated) return
|
||||
if (!confirm('Eliminare questo contenuto?')) return
|
||||
try {
|
||||
await api.delete(`/content/posts/${generated.id}`)
|
||||
setGenerated(null)
|
||||
} catch (err) {
|
||||
setError(err.message || 'Errore eliminazione')
|
||||
}
|
||||
}
|
||||
|
||||
const platformLabels = {
|
||||
instagram: 'Instagram',
|
||||
facebook: 'Facebook',
|
||||
youtube: 'YouTube',
|
||||
tiktok: 'TikTok',
|
||||
}
|
||||
|
||||
const contentTypeLabels = {
|
||||
text: 'Testo',
|
||||
image: 'Immagine',
|
||||
video: 'Video',
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h2 className="text-2xl font-bold" style={{ color: 'var(--ink)' }}>Contenuti</h2>
|
||||
<p className="mt-1 text-sm" style={{ color: 'var(--muted)' }}>
|
||||
Genera e gestisci contenuti per i tuoi personaggi
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-600 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Generation form */}
|
||||
<div style={cardStyle}>
|
||||
<h3 className="font-semibold text-sm uppercase tracking-wider mb-4" style={{ color: 'var(--muted)' }}>
|
||||
Genera Contenuto
|
||||
</h3>
|
||||
|
||||
<form onSubmit={handleGenerate} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--ink)' }}>Personaggio</label>
|
||||
<select
|
||||
value={form.character_id}
|
||||
onChange={(e) => handleChange('character_id', e.target.value)}
|
||||
style={inputStyle}
|
||||
required
|
||||
>
|
||||
<option value="">Seleziona personaggio...</option>
|
||||
{characters.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--ink)' }}>Piattaforma</label>
|
||||
<select
|
||||
value={form.platform}
|
||||
onChange={(e) => handleChange('platform', e.target.value)}
|
||||
style={inputStyle}
|
||||
>
|
||||
{Object.entries(platformLabels).map(([val, label]) => (
|
||||
<option key={val} value={val}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--ink)' }}>Tipo contenuto</label>
|
||||
<select
|
||||
value={form.content_type}
|
||||
onChange={(e) => handleChange('content_type', e.target.value)}
|
||||
style={inputStyle}
|
||||
>
|
||||
{Object.entries(contentTypeLabels).map(([val, label]) => (
|
||||
<option key={val} value={val}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--ink)' }}>
|
||||
Suggerimento tema <span className="font-normal" style={{ color: 'var(--muted)' }}>(opzionale)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.topic_hint}
|
||||
onChange={(e) => handleChange('topic_hint', e.target.value)}
|
||||
placeholder="Es. ultimi trend, tutorial..."
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.include_affiliates}
|
||||
onChange={(e) => handleChange('include_affiliates', e.target.checked)}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-9 h-5 bg-slate-200 rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-coral"></div>
|
||||
</label>
|
||||
<span className="text-sm" style={{ color: 'var(--ink)' }}>Includi link affiliati</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-2.5 text-white font-medium rounded-lg transition-opacity text-sm"
|
||||
style={{ backgroundColor: 'var(--coral)', opacity: loading ? 0.7 : 1 }}
|
||||
>
|
||||
{loading ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<span className="animate-spin rounded-full h-4 w-4 border-2 border-white border-t-transparent" />
|
||||
Generazione in corso...
|
||||
</span>
|
||||
) : 'Genera'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
<div style={cardStyle}>
|
||||
<h3 className="font-semibold text-sm uppercase tracking-wider mb-4" style={{ color: 'var(--muted)' }}>
|
||||
Ultimo Contenuto Generato
|
||||
</h3>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex flex-col items-center justify-center py-16">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-2 border-t-transparent" style={{ borderColor: 'var(--coral)' }} />
|
||||
<p className="text-sm mt-3" style={{ color: 'var(--muted)' }}>Generazione in corso...</p>
|
||||
</div>
|
||||
) : generated ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${
|
||||
generated.status === 'approved' ? 'bg-emerald-50 text-emerald-600' :
|
||||
generated.status === 'published' ? 'bg-blue-50 text-blue-600' :
|
||||
'bg-amber-50 text-amber-600'
|
||||
}`}>
|
||||
{generated.status === 'approved' ? 'Approvato' :
|
||||
generated.status === 'published' ? 'Pubblicato' : 'Bozza'}
|
||||
</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-slate-100 text-slate-600">
|
||||
{platformLabels[generated.platform_hint] || generated.platform_hint}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{editing ? (
|
||||
<div className="space-y-2">
|
||||
<textarea
|
||||
value={editText}
|
||||
onChange={(e) => setEditText(e.target.value)}
|
||||
rows={8}
|
||||
className="w-full px-4 py-2.5 rounded-lg text-sm resize-none focus:outline-none"
|
||||
style={{ border: '1px solid var(--border)', color: 'var(--ink)' }}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleSaveEdit}
|
||||
className="px-3 py-1.5 text-white text-xs rounded-lg"
|
||||
style={{ backgroundColor: 'var(--coral)' }}
|
||||
>
|
||||
Salva
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setEditing(false); setEditText(generated.text_content || '') }}
|
||||
className="px-3 py-1.5 bg-slate-100 text-slate-600 text-xs rounded-lg"
|
||||
>
|
||||
Annulla
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 rounded-lg" style={{ backgroundColor: 'var(--cream)' }}>
|
||||
<p className="text-sm whitespace-pre-wrap leading-relaxed" style={{ color: 'var(--ink)' }}>
|
||||
{generated.text_content}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{generated.hashtags && generated.hashtags.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-medium mb-1.5" style={{ color: 'var(--muted)' }}>Hashtag</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{generated.hashtags.map((tag, i) => (
|
||||
<span key={i} className="text-xs px-2 py-0.5 rounded" style={{ backgroundColor: '#FFF0EC', color: 'var(--coral)' }}>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 pt-3 border-t" style={{ borderColor: 'var(--border)' }}>
|
||||
{generated.status !== 'approved' && (
|
||||
<button
|
||||
onClick={handleApprove}
|
||||
className="text-xs px-3 py-1.5 bg-emerald-50 hover:bg-emerald-100 text-emerald-600 rounded-lg transition-colors font-medium"
|
||||
>
|
||||
Approva
|
||||
</button>
|
||||
)}
|
||||
{!editing && (
|
||||
<button
|
||||
onClick={() => setEditing(true)}
|
||||
className="text-xs px-3 py-1.5 bg-slate-100 hover:bg-slate-200 text-slate-600 rounded-lg transition-colors"
|
||||
>
|
||||
Modifica
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="text-xs px-3 py-1.5 text-red-500 hover:bg-red-50 rounded-lg transition-colors ml-auto"
|
||||
>
|
||||
Elimina
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<p className="text-4xl mb-3">✦</p>
|
||||
<p className="font-medium" style={{ color: 'var(--ink)' }}>Nessun contenuto generato</p>
|
||||
<p className="text-sm mt-1" style={{ color: 'var(--muted)' }}>
|
||||
Compila il form e clicca "Genera"
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
201
frontend/src/components/Dashboard.jsx
Normal file
201
frontend/src/components/Dashboard.jsx
Normal 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'
|
||||
}
|
||||
403
frontend/src/components/EditorialCalendar.jsx
Normal file
403
frontend/src/components/EditorialCalendar.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
80
frontend/src/components/Layout.jsx
Normal file
80
frontend/src/components/Layout.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
109
frontend/src/components/LoginPage.jsx
Normal file
109
frontend/src/components/LoginPage.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
408
frontend/src/components/PlanForm.jsx
Normal file
408
frontend/src/components/PlanForm.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
213
frontend/src/components/PlanList.jsx
Normal file
213
frontend/src/components/PlanList.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
16
frontend/src/components/ProtectedRoute.jsx
Normal file
16
frontend/src/components/ProtectedRoute.jsx
Normal 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" />
|
||||
}
|
||||
263
frontend/src/components/ScheduleView.jsx
Normal file
263
frontend/src/components/ScheduleView.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
287
frontend/src/components/SettingsPage.jsx
Normal file
287
frontend/src/components/SettingsPage.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
331
frontend/src/components/SocialAccounts.jsx
Normal file
331
frontend/src/components/SocialAccounts.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
25
frontend/src/index.css
Normal file
25
frontend/src/index.css
Normal file
@@ -0,0 +1,25 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=Fraunces:ital,wght@0,400;0,600;0,700;1,400&family=DM+Sans:wght@400;500;600&display=swap');
|
||||
|
||||
:root {
|
||||
--coral: #FF6B4A;
|
||||
--cream: #FAF8F3;
|
||||
--ink: #1A1A2E;
|
||||
--muted: #8B8B9A;
|
||||
--surface: #FFFFFF;
|
||||
--border: #E8E4DE;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--cream);
|
||||
color: var(--ink);
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
h1, h2, h3 {
|
||||
font-family: 'Fraunces', serif;
|
||||
}
|
||||
10
frontend/src/main.jsx
Normal file
10
frontend/src/main.jsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
)
|
||||
33
frontend/tailwind.config.js
Normal file
33
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,33 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{js,jsx}'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
coral: '#FF6B4A',
|
||||
cream: '#FAF8F3',
|
||||
ink: '#1A1A2E',
|
||||
muted: '#8B8B9A',
|
||||
border: '#E8E4DE',
|
||||
// Brand alias per compatibilità con componenti esistenti
|
||||
brand: {
|
||||
50: '#fff4f1',
|
||||
100: '#ffe4dd',
|
||||
200: '#ffc4b5',
|
||||
300: '#ff9d85',
|
||||
400: '#ff7a5c',
|
||||
500: '#FF6B4A',
|
||||
600: '#e8522f',
|
||||
700: '#c43f22',
|
||||
800: '#9e3219',
|
||||
900: '#7c2912',
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
serif: ['Fraunces', 'serif'],
|
||||
sans: ['DM Sans', 'sans-serif'],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
20
frontend/vite.config.js
Normal file
20
frontend/vite.config.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
base: '/leopost-full/',
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/leopost-full/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/leopost-full/, ''),
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user