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:
@@ -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'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user