admin の編集 UI は手書きしない。ResourceSchema を1つ宣言すると、GenericForm(フォーム自動生成)と SplitView(一覧 + 編集ペイン)が一覧 / 作成 / 更新 / 削除 / CSV インポートまで供給する。
FieldSchema — 全 variant
| type | UI | 補足 |
|---|---|---|
text | 1行テキスト | required / placeholder / help |
textarea | 複数行 | rows |
svg | SVG ソース編集 + プレビュー | previewWidth / previewHeight |
markdown | Markdown エディタ | rows |
number | 数値 | min / max |
select | 単一選択 | options: SelectOption[] |
multiselect | 複数選択 | options |
boolean | トグル | |
color | カラーピッカー | |
image | アップロード(/api/admin/upload) | aspect は square / wide / free。variants で sharp 変換(第39章) |
datetime | 日時 | |
stringList | 文字列配列 | ティッカー文言等 |
array | ネスト配列(折りたたみ行) | itemFields / itemLabel / defaultItem |
全 variant 共通の装飾が FieldDecorations:
locked— 4S / システム所有の readonly フィールドlockedReason— バッジの説明文(既定「4S Platform で管理」)advanced— 折りたたみ表示(上級者向け)
シリアライズ制約: schema は RSC 境界を props で越えるため、関数を含めてはならない。defaultItem は plain object、itemLabel は "{time} — {name}" 形式のテンプレート文字列で宣言し、renderItemLabel() が展開する。この制約により docs/DB_MCP_DESIGN.md の Field Manifest(layer / writableBy)とも互換になる — locked: true ≒ layer: "core" である。
locked の二層防御
UI の readonly だけでは防御にならない(DevTools で disabled を外せる)。必ず二層で守る:
- UI 層 —
GenericFormが locked フィールドを<fieldset disabled>で包み、LOCKED バッジとlockedReasonを表示する - サーバ層 — 保存直前に
applyLockedGuard(schema, values, existing)を必ず通す
// applyLockedGuard の規則
// UPDATE: locked フィールドは existing 値で常に上書き(client 送信値を無視)
// CREATE: locked フィールドを drop(4S sync 経由でしか入らない)
// nested array(例 venues.rooms[].nameJa)も id ペアリングで再帰的に保護
const guarded = applyLockedGuard(schema, values, existing);
createCmsActions(saveSingleton / saveListItem / deleteListItem)と createCmsRouteHandlers はこのガードと validatePayload(required の最終検証)を内蔵しており、アプリ側が呼び忘れる余地がない。
GenericForm / SplitView
GenericForm は schema からフォームを生成し、dirty 追跡(未変更なら保存ボタン無効)、⌘S ショートカット、liveFooter(live 値 + 更新関数を受けてプレビュー等を差し込むスロット)を持つ。スタイルは inline + var(--accent, ...) 方式で、テーマはシェル側の CSS 変数で上書きする。
SplitView は Linear 風の list + edit ペイン:
- 左: 検索 / 追加 / CSV インポート / 一覧、右:
GenericForm - 選択は URL
?id=に保持(リロード復元・共有可) - 楽観更新: 保存中も一覧が即時反映、失敗時 rollback
- 削除後は 次 → 前 → null の順で自動選択
- server actions は callback props(
onSaveItem/onCreateItem/onDeleteItem)で注入する。package がアプリの action を import することはない
CSV / TSV 一括インポート
schema.csvImport を宣言すると SplitView に CSV ボタンが生える。Sheets からのコピペ(TSV)を想定した列マップ宣言型で、パーサ関数を書かない:
csvImport: {
help: "列順: title<TAB>slug<TAB>body",
headers: ["title", "slug", "body"], // 列順どおりのフィールド名
arrayFields: ["tags"], // カンマ区切り → 配列
numberFields: ["order"], // Number() 変換
booleanFields: ["visible"], // true/1/yes/公開 → true
}
dot-path
field の name には edition.id / socialUrls.x のようなドットパスを許す。getByPath / setByPath(clone-on-write。配列は traverse しない)が読み書きを担い、ネストした singleton 設定を平坦なフォームで編集できる。validatePayload の required 判定も同じ getByPath を使うため、UI と検証で解釈がずれない。