feat: multi-platform generation + custom confirm modal + per-platform tabs

Backend:
- /generate now returns array of posts (one per platform selected)
- Each post generated with platform-specific LLM prompt and char limits
- Monthly counter incremented by number of platforms

Frontend:
- ConfirmModal: reusable Editorial Fresh modal replaces ugly browser confirm()
- ContentPage: platform tabs when multiple posts, switch between variants
- ContentPage: generatedPosts array state replaces single generated
- ContentArchive: uses ConfirmModal for delete confirmation
- Platform chips filtered by plan (Freemium: IG/FB only)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michele
2026-04-03 19:05:25 +02:00
parent 5620a71f1b
commit 3b17ed0a9b
4 changed files with 180 additions and 60 deletions

View File

@@ -0,0 +1,51 @@
import { useEffect } from 'react'
export default function ConfirmModal({ open, title, message, confirmLabel = 'Conferma', cancelLabel = 'Annulla', confirmStyle = 'danger', onConfirm, onCancel }) {
useEffect(() => {
if (!open) return
const onKey = (e) => { if (e.key === 'Escape') onCancel() }
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [open, onCancel])
if (!open) return null
const confirmColors = {
danger: { bg: 'var(--error)', color: 'white' },
primary: { bg: 'var(--ink)', color: 'white' },
success: { bg: 'var(--success)', color: 'white' },
}
const cc = confirmColors[confirmStyle] || confirmColors.danger
return (
<div style={{ position: 'fixed', inset: 0, zIndex: 9999, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div onClick={onCancel} style={{ position: 'absolute', inset: 0, backgroundColor: 'rgba(26,26,26,0.4)', backdropFilter: 'blur(2px)' }} />
<div style={{
position: 'relative', backgroundColor: 'var(--surface)', border: '1px solid var(--border)',
borderTop: '4px solid var(--accent)', padding: '2rem', maxWidth: 420, width: '90%',
animation: 'fade-up 0.2s ease-out both',
}}>
<h3 style={{ fontFamily: "'Fraunces', serif", fontSize: '1.15rem', fontWeight: 600, color: 'var(--ink)', margin: '0 0 0.5rem' }}>
{title}
</h3>
<p style={{ fontSize: '0.875rem', color: 'var(--ink-light)', lineHeight: 1.6, margin: '0 0 1.5rem' }}>
{message}
</p>
<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',
}}>
{cancelLabel}
</button>
<button onClick={onConfirm} style={{
padding: '0.5rem 1rem', fontSize: '0.85rem', fontWeight: 600, fontFamily: "'DM Sans', sans-serif",
backgroundColor: cc.bg, color: cc.color, border: 'none', cursor: 'pointer',
}}>
{confirmLabel}
</button>
</div>
</div>
</div>
)
}

View File

@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { api } from '../api'
import ConfirmModal from './ConfirmModal'
const statusLabels = { draft: 'Bozza', approved: 'Approvato', scheduled: 'Schedulato', published: 'Pubblicato' }
const statusColors = {
@@ -19,6 +20,7 @@ export default function ContentArchive() {
const [editText, setEditText] = useState('')
const [filterCharacter, setFilterCharacter] = useState('')
const [filterStatus, setFilterStatus] = useState('')
const [deleteTarget, setDeleteTarget] = useState(null)
useEffect(() => { loadData() }, [])
@@ -40,8 +42,7 @@ export default function ContentArchive() {
try { await api.post(`/content/posts/${postId}/approve`); loadData() } catch {}
}
const handleDelete = async (postId) => {
if (!confirm('Eliminare questo contenuto?')) return
try { await api.delete(`/content/posts/${postId}`); loadData() } catch {}
try { await api.delete(`/content/posts/${postId}`); setDeleteTarget(null); loadData() } catch {}
}
const handleSaveEdit = async (postId) => {
try {
@@ -171,7 +172,7 @@ export default function ContentArchive() {
)}
<button onClick={() => { setEditingId(post.id); setEditText(post.text_content || ''); setExpandedId(post.id) }}
style={{ ...btnSmall, backgroundColor: 'var(--cream-dark)', color: 'var(--ink-light)' }}>Modifica</button>
<button onClick={() => handleDelete(post.id)}
<button onClick={() => setDeleteTarget(post.id)}
style={{ ...btnSmall, color: 'var(--error)', backgroundColor: 'transparent', marginLeft: 'auto' }}>Elimina</button>
</div>
</div>
@@ -180,6 +181,16 @@ export default function ContentArchive() {
})}
</div>
)}
<ConfirmModal
open={deleteTarget !== null}
title="Elimina contenuto"
message="Sei sicuro di voler eliminare questo contenuto? L'operazione non è reversibile."
confirmLabel="Elimina"
cancelLabel="Annulla"
confirmStyle="danger"
onConfirm={() => handleDelete(deleteTarget)}
onCancel={() => setDeleteTarget(null)}
/>
<style>{`@keyframes spin { to { transform: rotate(360deg) } }`}</style>
</div>
)

