/**
* 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);
}