feat(WEB): 범용 스켈레톤 시스템 구축

- GenericPageSkeleton 범용 컴포넌트 추가 (커스텀 페이지용)
- AuthenticatedLayout 스피너 → 스켈레톤으로 변경
- (protected)/loading.tsx GenericPageSkeleton 적용
- subscription, account-info 페이지 개별 스켈레톤 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
유병철
2026-01-22 23:01:20 +09:00
parent 1575f9e680
commit 3b328204a2
5 changed files with 219 additions and 14 deletions

View File

@@ -1,4 +1,4 @@
import { ListPageSkeleton } from '@/components/ui/skeleton';
import { GenericPageSkeleton } from '@/components/ui/skeleton';
/**
* Protected Group Loading UI
@@ -7,8 +7,12 @@ import { ListPageSkeleton } from '@/components/ui/skeleton';
* - AuthenticatedLayout 내에서 표시됨 (사이드바, 헤더 유지)
* - React Suspense 자동 적용
* - 페이지 전환 시 즉각적인 피드백
* - 스켈레톤 UI로 레이아웃 유지하며 로딩 표시
* - 범용 스켈레톤으로 모든 페이지 타입에 대응
*
* 참고:
* - 리스트 페이지: IntegratedListTemplateV2 내부에서 상세 스켈레톤 처리
* - 커스텀 페이지: 이 범용 스켈레톤이 표시됨
*/
export default function ProtectedLoading() {
return <ListPageSkeleton />;
return <GenericPageSkeleton />;
}

View File

@@ -1,9 +1,14 @@
'use client';
import { useEffect, useState } from 'react';
import { User } from 'lucide-react';
import { AccountInfoClient } from '@/components/settings/AccountInfoManagement';
import { getAccountInfo } from '@/components/settings/AccountInfoManagement/actions';
import type { AccountInfo, TermsAgreement, MarketingConsent } from '@/components/settings/AccountInfoManagement/types';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
const DEFAULT_ACCOUNT_INFO: AccountInfo = {
id: '',
@@ -44,9 +49,72 @@ export default function AccountInfoPage() {
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="text-muted-foreground"> ...</div>
</div>
<PageLayout>
<PageHeader
title="계정정보"
description="계정 정보를 관리합니다"
icon={User}
/>
<div className="space-y-6">
{/* 계정 정보 카드 스켈레톤 */}
<Card>
<CardHeader>
<Skeleton className="h-5 w-24" />
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-24 w-24 rounded-full" />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Skeleton className="h-4 w-16" />
<Skeleton className="h-10 w-full" />
</div>
<div className="space-y-2">
<Skeleton className="h-4 w-16" />
<Skeleton className="h-10 w-20" />
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Skeleton className="h-4 w-12" />
<Skeleton className="h-10 w-full" />
</div>
<div className="space-y-2">
<Skeleton className="h-4 w-12" />
<Skeleton className="h-10 w-full" />
</div>
</div>
</CardContent>
</Card>
{/* 약관 동의 정보 카드 스켈레톤 */}
<Card>
<CardHeader>
<Skeleton className="h-5 w-32" />
</CardHeader>
<CardContent className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-4 w-32" />
</div>
<div className="space-y-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-4 w-32" />
</div>
</div>
<div className="space-y-4">
<Skeleton className="h-4 w-36" />
<div className="space-y-3 pl-4">
<Skeleton className="h-5 w-full" />
<Skeleton className="h-5 w-full" />
</div>
</div>
</CardContent>
</Card>
</div>
</PageLayout>
);
}

View File

