app/routes/media.tsx
569 lines and 36 functions in DrDustinEdwards/carrel, at commit d5d5e82 on 2026-10-24. This file on GitHub.
Functions
| Function | Lines | Complexity | Covered | CRAP | Mutation |
|---|---|---|---|---|---|
| meta | 59 to 61 | 2 | 0% | WatchCRAP 6 | No data: mutation testing has not run on this repo yet |
| reason | 63 to 70 | 5 | 0% | WatchCRAP 30 | No data: mutation testing has not run on this repo yet |
| loader | 72 to 138 | 28 | 82% | CriticalCRAP 33 | No data: mutation testing has not run on this repo yet |
| <anonymous:126> | 126 to 126 | 2 | 100% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| action | 155 to 215 | 29 | 56% | CriticalCRAP 101.6 | No data: mutation testing has not run on this repo yet |
| bytes | 165 to 165 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:191> | 191 to 199 | 8 | 69% | WatchCRAP 9.9 | No data: mutation testing has not run on this repo yet |
| writeOutcome | 218 to 226 | 3 | 33% | WatchCRAP 5.7 | No data: mutation testing has not run on this repo yet |
| MediaLibrary | 232 to 596 | 83 | 0% | CriticalCRAP 6972 | No data: mutation testing has not run on this repo yet |
| <anonymous:246> | 246 to 252 | 3 | 0% | WatchCRAP 12 | No data: mutation testing has not run on this repo yet |
| here | 255 to 261 | 5 | 0% | WatchCRAP 30 | No data: mutation testing has not run on this repo yet |
| <anonymous:272> | 272 to 272 | 2 | 0% | WatchCRAP 6 | No data: mutation testing has not run on this repo yet |
| <anonymous:273> | 273 to 273 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| label | 274 to 274 | 2 | 0% | WatchCRAP 6 | No data: mutation testing has not run on this repo yet |
| run | 275 to 287 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:275> | 275 to 287 | 11 | 100% | WatchCRAP 11 | No data: mutation testing has not run on this repo yet |
| <anonymous:282> | 282 to 282 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:337> | 337 to 337 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:346> | 346 to 348 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:379> | 379 to 383 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:426> | 426 to 426 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:458> | 458 to 506 | 13 | 0% | CriticalCRAP 182 | No data: mutation testing has not run on this repo yet |
| <anonymous:492> | 492 to 492 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:492> | 492 to 492 | 2 | 100% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:492> | 492 to 492 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| <anonymous:541> | 541 to 544 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:551> | 551 to 551 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:553> | 553 to 553 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:553> | 553 to 553 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| <anonymous:554> | 554 to 554 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:560> | 560 to 560 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:573> | 573 to 576 | 2 | 0% | WatchCRAP 6 | No data: mutation testing has not run on this repo yet |
| <anonymous:577> | 577 to 577 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:584> | 584 to 584 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:587> | 587 to 589 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:590> | 590 to 590 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
Imports
It imports app/components/media/detail-panel.tsx, app/components/media/format.ts, app/components/media/results.tsx, app/components/project-tabs.tsx, app/lib/context.ts, app/lib/media.server.ts, app/lib/projects.server.ts, app/lib/roles.ts, app/lib/sites.server.ts. No module of this repo imports it.
Source
// The media library for one site (stage 3, and the v0.4.0 writes): browse, search, filter by tag,
// upload, edit a file's alt text and tags, move files to the trash and back, delete for good, over the
// site API's media group. The files are the site's, in its own storage and served by it; this page is
// only the screen. A Reader browses, an Editor also uploads, edits alt text and tags and uses the
// trash, and only the Owner deletes for good or empties the trash, and then only what the site's own
// reference check lets go. Each write is optional per site (the site's meta says which); the screen
// shows only what the site offers.
//
// The tiles, the bulk bar, the drop zone and the confirm dialog are Capsomer's; the details are a panel
// on the page, not a modal dialog, so they work without script (each edit is a form that posts).
// Selection and the bulk bar need script. Capsomer's own inspector autosaves through a client call;
// this page keeps server forms so the versioned writes, their refusals and the authorship record are
// one path. There are no folders: the dustinedwards media schema has none.
import { useEffect, useRef, useState } from "react";
import { Form, Link, useFetcher, 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 { ConfirmDialog } from "capsomer/react/confirm-dialog";
import { Disclosure } from "capsomer/react/disclosure";
import { DropZone } from "capsomer/react/drop-zone";
import { Empty } from "capsomer/react/empty";
import { Field } from "capsomer/react/field";
import { Panel } from "capsomer/react/panel";
import { Status } from "capsomer/react/status";
import { PageHead } from "capsomer/react/page-head";
import { ProjectTabs } from "~/components/project-tabs";
import { MediaDetailPanel } from "~/components/media/detail-panel";
import { size } from "~/components/media/format";
import { BulkResultPanel, TrashEmptiedPanel, type FileOutcome } from "~/components/media/results";
import { getEnv, getViewer } from "~/lib/context";
import {
cleanMediaTag,
deleteMedia,
emptyMediaTrash,
listMedia,
listTrash,
mediaDetail,
mediaMeta,
parseMediaTags,
restoreMedia,
setMediaAlt,
setMediaTags,
trashMedia,
uploadMedia,
type MediaOffers,
type WriteOutcome,
} from "~/lib/media.server";
import { requireSiteProject } from "~/lib/projects.server";
import { can } from "~/lib/roles";
import { siteConnection, siteEntry, SiteNotConnected } from "~/lib/sites.server";
import { SiteApiError } from "@dustinedwards/site-api/client";
import type { Route } from "./+types/media";
export function meta({ loaderData: data }: Route.MetaArgs) {
return [{ title: data ? `Media · ${data.project.name} · Carrel` : "Carrel" }];
}
function reason(error: unknown): string | null {
if (error instanceof SiteNotConnected) return `This site is not connected yet: ${error.detail}`;
if (error instanceof SiteApiError) {
if (error.status === 501) return "This site has no media library yet.";
return error.body ? `The site refused: ${error.body.message}` : `The site answered ${error.status}.`;
}
return null;
}
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 q = url.searchParams.get("q")?.trim().slice(0, 200) ?? "";
const cursor = url.searchParams.get("cursor") ?? "";
const id = url.searchParams.get("id") ?? "";
const tagText = url.searchParams.get("tag")?.trim() ?? "";
const tag = tagText ? cleanMediaTag(tagText) : null;
const wantsTrash = url.searchParams.get("view") === "trash";
const base = {
project: { slug: project.slug, name: project.name, site: siteEntry(project.site).name, mentions: can(project.role, "read_mentions") },
canUpload: can(project.role, "edit"),
canDelete: can(project.role, "delete_media"),
q,
cursor,
};
const noOffers: MediaOffers = { alt: false, tags: false, trash: false };
const empty = {
limits: null,
offers: noOffers,
view: "library" as "library" | "trash",
tag: "",
tagInvalid: false,
trashFiles: [] as { id: string; label: string }[],
trashMore: false,
items: [],
nextCursor: null,
detail: null,
missingId: null,
};
const connection = siteConnection(env, project.site);
if (connection.state !== "connected") return { ...base, ...empty, unavailable: `This site is not connected yet: ${connection.detail}` };
try {
// The limits first: a site on the v0.1.0 contract has no media routes to ask.
const meta = await mediaMeta(env, project);
if (!meta) return { ...base, ...empty, unavailable: "This site has no media library yet." };
const { limits, offers } = meta;
// A site that offers no trash has no Trash view; a site that keeps no tags ignores the tag filter.
const view: "library" | "trash" = wantsTrash && offers.trash ? "trash" : "library";
const [list, detail, trash] = await Promise.all([
listMedia(env, project, { q: q || undefined, tag: offers.tags && tag ? tag : undefined, trashed: view === "trash" ? "only" : undefined, cursor: cursor || undefined, limit: 48 }),
id ? mediaDetail(env, project, id) : Promise.resolve(null),
// Emptying the trash names every file it will delete, so the Owner's Trash view lists them all (up to one request's worth).
view === "trash" && can(project.role, "delete_media") ? listTrash(env, project) : Promise.resolve(null),
]);
return {
...base,
unavailable: null,
limits,
offers,
view,
tag: offers.tags && tag ? tag : "",
tagInvalid: Boolean(offers.tags && tagText && !tag),
trashFiles: trash ? trash.items.map((i) => ({ id: i.id, label: i.filename ?? i.id })) : [],
trashMore: trash?.more ?? false,
items: list.items,
nextCursor: list.nextCursor,
detail,
missingId: id && !detail ? id : null,
};
} catch (error) {
const message = reason(error);
if (message === null) throw error;
return { ...base, ...empty, unavailable: message };
}
}
type WriteIntent = "set-alt" | "set-tags" | "trash" | "restore";
type Refusal = { id: string; message: string; usedBy: { type: string; id: string; title: string; detail: string }[] };
type ActionResult =
| { intent: WriteIntent; ok: true; id: string; recorded: boolean }
| { intent: WriteIntent; ok: false; id: string; message: string; conflict: boolean }
| { intent: "empty-trash"; needsConfirm: true }
| { intent: "empty-trash"; ok: true; deleted: string[]; refused: Refusal[]; more: boolean; unrecorded: number }
| { intent: "empty-trash"; ok: false; message: string }
| { intent: "upload"; ok: true; id: string; name: string }
| { intent: "upload"; ok: false; message: string }
| { intent: "delete"; needsConfirm: true; id: string }
| { intent: "delete"; ok: true; id: string }
| { intent: "delete"; ok: false; id: string; message: string; usedBy: { type: string; id: string; title: string; detail: string }[] };
export async function action({ params, request, context }: Route.ActionArgs): Promise<ActionResult> {
const env = getEnv(context);
const viewer = getViewer(context);
const form = await request.formData();
const intent = String(form.get("intent") ?? "");
if (intent === "upload") {
const project = await requireSiteProject(env.DB, viewer, params.project, "edit");
const file = form.get("file");
if (!(file instanceof File) || file.size === 0) return { intent, ok: false, message: "No file was chosen." };
try {
const outcome = await uploadMedia(env, project, { viewer }, { name: file.name, type: file.type, size: file.size, bytes: () => file.arrayBuffer() }, String(form.get("alt") ?? ""));
return outcome.ok ? { intent, ok: true, id: outcome.item.id, name: outcome.item.filename ?? outcome.item.id } : { intent, ok: false, message: outcome.message };
} catch (error) {
const message = reason(error);
if (message === null) throw error;
return { intent, ok: false, message };
}
}
if (intent === "delete") {
const project = await requireSiteProject(env.DB, viewer, params.project, "delete_media");
const id = String(form.get("id") ?? "");
// Two steps, as a first publish is: the file leaves the site's storage, and no undo brings it back.
if (form.get("confirm") !== "delete") return { intent, needsConfirm: true, id };
try {
const outcome = await deleteMedia(env, project, { viewer }, id);
return outcome.ok ? { intent, ok: true, id } : { intent, ok: false, id, message: outcome.message, usedBy: outcome.usedBy };
} catch (error) {
const message = reason(error);
if (message === null) throw error;
return { intent, ok: false, id, message, usedBy: [] };
}
}
if (intent === "set-alt" || intent === "set-tags" || intent === "trash" || intent === "restore") {
const project = await requireSiteProject(env.DB, viewer, params.project, "edit");
const id = String(form.get("id") ?? "");
const version = String(form.get("version") ?? "");
const outcome = await writeOutcome(async (): Promise<WriteOutcome> => {
if (!version) return { ok: false, conflict: false, message: "Carrel does not know this file's version, so nothing was sent. Reload the page and try again." };
if (intent === "set-alt") return setMediaAlt(env, project, { viewer }, id, String(form.get("alt") ?? ""), version);
if (intent === "trash") return trashMedia(env, project, { viewer }, id, version);
if (intent === "restore") return restoreMedia(env, project, { viewer }, id, version);
const parsed = parseMediaTags(String(form.get("tags") ?? ""));
if (!parsed.ok) return { ok: false, conflict: false, message: parsed.message };
return setMediaTags(env, project, { viewer }, id, parsed.tags, version);
});
return outcome.ok ? { intent, ok: true, id, recorded: outcome.recorded } : { intent, ok: false, id, message: outcome.message, conflict: outcome.conflict };
}
if (intent === "empty-trash") {
const project = await requireSiteProject(env.DB, viewer, params.project, "delete_media");
// Two steps, like delete: the page names every file first, because the files leave the site's storage for good.
if (form.get("confirm") !== "empty") return { intent, needsConfirm: true };
try {
return { intent, ok: true, ...(await emptyMediaTrash(env, project, { viewer })) };
} catch (error) {
const message = error instanceof Response ? await error.text() : reason(error);
if (message === null) throw error;
return { intent, ok: false, message: message || "The site did not empty the trash." };
}
}
throw new Response("Bad request", { status: 400 });
}
/** A site that is not connected or cannot be reached, said plainly for a write instead of a crash. */
async function writeOutcome(run: () => Promise<WriteOutcome>): Promise<WriteOutcome> {
try {
return await run();
} catch (error) {
const message = reason(error);
if (message === null) throw error;
return { ok: false, conflict: false, message };
}
}
type BulkOp = "trash" | "restore" | "delete" | "add-tags" | "remove-tags";
const WRITE_INTENTS: readonly string[] = ["set-alt", "set-tags", "trash", "restore"];
export default function MediaLibrary({ loaderData, actionData }: Route.ComponentProps) {
const { project, canUpload, canDelete, q, tag, tagInvalid, view, offers, trashFiles, trashMore, unavailable, limits, items, nextCursor, detail, missingId } = loaderData;
const navigation = useNavigation();
const revalidator = useRevalidator();
const busy = navigation.state !== "idle";
const deleter = useFetcher<ActionResult>();
const [confirming, setConfirming] = useState<HTMLElement | null>(null);
const [asking, setAsking] = useState(false);
const [emptying, setEmptying] = useState(false);
// The selection is by file id; a file no longer listed (a filter, the trash, a delete) drops out of it.
const [selected, setSelected] = useState<string[]>([]);
const [bulkTags, setBulkTags] = useState("");
const [bulkResult, setBulkResult] = useState<{ what: string; outcomes: FileOutcome[] } | null>(null);
const resultRef = useRef<HTMLElement>(null);
useEffect(() => {
const el = resultRef.current;
if (bulkResult && el) {
el.tabIndex = -1;
el.focus();
}
}, [bulkResult]);
const trashView = view === "trash";
const here = (over: Record<string, string>) => {
const params = new URLSearchParams();
const next = { q, tag, view: trashView ? "trash" : "", id: "", cursor: "", ...over };
for (const [k, v] of Object.entries(next)) if (v) params.set(k, v);
const text = params.toString();
return text ? `?${text}` : ".";
};
const result = deleter.data ?? actionData;
// Without script the Delete button posts and the server asks; with script the dialog asks first.
const serverAsks = result?.intent === "delete" && "needsConfirm" in result ? result.id : null;
const refused = result?.intent === "delete" && "ok" in result && !result.ok ? result : null;
const written = result && WRITE_INTENTS.includes(result.intent) && "ok" in result ? (result as Extract<ActionResult, { intent: WriteIntent }>) : null;
const emptied = result?.intent === "empty-trash" ? result : null;
const name = detail ? (detail.filename ?? detail.id) : "";
// Selection and the bulk bar: only where the site has the v0.4.0 routes and the person may write.
const bulkOffered = canUpload && (offers.tags || offers.trash);
const versions = new Map(items.map((i) => [i.id, i.version ?? ""]));
const chosen = items.filter((i) => selected.includes(i.id));
const label = (i: { id: string; filename: string | null }) => i.filename ?? i.id;
const run = (op: BulkOp, what: string) => async (picked: readonly { id: string; label: string }[]) => {
if ((op === "add-tags" || op === "remove-tags") && bulkTags.trim() === "") throw new Error("Type a tag first.");
const form = new FormData();
form.set("op", op);
if (op === "add-tags" || op === "remove-tags") form.set("tags", bulkTags);
for (const p of picked) form.append("item", `${p.id}\t${versions.get(p.id) ?? ""}`);
const response = await fetch(`/p/${encodeURIComponent(project.slug)}/media/bulk`, { method: "POST", body: form, headers: { accept: "application/json" } });
const body = (await response.json().catch(() => null)) as { results?: FileOutcome[]; error?: string } | null;
if (!response.ok || !body?.results) throw new Error(body?.error ?? `The server answered ${response.status}.`);
setBulkResult({ what, outcomes: body.results });
setSelected([]);
void revalidator.revalidate();
};
const done = "Done. The result for each file is listed above the files.";
const bulkActions = [
...(offers.tags
? [
{ id: "add-tags", label: "Add tag", said: done, run: run("add-tags", "Add tag") },
{ id: "remove-tags", label: "Remove tag", said: done, run: run("remove-tags", "Remove tag") },
]
: []),
...(offers.trash
? [trashView ? { id: "restore", label: "Restore", said: done, run: run("restore", "Restore") } : { id: "trash", label: "Move to trash", said: done, run: run("trash", "Move to trash") }]
: []),
...(canDelete
? [
{
id: "delete",
label: "Delete for good",
destructive: true,
confirmTitle: "Delete {n} file{s} from the site?",
confirmLead: `Each file below leaves ${project.site}'s storage, and nothing in Carrel can bring it back. The site's check refuses any file a post still uses, and that file stays.`,
confirmAction: "Delete {n} file{s}",
said: done,
run: run("delete", "Delete for good"),
},
]
: []),
];
return (
<div className="app-page">
<PageHead title={project.name} lead={`The files on ${project.site}, kept and served by the site. Carrel only shows them here.`} />
<ProjectTabs slug={project.slug} current="media" mentions={project.mentions} />
{unavailable ? (
<Banner tone="warn">{unavailable}</Banner>
) : (
<>
{/* An alert on a refusal, a status on success: one is an event to act on, the other a confirmation. */}
{actionData?.intent === "upload" ? (
actionData.ok ? (
<Banner tone="ok">
Uploaded {actionData.name}. <Link to={here({ id: actionData.id })}>See its details</Link>.
</Banner>
) : (
<Alert tone="crit">{actionData.message}</Alert>
)
) : null}
{result?.intent === "delete" && "ok" in result && result.ok ? <Banner tone="ok">Deleted {result.id} from the site.</Banner> : null}
{bulkResult ? <BulkResultPanel bulkResult={bulkResult} resultRef={resultRef} onClear={() => setBulkResult(null)} /> : null}
{emptied && "ok" in emptied && emptied.ok ? <TrashEmptiedPanel emptied={emptied} /> : null}
{emptied && "ok" in emptied && !emptied.ok ? <Alert tone="crit">{emptied.message}</Alert> : null}
{emptied && "needsConfirm" in emptied ? (
<Alert tone="warn" title={`Delete everything in the trash from ${project.site}?`}>
<Form method="post" className="app-form">
<p>These files leave the site's storage, and nothing in Carrel can bring them back. The site refuses any a post still uses.</p>
<ul className="app-list">
{trashFiles.map((f) => (
<li key={f.id}>{f.label}</li>
))}
</ul>
{trashMore ? <p>The trash holds more than one request handles; the rest stay until you empty it again.</p> : null}
<input type="hidden" name="confirm" value="empty" />
<div className="app-actions">
<Button type="submit" name="intent" value="empty-trash" variant="danger" pending={busy}>
Empty the trash
</Button>
<Link to={here({})} className="cap-btn">
Keep them
</Link>
</div>
</Form>
</Alert>
) : null}
<div className="app-media" data-detail={detail || missingId ? "" : undefined}>
<Panel
title={trashView ? "Trash" : "Files"}
count={items.length}
src={`On ${project.site}`}
flush
actions={
trashView && canDelete && trashFiles.length > 0 ? (
<Form method="post">
<Button
type="submit"
name="intent"
value="empty-trash"
variant="danger"
size="sm"
onClick={(event) => {
event.preventDefault();
setConfirming(event.currentTarget);
setEmptying(true);
}}
>
Empty the trash
</Button>
</Form>
) : undefined
}
>
<div className="cap-panel-pad">
{offers.trash ? (
<nav className="app-actions" aria-label="Library or trash">
<Link to={here({ view: "" })} className="cap-btn" data-variant={trashView ? undefined : "primary"} aria-current={trashView ? undefined : "page"}>
Library
</Link>
<Link to={here({ view: "trash" })} className="cap-btn" data-variant={trashView ? "primary" : undefined} aria-current={trashView ? "page" : undefined}>
Trash
</Link>
</nav>
) : null}
<Form method="get" className="app-filters" role="search" aria-label="Search media">
{trashView ? <input type="hidden" name="view" value="trash" /> : null}
<Field label="Search">
<input className="cap-input" type="search" name="q" defaultValue={q} placeholder="Name, address or alt text" />
</Field>
{offers.tags ? (
<Field label="Tag" error={tagInvalid ? "A tag is lower case words joined by hyphens." : undefined}>
<input className="cap-input" type="text" name="tag" defaultValue={tag} autoComplete="off" placeholder="Only files with this tag" />
</Field>
) : null}
<div className="app-actions">
<Button type="submit" variant="primary">
Search
</Button>
{q || tag ? (
<Link to={trashView ? "?view=trash" : "."} className="cap-btn">
Clear
</Link>
) : null}
</div>
</Form>
{canUpload && limits && !trashView ? (
<Disclosure summary="Upload a file" defaultOpen={actionData?.intent === "upload" && !actionData.ok}>
<Form method="post" encType="multipart/form-data" className="app-form">
<DropZone name="file" accept={limits.types.join(",")} maxBytes={limits.maxBytes} maxFiles={1} hint={`Up to ${size(limits.maxBytes)}: ${limits.types.map((t) => t.split("/")[1]).join(", ")}.`} />
<Field label="Alt text" help="What the image shows, for someone who cannot see it.">
<input className="cap-input" type="text" name="alt" autoComplete="off" />
</Field>
<div className="app-actions">
<Button type="submit" name="intent" value="upload" variant="primary" pending={busy && navigation.formData?.get("intent") === "upload"}>
Upload
</Button>
</div>
</Form>
</Disclosure>
) : null}
</div>
{items.length === 0 ? (
<Empty
kind={q || tag ? "no-match" : "nothing-yet"}
flush
title={q || tag ? "Nothing matches" : trashView ? "The trash is empty" : "No files yet"}
action={q || tag ? <Link to={trashView ? "?view=trash" : "."} className="cap-btn">Clear the search</Link> : undefined}
>
{q || tag
? "Try the name, the address, the alt text or another tag."
: trashView
? "Files you move to the trash wait here until you restore them or empty the trash."
: canUpload
? "Upload the first file above, or drop one into a post's editor."
: "Files appear here once someone uploads them."}
</Empty>
) : (
<div className="cap-panel-pad">
<div className="cap-media" data-size="m">
<ul className="cap-media-grid" role="list" aria-label={trashView ? "Files in the trash" : "Files"}>
{items.map((item) => {
const image = item.contentType.startsWith("image/");
const itemLabel = label(item);
return (
<li key={item.id} className="cap-media-tile" data-active={detail?.id === item.id ? "" : undefined} data-selected={selected.includes(item.id) ? "" : undefined}>
{/* The whole tile is the link, named for the file: one tab stop per file. */}
<Link to={here({ id: item.id })} className="cap-media-open" preventScrollReset aria-describedby={`flags-${item.id}`} aria-current={detail?.id === item.id ? "true" : undefined}>
<span className="cap-media-thumb">
{image ? (
<img src={item.src} alt="" loading="lazy" decoding="async" width={320} height={320} />
) : (
<span className="cap-media-doc" aria-hidden="true">
{(item.contentType.split("/").pop() ?? "file").slice(0, 5).toUpperCase()}
</span>
)}
</span>
<span className="cap-media-name" title={item.id}>
{itemLabel}
</span>
<span className="cap-media-meta">
{(item.contentType.split("/").pop() ?? "").toUpperCase()} · {size(item.bytes)}
</span>
</Link>
<span className="cap-media-flags" id={`flags-${item.id}`}>
{image && !item.alt && !trashView ? <Status tone="warn">No alt text</Status> : null}
{item.tags && item.tags.length > 0 ? <span className="cap-muted">{item.tags.join(", ")}</span> : null}
</span>
{bulkOffered ? (
<label className="cap-media-check">
<input
type="checkbox"
value={item.id}
aria-label={`Select ${itemLabel}`}
checked={selected.includes(item.id)}
onChange={(event) => setSelected((now) => (event.target.checked ? [...now, item.id] : now.filter((id) => id !== item.id)))}
/>
<span className="cap-media-check-box" aria-hidden="true">
<svg viewBox="0 0 16 16">
<path fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" d="M3.5 8.5l3 3 6-7" />
</svg>
</span>
<span className="cap-media-check-word" aria-hidden="true">
Selected
</span>
</label>
) : null}
</li>
);
})}
</ul>
</div>
{nextCursor ? (
<p className="app-more">
<Link to={here({ cursor: nextCursor })} className="cap-btn">
Next page
</Link>
</p>
) : null}
</div>
)}
</Panel>
{missingId ? (
<Panel title="Details">
<p>
The site has no file {missingId}. It may have been deleted. <Link to={here({})}>Back to the library</Link>.
</p>
</Panel>
) : null}
{detail ? (
<MediaDetailPanel
detail={detail}
site={project.site}
offers={offers}
canUpload={canUpload}
canDelete={canDelete}
written={written}
refused={refused}
serverAsks={serverAsks}
busy={busy}
pending={busy ? String(navigation.formData?.get("intent") ?? "") : null}
here={here}
onAskDelete={(button) => {
setConfirming(button);
setAsking(true);
}}
/>
) : null}
</div>
{bulkOffered && items.length > 0 ? (
<BulkBar
items={chosen.map((i) => ({ id: i.id, label: label(i) }))}
total={items.length}
onSelectAll={() => setSelected(items.map((i) => i.id))}
onClear={() => setSelected([])}
actions={bulkActions}
>
{offers.tags ? (
<span className="cap-bulk-field">
<label htmlFor="bulk-tags">Tag</label>
<input className="cap-input" id="bulk-tags" value={bulkTags} onChange={(event) => setBulkTags(event.target.value)} autoComplete="off" />
</span>
) : null}
</BulkBar>
) : null}
<ConfirmDialog
open={asking}
title={`Delete ${name} from the site?`}
lead="The file leaves the site's storage, and nothing in Carrel can bring it back."
body={detail && detail.usedBy.length > 0 ? ["The site's check lists places that use it, and may refuse."] : []}
action="Delete from the site"
returnTo={confirming}
perform={async () => {
if (!detail) return;
await deleter.submit({ intent: "delete", id: detail.id, confirm: "delete" }, { method: "post" });
}}
onClose={() => setAsking(false)}
/>
<ConfirmDialog
open={emptying}
title={`Delete ${trashFiles.length} ${trashFiles.length === 1 ? "file" : "files"} in the trash?`}
lead={`Every file below leaves ${project.site}'s storage, and nothing in Carrel can bring it back. The site refuses any file a post still uses, and that file stays in the trash.`}
body={[...trashFiles.map((f) => f.label), ...(trashMore ? ["The trash holds more than one request handles; the rest stay until you empty it again."] : [])]}
action="Empty the trash"
returnTo={confirming}
perform={async () => {
await deleter.submit({ intent: "empty-trash", confirm: "empty" }, { method: "post" });
}}
onClose={() => setEmptying(false)}
/>
</>
)}
</div>
);
}