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

/**
 * The Manage tab — the in-app content manager. Same two-pane skeleton as Generate, but instead of
 * composing prompts it edits the catalog: a real, nested folder tree of the two data roots
 * (Blocks = blocks, Lists = lists) on the left, and a big editor pane on the right.
 *
 * This is the read-only-tree foundation (phase 2): the tree shows the actual on-disk structure with
 * categories vs subfolders color-coded, force-prefix / implied-group folders badged, `_`-markers
 * abstracted into those badges, NSFW gated by the header toggle, a search filter, and a Refresh
 * button. Each folder has a gear by its name and each entry a hover Edit action; both select the
 * item into the right pane (the actual editors arrive in the next phases). Local mode only — when the
 * management backend isn't present (online build / static host) the tab is locked upstream in `App`.
 * @module gui/components/Manage
 */
import { useIntl, defineMessages } from "react-intl";
import { useManageTree, USER_ROOTS } from "../lib/manage/useManageTree.js";
import { isDpRoot } from "../lib/manageTree.js";
import ManageBlockEditor from "./ManageBlockEditor.jsx";
import ManageFolderEditor from "./ManageFolderEditor.jsx";
import ManageListEditor from "./ManageListEditor.jsx";
import ManageDetail from "./manage/ManageDetail.jsx";
import { Caret, GearIcon, RefreshIcon, RestoreIcon, TrashIcon } from "./manage/icons.jsx";
import { ChevronLeftIcon } from "./icons.jsx";
import { jsxDEV as _jsxDEV, Fragment as _Fragment } from "react/jsx-dev-runtime";
const msgs = defineMessages({
  ghostTitle: {
    id: "manage.ghostTitle",
    defaultMessage: "{path} — deleted locally; restore from the repo"
  },
  ghostTag: {
    id: "manage.ghostTag",
    defaultMessage: "ghost"
  },
  restoreTitle: {
    id: "manage.restoreTitle",
    defaultMessage: "Restore {label} from the repo"
  },
  restoreAria: {
    id: "manage.restoreAria",
    defaultMessage: "Restore {label}"
  },
  nsfw: {
    id: "manage.nsfw",
    defaultMessage: "NSFW"
  },
  nsfwTag: {
    id: "manage.nsfwTag",
    defaultMessage: "18+"
  },
  jsTitle: {
    id: "manage.jsTitle",
    defaultMessage: "Has a JS sidecar"
  },
  jsTag: {
    id: "manage.jsTag",
    defaultMessage: "JS"
  },
  editTitle: {
    id: "manage.editTitle",
    defaultMessage: "Edit {label}"
  },
  deleteTitle: {
    id: "manage.deleteTitle",
    defaultMessage: "Delete {label}"
  },
  lockedTitle: {
    id: "manage.lockedTitle",
    defaultMessage: "Manage is a local-mode feature"
  },
  lockedBody: {
    id: "manage.lockedBody",
    defaultMessage: "The content manager edits the prompt files on your disk, so it needs the local app (the dev server or a local build). It isn’t available in the online version."
  },
  collapse: {
    id: "manage.collapse",
    defaultMessage: "Collapse"
  },
  expand: {
    id: "manage.expand",
    defaultMessage: "Expand"
  },
  folderSettings: {
    id: "manage.folderSettings",
    defaultMessage: "Folder settings — {name}"
  },
  settingsFor: {
    id: "manage.settingsFor",
    defaultMessage: "Settings for {name}"
  },
  addTo: {
    id: "manage.addTo",
    defaultMessage: "Add to {name}"
  },
  newList: {
    id: "manage.newList",
    defaultMessage: "New list"
  },
  newBlock: {
    id: "manage.newBlock",
    defaultMessage: "New block"
  },
  newSubfolder: {
    id: "manage.newSubfolder",
    defaultMessage: "New subfolder"
  },
  newFolder: {
    id: "manage.newFolder",
    defaultMessage: "New folder"
  },
  badgePrefix: {
    id: "manage.badgePrefix",
    defaultMessage: "prefix"
  },
  badgeGroup: {
    id: "manage.badgeGroup",
    defaultMessage: "group"
  },
  badgeForcePrefixTitle: {
    id: "manage.badgeForcePrefixTitle",
    defaultMessage: "Force-prefix folder — its path shows in the token"
  },
  badgeGroupTitle: {
    id: "manage.badgeGroupTitle",
    defaultMessage: "Group folder — referencing it picks one random member"
  },
  panelTitle: {
    id: "manage.panelTitle",
    defaultMessage: "Manage"
  },
  searchPh: {
    id: "manage.searchPh",
    defaultMessage: "Search…"
  },
  refreshTitle: {
    id: "manage.refreshTitle",
    defaultMessage: "Refresh catalog (re-read from disk)"
  },
  refreshAria: {
    id: "manage.refreshAria",
    defaultMessage: "Refresh catalog"
  },
  loadingCatalog: {
    id: "manage.loadingCatalog",
    defaultMessage: "Loading the catalog…"
  },
  nothingMatches: {
    id: "manage.nothingMatches",
    defaultMessage: "Nothing matches."
  },
  emptyUserRoot: {
    id: "manage.emptyUserRoot",
    defaultMessage: "Nothing here yet — use + to add your own."
  },
  yoursTag: {
    id: "manage.yoursTag",
    defaultMessage: "yours",
    description: "Small badge marking the user's own content roots at the top of Manage"
  },
  backToList: {
    id: "manage.backToList",
    defaultMessage: "Back to list",
    description: "Phone-only button that returns from the editor to the tree (master/detail)"
  }
});

