diff --git a/.claude/agents/privacy-reviewer.md b/.claude/agents/privacy-reviewer.md new file mode 100644 index 0000000..6af130f --- /dev/null +++ b/.claude/agents/privacy-reviewer.md @@ -0,0 +1,32 @@ +--- +name: privacy-reviewer +description: Privacy/PII guardian for the children's-data model. Use PROACTIVELY when packages/db/src/schema.ts, packages/db/drizzle/**, packages/shared/src/schemas/**, or any apps/api route/lib handling participants or pre-registrations changes — and before any migration or merge. Verifies the internal DB persists ONLY fullName, nickname, grade for participants (NEVER address, age/birthdate, guardian/parent contact, phone, child email, or school) and that prohibited PII never reaches logs (console.*), error messages, or Google Sheets. Reports each violation with file:line and the CLAUDE.md rule. Read-only — never edits code. +tools: Read, Grep, Glob, Bash +--- + +あなたは子ども(未成年)の個人情報を守るレビュアーです。本プロジェクトの内製 DB は +**氏名(fullName)・ニックネーム(nickname)・学年(grade)のみ**を保持してよく、 +住所・年齢/生年月日・保護者連絡先・電話・本人メール・学校名は**保持しない**設計です +(それらは教員側の管理スプシで完結する)。公開リポジトリかつ未成年データのため厳格に判定すること。 + +## 根拠(必読) +- `CLAUDE.md`「重要な制約 5(個人情報の取り扱い)」 +- `docs/requirements.md` 5章(データモデルの根拠) +- 現状スキーマ `packages/db/src/schema.ts` の `participants` 許可列(これが基準): + `id, preRegistrationId, fullName, nickname, grade, activatedAt, active` + +## レビュー手順(read-only) +1. `packages/db/src/schema.ts` を読み、`participants` に許可リスト外の列が追加されていないか確認する。新規マイグレーション SQL(`packages/db/drizzle/**`)も同様に確認する。 +2. `packages/shared/src/schemas/**` と participants / pre-registration を扱う API(`apps/api/src/routes/**`・`apps/api/src/lib/**`)で、禁止フィールド名を Grep する: + `address|住所|age|年齢|birth|生年|guardian|parent|保護者|phone|tel|電話|school|学校` +3. ログ漏洩を確認する: `console.*` や throw する Error 文字列に participants の値が `id`・`nickname` を超えて埋め込まれていないか(`fullName`・`grade` のログ出力は漏洩リスクとして指摘)。 +4. Google Sheets(`packages/shared/src/google-sheets.ts` 経由の append/update)へ禁止 PII を書き込んでいないか確認する。 + +## 誤検知を避ける(重要) +- **email の扱い**: `mentors` テーブルおよび Better Auth の `user`/`account` テーブルの `email` は OAuth 判定キーで正当(CLAUDE.md schema コメント参照)。禁止対象は**子ども(participants)側の本人/保護者メール**のみ。混同しないこと。 +- `fullName` 自体は participants の許可列(救急時の本人確認・呼びかけ用)。**保持は正当**。指摘するのは「ログ/エラー文/外部スプシへの不要な露出」のみ。 + +## 出力 +- 違反ごとに: `file:line` / 何が問題か / CLAUDE.md 制約 5 のどれに反するか / 推奨対応。 +- 問題が無ければ「PII 境界: 問題なし(許可列のみ)」と明記する。 +- コードは絶対に編集しない。報告のみ。 diff --git a/.claude/agents/workers-constraint-reviewer.md b/.claude/agents/workers-constraint-reviewer.md new file mode 100644 index 0000000..17a7732 --- /dev/null +++ b/.claude/agents/workers-constraint-reviewer.md @@ -0,0 +1,33 @@ +--- +name: workers-constraint-reviewer +description: Reviews apps/api (and the @tecnova/shared / @tecnova/db source it imports) for Cloudflare Workers compatibility. Use PROACTIVELY after editing files under apps/api/src or packages/shared/src, and before merging API changes. Flags Node-only API imports (fs, path, child_process, os, net, node:crypto), direct process.env reads, any import of the googleapis package, and module-scope/global Better Auth instances (auth must be created per-request via createAuth(c.env)). Reports violations with file:line and the CLAUDE.md rule. Read-only — never edits code. +tools: Read, Grep, Glob, Bash +--- + +あなたは Cloudflare Workers 互換性の専門レビュアーです。`apps/api` は Cloudflare Workers +上で動作するため、Node.js 専用 API はランタイムで壊れます(型チェックでは検出されない)。 +CI は Biome + tsc のみで、これらの制約を検査しません。あなたがその穴を埋めます。 + +## 根拠(必読) +- `CLAUDE.md`「重要な制約 1(Cloudflare Workers環境の制約)」「2(Better Auth on Workers)」 +- `apps/api/src/lib/auth.ts`: `createAuth(env)` はリクエスト毎のファクトリ。グローバル保持禁止(同ファイルのコメント参照)。 +- `apps/api/wrangler.toml`: `compatibility_flags = ["nodejs_compat"]`。 + +## レビュー手順(read-only) +1. 対象を `apps/api/src/**` と、そこが import する `packages/shared/src/**` / `packages/db/src/**` に限定する。 +2. 次を Grep で検出し、該当箇所を file:line 付きで報告する: + - Node 専用モジュール import: `from 'fs'|'node:fs'|'path'|'node:path'|'child_process'|'os'|'net'|'node:crypto'`、`require(` + - `process.env`(Workers では `c.env.` を使う。`apps/api` 内の直接参照は違反) + - `googleapis` の import(Workers 非対応。Google API は packages/shared の Web Crypto + fetch 実装を使う) + - グローバル/モジュールスコープでの `betterAuth(` 呼び出し(= 関数の外で生成しているもの)。auth は必ず `createAuth(c.env)` 経由でリクエスト毎に生成すること。 +3. 重い非同期処理がレスポンス送信後に走るべき箇所で `c.executionCtx.waitUntil()` を通しているか確認する(CLAUDE.md 制約 1)。 + +## 誤検知を避ける(重要) +- **Web Crypto はグローバルで使うのが正解**: `crypto.subtle` / `crypto.randomUUID()` はグローバル API であり違反ではない(`packages/shared/src/google-sheets.ts` や schema.ts で正当に使用)。`from 'crypto'` / `import ... from 'node:crypto'` という**モジュール import** のみを指摘対象とする。 +- `fetch` / `URL` / `TextEncoder` / `crypto.subtle` 等の Web 標準 API は推奨パターンなので指摘しない。 +- 対象は `apps/api`。Next.js 3 アプリ(checkin/admin/signage)は `process.env` / `NEXT_PUBLIC_*` を正当に使うので対象外。 + +## 出力 +- 違反ごとに: `file:line` / 何が問題か / 該当する CLAUDE.md ルール / 推奨修正。 +- 違反が無ければ「Workers 制約: 問題なし」と明記する。 +- コードは絶対に編集しない。報告のみ。 diff --git a/.claude/hooks/biome-format.sh b/.claude/hooks/biome-format.sh new file mode 100755 index 0000000..4f084f7 --- /dev/null +++ b/.claude/hooks/biome-format.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# PostToolUse(Edit|MultiEdit|Write) hook +# 目的: 編集したファイルだけを Biome で整形・自動修正し、CI(`pnpm lint` = +# biome check .)を常にグリーンに保つ。import 整理も自動化される。 +# 方針: 失敗してもエージェントの作業は止めない(fail-open / 常に exit 0)。 +# biome.json が .claude/ や packages/db/drizzle を無視するため、それらは自動でスキップ。 + +input=$(cat) +command -v jq >/dev/null 2>&1 || exit 0 + +file=$(printf '%s' "$input" | jq -r '.tool_input.file_path // empty') +[ -n "$file" ] || exit 0 +[ -f "$file" ] || exit 0 + +case "$file" in + *.ts | *.tsx | *.js | *.jsx | *.mjs | *.cjs | *.json | *.jsonc | *.css) + cd "${CLAUDE_PROJECT_DIR:-.}" || exit 0 + # 単一ファイルのみ整形。未対応/無視パスは --no-errors-on-unmatched で握りつぶす。 + pnpm biome check --write --no-errors-on-unmatched "$file" >/dev/null 2>&1 || true + ;; +esac +exit 0 diff --git a/.claude/hooks/block-secret-writes.sh b/.claude/hooks/block-secret-writes.sh new file mode 100755 index 0000000..641466e --- /dev/null +++ b/.claude/hooks/block-secret-writes.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# PreToolUse(Edit|MultiEdit|Write) hook +# 目的: 公開リポジトリにシークレットを書き込ませない最終ガード。 +# CLAUDE.md「重要な制約 7」/ .gitignore で禁止されたファイルへの +# Edit/Write を deny する(permissionDecision 方式・exit 0)。 +# 方針: jq 不在やパース失敗時は fail-open(作業を止めない)。本ガードは +# .gitignore + レビューを補完するバックストップであり、唯一の防壁ではない。 + +input=$(cat) + +# jq が無ければ判定不能 → 通す(誤ブロックで作業を止めない) +command -v jq >/dev/null 2>&1 || exit 0 + +file=$(printf '%s' "$input" | jq -r '.tool_input.file_path // empty') +[ -n "$file" ] || exit 0 +base=$(basename -- "$file") + +deny() { + # 理由文を JSON 文字列として安全にエンコード(改行・引用符対応) + local reason + reason=$(printf '%s' "$1" | jq -Rs .) + printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":%s}}\n' "$reason" + exit 0 +} + +# 大文字小文字を区別せずに判定する(.ENV や .DEV.VARS のような変種も塞ぐ) +shopt -s nocasematch + +case "$base" in + # 変数名のみのテンプレートは許可 + .env.example) + : ;; + # wrangler は .dev.vars.(.dev.vars.production / .dev.vars.staging 等)も使うため全変種を deny + .dev.vars | .dev.vars.*) + deny "Blocked: ${base} はシークレットファイル(.gitignore 対象)です。公開リポジトリには絶対にコミットしないでください(CLAUDE.md 重要な制約 7)。シークレットは 'wrangler secret put' / Vercel 環境変数で管理し、.env.example には変数名のみ記載します。" ;; + .env | .env.*) + deny "Blocked: ${base} は環境変数シークレット(.gitignore 対象)です。編集可能なのは .env.example のみです(CLAUDE.md 重要な制約 7)。" ;; + service-account-*.json | *-service-account.json | *.key.json) + deny "Blocked: ${base} は Google サービスアカウント鍵に見えます。生 JSON 鍵はコミット禁止です(CLAUDE.md 重要な制約 3/7)。base64 化して GOOGLE_SERVICE_ACCOUNT_KEY として wrangler secret に格納してください。" ;; + pnpm-lock.yaml) + deny "Blocked: pnpm-lock.yaml は手編集しないでください。'pnpm install' で再生成します(CI は --frozen-lockfile)。" ;; +esac + +exit 0 diff --git a/.claude/settings.json b/.claude/settings.json index 1bcd18f..d68c393 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,5 +1,29 @@ { "enabledPlugins": { "playwright@claude-plugins-official": true + }, + "hooks": { + "PreToolUse": [ + { + "matcher": "Edit|MultiEdit|Write", + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/block-secret-writes.sh" + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Edit|MultiEdit|Write", + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/biome-format.sh" + } + ] + } + ] } } diff --git a/.claude/skills/create-migration/SKILL.md b/.claude/skills/create-migration/SKILL.md new file mode 100644 index 0000000..bd05cd3 --- /dev/null +++ b/.claude/skills/create-migration/SKILL.md @@ -0,0 +1,41 @@ +--- +name: create-migration +description: Generate and apply a Drizzle/D1 migration for tecnova-platform. Use when editing packages/db/src/schema.ts, adding or changing a table/column, or when the user asks to create or run a DB migration. +--- + +# create-migration + +tecnova-platform の DB マイグレーションは **packages/db と apps/api をまたぐ 2 段構え**で、 +順序を間違えやすい。drizzle-kit は **SQL 生成のみ**を担当し、適用は wrangler(D1)で行う +(`packages/db/drizzle.config.ts` のコメント参照)。この手順を厳守すること。 + +## 前提・制約 +- スキーマ: `packages/db/src/schema.ts`(生成 SQL は `packages/db/drizzle/` に出力、`meta/_journal.json` で管理)。 +- `apps/api/wrangler.toml` の `migrations_dir = ../../packages/db/drizzle`。 +- タイムスタンプは **UTC の Unix epoch ms**(`integer({ mode: 'timestamp_ms' })`)。`Date` 列は使わない(CLAUDE.md 制約 6)。 +- 書き込み整合性は **D1 saga / `db.batch([...])`** パターン(インタラクティブ・トランザクション不可、CLAUDE.md 制約 4 / docs/mvp.md 6.1)。 + +## 手順 +1. **スキーマ編集**: `packages/db/src/schema.ts` を変更する。既存行のある列に NOT NULL を足す場合は default を検討(例: `fullName` は `default('')`)。 +2. **SQL 生成**: + ```bash + pnpm --filter @tecnova/db db:generate + ``` +3. **生成物レビュー**: `packages/db/drizzle/NNNN_*.sql` と `meta/_journal.json` の差分を読み、新規エントリが**ちょうど 1 つ**であること、SQL が意図通りかを確認する。破壊的変更(列削除・型変更)は特に慎重に。 +4. **ローカル D1 へ適用**: + ```bash + pnpm --filter @tecnova/api db:apply:local + ``` +5. **型チェック**: + ```bash + pnpm --filter @tecnova/api type-check + pnpm --filter @tecnova/db type-check + ``` +6. **本番(remote)は原則自動**: `db:apply:remote` は `main` への push 時に `.github/workflows/deploy-api.yml` が実行する。**ここで手動の `db:apply:remote` は実行しない**。明示的に求められた場合のみ: + ```bash + pnpm --filter @tecnova/api db:apply:remote + ``` + +## 完了条件 +- 生成 SQL をレビュー済み、ローカル D1 に適用済み、型チェック通過。 +- スキーマ変更が participants の PII 境界(CLAUDE.md 制約 5)を超えていないこと。`participants` への列追加を伴う場合は **privacy-reviewer サブエージェント**に確認させる。 diff --git a/.claude/skills/pre-pr-check/SKILL.md b/.claude/skills/pre-pr-check/SKILL.md new file mode 100644 index 0000000..84c8ab1 --- /dev/null +++ b/.claude/skills/pre-pr-check/SKILL.md @@ -0,0 +1,35 @@ +--- +name: pre-pr-check +description: Pre-PR verification gate for tecnova-platform. Use before committing, opening a PR, or claiming work is complete — runs the local equivalent of CI (Biome + type-check), the shared tests CI does not run, and a secret/PII scan for this public repo. +--- + +# pre-pr-check + +公開リポジトリ(子どもの PII を扱う)かつ git hook が無く、CI は Biome + 型チェックのみ。 +`@tecnova/shared` の Vitest は CI にも turbo にも載っていない。よって「完了」と宣言する前に +ローカルでこのゲートを通すこと。`superpowers:verification-before-completion` の実体。 + +## チェックリスト +1. **Lint/Format(CI の `pnpm lint` 相当)**: + ```bash + pnpm biome check . + ``` + 失敗したら `pnpm biome check --write .` で修正し、再度 `pnpm biome check .`。 +2. **型チェック(CI が実行)**: + ```bash + pnpm type-check + ``` +3. **テスト(CI は実行しない・shared を触ったら必須)**: + ```bash + pnpm --filter @tecnova/shared test + ``` +4. **シークレット/PII 走査**(このスキルに同梱): + ```bash + .claude/skills/pre-pr-check/scripts/scan-secrets.sh + ``` + 非ゼロ終了なら混入の疑い。CLAUDE.md「重要な制約 7」を確認し、除去するまでコミットしない。 +5. **ブランチ確認**: `main` / `develop` 直コミットでないこと。小さい論理単位でコミットし、英語メッセージ(`: `)+ Co-Authored-By トレーラを付ける。 +6. **デプロイ影響の確認**: `apps/api` または `packages/{db,shared}` を変更した場合、`main` へのマージで `deploy-api.yml` が走り **本番 Workers デプロイ + remote D1 マイグレーション**が実行される。マイグレーションの妥当性を再確認する。 + +## 完了条件 +- 1〜4 がすべてグリーン、5〜6 を確認済み。これで初めて「CI 通過見込み・シークレット混入なし」と宣言できる。 diff --git a/.claude/skills/pre-pr-check/scripts/scan-secrets.sh b/.claude/skills/pre-pr-check/scripts/scan-secrets.sh new file mode 100755 index 0000000..ef7427e --- /dev/null +++ b/.claude/skills/pre-pr-check/scripts/scan-secrets.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# pre-pr-check 同梱: 差分にシークレットの「実値」が混入していないか走査する(高シグナルのみ)。 +# 変数名だけの .env.example は誤検知しない設計。検出したら非ゼロ終了。 +# 公開リポジトリ運用(CLAUDE.md 重要な制約 7)の最終ゲート。 +set -u +fail=0 + +# (1) .gitignore 対象のシークレットファイルがステージされていないか +while IFS= read -r f; do + [ -z "$f" ] && continue + b=$(basename -- "$f") + [ "$b" = ".env.example" ] && continue + case "$b" in + .dev.vars | .dev.vars.production | .env | .env.* | service-account-*.json | *-service-account.json | *.key.json) + echo "❌ secret file staged: $f" + fail=1 + ;; + esac +done < <(git diff --cached --name-only 2>/dev/null) + +# (2) 差分本文に実シークレットの痕跡(変数名のみの行は対象外) +diff=$(git diff --cached 2>/dev/null) +[ -n "$diff" ] || diff=$(git diff 2>/dev/null) + +if printf '%s\n' "$diff" | grep -qE -- '-----BEGIN[[:space:]]+([A-Z]+[[:space:]]+)?PRIVATE KEY-----'; then + echo "❌ private key material detected in diff" + fail=1 +fi +if printf '%s\n' "$diff" | grep -qE '"private_key"[[:space:]]*:|"type"[[:space:]]*:[[:space:]]*"service_account"'; then + echo "❌ service-account JSON content detected in diff" + fail=1 +fi + +if [ "$fail" -ne 0 ]; then + echo "→ CLAUDE.md 重要な制約 7(公開リポジトリ運用)を確認してください。" + exit 1 +fi +echo "✅ no secret material detected in diff" +exit 0 diff --git a/.gitignore b/.gitignore index 1c91b9b..1981252 100644 --- a/.gitignore +++ b/.gitignore @@ -155,6 +155,7 @@ vite.config.ts.timestamp-* .env.production .dev.vars .dev.vars.production +.dev.vars.* # Service account keys service-account-*.json diff --git a/CLAUDE.md b/CLAUDE.md index f557e4e..ddd6710 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,7 +38,7 @@ tecnova-platform/ ├── apps/ # エンドユーザー向けアプリ │ ├── api/ # Hono on Cloudflare Workers │ ├── checkin/ # Next.js iPad PWA(受付端末) -│ ├── admin/ # Next.js 管理PC画面 +│ ├── admin/ # Next.js 管理画面(PC・モバイル / PWA) │ └── signage/ # Next.js 会場サイネージ(大型モニター・キオスク) ├── packages/ # アプリ間で共有するライブラリ │ ├── db/ # Drizzle schema・migrations diff --git a/apps/admin/CLAUDE.md b/apps/admin/CLAUDE.md index adb00b0..3b14c54 100644 --- a/apps/admin/CLAUDE.md +++ b/apps/admin/CLAUDE.md @@ -1,9 +1,14 @@ @AGENTS.md -# admin(管理画面 / PC) +# admin(管理画面 / PC・モバイル) - **Next.js 16 / React 19**。App Router の API がトレーニングデータと乖離しているため、上記 AGENTS.md の通り実装前に `node_modules/next/dist/docs/` を確認すること。 - **dev ポート**: `3001`(`next dev --port 3001`)。api は `8787`、checkin は `3000`。 - **必須 env**: `NEXT_PUBLIC_API_URL`(未設定時は `http://localhost:8787` にフォールバック)。サンプルはリポジトリ root の `.env.example` を参照(ローカルは `.env.local` にコピー)。本番は Vercel 環境変数で設定。 - **新しい `@tecnova/*` パッケージを使うとき**: `next.config.ts` の `transpilePackages`(現状 `@tecnova/shared`, `@tecnova/ui`)に追加しないと ESM ビルドが壊れる。 - 認証は `(authed)/layout.tsx` で `MeProvider` をラップする構成。API 呼び出しは `@tecnova/ui` の `apiFetch`、ユーザー情報は `useMe()` を使う。 +- **レスポンシブ / ナビ**: `AppShell` がデスクトップ=固定サイドバー、モバイル=トップバー + ボトムタブで出し分ける。ナビ項目は `src/components/nav-items.ts` を唯一の真実の源とし、ロールで出し分ける。広いテーブルは `md` 未満で `RecordCard` のカード一覧に切り替える(`hidden md:block` ↔ `md:hidden`)。 +- **ダークモード**: `@tecnova/ui` の `ThemeProvider`(next-themes)を `layout.tsx` でラップする。`` が必須。切替 UI は `ThemeToggle`、トースト(`Toaster`)はテーマに追従する。 +- **ブランドロゴ**: 公式ロゴ `public/logo_tecnova.png`(checkin / signage と同一)を `BrandLogo`(`src/components/brand-logo.tsx`)で表示する。ワードマークが暗色のため**ダークモードでは白プレート**(`dark:bg-white`)を敷いて視認性を確保する(ライトは枠なし)。サイドバー・モバイルトップバー・ログインで共用。 +- **モーション**: `motion`(framer-motion v12)で控えめなマイクロインタラクションを付ける。トークンは `src/lib/motion.ts`(checkin と同方針)。`Reveal`(セクション/一覧のフェードアップ・スタッガー)、`AnimatedNumber`(サマリ数値のカウントアップ)、ナビのアクティブインジケータは `layoutId` で滑らせる(サイドバー=ピル / ボトムタブ=バー)。**すべて `prefers-reduced-motion` を尊重**(`Reveal`/`AnimatedNumber` は内部で、ナビは静的フォールバックで分岐)。 +- **PWA は iOS/Android 二重設定**(checkin と同様): iOS は `src/app/layout.tsx` の `appleWebApp`、Android/Chromium は `src/app/manifest.ts`。アイコンは `src/app/icon.tsx` / `apple-icon.tsx` が `ImageResponse` で生成し、**`public/logo_tecnova.png` を白地の正方形に中央配置**して埋め込む(`readFile` → data URL。Satori はネイティブ `` のみ対応=`next/image` 不可)。**ただし checkin と違いズームは許可**(管理画面はアクセシビリティ重視のため `viewport` で `maximumScale`/`userScalable` を制限しない)。 diff --git a/apps/admin/package.json b/apps/admin/package.json index 18eaad2..a9e1e78 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -13,6 +13,7 @@ "@tecnova/shared": "workspace:*", "@tecnova/ui": "workspace:*", "better-auth": "^1.6.9", + "motion": "^12.40.0", "next": "16.2.4", "react": "19.2.4", "react-dom": "19.2.4" diff --git a/apps/admin/public/file.svg b/apps/admin/public/file.svg deleted file mode 100644 index 004145c..0000000 --- a/apps/admin/public/file.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/apps/admin/public/globe.svg b/apps/admin/public/globe.svg deleted file mode 100644 index 567f17b..0000000 --- a/apps/admin/public/globe.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/apps/admin/public/logo_tecnova.png b/apps/admin/public/logo_tecnova.png new file mode 100644 index 0000000..c1b7689 Binary files /dev/null and b/apps/admin/public/logo_tecnova.png differ diff --git a/apps/admin/public/next.svg b/apps/admin/public/next.svg deleted file mode 100644 index 5174b28..0000000 --- a/apps/admin/public/next.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/apps/admin/public/vercel.svg b/apps/admin/public/vercel.svg deleted file mode 100644 index 7705396..0000000 --- a/apps/admin/public/vercel.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/apps/admin/public/window.svg b/apps/admin/public/window.svg deleted file mode 100644 index b2b2a44..0000000 --- a/apps/admin/public/window.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file 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 44a5007..4fa49e3 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'; @@ -20,6 +22,7 @@ import { SelectTrigger, SelectValue, } from '@tecnova/ui/components/select'; +import { Skeleton } from '@tecnova/ui/components/skeleton'; import { Table, TableBody, @@ -35,39 +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'; - -type State = - | { kind: 'loading' } - | { kind: 'ok'; mentors: MentorItem[] } - | { kind: 'error'; message: string }; +import { RecordCard, RecordField } from '@/components/record-card'; +import { Reveal } from '@/components/reveal'; 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') { @@ -84,55 +70,90 @@ export default function MentorsPage() { return (
- + + + + + + reload({ background: true })} /> + - + {/* データ領域。Reveal を常時マウントして入場は一度だけ(再フェッチで再生されない)。 + フラグメントを含むので gap-6 を再指定。 */} + + {state.kind === 'loading' && ( + <> +
+ +
+
+ {[0, 1, 2, 3, 4].map((i) => ( + + ))} +
+ + )} + {state.kind === 'error' && } - {state.kind === 'loading' && } - {state.kind === 'error' && ( - - 読み込めませんでした - {state.message} - - )} + {state.kind === 'ok' && + (state.data.mentors.length === 0 ? ( + + ) : ( + <> + {/* モバイル: カードリスト。 + card / row 両 variant を常時マウントし CSS で出し分ける(SSR 安全)。 + 各行は編集状態をローカルに持つため、編集途中で md をまたいでリサイズすると + 未保存の編集が見かけ上消える。admin の利用端末(PC / タブレット)では稀で、 + 保存すれば再取得で両者が同期するため許容するトレードオフ。 */} +
+ {state.data.mentors.map((m) => ( + reload({ background: true })} + variant="card" + /> + ))} +
- {state.kind === 'ok' && ( - - - - - メールアドレス - 名前 - ロール - 状態 - 登録日 - 最終ログイン - 操作 - - - - {state.mentors.length === 0 ? ( - - - まだ管理者が登録されていません - - - ) : ( - state.mentors.map((m) => ) - )} - -
-
- )} + {/* デスクトップ: テーブル */} + + + + + メールアドレス + 名前 + ロール + 状態 + 登録日 + 最終ログイン + 操作 + + + + {state.data.mentors.map((m) => ( + reload({ background: true })} + variant="row" + /> + ))} + +
+
+ + ))} +
); } -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'); @@ -149,7 +170,7 @@ function CreateMentorForm({ onCreated }: { onCreated: () => Promise }) { setEmail(''); setName(''); setRole('mentor'); - await onCreated(); + onCreated(); } catch (e) { toastError(e, '管理者を追加できませんでした'); } finally { @@ -207,7 +228,16 @@ function CreateMentorForm({ onCreated }: { onCreated: () => Promise }) { ); } -function MentorRow({ mentor, onUpdated }: { mentor: MentorItem; onUpdated: () => Promise }) { +function MentorRow({ + mentor, + onUpdated, + variant, +}: { + mentor: MentorItem; + onUpdated: () => void; + // 'row' = デスクトップのテーブル行 / 'card' = モバイルのカード + variant: 'row' | 'card'; +}) { const me = useMe(); const [role, setRole] = useState(mentor.role); const [active, setActive] = useState(mentor.active); @@ -216,7 +246,7 @@ function MentorRow({ mentor, onUpdated }: { mentor: MentorItem; onUpdated: () => const dirty = role !== mentor.role || active !== mentor.active; // 自分自身のロール降格 / 無効化は禁止(最後の admin が自分を外して詰むのを避ける) const isSelf = mentor.id === me.mentor.id; - const activeId = `mentor-active-${mentor.id}`; + const activeId = `mentor-active-${variant}-${mentor.id}`; const save = async () => { if (!dirty || busy) return; @@ -227,7 +257,7 @@ function MentorRow({ mentor, onUpdated }: { mentor: MentorItem; onUpdated: () => 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 { @@ -248,49 +278,74 @@ function MentorRow({ mentor, onUpdated }: { mentor: MentorItem; onUpdated: () => node ); + // 操作系 UI(ロール選択・有効チェック・保存)。テーブル行とカードで共有する。 + const roleControl = wrapSelfReadonly( + , + ); + + const activeControl = wrapSelfReadonly( + , + ); + + const saveControl = wrapSelfReadonly( + , + ); + + if (variant === 'card') { + return ( + +
+

