Skip to the content

Capsid menu

app/routes/editor.tsx

556 lines and 51 functions in DrDustinEdwards/carrel, at commit d5d5e82 on 2026-10-24. This file on GitHub.

Functions

The functions of editor.tsx
FunctionLinesComplexityCoveredCRAPMutation
meta53 to 5530%WatchCRAP 12No data: mutation testing has not run on this repo yet
loader57 to 1493889%CriticalCRAP 39.8No data: mutation testing has not run on this repo yet
<anonymous:108>108 to 1082100%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:109>109 to 1092100%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:134>134 to 1341100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:138>138 to 13810%OkCRAP 2No data: mutation testing has not run on this repo yet
action159 to 2292948%CriticalCRAP 149.3No data: mutation testing has not run on this repo yet
shouldRevalidate232 to 23520%WatchCRAP 6No data: mutation testing has not run on this repo yet
EditorRoute237 to 24010%OkCRAP 2No data: mutation testing has not run on this repo yet
utc242 to 242150%OkCRAP 1.1No data: mutation testing has not run on this repo yet
Editor244 to 594750%CriticalCRAP 5700No data: mutation testing has not run on this repo yet
<anonymous:265>265 to 27340%WatchCRAP 20No data: mutation testing has not run on this repo yet
<anonymous:267>267 to 27020%WatchCRAP 6No data: mutation testing has not run on this repo yet
<anonymous:271>271 to 27110%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:276>276 to 28340%WatchCRAP 20No data: mutation testing has not run on this repo yet
<anonymous:287>287 to 29320%WatchCRAP 6No data: mutation testing has not run on this repo yet
<anonymous:297>297 to 30320%WatchCRAP 6No data: mutation testing has not run on this repo yet
<anonymous:307>307 to 31330%WatchCRAP 12No data: mutation testing has not run on this repo yet
send321 to 32510%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:322>322 to 32520%WatchCRAP 6No data: mutation testing has not run on this repo yet
<anonymous:326>326 to 33240%WatchCRAP 20No data: mutation testing has not run on this repo yet
writeThrough335 to 34130%WatchCRAP 12No data: mutation testing has not run on this repo yet
<anonymous:345>345 to 34990%CriticalCRAP 90No data: mutation testing has not run on this repo yet
<anonymous:352>352 to 35430%WatchCRAP 12No data: mutation testing has not run on this repo yet
<anonymous:358>358 to 35820%WatchCRAP 6No data: mutation testing has not run on this repo yet
<anonymous:374>374 to 38310%OkCRAP 2No data: mutation testing has not run on this repo yet
ask386 to 38610%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:427>427 to 42710%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:427>427 to 4271100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:429>429 to 42920%WatchCRAP 6No data: mutation testing has not run on this repo yet
figure430 to 43010%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:442>442 to 44210%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:484>484 to 48410%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:489>489 to 48910%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:494>494 to 49410%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:499>499 to 49910%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:502>502 to 50210%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:509>509 to 50910%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:513>513 to 51310%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:528>528 to 52810%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:529>529 to 52910%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:531>531 to 53110%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:557>557 to 55710%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:558>558 to 55810%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:567>567 to 56710%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:568>568 to 56810%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:574>574 to 57410%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:577>577 to 57920%WatchCRAP 6No data: mutation testing has not run on this repo yet
<anonymous:580>580 to 58010%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:586>586 to 58610%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:587>587 to 59010%OkCRAP 2No data: mutation testing has not run on this repo yet

Imports

It imports app/components/editor/flags-panel.tsx, app/components/editor/frontmatter-fields.tsx, app/components/editor/layout.ts, app/components/editor/save-keys.ts, app/components/editor/schedule-dialog.tsx, app/components/editor/typing.ts, app/components/editor/writing-surface.tsx, app/components/frame.tsx, app/components/history/history-panel.tsx, app/components/writing/binder.tsx, app/lib/ai.server.ts, app/lib/content.server.ts, app/lib/context.ts, app/lib/google/drive.server.ts, app/lib/google/oauth.server.ts, app/lib/google/search-console.server.ts, app/lib/google/service-account.server.ts, app/lib/history.server.ts, app/lib/index.server.ts, app/lib/kind-words.ts, app/lib/legal.server.ts, app/lib/media.server.ts, app/lib/projects.server.ts, app/lib/roles.ts, app/lib/save-state.ts, app/lib/site-markdown.ts, app/lib/sites.server.ts, app/lib/writing/binder.ts. No module of this repo imports it.

