Sidebar reorganized from 9 flat items to 7 items in 3 groups: CREA: Genera, Libreria, Idee PIANIFICA: Calendario, Programmati GESTISCI: Personaggi Removed from primary nav: - Link Affiliati (secondary, move to Settings later) - Social (setup, accessible via Settings) - Commenti (unused, future Pro feature) Other changes: - /content/library route added (replaces /content/archive as primary) - /content/archive kept as fallback route - All links updated to point to /content/library - "Contenuti" renamed to "Genera" - "Pianificazione" renamed to "Calendario" - "Schedulazione" renamed to "Programmati" - "Nuovo piano" button removed from Dashboard - Nav group headers with uppercase labels - end prop on /content to avoid highlighting on /content/library Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
658 lines
33 KiB
JavaScript
658 lines
33 KiB
JavaScript
import { useState, useEffect, useRef } from 'react'
|
|
import { createPortal } from 'react-dom'
|
|
import { Link, useSearchParams } from 'react-router-dom'
|
|
import { api } from '../api'
|
|
import { useAuth } from '../AuthContext'
|
|
import ConfirmModal from './ConfirmModal'
|
|
|
|
const PLATFORMS = [
|
|
{ value: 'instagram', label: 'Instagram' },
|
|
{ value: 'facebook', label: 'Facebook' },
|
|
{ value: 'youtube', label: 'YouTube' },
|
|
{ value: 'tiktok', label: 'TikTok' },
|
|
]
|
|
|
|
const CONTENT_TYPES = [
|
|
{ value: 'text', label: 'Testo' },
|
|
{ value: 'image', label: 'Immagine' },
|
|
{ value: 'video', label: 'Video' },
|
|
{ value: 'reel', label: 'Reel/Short' },
|
|
{ value: 'story', label: 'Story' },
|
|
]
|
|
|
|
const TECNICHE_NARRATIVE = [
|
|
{ value: 'PAS', label: 'PAS', desc: 'Problema → Agitazione → Soluzione' },
|
|
{ value: 'AIDA', label: 'AIDA', desc: 'Attenzione → Interesse → Desiderio → Azione' },
|
|
{ value: 'Storytelling', label: 'Storytelling', desc: 'Narrazione con arco emotivo' },
|
|
{ value: 'Tutorial', label: 'Tutorial', desc: 'Step-by-step educativo' },
|
|
{ value: 'Listicle', label: 'Listicle', desc: 'Lista di consigli/esempi' },
|
|
{ value: 'Social_proof', label: 'Social Proof', desc: 'Risultati, testimonianze, numeri' },
|
|
{ value: 'Hook_domanda', label: 'Hook + Domanda', desc: 'Apertura con domanda provocatoria' },
|
|
]
|
|
|
|
const STATUS_LABELS = { approved: 'Approvato', published: 'Pubblicato', draft: 'Bozza' }
|
|
const STATUS_COLORS = {
|
|
approved: { bg: 'var(--success-light)', color: 'var(--success)' },
|
|
published: { bg: '#EFF6FF', color: '#1D4ED8' },
|
|
draft: { bg: '#FFFBEB', color: '#B45309' },
|
|
}
|
|
|
|
export default function ContentPage() {
|
|
const { isPro } = useAuth()
|
|
const [searchParams, setSearchParams] = useSearchParams()
|
|
const availablePlatforms = isPro ? PLATFORMS : PLATFORMS.filter(p => ['instagram', 'facebook'].includes(p.value))
|
|
const autoGenerateRef = useRef(false)
|
|
const [characters, setCharacters] = useState([])
|
|
const [loading, setLoading] = useState(false)
|
|
const [charsLoading, setCharsLoading] = useState(true)
|
|
const [error, setError] = useState('')
|
|
const [generatedPosts, setGeneratedPosts] = useState([]) // array of posts, one per platform
|
|
const [activePlatformIdx, setActivePlatformIdx] = useState(0)
|
|
const [editing, setEditing] = useState(false)
|
|
const [editText, setEditText] = useState('')
|
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
|
const [showScheduleModal, setShowScheduleModal] = useState(false)
|
|
const [scheduleDate, setScheduleDate] = useState('')
|
|
const [scheduleTime, setScheduleTime] = useState('09:00')
|
|
|
|
const [form, setForm] = useState({
|
|
character_id: '',
|
|
brief: '',
|
|
platforms: ['instagram'],
|
|
content_types: ['text'],
|
|
topic_hint: '',
|
|
tecnica: '',
|
|
include_affiliates: false,
|
|
})
|
|
|
|
useEffect(() => {
|
|
api.get('/characters/').then(d => {
|
|
setCharacters(d)
|
|
setCharsLoading(false)
|
|
|
|
// Auto-select default character (first active one)
|
|
const activeChars = d.filter(c => c.is_active)
|
|
const defaultChar = activeChars.length > 0 ? String(activeChars[0].id) : ''
|
|
|
|
// One-click flow: pre-fill from URL params
|
|
const urlTopic = searchParams.get('topic')
|
|
const urlCharacter = searchParams.get('character')
|
|
if (urlTopic && d.length > 0) {
|
|
const charId = urlCharacter || defaultChar
|
|
setForm(prev => ({ ...prev, character_id: charId, topic_hint: urlTopic }))
|
|
autoGenerateRef.current = true
|
|
setSearchParams({}, { replace: true })
|
|
} else if (defaultChar) {
|
|
// Pre-select default character
|
|
setForm(prev => ({ ...prev, character_id: prev.character_id || defaultChar }))
|
|
}
|
|
}).catch(() => setCharsLoading(false))
|
|
}, [])
|
|
|
|
// Auto-generate when arriving from one-click flow
|
|
useEffect(() => {
|
|
if (autoGenerateRef.current && form.character_id && !charsLoading) {
|
|
autoGenerateRef.current = false
|
|
// Small delay to let React render the form
|
|
setTimeout(() => {
|
|
document.querySelector('form')?.requestSubmit()
|
|
}, 300)
|
|
}
|
|
}, [form.character_id, charsLoading])
|
|
|
|
const toggleChip = (field, value) => {
|
|
setForm(prev => {
|
|
const arr = prev[field]
|
|
if (arr.includes(value)) {
|
|
return { ...prev, [field]: arr.length > 1 ? arr.filter(v => v !== value) : arr }
|
|
}
|
|
// Maintain canonical order (same as PLATFORMS/CONTENT_TYPES arrays)
|
|
const canonical = field === 'platforms' ? PLATFORMS.map(p => p.value) : CONTENT_TYPES.map(t => t.value)
|
|
const newArr = [...arr, value].sort((a, b) => canonical.indexOf(a) - canonical.indexOf(b))
|
|
return { ...prev, [field]: newArr }
|
|
})
|
|
}
|
|
|
|
const handleGenerate = async (e) => {
|
|
e.preventDefault()
|
|
if (!form.character_id) { setError('Seleziona un personaggio'); return }
|
|
setError('')
|
|
setLoading(true)
|
|
setGeneratedPosts([])
|
|
setActivePlatformIdx(0)
|
|
try {
|
|
const data = await api.post('/content/generate', {
|
|
character_id: parseInt(form.character_id),
|
|
platforms: form.platforms,
|
|
content_types: form.content_types,
|
|
topic_hint: form.topic_hint || null,
|
|
brief: [
|
|
form.tecnica ? `Tecnica narrativa: ${form.tecnica}` : '',
|
|
form.brief,
|
|
].filter(Boolean).join('. ') || null,
|
|
include_affiliates: form.include_affiliates,
|
|
})
|
|
// Backend returns array of posts (one per platform)
|
|
const posts = Array.isArray(data) ? data : [data]
|
|
setGeneratedPosts(posts)
|
|
setActivePlatformIdx(0)
|
|
setEditText(posts[0]?.text_content || '')
|
|
} catch (err) {
|
|
if (err.data?.missing_settings) {
|
|
setError('__MISSING_SETTINGS__')
|
|
} else if (err.data?.upgrade_required) {
|
|
setError(err.data.message || 'Limite piano raggiunto. Passa a Pro.')
|
|
} else {
|
|
setError(err.message || 'Errore nella generazione')
|
|
}
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
const generated = generatedPosts[activePlatformIdx] || null
|
|
|
|
const updateActivePost = (updates) => {
|
|
setGeneratedPosts(prev => prev.map((p, i) => i === activePlatformIdx ? { ...p, ...updates } : p))
|
|
}
|
|
|
|
const handleApprove = async () => {
|
|
if (!generated) return
|
|
try {
|
|
await api.post(`/content/posts/${generated.id}/approve`)
|
|
updateActivePost({ 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 })
|
|
updateActivePost({ text_content: editText })
|
|
setEditing(false)
|
|
} catch (err) { setError(err.message || 'Errore salvataggio') }
|
|
}
|
|
|
|
const handleDelete = async () => {
|
|
if (!generated) return
|
|
try {
|
|
await api.delete(`/content/posts/${generated.id}`)
|
|
const remaining = generatedPosts.filter((_, i) => i !== activePlatformIdx)
|
|
setGeneratedPosts(remaining)
|
|
setActivePlatformIdx(Math.min(activePlatformIdx, Math.max(0, remaining.length - 1)))
|
|
setShowDeleteConfirm(false)
|
|
} catch (err) { setError(err.message || 'Errore eliminazione') }
|
|
}
|
|
|
|
const handleSchedule = async () => {
|
|
if (!generated || !scheduleDate) return
|
|
try {
|
|
const scheduledAt = new Date(`${scheduleDate}T${scheduleTime}:00`).toISOString()
|
|
await api.post('/plans/schedule', {
|
|
post_id: generated.id,
|
|
platform: generated.platform_hint || 'instagram',
|
|
scheduled_at: scheduledAt,
|
|
})
|
|
updateActivePost({ status: 'scheduled' })
|
|
setShowScheduleModal(false)
|
|
setError('')
|
|
} catch (err) { setError(err.message || 'Errore nella schedulazione') }
|
|
}
|
|
|
|
return (
|
|
<div style={{ animation: 'fade-up 0.5s ease-out both' }}>
|
|
{/* Header */}
|
|
<div style={{ marginBottom: '2rem' }}>
|
|
<span className="editorial-tag">Contenuti</span>
|
|
<div className="editorial-line" />
|
|
<h2 style={{ fontFamily: "'Fraunces', serif", fontSize: '1.75rem', fontWeight: 600, letterSpacing: '-0.02em', color: 'var(--ink)', margin: '0.5rem 0 0.3rem' }}>
|
|
Genera Contenuti
|
|
</h2>
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '1rem' }}>
|
|
<p style={{ fontSize: '0.875rem', color: 'var(--ink-muted)', margin: 0 }}>
|
|
Definisci un brief editoriale, scegli piattaforma e tipo, poi genera. L'AI terrà conto del tono e dei topic del personaggio selezionato.
|
|
</p>
|
|
<Link to="/content/library" style={{ fontSize: '0.8rem', color: 'var(--accent)', whiteSpace: 'nowrap', textDecoration: 'none', fontWeight: 600 }}>
|
|
Libreria →
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
|
|
{/* No characters → gate */}
|
|
{!charsLoading && characters.length === 0 && (
|
|
<div style={{ padding: '3rem 2rem', textAlign: 'center', backgroundColor: 'var(--surface)', border: '1px solid var(--border)', borderTop: '4px solid var(--accent)', marginBottom: '1.5rem' }}>
|
|
<div style={{ fontSize: '2rem', color: 'var(--accent)', marginBottom: '1rem' }}>◎</div>
|
|
<h3 style={{ fontFamily: "'Fraunces', serif", fontSize: '1.1rem', fontWeight: 600, color: 'var(--ink)', margin: '0 0 0.5rem' }}>Prima crea un Personaggio</h3>
|
|
<p style={{ fontSize: '0.875rem', color: 'var(--ink-muted)', maxWidth: 360, margin: '0 auto 1.25rem', lineHeight: 1.6 }}>
|
|
Per generare contenuti serve almeno un personaggio che definisca la voce editoriale (tono, nicchia, pubblico).
|
|
</p>
|
|
<Link to="/characters/new" style={btnPrimary}>Crea il tuo primo Personaggio →</Link>
|
|
</div>
|
|
)}
|
|
|
|
{error && (error === '__MISSING_SETTINGS__' ? (
|
|
<div style={{ padding: '1.25rem 1.5rem', backgroundColor: '#FFFBEB', border: '1px solid #FDE68A', borderLeft: '4px solid #F59E0B', marginBottom: '1rem' }}>
|
|
<div style={{ fontWeight: 600, fontSize: '0.9rem', color: '#92400E', marginBottom: '0.5rem' }}>
|
|
⚙ Provider AI non configurato
|
|
</div>
|
|
<p style={{ fontSize: '0.85rem', color: '#78350F', margin: '0 0 0.75rem', lineHeight: 1.6 }}>
|
|
Per generare contenuti devi prima configurare un provider AI (Claude, OpenAI, Gemini...) e inserire la tua API key.
|
|
</p>
|
|
<Link to="/settings?tab=ai" style={{ ...btnPrimary, backgroundColor: '#F59E0B', textDecoration: 'none', fontSize: '0.82rem', padding: '0.5rem 1rem' }}>
|
|
Vai alle Impostazioni →
|
|
</Link>
|
|
</div>
|
|
) : (
|
|
<div style={{ padding: '0.75rem 1rem', backgroundColor: 'var(--error-light)', border: '1px solid #FED7D7', color: 'var(--error)', fontSize: '0.875rem', marginBottom: '1rem' }}>
|
|
{error}
|
|
</div>
|
|
))}
|
|
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(min(100%, 300px), 1fr))', gap: '1.25rem' }}>
|
|
{/* Generation form */}
|
|
<div style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)', borderTop: '4px solid var(--accent)', padding: '1.5rem' }}>
|
|
<div style={{ marginBottom: '1.25rem' }}>
|
|
<span style={labelStyle}>Genera Contenuto</span>
|
|
</div>
|
|
|
|
<form onSubmit={handleGenerate} style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
|
|
{/* Character select */}
|
|
<div>
|
|
<label style={labelStyle}>Personaggio</label>
|
|
<select value={form.character_id} onChange={e => setForm(p => ({ ...p, 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>
|
|
|
|
{/* Prompt / Brief strategico */}
|
|
<div>
|
|
<label style={{ ...labelStyle, marginBottom: '0.5rem' }}>
|
|
Prompt & Strategia del Post
|
|
</label>
|
|
|
|
{/* Tecnica narrativa chips */}
|
|
<div style={{ marginBottom: '0.75rem' }}>
|
|
<p style={{ fontSize: '0.7rem', fontWeight: 600, letterSpacing: '0.06em', textTransform: 'uppercase', color: 'var(--ink-muted)', margin: '0 0 0.4rem' }}>Tecnica narrativa</p>
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.35rem' }}>
|
|
{TECNICHE_NARRATIVE.map(t => (
|
|
<button key={t.value} type="button" onClick={() => setForm(p => ({ ...p, tecnica: p.tecnica === t.value ? '' : t.value }))} title={t.desc} style={{
|
|
padding: '0.3rem 0.75rem', fontSize: '0.78rem', fontFamily: "'DM Sans', sans-serif",
|
|
border: 'none', cursor: 'pointer',
|
|
backgroundColor: form.tecnica === t.value ? '#1A1A1A' : 'var(--cream-dark)',
|
|
color: form.tecnica === t.value ? 'white' : 'var(--ink-light)',
|
|
fontWeight: form.tecnica === t.value ? 600 : 400, transition: 'background-color 0.15s',
|
|
}}>
|
|
{t.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
{form.tecnica && (
|
|
<p style={{ fontSize: '0.7rem', color: 'var(--ink-muted)', margin: '0.3rem 0 0' }}>
|
|
{TECNICHE_NARRATIVE.find(t => t.value === form.tecnica)?.desc}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Brief textarea */}
|
|
<textarea value={form.brief} onChange={e => setForm(p => ({ ...p, brief: e.target.value }))}
|
|
placeholder="Descrivi in dettaglio cosa vuoi comunicare: obiettivo del post, a chi ti rivolgi, angolo narrativo, call to action, cosa deve provare chi legge. Es: 'Tutorial per principianti su sourdough — tono incoraggiante, mostra che è più facile del previsto, CTA: salva la ricetta e taggami nel risultato'"
|
|
rows={4} style={{ ...inputStyle, resize: 'vertical', lineHeight: 1.6 }}
|
|
onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} />
|
|
<p style={{ fontSize: '0.72rem', color: 'var(--ink-muted)', margin: '0.3rem 0 0', lineHeight: 1.5 }}>
|
|
Più sei specifico, più il contenuto sarà preciso e pubblicabile senza revisioni.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Platform chips */}
|
|
<div>
|
|
<label style={labelStyle}>Piattaforme <span style={{ fontWeight: 400, textTransform: 'none', letterSpacing: 0, color: 'var(--ink-muted)', fontSize: '0.75rem' }}>(seleziona una o più)</span></label>
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem', marginTop: '0.4rem' }}>
|
|
{availablePlatforms.map(p => {
|
|
const active = form.platforms.includes(p.value)
|
|
return (
|
|
<button key={p.value} type="button" onClick={() => toggleChip('platforms', p.value)} style={{
|
|
padding: '0.35rem 0.875rem', fontSize: '0.82rem', fontWeight: active ? 600 : 400,
|
|
fontFamily: "'DM Sans', sans-serif", border: 'none', cursor: 'pointer',
|
|
backgroundColor: active ? 'var(--ink)' : 'var(--cream-dark)',
|
|
color: active ? 'white' : 'var(--ink-light)',
|
|
transition: 'background-color 0.15s, color 0.15s',
|
|
}}>
|
|
{p.label}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Content type chips */}
|
|
<div>
|
|
<label style={labelStyle}>Tipo di contenuto <span style={{ fontWeight: 400, textTransform: 'none', letterSpacing: 0, color: 'var(--ink-muted)', fontSize: '0.75rem' }}>(seleziona uno o più)</span></label>
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem', marginTop: '0.4rem' }}>
|
|
{CONTENT_TYPES.map(t => {
|
|
const active = form.content_types.includes(t.value)
|
|
return (
|
|
<button key={t.value} type="button" onClick={() => toggleChip('content_types', t.value)} style={{
|
|
padding: '0.35rem 0.875rem', fontSize: '0.82rem', fontWeight: active ? 600 : 400,
|
|
fontFamily: "'DM Sans', sans-serif", border: 'none', cursor: 'pointer',
|
|
backgroundColor: active ? 'var(--accent)' : 'var(--cream-dark)',
|
|
color: active ? 'white' : 'var(--ink-light)',
|
|
transition: 'background-color 0.15s, color 0.15s',
|
|
}}>
|
|
{t.label}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Topic hint */}
|
|
<div>
|
|
<label style={labelStyle}>Parola chiave / Topic <span style={{ fontWeight: 400, textTransform: 'none', letterSpacing: 0, color: 'var(--ink-muted)', fontSize: '0.75rem' }}>(opzionale)</span></label>
|
|
<input type="text" value={form.topic_hint} onChange={e => setForm(p => ({ ...p, topic_hint: e.target.value }))}
|
|
placeholder="Es. ricetta pasta, trend primavera, lancio prodotto…" style={inputStyle}
|
|
onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} />
|
|
</div>
|
|
|
|
{/* Affiliates toggle */}
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
|
<button type="button" onClick={() => setForm(p => ({ ...p, include_affiliates: !p.include_affiliates }))} style={{
|
|
width: 44, height: 24, borderRadius: 12, border: 'none', cursor: 'pointer',
|
|
backgroundColor: form.include_affiliates ? 'var(--accent)' : 'var(--border-strong)',
|
|
position: 'relative', transition: 'background-color 0.2s',
|
|
flexShrink: 0,
|
|
}}>
|
|
<span style={{
|
|
position: 'absolute', top: 2, left: form.include_affiliates ? 22 : 2,
|
|
width: 20, height: 20, borderRadius: '50%', backgroundColor: 'white',
|
|
transition: 'left 0.2s',
|
|
}} />
|
|
</button>
|
|
<span style={{ fontSize: '0.875rem', color: 'var(--ink)' }}>Includi link affiliati</span>
|
|
</div>
|
|
|
|
<button type="submit" disabled={loading || characters.length === 0} style={{
|
|
...btnPrimary, width: '100%', justifyContent: 'center', display: 'flex', alignItems: 'center', gap: '0.5rem',
|
|
opacity: (loading || characters.length === 0) ? 0.6 : 1,
|
|
padding: '0.75rem',
|
|
}}>
|
|
{loading ? (
|
|
<>
|
|
<span style={{ width: 16, height: 16, border: '2px solid rgba(255,255,255,0.3)', borderTopColor: 'white', borderRadius: '50%', animation: 'spin 0.8s linear infinite', display: 'inline-block' }} />
|
|
Generazione in corso…
|
|
</>
|
|
) : '✦ Genera'}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
|
|
{/* Preview panel */}
|
|
<div style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)', borderTop: '4px solid var(--border-strong)', padding: '1.5rem' }}>
|
|
<span style={labelStyle}>Contenuto Generato</span>
|
|
|
|
{/* Platform tabs when multiple posts */}
|
|
{generatedPosts.length > 1 && (
|
|
<div style={{ display: 'flex', gap: '0', marginTop: '0.75rem', borderBottom: '2px solid var(--border)' }}>
|
|
{generatedPosts.map((p, i) => (
|
|
<button key={i} onClick={() => { setActivePlatformIdx(i); setEditText(p.text_content || ''); setEditing(false) }}
|
|
style={{
|
|
padding: '0.5rem 1rem', fontSize: '0.8rem', fontWeight: activePlatformIdx === i ? 700 : 400,
|
|
fontFamily: "'DM Sans', sans-serif", border: 'none', cursor: 'pointer',
|
|
backgroundColor: activePlatformIdx === i ? 'var(--surface)' : 'transparent',
|
|
color: activePlatformIdx === i ? 'var(--accent)' : 'var(--ink-muted)',
|
|
borderBottom: activePlatformIdx === i ? '2px solid var(--accent)' : '2px solid transparent',
|
|
marginBottom: '-2px', transition: 'all 0.15s',
|
|
}}>
|
|
{(p.platform_hint || '').charAt(0).toUpperCase() + (p.platform_hint || '').slice(1)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{loading ? (
|
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '4rem 0' }}>
|
|
<div style={{ width: 32, height: 32, border: '2px solid var(--border)', borderTopColor: 'var(--accent)', borderRadius: '50%', animation: 'spin 0.8s linear infinite', marginBottom: '1rem' }} />
|
|
<p style={{ fontSize: '0.875rem', color: 'var(--ink-muted)' }}>Generazione in corso…</p>
|
|
</div>
|
|
) : generated ? (
|
|
<div style={{ marginTop: '1rem' }}>
|
|
{/* Status + platform badges */}
|
|
<div style={{ display: 'flex', gap: '0.4rem', flexWrap: 'wrap', marginBottom: '1rem' }}>
|
|
{(() => { const sc = STATUS_COLORS[generated.status] || STATUS_COLORS.draft; return (
|
|
<span style={{ fontSize: '0.72rem', fontWeight: 700, padding: '0.2rem 0.5rem', backgroundColor: sc.bg, color: sc.color }}>
|
|
{STATUS_LABELS[generated.status] || generated.status}
|
|
</span>
|
|
)})()}
|
|
{generatedPosts.length <= 1 && generated.platform_hint && (
|
|
<span style={{ fontSize: '0.72rem', fontWeight: 500, padding: '0.2rem 0.5rem', backgroundColor: 'var(--cream-dark)', color: 'var(--ink-muted)', borderLeft: '2px solid var(--border)' }}>
|
|
{generated.platform_hint}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{editing ? (
|
|
<div>
|
|
<textarea value={editText} onChange={e => setEditText(e.target.value)} rows={8}
|
|
style={{ ...inputStyle, resize: 'vertical', lineHeight: 1.6 }}
|
|
onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} />
|
|
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.5rem' }}>
|
|
<button onClick={handleSaveEdit} style={btnPrimary}>Salva</button>
|
|
<button onClick={() => { setEditing(false); setEditText(generated.text_content || '') }} style={btnSecondary}>Annulla</button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div style={{ padding: '1rem', backgroundColor: 'var(--cream)', marginBottom: '1rem' }}>
|
|
<p style={{ fontSize: '0.875rem', whiteSpace: 'pre-wrap', lineHeight: 1.7, color: 'var(--ink)', margin: 0 }}>
|
|
{generated.text_content}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{generated.hashtags?.length > 0 && (
|
|
<HashtagEditor
|
|
hashtags={generated.hashtags}
|
|
onChange={newTags => updateActivePost({ hashtags: newTags })}
|
|
postId={generated.id}
|
|
/>
|
|
)}
|
|
|
|
<div style={{ display: 'flex', gap: '0.5rem', paddingTop: '1rem', borderTop: '1px solid var(--border)', flexWrap: 'wrap', alignItems: 'center' }}>
|
|
{generated.status !== 'approved' && generated.status !== 'scheduled' && (
|
|
<button onClick={handleApprove} style={{ ...btnPrimary, backgroundColor: 'var(--success)' }}>Approva</button>
|
|
)}
|
|
{generated.status === 'approved' && (
|
|
<button onClick={() => setShowScheduleModal(true)} style={{ ...btnPrimary, backgroundColor: '#3B82F6' }}>Schedula</button>
|
|
)}
|
|
{!editing && <button onClick={() => setEditing(true)} style={btnSecondary}>Modifica</button>}
|
|
<button onClick={() => setShowDeleteConfirm(true)} style={{ ...btnSecondary, marginLeft: 'auto', color: 'var(--error)' }}>Elimina</button>
|
|
</div>
|
|
{generated.status === 'approved' && (
|
|
<p style={{ fontSize: '0.75rem', color: 'var(--ink-muted)', marginTop: '0.5rem', lineHeight: 1.5 }}>
|
|
Per pubblicare automaticamente, connetti i tuoi account social in <Link to="/social" style={{ color: 'var(--accent)' }}>Impostazioni Social</Link>.
|
|
</p>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '4rem 1rem', textAlign: 'center' }}>
|
|
<div style={{ fontSize: '2.5rem', color: 'var(--border-strong)', marginBottom: '1rem' }}>✦</div>
|
|
<p style={{ fontFamily: "'Fraunces', serif", fontSize: '1rem', color: 'var(--ink)', margin: '0 0 0.5rem' }}>Nessun contenuto ancora</p>
|
|
<p style={{ fontSize: '0.82rem', color: 'var(--ink-muted)', margin: 0 }}>
|
|
Scrivi un brief, seleziona personaggio e piattaforma, poi clicca "Genera"
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<ConfirmModal
|
|
open={showDeleteConfirm}
|
|
title="Elimina contenuto"
|
|
message="Sei sicuro di voler eliminare questo contenuto? L'operazione non è reversibile."
|
|
confirmLabel="Elimina"
|
|
cancelLabel="Annulla"
|
|
confirmStyle="danger"
|
|
onConfirm={handleDelete}
|
|
onCancel={() => setShowDeleteConfirm(false)}
|
|
/>
|
|
{showScheduleModal && (
|
|
<ScheduleModal
|
|
date={scheduleDate}
|
|
time={scheduleTime}
|
|
onDateChange={setScheduleDate}
|
|
onTimeChange={setScheduleTime}
|
|
onConfirm={handleSchedule}
|
|
onCancel={() => setShowScheduleModal(false)}
|
|
platform={generated?.platform_hint}
|
|
/>
|
|
)}
|
|
<style>{`@keyframes spin { to { transform: rotate(360deg) } }`}</style>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function ScheduleModal({ date, time, onDateChange, onTimeChange, onConfirm, onCancel, platform }) {
|
|
// Default to tomorrow
|
|
const tomorrow = new Date(Date.now() + 86400000).toISOString().split('T')[0]
|
|
if (!date) onDateChange(tomorrow)
|
|
|
|
return createPortal(
|
|
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, zIndex: 99999, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
|
<div onClick={onCancel} style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'rgba(26,26,26,0.5)', backdropFilter: 'blur(3px)' }} />
|
|
<div style={{
|
|
position: 'relative', backgroundColor: 'var(--surface)', border: '1px solid var(--border)',
|
|
borderTop: '4px solid #3B82F6', padding: '2rem', maxWidth: 400, width: '90%',
|
|
boxShadow: '0 20px 60px rgba(0,0,0,0.15)',
|
|
}}>
|
|
<h3 style={{ fontFamily: "'Fraunces', serif", fontSize: '1.15rem', fontWeight: 600, color: 'var(--ink)', margin: '0 0 0.5rem' }}>
|
|
Schedula pubblicazione
|
|
</h3>
|
|
<p style={{ fontSize: '0.82rem', color: 'var(--ink-muted)', margin: '0 0 1.25rem', lineHeight: 1.5 }}>
|
|
Scegli data e ora per la pubblicazione{platform ? ` su ${platform}` : ''}.
|
|
Il post verrà pubblicato automaticamente quando i social saranno connessi.
|
|
</p>
|
|
<div style={{ display: 'flex', gap: '0.75rem', marginBottom: '1.5rem' }}>
|
|
<div style={{ flex: 1 }}>
|
|
<label style={{ display: 'block', fontSize: '0.7rem', fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--ink-muted)', marginBottom: '0.3rem' }}>Data</label>
|
|
<input type="date" value={date || tomorrow} onChange={e => onDateChange(e.target.value)}
|
|
min={new Date().toISOString().split('T')[0]}
|
|
style={{ width: '100%', padding: '0.5rem', border: '1px solid var(--border)', fontSize: '0.875rem', fontFamily: "'DM Sans', sans-serif", outline: 'none' }} />
|
|
</div>
|
|
<div style={{ width: 100 }}>
|
|
<label style={{ display: 'block', fontSize: '0.7rem', fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--ink-muted)', marginBottom: '0.3rem' }}>Ora</label>
|
|
<input type="time" value={time} onChange={e => onTimeChange(e.target.value)}
|
|
style={{ width: '100%', padding: '0.5rem', border: '1px solid var(--border)', fontSize: '0.875rem', fontFamily: "'DM Sans', sans-serif", outline: 'none' }} />
|
|
</div>
|
|
</div>
|
|
<div style={{ display: 'flex', gap: '0.5rem', justifyContent: 'flex-end' }}>
|
|
<button onClick={onCancel} style={{ padding: '0.5rem 1rem', fontSize: '0.85rem', fontWeight: 600, fontFamily: "'DM Sans', sans-serif", backgroundColor: 'var(--cream-dark)', color: 'var(--ink-light)', border: 'none', cursor: 'pointer' }}>
|
|
Annulla
|
|
</button>
|
|
<button onClick={onConfirm} style={{ padding: '0.5rem 1rem', fontSize: '0.85rem', fontWeight: 600, fontFamily: "'DM Sans', sans-serif", backgroundColor: '#3B82F6', color: 'white', border: 'none', cursor: 'pointer' }}>
|
|
Schedula
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>,
|
|
document.body
|
|
)
|
|
}
|
|
|
|
function HashtagEditor({ hashtags, onChange, postId }) {
|
|
const [newTag, setNewTag] = useState('')
|
|
const [editIdx, setEditIdx] = useState(null)
|
|
const [editValue, setEditValue] = useState('')
|
|
const saveTimer = useRef(null)
|
|
|
|
const persistHashtags = (tags) => {
|
|
clearTimeout(saveTimer.current)
|
|
saveTimer.current = setTimeout(() => {
|
|
api.put(`/content/posts/${postId}`, { hashtags: tags }).catch(() => {})
|
|
}, 500)
|
|
}
|
|
|
|
const updateAndSave = (newTags) => {
|
|
onChange(newTags)
|
|
persistHashtags(newTags)
|
|
}
|
|
|
|
const removeTag = (idx) => {
|
|
updateAndSave(hashtags.filter((_, i) => i !== idx))
|
|
}
|
|
|
|
const addTag = () => {
|
|
let tag = newTag.trim()
|
|
if (!tag) return
|
|
if (!tag.startsWith('#')) tag = `#${tag}`
|
|
if (!hashtags.includes(tag)) {
|
|
updateAndSave([...hashtags, tag])
|
|
}
|
|
setNewTag('')
|
|
}
|
|
|
|
const startEdit = (idx) => {
|
|
setEditIdx(idx)
|
|
setEditValue(hashtags[idx])
|
|
}
|
|
|
|
const confirmEdit = () => {
|
|
if (editIdx === null) return
|
|
let tag = editValue.trim()
|
|
if (!tag) { removeTag(editIdx); setEditIdx(null); return }
|
|
if (!tag.startsWith('#')) tag = `#${tag}`
|
|
const updated = [...hashtags]
|
|
updated[editIdx] = tag
|
|
updateAndSave(updated)
|
|
setEditIdx(null)
|
|
}
|
|
|
|
return (
|
|
<div style={{ marginBottom: '1rem' }}>
|
|
<span style={{ ...labelStyle, display: 'block', marginBottom: '0.4rem' }}>Hashtag</span>
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.35rem', marginBottom: '0.5rem' }}>
|
|
{hashtags.map((tag, i) => (
|
|
editIdx === i ? (
|
|
<input key={i} type="text" value={editValue} onChange={e => setEditValue(e.target.value)}
|
|
onBlur={confirmEdit} onKeyDown={e => { if (e.key === 'Enter') confirmEdit(); if (e.key === 'Escape') setEditIdx(null) }}
|
|
autoFocus style={{ fontSize: '0.78rem', padding: '0.15rem 0.5rem', border: '1px solid var(--accent)', outline: 'none', fontFamily: "'DM Sans', sans-serif", width: Math.max(60, editValue.length * 8) }} />
|
|
) : (
|
|
<span key={i} style={{ fontSize: '0.78rem', padding: '0.15rem 0.5rem', backgroundColor: 'var(--accent-light)', color: 'var(--accent)', cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: '0.3rem' }}
|
|
onClick={() => startEdit(i)}>
|
|
{tag}
|
|
<span onClick={e => { e.stopPropagation(); removeTag(i) }} style={{ fontSize: '0.65rem', cursor: 'pointer', opacity: 0.7, lineHeight: 1 }} title="Rimuovi">✕</span>
|
|
</span>
|
|
)
|
|
))}
|
|
</div>
|
|
<div style={{ display: 'flex', gap: '0.35rem', alignItems: 'center' }}>
|
|
<input type="text" value={newTag} onChange={e => setNewTag(e.target.value)}
|
|
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); addTag() } }}
|
|
placeholder="Aggiungi hashtag…" style={{ fontSize: '0.8rem', padding: '0.3rem 0.6rem', border: '1px solid var(--border)', outline: 'none', fontFamily: "'DM Sans', sans-serif", flex: 1 }}
|
|
onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} />
|
|
<button type="button" onClick={addTag} style={{ fontSize: '0.78rem', padding: '0.3rem 0.6rem', backgroundColor: 'var(--cream-dark)', border: 'none', cursor: 'pointer', color: 'var(--ink-light)' }}>+</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const labelStyle = {
|
|
fontSize: '0.72rem', fontWeight: 700, letterSpacing: '0.1em',
|
|
textTransform: 'uppercase', color: 'var(--ink)',
|
|
}
|
|
const inputStyle = {
|
|
width: '100%', padding: '0.625rem 0.875rem',
|
|
border: '1px solid var(--border)', borderRadius: 0,
|
|
fontSize: '0.875rem', color: 'var(--ink)',
|
|
backgroundColor: 'var(--surface)', outline: 'none',
|
|
boxSizing: 'border-box', transition: 'border-color 0.15s',
|
|
fontFamily: "'DM Sans', sans-serif",
|
|
}
|
|
const btnPrimary = {
|
|
display: 'inline-block', padding: '0.55rem 1.1rem',
|
|
backgroundColor: 'var(--ink)', color: 'white',
|
|
fontFamily: "'DM Sans', sans-serif", fontWeight: 600,
|
|
fontSize: '0.875rem', border: 'none', cursor: 'pointer',
|
|
}
|
|
const btnSecondary = {
|
|
...btnPrimary,
|
|
backgroundColor: 'var(--cream-dark)', color: 'var(--ink-light)',
|
|
}
|