@@ -1,8 +1,13 @@
'use client';
import { useEffect, useState } from 'react';
import { CreditCard } from 'lucide-react';
import { SubscriptionManagement } from '@/components/settings/SubscriptionManagement';
import { getSubscriptionData } from '@/components/settings/SubscriptionManagement/actions';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { Card, CardContent } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
export default function SubscriptionPage() {
const [data, setData] = useState<Awaited<ReturnType<typeof getSubscriptionData>>['data']>(undefined);
@@ -18,9 +23,47 @@ export default function SubscriptionPage() {
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="text-muted-foreground"> ...</div>
</div>
<PageLayout>
<PageHeader
title="구독관리"
description="구독 정보를 관리합니다"
icon={CreditCard}
/>
{/* 헤더 액션 버튼 스켈레톤 */}
<div className="flex justify-end gap-2 mb-4">
<Skeleton className="h-10 w-32" />
<Skeleton className="h-10 w-24" />
</div>
<div className="space-y-6">
{/* 구독 정보 카드 그리드 스켈레톤 */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{[1, 2, 3].map((i) => (
<Card key={i}>
<CardContent className="pt-6">
<Skeleton className="h-4 w-24 mb-2" />
<Skeleton className="h-8 w-32" />
</CardContent>
</Card>
))}
</div>
{/* 구독 정보 카드 스켈레톤 */}
<Card>
<CardContent className="pt-6">
<Skeleton className="h-4 w-20 mb-2" />
<Skeleton className="h-6 w-32 mb-6" />
<div className="space-y-6">
{[1, 2, 3].map((i) => (
<div key={i} className="flex items-center gap-4">
<Skeleton className="h-4 w-24 flex-shrink-0" />
<Skeleton className="h-2 flex-1" />
<Skeleton className="h-4 w-24" />
</div>
))}
</div>
</CardContent>
</Card>
</div>
</PageLayout>
);
}

View File

@@ -598,6 +598,65 @@ function PageHeaderSkeleton({
);
}
// ============================================
// 14. 범용 페이지 스켈레톤 (커스텀 페이지용)
// ============================================
/**
* 모든 페이지에 대응하는 심플한 범용 스켈레톤
* - 리스트 페이지: IntegratedListTemplateV2 내부에서 처리
* - 커스텀 페이지: 이 스켈레톤 사용 (settings, qms 등)
*/
function GenericPageSkeleton() {
return (
<div className="p-3 md:p-6 space-y-6 animate-pulse">
{/* 페이지 헤더 영역 */}
<div className="flex items-center gap-3">
<Skeleton className="h-10 w-10 rounded-lg" />
<div className="space-y-2">
<Skeleton className="h-7 w-40" />
<Skeleton className="h-4 w-56" />
</div>
</div>
{/* 액션 버튼 영역 */}
<div className="flex justify-end gap-2">
<Skeleton className="h-10 w-24 rounded-md" />
<Skeleton className="h-10 w-24 rounded-md" />
</div>
{/* 콘텐츠 카드 영역 */}
<Card>
<CardContent className="p-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="space-y-2">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-10 w-full rounded-md" />
</div>
))}
</div>
</CardContent>
</Card>
{/* 추가 콘텐츠 카드 */}
<Card>
<CardContent className="p-6">
<Skeleton className="h-4 w-32 mb-4" />
<div className="space-y-4">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex items-center gap-4">
<Skeleton className="h-4 w-24 flex-shrink-0" />
<Skeleton className="h-2 flex-1 rounded-full" />
<Skeleton className="h-4 w-20" />
</div>
))}
</div>
</CardContent>
</Card>
</div>
);
}
// ============================================
// Export
// ============================================
@@ -615,4 +674,5 @@ export {
ListPageSkeleton,
PageHeaderSkeleton,
ContentSkeleton,
GenericPageSkeleton,
};

View File

@@ -477,13 +477,43 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro
// By removing this check, we allow the component to render immediately with default values
// and update once hydration completes through the useEffect above.
// 🔧 새로고침 시 스피너 표시 (hydration 전)
// 🔧 새로고침 시 스켈레톤 표시 (hydration 전)
// GenericPageSkeleton import는 상단에 추가 필요
if (!isMounted) {
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="text-center">
<div className="inline-block h-10 w-10 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent mb-4"></div>
<p className="text-muted-foreground text-sm"> ...</p>
<div className="min-h-screen flex flex-col w-full">
{/* 헤더 스켈레톤 */}
<div className="px-8 py-5 mx-3 mt-3 mb-0 rounded-2xl bg-muted/50 animate-pulse h-[82px]" />
{/* 사이드바 + 콘텐츠 스켈레톤 */}
<div className="flex flex-1 gap-3 px-3 pb-3">
{/* 사이드바 스켈레톤 */}
<div className="hidden md:block w-64 mt-3">
<div className="h-[calc(100vh-118px)] rounded-2xl bg-muted/50 animate-pulse" />
</div>
{/* 콘텐츠 스켈레톤 */}
<div className="flex-1 p-3 md:p-6 space-y-6 animate-pulse">
{/* 페이지 헤더 */}
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-lg bg-muted" />
<div className="space-y-2">
<div className="h-7 w-40 rounded bg-muted" />
<div className="h-4 w-56 rounded bg-muted" />
</div>
</div>
{/* 콘텐츠 카드 */}
<div className="rounded-lg border bg-card p-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{[1, 2, 3, 4, 5, 6].map((i) => (
<div key={i} className="space-y-2">
<div className="h-4 w-20 rounded bg-muted" />
<div className="h-10 w-full rounded bg-muted" />
</div>
))}
</div>
</div>
</div>
</div>
</div>
);