Playbook

Admin Content Inventory と Actions を作る

MDXコンテンツの公開状態を一覧管理し、status変更を安全に行う管理画面を5フェーズで構築する手順。

admin-uicontent-modeloperations

このPlaybookで作るもの

Admin Content Inventory は、sample / playbook / build の公開状態を一覧で確認し、status を変更できる管理画面です。

  • MDX frontmatter を集約して公開状態を一覧表示する
  • 導線情報(Demo / 外部 Demo / Repo)を型別に表示する
  • type・status・公開状態でフィルタ・ソートする
  • status select + 保存ボタンで frontmatter を直接更新する
  • gray-matter の parse/stringify で配列やネストを安全に保つ

完成例

完成例は /admin/content で確認できます(要管理者ログイン)。

5 つのフェーズに分けて構築します。

フェーズゴール
Phase 1content inventory を作る
Phase 2公開状態と導線情報を表示する
Phase 3検索・フィルタ・ソートを追加する
Phase 4status 変更 UI を追加する
Phase 5gray-matter で frontmatter を安全に更新する

最終的なファイル構成は次のとおりです。

ファイル役割
src/lib/admin/content-inventory.ts全コンテンツを集約する型と関数
src/lib/admin/content-inventory-query.tsクエリのパース・フィルタ・ソート・URL 生成
src/app/admin/(protected)/content/page.tsx一覧ページ(サーバーコンポーネント)
src/app/admin/(protected)/content/actions.tsstatus 更新 Server Action
src/components/admin/ContentRowActions.tsxstatus select + 保存ボタン(クライアント)
src/components/admin/AdminContentFilters.tsx検索・フィルタ UI(クライアント)

Phase 1: content inventory を作る

ゴール: sample / playbook / build の frontmatter を集約して、型付きの一覧データを返す関数を作る。

ContentInventoryItem 型

3 種類のコンテンツを統一して扱うための型を定義します。type フィールドで種別を区別し、build 固有の導線フィールドは optional にします。

// src/lib/admin/content-inventory.ts
export type ContentType = "sample" | "playbook" | "build";

export type ContentInventoryItem = {
  type:                ContentType;
  title:               string;
  slug:                string;
  status:              string;
  isPublic:            boolean;
  updatedAt:           string;
  publicPath:          string;
  sourcePath:          string;
  tags?:               string[];
  demoPath?:           string;
  externalDemoUrl?:    string;
  repoUrl?:            string;
  repoIsPrivate?:      boolean;
  relatedPlaybookCount?: number;
};

getContentInventory 関数

各ローダーを呼び出して型に詰め替え、結合して返します。sample の isPublic は hidden-slugs.json も参照します。

export function getContentInventory(): ContentInventoryItem[] {
  const hiddenSlugs = new Set(getHiddenSlugs());

  const samples = getAllSamples().map((s) => ({
    type:      "sample" as const,
    title:     s.title,
    slug:      s.slug,
    status:    s.status,
    isPublic:  s.status === "reviewed" && !hiddenSlugs.has(s.slug),
    updatedAt: s.updatedAt,
    publicPath:  `/samples/${s.slug}`,
    sourcePath:  `content/samples/${s.slug}.mdx`,
  }));

  const playbooks = getAllPlaybooks().map((p) => ({
    type:     "playbook" as const,
    // ...
    isPublic: p.status === "reviewed",
  }));

  const builds = getAllBuilds().map((b) => ({
    type:             "build" as const,
    // ...
    isPublic:         b.status === "reviewed",
    demoPath:         b.demoPath,
    externalDemoUrl:  b.externalDemoUrl,
    repoUrl:          b.repo?.url,
    repoIsPrivate:    b.repo?.isPrivate,
    relatedPlaybookCount: b.relatedPlaybooks.length,
  }));

  return [...samples, ...playbooks, ...builds].sort(/* type → updatedAt */);
}

Phase 1 完了条件: getContentInventory() が全コンテンツを返す。型エラーがない。


Phase 2: 公開状態と導線情報を表示する

ゴール: inventory データをテーブルに表示し、status / 公開状態 / 導線情報を視覚的に区別する。

サマリーカード

画面上部に 4 つの集計カードを表示します。フィルタの結果ではなく全件を対象にします。

カード内容
Total全コンテンツ件数
PublicisPublic === true の件数
Review requiredstatus === "review_required" の件数
Hidden / Otherhidden・draft・deprecated の件数

StatusBadge

status ごとに色を変えてラベルを表示します。

const STATUS_STYLE: Record<string, string> = {
  reviewed:        "bg-green-100 text-green-700",
  review_required: "bg-yellow-100 text-yellow-700",
  draft:           "bg-gray-100 text-gray-500",
  hidden:          "bg-gray-100 text-gray-500",
  deprecated:      "bg-red-100 text-red-600",
};

LinksBadge(build 専用)

build の導線情報を小さなバッジで列挙します。repo.isPrivate が true のときは「Private Repo」と表示し、リンクではなく情報として提示します。

