diff --git a/backend/app/routers/editorial.py b/backend/app/routers/editorial.py index ebbacce..a46ebc6 100644 --- a/backend/app/routers/editorial.py +++ b/backend/app/routers/editorial.py @@ -1,20 +1,23 @@ """Editorial Calendar router. Espone endpoint per il calendario editoriale con awareness levels e formati narrativi. +Integra LLM per generare idee di contenuto reali basate su profilo personaggio. """ import csv import io from typing import Optional -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException from fastapi.responses import StreamingResponse from pydantic import BaseModel from sqlalchemy.orm import Session from ..auth import get_current_user from ..database import get_db +from ..models import Character, SystemSetting, User from ..services.calendar_service import CalendarService, FORMATI_NARRATIVI, AWARENESS_LEVELS +from ..services.llm import get_llm_provider router = APIRouter( prefix="/api/editorial", @@ -25,6 +28,19 @@ router = APIRouter( _calendar_service = CalendarService() +def _get_setting(db: Session, key: str, user_id: int = None) -> str | None: + if user_id is not None: + setting = db.query(SystemSetting).filter( + SystemSetting.key == key, SystemSetting.user_id == user_id + ).first() + if setting is not None: + return setting.value + setting = db.query(SystemSetting).filter( + SystemSetting.key == key, SystemSetting.user_id == None + ).first() + return setting.value if setting else None + + # === Schemas locali === class CalendarGenerateRequest(BaseModel): @@ -34,6 +50,7 @@ class CalendarGenerateRequest(BaseModel): num_posts: int = 7 start_date: Optional[str] = None character_id: Optional[int] = None + brief: Optional[str] = None class ExportCsvRequest(BaseModel): @@ -55,11 +72,16 @@ def get_formats(): @router.post("/generate-calendar") -def generate_calendar(request: CalendarGenerateRequest, db: Session = Depends(get_db)): - """Genera un calendario editoriale con awareness levels.""" +def generate_calendar( + request: CalendarGenerateRequest, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Genera un calendario editoriale con idee di contenuto AI.""" if not request.topics: return {"slots": [], "totale_post": 0} + # Generate structural slots slots = _calendar_service.generate_calendar( topics=request.topics, num_posts=request.num_posts, @@ -68,6 +90,82 @@ def generate_calendar(request: CalendarGenerateRequest, db: Session = Depends(ge start_date=request.start_date, ) + # Try to enrich with LLM-generated ideas + provider_name = _get_setting(db, "llm_provider", current_user.id) + api_key = _get_setting(db, "llm_api_key", current_user.id) + model = _get_setting(db, "llm_model", current_user.id) + + if provider_name and api_key and slots: + try: + base_url = _get_setting(db, "llm_base_url", current_user.id) + llm = get_llm_provider(provider_name, api_key, model, base_url=base_url) + + # Build context from character if available + char_context = "" + if request.character_id: + character = db.query(Character).filter( + Character.id == request.character_id, + Character.user_id == current_user.id, + ).first() + if character: + char_context = ( + f"Personaggio: {character.name}, nicchia: {character.niche}\n" + f"Target: {character.target_audience or 'generico'}\n" + f"Voce: {character.brand_voice or character.tone or 'professionale'}\n" + ) + + brief_context = f"\nBrief strategico: {request.brief}" if request.brief else "" + + slot_descriptions = "\n".join( + f"- Slot {s['indice']+1}: topic={s['topic']}, formato={s['formato_narrativo']}, " + f"awareness={s['awareness_label']}, data={s['data_pubblicazione']}" + for s in slots + ) + + system_prompt = ( + "Sei un social media strategist esperto. " + "Per ogni slot del calendario editoriale, genera un'idea di post concreta: " + "un titolo/hook (max 10 parole) e un mini-brief (max 25 parole) che descrive il contenuto. " + "Rispondi SOLO con il formato:\n" + "1. HOOK: ... | BRIEF: ...\n" + "2. HOOK: ... | BRIEF: ...\n" + "Una riga per slot, nient'altro." + ) + + prompt = ( + f"{char_context}{brief_context}\n\n" + f"Calendario ({len(slots)} slot):\n{slot_descriptions}\n\n" + f"Genera hook e brief per ogni slot:" + ) + + result = llm.generate(prompt, system=system_prompt) + + # Parse result and enrich slots + lines = [l.strip() for l in result.strip().splitlines() if l.strip()] + for i, line in enumerate(lines): + if i >= len(slots): + break + # Parse "N. HOOK: ... | BRIEF: ..." + hook = "" + brief = "" + if "HOOK:" in line and "BRIEF:" in line: + parts = line.split("BRIEF:") + hook_part = parts[0] + brief = parts[1].strip() if len(parts) > 1 else "" + hook = hook_part.split("HOOK:")[-1].strip().rstrip("|").strip() + elif "|" in line: + parts = line.split("|", 1) + hook = parts[0].strip().lstrip("0123456789. ") + brief = parts[1].strip() if len(parts) > 1 else "" + else: + hook = line.lstrip("0123456789. ").strip() + + slots[i]["hook"] = hook + slots[i]["idea_brief"] = brief + + except Exception: + pass # Fall back to structural-only slots + return { "slots": slots, "totale_post": len(slots), diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index f7b3419..45a3658 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -19,6 +19,7 @@ import CommentsQueue from './components/CommentsQueue' import SettingsPage from './components/SettingsPage' import EditorialCalendar from './components/EditorialCalendar' import AdminSettings from './components/AdminSettings' +import LandingPage from './components/LandingPage' import CookieBanner from './components/CookieBanner' import PrivacyPolicy from './components/legal/PrivacyPolicy' import TermsOfService from './components/legal/TermsOfService' @@ -35,6 +36,7 @@ export default function App() { {/* Public routes */} + } /> } /> } /> } /> @@ -42,7 +44,7 @@ export default function App() { } /> {/* Protected routes */} - }> + }> }> } /> } /> diff --git a/frontend/src/components/ContentPage.jsx b/frontend/src/components/ContentPage.jsx index 427bf27..f2ca515 100644 --- a/frontend/src/components/ContentPage.jsx +++ b/frontend/src/components/ContentPage.jsx @@ -1,4 +1,5 @@ import { useState, useEffect, useRef } from 'react' +import { createPortal } from 'react-dom' import { Link, useSearchParams } from 'react-router-dom' import { api } from '../api' import { useAuth } from '../AuthContext' @@ -50,6 +51,9 @@ export default function ContentPage() { const [editing, setEditing] = useState(false) const [editText, setEditText] = useState('') const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) + const [showScheduleModal, setShowScheduleModal] = useState(false) + const [scheduleDate, setScheduleDate] = useState('') + const [scheduleTime, setScheduleTime] = useState('09:00') const [form, setForm] = useState({ character_id: '', @@ -172,6 +176,21 @@ export default function ContentPage() { } catch (err) { setError(err.message || 'Errore eliminazione') } } + const handleSchedule = async () => { + if (!generated || !scheduleDate) return + try { + const scheduledAt = new Date(`${scheduleDate}T${scheduleTime}:00`).toISOString() + await api.post('/plans/schedule', { + post_id: generated.id, + platform: generated.platform_hint || 'instagram', + scheduled_at: scheduledAt, + }) + updateActivePost({ status: 'scheduled' }) + setShowScheduleModal(false) + setError('') + } catch (err) { setError(err.message || 'Errore nella schedulazione') } + } + return (
{/* Header */} @@ -429,13 +448,21 @@ export default function ContentPage() { /> )} -
- {generated.status !== 'approved' && ( +
+ {generated.status !== 'approved' && generated.status !== 'scheduled' && ( )} + {generated.status === 'approved' && ( + + )} {!editing && }
+ {generated.status === 'approved' && ( +

+ Per pubblicare automaticamente, connetti i tuoi account social in Impostazioni Social. +

+ )}
) : (
@@ -458,11 +485,69 @@ export default function ContentPage() { onConfirm={handleDelete} onCancel={() => setShowDeleteConfirm(false)} /> + {showScheduleModal && ( + setShowScheduleModal(false)} + platform={generated?.platform_hint} + /> + )}
) } +function ScheduleModal({ date, time, onDateChange, onTimeChange, onConfirm, onCancel, platform }) { + // Default to tomorrow + const tomorrow = new Date(Date.now() + 86400000).toISOString().split('T')[0] + if (!date) onDateChange(tomorrow) + + return createPortal( +
+
+
+

+ Schedula pubblicazione +

+

+ Scegli data e ora per la pubblicazione{platform ? ` su ${platform}` : ''}. + Il post verrà pubblicato automaticamente quando i social saranno connessi. +

+
+
+ + onDateChange(e.target.value)} + min={new Date().toISOString().split('T')[0]} + style={{ width: '100%', padding: '0.5rem', border: '1px solid var(--border)', fontSize: '0.875rem', fontFamily: "'DM Sans', sans-serif", outline: 'none' }} /> +
+
+ + onTimeChange(e.target.value)} + style={{ width: '100%', padding: '0.5rem', border: '1px solid var(--border)', fontSize: '0.875rem', fontFamily: "'DM Sans', sans-serif", outline: 'none' }} /> +
+
+
+ + +
+
+
, + document.body + ) +} + function HashtagEditor({ hashtags, onChange, postId }) { const [newTag, setNewTag] = useState('') const [editIdx, setEditIdx] = useState(null) diff --git a/frontend/src/components/EditorialCalendar.jsx b/frontend/src/components/EditorialCalendar.jsx index c87a34f..6c52a46 100644 --- a/frontend/src/components/EditorialCalendar.jsx +++ b/frontend/src/components/EditorialCalendar.jsx @@ -440,7 +440,25 @@ function CalendarioTab({ strategy }) {

{slot.topic}

- {slot.note &&

{slot.note}

} + {slot.hook && ( +

+ {slot.hook} +

+ )} + {slot.idea_brief && ( +

+ {slot.idea_brief} +

+ )} + {!slot.hook && slot.note &&

{slot.note}

} + {slot.hook && ( + + Genera contenuto → + + )}
) diff --git a/frontend/src/components/LandingPage.jsx b/frontend/src/components/LandingPage.jsx new file mode 100644 index 0000000..010c821 --- /dev/null +++ b/frontend/src/components/LandingPage.jsx @@ -0,0 +1,140 @@ +import { Link } from 'react-router-dom' + +export default function LandingPage() { + return ( +
+ {/* Nav */} + + + {/* Hero */} +
+ + Content Studio potenziato dall'AI + +

+ Crea contenuti social
+ senza pensarci. +

+

+ Definisci la tua voce una sola volta. L'AI genera, adatta e schedula i tuoi post + su Instagram, Facebook e oltre. Tu approvi con un click. +

+
+ + Inizia gratis + +
+
+ + {/* Features */} +
+
+ {[ + { title: 'Personaggi AI', desc: 'Definisci voce, tono, target e regole. L\'AI li ricorda per sempre — non ripeterti mai più.', icon: '◎' }, + { title: 'Multi-piattaforma', desc: 'Un click, contenuti adattati per Instagram, Facebook, YouTube e TikTok. Ognuno con il formato giusto.', icon: '◈' }, + { title: 'Suggerimenti smart', desc: 'La dashboard propone 3 idee ogni giorno basate sul tuo profilo. Click e genera.', icon: '✦' }, + { title: 'Impara da te', desc: 'Ogni post approvato insegna all\'AI il tuo stile. Più usi Leopost, più diventa bravo.', icon: '◇' }, + { title: 'Calendario editoriale', desc: 'L\'AI genera un piano settimanale con tecniche narrative (PAS, AIDA, Storytelling) e awareness levels.', icon: '▣' }, + { title: 'Hashtag intelligenti', desc: 'Set fissi per piattaforma + hashtag variabili generati dall\'AI per massimizzare la reach.', icon: '#' }, + ].map((f, i) => ( +
+ {f.icon} +

+ {f.title} +

+

+ {f.desc} +

+
+ ))} +
+
+ + {/* CTA */} +
+

+ Pronto a semplificare i tuoi social? +

+

+ Piano gratuito con 15 post al mese. Nessuna carta richiesta. +

+ + Crea il tuo account + +
+ + {/* Footer */} +
+

+ © {new Date().getFullYear()} Leopost +

+
+ {[{ to: '/privacy', label: 'Privacy' }, { to: '/termini', label: 'Termini' }, { to: '/cookie', label: 'Cookie' }].map(l => ( + + {l.label} + + ))} +
+
+
+ ) +} diff --git a/frontend/src/components/ProtectedRoute.jsx b/frontend/src/components/ProtectedRoute.jsx index 2cf9561..7be4a80 100644 --- a/frontend/src/components/ProtectedRoute.jsx +++ b/frontend/src/components/ProtectedRoute.jsx @@ -1,16 +1,24 @@ import { Navigate, Outlet } from 'react-router-dom' import { useAuth } from '../AuthContext' -export default function ProtectedRoute() { +export default function ProtectedRoute({ fallback = '/login' }) { const { user, loading } = useAuth() if (loading) { return ( -
-
+
+
+
) } - return user ? : + return user ? : }