tmp/webapp-docs/frontend/App.js

/**
 * The SPA shell: a slim brand top-bar (logo + wordmark + a Generate / Gallery / Single view switch
 * + a Providers dropdown (image + text providers, each with its BYOK key) + a provider-settings
 * gear + NSFW toggle) over the three top-level views and a privacy footer.
 *
 * All three views (Home, Gallery, SingleView) stay **mounted** the whole session and are shown one
 * at a time via a CSS class — so each keeps its full state (and scroll position) when you switch
 * tabs, with no manual save/restore. The shared bits they coordinate on — the saved-image feed, the
 * gallery search query, and the image open in the single view — live here in `App`. Clicking a
 * generated image (in Home) or a gallery thumbnail opens it in the single view; Back returns to
 * wherever you came from, intact. A shared link (`#s=…`) seeds settings on load.
 *
 * The online build (`VITE_ONLINE`) is a stripped variant: Generate works, but the Gallery/Single
 * tabs, the local image providers, and the NSFW toggle are shown **disabled** (greyed, with a
 * tooltip + a link to the full desktop version) because they need a local machine; NSFW is also
 * forced off. See `lib/online.js`.
 * @module gui/App
 */
import { lazy, Suspense, useEffect, useRef, useState } from "react";
import { useIntl, defineMessages } from "react-intl";
import { I18nProvider } from "./i18n/index.js";
import { ThemeProvider } from "./theme/ThemeProvider.jsx";
import { useUserThemes } from "./theme/userThemeStore.js";
import { hydrate, isHydrated, rehydrate, msSinceLastWrite } from "../storage/cache.js";
import { useSettings, loadSettings } from "./lib/settings.js";
import { readSharedSettings } from "./lib/share.js";
import { fetchGallery, linkAncestry } from "./lib/gallery.js";
import { fetchMagick } from "./lib/magick.js";
import { deleteImageFile, ingestImage } from "./lib/output.js";
import { deriveImage as runDerive, resizeImage as runResize } from "./lib/derive.js";
import { softLockedForNsfw } from "./lib/contentPolicy.js";
import { effectiveKey } from "./lib/sessionKeys.js";
import { ONLINE, lockedHint, openFullVersion } from "./lib/online.js";
import { getProvider, availableProviders } from "./lib/providers/index.js";
import { providerMode } from "./lib/useProvider.js";
import { refreshCatalog, ensureCatalog } from "./lib/promptEngine.js";
import { managerAvailable } from "./lib/manageApi.js";
import { generateIntoGallery as runGenerateIntoGallery } from "./lib/gallery/generateIntoGallery.js";
import { dialog } from "./lib/dialog.js";
import { APP_VERSION } from "./lib/version.js";
import NsfwToggle from "./components/NsfwToggle.jsx";
import PromptComposer from "./components/PromptComposer.jsx";
import ProvidersMenu from "./components/ProvidersMenu.jsx";
import ProviderGear from "./components/ProviderGear.jsx";
import LinksMenu from "./components/LinksMenu.jsx";
import ThemePicker from "./components/ThemePicker.jsx";
import DialogHost from "./components/DialogHost.jsx";
import UpdateBanner from "./components/UpdateBanner.jsx";
import { MoreIcon } from "./components/icons.jsx";
// Home (the Generate view) is imported statically, not lazily: it holds the building-block palette,
// which is the Largest Contentful Paint. The online build PRERENDERS the shell + palette to static
// HTML (see prerender/), so its markup must be in the server-rendered tree AND match the client's
// first render — a lazy Home would render nothing server-side and mismatch on hydrate. Keeping it
// static costs the local build nothing meaningful (it runs on localhost); the online build's FCP now
// comes from the prerendered HTML rather than from deferring this chunk.
import Home from "./components/Home.jsx";

// The local-only views are lazy-loaded so their code (and, for Manage, all of CodeMirror)
// is split into separate chunks the browser fetches only when the view is first opened —
// keeping the initial Generate-screen payload small. Once opened, a pane stays mounted (so
// its state/scroll persist), exactly as before; lazy only defers the FIRST mount.
import { jsxDEV as _jsxDEV } from "react/jsx-dev-runtime";
const Gallery = /*#__PURE__*/lazy(() => import("./components/Gallery.jsx"));
const SingleView = /*#__PURE__*/lazy(() => import("./components/SingleView.jsx"));
const Manage = /*#__PURE__*/lazy(() => import("./components/Manage.jsx"));