View File

@@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react'
import { Link } from 'react-router-dom'
import { api } from '../api'
import { useAuth } from '../AuthContext'
import ConfirmModal from './ConfirmModal'
const PLATFORMS = [
{ value: 'instagram', label: 'Instagram' },
@@ -42,9 +43,11 @@ export default function ContentPage() {
const [loading, setLoading] = useState(false)
const [charsLoading, setCharsLoading] = useState(true)
const [error, setError] = useState('')
const [generated, setGenerated] = useState(null)
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 [form, setForm] = useState({
character_id: '',
@@ -72,7 +75,8 @@ export default function ContentPage() {
if (!form.character_id) { setError('Seleziona un personaggio'); return }
setError('')
setLoading(true)
setGenerated(null)
setGeneratedPosts([])
setActivePlatformIdx(0)
try {
const data = await api.post('/content/generate', {
character_id: parseInt(form.character_id),
@@ -85,8 +89,11 @@ export default function ContentPage() {
].filter(Boolean).join('. ') || null,
include_affiliates: form.include_affiliates,
})
setGenerated(data)
setEditText(data.text_content || '')
// 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__')
@@ -100,26 +107,37 @@ export default function ContentPage() {
}
}
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`)
setGenerated(prev => ({ ...prev, status: 'approved' }))
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 })
setGenerated(prev => ({ ...prev, text_content: editText }))
updateActivePost({ text_content: editText })
setEditing(false)
} catch (err) { setError(err.message || 'Errore salvataggio') }
}
const handleDelete = async () => {
if (!confirm('Eliminare questo contenuto?')) return
if (!generated) return
try {
await api.delete(`/content/posts/${generated.id}`)
setGenerated(null)
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') }
}
@@ -314,6 +332,25 @@ export default function ContentPage() {
<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' }} />
@@ -356,7 +393,7 @@ export default function ContentPage() {
{generated.hashtags?.length > 0 && (
<HashtagEditor
hashtags={generated.hashtags}
onChange={newTags => setGenerated(prev => ({ ...prev, hashtags: newTags }))}
onChange={newTags => updateActivePost({ hashtags: newTags })}
postId={generated.id}
/>
)}
@@ -366,7 +403,7 @@ export default function ContentPage() {
<button onClick={handleApprove} style={{ ...btnPrimary, backgroundColor: 'var(--success)' }}>Approva</button>
)}
{!editing && <button onClick={() => setEditing(true)} style={btnSecondary}>Modifica</button>}
<button onClick={handleDelete} style={{ ...btnSecondary, marginLeft: 'auto', color: 'var(--error)' }}>Elimina</button>
<button onClick={() => setShowDeleteConfirm(true)} style={{ ...btnSecondary, marginLeft: 'auto', color: 'var(--error)' }}>Elimina</button>
</div>
</div>
) : (
@@ -380,6 +417,16 @@ export default function ContentPage() {
)}
</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)}
/>
<style>{`@keyframes spin { to { transform: rotate(360deg) } }`}</style>
</div>
)