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:
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
294
frontend/src/pages/PromptEditor.tsx
Normal file
294
frontend/src/pages/PromptEditor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user