From 743a6c1324a9e816fc5937026eb10fdb8d587294 Mon Sep 17 00:00:00 2001 From: Michele Date: Sat, 4 Apr 2026 12:52:01 +0200 Subject: [PATCH] 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) --- backend/app/models.py | 1 + backend/app/routers/content.py | 5 +- backend/app/schemas.py | 1 + frontend/src/components/ConfirmModal.jsx | 27 +++-- frontend/src/components/ContentArchive.jsx | 128 ++++++++++++++------- 5 files changed, 108 insertions(+), 54 deletions(-) diff --git a/backend/app/models.py b/backend/app/models.py index 82c037e..93c122a 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -64,6 +64,7 @@ class Post(Base): __tablename__ = "posts" 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) content_type = Column(String(20), default="text") # text, image, video, carousel text_content = Column(Text) diff --git a/backend/app/routers/content.py b/backend/app/routers/content.py index 418e84a..43c9b5a 100644 --- a/backend/app/routers/content.py +++ b/backend/app/routers/content.py @@ -3,6 +3,7 @@ Handles post generation via LLM, image generation, and CRUD operations on posts. """ +import uuid from datetime import date, datetime from fastapi import APIRouter, Depends, HTTPException, Query @@ -132,7 +133,8 @@ def generate_content( 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] = [] for platform in platforms: text = generate_post_text( @@ -152,6 +154,7 @@ def generate_content( ) post = Post( + batch_id=batch_id, character_id=character.id, user_id=current_user.id, content_type=request.content_type, diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 92ebfae..d8e94c0 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -82,6 +82,7 @@ class PostUpdate(BaseModel): class PostResponse(BaseModel): id: int + batch_id: Optional[str] = None character_id: int content_type: str text_content: Optional[str] = None diff --git a/frontend/src/components/ConfirmModal.jsx b/frontend/src/components/ConfirmModal.jsx index 25d41f1..b68cb8b 100644 --- a/frontend/src/components/ConfirmModal.jsx +++ b/frontend/src/components/ConfirmModal.jsx @@ -1,11 +1,16 @@ import { useEffect } from 'react' +import { createPortal } from 'react-dom' 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) + document.body.style.overflow = 'hidden' + return () => { + document.removeEventListener('keydown', onKey) + document.body.style.overflow = '' + } }, [open, onCancel]) if (!open) return null @@ -17,24 +22,25 @@ export default function ConfirmModal({ open, title, message, confirmLabel = 'Con } const cc = confirmColors[confirmStyle] || confirmColors.danger - return ( -
-
+ return createPortal( +
+
-

+

{title}

-

+

{message}

@@ -46,6 +52,7 @@ export default function ConfirmModal({ open, title, message, confirmLabel = 'Con
-
+
, + document.body ) } diff --git a/frontend/src/components/ContentArchive.jsx b/frontend/src/components/ContentArchive.jsx index 219899e..5d501fb 100644 --- a/frontend/src/components/ContentArchive.jsx +++ b/frontend/src/components/ContentArchive.jsx @@ -11,29 +11,43 @@ const statusColors = { 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() { const [posts, setPosts] = useState([]) const [characters, setCharacters] = useState([]) 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 [editText, setEditText] = useState('') const [filterCharacter, setFilterCharacter] = useState('') const [filterStatus, setFilterStatus] = useState('') - const [deleteTarget, setDeleteTarget] = useState(null) + const [deleteTarget, setDeleteTarget] = useState(null) // { id, batchKey } useEffect(() => { loadData() }, []) const loadData = async () => { setLoading(true) try { - const [postsData, charsData] = await Promise.all([ - api.get('/content/posts'), - api.get('/characters/'), - ]) + const [postsData, charsData] = await Promise.all([api.get('/content/posts'), api.get('/characters/')]) setPosts(postsData) setCharacters(charsData) - } catch { /* silent */ } finally { setLoading(false) } + } catch {} finally { setLoading(false) } } const getCharacterName = (id) => characters.find(c => c.id === id)?.name || '—' @@ -41,9 +55,16 @@ export default function ContentArchive() { const handleApprove = async (postId) => { 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) => { try { await api.put(`/content/posts/${postId}`, { text_content: editText }) @@ -58,6 +79,8 @@ export default function ContentArchive() { return true }) + const groups = groupByBatch(filtered) + const formatDate = (d) => d ? new Date(d).toLocaleDateString('it-IT', { day: '2-digit', month: 'short', year: 'numeric' }) : '—' return ( @@ -87,7 +110,7 @@ export default function ContentArchive() { {Object.entries(statusLabels).map(([val, label]) => )} - {filtered.length} contenut{filtered.length === 1 ? 'o' : 'i'} + {groups.length} contenut{groups.length === 1 ? 'o' : 'i'}
@@ -95,7 +118,7 @@ export default function ContentArchive() {
- ) : filtered.length === 0 ? ( + ) : groups.length === 0 ? (

Nessun contenuto trovato

@@ -104,30 +127,48 @@ export default function ContentArchive() {

) : ( -
- {filtered.map(post => { - const sc = statusColors[post.status] || statusColors.draft - const isExpanded = expandedId === post.id - const isEditing = editingId === post.id +
+ {groups.map(group => { + const activeIdx = activePlatform[group.key] || 0 + const activePost = group.posts[activeIdx] || group.posts[0] + const sc = statusColors[activePost.status] || statusColors.draft + const isExpanded = expandedKey === group.key + const isEditing = editingId === activePost.id + const hasMultiple = group.posts.length > 1 + return ( -
{ if (!isEditing) setExpandedId(isExpanded ? null : post.id) }}> +
{ if (!isEditing) setExpandedKey(isExpanded ? null : group.key) }}>
- {/* Header */} -
- - {statusLabels[post.status] || post.status} - - {post.platform_hint && ( - - {post.platform_hint} - - )} + + {/* Character name */} +

+ {getCharacterName(activePost.character_id)} +

+ + {/* Platform tabs */} +
+ {group.posts.map((p, i) => ( + + ))}
-

- {getCharacterName(post.character_id)} -

+ {/* Status badge */} +
+ + {statusLabels[activePost.status] || activePost.status} + +
{/* Text content */} {isEditing ? ( @@ -135,20 +176,20 @@ export default function ContentArchive() {