diff --git a/bun.lock b/bun.lock index f370735..4452943 100644 --- a/bun.lock +++ b/bun.lock @@ -4,6 +4,7 @@ "": { "name": "acad-ia-2", "dependencies": { + "@dnd-kit/react": "^0.3.2", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-checkbox": "^1.3.3", @@ -138,6 +139,18 @@ "@csstools/css-tokenizer": ["@csstools/css-tokenizer@3.0.4", "", {}, "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw=="], + "@dnd-kit/abstract": ["@dnd-kit/abstract@0.3.2", "", { "dependencies": { "@dnd-kit/geometry": "^0.3.2", "@dnd-kit/state": "^0.3.2", "tslib": "^2.6.2" } }, "sha512-uvPVK+SZYD6Viddn9M0K0JQdXknuVSxA/EbMlFRanve3P/XTc18oLa5zGftKSGjfQGmuzkZ34E26DSbly1zi3Q=="], + + "@dnd-kit/collision": ["@dnd-kit/collision@0.3.2", "", { "dependencies": { "@dnd-kit/abstract": "^0.3.2", "@dnd-kit/geometry": "^0.3.2", "tslib": "^2.6.2" } }, "sha512-pNmNSLCI8S9fNQ7QJ3fBCDjiT0sqBhUFcKgmyYaGvGCAU+kq0AP8OWlh0JSisc9k5mFyxmRpmFQcnJpILz/RPA=="], + + "@dnd-kit/dom": ["@dnd-kit/dom@0.3.2", "", { "dependencies": { "@dnd-kit/abstract": "^0.3.2", "@dnd-kit/collision": "^0.3.2", "@dnd-kit/geometry": "^0.3.2", "@dnd-kit/state": "^0.3.2", "tslib": "^2.6.2" } }, "sha512-cIUAVgt2szQyz6JRy7I+0r+xeyOAGH21Y15hb5bIyHoDEaZBvIDH+OOlD9eoLjCbsxDLN9WloU2CBi3OE6LYDg=="], + + "@dnd-kit/geometry": ["@dnd-kit/geometry@0.3.2", "", { "dependencies": { "@dnd-kit/state": "^0.3.2", "tslib": "^2.6.2" } }, "sha512-3UBPuIS7E3oGiHxOE8h810QA+0pnrnCtGxl4Os1z3yy5YkC/BEYGY+TxWPTQaY1/OMV7GCX7ZNMlama2QN3n3w=="], + + "@dnd-kit/react": ["@dnd-kit/react@0.3.2", "", { "dependencies": { "@dnd-kit/abstract": "^0.3.2", "@dnd-kit/dom": "^0.3.2", "@dnd-kit/state": "^0.3.2", "tslib": "^2.6.2" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-1Opg1xw6I75Z95c+rF2NJa0pdGb8rLAENtuopKtJ1J0PudWlz+P6yL137xy/6DV43uaRmNGtsdbMbR0yRYJ72g=="], + + "@dnd-kit/state": ["@dnd-kit/state@0.3.2", "", { "dependencies": { "@preact/signals-core": "^1.10.0", "tslib": "^2.6.2" } }, "sha512-dLUIkoYrIJhGXfF2wGLTfb46vUokEsO/OoE21TSfmahYrx7ysTmnwbePsznFaHlwgZhQEh6AlLvthLCeY21b1A=="], + "@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], "@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], @@ -250,6 +263,8 @@ "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="], + "@preact/signals-core": ["@preact/signals-core@1.14.0", "", {}, "sha512-AowtCcCU/33lFlh1zRFf/u+12rfrhtNakj7UpaGEsmMwUKpKWMVvcktOGcwBBNiB4lWrZWc01LhiyyzVklJyaQ=="], + "@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="], "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], diff --git a/package.json b/package.json index 50c7906..526ff2a 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "ci:verify": "prettier --check . && eslint . && tsc --noEmit" }, "dependencies": { + "@dnd-kit/react": "^0.3.2", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-checkbox": "^1.3.3", diff --git a/src/components/asignaturas/detalle/ContenidoTematico.tsx b/src/components/asignaturas/detalle/ContenidoTematico.tsx index 8ef2d1c..829b13e 100644 --- a/src/components/asignaturas/detalle/ContenidoTematico.tsx +++ b/src/components/asignaturas/detalle/ContenidoTematico.tsx @@ -1,3 +1,5 @@ +import { DragDropProvider } from '@dnd-kit/react' +import { isSortable, useSortable } from '@dnd-kit/react/sortable' import { useParams } from '@tanstack/react-router' import { Plus, @@ -11,7 +13,7 @@ import { import { useEffect, useRef, useState } from 'react' import type { ContenidoApi, ContenidoTemaApi } from '@/data/api/subjects.api' -import type { FocusEvent, KeyboardEvent } from 'react' +import type { FocusEvent, KeyboardEvent, ReactNode } from 'react' import { AlertDialog, @@ -50,6 +52,95 @@ export interface UnidadTematica { temas: Array } +function createClientId(prefix: string) { + try { + const c = (globalThis as any).crypto + if (c && typeof c.randomUUID === 'function') + return `${prefix}-${c.randomUUID()}` + } catch { + // ignore + } + return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}` +} + +function arrayMove(array: Array, fromIndex: number, toIndex: number) { + const next = array.slice() + const startIndex = fromIndex < 0 ? next.length + fromIndex : fromIndex + if (startIndex < 0 || startIndex >= next.length) return next + const endIndex = toIndex < 0 ? next.length + toIndex : toIndex + const [item] = next.splice(startIndex, 1) + next.splice(endIndex, 0, item) + return next +} + +function renumberUnidades(unidades: Array) { + return unidades.map((u, idx) => ({ ...u, numero: idx + 1 })) +} + +function InsertUnidadOverlay({ + onInsert, + position, +}: { + onInsert: () => void + position: 'top' | 'bottom' +}) { + return ( +
+ +
+ ) +} + +function SortableUnidad({ + id, + index, + registerContainer, + children, +}: { + id: string + index: number + registerContainer: (el: HTMLDivElement | null) => void + children: (args: { handleRef: (el: HTMLElement | null) => void }) => ReactNode +}) { + const { ref, handleRef, isDragSource, isDropTarget } = useSortable({ + id, + index, + }) + + return ( +
{ + ref(el) + registerContainer(el) + }} + className={cn( + 'group relative', + isDragSource && 'opacity-80', + isDropTarget && 'ring-primary/20 ring-2', + )} + > + {children({ handleRef })} +
+ ) +} + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } @@ -100,20 +191,18 @@ function mapContenidoItem(value: unknown, index: number): ContenidoApi | null { if (Array.isArray(value.temas)) { temas = value.temas .map(mapTemaValue) - .filter((t): t is ContenidoTemaApi => t !== null) - } else if (typeof value.temas === 'string' && value.temas.trim()) { - temas = value.temas - .split(/\r?\n|,/) - .map((t) => t.trim()) - .filter(Boolean) + .filter((x): x is ContenidoTemaApi => x !== null) } - return { unidad, titulo, temas } + return { + ...value, + unidad, + titulo, + temas, + } } function mapContenidoTematicoFromDb(value: unknown): Array { - if (value == null) return [] - if (typeof value === 'string') { try { return mapContenidoTematicoFromDb(JSON.parse(value)) @@ -192,6 +281,11 @@ export function ContenidoTematico() { const [temaDraftHoras, setTemaDraftHoras] = useState('') const [temaOriginalHoras, setTemaOriginalHoras] = useState(0) + const unidadesRef = useRef>([]) + useEffect(() => { + unidadesRef.current = unidades + }, [unidades]) + const persistUnidades = async (nextUnidades: Array) => { const payload = serializeUnidadesToApi(nextUnidades) await updateContenido.mutateAsync({ @@ -303,21 +397,84 @@ export function ContenidoTematico() { data ? data.contenido_tematico : undefined, ) - const transformed = contenido.map((u, idx) => ({ - id: `u-${u.unidad || idx + 1}`, - numero: u.unidad || idx + 1, - nombre: u.titulo || 'Sin título', - temas: Array.isArray(u.temas) - ? u.temas.map((t: any, tidx: number) => ({ - id: `t-${u.unidad || idx + 1}-${tidx + 1}`, - nombre: typeof t === 'string' ? t : t?.nombre || 'Tema', - horasEstimadas: t?.horasEstimadas || 0, - })) - : [], - })) + // 1. EL ESCUDO: Comparamos si nuestro estado local ya tiene esta info exacta + // (Esto ocurre justo después de arrastrar, ya que actualizamos la UI antes que la BD) + const currentPayload = JSON.stringify( + serializeUnidadesToApi(unidadesRef.current), + ) + + // Normalizamos la data de la BD para que tenga exactamente la misma forma que el payload + const incomingPayload = JSON.stringify( + contenido.map((u, idx) => ({ + unidad: u.unidad || idx + 1, + titulo: u.titulo || 'Sin título', + temas: Array.isArray(u.temas) + ? u.temas.map((t) => ({ + nombre: typeof t === 'string' ? t : t.nombre || 'Tema', + horasEstimadas: t?.horasEstimadas ?? 0, + descripcion: t?.descripcion, + })) + : [], + })), + ) + + // Si los datos son idénticos, abortamos el useEffect. + // ¡Nuestros IDs locales se salvan y no hay parpadeos! + if (currentPayload === incomingPayload && unidadesRef.current.length > 0) { + return + } + + // 2. Si llegamos aquí, es la carga inicial o alguien más editó la BD desde otro lado. + // Reciclamos IDs buscando por CONTENIDO (nombre), NUNCA POR ÍNDICE. + const prevUnidades = [...unidadesRef.current] + + const transformed = contenido.map((u, idx) => { + const dbTitulo = u.titulo || 'Sin título' + + // Buscamos si ya existe una unidad con este mismo título + const existingUnitIndex = prevUnidades.findIndex( + (prev) => prev.nombre === dbTitulo, + ) + let unidadId + let existingUnit = null + + if (existingUnitIndex !== -1) { + existingUnit = prevUnidades[existingUnitIndex] + unidadId = existingUnit.id + prevUnidades.splice(existingUnitIndex, 1) // Lo sacamos de la lista para no repetirlo + } else { + unidadId = createClientId(`u-${u.unidad || idx + 1}`) + } + + return { + id: unidadId, + numero: u.unidad || idx + 1, + nombre: dbTitulo, + temas: Array.isArray(u.temas) + ? u.temas.map((t: any, tidx: number) => { + const dbTemaNombre = + typeof t === 'string' ? t : t?.nombre || 'Tema' + + // Reciclamos subtemas por nombre también + const existingTema = existingUnit?.temas.find( + (prevT) => prevT.nombre === dbTemaNombre, + ) + const temaId = existingTema + ? existingTema.id + : createClientId(`t-${u.unidad || idx + 1}-${tidx + 1}`) + + return { + id: temaId, + nombre: dbTemaNombre, + horasEstimadas: t?.horasEstimadas || 0, + } + }) + : [], + } + }) setUnidades(transformed) - // Mantener las unidades ya expandidas si existen; si no, expandir la primera. + setExpandedUnits((prev) => { const validIds = new Set(transformed.map((u) => u.id)) const filtered = new Set( @@ -364,16 +521,22 @@ export function ContenidoTematico() { setExpandedUnits(newExpanded) } - const addUnidad = () => { - const newNumero = unidades.length + 1 - const newId = `u-${newNumero}` + const insertUnidadAt = (insertIndex: number) => { + const newId = createClientId('u') const newUnidad: UnidadTematica = { id: newId, nombre: 'Nueva Unidad', - numero: newNumero, + numero: 0, temas: [], } - const next = [...unidades, newUnidad] + + const clampedIndex = Math.max(0, Math.min(insertIndex, unidades.length)) + const next = renumberUnidades([ + ...unidades.slice(0, clampedIndex), + newUnidad, + ...unidades.slice(clampedIndex), + ]) + setUnidades(next) setExpandedUnits((prev) => { const n = new Set(prev) @@ -382,10 +545,40 @@ export function ContenidoTematico() { }) setPendingScrollUnitId(newId) - // Abrir edición del título inmediatamente setEditingUnit(newId) setUnitDraftNombre(newUnidad.nombre) setUnitOriginalNombre(newUnidad.nombre) + + void persistUnidades(next) + } + + const handleReorderEnd = (event: any) => { + if (event?.canceled) return + + const source = event?.operation?.source + if (!source) return + + // Type-guard nativo de dnd-kit para asegurar que el elemento tiene metadata de orden + if (!isSortable(source)) return + + // Extraemos las posiciones exactas calculadas por dnd-kit + const { initialIndex, index } = source.sortable + + // Si lo soltó en la misma posición de la que salió, cancelamos + if (initialIndex === index) return + + setUnidades((prev) => { + // Hacemos el movimiento usando los índices directos + const moved = arrayMove(prev, initialIndex, index) + const next = renumberUnidades(moved) + + // Disparamos la persistencia hacia Supabase + void persistUnidades(next).catch((err) => { + console.error('No se pudo guardar el orden de unidades', err) + }) + + return next + }) } // --- Lógica de Temas --- @@ -451,158 +644,185 @@ export function ContenidoTematico() { -
- {unidades.map((unidad) => ( -
{ - if (el) unitContainerRefs.current.set(unidad.id, el) - else unitContainerRefs.current.delete(unidad.id) - }} - > - - toggleUnit(unidad.id)} - > - -
- - - - - - Unidad {unidad.numero} - + +
+ {unidades.map((unidad, index) => ( + { + if (el) unitContainerRefs.current.set(unidad.id, el) + else unitContainerRefs.current.delete(unidad.id) + }} + > + {({ handleRef }) => ( + <> + {index > 0 ? ( + insertUnidadAt(index)} + /> + ) : null} - {editingUnit === unidad.id ? ( - setUnitDraftNombre(e.target.value)} - onBlur={() => { - if (cancelNextBlurRef.current) { - cancelNextBlurRef.current = false - return - } - commitEditUnit() - }} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault() - e.currentTarget.blur() - return - } - if (e.key === 'Escape') { - e.preventDefault() - cancelNextBlurRef.current = true - cancelEditUnit() - e.currentTarget.blur() - } - }} - className="h-8 max-w-md bg-white" - /> - ) : ( - beginEditUnit(unidad.id)} - > - {unidad.nombre} - - )} + {index === unidades.length - 1 ? ( + insertUnidadAt(unidades.length)} + /> + ) : null} -
- - {' '} - {unidad.temas.reduce( - (sum, t) => sum + (t.horasEstimadas || 0), - 0, - )} - h - - -
-
- - - -
- {unidad.temas.map((tema, idx) => ( - beginEditTema(unidad.id, tema.id)} - onDraftNombreChange={setTemaDraftNombre} - onDraftHorasChange={setTemaDraftHoras} - onEditorBlurCapture={handleTemaEditorBlurCapture} - onEditorKeyDownCapture={ - handleTemaEditorKeyDownCapture - } - onNombreInputRef={(el) => { - temaNombreInputElRef.current = el - }} - onDelete={() => - setDeleteDialog({ - type: 'tema', - id: tema.id, - parentId: unidad.id, - }) - } - /> - ))} - -
-
-
- - -
- ))} -
+ + toggleUnit(unidad.id)} + > + +
+ + + + + + + + Unidad {unidad.numero} + -
- -
+ {editingUnit === unidad.id ? ( + + setUnitDraftNombre(e.target.value) + } + onBlur={() => { + if (cancelNextBlurRef.current) { + cancelNextBlurRef.current = false + return + } + commitEditUnit() + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + e.currentTarget.blur() + return + } + if (e.key === 'Escape') { + e.preventDefault() + cancelNextBlurRef.current = true + cancelEditUnit() + e.currentTarget.blur() + } + }} + className="h-8 max-w-md bg-white" + /> + ) : ( + beginEditUnit(unidad.id)} + > + {unidad.nombre} + + )} + +
+ + {' '} + {unidad.temas.reduce( + (sum, t) => sum + (t.horasEstimadas || 0), + 0, + )} + h + + +
+
+
+ + +
+ {unidad.temas.map((tema, idx) => ( + + beginEditTema(unidad.id, tema.id) + } + onDraftNombreChange={setTemaDraftNombre} + onDraftHorasChange={setTemaDraftHoras} + onEditorBlurCapture={ + handleTemaEditorBlurCapture + } + onEditorKeyDownCapture={ + handleTemaEditorKeyDownCapture + } + onNombreInputRef={(el) => { + temaNombreInputElRef.current = el + }} + onDelete={() => + setDeleteDialog({ + type: 'tema', + id: tema.id, + parentId: unidad.id, + }) + } + /> + ))} + +
+
+
+
+
+ + )} + + ))} +
+