feat(01-04): SlideViewer con edit inline e ProgressIndicator con polling
- SlideViewer.tsx: navigazione frecce sinistra/destra tra 8 slide (Cover + 6 centrali + CTA) - EditableField component: click-to-edit per ogni campo (input/textarea) - Dot indicators + keyboard navigation (frecce sinistra/destra) - Caption Instagram editabile con contatore caratteri - Callback onEdit propaga modifiche al parent - ProgressIndicator.tsx: polling real-time via useJobStatus(jobId) - Barra progresso visuale con percentuale - Lista post con icone stato: pending/running/success/failed - onComplete(jobId) chiamato quando status diventa 'completed' - Poll ogni 2s (condizionale nel hook, si ferma quando completed/failed)
This commit is contained in:
153
frontend/src/components/ProgressIndicator.tsx
Normal file
153
frontend/src/components/ProgressIndicator.tsx
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
/**
|
||||||
|
* ProgressIndicator — mostra il progresso real-time di un job di generazione.
|
||||||
|
*
|
||||||
|
* Usa useJobStatus(jobId) con polling ogni 2 secondi finché status='running'.
|
||||||
|
* Il polling si ferma automaticamente quando status diventa 'completed' o 'failed'.
|
||||||
|
*
|
||||||
|
* Quando il job completa, chiama onComplete(jobId) per navigare a OutputReview.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { AlertTriangle, Check, Loader2, X } from 'lucide-react'
|
||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { useJobStatus } from '../api/hooks'
|
||||||
|
import type { PostResult } from '../types'
|
||||||
|
|
||||||
|
interface ProgressIndicatorProps {
|
||||||
|
jobId: string
|
||||||
|
onComplete: (jobId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProgressIndicator({ jobId, onComplete }: ProgressIndicatorProps) {
|
||||||
|
const { data: jobStatus, error } = useJobStatus(jobId)
|
||||||
|
const onCompleteRef = useRef(onComplete)
|
||||||
|
onCompleteRef.current = onComplete
|
||||||
|
|
||||||
|
// Quando il job diventa completed, chiama onComplete
|
||||||
|
useEffect(() => {
|
||||||
|
if (jobStatus?.status === 'completed') {
|
||||||
|
onCompleteRef.current(jobId)
|
||||||
|
}
|
||||||
|
}, [jobId, jobStatus?.status])
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 px-4 py-3 rounded-lg bg-red-500/10 border border-red-500/30">
|
||||||
|
<AlertTriangle size={16} className="text-red-400" />
|
||||||
|
<p className="text-sm text-red-400">Errore polling: {error.message}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!jobStatus) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 text-stone-400 text-sm">
|
||||||
|
<Loader2 size={16} className="animate-spin" />
|
||||||
|
Connessione al server...
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { total, completed, status, results } = jobStatus
|
||||||
|
const pct = total > 0 ? Math.round((completed / total) * 100) : 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Header con stato */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{status === 'running' && (
|
||||||
|
<Loader2 size={16} className="animate-spin text-amber-400" />
|
||||||
|
)}
|
||||||
|
{status === 'completed' && (
|
||||||
|
<Check size={16} className="text-emerald-400" />
|
||||||
|
)}
|
||||||
|
{status === 'failed' && (
|
||||||
|
<X size={16} className="text-red-400" />
|
||||||
|
)}
|
||||||
|
<span className="text-sm font-medium text-stone-200">
|
||||||
|
{status === 'running' && `Post ${completed} / ${total} in generazione...`}
|
||||||
|
{status === 'completed' && `Generazione completata — ${completed} / ${total} post`}
|
||||||
|
{status === 'failed' && 'Generazione fallita'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-mono text-stone-500">{pct}%</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Barra di progresso */}
|
||||||
|
<div className="w-full h-2 bg-stone-800 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
'h-full rounded-full transition-all duration-500',
|
||||||
|
status === 'failed' ? 'bg-red-500' : 'bg-amber-500',
|
||||||
|
].join(' ')}
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Lista post con stato individuale */}
|
||||||
|
{results.length > 0 && (
|
||||||
|
<div className="space-y-1.5 max-h-64 overflow-y-auto pr-1">
|
||||||
|
{/* Rende i risultati ricevuti finora */}
|
||||||
|
{results.map((r: PostResult) => (
|
||||||
|
<PostStatusRow key={r.slot_index} result={r} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Slot ancora in attesa (non ancora nei risultati) */}
|
||||||
|
{Array.from({ length: total - results.length }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={`pending-${i}`}
|
||||||
|
className="flex items-center gap-2 px-2 py-1.5 rounded text-xs text-stone-600"
|
||||||
|
>
|
||||||
|
<div className="w-3 h-3 rounded-full border border-stone-700 flex-shrink-0" />
|
||||||
|
Post {results.length + i + 1} — in attesa
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Messaggio quando nessun risultato ancora */}
|
||||||
|
{results.length === 0 && status === 'running' && (
|
||||||
|
<p className="text-xs text-stone-600 text-center py-2">
|
||||||
|
Generazione in corso — i risultati appariranno qui...
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Singola riga stato post
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function PostStatusRow({ result }: { result: PostResult }) {
|
||||||
|
const { slot_index, status, post, error } = result
|
||||||
|
|
||||||
|
const icon = {
|
||||||
|
success: <Check size={12} className="text-emerald-400" />,
|
||||||
|
failed: <X size={12} className="text-red-400" />,
|
||||||
|
pending: <Loader2 size={12} className="animate-spin text-stone-500" />,
|
||||||
|
}[status]
|
||||||
|
|
||||||
|
const title = post?.cover_title ?? (error ? 'Errore' : `Post ${slot_index + 1}`)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-2 px-2 py-1.5 rounded text-xs">
|
||||||
|
<span className="flex-shrink-0 mt-0.5">{icon}</span>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
status === 'success'
|
||||||
|
? 'text-stone-300'
|
||||||
|
: status === 'failed'
|
||||||
|
? 'text-red-400'
|
||||||
|
: 'text-stone-600'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Post {slot_index + 1}
|
||||||
|
{title && title !== `Post ${slot_index + 1}` && (
|
||||||
|
<> — <span className="text-stone-400">{title}</span></>
|
||||||
|
)}
|
||||||
|
{error && <span className="text-red-500"> ({error.slice(0, 60)}...)</span>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,20 +1,371 @@
|
|||||||
/**
|
/**
|
||||||
* SlideViewer — stub per Task 2a.
|
* SlideViewer — visualizzazione slide-by-slide con navigazione frecce e edit inline.
|
||||||
* Completato in Task 2b.
|
*
|
||||||
|
* Struttura carosello:
|
||||||
|
* Slide 0 = Cover (cover_title, cover_subtitle, cover_image_keyword)
|
||||||
|
* Slide 1-6 = Slide centrali s2-s7 (headline, body, image_keyword)
|
||||||
|
* Slide 7 = CTA (cta_text, cta_subtext, cta_image_keyword)
|
||||||
|
* + Caption Instagram editabile
|
||||||
|
*
|
||||||
|
* Ogni campo testo è editabile inline: click trasforma in input/textarea.
|
||||||
|
* Le modifiche aggiornano lo stato locale e propagano via callback onEdit.
|
||||||
|
* Keyboard navigation: frecce sinistra/destra.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { PostResult } from '../types'
|
import { ChevronLeft, ChevronRight, Edit3, Image } from 'lucide-react'
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import type { GeneratedPost, PostResult, SlideContent } from '../types'
|
||||||
|
|
||||||
|
// Numero totale di slide: Cover + 6 centrali + CTA = 8
|
||||||
|
const TOTAL_SLIDES = 8
|
||||||
|
|
||||||
interface SlideViewerProps {
|
interface SlideViewerProps {
|
||||||
result: PostResult
|
result: PostResult
|
||||||
onEdit: (updated: PostResult) => void
|
onEdit: (updated: PostResult) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SlideViewer({ result, onEdit: _onEdit }: SlideViewerProps) {
|
// ---------------------------------------------------------------------------
|
||||||
if (!result.post) return null
|
// Componente campo editabile inline
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface EditableFieldProps {
|
||||||
|
value: string
|
||||||
|
multiline?: boolean
|
||||||
|
className?: string
|
||||||
|
placeholder?: string
|
||||||
|
onChange: (v: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditableField({ value, multiline = false, className = '', placeholder, onChange }: EditableFieldProps) {
|
||||||
|
const [editing, setEditing] = useState(false)
|
||||||
|
const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editing && inputRef.current) {
|
||||||
|
inputRef.current.focus()
|
||||||
|
if ('select' in inputRef.current) {
|
||||||
|
inputRef.current.select()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [editing])
|
||||||
|
|
||||||
|
function handleBlur() {
|
||||||
|
setEditing(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(e: React.KeyboardEvent) {
|
||||||
|
if (!multiline && e.key === 'Enter') {
|
||||||
|
setEditing(false)
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setEditing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseClasses = 'w-full bg-transparent outline-none border-0 resize-none'
|
||||||
|
const editClasses = 'ring-1 ring-amber-500/50 rounded px-1.5 py-1 bg-stone-700/50'
|
||||||
|
|
||||||
|
if (editing) {
|
||||||
|
if (multiline) {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
ref={inputRef as React.RefObject<HTMLTextAreaElement>}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
rows={4}
|
||||||
|
className={[baseClasses, editClasses, className].join(' ')}
|
||||||
|
placeholder={placeholder}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
ref={inputRef as React.RefObject<HTMLInputElement>}
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
className={[baseClasses, editClasses, className].join(' ')}
|
||||||
|
placeholder={placeholder}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="text-sm text-stone-400 italic">
|
<span
|
||||||
Slide viewer — {result.post.slides.length} slide
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => setEditing(true)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && setEditing(true)}
|
||||||
|
title="Click per modificare"
|
||||||
|
className={[
|
||||||
|
'cursor-text hover:bg-stone-700/30 rounded px-1 -mx-1 transition-colors group relative',
|
||||||
|
className,
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{value || <span className="text-stone-600">{placeholder}</span>}
|
||||||
|
<Edit3
|
||||||
|
size={10}
|
||||||
|
className="inline-block ml-1 text-stone-600 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// SlideViewer
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function SlideViewer({ result, onEdit }: SlideViewerProps) {
|
||||||
|
const [currentSlide, setCurrentSlide] = useState(0)
|
||||||
|
// Copia locale del post per edit inline
|
||||||
|
const [post, setPost] = useState<GeneratedPost>(() => ({ ...result.post! }))
|
||||||
|
|
||||||
|
// Sync se il post esterno cambia (es. dopo rigenerazione)
|
||||||
|
useEffect(() => {
|
||||||
|
if (result.post) {
|
||||||
|
setPost({ ...result.post })
|
||||||
|
}
|
||||||
|
}, [result.post])
|
||||||
|
|
||||||
|
if (!result.post) return null
|
||||||
|
|
||||||
|
// Helper per aggiornare il post e propagare
|
||||||
|
function updatePost(updater: (prev: GeneratedPost) => GeneratedPost) {
|
||||||
|
setPost((prev) => {
|
||||||
|
const next = updater(prev)
|
||||||
|
// Propaga le modifiche al parent
|
||||||
|
onEdit({ ...result, post: next })
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aggiorna campo cover
|
||||||
|
function updateCover(field: keyof GeneratedPost, value: string) {
|
||||||
|
updatePost((p) => ({ ...p, [field]: value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aggiorna campo slide centrale (indice 0-based tra le 6 slide)
|
||||||
|
function updateSlide(slideIdx: number, field: keyof SlideContent, value: string) {
|
||||||
|
updatePost((p) => {
|
||||||
|
const slides = [...p.slides]
|
||||||
|
slides[slideIdx] = { ...slides[slideIdx], [field]: value }
|
||||||
|
return { ...p, slides }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigazione
|
||||||
|
const goNext = useCallback(() => setCurrentSlide((n) => Math.min(n + 1, TOTAL_SLIDES - 1)), [])
|
||||||
|
const goPrev = useCallback(() => setCurrentSlide((n) => Math.max(n - 1, 0)), [])
|
||||||
|
|
||||||
|
// Keyboard navigation
|
||||||
|
useEffect(() => {
|
||||||
|
function handler(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'ArrowRight') goNext()
|
||||||
|
if (e.key === 'ArrowLeft') goPrev()
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', handler)
|
||||||
|
return () => window.removeEventListener('keydown', handler)
|
||||||
|
}, [goNext, goPrev])
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Render slide corrente
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function renderSlideContent() {
|
||||||
|
// Slide 0 = Cover
|
||||||
|
if (currentSlide === 0) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-stone-500 mb-1">Titolo cover</p>
|
||||||
|
<p className="text-base font-bold text-stone-100">
|
||||||
|
<EditableField
|
||||||
|
value={post.cover_title}
|
||||||
|
onChange={(v) => updateCover('cover_title', v)}
|
||||||
|
placeholder="Titolo cover"
|
||||||
|
className="text-base font-bold"
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-stone-500 mb-1">Sottotitolo</p>
|
||||||
|
<p className="text-sm text-stone-300">
|
||||||
|
<EditableField
|
||||||
|
value={post.cover_subtitle}
|
||||||
|
onChange={(v) => updateCover('cover_subtitle', v)}
|
||||||
|
placeholder="Sottotitolo cover"
|
||||||
|
className="text-sm"
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-stone-500">
|
||||||
|
<Image size={12} />
|
||||||
|
<EditableField
|
||||||
|
value={post.cover_image_keyword}
|
||||||
|
onChange={(v) => updateCover('cover_image_keyword', v)}
|
||||||
|
placeholder="keyword immagine"
|
||||||
|
className="text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slide 7 = CTA
|
||||||
|
if (currentSlide === 7) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-stone-500 mb-1">CTA principale</p>
|
||||||
|
<p className="text-base font-bold text-amber-300">
|
||||||
|
<EditableField
|
||||||
|
value={post.cta_text}
|
||||||
|
onChange={(v) => updateCover('cta_text', v)}
|
||||||
|
placeholder="Testo CTA"
|
||||||
|
className="text-base font-bold text-amber-300"
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-stone-500 mb-1">Testo supporto</p>
|
||||||
|
<p className="text-sm text-stone-300">
|
||||||
|
<EditableField
|
||||||
|
value={post.cta_subtext}
|
||||||
|
onChange={(v) => updateCover('cta_subtext', v)}
|
||||||
|
placeholder="Testo di supporto CTA"
|
||||||
|
multiline
|
||||||
|
className="text-sm"
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-stone-500">
|
||||||
|
<Image size={12} />
|
||||||
|
<EditableField
|
||||||
|
value={post.cta_image_keyword}
|
||||||
|
onChange={(v) => updateCover('cta_image_keyword', v)}
|
||||||
|
placeholder="keyword immagine CTA"
|
||||||
|
className="text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slide 1-6 = slide centrali (indice 0-5 in post.slides)
|
||||||
|
const slideIdx = currentSlide - 1
|
||||||
|
const slide = post.slides[slideIdx]
|
||||||
|
if (!slide) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-stone-500 mb-1">Headline</p>
|
||||||
|
<p className="text-base font-semibold text-stone-100">
|
||||||
|
<EditableField
|
||||||
|
value={slide.headline}
|
||||||
|
onChange={(v) => updateSlide(slideIdx, 'headline', v)}
|
||||||
|
placeholder="Headline slide"
|
||||||
|
className="text-base font-semibold"
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-stone-500 mb-1">Body</p>
|
||||||
|
<p className="text-sm text-stone-300 leading-relaxed">
|
||||||
|
<EditableField
|
||||||
|
value={slide.body}
|
||||||
|
onChange={(v) => updateSlide(slideIdx, 'body', v)}
|
||||||
|
placeholder="Testo corpo slide"
|
||||||
|
multiline
|
||||||
|
className="text-sm leading-relaxed"
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-stone-500">
|
||||||
|
<Image size={12} />
|
||||||
|
<EditableField
|
||||||
|
value={slide.image_keyword}
|
||||||
|
onChange={(v) => updateSlide(slideIdx, 'image_keyword', v)}
|
||||||
|
placeholder="keyword immagine"
|
||||||
|
className="text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Label per la slide corrente
|
||||||
|
function slideLabel() {
|
||||||
|
if (currentSlide === 0) return 'Cover'
|
||||||
|
if (currentSlide === 7) return 'CTA'
|
||||||
|
return `Slide ${currentSlide + 1} / ${TOTAL_SLIDES}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Slide viewer con navigazione */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Freccia sinistra */}
|
||||||
|
<button
|
||||||
|
onClick={goPrev}
|
||||||
|
disabled={currentSlide === 0}
|
||||||
|
className="flex-shrink-0 w-8 h-8 rounded-lg flex items-center justify-center border border-stone-700 text-stone-400 hover:text-stone-200 hover:border-stone-600 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={16} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Area slide */}
|
||||||
|
<div className="flex-1 min-h-[140px] rounded-lg bg-stone-900 border border-stone-700 px-4 py-3">
|
||||||
|
{/* Indicatore slide */}
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-xs font-medium text-stone-500">{slideLabel()}</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{Array.from({ length: TOTAL_SLIDES }).map((_, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => setCurrentSlide(i)}
|
||||||
|
className={[
|
||||||
|
'w-1.5 h-1.5 rounded-full transition-colors',
|
||||||
|
i === currentSlide ? 'bg-amber-400' : 'bg-stone-700 hover:bg-stone-600',
|
||||||
|
].join(' ')}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{renderSlideContent()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Freccia destra */}
|
||||||
|
<button
|
||||||
|
onClick={goNext}
|
||||||
|
disabled={currentSlide === TOTAL_SLIDES - 1}
|
||||||
|
className="flex-shrink-0 w-8 h-8 rounded-lg flex items-center justify-center border border-stone-700 text-stone-400 hover:text-stone-200 hover:border-stone-600 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronRight size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Caption Instagram */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<p className="text-xs font-medium text-stone-500">Caption Instagram</p>
|
||||||
|
<textarea
|
||||||
|
value={post.caption_instagram}
|
||||||
|
onChange={(e) =>
|
||||||
|
updatePost((p) => ({ ...p, caption_instagram: e.target.value }))
|
||||||
|
}
|
||||||
|
rows={5}
|
||||||
|
placeholder="Caption Instagram..."
|
||||||
|
className="w-full px-3 py-2 rounded-lg bg-stone-900 border border-stone-700 text-stone-300 text-sm resize-none focus:outline-none focus:ring-1 focus:ring-amber-500/50 focus:border-amber-500/50"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-stone-600">
|
||||||
|
{post.caption_instagram.length}/2200 caratteri
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user