diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d44f839..8a83f5e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,6 +7,7 @@ import { GenerateSingle } from './pages/GenerateSingle' import { OutputReview } from './pages/OutputReview' import { PromptEditor } from './pages/PromptEditor' import { Settings } from './pages/Settings' +import { SwipeFile } from './pages/SwipeFile' const queryClient = new QueryClient({ defaultOptions: { @@ -29,6 +30,7 @@ function App() { } /> } /> } /> + } /> } /> diff --git a/frontend/src/api/hooks.ts b/frontend/src/api/hooks.ts index 48af6ca..af6f47c 100644 --- a/frontend/src/api/hooks.ts +++ b/frontend/src/api/hooks.ts @@ -10,7 +10,7 @@ */ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { apiDownload, apiGet, apiPost, apiPut, triggerDownload } from './client' +import { apiFetch, apiDownload, apiGet, apiPost, apiPut, triggerDownload } from './client' import type { CalendarRequest, CalendarResponse, @@ -23,6 +23,10 @@ import type { PromptListResponse, Settings, SettingsStatus, + SwipeItem, + SwipeItemCreate, + SwipeItemUpdate, + SwipeListResponse, } from '../types' // --------------------------------------------------------------------------- @@ -234,3 +238,49 @@ export function useResetPrompt() { }, }) } + +// --------------------------------------------------------------------------- +// Swipe File +// --------------------------------------------------------------------------- + +/** Lista tutte le idee salvate nello Swipe File (ordine: piu' recenti prima). */ +export function useSwipeItems() { + return useQuery({ + queryKey: ['swipe'], + queryFn: () => apiGet('/swipe/'), + staleTime: 30_000, + }) +} + +/** Aggiunge una nuova idea allo Swipe File. */ +export function useAddSwipeItem() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (item) => apiPost('/swipe/', item), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['swipe'] }) + }, + }) +} + +/** Aggiorna una voce esistente dello Swipe File. */ +export function useUpdateSwipeItem() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: ({ id, data }) => apiPut(`/swipe/${id}`, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['swipe'] }) + }, + }) +} + +/** Elimina una voce dallo Swipe File. */ +export function useDeleteSwipeItem() { + const queryClient = useQueryClient() + return useMutation<{ deleted: boolean }, Error, string>({ + mutationFn: (id) => apiFetch<{ deleted: boolean }>(`/swipe/${id}`, { method: 'DELETE' }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['swipe'] }) + }, + }) +} diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 5c61274..4639238 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, Pencil, Settings } from 'lucide-react' +import { BarChart2, Calendar, FileText, Home, Lightbulb, Pencil, Settings } from 'lucide-react' import { NavLink } from 'react-router-dom' interface NavItem { @@ -36,6 +36,11 @@ const navItems: NavItem[] = [ label: 'Prompt Editor', icon: , }, + { + to: '/swipe-file', + label: 'Swipe File', + icon: , + }, { to: '/impostazioni', label: 'Impostazioni', diff --git a/frontend/src/pages/SwipeFile.tsx b/frontend/src/pages/SwipeFile.tsx new file mode 100644 index 0000000..0e89bfe --- /dev/null +++ b/frontend/src/pages/SwipeFile.tsx @@ -0,0 +1,490 @@ +/** + * Pagina Swipe File — Cattura veloce di idee e topic per content marketing. + * + * Features: + * - Form aggiunta rapida inline (sempre visibile in cima alla lista) + * - Lista idee ordinata cronologicamente (piu' recenti prima) + * - Filtro rapido per nicchia via chip/pill cliccabili + * - Modifica inline: click su Pencil → i campi diventano input, Salva/Annulla + * - Eliminazione con dialog di conferma + * - Badge "Usato" per voci gia' utilizzate in un calendario + * - Data relativa ("3 giorni fa", "2 ore fa") + */ + +import { + Check, + CheckCircle, + Filter, + Lightbulb, + Pencil, + Plus, + Trash2, + X, +} from 'lucide-react' +import { useState } from 'react' +import { + useAddSwipeItem, + useDeleteSwipeItem, + useSwipeItems, + useUpdateSwipeItem, +} from '../api/hooks' +import type { SwipeItem, SwipeItemUpdate } from '../types' + +// --------------------------------------------------------------------------- +// Helper: data relativa +// --------------------------------------------------------------------------- + +function relativeTime(isoDate: string): string { + const now = Date.now() + const then = new Date(isoDate).getTime() + const diffMs = now - then + const diffSec = Math.floor(diffMs / 1000) + const diffMin = Math.floor(diffSec / 60) + const diffHr = Math.floor(diffMin / 60) + const diffDay = Math.floor(diffHr / 24) + const diffWeek = Math.floor(diffDay / 7) + const diffMonth = Math.floor(diffDay / 30) + + if (diffSec < 60) return 'adesso' + if (diffMin < 60) return `${diffMin} min fa` + if (diffHr < 24) return `${diffHr} ${diffHr === 1 ? 'ora' : 'ore'} fa` + if (diffDay < 7) return `${diffDay} ${diffDay === 1 ? 'giorno' : 'giorni'} fa` + if (diffWeek < 5) return `${diffWeek} ${diffWeek === 1 ? 'settimana' : 'settimane'} fa` + return `${diffMonth} ${diffMonth === 1 ? 'mese' : 'mesi'} fa` +} + +// --------------------------------------------------------------------------- +// Sotto-componente: card singola idea +// --------------------------------------------------------------------------- + +interface SwipeCardProps { + item: SwipeItem + isEditing: boolean + editForm: { topic: string; nicchia: string; note: string } + deleteConfirm: boolean + onStartEdit: () => void + onEditFormChange: (field: 'topic' | 'nicchia' | 'note', value: string) => void + onSaveEdit: () => void + onCancelEdit: () => void + onRequestDelete: () => void + onConfirmDelete: () => void + onCancelDelete: () => void + isSaving: boolean + isDeleting: boolean +} + +function SwipeCard({ + item, + isEditing, + editForm, + deleteConfirm, + onStartEdit, + onEditFormChange, + onSaveEdit, + onCancelEdit, + onRequestDelete, + onConfirmDelete, + onCancelDelete, + isSaving, + isDeleting, +}: SwipeCardProps) { + return ( +
+ {isEditing ? ( + /* Modalita' modifica inline */ +
+
+ + onEditFormChange('topic', e.target.value)} + className="w-full bg-stone-900 border border-stone-600 rounded-lg px-3 py-2 text-sm text-stone-100 focus:outline-none focus:ring-2 focus:ring-amber-500/50 focus:border-amber-500/50" + autoFocus + /> +
+
+
+ + onEditFormChange('nicchia', e.target.value)} + placeholder="Es. dentisti" + className="w-full bg-stone-900 border border-stone-600 rounded-lg px-3 py-2 text-sm text-stone-100 focus:outline-none focus:ring-2 focus:ring-amber-500/50 focus:border-amber-500/50" + /> +
+
+ + onEditFormChange('note', e.target.value)} + placeholder="Note aggiuntive..." + className="w-full bg-stone-900 border border-stone-600 rounded-lg px-3 py-2 text-sm text-stone-100 focus:outline-none focus:ring-2 focus:ring-amber-500/50 focus:border-amber-500/50" + /> +
+
+
+ + +
+
+ ) : ( + /* Modalita' visualizzazione */ + <> +
+
+

{item.topic}

+
+
+ + +
+
+ + {/* Badge nicchia e "Usato" */} +
+ {item.nicchia && ( + + {item.nicchia} + + )} + {item.used && ( + + + Usato + + )} +
+ + {/* Note */} + {item.note && ( +

{item.note}

+ )} + + {/* Data relativa */} +

{relativeTime(item.created_at)}

+ + )} + + {/* Dialog conferma eliminazione */} + {deleteConfirm && ( +
+

+ Eliminare questa idea? L'azione e' irreversibile. +

+
+ + +
+
+ )} +
+ ) +} + +// --------------------------------------------------------------------------- +// Pagina principale +// --------------------------------------------------------------------------- + +export function SwipeFile() { + // Form aggiunta + const [newItem, setNewItem] = useState({ topic: '', nicchia: '', note: '' }) + + // Modifica inline + const [editingId, setEditingId] = useState(null) + const [editForm, setEditForm] = useState({ topic: '', nicchia: '', note: '' }) + + // Conferma eliminazione + const [deleteConfirmId, setDeleteConfirmId] = useState(null) + + // Filtro nicchia + const [filterNicchia, setFilterNicchia] = useState(null) + + // API hooks + const { data, isLoading, isError } = useSwipeItems() + const addMutation = useAddSwipeItem() + const updateMutation = useUpdateSwipeItem() + const deleteMutation = useDeleteSwipeItem() + + const items = data?.items ?? [] + + // Nicchie uniche dalle idee (per chip filtro) + const uniqueNicchie = Array.from( + new Set(items.map((i) => i.nicchia).filter(Boolean) as string[]), + ).sort() + + // Lista filtrata + const filteredItems = + filterNicchia === null ? items : items.filter((i) => i.nicchia === filterNicchia) + + // --------------------------------------------------------------------------- + // Handlers: form aggiunta + // --------------------------------------------------------------------------- + + const handleAddSubmit = (e: React.FormEvent) => { + e.preventDefault() + if (newItem.topic.trim().length < 3) return + addMutation.mutate( + { + topic: newItem.topic.trim(), + nicchia: newItem.nicchia.trim() || null, + note: newItem.note.trim() || null, + }, + { + onSuccess: () => { + setNewItem({ topic: '', nicchia: '', note: '' }) + }, + }, + ) + } + + // --------------------------------------------------------------------------- + // Handlers: modifica inline + // --------------------------------------------------------------------------- + + const handleStartEdit = (item: SwipeItem) => { + setEditingId(item.id) + setEditForm({ + topic: item.topic, + nicchia: item.nicchia ?? '', + note: item.note ?? '', + }) + // Chiudi eventuale dialog di conferma aperto su questo item + if (deleteConfirmId === item.id) setDeleteConfirmId(null) + } + + const handleEditFormChange = (field: 'topic' | 'nicchia' | 'note', value: string) => { + setEditForm((prev) => ({ ...prev, [field]: value })) + } + + const handleSaveEdit = (itemId: string) => { + const update: SwipeItemUpdate = { + topic: editForm.topic.trim() || undefined, + nicchia: editForm.nicchia.trim() || null, + note: editForm.note.trim() || null, + } + updateMutation.mutate( + { id: itemId, data: update }, + { + onSuccess: () => { + setEditingId(null) + }, + }, + ) + } + + const handleCancelEdit = () => { + setEditingId(null) + } + + // --------------------------------------------------------------------------- + // Handlers: eliminazione + // --------------------------------------------------------------------------- + + const handleRequestDelete = (itemId: string) => { + setDeleteConfirmId(itemId) + // Se stiamo editando questo item, chiudi la modalita' edit + if (editingId === itemId) setEditingId(null) + } + + const handleConfirmDelete = (itemId: string) => { + deleteMutation.mutate(itemId, { + onSuccess: () => { + setDeleteConfirmId(null) + }, + }) + } + + const handleCancelDelete = () => { + setDeleteConfirmId(null) + } + + // --------------------------------------------------------------------------- + // Render + // --------------------------------------------------------------------------- + + return ( +
+ + {/* Header */} +
+
+
+ +

