diff --git a/apps/admin/src/app/(authed)/error.tsx b/apps/admin/src/app/(authed)/error.tsx new file mode 100644 index 0000000..ceb9289 --- /dev/null +++ b/apps/admin/src/app/(authed)/error.tsx @@ -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 ( +
+
+ +
+ +
+ ); +} diff --git a/apps/admin/src/app/(authed)/layout.tsx b/apps/admin/src/app/(authed)/layout.tsx index 522f955..459e4bb 100644 --- a/apps/admin/src/app/(authed)/layout.tsx +++ b/apps/admin/src/app/(authed)/layout.tsx @@ -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 ( - {children} + + {children} + ); diff --git a/apps/admin/src/app/(authed)/loading.tsx b/apps/admin/src/app/(authed)/loading.tsx new file mode 100644 index 0000000..43fa101 --- /dev/null +++ b/apps/admin/src/app/(authed)/loading.tsx @@ -0,0 +1,13 @@ +import { Skeleton } from '@tecnova/ui/components/skeleton'; + +// ソフトナビ時のコンテンツスケルトン。即時シェル化により AppShell(サイドバー等)は +// 保たれるため、本文スロットだけがこのフォールバックに置き換わる。 +export default function AuthedLoading() { + return ( +
+ + + +
+ ); +} diff --git a/apps/admin/src/app/error.tsx b/apps/admin/src/app/error.tsx new file mode 100644 index 0000000..fd2b7ab --- /dev/null +++ b/apps/admin/src/app/error.tsx @@ -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 ( +
+
+ +
+ +
+ ); +} diff --git a/apps/admin/src/components/bottom-nav.tsx b/apps/admin/src/components/bottom-nav.tsx index 769637e..32250d0 100644 --- a/apps/admin/src/components/bottom-nav.tsx +++ b/apps/admin/src/components/bottom-nav.tsx @@ -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'; @@ -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 ( ); diff --git a/apps/admin/src/components/mobile-top-bar.tsx b/apps/admin/src/components/mobile-top-bar.tsx index a8aa2c9..f8c1288 100644 --- a/apps/admin/src/components/mobile-top-bar.tsx +++ b/apps/admin/src/components/mobile-top-bar.tsx @@ -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 { ThemeToggle } from '@tecnova/ui/components/theme-toggle'; import { cn } from '@tecnova/ui/lib/utils'; import { AccountMenu } from './account-menu'; @@ -8,8 +9,10 @@ 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 (
{/* モバイルはタッチ確保のため 40px のヒットエリアにする。 */} - - {me.mentor.name.charAt(0)} - - } - /> + {me ? ( + + {me.mentor.name.charAt(0)} + + } + /> + ) : ( + + )}
); diff --git a/apps/admin/src/components/sidebar.tsx b/apps/admin/src/components/sidebar.tsx index 23dbf17..0b77867 100644 --- a/apps/admin/src/components/sidebar.tsx +++ b/apps/admin/src/components/sidebar.tsx @@ -2,7 +2,8 @@ import { IconSelector } from '@tabler/icons-react'; import { Button } from '@tecnova/ui/components/button'; -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 { motion, useReducedMotion } from 'motion/react'; @@ -15,11 +16,14 @@ import { isNavItemActive, visibleNavItems } from './nav-items'; // デスクトップ用の固定左サイドバー。ブランド → ナビ → フッター(テーマ切替 + // アカウント)の3段構成。モバイルでは AppShell 側で hidden にする。 +// 認証解決前(me ロード中)はクロームを即描画し、ロール依存のナビとアカウントだけ +// スケルトンにする(即時シェル)。 export function Sidebar({ 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 ( diff --git a/apps/checkin/src/components/app-shell.tsx b/apps/checkin/src/components/app-shell.tsx index 5e728be..a4515f4 100644 --- a/apps/checkin/src/components/app-shell.tsx +++ b/apps/checkin/src/components/app-shell.tsx @@ -2,7 +2,7 @@ import { IconClipboardCheck, IconHome, IconSettings } from '@tabler/icons-react'; import { Button } from '@tecnova/ui/components/button'; -import { MeProvider } from '@tecnova/ui/components/me-provider'; +import { MeGate, MeProvider } from '@tecnova/ui/components/me-provider'; import Image from 'next/image'; import Link from 'next/link'; import { usePathname } from 'next/navigation'; @@ -20,13 +20,15 @@ export function AppShell({ children }: Props) { } return ( - - }>{children} + + + }>{children} + ); } diff --git a/apps/signage/src/components/app-shell.tsx b/apps/signage/src/components/app-shell.tsx index 5400399..a9e79f2 100644 --- a/apps/signage/src/components/app-shell.tsx +++ b/apps/signage/src/components/app-shell.tsx @@ -1,6 +1,6 @@ 'use client'; -import { MeProvider } from '@tecnova/ui/components/me-provider'; +import { MeGate, MeProvider } from '@tecnova/ui/components/me-provider'; import { usePathname } from 'next/navigation'; // サイネージは全画面表示なので checkin のようなヘッダ chrome は持たず、MeProvider だけで包む。 @@ -11,13 +11,15 @@ export function AppShell({ children }: { children: React.ReactNode }) { return <>{children}; } return ( - - {children} + + + {children} + ); } diff --git a/docs/superpowers/specs/2026-06-02-admin-instant-shell-design.md b/docs/superpowers/specs/2026-06-02-admin-instant-shell-design.md new file mode 100644 index 0000000..23816a2 --- /dev/null +++ b/docs/superpowers/specs/2026-06-02-admin-instant-shell-design.md @@ -0,0 +1,84 @@ +# Sub-project 2: 耐障害性+即時インタラクティブシェル 設計 + +作成日: 2026-06-02 / 親spec: [`2026-06-02-admin-data-layer-modernization-design.md`](./2026-06-02-admin-data-layer-modernization-design.md) +ブランチ: `feat/admin-resilience-shell`(`refactor/admin-data-layer` から分岐 → PR は develop) + +## 0. ゴールと選択 + +ユーザー選択 = **フル即時インタラクティブシェル**。admin のコールドロードで、サイドバー等のクロームを**即描画**し、ユーザー依存部(ロール別ナビ・アカウント名)だけスケルトンにして `/api/me` 解決後に埋める。加えて `error.tsx`(描画クラッシュの安全網)と `loading.tsx`(ナビ時のコンテンツスケルトン)を追加する。 + +**コスト/リスクの正直な評価:** これは内部向け管理画面で「クロームが ~100–200ms 早く出る」ための変更で、`MeProvider`(**3アプリ共有**)の契約に手を入れる。便益は限定的・リスクは中。ユーザーは最小案(推奨)よりこちらを明示選択済み。後方互換を保ち、3アプリすべてを検証して安全に着地させる。 + +## 1. 中核設計: `MeProvider`(状態のみ)+ `MeGate`(ゲート)に分離 + +現状の `MeProvider` は「/api/me 取得 + 解決まで全体をスケルトンでゲート + ok のとき context 提供」を一手に担う。これを分離する(`packages/ui/src/components/me-provider.tsx`)。 + +```ts +export type MeState = + | { status: 'loading' } + | { status: 'ok'; me: Me } + | { status: 'forbidden'; message: string } + | { status: 'error'; message: string }; +``` + +- **`MeProvider`**: /api/me を取得し、401 は `window.location.replace(loginPath)`。**常に** children を `` で包む(ゲートしない・フォールバックを描画しない)。props: `loginPath?`(既定 `/login`)。 +- **`useMeState(): MeState`**: クローム用(me が null のときも扱える)。 +- **`useMe(): Me`**: 従来どおり non-null の `Me` を返す。status が ok でなければ throw(= `MeGate` の内側でのみ使う前提。既存の content 消費者はすべてゲート内なので安全)。 +- **`MeGate`**: status==='ok' のときだけ children を描画。loading/forbidden/error は従来 `MeProvider` が持っていたフォールバックを描画。props: `forbiddenMessage?`, `loadingClassName?`, `forbiddenClassName?`, `errorClassName?`, `loadingFallback?`(任意の ReactNode。指定時は loadingClassName より優先=admin はシェル型スケルトンも渡せる)。 + +**後方互換:** 旧 `{app}` は `{app}` に置換するだけで**完全に同じ挙動**になる。`useMe()` のシグネチャは不変。 + +## 2. checkin / signage 移行(挙動を完全維持) + +両アプリは `MeProvider` を「全体ゲート」として使用(signage は `useMe` 0、checkin は settings で 1)。フォールバック系 props を `MeGate` へ移すだけ。 + +- checkin `apps/checkin/src/components/app-shell.tsx`: `{children}`(Chrome は従来どおりゲート内=挙動不変)。 +- signage `apps/signage/src/components/app-shell.tsx`: 同様に `{children}`。 +- checkin settings の `useMe()` はゲート内なので不変。 + +## 3. admin 即時シェル + +`apps/admin/src/app/(authed)/layout.tsx`: + +```tsx + + + {children} + + + +``` + +- `AppShell` は**常に**描画(コールドロード中もクローム可視)。ページ本文だけ `MeGate` でゲート。 +- 以下を `useMe()` → `useMeState()` に変更し、status!=='ok' の間はスケルトン表示: + - `sidebar.tsx`: ナビ一覧=プレースホルダ行(ロール未確定のため実項目は出さない)、フッターのアカウント=スケルトン。ブランドロゴ(`BrandLogo`)は me 不要なので即表示。アクティブピル/layoutId は ok 後。 + - `bottom-nav.tsx`: タブ=スケルトン(モバイル)。 + - `mobile-top-bar.tsx`: アカウントボタン=スケルトン。ロゴは即表示。 + - `account-menu.tsx`: me が無い間はトリガをスケルトンにし、メニュー自体は ok 後のみ。 +- ナビは `visibleNavItems(role)` がロール必須なので、ok までスケルトン → ok で実ナビに差し替え。 +- content(mentors/pre-registrations 含む)の `useMe()` は `MeGate` 内なので不変。 + +## 4. error.tsx(描画クラッシュの安全網) + +- `apps/admin/src/app/(authed)/error.tsx`(`'use client'` 必須): `DataError`(SP1)を再利用+「再試行」(`reset()`)。 +- `apps/admin/src/app/error.tsx`(root, `'use client'`): 同様の最小フォールバック。 +- これは現状の per-page try/catch では拾えない**描画時 throw** を拾う。 + +## 5. loading.tsx(ナビ時のコンテンツスケルトン) + +即時シェル化により layout(AppShell)はナビ間で永続するため、`(authed)/loading.tsx` はコンテンツスロットのスケルトンとして意味を持つ(ソフトナビ時に AppShell を保ったまま本文だけスケルトン)。汎用のコンテンツスケルトン(リスト/サマリ風)を 1 つ用意して充てる。 + +## 6. 非ゴール / 注意 + +- `cacheComponents`/PPR は無効のまま。 +- `useOptimistic` は今回見送り(別途・任意)。 +- セッション 401 の遷移は `MeProvider` に残す(副作用は 1 箇所)。 +- 検証は **3 アプリすべて**: admin(Playwright で即時シェル=ローディング中にサイドバー骨格が見える/ok で実ナビ・アカウント/forbidden・error・コンテンツ)、checkin・signage(少なくとも描画+ゲート挙動が不変なこと)。type-check は admin / @tecnova/ui / checkin / signage、biome は変更ファイル全部。 + +## 7. ファイル構成 + +- 変更(shared): `packages/ui/src/components/me-provider.tsx`(分離。`MeGate`/`useMeState` を追加、`useMe` は維持) +- 変更(checkin): `apps/checkin/src/components/app-shell.tsx` +- 変更(signage): `apps/signage/src/components/app-shell.tsx` +- 変更(admin): `(authed)/layout.tsx`, `components/{sidebar,bottom-nav,mobile-top-bar,account-menu,app-shell}.tsx` +- 新規(admin): `app/(authed)/error.tsx`, `app/error.tsx`, `app/(authed)/loading.tsx`(+必要ならコンテンツスケルトン) diff --git a/packages/ui/src/components/me-provider.tsx b/packages/ui/src/components/me-provider.tsx index 336ae17..1fba61c 100644 --- a/packages/ui/src/components/me-provider.tsx +++ b/packages/ui/src/components/me-provider.tsx @@ -10,50 +10,46 @@ export interface Me { mentor: { id: string; email: string; name: string; role: 'admin' | 'mentor' }; } -type State = - | { kind: 'loading' } - | { kind: 'ok'; data: Me } - | { kind: 'forbidden'; message: string } - | { kind: 'error'; message: string }; +export type MeState = + | { status: 'loading' } + | { status: 'ok'; me: Me } + | { status: 'forbidden'; message: string } + | { status: 'error'; message: string }; -const MeContext = createContext(null); +const MeStateContext = createContext(null); +// 認証状態(loading/ok/forbidden/error)を返す。me が無い間も扱えるので +// シェル(サイドバー等)の即時描画に使う。 +export const useMeState = (): MeState => { + const state = useContext(MeStateContext); + if (!state) { + throw new Error('useMeState must be used inside MeProvider'); + } + return state; +}; + +// 認証済みの Me を返す。status が ok 以外では throw するため、MeGate の内側 +// (=認証済みが保証される領域)でのみ使う。シグネチャは従来どおり。 export const useMe = (): Me => { - const me = useContext(MeContext); - if (!me) { - throw new Error('useMe must be used inside MeProvider'); + const state = useMeState(); + if (state.status !== 'ok') { + throw new Error('useMe must be used inside (me is not loaded)'); } - return me; + return state.me; }; export interface MeProviderProps { children: React.ReactNode; // 401 を受けたときの遷移先。next/router を packages/ui に持ち込まない - // ため、window.location.replace で素朴に遷移する。/login は別ルート - // グループなので、フル再読み込みでも UX 差はほぼない。 + // ため、window.location.replace で素朴に遷移する。 loginPath?: string; - // 403 時のフォールバック文言。アプリごとに「管理画面の…」「受付アプリの…」 - // など差し替えたいので props で受け取る。 - forbiddenMessage?: string; - // ステータス別ラッパー
の className。背景色などをアプリ側で指定する。 - loadingClassName?: string; - forbiddenClassName?: string; - errorClassName?: string; } -const DEFAULT_LOADING_CLASS = 'flex flex-1 items-center justify-center p-8'; -const DEFAULT_FAILURE_CLASS = - 'flex flex-1 flex-col items-center justify-center gap-4 p-8 text-center'; - -export function MeProvider({ - children, - loginPath = '/login', - forbiddenMessage = 'アクセス権限がありません', - loadingClassName = DEFAULT_LOADING_CLASS, - forbiddenClassName = DEFAULT_FAILURE_CLASS, - errorClassName = DEFAULT_FAILURE_CLASS, -}: MeProviderProps) { - const [state, setState] = useState({ kind: 'loading' }); +// /api/me を取得して状態を context に流すだけのプロバイダ。**ゲートはしない** +// (常に children を描画する)。ゲートやフォールバックは MeGate が担う。 +// これにより、認証解決前でもシェルのクロームを即描画できる。 +export function MeProvider({ children, loginPath = '/login' }: MeProviderProps) { + const [state, setState] = useState({ status: 'loading' }); useEffect(() => { void (async () => { @@ -65,24 +61,56 @@ export function MeProvider({ } if (r.status === 403) { const body = (await r.json().catch(() => ({}))) as { message?: string }; - setState({ kind: 'forbidden', message: body.message ?? forbiddenMessage }); + setState({ status: 'forbidden', message: body.message ?? '' }); return; } if (!r.ok) { throw new Error(`HTTP ${r.status}`); } const data = (await r.json()) as Me; - setState({ kind: 'ok', data }); + setState({ status: 'ok', me: data }); } catch (e) { - setState({ - kind: 'error', - message: e instanceof Error ? e.message : String(e), - }); + setState({ status: 'error', message: e instanceof Error ? e.message : String(e) }); } })(); - }, [loginPath, forbiddenMessage]); + }, [loginPath]); + + return {children}; +} - if (state.kind === 'loading') { +export interface MeGateProps { + children: React.ReactNode; + // 403 時のフォールバック文言。アプリごとに差し替える。 + forbiddenMessage?: string; + // ステータス別ラッパー
の className。背景色などをアプリ側で指定する。 + loadingClassName?: string; + forbiddenClassName?: string; + errorClassName?: string; + // 指定するとローディング表示を差し替える(アプリ固有のスケルトン等)。 + loadingFallback?: React.ReactNode; +} + +const DEFAULT_LOADING_CLASS = 'flex flex-1 items-center justify-center p-8'; +const DEFAULT_FAILURE_CLASS = + 'flex flex-1 flex-col items-center justify-center gap-4 p-8 text-center'; + +// 認証状態でゲートする。status==='ok' のときだけ children を描画し、 +// それ以外は loading/forbidden/error のフォールバックを出す。 +// (従来 MeProvider が一手に担っていたゲート部分をここへ分離した。) +export function MeGate({ + children, + forbiddenMessage = 'アクセス権限がありません', + loadingClassName = DEFAULT_LOADING_CLASS, + forbiddenClassName = DEFAULT_FAILURE_CLASS, + errorClassName = DEFAULT_FAILURE_CLASS, + loadingFallback, +}: MeGateProps) { + const state = useMeState(); + + if (state.status === 'loading') { + if (loadingFallback !== undefined) { + return <>{loadingFallback}; + } return (
@@ -90,18 +118,18 @@ export function MeProvider({ ); } - if (state.kind === 'forbidden') { + if (state.status === 'forbidden') { return (
アクセス権限がありません - {state.message} + {state.message || forbiddenMessage}
); } - if (state.kind === 'error') { + if (state.status === 'error') { return (
@@ -112,5 +140,5 @@ export function MeProvider({ ); } - return {children}; + return <>{children}; }