Source

// The editor for one site item: the copied CodeMirror editor in a prose layout, autosave to D1 as
// you type, the site's own render in a sandboxed preview, and the writes the viewer's role allows.

import { useEffect, useRef, useState } from "react";
import { useFetcher, type ShouldRevalidateFunctionArgs } from "react-router";
import { Alert, Banner } from "capsomer/react/banner";
import { Button } from "capsomer/react/button";
import { ConfirmDialog } from "capsomer/react/confirm-dialog";
import { useMessage } from "capsomer/react/message";
import { Panel } from "capsomer/react/panel";
import { PublishGate, type GateCheck, type GateSite } from "capsomer/react/publish-gate";
import { Segmented } from "capsomer/react/segmented";
import { Pill } from "capsomer/react/status";
import { Time } from "capsomer/react/time";

import { FlagsPanel } from "~/components/editor/flags-panel";
import { useEditorLayout } from "~/components/editor/layout";
import { ScheduleDialog } from "~/components/editor/schedule-dialog";
import { FrontmatterFields } from "~/components/editor/frontmatter-fields";
import { AUTOSAVE_DELAY_MS, useSaveShortcut } from "~/components/editor/save-keys";
import { HistoryPanel } from "~/components/history/history-panel";
import { WritingSurface } from "~/components/editor/writing-surface";
import { useTypingRecede } from "~/components/editor/typing";
import { RouterLink } from "~/components/frame";
import { SiteBinder } from "~/components/writing/binder";
import { dismissItemFinding, itemFindings, lastAiPublication, listAiDrafts, publishedByLine } from "~/lib/ai.server";
import { autosave, discardDraft, readDoc, readDraft, writeToSite, type WriteOutcome } from "~/lib/content.server";
import { getEnv, getViewer } from "~/lib/context";
import { listRevisions } from "~/lib/history.server";
import { joinSource, splitSource } from "@dustinedwards/site-api/frontmatter";
import { listSections } from "~/lib/legal.server";
import { kindNoun, kindWord } from "~/lib/kind-words";
import { siteState } from "~/lib/save-state";
import { importFromDocs, lastSentDoc, sendToDocs } from "~/lib/google/drive.server";
import { isConnected } from "~/lib/google/oauth.server";
import { pageStats } from "~/lib/google/search-console.server";
import { GoogleNotConnected } from "~/lib/google/service-account.server";
import { searchItems, sitePaths } from "~/lib/index.server";
import { mediaLimits } from "~/lib/media.server";
import { requireSiteProject } from "~/lib/projects.server";
import { transitionsFor } from "capsomer/publish-transition";
import { PageHead } from "capsomer/react/page-head";
import { can } from "~/lib/roles";
import { figureMarkup } from "~/lib/site-markdown";
import { siteConnection, siteEntry, SiteNotConnected } from "~/lib/sites.server";
import { siteBinderTree } from "~/lib/writing/binder";

import type { Route } from "./+types/editor";

/** The editor shows the newest revisions; the history page lists them all. */
const HISTORY_SHOWN = 8;

export function meta({ loaderData: data }: Route.MetaArgs) {
  return [{ title: data ? `${data.title || data.itemId} ยท Carrel` : "Carrel" }];
}

