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

/**
 * The Home composer — a focused two-pane prompt workspace. The left pane is the
 * building-block cloud (keywords, lists, expansions, dynamic prompts); the right
 * pane is an editor-style composer: a prompt box that fills its space with a
 * rotating random suggestion, a compact action toolbar (generate / random /
 * clear / save / share), inline save + share panels, and the generated-prompt
 * list.
 *
 * Temporarily removed (see notes/plans/removed-pending-readd.md): image
 * generation, the chaos knob, presets, and the Normal/Anime style toggle (the
 * anime word lists mix SFW + explicit adult tags and need a proper split first).
 * @module web-app/components/Home
 */
import { useEffect, useMemo, useRef, useState } from "react";
import { getBlocks, generatePrompt, renderWrapperPart, expandPrompt } from "../lib/promptEngine.js";
import { saveCustomExpansion } from "../lib/customStore.js";
import { getDefaultWrapper } from "../lib/wrapperStore.js";
import { shareUrl } from "../lib/share.js";
import WrapperButton from "./WrapperFab.jsx";
import { jsxDEV as _jsxDEV, Fragment as _Fragment } from "react/jsx-dev-runtime";
const SUGGESTION_MS = 5000; // how often the rotating random suggestion refreshes

// Crisp monochrome action icons (stroke = currentColor) so the four field
// buttons read as one cohesive set.
const ico = {
  width: 18,
  height: 18,
  viewBox: "0 0 24 24",
  fill: "none",
  stroke: "currentColor",
  strokeWidth: 2,
  strokeLinecap: "round",
  strokeLinejoin: "round"
};
const SaveIcon = () => /*#__PURE__*/_jsxDEV("svg", {
  ...ico,
  "aria-hidden": "true",
  children: [/*#__PURE__*/_jsxDEV("path", {
    d: "M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"
  }, void 0, false), /*#__PURE__*/_jsxDEV("path", {
    d: "M17 21v-8H7v8M7 3v5h8"
  }, void 0, false)]
}, void 0, true);
const ShareIcon = () => /*#__PURE__*/_jsxDEV("svg", {
  ...ico,
  "aria-hidden": "true",
  children: [/*#__PURE__*/_jsxDEV("circle", {
    cx: "18",
    cy: "5",
    r: "3"
  }, void 0, false), /*#__PURE__*/_jsxDEV("circle", {
    cx: "6",
    cy: "12",
    r: "3"
  }, void 0, false), /*#__PURE__*/_jsxDEV("circle", {
    cx: "18",
    cy: "19",
    r: "3"
  }, void 0, false), /*#__PURE__*/_jsxDEV("line", {
    x1: "8.59",
    y1: "13.51",
    x2: "15.42",
    y2: "17.49"
  }, void 0, false), /*#__PURE__*/_jsxDEV("line", {
    x1: "15.41",
    y1: "6.51",
    x2: "8.59",
    y2: "10.49"
  }, void 0, false)]
}, void 0, true);
const ShuffleIcon = () => /*#__PURE__*/_jsxDEV("svg", {
  ...ico,
  "aria-hidden": "true",
  children: [/*#__PURE__*/_jsxDEV("polyline", {
    points: "16 3 21 3 21 8"
  }, void 0, false), /*#__PURE__*/_jsxDEV("line", {
    x1: "4",
    y1: "20",
    x2: "21",
    y2: "3"
  }, void 0, false), /*#__PURE__*/_jsxDEV("polyline", {
    points: "21 16 21 21 16 21"
  }, void 0, false), /*#__PURE__*/_jsxDEV("line", {
    x1: "15",
    y1: "15",
    x2: "21",
    y2: "21"
  }, void 0, false), /*#__PURE__*/_jsxDEV("line", {
    x1: "4",
    y1: "4",
    x2: "9",
    y2: "9"
  }, void 0, false)]
}, void 0, true);
const SparkleIcon = () => /*#__PURE__*/_jsxDEV("svg", {
  ...ico,
  fill: "currentColor",
  stroke: "none",
  "aria-hidden": "true",
  children: [/*#__PURE__*/_jsxDEV("path", {
    d: "M12 2.5l1.9 5.6 5.6 1.9-5.6 1.9L12 17.5l-1.9-5.6L4.5 10l5.6-1.9z"
  }, void 0, false), /*#__PURE__*/_jsxDEV("path", {
    d: "M19 14.5l.8 2.2 2.2.8-2.2.8-.8 2.2-.8-2.2-2.2-.8 2.2-.8z"
  }, void 0, false)]
}, void 0, true);

