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