tmp/webapp-docs/frontend/components/Gallery.js

/**
 * The photo gallery grid — a browseable feed of every image saved to `output/`, each paired with
 * its `.json` metadata sidecar. A **virtualized** responsive grid of lazy-loaded thumbnails with a
 * keyword search box; clicking a thumbnail opens it in the single-image view (a sibling top-level
 * view).
 *
 * Virtualized so it scales to very large galleries (100k+ images) with no performance loss: only the
 * cells inside (and just around) the viewport are in the DOM, positioned with a leading spacer and a
 * full-height container so the scrollbar stays honest. The math lives in the pure, unit-tested
 * `lib/virtual/windowRange` helper. Cells are uniform squares (`object-fit: cover`) — the fixed cell
 * size is what makes row-windowing exact at scale.
 *
 * This is a controlled component: the feed (`items`), loading flag, and search `query` live in
 * `App` so the gallery keeps its place when you switch tabs (the view stays mounted, just hidden).
 * Local-only: the feed needs the dev server's filesystem access, so a static/online build passes an
 * empty list and the grid shows an explanatory note.
 *
 * Two extras layer onto the grid:
 *   - a `composer` slot rendered above the header (the narrow prompt box atop the Gallery), and
 *   - `pending` **placeholder** cells prepended to the grid — busy skeletons for images that are
 *     generating right now (from the gallery's own prompt box), which drop out as the finished
 *     images land in the feed.
 * And a **multi-select** mode: a per-cell checkbox with select-all / clear and a mass delete.
 * @module gui/components/Gallery
 */
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useIntl, defineMessages, FormattedMessage } from "react-intl";
import { searchHaystack, promptText } from "../lib/gallery.js";
import { isOutputFile, openImageFile, revealImageFile } from "../lib/output.js";
import { columnsFor, windowRange } from "../lib/virtual/windowRange.js";

// Uniform cell geometry (px). CELL matches the historical grid-auto-rows/min-column size; GAP the
// grid gap. ROW_H (cell + gap) is the row pitch the windowing math steps by. OVERSCAN rows render
// just outside the viewport so a fast fling never shows a blank band.
import { jsxDEV as _jsxDEV, Fragment as _Fragment } from "react/jsx-dev-runtime";
const CELL = 180;
const GAP = 8;
const ROW_H = CELL + GAP;
const OVERSCAN = 3;
const msgs = defineMessages({
  title: {
    id: "gallery.title",
    defaultMessage: "Photo gallery"
  },
  loading: {
    id: "gallery.loading",
    defaultMessage: "loading…"
  },
  countImages: {
    id: "gallery.countImages",
    defaultMessage: "{count, plural, one {# image} other {# images}}"
  },
  countMatches: {
    id: "gallery.countMatches",
    defaultMessage: "{count, plural, one {# match} other {# matches}}"
  },
  searchPlaceholder: {
    id: "gallery.searchPlaceholder",
    defaultMessage: "Search prompts, DPL, provider…"
  },
  searchAria: {
    id: "gallery.searchAria",
    defaultMessage: "Search the gallery"
  },
  rescanTitle: {
    id: "gallery.rescanTitle",
    defaultMessage: "Rescan the output folder"
  },
  refresh: {
    id: "gallery.refresh",
    defaultMessage: "Refresh"
  },
  emptyNone: {
    id: "gallery.emptyNone",
    defaultMessage: "No images yet."
  },
  noMatch: {
    id: "gallery.noMatch",
    defaultMessage: "No images match “{query}”."
  },
  openAria: {
    id: "gallery.openAria",
    defaultMessage: "Open: {label}",
    description: "aria-label on a gallery thumbnail (label = the image's prompt)"
  },
  selectAria: {
    id: "gallery.selectAria",
    defaultMessage: "Select: {label}",
    description: "aria-label on a gallery thumbnail while in multi-select mode"
  },
  openDefault: {
    id: "gallery.openDefault",
    defaultMessage: "Open in default app"
  },
  reveal: {
    id: "gallery.reveal",
    defaultMessage: "Reveal in file explorer"
  },
  delete: {
    id: "gallery.delete",
    defaultMessage: "Delete image"
  },
  // Multi-select
  select: {
    id: "gallery.select",
    defaultMessage: "Select"
  },
  selectTitle: {
    id: "gallery.selectTitle",
    defaultMessage: "Select multiple images to delete"
  },
  done: {
    id: "gallery.done",
    defaultMessage: "Done"
  },
  doneSelecting: {
    id: "gallery.doneSelecting",
    defaultMessage: "Exit selection"
  },
  selectAll: {
    id: "gallery.selectAll",
    defaultMessage: "Select all"
  },
  clearSelection: {
    id: "gallery.clearSelection",
    defaultMessage: "Clear"
  },
  selectedCount: {
    id: "gallery.selectedCount",
    defaultMessage: "{count, plural, =0 {None selected} one {# selected} other {# selected}}"
  },
  deleteSelected: {
    id: "gallery.deleteSelected",
    defaultMessage: "{count, plural, one {Delete # image} other {Delete # images}}"
  },
  selectionBarAria: {
    id: "gallery.selectionBarAria",
    defaultMessage: "Selection actions"
  },
  generating: {
    id: "gallery.generating",
    defaultMessage: "Generating…"
  }
});

