feat(02-01): prompt editor UI with types, hooks, page, route, sidebar

- Add PromptInfo, PromptListResponse, PromptDetail types
- Add usePromptList, usePrompt, useSavePrompt, useResetPrompt hooks
- Create PromptEditor page with two-column layout, live variables
- Add /prompt-editor route in App.tsx
- Add Prompt Editor nav item with Pencil icon in Sidebar
- TypeScript compiles without errors

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Michele
2026-03-08 20:57:01 +01:00
parent 05972fa8f1
commit ca3dd59072
5 changed files with 372 additions and 1 deletions

View File

@@ -5,6 +5,7 @@ import { Dashboard } from './pages/Dashboard'
import { GenerateCalendar } from './pages/GenerateCalendar'
import { GenerateSingle } from './pages/GenerateSingle'
import { OutputReview } from './pages/OutputReview'
import { PromptEditor } from './pages/PromptEditor'
import { Settings } from './pages/Settings'
const queryClient = new QueryClient({
@@ -27,6 +28,7 @@ function App() {
<Route path="/genera" element={<GenerateCalendar />} />
<Route path="/genera-singolo" element={<GenerateSingle />} />
<Route path="/risultati/:jobId" element={<OutputReview />} />
<Route path="/prompt-editor" element={<PromptEditor />} />
<Route path="/impostazioni" element={<Settings />} />
</Routes>
</Layout>

View File

@@ -19,6 +19,8 @@ import type {
GenerateResponse,
JobStatus,
PostResult,
PromptDetail,
PromptListResponse,
Settings,
SettingsStatus,
} from '../types'
@@ -184,3 +186,51 @@ export function useDownloadEditedCsv() {
},
})
}
// ---------------------------------------------------------------------------
// Prompt Editor
// ---------------------------------------------------------------------------
/** Lista tutti i prompt disponibili con flag modificato/default. */
export function usePromptList() {
return useQuery<PromptListResponse>({
queryKey: ['prompts'],
queryFn: () => apiGet<PromptListResponse>('/prompts'),
staleTime: 30_000,
})
}
/** Carica contenuto + variabili di un singolo prompt. */
export function usePrompt(name: string | null) {
return useQuery<PromptDetail>({
queryKey: ['prompts', name],
queryFn: () => apiGet<PromptDetail>(`/prompts/${name}`),
enabled: !!name,
staleTime: 0, // Sempre fresco dopo edit
})
}
/** Salva il contenuto modificato di un prompt. */
export function useSavePrompt() {
const queryClient = useQueryClient()
return useMutation<PromptDetail, Error, { name: string; content: string }>({
mutationFn: ({ name, content }) =>
apiPut<PromptDetail>(`/prompts/${name}`, { content }),
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['prompts'] })
queryClient.setQueryData(['prompts', data.name], data)
},
})
}
/** Reset un prompt al default originale. */
export function useResetPrompt() {
const queryClient = useQueryClient()
return useMutation<PromptDetail, Error, string>({
mutationFn: (name) => apiPost<PromptDetail>(`/prompts/${name}/reset`),
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['prompts'] })
queryClient.setQueryData(['prompts', data.name], data)
},
})
}

View File

