Skip to content

Commit d287c8b

Browse files
committed
fix(ui): uniformar vacíos y esqueletos del panel
Cada vista tenía su propio vacío: texto suelto sin icono o una tarjeta discontinua dentro de la tabla. Ahora hay uno solo, con icono, descripción y acción cuando procede, sin marco doble. Los esqueletos repiten la forma real (alto de línea, rótulos, pestañas y contadores) y la barra de scroll queda reservada: al llegar los datos la página ya no salta.
1 parent db959a8 commit d287c8b

55 files changed

Lines changed: 883 additions & 416 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎src/components/bloque.tsx‎

Lines changed: 1 addition & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
1-
import { ChevronRightIcon, PlusIcon } from 'lucide-react'
1+
import { ChevronRightIcon } from 'lucide-react'
22
import { Link } from 'react-router'
33

4-
import { Button } from '@/components/ui/button'
5-
64
/** Bloque de una pantalla de resumen: rótulo, acción y marco. Lo comparten
75
* servidores, credenciales y créditos. */
86
export function Bloque({
@@ -56,26 +54,3 @@ export function VerTodo({
5654
</button>
5755
)
5856
}
59-
60-
export function VacioDeBloque({
61-
texto,
62-
accion,
63-
onAccion,
64-
}: {
65-
texto: string
66-
accion: string
67-
/** Sin manejador, solo el texto: no se ofrece un alta que daria 403. */
68-
onAccion?: () => void
69-
}) {
70-
return (
71-
<div className="flex flex-col items-start gap-3 p-4">
72-
<p className="text-muted-foreground text-sm">{texto}</p>
73-
{onAccion && (
74-
<Button variant="outline" size="sm" onClick={onAccion}>
75-
<PlusIcon />
76-
{accion}
77-
</Button>
78-
)}
79-
</div>
80-
)
81-
}

‎src/components/data-table.tsx‎

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { cn } from 'cn'
22
import { AlertTriangleIcon, RefreshCwIcon } from 'lucide-react'
33

44
import { Button } from '@/components/ui/button'
5+
import { LineaEsqueleto } from '@/components/states'
56
import { Skeleton } from '@/components/ui/skeleton'
67
import {
78
Table,
@@ -65,7 +66,7 @@ interface Props<T> {
6566
cargando: boolean
6667
error?: unknown
6768
onReintentar?: () => void
68-
/** Segun la causa: no tener nada no es lo mismo que no encontrar. */
69+
/** `EmptyState` sin marco; distinto sin datos que sin coincidencias. */
6970
vacio: React.ReactNode
7071
/** Filas del esqueleto: las de la carga anterior o la pagina entera. */
7172
filasEsperadas?: number
@@ -134,7 +135,7 @@ export function DataTable<T>({
134135
</TableRow>
135136
) : datos.length === 0 ? (
136137
<TableRow className="hover:bg-transparent">
137-
<TableCell colSpan={columnas.length} className="py-12 text-center">
138+
<TableCell colSpan={columnas.length} className="p-0">
138139
{vacio}
139140
</TableCell>
140141
</TableRow>
@@ -167,23 +168,15 @@ export function DataTable<T>({
167168
{cargando ? (
168169
<ul className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
169170
{Array.from({ length: Math.min(huesos, 6) }, (_, indice) => (
170-
<li key={indice} className="rounded-lg border p-4">
171-
<Skeleton className="h-5 w-2/5" />
172-
<div className="mt-4 grid grid-cols-2 gap-3">
173-
<Skeleton className="h-4 w-full" />
174-
<Skeleton className="h-4 w-2/3" />
175-
<Skeleton className="h-4 w-3/4" />
176-
<Skeleton className="h-4 w-1/2" />
177-
</div>
178-
</li>
171+
<TarjetaEsqueleto key={indice} columnas={columnas} />
179172
))}
180173
</ul>
181174
) : error ? (
182175
<div className="rounded-lg border p-4">
183176
<AvisoDeError error={error} onReintentar={onReintentar} />
184177
</div>
185178
) : datos.length === 0 ? (
186-
<div className="rounded-lg border px-6 py-12 text-center">{vacio}</div>
179+
<div className="rounded-lg border">{vacio}</div>
187180
) : (
188181
<ul className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
189182
{datos.map((fila, indice) => (
@@ -241,6 +234,32 @@ function TarjetaDeFila<T>({
241234
)
242235
}
243236

237+
/** Forma de `TarjetaDeFila`: sin salto al cargar. */
238+
function TarjetaEsqueleto<T>({ columnas }: { columnas: Columna<T>[] }) {
239+
const titulo = columnas.find((columna) => columna.rol === 'titulo') ?? columnas[0]
240+
const resto = columnas.filter(
241+
(columna) => columna !== titulo && columna.rol !== 'acciones',
242+
)
243+
244+
return (
245+
<li className="rounded-lg border p-4" aria-hidden>
246+
<LineaEsqueleto className="w-2/5" />
247+
<dl className="mt-4 grid grid-cols-2 gap-x-4 gap-y-3">
248+
{resto.map((columna) => (
249+
<div key={columna.key}>
250+
<dt className="text-muted-foreground text-[11px] tracking-[0.12em] uppercase">
251+
{columna.header}
252+
</dt>
253+
<dd className="mt-1">
254+
<LineaEsqueleto className="w-3/4" />
255+
</dd>
256+
</div>
257+
))}
258+
</dl>
259+
</li>
260+
)
261+
}
262+
244263
function CeldaEsqueleto<T>({ columna }: { columna: Columna<T> }) {
245264
if (columna.rol === 'acciones') {
246265
return (

‎src/components/states.tsx‎

Lines changed: 73 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,31 +30,56 @@ export function PageHeader({ title, description, action }: PageHeaderProps) {
3030
interface EmptyStateProps {
3131
icon: React.ElementType
3232
title: string
33-
description: string
33+
description?: string
3434
action?: React.ReactNode
35+
/** Suelto en página; tabla o tarjeta ya ponen marco. */
36+
enmarcado?: boolean
37+
/** Paneles pequeños: menos aire. */
38+
compacto?: boolean
3539
className?: string
3640
}
3741

42+
/** Vacío único de la app: todos iguales. */
3843
export function EmptyState({
3944
icon: Icon,
4045
title,
4146
description,
4247
action,
48+
enmarcado = false,
49+
compacto = false,
4350
className,
4451
}: EmptyStateProps) {
4552
return (
4653
<div
4754
className={cn(
48-
'border-border/70 flex flex-col items-center rounded-lg border border-dashed px-6 py-14 text-center',
55+
'flex flex-col items-center text-center whitespace-normal',
56+
compacto ? 'px-4 py-8' : 'px-6 py-12',
57+
enmarcado && 'rounded-lg border',
4958
className,
5059
)}
5160
>
52-
<span className="bg-muted text-muted-foreground grid size-11 place-items-center rounded-full">
53-
<Icon className="size-5" />
61+
<span
62+
className={cn(
63+
'bg-muted text-muted-foreground grid place-items-center rounded-full',
64+
compacto ? 'size-9' : 'size-11',
65+
)}
66+
>
67+
<Icon className={compacto ? 'size-4' : 'size-5'} />
5468
</span>
55-
<h2 className="mt-4 text-base font-semibold">{title}</h2>
56-
<p className="text-muted-foreground mt-1 max-w-sm text-sm">{description}</p>
57-
{action && <div className="mt-5">{action}</div>}
69+
<h2 className={cn('font-semibold', compacto ? 'mt-3 text-sm' : 'mt-4 text-base')}>
70+
{title}
71+
</h2>
72+
{description && (
73+
<p
74+
className={cn(
75+
'text-muted-foreground mt-1 max-w-sm text-pretty',
76+
compacto ? 'text-xs' : 'text-sm',
77+
)}
78+
>
79+
{description}
80+
</p>
81+
)}
82+
{action && <div className={compacto ? 'mt-4' : 'mt-5'}>{action}</div>}
5883
</div>
5984
)
6085
}
@@ -84,6 +109,47 @@ export function ErrorState({ error, onRetry }: ErrorStateProps) {
84109
)
85110
}
86111

112+
const ALTO_DE_LINEA = {
113+
xs: ['h-4', 'h-3'],
114+
sm: ['h-5', 'h-4'],
115+
base: ['h-6', 'h-4'],
116+
xl: ['h-7', 'h-5'],
117+
'2xl': ['h-8', 'h-7'],
118+
} as const
119+
120+
/** Alto de línea del texto real: sin salto al cargar. */
121+
export function LineaEsqueleto({
122+
texto = 'sm',
123+
className,
124+
}: {
125+
texto?: keyof typeof ALTO_DE_LINEA
126+
className?: string
127+
}) {
128+
const [linea, barra] = ALTO_DE_LINEA[texto]
129+
130+
return (
131+
<div className={cn('flex items-center', linea)}>
132+
<Skeleton className={cn(barra, className)} />
133+
</div>
134+
)
135+
}
136+
137+
export function ListaEsqueleto({ filas = 3 }: { filas?: number }) {
138+
return (
139+
<ul className="divide-y rounded-lg border" aria-hidden>
140+
{Array.from({ length: filas }, (_, indice) => (
141+
<li key={indice} className="flex items-center gap-3 p-4">
142+
<Skeleton className="size-4 shrink-0" />
143+
<div className="min-w-0 flex-1">
144+
<LineaEsqueleto className="w-32" />
145+
<LineaEsqueleto texto="xs" className="w-56 max-w-full" />
146+
</div>
147+
</li>
148+
))}
149+
</ul>
150+
)
151+
}
152+
87153
/** Imita la forma de la tabla para que la vista no salte al cargar. */
88154
export function TableSkeleton({
89155
rows = 4,

‎src/components/tab-nav.tsx‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,15 @@ import { NavLink } from 'react-router'
22

33
import { cn } from 'cn'
44

5+
import { Skeleton } from '@/components/ui/skeleton'
6+
57
export interface Pestana {
68
to: string
79
etiqueta: string
810
/** Lo que hay dentro, cuando ahorra entrar a mirarlo. */
911
cuenta?: number | null
12+
/** Cuenta en camino: hueco reservado, sin salto. */
13+
cuentaPendiente?: boolean
1014
/** La pestaña índice necesita `end`, o queda activa en todas las hijas. */
1115
end?: boolean
1216
}
@@ -38,10 +42,14 @@ export function TabNav({
3842
}
3943
>
4044
{pestana.etiqueta}
41-
{pestana.cuenta != null && (
42-
<span className="bg-muted text-muted-foreground rounded-full px-1.5 py-0.5 text-xs tabular-nums">
43-
{pestana.cuenta}
44-
</span>
45+
{pestana.cuentaPendiente ? (
46+
<Skeleton className="h-5 w-5 rounded-full" />
47+
) : (
48+
pestana.cuenta != null && (
49+
<span className="bg-muted text-muted-foreground rounded-full px-1.5 py-0.5 text-xs tabular-nums">
50+
{pestana.cuenta}
51+
</span>
52+
)
4553
)}
4654
</NavLink>
4755
</li>

‎src/features/access/GroupAccessDialog.tsx‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
2-
import { CalendarClockIcon, PlusIcon, XIcon } from 'lucide-react'
2+
import { CalendarClockIcon, PlusIcon, ServerIcon, XIcon } from 'lucide-react'
33
import { useEffect, useState } from 'react'
44
import { toast } from 'sonner'
55

66
import { FormDialogContent } from '@/components/form-dialog'
7+
import { EmptyState } from '@/components/states'
78
import { Button } from '@/components/ui/button'
89
import {
910
Dialog,
@@ -51,9 +52,12 @@ export function GroupAccessDialog({ slug, grupo, concesiones, onOpenChange }: Pr
5152

5253
<div className="divide-y rounded-lg border">
5354
{concesiones.length === 0 ? (
54-
<p className="text-muted-foreground p-3 text-sm">
55-
Todavía no alcanza ningún servidor.
56-
</p>
55+
<EmptyState
56+
compacto
57+
icon={ServerIcon}
58+
title="Sin accesos todavía"
59+
description="Todavía no alcanza ningún servidor."
60+
/>
5761
) : (
5862
concesiones.map((concesion) => (
5963
<Concesion key={concesion.id} slug={slug} concesion={concesion} />

‎src/features/access/GroupRow.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,7 @@ function Personas({
221221

222222
{grupo.members.length === 0 ? (
223223
<EmptyState
224+
compacto
224225
icon={UsersIcon}
225226
title="Todavía no hay nadie aquí"
226227
description={descripcionDelVacio(administra, fuera.length)}

‎src/features/access/GroupsTab.tsx‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,8 @@ import { useOutletContext } from 'react-router'
55
import { toast } from 'sonner'
66

77
import { ConfirmDialog } from '@/components/confirm-dialog'
8-
import { EmptyState } from '@/components/states'
8+
import { EmptyState, ListaEsqueleto } from '@/components/states'
99
import { Button } from '@/components/ui/button'
10-
import { Skeleton } from '@/components/ui/skeleton'
1110
import * as accessApi from '@/features/access/api'
1211
import { GroupAccessDialog } from '@/features/access/GroupAccessDialog'
1312
import { GroupNameDialog } from '@/features/access/GroupNameDialog'
@@ -51,8 +50,6 @@ export function GroupsTab() {
5150
const porGrupo = (grupo: AccessGroup) =>
5251
(concesiones.data ?? []).filter((concesion) => concesion.group === grupo.id)
5352

54-
if (grupos.isPending) return <Skeleton className="h-40 rounded-lg" />
55-
5653
return (
5754
<div className="space-y-3">
5855
<div className="flex items-center justify-between gap-3">
@@ -67,8 +64,11 @@ export function GroupsTab() {
6764
)}
6865
</div>
6966

70-
{lista.length === 0 ? (
67+
{grupos.isPending ? (
68+
<ListaEsqueleto filas={2} />
69+
) : lista.length === 0 ? (
7170
<EmptyState
71+
enmarcado
7272
icon={UsersIcon}
7373
title="Todavía no hay grupos"
7474
description="Sirven para dar el mismo acceso a varias personas de una vez."

‎src/features/access/MemberAccessSheet.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ export function MemberAccessSheet({ slug, miembro, open, onOpenChange }: Props)
7575
</p>
7676
) : consulta.data.servers.length === 0 ? (
7777
<EmptyState
78+
enmarcado
7879
icon={ServerIcon}
7980
title="No entra a ningún servidor"
8081
description="Dele toda la organización arriba, o compártale un servidor desde su ficha."

‎src/features/access/ServerAccess.tsx‎

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { toast } from 'sonner'
1515
import { ConfirmDialog } from '@/components/confirm-dialog'
1616
import { FieldError } from '@/components/field-error'
1717
import { InfoHint } from '@/components/info-hint'
18+
import { EmptyState } from '@/components/states'
1819
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
1920
import { Badge } from '@/components/ui/badge'
2021
import { Button } from '@/components/ui/button'
@@ -122,12 +123,18 @@ export function ServerAccess({
122123

123124
<Bloque titulo="Con acceso concedido">
124125
{concesiones.length === 0 ? (
125-
<p className="text-muted-foreground p-3 text-sm">
126-
{credencial
127-
? 'Todavía no entra nadie más con esta credencial.'
128-
: 'Todavía no entra nadie más a este servidor.'}
129-
{puedeRepartir && !conFormulario && ' Use «Compartir» para dar acceso.'}
130-
</p>
126+
<EmptyState
127+
compacto
128+
icon={UsersIcon}
129+
title="Nadie más entra todavía"
130+
description={
131+
puedeRepartir && !conFormulario
132+
? 'Use «Compartir» para dar acceso a alguien del equipo.'
133+
: credencial
134+
? 'Solo quien administra usa esta credencial.'
135+
: 'Solo quien administra entra a este servidor.'
136+
}
137+
/>
131138
) : (
132139
concesiones.map((concesion) => (
133140
<GrantRow

0 commit comments

Comments
 (0)