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)/mentors/page.tsx b/apps/admin/src/app/(authed)/mentors/page.tsx index 44a5007..91cc535 100644 --- a/apps/admin/src/app/(authed)/mentors/page.tsx +++ b/apps/admin/src/app/(authed)/mentors/page.tsx @@ -20,6 +20,7 @@ import { SelectTrigger, SelectValue, } from '@tecnova/ui/components/select'; +import { Skeleton } from '@tecnova/ui/components/skeleton'; import { Table, TableBody, @@ -41,6 +42,8 @@ import { toastError, toastSuccess } from '@tecnova/ui/lib/toast'; import { cn } from '@tecnova/ui/lib/utils'; import { type FormEvent, useCallback, useEffect, 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' } @@ -84,49 +87,81 @@ export default function MentorsPage() { return (
- + + + - + + + - {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.message} + + )} - {state.kind === 'ok' && ( - - - - - メールアドレス - 名前 - ロール - 状態 - 登録日 - 最終ログイン - 操作 - - - - {state.mentors.length === 0 ? ( - - - まだ管理者が登録されていません - - - ) : ( - state.mentors.map((m) => ) - )} - -
-
- )} + {state.kind === 'ok' && + (state.mentors.length === 0 ? ( +
+ まだ管理者が登録されていません +
+ ) : ( + <> + {/* モバイル: カードリスト。 + card / row 両 variant を常時マウントし CSS で出し分ける(SSR 安全)。 + 各行は編集状態をローカルに持つため、編集途中で md をまたいでリサイズすると + 未保存の編集が見かけ上消える。admin の利用端末(PC / タブレット)では稀で、 + 保存すれば再取得で両者が同期するため許容するトレードオフ。 */} +
+ {state.mentors.map((m) => ( + + ))} +
+ + {/* デスクトップ: テーブル */} + + + + + メールアドレス + 名前 + ロール + 状態 + 登録日 + 最終ログイン + 操作 + + + + {state.mentors.map((m) => ( + + ))} + +
+
+ + ))} +
); @@ -207,7 +242,16 @@ function CreateMentorForm({ onCreated }: { onCreated: () => Promise }) { ); } -function MentorRow({ mentor, onUpdated }: { mentor: MentorItem; onUpdated: () => Promise }) { +function MentorRow({ + mentor, + onUpdated, + variant, +}: { + mentor: MentorItem; + onUpdated: () => Promise; + // 'row' = デスクトップのテーブル行 / 'card' = モバイルのカード + variant: 'row' | 'card'; +}) { const me = useMe(); const [role, setRole] = useState(mentor.role); const [active, setActive] = useState(mentor.active); @@ -216,7 +260,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; @@ -248,49 +292,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..065c54c 100644 --- a/apps/admin/src/app/(authed)/page.tsx +++ b/apps/admin/src/app/(authed)/page.tsx @@ -33,8 +33,11 @@ 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 { 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' } @@ -96,42 +99,48 @@ export default function DashboardPage() { return (
- - - - - } - /> + + + + + + } + /> + - setSelectedParticipantId(id)} - /> + {/* DashboardBody はフラグメントを返すので、main の gap-6 を保つため Reveal 側で再指定する。 + 常時マウントなので入場は一度だけ(再フェッチで再生されない)。 */} + + setSelectedParticipantId(id)} + /> + -
+
- +
+ +
+
+ {[0, 1, 2, 3, 4].map((i) => ( + + ))} +
); } @@ -176,7 +192,7 @@ function DashboardBody({ 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 +311,19 @@ function DashboardBody({ ); } +function EmptySessions({ hasEvent }: { hasEvent: boolean }) { + return ( +
+ + + {hasEvent + ? 'このイベントのセッションはまだありません' + : 'この日のイベントはまだ作成されていません'} + +
+ ); +} + function SummaryCard({ label, value, @@ -263,12 +335,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..f1582a6 100644 --- a/apps/admin/src/app/(authed)/participants/page.tsx +++ b/apps/admin/src/app/(authed)/participants/page.tsx @@ -14,6 +14,7 @@ import { SelectTrigger, SelectValue, } from '@tecnova/ui/components/select'; +import { Skeleton } from '@tecnova/ui/components/skeleton'; import { Table, TableBody, @@ -28,6 +29,8 @@ 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'; +import { RecordCard, RecordField } from '@/components/record-card'; +import { Reveal } from '@/components/reveal'; type State = | { kind: 'loading' } @@ -96,10 +99,12 @@ export default function ParticipantsPage() { return (
- + + + -
-
+ +
無効 -
+ - {state.kind === 'loading' && } + {/* loading/error/ok を切り替えるデータ領域。Reveal を常時マウントして入場は一度だけにする + (検索のたびに再生されない)。フラグメントを含むので gap-6 を再指定。 */} + + {state.kind === 'loading' && ( + <> +
+ +
+
+ {[0, 1, 2, 3, 4, 5].map((i) => ( + + ))} +
+ + )} - {state.kind === 'error' && ( - - 読み込めませんでした - {state.message} - - )} + {state.kind === 'error' && ( + + 読み込めませんでした + {state.message} + + )} - {state.kind === 'ok' && ( - <> - -
- - - ID - 氏名 - ニックネーム - 学年 - ID発行日 - 状態 - - - - {state.data.participants.length === 0 ? ( + {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)} +
+
+ )) + )} +
+ + {/* デスクトップ: テーブル */} + +
+ - - 該当する利用者が見つかりません - + 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} ページ + +
+ + + + +
+
+ + )} + - + + + - + + + - {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.message} + + )} - {state.kind === 'ok' && ( - <> - - - - - 事前登録ID - 氏名 - ニックネーム - 学年 - 事前登録日 - 操作 - - - - {state.preRegistrations.length === 0 ? ( - - - ID未発行の事前登録はありません - - - ) : ( - state.preRegistrations.map((p) => ( - - )) - )} - -
-
+ {state.kind === 'ok' && ( + <> + {state.preRegistrations.length === 0 ? ( +
+ ID未発行の事前登録はありません +
+ ) : ( + <> + {/* モバイル: カードリスト */} +
+ {state.preRegistrations.map((p) => ( + + ))} +
+ + {/* デスクトップ: テーブル */} + + + + + 事前登録ID + 氏名 + ニックネーム + 学年 + 事前登録日 + 操作 + + + + {state.preRegistrations.map((p) => ( + + ))} + +
+
+ + )} - - - )} + + + )} +
); } @@ -186,40 +226,73 @@ 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 || '-'} + + )) + )} + + +
@@ -334,9 +407,12 @@ function CreatePreRegistrationForm({ onCreated }: { onCreated: () => Promise Promise; + // 'row' = デスクトップのテーブル行 / 'card' = モバイルのカード + variant: 'row' | 'card'; }) { const [busy, setBusy] = useState(false); const deleteDescription = `${item.preRegistrationId}(${item.fullName} / ${item.nickname})を削除します。この操作は取り消せません。`; @@ -365,6 +441,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 +492,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..dd01c19 100644 --- a/apps/admin/src/app/(authed)/stats/page.tsx +++ b/apps/admin/src/app/(authed)/stats/page.tsx @@ -27,7 +27,9 @@ import { apiErrorMessage, apiJson } from '@tecnova/ui/lib/api-client'; import { formatJstDate } from '@tecnova/ui/lib/format'; import { cn } from '@tecnova/ui/lib/utils'; import { useCallback, useEffect, useState } from 'react'; +import { AnimatedNumber } from '@/components/animated-number'; import { PageHeader } from '@/components/page-header'; +import { Reveal } from '@/components/reveal'; type SummaryState = | { kind: 'loading' } @@ -78,46 +80,51 @@ 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 側で再指定する。 */} + + +
); } @@ -126,7 +133,7 @@ function StatsBody({ summary }: { summary: SummaryState }) { if (summary.kind === 'loading') { return ( <> -
+
@@ -151,8 +158,13 @@ function StatsBody({ summary }: { summary: SummaryState }) { 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/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..769637e --- /dev/null +++ b/apps/admin/src/components/bottom-nav.tsx @@ -0,0 +1,61 @@ +'use client'; + +import { useMe } from '@tecnova/ui/components/me-provider'; +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 me = useMe(); + const pathname = usePathname(); + const prefersReduced = useReducedMotion(); + const items = 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..a8aa2c9 --- /dev/null +++ b/apps/admin/src/components/mobile-top-bar.tsx @@ -0,0 +1,45 @@ +'use client'; + +import { useMe } from '@tecnova/ui/components/me-provider'; +import { ThemeToggle } from '@tecnova/ui/components/theme-toggle'; +import { cn } from '@tecnova/ui/lib/utils'; +import { AccountMenu } from './account-menu'; +import { BrandLogo } from './brand-logo'; + +// モバイル用のトップバー。左にブランド、右にテーマ切替とアカウント。 +// ページタイトルは各ページの PageHeader が担うのでここでは出さない。 +export function MobileTopBar({ className }: { className?: string }) { + const me = useMe(); + + return ( +
+
+ + + 管理画面 + +
+
+ {/* モバイルはタッチ確保のため 40px のヒットエリアにする。 */} + + + {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..b8a6df2 100644 --- a/apps/admin/src/components/participant-detail-sheet.tsx +++ b/apps/admin/src/components/participant-detail-sheet.tsx @@ -110,9 +110,28 @@ 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) => ( + + ))} +
+
)} 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..23dbf17 --- /dev/null +++ b/apps/admin/src/components/sidebar.tsx @@ -0,0 +1,102 @@ +'use client'; + +import { IconSelector } from '@tabler/icons-react'; +import { Button } from '@tecnova/ui/components/button'; +import { useMe } from '@tecnova/ui/components/me-provider'; +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 にする。 +export function Sidebar({ className }: { className?: string }) { + const me = useMe(); + const pathname = usePathname(); + const prefersReduced = useReducedMotion(); + const items = 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/docs/superpowers/specs/2026-06-02-admin-ui-refresh-design.md b/docs/superpowers/specs/2026-06-02-admin-ui-refresh-design.md new file mode 100644 index 0000000..af5f5d8 --- /dev/null +++ b/docs/superpowers/specs/2026-06-02-admin-ui-refresh-design.md @@ -0,0 +1,185 @@ +# 管理画面 UI リフレッシュ設計(レスポンシブ + PWA + ダークモード) + +- 日付: 2026-06-02 +- 対象アプリ: `apps/admin`(Next.js 16 / React 19、ポート 3001) +- 関連共有パッケージ: `packages/ui`(`@tecnova/ui`) + +## 1. 目的 + +管理画面(admin)を **デスクトップ前提のレイアウトから、モバイルファーストのレスポンシブ UI** に作り直す。 +あわせて以下を達成する: + +1. スマホ〜タブレット〜デスクトップで破綻なく使えるレスポンシブ対応 +2. モバイルでホーム画面に追加できる **インストール可能な PWA** 化(checkin と同方針) +3. ブランド・フォントは維持したままの **洗練された見た目・UX のブラッシュアップ** +4. **ダークモード**(system + 手動トグル、永続化)の導入と Toaster のテーマ追従 + +## 2. 決定事項(ブレインストーミングで合意済み) + +| 項目 | 決定 | +| ---------------- | -------------------------------------------------------------------- | +| ナビゲーション | デスクトップ = 左サイドバー / モバイル・PWA = ボトムタブバー | +| リフレッシュ範囲 | Refined polish pass(既存トークン・LINE Seed JP を維持した磨き込み) | +| PWA | checkin と同方針(インストール可能・standalone 表示、オフラインなし)| +| ダークモード | トグルを追加(system + 手動、永続化、Sonner をテーマ追従に修正) | + +## 3. アプローチ + +`@tecnova/ui` には現状 `Sidebar` プリミティブが無い。shadcn の `Sidebar` はモバイルで +**Sheet ドロワー**に畳まれる挙動で、今回選択した「ボトムタブバー」と矛盾する。 +そのため shadcn Sidebar は導入せず、**既存プリミティブから軽量なカスタムシェルを自作**する。 + +テーマ基盤は admin 固有ではなく **共有 `@tecnova/ui` に置く**。理由は `Toaster`(Sonner)が +`@tecnova/ui` 側にあり、テーマ追従させるには同じ next-themes コンテキストが必要なため。 +共有化により将来他アプリでもダークモードを再利用できる。ただし **既存の checkin / signage が +ThemeProvider を持たなくても従来どおり light 表示になる**ことを保証する(後述)。 + +## 4. ナビゲーションシェル設計 + +`apps/admin/src/components/app-shell.tsx` を作り直し、ナビ定義を単一の真実の源に集約する。 + +### 4.1 nav-items(単一の真実の源) + +- 新規 `apps/admin/src/components/nav-items.ts` +- 形: `{ href, label, shortLabel, icon, adminOnly }[]` +- ロールによる出し分け(`adminOnly`)はここ 1 箇所に集約し、Sidebar と BottomNav が同じ配列を消費する +- 項目: ダッシュボード / 参加者 / 統計(全ロール)、事前登録・メンター(admin のみ) + +### 4.2 Sidebar(デスクトップ、`hidden md:flex`) + +- 固定幅 ~240px の左レール(折りたたみトグルは作らない = YAGNI) +- 構成: ブランド → ナビリスト(アクティブ状態強調)→ フッター(**ThemeToggle + アカウントドロップダウン**: 氏名・ロールバッジ・email・ログアウト) +- アクティブ判定は `usePathname()` + +### 4.3 MobileTopBar(`md:hidden`) + +- コンパクトなトップバー: 左に現在ページのタイトル、右にアカウントアバター +- アバター押下で메뉴(ロール表示 / ログアウト / ThemeToggle) + +### 4.4 BottomNav(`md:hidden`) + +- 画面下固定のタブバー、ロールでフィルタ(mentor=3 / admin=5 タブ) +- アイコン + 極小ラベル(`shortLabel`)、アクティブインジケータ +- **`env(safe-area-inset-bottom)`** ぶんの下パディングで iPhone のホームインジケータを回避 +- タッチターゲット ≥44px +- 5 が快適な上限。将来ナビが 5 を超えたら "More" シートに溢れ分を逃がす(**今回は作らない**・将来課題として明記) + +### 4.5 AppShell(オーケストレーション) + +- デスクトップ: サイドバー幅ぶんのオフセット +- モバイル: `
` 下端にボトムナビの高さ + safe-area ぶんのオフセット +- `Sidebar` / `MobileTopBar` / `BottomNav` をブレークポイントで出し分け + +## 5. テーマ基盤(共有 `@tecnova/ui`) + +- 新規 `packages/ui/src/components/theme-provider.tsx` + - `next-themes` の薄いラッパー: `attribute="class"`, `defaultTheme="system"`, `enableSystem`, `disableTransitionOnChange` +- 新規 `packages/ui/src/components/theme-toggle.tsx` + - light / dark / system を循環するボタン(Tabler `IconSun` / `IconMoon` / `IconDeviceDesktop`) + - ハイドレーション不整合を避けるため mounted 後に描画する next-themes 標準パターン +- 既存 `packages/ui/src/components/sonner.tsx` を修正 + - `useTheme().resolvedTheme` を読み、**`undefined` の場合は `'light'` にフォールバック** + - これにより ThemeProvider を持たない checkin / signage は**従来どおり light のまま**(視覚的変更なし) + - 既存コメント(「管理画面はテーマ切替を持たないので light 固定」)を実態に合わせて更新 +- `packages/ui/package.json` に `next-themes` を依存追加(admin は `@tecnova/ui` 経由で利用、直接依存は不要) +- admin ルートレイアウトを `ThemeProvider` でラップし、`` を付与(next-themes 要件) +- 色は `globals.css` の既存 `.dark` トークンを使用 — **トークンの追加・変更はしない** + +## 6. PWA(admin、checkin の二重設定をミラー) + +- 新規 `apps/admin/src/app/manifest.ts` + - `display: 'standalone'`、ブランドカラー、`lang: 'ja'` + - **orientation は固定しない**(admin はキオスクではなく回転自由にする。checkin の portrait 固定とは異なる) + - `name: 'テクノバ管理画面'` / `short_name: '管理画面'` +- 既存 `apps/admin/src/app/layout.tsx` を修正 + - `appleWebApp`(iOS)メタデータを追加 + - `viewport` を export。`themeColor` は **light/dark のメディアクエリ配列**にして PWA ステータスバーをテーマに追従 + - **ユーザーズームは許可**(`maximumScale` / `userScalable:false` を設定しない)。admin はアクセシビリティ重視のツールでありキオスクではない +- アイコン: admin/public にロゴ資産が無いため **プログラム生成**する + - `apps/admin/src/app/icon.tsx`(192/512)+ `apps/admin/src/app/apple-icon.tsx`(180)を `ImageResponse` で生成(ブランドブルー地のタイポグラフィックマーク) + - **バイナリ資産はコミットしない**。プラットフォームの既存方針(checkin のコメント)とも一致 + - manifest の `icons` は生成アイコンの URL を参照 + +## 7. ページ別レスポンシブ対応 + +- **広いテーブルはモバイルでカード化**: ダッシュボードのセッション、参加者一覧、メンター一覧は `) { @@ -62,7 +62,7 @@ function SelectContent({ data-slot="select-content" data-align-trigger={position === 'item-aligned'} className={cn( - 'z-50 max-h-(--radix-select-content-available-height) min-w-36 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-2xl text-popover-foreground shadow-2xl ring-1 ring-foreground/5 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 animate-none! relative bg-popover/70 before:pointer-events-none before:absolute before:inset-0 before:-z-1 before:rounded-[inherit] before:backdrop-blur-2xl before:backdrop-saturate-150 **:data-[slot$=-item]:focus:bg-foreground/10 **:data-[slot$=-item]:data-highlighted:bg-foreground/10 **:data-[slot$=-separator]:bg-foreground/5 **:data-[slot$=-trigger]:focus:bg-foreground/10 **:data-[slot$=-trigger]:aria-expanded:bg-foreground/10! **:data-[variant=destructive]:focus:bg-foreground/10! **:data-[variant=destructive]:text-accent-foreground! **:data-[variant=destructive]:**:text-accent-foreground!', + 'z-50 max-h-(--radix-select-content-available-height) min-w-36 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-2xl text-popover-foreground shadow-2xl ring-1 ring-foreground/5 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 relative bg-popover/70 before:pointer-events-none before:absolute before:inset-0 before:-z-1 before:rounded-[inherit] before:backdrop-blur-2xl before:backdrop-saturate-150 **:data-[slot$=-item]:focus:bg-foreground/10 **:data-[slot$=-item]:data-highlighted:bg-foreground/10 **:data-[slot$=-separator]:bg-foreground/5 **:data-[slot$=-trigger]:focus:bg-foreground/10 **:data-[slot$=-trigger]:aria-expanded:bg-foreground/10! **:data-[variant=destructive]:focus:bg-foreground/10! **:data-[variant=destructive]:text-accent-foreground! **:data-[variant=destructive]:**:text-accent-foreground!', position === 'popper' && 'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1', className, diff --git a/packages/ui/src/components/sonner.tsx b/packages/ui/src/components/sonner.tsx index 306e49c..77a7917 100644 --- a/packages/ui/src/components/sonner.tsx +++ b/packages/ui/src/components/sonner.tsx @@ -7,13 +7,18 @@ import { IconInfoCircle, IconLoader, } from '@tabler/icons-react'; +import { useTheme } from 'next-themes'; import { Toaster as Sonner, type ToasterProps } from 'sonner'; -// 管理画面はテーマ切替を持たないので next-themes を使わず light 固定にする。 +// アクティブなテーマに追従させる。ThemeProvider をツリーに持たないアプリ +// (checkin / signage)では resolvedTheme が undefined になるため light に +// フォールバックし、従来どおりの表示を保つ。 const Toaster = ({ ...props }: ToasterProps) => { + const { resolvedTheme } = useTheme(); + const theme = (resolvedTheme ?? 'light') as ToasterProps['theme']; return ( , diff --git a/packages/ui/src/components/theme-provider.tsx b/packages/ui/src/components/theme-provider.tsx new file mode 100644 index 0000000..02837f8 --- /dev/null +++ b/packages/ui/src/components/theme-provider.tsx @@ -0,0 +1,22 @@ +'use client'; + +import { ThemeProvider as NextThemesProvider, type ThemeProviderProps } from 'next-themes'; + +// アプリ全体のテーマ(light / dark / system)を司る薄いラッパー。 +// 既定値(class 属性での切替・system 既定・テーマ変更時のトランジション抑止)を +// ここに集約し、各アプリは で囲むだけでよい。 +// next-themes は に .dark / .light を付け替えるので、 +// globals.css の .dark トークンがそのまま効く。 +export function ThemeProvider({ children, ...props }: ThemeProviderProps) { + return ( + + {children} + + ); +} diff --git a/packages/ui/src/components/theme-toggle.tsx b/packages/ui/src/components/theme-toggle.tsx new file mode 100644 index 0000000..e8d0717 --- /dev/null +++ b/packages/ui/src/components/theme-toggle.tsx @@ -0,0 +1,69 @@ +'use client'; + +import { IconDeviceDesktop, IconMoon, IconSun } from '@tabler/icons-react'; +import { Button } from '@tecnova/ui/components/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from '@tecnova/ui/components/dropdown-menu'; +import { useTheme } from 'next-themes'; +import { useEffect, useState } from 'react'; + +const OPTIONS = [ + { value: 'light', label: 'ライト', Icon: IconSun }, + { value: 'dark', label: 'ダーク', Icon: IconMoon }, + { value: 'system', label: 'システム', Icon: IconDeviceDesktop }, +] as const; + +interface Props { + className?: string; + align?: 'start' | 'center' | 'end'; + // トリガーボタンのサイズ。モバイルではタッチ確保のため大きめを渡す。 + size?: 'icon-xs' | 'icon-sm' | 'icon' | 'icon-lg'; +} + +// light / dark / system を選べるテーマ切替。 +// next-themes はサーバ描画時にテーマが未確定なので、マウント後にだけ +// 実際のアイコン・選択状態を出してハイドレーション不整合を避ける。 +export function ThemeToggle({ className, align = 'end', size = 'icon-sm' }: Props) { + const { theme, resolvedTheme, setTheme } = useTheme(); + const [mounted, setMounted] = useState(false); + useEffect(() => setMounted(true), []); + + const ActiveIcon = !mounted + ? IconSun + : theme === 'system' + ? IconDeviceDesktop + : resolvedTheme === 'dark' + ? IconMoon + : IconSun; + + return ( + + + + + + + {OPTIONS.map(({ value, label, Icon }) => ( + + + {label} + + ))} + + + + ); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ddd597f..c14d180 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: better-auth: specifier: ^1.6.9 version: 1.6.9(@cloudflare/workers-types@4.20260502.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260502.1)(kysely@0.28.17))(next@16.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/node@20.19.39)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)) + motion: + specifier: ^12.40.0 + version: 12.40.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) next: specifier: 16.2.4 version: 16.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -239,6 +242,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + next-themes: + specifier: ^0.4.6 + version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) radix-ui: specifier: ^1.4.3 version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -2838,6 +2844,12 @@ packages: resolution: {integrity: sha512-XPUa/jz+P1oJvN9VBxw4L9MtdFfaH3DAryqPssqhb2kXjmb9npz0dly6rCsgFWOPr4Yg9mTfM3MDZgZZ+7A3lA==} engines: {node: ^20.0.0 || >=22.0.0} + next-themes@0.4.6: + resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==} + peerDependencies: + react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + next@16.2.4: resolution: {integrity: sha512-kPvz56wF5frc+FxlHI5qnklCzbq53HTwORaWBGdT0vNoKh1Aya9XC8aPauH4NJxqtzbWsS5mAbctm4cr+EkQ2Q==} engines: {node: '>=20.9.0'} @@ -5203,6 +5215,11 @@ snapshots: nanostores@1.3.0: {} + next-themes@0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + next@16.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: '@next/env': 16.2.4