// [id, labelKey, featureKey, descKey]. Gallery/Single are local-only — online shows them disabled.
// Manage needs the local-mode file backend (a runtime capability, not the build stage).
// labelKey/featureKey/descKey index into `msgs` below so the strings are localized at render.
const TABS = [["generate", "tabGenerate", null, "tabGenerateDesc"], ["gallery", "tabGallery", "featureGallery", "tabGalleryDesc"], ["single", "tabSingle", "featureSingle", "tabSingleDesc"], ["manage", "tabManage", null, "tabManageDesc"]];
const msgs = defineMessages({
  switchView: {
    id: "app.switchView",
    defaultMessage: "Switch view",
    description: "aria-label for the top-bar view switcher"
  },
  skipToContent: {
    id: "app.skipToContent",
    defaultMessage: "Skip to main content",
    description: "Keyboard skip link shown when focused, jumps past the header to the main view"
  },
  moreControls: {
    id: "app.moreControls",
    defaultMessage: "More controls",
    description: "aria-label/title for the top-bar overflow button that reveals the secondary controls (providers, theme, links…) on narrow screens"
  },
  tabGenerate: {
    id: "app.tab.generate",
    defaultMessage: "Generate"
  },
  tabGallery: {
    id: "app.tab.gallery",
    defaultMessage: "Gallery"
  },
  tabSingle: {
    id: "app.tab.single",
    defaultMessage: "Single"
  },
  tabManage: {
    id: "app.tab.manage",
    defaultMessage: "Manage"
  },
  tabGenerateDesc: {
    id: "app.tab.generate.desc",
    defaultMessage: "Compose prompts and generate images"
  },
  tabGalleryDesc: {
    id: "app.tab.gallery.desc",
    defaultMessage: "Browse your saved images"
  },
  tabSingleDesc: {
    id: "app.tab.single.desc",
    defaultMessage: "View one image up close — details, re-rolls, and resizes"
  },
  tabManageDesc: {
    id: "app.tab.manage.desc",
    defaultMessage: "Edit the building blocks and word lists on disk"
  },
  featureGallery: {
    id: "app.feature.gallery",
    defaultMessage: "The gallery",
    description: "Noun phrase for the gallery, used in the online-locked tooltip"
  },
  featureSingle: {
    id: "app.feature.single",
    defaultMessage: "The single-image view",
    description: "Noun phrase for the single view, used in the online-locked tooltip"
  },
  manageLocked: {
    id: "app.manageLocked",
    defaultMessage: "Manage needs the local app (file access) — unavailable online",
    description: "Tooltip when the Manage tab is unavailable (no local backend)"
  },
  deleteConfirm: {
    id: "app.deleteConfirm",
    defaultMessage: "Delete this image and its metadata from disk? This can't be undone.",
    description: "Confirm dialog before deleting an image from disk"
  },
  deleteManyConfirm: {
    id: "app.deleteManyConfirm",
    defaultMessage: "Delete {count, plural, one {# image} other {# images}} and their metadata from disk? This can't be undone.",
    description: "Confirm dialog before mass-deleting selected images from disk"
  },
  deleteManyFailed: {
    id: "app.deleteManyFailed",
    defaultMessage: "{count, plural, one {# image} other {# images}} couldn't be deleted and are still there.",
    description: "Alert shown when some images in a mass delete fail to delete"
  },
  deleteAction: {
    id: "app.deleteAction",
    defaultMessage: "Delete",
    description: "Accept-button label on the delete-image confirm dialog"
  },
  nsfwProceed: {
    id: "app.nsfwProceed",
    defaultMessage: "NSFW mode is on. Continue with {provider} anyway?",
    description: "Soft confirm before using a safe-for-work-only provider while NSFW mode is on"
  },
  footer: {
    id: "app.footer",
    defaultMessage: "Stored only in this browser · bring your own API key · nothing saved on a server",
    description: "Privacy footer line"
  }
});

/**
 * The application root: owns settings and establishes the i18n boundary so the
 * whole shell (and its localized strings) renders under a configured provider.
 * @returns {JSX.Element}
 */