export async function loader({ params, context }: Route.LoaderArgs) {
  const env = getEnv(context);
  const viewer = getViewer(context);
  const project = await requireSiteProject(env.DB, viewer, params.project, "read");
  const itemId = params.item;

  const draft = await readDraft(env.DB, project, viewer, itemId);
  let doc = null;
  let siteError: string | null = null;
  try {
    doc = await readDoc(env, project, itemId);
  } catch (error) {
    siteError = error instanceof SiteNotConnected ? error.detail : "The site did not answer, so this shows your Carrel draft only.";
  }
  // An item that exists only as an AI draft is still the person's to open: the list shows it.
  const aiDrafts = await listAiDrafts(env.DB, project, viewer, itemId);
  if (!doc && !draft && aiDrafts.length === 0 && !siteError) throw new Response("Not found", { status: 404 });

  // The site's revisions of this item, for the History panel. A site that does not answer leaves the
  // panel saying so, never the editor failing.
  let revisions: Awaited<ReturnType<typeof listRevisions>> = null;
  let historyError: string | null = null;
  if (doc) {
    try {
      revisions = await listRevisions(env, project, itemId);
    } catch (error) {
      historyError = error instanceof SiteNotConnected ? error.detail : "The site did not answer, so its history is not shown.";
    }
  }

  const [targets, flags, aiPublished, sentDoc, googleConnected, search, sections] = await Promise.all([
    searchItems(env.DB, project.id, {}),
    itemFindings(env.DB, project, itemId),
    lastAiPublication(env.DB, project, itemId),
    lastSentDoc(env.DB, project, itemId),
    can(project.role, "send_external") ? isConnected(env.DB, viewer) : Promise.resolve(false),
    pageStats(env, project, doc?.path ?? null),
    listSections(env.DB),
  ]);
  // Images go in only for someone who may edit, and only on a site with a media library. A site that
  // does not answer leaves the editor without them rather than failing the page.
  let mediaAccept: string | null = null;
  if (can(project.role, "edit") && !siteError) {
    try {
      mediaAccept = (await mediaLimits(env, project))?.types.join(",") ?? null;
    } catch {
      mediaAccept = null;
    }
  }
  const paths = sitePaths(targets);
  const linkTargets: { href: string; title: string; state: "published" | "scheduled" | "draft" }[] = targets
    .filter((t) => t.itemId !== itemId && paths.has(t.itemId))
    .map((t) => ({ href: paths.get(t.itemId)!, title: t.title || t.itemId, state: t.status }));

  return {
    project: { slug: project.slug, name: project.name, site: siteEntry(project.site).name, siteId: project.site },
    itemId,
    binder: siteBinderTree(targets, `/p/${project.slug}`),
    canEdit: can(project.role, "edit"),
    media: mediaAccept ? { endpoint: `/p/${encodeURIComponent(project.slug)}/media/api`, accept: mediaAccept } : null,
    canPublish: can(project.role, "publish"),
    title: doc?.title ?? "",
    kind: doc?.kind ?? null,
    status: doc?.status ?? null,
    path: doc?.path ?? null,
    everPublished: Boolean(doc?.publishedAt),
    publishedAt: doc?.publishedAt ?? null,
    // Where the post lives, for the publish controls' View link: the site's origin and the post's path.
    liveUrl: doc?.path && siteConnection(env, project.site).state === "connected" ? new URL(doc.path, (siteConnection(env, project.site) as { origin: string }).origin).href : null,
    publishAt: doc?.publishAt ?? null,
    version: doc?.version ?? null,
    source: draft?.source ?? doc?.source ?? "",
    baseVersion: draft ? draft.baseVersion : (doc?.version ?? null),
    draftAt: draft?.updatedAt ?? null,
    // The site moved on since this draft was started: saving it would be refused as stale.
    behind: Boolean(draft && doc && draft.baseVersion !== doc.version),
    siteError,
    revisions: revisions ? revisions.slice(0, HISTORY_SHOWN).map((r) => ({ version: r.version, at: r.at, author: r.author, message: r.message })) : null,
    revisionCount: revisions?.length ?? 0,
    historyError,
    linkTargets,
    sections: sections.map((s) => ({ key: s.key, title: s.title })),
    flags,
    aiDrafts,
    aiPublished: aiPublished ? { line: publishedByLine(aiPublished.client), at: aiPublished.publishedAt } : null,
    canSend: can(project.role, "send_external"),
    googleConnected,
    sentDoc: sentDoc ? { url: sentDoc.url, at: sentDoc.createdAt } : null,
    search: search
      ? { clicks: search.clicks, impressions: search.impressions, position: Math.round(search.position * 10) / 10, start: search.startDate, end: search.endDate }
      : null,
  };
}

