180 lines
5.5 KiB
TypeScript
Raw Normal View History

import type { ReactNode } from "react";
import Link from "next/link";
import { ChevronRightIcon } from "@heroicons/react/24/outline";
import { Skeleton } from "@/components/atoms/loading-skeleton";
import { ErrorState } from "@/components/atoms/error-state";
export interface BreadcrumbItem {
label: string;
href?: string;
}
interface PageLayoutProps {
icon?: ReactNode;
title: string;
description?: string;
actions?: ReactNode;
breadcrumbs?: BreadcrumbItem[];
loading?: boolean;
error?: Error | string | null;
onRetry?: () => void;
children: ReactNode;
}
export function PageLayout({
icon,
title,
description,
actions,
breadcrumbs,
loading = false,
error = null,
onRetry,
children,
}: PageLayoutProps) {
return (
<div className="py-[var(--cp-space-xl)] sm:py-[var(--cp-space-2xl)]" suppressHydrationWarning>
<div
className="max-w-[var(--cp-page-max-width)] mx-auto px-[var(--cp-space-lg)] sm:px-[var(--cp-page-padding)] md:px-8"
suppressHydrationWarning
>
{/* Breadcrumbs */}
{breadcrumbs && breadcrumbs.length > 0 && (
<nav className="mb-[var(--cp-space-lg)]" aria-label="Breadcrumb" suppressHydrationWarning>
<ol
className="flex items-center space-x-2 text-sm text-muted-foreground"
suppressHydrationWarning
>
{breadcrumbs.map((item, index) => (
<li key={index} className="flex items-center" suppressHydrationWarning>
{index > 0 && (
<ChevronRightIcon className="h-4 w-4 mx-2 text-muted-foreground/50" />
)}
{item.href ? (
<Link
href={item.href}
className="hover:text-foreground transition-colors duration-200 truncate"
suppressHydrationWarning
>
{item.label}
</Link>
) : (
<span
className="text-foreground font-medium truncate"
aria-current="page"
suppressHydrationWarning
>
{item.label}
</span>
)}
</li>
))}
</ol>
</nav>
)}
{/* Header */}
<div
className="mb-[var(--cp-space-xl)] sm:mb-[var(--cp-space-2xl)] pb-[var(--cp-space-xl)] sm:pb-[var(--cp-space-2xl)] border-b border-border"
suppressHydrationWarning
>
<div
className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-[var(--cp-space-lg)] sm:gap-[var(--cp-space-xl)]"
suppressHydrationWarning
>
<div className="flex items-start min-w-0 flex-1" suppressHydrationWarning>
{icon && (
<div
className="h-8 w-8 text-primary mr-[var(--cp-space-lg)] flex-shrink-0 mt-1"
suppressHydrationWarning
>
{icon}
</div>
)}
<div className="min-w-0 flex-1" suppressHydrationWarning>
<h1
className="text-2xl sm:text-3xl font-bold text-foreground leading-tight"
suppressHydrationWarning
>
{title}
</h1>
{description && (
<p
className="text-sm sm:text-base text-muted-foreground mt-1 leading-relaxed"
suppressHydrationWarning
>
{description}
</p>
)}
</div>
</div>
{actions && (
<div className="flex-shrink-0 w-full sm:w-auto" suppressHydrationWarning>
{actions}
</div>
)}
</div>
</div>
{/* Content with loading and error states */}
<div className="space-y-[var(--cp-space-2xl)]" suppressHydrationWarning>
{loading ? (
<PageLoadingState />
) : error ? (
<PageErrorState error={error} onRetry={onRetry} />
) : (
children
)}
</div>
</div>
</div>
);
}
function PageLoadingState() {
return (
<div className="py-[var(--cp-space-3xl)]">
<div className="space-y-6">
<div className="flex items-center gap-3">
<Skeleton className="h-8 w-8 rounded-full" />
<div className="space-y-2">
<Skeleton className="h-6 w-48" />
<Skeleton className="h-4 w-64" />
</div>
</div>
<div className="space-y-4">
{Array.from({ length: 3 }).map((_, i) => (
<div
key={i}
className="bg-card border border-border rounded-lg p-4 shadow-[var(--cp-shadow-1)]"
>
<Skeleton className="h-4 w-1/2 mb-2" />
<Skeleton className="h-3 w-3/4" />
</div>
))}
</div>
</div>
</div>
);
}
interface PageErrorStateProps {
error: Error | string;
onRetry?: () => void;
}
function PageErrorState({ error, onRetry }: PageErrorStateProps) {
const errorMessage = typeof error === "string" ? error : error.message;
return (
<div className="py-[var(--cp-space-3xl)]">
<ErrorState
title="Unable to load page"
message={errorMessage}
onRetry={onRetry}
variant="page"
/>
</div>
);
}