/**
 * A thumbnail that fades in once loaded. Hovering reveals the same actions as the generate
 * thumbnails: open in the OS default app, reveal in the file explorer, and delete. In multi-select
 * mode the whole cell becomes a toggle (checkbox), and the hover actions are hidden.
 */
function Thumb({
  item,
  onOpen,
  onDelete,
  selectMode,
  selected,
  onToggleSelect
}) {
  const intl = useIntl();
  const [loaded, setLoaded] = useState(false);
  const label = promptText(item) || item.file;
  const onDisk = isOutputFile(item.path);
  return /*#__PURE__*/_jsxDEV("div", {
    className: `g-cell${selected ? " is-selected" : ""}`,
    children: [/*#__PURE__*/_jsxDEV("button", {
      className: "g-open",
      onClick: () => selectMode ? onToggleSelect(item) : onOpen(item),
      title: label,
      "aria-label": intl.formatMessage(selectMode ? msgs.selectAria : msgs.openAria, {
        label
      }),
      "aria-pressed": selectMode ? selected : undefined,
      children: [/*#__PURE__*/_jsxDEV("img", {
        src: item.path,
        alt: label,
        loading: "lazy",
        decoding: "async",
        className: `g-img${loaded ? " loaded" : ""}`,
        onLoad: () => setLoaded(true)
      }, void 0, false), promptText(item) && /*#__PURE__*/_jsxDEV("span", {
        className: "g-cell-cap",
        children: promptText(item).slice(0, 120)
      }, void 0, false)]
    }, void 0, true), selectMode && /*#__PURE__*/_jsxDEV("span", {
      className: `g-check${selected ? " on" : ""}`,
      "aria-hidden": "true",
      children: selected ? "✓" : ""
    }, void 0, false), !selectMode && /*#__PURE__*/_jsxDEV("div", {
      className: "img-actions",
      children: [onDisk && /*#__PURE__*/_jsxDEV(_Fragment, {
        children: [/*#__PURE__*/_jsxDEV("button", {
          title: intl.formatMessage(msgs.openDefault),
          onClick: () => openImageFile(item.path),
          children: "↗"
        }, void 0, false), /*#__PURE__*/_jsxDEV("button", {
          title: intl.formatMessage(msgs.reveal),
          onClick: () => revealImageFile(item.path),
          children: "⌖"
        }, void 0, false)]
      }, void 0, true), onDelete && /*#__PURE__*/_jsxDEV("button", {
        title: intl.formatMessage(msgs.delete),
        onClick: () => onDelete(item),
        children: "✕"
      }, void 0, false)]
    }, void 0, true)]
  }, void 0, true);
}

/**
 * A busy placeholder cell for an image that's generating right now (from the gallery's prompt box).
 * It shows a shimmer + spinner and the prompt text, and is replaced by the real thumbnail once the
 * finished image lands in the feed.
 */
