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 { GenerateCalendar } from './pages/GenerateCalendar'
|
||||||
import { GenerateSingle } from './pages/GenerateSingle'
|
import { GenerateSingle } from './pages/GenerateSingle'
|
||||||
import { OutputReview } from './pages/OutputReview'
|
import { OutputReview } from './pages/OutputReview'
|
||||||
|
import { PromptEditor } from './pages/PromptEditor'
|
||||||
import { Settings } from './pages/Settings'
|
import { Settings } from './pages/Settings'
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
@@ -27,6 +28,7 @@ function App() {
|
|||||||
<Route path="/genera" element={<GenerateCalendar />} />
|
<Route path="/genera" element={<GenerateCalendar />} />
|
||||||
<Route path="/genera-singolo" element={<GenerateSingle />} />
|
<Route path="/genera-singolo" element={<GenerateSingle />} />
|
||||||
<Route path="/risultati/:jobId" element={<OutputReview />} />
|
<Route path="/risultati/:jobId" element={<OutputReview />} />
|
||||||
|
<Route path="/prompt-editor" element={<PromptEditor />} />
|
||||||
<Route path="/impostazioni" element={<Settings />} />
|
<Route path="/impostazioni" element={<Settings />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import type {
|
|||||||
GenerateResponse,
|
GenerateResponse,
|
||||||
JobStatus,
|
JobStatus,
|
||||||
PostResult,
|
PostResult,
|
||||||
|
PromptDetail,
|
||||||
|
PromptListResponse,
|
||||||
Settings,
|
Settings,
|
||||||
SettingsStatus,
|
SettingsStatus,
|
||||||
} from '../types'
|
} 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.
|
* 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'
|
import { NavLink } from 'react-router-dom'
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
@@ -31,6 +31,11 @@ const navItems: NavItem[] = [
|
|||||||
label: 'Genera Singolo Post',
|
label: 'Genera Singolo Post',
|
||||||
icon: <FileText size={18} />,
|
icon: <FileText size={18} />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
to: '/prompt-editor',
|
||||||
|
label: 'Prompt Editor',
|
||||||
|
icon: <Pencil size={18} />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
to: '/impostazioni',
|
to: '/impostazioni',
|
||||||
label: '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 {
|
export interface FormatsResponse {
|
||||||
[formato: string]: string
|
[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