diff --git a/.github/workflows/dependabot-bun-lock.yml b/.github/workflows/dependabot-bun-lock.yml new file mode 100644 index 00000000..016fd008 --- /dev/null +++ b/.github/workflows/dependabot-bun-lock.yml @@ -0,0 +1,137 @@ +# Dependabot bun.lock 同期ワークフロー +# Sync bun.lock for Dependabot pull requests. +# +# Dependabot は npm エコシステムを使うため `package.json` は更新できるが、 +# Bun のロックファイル (`bun.lock`) は更新できない。その結果、CI の +# `bun install --frozen-lockfile` がロック不一致で即座に失敗する。 +# 本ワークフローは dependabot が作成した PR を検知し、対応する +# `bun install` を実行して `bun.lock` を再生成し、PR ブランチに +# 自動コミット・プッシュする。 +# +# Dependabot updates `package.json` via the npm ecosystem but cannot regenerate +# Bun's `bun.lock`. CI then fails immediately at `bun install --frozen-lockfile` +# because the lockfile and manifest disagree. This workflow detects such PRs, +# runs `bun install` in each affected directory, and pushes the regenerated +# lockfile back to the PR branch so CI can pass. +name: Dependabot bun.lock sync + +on: + pull_request: + # bun.lock を持つ全ディレクトリの package.json を監視する。 + # dependabot.yml は現在 root / server/api / server/hocuspocus を対象にしているが、 + # 過去 (PR #374) には admin/package.json も dependabot に更新されており、 + # 将来 dependabot.yml が拡張されてもカバーできるよう全ロックを対象にする。 + # Watch every package.json that has a sibling bun.lock. Although the + # current dependabot.yml only tracks root / server/api / server/hocuspocus, + # admin/package.json was once updated by Dependabot (PR #374), and this + # list also future-proofs the workflow against expansions of dependabot.yml. + paths: + - "package.json" + - "admin/package.json" + - "server/api/package.json" + - "server/hocuspocus/package.json" + - "server/mcp/package.json" + +# bun install で書き戻すため contents: write が必要。 +# Needs `contents: write` to push the regenerated lockfile back to the PR branch. +permissions: + contents: write + +concurrency: + group: dependabot-bun-lock-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + sync: + name: Sync bun.lock + if: github.actor == 'dependabot[bot]' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6.0.2 + with: + # PR ブランチに直接書き戻すため、head ref をチェックアウトする。 + # Check out the PR head ref so we can push commits back to it. + ref: ${{ github.event.pull_request.head.ref }} + # ベースブランチとの差分計算用に履歴を全部取る。 + # Fetch full history so we can diff against the PR base. + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - uses: actions/setup-node@v6 + with: + node-version-file: ".nvmrc" + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.3" + + - name: Detect changed package.json directories + id: detect + env: + BASE_REF: ${{ github.event.pull_request.base.ref }} + run: | + set -euo pipefail + git fetch --no-tags --depth=1 origin "$BASE_REF" + changed=$(git diff --name-only "origin/${BASE_REF}...HEAD") + echo "Changed files in PR:" + echo "$changed" + + dirs=() + # bun.lock を持つ全ディレクトリ。`on.paths` と一致させる。 + # Every directory with a sibling bun.lock; keep in sync with `on.paths`. + for path in \ + "package.json" \ + "admin/package.json" \ + "server/api/package.json" \ + "server/hocuspocus/package.json" \ + "server/mcp/package.json"; do + if printf '%s\n' "$changed" | grep -Fxq "$path"; then + dir=$(dirname "$path") + dirs+=("$dir") + fi + done + + if [ ${#dirs[@]} -eq 0 ]; then + echo "No tracked package.json changed; nothing to do." + echo "dirs=" >> "$GITHUB_OUTPUT" + else + echo "dirs=${dirs[*]}" >> "$GITHUB_OUTPUT" + fi + + - name: Regenerate bun.lock + if: steps.detect.outputs.dirs != '' + run: | + set -euo pipefail + for dir in ${{ steps.detect.outputs.dirs }}; do + echo "::group::bun install ($dir)" + (cd "$dir" && bun install --no-summary) + echo "::endgroup::" + done + + - name: Commit and push bun.lock changes + if: steps.detect.outputs.dirs != '' + env: + HEAD_REF: ${{ github.event.pull_request.head.ref }} + run: | + set -euo pipefail + # bun.lock 以外の変更(例: node_modules メタ情報)は無視。 + # Only stage bun.lock files; ignore any other side effects. + git add -- ':(glob)**/bun.lock' bun.lock 2>/dev/null || true + + if git diff --cached --quiet; then + echo "bun.lock is already in sync; nothing to commit." + exit 0 + fi + + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git commit -m "chore(deps): sync bun.lock with package.json changes" + # GITHUB_TOKEN による push は新規ワークフロー実行をトリガーしないため、 + # 無限ループは発生しない (GitHub Actions の組み込み安全機構)。 + # PAT や GitHub App トークンに切り替える場合は、コミッタを確認する等の + # 別途ループ防止ロジックが必要になる。 + # Pushes made with GITHUB_TOKEN do not trigger new workflow runs, so + # no infinite loop occurs (built-in GitHub Actions safety feature). + # If switching to a PAT or GitHub App token, add separate + # loop-prevention logic (e.g. checking the committer). + git push origin "HEAD:${HEAD_REF}" diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index b2e593ec..83dbaf49 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -29,7 +29,7 @@ jobs: - name: Run Release Please id: rp - uses: googleapis/release-please-action@v4 + uses: googleapis/release-please-action@v5 with: token: ${{ secrets.GITHUB_TOKEN }} config-file: .release-please-config.json diff --git a/admin/package.json b/admin/package.json index 160cbb39..6e6fed36 100644 --- a/admin/package.json +++ b/admin/package.json @@ -12,9 +12,12 @@ }, "dependencies": { "@zedi/ui": "workspace:*", + "i18next": "^26.0.1", + "i18next-browser-languagedetector": "^8.2.1", "lucide-react": "^1.7.0", "react": "^19.2.4", "react-dom": "^19.2.4", + "react-i18next": "^17.0.1", "react-router-dom": "^7.13.2" }, "devDependencies": { diff --git a/admin/src/components/ConfirmActionDialog.tsx b/admin/src/components/ConfirmActionDialog.tsx index abbbd20b..758173b0 100644 --- a/admin/src/components/ConfirmActionDialog.tsx +++ b/admin/src/components/ConfirmActionDialog.tsx @@ -1,4 +1,5 @@ import { useState, useCallback } from "react"; +import { useTranslation } from "react-i18next"; import { AlertDialog, AlertDialogContent, @@ -71,7 +72,7 @@ export function ConfirmActionDialog({ onOpenChange, title, description, - confirmLabel = "確認", + confirmLabel, destructive = false, loading = false, confirmPhrase, @@ -79,6 +80,8 @@ export function ConfirmActionDialog({ onConfirm, children, }: ConfirmActionDialogProps) { + const { t } = useTranslation(); + const resolvedConfirmLabel = confirmLabel ?? t("common.confirm"); const [phraseInput, setPhraseInput] = useState(""); // ダイアログ閉じ時に確認フレーズ入力をリセット / Reset phrase input when dialog closes @@ -116,7 +119,7 @@ export function ConfirmActionDialog({
- キャンセル + {t("common.cancel")} - {loading ? "処理中..." : confirmLabel} + {loading ? t("common.processing") : resolvedConfirmLabel} diff --git a/admin/src/i18n/index.ts b/admin/src/i18n/index.ts new file mode 100644 index 00000000..127bb8c1 --- /dev/null +++ b/admin/src/i18n/index.ts @@ -0,0 +1,72 @@ +import i18n from "i18next"; +import { initReactI18next } from "react-i18next"; +import LanguageDetector from "i18next-browser-languagedetector"; + +// 翻訳をドメインごとに分割(1ファイルあたりのコード量を抑える) +// Translations are split per domain to keep individual files small. +import jaCommon from "./locales/ja/common.json"; +import jaNav from "./locales/ja/nav.json"; +import jaAuth from "./locales/ja/auth.json"; +import jaUsers from "./locales/ja/users.json"; +import jaAudit from "./locales/ja/audit.json"; +import jaWikiHealth from "./locales/ja/wikiHealth.json"; +import jaActivityLog from "./locales/ja/activityLog.json"; +import jaAiModels from "./locales/ja/aiModels.json"; +import enCommon from "./locales/en/common.json"; +import enNav from "./locales/en/nav.json"; +import enAuth from "./locales/en/auth.json"; +import enUsers from "./locales/en/users.json"; +import enAudit from "./locales/en/audit.json"; +import enWikiHealth from "./locales/en/wikiHealth.json"; +import enActivityLog from "./locales/en/activityLog.json"; +import enAiModels from "./locales/en/aiModels.json"; + +const ja = { + common: jaCommon, + nav: jaNav, + auth: jaAuth, + users: jaUsers, + audit: jaAudit, + wikiHealth: jaWikiHealth, + activityLog: jaActivityLog, + aiModels: jaAiModels, +}; + +const en = { + common: enCommon, + nav: enNav, + auth: enAuth, + users: enUsers, + audit: enAudit, + wikiHealth: enWikiHealth, + activityLog: enActivityLog, + aiModels: enAiModels, +}; + +/** + * 管理画面用 i18n インスタンス。 + * 本体アプリと同じ localStorage キー(`zedi-i18next-lng`)を共有して、 + * 言語設定が両画面間で一貫するようにする。 + * + * Admin i18n instance. Shares the same localStorage key as the main app + * (`zedi-i18next-lng`) so language preference stays consistent across surfaces. + */ +i18n + .use(LanguageDetector) + .use(initReactI18next) + .init({ + resources: { + ja: { translation: ja }, + en: { translation: en }, + }, + fallbackLng: "ja", + interpolation: { + escapeValue: false, + }, + detection: { + order: ["localStorage", "navigator"], + lookupLocalStorage: "zedi-i18next-lng", + }, + }); + +export default i18n; diff --git a/admin/src/i18n/locales/en/activityLog.json b/admin/src/i18n/locales/en/activityLog.json new file mode 100644 index 00000000..0e799fcc --- /dev/null +++ b/admin/src/i18n/locales/en/activityLog.json @@ -0,0 +1,29 @@ +{ + "title": "Activity Log", + "filters": { + "kind": "Kind", + "actor": "Actor" + }, + "columns": { + "kind": "Kind", + "actor": "Actor", + "detail": "Detail", + "relatedPages": "Related pages", + "createdAt": "Recorded at" + }, + "empty": "No activity logs yet.", + "showing": "Showing {{shown}} of {{total}}", + "kinds": { + "clip_ingest": "Clip ingest", + "chat_promote": "Chat promote", + "lint_run": "Lint run", + "wiki_generate": "Wiki generate", + "index_build": "Index build", + "wiki_schema_update": "Schema update" + }, + "actors": { + "user": "User", + "ai": "AI", + "system": "System" + } +} diff --git a/admin/src/i18n/locales/en/aiModels.json b/admin/src/i18n/locales/en/aiModels.json new file mode 100644 index 00000000..a0ab3a71 --- /dev/null +++ b/admin/src/i18n/locales/en/aiModels.json @@ -0,0 +1,30 @@ +{ + "title": "AI Model Management", + "syncing": "Syncing...", + "syncWithProvider": "Sync with provider", + "syncResult": "Sync result:", + "syncStat": "Added {{added}} / Deactivated {{deactivated}}", + "columns": { + "reorder": "Reorder", + "provider": "Provider", + "modelId": "Model ID", + "displayName": "Display name", + "tier": "Tier", + "active": "Active", + "sortOrder": "Order" + }, + "summary": "{{total}} models (active: {{active}})", + "dragHint": "Drag to reorder", + "displayNameAriaLabel": "Display name for {{modelId}}", + "tierAriaLabel": "Tier for {{name}}", + "preview": { + "title": "Sync preview", + "description": "New models will be added, and existing models no longer in scope will be deactivated. Existing models' display names and pricing are not overwritten. Sonnet-family models are added as inactive.", + "noChanges": "No changes", + "addLabel": "Add: {{name}}", + "deactivateLabel": "Deactivate: {{name}}", + "inactiveTag": "(inactive)", + "errorWarning": "Some providers reported errors. Providers with errors will not be synced.", + "confirm": "Run sync (Add {{added}} / Deactivate {{deactivated}})" + } +} diff --git a/admin/src/i18n/locales/en/audit.json b/admin/src/i18n/locales/en/audit.json new file mode 100644 index 00000000..3a6c9791 --- /dev/null +++ b/admin/src/i18n/locales/en/audit.json @@ -0,0 +1,20 @@ +{ + "title": "Audit Logs", + "filters": { + "action": "Filter by action", + "from": "From", + "to": "To" + }, + "columns": { + "createdAt": "Timestamp", + "actor": "Actor", + "action": "Action", + "target": "Target", + "diff": "Changes", + "ipAddress": "IP" + }, + "empty": "No audit logs.", + "actions": { + "user.role.update": "User: Role change" + } +} diff --git a/admin/src/i18n/locales/en/auth.json b/admin/src/i18n/locales/en/auth.json new file mode 100644 index 00000000..69672076 --- /dev/null +++ b/admin/src/i18n/locales/en/auth.json @@ -0,0 +1,7 @@ +{ + "title": "Zedi Admin", + "description": "To log in as an administrator, please sign in to the main app first.", + "signInButton": "Sign in to continue", + "afterSignIn": "After signing in, return to this page.", + "guardLoading": "Loading..." +} diff --git a/admin/src/i18n/locales/en/common.json b/admin/src/i18n/locales/en/common.json new file mode 100644 index 00000000..72b4c17f --- /dev/null +++ b/admin/src/i18n/locales/en/common.json @@ -0,0 +1,19 @@ +{ + "cancel": "Cancel", + "confirm": "Confirm", + "loading": "Loading...", + "saving": "Saving...", + "processing": "Processing...", + "previous": "Previous", + "next": "Next", + "all": "All", + "save": "Save", + "delete": "Delete", + "reload": "Reload", + "running": "Running...", + "page": "Page {{page}} / {{count}}", + "showingRange": "Showing {{rangeStart}}-{{rangeEnd}} of {{total}}", + "showingZero": "Showing 0 of {{total}}", + "totalCount": "Total: {{count}}", + "confirmPhraseDefault": "Type \"{{phrase}}\" to confirm" +} diff --git a/admin/src/i18n/locales/en/nav.json b/admin/src/i18n/locales/en/nav.json new file mode 100644 index 00000000..2c6fdaef --- /dev/null +++ b/admin/src/i18n/locales/en/nav.json @@ -0,0 +1,12 @@ +{ + "adminPanelTitle": "Zedi Admin", + "adminShortTitle": "Admin", + "menu": "Menu", + "items": { + "aiModels": "AI Models", + "users": "Users", + "auditLogs": "Audit Logs", + "wikiHealth": "Wiki Health", + "activityLog": "Activity Log" + } +} diff --git a/admin/src/i18n/locales/en/users.json b/admin/src/i18n/locales/en/users.json new file mode 100644 index 00000000..41010810 --- /dev/null +++ b/admin/src/i18n/locales/en/users.json @@ -0,0 +1,63 @@ +{ + "title": "User Management", + "statusFilterAriaLabel": "Status filter", + "searchEmailPlaceholder": "Search by email", + "searchEmailAriaLabel": "Search by email", + "columns": { + "email": "Email", + "name": "Name", + "status": "Status", + "role": "Role", + "pageCount": "Pages", + "createdAt": "Created", + "actions": "Actions" + }, + "actions": { + "restore": "Restore", + "suspend": "Suspend", + "delete": "Delete" + }, + "states": { + "saving": "Saving...", + "deleted": "Deleted" + }, + "card": { + "reasonPrefix": "Reason: {{reason}}", + "pageCount": "Pages: {{count}}" + }, + "row": { + "roleAriaLabel": "Role for {{email}}", + "suspendedReasonShort": "({{reason}})" + }, + "roleChange": { + "title": "Change role", + "description": "Change role of {{name}} from \"{{from}}\" to \"{{to}}\"?", + "confirm": "Change" + }, + "unsuspend": { + "title": "Lift suspension", + "description": "Lift the suspension and restore {{name}}'s account?", + "confirm": "Restore" + }, + "deleteUser": { + "title": "Delete user", + "description": "Delete {{name}}. Personal information will be anonymized, and sessions and OAuth links will be removed. This action cannot be undone.", + "confirm": "Delete" + }, + "impact": { + "title": "Impact:", + "notes": "Owned notes: {{count}}", + "sessions": "Active sessions: {{count}}", + "subscription": "Subscription: {{value}}", + "subscriptionActive": "Active", + "subscriptionNone": "None", + "lastAiUsage": "Last AI usage: {{date}}" + }, + "suspendDialog": { + "title": "Suspend user", + "description": "Suspend {{name}}. Suspended users lose access to all APIs and existing sessions are invalidated.", + "confirm": "Suspend", + "reasonLabel": "Reason (optional)", + "reasonPlaceholder": "Enter the reason for suspension" + } +} diff --git a/admin/src/i18n/locales/en/wikiHealth.json b/admin/src/i18n/locales/en/wikiHealth.json new file mode 100644 index 00000000..c79f2f22 --- /dev/null +++ b/admin/src/i18n/locales/en/wikiHealth.json @@ -0,0 +1,27 @@ +{ + "title": "Wiki Health Dashboard", + "runLint": "Run lint", + "filterRule": "Filter by rule", + "emptyAll": "No lint findings. Click \"Run lint\" to start.", + "emptyFiltered": "No matching findings.", + "columns": { + "rule": "Rule", + "severity": "Severity", + "detail": "Detail", + "createdAt": "Detected at", + "actions": "Actions" + }, + "pagesRelated": "{{count}} pages related", + "resolve": "Resolve", + "rules": { + "orphan": "Orphan", + "ghost_many": "Ghost Excess", + "title_similar": "Title Similar", + "conflict": "Conflict", + "broken_link": "Broken Link", + "stale": "Stale" + }, + "detail": { + "linkText": "\"{{linkText}}\" ({{count}})" + } +} diff --git a/admin/src/i18n/locales/ja/activityLog.json b/admin/src/i18n/locales/ja/activityLog.json new file mode 100644 index 00000000..d68dac1f --- /dev/null +++ b/admin/src/i18n/locales/ja/activityLog.json @@ -0,0 +1,29 @@ +{ + "title": "活動ログ", + "filters": { + "kind": "種別 / Kind", + "actor": "起点 / Actor" + }, + "columns": { + "kind": "種別", + "actor": "起点", + "detail": "詳細", + "relatedPages": "関連ページ", + "createdAt": "記録日時" + }, + "empty": "活動ログはまだありません。", + "showing": "表示 {{shown}} / 合計 {{total}} 件", + "kinds": { + "clip_ingest": "クリップ取り込み / Clip ingest", + "chat_promote": "Chat → Wiki 昇格 / Chat promote", + "lint_run": "Lint 実行 / Lint run", + "wiki_generate": "Wiki 生成 / Wiki generate", + "index_build": "Index 構築 / Index build", + "wiki_schema_update": "スキーマ更新 / Schema update" + }, + "actors": { + "user": "ユーザー / User", + "ai": "AI", + "system": "システム / System" + } +} diff --git a/admin/src/i18n/locales/ja/aiModels.json b/admin/src/i18n/locales/ja/aiModels.json new file mode 100644 index 00000000..5a097b9e --- /dev/null +++ b/admin/src/i18n/locales/ja/aiModels.json @@ -0,0 +1,30 @@ +{ + "title": "AI モデル管理", + "syncing": "同期中...", + "syncWithProvider": "プロバイダーと同期", + "syncResult": "同期結果:", + "syncStat": "追加 {{added}} / 無効化 {{deactivated}}", + "columns": { + "reorder": "並び替え", + "provider": "プロバイダー", + "modelId": "モデルID", + "displayName": "表示名", + "tier": "ティア", + "active": "有効", + "sortOrder": "並び順" + }, + "summary": "{{total}} 件(有効: {{active}})", + "dragHint": "ドラッグで並び替え", + "displayNameAriaLabel": "{{modelId}} の表示名", + "tierAriaLabel": "{{name}} のティア", + "preview": { + "title": "同期プレビュー", + "description": "新規モデルは追加され、同期対象から外れた既存モデルは非アクティブ化されます。 既存モデルの表示名や料金は上書きされません。Sonnet 系は非アクティブで追加されます。", + "noChanges": "変更なし", + "addLabel": "追加: {{name}}", + "deactivateLabel": "無効化: {{name}}", + "inactiveTag": "(非アクティブ)", + "errorWarning": "一部プロバイダーでエラーが発生しています。エラーのあるプロバイダーは同期されません。", + "confirm": "同期実行(追加 {{added}} / 無効化 {{deactivated}})" + } +} diff --git a/admin/src/i18n/locales/ja/audit.json b/admin/src/i18n/locales/ja/audit.json new file mode 100644 index 00000000..d77a7cb9 --- /dev/null +++ b/admin/src/i18n/locales/ja/audit.json @@ -0,0 +1,20 @@ +{ + "title": "監査ログ", + "filters": { + "action": "アクションで絞り込み", + "from": "期間(開始)", + "to": "期間(終了)" + }, + "columns": { + "createdAt": "日時", + "actor": "操作者", + "action": "アクション", + "target": "対象", + "diff": "変更内容", + "ipAddress": "IP" + }, + "empty": "監査ログはありません。", + "actions": { + "user.role.update": "ユーザー: ロール変更" + } +} diff --git a/admin/src/i18n/locales/ja/auth.json b/admin/src/i18n/locales/ja/auth.json new file mode 100644 index 00000000..52e26ad4 --- /dev/null +++ b/admin/src/i18n/locales/ja/auth.json @@ -0,0 +1,7 @@ +{ + "title": "Zedi 管理画面", + "description": "管理者としてログインするには、まずメインアプリでサインインしてください。", + "signInButton": "サインインして続ける", + "afterSignIn": "サインイン後、このページに戻ってきてください。", + "guardLoading": "Loading..." +} diff --git a/admin/src/i18n/locales/ja/common.json b/admin/src/i18n/locales/ja/common.json new file mode 100644 index 00000000..10fe867c --- /dev/null +++ b/admin/src/i18n/locales/ja/common.json @@ -0,0 +1,19 @@ +{ + "cancel": "キャンセル", + "confirm": "確認", + "loading": "読み込み中...", + "saving": "保存中...", + "processing": "処理中...", + "previous": "前へ", + "next": "次へ", + "all": "すべて", + "save": "保存", + "delete": "削除", + "reload": "再読み込み", + "running": "実行中...", + "page": "{{page}} / {{count}} ページ", + "showingRange": "{{rangeStart}}-{{rangeEnd}} 件を表示 / 合計 {{total}} 件", + "showingZero": "0 件を表示 / 合計 {{total}} 件", + "totalCount": "合計 {{count}} 件", + "confirmPhraseDefault": "確認のため「{{phrase}}」を入力してください / Type \"{{phrase}}\" to confirm" +} diff --git a/admin/src/i18n/locales/ja/nav.json b/admin/src/i18n/locales/ja/nav.json new file mode 100644 index 00000000..a4eb8248 --- /dev/null +++ b/admin/src/i18n/locales/ja/nav.json @@ -0,0 +1,12 @@ +{ + "adminPanelTitle": "Zedi 管理画面", + "adminShortTitle": "管理画面", + "menu": "メニュー", + "items": { + "aiModels": "AI モデル", + "users": "ユーザー管理", + "auditLogs": "監査ログ", + "wikiHealth": "Wiki Health", + "activityLog": "活動ログ" + } +} diff --git a/admin/src/i18n/locales/ja/users.json b/admin/src/i18n/locales/ja/users.json new file mode 100644 index 00000000..6ba33ec0 --- /dev/null +++ b/admin/src/i18n/locales/ja/users.json @@ -0,0 +1,63 @@ +{ + "title": "ユーザー管理", + "statusFilterAriaLabel": "ステータスフィルタ", + "searchEmailPlaceholder": "メールで検索", + "searchEmailAriaLabel": "メールで検索", + "columns": { + "email": "メール", + "name": "名前", + "status": "ステータス", + "role": "ロール", + "pageCount": "ページ数", + "createdAt": "作成日", + "actions": "操作" + }, + "actions": { + "restore": "復活", + "suspend": "サスペンド", + "delete": "削除" + }, + "states": { + "saving": "保存中...", + "deleted": "削除済み" + }, + "card": { + "reasonPrefix": "理由: {{reason}}", + "pageCount": "ページ数: {{count}}" + }, + "row": { + "roleAriaLabel": "{{email}} のロール", + "suspendedReasonShort": "({{reason}})" + }, + "roleChange": { + "title": "ロールを変更", + "description": "{{name}} のロールを「{{from}}」から「{{to}}」に変更しますか?", + "confirm": "変更する" + }, + "unsuspend": { + "title": "サスペンドを解除", + "description": "{{name}} のサスペンドを解除し、アカウントを復活させますか?", + "confirm": "復活させる" + }, + "deleteUser": { + "title": "ユーザーを削除", + "description": "{{name}} を削除します。個人情報は匿名化され、セッションと OAuth 連携は削除されます。この操作は元に戻せません。", + "confirm": "削除する" + }, + "impact": { + "title": "影響範囲:", + "notes": "所有ノート: {{count}} 件", + "sessions": "アクティブセッション: {{count}} 件", + "subscription": "サブスクリプション: {{value}}", + "subscriptionActive": "あり (active)", + "subscriptionNone": "なし", + "lastAiUsage": "最後の AI 使用: {{date}}" + }, + "suspendDialog": { + "title": "ユーザーをサスペンド", + "description": "{{name}} をサスペンドします。サスペンドされたユーザーはすべての API にアクセスできなくなり、既存セッションも無効化されます。", + "confirm": "サスペンド", + "reasonLabel": "理由(任意)", + "reasonPlaceholder": "サスペンドの理由を入力してください" + } +} diff --git a/admin/src/i18n/locales/ja/wikiHealth.json b/admin/src/i18n/locales/ja/wikiHealth.json new file mode 100644 index 00000000..22fadd33 --- /dev/null +++ b/admin/src/i18n/locales/ja/wikiHealth.json @@ -0,0 +1,27 @@ +{ + "title": "Wiki Health ダッシュボード", + "runLint": "Lint 実行", + "filterRule": "ルールで絞り込み", + "emptyAll": "Lint findings はありません。「Lint 実行」をクリックしてください。", + "emptyFiltered": "該当する findings はありません。", + "columns": { + "rule": "ルール", + "severity": "重要度", + "detail": "詳細", + "createdAt": "検出日時", + "actions": "操作" + }, + "pagesRelated": "{{count}} ページ関連", + "resolve": "解決", + "rules": { + "orphan": "孤立ページ / Orphan", + "ghost_many": "Ghost Link 過多 / Ghost Excess", + "title_similar": "タイトル類似 / Title Similar", + "conflict": "矛盾 / Conflict", + "broken_link": "リンク切れ / Broken Link", + "stale": "古い情報 / Stale" + }, + "detail": { + "linkText": "「{{linkText}}」({{count}} 件)" + } +} diff --git a/admin/src/lib/dateUtils.test.ts b/admin/src/lib/dateUtils.test.ts index 38bbbe83..856bf1a1 100644 --- a/admin/src/lib/dateUtils.test.ts +++ b/admin/src/lib/dateUtils.test.ts @@ -1,19 +1,50 @@ /** - * formatDate のテスト。 - * Tests for formatDate. + * formatDate / formatNumber / getActiveLocale のテスト。 + * Tests for formatDate, formatNumber, and getActiveLocale. */ -import { describe, it, expect } from "vitest"; -import { formatDate } from "./dateUtils"; +import { describe, it, expect, afterEach } from "vitest"; +import i18n from "@/i18n"; +import { formatDate, formatNumber, getActiveLocale } from "./dateUtils"; + +afterEach(async () => { + // 他テストファイルが期待する ja への復帰を保証する。 + // Restore the global ja default so subsequent test files keep their assumptions. + await i18n.changeLanguage("ja"); +}); + +describe("getActiveLocale", () => { + it("ja の時 ja-JP を返す / returns ja-JP when language is ja", async () => { + await i18n.changeLanguage("ja"); + expect(getActiveLocale()).toBe("ja-JP"); + }); + + it("en の時 en-US を返す / returns en-US when language is en", async () => { + await i18n.changeLanguage("en"); + expect(getActiveLocale()).toBe("en-US"); + }); + + it("未知言語は en-US にフォールバックする / falls back to en-US for unknown languages", async () => { + await i18n.changeLanguage("fr"); + expect(getActiveLocale()).toBe("en-US"); + }); +}); describe("formatDate", () => { - it("ISO 8601 を YYYY/MM/DD(ja-JP)に整形する / formats ISO date in ja-JP locale", () => { + it("ja で YYYY/MM/DD に整形する / formats as YYYY/MM/DD in ja", async () => { + await i18n.changeLanguage("ja"); expect(formatDate("2026-04-25T01:23:45Z")).toBe("2026/04/25"); }); - it("月日が 1 桁でも 0 埋めされる / zero-pads single-digit month/day", () => { + it("ja で 1 桁の月日を 0 埋めする / zero-pads single-digit month/day in ja", async () => { + await i18n.changeLanguage("ja"); expect(formatDate("2026-01-02T00:00:00Z")).toBe("2026/01/02"); }); + it("en で MM/DD/YYYY に整形する / formats as MM/DD/YYYY in en", async () => { + await i18n.changeLanguage("en"); + expect(formatDate("2026-04-25T01:23:45Z")).toBe("04/25/2026"); + }); + it("不正な日付文字列はそのまま返す / returns input as-is for invalid date", () => { expect(formatDate("not-a-date")).toBe("not-a-date"); }); @@ -22,3 +53,19 @@ describe("formatDate", () => { expect(formatDate("")).toBe(""); }); }); + +describe("formatNumber", () => { + it("ja でカンマ区切りに整形する / formats with comma separators in ja", async () => { + await i18n.changeLanguage("ja"); + expect(formatNumber(1234567)).toBe("1,234,567"); + }); + + it("en でカンマ区切りに整形する / formats with comma separators in en", async () => { + await i18n.changeLanguage("en"); + expect(formatNumber(1234567)).toBe("1,234,567"); + }); + + it('0 は "0" を返す / returns "0" for zero', () => { + expect(formatNumber(0)).toBe("0"); + }); +}); diff --git a/admin/src/lib/dateUtils.ts b/admin/src/lib/dateUtils.ts index 34ea747e..320ee53d 100644 --- a/admin/src/lib/dateUtils.ts +++ b/admin/src/lib/dateUtils.ts @@ -1,19 +1,46 @@ +import i18n from "@/i18n"; + /** - * ISO 8601 形式の日付文字列を日本語ロケールの日付形式に変換する。 - * Converts an ISO 8601 date string to Japanese locale date format. + * 現在の i18n 言語に対応する BCP 47 ロケールタグを返す。 + * 日本語以外は `en-US` にフォールバックする(将来言語追加時の保守性のため)。 + * + * Returns a BCP 47 locale tag matching the current i18n language. + * Falls back to `en-US` for any non-`ja` language so adding new locales later + * does not silently render Japanese. + */ +export function getActiveLocale(): "ja-JP" | "en-US" { + const lang = i18n.language?.split("-")[0]; + if (lang === "ja") return "ja-JP"; + return "en-US"; +} + +/** + * ISO 8601 形式の日付文字列を、現在の i18n ロケールに合わせた日付形式に変換する。 + * Converts an ISO 8601 date string to a date format matching the active i18n locale. * * @param iso - ISO 8601 形式の日付文字列 / ISO 8601 date string - * @returns 日本語形式(YYYY/MM/DD)の日付文字列。不正な入力の場合はそのまま返す。 - * Date string in Japanese format (YYYY/MM/DD). Returns input as-is for invalid input. + * @returns ロケール依存の日付文字列。不正な入力の場合はそのまま返す。 + * Locale-formatted date string. Returns input as-is for invalid input. */ export function formatDate(iso: string): string { const date = new Date(iso); if (Number.isNaN(date.getTime())) { return iso; } - return date.toLocaleDateString("ja-JP", { + return date.toLocaleDateString(getActiveLocale(), { year: "numeric", month: "2-digit", day: "2-digit", }); } + +/** + * 数値を現在の i18n ロケールの慣習で整形する(桁区切りなど)。 + * Formats a number using the active i18n locale (thousand separators, etc.). + * + * @param value - 数値 / numeric value + * @returns ロケール依存の数値文字列 / locale-formatted number string + */ +export function formatNumber(value: number): string { + return value.toLocaleString(getActiveLocale()); +} diff --git a/admin/src/main.tsx b/admin/src/main.tsx index e9e67814..0315b359 100644 --- a/admin/src/main.tsx +++ b/admin/src/main.tsx @@ -1,6 +1,7 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import App from "./App"; +import "./i18n"; import "./index.css"; const rootEl = document.getElementById("root"); diff --git a/admin/src/pages/ActivityLog.tsx b/admin/src/pages/ActivityLog.tsx index e0da56fc..9a670681 100644 --- a/admin/src/pages/ActivityLog.tsx +++ b/admin/src/pages/ActivityLog.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; import { Badge, Button, @@ -22,24 +23,6 @@ import { } from "@/api/activity"; import { formatDate } from "@/lib/dateUtils"; -/** - * ルール/起点のラベルマップ。 - * Labels for activity kind and actor. - */ -const KIND_LABELS: Record = { - clip_ingest: "クリップ取り込み / Clip ingest", - chat_promote: "Chat → Wiki 昇格 / Chat promote", - lint_run: "Lint 実行 / Lint run", - wiki_generate: "Wiki 生成 / Wiki generate", - index_build: "Index 構築 / Index build", - wiki_schema_update: "スキーマ更新 / Schema update", -}; -const ACTOR_LABELS: Record = { - user: "ユーザー / User", - ai: "AI", - system: "システム / System", -}; - const ANY = "__any__"; const KINDS: ActivityKind[] = [ "clip_ingest", @@ -82,6 +65,7 @@ function formatDetail(entry: ActivityEntry): string { * Admin Activity Log page. */ export default function ActivityLog() { + const { t } = useTranslation(); const [entries, setEntries] = useState([]); const [total, setTotal] = useState(0); const [loading, setLoading] = useState(true); @@ -130,26 +114,26 @@ export default function ActivityLog() { return (
-

