import { useNavigate } from '@tanstack/react-router' import CSL from 'citeproc' import { Globe, Link as LinkIcon, Loader2, Plus, RefreshCw, X, } from 'lucide-react' import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState, } from 'react' import type { BuscarBibliografiaRequest } from '@/data' import type { EndpointResult, GoogleBooksVolume, OpenLibraryDoc, } from '@/data/api/subjects.api' import type { TablesInsert } from '@/types/supabase' import { defineStepper } from '@/components/stepper' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from '@/components/ui/card' import { Checkbox } from '@/components/ui/checkbox' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select' import { Textarea } from '@/components/ui/textarea' import { Tooltip, TooltipContent, TooltipTrigger, } from '@/components/ui/tooltip' import { WizardLayout } from '@/components/wizard/WizardLayout' import { WizardResponsiveHeader } from '@/components/wizard/WizardResponsiveHeader' import { buscar_bibliografia } from '@/data' import { useCreateBibliografia } from '@/data/hooks/useSubjects' import { cn } from '@/lib/utils' type MetodoBibliografia = 'MANUAL' | 'EN_LINEA' | null export type FormatoCita = 'apa' | 'ieee' | 'vancouver' | 'chicago' type IdiomaBibliografia = | 'ALL' | 'ES' | 'EN' | 'DE' | 'ZH' | 'FR' | 'IT' | 'JA' | 'RU' const IDIOMA_LABEL: Record = { ALL: 'Todos', ES: 'Español', EN: 'Inglés', DE: 'Alemán', ZH: 'Chino', FR: 'Francés', IT: 'Italiano', JA: 'Japonés', RU: 'Ruso', } const IDIOMA_TO_GOOGLE: Record = { ALL: undefined, ES: 'es', EN: 'en', DE: 'de', ZH: 'zh', FR: 'fr', IT: 'it', JA: 'ja', RU: 'ru', } // ISO 639-2 (bibliographic codes) commonly used by Open Library. const IDIOMA_TO_OPEN_LIBRARY: Record = { ALL: undefined, ES: 'spa', EN: 'eng', DE: 'ger', ZH: 'chi', FR: 'fre', IT: 'ita', JA: 'jpn', RU: 'rus', } const MIN_YEAR = 1450 const MAX_YEAR = new Date().getFullYear() + 1 type CSLAuthor = { family: string given: string } type CSLItem = { id: string type: 'book' title: string author: Array publisher?: string issued?: { 'date-parts': Array>; circa?: boolean } status?: string ISBN?: string } type BibliografiaAsignaturaInsert = TablesInsert<'bibliografia_asignatura'> type BibliografiaTipo = BibliografiaAsignaturaInsert['tipo'] type BibliografiaTipoFuente = NonNullable< BibliografiaAsignaturaInsert['tipo_fuente'] > type BibliografiaRef = { id: string source: BibliografiaTipoFuente raw?: GoogleBooksVolume | OpenLibraryDoc title: string subtitle?: string authors: Array publisher?: string year?: number yearIsApproximate?: boolean isInPress?: boolean isbn?: string tipo: BibliografiaTipo } type WizardState = { metodo: MetodoBibliografia ia: { q: string idioma: IdiomaBibliografia showConservacionTooltip: boolean sugerencias: Array<{ id: string selected: boolean endpoint: EndpointResult['endpoint'] item: GoogleBooksVolume | OpenLibraryDoc }> isLoading: boolean errorMessage: string | null } manual: { draft: { title: string authorsText: string publisher: string yearText: string isbn: string } refs: Array } formato: FormatoCita | null refs: Array citaEdits: Record> generatingIds: Set isSaving: boolean errorMessage: string | null } type IASugerencia = WizardState['ia']['sugerencias'][number] function iaSugerenciaToEndpointResult(s: IASugerencia): EndpointResult { return s.endpoint === 'google' ? { endpoint: 'google', item: s.item as GoogleBooksVolume } : { endpoint: 'open_library', item: s.item as OpenLibraryDoc } } const Wizard = defineStepper( { id: 'metodo', title: 'Método', description: 'Manual o Buscar en línea' }, { id: 'paso2', title: 'Datos básicos', description: 'Seleccionar o capturar', }, { id: 'paso3', title: 'Detalles', description: 'Formato y citas' }, { id: 'resumen', title: 'Resumen', description: 'Confirmar' }, ) function parsearAutor(nombreCompleto: string): CSLAuthor { if (nombreCompleto.includes(',')) { return { family: nombreCompleto.split(',')[0]?.trim() ?? '', given: nombreCompleto.split(',')[1]?.trim() ?? '', } } const partes = nombreCompleto.trim().split(/\s+/).filter(Boolean) if (partes.length === 1) return { family: partes[0] ?? '', given: '' } const family = partes.pop() ?? '' const given = partes.join(' ') return { family, given } } function tryParseYear(publishedDate?: string): number | undefined { if (!publishedDate) return undefined const match = String(publishedDate).match(/\d{4}/) if (!match) return undefined const year = Number.parseInt(match[0], 10) return Number.isFinite(year) ? year : undefined } function sanitizeYearInput(value: string): string { return value.replace(/[^\d]/g, '').slice(0, 4) } function tryParseStrictYear(value: string): number | undefined { const cleaned = sanitizeYearInput(value) if (!/^\d{4}$/.test(cleaned)) return undefined const year = Number.parseInt(cleaned, 10) if (!Number.isFinite(year)) return undefined if (year < MIN_YEAR || year > MAX_YEAR) return undefined return year } function randomUUID(): string { try { const c = (globalThis as any).crypto if (c && typeof c.randomUUID === 'function') return c.randomUUID() } catch { // ignore } return `${Date.now()}-${Math.random().toString(16).slice(2)}` } function tryParseYearFromOpenLibrary(doc: OpenLibraryDoc): number | undefined { const y1 = doc['first_publish_year'] if (typeof y1 === 'number' && Number.isFinite(y1)) return y1 const years = doc['publish_year'] if (Array.isArray(years)) { const numeric = years .map((x) => (typeof x === 'number' ? x : Number(x))) .filter((n) => Number.isFinite(n)) if (numeric.length > 0) return Math.max(...numeric) } const published = doc['publish_date'] if (typeof published === 'string') return tryParseYear(published) return undefined } function getEndpointResultId(result: EndpointResult): string { if (result.endpoint === 'google') { return `google:${result.item.id}` } const doc = result.item const key = doc['key'] if (typeof key === 'string' && key.trim()) return `open_library:${key}` const cover = doc['cover_edition_key'] if (typeof cover === 'string' && cover.trim()) return `open_library:${cover}` const editionKey = doc['edition_key'] if (Array.isArray(editionKey) && typeof editionKey[0] === 'string') { return `open_library:${editionKey[0]}` } return `open_library:${randomUUID()}` } function endpointResultToRef(result: EndpointResult): BibliografiaRef { if (result.endpoint === 'google') { const volume = result.item const info = volume.volumeInfo ?? {} const title = (info.title ?? '').trim() || 'Sin título' const subtitle = typeof info.subtitle === 'string' ? info.subtitle.trim() : undefined const authors = Array.isArray(info.authors) ? info.authors : [] const publisher = typeof info.publisher === 'string' ? info.publisher : undefined const year = tryParseYear(info.publishedDate) const isbn = info.industryIdentifiers?.find((x) => x.identifier)?.identifier ?? undefined return { id: getEndpointResultId(result), source: 'BIBLIOTECA', raw: volume, title, subtitle, authors, publisher, year, isbn, tipo: 'BASICA', } } const doc = result.item const title = (typeof doc['title'] === 'string' ? doc['title'] : '').trim() || 'Sin título' const subtitle = typeof doc['subtitle'] === 'string' ? doc['subtitle'].trim() : undefined const authors = Array.isArray(doc['author_name']) ? (doc['author_name'] as Array).filter( (a): a is string => typeof a === 'string', ) : [] const publisher = Array.isArray(doc['publisher']) ? (doc['publisher'] as Array).find( (p): p is string => typeof p === 'string', ) : typeof doc['publisher'] === 'string' ? doc['publisher'] : undefined const year = tryParseYearFromOpenLibrary(doc) const isbn = Array.isArray(doc['isbn']) ? (doc['isbn'] as Array).find( (x): x is string => typeof x === 'string', ) : undefined return { id: getEndpointResultId(result), source: 'BIBLIOTECA', raw: doc, title, subtitle, authors, publisher, year, isbn, tipo: 'BASICA', } } function getResultYear(result: EndpointResult): number | undefined { if (result.endpoint === 'google') { const info = result.item.volumeInfo ?? {} return tryParseYear(info.publishedDate) } return tryParseYearFromOpenLibrary(result.item) } function sortResultsByMostRecent(a: EndpointResult, b: EndpointResult) { const ya = getResultYear(a) const yb = getResultYear(b) if (typeof ya === 'number' && typeof yb === 'number') return yb - ya if (typeof ya === 'number') return -1 if (typeof yb === 'number') return 1 return 0 } function citeprocHtmlToPlainText(value: string) { const input = value if (!input) return '' // citeproc suele devolver HTML + entidades (`&`, `&`, etc.). // Convertimos a texto plano usando el parser del navegador. try { const doc = new DOMParser().parseFromString(input, 'text/html') return (doc.body.textContent || '').replace(/\s+/g, ' ').trim() } catch { // Fallback ultra simple (por si DOMParser no existe en algún entorno). return input .replace(/<[^>]*>/g, ' ') .replace(/&?/g, '&') .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, "'") .replace(/\s+/g, ' ') .trim() } } async function fetchTextCached(url: string, cache: Map) { const cached = cache.get(url) if (cached) return cached const res = await fetch(url) if (!res.ok) throw new Error(`No se pudo cargar recurso: ${url}`) const text = await res.text() // En dev (SPA), una ruta inexistente puede devolver `index.html` con 200. // Eso rompe citeproc con errores poco claros. const trimmed = text.trim().toLowerCase() const looksLikeHtml = trimmed.startsWith(' = { apa: publicUrl('csl/styles/apa.csl'), ieee: publicUrl('csl/styles/ieee.csl'), chicago: publicUrl('csl/styles/chicago-author-date.csl'), vancouver: publicUrl('csl/styles/nlm-citation-sequence.csl'), } const CSL_LOCALE_URL = publicUrl('csl/locales/locales-es-MX.xml') export function NuevaBibliografiaModalContainer({ planId, asignaturaId, }: { planId: string asignaturaId: string }) { const navigate = useNavigate() const createBibliografia = useCreateBibliografia() const formatoStepRef = useRef(null) const [wizard, setWizard] = useState({ metodo: null, ia: { q: '', idioma: 'ALL', showConservacionTooltip: false, sugerencias: [], isLoading: false, errorMessage: null, }, manual: { draft: { title: '', authorsText: '', publisher: '', yearText: '', isbn: '', }, refs: [], }, formato: null, refs: [], citaEdits: { apa: {}, ieee: {}, chicago: {}, vancouver: {}, }, generatingIds: new Set(), isSaving: false, errorMessage: null, }) const styleCacheRef = useRef(new Map()) const localeCacheRef = useRef(new Map()) const titleOverrides = wizard.metodo === 'EN_LINEA' ? { paso2: 'Sugerencias', paso3: 'Estructura' } : { paso2: 'Datos básicos', paso3: 'Detalles' } const handleClose = () => { navigate({ to: `/planes/${planId}/asignaturas/${asignaturaId}/bibliografia/`, resetScroll: false, }) } const refsForStep3: Array = wizard.metodo === 'EN_LINEA' ? wizard.ia.sugerencias .filter((s) => s.selected) .map((s) => endpointResultToRef(iaSugerenciaToEndpointResult(s))) : wizard.manual.refs // Mantener `wizard.refs` como snapshot para pasos 3/4. useEffect(() => { setWizard((w) => ({ ...w, refs: refsForStep3 })) // eslint-disable-next-line react-hooks/exhaustive-deps }, [wizard.metodo, wizard.ia.sugerencias, wizard.manual.refs]) const citationsForFormato = useMemo(() => { if (!wizard.formato) return {} return wizard.citaEdits[wizard.formato] }, [wizard.citaEdits, wizard.formato]) const allCitationsReady = useMemo(() => { if (!wizard.formato) return false if (wizard.refs.length === 0) return false const map = wizard.citaEdits[wizard.formato] return wizard.refs.every( (r) => typeof map[r.id] === 'string' && map[r.id].trim().length > 0, ) }, [wizard.citaEdits, wizard.formato, wizard.refs]) const canContinueDesdeMetodo = wizard.metodo === 'MANUAL' || wizard.metodo === 'EN_LINEA' const canContinueDesdePaso2 = wizard.metodo === 'EN_LINEA' ? wizard.ia.sugerencias.some((s) => s.selected) : wizard.manual.refs.length > 0 const canContinueDesdePaso3 = Boolean(wizard.formato) && allCitationsReady async function handleBuscarSugerencias() { const hadNoSugerenciasBefore = wizard.ia.sugerencias.length === 0 if (wizard.ia.sugerencias.filter((s) => s.selected).length >= 20) return const q = wizard.ia.q.trim() if (!q) return setWizard((w) => ({ ...w, ia: { ...w.ia, // Conservar únicamente las sugerencias seleccionadas. sugerencias: w.ia.sugerencias.filter((s) => s.selected), showConservacionTooltip: false, isLoading: true, errorMessage: null, }, errorMessage: null, })) try { const idioma = wizard.ia.idioma const googleLangRestrict = IDIOMA_TO_GOOGLE[idioma] const openLibraryLanguage = IDIOMA_TO_OPEN_LIBRARY[idioma] const google: BuscarBibliografiaRequest['google'] = { orderBy: 'newest', startIndex: 0, } if (googleLangRestrict) google.langRestrict = googleLangRestrict const openLibrary: BuscarBibliografiaRequest['openLibrary'] = { sort: 'new', page: 1, } if (openLibraryLanguage) openLibrary.language = openLibraryLanguage const req: BuscarBibliografiaRequest = { searchTerms: { q }, google, openLibrary, } const items = (await buscar_bibliografia(req)) .slice() .sort(sortResultsByMostRecent) setWizard((w) => { const existingById = new Map(w.ia.sugerencias.map((s) => [s.id, s])) const newOnes = items .map((r) => ({ id: getEndpointResultId(r), selected: false, endpoint: r.endpoint, item: r.item, })) .filter((it) => !existingById.has(it.id)) const merged = [...w.ia.sugerencias, ...newOnes].slice() merged.sort( (a, b) => sortResultsByMostRecent( iaSugerenciaToEndpointResult(a), iaSugerenciaToEndpointResult(b), ) || a.id.localeCompare(b.id), ) return { ...w, ia: { ...w.ia, sugerencias: merged, showConservacionTooltip: hadNoSugerenciasBefore && newOnes.length > 0, isLoading: false, errorMessage: null, }, } }) } catch (e: any) { setWizard((w) => ({ ...w, ia: { ...w.ia, isLoading: false, errorMessage: typeof e?.message === 'string' ? e.message : 'Error al buscar bibliografía', }, })) } } async function generateCitasForFormato( formato: FormatoCita, refs: Array, options?: { force?: boolean }, ) { const force = Boolean(options?.force) setWizard((w) => { const nextIds = new Set(w.generatingIds) refs.forEach((r) => nextIds.add(r.id)) return { ...w, generatingIds: nextIds, } }) try { const xmlStyle = await fetchTextCached( CSL_STYLE_URL[formato], styleCacheRef.current, ) const xmlLocale = await fetchTextCached( CSL_LOCALE_URL, localeCacheRef.current, ) const cslItems: Record = {} for (const r of refs) { const trimmedTitle = r.title.trim() cslItems[r.id] = { id: r.id, type: 'book', title: trimmedTitle || 'Sin título', author: r.authors.map(parsearAutor), publisher: r.publisher, issued: r.isInPress || !r.year ? undefined : { 'date-parts': [[r.year]], circa: r.yearIsApproximate ? true : undefined, }, status: r.isInPress ? 'in press' : undefined, ISBN: r.isbn, } } const sys = { retrieveLocale: (_lang: string) => xmlLocale, retrieveItem: (id: string) => cslItems[id], } const engine = new CSL.Engine(sys as any, xmlStyle) engine.updateItems(Object.keys(cslItems)) const result = engine.makeBibliography() // result[0] contiene los metadatos, result[1] las citas formateadas const meta = result?.[0] as | { entry_ids?: Array> } | undefined const entries = (result?.[1] ?? []) as Array const citations: Record = {} // meta.entry_ids es un arreglo de arreglos: [["id-2"], ["id-1"], ...] const sortedIds = meta?.entry_ids ?? [] for (let i = 0; i < entries.length; i++) { const id = sortedIds[i]?.[0] // Sacamos el ID real de esta posición if (!id) continue const cita = citeprocHtmlToPlainText(entries[i] ?? '') citations[id] = cita } setWizard((w) => { const nextEdits = { ...w.citaEdits } const existing = nextEdits[formato] const merged: Record = { ...existing } for (const id of Object.keys(citations)) { merged[id] = force || !merged[id] || merged[id].trim().length === 0 ? (citations[id] ?? '') : merged[id] } nextEdits[formato] = merged const nextIds = new Set(w.generatingIds) refs.forEach((r) => nextIds.delete(r.id)) return { ...w, citaEdits: nextEdits, generatingIds: nextIds, } }) } catch (e: any) { setWizard((w) => { const nextIds = new Set(w.generatingIds) refs.forEach((r) => nextIds.delete(r.id)) return { ...w, generatingIds: nextIds, errorMessage: typeof e?.message === 'string' ? e.message : 'Error al generar citas', } }) } } useEffect(() => { if (!wizard.formato) return if (wizard.refs.length === 0) return const map = wizard.citaEdits[wizard.formato] const missing = wizard.refs.some( (r) => !map[r.id] || map[r.id].trim().length === 0, ) if (!missing) return if (wizard.generatingIds.size > 0) return void generateCitasForFormato(wizard.formato, wizard.refs) // eslint-disable-next-line react-hooks/exhaustive-deps }, [wizard.formato, wizard.refs]) async function handleCreate() { setWizard((w) => ({ ...w, isSaving: true, errorMessage: null })) try { if (!wizard.formato) throw new Error('Selecciona un formato') const map = wizard.citaEdits[wizard.formato] if (wizard.refs.length === 0) throw new Error('No hay referencias') await Promise.all( wizard.refs.map((r) => createBibliografia.mutateAsync({ asignatura_id: asignaturaId, tipo: r.tipo, cita: map[r.id] ?? '', tipo_fuente: r.source, biblioteca_item_id: null, }), ), ) setWizard((w) => ({ ...w, isSaving: false })) handleClose() } catch (e: any) { setWizard((w) => ({ ...w, isSaving: false, errorMessage: typeof e?.message === 'string' ? e.message : 'Error al guardar bibliografía', })) } } return ( {({ methods }) => { const idx = Wizard.utils.getIndex(methods.current.id) const isLast = idx >= Wizard.steps.length - 1 return ( } footerSlot={
{isLast ? ( ) : ( )}
} >
{wizard.errorMessage ? ( {wizard.errorMessage} ) : null} {idx === 0 && ( setWizard((w) => ({ ...w, metodo, formato: null, errorMessage: null, })) } /> )} {idx === 1 && ( {wizard.metodo === 'EN_LINEA' ? ( setWizard((w) => ({ ...w, ia: { ...w.ia, showConservacionTooltip: false }, })) } onChange={(patch) => setWizard((w) => ({ ...w, ia: { ...w.ia, ...patch, }, errorMessage: null, })) } onGenerate={handleBuscarSugerencias} /> ) : ( setWizard((w) => ({ ...w, manual: { ...w.manual, draft }, errorMessage: null, })) } onAddRef={(ref) => setWizard((w) => ({ ...w, manual: { ...w.manual, refs: [...w.manual.refs, ref], }, errorMessage: null, })) } onRemoveRef={(id) => setWizard((w) => ({ ...w, manual: { ...w.manual, refs: w.manual.refs.filter((r) => r.id !== id), }, })) } /> )} )} {idx === 2 && ( { setWizard((w) => ({ ...w, formato, errorMessage: null })) if (formato) { void generateCitasForFormato(formato, wizard.refs) } }} onRegenerate={() => { if (!wizard.formato) return void generateCitasForFormato( wizard.formato, wizard.refs, { force: true, }, ) }} onChangeRef={(id, patch) => setWizard((w) => ({ ...w, refs: w.refs.map((r) => r.id === id ? { ...r, ...patch } : r, ), })) } onChangeTipo={(id, tipo) => setWizard((w) => ({ ...w, refs: w.refs.map((r) => r.id === id ? { ...r, tipo } : r, ), })) } /> )} {idx === 3 && ( )}
) }}
) } function MetodoStep({ metodo, onChange, }: { metodo: MetodoBibliografia onChange: (metodo: MetodoBibliografia) => void }) { const isSelected = (m: Exclude) => metodo === m return (
onChange('MANUAL')} > Manual Captura referencias y edita la cita. onChange('EN_LINEA')} > Buscar en línea Busca sugerencias y selecciona las mejores.
) } function SugerenciasStep({ q, idioma, isLoading, errorMessage, sugerencias, showConservacionTooltip, onDismissConservacionTooltip, onChange, onGenerate, }: { q: string idioma: IdiomaBibliografia isLoading: boolean errorMessage: string | null sugerencias: Array<{ id: string selected: boolean endpoint: EndpointResult['endpoint'] item: GoogleBooksVolume | OpenLibraryDoc }> showConservacionTooltip: boolean onDismissConservacionTooltip: () => void onChange: ( patch: Partial<{ q: string idioma: IdiomaBibliografia sugerencias: any }>, ) => void onGenerate: () => void }) { const selectedCount = sugerencias.filter((s) => s.selected).length return (
Buscar sugerencias Conserva las seleccionadas y agrega nuevas.
onChange({ q: e.target.value.slice(0, 200) })} placeholder="Ej: ingeniería de software, bases de datos..." />
{!isLoading && q.trim().length < 3 ? (

El query debe ser de al menos 3 caracteres

) : ( )}
{errorMessage ? (
{errorMessage}
) : null}

Sugerencias

📌 {selectedCount} seleccionadas
Al generar más sugerencias, se conservarán las referencias seleccionadas.
{sugerencias.map((s) => { const selected = s.selected const badgeLabel = s.endpoint === 'google' ? 'Google' : 'Open Library' const title = s.endpoint === 'google' ? ( (s.item as GoogleBooksVolume).volumeInfo?.title ?? 'Sin título' ).trim() : (typeof (s.item as OpenLibraryDoc)['title'] === 'string' ? ((s.item as OpenLibraryDoc)['title'] as string) : 'Sin título' ).trim() const subtitle = s.endpoint === 'google' ? (typeof (s.item as GoogleBooksVolume).volumeInfo?.subtitle === 'string' ? ((s.item as GoogleBooksVolume).volumeInfo ?.subtitle as string) : '' ).trim() : (typeof (s.item as OpenLibraryDoc)['subtitle'] === 'string' ? ((s.item as OpenLibraryDoc)['subtitle'] as string) : '' ).trim() const browserHref = (() => { if (s.endpoint === 'google') { const info = (s.item as GoogleBooksVolume).volumeInfo const previewLink = typeof info?.previewLink === 'string' ? info.previewLink : undefined const infoLink = typeof info?.infoLink === 'string' ? info.infoLink : undefined return previewLink || infoLink } const key = (s.item as OpenLibraryDoc)['key'] if (typeof key === 'string' && key.trim()) { return `https://openlibrary.org/${key}` } return undefined })() const authors = s.endpoint === 'google' ? ( (s.item as GoogleBooksVolume).volumeInfo?.authors ?? [] ).join(', ') : Array.isArray((s.item as OpenLibraryDoc)['author_name']) ? ( (s.item as OpenLibraryDoc)[ 'author_name' ] as Array ) .filter((a): a is string => typeof a === 'string') .join(', ') : '' const year = s.endpoint === 'google' ? tryParseYear( (s.item as GoogleBooksVolume).volumeInfo?.publishedDate, ) : tryParseYearFromOpenLibrary(s.item as OpenLibraryDoc) return ( ) })}
) } function DatosBasicosManualStep({ draft, refs, onChangeDraft, onAddRef, onRemoveRef, }: { draft: WizardState['manual']['draft'] refs: Array onChangeDraft: (draft: WizardState['manual']['draft']) => void onAddRef: (ref: BibliografiaRef) => void onRemoveRef: (id: string) => void }) { const canAdd = draft.title.trim().length > 0 return (
Agregar referencia Captura los datos y agrégala a la lista.
onChangeDraft({ ...draft, title: e.target.value.slice(0, 500), }) } onBlur={() => { const trimmed = draft.title.trim() if (trimmed !== draft.title) { onChangeDraft({ ...draft, title: trimmed }) } }} />