feat(03-01): pagina SwipeFile UI + TanStack hooks + navigazione

- frontend/src/types.ts: aggiunge SwipeItem, SwipeItemCreate, SwipeItemUpdate, SwipeListResponse
- frontend/src/api/hooks.ts: aggiunge useSwipeItems, useAddSwipeItem, useUpdateSwipeItem, useDeleteSwipeItem
- frontend/src/pages/SwipeFile.tsx: pagina completa con form aggiunta, filtro nicchia, modifica inline, eliminazione con dialog conferma, data relativa
- frontend/src/components/Sidebar.tsx: aggiunge voce "Swipe File" con icona Lightbulb
- frontend/src/App.tsx: registra route /swipe-file
- TypeScript + build Vite: nessun errore
This commit is contained in:
Michele
2026-03-09 00:25:04 +01:00
parent d64c7f4524
commit d379789ec0
5 changed files with 580 additions and 2 deletions

View File

@@ -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() {
<Route path="/genera-singolo" element={<GenerateSingle />} />
<Route path="/risultati/:jobId" element={<OutputReview />} />
<Route path="/prompt-editor" element={<PromptEditor />} />
<Route path="/swipe-file" element={<SwipeFile />} />
<Route path="/impostazioni" element={<Settings />} />
</Routes>
</Layout>

View File

@@ -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<SwipeListResponse>({
queryKey: ['swipe'],
queryFn: () => apiGet<SwipeListResponse>('/swipe/'),
staleTime: 30_000,
})
}
/** Aggiunge una nuova idea allo Swipe File. */
export function useAddSwipeItem() {
const queryClient = useQueryClient()
return useMutation<SwipeItem, Error, SwipeItemCreate>({
mutationFn: (item) => apiPost<SwipeItem>('/swipe/', item),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['swipe'] })
},
})
}
/** Aggiorna una voce esistente dello Swipe File. */
export function useUpdateSwipeItem() {
const queryClient = useQueryClient()
return useMutation<SwipeItem, Error, { id: string; data: SwipeItemUpdate }>({
mutationFn: ({ id, data }) => apiPut<SwipeItem>(`/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'] })
},
})
}

View File

@@ -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: <Pencil size={18} />,
},
{
to: '/swipe-file',
label: 'Swipe File',
icon: <Lightbulb size={18} />,
},
{
to: '/impostazioni',
label: 'Impostazioni',

View File

@@ -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 (
<div className="bg-stone-800 border border-stone-700 rounded-xl p-4 space-y-3">
{isEditing ? (
/* Modalita' modifica inline */
<div className="space-y-3">
<div>
<label className="block text-xs text-stone-400 mb-1">Topic *</label>
<input
type="text"
value={editForm.topic}
onChange={(e) => 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
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs text-stone-400 mb-1">Nicchia</label>
<input
type="text"
value={editForm.nicchia}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-xs text-stone-400 mb-1">Note</label>
<input
type="text"
value={editForm.note}
onChange={(e) => 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"
/>
</div>
</div>
<div className="flex gap-2 pt-1">
<button
onClick={onSaveEdit}
disabled={isSaving || editForm.topic.trim().length < 3}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium bg-amber-500 text-stone-950 hover:bg-amber-400 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<Check size={13} />
{isSaving ? 'Salvataggio...' : 'Salva'}
</button>
<button
onClick={onCancelEdit}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium bg-stone-700 text-stone-300 hover:bg-stone-600 transition-colors"
>
<X size={13} />
Annulla
</button>
</div>
</div>
) : (
/* Modalita' visualizzazione */
<>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-stone-100 leading-snug">{item.topic}</p>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<button
onClick={onStartEdit}
title="Modifica"
className="p-1.5 rounded-lg text-stone-500 hover:text-stone-200 hover:bg-stone-700 transition-colors"
>
<Pencil size={14} />
</button>
<button
onClick={onRequestDelete}
title="Elimina"
className="p-1.5 rounded-lg text-stone-500 hover:text-red-400 hover:bg-red-400/10 transition-colors"
>
<Trash2 size={14} />
</button>
</div>
</div>
{/* Badge nicchia e "Usato" */}
<div className="flex items-center gap-2 flex-wrap">
{item.nicchia && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-stone-700 text-stone-300 border border-stone-600">
{item.nicchia}
</span>
)}
{item.used && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-amber-500/15 text-amber-400 border border-amber-500/30">
<CheckCircle size={11} />
Usato
</span>
)}
</div>
{/* Note */}
{item.note && (
<p className="text-xs text-stone-400 leading-relaxed">{item.note}</p>
)}
{/* Data relativa */}
<p className="text-xs text-stone-600">{relativeTime(item.created_at)}</p>
</>
)}
{/* Dialog conferma eliminazione */}
{deleteConfirm && (
<div className="border-t border-stone-700 pt-3 space-y-2">
<p className="text-xs text-stone-300">
Eliminare questa idea? L'azione e' irreversibile.
</p>
<div className="flex gap-2">
<button
onClick={onConfirmDelete}
disabled={isDeleting}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium bg-red-600 text-white hover:bg-red-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<Trash2 size={12} />
{isDeleting ? 'Eliminazione...' : 'Elimina'}
</button>
<button
onClick={onCancelDelete}
className="px-3 py-1.5 rounded-lg text-xs font-medium bg-stone-700 text-stone-300 hover:bg-stone-600 transition-colors"
>
Annulla
</button>
</div>
</div>
)}
</div>
)
}
// ---------------------------------------------------------------------------
// Pagina principale
// ---------------------------------------------------------------------------
export function SwipeFile() {
// Form aggiunta
const [newItem, setNewItem] = useState({ topic: '', nicchia: '', note: '' })
// Modifica inline
const [editingId, setEditingId] = useState<string | null>(null)
const [editForm, setEditForm] = useState({ topic: '', nicchia: '', note: '' })
// Conferma eliminazione
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null)
// Filtro nicchia
const [filterNicchia, setFilterNicchia] = useState<string | null>(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 (
<div className="max-w-2xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-start justify-between gap-4">
<div>
<div className="flex items-center gap-2 mb-1">
<Lightbulb size={20} className="text-amber-400" />
<h1 className="text-lg font-semibold text-stone-100">Swipe File</h1>
</div>
<p className="text-sm text-stone-400">
Cattura idee e topic interessanti per riusarli nei calendari.
</p>
</div>
<div className="text-right flex-shrink-0">
<p className="text-2xl font-bold text-stone-100">{data?.total ?? 0}</p>
<p className="text-xs text-stone-500">idee salvate</p>
</div>
</div>
{/* Form aggiunta rapida */}
<div className="bg-stone-800 border border-stone-700 rounded-xl p-4">
<h2 className="text-xs font-semibold text-stone-400 uppercase tracking-wide mb-3">
Aggiungi idea
</h2>
<form onSubmit={handleAddSubmit} className="space-y-3">
<input
type="text"
value={newItem.topic}
onChange={(e) => 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"
/>
<div className="grid grid-cols-2 gap-3">
<input
type="text"
value={newItem.nicchia}
onChange={(e) => 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"
/>
<input
type="text"
value={newItem.note}
onChange={(e) => 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"
/>
</div>
<button
type="submit"
disabled={newItem.topic.trim().length < 3 || addMutation.isPending}
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium bg-amber-500 text-stone-950 hover:bg-amber-400 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<Plus size={16} />
{addMutation.isPending ? 'Aggiunta...' : 'Aggiungi'}
</button>
</form>
</div>
{/* Filtro nicchia */}
{uniqueNicchie.length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
<Filter size={14} className="text-stone-500 flex-shrink-0" />
<button
onClick={() => setFilterNicchia(null)}
className={[
'px-3 py-1 rounded-full text-xs font-medium transition-colors',
filterNicchia === null
? 'bg-amber-500 text-stone-950'
: 'bg-stone-800 text-stone-400 border border-stone-700 hover:border-stone-500 hover:text-stone-200',
].join(' ')}
>
Tutte
</button>
{uniqueNicchie.map((nicchia) => (
<button
key={nicchia}
onClick={() => setFilterNicchia(nicchia === filterNicchia ? null : nicchia)}
className={[
'px-3 py-1 rounded-full text-xs font-medium transition-colors',
filterNicchia === nicchia
? 'bg-amber-500 text-stone-950'
: 'bg-stone-800 text-stone-400 border border-stone-700 hover:border-stone-500 hover:text-stone-200',
].join(' ')}
>
{nicchia}
</button>
))}
</div>
)}
{/* Lista idee */}
<div className="space-y-3">
{isLoading && (
<div className="text-center py-8 text-stone-500 text-sm">
Caricamento idee...
</div>
)}
{isError && (
<div className="bg-red-500/10 border border-red-500/30 rounded-xl p-4 text-sm text-red-400">
Errore nel caricamento delle idee. Riprova.
</div>
)}
{!isLoading && !isError && filteredItems.length === 0 && (
<div className="text-center py-12 space-y-3">
<Lightbulb size={32} className="text-stone-700 mx-auto" />
<div>
<p className="text-stone-400 text-sm font-medium">
{filterNicchia
? `Nessuna idea per la nicchia "${filterNicchia}"`
: 'Nessuna idea salvata'}
</p>
{!filterNicchia && (
<p className="text-stone-600 text-xs mt-1">
Usa il form qui sopra per aggiungere la prima idea.
</p>
)}
</div>
</div>
)}
{filteredItems.map((item) => (
<SwipeCard
key={item.id}
item={item}
isEditing={editingId === item.id}
editForm={editForm}
deleteConfirm={deleteConfirmId === item.id}
onStartEdit={() => 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}
/>
))}
</div>
</div>
)
}

View File

@@ -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
}