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/(authed)/mentors/page.tsx b/apps/admin/src/app/(authed)/mentors/page.tsx index 91cc535..426478e 100644 --- a/apps/admin/src/app/(authed)/mentors/page.tsx +++ b/apps/admin/src/app/(authed)/mentors/page.tsx @@ -10,6 +10,8 @@ import { Alert, AlertDescription, AlertTitle } from '@tecnova/ui/components/aler import { Button } from '@tecnova/ui/components/button'; import { Card, CardContent, CardHeader, CardTitle } from '@tecnova/ui/components/card'; import { Checkbox } from '@tecnova/ui/components/checkbox'; +import { DataError } from '@tecnova/ui/components/data-error'; +import { EmptyState } from '@tecnova/ui/components/empty-state'; import { Input } from '@tecnova/ui/components/input'; import { Label } from '@tecnova/ui/components/label'; import { useMe } from '@tecnova/ui/components/me-provider'; @@ -36,41 +38,22 @@ import { TooltipProvider, TooltipTrigger, } from '@tecnova/ui/components/tooltip'; +import { useApiResource } from '@tecnova/ui/hooks/use-api-resource'; import { apiJson } from '@tecnova/ui/lib/api-client'; import { formatJstDate } from '@tecnova/ui/lib/format'; import { toastError, toastSuccess } from '@tecnova/ui/lib/toast'; import { cn } from '@tecnova/ui/lib/utils'; -import { type FormEvent, useCallback, useEffect, useState } from 'react'; +import { type FormEvent, useState } from 'react'; import { PageHeader } from '@/components/page-header'; import { RecordCard, RecordField } from '@/components/record-card'; import { Reveal } from '@/components/reveal'; -type State = - | { kind: 'loading' } - | { kind: 'ok'; mentors: MentorItem[] } - | { kind: 'error'; message: string }; - export default function MentorsPage() { const me = useMe(); - const [state, setState] = useState({ kind: 'loading' }); - - const load = useCallback(async () => { - setState({ kind: 'loading' }); - try { - const data = await apiJson('/api/mentors'); - setState({ kind: 'ok', mentors: data.mentors }); - } catch (e) { - setState({ - kind: 'error', - message: e instanceof Error ? e.message : String(e), - }); - } - }, []); - - useEffect(() => { - if (me.mentor.role !== 'admin') return; - void load(); - }, [me.mentor.role, load]); + // admin のときだけ取得する。作成/更新後は reload() で再取得。 + const { state, reload } = useApiResource('/api/mentors', { + enabled: me.mentor.role === 'admin', + }); // ガード: ナビには非表示だが、URL 直叩き対策。/api/mentors も 403 で弾かれる。 if (me.mentor.role !== 'admin') { @@ -95,7 +78,7 @@ export default function MentorsPage() { - + {/* データ領域。Reveal を常時マウントして入場は一度だけ(再フェッチで再生されない)。 @@ -113,18 +96,11 @@ export default function MentorsPage() { )} - {state.kind === 'error' && ( - - 読み込めませんでした - {state.message} - - )} + {state.kind === 'error' && } {state.kind === 'ok' && - (state.mentors.length === 0 ? ( -
- まだ管理者が登録されていません -
+ (state.data.mentors.length === 0 ? ( + ) : ( <> {/* モバイル: カードリスト。 @@ -133,8 +109,8 @@ export default function MentorsPage() { 未保存の編集が見かけ上消える。admin の利用端末(PC / タブレット)では稀で、 保存すれば再取得で両者が同期するため許容するトレードオフ。 */}
- {state.mentors.map((m) => ( - + {state.data.mentors.map((m) => ( + ))}
@@ -153,8 +129,8 @@ export default function MentorsPage() { - {state.mentors.map((m) => ( - + {state.data.mentors.map((m) => ( + ))} @@ -167,7 +143,7 @@ export default function MentorsPage() { ); } -function CreateMentorForm({ onCreated }: { onCreated: () => Promise }) { +function CreateMentorForm({ onCreated }: { onCreated: () => void }) { const [email, setEmail] = useState(''); const [name, setName] = useState(''); const [role, setRole] = useState<'admin' | 'mentor'>('mentor'); @@ -184,7 +160,7 @@ function CreateMentorForm({ onCreated }: { onCreated: () => Promise }) { setEmail(''); setName(''); setRole('mentor'); - await onCreated(); + onCreated(); } catch (e) { toastError(e, '管理者を追加できませんでした'); } finally { @@ -248,7 +224,7 @@ function MentorRow({ variant, }: { mentor: MentorItem; - onUpdated: () => Promise; + onUpdated: () => void; // 'row' = デスクトップのテーブル行 / 'card' = モバイルのカード variant: 'row' | 'card'; }) { @@ -271,7 +247,7 @@ function MentorRow({ if (active !== mentor.active) body.active = active; await apiJson(`/api/mentors/${mentor.id}`, { method: 'PATCH', body }); toastSuccess(`${mentor.name} を保存しました`); - await onUpdated(); + onUpdated(); } catch (e) { toastError(e, '保存できませんでした'); } finally { diff --git a/apps/admin/src/app/(authed)/page.tsx b/apps/admin/src/app/(authed)/page.tsx index 065c54c..f80880a 100644 --- a/apps/admin/src/app/(authed)/page.tsx +++ b/apps/admin/src/app/(authed)/page.tsx @@ -9,10 +9,10 @@ import { } from '@tabler/icons-react'; import type { EventsListResponse, TodaySessionsResponse } from '@tecnova/shared/schemas'; import { toJstDateString } from '@tecnova/shared/venue-schedule'; -import { Alert, AlertDescription, AlertTitle } from '@tecnova/ui/components/alert'; import { Badge } from '@tecnova/ui/components/badge'; import { Button } from '@tecnova/ui/components/button'; import { Card, CardContent, CardHeader, CardTitle } from '@tecnova/ui/components/card'; +import { DataError } from '@tecnova/ui/components/data-error'; import { Select, SelectContent, @@ -31,19 +31,14 @@ import { } from '@tecnova/ui/components/table'; import { TableSkeleton } from '@tecnova/ui/components/table-skeleton'; import { TermBadge, UncountedBadge } from '@tecnova/ui/components/term-badge'; -import { apiErrorMessage, apiJson } from '@tecnova/ui/lib/api-client'; -import { useCallback, useEffect, useState } from 'react'; +import { type ResourceState, useApiResource } from '@tecnova/ui/hooks/use-api-resource'; +import { useState } from 'react'; import { AnimatedNumber } from '@/components/animated-number'; import { PageHeader } from '@/components/page-header'; import { ParticipantDetailSheet } from '@/components/participant-detail-sheet'; import { RecordCard, RecordField } from '@/components/record-card'; import { Reveal } from '@/components/reveal'; -type SessionsState = - | { kind: 'loading' } - | { kind: 'ok'; data: TodaySessionsResponse } - | { kind: 'error'; message: string }; - // セレクタで「今日」を選んでいる状態のセンチネル値。 // 空文字や undefined を使うと Select の制御値として扱いにくいのでこの形に。 const TODAY_VALUE = '__today__'; @@ -57,40 +52,19 @@ const fmtTime = (iso: string): string => }).format(new Date(iso)); export default function DashboardPage() { - const [sessions, setSessions] = useState({ kind: 'loading' }); - const [events, setEvents] = useState([]); const [selectedDate, setSelectedDate] = useState(TODAY_VALUE); const [selectedParticipantId, setSelectedParticipantId] = useState(null); - const loadSessions = useCallback(async (dateOrToday: string) => { - setSessions({ kind: 'loading' }); - try { - const path = - dateOrToday === TODAY_VALUE - ? '/api/sessions' - : `/api/sessions?date=${encodeURIComponent(dateOrToday)}`; - const data = await apiJson(path); - setSessions({ kind: 'ok', data }); - } catch (e) { - setSessions({ kind: 'error', message: apiErrorMessage(e) }); - } - }, []); - - useEffect(() => { - // イベント一覧は失敗しても致命ではないので、エラーは表示せず空で続行する。 - void (async () => { - try { - const r = await apiJson('/api/events'); - setEvents(r.events); - } catch { - setEvents([]); - } - })(); - }, []); + // 日付を path に含めることで、選択変更で自動再取得される。更新ボタンは reload()。 + const sessionsPath = + selectedDate === TODAY_VALUE + ? '/api/sessions' + : `/api/sessions?date=${encodeURIComponent(selectedDate)}`; + const sessions = useApiResource(sessionsPath); - useEffect(() => { - void loadSessions(selectedDate); - }, [selectedDate, loadSessions]); + // イベント一覧は失敗しても致命ではないので、取得できたときだけ使う(エラーは無視)。 + const eventsResource = useApiResource('/api/events'); + const events = eventsResource.state.kind === 'ok' ? eventsResource.state.data.events : []; const today = toJstDateString(new Date()); // 「本日」ラベル + イベントとして登録済みの過去日を結合する。 @@ -122,8 +96,8 @@ export default function DashboardPage() { type="button" variant="outline" size="sm" - onClick={() => loadSessions(selectedDate)} - disabled={sessions.kind === 'loading'} + onClick={() => sessions.reload()} + disabled={sessions.state.kind === 'loading'} > 更新 @@ -137,7 +111,7 @@ export default function DashboardPage() { 常時マウントなので入場は一度だけ(再フェッチで再生されない)。 */} setSelectedParticipantId(id)} /> @@ -156,10 +130,10 @@ function DashboardBody({ sessions, onSelectParticipant, }: { - sessions: SessionsState; + sessions: ResourceState; onSelectParticipant: (id: string) => void; }) { - if (sessions.kind === 'loading') { + if (sessions.kind === 'loading' || sessions.kind === 'idle') { return ( <>
@@ -180,12 +154,7 @@ function DashboardBody({ } if (sessions.kind === 'error') { - return ( - - セッションを読み込めませんでした - {sessions.message} - - ); + return ; } const { event, sessions: rows, summary } = sessions.data; diff --git a/apps/admin/src/app/(authed)/participants/page.tsx b/apps/admin/src/app/(authed)/participants/page.tsx index f1582a6..86e0e27 100644 --- a/apps/admin/src/app/(authed)/participants/page.tsx +++ b/apps/admin/src/app/(authed)/participants/page.tsx @@ -2,10 +2,11 @@ import { IconSearch, IconX } from '@tabler/icons-react'; import { GRADES, type Grade, type ParticipantsListResponse } from '@tecnova/shared/schemas'; -import { Alert, AlertDescription, AlertTitle } from '@tecnova/ui/components/alert'; import { Badge } from '@tecnova/ui/components/badge'; import { Button } from '@tecnova/ui/components/button'; import { Card } from '@tecnova/ui/components/card'; +import { DataError } from '@tecnova/ui/components/data-error'; +import { EmptyState } from '@tecnova/ui/components/empty-state'; import { Input } from '@tecnova/ui/components/input'; import { Select, @@ -24,7 +25,7 @@ import { TableRow, } from '@tecnova/ui/components/table'; import { TableSkeleton } from '@tecnova/ui/components/table-skeleton'; -import { apiErrorMessage, apiJson } from '@tecnova/ui/lib/api-client'; +import { useApiResource } from '@tecnova/ui/hooks/use-api-resource'; import { formatJstDate } from '@tecnova/ui/lib/format'; import { useEffect, useState } from 'react'; import { PageHeader } from '@/components/page-header'; @@ -32,11 +33,6 @@ import { ParticipantDetailSheet } from '@/components/participant-detail-sheet'; import { RecordCard, RecordField } from '@/components/record-card'; import { Reveal } from '@/components/reveal'; -type State = - | { kind: 'loading' } - | { kind: 'ok'; data: ParticipantsListResponse } - | { kind: 'error'; message: string }; - const PAGE_SIZE = 50; // 「すべて」を表すセンチネル値。SelectItem は空文字 value を受け付けない。 @@ -44,7 +40,6 @@ const ANY_GRADE = '__any_grade__'; const ANY_ACTIVE = '__any_active__'; export default function ParticipantsPage() { - const [state, setState] = useState({ kind: 'loading' }); const [search, setSearch] = useState(''); const [debouncedSearch, setDebouncedSearch] = useState(''); const [grade, setGrade] = useState(ANY_GRADE); @@ -73,26 +68,15 @@ export default function ParticipantsPage() { setPage(1); }; - useEffect(() => { - void (async () => { - setState({ kind: 'loading' }); - try { - const params = new URLSearchParams({ - page: String(page), - limit: String(PAGE_SIZE), - }); - if (debouncedSearch) params.set('search', debouncedSearch); - if (grade !== ANY_GRADE) params.set('grade', grade); - if (activeFilter !== ANY_ACTIVE) params.set('active', activeFilter); - const data = await apiJson( - `/api/participants?${params.toString()}`, - ); - setState({ kind: 'ok', data }); - } catch (e) { - setState({ kind: 'error', message: apiErrorMessage(e) }); - } - })(); - }, [debouncedSearch, page, grade, activeFilter]); + // クエリを path に組み立てる。page/検索/フィルタが変わると path が変わり、 + // useApiResource が自動で再取得する。 + const params = new URLSearchParams({ page: String(page), limit: String(PAGE_SIZE) }); + if (debouncedSearch) params.set('search', debouncedSearch); + if (grade !== ANY_GRADE) params.set('grade', grade); + if (activeFilter !== ANY_ACTIVE) params.set('active', activeFilter); + const { state } = useApiResource( + `/api/participants?${params.toString()}`, + ); const totalPages = state.kind === 'ok' ? Math.max(1, Math.ceil(state.data.pagination.total / PAGE_SIZE)) : 1; @@ -172,21 +156,14 @@ export default function ParticipantsPage() { )} - {state.kind === 'error' && ( - - 読み込めませんでした - {state.message} - - )} + {state.kind === 'error' && } {state.kind === 'ok' && ( <> {/* モバイル: カードリスト */}
{state.data.participants.length === 0 ? ( -
- 該当する利用者が見つかりません -
+ ) : ( state.data.participants.map((p) => ( new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Tokyo', @@ -80,29 +74,10 @@ const todayInJst = (): string => export default function PreRegistrationsPage() { const me = useMe(); - const [state, setState] = useState({ kind: 'loading' }); - - const load = useCallback(async () => { - setState({ kind: 'loading' }); - try { - const data = await apiJson('/api/pre-registrations'); - setState({ - kind: 'ok', - preRegistrations: data.preRegistrations, - activatedPreRegistrations: data.activatedPreRegistrations ?? [], - }); - } catch (e) { - setState({ - kind: 'error', - message: e instanceof Error ? e.message : String(e), - }); - } - }, []); - - useEffect(() => { - if (me.mentor.role !== 'admin') return; - void load(); - }, [me.mentor.role, load]); + // admin のときだけ取得する。作成/削除後は reload() で再取得。 + const { state, reload } = useApiResource('/api/pre-registrations', { + enabled: me.mentor.role === 'admin', + }); // ガード: ナビには非表示だが、URL 直叩き対策。/api/pre-registrations も 403 で弾かれる。 if (me.mentor.role !== 'admin') { @@ -126,7 +101,7 @@ export default function PreRegistrationsPage() { - + {/* データ領域。Reveal を常時マウントして入場は一度だけ(再フェッチで再生されない)。 @@ -144,28 +119,21 @@ export default function PreRegistrationsPage() {
)} - {state.kind === 'error' && ( - - 読み込めませんでした - {state.message} - - )} + {state.kind === 'error' && } {state.kind === 'ok' && ( <> - {state.preRegistrations.length === 0 ? ( -
- ID未発行の事前登録はありません -
+ {state.data.preRegistrations.length === 0 ? ( + ) : ( <> {/* モバイル: カードリスト */}
- {state.preRegistrations.map((p) => ( + {state.data.preRegistrations.map((p) => ( ))} @@ -185,11 +153,11 @@ export default function PreRegistrationsPage() { - {state.preRegistrations.map((p) => ( + {state.data.preRegistrations.map((p) => ( ))} @@ -199,7 +167,7 @@ export default function PreRegistrationsPage() { )} - + )} @@ -299,7 +267,7 @@ function ActivatedPreRegistrationsTable({ items }: { items: ActivatedPreRegistra ); } -function CreatePreRegistrationForm({ onCreated }: { onCreated: () => Promise }) { +function CreatePreRegistrationForm({ onCreated }: { onCreated: () => void }) { const [fullName, setFullName] = useState(''); const [nickname, setNickname] = useState(''); const [grade, setGrade] = useState(''); @@ -330,7 +298,7 @@ function CreatePreRegistrationForm({ onCreated }: { onCreated: () => Promise Promise; + onDeleted: () => void; // 'row' = デスクトップのテーブル行 / 'card' = モバイルのカード variant: 'row' | 'card'; }) { @@ -433,7 +401,7 @@ function PreRegistrationRow({ throw new ApiError(r.status, body); } toastSuccess(`${item.preRegistrationId} を削除しました`); - await onDeleted(); + onDeleted(); } catch (e) { toastError(e, '削除できませんでした'); } finally { diff --git a/apps/admin/src/app/(authed)/stats/page.tsx b/apps/admin/src/app/(authed)/stats/page.tsx index dd01c19..331bfab 100644 --- a/apps/admin/src/app/(authed)/stats/page.tsx +++ b/apps/admin/src/app/(authed)/stats/page.tsx @@ -9,9 +9,9 @@ import { IconSunset2, } from '@tabler/icons-react'; import type { ParticipationSummaryResponse } from '@tecnova/shared/schemas'; -import { Alert, AlertDescription, AlertTitle } from '@tecnova/ui/components/alert'; import { Button } from '@tecnova/ui/components/button'; import { Card, CardContent, CardHeader, CardTitle } from '@tecnova/ui/components/card'; +import { DataError } from '@tecnova/ui/components/data-error'; import { Input } from '@tecnova/ui/components/input'; import { Skeleton } from '@tecnova/ui/components/skeleton'; import { @@ -23,21 +23,15 @@ import { TableRow, } from '@tecnova/ui/components/table'; import { TableSkeleton } from '@tecnova/ui/components/table-skeleton'; -import { apiErrorMessage, apiJson } from '@tecnova/ui/lib/api-client'; +import { type ResourceState, useApiResource } from '@tecnova/ui/hooks/use-api-resource'; import { formatJstDate } from '@tecnova/ui/lib/format'; import { cn } from '@tecnova/ui/lib/utils'; -import { useCallback, useEffect, useState } from 'react'; +import { useState } from 'react'; import { AnimatedNumber } from '@/components/animated-number'; import { PageHeader } from '@/components/page-header'; import { Reveal } from '@/components/reveal'; -type SummaryState = - | { kind: 'loading' } - | { kind: 'ok'; data: ParticipationSummaryResponse } - | { kind: 'error'; message: string }; - export default function StatsPage() { - const [summary, setSummary] = useState({ kind: 'loading' }); // 入力中の値(適用ボタンを押すまで反映しない)。空文字 = フィルタなし。 const [fromInput, setFromInput] = useState(''); const [toInput, setToInput] = useState(''); @@ -45,24 +39,14 @@ export default function StatsPage() { const [appliedFrom, setAppliedFrom] = useState(''); const [appliedTo, setAppliedTo] = useState(''); - const loadSummary = useCallback(async (from: string, to: string) => { - setSummary({ kind: 'loading' }); - try { - const params = new URLSearchParams(); - if (from) params.set('from', from); - if (to) params.set('to', to); - const query = params.toString(); - const path = query ? `/api/stats/participation?${query}` : '/api/stats/participation'; - const data = await apiJson(path); - setSummary({ kind: 'ok', data }); - } catch (e) { - setSummary({ kind: 'error', message: apiErrorMessage(e) }); - } - }, []); - - useEffect(() => { - void loadSummary(appliedFrom, appliedTo); - }, [appliedFrom, appliedTo, loadSummary]); + // 確定レンジを path に組み立てる。適用/全期間で path が変わり自動再取得される。 + const rangeParams = new URLSearchParams(); + if (appliedFrom) rangeParams.set('from', appliedFrom); + if (appliedTo) rangeParams.set('to', appliedTo); + const rangeQuery = rangeParams.toString(); + const summary = useApiResource( + rangeQuery ? `/api/stats/participation?${rangeQuery}` : '/api/stats/participation', + ); const applyFilter = () => { setAppliedFrom(fromInput); @@ -107,7 +91,7 @@ export default function StatsPage() { type="button" size="sm" onClick={applyFilter} - disabled={summary.kind === 'loading'} + disabled={summary.state.kind === 'loading'} > 適用 @@ -123,14 +107,14 @@ export default function StatsPage() { {/* StatsBody はフラグメントを返すので、main の gap-6 を保つため Reveal 側で再指定する。 */} - + ); } -function StatsBody({ summary }: { summary: SummaryState }) { - if (summary.kind === 'loading') { +function StatsBody({ summary }: { summary: ResourceState }) { + if (summary.kind === 'loading' || summary.kind === 'idle') { return ( <>
@@ -146,12 +130,7 @@ function StatsBody({ summary }: { summary: SummaryState }) { } if (summary.kind === 'error') { - return ( - - 集計を読み込めませんでした - {summary.message} - - ); + return ; } const { totals, byDate } = summary.data; 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/participant-detail-sheet.tsx b/apps/admin/src/components/participant-detail-sheet.tsx index b8a6df2..fce9f4e 100644 --- a/apps/admin/src/components/participant-detail-sheet.tsx +++ b/apps/admin/src/components/participant-detail-sheet.tsx @@ -1,8 +1,8 @@ 'use client'; import type { ParticipantProfileResponse } from '@tecnova/shared/schemas'; -import { Alert, AlertDescription, AlertTitle } from '@tecnova/ui/components/alert'; import { Badge } from '@tecnova/ui/components/badge'; +import { DataError } from '@tecnova/ui/components/data-error'; import { Sheet, SheetContent, @@ -20,21 +20,14 @@ import { TableRow, } from '@tecnova/ui/components/table'; import { TermBadge } from '@tecnova/ui/components/term-badge'; -import { apiErrorMessage, apiJson } from '@tecnova/ui/lib/api-client'; +import { useApiResource } from '@tecnova/ui/hooks/use-api-resource'; import { formatJstDate } from '@tecnova/ui/lib/format'; -import { useEffect, useState } from 'react'; interface Props { participantId: string | null; onOpenChange: (open: boolean) => void; } -type State = - | { kind: 'idle' } - | { kind: 'loading' } - | { kind: 'ok'; data: ParticipantProfileResponse } - | { kind: 'error'; message: string }; - // ISO 文字列を JST の 'YYYY/MM/DD HH:mm' に整形する。空なら '—'。 const fmtDateTime = (iso: string | null): string => { if (!iso) return '—'; @@ -69,30 +62,11 @@ const fmtMinutes = (minutes: number | null): string => { }; export function ParticipantDetailSheet({ participantId, onOpenChange }: Props) { - const [state, setState] = useState({ kind: 'idle' }); const open = participantId !== null; - - useEffect(() => { - if (!participantId) { - setState({ kind: 'idle' }); - return; - } - let cancelled = false; - setState({ kind: 'loading' }); - void (async () => { - try { - const data = await apiJson( - `/checkin/participants/${encodeURIComponent(participantId)}`, - ); - if (!cancelled) setState({ kind: 'ok', data }); - } catch (e) { - if (!cancelled) setState({ kind: 'error', message: apiErrorMessage(e) }); - } - })(); - return () => { - cancelled = true; - }; - }, [participantId]); + // 開いているあいだだけ取得する(閉じている=path=null で idle)。 + const { state } = useApiResource( + participantId ? `/checkin/participants/${encodeURIComponent(participantId)}` : null, + ); return ( @@ -135,12 +109,7 @@ export function ParticipantDetailSheet({ participantId, onOpenChange }: Props) { )} - {state.kind === 'error' && ( - - 読み込めませんでした - {state.message} - - )} + {state.kind === 'error' && } {state.kind === 'ok' && } 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-data-layer-modernization-design.md b/docs/superpowers/specs/2026-06-02-admin-data-layer-modernization-design.md new file mode 100644 index 0000000..d64ca49 --- /dev/null +++ b/docs/superpowers/specs/2026-06-02-admin-data-layer-modernization-design.md @@ -0,0 +1,154 @@ +# admin データ層モダナイゼーション 設計(リサーチ結論つき) + +作成日: 2026-06-02 / 対象: `apps/admin`(一部 `packages/ui` 共有)/ Next.js 16.2.4 + React 19 + +## 0. 背景とゴール + +「Next.js のモダンなフォールバック / PPR が活かせる箇所を見直し、リファクタリングも兼ねて改善する」という依頼を受け、exa Web 検索+バージョン同梱ドキュメント(`node_modules/.pnpm/next@16.2.4`)+実コードで調査した。結論として **PPR / サーバーサイド・ストリーミングは現アーキテクチャには素直に効かない**。代わりに **クライアント取得パターンの共通化(リファクタリング)** が高価値・低リスクで、その先に段階的にサーバー取得への道がある。 + +### リサーチ結論(要点) + +- **PPR は 16.2.4 で `cacheComponents: true` に統合済み。`experimental.ppr` を設定するとハードエラー(ビルド不可)。** PPR/Cache Components は「サーバーレンダリングされる・キャッシュ可能・サーバー取得される」コンテンツの静的シェル+ストリーミングに効く。admin は全ページが `'use client'` で `useEffect`+`apiFetch` によりクロスオリジン API から取得しており、サーバー側にシェルへ畳み込めるデータが無い → **効果ゼロ・移行コストのみ**。`cacheComponents` は **有効化しない**。 +- **`loading.tsx` / Suspense は「サーバーコンポーネントが suspend したとき」しか発火しない。** `useEffect` 取得のクライアントページは描画時に suspend しないため、`loading.tsx` はナビゲーション直後に一瞬出て、API 取得完了前に消える。さらに `MeProvider` の認証ゲート(クライアント側 `/api/me` 待ち)に覆い隠される。現状のページ内スケルトン状態機械が正しい。 +- **サーバー取得は現状不可能。** Better Auth のセッション Cookie は **host-only**(`apps/api/src/lib/auth.ts` に `advanced.crossSubDomainCookies` 等の設定なし)で、API オリジン(`tecnova-api.sz-lab.jp` / `:8787`)にのみ発行される。admin オリジンの Next.js サーバーは `cookies()` でセッションを読めず、API へサーバー取得できない。解放には BFF プロキシ or クロスサブドメイン Cookie が必要(別サブプロジェクト)。 +- **真の高価値リファクタ=重複の共通化。** `loading|ok|error` の取得状態機械が **6 箇所**(5 ページ+詳細シート)でほぼ同形に重複。エラー用 `Alert`・空状態ブロックもページごとに再実装。レンダリング時クラッシュを拾う error boundary も無い。 + +### ゴール + +クライアント取得アーキテクチャを維持したまま、(1) 取得/ローディング/エラーの重複を共通プリミティブへ集約し、(2) 体感速度と耐障害性を上げ、(3) 将来のサーバー取得への移行口を用意する。**段階的に・低リスク順に**進める。 + +--- + +## 1. サブプロジェクト分割(この順に実装) + +| # | 名称 | 主眼 | リスク | 依存 | +|---|------|------|--------|------| +| 1 | データ層の共通化 | `useApiResource` フック+`DataError`/`EmptyState`。6 箇所の重複を解消 | 低(挙動・UI 不変) | なし | +| 2 | 耐障害性+即時シェル | `(authed)/error.tsx`、`MeProvider` 即時シェル化、各ルート `loading.tsx`+ページスケルトン抽出 | 中(共有 `MeProvider` に波及) | 1 | +| 3 | サーバー取得 | BFF or クロスサブドメイン Cookie → 閲覧系をサーバー取得+Suspense ストリーミング | 高(認証・セキュリティ・dev 環境) | 1, 2 | + +各サブプロジェクトは独立した spec → plan → 実装サイクルを持つ。本書はサブプロジェクト 1 を詳細化し、2・3 は概要と未決事項を記す。 + +--- + +## 2. サブプロジェクト 1(詳細): データ層の共通化 + +### 2.1 新規プリミティブ(`packages/ui`) + +#### `packages/ui/src/lib/use-api-resource.ts`(`'use client'`) + +```ts +export type ResourceState = + | { kind: 'idle' } + | { kind: 'loading' } + | { kind: 'ok'; data: T } + | { kind: 'error'; message: string }; + +export interface UseApiResourceResult { + state: ResourceState; + reload: () => void; +} + +// path が null か enabled=false のとき idle(取得しない)。 +// path が変わると自動で再取得する(クエリ文字列を path に含めることで +// 検索・フィルタ・ページング・日付変更の再取得を表現する)。 +// reload() は手動再取得(更新ボタン・ミューテーション後の再読込)。 +export function useApiResource( + path: string | null, + options?: { enabled?: boolean }, +): UseApiResourceResult; +``` + +実装方針: +- 内部は `useState>` + `reloadKey`(`reload()` で increment)。 +- `useEffect`(依存 `[path, enabled, reloadKey]`)で: `!path || enabled===false` → `idle` にして return。それ以外は `loading` にしてから `apiJson(path)` を呼ぶ。`participant-detail-sheet.tsx` 既存の **cancelled フラグ** によるアンマウント/パラメータ変更時の競合防止を踏襲。 +- エラー文言は既存の正規ヘルパ `apiErrorMessage(e)` を使う(文言生成も統一)。 +- `apiJson` は 204 を扱わない(JSON 前提)。削除など 204 を返す系は従来どおりページ側の `apiFetch` を使う(フックの対象外=ミューテーションは扱わない、読み取り専用フック)。 + +#### `packages/ui/src/components/data-error.tsx` + +```tsx +// 取得失敗時の destructive Alert(任意で再試行ボタン)。 +export function DataError({ + title = '読み込めませんでした', + message, + onRetry, +}: { title?: string; message: string; onRetry?: () => void }): JSX.Element; +``` + +#### `packages/ui/src/components/empty-state.tsx` + +```tsx +// 中央寄せの空状態ブロック(rounded-2xl border bg-card)。任意でアイコン。 +export function EmptyState({ + icon: Icon, + message, + className, +}: { icon?: ComponentType<{ className?: string }>; message: string; className?: string }): JSX.Element; +``` + +いずれも `packages/ui` の barrel/慣例に合わせてエクスポート。`apps/admin/next.config.ts` の `transpilePackages` は既に `@tecnova/ui` を含むため追加設定不要。 + +### 2.2 移行(admin 6 箇所) + +| 箇所 | path | enabled | reload 用途 | +|------|------|---------|-------------| +| dashboard `page.tsx` | `selectedDate===TODAY ? '/api/sessions' : '/api/sessions?date=…'` + 別途 `/api/events`(best-effort) | 常時 | 更新ボタン | +| participants | `/api/participants?page=&limit=&search=&grade=&active=`(debounced) | 常時 | — | +| stats | `/api/stats/participation?from=&to=`(適用済みレンジ) | 常時 | — | +| mentors | `/api/mentors` | `me.mentor.role==='admin'` | 作成/更新後 | +| pre-registrations | `/api/pre-registrations` | `me.mentor.role==='admin'` | 作成/削除後 | +| `participant-detail-sheet.tsx` | `participantId ? '/checkin/participants/:id' : null` | — | — | + +- フックは常に無条件で呼ぶ(hooks ルール)。ロールゲートの早期 return(アクセス権限 Alert)はフック呼び出しの **後** に置く(現状と同じ並び)。 +- 各ページの `type State`・`useEffect`・`useCallback(load)` を撤去し、`useApiResource` の `state`/`reload` に置換。 +- エラー表示を ``、空状態を `` に置換。 +- `events` は best-effort なので別の `useApiResource('/api/events')` を使い、`ok` のときだけ `events` を読む(エラーは無視)。 + +### 2.3 ファイル構成 + +- 新規: `packages/ui/src/lib/use-api-resource.ts`, `packages/ui/src/components/data-error.tsx`, `packages/ui/src/components/empty-state.tsx` +- 変更: `apps/admin/src/app/(authed)/page.tsx`, `participants/page.tsx`, `stats/page.tsx`, `mentors/page.tsx`, `pre-registrations/page.tsx`, `apps/admin/src/components/participant-detail-sheet.tsx` + +### 2.4 非ゴール / 不変条件 + +- UI・挙動・エンドポイント・debounce・ページング・ロールゲートは **不変**(純粋な内部リファクタ)。 +- ミューテーション(POST/PATCH/DELETE)はフック化しない(読み取り専用)。 +- `cacheComponents`/PPR は有効化しない。 +- checkin/signage は本サブプロジェクトでは変更しない(フックは将来 checkin にも転用可能だが対象外)。 + +### 2.5 検証 + +- `pnpm --filter admin --filter @tecnova/ui type-check` と `pnpm biome check apps/admin/src packages/ui/src` が green。 +- Playwright(`/api/me`+各データをモック)で 5 ページ+詳細シートが従来どおり描画・検索・フィルタ・ページング・更新・ミューテーション後再読込・エラー/空状態表示することを確認(デスクトップ/モバイル)。 +- 重複していた `type State` / `useEffect` 取得ロジックが各ページから消えていること。 + +--- + +## 3. サブプロジェクト 2(概要): 耐障害性+即時シェル + +- `apps/admin/src/app/(authed)/error.tsx`(`'use client'` 必須): レンダリング時クラッシュの安全網。`DataError` を再利用。ルート直下にも最小の `error.tsx` を検討。 +- `MeProvider` 即時シェル化: 現状は `/api/me` 解決までツリー全体をスケルトンで止める。`AppShell` のクローム(サイドバー/ナビ)を即描画し、**ページ本文だけ**を認証待ちにする。`packages/ui` 共有のため checkin/signage への影響を要確認(オプトイン or 後方互換に注意)。 +- ページスケルトン抽出(`ListPageSkeleton` 等)→ ページ内ローディングと将来の `loading.tsx` の両方を 1 コンポーネントで賄う。 +- `loading.tsx` は「ナビゲーション/コールド JS の窓」だけに効く点を理解した上で、即時シェル化後に追加(それ以前は `MeProvider` に隠れて無価値)。 +- 任意: ミューテーションフォームに React 19 `useOptimistic`。 + +未決: `MeProvider` を共有のまま変えるか、admin 専用ラッパに切り出すか。 + +--- + +## 4. サブプロジェクト 3(概要・別設計): サーバー取得 + +**前提となる認証アーキテクチャの決定(要ブレスト):** + +- **案A: 同一オリジン BFF プロキシ** — admin オリジンに Route Handler/リライトを置き `/api/*` を Workers API へ転送。Cookie を host-only のまま admin 同一オリジン化でき dev でも動く(`localhost:3001/api → :8787`)。実装量は多め(認証フローも admin 経由になり得る)。 +- **案B: クロスサブドメイン Cookie** — `apps/api/src/lib/auth.ts` に `advanced.crossSubDomainCookies = { enabled: true, domain: 'sz-lab.jp' }` + `defaultCookieAttributes = { sameSite: 'none', secure: true }`。admin サーバーが `cookies()` で読めて転送可。ただし Cookie が全 `*.sz-lab.jp` に拡大(露出リスク)、`SameSite=None`(CSRF 面拡大、Better Auth の origin/CSRF+CORS で緩和)、**localhost dev は別手当が必要**、当該ルートは動的レンダリング固定。 + +決定後、閲覧系(dashboard/stats)の初回データをサーバー取得+`` でストリーミング。検索/フィルタ/ミューテーション/詳細シートはクライアントのままのハイブリッド。`cookies()` 利用で動的化するため PPR の恩恵は限定的(per-user で全動的)。dev/prod の差異とセッションローテーション(Set-Cookie 伝播)に注意。本サブプロジェクトは独立 spec で詳細化する。 + +--- + +## 5. 参照 + +- リサーチ全文(4 スレッド: ppr-caching / loading-suspense / auth-ssr-feasibility / admin-audit): ワークフロー `admin-nextjs-modernization-research` の出力。 +- 本番ドメイン(同一親 `sz-lab.jp`・同サイト別オリジン)はメモリ管理(Public 禁止)。 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/data-error.tsx b/packages/ui/src/components/data-error.tsx new file mode 100644 index 0000000..b8af2d1 --- /dev/null +++ b/packages/ui/src/components/data-error.tsx @@ -0,0 +1,17 @@ +import { Alert, AlertDescription, AlertTitle } from '@tecnova/ui/components/alert'; + +interface DataErrorProps { + title?: string; + message: string; +} + +// 取得失敗時の共通エラー表示。admin 各画面でインライン重複していた +// destructive Alert を 1 箇所に集約する。 +export function DataError({ title = '読み込めませんでした', message }: DataErrorProps) { + return ( + + {title} + {message} + + ); +} diff --git a/packages/ui/src/components/empty-state.tsx b/packages/ui/src/components/empty-state.tsx new file mode 100644 index 0000000..9e4fa0f --- /dev/null +++ b/packages/ui/src/components/empty-state.tsx @@ -0,0 +1,26 @@ +import { cn } from '@tecnova/ui/lib/utils'; +import type { ComponentType } from 'react'; + +interface EmptyStateProps { + // 任意のアイコン(@tabler/icons-react など className を受け取るコンポーネント)。 + icon?: ComponentType<{ className?: string }>; + message: string; + className?: string; +} + +// 一覧が空のときの共通プレースホルダ。admin 各画面でインライン重複していた +// 「該当なし」ブロックを 1 箇所に集約する。テーブル内の空行には使わない +// (TableCell/colSpan のままにする)。 +export function EmptyState({ icon: Icon, message, className }: EmptyStateProps) { + return ( +
+ {Icon && } + {message} +
+ ); +} 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}; } diff --git a/packages/ui/src/hooks/use-api-resource.ts b/packages/ui/src/hooks/use-api-resource.ts new file mode 100644 index 0000000..5c7ad0e --- /dev/null +++ b/packages/ui/src/hooks/use-api-resource.ts @@ -0,0 +1,71 @@ +'use client'; + +import { apiErrorMessage, apiJson } from '@tecnova/ui/lib/api-client'; +import { useCallback, useEffect, useState } from 'react'; + +// 取得状態。idle = まだ取得していない(path=null / enabled=false)。 +export type ResourceState = + | { kind: 'idle' } + | { kind: 'loading' } + | { kind: 'ok'; data: T } + | { kind: 'error'; message: string }; + +export interface UseApiResourceResult { + state: ResourceState; + reload: () => void; +} + +export interface UseApiResourceOptions { + // false のあいだは取得せず idle のままにする(ロール待ち等の条件付き取得用)。 + enabled?: boolean; +} + +// path から JSON を取得し loading|ok|error|idle を返す読み取り専用フック。 +// admin の各画面で重複していた取得+状態機械を 1 箇所に集約する。 +// - path が null か enabled=false のとき idle(取得しない)。 +// - path が変わると自動で再取得する(クエリ文字列を path に含めて +// 検索・フィルタ・ページング・日付変更を表現する)。 +// - reload() で手動再取得(更新ボタン・ミューテーション後の再読込)。 +// アンマウントやパラメータ変更時に古いレスポンスで setState しないよう +// cancelled フラグでガードする(participant-detail-sheet の実装を踏襲)。 +// ミューテーション(POST/PATCH/DELETE)は扱わない。 +export const useApiResource = ( + path: string | null, + options?: UseApiResourceOptions, +): UseApiResourceResult => { + const enabled = options?.enabled ?? true; + // 取得予定なら最初から loading で初期化し、idle の一瞬のちらつきを避ける。 + const [state, setState] = useState>(() => + path && enabled ? { kind: 'loading' } : { kind: 'idle' }, + ); + const [reloadKey, setReloadKey] = useState(0); + + const reload = useCallback(() => { + setReloadKey((k) => k + 1); + }, []); + + // reloadKey は本文では参照しないが、reload() による手動再取得のトリガーとして + // 依存配列に必要(path/enabled が同じでも再フェッチさせる)。 + // biome-ignore lint/correctness/useExhaustiveDependencies: reloadKey is an intentional refetch trigger + useEffect(() => { + if (!path || !enabled) { + setState({ kind: 'idle' }); + return; + } + let cancelled = false; + setState({ kind: 'loading' }); + void (async () => { + try { + const data = await apiJson(path); + if (!cancelled) setState({ kind: 'ok', data }); + } catch (e) { + if (!cancelled) setState({ kind: 'error', message: apiErrorMessage(e) }); + } + })(); + return () => { + cancelled = true; + }; + }, [path, enabled, reloadKey]); + + return { state, reload }; +};