Skip to content

Commit 324f9be

Browse files
authored
fix: navegación del curso con breadcrumb y menús de sección y banco
fix: navegación del curso con breadcrumb y menús de sección y banco
2 parents e3b22fc + 9165984 commit 324f9be

12 files changed

Lines changed: 294 additions & 126 deletions

File tree

‎.cspell.json‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{
2+
"import": ["@cspell/dict-es-es/cspell-ext.json"],
3+
"language": "en,es",
4+
"words": [
5+
"Berlín",
6+
"dtos",
7+
"fgutierrez",
8+
"formisch",
9+
"katex",
10+
"keyline",
11+
"Londres",
12+
"onnavigate",
13+
"picklist",
14+
"ramtun",
15+
"subpage",
16+
"tailwindcss",
17+
"unsub"
18+
]
19+
}

‎Makefile‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ args ?=
1717
docker_tty ?= -it
1818

1919
.DEFAULT_GOAL := run
20-
.PHONY: run detach clean fmt lint migration machete clean-db db npmi npmu npmci setup deployer down
20+
.PHONY: run detach clean fmt lint migration machete clean-db db npmi npmu npmci setup deployer down spell
2121

2222
setup:
2323
rm -rf $(CLIENT)/node_modules
@@ -53,6 +53,9 @@ lint:
5353
cd $(CLIENT) && corepack pnpm run lint
5454
cd $(CLIENT) && corepack pnpm run check
5555