export default function App() {
  // The storage cache hydrates asynchronously (settings/presets/wrappers/provider overrides — from
  // localStorage online, one fetch locally, migrating any legacy keys). The stores read synchronously
  // from the cache afterward. When the cache isn't ready yet the stores return their DEFAULTS.
  //
  // LOCAL waits for hydration before the first render (a brief blank frame) so it never flashes
  // defaults over saved settings. ONLINE renders immediately with defaults instead — this is what
  // makes the build-time prerender work: the server render (empty cache → defaults) exactly matches
  // the client's first render (empty cache → defaults), so `hydrateRoot` reuses the prerendered DOM
  // with no mismatch. A returning visitor's saved settings then apply when hydration completes (an
  // input-value settle, not a layout change); a first-time visitor sees no settle at all. During the
  // Node prerender there's no async hydration to await anyway, so rendering with defaults is correct.
  const [ready, setReady] = useState(isHydrated());
  useEffect(() => {
    if (ready) return undefined;
    let alive = true;
    hydrate().finally(() => alive && setReady(true));
    return () => {
      alive = false;
    };
  }, [ready]);
  if (!ready && !ONLINE) return null;
  return /*#__PURE__*/_jsxDEV(HydratedApp, {}, void 0, false);
}

/**
 * The app once the storage cache is hydrated: owns settings and establishes the i18n boundary.
 * @returns {JSX.Element}
 */
function HydratedApp() {
  const [settings, setSettings] = useSettings();
  const [userThemes, addUserTheme, removeUserTheme] = useUserThemes();
  return /*#__PURE__*/_jsxDEV(I18nProvider, {
    locale: settings.locale,
    children: /*#__PURE__*/_jsxDEV(ThemeProvider, {
      mode: settings.themeMode,
      setMode: m => setSettings(s => ({
        ...s,
        themeMode: m
      })),
      accent: settings.accent,
      setAccent: a => setSettings(s => ({
        ...s,
        accent: a
      })),
      userThemes: userThemes,
      addUserTheme: addUserTheme,
      removeUserTheme: removeUserTheme,
      children: [/*#__PURE__*/_jsxDEV(AppShell, {
        settings: settings,
        setSettings: setSettings
      }, void 0, false), /*#__PURE__*/_jsxDEV(DialogHost, {}, void 0, false)]
    }, void 0, true)
  }, void 0, false);
}

/**
 * The application shell component (top bar + the views + footer).
 * @param {object} props
 * @param {object} props.settings The current settings.
 * @param {Function} props.setSettings Update the settings.
 * @returns {JSX.Element}
 */
