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

/**
 * The Home composer — a focused two-pane prompt workspace. The left pane is the
 * building-block cloud (keywords, lists, expansions, blocks); the right
 * pane hosts the reusable {@link PromptComposer} prompt box (with all its bells and
 * whistles) above the generated-prompt list.
 *
 * Image generation is back (per the active provider: api-tier renders into the Gallery;
 * the syntax tier copies a formatted prompt). Still removed (see
 * notes/plans/removed-pending-readd.md): presets and the Normal/Anime style toggle (the anime
 * word lists mix SFW + explicit adult tags and need a proper split first).
 * @module gui/components/Home
 */
import { useEffect, useRef, useState } from "react";
import { useIntl, defineMessages } from "react-intl";
import { generatePrompt, renderWrapperPart, expandPrompt } from "../lib/promptEngine.js";
import { shouldReflectSeed } from "../lib/home/seed.js";
import { buildRoll } from "../lib/home/buildRoll.js";
import { getDefaultWrapper } from "../lib/wrapperStore.js";
import { getProvider } from "../lib/providers/index.js";
import { flattenForProvider } from "../lib/useProvider.js";
import { softLockedForNsfw } from "../lib/contentPolicy.js";
import { dialog } from "../lib/dialog.js";
import PromptComposer from "./PromptComposer.jsx";
import PromptResult from "./PromptResult.jsx";
import BlockPalette from "./home/BlockPalette.jsx";
import { BlocksIcon, TrashIcon } from "./icons.jsx";
import { useImageBatches } from "../lib/home/useImageBatches.js";
import { jsxDEV as _jsxDEV } from "react/jsx-dev-runtime";
const msgs = defineMessages({
  promptsHeading: {
    id: "home.promptsHeading",
    defaultMessage: "Prompts"
  },
  generatedCount: {
    id: "home.generatedCount",
    defaultMessage: "{count} generated · {provider}"
  },
  clearAllTitle: {
    id: "home.clearAllTitle",
    defaultMessage: "Clear all results"
  },
  clearAll: {
    id: "home.clearAll",
    defaultMessage: "Clear all"
  },
  example: {
    id: "home.example",
    defaultMessage: "Example:"
  },
  nsfwProceed: {
    id: "home.nsfwProceed",
    defaultMessage: "NSFW mode is on. Generate with {provider} anyway?"
  },
  openBlocks: {
    id: "home.openBlocks",
    defaultMessage: "Building blocks"
  },
  openBlocksAria: {
    id: "home.openBlocksAria",
    defaultMessage: "Open building blocks"
  }
});

/**
 * The compose workspace.
 * @param {object} props
 * @param {object} props.settings The current settings.
 * @param {Function} props.setSettings Update the settings.
 * @param {Function} [props.onOpenImage] Open a generated image (by served path) in the single view.
 * @returns {JSX.Element}
 */
