diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0bca39f..8db44e0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,11 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { BrowserRouter, Route, Routes } from 'react-router-dom' +import { Layout } from './components/Layout' +import { Dashboard } from './pages/Dashboard' +import { GenerateCalendar } from './pages/GenerateCalendar' +import { GenerateSingle } from './pages/GenerateSingle' +import { OutputReview } from './pages/OutputReview' +import { Settings } from './pages/Settings' const queryClient = new QueryClient({ defaultOptions: { @@ -10,25 +16,20 @@ const queryClient = new QueryClient({ }, }) -function HomePage() { - return ( -
-
-

PostGenerator

-

Setup completo — pronto per la business logic.

-
-
- ) -} - function App() { return ( {/* basename must match the nginx subpath and Vite base config */} - - } /> - + + + } /> + } /> + } /> + } /> + } /> + + ) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 4aabc91..f9eaab1 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -43,3 +43,67 @@ export async function apiFetch( return response.json() as Promise } + +/** GET request typed helper */ +export function apiGet(endpoint: string): Promise { + return apiFetch(endpoint, { method: 'GET' }) +} + +/** POST request typed helper */ +export function apiPost(endpoint: string, body?: unknown): Promise { + return apiFetch(endpoint, { + method: 'POST', + body: body !== undefined ? JSON.stringify(body) : undefined, + }) +} + +/** PUT request typed helper */ +export function apiPut(endpoint: string, body?: unknown): Promise { + return apiFetch(endpoint, { + method: 'PUT', + body: body !== undefined ? JSON.stringify(body) : undefined, + }) +} + +/** + * Download file helper — returns a Blob from a GET or POST endpoint. + * Used for CSV export. + */ +export async function apiDownload( + endpoint: string, + method: 'GET' | 'POST' = 'GET', + body?: unknown, +): Promise { + const url = `${API_BASE}${endpoint}` + + const response = await fetch(url, { + method, + headers: { + 'Content-Type': 'application/json', + }, + body: body !== undefined ? JSON.stringify(body) : undefined, + }) + + if (!response.ok) { + const text = await response.text().catch(() => '') + throw new Error( + `Download error ${response.status} ${response.statusText}${text ? `: ${text}` : ''}`, + ) + } + + return response.blob() +} + +/** + * Trigger a browser file download from a Blob. + */ +export function triggerDownload(blob: Blob, filename: string): void { + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) +} diff --git a/frontend/src/api/hooks.ts b/frontend/src/api/hooks.ts new file mode 100644 index 0000000..2d9206d --- /dev/null +++ b/frontend/src/api/hooks.ts @@ -0,0 +1,186 @@ +/** + * TanStack Query hooks per tutte le API calls del PostGenerator. + * + * Pattern generale: + * - useQuery per lettura dati (GET) + * - useMutation per scrittura dati (POST/PUT) + * - Polling condizionale: refetchInterval attivo solo quando job in running + * + * Tutti gli endpoint usano API_BASE='/postgenerator/api' via apiGet/apiPost/apiPut/apiDownload. + */ + +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { apiDownload, apiGet, apiPost, apiPut, triggerDownload } from './client' +import type { + CalendarRequest, + CalendarResponse, + FormatsResponse, + GenerateRequest, + GenerateResponse, + JobStatus, + PostResult, + Settings, + SettingsStatus, +} from '../types' + +// --------------------------------------------------------------------------- +// Settings +// --------------------------------------------------------------------------- + +/** Carica le impostazioni correnti. api_key è mascherata (ultimi 4 char). */ +export function useSettings() { + return useQuery({ + queryKey: ['settings'], + queryFn: () => apiGet('/settings'), + staleTime: 60_000, + }) +} + +/** Verifica se API key è configurata e quale modello è attivo. */ +export function useSettingsStatus() { + return useQuery({ + queryKey: ['settings', 'status'], + queryFn: () => apiGet('/settings/status'), + staleTime: 30_000, + refetchOnWindowFocus: true, + }) +} + +/** Aggiorna le impostazioni via PUT /api/settings. */ +export function useUpdateSettings() { + const queryClient = useQueryClient() + return useMutation>({ + mutationFn: (settings) => apiPut('/settings', settings), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['settings'] }) + }, + }) +} + +// --------------------------------------------------------------------------- +// Calendario +// --------------------------------------------------------------------------- + +/** Recupera i formati narrativi disponibili (7 formati). */ +export function useFormats() { + return useQuery({ + queryKey: ['calendar', 'formats'], + queryFn: () => apiGet('/calendar/formats'), + staleTime: Infinity, // i formati non cambiano mai + }) +} + +// --------------------------------------------------------------------------- +// Generazione bulk (async con job_id) +// --------------------------------------------------------------------------- + +/** + * Avvia generazione batch di 13 post. + * Risponde 202 con {job_id, message} — la generazione gira in background. + * Il frontend deve fare polling su useJobStatus(jobId). + */ +export function useGenerateCalendar() { + return useMutation<{ job_id: string; message: string }, Error, CalendarRequest>({ + mutationFn: (request) => + apiPost<{ job_id: string; message: string }>('/generate/bulk', request), + }) +} + +/** + * Polling stato job. refetchInterval attivo solo quando status='running'. + * Smette di pollare automaticamente quando status diventa 'completed' o 'failed'. + */ +export function useJobStatus(jobId: string | null) { + return useQuery({ + queryKey: ['jobs', jobId, 'status'], + queryFn: () => apiGet(`/generate/job/${jobId}/status`), + enabled: !!jobId, + refetchInterval: (query) => { + const status = query.state.data?.status + // Polla ogni 2s solo quando il job è in esecuzione + if (status === 'running') return 2000 + return false + }, + staleTime: 0, + }) +} + +/** + * Risultati completi di un job completato. + * Usato in OutputReview dopo che il job ha status='completed'. + */ +export function useJobResults(jobId: string | null) { + return useQuery({ + queryKey: ['jobs', jobId, 'results'], + queryFn: () => apiGet(`/generate/job/${jobId}`), + enabled: !!jobId, + staleTime: 60_000, + }) +} + +// --------------------------------------------------------------------------- +// Generazione singolo post +// --------------------------------------------------------------------------- + +/** + * Genera un singolo post (sync — ritorna PostResult direttamente). + * Usato per rigenerare post falliti o in GenerateSingle. + */ +export function useGenerateSingle() { + return useMutation({ + mutationFn: (request) => apiPost('/generate/single', request), + }) +} + +// --------------------------------------------------------------------------- +// Calendar generation (sync, per CalendarRequest -> CalendarResponse) +// --------------------------------------------------------------------------- + +/** + * Genera calendario editoriale (sync). + * Diverso da useGenerateCalendar: questo chiama POST /api/calendar/generate + * che ritorna i slot del calendario senza generare il contenuto LLM. + */ +export function useGenerateCalendarPlan() { + return useMutation({ + mutationFn: (request) => + apiPost('/calendar/generate', request), + }) +} + +// --------------------------------------------------------------------------- +// Export CSV +// --------------------------------------------------------------------------- + +/** + * Scarica il CSV originale del job (GET /api/export/{jobId}/csv). + * Triggera download browser automaticamente. + */ +export function useDownloadCsv() { + return useMutation({ + mutationFn: async (jobId: string) => { + const blob = await apiDownload(`/export/${jobId}/csv`, 'GET') + triggerDownload(blob, `postgenerator-${jobId}.csv`) + }, + }) +} + +/** + * Scarica il CSV con le modifiche inline (POST /api/export/{jobId}/csv). + * Invia i risultati modificati e triggera download browser. + */ +export function useDownloadEditedCsv() { + return useMutation< + void, + Error, + { jobId: string; results: PostResult[]; campagna: string } + >({ + mutationFn: async ({ jobId, results, campagna }) => { + const blob = await apiDownload(`/export/${jobId}/csv`, 'POST', { + results, + campagna, + }) + triggerDownload(blob, `postgenerator-${jobId}-edited.csv`) + }, + }) +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx new file mode 100644 index 0000000..3a4bb92 --- /dev/null +++ b/frontend/src/components/Layout.tsx @@ -0,0 +1,22 @@ +/** + * Layout wrapper principale con Sidebar a sinistra e area contenuto a destra. + * Tutti i child sono renderizzati nell'area contenuto scrollabile. + */ + +import type { ReactNode } from 'react' +import { Sidebar } from './Sidebar' + +interface LayoutProps { + children: ReactNode +} + +export function Layout({ children }: LayoutProps) { + return ( +
+ +
+ {children} +
+
+ ) +} diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx new file mode 100644 index 0000000..a46be18 --- /dev/null +++ b/frontend/src/components/Sidebar.tsx @@ -0,0 +1,85 @@ +/** + * Sidebar di navigazione principale. + * Logo + link di navigazione con evidenziazione route attiva. + * Usa NavLink da react-router-dom per active state automatico. + */ + +import { BarChart2, Calendar, FileText, Home, Settings } from 'lucide-react' +import { NavLink } from 'react-router-dom' + +interface NavItem { + to: string + label: string + icon: React.ReactNode + end?: boolean +} + +const navItems: NavItem[] = [ + { + to: '/', + label: 'Dashboard', + icon: , + end: true, + }, + { + to: '/genera', + label: 'Genera Calendario', + icon: , + }, + { + to: '/genera-singolo', + label: 'Genera Singolo Post', + icon: , + }, + { + to: '/impostazioni', + label: 'Impostazioni', + icon: , + }, +] + +export function Sidebar() { + return ( + + ) +} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx new file mode 100644 index 0000000..43d9c8d --- /dev/null +++ b/frontend/src/pages/Dashboard.tsx @@ -0,0 +1,139 @@ +/** + * Dashboard principale. + * Mostra stato API key e quick actions per iniziare. + */ + +import { AlertTriangle, ArrowRight, Calendar, FileText, Settings } from 'lucide-react' +import { Link } from 'react-router-dom' +import { useSettingsStatus } from '../api/hooks' + +export function Dashboard() { + const { data: status, isLoading } = useSettingsStatus() + + const apiKeyOk = status?.api_key_configured ?? false + + return ( +
+ {/* Header */} +
+

Dashboard

+

+ Genera un calendario editoriale di 13 caroselli Instagram strategici, pronti per Canva. +

+
+ + {/* Banner API key non configurata */} + {!isLoading && !apiKeyOk && ( +
+ +
+

API key Claude non configurata

+

+ Devi configurare la tua API key Anthropic prima di poter generare contenuti.{' '} + + Vai alle Impostazioni + +

+
+
+ )} + + {/* Banner API key OK */} + {!isLoading && apiKeyOk && ( +
+
+

+ API key configurata — modello: {status?.llm_model} +

+
+ )} + + {/* Quick actions */} +
+

Azioni rapide

+ +
+ +
+
+ +
+
+

Genera Calendario

+

13 post in un click

+
+
+ + + + +
+
+ +
+
+

Genera Singolo Post

+

Test e rigenerazione

+
+
+ + + + +
+
+ +
+
+

Impostazioni

+

API key, modello, nicchie

+
+
+ + +
+
+ + {/* Descrizione sistema */} +
+

Come funziona

+
+ {[ + { n: '1', title: 'Configura', desc: 'Aggiungi API key e scegli le nicchie target' }, + { n: '2', title: 'Genera', desc: 'Inserisci obiettivo campagna, Claude crea 13 post' }, + { n: '3', title: 'Esporta', desc: 'Scarica il CSV pronto per Canva Bulk Create' }, + ].map((step) => ( +
+
+ + {step.n} + + {step.title} +
+

{step.desc}

+
+ ))} +
+
+
+ ) +} diff --git a/frontend/src/pages/GenerateCalendar.tsx b/frontend/src/pages/GenerateCalendar.tsx new file mode 100644 index 0000000..ff88184 --- /dev/null +++ b/frontend/src/pages/GenerateCalendar.tsx @@ -0,0 +1,6 @@ +/** + * Pagina Genera Calendario (stub — completata nel Task 2c) + */ +export function GenerateCalendar() { + return
Genera Calendario — in costruzione
+} diff --git a/frontend/src/pages/GenerateSingle.tsx b/frontend/src/pages/GenerateSingle.tsx new file mode 100644 index 0000000..4e021f1 --- /dev/null +++ b/frontend/src/pages/GenerateSingle.tsx @@ -0,0 +1,6 @@ +/** + * Pagina Genera Singolo Post (stub — completata nel Task 2c) + */ +export function GenerateSingle() { + return
Genera Singolo Post — in costruzione
+} diff --git a/frontend/src/pages/OutputReview.tsx b/frontend/src/pages/OutputReview.tsx new file mode 100644 index 0000000..5b0870b --- /dev/null +++ b/frontend/src/pages/OutputReview.tsx @@ -0,0 +1,6 @@ +/** + * Pagina Output Review (stub — completata nel Task 2c) + */ +export function OutputReview() { + return
Output Review — in costruzione
+} diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx new file mode 100644 index 0000000..5c4a6f8 --- /dev/null +++ b/frontend/src/pages/Settings.tsx @@ -0,0 +1,252 @@ +/** + * Pagina Impostazioni. + * Configura API key Claude, modello LLM, brand, tono, nicchie, frequenza. + */ + +import { Check, Eye, EyeOff, Loader2 } from 'lucide-react' +import { useEffect, useState } from 'react' +import { useSettings, useUpdateSettings } from '../api/hooks' +import type { Settings as SettingsType } from '../types' + +const ALL_NICCHIE = ['generico', 'dentisti', 'avvocati', 'ecommerce', 'local_business', 'agenzie'] + +const NICCHIA_LABELS: Record = { + generico: 'Generico', + dentisti: 'Dentisti', + avvocati: 'Avvocati', + ecommerce: 'E-commerce', + local_business: 'Local Business', + agenzie: 'Agenzie', +} + +const LLM_MODELS = [ + { value: 'claude-sonnet-4-5', label: 'Claude Sonnet 4.5 (raccomandato)' }, + { value: 'claude-haiku-3-5', label: 'Claude Haiku 3.5 (più veloce, meno costoso)' }, +] + +export function Settings() { + const { data: settings, isLoading } = useSettings() + const updateMutation = useUpdateSettings() + + const [form, setForm] = useState>({}) + const [showApiKey, setShowApiKey] = useState(false) + const [saved, setSaved] = useState(false) + + // Popola il form quando i settings arrivano dal backend + useEffect(() => { + if (settings) { + setForm({ + api_key: '', // Non pre-populare l'API key (mascherata) + llm_model: settings.llm_model, + nicchie_attive: settings.nicchie_attive, + lingua: settings.lingua, + frequenza_post: settings.frequenza_post, + brand_name: settings.brand_name ?? '', + tono: settings.tono ?? 'diretto e concreto', + }) + } + }, [settings]) + + function handleNicchiaToggle(nicchia: string) { + setForm((prev) => { + const current = prev.nicchie_attive ?? [] + if (current.includes(nicchia)) { + // Non permettere di deselezionare tutte le nicchie + if (current.length <= 1) return prev + return { ...prev, nicchie_attive: current.filter((n) => n !== nicchia) } + } + return { ...prev, nicchie_attive: [...current, nicchia] } + }) + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + + // Prepara il payload: non inviare api_key se vuota (evita sovrascrittura) + const payload: Partial = { ...form } + if (!payload.api_key || payload.api_key.trim() === '') { + delete payload.api_key + } + + try { + await updateMutation.mutateAsync(payload) + setSaved(true) + setForm((prev) => ({ ...prev, api_key: '' })) // Reset campo API key dopo salvataggio + setTimeout(() => setSaved(false), 3000) + } catch { + // L'errore è gestito da updateMutation.error + } + } + + if (isLoading) { + return ( +
+ +
+ ) + } + + return ( +
+
+

Impostazioni

+

+ Configura API key, modello LLM e parametri di generazione. +

+
+ +
+ + {/* API Key */} +
+

Anthropic

+
+ +
+ setForm((p) => ({ ...p, api_key: e.target.value }))} + placeholder={settings?.api_key ? '••••••••••••••••' : 'sk-ant-...'} + className="w-full px-3 py-2 pr-10 rounded-lg bg-stone-800 border border-stone-700 text-stone-100 text-sm placeholder-stone-600 focus:outline-none focus:ring-2 focus:ring-amber-500/50 focus:border-amber-500/50" + /> + +
+