function AppShell({
  settings,
  setSettings
}) {
  const intl = useIntl();
  const [view, setView] = useState("generate"); // "generate" | "gallery" | "single"
  // Narrow screens collapse the secondary header controls (providers / gear / NSFW / theme / links)
  // behind a single "⋯" overflow button. `menuOpen` toggles the panel; it starts CLOSED so the
  // server-prerendered markup matches the client's first render (no hydration mismatch — the panel's
  // visibility is otherwise CSS-driven by width, never by JS at first paint). On desktop the controls
  // are always shown inline and this state is inert.
  const [menuOpen, setMenuOpen] = useState(false);
  const overflowRef = useRef(null); // wraps the toggle + controls, for outside-click dismissal
  // Views the user has opened at least once. A lazy pane is only rendered after its first
  // open, then kept mounted — so we never fetch the Gallery/Single/Manage chunks (CodeMirror
  // included) until they're actually needed, without losing per-view state on tab switches.
  const [opened, setOpened] = useState(() => new Set(["generate"]));
  const [items, setItems] = useState([]); // the saved-image feed (newest first)
  const [loadingItems, setLoadingItems] = useState(true);
  const [query, setQuery] = useState(""); // gallery search (lifted so it persists + chips can set it)
  const [current, setCurrent] = useState(null); // the image open in the single view
  const [returnTo, setReturnTo] = useState("generate"); // where the single view's Back goes
  const [deriveError, setDeriveError] = useState(""); // last re-roll / variation / resize failure
  const [derivations, setDerivations] = useState([]); // in-flight ops: { id, parentPath, kind }
  const [magick, setMagick] = useState({
    available: false,
    formats: []
  });
  const [managerOk, setManagerOk] = useState(false); // local-mode content backend present?
  const [galleryPending, setGalleryPending] = useState([]); // live placeholders for in-flight gens

  // A shared link (#s=...) seeds settings on load, then the hash is cleared.
  useEffect(() => {
    const shared = readSharedSettings();
    if (shared) {
      setSettings(s => ({
        ...s,
        ...shared
      }));
      history.replaceState(null, "", location.pathname + location.search);
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  // Load the feed + ImageMagick capability once for the session. The online build has no gallery
  // and no NSFW option, so it skips the feed and forces adult content off.
  useEffect(() => {
    if (ONLINE) {
      setLoadingItems(false);
      if (settings.includeAdult) setSettings(s => ({
        ...s,
        includeAdult: false
      }));
      // A saved provider that can't run online (a local-direct or hosted-proxy one) — fall back to
      // the first browser-direct image provider so generation works out of the box.
      const cur = getProvider(settings.provider);
      const usableOnline = cur && !cur.local && cur.transport !== "hosted-proxy";
      if (!usableOnline) {
        const avail = availableProviders();
        const fallback = avail.find(p => p.loadGenerate) || avail[0];
        if (fallback) {
          setSettings(s => ({
            ...s,
            provider: fallback.id,
            mode: providerMode(fallback.id)
          }));
        }
      }
    } else {
      loadFeed();
    }
    fetchMagick().then(setMagick);
    // Load the bundled prompt corpus — code-split off the initial module graph. The palette already
    // renders from the (free) name catalog, so defer the heavy content fetch to idle time: that keeps
    // it from competing with the preloaded fonts + first paint for bandwidth (better LCP), and the
    // palette/generation fill in once it resolves (via the catalog-change notification). See
    // promptEngine.ensureCatalog.
    const idle = typeof requestIdleCallback === "function" ? requestIdleCallback : fn => setTimeout(fn, 200);
    idle(() => ensureCatalog());
    // Switch the engine from the build-time bundle to the live disk snapshot (local mode only).
    // A no-op online / on a static host — Generate keeps using the bundled catalog.
    refreshCatalog().catch(() => {});
    // Probe for the content-management backend; its presence unlocks the Manage tab.
    managerAvailable().then(setManagerOk);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  // Remember each view the moment it's first shown, so its lazy pane mounts and then persists.
  useEffect(() => {
    setOpened(o => o.has(view) ? o : new Set(o).add(view));
  }, [view]);

  // While the narrow-screen overflow menu is open, dismiss it on an outside click or Escape.
  // Client-only (effect never runs during the Node prerender), so it doesn't affect first paint.
  useEffect(() => {
    if (!menuOpen) return undefined;
    const onPointer = e => {
      if (overflowRef.current && !overflowRef.current.contains(e.target)) setMenuOpen(false);
    };
    const onKey = e => {
      if (e.key === "Escape") setMenuOpen(false);
    };
    document.addEventListener("pointerdown", onPointer);
    document.addEventListener("keydown", onKey);
    return () => {
      document.removeEventListener("pointerdown", onPointer);
      document.removeEventListener("keydown", onKey);
    };
  }, [menuOpen]);

  // Local mode: one SSE stream makes the app aware of changes to its own files and hot-reloads.
  //   • data     → re-read the live catalog (lists / blocks) so Generate + Manage reflect
  //                external edits (Manage's tree refreshes via its subscribeCatalog).
  //   • output   → reload the gallery feed so images added / removed / changed on disk appear.
  //   • settings → re-read settings from disk so an external edit applies — but skip events caused
  //                by our OWN writes (msSinceLastWrite) and only swap when the value actually
  //                changed, so we never fight or clobber the app's own saves (never-corrupt rule).
  // A no-op online (no backend) or until the local content backend is detected.
  useEffect(() => {
    if (ONLINE || !managerOk) return undefined;
    let es;
    const timers = {};
    const debounce = (key, fn, ms = 250) => {
      clearTimeout(timers[key]);
      timers[key] = setTimeout(fn, ms);
    };
    try {
      es = new EventSource("/api/manage/watch");
      es.addEventListener("data", () => debounce("data", () => refreshCatalog().catch(() => {})));
      es.addEventListener("output", () => debounce("output", () => loadFeed()));
      es.addEventListener("settings", () => debounce("settings", async () => {
        if (msSinceLastWrite() < 1500) return; // our own save — ignore
        await rehydrate();
        const fresh = loadSettings();
        setSettings(prev => JSON.stringify(prev) === JSON.stringify(fresh) ? prev : fresh);
      }));
    } catch {
      /* no SSE here — the manual refresh paths still work */
    }
    return () => {
      if (es) es.close();
      for (const t of Object.values(timers)) clearTimeout(t);
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [managerOk]);
  async function loadFeed() {
    setLoadingItems(true);
    const it = linkAncestry(await fetchGallery());
    setItems(it);
    setLoadingItems(false);
    return it;
  }

  // Switch view via a header tab. Going to the single view the first time (no image loaded yet)
  // lands on a random one; otherwise it keeps the last image shown.
  function go(next) {
    if (next === "single") {
      if (view !== "single") setReturnTo(view);
      if (!current && items.length) setCurrent(items[Math.floor(Math.random() * items.length)]);
    }
    setView(next);
  }

  // Open a gallery thumbnail in the single view (Back returns to the gallery).
  function openFromGallery(item) {
    setCurrent(item);
    setReturnTo("gallery");
    setView("single");
  }

  // Open a freshly generated image (by its served path) in the single view (Back returns to
  // Generate). Resolve the path to a feed item — refreshing the feed if it's not loaded yet.
  async function openGeneratedImage(path) {
    let found = items.find(i => i.path === path);
    if (!found) found = (await loadFeed()).find(i => i.path === path);
    const name = (path.split("/").pop() || "").replace(/\.[^.]+$/, "");
    setCurrent(found || {
      path,
      file: path.split("/").pop(),
      name,
      meta: null
    });
    setReturnTo("generate");
    setView("single");
  }

  // Search the gallery for a keyword (from a single-view keyword chip).
  function searchFor(term) {
    setQuery(term);
    setView("gallery");
  }

  // Apply an updated metadata sidecar (e.g. an edited keyword list) to the feed + the open image,
  // so the single view reflects the save without a full feed reload.
  function updateItemMeta(path, meta) {
    if (!meta) return;
    setItems(list => list.map(x => x.path === path ? {
      ...x,
      meta
    } : x));
    setCurrent(cur => cur && cur.path === path ? {
      ...cur,
      meta
    } : cur);
  }

  // After a derive/resize lands, refresh the feed (rebuilds ancestry) and re-sync the open image to
  // its fresh copy so its children strips update in place — without leaving the current image.
  async function refreshAndResync(parentPath) {
    const fresh = await loadFeed();
    setCurrent(cur => cur ? fresh.find(i => i.path === cur.path) || cur : cur);
    return fresh;
  }

  // Re-roll / vary the open image: generate a NEW image from a chosen prompt layer. We deliberately
  // DON'T navigate away — a live placeholder appears in the matching strip below the image (via the
  // `derivations` list), and the finished child slots into that strip once the feed refreshes.
  // Soft NSFW gate: if the provider about to be called is safe-for-work-only and NSFW mode is on,
  // ask once before proceeding. Returns false only when the user declines. Never hard-blocks.
  async function nsfwOkFor(provider) {
    if (!softLockedForNsfw(provider, settings.includeAdult)) return true;
    return dialog.confirm({
      message: intl.formatMessage(msgs.nsfwProceed, {
        provider: provider?.label
      })
    });
  }
  async function deriveImage(item, kind, source) {
    setDeriveError("");
    if (!(await nsfwOkFor(getProvider(item.meta?.provider)))) return;
    const id = `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
    setDerivations(d => [...d, {
      id,
      parentPath: item.path,
      kind
    }]);
    try {
      await runDerive({
        item,
        kind,
        source,
        settings
      });
      await refreshAndResync(item.path);
    } catch (e) {
      setDeriveError(e.message || String(e));
    } finally {
      setDerivations(d => d.filter(x => x.id !== id));
    }
  }

  // AI upscale: run a provider's upscale adapter on the open image and save the result as a tracked
  // child (Resizes strip), same live-placeholder flow as a derive. Dormant until a provider ships a
  // `loadUpscale` adapter + `capabilities.upscale`; the single view only offers providers that have.
  async function upscaleImage(item, providerId) {
    setDeriveError("");
    const prov = getProvider(providerId);
    if (!prov?.loadUpscale) return;
    if (!(await nsfwOkFor(prov))) return;
    const id = `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
    setDerivations(d => [...d, {
      id,
      parentPath: item.path,
      kind: "resize"
    }]);
    try {
      const upscale = await prov.loadUpscale();
      const key = effectiveKey(prov.id, settings);
      const src = item.path.startsWith("/") ? new URL(item.path, location.origin).href : item.path;
      // Pass the LIVE app settings merged with this upscaler's own params (e.g. a local WebUI's URL),
      // not the source image's frozen generation snapshot.
      const upSettings = {
        ...settings,
        ...(settings.providerParams?.[prov.id] || {})
      };
      const {
        images
      } = await upscale({
        image: src,
        key,
        settings: upSettings
      });
      if (!images || !images.length) throw new Error("The upscaler returned no image.");
      const childMeta = {
        prompt: item.meta?.prompt || null,
        negative: item.meta?.negative || null,
        provider: item.meta?.provider,
        providerLabel: item.meta?.providerLabel,
        settings: {
          ...(item.meta?.settings || {}),
          aiUpscaleBy: prov.id
        },
        parent: item.name,
        derivedKind: "resize",
        aiUpscaleBy: prov.id,
        savedAt: new Date().toISOString()
      };
      await ingestImage(images[0], childMeta);
      await refreshAndResync(item.path);
    } catch (e) {
      setDeriveError(e.message || String(e));
    } finally {
      setDerivations(d => d.filter(x => x.id !== id));
    }
  }

  // Resize (downscale / upscale via ImageMagick) the open image into a new tracked child — same
  // in-place, live-placeholder flow as a derive, landing in the Resizes strip.
  async function resizeImage(item, scale) {
    setDeriveError("");
    const id = `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
    setDerivations(d => [...d, {
      id,
      parentPath: item.path,
      kind: "resize"
    }]);
    try {
      await runResize({
        item,
        scale
      });
      await refreshAndResync(item.path);
    } catch (e) {
      setDeriveError(e.message || String(e));
    } finally {
      setDerivations(d => d.filter(x => x.id !== id));
    }
  }

  // Delete an image (+ sidecar) from disk; in the single view, land on a neighbor (or leave).
  async function deleteItem(item) {
    if (!(await dialog.confirm({
      message: intl.formatMessage(msgs.deleteConfirm),
      confirmLabel: intl.formatMessage(msgs.deleteAction),
      destructive: true
    }))) return;
    await deleteImageFile(item.path);
    const i = items.findIndex(x => x.path === item.path);
    const neighbor = items[i + 1] || items[i - 1] || null;
    setItems(list => list.filter(x => x.path !== item.path));
    setCurrent(cur => cur && cur.path === item.path ? neighbor : cur);
    if (!neighbor && view === "single") setView(returnTo);
  }

  // Mass-delete a set of images (+ their sidecars) from disk, after one confirm. Used by the
  // gallery's multi-select mode. If the image open in the single view is among them, land on a
  // neighbor (or leave the single view).
  async function deleteManyItems(paths) {
    const targets = Array.isArray(paths) ? paths.filter(Boolean) : [];
    if (!targets.length) return false;
    if (!(await dialog.confirm({
      message: intl.formatMessage(msgs.deleteManyConfirm, {
        count: targets.length
      }),
      confirmLabel: intl.formatMessage(msgs.deleteAction),
      destructive: true
    }))) return false; // cancelled — the caller keeps the selection
    const doomed = new Set(targets);
    // Delete each file independently (one failure must not abort the rest), then rebuild the feed
    // from disk — that's authoritative, so we never clobber a concurrent SSE refresh or a gallery
    // generation that landed while the confirm dialog was open (the stale-snapshot bug).
    const results = await Promise.allSettled(targets.map(p => deleteImageFile(p)));
    const fresh = await loadFeed();
    setCurrent(cur => cur && doomed.has(cur.path) ? fresh[0] || null : cur);
    if (view === "single" && current && doomed.has(current.path)) setView(returnTo);
    const failed = results.filter(r => r.status === "rejected").length;
    if (failed) {
      // Surface partial failures rather than swallowing them; the successfully-deleted ones are
      // already gone from the rebuilt feed, and any that failed are still present.
      await dialog.alert({
        message: intl.formatMessage(msgs.deleteManyFailed, {
          count: failed
        })
      });
    }
    return true;
  }

  // Generate images straight into the gallery from the gallery's own prompt box: placeholder cells
  // appear at the top of the grid immediately and resolve into the finished images (via the feed) as
  // each batch lands. Soft NSFW gate first, mirroring Home. Errors propagate to the composer.
  async function generateIntoGallery(text) {
    if (!(await nsfwOkFor(getProvider(settings.provider)))) return;
    await runGenerateIntoGallery({
      text,
      settings,
      onAddPending: extra => setGalleryPending(p => [...extra, ...p]),
      onRemovePending: ids => {
        const gone = new Set(ids);
        setGalleryPending(p => p.filter(x => !gone.has(x.id)));
      },
      onBatchDone: () => loadFeed()
    });
  }
  const returnLabel = intl.formatMessage(returnTo === "generate" ? msgs.tabGenerate : msgs.tabGallery);
  return /*#__PURE__*/_jsxDEV("div", {
    className: "app",
    children: [/*#__PURE__*/_jsxDEV("a", {
      className: "skip-link",
      href: "#main-content",
      children: intl.formatMessage(msgs.skipToContent)
    }, void 0, false), /*#__PURE__*/_jsxDEV("h1", {
      className: "sr-only",
      children: "Random AI Prompt — richer AI image & text prompts, automatically"
    }, void 0, false), /*#__PURE__*/_jsxDEV("header", {
      className: "topbar",
      children: [/*#__PURE__*/_jsxDEV("div", {
        className: "brand",
        title: "Random AI Prompt",
        children: [/*#__PURE__*/_jsxDEV("img", {
          src: "/logo.png",
          alt: ""
        }, void 0, false), /*#__PURE__*/_jsxDEV("span", {
          className: "wordmark",
          children: "Random AI Prompt"
        }, void 0, false)]
      }, void 0, true), /*#__PURE__*/_jsxDEV("div", {
        className: "view-switch",
        role: "tablist",
        "aria-label": intl.formatMessage(msgs.switchView),
        children: TABS.map(([id, labelKey, featureKey, descKey]) => {
          const label = intl.formatMessage(msgs[labelKey]);
          // Manage is gated on the local-mode backend (a runtime capability); Gallery/Single are
          // gated on the online build. Manage clicks while locked are inert (no full-version link).
          const locked = id === "manage" ? !managerOk : ONLINE && id !== "generate";
          const hint = id === "manage" ? intl.formatMessage(msgs.manageLocked) : lockedHint(intl, featureKey ? intl.formatMessage(msgs[featureKey]) : label);
          // Unlocked tabs get a plain descriptive tooltip; locked ones explain why.
          const tabTitle = locked ? hint : intl.formatMessage(msgs[descKey]);
          // Whether this tab's panel is actually in the DOM (so aria-controls references a real
          // element). Generate is always mounted; the local-only panes mount once opened.
          const paneMounted = id === "generate" || (id === "manage" ? managerOk && opened.has("manage") : !ONLINE && opened.has(id));
          return /*#__PURE__*/_jsxDEV("button", {
            id: `tab-${id}`,
            role: "tab",
            "aria-selected": !locked && view === id,
            "aria-disabled": locked || undefined,
            "aria-controls": paneMounted ? `pane-${id}` : undefined,
            className: `vs-tab${!locked && view === id ? " on" : ""}${locked ? " is-locked" : ""}`,
            title: tabTitle,
            onClick: () => locked ? id !== "manage" ? openFullVersion() : undefined : go(id),
            children: [label, locked && /*#__PURE__*/_jsxDEV("span", {
              className: "lock-badge",
              "aria-hidden": "true",
              children: "🔒"
            }, void 0, false)]
          }, id, true);
        })
      }, void 0, false), /*#__PURE__*/_jsxDEV("div", {
        className: "topbar-spacer"
      }, void 0, false), /*#__PURE__*/_jsxDEV("div", {
        className: "topbar-overflow",
        ref: overflowRef,
        children: [/*#__PURE__*/_jsxDEV("button", {
          type: "button",
          className: "topbar-overflow-toggle",
          "aria-haspopup": "true",
          "aria-expanded": menuOpen,
          "aria-controls": "topbar-controls",
          "aria-label": intl.formatMessage(msgs.moreControls),
          title: intl.formatMessage(msgs.moreControls),
          onClick: () => setMenuOpen(o => !o),
          children: /*#__PURE__*/_jsxDEV(MoreIcon, {}, void 0, false)
        }, void 0, false), /*#__PURE__*/_jsxDEV("div", {
          className: "topbar-controls",
          id: "topbar-controls",
          children: [/*#__PURE__*/_jsxDEV(ProvidersMenu, {
            settings: settings,
            setSettings: setSettings
          }, void 0, false), /*#__PURE__*/_jsxDEV(ProviderGear, {
            settings: settings,
            setSettings: setSettings
          }, void 0, false), /*#__PURE__*/_jsxDEV(NsfwToggle, {
            settings: settings,
            setSettings: setSettings,
            locked: ONLINE
          }, void 0, false), /*#__PURE__*/_jsxDEV(ThemePicker, {}, void 0, false), /*#__PURE__*/_jsxDEV(LinksMenu, {
            settings: settings,
            setSettings: setSettings
          }, void 0, false)]
        }, void 0, true)]
      }, void 0, true)]
    }, void 0, true), /*#__PURE__*/_jsxDEV(UpdateBanner, {}, void 0, false), /*#__PURE__*/_jsxDEV("main", {
      id: "main-content",
      tabIndex: -1,
      children: [/*#__PURE__*/_jsxDEV("div", {
        id: "pane-generate",
        role: "tabpanel",
        "aria-labelledby": "tab-generate",
        hidden: view !== "generate",
        className: `view-pane${view === "generate" ? " on" : ""}`,
        children: /*#__PURE__*/_jsxDEV(Home, {
          settings: settings,
          setSettings: setSettings,
          onOpenImage: ONLINE ? undefined : openGeneratedImage
        }, void 0, false)
      }, void 0, false), !ONLINE && opened.has("gallery") && /*#__PURE__*/_jsxDEV("div", {
        id: "pane-gallery",
        role: "tabpanel",
        "aria-labelledby": "tab-gallery",
        hidden: view !== "gallery",
        className: `view-pane${view === "gallery" ? " on" : ""}`,
        children: /*#__PURE__*/_jsxDEV(Suspense, {
          fallback: null,
          children: /*#__PURE__*/_jsxDEV(Gallery, {
            items: items,
            loading: loadingItems,
            query: query,
            onQueryChange: setQuery,
            onOpen: openFromGallery,
            onRefresh: loadFeed,
            onDelete: deleteItem,
            onDeleteMany: deleteManyItems,
            pending: galleryPending,
            composer: /*#__PURE__*/_jsxDEV("div", {
              className: "g-composer",
              children: /*#__PURE__*/_jsxDEV(PromptComposer, {
                settings: settings,
                setSettings: setSettings,
                compact: true,
                onGenerate: generateIntoGallery
              }, void 0, false)
            }, void 0, false)
          }, void 0, false)
        }, void 0, false)
      }, void 0, false), !ONLINE && opened.has("single") && /*#__PURE__*/_jsxDEV("div", {
        id: "pane-single",
        role: "tabpanel",
        "aria-labelledby": "tab-single",
        hidden: view !== "single",
        className: `view-pane${view === "single" ? " on" : ""}`,
        children: /*#__PURE__*/_jsxDEV(Suspense, {
          fallback: null,
          children: /*#__PURE__*/_jsxDEV(SingleView, {
            items: items,
            current: current,
            magick: magick,
            settings: settings,
            active: view === "single",
            returnLabel: returnLabel,
            onBack: () => setView(returnTo),
            onNavigate: setCurrent,
            onDelete: deleteItem,
            onSearch: searchFor,
            onMetaUpdate: updateItemMeta,
            onDerive: deriveImage,
            onResize: resizeImage,
            onUpscale: upscaleImage,
            derivations: derivations,
            deriveError: deriveError
          }, void 0, false)
        }, void 0, false)
      }, void 0, false), managerOk && opened.has("manage") && /*#__PURE__*/_jsxDEV("div", {
        id: "pane-manage",
        role: "tabpanel",
        "aria-labelledby": "tab-manage",
        hidden: view !== "manage",
        className: `view-pane${view === "manage" ? " on" : ""}`,
        children: /*#__PURE__*/_jsxDEV(Suspense, {
          fallback: null,
          children: /*#__PURE__*/_jsxDEV(Manage, {
            settings: settings,
            available: managerOk,
            active: view === "manage"
          }, void 0, false)
        }, void 0, false)
      }, void 0, false)]
    }, void 0, true), /*#__PURE__*/_jsxDEV("footer", {
      children: [/*#__PURE__*/_jsxDEV("span", {
        className: "footer-privacy",
        children: intl.formatMessage(msgs.footer)
      }, void 0, false), /*#__PURE__*/_jsxDEV("span", {
        className: "footer-version",
        title: `Version ${APP_VERSION}`,
        children: ["v", APP_VERSION]
      }, void 0, true)]
    }, void 0, true)]
  }, void 0, true);
}