From 3b17ed0a9bb7bfbf544297e892d6a70dab58fbcf Mon Sep 17 00:00:00 2001 From: Michele Date: Fri, 3 Apr 2026 19:05:25 +0200 Subject: [PATCH] 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) --- backend/app/routers/content.py | 105 ++++++++++++--------- frontend/src/components/ConfirmModal.jsx | 51 ++++++++++ frontend/src/components/ContentArchive.jsx | 17 +++- frontend/src/components/ContentPage.jsx | 67 +++++++++++-- 4 files changed, 180 insertions(+), 60 deletions(-) create mode 100644 frontend/src/components/ConfirmModal.jsx diff --git a/backend/app/routers/content.py b/backend/app/routers/content.py index f723a59..418e84a 100644 --- a/backend/app/routers/content.py +++ b/backend/app/routers/content.py @@ -45,14 +45,13 @@ def _get_setting(db: Session, key: str, user_id: int = None) -> str | None: return setting.value -@router.post("/generate", response_model=PostResponse) +@router.post("/generate", response_model=list[PostResponse]) def generate_content( request: GenerateContentRequest, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): - """Generate content for a character using LLM.""" - # Validate character belongs to user + """Generate content for a character using LLM. One post per platform.""" character = ( db.query(Character) .filter(Character.id == request.character_id, Character.user_id == current_user.id) @@ -61,6 +60,9 @@ def generate_content( if not character: raise HTTPException(status_code=404, detail="Character not found") + # Determine platforms to generate for + platforms = request.platforms if request.platforms else [request.platform] + # Check monthly post limit first_of_month = date.today().replace(day=1) if current_user.posts_reset_date != first_of_month: @@ -68,11 +70,20 @@ def generate_content( current_user.posts_reset_date = first_of_month db.commit() - allowed, msg = check_limit(current_user, "posts_per_month", current_user.posts_generated_this_month or 0) + current_count = current_user.posts_generated_this_month or 0 + allowed, msg = check_limit(current_user, "posts_per_month", current_count) if not allowed: raise HTTPException(status_code=403, detail={"message": msg, "upgrade_required": True}) - # Get LLM settings (user-specific first, then global) + # Also check if we have room for all platforms + allowed_after, msg_after = check_limit(current_user, "posts_per_month", current_count + len(platforms) - 1) + if not allowed_after: + raise HTTPException(status_code=403, detail={ + "message": f"Non hai abbastanza post rimanenti per generare su {len(platforms)} piattaforme. {msg_after}", + "upgrade_required": True, + }) + + # Get LLM settings provider_name = request.provider or _get_setting(db, "llm_provider", current_user.id) api_key = _get_setting(db, "llm_api_key", current_user.id) model = request.model or _get_setting(db, "llm_model", current_user.id) @@ -94,7 +105,6 @@ def generate_content( }, ) - # Build character dict for content service char_dict = { "name": character.name, "niche": character.niche, @@ -102,22 +112,11 @@ def generate_content( "tone": character.tone or "professional", } - # Create LLM provider and generate text base_url = _get_setting(db, "llm_base_url", current_user.id) llm = get_llm_provider(provider_name, api_key, model, base_url=base_url) - text = generate_post_text( - character=char_dict, - llm_provider=llm, - platform=request.effective_platform, - topic_hint=request.topic_hint, - brief=request.brief, - ) - # Generate hashtags - hashtags = generate_hashtags(text, llm, request.effective_platform) - - # Handle affiliate links - affiliate_links_used: list[dict] = [] + # Preload affiliate links once + affiliate_link_dicts: list[dict] = [] if request.include_affiliates: links = ( db.query(AffiliateLink) @@ -128,39 +127,51 @@ def generate_content( ) .all() ) - if links: - link_dicts = [ - { - "url": link.url, - "label": link.name, - "keywords": link.topics or [], - } - for link in links - ] + affiliate_link_dicts = [ + {"url": link.url, "label": link.name, "keywords": link.topics or []} + for link in links + ] + + # Generate one post per platform + posts_created: list[Post] = [] + for platform in platforms: + text = generate_post_text( + character=char_dict, + llm_provider=llm, + platform=platform, + topic_hint=request.topic_hint, + brief=request.brief, + ) + + hashtags = generate_hashtags(text, llm, platform) + + affiliate_links_used: list[dict] = [] + if affiliate_link_dicts: text, affiliate_links_used = inject_affiliate_links( - text, link_dicts, character.topics or [] + text, affiliate_link_dicts, character.topics or [] ) - # Create post record - post = Post( - character_id=character.id, - user_id=current_user.id, - content_type=request.content_type, - text_content=text, - hashtags=hashtags, - affiliate_links_used=affiliate_links_used, - llm_provider=provider_name, - llm_model=model, - platform_hint=request.platform, - status="draft", - ) - db.add(post) + post = Post( + character_id=character.id, + user_id=current_user.id, + content_type=request.content_type, + text_content=text, + hashtags=hashtags, + affiliate_links_used=affiliate_links_used, + llm_provider=provider_name, + llm_model=model, + platform_hint=platform, + status="draft", + ) + db.add(post) + posts_created.append(post) - # Increment monthly counter - current_user.posts_generated_this_month = (current_user.posts_generated_this_month or 0) + 1 + # Increment monthly counter by number of posts generated + current_user.posts_generated_this_month = current_count + len(platforms) db.commit() - db.refresh(post) - return post + for post in posts_created: + db.refresh(post) + return posts_created @router.post("/generate-image", response_model=PostResponse) diff --git a/frontend/src/components/ConfirmModal.jsx b/frontend/src/components/ConfirmModal.jsx new file mode 100644 index 0000000..25d41f1 --- /dev/null +++ b/frontend/src/components/ConfirmModal.jsx @@ -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 ( +
+
+
+

+ {title} +

+

+ {message} +

+
+ + +
+
+
+ ) +} diff --git a/frontend/src/components/ContentArchive.jsx b/frontend/src/components/ContentArchive.jsx index b89eb79..219899e 100644 --- a/frontend/src/components/ContentArchive.jsx +++ b/frontend/src/components/ContentArchive.jsx @@ -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() { )} -
@@ -180,6 +181,16 @@ export default function ContentArchive() { })} )} + handleDelete(deleteTarget)} + onCancel={() => setDeleteTarget(null)} + /> ) diff --git a/frontend/src/components/ContentPage.jsx b/frontend/src/components/ContentPage.jsx index 47bc55c..1712ff2 100644 --- a/frontend/src/components/ContentPage.jsx +++ b/frontend/src/components/ContentPage.jsx @@ -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() {
Contenuto Generato + {/* Platform tabs when multiple posts */} + {generatedPosts.length > 1 && ( +
+ {generatedPosts.map((p, i) => ( + + ))} +
+ )} + {loading ? (
@@ -356,7 +393,7 @@ export default function ContentPage() { {generated.hashtags?.length > 0 && ( setGenerated(prev => ({ ...prev, hashtags: newTags }))} + onChange={newTags => updateActivePost({ hashtags: newTags })} postId={generated.id} /> )} @@ -366,7 +403,7 @@ export default function ContentPage() { )} {!editing && } - +
) : ( @@ -380,6 +417,16 @@ export default function ContentPage() { )}
+ setShowDeleteConfirm(false)} + /> )