Files
leopost-full/frontend/src/components/ContentPage.jsx
Michele 2b9129591c refactor: restructure sidebar navigation with logical groups
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>
2026-04-07 10:34:09 +02:00

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.&#10;&#10;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)',
}