Merge pull request 'contenido es ordenable, botón de nueva unidad después de cada unidad, mejora de UX con unidades expandidas' (#185) from issue/182-funcionalidad-de-reacomodo-e-insercin-aleatoria-de into main
Reviewed-on: #185
This commit was merged in pull request #185.
This commit is contained in:
15
bun.lock
15
bun.lock
@@ -4,6 +4,7 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "acad-ia-2",
|
"name": "acad-ia-2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/react": "^0.3.2",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-avatar": "^1.1.11",
|
"@radix-ui/react-avatar": "^1.1.11",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
@@ -138,6 +139,18 @@
|
|||||||
|
|
||||||
"@csstools/css-tokenizer": ["@csstools/css-tokenizer@3.0.4", "", {}, "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw=="],
|
"@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/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=="],
|
"@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=="],
|
"@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/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=="],
|
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"ci:verify": "prettier --check . && eslint . && tsc --noEmit"
|
"ci:verify": "prettier --check . && eslint . && tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/react": "^0.3.2",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-avatar": "^1.1.11",
|
"@radix-ui/react-avatar": "^1.1.11",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
|
|||||||
@@ -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 { useParams } from '@tanstack/react-router'
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
@@ -11,7 +13,7 @@ import {
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
import type { ContenidoApi, ContenidoTemaApi } from '@/data/api/subjects.api'
|
import type { ContenidoApi, ContenidoTemaApi } from '@/data/api/subjects.api'
|
||||||
import type { FocusEvent, KeyboardEvent } from 'react'
|
import type { FocusEvent, KeyboardEvent, ReactNode } from 'react'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
@@ -50,6 +52,95 @@ export interface UnidadTematica {
|
|||||||
temas: Array<Tema>
|
temas: Array<Tema>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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<T>(array: Array<T>, 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<UnidadTematica>) {
|
||||||
|
return unidades.map((u, idx) => ({ ...u, numero: idx + 1 }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function InsertUnidadOverlay({
|
||||||
|
onInsert,
|
||||||
|
position,
|
||||||
|
}: {
|
||||||
|
onInsert: () => void
|
||||||
|
position: 'top' | 'bottom'
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'pointer-events-auto absolute right-0 left-0 z-30 flex justify-center',
|
||||||
|
// Match the `space-y-4` gap so the hover target is *between* units.
|
||||||
|
position === 'top' ? '-top-4 h-4' : '-bottom-4 h-4',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="bg-background/95 border-border/60 hover:bg-background opacity-0 shadow-sm transition-opacity group-hover:opacity-100"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onInsert()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus className="mr-2 h-3 w-3" /> Nueva unidad
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
ref={(el) => {
|
||||||
|
ref(el)
|
||||||
|
registerContainer(el)
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'group relative',
|
||||||
|
isDragSource && 'opacity-80',
|
||||||
|
isDropTarget && 'ring-primary/20 ring-2',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children({ handleRef })}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
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)) {
|
if (Array.isArray(value.temas)) {
|
||||||
temas = value.temas
|
temas = value.temas
|
||||||
.map(mapTemaValue)
|
.map(mapTemaValue)
|
||||||
.filter((t): t is ContenidoTemaApi => t !== null)
|
.filter((x): x is ContenidoTemaApi => x !== null)
|
||||||
} else if (typeof value.temas === 'string' && value.temas.trim()) {
|
|
||||||
temas = value.temas
|
|
||||||
.split(/\r?\n|,/)
|
|
||||||
.map((t) => t.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { unidad, titulo, temas }
|
return {
|
||||||
|
...value,
|
||||||
|
unidad,
|
||||||
|
titulo,
|
||||||
|
temas,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapContenidoTematicoFromDb(value: unknown): Array<ContenidoApi> {
|
function mapContenidoTematicoFromDb(value: unknown): Array<ContenidoApi> {
|
||||||
if (value == null) return []
|
|
||||||
|
|
||||||
if (typeof value === 'string') {
|
if (typeof value === 'string') {
|
||||||
try {
|
try {
|
||||||
return mapContenidoTematicoFromDb(JSON.parse(value))
|
return mapContenidoTematicoFromDb(JSON.parse(value))
|
||||||
@@ -192,7 +281,16 @@ export function ContenidoTematico() {
|
|||||||
const [temaDraftHoras, setTemaDraftHoras] = useState('')
|
const [temaDraftHoras, setTemaDraftHoras] = useState('')
|
||||||
const [temaOriginalHoras, setTemaOriginalHoras] = useState(0)
|
const [temaOriginalHoras, setTemaOriginalHoras] = useState(0)
|
||||||
|
|
||||||
|
const didInitExpandedUnitsRef = useRef(false)
|
||||||
|
|
||||||
|
const unidadesRef = useRef<Array<UnidadTematica>>([])
|
||||||
|
useEffect(() => {
|
||||||
|
unidadesRef.current = unidades
|
||||||
|
}, [unidades])
|
||||||
|
|
||||||
const persistUnidades = async (nextUnidades: Array<UnidadTematica>) => {
|
const persistUnidades = async (nextUnidades: Array<UnidadTematica>) => {
|
||||||
|
// A partir del primer guardado, ya respetamos lo que el usuario deje expandido.
|
||||||
|
didInitExpandedUnitsRef.current = true
|
||||||
const payload = serializeUnidadesToApi(nextUnidades)
|
const payload = serializeUnidadesToApi(nextUnidades)
|
||||||
await updateContenido.mutateAsync({
|
await updateContenido.mutateAsync({
|
||||||
subjectId: asignaturaId,
|
subjectId: asignaturaId,
|
||||||
@@ -303,28 +401,107 @@ export function ContenidoTematico() {
|
|||||||
data ? data.contenido_tematico : undefined,
|
data ? data.contenido_tematico : undefined,
|
||||||
)
|
)
|
||||||
|
|
||||||
const transformed = contenido.map((u, idx) => ({
|
// 1. EL ESCUDO: Comparamos si nuestro estado local ya tiene esta info exacta
|
||||||
id: `u-${u.unidad || idx + 1}`,
|
// (Esto ocurre justo después de arrastrar, ya que actualizamos la UI antes que la BD)
|
||||||
numero: u.unidad || idx + 1,
|
const currentPayload = JSON.stringify(
|
||||||
nombre: u.titulo || 'Sin título',
|
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)
|
temas: Array.isArray(u.temas)
|
||||||
? u.temas.map((t: any, tidx: number) => ({
|
? u.temas.map((t) => {
|
||||||
id: `t-${u.unidad || idx + 1}-${tidx + 1}`,
|
if (typeof t === 'string') {
|
||||||
nombre: typeof t === 'string' ? t : t?.nombre || 'Tema',
|
return {
|
||||||
horasEstimadas: t?.horasEstimadas || 0,
|
nombre: t,
|
||||||
}))
|
horasEstimadas: 0,
|
||||||
|
descripcion: undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
nombre: 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)
|
setUnidades(transformed)
|
||||||
// Mantener las unidades ya expandidas si existen; si no, expandir la primera.
|
|
||||||
setExpandedUnits((prev) => {
|
setExpandedUnits((prev) => {
|
||||||
const validIds = new Set(transformed.map((u) => u.id))
|
const validIds = new Set(transformed.map((u) => u.id))
|
||||||
const filtered = new Set(
|
const filtered = new Set(
|
||||||
Array.from(prev).filter((id) => validIds.has(id)),
|
Array.from(prev).filter((id) => validIds.has(id)),
|
||||||
)
|
)
|
||||||
if (filtered.size > 0) return filtered
|
|
||||||
return transformed.length > 0 ? new Set([transformed[0].id]) : new Set()
|
// Expandir la primera unidad solo una vez al llegar a la ruta.
|
||||||
|
// Luego, no auto-expandimos de nuevo (aunque `data` cambie).
|
||||||
|
if (!didInitExpandedUnitsRef.current && transformed.length > 0) {
|
||||||
|
return filtered.size > 0 ? filtered : new Set([transformed[0].id])
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered
|
||||||
})
|
})
|
||||||
}, [data])
|
}, [data])
|
||||||
|
|
||||||
@@ -364,16 +541,22 @@ export function ContenidoTematico() {
|
|||||||
setExpandedUnits(newExpanded)
|
setExpandedUnits(newExpanded)
|
||||||
}
|
}
|
||||||
|
|
||||||
const addUnidad = () => {
|
const insertUnidadAt = (insertIndex: number) => {
|
||||||
const newNumero = unidades.length + 1
|
const newId = createClientId('u')
|
||||||
const newId = `u-${newNumero}`
|
|
||||||
const newUnidad: UnidadTematica = {
|
const newUnidad: UnidadTematica = {
|
||||||
id: newId,
|
id: newId,
|
||||||
nombre: 'Nueva Unidad',
|
nombre: 'Nueva Unidad',
|
||||||
numero: newNumero,
|
numero: 0,
|
||||||
temas: [],
|
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)
|
setUnidades(next)
|
||||||
setExpandedUnits((prev) => {
|
setExpandedUnits((prev) => {
|
||||||
const n = new Set(prev)
|
const n = new Set(prev)
|
||||||
@@ -382,10 +565,40 @@ export function ContenidoTematico() {
|
|||||||
})
|
})
|
||||||
setPendingScrollUnitId(newId)
|
setPendingScrollUnitId(newId)
|
||||||
|
|
||||||
// Abrir edición del título inmediatamente
|
|
||||||
setEditingUnit(newId)
|
setEditingUnit(newId)
|
||||||
setUnitDraftNombre(newUnidad.nombre)
|
setUnitDraftNombre(newUnidad.nombre)
|
||||||
setUnitOriginalNombre(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 ---
|
// --- Lógica de Temas ---
|
||||||
@@ -451,15 +664,25 @@ export function ContenidoTematico() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<DragDropProvider onDragEnd={handleReorderEnd}>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{unidades.map((unidad) => (
|
{unidades.map((unidad, index) => (
|
||||||
<div
|
<SortableUnidad
|
||||||
key={unidad.id}
|
key={unidad.id}
|
||||||
ref={(el) => {
|
id={unidad.id}
|
||||||
|
index={index}
|
||||||
|
registerContainer={(el) => {
|
||||||
if (el) unitContainerRefs.current.set(unidad.id, el)
|
if (el) unitContainerRefs.current.set(unidad.id, el)
|
||||||
else unitContainerRefs.current.delete(unidad.id)
|
else unitContainerRefs.current.delete(unidad.id)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{({ handleRef }) => (
|
||||||
|
<>
|
||||||
|
<InsertUnidadOverlay
|
||||||
|
position="bottom"
|
||||||
|
onInsert={() => insertUnidadAt(index + 1)}
|
||||||
|
/>
|
||||||
|
|
||||||
<Card className="overflow-hidden border-slate-200 shadow-sm">
|
<Card className="overflow-hidden border-slate-200 shadow-sm">
|
||||||
<Collapsible
|
<Collapsible
|
||||||
open={expandedUnits.has(unidad.id)}
|
open={expandedUnits.has(unidad.id)}
|
||||||
@@ -467,9 +690,19 @@ export function ContenidoTematico() {
|
|||||||
>
|
>
|
||||||
<CardHeader className="border-b border-slate-100 bg-slate-50/50 py-3">
|
<CardHeader className="border-b border-slate-100 bg-slate-50/50 py-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<GripVertical className="h-4 w-4 cursor-grab text-slate-300" />
|
<span
|
||||||
|
ref={handleRef as any}
|
||||||
|
className="inline-flex cursor-grab touch-none items-center text-slate-300"
|
||||||
|
aria-label="Reordenar unidad"
|
||||||
|
>
|
||||||
|
<GripVertical className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
<CollapsibleTrigger asChild>
|
<CollapsibleTrigger asChild>
|
||||||
<Button variant="ghost" size="sm" className="h-auto p-0">
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-auto p-0"
|
||||||
|
>
|
||||||
{expandedUnits.has(unidad.id) ? (
|
{expandedUnits.has(unidad.id) ? (
|
||||||
<ChevronDown className="h-4 w-4" />
|
<ChevronDown className="h-4 w-4" />
|
||||||
) : (
|
) : (
|
||||||
@@ -485,7 +718,9 @@ export function ContenidoTematico() {
|
|||||||
<Input
|
<Input
|
||||||
ref={unitTitleInputRef}
|
ref={unitTitleInputRef}
|
||||||
value={unitDraftNombre}
|
value={unitDraftNombre}
|
||||||
onChange={(e) => setUnitDraftNombre(e.target.value)}
|
onChange={(e) =>
|
||||||
|
setUnitDraftNombre(e.target.value)
|
||||||
|
}
|
||||||
onBlur={() => {
|
onBlur={() => {
|
||||||
if (cancelNextBlurRef.current) {
|
if (cancelNextBlurRef.current) {
|
||||||
cancelNextBlurRef.current = false
|
cancelNextBlurRef.current = false
|
||||||
@@ -531,7 +766,10 @@ export function ContenidoTematico() {
|
|||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8 text-slate-400 hover:text-red-500"
|
className="h-8 w-8 text-slate-400 hover:text-red-500"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setDeleteDialog({ type: 'unidad', id: unidad.id })
|
setDeleteDialog({
|
||||||
|
type: 'unidad',
|
||||||
|
id: unidad.id,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
@@ -554,10 +792,14 @@ export function ContenidoTematico() {
|
|||||||
}
|
}
|
||||||
draftNombre={temaDraftNombre}
|
draftNombre={temaDraftNombre}
|
||||||
draftHoras={temaDraftHoras}
|
draftHoras={temaDraftHoras}
|
||||||
onBeginEdit={() => beginEditTema(unidad.id, tema.id)}
|
onBeginEdit={() =>
|
||||||
|
beginEditTema(unidad.id, tema.id)
|
||||||
|
}
|
||||||
onDraftNombreChange={setTemaDraftNombre}
|
onDraftNombreChange={setTemaDraftNombre}
|
||||||
onDraftHorasChange={setTemaDraftHoras}
|
onDraftHorasChange={setTemaDraftHoras}
|
||||||
onEditorBlurCapture={handleTemaEditorBlurCapture}
|
onEditorBlurCapture={
|
||||||
|
handleTemaEditorBlurCapture
|
||||||
|
}
|
||||||
onEditorKeyDownCapture={
|
onEditorKeyDownCapture={
|
||||||
handleTemaEditorKeyDownCapture
|
handleTemaEditorKeyDownCapture
|
||||||
}
|
}
|
||||||
@@ -586,23 +828,12 @@ export function ContenidoTematico() {
|
|||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</>
|
||||||
|
)}
|
||||||
|
</SortableUnidad>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</DragDropProvider>
|
||||||
<div className="flex justify-center pt-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="gap-2"
|
|
||||||
onClick={(e) => {
|
|
||||||
// Evita que Enter vuelva a disparar el click sobre el botón.
|
|
||||||
e.currentTarget.blur()
|
|
||||||
addUnidad()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4" /> Nueva unidad
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DeleteConfirmDialog
|
<DeleteConfirmDialog
|
||||||
dialog={deleteDialog}
|
dialog={deleteDialog}
|
||||||
|
|||||||
Reference in New Issue
Block a user