+ {settings?.api_key + ? 'API key già configurata. Inserisci una nuova per sostituirla.' + : 'Richiesta per la generazione. Ottienila su console.anthropic.com'} +

+
+ +
+ + +
+
+ + {/* Brand */} +
+

Brand

+
+ + setForm((p) => ({ ...p, brand_name: e.target.value || null }))} + placeholder="Es. Studio Rossi & Associati" + className="w-full px-3 py-2 rounded-lg bg-stone-800 border border-stone-700 text-stone-100 text-sm placeholder-stone-600 focus:outline-none focus:ring-2 focus:ring-amber-500/50 focus:border-amber-500/50" + /> +
+ +
+ + setForm((p) => ({ ...p, tono: e.target.value }))} + placeholder="Es. professionale ma amichevole" + className="w-full px-3 py-2 rounded-lg bg-stone-800 border border-stone-700 text-stone-100 text-sm placeholder-stone-600 focus:outline-none focus:ring-2 focus:ring-amber-500/50 focus:border-amber-500/50" + /> +
+
+ + {/* Calendario */} +
+

Calendario

+
+ + setForm((p) => ({ ...p, frequenza_post: parseInt(e.target.value) || 3 }))} + className="w-24 px-3 py-2 rounded-lg bg-stone-800 border border-stone-700 text-stone-100 text-sm focus:outline-none focus:ring-2 focus:ring-amber-500/50 focus:border-amber-500/50" + /> +

