close #150: Se implementó el modal de “Agregar Bibliografía” con búsqueda en línea, generación de citas y tipado fuerte
- Se creó el modal de “Agregar Bibliografía” como ruta-modal y se enlazó desde el botón correspondiente con estilo consistente. - Se implementó la búsqueda de sugerencias en línea mediante Edge Function y se conservó únicamente lo seleccionado al regenerar sugerencias. - Se replicó el tooltip de “seleccionadas” con control total: se mostró solo en la primera generación y se permitió cerrarlo únicamente con el tache. - Se integró la generación de citas con citeproc-js y se cargaron los recursos CSL/locale desde archivos locales en public/, usando BASE_URL. - Se decodificaron entidades HTML en las citas generadas (p. ej., & → &). - Se habilitó la regeneración forzada de citas por formato y se conservaron las citas (incluidas ediciones) al alternar formatos. - Se mejoró la UI: se usó textarea autoajustable para citas y se estiró el select de tipo a ancho completo en sm+; se validó cantidad 1–40 o vacío (con deshabilitado del botón). - Se tipó fuertemente la inserción a bibliografia_asignatura y se tiparon source/tipo en las referencias conforme a los tipos de Supabase.
This commit was merged in pull request #158.
This commit is contained in:
@@ -1,18 +1,24 @@
|
|||||||
import * as React from "react"
|
import * as React from 'react'
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
const Textarea = React.forwardRef<
|
||||||
|
HTMLTextAreaElement,
|
||||||
|
React.ComponentProps<'textarea'>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
return (
|
return (
|
||||||
<textarea
|
<textarea
|
||||||
|
ref={ref}
|
||||||
data-slot="textarea"
|
data-slot="textarea"
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
'border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|
||||||
|
Textarea.displayName = 'Textarea'
|
||||||
|
|
||||||
export { Textarea }
|
export { Textarea }
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import type {
|
|||||||
AsignaturaSugerida,
|
AsignaturaSugerida,
|
||||||
DataAsignaturaSugerida,
|
DataAsignaturaSugerida,
|
||||||
} from '@/features/asignaturas/nueva/types'
|
} from '@/features/asignaturas/nueva/types'
|
||||||
import type { Database, TablesInsert } from '@/types/supabase'
|
import type { Database, Tables, TablesInsert } from '@/types/supabase'
|
||||||
|
|
||||||
const EDGE = {
|
const EDGE = {
|
||||||
generate_subject_suggestions: 'generate-subject-suggestions',
|
generate_subject_suggestions: 'generate-subject-suggestions',
|
||||||
@@ -85,8 +85,8 @@ export type GoogleBooksVolume = {
|
|||||||
export async function buscar_bibliografia(
|
export async function buscar_bibliografia(
|
||||||
input: BuscarBibliografiaRequest,
|
input: BuscarBibliografiaRequest,
|
||||||
): Promise<Array<GoogleBooksVolume>> {
|
): Promise<Array<GoogleBooksVolume>> {
|
||||||
const q = input?.searchTerms?.q
|
const q = input.searchTerms.q
|
||||||
const maxResults = input?.searchTerms?.maxResults
|
const maxResults = input.searchTerms.maxResults
|
||||||
|
|
||||||
if (typeof q !== 'string' || q.trim().length < 1) {
|
if (typeof q !== 'string' || q.trim().length < 1) {
|
||||||
throw new Error('q es requerido')
|
throw new Error('q es requerido')
|
||||||
@@ -158,7 +158,7 @@ export type PlanEstudioInSubject = Pick<
|
|||||||
|
|
||||||
export type EstructuraAsignaturaInSubject = Pick<
|
export type EstructuraAsignaturaInSubject = Pick<
|
||||||
EstructuraAsignatura,
|
EstructuraAsignatura,
|
||||||
'id' | 'nombre' | 'version' | 'definicion'
|
'id' | 'nombre' | 'definicion'
|
||||||
>
|
>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -529,13 +529,9 @@ export async function lineas_delete(lineaId: string) {
|
|||||||
return lineaId
|
return lineaId
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function bibliografia_insert(entry: {
|
export async function bibliografia_insert(
|
||||||
asignatura_id: string
|
entry: TablesInsert<'bibliografia_asignatura'>,
|
||||||
tipo: 'BASICA' | 'COMPLEMENTARIA'
|
): Promise<Tables<'bibliografia_asignatura'>> {
|
||||||
cita: string
|
|
||||||
tipo_fuente: 'MANUAL' | 'BIBLIOTECA'
|
|
||||||
biblioteca_item_id?: string | null
|
|
||||||
}) {
|
|
||||||
const supabase = supabaseBrowser()
|
const supabase = supabaseBrowser()
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('bibliografia_asignatura')
|
.from('bibliografia_asignatura')
|
||||||
@@ -544,7 +540,7 @@ export async function bibliografia_insert(entry: {
|
|||||||
.single()
|
.single()
|
||||||
|
|
||||||
if (error) throw error
|
if (error) throw error
|
||||||
return data
|
return data as Tables<'bibliografia_asignatura'>
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function bibliografia_update(
|
export async function bibliografia_update(
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useNavigate } from '@tanstack/react-router'
|
import { useNavigate } from '@tanstack/react-router'
|
||||||
import CSL from 'citeproc'
|
import CSL from 'citeproc'
|
||||||
import { Loader2, Plus, RefreshCw, Sparkles } from 'lucide-react'
|
import { Globe, Loader2, Plus, RefreshCw, X } from 'lucide-react'
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
|
||||||
import type { BuscarBibliografiaRequest } from '@/data'
|
import type { BuscarBibliografiaRequest } from '@/data'
|
||||||
import type { GoogleBooksVolume } from '@/data/api/subjects.api'
|
import type { GoogleBooksVolume } from '@/data/api/subjects.api'
|
||||||
|
import type { TablesInsert } from '@/types/supabase'
|
||||||
|
|
||||||
import { defineStepper } from '@/components/stepper'
|
import { defineStepper } from '@/components/stepper'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@@ -26,6 +27,11 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@/components/ui/tooltip'
|
||||||
import { WizardLayout } from '@/components/wizard/WizardLayout'
|
import { WizardLayout } from '@/components/wizard/WizardLayout'
|
||||||
import { WizardResponsiveHeader } from '@/components/wizard/WizardResponsiveHeader'
|
import { WizardResponsiveHeader } from '@/components/wizard/WizardResponsiveHeader'
|
||||||
import { buscar_bibliografia } from '@/data'
|
import { buscar_bibliografia } from '@/data'
|
||||||
@@ -50,9 +56,15 @@ type CSLItem = {
|
|||||||
ISBN?: string
|
ISBN?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type BibliografiaAsignaturaInsert = TablesInsert<'bibliografia_asignatura'>
|
||||||
|
type BibliografiaTipo = BibliografiaAsignaturaInsert['tipo']
|
||||||
|
type BibliografiaTipoFuente = NonNullable<
|
||||||
|
BibliografiaAsignaturaInsert['tipo_fuente']
|
||||||
|
>
|
||||||
|
|
||||||
type BibliografiaRef = {
|
type BibliografiaRef = {
|
||||||
id: string
|
id: string
|
||||||
source: 'IA' | 'MANUAL'
|
source: BibliografiaTipoFuente
|
||||||
raw?: GoogleBooksVolume
|
raw?: GoogleBooksVolume
|
||||||
title: string
|
title: string
|
||||||
authors: Array<string>
|
authors: Array<string>
|
||||||
@@ -60,14 +72,15 @@ type BibliografiaRef = {
|
|||||||
year?: number
|
year?: number
|
||||||
isbn?: string
|
isbn?: string
|
||||||
|
|
||||||
tipo: 'BASICA' | 'COMPLEMENTARIA'
|
tipo: BibliografiaTipo
|
||||||
}
|
}
|
||||||
|
|
||||||
type WizardState = {
|
type WizardState = {
|
||||||
metodo: MetodoBibliografia
|
metodo: MetodoBibliografia
|
||||||
ia: {
|
ia: {
|
||||||
q: string
|
q: string
|
||||||
cantidadDeSugerencias: number
|
cantidadDeSugerencias: number | null
|
||||||
|
showConservacionTooltip: boolean
|
||||||
sugerencias: Array<{
|
sugerencias: Array<{
|
||||||
id: string
|
id: string
|
||||||
selected: boolean
|
selected: boolean
|
||||||
@@ -95,7 +108,7 @@ type WizardState = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const Wizard = defineStepper(
|
const Wizard = defineStepper(
|
||||||
{ id: 'metodo', title: 'Método', description: 'Manual o Con IA' },
|
{ id: 'metodo', title: 'Método', description: 'Manual o Buscar en línea' },
|
||||||
{
|
{
|
||||||
id: 'paso2',
|
id: 'paso2',
|
||||||
title: 'Datos básicos',
|
title: 'Datos básicos',
|
||||||
@@ -138,7 +151,7 @@ function volumeToRef(volume: GoogleBooksVolume): BibliografiaRef {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id: volume.id,
|
id: volume.id,
|
||||||
source: 'IA',
|
source: 'MANUAL',
|
||||||
raw: volume,
|
raw: volume,
|
||||||
title,
|
title,
|
||||||
authors,
|
authors,
|
||||||
@@ -149,6 +162,50 @@ function volumeToRef(volume: GoogleBooksVolume): BibliografiaRef {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AutoSizeTextarea({
|
||||||
|
value,
|
||||||
|
disabled,
|
||||||
|
placeholder,
|
||||||
|
className,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: string
|
||||||
|
disabled?: boolean
|
||||||
|
placeholder?: string
|
||||||
|
className?: string
|
||||||
|
onChange: (next: string) => void
|
||||||
|
}) {
|
||||||
|
const ref = useRef<HTMLTextAreaElement | null>(null)
|
||||||
|
|
||||||
|
const autosize = () => {
|
||||||
|
const el = ref.current
|
||||||
|
if (!el) return
|
||||||
|
el.style.height = '0px'
|
||||||
|
el.style.height = `${el.scrollHeight}px`
|
||||||
|
}
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
autosize()
|
||||||
|
}, [value])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Textarea
|
||||||
|
ref={ref}
|
||||||
|
rows={1}
|
||||||
|
value={value}
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className={cn('min-h-0 resize-none overflow-hidden pr-10', className)}
|
||||||
|
onChange={(e) => {
|
||||||
|
const el = e.currentTarget
|
||||||
|
el.style.height = '0px'
|
||||||
|
el.style.height = `${el.scrollHeight}px`
|
||||||
|
onChange(el.value)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function citeprocHtmlToPlainText(value: string) {
|
function citeprocHtmlToPlainText(value: string) {
|
||||||
const input = value
|
const input = value
|
||||||
if (!input) return ''
|
if (!input) return ''
|
||||||
@@ -238,6 +295,7 @@ export function NuevaBibliografiaModalContainer({
|
|||||||
ia: {
|
ia: {
|
||||||
q: '',
|
q: '',
|
||||||
cantidadDeSugerencias: 10,
|
cantidadDeSugerencias: 10,
|
||||||
|
showConservacionTooltip: false,
|
||||||
sugerencias: [],
|
sugerencias: [],
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
errorMessage: null,
|
errorMessage: null,
|
||||||
@@ -318,20 +376,47 @@ export function NuevaBibliografiaModalContainer({
|
|||||||
const canContinueDesdePaso3 = Boolean(wizard.formato) && allCitationsReady
|
const canContinueDesdePaso3 = Boolean(wizard.formato) && allCitationsReady
|
||||||
|
|
||||||
async function handleBuscarSugerencias() {
|
async function handleBuscarSugerencias() {
|
||||||
|
const hadNoSugerenciasBefore = wizard.ia.sugerencias.length === 0
|
||||||
|
|
||||||
|
const cantidad = wizard.ia.cantidadDeSugerencias
|
||||||
|
if (
|
||||||
|
!Number.isFinite(cantidad ?? Number.NaN) ||
|
||||||
|
(cantidad as number) < 1 ||
|
||||||
|
(cantidad as number) > 40
|
||||||
|
) {
|
||||||
|
setWizard((w) => ({
|
||||||
|
...w,
|
||||||
|
ia: {
|
||||||
|
...w.ia,
|
||||||
|
errorMessage:
|
||||||
|
'La cantidad de sugerencias debe ser un entero entre 1 y 40 (o vacío).',
|
||||||
|
},
|
||||||
|
errorMessage: null,
|
||||||
|
}))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const selected = wizard.ia.sugerencias.filter((s) => s.selected)
|
||||||
|
|
||||||
setWizard((w) => ({
|
setWizard((w) => ({
|
||||||
...w,
|
...w,
|
||||||
ia: { ...w.ia, isLoading: true, errorMessage: null },
|
ia: {
|
||||||
|
...w.ia,
|
||||||
|
// Conservar únicamente las sugerencias seleccionadas.
|
||||||
|
sugerencias: w.ia.sugerencias.filter((s) => s.selected),
|
||||||
|
showConservacionTooltip: false,
|
||||||
|
isLoading: true,
|
||||||
|
errorMessage: null,
|
||||||
|
},
|
||||||
errorMessage: null,
|
errorMessage: null,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const selectedCount = wizard.ia.sugerencias.filter(
|
const selectedCount = selected.length
|
||||||
(s) => s.selected,
|
|
||||||
).length
|
|
||||||
const req: BuscarBibliografiaRequest = {
|
const req: BuscarBibliografiaRequest = {
|
||||||
searchTerms: {
|
searchTerms: {
|
||||||
q: wizard.ia.q,
|
q: wizard.ia.q,
|
||||||
maxResults: wizard.ia.cantidadDeSugerencias + selectedCount,
|
maxResults: (cantidad as number) + selectedCount,
|
||||||
// orderBy: ignorado por ahora
|
// orderBy: ignorado por ahora
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -343,7 +428,7 @@ export function NuevaBibliografiaModalContainer({
|
|||||||
|
|
||||||
const newOnes = items
|
const newOnes = items
|
||||||
.filter((it) => !existingById.has(it.id))
|
.filter((it) => !existingById.has(it.id))
|
||||||
.slice(0, w.ia.cantidadDeSugerencias)
|
.slice(0, cantidad as number)
|
||||||
.map((it) => ({ id: it.id, selected: false, volume: it }))
|
.map((it) => ({ id: it.id, selected: false, volume: it }))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -351,6 +436,8 @@ export function NuevaBibliografiaModalContainer({
|
|||||||
ia: {
|
ia: {
|
||||||
...w.ia,
|
...w.ia,
|
||||||
sugerencias: [...w.ia.sugerencias, ...newOnes],
|
sugerencias: [...w.ia.sugerencias, ...newOnes],
|
||||||
|
showConservacionTooltip:
|
||||||
|
hadNoSugerenciasBefore && newOnes.length > 0,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
errorMessage: null,
|
errorMessage: null,
|
||||||
},
|
},
|
||||||
@@ -374,7 +461,11 @@ export function NuevaBibliografiaModalContainer({
|
|||||||
async function generateCitasForFormato(
|
async function generateCitasForFormato(
|
||||||
formato: FormatoCita,
|
formato: FormatoCita,
|
||||||
refs: Array<BibliografiaRef>,
|
refs: Array<BibliografiaRef>,
|
||||||
|
options?: {
|
||||||
|
force?: boolean
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
|
const force = Boolean(options?.force)
|
||||||
setWizard((w) => {
|
setWizard((w) => {
|
||||||
const nextIds = new Set(w.generatingIds)
|
const nextIds = new Set(w.generatingIds)
|
||||||
refs.forEach((r) => nextIds.add(r.id))
|
refs.forEach((r) => nextIds.add(r.id))
|
||||||
@@ -432,9 +523,10 @@ export function NuevaBibliografiaModalContainer({
|
|||||||
const merged: Record<string, string> = { ...existing }
|
const merged: Record<string, string> = { ...existing }
|
||||||
|
|
||||||
for (const id of Object.keys(citations)) {
|
for (const id of Object.keys(citations)) {
|
||||||
if (!merged[id] || merged[id].trim().length === 0) {
|
merged[id] =
|
||||||
merged[id] = citations[id] ?? ''
|
force || !merged[id] || merged[id].trim().length === 0
|
||||||
}
|
? (citations[id] ?? '')
|
||||||
|
: merged[id]
|
||||||
}
|
}
|
||||||
nextEdits[formato] = merged
|
nextEdits[formato] = merged
|
||||||
|
|
||||||
@@ -490,7 +582,7 @@ export function NuevaBibliografiaModalContainer({
|
|||||||
asignatura_id: asignaturaId,
|
asignatura_id: asignaturaId,
|
||||||
tipo: r.tipo,
|
tipo: r.tipo,
|
||||||
cita: map[r.id] ?? '',
|
cita: map[r.id] ?? '',
|
||||||
tipo_fuente: 'MANUAL',
|
tipo_fuente: r.source,
|
||||||
biblioteca_item_id: null,
|
biblioteca_item_id: null,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -532,7 +624,7 @@ export function NuevaBibliografiaModalContainer({
|
|||||||
}
|
}
|
||||||
footerSlot={
|
footerSlot={
|
||||||
<Wizard.Stepper.Controls>
|
<Wizard.Stepper.Controls>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex grow items-center justify-between">
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => methods.prev()}
|
onClick={() => methods.prev()}
|
||||||
@@ -602,6 +694,15 @@ export function NuevaBibliografiaModalContainer({
|
|||||||
isLoading={wizard.ia.isLoading}
|
isLoading={wizard.ia.isLoading}
|
||||||
errorMessage={wizard.ia.errorMessage}
|
errorMessage={wizard.ia.errorMessage}
|
||||||
sugerencias={wizard.ia.sugerencias}
|
sugerencias={wizard.ia.sugerencias}
|
||||||
|
showConservacionTooltip={
|
||||||
|
wizard.ia.showConservacionTooltip
|
||||||
|
}
|
||||||
|
onDismissConservacionTooltip={() =>
|
||||||
|
setWizard((w) => ({
|
||||||
|
...w,
|
||||||
|
ia: { ...w.ia, showConservacionTooltip: false },
|
||||||
|
}))
|
||||||
|
}
|
||||||
onChange={(patch) =>
|
onChange={(patch) =>
|
||||||
setWizard((w) => ({
|
setWizard((w) => ({
|
||||||
...w,
|
...w,
|
||||||
@@ -664,7 +765,13 @@ export function NuevaBibliografiaModalContainer({
|
|||||||
}}
|
}}
|
||||||
onRegenerate={() => {
|
onRegenerate={() => {
|
||||||
if (!wizard.formato) return
|
if (!wizard.formato) return
|
||||||
void generateCitasForFormato(wizard.formato, wizard.refs)
|
void generateCitasForFormato(
|
||||||
|
wizard.formato,
|
||||||
|
wizard.refs,
|
||||||
|
{
|
||||||
|
force: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
}}
|
}}
|
||||||
onChangeTipo={(id, tipo) =>
|
onChangeTipo={(id, tipo) =>
|
||||||
setWizard((w) => ({
|
setWizard((w) => ({
|
||||||
@@ -752,7 +859,7 @@ function MetodoStep({
|
|||||||
>
|
>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Sparkles className="text-primary h-5 w-5" /> Con IA
|
<Globe className="text-primary h-5 w-5" /> Buscar en línea
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Busca sugerencias y selecciona las mejores.
|
Busca sugerencias y selecciona las mejores.
|
||||||
@@ -769,11 +876,13 @@ function SugerenciasStep({
|
|||||||
isLoading,
|
isLoading,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
sugerencias,
|
sugerencias,
|
||||||
|
showConservacionTooltip,
|
||||||
|
onDismissConservacionTooltip,
|
||||||
onChange,
|
onChange,
|
||||||
onGenerate,
|
onGenerate,
|
||||||
}: {
|
}: {
|
||||||
q: string
|
q: string
|
||||||
cantidad: number
|
cantidad: number | null
|
||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
errorMessage: string | null
|
errorMessage: string | null
|
||||||
sugerencias: Array<{
|
sugerencias: Array<{
|
||||||
@@ -781,10 +890,12 @@ function SugerenciasStep({
|
|||||||
selected: boolean
|
selected: boolean
|
||||||
volume: GoogleBooksVolume
|
volume: GoogleBooksVolume
|
||||||
}>
|
}>
|
||||||
|
showConservacionTooltip: boolean
|
||||||
|
onDismissConservacionTooltip: () => void
|
||||||
onChange: (
|
onChange: (
|
||||||
patch: Partial<{
|
patch: Partial<{
|
||||||
q: string
|
q: string
|
||||||
cantidadDeSugerencias: number
|
cantidadDeSugerencias: number | null
|
||||||
sugerencias: any
|
sugerencias: any
|
||||||
}>,
|
}>,
|
||||||
) => void
|
) => void
|
||||||
@@ -792,6 +903,12 @@ function SugerenciasStep({
|
|||||||
}) {
|
}) {
|
||||||
const selectedCount = sugerencias.filter((s) => s.selected).length
|
const selectedCount = sugerencias.filter((s) => s.selected).length
|
||||||
|
|
||||||
|
const cantidadIsValid =
|
||||||
|
typeof cantidad === 'number' &&
|
||||||
|
Number.isFinite(cantidad) &&
|
||||||
|
cantidad >= 1 &&
|
||||||
|
cantidad <= 40
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Card>
|
<Card>
|
||||||
@@ -818,13 +935,27 @@ function SugerenciasStep({
|
|||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
max={40}
|
max={40}
|
||||||
value={cantidad}
|
step={1}
|
||||||
onChange={(e) =>
|
inputMode="numeric"
|
||||||
onChange({
|
placeholder="Ej. 10"
|
||||||
cantidadDeSugerencias:
|
value={cantidad ?? ''}
|
||||||
Number.parseInt(e.target.value || '0', 10) || 0,
|
onKeyDown={(e) => {
|
||||||
})
|
if (['.', ',', '-', 'e', 'E', '+'].includes(e.key)) {
|
||||||
}
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onChange={(e) => {
|
||||||
|
const raw = e.target.value
|
||||||
|
if (raw === '') {
|
||||||
|
onChange({ cantidadDeSugerencias: null })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const asNumber = Number(raw)
|
||||||
|
if (!Number.isFinite(asNumber)) return
|
||||||
|
const n = Math.floor(Math.abs(asNumber))
|
||||||
|
const capped = Math.min(Math.max(n >= 1 ? n : 1, 1), 40)
|
||||||
|
onChange({ cantidadDeSugerencias: capped })
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -832,7 +963,7 @@ function SugerenciasStep({
|
|||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={onGenerate}
|
onClick={onGenerate}
|
||||||
disabled={isLoading || q.trim().length === 0}
|
disabled={isLoading || q.trim().length === 0 || !cantidadIsValid}
|
||||||
className="gap-2"
|
className="gap-2"
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
@@ -846,12 +977,6 @@ function SugerenciasStep({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<div className="text-muted-foreground text-sm">
|
|
||||||
{selectedCount} seleccionadas
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{errorMessage ? (
|
{errorMessage ? (
|
||||||
<div className="text-destructive text-sm">{errorMessage}</div>
|
<div className="text-destructive text-sm">{errorMessage}</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -859,7 +984,34 @@ function SugerenciasStep({
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="text-sm font-medium">Sugerencias</div>
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<h3 className="text-base font-medium">Sugerencias</h3>
|
||||||
|
<Tooltip open={showConservacionTooltip}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div className="bg-muted text-foreground inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-sm font-semibold">
|
||||||
|
<span aria-hidden>📌</span>
|
||||||
|
{selectedCount} seleccionadas
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom" sideOffset={8} className="max-w-xs">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<span className="flex-1 text-sm">
|
||||||
|
Al generar más sugerencias, se conservarán las referencias
|
||||||
|
seleccionadas.
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-5 w-5"
|
||||||
|
onClick={onDismissConservacionTooltip}
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
<div className="max-h-96 space-y-1 overflow-y-auto pr-1">
|
<div className="max-h-96 space-y-1 overflow-y-auto pr-1">
|
||||||
{sugerencias.map((s) => {
|
{sugerencias.map((s) => {
|
||||||
const info = s.volume.volumeInfo ?? {}
|
const info = s.volume.volumeInfo ?? {}
|
||||||
@@ -1062,28 +1214,25 @@ function FormatoYCitasStep({
|
|||||||
generatingIds: Set<string>
|
generatingIds: Set<string>
|
||||||
onChangeFormato: (formato: FormatoCita | null) => void
|
onChangeFormato: (formato: FormatoCita | null) => void
|
||||||
onRegenerate: () => void
|
onRegenerate: () => void
|
||||||
onChangeTipo: (id: string, tipo: 'BASICA' | 'COMPLEMENTARIA') => void
|
onChangeTipo: (id: string, tipo: BibliografiaTipo) => void
|
||||||
onChangeCita: (id: string, value: string) => void
|
onChangeCita: (id: string, value: string) => void
|
||||||
}) {
|
}) {
|
||||||
const isGeneratingAny = generatingIds.size > 0
|
const isGeneratingAny = generatingIds.size > 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-6">
|
||||||
<Card>
|
{/* 1. SECCIÓN DE CONTROLES: Sutil, compacta y sticky */}
|
||||||
<CardHeader>
|
<div className="bg-muted/40 border-border sticky top-0 z-10 rounded-lg border p-4 backdrop-blur-md">
|
||||||
<CardTitle>Formato</CardTitle>
|
<div className="flex flex-col items-end justify-between gap-4 sm:flex-row">
|
||||||
<CardDescription>
|
<div className="w-full flex-1 space-y-1.5 sm:max-w-xs">
|
||||||
Selecciona un formato para generar las citas.
|
<Label className="text-muted-foreground text-xs tracking-wider uppercase">
|
||||||
</CardDescription>
|
Formato de citación
|
||||||
</CardHeader>
|
</Label>
|
||||||
<CardContent className="flex flex-wrap items-end justify-between gap-3">
|
|
||||||
<div className="min-w-55 flex-1">
|
|
||||||
<Label>Formato</Label>
|
|
||||||
<Select
|
<Select
|
||||||
value={formato ?? ''}
|
value={formato ?? ''}
|
||||||
onValueChange={(v) => onChangeFormato(v as any)}
|
onValueChange={(v) => onChangeFormato(v as any)}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger className="bg-background">
|
||||||
<SelectValue placeholder="Seleccionar…" />
|
<SelectValue placeholder="Seleccionar…" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -1097,77 +1246,94 @@ function FormatoYCitasStep({
|
|||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="secondary" // Cambiado a secondary para menor peso visual
|
||||||
className="gap-2"
|
className="w-full gap-2 sm:w-auto"
|
||||||
onClick={onRegenerate}
|
onClick={onRegenerate}
|
||||||
disabled={!formato || refs.length === 0 || isGeneratingAny}
|
disabled={!formato || refs.length === 0 || isGeneratingAny}
|
||||||
>
|
>
|
||||||
<RefreshCw className="h-4 w-4" /> Regenerar
|
<RefreshCw className="h-4 w-4" /> Regenerar citas
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
{/* 2. SECCIÓN DE LISTA: Separación visual clara */}
|
||||||
{refs.map((r) => {
|
<div className="space-y-4">
|
||||||
const infoText = [
|
{/* {refs.length > 0 && (
|
||||||
r.title,
|
<div className="flex items-center gap-2">
|
||||||
r.authors.join(', '),
|
<h3 className="text-muted-foreground text-sm font-medium">
|
||||||
r.publisher,
|
Referencias añadidas
|
||||||
r.year ? String(r.year) : undefined,
|
</h3>
|
||||||
]
|
<Badge variant="secondary" className="text-xs">
|
||||||
.filter(Boolean)
|
{refs.length}
|
||||||
.join(' • ')
|
</Badge>
|
||||||
|
</div>
|
||||||
|
)} */}
|
||||||
|
|
||||||
const isGenerating = generatingIds.has(r.id)
|
<div className="space-y-3">
|
||||||
return (
|
{refs.map((r) => {
|
||||||
<Card key={r.id}>
|
const infoText = [
|
||||||
<CardHeader>
|
r.authors.join(', '),
|
||||||
<CardTitle className="text-base">{r.title}</CardTitle>
|
r.publisher,
|
||||||
<CardDescription className="wrap-break-word">
|
r.year ? String(r.year) : undefined,
|
||||||
{infoText}
|
]
|
||||||
</CardDescription>
|
.filter(Boolean)
|
||||||
</CardHeader>
|
.join(' • ')
|
||||||
<CardContent className="space-y-3">
|
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
const isGenerating = generatingIds.has(r.id)
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Cita</Label>
|
return (
|
||||||
<Input
|
<Card key={r.id} className="overflow-hidden">
|
||||||
value={citations[r.id] ?? ''}
|
<CardHeader className="bg-muted/10">
|
||||||
onChange={(e) => onChangeCita(r.id, e.target.value)}
|
<CardTitle className="text-base leading-tight">
|
||||||
disabled={isGenerating || isGeneratingAny}
|
{r.title}
|
||||||
placeholder="Cita generada…"
|
</CardTitle>
|
||||||
/>
|
<CardDescription className="wrap-break-word">
|
||||||
{isGenerating ? (
|
{infoText}
|
||||||
<div className="text-muted-foreground flex items-center gap-2 text-xs">
|
</CardDescription>
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />{' '}
|
</CardHeader>
|
||||||
Generando cita…
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-1 gap-2 sm:grid-cols-12">
|
||||||
|
<div className="space-y-2 sm:col-span-9">
|
||||||
|
<Label className="text-xs">Cita formateada</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<AutoSizeTextarea
|
||||||
|
value={citations[r.id] ?? ''}
|
||||||
|
onChange={(next) => onChangeCita(r.id, next)}
|
||||||
|
disabled={isGenerating || isGeneratingAny}
|
||||||
|
placeholder="Cita generada…"
|
||||||
|
/>
|
||||||
|
{isGenerating && (
|
||||||
|
<div className="absolute inset-y-0 right-3 flex items-center">
|
||||||
|
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="flex w-full flex-col items-start gap-2 sm:col-span-3 sm:items-stretch">
|
||||||
<Label>Tipo</Label>
|
<Label className="text-xs">Tipo</Label>
|
||||||
<Select
|
<Select
|
||||||
value={r.tipo}
|
value={r.tipo}
|
||||||
onValueChange={(v) => onChangeTipo(r.id, v as any)}
|
onValueChange={(v) => onChangeTipo(r.id, v as any)}
|
||||||
disabled={isGenerating || isGeneratingAny}
|
disabled={isGenerating || isGeneratingAny}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger className="w-full">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="BASICA">Básica</SelectItem>
|
<SelectItem value="BASICA">Básica</SelectItem>
|
||||||
<SelectItem value="COMPLEMENTARIA">
|
<SelectItem value="COMPLEMENTARIA">
|
||||||
Complementaria
|
Complementaria
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CardContent>
|
||||||
</CardContent>
|
</Card>
|
||||||
</Card>
|
)
|
||||||
)
|
})}
|
||||||
})}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -1184,41 +1350,84 @@ function ResumenStep({
|
|||||||
refs: Array<BibliografiaRef>
|
refs: Array<BibliografiaRef>
|
||||||
citations: Record<string, string>
|
citations: Record<string, string>
|
||||||
}) {
|
}) {
|
||||||
return (
|
// 1. Separar las referencias
|
||||||
<div className="space-y-4">
|
const basicas = refs.filter((r) => r.tipo === 'BASICA')
|
||||||
<Card>
|
const complementarias = refs.filter((r) => r.tipo === 'COMPLEMENTARIA')
|
||||||
<CardHeader>
|
const metodoLabel =
|
||||||
<CardTitle>Resumen</CardTitle>
|
metodo === 'MANUAL' ? 'Manual' : metodo === 'IA' ? 'Buscar en línea' : '—'
|
||||||
<CardDescription>Revisa antes de agregar.</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-2 text-sm">
|
|
||||||
<div>
|
|
||||||
<span className="font-medium">Método:</span> {metodo ?? '—'}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="font-medium">Formato:</span> {formato ?? '—'}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="font-medium">Referencias:</span> {refs.length}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
return (
|
||||||
{refs.map((r) => (
|
<div className="space-y-8">
|
||||||
<Card key={r.id}>
|
{/* Panel de Resumen General */}
|
||||||
<CardHeader>
|
<div className="bg-muted/40 rounded-lg border p-4">
|
||||||
<CardTitle className="text-base">{r.title}</CardTitle>
|
<h3 className="text-foreground mb-4 text-sm font-semibold">
|
||||||
<CardDescription>
|
Resumen de importación
|
||||||
{r.tipo === 'BASICA' ? 'Básica' : 'Complementaria'}
|
</h3>
|
||||||
</CardDescription>
|
<div className="grid grid-cols-2 gap-4 text-sm sm:grid-cols-4">
|
||||||
</CardHeader>
|
<div>
|
||||||
<CardContent>
|
<p className="text-muted-foreground text-xs uppercase">Método</p>
|
||||||
<div className="text-sm">{citations[r.id] ?? ''}</div>
|
<p className="font-medium">{metodoLabel}</p>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
<div>
|
||||||
))}
|
<p className="text-muted-foreground text-xs uppercase">Formato</p>
|
||||||
|
<p className="font-medium uppercase">{formato ?? '—'}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground text-xs uppercase">Básicas</p>
|
||||||
|
<p className="font-medium">{basicas.length}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground text-xs uppercase">
|
||||||
|
Complementarias
|
||||||
|
</p>
|
||||||
|
<p className="font-medium">{complementarias.length}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Sección: Bibliografía Básica */}
|
||||||
|
{basicas.length > 0 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="text-foreground border-b pb-2 text-sm font-medium">
|
||||||
|
Bibliografía Básica
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{basicas.map((r) => (
|
||||||
|
<div
|
||||||
|
key={r.id}
|
||||||
|
className="bg-background rounded-md border p-3 text-sm shadow-sm"
|
||||||
|
>
|
||||||
|
<p className="mb-1 font-medium">{r.title}</p>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{citations[r.id] ?? 'Sin cita generada'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sección: Bibliografía Complementaria */}
|
||||||
|
{complementarias.length > 0 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="text-foreground border-b pb-2 text-sm font-medium">
|
||||||
|
Bibliografía Complementaria
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{complementarias.map((r) => (
|
||||||
|
<div
|
||||||
|
key={r.id}
|
||||||
|
className="bg-background rounded-md border p-3 text-sm shadow-sm"
|
||||||
|
>
|
||||||
|
<p className="mb-1 font-medium">{r.title}</p>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{citations[r.id] ?? 'Sin cita generada'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user