From 62b96c9f20d3fb1a76b69694694d858ca773c204 Mon Sep 17 00:00:00 2001 From: tmba Date: Tue, 2 Jun 2026 16:08:58 +0900 Subject: [PATCH 01/17] docs: add admin UI refresh design spec Responsive layout (sidebar + bottom nav), installable PWA, and dark mode for the admin app. Brainstormed scope and decisions. Co-Authored-By: Claude Opus 4.8 --- .../2026-06-02-admin-ui-refresh-design.md | 185 ++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-02-admin-ui-refresh-design.md 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. ページ別レスポンシブ対応 + +- **広いテーブルはモバイルでカード化**: ダッシュボードのセッション、参加者一覧、メンター一覧は ` Date: Tue, 2 Jun 2026 16:19:42 +0900 Subject: [PATCH 02/17] feat(ui): add theme provider/toggle and make Toaster theme-aware Introduces a shared next-themes wrapper (ThemeProvider) and a light/dark/system ThemeToggle, and updates the Sonner Toaster to follow the active theme. Falls back to light when no provider is present, so checkin/signage are unaffected. Co-Authored-By: Claude Opus 4.8 --- packages/ui/package.json | 1 + packages/ui/src/components/sonner.tsx | 9 ++- packages/ui/src/components/theme-provider.tsx | 22 ++++++ packages/ui/src/components/theme-toggle.tsx | 70 +++++++++++++++++++ pnpm-lock.yaml | 14 ++++ 5 files changed, 114 insertions(+), 2 deletions(-) create mode 100644 packages/ui/src/components/theme-provider.tsx create mode 100644 packages/ui/src/components/theme-toggle.tsx diff --git a/packages/ui/package.json b/packages/ui/package.json index 0e9402d..65ccec8 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -14,6 +14,7 @@ "@tecnova/shared": "workspace:*", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "next-themes": "^0.4.6", "radix-ui": "^1.4.3", "react": "^19.2.4", "react-dom": "^19.2.4", 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..9c6c6fb --- /dev/null +++ b/packages/ui/src/components/theme-toggle.tsx @@ -0,0 +1,70 @@ +'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'; +} + +// light / dark / system を選べるテーマ切替。 +// next-themes はサーバ描画時にテーマが未確定なので、マウント後にだけ +// 実際のアイコン・選択状態を出してハイドレーション不整合を避ける。 +export function ThemeToggle({ className, align = 'end' }: 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..88e0a36 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -239,6 +239,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 +2841,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 +5212,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 From 1d6a400114d85461e71a1f7ca9722729949b73ea Mon Sep 17 00:00:00 2001 From: tmba Date: Tue, 2 Jun 2026 16:25:38 +0900 Subject: [PATCH 03/17] feat(admin): responsive nav shell (sidebar + bottom tabs) and theme wiring Replace the desktop-only top header/nav with an adaptive shell: a fixed left sidebar on desktop and a mobile top bar + bottom tab bar (with safe-area inset) on small screens, both driven by a single nav-items source of truth. Wrap the app in ThemeProvider for dark mode. Co-Authored-By: Claude Opus 4.8 --- apps/admin/src/app/layout.tsx | 12 +- apps/admin/src/components/account-menu.tsx | 66 +++++++++ apps/admin/src/components/app-shell.tsx | 135 +++---------------- apps/admin/src/components/bottom-nav.tsx | 49 +++++++ apps/admin/src/components/mobile-top-bar.tsx | 43 ++++++ apps/admin/src/components/nav-items.ts | 50 +++++++ apps/admin/src/components/sidebar.tsx | 83 ++++++++++++ 7 files changed, 317 insertions(+), 121 deletions(-) create mode 100644 apps/admin/src/components/account-menu.tsx create mode 100644 apps/admin/src/components/bottom-nav.tsx create mode 100644 apps/admin/src/components/mobile-top-bar.tsx create mode 100644 apps/admin/src/components/nav-items.ts create mode 100644 apps/admin/src/components/sidebar.tsx diff --git a/apps/admin/src/app/layout.tsx b/apps/admin/src/app/layout.tsx index 925b944..0b8b59a 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 { 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({ @@ -19,8 +20,15 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - - {children} + // next-themes が に .dark / .light を付け替えるため suppressHydrationWarning が必要。 + + + {children} + ); } 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/app-shell.tsx b/apps/admin/src/components/app-shell.tsx index e62a598..ce9de64 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}
+
+ +
+ +
+ {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..293f303 --- /dev/null +++ b/apps/admin/src/components/bottom-nav.tsx @@ -0,0 +1,49 @@ +'use client'; + +import { useMe } from '@tecnova/ui/components/me-provider'; +import { cn } from '@tecnova/ui/lib/utils'; +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; +import { isNavItemActive, visibleNavItems } from './nav-items'; + +// モバイル用のボトムタブバー。画面下に固定し、iPhone のホームインジケータを +// 避けるため safe-area ぶんの余白を足す。ロールに応じて 3〜5 タブを出す。 +export function BottomNav({ className }: { className?: string }) { + const me = useMe(); + const pathname = usePathname(); + const items = visibleNavItems(me.mentor.role); + + return ( + + ); +} diff --git a/apps/admin/src/components/mobile-top-bar.tsx b/apps/admin/src/components/mobile-top-bar.tsx new file mode 100644 index 0000000..0fef04e --- /dev/null +++ b/apps/admin/src/components/mobile-top-bar.tsx @@ -0,0 +1,43 @@ +'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'; + +// モバイル用のトップバー。左にブランド、右にテーマ切替とアカウント。 +// ページタイトルは各ページの PageHeader が担うのでここでは出さない。 +export function MobileTopBar({ className }: { className?: string }) { + const me = useMe(); + + return ( +
+
+ + tec + + テクノバ管理画面 +
+
+ + + {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/sidebar.tsx b/apps/admin/src/components/sidebar.tsx new file mode 100644 index 0000000..de15262 --- /dev/null +++ b/apps/admin/src/components/sidebar.tsx @@ -0,0 +1,83 @@ +'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 Link from 'next/link'; +import { usePathname } from 'next/navigation'; +import { AccountMenu } from './account-menu'; +import { isNavItemActive, visibleNavItems } from './nav-items'; + +// デスクトップ用の固定左サイドバー。ブランド → ナビ → フッター(テーマ切替 + +// アカウント)の3段構成。モバイルでは AppShell 側で hidden にする。 +export function Sidebar({ className }: { className?: string }) { + const me = useMe(); + const pathname = usePathname(); + const items = visibleNavItems(me.mentor.role); + + return ( + + ); +} From 67f50a01e1bb140a5f471ad5f543b40b97d84489 Mon Sep 17 00:00:00 2001 From: tmba Date: Tue, 2 Jun 2026 16:29:44 +0900 Subject: [PATCH 04/17] feat(admin): make installable as a PWA on mobile Add a web manifest (Android/Chromium), appleWebApp metadata (iOS), a theme-aware viewport (light/dark status bar, zoom allowed), and programmatically generated app icons (icon.tsx / apple-icon.tsx). No binary assets committed; mirrors checkin's standalone, no-offline setup. Co-Authored-By: Claude Opus 4.8 --- apps/admin/src/app/apple-icon.tsx | 28 ++++++++++++++++++++++++++++ apps/admin/src/app/icon.tsx | 28 ++++++++++++++++++++++++++++ apps/admin/src/app/layout.tsx | 21 ++++++++++++++++++++- apps/admin/src/app/manifest.ts | 22 ++++++++++++++++++++++ 4 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 apps/admin/src/app/apple-icon.tsx create mode 100644 apps/admin/src/app/icon.tsx create mode 100644 apps/admin/src/app/manifest.ts diff --git a/apps/admin/src/app/apple-icon.tsx b/apps/admin/src/app/apple-icon.tsx new file mode 100644 index 0000000..481e376 --- /dev/null +++ b/apps/admin/src/app/apple-icon.tsx @@ -0,0 +1,28 @@ +import { ImageResponse } from 'next/og'; + +// iOS のホーム画面用アイコン(apple-touch-icon)。iOS が角丸を付けるので +// フルブリードのブランドブルー地に "tec" を置くだけでよい。 +export const size = { width: 180, height: 180 }; +export const contentType = 'image/png'; + +export default function AppleIcon() { + return new ImageResponse( +
+ tec +
, + { ...size }, + ); +} diff --git a/apps/admin/src/app/icon.tsx b/apps/admin/src/app/icon.tsx new file mode 100644 index 0000000..20b8318 --- /dev/null +++ b/apps/admin/src/app/icon.tsx @@ -0,0 +1,28 @@ +import { ImageResponse } from 'next/og'; + +// ブラウザタブ・PWA 用のアイコンをプログラム生成する(PNG をリポジトリに置かない方針)。 +// ブランドブルー地に "tec" のワードマーク。ImageResponse は flexbox のみ対応。 +export const size = { width: 512, height: 512 }; +export const contentType = 'image/png'; + +export default function Icon() { + return new ImageResponse( +
+ tec +
, + { ...size }, + ); +} diff --git a/apps/admin/src/app/layout.tsx b/apps/admin/src/app/layout.tsx index 0b8b59a..a09bfc8 100644 --- a/apps/admin/src/app/layout.tsx +++ b/apps/admin/src/app/layout.tsx @@ -1,4 +1,4 @@ -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'; @@ -12,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({ diff --git a/apps/admin/src/app/manifest.ts b/apps/admin/src/app/manifest.ts new file mode 100644 index 0000000..85e3e60 --- /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', + icons: [ + { src: '/icon', sizes: '512x512', type: 'image/png' }, + { src: '/favicon.ico', sizes: 'any', type: 'image/x-icon' }, + ], + }; +} From 62ec029d17da8914bf8e7ee2f4ff00f4a7506f60 Mon Sep 17 00:00:00 2001 From: tmba Date: Tue, 2 Jun 2026 16:42:27 +0900 Subject: [PATCH 05/17] feat(admin): responsive per-page layouts (tables become cards on mobile) Wide data tables (dashboard, participants, mentors, pre-registrations) now render as stacked cards under md and as tables at md+, via a shared RecordCard/RecordField. Stats keeps its narrow table with a 2-up KPI grid on mobile. PageHeader stacks, summary cards shrink, and the login page gets a brand mark. Also fixes a nested
in the shell. Co-Authored-By: Claude Opus 4.8 --- apps/admin/src/app/(authed)/mentors/page.tsx | 192 +++++++++----- apps/admin/src/app/(authed)/page.tsx | 84 +++++- .../src/app/(authed)/participants/page.tsx | 55 +++- .../app/(authed)/pre-registrations/page.tsx | 251 ++++++++++++------ apps/admin/src/app/(authed)/stats/page.tsx | 23 +- apps/admin/src/app/login/page.tsx | 5 +- apps/admin/src/components/app-shell.tsx | 8 +- apps/admin/src/components/page-header.tsx | 9 +- apps/admin/src/components/record-card.tsx | 57 ++++ 9 files changed, 510 insertions(+), 174 deletions(-) create mode 100644 apps/admin/src/components/record-card.tsx diff --git a/apps/admin/src/app/(authed)/mentors/page.tsx b/apps/admin/src/app/(authed)/mentors/page.tsx index 44a5007..8c1876e 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,7 @@ 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'; type State = | { kind: 'loading' } @@ -91,7 +93,18 @@ export default function MentorsPage() { - {state.kind === 'loading' && } + {state.kind === 'loading' && ( + <> +
+ +
+
+ {[0, 1, 2, 3, 4].map((i) => ( + + ))} +
+ + )} {state.kind === 'error' && ( 読み込めませんでした @@ -99,34 +112,43 @@ export default function MentorsPage() { )} - {state.kind === 'ok' && ( - - - - - メールアドレス - 名前 - ロール - 状態 - 登録日 - 最終ログイン - 操作 - - - - {state.mentors.length === 0 ? ( - - - まだ管理者が登録されていません - - - ) : ( - state.mentors.map((m) => ) - )} - -
-
- )} + {state.kind === 'ok' && + (state.mentors.length === 0 ? ( +
+ まだ管理者が登録されていません +
+ ) : ( + <> + {/* モバイル: カードリスト */} +
+ {state.mentors.map((m) => ( + + ))} +
+ + {/* デスクトップ: テーブル */} + + + + + メールアドレス + 名前 + ロール + 状態 + 登録日 + 最終ログイン + 操作 + + + + {state.mentors.map((m) => ( + + ))} + +
+
+ + ))}
); @@ -207,7 +229,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 +247,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 +279,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..f6bb099 100644 --- a/apps/admin/src/app/(authed)/page.tsx +++ b/apps/admin/src/app/(authed)/page.tsx @@ -35,6 +35,7 @@ import { apiErrorMessage, apiJson } from '@tecnova/ui/lib/api-client'; import { useCallback, useEffect, useState } from 'react'; import { PageHeader } from '@/components/page-header'; import { ParticipantDetailSheet } from '@/components/participant-detail-sheet'; +import { RecordCard, RecordField } from '@/components/record-card'; type SessionsState = | { kind: 'loading' } @@ -153,12 +154,19 @@ function DashboardBody({ if (sessions.kind === 'loading') { return ( <> -
+
- +
+ +
+
+ {[0, 1, 2, 3, 4].map((i) => ( + + ))} +
); } @@ -176,7 +184,7 @@ function DashboardBody({ return ( <> -
+
- + {/* モバイル: カードリスト */} +
+ {rows.length === 0 ? ( + + ) : ( + rows.map((s) => ( + onSelectParticipant(s.participantId)} + ariaLabel={`${s.nickname} の詳細を開く`} + > +
+
+

{s.nickname}

+

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

+
+ + {s.isPresent ? '来場中' : '退出済'} + +
+
+ {s.term && ( + + + + {!s.counted && } + + + )} + {fmtTime(s.checkedInAt)} + + {s.checkedOutAt ? fmtTime(s.checkedOutAt) : '—'} + + + {s.participantId} + +
+
+ )) + )} +
+ + {/* デスクトップ: テーブル */} + @@ -252,6 +305,19 @@ function DashboardBody({ ); } +function EmptySessions({ hasEvent }: { hasEvent: boolean }) { + return ( +
+ + + {hasEvent + ? 'このイベントのセッションはまだありません' + : 'この日のイベントはまだ作成されていません'} + +
+ ); +} + function SummaryCard({ label, value, @@ -263,12 +329,14 @@ function SummaryCard({ }) { return ( - - {label} - + + + {label} + + -
{value}
+
{value}
); diff --git a/apps/admin/src/app/(authed)/participants/page.tsx b/apps/admin/src/app/(authed)/participants/page.tsx index 5e873e5..6670eae 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,7 @@ 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'; type State = | { kind: 'loading' } @@ -99,7 +101,7 @@ export default function ParticipantsPage() {
-
+
- {state.kind === 'loading' && } + {state.kind === 'loading' && ( + <> +
+ +
+
+ {[0, 1, 2, 3, 4, 5].map((i) => ( + + ))} +
+ + )} {state.kind === 'error' && ( @@ -162,7 +175,43 @@ export default function ParticipantsPage() { {state.kind === 'ok' && ( <> - + {/* モバイル: カードリスト */} +
+ {state.data.participants.length === 0 ? ( +
+ 該当する利用者が見つかりません +
+ ) : ( + state.data.participants.map((p) => ( + setSelectedParticipantId(p.id)} + ariaLabel={`${p.nickname} の詳細を開く`} + > +
+
+

{p.nickname}

+

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

+
+ + {p.active ? '有効' : '無効'} + +
+
+ + {p.id} + + {formatJstDate(p.activatedAt)} +
+
+ )) + )} +
+ + {/* デスクトップ: テーブル */} +
diff --git a/apps/admin/src/app/(authed)/pre-registrations/page.tsx b/apps/admin/src/app/(authed)/pre-registrations/page.tsx index 42db430..21d0380 100644 --- a/apps/admin/src/app/(authed)/pre-registrations/page.tsx +++ b/apps/admin/src/app/(authed)/pre-registrations/page.tsx @@ -44,6 +44,7 @@ import { SelectTrigger, SelectValue, } from '@tecnova/ui/components/select'; +import { Skeleton } from '@tecnova/ui/components/skeleton'; import { Table, TableBody, @@ -57,6 +58,7 @@ import { ApiError, apiFetch, apiJson } from '@tecnova/ui/lib/api-client'; import { toastError, toastSuccess } from '@tecnova/ui/lib/toast'; import { type FormEvent, useCallback, useEffect, useState } from 'react'; import { PageHeader } from '@/components/page-header'; +import { RecordCard, RecordField } from '@/components/record-card'; type State = | { kind: 'loading' } @@ -122,7 +124,18 @@ export default function PreRegistrationsPage() { - {state.kind === 'loading' && } + {state.kind === 'loading' && ( + <> +
+ +
+
+ {[0, 1, 2, 3, 4].map((i) => ( + + ))} +
+ + )} {state.kind === 'error' && ( 読み込めませんでした @@ -132,33 +145,51 @@ export default function PreRegistrationsPage() { {state.kind === 'ok' && ( <> - -
- - - 事前登録ID - 氏名 - ニックネーム - 学年 - 事前登録日 - 操作 - - - - {state.preRegistrations.length === 0 ? ( - - - ID未発行の事前登録はありません - - - ) : ( - state.preRegistrations.map((p) => ( - - )) - )} - -
-
+ {state.preRegistrations.length === 0 ? ( +
+ ID未発行の事前登録はありません +
+ ) : ( + <> + {/* モバイル: カードリスト */} +
+ {state.preRegistrations.map((p) => ( + + ))} +
+ + {/* デスクトップ: テーブル */} + + + + + 事前登録ID + 氏名 + ニックネーム + 学年 + 事前登録日 + 操作 + + + + {state.preRegistrations.map((p) => ( + + ))} + +
+
+ + )} @@ -186,40 +217,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 +398,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 +432,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 +483,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..79ac12b 100644 --- a/apps/admin/src/app/(authed)/stats/page.tsx +++ b/apps/admin/src/app/(authed)/stats/page.tsx @@ -126,7 +126,7 @@ function StatsBody({ summary }: { summary: SummaryState }) { if (summary.kind === 'loading') { return ( <> -
+
@@ -152,7 +152,12 @@ function StatsBody({ summary }: { summary: SummaryState }) { return ( <>
- + - - {label} - + + + + {label} + + -
{value}
+
{value}
); diff --git a/apps/admin/src/app/login/page.tsx b/apps/admin/src/app/login/page.tsx index f55f8c5..6092823 100644 --- a/apps/admin/src/app/login/page.tsx +++ b/apps/admin/src/app/login/page.tsx @@ -41,9 +41,12 @@ export default function LoginPage() { }; return ( -
+
+ + tec +

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

diff --git a/apps/admin/src/components/app-shell.tsx b/apps/admin/src/components/app-shell.tsx index ce9de64..09bd6b7 100644 --- a/apps/admin/src/components/app-shell.tsx +++ b/apps/admin/src/components/app-shell.tsx @@ -16,11 +16,11 @@ export function AppShell({ children }: Props) { return (
-
+ {/* 各ページが自前の
を持つので、ここはラッパ div に留める(main の入れ子回避)。 + モバイルではボトムナビ + safe-area ぶん下に余白を取り、最後の要素が隠れないようにする。 */} +
-
- {children} -
+ {children}
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/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} +
+ ); +} From 916dccc6f2b9c6d0bb53d528f4b9c274f71584e9 Mon Sep 17 00:00:00 2001 From: tmba Date: Tue, 2 Jun 2026 16:50:01 +0900 Subject: [PATCH 06/17] fix(admin): drop invalid manifest favicon icon, declutter mobile KPIs Chrome rejected the .ico manifest entry as an invalid image; keep only the generated /icon PNG (favicon stays auto-linked for the tab). Hide summary-card icons under the sm breakpoint so KPI labels have room. Co-Authored-By: Claude Opus 4.8 --- apps/admin/src/app/(authed)/page.tsx | 2 +- apps/admin/src/app/(authed)/stats/page.tsx | 4 +++- apps/admin/src/app/manifest.ts | 8 ++++---- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/apps/admin/src/app/(authed)/page.tsx b/apps/admin/src/app/(authed)/page.tsx index f6bb099..c5555fa 100644 --- a/apps/admin/src/app/(authed)/page.tsx +++ b/apps/admin/src/app/(authed)/page.tsx @@ -333,7 +333,7 @@ function SummaryCard({ {label} - +
{value}
diff --git a/apps/admin/src/app/(authed)/stats/page.tsx b/apps/admin/src/app/(authed)/stats/page.tsx index 79ac12b..073ff95 100644 --- a/apps/admin/src/app/(authed)/stats/page.tsx +++ b/apps/admin/src/app/(authed)/stats/page.tsx @@ -237,7 +237,9 @@ function SummaryCard({ {label} - +
{value}
diff --git a/apps/admin/src/app/manifest.ts b/apps/admin/src/app/manifest.ts index 85e3e60..546d865 100644 --- a/apps/admin/src/app/manifest.ts +++ b/apps/admin/src/app/manifest.ts @@ -14,9 +14,9 @@ export default function manifest(): MetadataRoute.Manifest { background_color: '#ffffff', theme_color: '#2563eb', lang: 'ja', - icons: [ - { src: '/icon', sizes: '512x512', type: 'image/png' }, - { src: '/favicon.ico', sizes: 'any', type: 'image/x-icon' }, - ], + // 生成アイコン(app/icon.tsx)の 512px PNG を参照する。favicon.ico は + // ブラウザタブ用に Next が自動リンクするのでマニフェストには含めない + // (.ico をマニフェストアイコンにすると Chrome が不正画像として警告するため)。 + icons: [{ src: '/icon', sizes: '512x512', type: 'image/png' }], }; } From d98a75a6656785b5536d4b5e21f987bf82d18877 Mon Sep 17 00:00:00 2001 From: tmba Date: Tue, 2 Jun 2026 16:56:53 +0900 Subject: [PATCH 07/17] chore(claude): add secret-guard and Biome-format hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PreToolUse: block writing secret/lockfile (.dev.vars*, .env*, service-account JSON, pnpm-lock.yaml) per CLAUDE.md §7; PostToolUse: run Biome autofix on edited files to keep CI green. Also broaden .gitignore to .dev.vars.* (wrangler env-specific secret files). Co-Authored-By: Claude Opus 4.8 --- .claude/hooks/biome-format.sh | 22 ++++++++++++++ .claude/hooks/block-secret-writes.sh | 44 ++++++++++++++++++++++++++++ .claude/settings.json | 24 +++++++++++++++ .gitignore | 1 + 4 files changed, 91 insertions(+) create mode 100755 .claude/hooks/biome-format.sh create mode 100755 .claude/hooks/block-secret-writes.sh 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/.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 From d5bda59a64c5cf1b19d5f2f51ce0d2060b81419c Mon Sep 17 00:00:00 2001 From: tmba Date: Tue, 2 Jun 2026 16:56:53 +0900 Subject: [PATCH 08/17] chore(claude): add workers-constraint and privacy reviewer subagents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read-only reviewers encoding project constraints CI cannot check: Cloudflare Workers compatibility (CLAUDE.md §1/§2) and the children's-data PII boundary (CLAUDE.md §5). Co-Authored-By: Claude Opus 4.8 --- .claude/agents/privacy-reviewer.md | 32 ++++++++++++++++++ .claude/agents/workers-constraint-reviewer.md | 33 +++++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 .claude/agents/privacy-reviewer.md create mode 100644 .claude/agents/workers-constraint-reviewer.md 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 制約: 問題なし」と明記する。 +- コードは絶対に編集しない。報告のみ。 From 4157070109e1b9aca858a7342ff3bb9754817208 Mon Sep 17 00:00:00 2001 From: tmba Date: Tue, 2 Jun 2026 16:56:53 +0900 Subject: [PATCH 09/17] chore(claude): add create-migration and pre-pr-check skills create-migration wraps the drizzle-kit generate -> wrangler D1 apply flow; pre-pr-check mirrors CI (Biome + type-check), runs @tecnova/shared tests CI omits, and scans the diff for secrets before commit. Co-Authored-By: Claude Opus 4.8 --- .claude/skills/create-migration/SKILL.md | 41 +++++++++++++++++++ .claude/skills/pre-pr-check/SKILL.md | 35 ++++++++++++++++ .../pre-pr-check/scripts/scan-secrets.sh | 39 ++++++++++++++++++ 3 files changed, 115 insertions(+) create mode 100644 .claude/skills/create-migration/SKILL.md create mode 100644 .claude/skills/pre-pr-check/SKILL.md create mode 100755 .claude/skills/pre-pr-check/scripts/scan-secrets.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 From 312d3a3548299fbfe908ad08d7c6ba014c8ccce1 Mon Sep 17 00:00:00 2001 From: tmba Date: Tue, 2 Jun 2026 17:01:15 +0900 Subject: [PATCH 10/17] fix(admin): address review findings (a11y + responsive) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - bottom-nav: active tab used dark --primary (2.6:1, an inversion); use the lighter sidebar-primary in dark to meet WCAG AA, add focus-visible ring - stats: loaded KPI grid was missing the base grid-cols-2, so it stacked 1-up on mobile and the total's col-span-2 was dead — restore 2-up - mobile top bar: enlarge theme/account controls to 40px touch targets - record-card: enrich tappable-card aria-labels with grade + status - mentors: document the card/row dual-mount edit-state trade-off Co-Authored-By: Claude Opus 4.8 --- apps/admin/src/app/(authed)/mentors/page.tsx | 6 +++++- apps/admin/src/app/(authed)/page.tsx | 2 +- apps/admin/src/app/(authed)/participants/page.tsx | 2 +- apps/admin/src/app/(authed)/stats/page.tsx | 2 +- apps/admin/src/components/bottom-nav.tsx | 8 +++++--- apps/admin/src/components/mobile-top-bar.tsx | 5 +++-- packages/ui/src/components/theme-toggle.tsx | 11 +++++------ 7 files changed, 21 insertions(+), 15 deletions(-) diff --git a/apps/admin/src/app/(authed)/mentors/page.tsx b/apps/admin/src/app/(authed)/mentors/page.tsx index 8c1876e..5fe6c92 100644 --- a/apps/admin/src/app/(authed)/mentors/page.tsx +++ b/apps/admin/src/app/(authed)/mentors/page.tsx @@ -119,7 +119,11 @@ export default function MentorsPage() { ) : ( <> - {/* モバイル: カードリスト */} + {/* モバイル: カードリスト。 + card / row 両 variant を常時マウントし CSS で出し分ける(SSR 安全)。 + 各行は編集状態をローカルに持つため、編集途中で md をまたいでリサイズすると + 未保存の編集が見かけ上消える。admin の利用端末(PC / タブレット)では稀で、 + 保存すれば再取得で両者が同期するため許容するトレードオフ。 */}
{state.mentors.map((m) => ( diff --git a/apps/admin/src/app/(authed)/page.tsx b/apps/admin/src/app/(authed)/page.tsx index c5555fa..796d65f 100644 --- a/apps/admin/src/app/(authed)/page.tsx +++ b/apps/admin/src/app/(authed)/page.tsx @@ -203,7 +203,7 @@ function DashboardBody({ onSelectParticipant(s.participantId)} - ariaLabel={`${s.nickname} の詳細を開く`} + ariaLabel={`${s.nickname}(${s.grade}・${s.isPresent ? '来場中' : '退出済'})の詳細を開く`} >
diff --git a/apps/admin/src/app/(authed)/participants/page.tsx b/apps/admin/src/app/(authed)/participants/page.tsx index 6670eae..cd2d236 100644 --- a/apps/admin/src/app/(authed)/participants/page.tsx +++ b/apps/admin/src/app/(authed)/participants/page.tsx @@ -186,7 +186,7 @@ export default function ParticipantsPage() { setSelectedParticipantId(p.id)} - ariaLabel={`${p.nickname} の詳細を開く`} + ariaLabel={`${p.nickname}(${p.grade}・${p.active ? '有効' : '無効'})の詳細を開く`} >
diff --git a/apps/admin/src/app/(authed)/stats/page.tsx b/apps/admin/src/app/(authed)/stats/page.tsx index 073ff95..8e64a9c 100644 --- a/apps/admin/src/app/(authed)/stats/page.tsx +++ b/apps/admin/src/app/(authed)/stats/page.tsx @@ -151,7 +151,7 @@ function StatsBody({ summary }: { summary: SummaryState }) { return ( <> -
+
{active && ( - + )} {item.shortLabel} diff --git a/apps/admin/src/components/mobile-top-bar.tsx b/apps/admin/src/components/mobile-top-bar.tsx index 0fef04e..7bce67b 100644 --- a/apps/admin/src/components/mobile-top-bar.tsx +++ b/apps/admin/src/components/mobile-top-bar.tsx @@ -24,14 +24,15 @@ export function MobileTopBar({ className }: { className?: string }) { テクノバ管理画面
- + {/* モバイルはタッチ確保のため 40px のヒットエリアにする。 */} + {me.mentor.name.charAt(0)} diff --git a/packages/ui/src/components/theme-toggle.tsx b/packages/ui/src/components/theme-toggle.tsx index 9c6c6fb..e8d0717 100644 --- a/packages/ui/src/components/theme-toggle.tsx +++ b/packages/ui/src/components/theme-toggle.tsx @@ -21,12 +21,14 @@ const OPTIONS = [ 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' }: Props) { +export function ThemeToggle({ className, align = 'end', size = 'icon-sm' }: Props) { const { theme, resolvedTheme, setTheme } = useTheme(); const [mounted, setMounted] = useState(false); useEffect(() => setMounted(true), []); @@ -45,7 +47,7 @@ export function ThemeToggle({ className, align = 'end' }: Props) { - + {OPTIONS.map(({ value, label, Icon }) => ( From c9ff478e5531cc73acc50cd265e222f04a8f8325 Mon Sep 17 00:00:00 2001 From: tmba Date: Tue, 2 Jun 2026 17:02:23 +0900 Subject: [PATCH 11/17] docs: note admin responsive/dark-mode/PWA in CLAUDE.md Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 2 +- apps/admin/CLAUDE.md | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) 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..8350af2 100644 --- a/apps/admin/CLAUDE.md +++ b/apps/admin/CLAUDE.md @@ -1,9 +1,12 @@ @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`)はテーマに追従する。 +- **PWA は iOS/Android 二重設定**(checkin と同様): iOS は `src/app/layout.tsx` の `appleWebApp`、Android/Chromium は `src/app/manifest.ts`。アイコンは `src/app/icon.tsx` / `apple-icon.tsx` で生成(PNG は置かない)。**ただし checkin と違いズームは許可**(管理画面はアクセシビリティ重視のため `viewport` で `maximumScale`/`userScalable` を制限しない)。 From 16fc708a80653df4f7d5449ebf81e3eaef494ce2 Mon Sep 17 00:00:00 2001 From: tmba Date: Tue, 2 Jun 2026 17:55:33 +0900 Subject: [PATCH 12/17] feat(admin): adopt official tec-nova logo for brand marks and PWA icons Replace the placeholder "tec" wordmark with the official logo (public/logo_tecnova.png, the same asset checkin/signage use). - BrandLogo component renders the logo; the wordmark is dark, so on dark-mode surfaces it sits on a white plate (dark:bg-white) to stay legible while preserving the brand colors (no plate in light mode). - icon.tsx / apple-icon.tsx now embed the logo centered on a white square via ImageResponse (readFile -> data URL; Satori needs a native , so next/image is intentionally not used). - Drop the unused Next.js scaffolding SVGs from public/. Co-Authored-By: Claude Opus 4.8 --- apps/admin/public/file.svg | 1 - apps/admin/public/globe.svg | 1 - apps/admin/public/logo_tecnova.png | Bin 0 -> 109830 bytes apps/admin/public/next.svg | 1 - apps/admin/public/vercel.svg | 1 - apps/admin/public/window.svg | 1 - apps/admin/src/app/apple-icon.tsx | 19 +++++----- apps/admin/src/app/icon.tsx | 20 +++++----- apps/admin/src/components/brand-logo.tsx | 38 +++++++++++++++++++ apps/admin/src/components/mobile-top-bar.tsx | 7 ++-- 10 files changed, 63 insertions(+), 26 deletions(-) delete mode 100644 apps/admin/public/file.svg delete mode 100644 apps/admin/public/globe.svg create mode 100644 apps/admin/public/logo_tecnova.png delete mode 100644 apps/admin/public/next.svg delete mode 100644 apps/admin/public/vercel.svg delete mode 100644 apps/admin/public/window.svg create mode 100644 apps/admin/src/components/brand-logo.tsx 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 0000000000000000000000000000000000000000..c1b768971c92efeba86caabc9cab0ad50bca1cbb GIT binary patch literal 109830 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 VYoDA983bIq+WJ~gPugAke*hOFS>pfz literal 0 HcmV?d00001 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/apple-icon.tsx b/apps/admin/src/app/apple-icon.tsx index 481e376..47728cd 100644 --- a/apps/admin/src/app/apple-icon.tsx +++ b/apps/admin/src/app/apple-icon.tsx @@ -1,11 +1,15 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; import { ImageResponse } from 'next/og'; -// iOS のホーム画面用アイコン(apple-touch-icon)。iOS が角丸を付けるので -// フルブリードのブランドブルー地に "tec" を置くだけでよい。 +// iOS ホーム画面用アイコン(apple-touch-icon)。iOS が角丸を付けるので白地フルブリードでよい。 +// 公式ロゴ(横長ワードマーク)を中央配置する。詳細は icon.tsx と同方針。 export const size = { width: 180, height: 180 }; export const contentType = 'image/png'; -export default function AppleIcon() { +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(
- tec + {/* 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 index 20b8318..6a19a26 100644 --- a/apps/admin/src/app/icon.tsx +++ b/apps/admin/src/app/icon.tsx @@ -1,11 +1,16 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; import { ImageResponse } from 'next/og'; -// ブラウザタブ・PWA 用のアイコンをプログラム生成する(PNG をリポジトリに置かない方針)。 -// ブランドブルー地に "tec" のワードマーク。ImageResponse は flexbox のみ対応。 +// ブラウザタブ・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 function Icon() { +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(
- tec + {/* biome-ignore lint/performance/noImgElement: ImageResponse(Satori) はネイティブ のみ対応。next/image は使えない。 */} + tec-nova Nagasaki
, { ...size }, ); 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 index 7bce67b..a8aa2c9 100644 --- a/apps/admin/src/components/mobile-top-bar.tsx +++ b/apps/admin/src/components/mobile-top-bar.tsx @@ -4,6 +4,7 @@ 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 が担うのでここでは出さない。 @@ -18,10 +19,10 @@ export function MobileTopBar({ className }: { className?: string }) { )} >
- - tec + + + 管理画面 - テクノバ管理画面
{/* モバイルはタッチ確保のため 40px のヒットエリアにする。 */} From c974cb1a745026ec3f61b6594bda52ffd0273fd8 Mon Sep 17 00:00:00 2001 From: tmba Date: Tue, 2 Jun 2026 17:55:48 +0900 Subject: [PATCH 13/17] feat(admin): add restrained motion micro-interactions and refine nav Mirror checkin's "Cohesive Elevation" motion (motion/react), all prefers-reduced-motion aware: - Shared tokens in lib/motion.ts; Reveal (one cohesive section/body fade-up) and AnimatedNumber (summary count-up). - Sliding active indicator via shared layoutId: a filled pill in the desktop sidebar and a bar in the mobile bottom nav. - Login adopts the logo (decorative alt to avoid double-announce next to the brand text) plus an entrance and CTA tap feedback. Each page wraps its swapping data area in a single always-mounted Reveal (flex flex-col gap-6 preserves the layout gap) so the entrance plays once and does not replay on every refetch/search; no per-card stagger. Nav a11y/contrast (from review): add aria-current + aria-label to the sidebar nav, and use the AA-compliant primary pairing for the active pill in light mode (light --sidebar-primary was 3.77:1), matching bottom-nav. Co-Authored-By: Claude Opus 4.8 --- apps/admin/package.json | 1 + apps/admin/src/app/(authed)/mentors/page.tsx | 127 ++++---- apps/admin/src/app/(authed)/page.tsx | 84 ++--- .../src/app/(authed)/participants/page.tsx | 287 +++++++++--------- .../app/(authed)/pre-registrations/page.tsx | 151 ++++----- apps/admin/src/app/(authed)/stats/page.tsx | 87 +++--- apps/admin/src/app/login/page.tsx | 65 ++-- apps/admin/src/components/animated-number.tsx | 37 +++ apps/admin/src/components/bottom-nav.tsx | 16 +- apps/admin/src/components/reveal.tsx | 32 ++ apps/admin/src/components/sidebar.tsx | 59 ++-- apps/admin/src/lib/motion.ts | 24 ++ pnpm-lock.yaml | 3 + 13 files changed, 575 insertions(+), 398 deletions(-) create mode 100644 apps/admin/src/components/animated-number.tsx create mode 100644 apps/admin/src/components/reveal.tsx create mode 100644 apps/admin/src/lib/motion.ts 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/src/app/(authed)/mentors/page.tsx b/apps/admin/src/app/(authed)/mentors/page.tsx index 5fe6c92..91cc535 100644 --- a/apps/admin/src/app/(authed)/mentors/page.tsx +++ b/apps/admin/src/app/(authed)/mentors/page.tsx @@ -43,6 +43,7 @@ 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' } @@ -86,73 +87,81 @@ export default function MentorsPage() { return (
- + + + - + + + - {state.kind === 'loading' && ( - <> -
- -
-
- {[0, 1, 2, 3, 4].map((i) => ( - - ))} -
- - )} - {state.kind === 'error' && ( - - 読み込めませんでした - {state.message} - - )} - - {state.kind === 'ok' && - (state.mentors.length === 0 ? ( -
- まだ管理者が登録されていません -
- ) : ( + {/* データ領域。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 ? ( +
+ まだ管理者が登録されていません +
+ ) : ( + <> + {/* モバイル: カードリスト。 card / row 両 variant を常時マウントし CSS で出し分ける(SSR 安全)。 各行は編集状態をローカルに持つため、編集途中で md をまたいでリサイズすると 未保存の編集が見かけ上消える。admin の利用端末(PC / タブレット)では稀で、 保存すれば再取得で両者が同期するため許容するトレードオフ。 */} -
- {state.mentors.map((m) => ( - - ))} -
+
+ {state.mentors.map((m) => ( + + ))} +
- {/* デスクトップ: テーブル */} - - - - - メールアドレス - 名前 - ロール - 状態 - 登録日 - 最終ログイン - 操作 - - - - {state.mentors.map((m) => ( - - ))} - -
-
- - ))} + {/* デスクトップ: テーブル */} + + + + + メールアドレス + 名前 + ロール + 状態 + 登録日 + 最終ログイン + 操作 + + + + {state.mentors.map((m) => ( + + ))} + +
+
+ + ))} +
); diff --git a/apps/admin/src/app/(authed)/page.tsx b/apps/admin/src/app/(authed)/page.tsx index 796d65f..065c54c 100644 --- a/apps/admin/src/app/(authed)/page.tsx +++ b/apps/admin/src/app/(authed)/page.tsx @@ -33,9 +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' } @@ -97,42 +99,48 @@ export default function DashboardPage() { return (
- - - - - } - /> + + + + + + } + /> + - setSelectedParticipantId(id)} - /> + {/* DashboardBody はフラグメントを返すので、main の gap-6 を保つため Reveal 側で再指定する。 + 常時マウントなので入場は一度だけ(再フェッチで再生されない)。 */} + + setSelectedParticipantId(id)} + /> +

