Skip to the content

Capsid menu

app/routes/project.tsx

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

Functions

The functions of project.tsx
FunctionLinesComplexityCoveredCRAPMutation
meta31 to 3320%WatchCRAP 6No data: mutation testing has not run on this repo yet
loader35 to 6910100%WatchCRAP 10No data: mutation testing has not run on this repo yet
action71 to 8240%WatchCRAP 20No data: mutation testing has not run on this repo yet
when84 to 8830%WatchCRAP 12No data: mutation testing has not run on this repo yet
Project95 to 347330%CriticalCRAP 1122No data: mutation testing has not run on this repo yet
<anonymous:105>105 to 10510%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:106>106 to 10610%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:107>107 to 11430%WatchCRAP 12No data: mutation testing has not run on this repo yet
run116 to 12710%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:116>116 to 12710100%WatchCRAP 10No data: mutation testing has not run on this repo yet
<anonymous:123>123 to 12310%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:176>176 to 17610%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:178>178 to 17810%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:184>184 to 19930%WatchCRAP 12No data: mutation testing has not run on this repo yet
<anonymous:224>224 to 22410%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:264>264 to 305150%CriticalCRAP 240No data: mutation testing has not run on this repo yet
<anonymous:273>273 to 27310%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:273>273 to 2732100%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:273>273 to 2731100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:314>314 to 31420%WatchCRAP 6No data: mutation testing has not run on this repo yet
<anonymous:316>316 to 31610%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:316>316 to 3161100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:317>317 to 31710%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:340>340 to 34010%OkCRAP 2No data: mutation testing has not run on this repo yet

Imports

It imports app/components/project-tabs.tsx, app/lib/content.server.ts, app/lib/context.ts, app/lib/index.server.ts, app/lib/kind-words.ts, app/lib/projects.server.ts, app/lib/roles.ts, app/lib/sites.server.ts, app/lib/waiting.server.ts. No module of this repo imports it.

Source

// The one view for a site: every post the site reports, filtered by status and kind, searched with
// FTS5 over title and body. The list is Carrel's index; Refresh asks the site again.

import { ContentStatus } from "@dustinedwards/site-api";
import { useEffect, useRef, useState } from "react";
import { Form, Link, useNavigation, useRevalidator } from "react-router";
import { Alert, Banner } from "capsomer/react/banner";
import { BulkBar } from "capsomer/react/bulk-bar";
import { Button } from "capsomer/react/button";
import { Empty } from "capsomer/react/empty";
import { Field } from "capsomer/react/field";
import { Panel } from "capsomer/react/panel";
import { Select } from "capsomer/react/select";
import { Pill, Status } from "capsomer/react/status";
import { Time } from "capsomer/react/time";
import { PageHead } from "capsomer/react/page-head";

import { ProjectTabs } from "~/components/project-tabs";

import { getEnv, getViewer } from "~/lib/context";
import { contentDeleteOffered } from "~/lib/content.server";
import { kindsIn, refreshIndex, searchItems } from "~/lib/index.server";
import { kindPlural } from "~/lib/kind-words";
import { requireSiteProject } from "~/lib/projects.server";
import { can } from "~/lib/roles";
import { siteConnection, siteEntry } from "~/lib/sites.server";
import { waitingWork, withWaitingWork } from "~/lib/waiting.server";

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

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

