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
| Function | Lines | Complexity | Covered | CRAP | Mutation |
|---|---|---|---|---|---|
| meta | 35 to 37 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| statusOf | 43 to 51 | 4 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| daysUntil | 54 to 56 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| loader | 58 to 153 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:59> | 59 to 152 | 6 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:64> | 64 to 65 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:76> | 76 to 76 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:77> | 77 to 77 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:78> | 78 to 84 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:88> | 88 to 89 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:100> | 100 to 111 | 4 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:114> | 114 to 121 | 7 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:144> | 144 to 144 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:145> | 145 to 145 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:146> | 146 to 146 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:148> | 148 to 148 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| action | 160 to 233 | 18 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| AdminPosts | 235 to 397 | 34 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:267> | 267 to 267 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:268> | 268 to 268 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| toggle | 271 to 274 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:272> | 272 to 273 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:273> | 273 to 273 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:279> | 279 to 279 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No 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>
);
}