const badges = [
  item.demoPath         && { label: "内部Demo" },
  item.externalDemoUrl  && { label: "外部Demo" },
  item.repoUrl          && { label: item.repoIsPrivate ? "Private Repo" : "GitHub" },
  (item.relatedPlaybookCount ?? 0) > 0 && { label: "Playbook" },
].filter(Boolean);

Phase 2 完了条件: テーブルに全コンテンツが表示され、status バッジ・公開バッジ・LinksBadge が正しく表示される。


Phase 3: 検索・フィルタ・ソートを追加する

ゴール: type / status / 公開状態で絞り込め、タイトル・slug・パスでキーワード検索できるようにする。

クエリ型

URL クエリを型付きの構造体に変換します。不正な値はデフォルトにフォールバックします。

// src/lib/admin/content-inventory-query.ts
export type AdminContentInventoryQuery = {
  q?:     string;
  type:   "all" | "sample" | "playbook" | "build";
  status?: string;
  public: "all" | "public" | "private";
  sort:   "updated_desc" | "updated_asc" | "type_asc" | "status_asc" | "title_asc";
};

フィルタ関数

各条件を AND で適用します。q はタイトル・slug・sourcePath・publicPath の 4 フィールドを対象にします。

export function filterAdminContentInventory(
  items: ContentInventoryItem[],
  query: AdminContentInventoryQuery
): ContentInventoryItem[] {
  return items.filter((item) => {
    if (query.type !== "all"         && item.type    !== query.type)   return false;
    if (query.status                 && item.status  !== query.status) return false;
    if (query.public === "public"    && !item.isPublic)                return false;
    if (query.public === "private"   &&  item.isPublic)                return false;
    if (query.q) {
      const q = query.q.toLowerCase();
      const hit =
        item.title.toLowerCase().includes(q)      ||
        item.slug.toLowerCase().includes(q)       ||
        item.sourcePath.toLowerCase().includes(q) ||
        item.publicPath.toLowerCase().includes(q);
      if (!hit) return false;
    }
    return true;
  });
}

sort 関数

5 種類のソートに対応します。status ソートはレビュー優先の数値マップで実装します。

const STATUS_ORDER = {
  reviewed: 0, review_required: 1, draft: 2, hidden: 3, deprecated: 4,
};

URL 生成

デフォルト値(type: "all", public: "all", sort: "updated_desc")はクエリから省略して URL を短くします。

export function buildAdminContentInventoryHref(options): string {
  // ...省略値を削除した URL を返す
  return `/admin/content${qs ? `?${qs}` : ""}`;
}

Phase 3 完了条件: type / status / 公開状態フィルタ・q 検索・5 種ソートが動作する。active chip で条件を個別解除できる。


Phase 4: status 変更 UI を追加する

ゴール: 各行に status select + 保存ボタンを置き、保存成功・失敗をインラインで表示する。

ContentRowActions コンポーネント

クライアントコンポーネントで 3 つの状態を管理します。

// src/components/admin/ContentRowActions.tsx
"use client";

const SAMPLE_STATUSES   = ["draft", "review_required", "reviewed", "deprecated"] as const;
const BUILD_PB_STATUSES = ["draft", "review_required", "reviewed", "deprecated", "hidden"] as const;

export function ContentRowActions({ type, slug, currentStatus }) {
  const [selectedStatus, setSelectedStatus] = useState(currentStatus);
  const [error,  setError]  = useState<string | null>(null);
  const [saved,  setSaved]  = useState(false);
  const [isPending, startTransition] = useTransition();

  const handleSelect = (value: string) => {
    setSelectedStatus(value);
    setSaved(false);        // 選択変更で成功表示をリセット
  };

  const handleSave = () => {
    setError(null);
    setSaved(false);
    startTransition(async () => {
      const result = await updateStatusAction(type, slug, selectedStatus);
      if (result.error) {
        setError(result.error);
      } else {
        setSaved(true);
        router.refresh();
      }
    });
  };

成功表示とエラー表示は排他的です。保存を開始するタイミングで両方をリセットし、結果に応じてどちらか一方をセットします。

  return (
    <div className="flex flex-col gap-1.5">
      <div className="flex gap-1">
        <select value={selectedStatus} onChange={(e) => handleSelect(e.target.value)}>
          {statuses.map((s) => <option key={s} value={s}>{s}</option>)}
        </select>
        <button onClick={handleSave} disabled={isPending || selectedStatus === currentStatus}>
          {isPending ? "…" : "保存"}
        </button>
      </div>
      {error && <span className="text-xs text-red-500">{error}</span>}
      {saved && <span className="text-xs text-green-600">保存しました</span>}
    </div>
  );
}

sample と playbook / build で許可 status のリストを分けます。sample は hidden を持たず、hidden は status で管理します。

Phase 4 完了条件: status を選択して保存ボタンを押すと「保存しました」が表示され、再選択すると消える。エラー時はエラーメッセージが表示される。


Phase 5: gray-matter で frontmatter を安全に更新する

ゴール: Server Action で MDX ファイルの frontmatter を安全に更新し、キャッシュを無効化する。

Server Action の全体像

updateStatusAction は slug 検証・status 検証・ファイル探索・書き換え・revalidate の順に処理します。

// src/app/admin/(protected)/content/actions.ts
"use server";

