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.
+