export default function Home({
  settings,
  setSettings,
  onOpenImage
}) {
  const intl = useIntl();
  const [paletteOpen, setPaletteOpen] = useState(false); // phone: building-block drawer open?
  // Hover tooltip for a building block: its label, description (piped from the v3 file /
  // sidecar), and a LIVE example output that re-rolls while the pointer rests on the chip.
  const [tip, setTip] = useState(null); // { token, label, description, x, y }
  const [tipEx, setTipEx] = useState("");
  const composerRef = useRef(null); // the PromptComposer — palette inserts reach its active editor

  // --- Active image provider (selection lives in settings; knobs are per-provider) ---
  const provider = getProvider(settings.provider);
  const [providerFmt, setProviderFmt] = useState(null); // syntax/plain tier: prompt formatter
  const [providerDefaults, setProviderDefaults] = useState({});
  useEffect(() => {
    let alive = true;
    const p = getProvider(settings.provider);
    Promise.resolve(p?.loadSettings ? p.loadSettings() : {
      defaults: {}
    }).then(m => alive && setProviderDefaults(m?.defaults || {}));
    if (p?.loadFormat) p.loadFormat().then(f => alive && setProviderFmt(() => f));else setProviderFmt(null);
    return () => {
      alive = false;
    };
  }, [settings.provider]);

  // The flat settings an adapter/formatter reads: app settings + this provider's namespaced
  // params (under its schema defaults) + the dialect mode.
  const flat = flattenForProvider(settings, providerDefaults);
  const canGenerateImages = provider?.tier === "api" && !!provider?.loadGenerate;

  // The generated-prompt list and its image-batch lifecycle (run a batch + the rewrite passes,
  // and remove/clear images/batches/all) live in their own hook; Home just drives them.
  const {
    prompts,
    setPrompts,
    nextId,
    imgError,
    makeBatch,
    removeImage,
    removeBatch,
    clearImages,
    clearAll
  } = useImageBatches({
    settings,
    provider,
    flat
  });
  const settingsRef = useRef(settings);
  settingsRef.current = settings;

  // Phone: dismiss the building-block drawer on Escape (the scrim handles tap-away). Client-only,
  // so it never runs during the prerender.
  useEffect(() => {
    if (!paletteOpen) return undefined;
    const onKey = e => {
      if (e.key === "Escape") setPaletteOpen(false);
    };
    document.addEventListener("keydown", onKey);
    return () => document.removeEventListener("keydown", onKey);
  }, [paletteOpen]);

  // The palette inserts a building block into the composer's active editor (prompt or negative).
  function insert(token) {
    composerRef.current?.insert(token);
  }

  // --- Building-block hover tooltip (label + description + a refreshing example) ---
  const showTip = (item, e) => setTip({
    token: item.token,
    label: item.label,
    description: item.description,
    x: e.clientX,
    y: e.clientY
  });
  const moveTip = e => setTip(t => t ? {
    ...t,
    x: e.clientX,
    y: e.clientY
  } : t);
  const hideTip = () => setTip(null);

  // While a tip is shown, expand its token into a fresh example, re-rolling on an interval.
  // Examples are rendered WITHOUT the auto fx/artists framing so they show just the block.
  const tipToken = tip?.token;
  useEffect(() => {
    if (!tipToken) {
      setTipEx("");
      return undefined;
    }
    const roll = () => {
      try {
        setTipEx(expandPrompt(tipToken, {
          ...settingsRef.current,
          autoAddFx: false,
          autoAddArtists: false
        }));
      } catch {
        setTipEx("");
      }
    };
    roll();
    const id = setInterval(roll, 1400);
    return () => clearInterval(id);
  }, [tipToken]);

  // Generate from the effective prompt text (supplied by the composer). Frame each prompt with the
  // active wrapper, roll the batch, and auto-fire an image batch per prompt (api providers only).
  // Errors propagate to the composer, which surfaces them inline.
  async function buildPrompts(text) {
    const w = !settings.wrapperName || settings.wrapperName === "Default" ? getDefaultWrapper() : settings.wrapper ?? getDefaultWrapper();
    const {
      prompts: out,
      rollSeed
    } = buildRoll({
      settings,
      text,
      wrapper: w,
      mode: flat.mode,
      deps: {
        renderWrapperPart,
        generatePrompt,
        nextId
      }
    });
    // A new roll ADDS to the list, newest on top (Clear all / per-prompt clear to remove).
    setPrompts(prev => [...out, ...prev]);
    if (shouldReflectSeed(settings, rollSeed)) setSettings({
      ...settings,
      promptSeed: rollSeed
    });
    // Auto-render: kick off an image batch for each new prompt (api providers only). If the chosen
    // provider is safe-for-work-only and NSFW mode is on, ask once before sending.
    const nsfwOk = !softLockedForNsfw(provider, settings.includeAdult) || (await dialog.confirm({
      message: intl.formatMessage(msgs.nsfwProceed, {
        provider: provider?.label
      })
    }));
    if (canGenerateImages && nsfwOk) out.forEach(p => makeBatch(p.id, p.text, p.dpl));
  }

  // Copy the prompt. For a syntax/plain provider (e.g. Midjourney) copy the FORMATTED prompt
  // (with the provider's --params), which is the whole point of the syntax tier.
  function copyPrompt(p) {
    const text = provider?.tier !== "api" && providerFmt ? providerFmt(p, flat) : p;
    navigator.clipboard?.writeText(text).catch(() => {});
  }
  return /*#__PURE__*/_jsxDEV("div", {
    className: `workspace home${paletteOpen ? " palette-open" : ""}`,
    children: [/*#__PURE__*/_jsxDEV(BlockPalette, {
      includeAdult: settings.includeAdult,
      onInsert: insert,
      onShowTip: showTip,
      onMoveTip: moveTip,
      onHideTip: hideTip,
      onClose: () => setPaletteOpen(false)
    }, void 0, false), /*#__PURE__*/_jsxDEV("div", {
      className: "palette-scrim",
      onClick: () => setPaletteOpen(false),
      "aria-hidden": "true"
    }, void 0, false), /*#__PURE__*/_jsxDEV("button", {
      type: "button",
      className: "palette-trigger",
      onClick: () => setPaletteOpen(true),
      "aria-controls": "block-palette",
      "aria-expanded": paletteOpen,
      "aria-label": intl.formatMessage(msgs.openBlocksAria),
      title: intl.formatMessage(msgs.openBlocks),
      children: /*#__PURE__*/_jsxDEV(BlocksIcon, {}, void 0, false)
    }, void 0, false), /*#__PURE__*/_jsxDEV("div", {
      className: "main-col",
      children: [/*#__PURE__*/_jsxDEV(PromptComposer, {
        ref: composerRef,
        settings: settings,
        setSettings: setSettings,
        onGenerate: buildPrompts
      }, void 0, false), imgError && /*#__PURE__*/_jsxDEV("p", {
        className: "error",
        role: "alert",
        children: imgError
      }, void 0, false), prompts.length > 0 && /*#__PURE__*/_jsxDEV("section", {
        className: "card results-card",
        children: [/*#__PURE__*/_jsxDEV("div", {
          className: "results-head",
          children: [/*#__PURE__*/_jsxDEV("h2", {
            children: intl.formatMessage(msgs.promptsHeading)
          }, void 0, false), /*#__PURE__*/_jsxDEV("div", {
            className: "results-head-right",
            children: [/*#__PURE__*/_jsxDEV("span", {
              className: "count",
              children: intl.formatMessage(msgs.generatedCount, {
                count: prompts.length,
                provider: provider?.label
              })
            }, void 0, false), /*#__PURE__*/_jsxDEV("button", {
              className: "clear-all-btn",
              onClick: clearAll,
              title: intl.formatMessage(msgs.clearAllTitle),
              "aria-label": intl.formatMessage(msgs.clearAll),
              children: /*#__PURE__*/_jsxDEV(TrashIcon, {}, void 0, false)
            }, void 0, false)]
          }, void 0, true)]
        }, void 0, true), /*#__PURE__*/_jsxDEV("ul", {
          className: "prompts",
          children: prompts.map((p, i) => /*#__PURE__*/_jsxDEV(PromptResult, {
            prompt: p,
            index: i,
            number: prompts.length - i,
            settings: settings,
            canGenerate: canGenerateImages,
            onGenerate: makeBatch,
            onCopy: copyPrompt,
            onRemoveImage: removeImage,
            onRemoveBatch: removeBatch,
            onClearImages: clearImages,
            onImageClick: onOpenImage
          }, p.id, false))
        }, void 0, false)]
      }, void 0, true)]
    }, void 0, true), tip && /*#__PURE__*/_jsxDEV("div", {
      className: "block-tip",
      style: (() => {
        const vw = typeof window !== "undefined" ? window.innerWidth : 9999;
        const vh = typeof window !== "undefined" ? window.innerHeight : 9999;
        const left = Math.max(8, Math.min(tip.x + 16, vw - 360));
        // If the pointer is in the lower part of the screen, anchor the tip's bottom above it.
        return tip.y > vh * 0.6 ? {
          left,
          bottom: vh - tip.y + 18,
          maxHeight: tip.y - 16
        } : {
          left,
          top: tip.y + 18,
          maxHeight: vh - tip.y - 28
        };
      })(),
      role: "tooltip",
      children: [/*#__PURE__*/_jsxDEV("div", {
        className: "block-tip-name",
        children: [/*#__PURE__*/_jsxDEV("span", {
          className: "block-tip-label",
          children: tip.label
        }, void 0, false), /*#__PURE__*/_jsxDEV("code", {
          className: "block-tip-token",
          children: tip.token
        }, void 0, false)]
      }, void 0, true), tip.description && /*#__PURE__*/_jsxDEV("div", {
        className: "block-tip-desc",
        children: tip.description
      }, void 0, false), tipEx && /*#__PURE__*/_jsxDEV("div", {
        className: "block-tip-ex",
        children: [/*#__PURE__*/_jsxDEV("span", {
          className: "block-tip-ex-label",
          children: intl.formatMessage(msgs.example)
        }, void 0, false), " ", tipEx]
      }, void 0, true)]
    }, void 0, true)]
  }, void 0, true);
}