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() {
) : (
- {post.text_content || '(nessun testo)'}
+ {activePost.text_content || '(nessun testo)'}
)}
{/* Hashtags when expanded */}
- {isExpanded && !isEditing && post.hashtags?.length > 0 && (
+ {isExpanded && !isEditing && activePost.hashtags?.length > 0 && (
- {post.hashtags.map((tag, i) => (
+ {activePost.hashtags.map((tag, i) => (
{tag}
@@ -158,21 +199,21 @@ export default function ContentArchive() {
{/* Footer */}
- {formatDate(post.created_at)}
- {post.hashtags?.length > 0 && (
- {post.hashtags.length} hashtag
+ {formatDate(activePost.created_at)}
+ {activePost.hashtags?.length > 0 && (
+ {activePost.hashtags.length} hashtag
)}
{/* Actions */}
e.stopPropagation()}>
- {post.status === 'draft' && (
-
+ {activePost.status === 'draft' && (
+
)}
-
@@ -181,6 +222,7 @@ export default function ContentArchive() {
})}
)}
+
handleDelete(deleteTarget)}
+ onConfirm={handleDelete}
onCancel={() => setDeleteTarget(null)}
/>