feat(01-04): layout, routing, API hooks, tipi TypeScript, Dashboard, Settings

- types.ts: CalendarSlot, GeneratedPost, PostResult, JobStatus, Settings, SettingsStatus
- api/client.ts: aggiunto apiGet, apiPost, apiPut, apiDownload, triggerDownload
- api/hooks.ts: 10+ hooks TanStack Query (settings, generate, job polling, CSV export)
- components/Layout.tsx + Sidebar.tsx: sidebar stone/amber palette con 4 nav link
- pages/Dashboard.tsx: banner API key, quick actions link, step guide
- pages/Settings.tsx: form completo (API key password, LLM select, brand, nicchie checkbox)
- App.tsx: 5 route con BrowserRouter basename='/postgenerator', QueryClientProvider, Layout
This commit is contained in:
Michele
2026-03-08 02:23:55 +01:00
parent 60b46cb5c1
commit 738a877d39
11 changed files with 941 additions and 14 deletions

View File

@@ -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 (
<div className="min-h-screen bg-gray-950 text-gray-100 flex items-center justify-center">
<div className="text-center space-y-4">
<h1 className="text-4xl font-bold tracking-tight">PostGenerator</h1>
<p className="text-gray-400 text-lg">Setup completo pronto per la business logic.</p>
</div>
</div>
)
}
function App() {
return (
<QueryClientProvider client={queryClient}>
{/* basename must match the nginx subpath and Vite base config */}
<BrowserRouter basename="/postgenerator">
<Routes>
<Route path="/" element={<HomePage />} />
</Routes>
<Layout>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/genera" element={<GenerateCalendar />} />
<Route path="/genera-singolo" element={<GenerateSingle />} />
<Route path="/risultati/:jobId" element={<OutputReview />} />
<Route path="/impostazioni" element={<Settings />} />
</Routes>
</Layout>
</BrowserRouter>
</QueryClientProvider>
)

View File

@@ -43,3 +43,67 @@ export async function apiFetch<T>(
return response.json() as Promise<T>
}
/** GET request typed helper */
export function apiGet<T>(endpoint: string): Promise<T> {
return apiFetch<T>(endpoint, { method: 'GET' })
}
/** POST request typed helper */
export function apiPost<T>(endpoint: string, body?: unknown): Promise<T> {
return apiFetch<T>(endpoint, {
method: 'POST',
body: body !== undefined ? JSON.stringify(body) : undefined,
})
}
/** PUT request typed helper */
export function apiPut<T>(endpoint: string, body?: unknown): Promise<T> {
return apiFetch<T>(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<Blob> {
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)
}

186
frontend/src/api/hooks.ts Normal file
View File

@@ -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<Settings>({
queryKey: ['settings'],
queryFn: () => apiGet<Settings>('/settings'),
staleTime: 60_000,
})
}
/** Verifica se API key è configurata e quale modello è attivo. */
export function useSettingsStatus() {
return useQuery<SettingsStatus>({
queryKey: ['settings', 'status'],
queryFn: () => apiGet<SettingsStatus>('/settings/status'),
staleTime: 30_000,
refetchOnWindowFocus: true,
})
}
/** Aggiorna le impostazioni via PUT /api/settings. */
export function useUpdateSettings() {
const queryClient = useQueryClient()
return useMutation<Settings, Error, Partial<Settings>>({
mutationFn: (settings) => apiPut<Settings>('/settings', settings),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['settings'] })
},
})
}
// ---------------------------------------------------------------------------
// Calendario
// ---------------------------------------------------------------------------
/** Recupera i formati narrativi disponibili (7 formati). */
export function useFormats() {
return useQuery<FormatsResponse>({
queryKey: ['calendar', 'formats'],
queryFn: () => apiGet<FormatsResponse>('/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<JobStatus>({
queryKey: ['jobs', jobId, 'status'],
queryFn: () => apiGet<JobStatus>(`/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<GenerateResponse>({
queryKey: ['jobs', jobId, 'results'],
queryFn: () => apiGet<GenerateResponse>(`/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<PostResult, Error, GenerateRequest>({
mutationFn: (request) => apiPost<PostResult>('/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<CalendarResponse, Error, CalendarRequest>({
mutationFn: (request) =>
apiPost<CalendarResponse>('/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<void, Error, string>({
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`)
},
})
}

View File

@@ -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 (
<div className="flex min-h-screen bg-stone-900 text-stone-100">
<Sidebar />
<main className="flex-1 overflow-y-auto">
{children}
</main>
</div>
)
}

View File

@@ -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: <Home size={18} />,
end: true,
},
{
to: '/genera',
label: 'Genera Calendario',
icon: <Calendar size={18} />,
},
{
to: '/genera-singolo',
label: 'Genera Singolo Post',
icon: <FileText size={18} />,
},
{
to: '/impostazioni',
label: 'Impostazioni',
icon: <Settings size={18} />,
},
]
export function Sidebar() {
return (
<aside className="w-60 min-h-screen bg-stone-950 border-r border-stone-800 flex flex-col">
{/* Logo / Titolo */}
<div className="px-5 py-6 border-b border-stone-800">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-lg bg-amber-500 flex items-center justify-center flex-shrink-0">
<BarChart2 size={16} className="text-stone-950" />
</div>
<div>
<p className="text-sm font-semibold text-stone-100 leading-tight">PostGenerator</p>
<p className="text-xs text-stone-500 leading-tight">by Michele</p>
</div>
</div>
</div>
{/* Navigazione */}
<nav className="flex-1 px-3 py-4 space-y-0.5">
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.end}
className={({ isActive }) =>
[
'flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm transition-colors',
isActive
? 'bg-amber-500/15 text-amber-400 font-medium'
: 'text-stone-400 hover:text-stone-200 hover:bg-stone-800/60',
].join(' ')
}
>
<span className="flex-shrink-0">{item.icon}</span>
<span>{item.label}</span>
</NavLink>
))}
</nav>
{/* Footer sidebar */}
<div className="px-5 py-4 border-t border-stone-800">
<p className="text-xs text-stone-600">v1.0 Core Pipeline</p>
</div>
</aside>
)
}

View File

@@ -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 (
<div className="max-w-3xl mx-auto px-6 py-10 space-y-8">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-stone-100">Dashboard</h1>
<p className="mt-1 text-stone-400 text-sm">
Genera un calendario editoriale di 13 caroselli Instagram strategici, pronti per Canva.
</p>
</div>
{/* Banner API key non configurata */}
{!isLoading && !apiKeyOk && (
<div className="flex items-start gap-3 px-4 py-3 rounded-lg bg-amber-500/10 border border-amber-500/30">
<AlertTriangle size={18} className="text-amber-400 flex-shrink-0 mt-0.5" />
<div className="text-sm">
<p className="text-amber-300 font-medium">API key Claude non configurata</p>
<p className="text-stone-400 mt-0.5">
Devi configurare la tua API key Anthropic prima di poter generare contenuti.{' '}
<Link to="/impostazioni" className="text-amber-400 underline underline-offset-2 hover:text-amber-300">
Vai alle Impostazioni
</Link>
</p>
</div>
</div>
)}
{/* Banner API key OK */}
{!isLoading && apiKeyOk && (
<div className="flex items-center gap-3 px-4 py-3 rounded-lg bg-emerald-500/10 border border-emerald-500/30">
<div className="w-2 h-2 rounded-full bg-emerald-400 flex-shrink-0" />
<p className="text-sm text-emerald-300">
API key configurata modello: <span className="font-mono text-xs">{status?.llm_model}</span>
</p>
</div>
)}
{/* Quick actions */}
<div className="space-y-3">
<h2 className="text-xs font-semibold text-stone-500 uppercase tracking-wider">Azioni rapide</h2>
<div className="grid gap-3 sm:grid-cols-2">
<Link
to="/genera"
className={[
'group flex items-center justify-between px-5 py-4 rounded-xl border transition-all',
apiKeyOk
? 'bg-stone-800 border-stone-700 hover:border-amber-500/50 hover:bg-stone-700/80'
: 'bg-stone-800/40 border-stone-800 opacity-60 cursor-not-allowed pointer-events-none',
].join(' ')}
>
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-lg bg-amber-500/15 flex items-center justify-center">
<Calendar size={18} className="text-amber-400" />
</div>
<div>
<p className="text-sm font-medium text-stone-100">Genera Calendario</p>
<p className="text-xs text-stone-500">13 post in un click</p>
</div>
</div>
<ArrowRight size={16} className="text-stone-600 group-hover:text-stone-300 transition-colors" />
</Link>
<Link
to="/genera-singolo"
className={[
'group flex items-center justify-between px-5 py-4 rounded-xl border transition-all',
apiKeyOk
? 'bg-stone-800 border-stone-700 hover:border-amber-500/50 hover:bg-stone-700/80'
: 'bg-stone-800/40 border-stone-800 opacity-60 cursor-not-allowed pointer-events-none',
].join(' ')}
>
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-lg bg-stone-700 flex items-center justify-center">
<FileText size={18} className="text-stone-400" />
</div>
<div>
<p className="text-sm font-medium text-stone-100">Genera Singolo Post</p>
<p className="text-xs text-stone-500">Test e rigenerazione</p>
</div>
</div>
<ArrowRight size={16} className="text-stone-600 group-hover:text-stone-300 transition-colors" />
</Link>
<Link
to="/impostazioni"
className="group flex items-center justify-between px-5 py-4 rounded-xl border bg-stone-800 border-stone-700 hover:border-stone-600 hover:bg-stone-700/80 transition-all"
>
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-lg bg-stone-700 flex items-center justify-center">
<Settings size={18} className="text-stone-400" />
</div>
<div>
<p className="text-sm font-medium text-stone-100">Impostazioni</p>
<p className="text-xs text-stone-500">API key, modello, nicchie</p>
</div>
</div>
<ArrowRight size={16} className="text-stone-600 group-hover:text-stone-300 transition-colors" />
</Link>
</div>
</div>
{/* Descrizione sistema */}
<div className="space-y-3">
<h2 className="text-xs font-semibold text-stone-500 uppercase tracking-wider">Come funziona</h2>
<div className="grid gap-2 sm:grid-cols-3">
{[
{ 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) => (
<div key={step.n} className="px-4 py-3 rounded-lg bg-stone-800/50 border border-stone-800">
<div className="flex items-center gap-2 mb-1.5">
<span className="w-5 h-5 rounded-full bg-amber-500/20 text-amber-400 text-xs font-bold flex items-center justify-center">
{step.n}
</span>
<span className="text-sm font-medium text-stone-200">{step.title}</span>
</div>
<p className="text-xs text-stone-500">{step.desc}</p>
</div>
))}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,6 @@
/**
* Pagina Genera Calendario (stub — completata nel Task 2c)
*/
export function GenerateCalendar() {
return <div className="p-6 text-stone-400">Genera Calendario in costruzione</div>
}

View File

@@ -0,0 +1,6 @@
/**
* Pagina Genera Singolo Post (stub — completata nel Task 2c)
*/
export function GenerateSingle() {
return <div className="p-6 text-stone-400">Genera Singolo Post in costruzione</div>
}

View File

@@ -0,0 +1,6 @@
/**
* Pagina Output Review (stub — completata nel Task 2c)
*/
export function OutputReview() {
return <div className="p-6 text-stone-400">Output Review in costruzione</div>
}

View File

@@ -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<string, string> = {
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<Partial<SettingsType>>({})
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<SettingsType> = { ...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 (
<div className="flex items-center justify-center min-h-64">
<Loader2 className="animate-spin text-stone-500" size={24} />
</div>
)
}
return (
<div className="max-w-2xl mx-auto px-6 py-10 space-y-8">
<div>
<h1 className="text-2xl font-bold text-stone-100">Impostazioni</h1>
<p className="mt-1 text-stone-400 text-sm">
Configura API key, modello LLM e parametri di generazione.
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
{/* API Key */}
<section className="space-y-4">
<h2 className="text-xs font-semibold text-stone-500 uppercase tracking-wider">Anthropic</h2>
<div className="space-y-1.5">
<label className="block text-sm font-medium text-stone-300">
API Key Claude
</label>
<div className="relative">
<input
type={showApiKey ? 'text' : 'password'}
value={form.api_key ?? ''}
onChange={(e) => 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"
/>
<button
type="button"
onClick={() => setShowApiKey((v) => !v)}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-stone-500 hover:text-stone-300 transition-colors"
>
{showApiKey ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
<p className="text-xs text-stone-600">
{settings?.api_key
? 'API key già configurata. Inserisci una nuova per sostituirla.'
: 'Richiesta per la generazione. Ottienila su console.anthropic.com'}
</p>
</div>
<div className="space-y-1.5">
<label className="block text-sm font-medium text-stone-300">Modello LLM</label>
<select
value={form.llm_model ?? 'claude-sonnet-4-5'}
onChange={(e) => setForm((p) => ({ ...p, llm_model: e.target.value }))}
className="w-full 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"
>
{LLM_MODELS.map((m) => (
<option key={m.value} value={m.value}>{m.label}</option>
))}
</select>
</div>
</section>
{/* Brand */}
<section className="space-y-4">
<h2 className="text-xs font-semibold text-stone-500 uppercase tracking-wider">Brand</h2>
<div className="space-y-1.5">
<label className="block text-sm font-medium text-stone-300">
Nome Brand / Studio <span className="text-stone-600 font-normal">(opzionale)</span>
</label>
<input
type="text"
value={form.brand_name ?? ''}
onChange={(e) => 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"
/>
</div>
<div className="space-y-1.5">
<label className="block text-sm font-medium text-stone-300">Tono di voce</label>
<input
type="text"
value={form.tono ?? 'diretto e concreto'}
onChange={(e) => 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"
/>
</div>
</section>
{/* Calendario */}
<section className="space-y-4">
<h2 className="text-xs font-semibold text-stone-500 uppercase tracking-wider">Calendario</h2>
<div className="space-y-1.5">
<label className="block text-sm font-medium text-stone-300">
Frequenza post settimanale
</label>
<input
type="number"
min={1}
max={7}
value={form.frequenza_post ?? 3}
onChange={(e) => 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"
/>
<p className="text-xs text-stone-600">Da 1 a 7 post a settimana. Default: 3 (lun, mer, ven).</p>
</div>
<div className="space-y-2">
<label className="block text-sm font-medium text-stone-300">Nicchie attive</label>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{ALL_NICCHIE.map((nicchia) => {
const isActive = (form.nicchie_attive ?? []).includes(nicchia)
return (
<button
key={nicchia}
type="button"
onClick={() => handleNicchiaToggle(nicchia)}
className={[
'flex items-center gap-2 px-3 py-2 rounded-lg border text-sm transition-all text-left',
isActive
? 'bg-amber-500/15 border-amber-500/40 text-amber-300'
: 'bg-stone-800 border-stone-700 text-stone-400 hover:border-stone-600',
].join(' ')}
>
<span
className={[
'w-4 h-4 rounded border flex-shrink-0 flex items-center justify-center',
isActive ? 'bg-amber-500 border-amber-500' : 'border-stone-600',
].join(' ')}
>
{isActive && <Check size={10} className="text-stone-950" />}
</span>
{NICCHIA_LABELS[nicchia] ?? nicchia}
</button>
)
})}
</div>
<p className="text-xs text-stone-600">"Generico" è sempre incluso automaticamente nel 50% degli slot.</p>
</div>
</section>
{/* Errore / Successo */}
{updateMutation.error && (
<div className="px-4 py-3 rounded-lg bg-red-500/10 border border-red-500/30 text-sm text-red-400">
{updateMutation.error.message}
</div>
)}
{/* Submit */}
<div className="flex items-center gap-3 pt-2">
<button
type="submit"
disabled={updateMutation.isPending}
className="flex items-center gap-2 px-5 py-2.5 rounded-lg bg-amber-500 text-stone-950 text-sm font-semibold hover:bg-amber-400 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{updateMutation.isPending && <Loader2 size={14} className="animate-spin" />}
Salva impostazioni
</button>
{saved && (
<div className="flex items-center gap-1.5 text-sm text-emerald-400">
<Check size={14} />
Salvato!
</div>
)}
</div>
</form>
</div>
)
}

160
frontend/src/types.ts Normal file
View File

@@ -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
}