Skip to the content

Capsid menu

app/routes/admin.posts._index.tsx

354 lines and 24 functions in DrDustinEdwards/dustinedwards-info, at commit 1590ba4 on 2026-10-24. This file on GitHub.

Functions

The functions of admin.posts._index.tsx
FunctionLinesComplexityCoveredCRAPMutation
meta35 to 371No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
statusOf43 to 514No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
daysUntil54 to 561No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
loader58 to 1531No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:59>59 to 1526No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:64>64 to 651No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:76>76 to 761No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:77>77 to 771No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:78>78 to 841No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:88>88 to 891No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:100>100 to 1114No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:114>114 to 1217No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:144>144 to 1441No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:145>145 to 1451No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:146>146 to 1461No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:148>148 to 1481No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
action160 to 23318No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
AdminPosts235 to 39734No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:267>267 to 2671No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:268>268 to 2681No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
toggle271 to 2741No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:272>272 to 2732No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:273>273 to 2731No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:279>279 to 2791No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code

Imports

It imports app/components/admin/posts-confirm-dialogs.tsx, app/components/admin/posts-filters.tsx, app/components/admin/posts-table.tsx, app/components/admin/posts-toolbar.tsx, app/db/index.ts, app/lib/admin-actor.server.ts, app/lib/admin/posts-actions.server.ts, app/lib/admin/posts-filters.ts, app/lib/context.ts, app/lib/destructive.mjs, app/lib/editor/delete-left.mjs, app/lib/error-message.mjs, app/lib/search/ask-guard.server.ts, app/lib/search/ask.server.ts, app/lib/timing.ts. No module of this repo imports it.

Source

import { useEffect, useState } from "react";
import { Form, Link, data, useNavigation } from "react-router";

import { Alert, Banner } from "capsomer/react/banner";
import { Button } from "capsomer/react/button";
import { Empty } from "capsomer/react/empty";

import { PageHead } from "capsomer/react/page-head";
import { PostsConfirmDialogs } from "~/components/admin/posts-confirm-dialogs";
import { PostsFilters } from "~/components/admin/posts-filters";
import { PostsTable } from "~/components/admin/posts-table";
import { PostsToolbar } from "~/components/admin/posts-toolbar";
import { listAllPostsForAdmin, listAllPostTagsForAdmin } from "~/db";
import { adminActorContext } from "~/lib/admin-actor.server";
import { getEnv } from "~/lib/context";
import { timed, timedLoader } from "~/lib/timing";
import { CONFIRM_FIELD, confirmationSatisfied } from "~/lib/destructive.mjs";
import {
  askSyncConfirmCount,
  bulkDeletePosts,
  bulkTagPosts,
  duplicatePost,
  regeneratePosts,
  resetAskBudgetAndReport,
  syncAskIndex,
  unpublishPost,
} from "~/lib/admin/posts-actions.server";
import { deleteLeftBehind } from "~/lib/editor/delete-left.mjs";
import { FILTER_KEYS, postsHref, readFilters } from "~/lib/admin/posts-filters";
import { askAvailable, askStatusContext } from "~/lib/search/ask.server";
import { readAskBudget } from "~/lib/search/ask-guard.server";
import type { Route } from "./+types/admin.posts._index";
import { errorMessage } from "~/lib/error-message.mjs";

export function meta() {
  return [{ title: "Posts ยท Admin" }, { name: "robots", content: "noindex" }];
}

/**
 * `scheduled` is derived, not a column: a published row whose `publish_at` is still ahead.
 * Derived in the loader because it reads the clock.
 */
function statusOf(
  status: string,
  publishAt: Date | null,
  now: number,
): "published" | "scheduled" | "draft" {
  if (status !== "published") return "draft";
  if (publishAt && publishAt.getTime() > now) return "scheduled";
  return "published";
}