type ActionResult =
  | { intent: "autosave"; updatedAt: string }
  | { intent: "discard"; discarded: true }
  | { intent: "publish"; needsConfirm: true }
  | { intent: "dismiss-flag"; dismissed: number }
  | { intent: "write"; outcome: WriteOutcome }
  | { intent: "google"; ok: boolean; message: string; url?: string; imported?: boolean };

export async function action({ params, request, context }: Route.ActionArgs): Promise<ActionResult> {
  const env = getEnv(context);
  const viewer = getViewer(context);
  const project = await requireSiteProject(env.DB, viewer, params.project, "read");
  const itemId = params.item;
  const form = await request.formData();
  const intent = String(form.get("intent") ?? "");
  const source = String(form.get("source") ?? "").replace(/\r\n/g, "\n");
  const version = String(form.get("expectedVersion") ?? "") || null;

  switch (intent) {
    case "autosave": {
      const saved = await autosave(env.DB, project, viewer, itemId, { source, baseVersion: version });
      return { intent, updatedAt: saved.updatedAt };
    }
    case "discard":
      await discardDraft(env.DB, project, viewer, itemId);
      return { intent, discarded: true };
    case "dismiss-flag": {
      const id = Number(form.get("flag"));
      if (!Number.isInteger(id)) throw new Response("Bad request", { status: 400 });
      await dismissItemFinding(env.DB, project, viewer, itemId, id);
      return { intent, dismissed: id };
    }
    case "send-to-docs":
    case "import-from-docs": {
      try {
        if (intent === "send-to-docs") {
          const sent = await sendToDocs(env, project, viewer, itemId);
          return sent.ok ? { intent: "google", ok: true, message: "Sent to Google Docs.", url: sent.url } : { intent: "google", ok: false, message: sent.message };
        }
        const imported = await importFromDocs(env, project, viewer, itemId);
        return imported.ok
          ? { intent: "google", ok: true, imported: true, message: `Imported ${imported.words} words from Google Docs as your draft.` }
          : { intent: "google", ok: false, message: imported.message };
      } catch (error) {
        if (error instanceof GoogleNotConnected) return { intent: "google", ok: false, message: error.detail };
        throw error;
      }
    }
    case "save":
      return { intent: "write", outcome: await writeToSite(env, project, viewer, itemId, { action: "save", source, expectedVersion: version }) };
    case "publish": {
      // Refused before the ceremony, so a person who may not publish is never asked to confirm.
      if (!can(project.role, "publish")) throw new Response("Forbidden", { status: 403 });
      if (!version) throw new Response("Save the post to the site before publishing it.", { status: 400 });
      const current = await readDoc(env, project, itemId);
      // The site's ceremony, kept: a post that has never been public is published only on a second, explicit ask.
      if (current && !current.publishedAt && form.get("confirm") !== "first-publish") return { intent, needsConfirm: true };
      return { intent: "write", outcome: await writeToSite(env, project, viewer, itemId, { action: "publish", source, expectedVersion: version }) };
    }
    case "schedule": {
      const publishAt = String(form.get("publishAt") ?? "");
      if (!version || Number.isNaN(Date.parse(publishAt))) throw new Response("A schedule needs a saved post and a time.", { status: 400 });
      return {
        intent: "write",
        outcome: await writeToSite(env, project, viewer, itemId, {
          action: "schedule",
          source,
          expectedVersion: version,
          publishAt: new Date(publishAt).toISOString(),
        }),
      };
    }
    case "unpublish":
      if (!version) throw new Response("Only a post on the site can be unpublished.", { status: 400 });
      return { intent: "write", outcome: await writeToSite(env, project, viewer, itemId, { action: "unpublish", expectedVersion: version }) };
    default:
      throw new Response("Bad request", { status: 400 });
  }
}