{mentor.name}

+

{mentor.email}

+
+
+
+ ロール + {roleControl} +
+
+ 状態 + {activeControl} +
+ {formatJstDate(mentor.createdAt)} + {formatJstDate(mentor.lastLoginAt)} +
{saveControl}
+
+
+ ); + } + return ( {mentor.email} {mentor.name} - - {wrapSelfReadonly( - , - )} - - - {wrapSelfReadonly( - , - )} - + {roleControl} + {activeControl} {formatJstDate(mentor.createdAt)} {formatJstDate(mentor.lastLoginAt)} - - {wrapSelfReadonly( - , - )} - + {saveControl} ); } diff --git a/apps/admin/src/app/(authed)/page.tsx b/apps/admin/src/app/(authed)/page.tsx index a2d5475..602cf01 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,15 +31,13 @@ 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'; - -type SessionsState = - | { kind: 'loading' } - | { kind: 'ok'; data: TodaySessionsResponse } - | { kind: 'error'; message: string }; +import { RecordCard, RecordField } from '@/components/record-card'; +import { Reveal } from '@/components/reveal'; // セレクタで「今日」を選んでいる状態のセンチネル値。 // 空文字や undefined を使うと Select の制御値として扱いにくいのでこの形に。 @@ -54,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()); // 「本日」ラベル + イベントとして登録済みの過去日を結合する。 @@ -96,42 +73,48 @@ export default function DashboardPage() { return (
- - - - - } - /> + + + + + + } + /> + - setSelectedParticipantId(id)} - /> + {/* DashboardBody はフラグメントを返すので、main の gap-6 を保つため Reveal 側で再指定する。 + 常時マウントなので入場は一度だけ(再フェッチで再生されない)。 */} + + setSelectedParticipantId(id)} + /> + ; onSelectParticipant: (id: string) => void; }) { - if (sessions.kind === 'loading') { + if (sessions.kind === 'loading' || sessions.kind === 'idle') { return ( <> -
+
- +
+ +
+
+ {[0, 1, 2, 3, 4].map((i) => ( + + ))} +
); } if (sessions.kind === 'error') { - return ( - - セッションを読み込めませんでした - {sessions.message} - - ); + return ; } const { event, sessions: rows, summary } = sessions.data; return ( <> -
+
- - + {/* モバイル: カードリスト */} +
+ {rows.length === 0 ? ( + + ) : ( + rows.map((s) => ( + onSelectParticipant(s.participantId)} + ariaLabel={`${s.nickname}(${s.grade}・${s.isPresent ? '来場中' : '退出済'})の詳細を開く`} + > +
+
+

{s.nickname}

+

+ {s.fullName}・{s.grade} +

+
+ + {s.isPresent ? '来場中' : '退出済'} + +
+
+ {s.term && ( + + + + {!s.counted && } + + + )} + {fmtTime(s.checkedInAt)} + + {s.checkedOutAt ? fmtTime(s.checkedOutAt) : '—'} + + + {s.participantId} + +
+
+ )) + )} +
+ {/* デスクトップ: テーブル */} + @@ -252,6 +280,19 @@ function DashboardBody({ ); } +function EmptySessions({ hasEvent }: { hasEvent: boolean }) { + return ( +
+ + + {hasEvent + ? 'このイベントのセッションはまだありません' + : 'この日のイベントはまだ作成されていません'} + +
+ ); +} + function SummaryCard({ label, value, @@ -263,12 +304,16 @@ function SummaryCard({ }) { return ( - - {label} - + + + {label} + + -
{value}
+
+ +
); diff --git a/apps/admin/src/app/(authed)/participants/page.tsx b/apps/admin/src/app/(authed)/participants/page.tsx index 5e873e5..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, @@ -14,6 +15,7 @@ import { SelectTrigger, SelectValue, } from '@tecnova/ui/components/select'; +import { Skeleton } from '@tecnova/ui/components/skeleton'; import { Table, TableBody, @@ -23,16 +25,13 @@ 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'; import { ParticipantDetailSheet } from '@/components/participant-detail-sheet'; - -type State = - | { kind: 'loading' } - | { kind: 'ok'; data: ParticipantsListResponse } - | { kind: 'error'; message: string }; +import { RecordCard, RecordField } from '@/components/record-card'; +import { Reveal } from '@/components/reveal'; const PAGE_SIZE = 50; @@ -41,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); @@ -70,36 +68,27 @@ 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; return (
- + + + -
-
+ +
無効 -
+ + + {/* loading/error/ok を切り替えるデータ領域。Reveal を常時マウントして入場は一度だけにする + (検索のたびに再生されない)。フラグメントを含むので gap-6 を再指定。 */} + + {state.kind === 'loading' && ( + <> +
+ +
+
+ {[0, 1, 2, 3, 4, 5].map((i) => ( + + ))} +
+ + )} - {state.kind === 'loading' && } + {state.kind === 'error' && } - {state.kind === 'error' && ( - - 読み込めませんでした - {state.message} - - )} + {state.kind === 'ok' && ( + <> + {/* モバイル: カードリスト */} +
+ {state.data.participants.length === 0 ? ( + + ) : ( + state.data.participants.map((p) => ( + setSelectedParticipantId(p.id)} + ariaLabel={`${p.nickname}(${p.grade}・${p.active ? '有効' : '無効'})の詳細を開く`} + > +
+
+

{p.nickname}

+

+ {p.fullName}・{p.grade} +

+
+ + {p.active ? '有効' : '無効'} + +
+
+ + {p.id} + + {formatJstDate(p.activatedAt)} +
+
+ )) + )} +
- {state.kind === 'ok' && ( - <> - -
- - - ID - 氏名 - ニックネーム - 学年 - ID発行日 - 状態 - - - - {state.data.participants.length === 0 ? ( + {/* デスクトップ: テーブル */} + +
+ - - 該当する利用者が見つかりません - + ID + 氏名 + ニックネーム + 学年 + ID発行日 + 状態 - ) : ( - state.data.participants.map((p) => ( - setSelectedParticipantId(p.id)} - > - {p.id} - {p.fullName} - {p.nickname} - {p.grade} - {formatJstDate(p.activatedAt)} - - - {p.active ? '有効' : '無効'} - + + + {state.data.participants.length === 0 ? ( + + + 該当する利用者が見つかりません - )) - )} - -
-
+ ) : ( + state.data.participants.map((p) => ( + setSelectedParticipantId(p.id)} + > + {p.id} + {p.fullName} + {p.nickname} + {p.grade} + {formatJstDate(p.activatedAt)} + + + {p.active ? '有効' : '無効'} + + + + )) + )} + + +
-
- - 全 {state.data.pagination.total} 件 ・ {page} / {totalPages} ページ - -
- - - - -
-
- - )} +
+ + 全 {state.data.pagination.total} 件 ・ {page} / {totalPages} ページ + +
+ + + + +
+
+ + )} + new Intl.DateTimeFormat('en-CA', { @@ -77,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') { @@ -115,54 +93,84 @@ export default function PreRegistrationsPage() { return (
- + + + - + + reload({ background: true })} /> + - {state.kind === 'loading' && } - {state.kind === 'error' && ( - - 読み込めませんでした - {state.message} - - )} + {/* データ領域。Reveal を常時マウントして入場は一度だけ(再フェッチで再生されない)。 + フラグメントを含むので gap-6 を再指定。 */} + + {state.kind === 'loading' && ( + <> +
+ +
+
+ {[0, 1, 2, 3, 4].map((i) => ( + + ))} +
+ + )} + {state.kind === 'error' && } - {state.kind === 'ok' && ( - <> - - - - - 事前登録ID - 氏名 - ニックネーム - 学年 - 事前登録日 - 操作 - - - - {state.preRegistrations.length === 0 ? ( - - - ID未発行の事前登録はありません - - - ) : ( - state.preRegistrations.map((p) => ( - - )) - )} - -
-
+ {state.kind === 'ok' && ( + <> + {state.data.preRegistrations.length === 0 ? ( + + ) : ( + <> + {/* モバイル: カードリスト */} +
+ {state.data.preRegistrations.map((p) => ( + reload({ background: true })} + variant="card" + /> + ))} +
- - - )} + {/* デスクトップ: テーブル */} + + + + + 事前登録ID + 氏名 + ニックネーム + 学年 + 事前登録日 + 操作 + + + + {state.data.preRegistrations.map((p) => ( + reload({ background: true })} + variant="row" + /> + ))} + +
+
+ + )} + + + + )} +
); } @@ -186,47 +194,80 @@ function ActivatedPreRegistrationsTable({ items }: { items: ActivatedPreRegistra - - - - 事前登録ID - 本登録ID - 氏名 - ニックネーム - 学年 - 事前登録日 - ID発行日時 - - - - {items.length === 0 ? ( + {/* モバイル: カードリスト */} +
+ {items.length === 0 ? ( +

+ ID発行済みの利用者はありません +

+ ) : ( + items.map((item) => ( + +
+

{item.nickname}

+

+ {item.fullName}・{item.grade} +

+
+
+ + {item.preRegistrationId} + + + {item.internalId || '-'} + + {item.registeredAt} + {item.activatedAt || '-'} +
+
+ )) + )} +
+ + {/* デスクトップ: テーブル */} +
+
+ - - ID発行済みの利用者はありません - + 事前登録ID + 本登録ID + 氏名 + ニックネーム + 学年 + 事前登録日 + ID発行日時 - ) : ( - items.map((item) => ( - - {item.preRegistrationId} - {item.internalId || '-'} - {item.fullName} - {item.nickname} - {item.grade} - {item.registeredAt} - {item.activatedAt || '-'} + + + {items.length === 0 ? ( + + + ID発行済みの利用者はありません + - )) - )} - -
+ ) : ( + items.map((item) => ( + + {item.preRegistrationId} + {item.internalId || '-'} + {item.fullName} + {item.nickname} + {item.grade} + {item.registeredAt} + {item.activatedAt || '-'} + + )) + )} + + +
); } -function CreatePreRegistrationForm({ onCreated }: { onCreated: () => Promise }) { +function CreatePreRegistrationForm({ onCreated }: { onCreated: () => void }) { const [fullName, setFullName] = useState(''); const [nickname, setNickname] = useState(''); const [grade, setGrade] = useState(''); @@ -257,7 +298,7 @@ function CreatePreRegistrationForm({ onCreated }: { onCreated: () => Promise Promise Promise; + onDeleted: () => void; + // 'row' = デスクトップのテーブル行 / 'card' = モバイルのカード + variant: 'row' | 'card'; }) { const [busy, setBusy] = useState(false); const deleteDescription = `${item.preRegistrationId}(${item.fullName} / ${item.nickname})を削除します。この操作は取り消せません。`; @@ -357,7 +401,7 @@ function PreRegistrationRow({ throw new ApiError(r.status, body); } toastSuccess(`${item.preRegistrationId} を削除しました`); - await onDeleted(); + onDeleted(); } catch (e) { toastError(e, '削除できませんでした'); } finally { @@ -365,6 +409,50 @@ function PreRegistrationRow({ } }; + const deleteButton = ( + + + + + + + 事前登録を削除しますか? + {deleteDescription} + + + キャンセル + + 削除 + + + + + ); + + if (variant === 'card') { + return ( + +
+
+

{item.nickname}

+

+ {item.fullName}・{item.grade} +

+
+ {deleteButton} +
+
+ + {item.preRegistrationId} + + {item.registeredAt} +
+
+ ); + } + return ( {item.preRegistrationId} @@ -372,27 +460,7 @@ function PreRegistrationRow({ {item.nickname} {item.grade} {item.registeredAt} - - - - - - - - 事前登録を削除しますか? - {deleteDescription} - - - キャンセル - - 削除 - - - - - + {deleteButton} ); } diff --git a/apps/admin/src/app/(authed)/stats/page.tsx b/apps/admin/src/app/(authed)/stats/page.tsx index 0cc75f8..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,19 +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'; - -type SummaryState = - | { kind: 'loading' } - | { kind: 'ok'; data: ParticipationSummaryResponse } - | { kind: 'error'; message: string }; +import { Reveal } from '@/components/reveal'; export default function StatsPage() { - const [summary, setSummary] = useState({ kind: 'loading' }); // 入力中の値(適用ボタンを押すまで反映しない)。空文字 = フィルタなし。 const [fromInput, setFromInput] = useState(''); const [toInput, setToInput] = useState(''); @@ -43,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); @@ -78,55 +64,60 @@ export default function StatsPage() { return (
- - setFromInput(e.target.value)} - className="w-40" - /> - - setToInput(e.target.value)} - className="w-40" - /> - - {hasFilter && ( - - )} - - } - /> + {hasFilter && ( + + )} + + } + /> + - + {/* 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 ( <> -
+
@@ -139,20 +130,20 @@ function StatsBody({ summary }: { summary: SummaryState }) { } if (summary.kind === 'error') { - return ( - - 集計を読み込めませんでした - {summary.message} - - ); + return ; } const { totals, byDate } = summary.data; return ( <> -
- +
+ - - {label} - + + + + {label} + + -
{value}
+
+ +
); diff --git a/apps/admin/src/app/apple-icon.tsx b/apps/admin/src/app/apple-icon.tsx new file mode 100644 index 0000000..47728cd --- /dev/null +++ b/apps/admin/src/app/apple-icon.tsx @@ -0,0 +1,29 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { ImageResponse } from 'next/og'; + +// iOS ホーム画面用アイコン(apple-touch-icon)。iOS が角丸を付けるので白地フルブリードでよい。 +// 公式ロゴ(横長ワードマーク)を中央配置する。詳細は icon.tsx と同方針。 +export const size = { width: 180, height: 180 }; +export const contentType = 'image/png'; + +export default async function AppleIcon() { + const logo = await readFile(join(process.cwd(), 'public', 'logo_tecnova.png')); + const src = `data:image/png;base64,${logo.toString('base64')}`; + return new ImageResponse( +
+ {/* biome-ignore lint/performance/noImgElement: ImageResponse(Satori) はネイティブ のみ対応。next/image は使えない。 */} + tec-nova Nagasaki +
, + { ...size }, + ); +} 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/app/icon.tsx b/apps/admin/src/app/icon.tsx new file mode 100644 index 0000000..6a19a26 --- /dev/null +++ b/apps/admin/src/app/icon.tsx @@ -0,0 +1,30 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { ImageResponse } from 'next/og'; + +// ブラウザタブ・PWA 用アイコン。公式ロゴ(横長ワードマーク)を白地の正方形に +// 中央配置して生成する。ImageResponse(Satori) は flexbox のみ・画像は で埋める。 +// ロゴ PNG は public から読み込み base64 で渡す(admin は Vercel/Node ランタイムなので fs 可)。 +export const size = { width: 512, height: 512 }; +export const contentType = 'image/png'; + +export default async function Icon() { + const logo = await readFile(join(process.cwd(), 'public', 'logo_tecnova.png')); + const src = `data:image/png;base64,${logo.toString('base64')}`; + return new ImageResponse( +
+ {/* biome-ignore lint/performance/noImgElement: ImageResponse(Satori) はネイティブ のみ対応。next/image は使えない。 */} + tec-nova Nagasaki +
, + { ...size }, + ); +} diff --git a/apps/admin/src/app/layout.tsx b/apps/admin/src/app/layout.tsx index 925b944..a09bfc8 100644 --- a/apps/admin/src/app/layout.tsx +++ b/apps/admin/src/app/layout.tsx @@ -1,6 +1,7 @@ -import type { Metadata } from 'next'; +import type { Metadata, Viewport } from 'next'; import { LINE_Seed_JP } from 'next/font/google'; import '@tecnova/ui/globals.css'; +import { ThemeProvider } from '@tecnova/ui/components/theme-provider'; import { cn } from '@tecnova/ui/lib/utils'; const fontSans = LINE_Seed_JP({ @@ -11,6 +12,25 @@ const fontSans = LINE_Seed_JP({ export const metadata: Metadata = { title: 'テクノバ管理画面', + // iOS Safari に PWA 起動を伝える(Android / Chromium 用は app/manifest.ts)。両方必要。 + appleWebApp: { + capable: true, + title: 'テクノバ管理画面', + // 上部にトップバーがあるため content を status bar 下に収める 'default' を使う + // (checkin の 'black-translucent' はキオスク用途)。 + statusBarStyle: 'default', + }, +}; + +export const viewport: Viewport = { + // PWA のステータスバー色をテーマに追従させる(light: 背景白 / dark: 背景黒)。 + themeColor: [ + { media: '(prefers-color-scheme: light)', color: '#ffffff' }, + { media: '(prefers-color-scheme: dark)', color: '#0a0a0a' }, + ], + width: 'device-width', + initialScale: 1, + // 管理画面はアクセシビリティ重視。checkin と違いズームは制限しない。 }; export default function RootLayout({ @@ -19,8 +39,15 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - - {children} + // next-themes が に .dark / .light を付け替えるため suppressHydrationWarning が必要。 + + + {children} + ); } diff --git a/apps/admin/src/app/login/page.tsx b/apps/admin/src/app/login/page.tsx index f55f8c5..1ceb830 100644 --- a/apps/admin/src/app/login/page.tsx +++ b/apps/admin/src/app/login/page.tsx @@ -11,12 +11,17 @@ import { CardHeader, CardTitle, } from '@tecnova/ui/components/card'; +import { motion, useReducedMotion } from 'motion/react'; import { useState } from 'react'; +import { BrandLogo } from '@/components/brand-logo'; +import { Reveal } from '@/components/reveal'; import { authClient } from '@/lib/auth-client'; +import { tapScale } from '@/lib/motion'; export default function LoginPage() { const [error, setError] = useState(null); const [busy, setBusy] = useState(false); + const prefersReduced = useReducedMotion(); const signIn = async () => { setBusy(true); @@ -41,32 +46,39 @@ export default function LoginPage() { }; return ( -
- - -

- テクノバながさき 運営管理 -

- 管理画面にログイン - - 許可リストに登録された管理者のみログインできます。 Google アカウントで認証してください。 - -
- {error && ( - - - ログインエラー - {error} - - - )} - - - -
+
+ + + + {/* 直後に「テクノバながさき 運営管理」の文字があるので、ロゴは装飾扱い(alt="")にして二重読み上げを避ける */} + +

+ テクノバながさき 運営管理 +

+ 管理画面にログイン + + 許可リストに登録された管理者のみログインできます。 Google + アカウントで認証してください。 + +
+ {error && ( + + + ログインエラー + {error} + + + )} + + + + + +
+
); } diff --git a/apps/admin/src/app/manifest.ts b/apps/admin/src/app/manifest.ts new file mode 100644 index 0000000..546d865 --- /dev/null +++ b/apps/admin/src/app/manifest.ts @@ -0,0 +1,22 @@ +import type { MetadataRoute } from 'next'; + +// admin を「ホーム画面に追加」できる PWA にするためのマニフェスト(Android / Chromium 用)。 +// iOS 側は layout.tsx の appleWebApp で別途設定する(両方必要)。 +// アイコンは app/icon.tsx・app/apple-icon.tsx でプログラム生成し、PNG は置かない。 +// checkin と異なり orientation は固定しない(管理画面は縦横どちらでも使う)。 +export default function manifest(): MetadataRoute.Manifest { + return { + name: 'テクノバ管理画面', + short_name: '管理画面', + description: 'テクノバながさきの運営管理画面', + start_url: '/', + display: 'standalone', + background_color: '#ffffff', + theme_color: '#2563eb', + lang: 'ja', + // 生成アイコン(app/icon.tsx)の 512px PNG を参照する。favicon.ico は + // ブラウザタブ用に Next が自動リンクするのでマニフェストには含めない + // (.ico をマニフェストアイコンにすると Chrome が不正画像として警告するため)。 + icons: [{ src: '/icon', sizes: '512x512', type: 'image/png' }], + }; +} diff --git a/apps/admin/src/components/account-menu.tsx b/apps/admin/src/components/account-menu.tsx new file mode 100644 index 0000000..a17a82c --- /dev/null +++ b/apps/admin/src/components/account-menu.tsx @@ -0,0 +1,66 @@ +'use client'; + +import { IconLogout } from '@tabler/icons-react'; +import { Badge } from '@tecnova/ui/components/badge'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@tecnova/ui/components/dropdown-menu'; +import { useMe } from '@tecnova/ui/components/me-provider'; +import { useRouter } from 'next/navigation'; +import { authClient } from '@/lib/auth-client'; + +interface Props { + /** ドロップダウンを開くトリガー要素(サイドバー / トップバーで見た目が異なる) */ + trigger: React.ReactNode; + align?: 'start' | 'center' | 'end'; + side?: 'top' | 'right' | 'bottom' | 'left'; +} + +// 管理者情報(氏名・ロール・Google アカウント・メール)とログアウトをまとめた +// アカウントメニュー。サイドバーのフッターとモバイルのトップバーで共有する。 +export function AccountMenu({ trigger, align = 'end', side }: Props) { + const me = useMe(); + const router = useRouter(); + + const signOut = async () => { + await authClient.signOut(); + router.replace('/login'); + }; + + return ( + + {trigger} + + +
+
+ 管理者名 + {me.mentor.name} +
+
+ Googleアカウント名 + {me.user.name} +
+
+ メールアドレス + {me.user.email} +
+ + {me.mentor.role} + +
+
+ + + + ログアウト + +
+
+ ); +} diff --git a/apps/admin/src/components/animated-number.tsx b/apps/admin/src/components/animated-number.tsx new file mode 100644 index 0000000..169ef50 --- /dev/null +++ b/apps/admin/src/components/animated-number.tsx @@ -0,0 +1,37 @@ +'use client'; + +import { animate, motion, useMotionValue, useReducedMotion, useTransform } from 'motion/react'; +import { useEffect } from 'react'; + +type AnimatedNumberProps = { + value: number; + className?: string; + // カウントアップの長さ(ms)。reduced-motion 時は無視して即値を出す。 + durationMs?: number; +}; + +// 0 → value をカウントアップ表示する。prefers-reduced-motion を尊重し、その時はアニメーションせず即値を出す。 +// 値は整数想定(来場者数・参加回数)。桁揃えは呼び出し側で tabular-nums を付ける。 +// 注: サマリカードは再フェッチ(更新ボタン・日付/期間変更)のたびに loading→ok で再マウントされるため、 +// その都度 0 から数え直す。これは「データが更新された」フィードバックとして許容する意図的な挙動 +// (止めたい場合は再フェッチ中も前回データを残してカードをアンマウントしない設計が必要)。 +export function AnimatedNumber({ value, className, durationMs = 700 }: AnimatedNumberProps) { + const prefersReduced = useReducedMotion(); + // reduced-motion なら最初から value で初期化し、0 からの一瞬のちらつきも避ける。 + const motionValue = useMotionValue(prefersReduced ? value : 0); + const text = useTransform(motionValue, (latest) => String(Math.round(latest))); + + useEffect(() => { + if (prefersReduced) { + motionValue.set(value); + return; + } + const controls = animate(motionValue, value, { + duration: durationMs / 1000, + ease: 'easeOut', + }); + return () => controls.stop(); + }, [value, durationMs, prefersReduced, motionValue]); + + return {text}; +} diff --git a/apps/admin/src/components/app-shell.tsx b/apps/admin/src/components/app-shell.tsx index e62a598..09bd6b7 100644 --- a/apps/admin/src/components/app-shell.tsx +++ b/apps/admin/src/components/app-shell.tsx @@ -1,131 +1,28 @@ 'use client'; -import { - IconChartBar, - IconChevronDown, - IconClipboardList, - IconLayoutDashboard, - IconLogout, - IconUserShield, - IconUsers, -} from '@tabler/icons-react'; -import { Badge } from '@tecnova/ui/components/badge'; -import { Button } from '@tecnova/ui/components/button'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from '@tecnova/ui/components/dropdown-menu'; -import { useMe } from '@tecnova/ui/components/me-provider'; -import { Separator } from '@tecnova/ui/components/separator'; -import { cn } from '@tecnova/ui/lib/utils'; -import Link from 'next/link'; -import { usePathname, useRouter } from 'next/navigation'; -import { authClient } from '@/lib/auth-client'; - -interface NavItem { - href: string; - label: string; - Icon: typeof IconLayoutDashboard; -} +import { BottomNav } from './bottom-nav'; +import { MobileTopBar } from './mobile-top-bar'; +import { Sidebar } from './sidebar'; interface Props { children: React.ReactNode; } +// 認証必須セクションのレイアウトシェル。 +// - デスクトップ(md+): 固定サイドバー + 本文 +// - モバイル: 上にトップバー、下にボトムタブバー、その間に本文 +// 本文はボトムナビ + safe-area ぶん下に余白を取り、最後の要素が隠れないようにする。 export function AppShell({ children }: Props) { - const me = useMe(); - const pathname = usePathname(); - const router = useRouter(); - - const signOut = async () => { - await authClient.signOut(); - router.replace('/login'); - }; - - const navItems: NavItem[] = [ - { href: '/', label: 'ダッシュボード', Icon: IconLayoutDashboard }, - { href: '/participants', label: '利用者一覧', Icon: IconUsers }, - { href: '/stats', label: '集計', Icon: IconChartBar }, - ...(me.mentor.role === 'admin' - ? [ - { - href: '/pre-registrations', - label: '事前登録管理', - Icon: IconClipboardList, - }, - { href: '/mentors', label: '管理者一覧', Icon: IconUserShield }, - ] - : []), - ]; - return ( -
-
-

テクノバ管理画面

- - - - - - -
-
- 管理者名 - {me.mentor.name} -
-
- Googleアカウント名 - {me.user.name} -
-
- メールアドレス - {me.user.email} -
- - {me.mentor.role} - -
-
- - - - ログアウト - -
-
-
- - - -
{children}
+
+ + {/* 各ページが自前の
を持つので、ここはラッパ div に留める(main の入れ子回避)。 + モバイルではボトムナビ + safe-area ぶん下に余白を取り、最後の要素が隠れないようにする。 */} +
+ + {children} +
+
); } diff --git a/apps/admin/src/components/bottom-nav.tsx b/apps/admin/src/components/bottom-nav.tsx new file mode 100644 index 0000000..32250d0 --- /dev/null +++ b/apps/admin/src/components/bottom-nav.tsx @@ -0,0 +1,73 @@ +'use client'; + +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'; +import { usePathname } from 'next/navigation'; +import { navIndicatorTransition } from '@/lib/motion'; +import { isNavItemActive, visibleNavItems } from './nav-items'; + +// モバイル用のボトムタブバー。画面下に固定し、iPhone のホームインジケータを +// 避けるため safe-area ぶんの余白を足す。ロールに応じて 3〜5 タブを出す。 +// 認証解決前はバーの枠だけ即描画し、ロール依存のタブはスケルトンにする(即時シェル)。 +export function BottomNav({ className }: { className?: string }) { + const meState = useMeState(); + const pathname = usePathname(); + const prefersReduced = useReducedMotion(); + const me = meState.status === 'ok' ? meState.me : null; + const items = me ? visibleNavItems(me.mentor.role) : []; + + return ( + + ); +} diff --git a/apps/admin/src/components/brand-logo.tsx b/apps/admin/src/components/brand-logo.tsx new file mode 100644 index 0000000..ce154fa --- /dev/null +++ b/apps/admin/src/components/brand-logo.tsx @@ -0,0 +1,38 @@ +import { cn } from '@tecnova/ui/lib/utils'; +import Image from 'next/image'; + +// tec-nova Nagasaki 公式ロゴ。サイドバー / トップバー / ログインで共用する。 +// ロゴのワードマークは暗色なので、ダークモードの暗い面では視認できなくなる。 +// そのためダークモードでだけ白いプレートを敷いて色味ごと見せる(ライトでは枠なし=余白のみ)。 +// 元画像のアスペクト比は 2277:597 ≈ 3.81:1。高さは imgClassName(例: 'h-7')で指定する。 +export function BrandLogo({ + imgClassName = 'h-7', + className, + priority, + // 隣にブランド名テキストがある場所(ログイン画面)では alt="" を渡して + // スクリーンリーダーの二重読み上げを避ける。既定は情報を持つロゴとして読ませる。 + alt = 'tec-nova Nagasaki', +}: { + imgClassName?: string; + className?: string; + priority?: boolean; + alt?: string; +}) { + return ( + + {alt} + + ); +} diff --git a/apps/admin/src/components/mobile-top-bar.tsx b/apps/admin/src/components/mobile-top-bar.tsx new file mode 100644 index 0000000..f8c1288 --- /dev/null +++ b/apps/admin/src/components/mobile-top-bar.tsx @@ -0,0 +1,52 @@ +'use client'; + +import { useMeState } from '@tecnova/ui/components/me-provider'; +import { Skeleton } from '@tecnova/ui/components/skeleton'; +import { ThemeToggle } from '@tecnova/ui/components/theme-toggle'; +import { cn } from '@tecnova/ui/lib/utils'; +import { AccountMenu } from './account-menu'; +import { BrandLogo } from './brand-logo'; + +// モバイル用のトップバー。左にブランド、右にテーマ切替とアカウント。 +// ページタイトルは各ページの PageHeader が担うのでここでは出さない。 +// 認証解決前はブランド/テーマ切替を即描画し、アカウントだけスケルトンにする。 +export function MobileTopBar({ className }: { className?: string }) { + const meState = useMeState(); + const me = meState.status === 'ok' ? meState.me : null; + + return ( +
+
+ + + 管理画面 + +
+
+ {/* モバイルはタッチ確保のため 40px のヒットエリアにする。 */} + + {me ? ( + + {me.mentor.name.charAt(0)} + + } + /> + ) : ( + + )} +
+
+ ); +} diff --git a/apps/admin/src/components/nav-items.ts b/apps/admin/src/components/nav-items.ts new file mode 100644 index 0000000..60f5b44 --- /dev/null +++ b/apps/admin/src/components/nav-items.ts @@ -0,0 +1,50 @@ +import { + IconChartBar, + IconClipboardList, + IconLayoutDashboard, + IconUserShield, + IconUsers, +} from '@tabler/icons-react'; + +export interface NavItem { + href: string; + /** サイドバー等で使うフルラベル */ + label: string; + /** ボトムナビ用の短縮ラベル */ + shortLabel: string; + Icon: typeof IconLayoutDashboard; + /** admin ロールにのみ表示する項目 */ + adminOnly?: boolean; +} + +// サイドバー(デスクトップ)とボトムナビ(モバイル)が共有する唯一の真実の源。 +// ここでロール出し分けを一元管理する。 +export const NAV_ITEMS: NavItem[] = [ + { href: '/', label: 'ダッシュボード', shortLabel: 'ホーム', Icon: IconLayoutDashboard }, + { href: '/participants', label: '利用者一覧', shortLabel: '利用者', Icon: IconUsers }, + { href: '/stats', label: '集計', shortLabel: '集計', Icon: IconChartBar }, + { + href: '/pre-registrations', + label: '事前登録管理', + shortLabel: '事前登録', + Icon: IconClipboardList, + adminOnly: true, + }, + { + href: '/mentors', + label: '管理者一覧', + shortLabel: '管理者', + Icon: IconUserShield, + adminOnly: true, + }, +]; + +// ロールに応じて表示すべきナビ項目を返す。 +export const visibleNavItems = (role: 'admin' | 'mentor'): NavItem[] => + NAV_ITEMS.filter((item) => !item.adminOnly || role === 'admin'); + +// アクティブ判定。'/' はダッシュボード専用なので前方一致にせず完全一致で見る。 +export const isNavItemActive = (item: NavItem, pathname: string): boolean => { + if (item.href === '/') return pathname === '/'; + return pathname === item.href || pathname.startsWith(`${item.href}/`); +}; diff --git a/apps/admin/src/components/page-header.tsx b/apps/admin/src/components/page-header.tsx index 51f5fe0..96cb784 100644 --- a/apps/admin/src/components/page-header.tsx +++ b/apps/admin/src/components/page-header.tsx @@ -8,14 +8,17 @@ interface Props { } // 各ページ共通のヘッダ。title / description / actions の3スロット構成。 -// PC 前提だが actions が増えたときは折り返すように flex-wrap を入れている。 +// モバイルでは縦積み、sm 以上でタイトルと actions を左右に並べる。 export function PageHeader({ title, description, actions, className }: Props) { return (
-

