app/routes/editor.tsx
556 lines and 51 functions in DrDustinEdwards/carrel, at commit d5d5e82 on 2026-10-24. This file on GitHub.
Functions
| Function | Lines | Complexity | Covered | CRAP | Mutation |
|---|---|---|---|---|---|
| meta | 53 to 55 | 3 | 0% | WatchCRAP 12 | No data: mutation testing has not run on this repo yet |
| loader | 57 to 149 | 38 | 89% | CriticalCRAP 39.8 | No data: mutation testing has not run on this repo yet |
| <anonymous:108> | 108 to 108 | 2 | 100% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:109> | 109 to 109 | 2 | 100% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:134> | 134 to 134 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| <anonymous:138> | 138 to 138 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| action | 159 to 229 | 29 | 48% | CriticalCRAP 149.3 | No data: mutation testing has not run on this repo yet |
| shouldRevalidate | 232 to 235 | 2 | 0% | WatchCRAP 6 | No data: mutation testing has not run on this repo yet |
| EditorRoute | 237 to 240 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| utc | 242 to 242 | 1 | 50% | OkCRAP 1.1 | No data: mutation testing has not run on this repo yet |
| Editor | 244 to 594 | 75 | 0% | CriticalCRAP 5700 | No data: mutation testing has not run on this repo yet |
| <anonymous:265> | 265 to 273 | 4 | 0% | WatchCRAP 20 | No data: mutation testing has not run on this repo yet |
| <anonymous:267> | 267 to 270 | 2 | 0% | WatchCRAP 6 | No data: mutation testing has not run on this repo yet |
| <anonymous:271> | 271 to 271 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:276> | 276 to 283 | 4 | 0% | WatchCRAP 20 | No data: mutation testing has not run on this repo yet |
| <anonymous:287> | 287 to 293 | 2 | 0% | WatchCRAP 6 | No data: mutation testing has not run on this repo yet |
| <anonymous:297> | 297 to 303 | 2 | 0% | WatchCRAP 6 | No data: mutation testing has not run on this repo yet |
| <anonymous:307> | 307 to 313 | 3 | 0% | WatchCRAP 12 | No data: mutation testing has not run on this repo yet |
| send | 321 to 325 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:322> | 322 to 325 | 2 | 0% | WatchCRAP 6 | No data: mutation testing has not run on this repo yet |
| <anonymous:326> | 326 to 332 | 4 | 0% | WatchCRAP 20 | No data: mutation testing has not run on this repo yet |
| writeThrough | 335 to 341 | 3 | 0% | WatchCRAP 12 | No data: mutation testing has not run on this repo yet |
| <anonymous:345> | 345 to 349 | 9 | 0% | CriticalCRAP 90 | No data: mutation testing has not run on this repo yet |
| <anonymous:352> | 352 to 354 | 3 | 0% | WatchCRAP 12 | No data: mutation testing has not run on this repo yet |
| <anonymous:358> | 358 to 358 | 2 | 0% | WatchCRAP 6 | No data: mutation testing has not run on this repo yet |
| <anonymous:374> | 374 to 383 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| ask | 386 to 386 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:427> | 427 to 427 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:427> | 427 to 427 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| <anonymous:429> | 429 to 429 | 2 | 0% | WatchCRAP 6 | No data: mutation testing has not run on this repo yet |
| figure | 430 to 430 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:442> | 442 to 442 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:484> | 484 to 484 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:489> | 489 to 489 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:494> | 494 to 494 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:499> | 499 to 499 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:502> | 502 to 502 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:509> | 509 to 509 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:513> | 513 to 513 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:528> | 528 to 528 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:529> | 529 to 529 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:531> | 531 to 531 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:557> | 557 to 557 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:558> | 558 to 558 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:567> | 567 to 567 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:568> | 568 to 568 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:574> | 574 to 574 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:577> | 577 to 579 | 2 | 0% | WatchCRAP 6 | No data: mutation testing has not run on this repo yet |
| <anonymous:580> | 580 to 580 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:586> | 586 to 586 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:587> | 587 to 590 | 1 | 0% | OkCRAP 2 | No 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>
);
}