/** Rounded up: a post live in 30 hours is "in 2 days", never a number that has already passed. */
function daysUntil(publishAt: Date, now: number) {
  return Math.max(1, Math.ceil((publishAt.getTime() - now) / 86_400_000));
}

export async function loader({ request, context }: Route.LoaderArgs) {
  return timedLoader(context, async (timings) => {
    const env = getEnv(context);
    const filters = readFilters(new URL(request.url).searchParams);
    const deleteLeft = deleteLeftBehind(new URL(request.url).searchParams);

    const askPromise = timed(timings, "ask_status_uncached", () =>
      context.get(askStatusContext)(),
    );
    const askOn = askAvailable(env);
    /*
     * The catch is attached at creation: a promise that rejects before it is awaited is an unhandled
     * rejection. A failing budget read must not take the page down, and it renders as a failure.
     */
    const budgetPromise: Promise<
      | { budget: Awaited<ReturnType<typeof readAskBudget>>; budgetError: null }
      | { budget: null; budgetError: string | null }
    > = askOn
      ? timed(timings, "ask_budget_do", () => readAskBudget(env)).then(
          (budget) => ({ budget, budgetError: null }),
          (error: unknown) => {
            console.error(JSON.stringify({ alert: "ask-budget-read-failed", detail: errorMessage(error) }));
            return {
              budget: null,
              budgetError: errorMessage(error),
            };
          },
        )
      : Promise.resolve({ budget: null, budgetError: null });

    const [rows, tagRows] = await timed(timings, "d1_admin_posts", () =>
      Promise.all([listAllPostsForAdmin(env), listAllPostTagsForAdmin(env)]),
    );

    const tagsBySlug = new Map<string, string[]>();
    for (const row of tagRows) {
      const list = tagsBySlug.get(row.slug);
      if (list) list.push(row.tag);
      else tagsBySlug.set(row.slug, [row.tag]);
    }

    const now = Date.now();
    const all = rows.map((post) => {
      const state = statusOf(post.status, post.publishAt, now);
      return {
        ...post,
        state,
        tags: tagsBySlug.get(post.slug) ?? [],
        // A number by the time the component sees it: rendering from a date reads the clock, and server
        // and hydration would disagree near a day boundary.
        scheduledInDays:
          state === "scheduled" && post.publishAt ? daysUntil(post.publishAt, now) : null,
      };
    });

    const needle = filters.q.toLowerCase();
    const posts = all.filter((post) => {
      if (filters.status && post.state !== filters.status) return false;
      if (filters.tag && !post.tags.includes(filters.tag)) return false;
      if (!needle) return true;
      return (
        post.title.toLowerCase().includes(needle) || post.slug.toLowerCase().includes(needle)
      );
    });

    const filtered = Boolean(filters.q || filters.status || filters.tag);

    // An admin page may await the AI layer; no public route ever does.
    const ask = await askPromise;

    const { budget, budgetError } = await budgetPromise;

    const payload = {
      posts,
      /* With Ask on, a null `ask` is a failed status read, never "up to date". */
      askOn,
      /** What a delete from the editor left behind, when it redirected here. */
      deleteLeft,
      ask,
      budget,
      budgetError,
      filters,
      filtered,
      total: all.length,
      statusCounts: {
        all: all.length,
        published: all.filter((post) => post.state === "published").length,
        draft: all.filter((post) => post.state === "draft").length,
        scheduled: all.filter((post) => post.state === "scheduled").length,
      },
      tagOptions: [...new Set(tagRows.map((row) => row.tag))].sort(),
    };

    return data(payload);
  });
}

/*
 * The dispatch stays here and each intent's work is in lib/admin/posts-actions.server.ts. The two
 * destructive intents check the typed confirmation in their own branch, where check:destructive
 * reads it, before the handler runs.
 */
