feat: Introduce PageLayout component to standardize page headers and overall structure across various application pages.

This commit is contained in:
2026-03-02 15:35:34 -03:00
parent fe730998a9
commit 2f76ba2f87
12 changed files with 724 additions and 653 deletions
+117 -53
View File
@@ -1,13 +1,14 @@
"use client";
import React from "react";
import React, { useEffect, useState } from "react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useParams, useRouter } from "next/navigation";
import {
Layout, CheckCircle2, BarChart2, Settings, Folder,
GraduationCap, Megaphone, Users, Award, Video,
BookOpen, ShieldCheck, Radio, TrendingUp
BookOpen, ShieldCheck, Radio, TrendingUp, ChevronLeft
} from "lucide-react";
import { cmsApi, Course } from "@/lib/api";
type TabKey =
| "outline"
@@ -27,6 +28,12 @@ type TabKey =
interface CourseEditorLayoutProps {
children: React.ReactNode;
activeTab: TabKey;
/** Título de la sección específica (ej: "Grading Policy"). Si se omite, usa el nombre del curso. */
pageTitle?: string;
/** Descripción de la sección específica. */
pageDescription?: string;
/** Acciones extra que van a la derecha del header (Preview, Publish, etc.) */
pageActions?: React.ReactNode;
}
interface Tab {
@@ -43,8 +50,21 @@ interface Group {
tabs: Tab[];
}
export default function CourseEditorLayout({ children, activeTab }: CourseEditorLayoutProps) {
const { id } = useParams() as { id: string };
export default function CourseEditorLayout({
children,
activeTab,
pageTitle,
pageDescription,
pageActions,
}: CourseEditorLayoutProps) {
const params = useParams() as { id: string };
const id = params.id;
const router = useRouter();
const [course, setCourse] = useState<Course | null>(null);
useEffect(() => {
cmsApi.getCourse(id).then(setCourse).catch(console.error);
}, [id]);
const groups: Group[] = [
{
@@ -101,65 +121,109 @@ export default function CourseEditorLayout({ children, activeTab }: CourseEditor
const activeGroup = groups.find((g) => g.tabs.some((t) => t.key === activeTab));
const subTabs = activeGroup?.tabs ?? [];
return (
<div className="space-y-0">
{/* PRIMARY GROUP NAV */}
<nav
className="bg-white dark:bg-black/20 border border-black/8 dark:border-white/8 rounded-t-xl overflow-hidden"
aria-label="Grupos del editor de cursos"
>
<ul className="flex border-b border-black/8 dark:border-white/8">
{groups.map((group) => {
const Icon = group.icon;
const isGroupActive = group.key === activeGroup?.key;
// When clicking a group, go to its first tab
const groupHref = group.tabs[0].href;
return (
<li key={group.key} className="flex-shrink-0">
<Link
href={groupHref}
className={`flex items-center gap-2 px-6 py-3 text-sm font-black uppercase tracking-wider transition-all border-b-2 whitespace-nowrap ${isGroupActive
? "border-blue-600 dark:border-blue-400 text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/10"
: "border-transparent text-slate-500 dark:text-gray-400 hover:text-slate-800 dark:hover:text-gray-100 hover:bg-slate-50 dark:hover:bg-white/5"
}`}
>
<Icon className="w-4 h-4 flex-shrink-0" aria-hidden="true" />
{group.label}
</Link>
</li>
);
})}
</ul>
// Determine header text
const displayTitle = pageTitle || "Course Editor";
const displayDescription = pageDescription
|| (course ? `${course.title}` : "Cargando curso...");
{/* SECONDARY SUB-TAB NAV */}
{subTabs.length > 1 && (
<ul className="flex bg-slate-50 dark:bg-white/[0.03] border-b border-black/5 dark:border-white/5 px-2 gap-1" aria-label="Sub-secciones">
{subTabs.map((tab) => {
const Icon = tab.icon;
const isActive = tab.key === activeTab;
return (
<div className="min-h-screen bg-transparent">
<div className="max-w-7xl mx-auto px-6 pt-8 pb-12">
{/* ── PAGE HEADER ── */}
<div className="flex items-center justify-between gap-4 mb-6">
<div className="flex items-center gap-3">
<button
onClick={() => router.push("/")}
className="p-1.5 hover:bg-black/5 dark:hover:bg-white/10 rounded-full transition-colors text-slate-500 hover:text-slate-800 dark:hover:text-gray-200"
aria-label="Volver al inicio"
>
<ChevronLeft className="w-5 h-5" />
</button>
<div>
<h1 className="text-2xl font-black text-gray-900 dark:text-white tracking-tight leading-tight">
{displayTitle}
</h1>
<p className="text-sm text-slate-500 dark:text-gray-400 mt-0.5">
{displayDescription}
{course?.pacing_mode && (
<span className={`ml-2 text-xs font-bold px-1.5 py-0.5 rounded ${course.pacing_mode === "instructor_led"
? "bg-purple-100 dark:bg-purple-500/20 text-purple-700 dark:text-purple-400"
: "bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-400"
}`}>
{course.pacing_mode.replace("_", " ").toUpperCase()}
</span>
)}
</p>
</div>
</div>
{pageActions && (
<div className="flex items-center gap-3 shrink-0">
{pageActions}
</div>
)}
</div>
{/* ── TAB NAVIGATION ── */}
<nav
className="bg-white dark:bg-black/20 border border-black/8 dark:border-white/8 rounded-xl overflow-hidden mb-6"
aria-label="Grupos del editor de cursos"
>
<ul className="flex border-b border-black/8 dark:border-white/8">
{groups.map((group) => {
const Icon = group.icon;
const isGroupActive = group.key === activeGroup?.key;
const groupHref = group.tabs[0].href;
return (
<li key={tab.key} className="flex-shrink-0">
<li key={group.key} className="flex-shrink-0">
<Link
href={tab.href}
aria-current={isActive ? "page" : undefined}
className={`flex items-center gap-1.5 px-4 py-2 my-1 text-xs font-bold uppercase tracking-wider rounded-lg transition-all whitespace-nowrap ${isActive
? "bg-blue-600 text-white shadow-sm shadow-blue-500/30"
: "text-slate-500 dark:text-gray-400 hover:text-slate-800 dark:hover:text-gray-100 hover:bg-black/5 dark:hover:bg-white/5"
href={groupHref}
className={`flex items-center gap-2 px-6 py-3 text-sm font-black uppercase tracking-wider transition-all border-b-2 whitespace-nowrap ${isGroupActive
? "border-blue-600 dark:border-blue-400 text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/10"
: "border-transparent text-slate-500 dark:text-gray-400 hover:text-slate-800 dark:hover:text-gray-100 hover:bg-slate-50 dark:hover:bg-white/5"
}`}
>
<Icon className="w-3.5 h-3.5 flex-shrink-0" aria-hidden="true" />
{tab.label}
<Icon className="w-4 h-4 flex-shrink-0" aria-hidden="true" />
{group.label}
</Link>
</li>
);
})}
</ul>
)}
</nav>
{/* Content */}
<div className="pt-6 space-y-6">
{children}
{/* SECONDARY SUB-TAB NAV */}
{subTabs.length > 1 && (
<ul
className="flex bg-slate-50 dark:bg-white/[0.03] px-2 gap-1"
aria-label="Sub-secciones"
>
{subTabs.map((tab) => {
const Icon = tab.icon;
const isActive = tab.key === activeTab;
return (
<li key={tab.key} className="flex-shrink-0">
<Link
href={tab.href}
aria-current={isActive ? "page" : undefined}
className={`flex items-center gap-1.5 px-4 py-2 my-1 text-xs font-bold uppercase tracking-wider rounded-lg transition-all whitespace-nowrap ${isActive
? "bg-blue-600 text-white shadow-sm shadow-blue-500/30"
: "text-slate-500 dark:text-gray-400 hover:text-slate-800 dark:hover:text-gray-100 hover:bg-black/5 dark:hover:bg-white/5"
}`}
>
<Icon className="w-3.5 h-3.5 flex-shrink-0" aria-hidden="true" />
{tab.label}
</Link>
</li>
);
})}
</ul>
)}
</nav>
{/* ── PAGE CONTENT ── */}
<div className="space-y-6">
{children}
</div>
</div>
</div>
);
+93
View File
@@ -0,0 +1,93 @@
"use client";
import React from "react";
import Link from "next/link";
import { ChevronLeft } from "lucide-react";
interface PageLayoutProps {
/** Título principal de la página */
title: string;
/** Descripción/subtítulo opcional */
description?: string;
/** Acciones que van a la derecha del título (botones, etc.) */
actions?: React.ReactNode;
/** Componente de navegación secundaria (ej: pestañas del editor) */
navigation?: React.ReactNode;
/** Link de "volver atrás" — mostrar solo cuando sea necesario */
backHref?: string;
backLabel?: string;
/** Contenido principal de la página */
children: React.ReactNode;
/** Ancho máximo del contenedor: 'default' (max-w-7xl) o 'narrow' (max-w-5xl) */
maxWidth?: "default" | "narrow" | "wide";
}
const MAX_WIDTH_CLASS = {
default: "max-w-7xl",
narrow: "max-w-5xl",
wide: "max-w-screen-2xl",
};
/**
* Componente de layout estándar para todas las páginas del Studio.
* Garantiza: mismo padding superior, misma jerarquía tipográfica, mismo max-width.
*/
export default function PageLayout({
title,
description,
actions,
navigation,
backHref,
backLabel = "Volver",
children,
maxWidth = "default",
}: PageLayoutProps) {
const containerClass = MAX_WIDTH_CLASS[maxWidth];
return (
<div className="min-h-screen bg-transparent">
<div className={`${containerClass} mx-auto px-6 pt-10 pb-12`}>
{/* Back link */}
{backHref && (
<Link
href={backHref}
className="inline-flex items-center gap-1.5 text-sm font-medium text-slate-500 hover:text-slate-800 dark:text-gray-400 dark:hover:text-gray-100 transition-colors mb-5"
>
<ChevronLeft className="w-4 h-4" />
{backLabel}
</Link>
)}
{/* Page header */}
<div className="flex items-start justify-between gap-4 mb-8">
<div>
<h1 className="text-2xl font-black text-gray-900 dark:text-white tracking-tight leading-tight">
{title}
</h1>
{description && (
<p className="mt-1.5 text-sm text-slate-500 dark:text-gray-400">
{description}
</p>
)}
</div>
{actions && (
<div className="flex items-center gap-3 shrink-0">
{actions}
</div>
)}
</div>
{/* Optional secondary navigation (tabs, breadcrumbs, etc.) */}
{navigation && (
<div className="mb-6">
{navigation}
</div>
)}
{/* Main content */}
{children}
</div>
</div>
);
}