/**
 * The compose workspace.
 * @param {object} props
 * @param {object} props.settings The current settings.
 * @param {Function} props.setSettings Update the settings.
 * @returns {JSX.Element}
 */
export default function Home({
  settings,
  setSettings
}) {
  const [version, setVersion] = useState(0); // bump to refresh custom blocks
  const [query, setQuery] = useState("");
  const [activeCat, setActiveCat] = useState("");
  const [dynVer, setDynVer] = useState("v3"); // "v3" (default) | "v2" | "v1" superset for the dynamic blocks
  const [expName, setExpName] = useState("");
  const [prompts, setPrompts] = useState([]);
  const [error, setError] = useState("");
  const [suggestion, setSuggestion] = useState("");
  const [panel, setPanel] = useState(""); // "" | "save" | "share"
  const [shareLink, setShareLink] = useState("");
  const [copied, setCopied] = useState(false);
  // 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 blocks = useMemo(() => getBlocks(), [version]);
  const prompt = settings.prompt;
  const setPrompt = p => setSettings({
    ...settings,
    prompt: p
  });

  // A fresh random prompt suggestion that cycles every few seconds. The latest
  // settings live in a ref so the interval reads current word lists without
  // resetting its timer on every keystroke.
  const settingsRef = useRef(settings);
  settingsRef.current = settings;
  useEffect(() => {
    const roll = () => {
      try {
        setSuggestion(generatePrompt({
          ...settingsRef.current,
          prompt: "{#random-words}"
        }));
      } catch {
        /* engine not ready — skip this tick */
      }
    };
    roll();
    const id = setInterval(roll, SUGGESTION_MS);
    return () => clearInterval(id);
  }, []);
  function insert(token) {
    const sep = prompt && !/\s$/.test(prompt) ? ", " : "";
    setPrompt(`${prompt}${sep}${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]);

  // Random drops the currently-shown suggestion into the prompt box.
  function useSuggestion() {
    if (suggestion) setPrompt(suggestion);
  }

  // Generate from whatever is typed; if the box is empty, fall back to the
  // current suggestion (or a fresh random roll) so it's never a no-op.
  function buildPrompts() {
    setError("");
    try {
      // Frame each prompt with the active wrapper (start, your prompt, end) — the v3 root layer.
      // The wrapper boxes are DPL, so render them (probability/bullets) per prompt before joining.
      const text = prompt && prompt.trim() ? prompt : suggestion || "{#random-words}";
      // The Default wrapper is read live (so edits to it apply); a chosen named/None wrapper uses
      // its stored snapshot.
      const w = !settings.wrapperName || settings.wrapperName === "Default" ? getDefaultWrapper() : settings.wrapper ?? getDefaultWrapper();
      const count = Math.max(1, Number(settings.promptCount) || 1);
      // Whether blocks may contribute their own `Auto Begin` / `Auto End` framing (default on). When
      // off, only the user wrapper (or None) frames the prompt — no input from any block.
      const useAuto = settings.useAutoSections !== false;
      const out = [];
      for (let i = 0; i < count; i++) {
        const wrapped = [renderWrapperPart(w.start, settings), text, renderWrapperPart(w.end, settings)].map(s => (s || "").trim()).filter(Boolean).join(", ");
        const sink = {
          begin: [],
          end: []
        };
        const result = generatePrompt({
          ...settings,
          prompt: wrapped,
          autoSink: useAuto ? sink : null
        });
        // Fold each fired block's Auto Begin / Auto End into the prompt's start / end.
        const framed = useAuto ? [sink.begin.join(", "), result, sink.end.join(", ")].map(s => s.trim()).filter(Boolean).join(", ") : result;
        out.push(framed);
      }
      setPrompts(out);
    } catch (e) {
      setError(e.message || String(e));
    }
  }
  function toggleSave() {
    setPanel(p => p === "save" ? "" : "save");
  }
  // Opening Share builds a fresh link so it's ready to copy; the link stays
  // visible even if the clipboard is blocked.
  function toggleShare() {
    if (panel === "share") {
      setPanel("");
    } else {
      setShareLink(shareUrl(settings));
      setPanel("share");
    }
  }
  async function copyLink(url = shareLink) {
    try {
      await navigator.clipboard.writeText(url);
      setCopied(true);
      setTimeout(() => setCopied(false), 2000);
    } catch {
      setCopied(false); // leave the link visible for manual copy
    }
  }
  function saveExpansion() {
    const name = expName.trim();
    if (!name || !prompt.trim()) return;
    saveCustomExpansion(name, prompt);
    setExpName("");
    setPanel("");
    setVersion(v => v + 1);
  }
  function copyPrompt(p) {
    navigator.clipboard?.writeText(p).catch(() => {});
  }

  // Filter blocks by the single search box (matches token or label). Category pills
  // (the Lists folder headers) are kept only when a following entry survives.
  const q = query.trim().toLowerCase();
  const matchItem = i => (i.token || "").toLowerCase().includes(q) || (i.label || "").toLowerCase().includes(q);
  function filterItems(items) {
    if (!q) return items;
    const out = [];
    for (let k = 0; k < items.length; k++) {
      const i = items[k];
      if (i.category) {
        let any = false;
        for (let j = k + 1; j < items.length && !items[j].category; j++) if (matchItem(items[j])) {
          any = true;
          break;
        }
        if (any) out.push(i);
      } else if (matchItem(i)) {
        out.push(i);
      }
    }
    return out;
  }
  // Dynamic blocks carry v2/v1 variants; pick the one the navbar superset link selects.
  const effItems = b => b.dynVersioned ? b.variants[dynVer] || [] : b.items;
  const filtered = blocks.map(b => ({
    ...b,
    items: filterItems(effItems(b))
  })).filter(b => b.items.some(i => !i.category));

  // The active category (falls back to the first available when the current
  // selection is filtered away or unset).
  const active = filtered.find(b => b.title === activeCat) || filtered[0] || null;
  const activeItems = active ? active.items : [];
  return /*#__PURE__*/_jsxDEV("div", {
    className: "workspace",
    children: [/*#__PURE__*/_jsxDEV("aside", {
      className: "sidebar",
      children: [/*#__PURE__*/_jsxDEV("div", {
        className: "panel-head",
        children: [/*#__PURE__*/_jsxDEV("h3", {
          className: "panel-title",
          children: "Building blocks"
        }, void 0, false), /*#__PURE__*/_jsxDEV("input", {
          className: "picker-filter",
          placeholder: "Search blocks…",
          value: query,
          onChange: e => setQuery(e.target.value)
        }, void 0, false)]
      }, void 0, true), filtered.length === 0 ? /*#__PURE__*/_jsxDEV("p", {
        className: "empty",
        children: ["No building blocks match “", query, "”."]
      }, void 0, true) : /*#__PURE__*/_jsxDEV(_Fragment, {
        children: [/*#__PURE__*/_jsxDEV("nav", {
          className: "cat-tabs",
          children: (() => {
            const rows = [];
            let headDone = false;
            for (const b of filtered) {
              // A single "Prompts" heading (with the v1/v2 superset switch) precedes the
              // full/partial sub-tabs; the sub-tabs are full-width rows, just indented.
              if (b.dynVersioned && !headDone) {
                headDone = true;
                rows.push(/*#__PURE__*/_jsxDEV("div", {
                  className: "cat-head",
                  children: [/*#__PURE__*/_jsxDEV("span", {
                    className: "cat-name",
                    children: "Blocks"
                  }, void 0, false), /*#__PURE__*/_jsxDEV("span", {
                    className: "ver-links",
                    children: ["v1", "v2", "v3"].map(v => /*#__PURE__*/_jsxDEV("button", {
                      className: `ver-link${dynVer === v ? " on" : ""}`,
                      title: v === "v3" ? "Current generators (default)" : `Frozen legacy (${v}) generators`,
                      onClick: () => setDynVer(v),
                      children: v
                    }, v, false))
                  }, void 0, false)]
                }, "__prompts", true));
              }
              rows.push(/*#__PURE__*/_jsxDEV("button", {
                className: `cat-tab${b.dynVersioned ? " sub" : ""}${active && active.title === b.title ? " on" : ""}`,
                onClick: () => setActiveCat(b.title),
                children: [/*#__PURE__*/_jsxDEV("span", {
                  className: "cat-name",
                  children: b.dynVersioned ? b.subLabel || b.title : b.title
                }, void 0, false), /*#__PURE__*/_jsxDEV("span", {
                  className: "count-pill",
                  children: b.items.filter(i => !i.category).length
                }, void 0, false)]
              }, b.title, true));
            }
            return rows;
          })()
        }, void 0, false), /*#__PURE__*/_jsxDEV("div", {
          className: "chip-area",
          children: [active && active.hint && /*#__PURE__*/_jsxDEV("p", {
            className: "cat-hint",
            children: active.hint
          }, void 0, false), /*#__PURE__*/_jsxDEV("div", {
            className: "picker-list",
            children: [activeItems.slice(0, 400).map((i, idx) => i.category ? i.token ? /*#__PURE__*/_jsxDEV("button", {
              className: "cat-pill cat-pill-group",
              onMouseEnter: e => showTip(i, e),
              onMouseMove: moveTip,
              onMouseLeave: hideTip,
              onClick: () => insert(i.token),
              children: i.label
            }, `cat-${i.label}-${idx}`, false) : /*#__PURE__*/_jsxDEV("span", {
              className: "cat-pill",
              title: i.description || i.label,
              children: i.label
            }, `cat-${i.label}-${idx}`, false) : /*#__PURE__*/_jsxDEV("button", {
              className: "chip",
              onMouseEnter: e => showTip(i, e),
              onMouseMove: moveTip,
              onMouseLeave: hideTip,
              onClick: () => insert(i.token),
              children: i.label
            }, i.token, false)), activeItems.length > 400 && /*#__PURE__*/_jsxDEV("span", {
              className: "picker-more",
              children: ["+", activeItems.length - 400, " more — keep typing to filter"]
            }, void 0, true)]
          }, void 0, true)]
        }, void 0, true)]
      }, void 0, true)]
    }, void 0, true), /*#__PURE__*/_jsxDEV("div", {
      className: "main-col",
      children: [/*#__PURE__*/_jsxDEV("section", {
        className: "card composer",
        children: [/*#__PURE__*/_jsxDEV("div", {
          className: "composer-field",
          children: [/*#__PURE__*/_jsxDEV("textarea", {
            className: "prompt-input",
            value: prompt,
            onChange: e => setPrompt(e.target.value),
            placeholder: suggestion ? `Try: ${suggestion}` : "Type a prompt, or use the building blocks on the left…"
          }, void 0, false), prompt && /*#__PURE__*/_jsxDEV("button", {
            className: "clear-x",
            onClick: () => setPrompt(""),
            title: "Clear the prompt",
            "aria-label": "Clear the prompt",
            children: "✕"
          }, void 0, false), /*#__PURE__*/_jsxDEV("div", {
            className: "field-bar",
            children: [/*#__PURE__*/_jsxDEV("div", {
              className: "grow"
            }, void 0, false), /*#__PURE__*/_jsxDEV(WrapperButton, {
              settings: settings,
              setSettings: setSettings
            }, void 0, false), /*#__PURE__*/_jsxDEV("button", {
              className: `field-act${panel === "save" ? " on" : ""}`,
              onClick: toggleSave,
              disabled: !prompt.trim(),
              title: "Save as block",
              "aria-label": "Save as block",
              "aria-pressed": panel === "save",
              children: /*#__PURE__*/_jsxDEV(SaveIcon, {}, void 0, false)
            }, void 0, false), /*#__PURE__*/_jsxDEV("button", {
              className: `field-act${panel === "share" ? " on" : ""}`,
              onClick: toggleShare,
              title: "Share link",
              "aria-label": "Share link",
              "aria-pressed": panel === "share",
              children: /*#__PURE__*/_jsxDEV(ShareIcon, {}, void 0, false)
            }, void 0, false), /*#__PURE__*/_jsxDEV("button", {
              className: "field-act",
              onClick: useSuggestion,
              disabled: !suggestion,
              title: "Random — drop a suggestion in",
              "aria-label": "Random suggestion",
              children: /*#__PURE__*/_jsxDEV(ShuffleIcon, {}, void 0, false)
            }, void 0, false), /*#__PURE__*/_jsxDEV("button", {
              className: "field-act primary",
              onClick: buildPrompts,
              title: `Generate prompt${settings.promptCount > 1 ? "s" : ""}`,
              "aria-label": "Generate prompt",
              children: /*#__PURE__*/_jsxDEV(SparkleIcon, {}, void 0, false)
            }, void 0, false)]
          }, void 0, true)]
        }, void 0, true), panel === "save" && /*#__PURE__*/_jsxDEV("div", {
          className: "action-panel",
          children: /*#__PURE__*/_jsxDEV("div", {
            className: "ap-row",
            children: [/*#__PURE__*/_jsxDEV("i", {
              className: "panel-icon",
              "aria-hidden": "true",
              children: /*#__PURE__*/_jsxDEV(SaveIcon, {}, void 0, false)
            }, void 0, false), /*#__PURE__*/_jsxDEV("input", {
              className: "panel-input",
              placeholder: "Save this prompt as a reusable block…",
              value: expName,
              onChange: e => setExpName(e.target.value),
              onKeyDown: e => e.key === "Enter" && saveExpansion(),
              "aria-label": "Expansion name",
              autoFocus: true
            }, void 0, false), /*#__PURE__*/_jsxDEV("button", {
              className: "primary",
              onClick: saveExpansion,
              disabled: !expName.trim() || !prompt.trim(),
              children: "Save"
            }, void 0, false)]
          }, void 0, true)
        }, void 0, false), panel === "share" && /*#__PURE__*/_jsxDEV("div", {
          className: "action-panel",
          children: /*#__PURE__*/_jsxDEV("div", {
            className: "ap-row",
            children: [/*#__PURE__*/_jsxDEV("i", {
              className: "panel-icon",
              "aria-hidden": "true",
              children: /*#__PURE__*/_jsxDEV(ShareIcon, {}, void 0, false)
            }, void 0, false), /*#__PURE__*/_jsxDEV("input", {
              className: "panel-input",
              readOnly: true,
              value: shareLink,
              onFocus: e => e.target.select(),
              "aria-label": "Shareable link that restores these settings"
            }, void 0, false), /*#__PURE__*/_jsxDEV("button", {
              className: "primary",
              onClick: () => copyLink(),
              children: copied ? "✓ Copied" : "Copy"
            }, void 0, false)]
          }, void 0, true)
        }, void 0, false), error && /*#__PURE__*/_jsxDEV("p", {
          className: "error",
          children: error
        }, void 0, false)]
      }, void 0, true), prompts.length > 0 && /*#__PURE__*/_jsxDEV("section", {
        className: "card results-card",
        children: [/*#__PURE__*/_jsxDEV("div", {
          className: "results-head",
          children: [/*#__PURE__*/_jsxDEV("h2", {
            children: "Prompts"
          }, void 0, false), /*#__PURE__*/_jsxDEV("span", {
            className: "count",
            children: [prompts.length, " generated"]
          }, void 0, true)]
        }, void 0, true), /*#__PURE__*/_jsxDEV("ul", {
          className: "prompts",
          children: prompts.map((p, i) => /*#__PURE__*/_jsxDEV("li", {
            children: [/*#__PURE__*/_jsxDEV("span", {
              className: "idx",
              children: String(i + 1).padStart(2, "0")
            }, void 0, false), /*#__PURE__*/_jsxDEV("span", {
              children: p
            }, void 0, false), /*#__PURE__*/_jsxDEV("button", {
              className: "copy-mini",
              title: "Copy",
              onClick: () => copyPrompt(p),
              children: "copy"
            }, void 0, false)]
          }, i, true))
        }, 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: "Example:"
        }, void 0, false), " ", tipEx]
      }, void 0, true)]
    }, void 0, true)]
  }, void 0, true);
}