@@ -4,7 +4,7 @@
* Usa NavLink da react-router-dom per active state automatico.
*/
import { BarChart2, Calendar, FileText, Home, Settings } from 'lucide-react'
import { BarChart2, Calendar, FileText, Home, Pencil, Settings } from 'lucide-react'
import { NavLink } from 'react-router-dom'
interface NavItem {
@@ -31,6 +31,11 @@ const navItems: NavItem[] = [
label: 'Genera Singolo Post',
icon: <FileText size={18} />,
},
{
to: '/prompt-editor',
label: 'Prompt Editor',
icon: <Pencil size={18} />,
},
{
to: '/impostazioni',
label: 'Impostazioni',

View File

@@ -0,0 +1,294 @@
/**
* Pagina Prompt Editor.
* Lista dei prompt disponibili con badge modificato/default,
* textarea per editing con variabili live, salva e reset al default.
*/
import { Check, Loader2, RotateCcw, Save } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import { usePrompt, usePromptList, useResetPrompt, useSavePrompt } from '../api/hooks'
/** Regex per estrarre variabili {{nome}} dal testo — client-side per feedback live. */
const VARIABLE_REGEX = /\{\{(\w+)\}\}/g
export function PromptEditor() {
const { data: promptList, isLoading: isLoadingList } = usePromptList()
const saveMutation = useSavePrompt()
const resetMutation = useResetPrompt()
const [selectedPrompt, setSelectedPrompt] = useState<string | null>(null)
const [editContent, setEditContent] = useState('')
const [serverContent, setServerContent] = useState('')
const [showResetConfirm, setShowResetConfirm] = useState(false)
const [saveStatus, setSaveStatus] = useState<'idle' | 'success' | 'error'>('idle')
// Carica dettaglio prompt selezionato
const { data: promptDetail, isLoading: isLoadingDetail } = usePrompt(selectedPrompt)
// Sincronizza textarea con dati dal server quando cambia il prompt selezionato
useEffect(() => {
if (promptDetail) {
setEditContent(promptDetail.content)
setServerContent(promptDetail.content)
setShowResetConfirm(false)
setSaveStatus('idle')
}
}, [promptDetail])
// Variabili estratte live dal contenuto nella textarea
const clientVariables = useMemo(() => {
const matches = new Set<string>()
let match: RegExpExecArray | null
const regex = new RegExp(VARIABLE_REGEX.source, 'g')
while ((match = regex.exec(editContent)) !== null) {
matches.add(match[1])
}
return Array.from(matches).sort()
}, [editContent])
// Dirty flag — true se il contenuto nella textarea differisce dal server
const dirty = editContent !== serverContent
// Seleziona primo prompt automaticamente
useEffect(() => {
if (promptList?.prompts.length && !selectedPrompt) {
setSelectedPrompt(promptList.prompts[0].name)
}
}, [promptList, selectedPrompt])
// Handler: seleziona prompt dalla lista
function handleSelectPrompt(name: string) {
setSelectedPrompt(name)
setSaveStatus('idle')
}
// Handler: salva prompt
async function handleSave() {
if (!selectedPrompt || !dirty) return
setSaveStatus('idle')
try {
const result = await saveMutation.mutateAsync({
name: selectedPrompt,
content: editContent,
})
setServerContent(result.content)
setSaveStatus('success')
setTimeout(() => setSaveStatus('idle'), 3000)
} catch {
setSaveStatus('error')
}
}
// Handler: reset al default
async function handleReset() {
if (!selectedPrompt) return
try {
const result = await resetMutation.mutateAsync(selectedPrompt)
setEditContent(result.content)
setServerContent(result.content)
setShowResetConfirm(false)
setSaveStatus('success')
setTimeout(() => setSaveStatus('idle'), 3000)
} catch {
setSaveStatus('error')
}
}
// Trova info del prompt selezionato nella lista (per badge)
const selectedInfo = promptList?.prompts.find((p) => p.name === selectedPrompt)
// Il badge riflette lo stato reale: se dirty OR gia' modificato nel server
const isModified = dirty || (selectedInfo?.modified ?? false)
if (isLoadingList) {
return (
<div className="flex items-center justify-center min-h-64">
<Loader2 className="animate-spin text-stone-500" size={24} />
</div>
)
}
return (
<div className="px-6 py-10 space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-stone-100">Prompt Editor</h1>
<p className="mt-1 text-stone-400 text-sm">
Modifica i prompt LLM usati per generare i caroselli. Ogni prompt usa variabili{' '}
<code className="text-amber-400/80 bg-stone-800 px-1 rounded">{'{{nome}}'}</code>{' '}
sostituite automaticamente durante la generazione.
</p>
</div>
{/* Layout a due colonne */}
<div className="flex flex-col lg:flex-row gap-6">
{/* Colonna sinistra: Lista prompt */}
<div className="lg:w-1/3 w-full">
<div className="rounded-xl border border-stone-800 bg-stone-900/50 overflow-hidden">
<div className="px-4 py-3 border-b border-stone-800">
<h2 className="text-xs font-semibold text-stone-500 uppercase tracking-wider">
Prompt disponibili
</h2>
</div>
<div className="divide-y divide-stone-800/60">
{promptList?.prompts.map((p) => (
<button
key={p.name}
onClick={() => handleSelectPrompt(p.name)}
className={[
'w-full text-left px-4 py-3 flex items-center justify-between gap-2 transition-colors',
selectedPrompt === p.name
? 'bg-amber-500/10 text-amber-300'
: 'text-stone-300 hover:bg-stone-800/60 hover:text-stone-100',
].join(' ')}
>
<span className="text-sm font-medium truncate">{p.name}</span>
<span
className={[
'flex-shrink-0 text-xs px-2 py-0.5 rounded-full font-medium',
p.modified
? 'bg-amber-500/20 text-amber-400'
: 'bg-stone-700/50 text-stone-500',
].join(' ')}
>
{p.modified ? 'Modificato' : 'Default'}
</span>
</button>
))}
</div>
</div>
</div>
{/* Colonna destra: Editor */}
<div className="lg:w-2/3 w-full space-y-4">
{selectedPrompt ? (
isLoadingDetail ? (
<div className="flex items-center justify-center min-h-64">
<Loader2 className="animate-spin text-stone-500" size={24} />
</div>
) : (
<>
{/* Header editor */}
<div className="flex items-center gap-3">
<h2 className="text-lg font-semibold text-stone-100">{selectedPrompt}</h2>
<span
className={[
'text-xs px-2 py-0.5 rounded-full font-medium',
isModified
? 'bg-amber-500/20 text-amber-400'
: 'bg-stone-700/50 text-stone-500',
].join(' ')}
>
{isModified ? 'Modificato' : 'Default'}
</span>
{dirty && (
<span className="text-xs text-stone-500 italic">
modifiche non salvate
</span>
)}
</div>
{/* Textarea */}
<textarea
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
className="w-full min-h-[400px] px-4 py-3 rounded-xl bg-stone-900 border border-stone-700 text-stone-200 text-sm font-mono leading-relaxed resize-y focus:outline-none focus:ring-2 focus:ring-amber-500/50 focus:border-amber-500/50 placeholder-stone-600"
placeholder="Contenuto del prompt..."
spellCheck={false}
/>
{/* Variabili richieste */}
{clientVariables.length > 0 && (
<div className="space-y-2">
<h3 className="text-xs font-semibold text-stone-500 uppercase tracking-wider">
Variabili richieste ({clientVariables.length})
</h3>
<div className="flex flex-wrap gap-2">
{clientVariables.map((v) => (
<span
key={v}
className="inline-flex items-center px-2.5 py-1 rounded-md bg-stone-700/70 text-amber-200 text-xs font-mono"
>
{`{{${v}}}`}
</span>
))}
</div>
</div>
)}
{/* Azioni */}
<div className="flex items-center gap-3 pt-2">
{/* Salva */}
<button
onClick={handleSave}
disabled={!dirty || saveMutation.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"
>
{saveMutation.isPending ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Save size={14} />
)}
Salva
</button>
{/* Reset al Default */}
{!showResetConfirm ? (
<button
onClick={() => setShowResetConfirm(true)}
disabled={!(selectedInfo?.modified) || resetMutation.isPending}
className="flex items-center gap-2 px-4 py-2.5 rounded-lg bg-stone-700 text-stone-300 text-sm font-medium hover:bg-stone-600 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<RotateCcw size={14} />
Reset al Default
</button>
) : (
<div className="flex items-center gap-2">
<span className="text-sm text-stone-400">
Sei sicuro? Ripristinera' il prompt originale.
</span>
<button
onClick={handleReset}
disabled={resetMutation.isPending}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-red-500/20 text-red-400 text-sm font-medium hover:bg-red-500/30 transition-colors"
>
{resetMutation.isPending ? (
<Loader2 size={12} className="animate-spin" />
) : (
<RotateCcw size={12} />
)}
Conferma
</button>
<button
onClick={() => setShowResetConfirm(false)}
className="px-3 py-1.5 rounded-lg text-stone-500 text-sm hover:text-stone-300 transition-colors"
>
Annulla
</button>
</div>
)}
{/* Feedback salvataggio */}
{saveStatus === 'success' && (
<div className="flex items-center gap-1.5 text-sm text-emerald-400">
<Check size={14} />
Salvato con successo
</div>
)}
{saveStatus === 'error' && (
<div className="text-sm text-red-400">
{saveMutation.error?.message || resetMutation.error?.message || 'Errore durante il salvataggio'}
</div>
)}
</div>
</>
)
) : (
<div className="flex items-center justify-center min-h-64 text-stone-500 text-sm">
Seleziona un prompt dalla lista per iniziare a modificarlo.
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -159,3 +159,23 @@ export interface SettingsStatus {
export interface FormatsResponse {
[formato: string]: string
}
// ---------------------------------------------------------------------------
// Prompt Editor
// ---------------------------------------------------------------------------
export interface PromptInfo {
name: string
modified: boolean
}
export interface PromptListResponse {
prompts: PromptInfo[]
}
export interface PromptDetail {
name: string
content: string
variables: string[]
modified: boolean
}