ガイド 第19

手動ファイン調整エディタ

@event/session-card

SessionCardDesigner@event/session-card)は ivs の /admin/session-card/[id] designer の忠実移植 + 汎用化である。Session / API 直結を外し、CardModel を編集して onChange(patch) を発行するだけの存在にした。永続化は呼び出し側の仕事で、onSave prop があるときだけ SAVE ボタンが出る。

機能一式

  • 登壇者ピックアップ & 並び替え — native HTML5 DnD + ↑↓ ボタン。allSpeakers(母集団)から show / hide を切り替え、表示中のみが model.speakers として patch される
  • タイトル / 役職の \n 明示改行上書き — textarea 1行 = カード1行
  • 倍率スライダー — 全体 scale(0.6–1.6)/ titleScale / roleScale / per-speaker nameScales[id](0.5–1.6)
  • theme / lang / variant 切替 — variant は表示人数からの recommendedVariant が基本で、明示的に多い枠(upsize)だけ選べる
  • ライブプレビューは <ScaledCard>(ResizeObserver で実測 scale。calc(100cqw / 1920px) はブラウザ互換が不安定なため JS 実測方式)

内部編集状態は mount 時の model から初期化する defaultValue 方式なので、別カードへ切り替えるときは key={model.id} で remount する

onResolved auto-prefill — 核心トリック

textarea の初期値問題: 「いまカードが実際にどう改行しているか」を近似計算で出すと、プレビューと textarea が微妙にずれる。ivs の解法はカード自身に教えさせることである。

  1. 画面外に「上書きを全部空にした純自動カード」(autoModel)をもう1枚描画する
  2. そのカードがレイアウト確定時に onResolved: (r: ResolvedCardLines) => void自分が実際に描画した行割りtitleLines / roleLines: Record<speakerId, string[]>)を親へ報告する
  3. designer はこれをユーザー未編集の textarea に流し込む。カードの実計算そのものなので、プレビューの改行と textarea が完全一致する

ユーザーが手で書き換えた行は editedRoles(Set<speakerId>)/ titleEdited で追跡し、プレフィルで潰さない。↺ ボタンで編集フラグを外すと auto カードの行割りに戻る。プレフィルは表示用であって上書きではないため、onChange は発行しない — 保存されるのは「ユーザーが編集した行」だけで、自動改行を上書きとして凍結しない。

永続化 — OverlayStore single-record PATCH

createContentOverlayStore({ store, kv? })@event/session-card/persist、server 専用 subpath)が ivs の修正済みプロトコルを実装する:

  • single-record PATCH: 対象セッション1件だけを read-modify-write する。旧実装の「配列全体を null hash で保存 → 他レコードを黙って消す」バグの根治
  • expectedHash 楽観ロック + 競合時は最新を取り直して最大 maxRetry(既定6)回リトライ
  • overlay の各フィールドは undefined = 触らない / null = 消す のセマンティクス
  • 対象フィールドは @event/schemaCARD_OVERLAY_FIELDS 登録簿駆動(content 読み込み・KV ミラーと同じ集合。ドリフト不能)
  • 成功後、全 overlay を KV(key cms:session-overlay、TTL 15分)へミラーし、ISR を待たず公開側へ即時反映する。KV 失敗は握りつぶす(content には保存済み。リビルドで反映される)

cardSpeakerIds のタイムスタンプ規則

last-writer-wins(第6章)の手動側基準 cardSpeakerIdsUpdatedAt は、値が実際に変わった時だけサーバ時刻を刻む。同じ並びを保存し直しただけで時刻が更新されると、4S 側の新しい lineup 編集を不当に打ち負かすためである。選択をクリアした場合はタイムスタンプごと削除する。

starter の配線例

// CardStudio.tsx — CardModel → CardOverlay の写像(lang で JP/EN どちらへ書くか分岐)
function modelToOverlay(model: CardModel): CardOverlay {
  const isEn = model.lang === "en";
  return {
    cardVariant: model.variant,
    cardTheme: model.theme,
    cardSpeakerIds: model.speakers.map((s) => s.id),
    cardScale: model.scale,
    cardTitleScale: model.titleScale,
    cardRoleScale: model.roleScale,
    cardNameScales: model.nameScales,
    ...(isEn
      ? { cardTitleEn: model.titleOverride ?? null, cardRolesEn: model.roleOverrides }
      : { cardTitle: model.titleOverride ?? null, cardRoles: model.roleOverrides }),
  } as CardOverlay;
}

server action 側は adminAuth.isAuthedFromValue(cookie) を再検査してから overlayStore.patch(sessionId, overlay) を呼ぶ。ページ側は listSessionsFromEvent(config, { overlay }) に content JSON の overlay を注入し、resolveCardModel(session, speakers) で描画用 model を組む。