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 { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { BrowserRouter, Route, Routes } from 'react-router-dom'
|
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({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
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() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
{/* basename must match the nginx subpath and Vite base config */}
|
{/* basename must match the nginx subpath and Vite base config */}
|
||||||
<BrowserRouter basename="/postgenerator">
|
<BrowserRouter basename="/postgenerator">
|
||||||
<Routes>
|
<Layout>
|
||||||
<Route path="/" element={<HomePage />} />
|
<Routes>
|
||||||
</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>
|
</BrowserRouter>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -43,3 +43,67 @@ export async function apiFetch<T>(
|
|||||||
|
|
||||||
return response.json() as Promise<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