export async function action({ request, context }: Route.ActionArgs) {
  const form = await request.formData();
  const env = getEnv(context);
  const intent = form.get("intent");
  /* Required by `savePost` and `deletePost` rather than defaulting to the most privileged principal. */
  const actor = context.get(adminActorContext);

  if (intent === "regenerate") return regeneratePosts(env);

  // Separate from Regenerate: D1 search must not be held hostage to the AI index, and the AI index
  // must never be repaired by touching D1.
  if (intent === "sync-ask") {
    if (!askAvailable(env)) {
      return { message: "Ask is not enabled: no AI Search binding.", failed: true as const };
    }
    /*
     * Checked here. "Sync" reads as additive and is not: `pruneAskCorpus` deletes every record this
     * run did not upload. The count is 1 because how many it removes cannot be known first.
     */
    const typed = String(form.get(CONFIRM_FIELD) ?? "").trim();

    if (!confirmationSatisfied(typed, 1)) {
      return { confirmSyncAsk: await askSyncConfirmCount(env) };
    }

    return syncAskIndex(env);
  }

  if (intent === "duplicate") return duplicatePost(env, form, actor);

  if (intent === "unpublish") return unpublishPost(env, form, actor);

  /*
   * Each iterates the per-post writer, so every gate on one post guards all. `expectedHeadSha` is
   * omitted because each commit advances head.
   */
  if (
    intent === "bulk-delete" ||
    intent === "bulk-add-tag" ||
    intent === "bulk-remove-tag"
  ) {
    const slugs = form.getAll("slug").map(String).filter(Boolean);
    if (slugs.length === 0) return { message: "Nothing selected.", failed: true as const };

    if (intent === "bulk-delete") {
      /*
       * Enforced here, not only in the UI: with scripting off an `onClick` never runs. The count comes
       * from this request's slugs, never the form, so a stale page cannot authorize a different-size delete.
       */
      const typed = String(form.get(CONFIRM_FIELD) ?? "").trim();
      if (!confirmationSatisfied(typed, slugs.length)) {
        return {
          confirmDelete: { slugs, count: slugs.length, typed },
          message:
            `Nothing was deleted. ${slugs.length} post(s) are selected and the ` +
            `confirmation read ${typed || "(blank)"}. Type the count exactly to confirm.`,
        };
      }
      return bulkDeletePosts(env, slugs, actor);
    }

    return bulkTagPosts(env, form, slugs, intent, actor);
  }

  if (intent === "reset-ask-budget") return resetAskBudgetAndReport(env);

  return data(
    {
      message: `Nothing was done: ${String(intent ?? "(none)")} is not an action this page knows.`,
      failed: true as const,
    },
    { status: 400 },
  );
}