56+
spell:
57+
corepack pnpm --package=cspell --package=@cspell/dict-es-es dlx cspell lint --no-progress "$(CLIENT)/src/**/*.{svelte,ts,css}"
58+
5659
migration:
5760
cd $(SERVER) && sqlx migrate add --source ./config/migrations "$(name)"
5861

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { FileText, Library, Users } from "lucide-svelte"
2+
3+
/** Secciones de un curso: fuente única de las pestañas del curso y del menú del breadcrumb. */
4+
export const courseSections = (courseId: string) => {
5+
const base = `/courses/${courseId}`
6+
7+
return [
8+
{
9+
label: "Cuestionarios",
10+
icon: FileText,
11+
href: `${base}/quizzes`,
12+
isActive: (pathname: string) =>
13+
pathname === base || pathname.startsWith(`${base}/quizzes`),
14+
},
15+
{
16+
label: "Bancos de preguntas",
17+
icon: Library,
18+
href: `${base}/banks`,
19+
isActive: (pathname: string) => pathname.startsWith(`${base}/banks`),
20+
},
21+
{
22+
label: "Miembros del curso",
23+
icon: Users,
24+
href: `${base}/members`,
25+
isActive: (pathname: string) => pathname.startsWith(`${base}/members`),
26+
},
27+
]
28+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
<script lang="ts">
2+
import { Check, ChevronRight } from "lucide-svelte"
3+
4+
interface BreadcrumbMenuProps {
5+
label: string
6+
items: { label: string; href: string; current?: boolean }[]
7+
empty?: string
8+
disabled?: boolean
9+
}
10+
11+
let { label, items, empty = "Sin elementos", disabled = false }: BreadcrumbMenuProps = $props()
12+
13+
let open = $state(false)
14+
let root = $state<HTMLElement>()
15+
16+
$effect(() => {
17+
if (disabled) open = false
18+
})
19+
</script>
20+
21+
<svelte:window
22+
onpointerdown={(event) => {
23+
if (open && root && !root.contains(event.target as Node)) open = false
24+
}}
25+
onkeydown={(event) => {
26+
if (open && event.key === "Escape") open = false
27+
}}
28+
/>
29+
30+
<!-- El separador del breadcrumb es el disparador: abre las alternativas de ese nivel. -->
31+
<div class="relative mx-1 shrink-0" bind:this={root}>
32+
<button
33+
class="flex size-6 cursor-pointer items-center justify-center rounded-[4px] text-zinc-500 hover:bg-zinc-200 hover:text-black disabled:cursor-default disabled:hover:bg-transparent disabled:hover:text-zinc-500"
34+
type="button"
35+
aria-label={label}
36+
aria-expanded={open}
37+
{disabled}
38+
onclick={() => (open = !open)}
39+
>
40+
<ChevronRight
41+
class="transition-transform duration-150 {open ? 'rotate-90' : ''}"
42+
size={14}
43+
aria-hidden="true"
44+
/>
45+
</button>
46+
47+
{#if open}
48+
<ul
49+
class="panel-elevated absolute top-full left-0 z-20 m-0 mt-1.5 max-h-72 w-72 max-w-[80vw] list-none overflow-y-auto p-1"
50+
aria-label={label}
51+
>
52+
{#each items as item (item.href)}
53+
<li>
54+
<a
55+
class="row-hover flex items-center justify-between gap-2 rounded-[4px] px-2.5 py-2 text-sm text-zinc-800 hover:text-black"
56+
class:font-medium={item.current}
57+
href={item.href}
58+
aria-current={item.current ? "page" : undefined}
59+
onclick={() => (open = false)}
60+
>
61+
<span class="truncate">{item.label}</span>
62+
{#if item.current}
63+
<Check class="shrink-0" size={14} aria-hidden="true" />
64+
{/if}
65+
</a>
66+
</li>
67+
{:else}
68+
<li class="px-2.5 py-2 text-sm text-zinc-600">{empty}</li>
69+
{/each}
70+
</ul>
71+
{/if}
72+
</div>

‎apps/client/src/routes/(protected)/+layout.svelte‎

Lines changed: 52 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -72,62 +72,63 @@
7272
</script>
7373

7474
<main class="mx-auto flex min-h-dvh max-w-312 flex-col gap-2 p-3 sm:p-4 lg:p-6">
75-
<header class="panel-elevated mx-auto w-full p-3 sm:p-4">
76-
<div class="flex items-center justify-between gap-4 px-8">
77-
<div>
78-
<p class="m-0 text-xs font-semibold tracking-widest text-zinc-500">
79-
INF-UCT: RAMTUN
80-
</p>
81-
<h1 class="mt-0.5 mb-0 text-xl leading-tight text-black sm:text-2xl">
82-
Cuestionarios en línea
83-
</h1>
84-
</div>
75+
<header
76+
class="panel-elevated mx-auto flex w-full items-center justify-between gap-2 px-2 py-2 sm:grid sm:grid-cols-[1fr_auto_1fr] sm:gap-4 sm:px-8 sm:py-3.5 lg:gap-8"
77+
>
78+
<div class="min-w-0">
79+
<p class="m-0 text-xs font-semibold tracking-widest text-zinc-500">INF-UCT: RAMTUN</p>
80+
<h1 class="mt-0.5 mb-0 text-base leading-tight text-black sm:text-2xl">
81+
Cuestionarios en línea
82+
</h1>
83+
</div>
8584

86-
<div class="mt-2.5 flex items-center gap-1">
87-
<a class="action-tab justify-center" data-active={isActive("/join")} href="/join">
88-
<DoorOpen size={16} aria-hidden="true" />
89-
<span class="hidden lg:inline">Unirse</span>
90-
</a>
85+
<nav class="flex items-center sm:gap-2 lg:gap-6" aria-label="Principal">
86+
<a
87+
class="text-tab min-h-11 min-w-11 justify-center lg:min-w-0"
88+
data-active={isActive("/join")}
89+
href="/join"
90+
title="Unirse"
91+
>
92+
<DoorOpen size={16} aria-hidden="true" />
93+
<span class="hidden lg:inline">Unirse</span>
94+
</a>
95+
<a
96+
class="text-tab min-h-11 min-w-11 justify-center lg:min-w-0"
97+
data-active={isActive("/results")}
98+
href="/results"
99+
title="Resultados"
100+
>
101+
<ClipboardList size={16} aria-hidden="true" />
102+
<span class="hidden lg:inline">Resultados</span>
103+
</a>
104+
{#if showCoursesNav}
91105
<a
92-
class="action-tab justify-center"
93-
data-active={isActive("/results")}
94-
href="/results"
106+
class="text-tab min-h-11 min-w-11 justify-center lg:min-w-0"
107+
data-active={isActive("/courses")}
108+
href="/courses"
109+
title="Cursos"
95110
>
96-
<ClipboardList size={16} aria-hidden="true" />
97-
<span class="hidden lg:inline">Resultados</span>
111+
<Layers size={16} aria-hidden="true" />
112+
<span class="hidden lg:inline">Cursos</span>
98113
</a>
99-
{#if showCoursesNav}
100-
<a
101-
class="action-tab justify-center"
102-
data-active={isActive("/courses")}
103-
href="/courses"
104-
>
105-
<Layers size={16} aria-hidden="true" />
106-
<span class="hidden lg:inline">Cursos</span>
107-
</a>
108-
{/if}
109-
110-
<span class="mx-1 h-6 w-px bg-zinc-300" aria-hidden="true"></span>
111-
112-
<div class="flex shrink-0 items-stretch gap-1.5 sm:gap-2">
113-
<div
114-
class="flex h-10 min-w-0 flex-col justify-center rounded-md border border-zinc-200 bg-zinc-50 px-2.5 text-left sm:min-w-44 sm:px-3 sm:text-right"
115-
>
116-
<p class="truncate text-sm font-semibold text-zinc-800">
117-
{authStore.user?.name}
118-
</p>
119-
<p class="text-xs text-zinc-600">{authStore.user?.role.toDisplay()}</p>
120-
</div>
121-
<button
122-
class="inline-flex size-10 cursor-pointer items-center justify-center self-stretch rounded-[4px] border border-zinc-900 bg-zinc-900 text-white transition-colors duration-200 hover:bg-zinc-800"
123-
type="button"
124-
onclick={handleLogout}
125-
title="Cerrar sesión"
126-
>
127-
<LogOut size={14} aria-hidden="true" />
128-
</button>
129-
</div>
114+
{/if}
115+
</nav>
116+
117+
<div class="flex min-w-0 items-center gap-2.5 justify-self-end">
118+
<div class="hidden min-w-0 leading-tight sm:block">
119+
<p class="m-0 max-w-56 truncate text-sm font-semibold text-zinc-800">
120+
{authStore.user?.name}
121+
</p>
122+
<p class="m-0 text-xs text-zinc-600">{authStore.user?.role.toDisplay()}</p>
130123
</div>
124+
<button
125+
class="btn-close size-11 cursor-pointer"
126+
type="button"
127+
onclick={handleLogout}
128+
title="Cerrar sesión"
129+
>
130+
<LogOut size={16} aria-hidden="true" />
131+
</button>
131132
</div>
132133
</header>
133134

‎apps/client/src/routes/(protected)/courses/[courseId]/+layout.svelte‎

Lines changed: 24 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,53 +1,37 @@
11
<script lang="ts">
22
import { page } from "$app/state"
3-
import { Users, Library, FileText } from "lucide-svelte"
4-
5-
import PageHeader from "$lib/shared/components/PageHeader.svelte"
3+
import { courseSections } from "$lib/courses/sections"
4+
import Title from "$lib/shared/components/Title.svelte"
65
76
let { children, data } = $props()
87
9-
const base = $derived(`/courses/${data.courseId}`)
10-
const banksHref = $derived(`${base}/banks`)
11-
const quizzesHref = $derived(`${base}/quizzes`)
12-
const membersHref = $derived(`${base}/members`)
13-
14-
const isQuizzes = $derived(
15-
page.url.pathname === base || page.url.pathname.startsWith(quizzesHref)
16-
)
8+
const sections = $derived(courseSections(data.courseId))
179
18-
const isBanks = $derived(page.url.pathname.startsWith(banksHref))
19-
const isMembers = $derived(page.url.pathname.startsWith(membersHref))
10+
// El detalle de un banco muestra su breadcrumb en lugar del título y las pestañas del curso.
11+
const isBankDetail = $derived(page.params.bankId !== undefined)
2012
</script>
2113

2214
<section class="flex flex-col py-2">
23-
<section class="flex flex-col">
24-
<PageHeader supra={`${data.course.code} - ${data.course.year}`} title={data.course.name}>
25-
<a
26-
class="action-tab min-w-0 shrink justify-center"
27-
data-active={isQuizzes}
28-
href={quizzesHref}
29-
>
30-
<FileText size={16} aria-hidden="true" />
31-
<span class="truncate">Cuestionarios</span>
32-
</a>
33-
<a
34-
class="action-tab min-w-0 shrink justify-center"
35-
data-active={isBanks}
36-
href={banksHref}
37-
>
38-
<Library size={16} aria-hidden="true" />
39-
<span class="truncate">Bancos de preguntas</span>
40-
</a>
41-
<a
42-
class="action-tab min-w-0 shrink justify-center"
43-
data-active={isMembers}
44-
href={membersHref}
15+
{#if !isBankDetail}
16+
<header class="mb-5">
17+
<div class="flex flex-wrap items-baseline gap-x-3 gap-y-1">
18+
<Title size="page">{data.course.name}</Title>
19+
<p class="m-0 text-sm text-zinc-600">{data.course.code} · {data.course.year}</p>
20+
</div>
21+
22+
<nav
23+
class="mt-3 flex gap-6 overflow-x-auto border-b border-zinc-300"
24+
aria-label="Secciones del curso"
4525
>
46-
<Users size={16} aria-hidden="true" />
47-
<span class="truncate">Miembros del curso</span>
48-
</a>
49-
</PageHeader>
50-
</section>
26+
{#each sections as { label, icon: Icon, href, isActive } (href)}
27+
<a class="text-tab" data-active={isActive(page.url.pathname)} {href}>
28+
<Icon size={16} aria-hidden="true" />
29+
{label}
30+
</a>
31+
{/each}
32+
</nav>
33+
</header>
34+
{/if}
5135

5236
<div>
5337
{#key page.url.pathname}

‎apps/client/src/routes/(protected)/courses/[courseId]/banks/+page.svelte‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@
5757
<header>
5858
<div class="flex flex-wrap items-start justify-between gap-3">
5959
<div>
60-
<h3 class="mt-2 mb-0 text-xl text-black">Bancos de preguntas</h3>
60+
<h3 class="sr-only">Bancos de preguntas</h3>
6161
<p class="m-0 mt-2 text-sm text-zinc-700">
6262
Sube y gestiona bancos de preguntas en formato JSON.
6363
</p>

0 commit comments

Comments
 (0)