diff --git a/client/src/api.ts b/client/src/api.ts index 4e03fe485..1cd0e4ba9 100644 --- a/client/src/api.ts +++ b/client/src/api.ts @@ -54,8 +54,12 @@ import { EditAcademyOnlineAccessFormValues, CentralIdentityUserLicenseResult, CentralIdentityAppLicense, - StoreDigitalDeliveryOption, StoreOrderWithStripeSession, StoreOrderListItem, - ManualPrintJobPayload, ManualPrintJobPayloadResponse, ResubmitPrintJobRefusal, + StoreDigitalDeliveryOption, + StoreOrderWithStripeSession, + StoreOrderListItem, + ManualPrintJobPayload, + ManualPrintJobPayloadResponse, + ResubmitPrintJobRefusal, OrderCharge, OrderSession, CentralIdentityOrgAdminResult, @@ -819,11 +823,11 @@ class API { async adminSubmitManualPrintJob( order_id: string, - payload: ManualPrintJobPayload + payload: ManualPrintJobPayload, ) { const res = await axios.post<{ data: unknown } & ConductorBaseResponse>( `/store/admin/orders/${order_id}/print-job`, - payload + payload, ); return res; } @@ -2841,11 +2845,18 @@ class API { return res.data; } - async createMatter(projectID: string, type: CreateMatterSelection, overwrite: boolean) { - const res = await axios.post(`/remixer/${projectID}/create-matter`, { - type, - overwrite, - }); + async createMatter( + projectID: string, + type: CreateMatterSelection, + overwrite: boolean, + ) { + const res = await axios.post( + `/remixer/${projectID}/create-matter`, + { + type, + overwrite, + }, + ); return res.data; } @@ -2929,6 +2940,7 @@ class API { reparented?: unknown[]; /** False when the live TOC was unavailable, so `untracked` proves nothing. */ reconciled?: boolean; + publishedAt?: Date | string; } & ConductorBaseResponse >(`/remixer/${id}/project`, {}); return res.data; @@ -2962,11 +2974,16 @@ class API { return res.data; } - async getRemixerTreeFlattened(id: string, path: string, subdomain: string) { + async getRemixerTreeFlattened( + id: string, + path: string, + subdomain: string, + options?: { flatten?: boolean | true; preserveConfigs?: boolean | true }, + ) { const res = await axios.post(`/remixer/${id}/page/tree`, { path, subdomain, - flatten: true, + options, }); return res.data; } diff --git a/client/src/components/remixer/BookActions.tsx b/client/src/components/remixer/BookActions.tsx index f2d34889f..86b627802 100644 --- a/client/src/components/remixer/BookActions.tsx +++ b/client/src/components/remixer/BookActions.tsx @@ -8,6 +8,7 @@ import { IconArrowForwardUp, IconPlus, IconTrash, + IconRestore, IconChevronUp, IconChevronDown, } from "@tabler/icons-react"; @@ -16,6 +17,8 @@ interface BookActionsProps { isNarrowScreen: boolean; onAddItem: () => void; onDeleteItem: () => void; + onRestoreItem: () => void; + isSelectedItemDeleted: boolean; onUndo: () => void; onRedo: () => void; isAllExpanded: boolean; @@ -36,6 +39,8 @@ const BookActions: React.FC = ({ isNarrowScreen, onAddItem, onDeleteItem, + onRestoreItem, + isSelectedItemDeleted, onUndo, onRedo, isAllExpanded, @@ -53,7 +58,14 @@ const BookActions: React.FC = ({ onAddItem(); } }, - { + isSelectedItemDeleted ? { + title: "Restore", + icon: , + variant: "outline", + onClick: () => { + onRestoreItem(); + } + } : { title: "Delete", icon: , variant: "destructive", diff --git a/client/src/components/remixer/BookContent/ContextMenu.tsx b/client/src/components/remixer/BookContent/ContextMenu.tsx index 77312b0b1..cf7099885 100644 --- a/client/src/components/remixer/BookContent/ContextMenu.tsx +++ b/client/src/components/remixer/BookContent/ContextMenu.tsx @@ -12,6 +12,7 @@ type ContextMenuAction = | "add-below" | "add-to" | "delete" + | "restore" | "modify" | "duplicate"; @@ -19,6 +20,7 @@ interface ContextMenuProps { contextMenu: ContextMenuPosition | null; canAddSibling: boolean; canDuplicate: boolean; + isDeleted: boolean; addAboveLabel: string; addToLabel: string; addBelowLabel: string; @@ -37,6 +39,7 @@ const ContextMenu: React.FC = ({ contextMenu, canAddSibling, canDuplicate, + isDeleted, addAboveLabel, addToLabel, addBelowLabel, @@ -104,14 +107,25 @@ const ContextMenu: React.FC = ({ margin: "4px 0", }} /> -
(e.currentTarget.style.background = "#f0f0f0")} - onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")} - onClick={() => onAction("delete")} - > - Delete -
+ {isDeleted ? ( +
(e.currentTarget.style.background = "#f0f0f0")} + onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")} + onClick={() => onAction("restore")} + > + Restore +
+ ) : ( +
(e.currentTarget.style.background = "#f0f0f0")} + onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")} + onClick={() => onAction("delete")} + > + Delete +
+ )}
(e.currentTarget.style.background = "#f0f0f0")} diff --git a/client/src/components/remixer/BookContent/Dashboard.tsx b/client/src/components/remixer/BookContent/Dashboard.tsx index 747b4f9f0..4c0d8f5b0 100644 --- a/client/src/components/remixer/BookContent/Dashboard.tsx +++ b/client/src/components/remixer/BookContent/Dashboard.tsx @@ -20,7 +20,7 @@ import { sortMatterSiblings, } from "../services"; import TreeNodeContainer from "./TreeNodeContainer"; -import { STATUS_PALETTE } from "../style"; +import { CATALOG_NODE_HIGHLIGHT_STYLE, STATUS_PALETTE } from "../style"; type DropPosition = "before" | "inside" | "after"; type TreeId = "library" | "book"; @@ -504,6 +504,7 @@ const TreeDnd: React.FC = ({ const isPlacementChanged = page.isPlacementChanged ?? page.movedItem === true; const isSelected = selectedNodeId === page["@id"]; + const isCatalogHighlighted = !isBookTree && isSelected; const itemLink = page["uri.ui"] || page["@href"]; const isInteractionLocked = isDefaultMatterPage(page); const isDropInside = @@ -526,7 +527,9 @@ const TreeDnd: React.FC = ({ isRenamed={isRenamed} isPlacementChanged={isPlacementChanged} isSelected={isSelected} + isCatalogHighlighted={isCatalogHighlighted} isBookTree={isBookTree} + isBookRootChild={isBookTree && depth === 1} isInteractionLocked={isInteractionLocked} isVisualLocked={isInteractionLocked} itemLink={itemLink} @@ -604,6 +607,7 @@ const TreeDnd: React.FC = ({ const isPlacementChanged = root.isPlacementChanged ?? root.movedItem === true; const isSelected = selectedNodeId === root["@id"]; + const isCatalogHighlighted = !isBookTree && isSelected; const itemLink = root["uri.ui"] || root["@href"]; const targetLevel = 1; const isInteractionLocked = isDefaultMatterPage(root); @@ -619,7 +623,11 @@ const TreeDnd: React.FC = ({ ); return ( -
+
) => { @@ -766,39 +774,69 @@ const TreeDnd: React.FC = ({ - {!isBookTree && itemLink ? ( - event.stopPropagation()} - > - {displayTitle} - - - ) : ( - - {displayTitle} - - )} + {(() => { + const showLink = + Boolean(itemLink) && + itemLink !== "#" && + (!isBookTree || !isImported); + const titleStyle: React.CSSProperties = { + whiteSpace: "nowrap", + fontStyle: isInteractionLocked ? "italic" : "normal", + color: isInteractionLocked ? "#6b7280" : "inherit", + textDecoration: isDeleted ? "line-through" : "none", + }; + + if (!showLink) { + return ( + + {displayTitle} + + ); + } + + if (!isBookTree) { + return ( + event.stopPropagation()} + > + {displayTitle} + + + ); + } + + return ( + <> + + {displayTitle} + + event.stopPropagation()} + style={{ display: "inline-flex", marginLeft: 8 }} + > + + + + ); + })()} {renderNodes( root["@id"], diff --git a/client/src/components/remixer/BookContent/TreeNodeContainer.tsx b/client/src/components/remixer/BookContent/TreeNodeContainer.tsx index f5bcfd634..263e73dc6 100644 --- a/client/src/components/remixer/BookContent/TreeNodeContainer.tsx +++ b/client/src/components/remixer/BookContent/TreeNodeContainer.tsx @@ -1,6 +1,7 @@ import React, { DragEvent } from "react"; import { Icon, List } from "semantic-ui-react"; import { RemixerSubPage } from "../model"; +import { CATALOG_NODE_HIGHLIGHT_STYLE } from "../style"; interface StatusPalette { info: string; @@ -25,7 +26,11 @@ interface TreeNodeContainerProps { isRenamed: boolean; isPlacementChanged: boolean; isSelected: boolean; + /** Catalog-opened book in the library tree — highlight wraps the node until another book is selected. */ + isCatalogHighlighted?: boolean; isBookTree: boolean; + /** Direct child of the book cover — always shown with a folder icon in the book tree. */ + isBookRootChild?: boolean; isInteractionLocked?: boolean; isVisualLocked?: boolean; itemLink?: string; @@ -58,7 +63,9 @@ const TreeNodeContainerComponent: React.FC = ({ isRenamed, isPlacementChanged, isSelected, + isCatalogHighlighted = false, isBookTree, + isBookRootChild = false, isInteractionLocked = false, isVisualLocked = false, itemLink, @@ -80,7 +87,14 @@ const TreeNodeContainerComponent: React.FC = ({ children, }) => { return ( -
+
) => onDragStart(page, event)} @@ -137,37 +151,73 @@ const TreeNodeContainerComponent: React.FC = ({ )} - + - {!isBookTree && itemLink ? ( - event.stopPropagation()} - > - {displayTitle} - - - ) : ( - - {displayTitle} - - )} + {(() => { + const showLink = + Boolean(itemLink) && + itemLink !== "#" && + (!isBookTree || !isImported); + const titleStyle: React.CSSProperties = { + whiteSpace: "nowrap", + fontStyle: isVisualLocked ? "italic" : "normal", + color: isVisualLocked ? "#6b7280" : "inherit", + textDecoration: isDeleted ? "line-through" : "none", + }; + + if (!showLink) { + return ( + + {displayTitle} + + ); + } + + // Library tree: title is the link. Book tree: only the icon links. + if (!isBookTree) { + return ( + event.stopPropagation()} + > + {displayTitle} + + + ); + } + + return ( + <> + + {displayTitle} + + event.stopPropagation()} + > + + + + ); + })()} {isDeleted && ( = ({ return ( Catalog Book - + = ({ density="compact" striped bordered - maxHeight="min(55vh, 420px)" + maxHeight="min(50vh, 420px)" stickyHeader classNames={{ + // Davis wrapper is always `overflow-auto`; with maxHeight that creates a + // second scrollbar beside the table scroll region. Keep overflow on the + // maxHeight container only. + wrapper: "!overflow-hidden", table: "w-full min-w-[640px] table-fixed", - + pagination: + "[&_select]:min-w-[4.5rem] [&_select]:pl-2 [&_select]:pr-7", }} emptyState="No books match your search." onRowClick={handleRowClick} diff --git a/client/src/components/remixer/ControlPanel.tsx b/client/src/components/remixer/ControlPanel.tsx index 23024fb85..230ecde95 100644 --- a/client/src/components/remixer/ControlPanel.tsx +++ b/client/src/components/remixer/ControlPanel.tsx @@ -1,7 +1,7 @@ import { Button, IconButton, Menu, Select, Tooltip, type ButtonProps, type IconButtonProps } from "@libretexts/davis-react"; import { useEffect, useState } from "react"; import { CopyMode } from "./model"; -import { IconAtom, IconClockEdit, IconDeviceFloppy, IconDownload, IconPencilPause, IconRefresh, IconSettings } from "@tabler/icons-react"; +import { IconAtom, IconBook, IconCloudUpload, IconClockEdit, IconDownload, IconRefresh, IconSettings } from "@tabler/icons-react"; import ConsultInsightButton from "../NextGenComponents/ConsultInsightButton"; import { dumpProjectToLocalStorageToJsonFile, @@ -144,7 +144,7 @@ const ControlPanelNewUITemp: React.FC = ({ } as ControlPanelAction] : []), { title: "Save as Draft", - icon: , + icon: , variant: "outline", group: 'right', onClick: () => { @@ -153,7 +153,7 @@ const ControlPanelNewUITemp: React.FC = ({ }, { title: "Save Changes", - icon: , + icon: , variant: "primary", group: 'right', onClick: () => { @@ -169,7 +169,10 @@ const ControlPanelNewUITemp: React.FC = ({ name="remixer-mode" label="" placeholder="Mode..." - className="w-48 mb-1" + className="w-full min-w-0 [&>div]:!mt-0" + labelClassName="sr-only" + selectClassName="!h-10 !box-border !py-0 !pr-10 leading-none" + value={copyModeState} onChange={(e) => { onCopyModeChange(e.target.value); @@ -214,7 +217,8 @@ const ControlPanelNewUITemp: React.FC = ({ onClick={action.onClick} title={action.tooltip} disabled={action.disabled} - className="m-0!" // This is a temp fix until Semantic UI is removed from the project. It's applying a margin to the button that isn't needed + size="md" + className="m-0! shrink-0" // Temp fix: Semantic UI margin until SUI is removed /> ) @@ -236,7 +240,7 @@ const ControlPanelNewUITemp: React.FC = ({
-
+
{ actions.filter(action => action.group === 'right').map((action, index) => { if ('tooltip' in action) { @@ -249,6 +253,7 @@ const ControlPanelNewUITemp: React.FC = ({ onClick={action.onClick} title={action.tooltip} disabled={action.disabled} + className="!inline-flex !flex-row !items-center shrink-0 whitespace-nowrap" /> ) @@ -261,6 +266,8 @@ const ControlPanelNewUITemp: React.FC = ({ icon={action.icon} onClick={action.onClick} disabled={action.disabled} + size="sm" + className="!inline-flex !h-10 !box-border !flex-row !items-center !py-0 shrink-0 whitespace-nowrap m-0!" > {action.title} diff --git a/client/src/components/remixer/EditPanel.tsx b/client/src/components/remixer/EditPanel.tsx index a10d877bf..bf084bca1 100644 --- a/client/src/components/remixer/EditPanel.tsx +++ b/client/src/components/remixer/EditPanel.tsx @@ -8,12 +8,12 @@ import { Modal, Link, Text, - IconButton, } from "@libretexts/davis-react"; -import { IconDeviceFloppy, IconEdit } from "@tabler/icons-react"; +import { IconDeviceFloppy } from "@tabler/icons-react"; import { getRemixerPageUriUi, isRemixerBookRoot, + joinPrefixAndIndex, sanitizeRemixerPageTitle, toEditableRemixerTitle, } from "./services"; @@ -28,6 +28,8 @@ interface EditPanelProps { library: Library; /** Project cover page id; the book root allows colons in its title. */ coverPageId: string; + /** True for default front/back-matter pages and matter-root containers — URL ending is not user-editable for these. */ + isMatterPage?: boolean; } /** Truncate to `maxLen` characters with "..." in the middle (e.g. 25 → "abcdefghij...opqrstuvwxy"). */ @@ -39,11 +41,42 @@ function truncateMiddle(value: string, maxLen: number): string { return `${value.slice(0, front)}...${value.slice(-back)}`; } -/** Path-segment-safe characters only — no `/`, spaces, `?`, `#`, etc. */ +/** + * Path-segment-safe characters only — no `/`, spaces, `?`, `#`, etc. Mirrors the + * server's `sanitizeRemixerUriEnding`, which re-applies this on save. + * + * The override is stored as literal path text, matching what auto-numbering + * produces (`01:_Introduction`, with a raw colon). `%` is excluded on purpose: + * the publish job encodes the whole path on the way out, so a stored `%3A` + * would reach the library as three literal characters and publish the page at + * `01%253A_Introduction`. Values are decoded first so an ending seeded from an + * encoded `uri.ui` leaf heals instead of being stripped to `013A_Introduction`. + */ function sanitizeUriEnding(value: string | undefined): string | undefined { if (!value) return undefined; - const cleaned = value.replace(/[^A-Za-z0-9._~%-]/g, ""); - return cleaned.length > 0 ? cleaned : undefined; // return undefined if nothing remains after sanitization + const cleaned = decodeUriEndingLeaf(value).replace( + /[^A-Za-z0-9._~():-]/g, + "", + ); + // Nothing usable left, or a dots-only leaf ("." / "..") that would read as a + // relative path segment rather than a page name. + if (cleaned.length === 0 || /^\.+$/.test(cleaned)) return undefined; + return cleaned; +} + +/** Percent-decodes a URL path leaf, leaving malformed encodings untouched. */ +function decodeUriEndingLeaf(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +/** The last path segment of a page URL, decoded for display and editing. */ +function uriEndingFrom(uri: string | undefined): string { + if (!uri) return ""; + return decodeUriEndingLeaf(uri.split("/").slice(-1)[0] ?? ""); } const EditPanel: React.FC = (props) => { @@ -55,6 +88,7 @@ const EditPanel: React.FC = (props) => { formattedPathPartsDefault, library, coverPageId, + isMatterPage, } = props; const [page, setPage] = useState(currentPage); @@ -62,12 +96,20 @@ const EditPanel: React.FC = (props) => { const currentPageParentPath = currentPageUri ? currentPageUri.split("/").slice(0, -1).join("/") : ""; - + const titleInputRef = useRef(null); const isBookRoot = isRemixerBookRoot(currentPage, coverPageId); - const [overrideUriUiEnding, setOverrideUriUiEnding] = useState( - sanitizeUriEnding(currentPage?.overrideUriUiEnding ||currentPageUri?.split("/").slice(-1)[0] ||undefined), + const [overrideUriUiEnding, setOverrideUriUiEnding] = useState< + string | undefined + >( + sanitizeUriEnding( + currentPage?.overrideUriUiEnding || uriEndingFrom(currentPageUri), + ), ); + // Mirrors `formattedPathOverride`'s checkbox semantics exactly: checked + // means the override is active and its value is persisted on save; + // unchecked means it's cleared, and the page's URL is reconstructed back + // to its auto-generated ending on the next publish. const [enableOverrideUriUiEnding, setEnableOverrideUriUiEnding] = useState(false); @@ -87,8 +129,14 @@ const EditPanel: React.FC = (props) => { formattedPathOverride: overridden, formattedPathPrefix: prefix, formattedPathIndex: index, - formattedPath: overridden ? `${prefix}${index}`.trim() : undefined, - overrideUriUiEnding: enableOverrideUriUiEnding ? overrideUriUiEnding : undefined, + formattedPath: overridden + ? joinPrefixAndIndex(prefix ?? "", index ?? "").trim() + : undefined, + // Checked → persist the override value; unchecked → clear it so the + // page's URL is reconstructed to its auto-generated ending on publish. + overrideUriUiEnding: enableOverrideUriUiEnding + ? overrideUriUiEnding + : undefined, }; handleSave(normalizedPage); }; @@ -105,9 +153,9 @@ const EditPanel: React.FC = (props) => { setPage({ ...currentPage, title, "@title": title }); const uri = getRemixerPageUriUi(currentPage); setOverrideUriUiEnding( - sanitizeUriEnding(uri.split("/").slice(-1)[0] ?? ""), + sanitizeUriEnding(currentPage.overrideUriUiEnding || uriEndingFrom(uri)), ); - setEnableOverrideUriUiEnding(false); + setEnableOverrideUriUiEnding(!!currentPage.overrideUriUiEnding); }, [currentPage, open]); useEffect(() => { @@ -129,7 +177,7 @@ const EditPanel: React.FC = (props) => { {!isBookRoot && ( = (props) => { className="flex-7" /> - {currentPageUri && ( - - - - {truncateMiddle(currentPageParentPath, 25)} - {" "} - / - - {enableOverrideUriUiEnding ? ( + {currentPageUri && !isBookRoot && !isMatterPage && ( + <> + { + const enabled = checked === true; + setEnableOverrideUriUiEnding(enabled); + if (!enabled) { + // Disabling reconstructs the URL: clear the override so + // the page falls back to its auto-generated ending on + // the next publish. + setOverrideUriUiEnding(undefined); + } else if (!overrideUriUiEnding) { + setOverrideUriUiEnding( + sanitizeUriEnding(uriEndingFrom(currentPageUri)), + ); + } + }} + /> + + + + {truncateMiddle(currentPageParentPath, 25)} + {" "} + / + setOverrideUriUiEnding(sanitizeUriEnding(e.target.value)) } className="flex-1" /> - ) : ( - {overrideUriUiEnding} - )} - {/* } - aria-label={ - enableOverrideUriUiEnding - ? "Stop editing URL ending" - : "Edit URL ending" - } - onClick={() => - setEnableOverrideUriUiEnding((prev) => !prev) - } - /> */} - + + )} {!currentPage?.["@id"].startsWith("new-") && ( = ({ } return ( - + {remixerData.libraries && (