feat: group posts by batch in archive + fullscreen confirm modal

Backend:
- Add batch_id column to Post model (UUID, groups posts from same generation)
- Set batch_id in /generate endpoint for all posts in same request

Frontend:
- ContentArchive: group posts by batch_id into single cards with platform tabs
- Character name at top, platform tabs below, status badge, text preview
- Click platform tab to switch between variants of same content
- ConfirmModal: render via React portal to document.body for true fullscreen overlay
- Add box-shadow and higher z-index for better visual separation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michele
2026-04-04 12:52:01 +02:00
parent 3b17ed0a9b
commit 743a6c1324
5 changed files with 108 additions and 54 deletions

View File

@@ -64,6 +64,7 @@ class Post(Base):
__tablename__ = "posts" __tablename__ = "posts"
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
batch_id = Column(String(36), nullable=True, index=True) # groups posts generated together
character_id = Column(Integer, ForeignKey("characters.id"), nullable=False) character_id = Column(Integer, ForeignKey("characters.id"), nullable=False)
content_type = Column(String(20), default="text") # text, image, video, carousel content_type = Column(String(20), default="text") # text, image, video, carousel
text_content = Column(Text) text_content = Column(Text)

View File

@@ -3,6 +3,7 @@
Handles post generation via LLM, image generation, and CRUD operations on posts. Handles post generation via LLM, image generation, and CRUD operations on posts.
""" """
import uuid
from datetime import date, datetime from datetime import date, datetime
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
@@ -132,7 +133,8 @@ def generate_content(
for link in links for link in links
] ]
# Generate one post per platform # Generate one post per platform, all sharing the same batch_id
batch_id = str(uuid.uuid4())
posts_created: list[Post] = [] posts_created: list[Post] = []
for platform in platforms: for platform in platforms:
text = generate_post_text( text = generate_post_text(
@@ -152,6 +154,7 @@ def generate_content(
) )
post = Post( post = Post(
batch_id=batch_id,
character_id=character.id, character_id=character.id,
user_id=current_user.id, user_id=current_user.id,
content_type=request.content_type, content_type=request.content_type,

View File

@@ -82,6 +82,7 @@ class PostUpdate(BaseModel):
class PostResponse(BaseModel): class PostResponse(BaseModel):
id: int id: int
batch_id: Optional[str] = None
character_id: int character_id: int
content_type: str content_type: str
text_content: Optional[str] = None text_content: Optional[str] = None

View File

@@ -1,11 +1,16 @@
import { useEffect } from 'react' import { useEffect } from 'react'
import { createPortal } from 'react-dom'
export default function ConfirmModal({ open, title, message, confirmLabel = 'Conferma', cancelLabel = 'Annulla', confirmStyle = 'danger', onConfirm, onCancel }) { export default function ConfirmModal({ open, title, message, confirmLabel = 'Conferma', cancelLabel = 'Annulla', confirmStyle = 'danger', onConfirm, onCancel }) {
useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
const onKey = (e) => { if (e.key === 'Escape') onCancel() } const onKey = (e) => { if (e.key === 'Escape') onCancel() }
document.addEventListener('keydown', onKey) document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey) document.body.style.overflow = 'hidden'
return () => {
document.removeEventListener('keydown', onKey)
document.body.style.overflow = ''
}
}, [open, onCancel]) }, [open, onCancel])
if (!open) return null if (!open) return null
@@ -17,24 +22,25 @@ export default function ConfirmModal({ open, title, message, confirmLabel = 'Con
} }
const cc = confirmColors[confirmStyle] || confirmColors.danger const cc = confirmColors[confirmStyle] || confirmColors.danger
return ( return createPortal(
<div style={{ position: 'fixed', inset: 0, zIndex: 9999, display: 'flex', alignItems: 'center', justifyContent: 'center' }}> <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: 'absolute', inset: 0, backgroundColor: 'rgba(26,26,26,0.4)', backdropFilter: 'blur(2px)' }} /> <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={{ <div style={{
position: 'relative', backgroundColor: 'var(--surface)', border: '1px solid var(--border)', position: 'relative', backgroundColor: 'var(--surface, #fff)', border: '1px solid var(--border, #E5E0D8)',
borderTop: '4px solid var(--accent)', padding: '2rem', maxWidth: 420, width: '90%', borderTop: '4px solid var(--accent, #E85A4F)', padding: '2rem', maxWidth: 420, width: '90%',
boxShadow: '0 20px 60px rgba(0,0,0,0.15)',
animation: 'fade-up 0.2s ease-out both', 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' }}> <h3 style={{ fontFamily: "'Fraunces', serif", fontSize: '1.15rem', fontWeight: 600, color: 'var(--ink, #1A1A1A)', margin: '0 0 0.5rem' }}>
{title} {title}
</h3> </h3>
<p style={{ fontSize: '0.875rem', color: 'var(--ink-light)', lineHeight: 1.6, margin: '0 0 1.5rem' }}> <p style={{ fontSize: '0.875rem', color: 'var(--ink-light, #4A4A4A)', lineHeight: 1.6, margin: '0 0 1.5rem' }}>
{message} {message}
</p> </p>
<div style={{ display: 'flex', gap: '0.5rem', justifyContent: 'flex-end' }}> <div style={{ display: 'flex', gap: '0.5rem', justifyContent: 'flex-end' }}>
<button onClick={onCancel} style={{ <button onClick={onCancel} style={{
padding: '0.5rem 1rem', fontSize: '0.85rem', fontWeight: 600, fontFamily: "'DM Sans', sans-serif", 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', backgroundColor: 'var(--cream-dark, #F5F0E8)', color: 'var(--ink-light, #4A4A4A)', border: 'none', cursor: 'pointer',
}}> }}>
{cancelLabel} {cancelLabel}
</button> </button>
@@ -46,6 +52,7 @@ export default function ConfirmModal({ open, title, message, confirmLabel = 'Con
</button> </button>
</div> </div>
</div> </div>
</div> </div>,
document.body
) )
} }

View File

@@ -11,29 +11,43 @@ const statusColors = {
published: { bg: '#F5F3FF', color: '#6D28D9' }, published: { bg: '#F5F3FF', color: '#6D28D9' },
} }
function groupByBatch(posts) {
const groups = []
const batchMap = {}
for (const post of posts) {
const key = post.batch_id || `solo_${post.id}`
if (batchMap[key]) {
batchMap[key].posts.push(post)
} else {
const group = { key, posts: [post] }
batchMap[key] = group
groups.push(group)
}
}
return groups
}
export default function ContentArchive() { export default function ContentArchive() {
const [posts, setPosts] = useState([]) const [posts, setPosts] = useState([])
const [characters, setCharacters] = useState([]) const [characters, setCharacters] = useState([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [expandedId, setExpandedId] = useState(null) const [expandedKey, setExpandedKey] = useState(null)
const [activePlatform, setActivePlatform] = useState({}) // key → index
const [editingId, setEditingId] = useState(null) const [editingId, setEditingId] = useState(null)
const [editText, setEditText] = useState('') const [editText, setEditText] = useState('')
const [filterCharacter, setFilterCharacter] = useState('') const [filterCharacter, setFilterCharacter] = useState('')
const [filterStatus, setFilterStatus] = useState('') const [filterStatus, setFilterStatus] = useState('')
const [deleteTarget, setDeleteTarget] = useState(null) const [deleteTarget, setDeleteTarget] = useState(null) // { id, batchKey }
useEffect(() => { loadData() }, []) useEffect(() => { loadData() }, [])
const loadData = async () => { const loadData = async () => {
setLoading(true) setLoading(true)
try { try {
const [postsData, charsData] = await Promise.all([ const [postsData, charsData] = await Promise.all([api.get('/content/posts'), api.get('/characters/')])
api.get('/content/posts'),
api.get('/characters/'),
])
setPosts(postsData) setPosts(postsData)
setCharacters(charsData) setCharacters(charsData)
} catch { /* silent */ } finally { setLoading(false) } } catch {} finally { setLoading(false) }
} }
const getCharacterName = (id) => characters.find(c => c.id === id)?.name || '—' const getCharacterName = (id) => characters.find(c => c.id === id)?.name || '—'
@@ -41,9 +55,16 @@ export default function ContentArchive() {
const handleApprove = async (postId) => { const handleApprove = async (postId) => {
try { await api.post(`/content/posts/${postId}/approve`); loadData() } catch {} try { await api.post(`/content/posts/${postId}/approve`); loadData() } catch {}
} }
const handleDelete = async (postId) => {
try { await api.delete(`/content/posts/${postId}`); setDeleteTarget(null); loadData() } catch {} const handleDelete = async () => {
if (!deleteTarget) return
try {
await api.delete(`/content/posts/${deleteTarget.id}`)
setDeleteTarget(null)
loadData()
} catch {}
} }
const handleSaveEdit = async (postId) => { const handleSaveEdit = async (postId) => {
try { try {
await api.put(`/content/posts/${postId}`, { text_content: editText }) await api.put(`/content/posts/${postId}`, { text_content: editText })
@@ -58,6 +79,8 @@ export default function ContentArchive() {
return true return true
}) })
const groups = groupByBatch(filtered)
const formatDate = (d) => d ? new Date(d).toLocaleDateString('it-IT', { day: '2-digit', month: 'short', year: 'numeric' }) : '—' const formatDate = (d) => d ? new Date(d).toLocaleDateString('it-IT', { day: '2-digit', month: 'short', year: 'numeric' }) : '—'
return ( return (
@@ -87,7 +110,7 @@ export default function ContentArchive() {
{Object.entries(statusLabels).map(([val, label]) => <option key={val} value={val}>{label}</option>)} {Object.entries(statusLabels).map(([val, label]) => <option key={val} value={val}>{label}</option>)}
</select> </select>
<span style={{ fontSize: '0.78rem', color: 'var(--ink-muted)', marginLeft: 'auto' }}> <span style={{ fontSize: '0.78rem', color: 'var(--ink-muted)', marginLeft: 'auto' }}>
{filtered.length} contenut{filtered.length === 1 ? 'o' : 'i'} {groups.length} contenut{groups.length === 1 ? 'o' : 'i'}
</span> </span>
</div> </div>
@@ -95,7 +118,7 @@ export default function ContentArchive() {
<div style={{ display: 'flex', justifyContent: 'center', padding: '3rem 0' }}> <div style={{ display: 'flex', justifyContent: 'center', padding: '3rem 0' }}>
<div style={{ width: 32, height: 32, border: '2px solid var(--border)', borderTopColor: 'var(--accent)', borderRadius: '50%', animation: 'spin 0.8s linear infinite' }} /> <div style={{ width: 32, height: 32, border: '2px solid var(--border)', borderTopColor: 'var(--accent)', borderRadius: '50%', animation: 'spin 0.8s linear infinite' }} />
</div> </div>
) : filtered.length === 0 ? ( ) : groups.length === 0 ? (
<div style={{ textAlign: 'center', padding: '4rem 1rem', backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}> <div style={{ textAlign: 'center', padding: '4rem 1rem', backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}>
<div style={{ fontSize: '2.5rem', color: 'var(--border-strong)', marginBottom: '1rem' }}></div> <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 trovato</p> <p style={{ fontFamily: "'Fraunces', serif", fontSize: '1rem', color: 'var(--ink)', margin: '0 0 0.5rem' }}>Nessun contenuto trovato</p>
@@ -104,30 +127,48 @@ export default function ContentArchive() {
</p> </p>
</div> </div>
) : ( ) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))', gap: '1rem' }}> <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(380px, 1fr))', gap: '1rem' }}>
{filtered.map(post => { {groups.map(group => {
const sc = statusColors[post.status] || statusColors.draft const activeIdx = activePlatform[group.key] || 0
const isExpanded = expandedId === post.id const activePost = group.posts[activeIdx] || group.posts[0]
const isEditing = editingId === post.id const sc = statusColors[activePost.status] || statusColors.draft
const isExpanded = expandedKey === group.key
const isEditing = editingId === activePost.id
const hasMultiple = group.posts.length > 1
return ( return (
<div key={post.id} style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)', borderTop: `3px solid ${sc.color}`, cursor: 'pointer', transition: 'border-color 0.15s' }} <div key={group.key} style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)', borderTop: `3px solid ${sc.color}`, cursor: 'pointer', transition: 'border-color 0.15s' }}
onClick={() => { if (!isEditing) setExpandedId(isExpanded ? null : post.id) }}> onClick={() => { if (!isEditing) setExpandedKey(isExpanded ? null : group.key) }}>
<div style={{ padding: '1.25rem' }}> <div style={{ padding: '1.25rem' }}>
{/* Header */}
<div style={{ display: 'flex', gap: '0.4rem', flexWrap: 'wrap', marginBottom: '0.75rem' }}> {/* Character name */}
<span style={{ fontSize: '0.72rem', fontWeight: 700, padding: '0.15rem 0.5rem', backgroundColor: sc.bg, color: sc.color }}> <p style={{ fontSize: '0.82rem', fontWeight: 700, color: 'var(--ink)', margin: '0 0 0.5rem' }}>
{statusLabels[post.status] || post.status} {getCharacterName(activePost.character_id)}
</span> </p>
{post.platform_hint && (
<span style={{ fontSize: '0.72rem', fontWeight: 500, padding: '0.15rem 0.5rem', backgroundColor: 'var(--cream-dark)', color: 'var(--ink-muted)' }}> {/* Platform tabs */}
{post.platform_hint} <div style={{ display: 'flex', gap: '0', marginBottom: '0.5rem', borderBottom: hasMultiple ? '2px solid var(--border)' : 'none' }}>
</span> {group.posts.map((p, i) => (
)} <button key={p.id} onClick={e => { e.stopPropagation(); setActivePlatform(prev => ({ ...prev, [group.key]: i })); setEditing && setEditingId(null) }}
style={{
padding: '0.35rem 0.75rem', fontSize: '0.75rem', fontWeight: activeIdx === i ? 700 : 400,
fontFamily: "'DM Sans', sans-serif", border: 'none', cursor: 'pointer',
backgroundColor: activeIdx === i ? 'var(--surface)' : 'transparent',
color: activeIdx === i ? 'var(--ink)' : 'var(--ink-muted)',
borderBottom: hasMultiple ? (activeIdx === i ? '2px solid var(--accent)' : '2px solid transparent') : 'none',
marginBottom: hasMultiple ? '-2px' : 0, transition: 'all 0.15s',
}}>
{(p.platform_hint || 'post').charAt(0).toUpperCase() + (p.platform_hint || 'post').slice(1)}
</button>
))}
</div> </div>
<p style={{ fontSize: '0.78rem', fontWeight: 600, color: 'var(--ink-light)', margin: '0 0 0.5rem' }}> {/* Status badge */}
{getCharacterName(post.character_id)} <div style={{ marginBottom: '0.75rem' }}>
</p> <span style={{ fontSize: '0.7rem', fontWeight: 700, padding: '0.15rem 0.5rem', backgroundColor: sc.bg, color: sc.color }}>
{statusLabels[activePost.status] || activePost.status}
</span>
</div>
{/* Text content */} {/* Text content */}
{isEditing ? ( {isEditing ? (
@@ -135,20 +176,20 @@ export default function ContentArchive() {
<textarea value={editText} onChange={e => setEditText(e.target.value)} rows={6} <textarea value={editText} onChange={e => setEditText(e.target.value)} rows={6}
style={{ ...inputStyle, resize: 'vertical', lineHeight: 1.6, fontSize: '0.85rem' }} /> style={{ ...inputStyle, resize: 'vertical', lineHeight: 1.6, fontSize: '0.85rem' }} />
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.5rem' }}> <div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.5rem' }}>
<button onClick={() => handleSaveEdit(post.id)} style={btnPrimary}>Salva</button> <button onClick={() => handleSaveEdit(activePost.id)} style={btnPrimary}>Salva</button>
<button onClick={() => setEditingId(null)} style={btnSecondary}>Annulla</button> <button onClick={() => setEditingId(null)} style={btnSecondary}>Annulla</button>
</div> </div>
</div> </div>
) : ( ) : (
<p style={{ fontSize: '0.85rem', color: 'var(--ink)', lineHeight: 1.6, margin: 0, whiteSpace: isExpanded ? 'pre-wrap' : 'normal', overflow: isExpanded ? 'visible' : 'hidden', display: isExpanded ? 'block' : '-webkit-box', WebkitLineClamp: isExpanded ? 'unset' : 3, WebkitBoxOrient: 'vertical' }}> <p style={{ fontSize: '0.85rem', color: 'var(--ink)', lineHeight: 1.6, margin: 0, whiteSpace: isExpanded ? 'pre-wrap' : 'normal', overflow: isExpanded ? 'visible' : 'hidden', display: isExpanded ? 'block' : '-webkit-box', WebkitLineClamp: isExpanded ? 'unset' : 3, WebkitBoxOrient: 'vertical' }}>
{post.text_content || '(nessun testo)'} {activePost.text_content || '(nessun testo)'}
</p> </p>
)} )}
{/* Hashtags when expanded */} {/* Hashtags when expanded */}
{isExpanded && !isEditing && post.hashtags?.length > 0 && ( {isExpanded && !isEditing && activePost.hashtags?.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.3rem', marginTop: '0.75rem' }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.3rem', marginTop: '0.75rem' }}>
{post.hashtags.map((tag, i) => ( {activePost.hashtags.map((tag, i) => (
<span key={i} style={{ fontSize: '0.72rem', padding: '0.1rem 0.4rem', backgroundColor: 'var(--accent-light)', color: 'var(--accent)' }}> <span key={i} style={{ fontSize: '0.72rem', padding: '0.1rem 0.4rem', backgroundColor: 'var(--accent-light)', color: 'var(--accent)' }}>
{tag} {tag}
</span> </span>
@@ -158,21 +199,21 @@ export default function ContentArchive() {
{/* Footer */} {/* Footer */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginTop: '0.75rem', paddingTop: '0.75rem', borderTop: '1px solid var(--border)' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginTop: '0.75rem', paddingTop: '0.75rem', borderTop: '1px solid var(--border)' }}>
<span style={{ fontSize: '0.72rem', color: 'var(--ink-muted)' }}>{formatDate(post.created_at)}</span> <span style={{ fontSize: '0.72rem', color: 'var(--ink-muted)' }}>{formatDate(activePost.created_at)}</span>
{post.hashtags?.length > 0 && ( {activePost.hashtags?.length > 0 && (
<span style={{ fontSize: '0.72rem', color: 'var(--ink-muted)' }}>{post.hashtags.length} hashtag</span> <span style={{ fontSize: '0.72rem', color: 'var(--ink-muted)' }}>{activePost.hashtags.length} hashtag</span>
)} )}
</div> </div>
{/* Actions */} {/* Actions */}
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.75rem', paddingTop: '0.75rem', borderTop: '1px solid var(--border)' }} <div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.75rem', paddingTop: '0.75rem', borderTop: '1px solid var(--border)' }}
onClick={e => e.stopPropagation()}> onClick={e => e.stopPropagation()}>
{post.status === 'draft' && ( {activePost.status === 'draft' && (
<button onClick={() => handleApprove(post.id)} style={{ ...btnSmall, backgroundColor: 'var(--success-light)', color: 'var(--success)' }}>Approva</button> <button onClick={() => handleApprove(activePost.id)} style={{ ...btnSmall, backgroundColor: 'var(--success-light)', color: 'var(--success)' }}>Approva</button>
)} )}
<button onClick={() => { setEditingId(post.id); setEditText(post.text_content || ''); setExpandedId(post.id) }} <button onClick={() => { setEditingId(activePost.id); setEditText(activePost.text_content || ''); setExpandedKey(group.key) }}
style={{ ...btnSmall, backgroundColor: 'var(--cream-dark)', color: 'var(--ink-light)' }}>Modifica</button> style={{ ...btnSmall, backgroundColor: 'var(--cream-dark)', color: 'var(--ink-light)' }}>Modifica</button>
<button onClick={() => setDeleteTarget(post.id)} <button onClick={() => setDeleteTarget({ id: activePost.id, batchKey: group.key })}
style={{ ...btnSmall, color: 'var(--error)', backgroundColor: 'transparent', marginLeft: 'auto' }}>Elimina</button> style={{ ...btnSmall, color: 'var(--error)', backgroundColor: 'transparent', marginLeft: 'auto' }}>Elimina</button>
</div> </div>
</div> </div>
@@ -181,6 +222,7 @@ export default function ContentArchive() {
})} })}
</div> </div>
)} )}
<ConfirmModal <ConfirmModal
open={deleteTarget !== null} open={deleteTarget !== null}
title="Elimina contenuto" title="Elimina contenuto"
@@ -188,7 +230,7 @@ export default function ContentArchive() {
confirmLabel="Elimina" confirmLabel="Elimina"
cancelLabel="Annulla" cancelLabel="Annulla"
confirmStyle="danger" confirmStyle="danger"
onConfirm={() => handleDelete(deleteTarget)} onConfirm={handleDelete}
onCancel={() => setDeleteTarget(null)} onCancel={() => setDeleteTarget(null)}
/> />
<style>{`@keyframes spin { to { transform: rotate(360deg) } }`}</style> <style>{`@keyframes spin { to { transform: rotate(360deg) } }`}</style>