Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions apps/admin/src/app/(authed)/error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
'use client';

import { Button } from '@tecnova/ui/components/button';
import { DataError } from '@tecnova/ui/components/data-error';

// 認証必須セクションの描画時クラッシュを拾う Error Boundary。
// 各ページの try/catch では拾えない描画中の throw をここで受ける。
// error.tsx は Client Component 必須。
export default function AuthedError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<main className="flex flex-1 flex-col items-center justify-center gap-4 p-8">
<div className="w-full max-w-md">
<DataError
title="エラーが発生しました"
message={error.message || '予期しないエラーが発生しました'}
/>
</div>
<Button type="button" variant="outline" onClick={reset}>
再試行
</Button>
</main>
);
}
10 changes: 6 additions & 4 deletions apps/admin/src/app/(authed)/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
import { MeProvider } from '@tecnova/ui/components/me-provider';
import { MeGate, MeProvider } from '@tecnova/ui/components/me-provider';
import { Toaster } from '@tecnova/ui/components/sonner';
import { AppShell } from '@/components/app-shell';

// 認証必須セクション全体のレイアウト。MeProvider が /api/me を取得し、
// AppShell が共通ヘッダーとナビを描画する。/login は別ルートグループなので
// このレイアウトは適用されない
// AppShell(サイドバー等のクローム)は認証解決を待たずに即描画する。
// ページ本文だけ MeGate でゲートする(即時シェル)。/login は別ルートグループ
// CRUD のフィードバックはここに置いた Toaster でまとめて受ける。
export default function AuthedLayout({ children }: { children: React.ReactNode }) {
return (
<MeProvider>
<AppShell>{children}</AppShell>
<AppShell>
<MeGate>{children}</MeGate>
</AppShell>
<Toaster richColors position="top-right" />
</MeProvider>
);
Expand Down
13 changes: 13 additions & 0 deletions apps/admin/src/app/(authed)/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Skeleton } from '@tecnova/ui/components/skeleton';

// ソフトナビ時のコンテンツスケルトン。即時シェル化により AppShell(サイドバー等)は
// 保たれるため、本文スロットだけがこのフォールバックに置き換わる。
export default function AuthedLoading() {
return (
<div className="flex flex-1 flex-col gap-6 p-4 md:p-8">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-5 w-72" />
<Skeleton className="h-64 w-full" />
</div>
);
}
27 changes: 27 additions & 0 deletions apps/admin/src/app/error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
'use client';

import { Button } from '@tecnova/ui/components/button';
import { DataError } from '@tecnova/ui/components/data-error';

// ルート段の Error Boundary((authed) の境界で拾えなかった描画クラッシュの受け皿)。
export default function RootError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<main className="flex min-h-svh flex-col items-center justify-center gap-4 p-8">
<div className="w-full max-w-md">
<DataError
title="エラーが発生しました"
message={error.message || '予期しないエラーが発生しました'}
/>
</div>
<Button type="button" variant="outline" onClick={reset}>
再試行
</Button>
</main>
);
}
76 changes: 44 additions & 32 deletions apps/admin/src/components/bottom-nav.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client';

import { useMe } from '@tecnova/ui/components/me-provider';
import { useMeState } from '@tecnova/ui/components/me-provider';
import { Skeleton } from '@tecnova/ui/components/skeleton';
import { cn } from '@tecnova/ui/lib/utils';
import { motion, useReducedMotion } from 'motion/react';
import Link from 'next/link';
Expand All @@ -10,11 +11,13 @@ import { isNavItemActive, visibleNavItems } from './nav-items';

