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;
|
2026-01-15 11:28:25 +09:00
|
|
|
href?: string | undefined;
|
2025-09-17 18:43:43 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface PageLayoutProps {
|
2026-01-15 11:28:25 +09:00
|
|
|
icon?: ReactNode | undefined;
|
2025-09-17 18:43:43 +09:00
|
|
|
title: string;
|
2026-01-15 11:28:25 +09:00
|
|
|
description?: string | undefined;
|
|
|
|
|
actions?: ReactNode | undefined;
|
|
|
|
|
breadcrumbs?: BreadcrumbItem[] | undefined;
|
|
|
|
|
loading?: boolean | undefined;
|
2026-03-04 11:59:22 +09:00
|
|
|
loadingFallback?: ReactNode | undefined;
|
2026-01-15 11:28:25 +09:00
|
|
|
error?: Error | string | null | undefined;
|
|
|
|
|
onRetry?: (() => void) | undefined;
|
2025-09-17 18:43:43 +09:00
|
|
|
children: ReactNode;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function PageLayout({
|
|
|
|
|
icon,
|
|
|
|
|
title,
|
|
|
|
|
description,
|
|
|
|
|
actions,
|
|
|
|
|
breadcrumbs,
|
|
|
|
|
loading = false,
|
2026-03-04 11:59:22 +09:00
|
|
|
loadingFallback,
|
2025-09-17 18:43:43 +09:00
|
|
|
error = null,
|
|
|
|
|
onRetry,
|
|
|
|
|
children,
|
|
|
|
|
}: PageLayoutProps) {
|
|
|
|
|
return (
|
2026-03-04 11:59:22 +09:00
|
|
|
<div className="py-[var(--cp-space-lg)] sm:py-[var(--cp-space-xl)] md:py-[var(--cp-space-2xl)]">
|
|
|
|
|
<div className="max-w-[var(--cp-page-max-width)] mx-auto px-[var(--cp-space-md)] sm:px-[var(--cp-space-lg)] md:px-8">
|
2026-02-04 18:23:58 +09:00
|
|
|
{/* Breadcrumbs - scrollable on mobile */}
|
2025-09-17 18:43:43 +09:00
|
|
|
{breadcrumbs && breadcrumbs.length > 0 && (
|
2026-02-04 18:23:58 +09:00
|
|
|
<nav
|
|
|
|
|
className="mb-[var(--cp-space-md)] sm:mb-[var(--cp-space-lg)] -mx-[var(--cp-space-md)] px-[var(--cp-space-md)] sm:mx-0 sm:px-0 overflow-x-auto scrollbar-none"
|
|
|
|
|
aria-label="Breadcrumb"
|
|
|
|
|
>
|
2026-03-04 11:59:22 +09:00
|
|
|
<ol className="flex items-center space-x-1 sm:space-x-2 text-sm text-muted-foreground whitespace-nowrap">
|
2025-09-17 18:43:43 +09:00
|
|
|
{breadcrumbs.map((item, index) => (
|
2026-03-04 11:59:22 +09:00
|
|
|
<li key={index} className="flex items-center flex-shrink-0">
|
2025-09-17 18:43:43 +09:00
|
|
|
{index > 0 && (
|
2026-02-04 18:23:58 +09:00
|
|
|
<ChevronRightIcon className="h-4 w-4 mx-1 sm:mx-2 text-muted-foreground/50 flex-shrink-0" />
|
2025-09-17 18:43:43 +09:00
|
|
|
)}
|
|
|
|
|
{item.href ? (
|
|
|
|
|
<Link
|
|
|
|
|
href={item.href}
|
2026-02-04 18:23:58 +09:00
|
|
|
className="hover:text-foreground transition-colors duration-200 py-1 px-0.5 -mx-0.5 rounded"
|
2025-09-17 18:43:43 +09:00
|
|
|
>
|
|
|
|
|
{item.label}
|
|
|
|
|
</Link>
|
|
|
|
|
) : (
|
2026-03-04 11:59:22 +09:00
|
|
|
<span className="text-foreground font-medium py-1" aria-current="page">
|
2025-09-17 18:43:43 +09:00
|
|
|
{item.label}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</li>
|
|
|
|
|
))}
|
|
|
|
|
</ol>
|
|
|
|
|
</nav>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Header */}
|
2026-03-04 11:59:22 +09:00
|
|
|
<div className="mb-[var(--cp-space-lg)] sm:mb-[var(--cp-space-xl)] md:mb-[var(--cp-space-2xl)] pb-[var(--cp-space-lg)] sm:pb-[var(--cp-space-xl)] md:pb-[var(--cp-space-2xl)] border-b border-border">
|
|
|
|
|
<div className="flex flex-col gap-[var(--cp-space-md)] sm:gap-[var(--cp-space-lg)]">
|
2026-02-04 18:23:58 +09:00
|
|
|
{/* Title row */}
|
2026-03-04 11:59:22 +09:00
|
|
|
<div className="flex items-start min-w-0 flex-1">
|
2025-09-17 18:43:43 +09:00
|
|
|
{icon && (
|
2026-03-04 11:59:22 +09:00
|
|
|
<div className="h-7 w-7 sm:h-8 sm:w-8 text-primary mr-[var(--cp-space-md)] sm:mr-[var(--cp-space-lg)] flex-shrink-0 mt-0.5">
|
2025-09-17 18:43:43 +09:00
|
|
|
{icon}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-03-04 11:59:22 +09:00
|
|
|
<div className="min-w-0 flex-1">
|
|
|
|
|
<h1 className="text-xl sm:text-2xl md:text-3xl font-bold text-foreground leading-tight">
|
2025-09-17 18:43:43 +09:00
|
|
|
{title}
|
|
|
|
|
</h1>
|
|
|
|
|
{description && (
|
2026-03-04 11:59:22 +09:00
|
|
|
<p className="text-sm text-muted-foreground mt-1 leading-relaxed line-clamp-2 sm:line-clamp-none">
|
2025-09-17 18:43:43 +09:00
|
|
|
{description}
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-02-04 18:23:58 +09:00
|
|
|
|
|
|
|
|
{/* Actions - full width on mobile, stacks buttons */}
|
2026-01-05 15:57:50 +09:00
|
|
|
{actions && (
|
2026-03-04 11:59:22 +09:00
|
|
|
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-3 w-full sm:w-auto [&>*]:w-full [&>*]:sm:w-auto">
|
2026-01-05 15:57:50 +09:00
|
|
|
{actions}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2025-09-17 18:43:43 +09:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Content with loading and error states */}
|
2026-03-04 11:59:22 +09:00
|
|
|
<div className="space-y-[var(--cp-space-xl)] sm:space-y-[var(--cp-space-2xl)]">
|
2026-03-05 15:31:47 +09:00
|
|
|
{renderPageContent({
|
|
|
|
|
loading,
|
|
|
|
|
error: error ?? undefined,
|
|
|
|
|
children,
|
|
|
|
|
onRetry,
|
|
|
|
|
loadingFallback,
|
|
|
|
|
})}
|
2025-09-17 18:43:43 +09:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 15:31:47 +09:00
|
|
|
function renderPageContent({
|
|
|
|
|
loading,
|
|
|
|
|
error,
|
|
|
|
|
children,
|
|
|
|
|
onRetry,
|
|
|
|
|
loadingFallback,
|
|
|
|
|
}: {
|
|
|
|
|
loading: boolean | undefined;
|
|
|
|
|
error: Error | string | undefined;
|
|
|
|
|
children: React.ReactNode;
|
|
|
|
|
onRetry: (() => void) | undefined;
|
|
|
|
|
loadingFallback: React.ReactNode;
|
|
|
|
|
}): React.ReactNode {
|
2026-01-15 11:28:25 +09:00
|
|
|
if (loading) {
|
2026-03-04 11:59:22 +09:00
|
|
|
return loadingFallback ?? <PageLoadingState />;
|
2026-01-15 11:28:25 +09:00
|
|
|
}
|
|
|
|
|
if (error) {
|
|
|
|
|
return <PageErrorState error={error} onRetry={onRetry} />;
|
|
|
|
|
}
|
|
|
|
|
return children;
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-17 18:43:43 +09:00
|
|
|
function PageLoadingState() {
|
|
|
|
|
return (
|
2026-02-04 18:23:58 +09:00
|
|
|
<div className="py-[var(--cp-space-xl)] sm:py-[var(--cp-space-3xl)]">
|
|
|
|
|
<div className="space-y-4 sm:space-y-6">
|
2025-09-17 18:43:43 +09:00
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
<Skeleton className="h-8 w-8 rounded-full" />
|
|
|
|
|
<div className="space-y-2">
|
2026-02-04 18:23:58 +09:00
|
|
|
<Skeleton className="h-6 w-36 sm:w-48" />
|
|
|
|
|
<Skeleton className="h-4 w-48 sm:w-64" />
|
2025-09-17 18:43:43 +09:00
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-02-04 18:23:58 +09:00
|
|
|
<div className="space-y-3 sm:space-y-4">
|
2025-09-17 18:43:43 +09:00
|
|
|
{Array.from({ length: 3 }).map((_, i) => (
|
2025-12-16 13:54:31 +09:00
|
|
|
<div
|
|
|
|
|
key={i}
|
2026-02-04 18:23:58 +09:00
|
|
|
className="bg-card border border-border rounded-lg p-3 sm:p-4 shadow-[var(--cp-shadow-1)]"
|
2025-12-16 13:54:31 +09:00
|
|
|
>
|
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;
|
2026-01-15 11:28:25 +09:00
|
|
|
onRetry?: (() => void) | undefined;
|
2025-09-17 18:43:43 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function PageErrorState({ error, onRetry }: PageErrorStateProps) {
|
|
|
|
|
const errorMessage = typeof error === "string" ? error : error.message;
|
|
|
|
|
|
|
|
|
|
return (
|
2026-02-04 18:23:58 +09:00
|
|
|
<div className="py-[var(--cp-space-xl)] sm:py-[var(--cp-space-3xl)]">
|
2025-09-17 18:43:43 +09:00
|
|
|
<ErrorState
|
|
|
|
|
title="Unable to load page"
|
|
|
|
|
message={errorMessage}
|
|
|
|
|
onRetry={onRetry}
|
|
|
|
|
variant="page"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|