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

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