function PlaceholderCell({
  label
}) {
  const intl = useIntl();
  return /*#__PURE__*/_jsxDEV("div", {
    className: "g-cell is-placeholder",
    "aria-hidden": "true",
    children: [/*#__PURE__*/_jsxDEV("div", {
      className: "g-ph-shimmer"
    }, void 0, false), /*#__PURE__*/_jsxDEV("div", {
      className: "g-ph-spin"
    }, void 0, false), /*#__PURE__*/_jsxDEV("span", {
      className: "g-cell-cap g-ph-cap",
      children: label || intl.formatMessage(msgs.generating)
    }, void 0, false)]
  }, void 0, true);
}

/**
 * The photo gallery grid (virtualized).
 * @param {object} props
 * @param {object[]} props.items The feed items (newest first).
 * @param {boolean} props.loading Whether the feed is still loading.
 * @param {string} props.query The current search query (lives in App).
 * @param {Function} props.onQueryChange `(value)`.
 * @param {Function} props.onOpen `(item)` — open an image in the single view.
 * @param {Function} props.onRefresh Re-scan the output folder.
 * @param {Function} [props.onDelete] `(item)` — delete an image (+ sidecar) from disk.
 * @param {Function} [props.onDeleteMany] `(paths)` — mass-delete images (+ sidecars) from disk.
 * @param {object[]} [props.pending] Live placeholder descriptors `{ id, label }` for in-flight gens.
 * @param {import('react').ReactNode} [props.composer] A slot rendered above the header (the box).
 * @returns {JSX.Element}
 */
