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:
@@ -7,6 +7,7 @@ import { GenerateSingle } from './pages/GenerateSingle'
|
|||||||
import { OutputReview } from './pages/OutputReview'
|
import { OutputReview } from './pages/OutputReview'
|
||||||
import { PromptEditor } from './pages/PromptEditor'
|
import { PromptEditor } from './pages/PromptEditor'
|
||||||
import { Settings } from './pages/Settings'
|
import { Settings } from './pages/Settings'
|
||||||
|
import { SwipeFile } from './pages/SwipeFile'
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
@@ -29,6 +30,7 @@ function App() {
|
|||||||
<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="/prompt-editor" element={<PromptEditor />} />
|
||||||
|
<Route path="/swipe-file" element={<SwipeFile />} />
|
||||||
<Route path="/impostazioni" element={<Settings />} />
|
<Route path="/impostazioni" element={<Settings />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
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 {
|
import type {
|
||||||
CalendarRequest,
|
CalendarRequest,
|
||||||
CalendarResponse,
|
CalendarResponse,
|
||||||
@@ -23,6 +23,10 @@ import type {
|
|||||||
PromptListResponse,
|
PromptListResponse,
|
||||||
Settings,
|
Settings,
|
||||||
SettingsStatus,
|
SettingsStatus,
|
||||||
|
SwipeItem,
|
||||||
|
SwipeItemCreate,
|
||||||
|
SwipeItemUpdate,
|
||||||
|
SwipeListResponse,
|
||||||
} from '../types'
|
} 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'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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, Pencil, Settings } from 'lucide-react'
|
import { BarChart2, Calendar, FileText, Home, Lightbulb, Pencil, Settings } from 'lucide-react'
|
||||||
import { NavLink } from 'react-router-dom'
|
import { NavLink } from 'react-router-dom'
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
@@ -36,6 +36,11 @@ const navItems: NavItem[] = [
|
|||||||
label: 'Prompt Editor',
|
label: 'Prompt Editor',
|
||||||
icon: <Pencil size={18} />,
|
icon: <Pencil size={18} />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
to: '/swipe-file',
|
||||||
|
label: 'Swipe File',
|
||||||
|
icon: <Lightbulb size={18} />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
to: '/impostazioni',
|
to: '/impostazioni',
|
||||||
label: 'Impostazioni',
|
label: 'Impostazioni',
|
||||||
|
|||||||
490
frontend/src/pages/SwipeFile.tsx
Normal file
490
frontend/src/pages/SwipeFile.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -179,3 +179,34 @@ export interface PromptDetail {
|
|||||||
variables: string[]
|
variables: string[]
|
||||||
modified: boolean
|
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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user