// モバイル用のボトムタブバー。画面下に固定し、iPhone のホームインジケータを
// 避けるため safe-area ぶんの余白を足す。ロールに応じて 3〜5 タブを出す。
// 認証解決前はバーの枠だけ即描画し、ロール依存のタブはスケルトンにする(即時シェル)。
export function BottomNav({ className }: { className?: string }) {
const me = useMe();
const meState = useMeState();
const pathname = usePathname();
const prefersReduced = useReducedMotion();
const items = visibleNavItems(me.mentor.role);
const me = meState.status === 'ok' ? meState.me : null;
const items = me ? visibleNavItems(me.mentor.role) : [];

return (
<nav
Expand All @@ -25,36 +28,45 @@ export function BottomNav({ className }: { className?: string }) {
)}
>
<ul className="flex items-stretch">
{items.map((item) => {
const active = isNavItemActive(item, pathname);
return (
<li key={item.href} className="flex-1">
<Link
href={item.href}
aria-current={active ? 'page' : undefined}
className={cn(
'relative flex h-14 flex-col items-center justify-center gap-1 text-[10px] font-medium transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset focus-visible:outline-none',
// dark の --primary は背景に対してコントラスト不足(2.6:1)なので、
// dark では明るい sidebar-primary を使って AA を満たす。
active ? 'text-primary dark:text-sidebar-primary' : 'text-muted-foreground',
)}
>
{active &&
(prefersReduced ? (
<span className="absolute inset-x-4 top-0 h-0.5 rounded-full bg-primary dark:bg-sidebar-primary" />
) : (
<motion.span
layoutId="bottomnav-active"
transition={navIndicatorTransition}
className="absolute inset-x-4 top-0 h-0.5 rounded-full bg-primary dark:bg-sidebar-primary"
/>
))}
<item.Icon className="size-5" stroke={active ? 2.2 : 1.7} />
<span className="leading-none">{item.shortLabel}</span>
</Link>
{!me &&
// ロール未確定(ロード中)はスケルトンのタブを出す。
[0, 1, 2, 3, 4].map((i) => (
<li key={i} className="flex flex-1 flex-col items-center justify-center gap-1 py-2">
<Skeleton className="size-5 rounded-md" />
<Skeleton className="h-2 w-8" />
</li>
);
})}
))}
{me &&
items.map((item) => {
const active = isNavItemActive(item, pathname);
return (
<li key={item.href} className="flex-1">
<Link
href={item.href}
aria-current={active ? 'page' : undefined}
className={cn(
'relative flex h-14 flex-col items-center justify-center gap-1 text-[10px] font-medium transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset focus-visible:outline-none',
// dark の --primary は背景に対してコントラスト不足(2.6:1)なので、
// dark では明るい sidebar-primary を使って AA を満たす。
active ? 'text-primary dark:text-sidebar-primary' : 'text-muted-foreground',
)}
>
{active &&
(prefersReduced ? (
<span className="absolute inset-x-4 top-0 h-0.5 rounded-full bg-primary dark:bg-sidebar-primary" />
) : (
<motion.span
layoutId="bottomnav-active"
transition={navIndicatorTransition}
className="absolute inset-x-4 top-0 h-0.5 rounded-full bg-primary dark:bg-sidebar-primary"
/>
))}
<item.Icon className="size-5" stroke={active ? 2.2 : 1.7} />
<span className="leading-none">{item.shortLabel}</span>
</Link>
</li>
);
})}
</ul>
</nav>
);
Expand Down
35 changes: 21 additions & 14 deletions apps/admin/src/components/mobile-top-bar.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
'use client';

import { useMe } from '@tecnova/ui/components/me-provider';
import { useMeState } from '@tecnova/ui/components/me-provider';
import { Skeleton } from '@tecnova/ui/components/skeleton';
import { ThemeToggle } from '@tecnova/ui/components/theme-toggle';
import { cn } from '@tecnova/ui/lib/utils';
import { AccountMenu } from './account-menu';
import { BrandLogo } from './brand-logo';

// モバイル用のトップバー。左にブランド、右にテーマ切替とアカウント。
// ページタイトルは各ページの PageHeader が担うのでここでは出さない。
// 認証解決前はブランド/テーマ切替を即描画し、アカウントだけスケルトンにする。
export function MobileTopBar({ className }: { className?: string }) {
const me = useMe();
const meState = useMeState();
const me = meState.status === 'ok' ? meState.me : null;

return (
<header
Expand All @@ -27,18 +30,22 @@ export function MobileTopBar({ className }: { className?: string }) {
<div className="flex shrink-0 items-center gap-1">
{/* モバイルはタッチ確保のため 40px のヒットエリアにする。 */}
<ThemeToggle size="icon-lg" />
<AccountMenu
align="end"
trigger={
<button
type="button"
aria-label="アカウント"
className="flex size-10 items-center justify-center rounded-full bg-secondary text-sm font-semibold text-secondary-foreground transition-colors hover:bg-secondary/80 focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none"
>
{me.mentor.name.charAt(0)}
</button>
}
/>
{me ? (
<AccountMenu
align="end"
trigger={
<button
type="button"
aria-label="アカウント"
className="flex size-10 items-center justify-center rounded-full bg-secondary text-sm font-semibold text-secondary-foreground transition-colors hover:bg-secondary/80 focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none"
>
{me.mentor.name.charAt(0)}
</button>
}
/>
) : (
<Skeleton className="size-10 rounded-full" />
)}
</div>
</header>
);
Expand Down
Loading
Loading