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:
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
186
frontend/src/api/hooks.ts
Normal 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`)
|
||||
},
|
||||
})
|
||||
}
|
||||
22
frontend/src/components/Layout.tsx
Normal file
22
frontend/src/components/Layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
85
frontend/src/components/Sidebar.tsx
Normal file
85
frontend/src/components/Sidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
139
frontend/src/pages/Dashboard.tsx
Normal file
139
frontend/src/pages/Dashboard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
6
frontend/src/pages/GenerateCalendar.tsx
Normal file
6
frontend/src/pages/GenerateCalendar.tsx
Normal 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>
|
||||
}
|
||||
6
frontend/src/pages/GenerateSingle.tsx
Normal file
6
frontend/src/pages/GenerateSingle.tsx
Normal 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>
|
||||
}
|
||||
6
frontend/src/pages/OutputReview.tsx
Normal file
6
frontend/src/pages/OutputReview.tsx
Normal 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>
|
||||
}
|
||||
252
frontend/src/pages/Settings.tsx
Normal file
252
frontend/src/pages/Settings.tsx
Normal 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
160
frontend/src/types.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user