export async function loader({ params, request, context }: Route.LoaderArgs) {
  const env = getEnv(context);
  const project = await requireSiteProject(env.DB, getViewer(context), params.project, "read");
  const url = new URL(request.url);
  const status = ContentStatus.safeParse(url.searchParams.get("status"));
  const filters = {
    q: url.searchParams.get("q")?.trim() || undefined,
    status: status.success ? status.data : undefined,
    kind: url.searchParams.get("kind")?.trim() || undefined,
  };
  const viewer = getViewer(context);
  const [indexed, kinds, waiting] = await Promise.all([
    searchItems(env.DB, project.id, filters),
    kindsIn(env.DB, project.id),
    waitingWork(env.DB, project, viewer),
  ]);
  const items = withWaitingWork(indexed, waiting, filters);
  const connection = siteConnection(env, project.site);
  // Delete is the Owner's, and a site may not offer it. The Owner is told which, and why, beside the actions.
  const canDelete = can(project.role, "delete_content");
  const deleteSupport = canDelete && connection.state === "connected" ? await contentDeleteOffered(env, project) : null;
  return {
    canDelete,
    deleteOffered: deleteSupport?.offered === true,
    deleteReason: deleteSupport && !deleteSupport.offered ? deleteSupport.reason : null,
    project: { slug: project.slug, name: project.name, site: siteEntry(project.site).name, mentions: can(project.role, "read_mentions") },
    canEdit: can(project.role, "edit"),
    canCreate: can(project.role, "edit") && siteEntry(project.site).newSource !== undefined,
    connected: connection.state === "connected",
    connectionDetail: connection.state === "connected" ? null : connection.detail,
    filters,
    kinds,
    items,
  };
}

export async function action({ params, request, context }: Route.ActionArgs) {
  const env = getEnv(context);
  const project = await requireSiteProject(env.DB, getViewer(context), params.project, "read");
  const form = await request.formData();
  if (form.get("intent") !== "refresh") throw new Response("Bad request", { status: 400 });
  try {
    const result = await refreshIndex(env, { id: project.id, site: project.site });
    return { refreshed: result, error: null };
  } catch (error) {
    return { refreshed: null, error: error instanceof Error ? error.message : String(error) };
  }
}

function when(iso: string | null): string {
  if (!iso) return "";
  const date = new Date(iso);
  return Number.isNaN(date.getTime()) ? iso : date.toISOString().slice(0, 10);
}

type BulkOutcome = { id: string; title: string; ok: boolean; message: string; copyId?: string };
type BulkOp = "tag-add" | "tag-remove" | "duplicate" | "delete";

const DONE = "Done. The result for each post is listed below the table.";

