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:
|
* Flusso:
|
||||||
* 1. Utente compila form (obiettivo + settimane)
|
* 1. Utente compila form (obiettivo + settimane)
|
||||||
* 2. Submit chiama POST /api/generate/bulk → ritorna {job_id} (202 Accepted)
|
* 2. Opzionale: seleziona topic override dallo Swipe File per slot specifici
|
||||||
* 3. Mostra ProgressIndicator con polling ogni 2s su job_id
|
* 3. Submit chiama POST /api/generate/bulk → ritorna {job_id} (202 Accepted)
|
||||||
* 4. Quando il job completa → navigate a /risultati/:jobId
|
* 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.
|
* 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 { useState } from 'react'
|
||||||
import { Link, useNavigate } from 'react-router-dom'
|
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 { 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() {
|
export function GenerateCalendar() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { data: status } = useSettingsStatus()
|
const { data: status } = useSettingsStatus()
|
||||||
const { data: settings } = useSettings()
|
const { data: settings } = useSettings()
|
||||||
|
const { data: swipeData } = useSwipeItems()
|
||||||
const generateMutation = useGenerateCalendar()
|
const generateMutation = useGenerateCalendar()
|
||||||
|
const markUsed = useMarkSwipeUsed()
|
||||||
|
|
||||||
const apiKeyOk = status?.api_key_configured ?? false
|
const apiKeyOk = status?.api_key_configured ?? false
|
||||||
|
const swipeItems = swipeData?.items ?? []
|
||||||
|
|
||||||
// Stato del form
|
// Stato del form
|
||||||
const [obiettivo, setObiettivo] = useState('')
|
const [obiettivo, setObiettivo] = useState('')
|
||||||
@@ -32,9 +129,43 @@ export function GenerateCalendar() {
|
|||||||
const [tono, setTono] = useState('')
|
const [tono, setTono] = useState('')
|
||||||
const [customNicchie, setCustomNicchie] = useState(false)
|
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
|
// Job ID quando la generazione parte
|
||||||
const [jobId, setJobId] = useState<string | null>(null)
|
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) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!apiKeyOk || !obiettivo.trim()) return
|
if (!apiKeyOk || !obiettivo.trim()) return
|
||||||
@@ -44,6 +175,12 @@ export function GenerateCalendar() {
|
|||||||
settimane,
|
settimane,
|
||||||
frequenza_post: settings?.frequenza_post ?? 3,
|
frequenza_post: settings?.frequenza_post ?? 3,
|
||||||
nicchie: customNicchie ? settings?.nicchie_attive : undefined,
|
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 {
|
try {
|
||||||
@@ -186,6 +323,85 @@ export function GenerateCalendar() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 */}
|
{/* Nicchie */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="flex items-center gap-2 cursor-pointer">
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export interface CalendarRequest {
|
|||||||
nicchie?: string[] | null
|
nicchie?: string[] | null
|
||||||
frequenza_post?: number
|
frequenza_post?: number
|
||||||
data_inizio?: string | null
|
data_inizio?: string | null
|
||||||
|
topic_overrides?: Record<number, string> | null // slot_index -> topic
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CalendarResponse {
|
export interface CalendarResponse {
|
||||||
|
|||||||
Reference in New Issue
Block a user