Swipe File

+
+

+ Cattura idee e topic interessanti per riusarli nei calendari. +

+
+
+

{data?.total ?? 0}

+

idee salvate

+
+
+ + {/* Form aggiunta rapida */} +
+

+ Aggiungi idea +

+
+ setNewItem((prev) => ({ ...prev, topic: e.target.value }))} + placeholder="Scrivi un'idea o topic..." + className="w-full bg-stone-900 border border-stone-600 rounded-lg px-3 py-2.5 text-sm text-stone-100 placeholder-stone-500 focus:outline-none focus:ring-2 focus:ring-amber-500/50 focus:border-amber-500/50" + /> +
+ setNewItem((prev) => ({ ...prev, nicchia: e.target.value }))} + placeholder="Es. dentisti" + className="bg-stone-900 border border-stone-600 rounded-lg px-3 py-2 text-sm text-stone-100 placeholder-stone-500 focus:outline-none focus:ring-2 focus:ring-amber-500/50 focus:border-amber-500/50" + /> + setNewItem((prev) => ({ ...prev, note: e.target.value }))} + placeholder="Note aggiuntive..." + className="bg-stone-900 border border-stone-600 rounded-lg px-3 py-2 text-sm text-stone-100 placeholder-stone-500 focus:outline-none focus:ring-2 focus:ring-amber-500/50 focus:border-amber-500/50" + /> +
+ +
+
+ + {/* Filtro nicchia */} + {uniqueNicchie.length > 0 && ( +
+ + + {uniqueNicchie.map((nicchia) => ( + + ))} +
+ )} + + {/* Lista idee */} +
+ {isLoading && ( +
+ Caricamento idee... +
+ )} + + {isError && ( +
+ Errore nel caricamento delle idee. Riprova. +
+ )} + + {!isLoading && !isError && filteredItems.length === 0 && ( +
+ +
+

+ {filterNicchia + ? `Nessuna idea per la nicchia "${filterNicchia}"` + : 'Nessuna idea salvata'} +

+ {!filterNicchia && ( +

+ Usa il form qui sopra per aggiungere la prima idea. +

+ )} +
+
+ )} + + {filteredItems.map((item) => ( + handleStartEdit(item)} + onEditFormChange={handleEditFormChange} + onSaveEdit={() => handleSaveEdit(item.id)} + onCancelEdit={handleCancelEdit} + onRequestDelete={() => handleRequestDelete(item.id)} + onConfirmDelete={() => handleConfirmDelete(item.id)} + onCancelDelete={handleCancelDelete} + isSaving={updateMutation.isPending} + isDeleting={deleteMutation.isPending} + /> + ))} +
+
+ ) +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 1408fad..717a503 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -179,3 +179,34 @@ export interface PromptDetail { variables: string[] modified: boolean } + +// --------------------------------------------------------------------------- +// Swipe File +// --------------------------------------------------------------------------- + +export interface SwipeItem { + id: string + topic: string + nicchia?: string | null + note?: string | null + created_at: string + updated_at: string + used: boolean +} + +export interface SwipeItemCreate { + topic: string + nicchia?: string | null + note?: string | null +} + +export interface SwipeItemUpdate { + topic?: string | null + nicchia?: string | null + note?: string | null +} + +export interface SwipeListResponse { + items: SwipeItem[] + total: number +}