- {/* モバイル: カードリスト */}
{rows.length === 0 ? ( @@ -237,7 +244,6 @@ function DashboardBody({ )) )}
- {/* デスクトップ: テーブル */} @@ -336,7 +342,9 @@ function SummaryCard({ -
{value}
+
+ +
); diff --git a/apps/admin/src/app/(authed)/participants/page.tsx b/apps/admin/src/app/(authed)/participants/page.tsx index cd2d236..f1582a6 100644 --- a/apps/admin/src/app/(authed)/participants/page.tsx +++ b/apps/admin/src/app/(authed)/participants/page.tsx @@ -30,6 +30,7 @@ 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' } @@ -98,9 +99,11 @@ export default function ParticipantsPage() { return (
- + + + -
+
無効 -
+ - {state.kind === 'loading' && ( - <> -
- -
-
- {[0, 1, 2, 3, 4, 5].map((i) => ( - - ))} -
- - )} + {/* 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' && ( - <> - {/* モバイル: カードリスト */} -
- {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} -

+ {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)}
- - {p.active ? '有効' : '無効'} - -
-
- - {p.id} - - {formatJstDate(p.activatedAt)} -
- - )) - )} -
+ + )) + )} +
- {/* デスクトップ: テーブル */} - -
- - - 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} ページ + +
+ + + + +
+
+ + )} + - + + + - + + + - {state.kind === 'loading' && ( - <> -
- -
-
- {[0, 1, 2, 3, 4].map((i) => ( - - ))} -
- - )} - {state.kind === 'error' && ( - - 読み込めませんでした - {state.message} - - )} - - {state.kind === 'ok' && ( - <> - {state.preRegistrations.length === 0 ? ( -
- ID未発行の事前登録はありません + {/* データ領域。Reveal を常時マウントして入場は一度だけ(再フェッチで再生されない)。 + フラグメントを含むので gap-6 を再指定。 */} + + {state.kind === 'loading' && ( + <> +
+ +
+
+ {[0, 1, 2, 3, 4].map((i) => ( + + ))}
- ) : ( - <> - {/* モバイル: カードリスト */} -
- {state.preRegistrations.map((p) => ( - - ))} + + )} + {state.kind === 'error' && ( + + 読み込めませんでした + {state.message} + + )} + + {state.kind === 'ok' && ( + <> + {state.preRegistrations.length === 0 ? ( +
+ ID未発行の事前登録はありません
+ ) : ( + <> + {/* モバイル: カードリスト */} +
+ {state.preRegistrations.map((p) => ( + + ))} +
- {/* デスクトップ: テーブル */} - - - - - 事前登録ID - 氏名 - ニックネーム - 学年 - 事前登録日 - 操作 - - - - {state.preRegistrations.map((p) => ( - - ))} - -
-
- - )} + {/* デスクトップ: テーブル */} + + + + + 事前登録ID + 氏名 + ニックネーム + 学年 + 事前登録日 + 操作 + + + + {state.preRegistrations.map((p) => ( + + ))} + +
+
+ + )} - - - )} + + + )} +
); } diff --git a/apps/admin/src/app/(authed)/stats/page.tsx b/apps/admin/src/app/(authed)/stats/page.tsx index 8e64a9c..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 側で再指定する。 */} + + +
); } @@ -242,7 +249,9 @@ function SummaryCard({ /> -
{value}
+
+ +
); diff --git a/apps/admin/src/app/login/page.tsx b/apps/admin/src/app/login/page.tsx index 6092823..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); @@ -42,34 +47,38 @@ export default function LoginPage() { return (
- - - - tec - -

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

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

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

+ 管理画面にログイン + + 許可リストに登録された管理者のみログインできます。 Google + アカウントで認証してください。 + +
+ {error && ( + + + ログインエラー + {error} + + + )} + + + + + +
+
); } 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/bottom-nav.tsx b/apps/admin/src/components/bottom-nav.tsx index 7638c26..769637e 100644 --- a/apps/admin/src/components/bottom-nav.tsx +++ b/apps/admin/src/components/bottom-nav.tsx @@ -2,8 +2,10 @@ 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 のホームインジケータを @@ -11,6 +13,7 @@ import { isNavItemActive, visibleNavItems } from './nav-items'; export function BottomNav({ className }: { className?: string }) { const me = useMe(); const pathname = usePathname(); + const prefersReduced = useReducedMotion(); const items = visibleNavItems(me.mentor.role); return ( @@ -36,9 +39,16 @@ export function BottomNav({ className }: { className?: string }) { active ? 'text-primary dark:text-sidebar-primary' : 'text-muted-foreground', )} > - {active && ( - - )} + {active && + (prefersReduced ? ( + + ) : ( + + ))} {item.shortLabel} 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 index de15262..0076708 100644 --- a/apps/admin/src/components/sidebar.tsx +++ b/apps/admin/src/components/sidebar.tsx @@ -5,9 +5,12 @@ 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'; // デスクトップ用の固定左サイドバー。ブランド → ナビ → フッター(テーマ切替 + @@ -15,6 +18,7 @@ import { isNavItemActive, visibleNavItems } from './nav-items'; export function Sidebar({ className }: { className?: string }) { const me = useMe(); const pathname = usePathname(); + const prefersReduced = useReducedMotion(); const items = visibleNavItems(me.mentor.role); return ( @@ -25,32 +29,47 @@ export function Sidebar({ className }: { className?: string }) { )} >
- - tec + + + 管理画面 - テクノバ管理画面
-