export default function Gallery({
  items,
  loading,
  query,
  onQueryChange,
  onOpen,
  onRefresh,
  onDelete,
  onDeleteMany,
  pending = [],
  composer = null
}) {
  const intl = useIntl();
  const q = query.trim().toLowerCase();
  const filtered = useMemo(() => q ? items.filter(it => searchHaystack(it).includes(q)) : items, [items, q]);

  // --- Multi-select state ---
  const [selectMode, setSelectMode] = useState(false);
  const [selected, setSelected] = useState(() => new Set()); // item.path values
  const canSelect = typeof onDeleteMany === "function";

  // Prune the selection when the feed changes (deleted/rescanned) so the count never counts ghosts.
  useEffect(() => {
    setSelected(prev => {
      if (!prev.size) return prev;
      const live = new Set(items.map(i => i.path));
      const next = new Set([...prev].filter(p => live.has(p)));
      return next.size === prev.size ? prev : next;
    });
  }, [items]);
  const toggleSelect = item => setSelected(prev => {
    const next = new Set(prev);
    if (next.has(item.path)) next.delete(item.path);else next.add(item.path);
    return next;
  });
  const selectAllFiltered = () => setSelected(new Set(filtered.map(i => i.path)));
  const clearSelection = () => setSelected(new Set());
  const exitSelect = () => {
    setSelectMode(false);
    setSelected(new Set());
  };
  async function deleteSelected() {
    const paths = [...selected];
    if (!paths.length) return;
    // Clear the selection only when the delete actually happened — `onDeleteMany` returns false when
    // the user cancels the confirm dialog, in which case the selection should stay put.
    const deleted = await onDeleteMany(paths);
    if (deleted === false) return;
    setSelected(new Set());
  }
  const scrollerRef = useRef(null);
  const gridRef = useRef(null);
  // Live scroll/size metrics that drive the window. `gridTop` is where the grid begins within the
  // scroller (below the header), so the grid's own offset is scrollTop - gridTop.
  const [metrics, setMetrics] = useState({
    scrollTop: 0,
    viewportH: 0,
    width: 0,
    gridTop: 0
  });

  // Re-measure the scroller + grid geometry (viewport height, content width, grid offset, scrollTop).
  // Cheap: a couple of rect reads, coalesced via rAF on scroll.
  const measure = () => {
    const sc = scrollerRef.current;
    const grid = gridRef.current;
    if (!sc) return;
    const scRect = sc.getBoundingClientRect();
    const gridTop = grid ? grid.getBoundingClientRect().top - scRect.top + sc.scrollTop : 0;
    setMetrics(m => {
      const next = {
        scrollTop: sc.scrollTop,
        viewportH: sc.clientHeight,
        width: grid ? grid.clientWidth : sc.clientWidth,
        gridTop
      };
      // Skip the state churn if nothing meaningful moved (avoids a scroll→render feedback loop).
      if (next.scrollTop === m.scrollTop && next.viewportH === m.viewportH && next.width === m.width && next.gridTop === m.gridTop) return m;
      return next;
    });
  };

  // Measure on mount + whenever the scroller resizes (incl. becoming visible on a tab switch, and
  // the header wrapping on narrow screens, which shifts the grid's top). Also re-run when the
  // pending row or selection bar appears/disappears (they shift the grid's top).
  useLayoutEffect(() => {
    measure();
    const sc = scrollerRef.current;
    if (!sc || typeof ResizeObserver === "undefined") return undefined;
    const ro = new ResizeObserver(() => measure());
    ro.observe(sc);
    if (gridRef.current) ro.observe(gridRef.current);
    return () => ro.disconnect();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [filtered.length, pending.length, selectMode]);

  // rAF-throttle scroll so a fast fling doesn't dispatch a setState per scroll event.
  const rafRef = useRef(0);
  const onScroll = () => {
    if (rafRef.current) return;
    rafRef.current = requestAnimationFrame(() => {
      rafRef.current = 0;
      measure();
    });
  };
  useEffect(() => () => rafRef.current && cancelAnimationFrame(rafRef.current), []);
  const cols = columnsFor({
    width: metrics.width,
    minCell: CELL,
    gap: GAP
  });
  const rowCount = Math.ceil(filtered.length / cols);
  const {
    startRow,
    endRow,
    topPad,
    totalHeight
  } = windowRange({
    scrollTop: metrics.scrollTop,
    gridTop: metrics.gridTop,
    viewportH: metrics.viewportH || 600,
    // a sane default before the first measure
    rowHeight: ROW_H,
    rowCount,
    overscan: OVERSCAN
  });
  const windowStart = startRow * cols;
  const windowEnd = Math.min(filtered.length, endRow * cols);
  const windowItems = filtered.slice(windowStart, windowEnd);
  const gridStyle = {
    gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`,
    gridAutoRows: `${CELL}px`,
    gap: `${GAP}px`
  };
  // "All selected" must be judged against the currently-filtered items — a raw size compare is wrong
  // when the selection still holds items from a previous filter.
  const allSelected = filtered.length > 0 && filtered.every(item => selected.has(item.path));
  return /*#__PURE__*/_jsxDEV("div", {
    className: "gallery-view",
    ref: scrollerRef,
    onScroll: onScroll,
    children: /*#__PURE__*/_jsxDEV("div", {
      className: "g-inner",
      children: [composer, /*#__PURE__*/_jsxDEV("div", {
        className: "g-head",
        children: [/*#__PURE__*/_jsxDEV("div", {
          className: "g-head-left",
          children: [/*#__PURE__*/_jsxDEV("h2", {
            className: "g-title",
            children: intl.formatMessage(msgs.title)
          }, void 0, false), /*#__PURE__*/_jsxDEV("span", {
            className: "g-count",
            "aria-live": "polite",
            children: [loading ? intl.formatMessage(msgs.loading) : intl.formatMessage(msgs.countImages, {
              count: items.length
            }), !loading && q && ` · ${intl.formatMessage(msgs.countMatches, {
              count: filtered.length
            })}`]
          }, void 0, true)]
        }, void 0, true), /*#__PURE__*/_jsxDEV("div", {
          className: "g-head-right",
          children: [/*#__PURE__*/_jsxDEV("input", {
            className: "g-search",
            type: "search",
            placeholder: intl.formatMessage(msgs.searchPlaceholder),
            "aria-label": intl.formatMessage(msgs.searchAria),
            value: query,
            onChange: e => onQueryChange(e.target.value)
          }, void 0, false), canSelect && !selectMode && /*#__PURE__*/_jsxDEV("button", {
            className: "link-btn",
            onClick: () => setSelectMode(true),
            title: intl.formatMessage(msgs.selectTitle),
            children: intl.formatMessage(msgs.select)
          }, void 0, false), /*#__PURE__*/_jsxDEV("button", {
            className: "link-btn",
            onClick: onRefresh,
            title: intl.formatMessage(msgs.rescanTitle),
            children: intl.formatMessage(msgs.refresh)
          }, void 0, false)]
        }, void 0, true)]
      }, void 0, true), selectMode && /*#__PURE__*/_jsxDEV("div", {
        className: "g-select-bar",
        role: "toolbar",
        "aria-label": intl.formatMessage(msgs.selectionBarAria),
        children: [/*#__PURE__*/_jsxDEV("span", {
          className: "g-select-count",
          children: intl.formatMessage(msgs.selectedCount, {
            count: selected.size
          })
        }, void 0, false), /*#__PURE__*/_jsxDEV("div", {
          className: "grow"
        }, void 0, false), /*#__PURE__*/_jsxDEV("button", {
          className: "link-btn",
          onClick: selectAllFiltered,
          disabled: filtered.length === 0 || allSelected,
          children: intl.formatMessage(msgs.selectAll)
        }, void 0, false), /*#__PURE__*/_jsxDEV("button", {
          className: "link-btn",
          onClick: clearSelection,
          disabled: selected.size === 0,
          children: intl.formatMessage(msgs.clearSelection)
        }, void 0, false), /*#__PURE__*/_jsxDEV("button", {
          className: "btn-destructive",
          onClick: deleteSelected,
          disabled: selected.size === 0,
          children: intl.formatMessage(msgs.deleteSelected, {
            count: selected.size
          })
        }, void 0, false), /*#__PURE__*/_jsxDEV("button", {
          className: "link-btn",
          onClick: exitSelect,
          title: intl.formatMessage(msgs.doneSelecting),
          children: intl.formatMessage(msgs.done)
        }, void 0, false)]
      }, void 0, true), !loading && items.length === 0 && pending.length === 0 && /*#__PURE__*/_jsxDEV("div", {
        className: "g-empty",
        children: [/*#__PURE__*/_jsxDEV("p", {
          children: intl.formatMessage(msgs.emptyNone)
        }, void 0, false), /*#__PURE__*/_jsxDEV("p", {
          className: "g-empty-sub",
          children: /*#__PURE__*/_jsxDEV(FormattedMessage, {
            id: "gallery.emptySub",
            defaultMessage: "Generate some images and they'll appear here, read straight from your <code>output/</code> folder. (The gallery needs the local dev server — a static build has no folder to read.)",
            values: {
              code: chunks => /*#__PURE__*/_jsxDEV("code", {
                children: chunks
              }, void 0, false)
            }
          }, void 0, false)
        }, void 0, false)]
      }, void 0, true), !loading && items.length > 0 && filtered.length === 0 && pending.length === 0 && /*#__PURE__*/_jsxDEV("div", {
        className: "g-empty",
        children: /*#__PURE__*/_jsxDEV("p", {
          children: intl.formatMessage(msgs.noMatch, {
            query
          })
        }, void 0, false)
      }, void 0, false), pending.length > 0 && /*#__PURE__*/_jsxDEV("div", {
        className: "g-grid g-grid-pending",
        style: gridStyle,
        children: pending.map(p => /*#__PURE__*/_jsxDEV(PlaceholderCell, {
          label: p.label
        }, p.id, false))
      }, void 0, false), filtered.length > 0 && /*#__PURE__*/_jsxDEV("div", {
        className: "g-grid-window",
        ref: gridRef,
        style: {
          height: totalHeight
        },
        children: /*#__PURE__*/_jsxDEV("div", {
          className: "g-grid",
          style: {
            transform: `translateY(${topPad}px)`,
            ...gridStyle
          },
          children: windowItems.map(item => /*#__PURE__*/_jsxDEV(Thumb, {
            item: item,
            onOpen: onOpen,
            onDelete: onDelete,
            selectMode: selectMode,
            selected: selected.has(item.path),
            onToggleSelect: toggleSelect
          }, item.path, false))
        }, void 0, false)
      }, void 0, false)]
    }, void 0, true)
  }, void 0, false);
}