Da 1 a 7 post a settimana. Default: 3 (lun, mer, ven).

+
+ +
+ +
+ {ALL_NICCHIE.map((nicchia) => { + const isActive = (form.nicchie_attive ?? []).includes(nicchia) + return ( + + ) + })} +
+

"Generico" è sempre incluso automaticamente nel 50% degli slot.

+
+
+ + {/* Errore / Successo */} + {updateMutation.error && ( +
+ {updateMutation.error.message} +
+ )} + + {/* Submit */} +
+ + + {saved && ( +
+ + Salvato! +
+ )} +
+
+
+ ) +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts new file mode 100644 index 0000000..a905d7d --- /dev/null +++ b/frontend/src/types.ts @@ -0,0 +1,160 @@ +/** + * TypeScript types che rispecchiano gli schemas Pydantic del backend PostGenerator. + * + * Mantenere sincronizzato con: + * - backend/schemas/calendar.py + * - backend/schemas/generate.py + * - backend/schemas/settings.py + * - backend/routers/generate.py (JobStatusResponse) + */ + +// --------------------------------------------------------------------------- +// Calendario editoriale +// --------------------------------------------------------------------------- + +export interface CalendarSlot { + indice: number + tipo_contenuto: TipoContenuto + livello_schwartz: LivelloSchwartz + formato_narrativo: FormatoNarrativo + funzione: string + fase_campagna: string + target_nicchia: string + data_pub_suggerita: string // YYYY-MM-DD + topic?: string | null +} + +export interface CalendarRequest { + obiettivo_campagna: string + settimane?: number + nicchie?: string[] | null + frequenza_post?: number + data_inizio?: string | null +} + +export interface CalendarResponse { + campagna: string + slots: CalendarSlot[] + totale_post: number +} + +// --------------------------------------------------------------------------- +// Tipi di dominio (valori fissi dal backend) +// --------------------------------------------------------------------------- + +export type TipoContenuto = + | 'valore' + | 'storytelling' + | 'news' + | 'riprova_sociale' + | 'coinvolgimento' + | 'promozione' + +export type LivelloSchwartz = 'L1' | 'L2' | 'L3' | 'L4' | 'L5' + +export type FormatoNarrativo = + | 'PAS' + | 'AIDA' + | 'BAB' + | 'Listicle' + | 'Storytelling' + | 'Dato_Implicazione' + | 'Obiezione_Risposta' + +// --------------------------------------------------------------------------- +// Post generati (output LLM) +// --------------------------------------------------------------------------- + +export interface SlideContent { + headline: string + body: string + image_keyword: string +} + +export interface GeneratedPost { + // Cover slide + cover_title: string + cover_subtitle: string + cover_image_keyword: string + // Slide centrali (s2-s7) — sempre 6 + slides: SlideContent[] + // CTA slide + cta_text: string + cta_subtext: string + cta_image_keyword: string + // Caption Instagram + caption_instagram: string +} + +export type PostStatus = 'success' | 'failed' | 'pending' + +export interface PostResult { + slot_index: number + status: PostStatus + post?: GeneratedPost | null + error?: string | null + // slot viene aggiunto dal frontend dopo merge con CalendarSlot + slot?: CalendarSlot +} + +export interface GenerateResponse { + campagna: string + results: PostResult[] + total: number + success_count: number + failed_count: number +} + +// --------------------------------------------------------------------------- +// Job status (polling) +// --------------------------------------------------------------------------- + +export type JobStatusType = 'running' | 'completed' | 'failed' + +export interface JobStatus { + job_id: string + status: JobStatusType + total: number + completed: number + current_post: number + results: PostResult[] + error?: string | null +} + +// --------------------------------------------------------------------------- +// Richiesta generazione singolo post +// --------------------------------------------------------------------------- + +export interface GenerateRequest { + slot: CalendarSlot + obiettivo_campagna: string + brand_name?: string | null + tono?: string | null +} + +// --------------------------------------------------------------------------- +// Settings +// --------------------------------------------------------------------------- + +export interface Settings { + api_key?: string | null + llm_model: string + nicchie_attive: string[] + lingua: string + frequenza_post: number + brand_name?: string | null + tono?: string | null +} + +export interface SettingsStatus { + api_key_configured: boolean + llm_model: string +} + +// --------------------------------------------------------------------------- +// Formati narrativi (risposta GET /api/calendar/formats) +// --------------------------------------------------------------------------- + +export interface FormatsResponse { + [formato: string]: string +}