/**
 * The Manage workspace.
 * @param {object} props
 * @param {object} props.settings Current settings (reads `includeAdult`).
 * @param {boolean} props.available Whether the local-mode backend is present.
 * @param {boolean} props.active Whether this tab is currently shown.
 * @returns {JSX.Element}
 */
export default function Manage({
  settings,
  available,
  active
}) {
  const intl = useIntl();
  const {
    tree,
    loading,
    error,
    query,
    setQuery,
    expanded,
    toggle,
    selected,
    setSelected,
    refreshing,
    models,
    addMenu,
    setAddMenu,
    dragEntry,
    setDragEntry,
    dropTarget,
    setDropTarget,
    onRefresh,
    openEntry,
    handleChanged,
    newFile,
    newFolder,
    moveEntryTo,
    deleteEntry,
    overrideEntry,
    restoreGhost
  } = useManageTree({
    settings,
    available,
    active
  });

  // One entry pill. A normal entry has a hover Edit action (clicking the pill never inserts). A
  // "ghost" (deleted locally, still upstream) is faded and offers only Restore.
  const EntryPill = ({
    e
  }) => e.ghost ? /*#__PURE__*/_jsxDEV("span", {
    className: `mg-pill kind-${e.kind} is-ghost`,
    title: intl.formatMessage(msgs.ghostTitle, {
      path: e.path
    }),
    children: [/*#__PURE__*/_jsxDEV("span", {
      className: "mg-pill-label",
      children: e.label
    }, void 0, false), /*#__PURE__*/_jsxDEV("span", {
      className: "mg-ghost-tag",
      children: intl.formatMessage(msgs.ghostTag)
    }, void 0, false), /*#__PURE__*/_jsxDEV("button", {
      className: "mg-pill-act",
      title: intl.formatMessage(msgs.restoreTitle, {
        label: e.label
      }),
      "aria-label": intl.formatMessage(msgs.restoreAria, {
        label: e.label
      }),
      onClick: () => restoreGhost(e),
      children: /*#__PURE__*/_jsxDEV(RestoreIcon, {}, void 0, false)
    }, void 0, false)]
  }, void 0, true) :
  /*#__PURE__*/
  // The whole pill opens the entry (click or Enter/Space) — no separate Edit icon. Delete
  // stays as its own button and stops propagation so it doesn't also open the editor.
  _jsxDEV("span", {
    className: `mg-pill is-clickable kind-${e.kind}${selected?.path === e.path ? " on" : ""}`,
    title: intl.formatMessage(msgs.editTitle, {
      label: e.label
    }),
    role: "button",
    tabIndex: 0,
    draggable: true,
    onClick: () => openEntry(e),
    onKeyDown: ev => {
      if (ev.key === "Enter" || ev.key === " ") {
        ev.preventDefault();
        openEntry(e);
      }
    },
    onDragStart: () => setDragEntry(e),
    onDragEnd: () => setDragEntry(null),
    children: [/*#__PURE__*/_jsxDEV("span", {
      className: "mg-pill-label",
      children: e.label
    }, void 0, false), e.nsfw && /*#__PURE__*/_jsxDEV("span", {
      className: "mg-nsfw",
      title: intl.formatMessage(msgs.nsfw),
      children: intl.formatMessage(msgs.nsfwTag)
    }, void 0, false), e.hasJsSidecar && /*#__PURE__*/_jsxDEV("span", {
      className: "mg-js",
      title: intl.formatMessage(msgs.jsTitle),
      children: intl.formatMessage(msgs.jsTag)
    }, void 0, false), /*#__PURE__*/_jsxDEV("button", {
      className: "mg-pill-act mg-pill-del",
      title: intl.formatMessage(msgs.deleteTitle, {
        label: e.label
      }),
      "aria-label": intl.formatMessage(msgs.deleteTitle, {
        label: e.label
      }),
      onClick: ev => {
        ev.stopPropagation();
        deleteEntry(e);
      },
      children: /*#__PURE__*/_jsxDEV(TrashIcon, {}, void 0, false)
    }, void 0, false)]
  }, void 0, true);
  if (!available) {
    return /*#__PURE__*/_jsxDEV("div", {
      className: "manage-locked",
      children: [/*#__PURE__*/_jsxDEV("h2", {
        children: intl.formatMessage(msgs.lockedTitle)
      }, void 0, false), /*#__PURE__*/_jsxDEV("p", {
        children: intl.formatMessage(msgs.lockedBody)
      }, void 0, false)]
    }, void 0, true);
  }

  // Recursive folder renderer.
  const FolderNode = ({
    node,
    root
  }) => {
    const key = `${root}:${node.path}`;
    const open = expanded.has(key);
    const cls = ["mg-folder", node.isCategory ? "is-category" : "is-subfolder", node.markers.length ? "is-special" : ""].filter(Boolean).join(" ");
    const isDrop = dropTarget === key && dragEntry && dragEntry.root === root;
    return /*#__PURE__*/_jsxDEV("div", {
      className: cls,
      children: [/*#__PURE__*/_jsxDEV("div", {
        className: `mg-folder-head${isDrop ? " is-drop" : ""}`,
        onDragOver: ev => {
          if (dragEntry && dragEntry.root === root) {
            ev.preventDefault();
            setDropTarget(key);
          }
        },
        onDragLeave: () => setDropTarget(d => d === key ? null : d),
        onDrop: ev => {
          ev.preventDefault();
          const e = dragEntry;
          setDropTarget(null);
          setDragEntry(null);
          if (e) moveEntryTo(e, root, node.path);
        },
        children: [/*#__PURE__*/_jsxDEV("button", {
          className: "mg-twirl",
          onClick: () => toggle(key),
          "aria-expanded": open,
          "aria-label": intl.formatMessage(open ? msgs.collapse : msgs.expand),
          children: /*#__PURE__*/_jsxDEV(Caret, {
            open: open
          }, void 0, false)
        }, void 0, false), /*#__PURE__*/_jsxDEV("span", {
          className: "mg-folder-name",
          onClick: () => toggle(key),
          children: node.name
        }, void 0, false), !node.ghostFolder && /*#__PURE__*/_jsxDEV("button", {
          className: "mg-gear",
          title: intl.formatMessage(msgs.folderSettings, {
            name: node.name
          }),
          "aria-label": intl.formatMessage(msgs.settingsFor, {
            name: node.name
          }),
          onClick: () => setSelected({
            type: "folder",
            ...node
          }),
          children: /*#__PURE__*/_jsxDEV(GearIcon, {}, void 0, false)
        }, void 0, false), !node.ghostFolder && /*#__PURE__*/_jsxDEV("span", {
          className: "mg-add-wrap",
          children: [/*#__PURE__*/_jsxDEV("button", {
            className: "mg-add",
            title: intl.formatMessage(msgs.addTo, {
              name: node.name
            }),
            "aria-label": intl.formatMessage(msgs.addTo, {
              name: node.name
            }),
            onClick: () => setAddMenu(m => m && m.folder === node.path && m.root === root ? null : {
              root,
              folder: node.path
            }),
            children: "+"
          }, void 0, false), addMenu && addMenu.root === root && addMenu.folder === node.path && /*#__PURE__*/_jsxDEV("span", {
            className: "mg-add-menu",
            role: "menu",
            children: [/*#__PURE__*/_jsxDEV("button", {
              onClick: () => newFile(root, node.path),
              children: intl.formatMessage(isDpRoot(root) ? msgs.newBlock : msgs.newList)
            }, void 0, false), /*#__PURE__*/_jsxDEV("button", {
              onClick: () => newFolder(root, node.path),
              children: intl.formatMessage(msgs.newSubfolder)
            }, void 0, false)]
          }, void 0, true)]
        }, void 0, true), node.markers.map(m => /*#__PURE__*/_jsxDEV("span", {
          className: `mg-badge mg-badge-${m}`,
          title: badgeTitle(intl, m),
          children: intl.formatMessage(m === "force-prefix" ? msgs.badgePrefix : msgs.badgeGroup)
        }, m, false)), /*#__PURE__*/_jsxDEV("span", {
          className: "mg-count count-pill",
          children: node.entryCount
        }, void 0, false)]
      }, void 0, true), open && /*#__PURE__*/_jsxDEV("div", {
        className: "mg-folder-body",
        children: [node.children.map(c => /*#__PURE__*/_jsxDEV(FolderNode, {
          node: c,
          root: root
        }, c.path, false)), node.entries.length > 0 && /*#__PURE__*/_jsxDEV("div", {
          className: "mg-entries",
          children: node.entries.map(e => /*#__PURE__*/_jsxDEV(EntryPill, {
            e: e
          }, e.path, false))
        }, void 0, false)]
      }, void 0, true)]
    }, void 0, true);
  };
  return (
    /*#__PURE__*/
    // `detail-open` (a selection is active) drives the phone master/detail swap: with no selection
    // the tree fills the screen; selecting an entry/folder shows the editor with a Back control.
    // On wider screens the class is inert — both panes show side by side as always.
    _jsxDEV("div", {
      className: `workspace manage${selected ? " detail-open" : ""}`,
      children: [/*#__PURE__*/_jsxDEV("aside", {
        className: "sidebar mg-sidebar",
        children: [/*#__PURE__*/_jsxDEV("div", {
          className: "panel-head",
          children: [/*#__PURE__*/_jsxDEV("h3", {
            className: "panel-title",
            children: intl.formatMessage(msgs.panelTitle)
          }, void 0, false), /*#__PURE__*/_jsxDEV("div", {
            className: "mg-head-actions",
            children: [/*#__PURE__*/_jsxDEV("input", {
              className: "picker-filter",
              placeholder: intl.formatMessage(msgs.searchPh),
              value: query,
              onChange: e => setQuery(e.target.value)
            }, void 0, false), /*#__PURE__*/_jsxDEV("button", {
              className: `mg-refresh${refreshing ? " spinning" : ""}`,
              onClick: onRefresh,
              title: intl.formatMessage(msgs.refreshTitle),
              "aria-label": intl.formatMessage(msgs.refreshAria),
              disabled: refreshing,
              children: /*#__PURE__*/_jsxDEV(RefreshIcon, {}, void 0, false)
            }, void 0, false)]
          }, void 0, true)]
        }, void 0, true), loading && !tree ? /*#__PURE__*/_jsxDEV("p", {
          className: "empty",
          children: intl.formatMessage(msgs.loadingCatalog)
        }, void 0, false) : error ? /*#__PURE__*/_jsxDEV("p", {
          className: "error",
          children: error
        }, void 0, false) : /*#__PURE__*/_jsxDEV("div", {
          className: "mg-tree",
          children: models.map(({
            root,
            title,
            model
          }) => /*#__PURE__*/_jsxDEV("div", {
            className: `mg-root${USER_ROOTS.has(root) ? " mg-root-user" : ""}${dropTarget === `${root}:` && dragEntry?.root === root ? " is-drop" : ""}`,
            onDragOver: ev => {
              if (dragEntry && dragEntry.root === root) {
                ev.preventDefault();
                setDropTarget(`${root}:`);
              }
            },
            onDrop: ev => {
              ev.preventDefault();
              const e = dragEntry;
              setDropTarget(null);
              setDragEntry(null);
              if (e) moveEntryTo(e, root, "");
            },
            children: [/*#__PURE__*/_jsxDEV("div", {
              className: "mg-root-title",
              children: [/*#__PURE__*/_jsxDEV("span", {
                children: title
              }, void 0, false), USER_ROOTS.has(root) && /*#__PURE__*/_jsxDEV("span", {
                className: "mg-yours-tag",
                children: intl.formatMessage(msgs.yoursTag)
              }, void 0, false), /*#__PURE__*/_jsxDEV("span", {
                className: "mg-add-wrap",
                children: [/*#__PURE__*/_jsxDEV("button", {
                  className: "mg-add",
                  title: intl.formatMessage(msgs.addTo, {
                    name: title
                  }),
                  "aria-label": intl.formatMessage(msgs.addTo, {
                    name: title
                  }),
                  onClick: () => setAddMenu(m => m && m.root === root && m.folder === "" ? null : {
                    root,
                    folder: ""
                  }),
                  children: "+"
                }, void 0, false), addMenu && addMenu.root === root && addMenu.folder === "" && /*#__PURE__*/_jsxDEV("span", {
                  className: "mg-add-menu",
                  role: "menu",
                  children: [/*#__PURE__*/_jsxDEV("button", {
                    onClick: () => newFile(root, ""),
                    children: intl.formatMessage(isDpRoot(root) ? msgs.newBlock : msgs.newList)
                  }, void 0, false), /*#__PURE__*/_jsxDEV("button", {
                    onClick: () => newFolder(root, ""),
                    children: intl.formatMessage(msgs.newFolder)
                  }, void 0, false)]
                }, void 0, true)]
              }, void 0, true)]
            }, void 0, true), model.children.length === 0 && model.entries.length === 0 ? /*#__PURE__*/_jsxDEV("p", {
              className: "empty",
              children: intl.formatMessage(USER_ROOTS.has(root) && !query.trim() ? msgs.emptyUserRoot : msgs.nothingMatches)
            }, void 0, false) : /*#__PURE__*/_jsxDEV(_Fragment, {
              children: [model.children.map(c => /*#__PURE__*/_jsxDEV(FolderNode, {
                node: c,
                root: root
              }, c.path, false)), model.entries.map(e => /*#__PURE__*/_jsxDEV(EntryPill, {
                e: e
              }, e.path, false))]
            }, void 0, true)]
          }, root, true))
        }, void 0, false)]
      }, void 0, true), /*#__PURE__*/_jsxDEV("div", {
        className: "main-col mg-main",
        children: [/*#__PURE__*/_jsxDEV("button", {
          type: "button",
          className: "mg-back",
          onClick: () => setSelected(null),
          children: [/*#__PURE__*/_jsxDEV(ChevronLeftIcon, {}, void 0, false), intl.formatMessage(msgs.backToList)]
        }, void 0, true), selected?.type === "folder" ? /*#__PURE__*/_jsxDEV(ManageFolderEditor, {
          node: selected,
          onChanged: handleChanged
        }, void 0, false) : selected?.type === "entry" && selected.kind === "generator" ? /*#__PURE__*/_jsxDEV(ManageBlockEditor, {
          entry: selected,
          settings: settings,
          onChanged: handleChanged,
          onOverride: overrideEntry
        }, void 0, false) : selected?.type === "entry" && (selected.kind === "list" || selected.kind === "group") ? /*#__PURE__*/_jsxDEV(ManageListEditor, {
          entry: selected,
          settings: settings,
          onChanged: handleChanged,
          onOverride: overrideEntry
        }, void 0, false) : /*#__PURE__*/_jsxDEV(ManageDetail, {
          selected: selected
        }, void 0, false)]
      }, void 0, true)]
    }, void 0, true)
  );
}
function badgeTitle(intl, m) {
  if (m === "force-prefix") return intl.formatMessage(msgs.badgeForcePrefixTitle);
  if (m === "group") return intl.formatMessage(msgs.badgeGroupTitle);
  return m;
}