diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8db44e0..d44f839 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> diff --git a/frontend/src/api/hooks.ts b/frontend/src/api/hooks.ts index 2d9206d..48af6ca 100644 --- a/frontend/src/api/hooks.ts +++ b/frontend/src/api/hooks.ts @@ -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({ + queryKey: ['prompts'], + queryFn: () => apiGet('/prompts'), + staleTime: 30_000, + }) +} + +/** Carica contenuto + variabili di un singolo prompt. */ +export function usePrompt(name: string | null) { + return useQuery({ + queryKey: ['prompts', name], + queryFn: () => apiGet(`/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({ + mutationFn: ({ name, content }) => + apiPut(`/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({ + mutationFn: (name) => apiPost(`/prompts/${name}/reset`), + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: ['prompts'] }) + queryClient.setQueryData(['prompts', data.name], data) + }, + }) +} diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index a46be18..5c61274 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -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: , }, + { + to: '/prompt-editor', + label: 'Prompt Editor', + icon: , + }, { to: '/impostazioni', label: 'Impostazioni', diff --git a/frontend/src/pages/PromptEditor.tsx b/frontend/src/pages/PromptEditor.tsx new file mode 100644 index 0000000..f9b1829 --- /dev/null +++ b/frontend/src/pages/PromptEditor.tsx @@ -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(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() + 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 ( +
+ +
+ ) + } + + return ( +
+ {/* Header */} +
+

Prompt Editor

+

+ Modifica i prompt LLM usati per generare i caroselli. Ogni prompt usa variabili{' '} + {'{{nome}}'}{' '} + sostituite automaticamente durante la generazione. +

+
+ + {/* Layout a due colonne */} +
+ {/* Colonna sinistra: Lista prompt */} +
+
+
+

+ Prompt disponibili +

+
+
+ {promptList?.prompts.map((p) => ( + + ))} +
+
+
+ + {/* Colonna destra: Editor */} +
+ {selectedPrompt ? ( + isLoadingDetail ? ( +
+ +
+ ) : ( + <> + {/* Header editor */} +
+

{selectedPrompt}

+ + {isModified ? 'Modificato' : 'Default'} + + {dirty && ( + + modifiche non salvate + + )} +
+ + {/* Textarea */} +