export default function AdminPosts({
  loaderData,
  actionData,
}: Route.ComponentProps) {
  const {
    posts,
    askOn,
    ask,
    budget,
    budgetError,
    filters,
    filtered,
    total,
    statusCounts,
    tagOptions,
  } = loaderData;
  const scheduledTotal = statusCounts.scheduled;
  const askUnread = askOn && !ask;
  /* Narrowed by key: the 400 answer carries only `message`, so the union no longer has these on every arm. */
  const confirmSyncAsk =
    actionData && "confirmSyncAsk" in actionData ? actionData.confirmSyncAsk : undefined;
  const confirmDelete =
    actionData && "confirmDelete" in actionData ? actionData.confirmDelete : undefined;
  const message = actionData && "message" in actionData ? actionData.message : undefined;
  const failed = actionData && "failed" in actionData ? actionData.failed === true : false;

  const navigation = useNavigation();
  const pending = navigation.state === "loading" && navigation.location != null;
  const askDrifted = ask ? ask.missing.length > 0 || ask.stale.length > 0 : false;

  /* Keyed by slug, not row index, so a re-render or filter change cannot re-point it at another post. */
  const [selected, setSelected] = useState<string[]>([]);
  const visible = posts.map((post) => post.slug);
  const chosen = selected.filter((slug) => visible.includes(slug));
  const allShown = chosen.length > 0 && chosen.length === visible.length;

  const toggle = (slug: string) =>
    setSelected((prev) =>
      prev.includes(slug) ? prev.filter((s) => s !== slug) : [...prev, slug],
    );

  const cancelHref = postsHref(filters);

  const [hydrated, setHydrated] = useState(false);
  useEffect(() => setHydrated(true), []);

  const askedFor = [
    filters.q ? `"${filters.q}"` : null,
    filters.status ? `status ${filters.status}` : null,
    filters.tag ? `tag ${filters.tag}` : null,
  ].filter(Boolean);

  return (
    <div className="app-page">
      <PageHead
        title="Posts"
        lead={
          `${statusCounts.all} post${statusCounts.all === 1 ? "" : "s"}, ` +
          `${statusCounts.published} published and ${statusCounts.draft} draft(s).` +
          (askDrifted && ask
            ? ` ${ask.missing.length + ask.stale.length} of them have changed since search last read them.`
            : askUnread
              ? " The search index status could not be read, so whether search is up to date is unknown."
              : ask
                ? " Search is up to date with all of them."
                : "")
        }
        actions={<PostsToolbar />}
      />

      <PostsFilters
        filters={filters}
        filtered={filtered}
        statusCounts={statusCounts}
        tagOptions={tagOptions}
      />

      {scheduledTotal > 0 ? (
        <Banner tone="info">
          {scheduledTotal} post{scheduledTotal === 1 ? " is" : "s are"} scheduled and not yet
          public. <Link to={`/admin/posts?${FILTER_KEYS.status}=scheduled`}>Show the queue</Link>
        </Banner>
      ) : null}

      {loaderData.deleteLeft ? (
        <Banner tone="neutral">
          {`Deleted "${loaderData.deleteLeft.slug}". ${loaderData.deleteLeft.problems.join(" ")}`}
        </Banner>
      ) : null}

      {/*
        A live announcement, unlike the drift region: it reports what the submit just did. The
        status region is always in the DOM, because a region inserted already holding its text is
        often not read; a failure is an alert, which is read on insertion.
      */}
      <div role="status">{message && !failed ? <Banner tone="ok">{message}</Banner> : null}</div>
      {message && failed ? <Alert tone="crit">{message}</Alert> : null}

      <PostsConfirmDialogs
        confirmSyncAsk={confirmSyncAsk}
        confirmDelete={confirmDelete}
        cancelHref={cancelHref}
      />

      {/* Surfaced here: a save can succeed while its Ask sync fails, and then it redirects. */}
      {askDrifted && ask ? (
        /* A standing condition, so a banner and never an alert. */
        <Banner
          tone="warn"
          title="Search is answering from older text"
          actions={
            <Form method="post">
              <Button type="submit" name="intent" value="sync-ask">
                Rebuild the answer index
              </Button>
            </Form>
          }
        >
          {`${ask.missing.length} post(s) are missing from the answer index and ` +
            `${ask.stale.length} record(s) in it no longer match the site, so an ` +
            `answer may quote text that has changed.`}
        </Banner>
      ) : null}

      {posts.length === 0 ? (
        filtered ? (
          <Empty
            kind="no-match"
            action={
              <Link to="/admin/posts" className="cap-btn">
                Clear filters
              </Link>
            }
          >
            No posts match {askedFor.join(", ")}. Searched {total} post
            {total === 1 ? "" : "s"} by title and slug.
          </Empty>
        ) : (
          <Empty kind="nothing-yet">No posts yet.</Empty>
        )
      ) : (
        <PostsTable
          posts={posts}
          visible={visible}
          chosen={chosen}
          allShown={allShown}
          setSelected={setSelected}
          toggle={toggle}
          hydrated={hydrated}
          pending={pending}
          filtered={filtered}
          total={total}
          tagOptions={tagOptions}
          askOn={askOn}
          ask={ask}
          askUnread={askUnread}
          budget={budget}
          budgetError={budgetError}
        />
      )}
    </div>
  );
}