Activity Log / 活動ログ

+

{t("activityLog.title")}

- すべて + {t("common.all")} {ACTORS.map((a) => ( - {ACTOR_LABELS[a]} + {t(`activityLog.actors.${a}`)} ))} @@ -180,29 +164,29 @@ export default function ActivityLog() { )} {loading && entries.length === 0 ? ( -

読み込み中...

+

{t("common.loading")}

) : entries.length === 0 ? ( -

活動ログはまだありません。

+

{t("activityLog.empty")}

) : (
- 種別 - 起点 - 詳細 - 関連ページ - 記録日時 + {t("activityLog.columns.kind")} + {t("activityLog.columns.actor")} + {t("activityLog.columns.detail")} + {t("activityLog.columns.relatedPages")} + {t("activityLog.columns.createdAt")} {entries.map((entry) => ( - {KIND_LABELS[entry.kind]} + {t(`activityLog.kinds.${entry.kind}`)} - {ACTOR_LABELS[entry.actor]} + {t(`activityLog.actors.${entry.actor}`)} {formatDetail(entry)} @@ -218,7 +202,7 @@ export default function ActivityLog() {

- 表示 {entries.length} / 合計 {total} 件 + {t("activityLog.showing", { shown: entries.length, total })}

)} diff --git a/admin/src/pages/Layout.tsx b/admin/src/pages/Layout.tsx index 634a02f8..604c7525 100644 --- a/admin/src/pages/Layout.tsx +++ b/admin/src/pages/Layout.tsx @@ -1,5 +1,6 @@ import { Outlet, Link, useLocation } from "react-router-dom"; import { Bot, Users, ScrollText, HeartPulse, Activity } from "lucide-react"; +import { useTranslation } from "react-i18next"; import { SidebarProvider, Sidebar, @@ -15,12 +16,12 @@ import { SidebarHeader, } from "@zedi/ui"; -const navLinks = [ - { to: "/ai-models", label: "AI モデル", icon: Bot }, - { to: "/users", label: "ユーザー管理", icon: Users }, - { to: "/audit-logs", label: "監査ログ", icon: ScrollText }, - { to: "/wiki-health", label: "Wiki Health", icon: HeartPulse }, - { to: "/activity-log", label: "活動ログ", icon: Activity }, +const NAV_ITEMS = [ + { to: "/ai-models", labelKey: "nav.items.aiModels", icon: Bot }, + { to: "/users", labelKey: "nav.items.users", icon: Users }, + { to: "/audit-logs", labelKey: "nav.items.auditLogs", icon: ScrollText }, + { to: "/wiki-health", labelKey: "nav.items.wikiHealth", icon: HeartPulse }, + { to: "/activity-log", labelKey: "nav.items.activityLog", icon: Activity }, ]; /** @@ -29,19 +30,21 @@ const navLinks = [ */ export default function Layout() { const location = useLocation(); + const { t } = useTranslation(); return ( - Zedi 管理画面 + {t("nav.adminPanelTitle")} - メニュー + {t("nav.menu")} - {navLinks.map(({ to, label, icon: Icon }) => { + {NAV_ITEMS.map(({ to, labelKey, icon: Icon }) => { + const label = t(labelKey); const isActive = location.pathname === to || (to !== "/" && location.pathname.startsWith(to)); return ( @@ -63,7 +66,7 @@ export default function Layout() {
- 管理画面 + {t("nav.adminShortTitle")}
diff --git a/admin/src/pages/Login.tsx b/admin/src/pages/Login.tsx index 61632a47..16270e57 100644 --- a/admin/src/pages/Login.tsx +++ b/admin/src/pages/Login.tsx @@ -1,26 +1,27 @@ import { Button } from "@zedi/ui"; +import { useTranslation } from "react-i18next"; /** * 管理者ログイン案内 * メインアプリでサインイン後、このドメインに戻るとセッションが有効になる。 + * + * Admin login prompt. Once the user signs in via the main app, returning to + * this domain establishes the admin session. */ export default function Login() { + const { t } = useTranslation(); const mainAppUrl = import.meta.env.VITE_MAIN_APP_URL || "https://zedi-note.app"; const signInUrl = `${mainAppUrl}/sign-in`; return (
-

Zedi 管理画面

-

- 管理者としてログインするには、まずメインアプリでサインインしてください。 -

+

{t("auth.title")}

+

{t("auth.description")}

-

- サインイン後、このページに戻ってきてください。 -

+

{t("auth.afterSignIn")}

); diff --git a/admin/src/pages/ai-models/AiModelCard.tsx b/admin/src/pages/ai-models/AiModelCard.tsx index c698e1e5..a368a42d 100644 --- a/admin/src/pages/ai-models/AiModelCard.tsx +++ b/admin/src/pages/ai-models/AiModelCard.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next"; import { Button, Card, @@ -21,6 +22,7 @@ interface AiModelCardProps { /** * モバイル用リスト表示(カード形式)。ドラッグ並び替えはなし。 + * Mobile card view of an AI model row (no drag reorder support). */ export function AiModelCard({ model: m, @@ -29,6 +31,7 @@ export function AiModelCard({ onTierChange, onToggleActive, }: AiModelCardProps) { + const { t } = useTranslation(); return ( @@ -50,7 +53,7 @@ export function AiModelCard({ } }} className="h-8 text-sm" - aria-label={`${m.modelId} の表示名`} + aria-label={t("aiModels.displayNameAriaLabel", { modelId: m.modelId })} />
@@ -60,7 +63,10 @@ export function AiModelCard({ if (v === "free" || v === "pro") onTierChange(v); }} > - + diff --git a/admin/src/pages/ai-models/AiModelRow.tsx b/admin/src/pages/ai-models/AiModelRow.tsx index 48784b4b..731eb8bb 100644 --- a/admin/src/pages/ai-models/AiModelRow.tsx +++ b/admin/src/pages/ai-models/AiModelRow.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next"; import { Button, Input, @@ -44,6 +45,7 @@ export function AiModelRow({ onDrop, onDragEnd, }: AiModelRowProps) { + const { t } = useTranslation(); return ( - すべて - {KNOWN_ACTIONS.map((a) => ( - - {a.label} + {t("common.all")} + {KNOWN_ACTION_VALUES.map((value) => ( + + {t(`audit.actions.${value}`)} ))} @@ -134,7 +134,7 @@ export function AuditLogsContent({
読み込み中...

+

{t("common.loading")}

) : logs.length === 0 ? ( -

監査ログはありません。

+

{t("audit.empty")}

) : ( <>
- 日時 - 操作者 - アクション - 対象 - 変更内容 - IP + {t("audit.columns.createdAt")} + {t("audit.columns.actor")} + {t("audit.columns.action")} + {t("audit.columns.target")} + {t("audit.columns.diff")} + {t("audit.columns.ipAddress")} @@ -219,13 +219,15 @@ export function AuditLogsContent({

- {total > 0 ? `${rangeStart}-${rangeEnd}` : "0"} 件を表示 / 合計 {total} 件 + {total > 0 + ? t("common.showingRange", { rangeStart, rangeEnd, total }) + : t("common.showingZero", { total })}

{total > pageSize && (
- {page + 1} / {pageCount} ページ + {t("common.page", { page: page + 1, count: pageCount })}
diff --git a/admin/src/pages/users/SuspendDialog.tsx b/admin/src/pages/users/SuspendDialog.tsx index df16d60d..0fe66558 100644 --- a/admin/src/pages/users/SuspendDialog.tsx +++ b/admin/src/pages/users/SuspendDialog.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { useTranslation } from "react-i18next"; import { Label, Textarea } from "@zedi/ui"; import type { UserAdmin } from "@/api/admin"; import { ConfirmActionDialog } from "@/components/ConfirmActionDialog"; @@ -17,6 +18,7 @@ interface SuspendDialogProps { * Dialog for entering suspension reason before suspending a user. */ export function SuspendDialog({ user, onClose, onConfirm }: SuspendDialogProps) { + const { t } = useTranslation(); const [reason, setReason] = useState(""); const handleConfirm = () => { @@ -37,23 +39,21 @@ export function SuspendDialog({ user, onClose, onConfirm }: SuspendDialogProps)
- +