/** Autosave writes only the viewer's own draft, so the page has nothing to reload, and the site is not asked. */
export function shouldRevalidate({ formData, defaultShouldRevalidate }: ShouldRevalidateFunctionArgs) {
  if (formData?.get("intent") === "autosave") return false;
  return defaultShouldRevalidate;
}

export default function EditorRoute({ loaderData }: Route.ComponentProps) {
  // A new item gets a fresh editor, not the last item's cursor and history.
  return <Editor key={`${loaderData.project.slug}/${loaderData.itemId}`} data={loaderData} />;
}

const utc = (iso: string) => `${iso.slice(0, 16).replace("T", " ")} UTC`;

function Editor({ data }: { data: Route.ComponentProps["loaderData"] }) {
  const [source, setSource] = useState(data.source);
  const [savedSource, setSavedSource] = useState(data.source);
  const [savedAt, setSavedAt] = useState<string | null>(data.draftAt);
  const [layout, chooseLayout] = useEditorLayout();
  const [asking, setAsking] = useState<null | { kind: "discard" | "import" | "schedule" | "dismiss"; flag?: number; opener: HTMLElement | null }>(null);
  const autosaver = useFetcher<ActionResult>();
  const writer = useFetcher<ActionResult>();
  const { say } = useMessage();
  useTypingRecede();

  const readOnly = !data.canEdit;
  const live = data.status === "published" || data.status === "scheduled";
  const onSite = data.version !== null;
  // Changes to a live post change the public site, which is the Owner's to do.
  const maySave = data.canEdit && (!live || data.canPublish);


  // Autosave a beat after typing stops. The base version travels with it, so a later save is checked
  // against the version this draft started from.
  const inFlight = useRef<string | null>(null);
  useEffect(() => {
    if (readOnly || source === savedSource || autosaver.state !== "idle") return;
    const timer = window.setTimeout(() => {
      inFlight.current = source;
      void autosaver.submit({ intent: "autosave", source, expectedVersion: data.baseVersion ?? "" }, { method: "post" });
    }, AUTOSAVE_DELAY_MS);
    return () => window.clearTimeout(timer);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [source, savedSource, readOnly, data.baseVersion, autosaver.state]);

  // Counted as saved only once the server said so; a failed autosave leaves the text unsaved and retried.
  useEffect(() => {
    if (autosaver.state !== "idle" || inFlight.current === null) return;
    if (autosaver.data?.intent === "autosave") {
      setSavedSource(inFlight.current);
      setSavedAt(autosaver.data.updatedAt);
    }
    inFlight.current = null;
  }, [autosaver.state, autosaver.data]);

  // After a discard, the loader has the site's text again, which replaces the local copy.
  const discarded = writer.data?.intent === "discard" && writer.state === "idle";
  useEffect(() => {
    if (discarded) {
      setSource(data.source);
      setSavedSource(data.source);
      setSavedAt(null);
    }
  }, [discarded, data.source]);

  // After an import, the loader has the imported draft, which replaces the local copy.
  const imported = writer.data?.intent === "google" && writer.data.imported === true && writer.state === "idle";
  useEffect(() => {
    if (imported) {
      setSource(data.source);
      setSavedSource(data.source);
      setSavedAt(data.draftAt);
    }
  }, [imported, data.source, data.draftAt]);

  // A write that reached the site clears the draft there, so the local copy counts as saved.
  const outcome = writer.data?.intent === "write" ? writer.data.outcome : null;
  useEffect(() => {
    if (outcome?.ok && writer.state === "idle") {
      setSavedSource(source);
      setSavedAt(null);
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [outcome, writer.state]);

  // What the controls send is read at the moment they send it: the publish controls keep an Undo that
  // runs later, and it must carry the version the site has then, not the one this render saw.
  const latest = useRef({ source, version: data.baseVersion });
  latest.current = { source, version: data.baseVersion };
  const waiting = useRef<((result: ActionResult) => void) | null>(null);
  const viaGate = useRef(false);
  const send = (intent: string, extra: Record<string, string> = {}) =>
    new Promise<ActionResult>((resolve) => {
      waiting.current = resolve;
      void writer.submit({ intent, source: latest.current.source, expectedVersion: latest.current.version ?? "", ...extra }, { method: "post" });
    });
  useEffect(() => {
    if (writer.state === "idle" && writer.data && waiting.current) {
      const done = waiting.current;
      waiting.current = null;
      done(writer.data);
    }
  }, [writer.state, writer.data]);

  // The publish controls' two verbs. A rejection carries the site's own words, which the gate says.
  const writeThrough = async (intent: string, extra: Record<string, string> = {}) => {
    viaGate.current = true;
    const result = await send(intent, extra);
    viaGate.current = false;
    if (result.intent !== "write") throw new Error("The site did not answer.");
    if (!result.outcome.ok) throw new Error(result.outcome.message);
  };

  // Results of the buttons Carrel owns are said in the message region; the gate says its own.
  const said = useRef<unknown>(null);
  useEffect(() => {
    if (writer.state !== "idle" || !outcome || said.current === outcome || viaGate.current) return;
    said.current = outcome;
    if (outcome.ok) say(outcome.action === "save" ? "Saved to the site." : outcome.action === "schedule" ? "Scheduled." : outcome.action === "publish" ? "Published." : "Returned to draft.");
  }, [outcome, writer.state, say]);

  // Cmd+S saves in place: a draft stays a draft, a live post is updated.
  useSaveShortcut(() => {
    if (maySave && writer.state === "idle") void send("save");
  });

  const busy = writer.state !== "idle";
  const transitions = data.status ? transitionsFor(data.status === "draft" ? "draft" : "published", data.everPublished) : [];
  const saveTransition = transitions.find((t) => t.id === "save-draft" || t.id === "save");
  const saveState =
    autosaver.state !== "idle" ? "Saving to Carrel" : source !== savedSource ? "Not yet saved" : savedAt ? `Draft saved in Carrel ${new Date(savedAt).toLocaleTimeString()}` : onSite ? siteState(data.status, data.everPublished) : "";
  // Absolute, not relative: from /p/x/e/item a bare "preview" would resolve to /p/x/e/preview.
  const previewSrc = `/p/${encodeURIComponent(data.project.slug)}/e/${encodeURIComponent(data.itemId)}/preview?at=${encodeURIComponent(savedAt ?? data.version ?? "")}`;
  const failure = outcome && !outcome.ok && !viaGate.current ? outcome : null;
  const google = writer.data?.intent === "google" && !busy ? writer.data : null;

  const gateSite: GateSite = {
    id: data.project.siteId,
    name: data.project.site,
    first: !data.everPublished,
    published: data.status === "published" && data.liveUrl ? { at: data.publishedAt ?? "", url: data.liveUrl } : null,
    hold: data.status === "scheduled" && data.publishAt ? { reason: "scheduled", untilLabel: utc(data.publishAt), text: `Scheduled for ${utc(data.publishAt)}` } : null,
  };
  // Open flags never stop a publish here (checks and reviewers flag, they do not decide), so each is advisory.
  const gateChecks: GateCheck[] = data.flags.map((f) => ({
    id: `flag-${f.id}`,
    name: "Flag",
    required: false,
    ok: f.status !== "open",
    cause: f.message,
    pass: "Dismissed",
    href: "#flags",
    fix: "Change the text, or dismiss the flag.",
  }));

  const historyPath = `/p/${encodeURIComponent(data.project.slug)}/e/${encodeURIComponent(data.itemId)}/history`;
  const ask = (kind: "discard" | "import" | "schedule" | "dismiss", opener: HTMLElement | null, flag?: number) => setAsking({ kind, opener, flag });

  return (
    <div className="app-page app-editor" data-layout={layout}>
      <PageHead
        renderLink={RouterLink}
        crumbs={[{ label: data.project.name, href: `/p/${data.project.slug}` }, { label: kindWord(data.kind) }]}
        title={data.title || data.itemId}
        lead={
          <>
            {data.status === "published" ? <Pill tone="ok">Published</Pill> : data.status === "scheduled" ? <Pill tone="info">Scheduled</Pill> : data.status === "draft" ? <Pill variant="secondary">Draft</Pill> : <Pill variant="outline">Not on the site yet</Pill>}{" "}
            {data.path ? <span className="cap-mono">{data.path}</span> : null}
          </>
        }
        actions={<Segmented legend="Layout" hideLegend size="sm" value={layout} onChange={chooseLayout} options={[{ value: "write", label: "Write" }, { value: "split", label: "Split" }, { value: "preview", label: "Preview" }]} />}
      />

      <div className="app-notices">
        {data.siteError ? <Banner tone="warn">{data.siteError}</Banner> : null}
        {data.behind ? (
          <Banner tone="warn" title="The site's copy changed">
            It changed after this draft was started, so saving it will be refused. Discard the draft to load the site's version, then reapply your changes.
          </Banner>
        ) : null}
        {data.aiPublished && data.status === "published" ? (
          <Banner tone="info">
            {data.aiPublished.line} <Time at={data.aiPublished.at} format="exact" />
          </Banner>
        ) : null}
        {data.canEdit && live && !data.canPublish ? <Banner tone="info">This {kindNoun(data.kind)} is live. Your changes autosave here as your draft; sending them to the site is the Owner's step.</Banner> : null}
      </div>

      <div className="app-editor-grid">
        <div className="app-editor-main">
          <div className="app-editor-body" data-layout={layout}>
            {layout !== "preview" ? (
              <section className="app-editor-pane" aria-label="Write">
                <FrontmatterFields source={source} onChange={setSource} readOnly={readOnly} sections={data.sections} kind={data.kind} />
                <WritingSurface
                  label={kindWord(data.kind)}
                  value={splitSource(source).body}
                  onChange={(body) => setSource((current) => joinSource({ ...splitSource(current), body }))}
                  readOnly={readOnly}
                  linkTargets={data.linkTargets.map((t) => ({ href: t.href, title: t.title, hint: t.href, note: t.state !== "published" ? `not live yet (${t.state})` : undefined }))}
                  media={data.media ? { ...data.media, figure: (url: string, alt: string) => figureMarkup(data.project.siteId, url, alt) } : null}
                />
              </section>
            ) : null}
            {layout !== "write" ? (
              <section className="app-preview-pane" aria-label="Preview">
                {/* Sandboxed with no permissions: the site's page runs no script and has an opaque origin. */}
                <iframe title={`Preview of ${data.title || data.itemId} as ${data.project.site} renders it`} src={previewSrc} sandbox="" />
              </section>
            ) : null}
          </div>
          {data.flags.length > 0 || data.aiDrafts.length > 0 ? (
            <FlagsPanel flags={data.flags} aiDrafts={data.aiDrafts} canPublish={data.canPublish} onDismiss={(button, flag) => ask("dismiss", button, flag)} />
          ) : null}
          {data.revisions || data.historyError ? (
            <HistoryPanel
              revisions={data.revisions}
              revisionCount={data.revisionCount}
              historyError={data.historyError}
              historyPath={historyPath}
              noun={kindNoun(data.kind)}
              againstSite={Boolean(savedAt) && onSite}
              aiDrafts={data.aiDrafts}
            />
          ) : null}
        </div>

        <aside className="app-editor-side" aria-label="Binder, saving and publishing">
          <Panel title="Binder" src="This site's writing">
            <SiteBinder nodes={data.binder} current={data.itemId} />
          </Panel>
          {data.canEdit ? (
            <Panel title="This draft" src={data.version ? (data.status === "draft" ? "Draft on the site" : "On the site") : "Only in Carrel so far"}>
              <div className="app-stack" data-tight>
                <p role="status" className="app-savestate">
                  {saveState}
                </p>
                {failure ? <Alert tone="crit">{failure.message}</Alert> : null}
                {google ? (
                  google.ok ? (
                    <Banner tone="ok">
                      {google.message}{" "}
                      {google.url ? (
                        <a href={google.url} target="_blank" rel="noreferrer">
                          Open the Doc<span className="cap-sr-only"> (opens in a new tab)</span>
                        </a>
                      ) : null}
                    </Banner>
                  ) : (
                    <Alert tone="crit">{google.message}</Alert>
                  )
                ) : null}
                <div className="app-actions">
                  {!onSite && maySave ? (
                    <Button variant="primary" pending={busy} onClick={() => void send("save")}>
                      Save draft to the site
                    </Button>
                  ) : null}
                  {onSite && maySave && saveTransition ? (
                    <Button variant={data.status === "draft" ? "default" : "primary"} pending={busy} onClick={() => void send("save")}>
                      {saveTransition.label}
                    </Button>
                  ) : null}
                  {onSite && data.canPublish && data.status === "scheduled" ? (
                    <Button pending={busy} onClick={() => void send("unpublish")}>
                      Revert to draft
                    </Button>
                  ) : null}
                  {onSite && data.canPublish && data.status !== "published" ? (
                    <Button onClick={(event) => ask("schedule", event.currentTarget)}>Schedule</Button>
                  ) : null}
                  {savedAt ? (
                    <Button variant="quiet" pending={busy} onClick={(event) => ask("discard", event.currentTarget)}>
                      Discard my draft
                    </Button>
                  ) : null}
                </div>
                {data.canSend && data.googleConnected ? (
                  <div className="app-actions">
                    <Button variant="quiet" pending={busy} onClick={() => void send("send-to-docs")}>
                      Send to Google Docs
                    </Button>
                    {data.sentDoc ? (
                      <Button variant="quiet" pending={busy} onClick={(event) => ask("import", event.currentTarget)}>
                        Import from Docs
                      </Button>
                    ) : null}
                  </div>
                ) : null}
              </div>
            </Panel>
          ) : null}

          {onSite && data.canPublish ? (
            <PublishGate
              label="Publish checks"
              sites={[gateSite]}
              checks={gateChecks}
              publish={() => writeThrough("publish", { confirm: "first-publish" })}
              unpublish={() => writeThrough("unpublish")}
              say={say}
              fail={(text) => say(text)}
            />
          ) : null}

          {data.search ? (
            <Panel title="Search" src={`${data.search.start} to ${data.search.end}`} size="sm">
              <dl className="app-facts">
                <dt>Clicks</dt>
                <dd className="cap-num">{data.search.clicks}</dd>
                <dt>Impressions</dt>
                <dd className="cap-num">{data.search.impressions}</dd>
                <dt>Position</dt>
                <dd className="cap-num">{data.search.position}</dd>
              </dl>
            </Panel>
          ) : null}
        </aside>
      </div>

      <ConfirmDialog
        open={asking?.kind === "discard"}
        title="Discard your draft?"
        lead="Your working copy in Carrel is deleted, and the editor goes back to the site's text. This cannot be undone."
        body={[]}
        action="Discard my draft"
        returnTo={asking?.opener}
        perform={async () => void (await send("discard"))}
        onClose={() => setAsking(null)}
      />
      <ConfirmDialog
        open={asking?.kind === "import"}
        title="Import from Google Docs?"
        lead="The Doc's text replaces your working draft. This cannot be undone."
        body={[]}
        action="Import and replace"
        returnTo={asking?.opener}
        perform={async () => void (await send("import-from-docs"))}
        onClose={() => setAsking(null)}
      />
      <ConfirmDialog
        open={asking?.kind === "dismiss"}
        title="Dismiss this flag?"
        lead="A dismissed flag no longer holds publish, and it cannot be reopened."
        body={asking?.flag !== undefined ? [data.flags.find((f) => f.id === asking.flag)?.message ?? ""] : []}
        action="Dismiss flag"
        returnTo={asking?.opener}
        perform={async () => {
          if (asking?.flag !== undefined) await writer.submit({ intent: "dismiss-flag", flag: String(asking.flag) }, { method: "post" });
        }}
        onClose={() => setAsking(null)}
      />
      <ScheduleDialog
        open={asking?.kind === "schedule"}
        noun={kindNoun(data.kind)}
        returnTo={asking?.opener}
        onClose={() => setAsking(null)}
        schedule={async (publishAt) => {
          await send("schedule", { publishAt });
          setAsking(null);
        }}
      />
    </div>
  );
}