{title}

+

{title}

{description &&

{description}

}
{actions &&
{actions}
} diff --git a/apps/admin/src/components/participant-detail-sheet.tsx b/apps/admin/src/components/participant-detail-sheet.tsx index f353289..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 ( @@ -110,18 +84,32 @@ export function ParticipantDetailSheet({ participantId, onOpenChange }: Props) {
{state.kind === 'loading' && ( + // 実コンテンツ(DetailBody)と同じ骨格のプレースホルダにして、 + // 読み込み完了時のレイアウトの飛びをなくす。 <> - - +
+
+ + +
+
+ {[0, 1, 2, 3, 4, 5, 6, 7].map((i) => ( + + ))} +
+
+
+ +
+ {[0, 1, 2, 3].map((i) => ( + + ))} +
+
)} - {state.kind === 'error' && ( - - 読み込めませんでした - {state.message} - - )} + {state.kind === 'error' && } {state.kind === 'ok' && }
diff --git a/apps/admin/src/components/record-card.tsx b/apps/admin/src/components/record-card.tsx new file mode 100644 index 0000000..21a58d0 --- /dev/null +++ b/apps/admin/src/components/record-card.tsx @@ -0,0 +1,57 @@ +'use client'; + +import { cn } from '@tecnova/ui/lib/utils'; + +interface RecordCardProps { + /** 指定するとカード全体がタップ可能になる(一覧→詳細など) */ + onClick?: () => void; + /** タップ可能なときのアクセシブルネーム(スクリーンリーダー向け) */ + ariaLabel?: string; + className?: string; + children: React.ReactNode; +} + +const BASE = 'rounded-2xl border bg-card p-4 text-card-foreground shadow-xs'; + +// モバイルでテーブルの代わりに使うカード。全ページで見た目を揃えるための共通チップ。 +// onClick を渡すとカード全面に重ねた
+ ); +} + +interface RecordFieldProps { + label: string; + children: React.ReactNode; + className?: string; +} + +// カード内の「ラベル : 値」行。左にラベル、右に値。 +export function RecordField({ label, children, className }: RecordFieldProps) { + return ( +
+ {label} + {children} +
+ ); +} diff --git a/apps/admin/src/components/reveal.tsx b/apps/admin/src/components/reveal.tsx new file mode 100644 index 0000000..6291353 --- /dev/null +++ b/apps/admin/src/components/reveal.tsx @@ -0,0 +1,32 @@ +'use client'; + +import { motion, useReducedMotion } from 'motion/react'; +import type { ReactNode } from 'react'; +import { revealAnimate, revealInitial, revealTransition } from '@/lib/motion'; + +// 子をフェードアップで入場させる薄いラッパ。index でセクション間のスタッガーをずらす。 +// データ一覧は「カードごと」ではなく、本ラッパでまとめて一度だけ入場させる +// (Cohesive Elevation。再フェッチのたびにカスケードが再生されるのを避けるため、 +// ラッパは loading/ok を切り替える領域の“外側”に常時マウントして使う)。 +// prefers-reduced-motion 時は initial を無効化して即表示する。 +export function Reveal({ + index = 0, + className, + children, +}: { + index?: number; + className?: string; + children: ReactNode; +}) { + const prefersReduced = useReducedMotion(); + return ( + + {children} + + ); +} diff --git a/apps/admin/src/components/sidebar.tsx b/apps/admin/src/components/sidebar.tsx new file mode 100644 index 0000000..0b77867 --- /dev/null +++ b/apps/admin/src/components/sidebar.tsx @@ -0,0 +1,119 @@ +'use client'; + +import { IconSelector } from '@tabler/icons-react'; +import { Button } from '@tecnova/ui/components/button'; +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'; +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; +import { navIndicatorTransition } from '@/lib/motion'; +import { AccountMenu } from './account-menu'; +import { BrandLogo } from './brand-logo'; +import { isNavItemActive, visibleNavItems } from './nav-items'; + +// デスクトップ用の固定左サイドバー。ブランド → ナビ → フッター(テーマ切替 + +// アカウント)の3段構成。モバイルでは AppShell 側で hidden にする。 +// 認証解決前(me ロード中)はクロームを即描画し、ロール依存のナビとアカウントだけ +// スケルトンにする(即時シェル)。 +export function Sidebar({ className }: { className?: string }) { + const meState = useMeState(); + const pathname = usePathname(); + const prefersReduced = useReducedMotion(); + const me = meState.status === 'ok' ? meState.me : null; + const items = me ? visibleNavItems(me.mentor.role) : []; + + return ( + + ); +} diff --git a/apps/admin/src/lib/motion.ts b/apps/admin/src/lib/motion.ts new file mode 100644 index 0000000..b2123bd --- /dev/null +++ b/apps/admin/src/lib/motion.ts @@ -0,0 +1,24 @@ +import type { Transition } from 'motion/react'; + +// 入場・小要素演出のイージングは全画面でこの値に統一する(checkin / signage と同値)。 +const EASE_OUT = 'easeOut' as const; + +// セクション/カードのフェードアップ入場。 +export const revealInitial = { opacity: 0, y: 12 } as const; +export const revealAnimate = { opacity: 1, y: 0 } as const; +export const REVEAL_STAGGER_STEP = 0.06; +export const revealTransition = (index = 0): Transition => ({ + duration: 0.4, + ease: EASE_OUT, + delay: index * REVEAL_STAGGER_STEP, +}); + +// 主要ボタンの押下フィードバック。 +export const tapScale = { scale: 0.97 } as const; + +// ナビのアクティブインジケータ(layoutId で滑らせる)。控えめに、跳ねさせない。 +export const navIndicatorTransition: Transition = { + type: 'spring', + stiffness: 480, + damping: 38, +}; diff --git a/apps/checkin/src/app/first-time/page.tsx b/apps/checkin/src/app/first-time/page.tsx index 6354c30..d195fb3 100644 --- a/apps/checkin/src/app/first-time/page.tsx +++ b/apps/checkin/src/app/first-time/page.tsx @@ -28,22 +28,18 @@ import { Card, CardContent, CardDescription, CardHeader } from '@tecnova/ui/comp import { Input } from '@tecnova/ui/components/input'; import { Skeleton } from '@tecnova/ui/components/skeleton'; import { Table, TableBody, TableCell, TableRow } from '@tecnova/ui/components/table'; -import { apiFetch, readErrorMessage } from '@tecnova/ui/lib/api-client'; +import { useApiResource } from '@tecnova/ui/hooks/use-api-resource'; import { motion, useReducedMotion } from 'motion/react'; import Link from 'next/link'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useMemo, useState } from 'react'; import { AnimatedNumber } from '@/components/animated-number'; import { PageShell } from '@/components/page-shell'; import { PanelHeader } from '@/components/panel-header'; import { Reveal } from '@/components/reveal'; +import { CheckinErrorScreen } from '@/components/screen-error'; import { formatJapaneseDate } from '@/lib/format'; import { listItemTransition } from '@/lib/motion'; -type State = - | { kind: 'loading' } - | { kind: 'list'; items: PreRegisteredListResponse['participants'] } - | { kind: 'error'; message: string }; - type PreRegisteredParticipant = PreRegisteredListResponse['participants'][number]; function ParticipantDetails({ item }: { item: PreRegisteredParticipant }) { @@ -124,33 +120,14 @@ function RegistrationSteps() { export default function FirstTimePage() { const prefersReduced = useReducedMotion(); - const [state, setState] = useState({ kind: 'loading' }); + const { state, reload } = useApiResource('/checkin/pre-registered'); const [query, setQuery] = useState(''); - const loadParticipants = useCallback(async () => { - setState({ kind: 'loading' }); - try { - const r = await apiFetch('/checkin/pre-registered'); - if (!r.ok) throw new Error(await readErrorMessage(r)); - const data = (await r.json()) as PreRegisteredListResponse; - setState({ kind: 'list', items: data.participants }); - } catch (e) { - setState({ - kind: 'error', - message: e instanceof Error ? e.message : String(e), - }); - } - }, []); - - useEffect(() => { - void loadParticipants(); - }, [loadParticipants]); - const filteredItems = useMemo(() => { - if (state.kind !== 'list') return []; + if (state.kind !== 'ok') return []; const normalizedQuery = query.trim().toLowerCase(); - if (!normalizedQuery) return state.items; - return state.items.filter((item) => { + if (!normalizedQuery) return state.data.participants; + return state.data.participants.filter((item) => { const values = [ item.fullName, item.nickname, @@ -162,7 +139,7 @@ export default function FirstTimePage() { }); }, [query, state]); - if (state.kind === 'loading') { + if (state.kind === 'idle' || state.kind === 'loading') { return (
@@ -189,27 +166,21 @@ export default function FirstTimePage() { if (state.kind === 'error') { return ( -
- - -
- - -
-
+ + + + + } + /> ); } @@ -233,14 +204,18 @@ export default function FirstTimePage() { style={{ height: 'auto' }} className="w-fit px-4 py-2 text-base" > - 未登録 + 未登録{' '} +
- {state.items.length === 0 ? ( + {state.data.participants.length === 0 ? (