feat(03-02): picker Swipe File nel form Genera Calendario + mark-used

- CalendarRequest in types.ts: aggiunto topic_overrides?: Record<number, string>
- hooks.ts: aggiunto useMarkSwipeUsed hook (POST /swipe/{id}/mark-used)
- GenerateCalendar.tsx: sezione Topic Override con griglia 13 slot
  - Bottone "Da Swipe File" per aprire picker inline per ogni slot
  - Picker mostra lista idee con nicchia badge e badge Usato
  - Selezione assegna topic allo slot e chiama mark-used
  - Bottone X per rimuovere override da uno slot
  - Override inclusi in CalendarRequest.topic_overrides al submit
  - Riepilogo counter override selezionati
This commit is contained in:
Michele
2026-03-09 00:33:00 +01:00
parent 67769dd68d
commit f449d945e9
3 changed files with 234 additions and 6 deletions

View File

@@ -284,3 +284,14 @@ export function useDeleteSwipeItem() {
},
})
}
/** Marca un'idea dello Swipe File come "usata". */
export function useMarkSwipeUsed() {
const queryClient = useQueryClient()
return useMutation<SwipeItem, Error, string>({
mutationFn: (id) => apiPost<SwipeItem>(`/swipe/${id}/mark-used`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['swipe'] })
},
})
}

View File

