Skip to the content

Capsid menu

app/components/media/library-insert.tsx

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

Functions

The functions of library-insert.tsx
FunctionLinesComplexityCoveredCRAPMutation
LibraryInsert19 to 170180%CriticalCRAP 342No data: mutation testing has not run on this repo yet
<anonymous:28>28 to 3040%WatchCRAP 20No data: mutation testing has not run on this repo yet
<anonymous:33>33 to 3520%WatchCRAP 6No data: mutation testing has not run on this repo yet
close37 to 4110%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:44>44 to 4410%OkCRAP 2No data: mutation testing has not run on this repo yet
insert47 to 5130%WatchCRAP 12No data: mutation testing has not run on this repo yet
<anonymous:58>58 to 6110%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:65>65 to 6520%WatchCRAP 6No data: mutation testing has not run on this repo yet
<anonymous:80>80 to 8010%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:82>82 to 8720%WatchCRAP 6No data: mutation testing has not run on this repo yet
<anonymous:101>101 to 10410%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:107>107 to 10710%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:124>124 to 14020%WatchCRAP 6No data: mutation testing has not run on this repo yet
<anonymous:129>129 to 13220%WatchCRAP 6No data: mutation testing has not run on this repo yet
<anonymous:151>151 to 15410%OkCRAP 2No data: mutation testing has not run on this repo yet

Imports

It imports app/components/media/upload.ts. Imported by app/components/editor/writing-surface.tsx.

Source

// "From library": an image already on the site goes into the post, with its alt text. Capsomer's editor
// uploads (a drop, a paste, its Insert image button) but has no way to pick a file the site already
// holds, so this is Carrel's: a dialog over the project's media endpoint, then an alt step that starts
// from the alt the file already has, then one insert at the cursor through the writing surface, in
// whichever editor it shows.

import { useEffect, useRef, useState } from "react";
import { useFetcher } from "react-router";
import { Button } from "capsomer/react/button";
import { Dialog, DialogBody, DialogClose, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "capsomer/react/dialog";
import { Empty } from "capsomer/react/empty";
import { Field } from "capsomer/react/field";

import type { EditorMedia } from "./upload";

type Row = { id: string; url: string; src: string; filename: string | null; alt: string; contentType: string };
type Picked = { url: string; src: string; name: string };

export function LibraryInsert({ media, insert: put }: { media: EditorMedia; insert: (markdown: string) => void }) {
  const [open, setOpen] = useState(false);
  const [picked, setPicked] = useState<Picked | null>(null);
  const [alt, setAlt] = useState("");
  const [q, setQ] = useState("");
  const [opener, setOpener] = useState<HTMLElement | null>(null);
  const altField = useRef<HTMLInputElement>(null);
  const list = useFetcher<{ items: Row[]; nextCursor: string | null; error?: string }>();

  useEffect(() => {
    if (open && list.state === "idle" && !list.data) void list.load(media.endpoint);
  }, [open, list, media.endpoint]);

  // Focus goes to the alt field once a file is picked: the step is otherwise silent.
  useEffect(() => {
    if (picked) altField.current?.focus();
  }, [picked]);

  const close = () => {
    setOpen(false);
    setPicked(null);
    setAlt("");
  };

  // Images only: a PDF in the library is not something a figure can show.
  const images = (list.data?.items ?? []).filter((o) => o.contentType.startsWith("image/"));
  const loading = !list.data;

  const insert = () => {
    if (!picked || !alt.trim()) return;
    put(media.figure(picked.url, alt.trim()));
    close();
  };

  return (
    <>
      <Button
        variant="quiet"
        size="sm"
        onClick={(event) => {
          setOpener(event.currentTarget);
          setOpen(true);
        }}
      >
        Insert from library
      </Button>
      <Dialog open={open} onOpenChange={(o) => !o && close()} placement="center" size="lg" returnTo={opener} aria-labelledby="library-title">
        <DialogHeader divider>
          <DialogTitle id="library-title">{picked ? "Describe the image" : "Insert an image from the library"}</DialogTitle>
          <DialogDescription>{picked ? "The image is on the site. It is not in the post until it has alt text." : "Pick an image already on the site, then describe it."}</DialogDescription>
        </DialogHeader>
        <DialogBody>
          {picked ? (
            <div className="app-pick-alt" role="group" aria-label="Describe the image">
              <img className="app-pick-thumb" src={picked.src} alt="" />
              <div className="app-form">
                <Field label="Alt text" required help="What the image shows, for someone who cannot see it.">
                  <input
                    ref={altField}
                    className="cap-input"
                    value={alt}
                    onChange={(event) => setAlt(event.target.value)}
                    autoComplete="off"
                    onKeyDown={(event) => {
                      if (event.key === "Enter") {
                        event.preventDefault();
                        insert();
                      }
                    }}
                  />
                </Field>
                <p className="cap-muted">{picked.name}</p>
              </div>
            </div>
          ) : (
            <div className="app-form" role="group" aria-label="Insert an image from the library">
              <p className="cap-sr-only" role="status">
                {loading ? "Loading media" : images.length === 0 ? "No images" : `${images.length} image${images.length === 1 ? "" : "s"}`}
              </p>
              <form
                className="app-filters"
                role="search"
                onSubmit={(event) => {
                  event.preventDefault();
                  void list.load(`${media.endpoint}?q=${encodeURIComponent(q.trim())}`);
                }}
              >
                <Field label="Search the library">
                  <input className="cap-input" type="search" value={q} onChange={(event) => setQ(event.target.value)} placeholder="Name or alt text" />
                </Field>
                <Button type="submit">Search</Button>
              </form>
              {list.data?.error ? (
                <Empty kind="failed" flush title="The library did not answer">
                  {list.data.error}
                </Empty>
              ) : loading ? (
                <p className="cap-muted">Loading media...</p>
              ) : images.length === 0 ? (
                <Empty kind={q.trim() ? "no-match" : "nothing-yet"} flush title={q.trim() ? `No images match “${q.trim()}”` : "No images in the library yet"}>
                  {q.trim() ? "Try the name or the alt text." : "Drop or paste one into the editor to upload it."}
                </Empty>
              ) : (
                <div className="cap-media" data-size="s">
                  <ul className="cap-media-grid" role="list" aria-label="Images">
                    {images.map((o) => (
                      <li key={o.id} className="cap-media-tile">
                        <button
                          type="button"
                          className="cap-media-open"
                          onClick={() => {
                            setPicked({ url: o.url, src: o.src, name: o.filename ?? o.id });
                            setAlt(o.alt);
                          }}
                        >
                          <span className="cap-media-thumb">
                            <img src={o.src} alt="" loading="lazy" width={160} height={160} />
                          </span>
                          <span className="cap-media-name">{o.filename ?? o.id}</span>
                        </button>
                      </li>
                    ))}
                  </ul>
                </div>
              )}
              {list.data?.nextCursor ? <p className="cap-muted">Showing the first page. Search, or open the media library to page through the rest.</p> : null}
            </div>
          )}
        </DialogBody>
        <DialogFooter align="between">
          {picked ? (
            <Button
              onClick={() => {
                setPicked(null);
                setAlt("");
              }}
            >
              Back to the library
            </Button>
          ) : (
            <DialogClose part="cancel">Cancel</DialogClose>
          )}
          {picked ? (
            <Button variant="primary" onClick={insert} disabledReason={alt.trim() === "" ? "Describe the image first." : undefined}>
              Insert figure
            </Button>
          ) : null}
        </DialogFooter>
      </Dialog>
    </>
  );
}