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:
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user