const CONTENT_DIRS = {
  sample:   path.join(process.cwd(), "content/samples"),
  playbook: path.join(process.cwd(), "content/playbooks"),
  build:    path.join(process.cwd(), "content/builds"),
} as const;

const PUBLIC_PATHS: Record<ContentType, string> = {
  sample:   "/samples",
  playbook: "/playbooks",
  build:    "/builds",
};

const SLUG_RE = /^[a-z0-9-]+$/;

path traversal 対策

ファイルを探す前に slug を正規表現で検証し、解決後のパスが想定ディレクトリの外に出ていないかを確認します。

function findContentFile(type: ContentType, slug: string): string | null {
  const dir = CONTENT_DIRS[type];
  const dirResolved = path.resolve(dir);

  for (const filePath of collectMdxFiles(dir)) {
    const resolved = path.resolve(filePath);
    if (!resolved.startsWith(dirResolved + path.sep)) continue; // パスが外に出ていたらスキップ

    const { data } = matter(fs.readFileSync(filePath, "utf-8"));
    if (data.slug === slug) return filePath;
  }
  return null;
}

SLUG_RE../ のような相対パスを弾き、path.resolve + startsWith はシンボリックリンクや .. による上位ディレクトリ到達を防ぎます。

gray-matter による安全な書き換え

regex replace は frontmatter の位置やクォートスタイルに依存します。gray-matter の parse/stringify を使うことで、配列・ネストオブジェクト・本文を壊さずに特定フィールドだけを更新できます。

const raw    = fs.readFileSync(filePath, "utf-8");
const parsed = matter(raw);
parsed.data.status = newStatus;
const updated = matter.stringify(parsed.content, parsed.data);
fs.writeFileSync(filePath, updated, "utf-8");

parsed.content は frontmatter を除いた MDX 本文です。matter.stringify は js-yaml の dump を使うため、YAML のクォートスタイルが cosmetic に変わる場合がありますが、Zod バリデーションは通過します。

revalidatePath

Server Action でファイルを更新しても、Next.js がページをキャッシュしているとブラウザには古い内容が表示されたままになります。2 パスを明示的に無効化します。

revalidatePath("/admin/content");      // 管理画面に最新 status を反映
revalidatePath(PUBLIC_PATHS[type]);    // 公開ページにも反映(status 変更で公開/非公開が変わる場合)

PUBLIC_PATHS マップを使うことで、/${type}s のような文字列連結を避けて型安全を保ちます。

Phase 5 完了条件: updateStatusAction でファイルの status が更新され、管理画面と公開ページに即時反映される。


設計判断まとめ

reviewed のみ公開にする理由review_required を暫定公開すると、レビュー前のコンテンツが公開されたまま忘れられるリスクがあります。公開は reviewed への昇格という明示的な操作が必要な設計にすることで、意図しない公開を防ぎます。

status: hidden に一本化する理由hidden: true という別フィールドを用意すると、公開判定が status === "reviewed" && !hidden になり、2 つの条件を同期させる必要が生じます。status: hidden に一本化すると公開判定は status === "reviewed" のみになり、Admin Actions の UI もシンプルになります。

demoPath / externalDemoUrl / repo.url の役割の違いdemoPath は TOOLS BOX 内の route(/tasks など)、externalDemoUrl は外部にデプロイした Playground の URL、repo.url はコードリポジトリへの導線です。3 つを同じフィールドにまとめると表示の出し分けができなくなるため、別フィールドで管理します。

repo.isPrivate を frontmatter で持つ理由。private リポジトリの URL をリンクとして表示するとアクセスできない画面に誘導してしまいます。isPrivate: true を設定すると「Private Repo」という非リンク表示に切り替わり、URL は保持したまま不正な誘導を避けられます。GitHub API による自動判定はビルド時の外部依存を増やすため、frontmatter で明示管理します。

Server Action で status 変更する理由。管理画面はすでに認証済みセッションで保護されており、API route を別途設ける必要がありません。Server Action は "use server" ディレクティブによりサーバー側でのみ実行されるため、クライアントから直接呼び出しても安全です。

path traversal を 2 段階で防ぐ理由SLUG_RE だけでは slug が DB 由来や URL クエリから来た場合に不十分なケースがあります。path.resolve + startsWith を組み合わせることで、slug が正当でも content/samples/../../.env のようなシンボリックリンク経由のアクセスを防ぎます。

revalidatePath が必要な理由。Next.js のサーバーコンポーネントはビルド時またはリクエスト時にキャッシュされます。Server Action でファイルを書き換えてもキャッシュが残っていると古い status が表示されます。revalidatePath でそのパスのキャッシュを明示的に無効化することで、次のリクエストで最新データが表示されます。


次に足せるもの

  • sample 専用の非表示管理 — 既存の hidden-slugs.json を維持する場合のみ、sample 向けに別タスクとして扱う
  • 一括 status 変更 — チェックボックスで複数選択し、一括で status を変更する
  • updatedAt 自動更新 — status 変更時に frontmatter の updatedAt を今日の日付に書き換える
  • 変更履歴 — status 変更のログを管理画面内で確認できるようにする