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 };
+};