2025-09-17 18:43:43 +09:00
|
|
|
import type { ReactNode } from "react";
|
|
|
|
|
import Link from "next/link";
|
|
|
|
|
import { ChevronRightIcon } from "@heroicons/react/24/outline";
|
2025-09-20 11:35:40 +09:00
|
|
|
import { Skeleton } from "@/components/atoms/loading-skeleton";
|
|
|
|
|
import { ErrorState } from "@/components/atoms/error-state";
|
2025-09-17 18:43:43 +09:00
|
|
|
|
|
|
|
|
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 (
|
2026-01-05 15:57:50 +09:00
|
|
|
<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
|
|
|
|
|
>
|
2025-09-17 18:43:43 +09:00
|
|
|
{/* Breadcrumbs */}
|
|
|
|
|
{breadcrumbs && breadcrumbs.length > 0 && (
|
2026-01-05 15:57:50 +09:00
|
|
|
<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
|
|
|
|
|
>
|
2025-09-17 18:43:43 +09:00
|
|
|
{breadcrumbs.map((item, index) => (
|
2026-01-05 15:57:50 +09:00
|
|
|
<li key={index} className="flex items-center" suppressHydrationWarning>
|
2025-09-17 18:43:43 +09:00
|
|
|
{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"
|
2026-01-05 15:57:50 +09:00
|
|
|
suppressHydrationWarning
|
2025-09-17 18:43:43 +09:00
|
|
|
>
|
|
|
|
|
{item.label}
|
|
|
|
|
</Link>
|
|
|
|
|
) : (
|
2026-01-05 15:57:50 +09:00
|
|
|
<span
|
|
|
|
|
className="text-foreground font-medium truncate"
|
|
|
|
|
aria-current="page"
|
|
|
|
|
suppressHydrationWarning
|
|
|
|
|
>
|
2025-09-17 18:43:43 +09:00
|
|
|
{item.label}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</li>
|
|
|
|
|
))}
|
|
|
|
|
</ol>
|
|
|
|
|
</nav>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Header */}
|
2026-01-05 15:57:50 +09:00
|
|
|
<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>
|
2025-09-17 18:43:43 +09:00
|
|
|
{icon && (
|
2026-01-05 15:57:50 +09:00
|
|
|
<div
|
|
|
|
|
className="h-8 w-8 text-primary mr-[var(--cp-space-lg)] flex-shrink-0 mt-1"
|
|
|
|
|
suppressHydrationWarning
|
|
|
|
|
>
|
2025-09-17 18:43:43 +09:00
|
|
|
{icon}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-01-05 15:57:50 +09:00
|
|
|
<div className="min-w-0 flex-1" suppressHydrationWarning>
|
|
|
|
|
<h1
|
|
|
|
|
className="text-2xl sm:text-3xl font-bold text-foreground leading-tight"
|
|
|
|
|
suppressHydrationWarning
|
|
|
|
|
>
|
2025-09-17 18:43:43 +09:00
|
|
|
{title}
|
|
|
|
|
</h1>
|
|
|
|
|
{description && (
|
2026-01-05 15:57:50 +09:00
|
|
|
<p
|
|
|
|
|
className="text-sm sm:text-base text-muted-foreground mt-1 leading-relaxed"
|
|
|
|
|
suppressHydrationWarning
|
|
|
|
|
>
|
2025-09-17 18:43:43 +09:00
|
|
|
{description}
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-01-05 15:57:50 +09:00
|
|
|
{actions && (
|
|
|
|
|
<div className="flex-shrink-0 w-full sm:w-auto" suppressHydrationWarning>
|
|
|
|
|
{actions}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2025-09-17 18:43:43 +09:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Content with loading and error states */}
|
2026-01-05 15:57:50 +09:00
|
|
|
<div className="space-y-[var(--cp-space-2xl)]" suppressHydrationWarning>
|
2025-09-17 18:43:43 +09:00
|
|
|
{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) => (
|
2025-12-16 13:54:31 +09:00
|
|
|
<div
|
|
|
|
|
key={i}
|
|
|
|
|
className="bg-card border border-border rounded-lg p-4 shadow-[var(--cp-shadow-1)]"
|
|
|
|
|
>
|
2025-09-17 18:43:43 +09:00
|
|
|
<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>
|
|
|
|
|
);
|
|
|
|
|
}
|