@@ -3,27 +3,124 @@
*
* Flusso:
* 1. Utente compila form (obiettivo + settimane)
* 2. Submit chiama POST /api/generate/bulk → ritorna {job_id} (202 Accepted)
* 3. Mostra ProgressIndicator con polling ogni 2s su job_id
* 4. Quando il job completa → navigate a /risultati/:jobId
* 2. Opzionale: seleziona topic override dallo Swipe File per slot specifici
* 3. Submit chiama POST /api/generate/bulk → ritorna {job_id} (202 Accepted)
* 4. Mostra ProgressIndicator con polling ogni 2s su job_id
* 5. Quando il job completa → navigate a /risultati/:jobId
*
* Il pulsante Genera è disabilitato se API key non configurata.
*/
import { AlertTriangle, Loader2 } from 'lucide-react'
import { AlertTriangle, Lightbulb, Loader2, X } from 'lucide-react'
import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useGenerateCalendar, useSettings, useSettingsStatus } from '../api/hooks'
import {
useGenerateCalendar,
useMarkSwipeUsed,
useSettings,
useSettingsStatus,
useSwipeItems,
} from '../api/hooks'
import { ProgressIndicator } from '../components/ProgressIndicator'
import type { CalendarRequest } from '../types'
import type { CalendarRequest, SwipeItem } from '../types'
// ---------------------------------------------------------------------------
// Tipi locali
// ---------------------------------------------------------------------------
interface SlotOverride {
topic: string
swipeId: string
}
// ---------------------------------------------------------------------------
// Componente picker inline Swipe File
// ---------------------------------------------------------------------------
interface SwipePickerProps {
slotIndex: number
items: SwipeItem[]
onSelect: (item: SwipeItem) => void
onClose: () => void
}
function SwipePicker({ slotIndex, items, onSelect, onClose }: SwipePickerProps) {
return (
<div className="mt-2 rounded-lg border border-stone-600 bg-stone-900 shadow-xl overflow-hidden">
<div className="flex items-center justify-between px-3 py-2 border-b border-stone-700">
<span className="text-xs font-medium text-stone-300">
Slot {slotIndex + 1} Seleziona dal tuo Swipe File
</span>
<button
type="button"
onClick={onClose}
className="text-stone-500 hover:text-stone-300 transition-colors"
>
<X size={14} />
</button>
</div>
{items.length === 0 ? (
<div className="px-4 py-5 text-center">
<Lightbulb size={20} className="text-stone-600 mx-auto mb-2" />
<p className="text-xs text-stone-500">Nessuna idea nel tuo Swipe File.</p>
<Link
to="/swipe-file"
className="text-xs text-amber-400 underline underline-offset-2 mt-1 inline-block"
>
Aggiungi idee allo Swipe File
</Link>
</div>
) : (
<div className="max-h-56 overflow-y-auto divide-y divide-stone-800">
{items.map((item) => (
<button
key={item.id}
type="button"
onClick={() => onSelect(item)}
className="w-full text-left px-3 py-2.5 hover:bg-stone-800 transition-colors group"
>
<div className="flex items-start gap-2">
<div className="flex-1 min-w-0">
<p className="text-xs text-stone-200 truncate group-hover:text-stone-100">
{item.topic}
</p>
<div className="flex items-center gap-2 mt-0.5">
{item.nicchia && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-stone-700 text-stone-400 border border-stone-600">
{item.nicchia}
</span>
)}
{item.used && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-amber-500/20 text-amber-400 border border-amber-500/30">
Usato
</span>
)}
</div>
</div>
</div>
</button>
))}
</div>
)}
</div>
)
}
// ---------------------------------------------------------------------------
// Componente principale
// ---------------------------------------------------------------------------
export function GenerateCalendar() {
const navigate = useNavigate()
const { data: status } = useSettingsStatus()
const { data: settings } = useSettings()
const { data: swipeData } = useSwipeItems()
const generateMutation = useGenerateCalendar()
const markUsed = useMarkSwipeUsed()
const apiKeyOk = status?.api_key_configured ?? false
const swipeItems = swipeData?.items ?? []
// Stato del form
const [obiettivo, setObiettivo] = useState('')
@@ -32,9 +129,43 @@ export function GenerateCalendar() {
const [tono, setTono] = useState('')
const [customNicchie, setCustomNicchie] = useState(false)
// Stato topic override
const [topicOverrides, setTopicOverrides] = useState<Record<number, SlotOverride>>({})
const [showSwipePicker, setShowSwipePicker] = useState<number | null>(null)
// Job ID quando la generazione parte
const [jobId, setJobId] = useState<string | null>(null)
// ---------------------------------------------------------------------------
// Handlers override
// ---------------------------------------------------------------------------
function handleSelectSwipeItem(slotIndex: number, item: SwipeItem) {
setTopicOverrides((prev) => ({
...prev,
[slotIndex]: { topic: item.topic, swipeId: item.id },
}))
setShowSwipePicker(null)
// Marca come usata (fire and forget)
markUsed.mutate(item.id)
}
function handleRemoveOverride(slotIndex: number) {
setTopicOverrides((prev) => {
const next = { ...prev }
delete next[slotIndex]
return next
})
}
function handleOpenPicker(slotIndex: number) {
setShowSwipePicker((prev) => (prev === slotIndex ? null : slotIndex))
}
// ---------------------------------------------------------------------------
// Submit
// ---------------------------------------------------------------------------
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!apiKeyOk || !obiettivo.trim()) return
@@ -44,6 +175,12 @@ export function GenerateCalendar() {
settimane,
frequenza_post: settings?.frequenza_post ?? 3,
nicchie: customNicchie ? settings?.nicchie_attive : undefined,
topic_overrides:
Object.keys(topicOverrides).length > 0
? Object.fromEntries(
Object.entries(topicOverrides).map(([k, v]) => [parseInt(k), v.topic])
)
: undefined,
}
try {
@@ -186,6 +323,85 @@ export function GenerateCalendar() {
/>
</div>
{/* Topic Override dallo Swipe File */}
<div className="space-y-2">
<div>
<p className="text-sm font-medium text-stone-300">Topic Override <span className="text-stone-600 font-normal">(opzionale)</span></p>
<p className="text-xs text-stone-500 mt-0.5">
Seleziona topic dallo Swipe File per slot specifici. Gli slot senza override useranno la generazione automatica.
</p>
</div>
{/* Griglia 13 slot */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
{Array.from({ length: 13 }, (_, i) => i).map((slotIndex) => {
const override = topicOverrides[slotIndex]
const isPickerOpen = showSwipePicker === slotIndex
return (
<div key={slotIndex} className="relative">
<div
className={`rounded-lg border px-2.5 py-2 text-xs transition-colors ${
override
? 'border-amber-500/30 bg-amber-500/5'
: 'border-stone-700 bg-stone-800'
}`}
>
<div className="flex items-center justify-between gap-1 mb-1">
<span className="text-stone-500 font-medium">Slot {slotIndex + 1}</span>
{override && (
<button
type="button"
onClick={() => handleRemoveOverride(slotIndex)}
className="text-stone-500 hover:text-red-400 transition-colors flex-shrink-0"
title="Rimuovi override"
>
<X size={12} />
</button>
)}
</div>
{override ? (
<p className="text-stone-300 truncate" title={override.topic}>
{override.topic}
</p>
) : (
<button
type="button"
onClick={() => handleOpenPicker(slotIndex)}
disabled={!apiKeyOk}
className="flex items-center gap-1 text-stone-500 hover:text-amber-400 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<Lightbulb size={11} />
<span>Da Swipe File</span>
</button>
)}
</div>
{/* Picker inline */}
{isPickerOpen && (
<div className="absolute top-full left-0 right-0 z-20 mt-1 min-w-[260px]">
<SwipePicker
slotIndex={slotIndex}
items={swipeItems}
onSelect={(item) => handleSelectSwipeItem(slotIndex, item)}
onClose={() => setShowSwipePicker(null)}
/>
</div>
)}
</div>
)
})}
</div>
{/* Riepilogo override selezionati */}
{Object.keys(topicOverrides).length > 0 && (
<p className="text-xs text-amber-400/80">
{Object.keys(topicOverrides).length} override selezionati — questi slot useranno i topic dello Swipe File.
</p>
)}
</div>
{/* Nicchie */}
<div className="space-y-1.5">
<label className="flex items-center gap-2 cursor-pointer">

View File

@@ -30,6 +30,7 @@ export interface CalendarRequest {
nicchie?: string[] | null
frequenza_post?: number
data_inizio?: string | null
topic_overrides?: Record<number, string> | null // slot_index -> topic
}
export interface CalendarResponse {