export default function Project({ loaderData, actionData }: Route.ComponentProps) {
  const { project, canEdit, canCreate, canDelete, deleteOffered, deleteReason, connected, connectionDetail, filters, kinds, items } = loaderData;
  const navigation = useNavigation();
  const revalidator = useRevalidator();
  // The selection is by post id; a post no longer listed (a filter, a delete) drops out of it.
  const [selected, setSelected] = useState<string[]>([]);
  const [tag, setTag] = useState("");
  const [result, setResult] = useState<{ what: string; outcomes: BulkOutcome[] } | null>(null);
  const resultRef = useRef<HTMLElement>(null);
  // Only a post the site holds can be acted on; one that exists only in Carrel (an AI draft for a new post) cannot.
  const selectable = items.filter((i) => !i.isNew);
  const chosen = selectable.filter((i) => selected.includes(i.itemId));
  useEffect(() => {
    if (!result) return;
    const el = resultRef.current;
    if (el) {
      el.tabIndex = -1;
      el.focus();
    }
  }, [result]);

  const run = (op: BulkOp, what: string) => async (picked: readonly { id: string; label: string }[]) => {
    if ((op === "tag-add" || op === "tag-remove") && tag.trim() === "") throw new Error("Type a tag first.");
    const form = new FormData();
    form.set("op", op);
    if (op === "tag-add" || op === "tag-remove") form.set("tag", tag);
    for (const p of picked) form.append("id", p.id);
    const response = await fetch(`/p/${encodeURIComponent(project.slug)}/bulk`, { method: "POST", body: form, headers: { accept: "application/json" } });
    const body = (await response.json().catch(() => null)) as { results?: BulkOutcome[]; error?: string } | null;
    if (!response.ok || !body?.results) throw new Error(body?.error ?? `The server answered ${response.status}.`);
    setResult({ what, outcomes: body.results });
    void revalidator.revalidate();
  };
  const refreshing = navigation.state !== "idle" && navigation.formData?.get("intent") === "refresh";
  const filtered = Boolean(filters.q || filters.status || filters.kind);

  return (
    <div className="app-page">
      <PageHead
        title={project.name}
        lead={`Everything on ${project.site}, from Carrel's index of the site.`}
        actions={
          <>
            {connected ? (
              <Form method="post">
                <Button type="submit" name="intent" value="refresh" pending={refreshing}>
                  {refreshing ? "Refreshing" : "Refresh from the site"}
                </Button>
              </Form>
            ) : null}
            {canCreate && connected ? (
              <Link to="new" className="cap-btn" data-variant="primary">
                New post
              </Link>
            ) : null}
          </>
        }
      />

      <ProjectTabs slug={project.slug} current="posts" mentions={project.mentions} />

      {!connected ? (
        <Banner tone="warn" title="This site is not connected yet">
          {connectionDetail} Posts appear here once the site's key is set.
        </Banner>
      ) : null}

      {actionData?.error ? (
        <Alert tone="crit">The site could not be read: {actionData.error}</Alert>
      ) : actionData?.refreshed ? (
        <Banner tone="ok">
          {actionData.refreshed.listed} posts listed, {actionData.refreshed.fetched} read in full
          {actionData.refreshed.pending > 0 ? `, ${actionData.refreshed.pending} more on the next refresh` : ""}.
        </Banner>
      ) : null}

      {result ? (
        <Panel
          title="Result"
          id="bulk-result"
          ref={resultRef}
          src={`${result.what}: ${result.outcomes.filter((o) => o.ok).length} of ${result.outcomes.length} done`}
          actions={
            <Button size="sm" onClick={() => setResult(null)}>
              Clear this result
            </Button>
          }
        >
          <ul className="app-flags" aria-label="Result for each post">
            {result.outcomes.map((o) => (
              <li key={o.id}>
                {o.ok ? <Status tone="ok">Done</Status> : <Status tone="crit">Left as it was</Status>}
                <span>
                  <strong>{o.title}</strong> <span className="cap-mono cap-muted">{o.id}</span>
                  <br />
                  {o.message}
                  {o.copyId ? (
                    <>
                      {" "}
                      <Link to={`e/${encodeURIComponent(o.copyId)}`}>Open the copy</Link>
                    </>
                  ) : null}
                </span>
              </li>
            ))}
          </ul>
        </Panel>
      ) : null}

      <Panel title="Posts" count={items.length} src={`Carrel's index of ${project.site}`} flush>
        <div className="cap-panel-pad">
          <Form method="get" className="app-filters" role="search" aria-label="Filter posts">
            <Field label="Search">
              <input className="cap-input" type="search" name="q" defaultValue={filters.q ?? ""} placeholder="Words in the title or text" />
            </Field>
            <Field label="Status">
              <Select
                name="status"
                defaultValue={filters.status ?? ""}
                options={[
                  { value: "", label: "Any" },
                  { value: "draft", label: "Draft" },
                  { value: "scheduled", label: "Scheduled" },
                  { value: "published", label: "Published" },
                ]}
              />
            </Field>
            {kinds.length > 1 ? (
              <Field label="Kind">
                <Select name="kind" defaultValue={filters.kind ?? ""} options={[{ value: "", label: "Any" }, ...kinds.map((k) => ({ value: k, label: k }))]} />
              </Field>
            ) : null}
            <div className="app-actions">
              <Button type="submit" variant="primary">
                Filter
              </Button>
              {filtered ? (
                <Link to="." className="cap-btn">
                  Clear
                </Link>
              ) : null}
            </div>
          </Form>
        </div>

        {items.length === 0 ? (
          <Empty kind={filtered ? "no-match" : "nothing-yet"} flush title={filtered ? `No ${filters.kind ? kindPlural(filters.kind).toLowerCase() : "items"} match` : "Nothing in the index yet"} action={filtered ? <Link to="." className="cap-btn">Clear the filter</Link> : undefined}>
            {filtered ? "Nothing on this site fits the search and filters above." : "Refresh from the site to read what it has."}
          </Empty>
        ) : (
          <div className="cap-table-wrap" role="region" aria-labelledby="posts-caption" tabIndex={0}>
            <table className="cap-table">
              <caption id="posts-caption" className="cap-sr-only">
                {filters.kind ? kindPlural(filters.kind) : kinds.length > 1 ? "Everything" : "Posts"} on {project.site}
              </caption>
              <thead>
                <tr>
                  {canEdit ? (
                    <th scope="col">
                      <span className="cap-sr-only">Select</span>
                    </th>
                  ) : null}
                  <th scope="col">Title</th>
                  {kinds.length > 1 ? <th scope="col">Kind</th> : null}
                  <th scope="col">Status</th>
                  <th scope="col">Updated</th>
                </tr>
              </thead>
              <tbody>
                {items.map((item) => (
                  <tr key={item.itemId} data-state={selected.includes(item.itemId) ? "selected" : undefined}>
                    {canEdit ? (
                      <td>
                        <label className="cap-check">
                          <input
                            type="checkbox"
                            checked={selected.includes(item.itemId)}
                            disabled={item.isNew}
                            onChange={(event) => setSelected((now) => (event.target.checked ? [...now, item.itemId] : now.filter((id) => id !== item.itemId)))}
                          />
                          <span className="cap-sr-only">
                            Select {item.title || item.itemId}
                            {item.isNew ? " (only in Carrel, so it cannot be acted on in bulk)" : ""}
                          </span>
                        </label>
                      </td>
                    ) : null}
                    <th scope="row">
                      <Link className="cap-table-open" to={`e/${encodeURIComponent(item.itemId)}`}>
                        {item.title || item.itemId}
                      </Link>
                      <span className="cap-table-aside">{item.path ?? item.itemId}</span>
                    </th>
                    {kinds.length > 1 ? <td>{item.kind}</td> : null}
                    <td>
                      {item.status === "published" ? (
                        <Pill tone="ok">Published</Pill>
                      ) : item.status === "scheduled" ? (
                        <Pill tone="info">Scheduled{item.publishAt ? ` ${when(item.publishAt)}` : ""}</Pill>
                      ) : (
                        <Pill variant="secondary">{item.isNew ? "New draft" : "Draft"}</Pill>
                      )}
                      {item.aiDrafts > 0 ? (
                        <Pill tone="info">
                          {item.aiDrafts === 1 ? "1 AI draft waiting" : `${item.aiDrafts} AI drafts waiting`}
                        </Pill>
                      ) : null}
                    </td>
                    <td>{item.updatedAt ? <Time at={item.updatedAt} /> : null}</td>
                  </tr>
                ))}
              </tbody>
            </table>
          </div>
        )}
      </Panel>

      {canEdit ? (
        <BulkBar
          items={chosen.map((i) => ({ id: i.itemId, label: `${i.title || i.itemId} (${i.itemId})` }))}
          total={selectable.length}
          onSelectAll={() => setSelected(selectable.map((i) => i.itemId))}
          onClear={() => setSelected([])}
          actions={[
            { id: "tag-add", label: "Add tag", said: DONE, run: run("tag-add", "Add tag") },
            { id: "tag-remove", label: "Remove tag", said: DONE, run: run("tag-remove", "Remove tag") },
            { id: "duplicate", label: "Duplicate", said: DONE, run: run("duplicate", "Duplicate") },
            ...(canDelete && deleteOffered
              ? [
                  {
                    id: "delete",
                    label: "Delete",
                    destructive: true,
                    confirmTitle: "Delete {n} post{s} from the site?",
                    confirmLead: `Each post below is removed from ${project.site}. Carrel cannot bring one back; the site's own history may still hold its text. Your working drafts of them stay in Carrel.`,
                    confirmAction: "Delete {n} post{s}",
                    said: DONE,
                    run: run("delete", "Delete"),
                  },
                ]
              : []),
          ]}
        >
          <span className="cap-bulk-field">
            <label htmlFor="bulk-tag">Tag</label>
            <input className="cap-input" id="bulk-tag" value={tag} onChange={(event) => setTag(event.target.value)} autoComplete="off" />
          </span>
          {canDelete && deleteReason ? <span className="cap-bulk-detail">Delete is not offered: {deleteReason}</span> : null}
        </BulkBar>
      ) : null}
    </div>
  );
}