Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc165b7e1c | ||
|
|
d2a5f0cac7 | ||
|
|
d734867ea9 | ||
|
|
11b8f5fa4e | ||
|
|
87832c2c3b | ||
|
|
7dfe33de8d | ||
|
|
c1b41e421f | ||
|
|
515175bf0b | ||
|
|
df0f56a127 | ||
|
|
69d5843439 | ||
|
|
902f9d06d2 | ||
|
|
70faa6f4e3 | ||
|
|
008b271299 | ||
|
|
f156599cab | ||
|
|
3f9d0960e5 | ||
|
|
b45b39154e | ||
|
|
499baaa7f8 | ||
|
|
0dc6c01b7e | ||
|
|
4ae26167af | ||
|
|
e1912847d6 | ||
|
|
7e7561662a | ||
|
|
7a932eac47 |
@@ -3708,8 +3708,6 @@ openFromComputer = "Open from computer"
|
||||
openSettings = "Open settings"
|
||||
other = "Other"
|
||||
recent = "Recent"
|
||||
search = "Search"
|
||||
searchPlaceholder = "Search files..."
|
||||
viewAll = "View all {{count}} files"
|
||||
|
||||
[fileSidebar.fileItem]
|
||||
@@ -3749,7 +3747,6 @@ backToMyFiles = "Back to My Files"
|
||||
breadcrumbs = "Folder path"
|
||||
cancel = "Cancel"
|
||||
classification = "Classification"
|
||||
clearSearch = "Clear search"
|
||||
clearSelection = "Clear selection"
|
||||
closeDetails = "Close details"
|
||||
create = "Create"
|
||||
@@ -3813,8 +3810,6 @@ resizeFolderTree = "Resize folder tree (arrow keys, Shift for bigger steps; doub
|
||||
save = "Save"
|
||||
saveToServer = "Save to server"
|
||||
saveToServerDisabledHint = "Saving to the server isn't enabled on this server. Ask your admin to enable it."
|
||||
search = "Search"
|
||||
searchPlaceholder = "Search this folder & subfolders"
|
||||
selectAll = "Select all"
|
||||
selectAllHint = "Click to select all. Tip: hold Ctrl (or Cmd) to add files one at a time, Shift to select a range."
|
||||
selectedCount = "{{count}} selected"
|
||||
@@ -3874,6 +3869,10 @@ title = "No cloud files yet"
|
||||
hint = "Files saved without uploading stay here. Drop a file to add one."
|
||||
title = "No local-only files"
|
||||
|
||||
[filesPage.empty.noResults]
|
||||
hint = "No files in this folder match your filter. Try a different term or clear the filter."
|
||||
title = "No matching files"
|
||||
|
||||
[filesPage.empty.recent]
|
||||
hint = "Files you open or edit will appear here."
|
||||
title = "Nothing modified yet"
|
||||
@@ -3945,6 +3944,11 @@ localHint = "Only stored in this browser"
|
||||
shared = "Shared"
|
||||
sharedHint = "Shared with you via link"
|
||||
|
||||
[filesPage.search]
|
||||
clear = "Clear filter"
|
||||
label = "Filter files by name"
|
||||
placeholder = "Filter files…"
|
||||
|
||||
[filesPage.sort]
|
||||
modifiedAsc = "Oldest first"
|
||||
modifiedDesc = "Recent first"
|
||||
@@ -7891,15 +7895,6 @@ viewAll = "View all"
|
||||
description = "Pipeline runs, deploys and agent events will appear here."
|
||||
title = "Nothing here yet"
|
||||
|
||||
[portal.search]
|
||||
ariaLabel = "Search"
|
||||
placeholder = "Search Stirling — endpoints, pipelines, docs…"
|
||||
|
||||
[portal.search.empty]
|
||||
noActionsDescription = "Quick actions will appear here once they're available."
|
||||
noActionsTitle = "No quick actions"
|
||||
noMatches = "No matches for \"{{query}}\""
|
||||
noMatchesDescription = "Try a different keyword or browse the catalogue."
|
||||
|
||||
[portal.settings.groups]
|
||||
admin = "Admin"
|
||||
@@ -9722,6 +9717,17 @@ title = "Upload to Server"
|
||||
updateButton = "Update on Server"
|
||||
uploadButton = "Upload to Server"
|
||||
|
||||
[superSearch]
|
||||
ariaLabel = "Super search"
|
||||
hint = "Type to search"
|
||||
placeholder = "Search Stirling"
|
||||
|
||||
[superSearch.group]
|
||||
files = "Files"
|
||||
processor = "Processor"
|
||||
settings = "Settings"
|
||||
tools = "Tools"
|
||||
|
||||
[survey]
|
||||
title = "Stirling-PDF Survey"
|
||||
|
||||
@@ -10711,6 +10717,7 @@ exitRedaction = "Exit Redaction Mode"
|
||||
exportAll = "Export PDF"
|
||||
exportSelected = "Export Selected Pages"
|
||||
formFill = "Fill Form"
|
||||
hideToolbar = "Hide toolbar"
|
||||
multiTool = "Multi-Tool"
|
||||
panMode = "Pan Mode"
|
||||
print = "Print PDF"
|
||||
@@ -10730,6 +10737,7 @@ selectAll = "Select All"
|
||||
selectByNumber = "Select by Page Numbers"
|
||||
selectLanguage = "Select language"
|
||||
share = "Share"
|
||||
showToolbar = "Show toolbar"
|
||||
toggleAnnotations = "Toggle Annotations Visibility"
|
||||
toggleAttachments = "Toggle Attachments"
|
||||
toggleBookmarks = "Toggle Bookmarks"
|
||||
|
||||
@@ -3719,8 +3719,6 @@ openFromComputer = "Open from computer"
|
||||
openSettings = "Open settings"
|
||||
other = "Other"
|
||||
recent = "Recent"
|
||||
search = "Search"
|
||||
searchPlaceholder = "Search files..."
|
||||
viewAll = "View all {{count}} files"
|
||||
|
||||
[fileSidebar.fileItem]
|
||||
@@ -3749,7 +3747,6 @@ backToMyFiles = "Back to My Files"
|
||||
breadcrumbs = "Folder path"
|
||||
cancel = "Cancel"
|
||||
classification = "Classification"
|
||||
clearSearch = "Clear search"
|
||||
clearSelection = "Clear selection"
|
||||
closeDetails = "Close details"
|
||||
create = "Create"
|
||||
@@ -3813,8 +3810,6 @@ resizeFolderTree = "Resize folder tree (arrow keys, Shift for bigger steps; doub
|
||||
save = "Save"
|
||||
saveToServer = "Save to server"
|
||||
saveToServerDisabledHint = "Saving to the server isn't enabled on this server. Ask your admin to enable it."
|
||||
search = "Search"
|
||||
searchPlaceholder = "Search this folder & subfolders"
|
||||
selectAll = "Select all"
|
||||
selectAllHint = "Click to select all. Tip: hold Ctrl (or Cmd) to add files one at a time, Shift to select a range."
|
||||
selectedCount = "{{count}} selected"
|
||||
@@ -3874,6 +3869,10 @@ title = "No cloud files yet"
|
||||
hint = "Files saved without uploading stay here. Drop a file to add one."
|
||||
title = "No local-only files"
|
||||
|
||||
[filesPage.empty.noResults]
|
||||
hint = "No files in this folder match your filter. Try a different term or clear the filter."
|
||||
title = "No matching files"
|
||||
|
||||
[filesPage.empty.recent]
|
||||
hint = "Files you open or edit will appear here."
|
||||
title = "Nothing modified yet"
|
||||
@@ -3945,6 +3944,11 @@ localHint = "Only stored in this browser"
|
||||
shared = "Shared"
|
||||
sharedHint = "Shared with you via link"
|
||||
|
||||
[filesPage.search]
|
||||
clear = "Clear filter"
|
||||
label = "Filter files by name"
|
||||
placeholder = "Filter files…"
|
||||
|
||||
[filesPage.sort]
|
||||
modifiedAsc = "Oldest first"
|
||||
modifiedDesc = "Recent first"
|
||||
@@ -7981,16 +7985,6 @@ viewAll = "View all"
|
||||
description = "Pipeline runs, deploys and agent events will appear here."
|
||||
title = "Nothing here yet"
|
||||
|
||||
[portal.search]
|
||||
ariaLabel = "Search"
|
||||
placeholder = "Search Stirling — endpoints, pipelines, docs…"
|
||||
|
||||
[portal.search.empty]
|
||||
noActionsDescription = "Quick actions will appear here once they're available."
|
||||
noActionsTitle = "No quick actions"
|
||||
noMatches = "No matches for \"{{query}}\""
|
||||
noMatchesDescription = "Try a different keyword or browse the catalogue."
|
||||
|
||||
[portal.settings.groups]
|
||||
admin = "Admin"
|
||||
|
||||
@@ -9046,9 +9040,7 @@ updateBehaviorError = "Could not change update behavior"
|
||||
updateBehaviorErrorLocked = "This setting is locked by your administrator."
|
||||
updateBehaviorLockedDescription = "Your administrator has configured how Stirling-PDF handles updates on this machine. Contact them to change this."
|
||||
updateBehaviorSaved = "Update behavior saved."
|
||||
versionMismatch = "The app frontend ({{frontendVersion}}) and backend ({{backendVersion}}) are running different versions. This usually happens briefly while an update is being applied."
|
||||
versionMismatchResolution = "Restart Stirling-PDF to finish applying the update. If the warning persists after restarting, reinstall the latest version to bring both components back in sync."
|
||||
versionMismatchTitle = "Version mismatch"
|
||||
versionMismatch = "Warning: A mismatch has been detected between the client version and the AppConfig version. Using different versions can lead to compatibility issues, errors, and security risks. Please ensure that server and client are using the same version."
|
||||
viewDetails = "View Details"
|
||||
|
||||
[settings.general.versionInfo]
|
||||
@@ -9846,6 +9838,17 @@ title = "Upload to Server"
|
||||
updateButton = "Update on Server"
|
||||
uploadButton = "Upload to Server"
|
||||
|
||||
[superSearch]
|
||||
ariaLabel = "Super search"
|
||||
hint = "Type to search"
|
||||
placeholder = "Search Stirling"
|
||||
|
||||
[superSearch.group]
|
||||
files = "Files"
|
||||
processor = "Processor"
|
||||
settings = "Settings"
|
||||
tools = "Tools"
|
||||
|
||||
[survey]
|
||||
title = "Stirling-PDF Survey"
|
||||
|
||||
@@ -10868,6 +10871,7 @@ exitRedaction = "Exit Redaction Mode"
|
||||
exportAll = "Export PDF"
|
||||
exportSelected = "Export Selected Pages"
|
||||
formFill = "Fill Form"
|
||||
hideToolbar = "Hide toolbar"
|
||||
multiTool = "Multi-Tool"
|
||||
panMode = "Pan Mode"
|
||||
print = "Print PDF"
|
||||
@@ -10887,6 +10891,7 @@ selectAll = "Select All"
|
||||
selectByNumber = "Select by Page Numbers"
|
||||
selectLanguage = "Select language"
|
||||
share = "Share"
|
||||
showToolbar = "Show toolbar"
|
||||
toggleAnnotations = "Toggle Annotations Visibility"
|
||||
toggleAttachments = "Toggle Attachments"
|
||||
toggleBookmarks = "Toggle Bookmarks"
|
||||
|
||||
@@ -16,6 +16,7 @@ import DriveFileRenameOutlineIcon from "@mui/icons-material/DriveFileRenameOutli
|
||||
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
|
||||
import UploadFileIcon from "@mui/icons-material/UploadFile";
|
||||
import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder";
|
||||
import SearchIcon from "@mui/icons-material/Search";
|
||||
|
||||
import { FileId } from "@app/types/file";
|
||||
import { FolderId, FolderRecord, ROOT_FOLDER_ID } from "@app/types/folder";
|
||||
@@ -89,6 +90,8 @@ interface FileGridProps {
|
||||
onChangeSortMode?: (mode: FilesPageSortMode) => void;
|
||||
/** Drives the empty-state copy. */
|
||||
currentTab?: "all" | "local" | "cloud" | "recent" | "shared" | "sharedByMe";
|
||||
/** A filter is applied; an empty result then means "no matches", not "no files". */
|
||||
searchActive?: boolean;
|
||||
/** Cloud reachability; switches the cloud empty-state copy. */
|
||||
serverReachable?: boolean;
|
||||
/** Empty-state CTA handlers; if absent the matching button hides. */
|
||||
@@ -104,6 +107,7 @@ export function FileGrid(props: FileGridProps & { loading?: boolean }) {
|
||||
entries,
|
||||
loading,
|
||||
currentTab,
|
||||
searchActive,
|
||||
serverReachable,
|
||||
onEmptyUpload,
|
||||
onEmptyCreateFolder,
|
||||
@@ -115,15 +119,28 @@ export function FileGrid(props: FileGridProps & { loading?: boolean }) {
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
const emptyState = (
|
||||
<EmptyState
|
||||
tab={currentTab}
|
||||
searchActive={searchActive}
|
||||
serverReachable={serverReachable}
|
||||
onUpload={onEmptyUpload}
|
||||
onCreateFolder={onEmptyCreateFolder}
|
||||
newFolderDisabledReason={newFolderDisabledReason}
|
||||
/>
|
||||
);
|
||||
// When a filter empties the list view, keep the column headers in place and
|
||||
// show the no-results message beneath them, rather than replacing the whole
|
||||
// table. Grid view (cards, no headers) just shows the empty state.
|
||||
if (viewMode === "list" && searchActive) {
|
||||
return (
|
||||
<>
|
||||
<ListView {...props} />
|
||||
{emptyState}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return emptyState;
|
||||
}
|
||||
|
||||
if (viewMode === "list") {
|
||||
@@ -187,6 +204,8 @@ function SkeletonGrid({ viewMode }: { viewMode: FilesPageViewMode }) {
|
||||
interface EmptyStateProps {
|
||||
/** Drives copy + iconography. */
|
||||
tab?: "all" | "local" | "cloud" | "recent" | "shared" | "sharedByMe";
|
||||
/** When true the empty list is the result of a filter, not a bare folder. */
|
||||
searchActive?: boolean;
|
||||
/** Switches the cloud empty-state copy. */
|
||||
serverReachable?: boolean;
|
||||
/** CTA handlers; absent => button hidden. */
|
||||
@@ -198,12 +217,35 @@ interface EmptyStateProps {
|
||||
|
||||
function EmptyState({
|
||||
tab = "all",
|
||||
searchActive = false,
|
||||
serverReachable = true,
|
||||
onUpload,
|
||||
onCreateFolder,
|
||||
newFolderDisabledReason,
|
||||
}: EmptyStateProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// A filter with no matches isn't an empty folder - say so, and skip the
|
||||
// upload / new-folder CTAs since clearing the filter is the way out.
|
||||
if (searchActive) {
|
||||
return (
|
||||
<div className="files-page-empty">
|
||||
<span className="files-page-empty-icon">
|
||||
<SearchIcon style={{ fontSize: "2.5rem" }} />
|
||||
</span>
|
||||
<div className="files-page-empty-title">
|
||||
{t("filesPage.empty.noResults.title", "No matching files")}
|
||||
</div>
|
||||
<div className="files-page-empty-hint">
|
||||
{t(
|
||||
"filesPage.empty.noResults.hint",
|
||||
"No files in this folder match your filter. Try a different term or clear the filter.",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { titleKey, titleFallback, hintKey, hintFallback } = (() => {
|
||||
switch (tab) {
|
||||
case "local":
|
||||
|
||||
@@ -7,11 +7,19 @@ import React, {
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { Drawer, Group, MultiSelect, Select, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
Drawer,
|
||||
Group,
|
||||
MultiSelect,
|
||||
Select,
|
||||
TextInput,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { ActionIcon } from "@app/ui/ActionIcon";
|
||||
import { SegmentedControl } from "@app/ui/SegmentedControl";
|
||||
import { useMediaQuery } from "@mantine/hooks";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import SearchIcon from "@mui/icons-material/Search";
|
||||
import UploadFileIcon from "@mui/icons-material/UploadFile";
|
||||
import QrCode2Icon from "@mui/icons-material/QrCode2";
|
||||
@@ -51,6 +59,7 @@ import { StirlingFileStub } from "@app/types/fileContext";
|
||||
import { FolderId, ROOT_FOLDER_ID } from "@app/types/folder";
|
||||
|
||||
import { FileGrid, FilesPageEntry } from "@app/components/filesPage/FileGrid";
|
||||
import SuperSearch from "@app/components/shared/superSearch/SuperSearch";
|
||||
import { FileDetailsPanel } from "@app/components/filesPage/FileDetailsPanel";
|
||||
import BulkUploadToServerModal from "@app/components/shared/BulkUploadToServerModal";
|
||||
import MobileUploadModal from "@app/components/shared/MobileUploadModal";
|
||||
@@ -685,13 +694,19 @@ export default function FileManagerView() {
|
||||
}, [navigate]);
|
||||
|
||||
// ─── keyboard shortcuts ─────────────────────────────────────────────────
|
||||
const searchInputRef = useRef<HTMLInputElement | null>(null);
|
||||
// Focus the super-search input (stable id), used by the "/" shortcut and the
|
||||
// FileSidebar rail Search button.
|
||||
const focusSearch = useCallback(() => {
|
||||
(
|
||||
document.getElementById("super-search-input") as HTMLInputElement | null
|
||||
)?.focus();
|
||||
}, []);
|
||||
// External focus trigger (used by the FileSidebar rail Search button).
|
||||
useEffect(() => {
|
||||
const onFocus = () => searchInputRef.current?.focus();
|
||||
window.addEventListener("files-page:focus-search", onFocus);
|
||||
return () => window.removeEventListener("files-page:focus-search", onFocus);
|
||||
}, []);
|
||||
window.addEventListener("files-page:focus-search", focusSearch);
|
||||
return () =>
|
||||
window.removeEventListener("files-page:focus-search", focusSearch);
|
||||
}, [focusSearch]);
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
const active = document.activeElement as HTMLElement | null;
|
||||
@@ -734,13 +749,19 @@ export default function FileManagerView() {
|
||||
// "/" focuses the search field.
|
||||
if (e.key === "/" && !inInput) {
|
||||
e.preventDefault();
|
||||
searchInputRef.current?.focus();
|
||||
focusSearch();
|
||||
return;
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [visibleFiles, selectedFileIds, removeFiles, setSelectedFileIds]);
|
||||
}, [
|
||||
visibleFiles,
|
||||
selectedFileIds,
|
||||
removeFiles,
|
||||
setSelectedFileIds,
|
||||
focusSearch,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
@@ -889,11 +910,7 @@ export default function FileManagerView() {
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<SearchField
|
||||
ref={searchInputRef}
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
/>
|
||||
<SuperSearch />
|
||||
<div className="files-page-header-actions">
|
||||
<Tooltip
|
||||
label={
|
||||
@@ -1342,6 +1359,27 @@ export default function FileManagerView() {
|
||||
aria-label={t("filesPage.typeFilter.label", "Filter by type")}
|
||||
/>
|
||||
)}
|
||||
<TextInput
|
||||
size="xs"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
placeholder={t("filesPage.search.placeholder", "Filter files…")}
|
||||
leftSection={<SearchIcon sx={{ fontSize: "1rem" }} />}
|
||||
rightSection={
|
||||
search ? (
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
onClick={() => setSearch("")}
|
||||
aria-label={t("filesPage.search.clear", "Clear filter")}
|
||||
>
|
||||
<CloseIcon sx={{ fontSize: "0.9rem" }} />
|
||||
</ActionIcon>
|
||||
) : null
|
||||
}
|
||||
aria-label={t("filesPage.search.label", "Filter files by name")}
|
||||
style={{ width: 180 }}
|
||||
/>
|
||||
<Select
|
||||
size="xs"
|
||||
value={sortMode}
|
||||
@@ -1433,6 +1471,7 @@ export default function FileManagerView() {
|
||||
entries={entries}
|
||||
loading={loading}
|
||||
currentTab={currentTab}
|
||||
searchActive={search.trim().length > 0}
|
||||
serverReachable={folders.serverReachable}
|
||||
selectedFileIds={selectedFileIds}
|
||||
activeWorkspaceFileIds={activeWorkspaceFileIdSet}
|
||||
@@ -1655,39 +1694,6 @@ export default function FileManagerView() {
|
||||
);
|
||||
}
|
||||
|
||||
const SearchField = React.forwardRef<
|
||||
HTMLInputElement,
|
||||
{ value: string; onChange: (v: string) => void }
|
||||
>(function SearchField({ value, onChange }, ref) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="files-page-search">
|
||||
<SearchIcon fontSize="small" style={{ color: "var(--text-muted)" }} />
|
||||
<input
|
||||
ref={ref}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.currentTarget.value)}
|
||||
placeholder={t(
|
||||
"filesPage.searchPlaceholder",
|
||||
"Search this folder & subfolders",
|
||||
)}
|
||||
aria-label={t("filesPage.search", "Search")}
|
||||
/>
|
||||
{value && (
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
onClick={() => onChange("")}
|
||||
aria-label={t("filesPage.clearSearch", "Clear search")}
|
||||
>
|
||||
×
|
||||
</ActionIcon>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
function Breadcrumbs() {
|
||||
const { t } = useTranslation();
|
||||
const folders = useFolders();
|
||||
|
||||
@@ -1,3 +1,40 @@
|
||||
/* Positioning context for the viewer toolbar's reopen tab, which hangs below
|
||||
the bar and so must sit outside the overflow-clipped wrapper below. */
|
||||
.workbenchBarShell {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
z-index: 51;
|
||||
}
|
||||
|
||||
/* Little pull-tab shown while the viewer tool row is retracted. Anchored to the
|
||||
bar's bottom-right edge (where the retract handle sat). Small by design - it
|
||||
just brings the row back. */
|
||||
.workbenchBarReopenTab {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.25rem;
|
||||
height: 0.875rem;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-top: none;
|
||||
border-radius: 0 0 7px 7px;
|
||||
background: var(--bg-toolbar);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
z-index: 52;
|
||||
transition:
|
||||
color 0.15s ease,
|
||||
background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.workbenchBarReopenTab:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
/* WorkbenchBar slide-in/out animation using CSS grid trick */
|
||||
.workbenchBarWrapper {
|
||||
display: grid;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { useEffect, useState, Suspense, lazy } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
|
||||
import { Box, Loader, Center } from "@mantine/core";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||
import { useFileHandler } from "@app/hooks/useFileHandler";
|
||||
import { useFileState } from "@app/contexts/FileContext";
|
||||
@@ -67,10 +70,13 @@ export default function Workbench() {
|
||||
const selectedTool = selectedToolId ? toolRegistry[selectedToolId] : null;
|
||||
const { addFiles } = useFileHandler();
|
||||
const hasFiles = activeFiles.length > 0;
|
||||
// Custom workbench views (e.g. Watched Folders) manage their own content and may
|
||||
// have no workbench files, but still need the bar's view switcher so users can
|
||||
// navigate back out.
|
||||
const isCustomViewActive = !isBaseWorkbench(currentView);
|
||||
const { t } = useTranslation();
|
||||
|
||||
// The viewer's tool row can be retracted to give the document more height.
|
||||
// State lives here (not in WorkbenchBar) so the reopen tab can hang below the
|
||||
// bar, outside the bar's overflow-clipped wrapper. Scoped to the viewer.
|
||||
const [viewerToolbarCollapsed, setViewerToolbarCollapsed] = useState(false);
|
||||
const showReopenTab = currentView === "viewer" && viewerToolbarCollapsed;
|
||||
|
||||
// Enable bar transitions after first paint so the initial hidden state shows
|
||||
// without animating (landing page on load shouldn't animate the bar up).
|
||||
@@ -224,18 +230,38 @@ export default function Workbench() {
|
||||
{currentView !== "myFiles" &&
|
||||
!customWorkbenchViews.find((v) => v.workbenchId === currentView)
|
||||
?.hideTopControls && (
|
||||
<div
|
||||
className={styles.workbenchBarWrapper}
|
||||
data-hidden={String(!hasFiles && !isCustomViewActive)}
|
||||
data-no-transition={String(!barTransitionEnabled)}
|
||||
>
|
||||
<div className={styles.workbenchBarInner}>
|
||||
<WorkbenchBar
|
||||
currentView={currentView}
|
||||
setCurrentView={setCurrentView}
|
||||
hasFiles={hasFiles}
|
||||
/>
|
||||
<div className={styles.workbenchBarShell}>
|
||||
<div
|
||||
className={styles.workbenchBarWrapper}
|
||||
data-hidden="false"
|
||||
data-no-transition={String(!barTransitionEnabled)}
|
||||
>
|
||||
<div className={styles.workbenchBarInner}>
|
||||
<WorkbenchBar
|
||||
currentView={currentView}
|
||||
setCurrentView={setCurrentView}
|
||||
hasFiles={hasFiles}
|
||||
viewerToolbarCollapsed={viewerToolbarCollapsed}
|
||||
onCollapseViewerToolbar={setViewerToolbarCollapsed}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Reopen tab: a little handle hanging off the bar's bottom-right
|
||||
while the viewer tool row is retracted. */}
|
||||
{showReopenTab && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="quiet"
|
||||
className={styles.workbenchBarReopenTab}
|
||||
onClick={() => setViewerToolbarCollapsed(false)}
|
||||
aria-expanded={false}
|
||||
aria-label={t("workbenchBar.showToolbar", "Show toolbar")}
|
||||
title={t("workbenchBar.showToolbar", "Show toolbar")}
|
||||
leftSection={
|
||||
<KeyboardArrowDownIcon sx={{ fontSize: "1rem" }} />
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,4 +1,28 @@
|
||||
/* AppConfigModal styles */
|
||||
|
||||
/* Deep-link highlight: pulses the control the super search jumped to
|
||||
(navigated via /settings/{section}?focus={anchor}). */
|
||||
@keyframes settings-focus-pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 3px var(--mantine-color-blue-5);
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--mantine-color-blue-5) 16%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 6px transparent;
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-focus-target {
|
||||
animation: settings-focus-pulse 1.8s ease-out;
|
||||
border-radius: 8px;
|
||||
scroll-margin: 1rem;
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
|
||||
@@ -146,6 +146,33 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
|
||||
[navigate, urlSync],
|
||||
);
|
||||
|
||||
// Deep-link: /settings/{section}?focus={anchor} scrolls to and briefly
|
||||
// highlights the matching control (used by the global super search to jump
|
||||
// straight to an individual setting row).
|
||||
useEffect(() => {
|
||||
if (!opened) return;
|
||||
const focus = new URLSearchParams(location.search).get("focus");
|
||||
if (!focus) return;
|
||||
let raf = 0;
|
||||
// Wait for the (possibly just-switched) section to render before scrolling.
|
||||
const timer = window.setTimeout(() => {
|
||||
raf = window.requestAnimationFrame(() => {
|
||||
const el = document.getElementById(focus);
|
||||
if (!el) return;
|
||||
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
el.classList.add("settings-focus-target");
|
||||
window.setTimeout(
|
||||
() => el.classList.remove("settings-focus-target"),
|
||||
1800,
|
||||
);
|
||||
});
|
||||
}, 150);
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
if (raf) window.cancelAnimationFrame(raf);
|
||||
};
|
||||
}, [opened, active, location.search]);
|
||||
|
||||
// Backwards-compat: external `appConfig:navigate` events route through the
|
||||
// same switchSection path so they get the no-flash treatment too.
|
||||
useEffect(() => {
|
||||
|
||||
@@ -33,11 +33,9 @@ import { Wordmark } from "@app/components/shared/Wordmark";
|
||||
import { AppSwitcher } from "@app/components/shared/AppSwitcher";
|
||||
import type { StirlingFileStub } from "@app/types/fileContext";
|
||||
import MenuIcon from "@mui/icons-material/Menu";
|
||||
import SearchIcon from "@mui/icons-material/Search";
|
||||
import FolderOpenIcon from "@mui/icons-material/FolderOpen";
|
||||
import FolderSpecialIcon from "@mui/icons-material/FolderSpecial";
|
||||
import UploadFileIcon from "@mui/icons-material/UploadFile";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
|
||||
import SettingsIcon from "@mui/icons-material/Settings";
|
||||
@@ -101,8 +99,6 @@ export interface FileSidebarProps {
|
||||
onUploadFiles?: (files: File[]) => void | Promise<void>;
|
||||
/** Override the Google Drive handler. */
|
||||
onPickGoogleDriveFiles?: (files: File[]) => void | Promise<void>;
|
||||
/** Override the Search row click (e.g. focus the /files search input). */
|
||||
onSearchClick?: () => void;
|
||||
/** Extra action row inserted under Open-from-computer (e.g. New folder). */
|
||||
extraAction?: {
|
||||
icon: React.ReactNode;
|
||||
@@ -156,7 +152,6 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
toggleIcon,
|
||||
onUploadFiles,
|
||||
onPickGoogleDriveFiles,
|
||||
onSearchClick,
|
||||
extraAction,
|
||||
},
|
||||
ref,
|
||||
@@ -167,9 +162,6 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
// Classification off (non-SaaS / AI-off) → never show the per-row label chip,
|
||||
// even if a stub carries labels from an imported PDF; keeps the row plain.
|
||||
const classificationEnabled = useClassificationEnabled();
|
||||
const [searchActive, setSearchActive] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const nativeFileInputRef = useRef<HTMLInputElement>(null);
|
||||
// State (not ref) so setting it triggers a re-render - avoids racing addFiles state updates.
|
||||
const [pendingViewFileId, setPendingViewFileId] = useState<string | null>(
|
||||
@@ -438,14 +430,9 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
}
|
||||
}, [pendingViewFileId, state.files.ids, setActiveFileId, navActions]);
|
||||
|
||||
// Memoized so an unrelated re-render (e.g. a policy-run store tick) keeps a
|
||||
// stable array identity — avoids re-running the grouping memo + backfill effect.
|
||||
const filteredFileStubs = useMemo(() => {
|
||||
const q = searchQuery.trim().toLowerCase();
|
||||
return q
|
||||
? allFileStubs.filter((stub) => stub.name.toLowerCase().includes(q))
|
||||
: allFileStubs;
|
||||
}, [allFileStubs, searchQuery]);
|
||||
// File filtering now lives in the global super search (top bar); the
|
||||
// sidebar simply lists every stored file.
|
||||
const filteredFileStubs = allFileStubs;
|
||||
|
||||
// SaaS groups by classification label; core returns null → one flat, recency-sorted list.
|
||||
const fileGroups = useFileSidebarGroups(filteredFileStubs);
|
||||
@@ -472,29 +459,6 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
[],
|
||||
);
|
||||
|
||||
// Handle search activation
|
||||
const handleSearchClick = useCallback(() => {
|
||||
if (onSearchClick) {
|
||||
onSearchClick();
|
||||
return;
|
||||
}
|
||||
if (collapsed && onToggleCollapse) {
|
||||
onToggleCollapse();
|
||||
}
|
||||
setSearchActive(true);
|
||||
}, [collapsed, onToggleCollapse, onSearchClick]);
|
||||
|
||||
const handleSearchClose = useCallback(() => {
|
||||
setSearchActive(false);
|
||||
setSearchQuery("");
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchActive && searchInputRef.current) {
|
||||
searchInputRef.current.focus();
|
||||
}
|
||||
}, [searchActive]);
|
||||
|
||||
// Handle Google Drive
|
||||
const handleGoogleDriveClick = useCallback(async () => {
|
||||
if (!isGoogleDriveEnabled) return;
|
||||
@@ -885,56 +849,6 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
{/* Search row */}
|
||||
<Tooltip
|
||||
label={t("fileSidebar.search", "Search")}
|
||||
position="right"
|
||||
withinPortal
|
||||
disabled={!collapsed}
|
||||
>
|
||||
<div
|
||||
className={`file-sidebar-search-row${searchActive && !collapsed ? " active" : ""}`}
|
||||
onClick={!searchActive ? handleSearchClick : undefined}
|
||||
role={!searchActive ? "button" : undefined}
|
||||
tabIndex={!searchActive ? 0 : undefined}
|
||||
onKeyDown={
|
||||
!searchActive
|
||||
? (e) => e.key === "Enter" && handleSearchClick()
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{searchActive && !collapsed ? (
|
||||
<CloseIcon
|
||||
className="file-sidebar-search-icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSearchClose();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<SearchIcon className="file-sidebar-search-icon" />
|
||||
)}
|
||||
{!collapsed &&
|
||||
(searchActive ? (
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
className="file-sidebar-search-input sidebar-content-fade"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={t(
|
||||
"fileSidebar.searchPlaceholder",
|
||||
"Search files...",
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
) : (
|
||||
<span className="file-sidebar-search-label sidebar-content-fade">
|
||||
{t("fileSidebar.search", "Search")}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
{/* Scrollable content */}
|
||||
<div className="file-sidebar-scroll">
|
||||
{/* Hidden native file input - kept outside the !collapsed gate so
|
||||
@@ -1282,16 +1196,14 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
!searchActive && (
|
||||
<div className="file-sidebar-empty">
|
||||
<p className="file-sidebar-empty-text">
|
||||
{t("fileSidebar.noFiles", "No files yet")}
|
||||
</p>
|
||||
<p className="file-sidebar-empty-hint">
|
||||
{t("fileSidebar.dropHint", "Open files to get started")}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
<div className="file-sidebar-empty">
|
||||
<p className="file-sidebar-empty-text">
|
||||
{t("fileSidebar.noFiles", "No files yet")}
|
||||
</p>
|
||||
<p className="file-sidebar-empty-hint">
|
||||
{t("fileSidebar.dropHint", "Open files to get started")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2,11 +2,17 @@
|
||||
/* Horizontal toolbar at the top of the workbench area. */
|
||||
|
||||
.workbench-bar {
|
||||
/* Flex-wrap layout that reflows by content width (measured in JS, which sets
|
||||
data-wrapped). Roomy: row 1 is [views | search | globals] and the tool
|
||||
icons sit on their own full-width row below. Crowded (data-wrapped): the
|
||||
search bumps to its own row, leaving [views | globals] on row 1 and the
|
||||
tools on row 3 — three rows total. `order` drives the reflow. */
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
align-content: flex-start;
|
||||
min-height: 40px;
|
||||
column-gap: 8px;
|
||||
min-height: 48px;
|
||||
padding: 0 8px;
|
||||
background-color: var(--bg-toolbar);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
@@ -16,12 +22,16 @@
|
||||
|
||||
/* ---- View switcher (left) ---- */
|
||||
.workbench-bar-views {
|
||||
order: 1;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
order: 1;
|
||||
height: 40px;
|
||||
/* min-width:0 lets it shrink instead of forcing a wrap; overflow clips labels
|
||||
on very narrow viewports. */
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
height: 48px;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
@@ -85,53 +95,88 @@
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
/* ---- Center: tool buttons ---- */
|
||||
|
||||
/* Single-row: center sits between views and globals */
|
||||
.workbench-bar-center {
|
||||
/* ---- Super search ---- */
|
||||
/* Roomy: grows to fill the centre between views and globals. */
|
||||
.workbench-bar-search {
|
||||
order: 2;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
height: 40px;
|
||||
overflow: hidden;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
/* Two-row: center drops below views+globals */
|
||||
.workbench-bar[data-wrapped="true"] .workbench-bar-center {
|
||||
/* Crowded: drops to its own full-width row between the top row and the tools. */
|
||||
.workbench-bar[data-wrapped="true"] .workbench-bar-search {
|
||||
order: 3;
|
||||
flex: 0 0 100%;
|
||||
height: auto;
|
||||
padding: 4px 0;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
justify-content: flex-start;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: thin;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.workbench-bar[data-wrapped="true"] .workbench-bar-center > * {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ---- Right: global buttons (theme / language / download) ---- */
|
||||
/* ---- Tool buttons (own full-width row, always below the top row) ---- */
|
||||
.workbench-bar-center {
|
||||
order: 4;
|
||||
flex: 0 0 100%;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px;
|
||||
/* Symmetric side padding leaves room for the retract handle pinned right
|
||||
without knocking the centred tool icons off-centre. */
|
||||
padding: 4px 36px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
/* Retract / reopen handle for the viewer tool row. */
|
||||
.workbench-bar-toolbar-handle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition:
|
||||
color 0.15s ease,
|
||||
background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.workbench-bar-toolbar-handle:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
/* Pinned to the right edge of the tool row. */
|
||||
.workbench-bar-toolbar-handle-retract {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
/* ---- Right: global buttons (print / download / save / close), row 1 ---- */
|
||||
.workbench-bar-globals {
|
||||
order: 3;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
order: 3;
|
||||
height: 40px;
|
||||
min-width: 0;
|
||||
height: 48px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* In two-row mode globals moves to row 1 right side */
|
||||
/* Crowded: globals stays on row 1, moving up beside the views. */
|
||||
.workbench-bar[data-wrapped="true"] .workbench-bar-globals {
|
||||
order: 2;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Shared action icon style - applies to both center and global buttons.
|
||||
|
||||
@@ -32,6 +32,7 @@ import { ViewerContext, useViewer } from "@app/contexts/ViewerContext";
|
||||
import { WorkbenchType, isBaseWorkbench } from "@app/types/workbench";
|
||||
import { Tooltip } from "@app/components/shared/Tooltip";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import SuperSearch from "@app/components/shared/superSearch/SuperSearch";
|
||||
import ViewerShareButton from "@app/components/viewer/ViewerShareButton";
|
||||
import { useSharingEnabled } from "@app/hooks/useSharingEnabled";
|
||||
import { usePolicyFileBadges } from "@app/hooks/usePolicyFileBadges";
|
||||
@@ -52,6 +53,7 @@ import InsertDriveFileOutlinedIcon from "@mui/icons-material/InsertDriveFileOutl
|
||||
import FolderOutlinedIcon from "@mui/icons-material/FolderOutlined";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import PrintIcon from "@mui/icons-material/Print";
|
||||
import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp";
|
||||
import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined";
|
||||
import "@app/components/shared/WorkbenchBar.css";
|
||||
|
||||
@@ -67,6 +69,10 @@ interface WorkbenchBarProps {
|
||||
currentView: WorkbenchType;
|
||||
setCurrentView: (view: WorkbenchType) => void;
|
||||
hasFiles: boolean;
|
||||
/** Whether the viewer's tool row is currently retracted. */
|
||||
viewerToolbarCollapsed?: boolean;
|
||||
/** Setter for the viewer tool-row retract state (owned by Workbench). */
|
||||
onCollapseViewerToolbar?: (collapsed: boolean) => void;
|
||||
}
|
||||
|
||||
function renderWithTooltip(
|
||||
@@ -91,6 +97,8 @@ export default function WorkbenchBar({
|
||||
currentView,
|
||||
setCurrentView,
|
||||
hasFiles,
|
||||
viewerToolbarCollapsed = false,
|
||||
onCollapseViewerToolbar,
|
||||
}: WorkbenchBarProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
@@ -114,6 +122,7 @@ export default function WorkbenchBar({
|
||||
} = useToolWorkflow();
|
||||
const { selectedTool } = useNavigationState();
|
||||
const isCustomView = !isBaseWorkbench(currentView);
|
||||
const isViewer = currentView === "viewer";
|
||||
const disableForFullscreen =
|
||||
toolPanelMode === "fullscreen" && leftPanelView === "toolPicker";
|
||||
const terminology = useFileActionTerminology();
|
||||
@@ -434,34 +443,31 @@ export default function WorkbenchBar({
|
||||
})),
|
||||
];
|
||||
|
||||
// Reflow the top row by content width: when the views + globals leave too
|
||||
// little room for a usable search, bump the search to its own row
|
||||
const barRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const bar = barRef.current;
|
||||
if (!bar) return;
|
||||
|
||||
const MIN_SEARCH_WIDTH = 320;
|
||||
const measure = () => {
|
||||
const viewsEl = bar.querySelector<HTMLElement>(".workbench-bar-views");
|
||||
const globalsEl = bar.querySelector<HTMLElement>(
|
||||
".workbench-bar-globals",
|
||||
);
|
||||
const centerEl = bar.querySelector<HTMLElement>(".workbench-bar-center");
|
||||
|
||||
const viewsWidth = viewsEl?.offsetWidth ?? 0;
|
||||
const globalsWidth = globalsEl?.offsetWidth ?? 0;
|
||||
const centerChildren = centerEl
|
||||
? (Array.from(centerEl.children) as HTMLElement[])
|
||||
: [];
|
||||
const centerWidth =
|
||||
centerChildren.reduce((sum, el) => sum + el.offsetWidth, 0) +
|
||||
Math.max(0, centerChildren.length - 1) * 2; // gap: 2px
|
||||
|
||||
const needed = viewsWidth + centerWidth + globalsWidth + 24; // 24px bar padding
|
||||
bar.dataset.wrapped = String(needed > bar.clientWidth);
|
||||
// clientWidth minus the two side clusters, the bar's 16px h-padding and
|
||||
// the two 8px column gaps flanking the search.
|
||||
const available = bar.clientWidth - viewsWidth - globalsWidth - 16 - 16;
|
||||
bar.dataset.wrapped = String(available < MIN_SEARCH_WIDTH);
|
||||
};
|
||||
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(bar);
|
||||
const viewsEl = bar.querySelector<HTMLElement>(".workbench-bar-views");
|
||||
const globalsEl = bar.querySelector<HTMLElement>(".workbench-bar-globals");
|
||||
if (viewsEl) ro.observe(viewsEl);
|
||||
if (globalsEl) ro.observe(globalsEl);
|
||||
measure();
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
@@ -523,27 +529,50 @@ export default function WorkbenchBar({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tool buttons - second row, only rendered when buttons exist */}
|
||||
{sectionsWithButtons.length > 0 && (
|
||||
<div className="workbench-bar-center">
|
||||
{sectionsWithButtons.map(
|
||||
({ section, buttons: sectionButtons }, idx) => (
|
||||
<React.Fragment key={section}>
|
||||
{idx > 0 && <div className="workbench-bar-divider" />}
|
||||
{sectionButtons.map((btn) => {
|
||||
const content = renderButton(btn);
|
||||
if (!content) return null;
|
||||
return (
|
||||
<div key={btn.id} className="workbench-bar-action-wrapper">
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Global super search - always present, even on the homepage */}
|
||||
<div className="workbench-bar-search">
|
||||
<SuperSearch />
|
||||
</div>
|
||||
|
||||
{/* Tool buttons - second row, only rendered when buttons exist. In the
|
||||
viewer the row is retractable: a handle on its right edge hides the
|
||||
whole row; Workbench then shows a tab below the bar to bring it back. */}
|
||||
{sectionsWithButtons.length > 0 &&
|
||||
!(isViewer && viewerToolbarCollapsed) && (
|
||||
<div className="workbench-bar-center">
|
||||
{sectionsWithButtons.map(
|
||||
({ section, buttons: sectionButtons }, idx) => (
|
||||
<React.Fragment key={section}>
|
||||
{idx > 0 && <div className="workbench-bar-divider" />}
|
||||
{sectionButtons.map((btn) => {
|
||||
const content = renderButton(btn);
|
||||
if (!content) return null;
|
||||
return (
|
||||
<div
|
||||
key={btn.id}
|
||||
className="workbench-bar-action-wrapper"
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
),
|
||||
)}
|
||||
{isViewer && onCollapseViewerToolbar && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="quiet"
|
||||
className="workbench-bar-toolbar-handle workbench-bar-toolbar-handle-retract"
|
||||
onClick={() => onCollapseViewerToolbar(true)}
|
||||
aria-expanded
|
||||
aria-label={t("workbenchBar.hideToolbar", "Hide toolbar")}
|
||||
title={t("workbenchBar.hideToolbar", "Hide toolbar")}
|
||||
leftSection={<KeyboardArrowUpIcon sx={{ fontSize: "1rem" }} />}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Right: Global buttons - export group left, close anchored right */}
|
||||
<div className="workbench-bar-globals">
|
||||
|
||||
@@ -4,6 +4,10 @@ import { useTranslation } from "react-i18next";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import { NavKey, VALID_NAV_KEYS } from "@app/components/shared/config/types";
|
||||
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
|
||||
import {
|
||||
buildMatchSnippet,
|
||||
getSettingsSectionContent,
|
||||
} from "@app/data/settingsContentSearch";
|
||||
import type {
|
||||
ConfigNavSection,
|
||||
ConfigNavItem,
|
||||
@@ -24,102 +28,6 @@ interface SettingsSearchOption {
|
||||
matchedContext?: string;
|
||||
}
|
||||
|
||||
const SETTINGS_SEARCH_TRANSLATION_PREFIXES: Partial<Record<string, string[]>> =
|
||||
{
|
||||
general: ["settings.general"],
|
||||
hotkeys: ["settings.hotkeys"],
|
||||
account: ["account"],
|
||||
people: ["settings.workspace"],
|
||||
teams: ["settings.workspace", "settings.team"],
|
||||
"api-keys": ["settings.developer"],
|
||||
connectionMode: ["settings.connection"],
|
||||
planBilling: ["settings.planBilling"],
|
||||
adminGeneral: ["admin.settings.general"],
|
||||
adminFeatures: ["admin.settings.features"],
|
||||
adminEndpoints: ["admin.settings.endpoints"],
|
||||
adminDatabase: ["admin.settings.database"],
|
||||
adminAdvanced: ["admin.settings.advanced"],
|
||||
adminSecurity: ["admin.settings.security"],
|
||||
adminMcp: ["admin.settings.mcp"],
|
||||
adminConnections: [
|
||||
"admin.settings.connections",
|
||||
"admin.settings.mail",
|
||||
"admin.settings.security",
|
||||
"admin.settings.telegram",
|
||||
"admin.settings.premium",
|
||||
"admin.settings.general",
|
||||
"settings.securityAuth",
|
||||
"settings.connection",
|
||||
],
|
||||
adminPlan: [
|
||||
"settings.planBilling",
|
||||
"admin.settings.premium",
|
||||
"settings.licensingAnalytics",
|
||||
],
|
||||
adminAudit: ["settings.licensingAnalytics"],
|
||||
adminUsage: ["settings.licensingAnalytics"],
|
||||
adminLegal: ["admin.settings.legal"],
|
||||
adminPrivacy: ["admin.settings.privacy"],
|
||||
};
|
||||
|
||||
const getTranslationPrefixesForNavKey = (key: string): string[] => {
|
||||
const explicitPrefixes = SETTINGS_SEARCH_TRANSLATION_PREFIXES[key] ?? [];
|
||||
|
||||
const inferredPrefixes: string[] = [];
|
||||
|
||||
if (key.startsWith("admin")) {
|
||||
const adminSuffix = key.replace(/^admin/, "");
|
||||
const normalizedAdminSuffix =
|
||||
adminSuffix.charAt(0).toLowerCase() + adminSuffix.slice(1);
|
||||
inferredPrefixes.push(`admin.settings.${normalizedAdminSuffix}`);
|
||||
} else {
|
||||
inferredPrefixes.push(`settings.${key}`);
|
||||
}
|
||||
|
||||
return Array.from(new Set([...explicitPrefixes, ...inferredPrefixes]));
|
||||
};
|
||||
|
||||
const flattenTranslationStrings = (value: unknown): string[] => {
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? [trimmed] : [];
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.flatMap(flattenTranslationStrings);
|
||||
}
|
||||
|
||||
if (value && typeof value === "object") {
|
||||
return Object.values(value as Record<string, unknown>).flatMap(
|
||||
flattenTranslationStrings,
|
||||
);
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
const buildMatchSnippet = (text: string, query: string): string => {
|
||||
const normalizedText = text.toLocaleLowerCase();
|
||||
const normalizedQuery = query.toLocaleLowerCase();
|
||||
const matchIndex = normalizedText.indexOf(normalizedQuery);
|
||||
|
||||
if (matchIndex === -1) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const maxLength = 84;
|
||||
const contextPadding = 28;
|
||||
const start = Math.max(0, matchIndex - contextPadding);
|
||||
const end = Math.min(text.length, matchIndex + query.length + contextPadding);
|
||||
const snippet = text.slice(start, end);
|
||||
|
||||
if (snippet.length <= maxLength) {
|
||||
return `${start > 0 ? "…" : ""}${snippet}${end < text.length ? "…" : ""}`;
|
||||
}
|
||||
|
||||
return `${start > 0 ? "…" : ""}${snippet.slice(0, maxLength)}${end < text.length ? "…" : ""}`;
|
||||
};
|
||||
|
||||
export const SettingsSearchBar: React.FC<SettingsSearchBarProps> = ({
|
||||
configNavSections,
|
||||
onNavigate,
|
||||
@@ -135,19 +43,12 @@ export const SettingsSearchBar: React.FC<SettingsSearchBarProps> = ({
|
||||
section.items
|
||||
.filter((item: ConfigNavItem) => !item.disabled)
|
||||
.map((item: ConfigNavItem) => {
|
||||
const translationPrefixes = getTranslationPrefixesForNavKey(item.key);
|
||||
const translationContent = translationPrefixes.flatMap((prefix) =>
|
||||
flattenTranslationStrings(
|
||||
t(prefix, { returnObjects: true, defaultValue: {} } as any),
|
||||
),
|
||||
);
|
||||
|
||||
const searchableContent = Array.from(
|
||||
new Set([
|
||||
item.label,
|
||||
section.title,
|
||||
`/settings/${item.key}`,
|
||||
...translationContent,
|
||||
...getSettingsSectionContent(item.key, t),
|
||||
]),
|
||||
);
|
||||
|
||||
|
||||
+17
-43
@@ -11,7 +11,6 @@ import {
|
||||
Group,
|
||||
Anchor,
|
||||
Badge,
|
||||
Alert,
|
||||
} from "@mantine/core";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { ActionIcon } from "@app/ui/ActionIcon";
|
||||
@@ -305,6 +304,14 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
{frontendVersionLabel}
|
||||
</Text>
|
||||
</Text>
|
||||
{mismatchVersion && (
|
||||
<Text size="sm" c="red" mt={4}>
|
||||
{t(
|
||||
"settings.general.updates.versionMismatch",
|
||||
"Warning: A mismatch has been detected between the client version and the AppConfig version. Using different versions can lead to compatibility issues, errors, and security risks. Please ensure that server and client are using the same version.",
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</Group>
|
||||
)}
|
||||
@@ -454,48 +461,6 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* Frontend/backend version mismatch notice. Informational, not an
|
||||
error, and deliberately separated from "Check for Updates" — the
|
||||
update check only looks for a newer *release* and cannot
|
||||
reconcile a mismatch between the app's bundled components. The
|
||||
mismatch is normally transient while an update is being applied,
|
||||
so the resolution is to restart (or reinstall) the app rather
|
||||
than to check for updates. */}
|
||||
{mismatchVersion && (
|
||||
<Alert
|
||||
variant="light"
|
||||
color="yellow"
|
||||
icon={
|
||||
<LocalIcon
|
||||
icon="info-outline-rounded"
|
||||
width="1.2rem"
|
||||
height="1.2rem"
|
||||
/>
|
||||
}
|
||||
title={t(
|
||||
"settings.general.updates.versionMismatchTitle",
|
||||
"Version mismatch",
|
||||
)}
|
||||
>
|
||||
<Text size="sm">
|
||||
{t(
|
||||
"settings.general.updates.versionMismatch",
|
||||
"The app frontend ({{frontendVersion}}) and backend ({{backendVersion}}) are running different versions. This usually happens briefly while an update is being applied.",
|
||||
{
|
||||
frontendVersion: frontendVersionLabel,
|
||||
backendVersion: config?.appVersion ?? "",
|
||||
},
|
||||
)}
|
||||
</Text>
|
||||
<Text size="sm" mt={4}>
|
||||
{t(
|
||||
"settings.general.updates.versionMismatchResolution",
|
||||
"Restart Stirling-PDF to finish applying the update. If the warning persists after restarting, reinstall the latest version to bring both components back in sync.",
|
||||
)}
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
@@ -504,6 +469,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="md">
|
||||
<div
|
||||
id="setting-theme"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
@@ -541,6 +507,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
id="setting-language"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
@@ -566,6 +533,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="md">
|
||||
<div
|
||||
id="setting-tool-picker-mode"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
@@ -604,6 +572,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
id="setting-startup-view"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
@@ -646,6 +615,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
id="setting-reader-zoom"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
@@ -701,6 +671,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
id="setting-hide-unavailable-tools"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
@@ -732,6 +703,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
id="setting-hide-unavailable-conversions"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
@@ -772,6 +744,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
withArrow
|
||||
>
|
||||
<div
|
||||
id="setting-auto-unzip"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
@@ -809,6 +782,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
withArrow
|
||||
>
|
||||
<div
|
||||
id="setting-auto-unzip-file-limit"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -146,7 +146,7 @@ const HotkeysSection: React.FC = () => {
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<div>
|
||||
<div id="setting-hotkeys-search">
|
||||
<Text fw={600} size="lg">
|
||||
{t("settings.hotkeys.title", "Keyboard Shortcuts")}
|
||||
</Text>
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
.super-search {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
.super-search input {
|
||||
box-sizing: border-box;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 8px;
|
||||
background-color: var(--bg-muted);
|
||||
/* Tighter than the shared TextInput default (8px/14px) for a compact,
|
||||
professional bar — height lands in line with the 28px action icons. */
|
||||
padding-top: 6px;
|
||||
padding-bottom: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .super-search input {
|
||||
background-color: var(--input-bg);
|
||||
}
|
||||
|
||||
.super-search input:focus {
|
||||
border-color: var(--accent-interactive);
|
||||
}
|
||||
|
||||
/* Keyboard-shortcut hint pinned to the right of the input. */
|
||||
.super-search-kbd {
|
||||
position: absolute;
|
||||
right: 0.5rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
pointer-events: none;
|
||||
font-size: 0.7rem;
|
||||
line-height: 1;
|
||||
padding: 0.15rem 0.35rem;
|
||||
border-radius: 4px;
|
||||
color: var(--text-muted);
|
||||
background: var(--hover-bg);
|
||||
border: 1px solid var(--border-subtle);
|
||||
/* Tiny breathing room between the ⌘ glyph and the K. */
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
/* Portalled to <body>; coords are set inline from the input's viewport rect. */
|
||||
.super-search-dropdown {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
padding: 0.35rem;
|
||||
background: var(--bg-toolbar);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.super-search-empty {
|
||||
padding: 0.75rem 0.6rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.super-search-group + .super-search-group {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.super-search-group-label {
|
||||
padding: 0.35rem 0.5rem 0.2rem;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.super-search-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
width: 100%;
|
||||
padding: 0.45rem 0.5rem;
|
||||
border: none;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.super-search-item.active,
|
||||
.super-search-item:hover {
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.super-search-item-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 1.4rem;
|
||||
height: 1.4rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.super-search-item-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.super-search-item-title {
|
||||
font-size: 0.875rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text } from "@mantine/core";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { TextInput } from "@app/components/shared/TextInput";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import { isMacLike } from "@app/utils/hotkeys";
|
||||
import {
|
||||
useSuperSearch,
|
||||
SuperSearchResult,
|
||||
type UseSuperSearchResult,
|
||||
} from "@app/hooks/useSuperSearch";
|
||||
import "@app/components/shared/superSearch/SuperSearch.css";
|
||||
|
||||
interface DropdownRect {
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
}
|
||||
|
||||
interface SuperSearchProps {
|
||||
/**
|
||||
* Results provider, called as a hook — it MUST be referentially stable for
|
||||
* the component's lifetime. Defaults to the editor's files/tools/settings/
|
||||
* Processor provider; the portal passes its own destinations provider.
|
||||
*/
|
||||
useResults?: (query: string, active: boolean) => UseSuperSearchResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Global "super search": a single entry point that searches across the host
|
||||
* app's destinations from a persistent bar. The results hang in a dropdown
|
||||
* directly below the input; Cmd/Ctrl+K focuses and opens it.
|
||||
*
|
||||
* The dropdown is portalled to <body>: the host bar's inner wrapper may set
|
||||
* `overflow: hidden`, which would otherwise clip it.
|
||||
*/
|
||||
export default function SuperSearch({
|
||||
useResults = useSuperSearch,
|
||||
}: SuperSearchProps = {}) {
|
||||
const { t } = useTranslation();
|
||||
const [query, setQuery] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [highlight, setHighlight] = useState(0);
|
||||
const [rect, setRect] = useState<DropdownRect | null>(null);
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { groups, flatResults, loadingFiles } = useResults(query, open);
|
||||
|
||||
const trimmed = query.trim();
|
||||
const hasQuery = trimmed.length > 0;
|
||||
|
||||
// Keep the highlighted index within bounds as results change.
|
||||
useEffect(() => {
|
||||
setHighlight((h) =>
|
||||
flatResults.length === 0 ? 0 : Math.min(h, flatResults.length - 1),
|
||||
);
|
||||
}, [flatResults.length]);
|
||||
|
||||
const close = useCallback(() => {
|
||||
setOpen(false);
|
||||
setHighlight(0);
|
||||
}, []);
|
||||
|
||||
// Position the portalled dropdown directly under the input while open.
|
||||
const updateRect = useCallback(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const r = el.getBoundingClientRect();
|
||||
setRect({ top: r.bottom + 6, left: r.left, width: r.width });
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open) return;
|
||||
updateRect();
|
||||
window.addEventListener("resize", updateRect);
|
||||
window.addEventListener("scroll", updateRect, true);
|
||||
return () => {
|
||||
window.removeEventListener("resize", updateRect);
|
||||
window.removeEventListener("scroll", updateRect, true);
|
||||
};
|
||||
}, [open, updateRect]);
|
||||
|
||||
const selectResult = useCallback(
|
||||
(result: SuperSearchResult | undefined) => {
|
||||
if (!result) return;
|
||||
void result.onSelect();
|
||||
setQuery("");
|
||||
close();
|
||||
inputRef.current?.blur();
|
||||
},
|
||||
[close],
|
||||
);
|
||||
|
||||
// Global Cmd/Ctrl+K to focus + open the search.
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
const combo =
|
||||
(isMacLike() ? e.metaKey : e.ctrlKey) && !e.altKey && !e.shiftKey;
|
||||
if (combo && (e.key === "k" || e.key === "K")) {
|
||||
e.preventDefault();
|
||||
setOpen(true);
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, []);
|
||||
|
||||
// Close on click outside the input or the (portalled) dropdown.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onMouseDown = (e: MouseEvent) => {
|
||||
const target = e.target as Node;
|
||||
if (
|
||||
containerRef.current?.contains(target) ||
|
||||
dropdownRef.current?.contains(target)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
close();
|
||||
};
|
||||
document.addEventListener("mousedown", onMouseDown);
|
||||
return () => document.removeEventListener("mousedown", onMouseDown);
|
||||
}, [open, close]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
if (!open) setOpen(true);
|
||||
setHighlight((h) =>
|
||||
flatResults.length === 0 ? 0 : (h + 1) % flatResults.length,
|
||||
);
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setHighlight((h) =>
|
||||
flatResults.length === 0
|
||||
? 0
|
||||
: (h - 1 + flatResults.length) % flatResults.length,
|
||||
);
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
selectResult(flatResults[highlight]);
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
if (hasQuery) {
|
||||
setQuery("");
|
||||
} else {
|
||||
close();
|
||||
inputRef.current?.blur();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const shortcutHint = useMemo(() => (isMacLike() ? "⌘K" : "Ctrl+K"), []);
|
||||
|
||||
const showNoResults =
|
||||
open && hasQuery && !loadingFiles && flatResults.length === 0;
|
||||
|
||||
const dropdown =
|
||||
open && rect ? (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="super-search-dropdown"
|
||||
role="listbox"
|
||||
style={{ top: rect.top, left: rect.left, width: rect.width }}
|
||||
// Keep focus on the input when clicking inside the dropdown.
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
{!hasQuery && (
|
||||
<div className="super-search-empty">
|
||||
{t("superSearch.hint", "Type to search")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showNoResults && (
|
||||
<div className="super-search-empty">
|
||||
{t("search.noResults", "No results found")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasQuery &&
|
||||
groups.map((group) => (
|
||||
<div key={group.id} className="super-search-group">
|
||||
<div className="super-search-group-label">{group.label}</div>
|
||||
{group.results.map((result) => {
|
||||
const index = flatResults.indexOf(result);
|
||||
const active = index === highlight;
|
||||
return (
|
||||
<Button
|
||||
key={result.key}
|
||||
type="button"
|
||||
variant="quiet"
|
||||
justify="start"
|
||||
fullWidth
|
||||
role="option"
|
||||
aria-selected={active}
|
||||
className={`super-search-item${active ? " active" : ""}`}
|
||||
onMouseEnter={() => setHighlight(index)}
|
||||
onClick={() => selectResult(result)}
|
||||
>
|
||||
<span className="super-search-item-icon">
|
||||
{result.icon ?? (
|
||||
<LocalIcon
|
||||
icon={result.iconName ?? "search-rounded"}
|
||||
width="1.1rem"
|
||||
height="1.1rem"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<span className="super-search-item-text">
|
||||
<span className="super-search-item-title">
|
||||
{result.title}
|
||||
</span>
|
||||
{result.subtitle && (
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{result.subtitle}
|
||||
</Text>
|
||||
)}
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="super-search" ref={containerRef} onKeyDown={handleKeyDown}>
|
||||
<TextInput
|
||||
id="super-search-input"
|
||||
name="super-search-input"
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={setQuery}
|
||||
placeholder={t("superSearch.placeholder", "Search Stirling")}
|
||||
icon={
|
||||
<LocalIcon icon="search-rounded" width="1.1rem" height="1.1rem" />
|
||||
}
|
||||
autoComplete="off"
|
||||
aria-label={t("superSearch.ariaLabel", "Super search")}
|
||||
onFocus={() => setOpen(true)}
|
||||
/>
|
||||
{!open && !hasQuery && (
|
||||
<kbd className="super-search-kbd" aria-hidden="true">
|
||||
{shortcutHint}
|
||||
</kbd>
|
||||
)}
|
||||
{dropdown && createPortal(dropdown, document.body)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||
import { useSidebarContext } from "@app/contexts/SidebarContext";
|
||||
import { useIsMobile } from "@app/hooks/useIsMobile";
|
||||
import ToolPanel from "@app/components/tools/ToolPanel";
|
||||
import ToolSearch from "@app/components/tools/toolPicker/ToolSearch";
|
||||
import { usePoliciesEnabled } from "@app/components/policies/usePoliciesEnabled";
|
||||
import { PolicyAutoRunController } from "@app/components/policies/PolicyAutoRunController";
|
||||
import { useFavoriteToolItems } from "@app/hooks/tools/useFavoriteToolItems";
|
||||
@@ -43,7 +42,6 @@ export default function RightSidebar() {
|
||||
const {
|
||||
leftPanelView,
|
||||
isPanelVisible,
|
||||
searchQuery,
|
||||
filteredTools,
|
||||
toolRegistry,
|
||||
setSearchQuery,
|
||||
@@ -96,7 +94,6 @@ export default function RightSidebar() {
|
||||
const inToolView = leftPanelView !== "toolPicker";
|
||||
// Show X (close) button only when there's somewhere to go back to.
|
||||
const showCloseButton = inToolView || allToolsView;
|
||||
const showHeaderSearch = showCloseButton || leftPanelView === "toolPicker";
|
||||
|
||||
const handleHeaderBack = () => {
|
||||
if (inToolView) {
|
||||
@@ -110,25 +107,17 @@ export default function RightSidebar() {
|
||||
withViewTransition(() => handleToolSelect(id));
|
||||
};
|
||||
|
||||
// Typing in the header search while inside a tool exits the tool and lifts the
|
||||
// panel into the all-tools view so the user immediately sees search results.
|
||||
const handleHeaderSearchChange = (value: string) => {
|
||||
if (inToolView) {
|
||||
withViewTransition(() => {
|
||||
handleBackToTools();
|
||||
setAllToolsView(true);
|
||||
setSearchQuery(value);
|
||||
});
|
||||
return;
|
||||
}
|
||||
setSearchQuery(value);
|
||||
};
|
||||
|
||||
const activeTool: ToolRegistryEntry | null =
|
||||
inToolView && selectedToolKey
|
||||
? (toolRegistry[selectedToolKey as ToolId] ?? null)
|
||||
: null;
|
||||
|
||||
// Tool search now lives in the global super search, so the panel header
|
||||
// carries only the active-tool pill or the collapse/close control. When it's
|
||||
// just the collapse chevron, float it into the corner (see CSS) so the tool
|
||||
// list starts at the top instead of below an empty band.
|
||||
const headerCollapseOnly = !activeTool && !showCloseButton;
|
||||
|
||||
const expandedWidth = "18.5rem";
|
||||
|
||||
const computedWidth = () => {
|
||||
@@ -234,6 +223,7 @@ export default function RightSidebar() {
|
||||
flexShrink: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<>
|
||||
@@ -255,18 +245,13 @@ export default function RightSidebar() {
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="tool-panel__compact-header">
|
||||
{showHeaderSearch ? (
|
||||
<div className="tool-panel__compact-header-search">
|
||||
<ToolSearch
|
||||
value={searchQuery}
|
||||
onChange={handleHeaderSearchChange}
|
||||
toolRegistry={toolRegistry}
|
||||
mode="filter"
|
||||
autoFocus={allToolsView && !inToolView}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
className={`tool-panel__compact-header${
|
||||
headerCollapseOnly
|
||||
? " tool-panel__compact-header--collapse-only"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{showCloseButton ? (
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
|
||||
@@ -266,6 +266,18 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* When the header holds only the collapse chevron (no active tool, no search),
|
||||
float it into the corner instead of reserving an empty band, so the tool list
|
||||
starts at the top of the panel. */
|
||||
.tool-panel__compact-header--collapse-only {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
min-height: 0;
|
||||
padding: 1rem 0.5rem 0.5rem;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.tool-panel__compact-header .tool-panel__expand-btn {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
@@ -176,7 +176,11 @@ export function useViewerWorkbenchBarButtons(
|
||||
aria-label={searchLabel}
|
||||
onClick={viewer.searchInterfaceActions.toggle}
|
||||
>
|
||||
<LocalIcon icon="search" width="1rem" height="1rem" />
|
||||
<LocalIcon
|
||||
icon="find-in-page-rounded"
|
||||
width="1.25rem"
|
||||
height="1.25rem"
|
||||
/>
|
||||
</ActionIcon>
|
||||
</div>
|
||||
</Popover.Target>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Search index for the admin portal ("Processor") destinations offered by the
|
||||
* global super search. Core and desktop builds ship no portal, so the index is
|
||||
* empty and the Processor group never renders; the proprietary build (which
|
||||
* mounts the portal as a route-set) shadows this with the real view list.
|
||||
*/
|
||||
export interface ProcessorSearchEntry {
|
||||
/** Portal view id — stable key for the result row. */
|
||||
id: string;
|
||||
/** i18n key for the view's display name (shared with the portal sidebar). */
|
||||
labelKey: string;
|
||||
labelFallback: string;
|
||||
/** In-app path to navigate to. Empty when externalUrl is set. */
|
||||
path: string;
|
||||
/** Opens in a new tab instead of navigating (e.g. hosted docs). */
|
||||
externalUrl?: string;
|
||||
/** Extra fuzzy-match terms beyond the label. */
|
||||
keywords?: string[];
|
||||
}
|
||||
|
||||
export const PROCESSOR_SEARCH_INDEX: ProcessorSearchEntry[] = [];
|
||||
@@ -0,0 +1,157 @@
|
||||
import i18n from "i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
|
||||
/**
|
||||
* Content-level settings search: matches a query against every translation
|
||||
* string a settings section renders, so a term that appears anywhere on a
|
||||
* settings page ("SMTP", "OCR", a field label…) finds that section without a
|
||||
* curated keyword.
|
||||
*
|
||||
* This is the single source for the technique — the in-modal settings search
|
||||
* bar and the global super search both build on it. It is intentionally
|
||||
* component-free (translation subtrees only) so the always-mounted super
|
||||
* search bar can use it without pulling the lazy settings modal into the main
|
||||
* bundle.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Translation subtrees whose strings appear on each settings section, for the
|
||||
* sections where the nav key doesn't map 1:1 onto a toml prefix. Keys missing
|
||||
* here fall back to the inferred `settings.<key>` / `admin.settings.<key>`
|
||||
* prefix.
|
||||
*/
|
||||
const SECTION_TRANSLATION_PREFIXES: Partial<Record<string, string[]>> = {
|
||||
general: ["settings.general"],
|
||||
hotkeys: ["settings.hotkeys"],
|
||||
account: ["account"],
|
||||
people: ["settings.workspace"],
|
||||
teams: ["settings.workspace", "settings.team"],
|
||||
"api-keys": ["settings.developer"],
|
||||
connectionMode: ["settings.connection"],
|
||||
planBilling: ["settings.planBilling"],
|
||||
adminGeneral: ["admin.settings.general"],
|
||||
adminFeatures: ["admin.settings.features"],
|
||||
adminEndpoints: ["admin.settings.endpoints"],
|
||||
adminDatabase: ["admin.settings.database"],
|
||||
adminAdvanced: ["admin.settings.advanced"],
|
||||
adminSecurity: ["admin.settings.security"],
|
||||
adminMcp: ["admin.settings.mcp"],
|
||||
adminConnections: [
|
||||
"admin.settings.connections",
|
||||
"admin.settings.mail",
|
||||
"admin.settings.security",
|
||||
"admin.settings.telegram",
|
||||
"admin.settings.premium",
|
||||
"admin.settings.general",
|
||||
"settings.securityAuth",
|
||||
"settings.connection",
|
||||
],
|
||||
adminPlan: [
|
||||
"settings.planBilling",
|
||||
"admin.settings.premium",
|
||||
"settings.licensingAnalytics",
|
||||
],
|
||||
adminAudit: ["settings.licensingAnalytics"],
|
||||
adminUsage: ["settings.licensingAnalytics"],
|
||||
adminLegal: ["admin.settings.legal"],
|
||||
adminPrivacy: ["admin.settings.privacy"],
|
||||
};
|
||||
|
||||
export const getTranslationPrefixesForNavKey = (key: string): string[] => {
|
||||
const explicitPrefixes = SECTION_TRANSLATION_PREFIXES[key] ?? [];
|
||||
|
||||
const inferredPrefixes: string[] = [];
|
||||
|
||||
if (key.startsWith("admin")) {
|
||||
const adminSuffix = key.replace(/^admin/, "");
|
||||
const normalizedAdminSuffix =
|
||||
adminSuffix.charAt(0).toLowerCase() + adminSuffix.slice(1);
|
||||
inferredPrefixes.push(`admin.settings.${normalizedAdminSuffix}`);
|
||||
} else {
|
||||
inferredPrefixes.push(`settings.${key}`);
|
||||
}
|
||||
|
||||
return Array.from(new Set([...explicitPrefixes, ...inferredPrefixes]));
|
||||
};
|
||||
|
||||
export const flattenTranslationStrings = (value: unknown): string[] => {
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? [trimmed] : [];
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.flatMap(flattenTranslationStrings);
|
||||
}
|
||||
|
||||
if (value && typeof value === "object") {
|
||||
return Object.values(value as Record<string, unknown>).flatMap(
|
||||
flattenTranslationStrings,
|
||||
);
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
/** Trims a matched string to a short snippet centred on the query hit. */
|
||||
export const buildMatchSnippet = (text: string, query: string): string => {
|
||||
const normalizedText = text.toLocaleLowerCase();
|
||||
const normalizedQuery = query.toLocaleLowerCase();
|
||||
const matchIndex = normalizedText.indexOf(normalizedQuery);
|
||||
|
||||
if (matchIndex === -1) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const maxLength = 84;
|
||||
const contextPadding = 28;
|
||||
const start = Math.max(0, matchIndex - contextPadding);
|
||||
const end = Math.min(text.length, matchIndex + query.length + contextPadding);
|
||||
const snippet = text.slice(start, end);
|
||||
|
||||
if (snippet.length <= maxLength) {
|
||||
return `${start > 0 ? "…" : ""}${snippet}${end < text.length ? "…" : ""}`;
|
||||
}
|
||||
|
||||
return `${start > 0 ? "…" : ""}${snippet.slice(0, maxLength)}${end < text.length ? "…" : ""}`;
|
||||
};
|
||||
|
||||
// Flattening every subtree on each keystroke would be wasteful; sections'
|
||||
// content is static per language, so cache it and drop the cache on switch.
|
||||
const contentCache = new Map<string, string[]>();
|
||||
let contentCacheLanguage: string | undefined;
|
||||
|
||||
export function getSettingsSectionContent(key: string, t: TFunction): string[] {
|
||||
if (contentCacheLanguage !== i18n.language) {
|
||||
contentCache.clear();
|
||||
contentCacheLanguage = i18n.language;
|
||||
}
|
||||
const cached = contentCache.get(key);
|
||||
if (cached) return cached;
|
||||
|
||||
const content = getTranslationPrefixesForNavKey(key).flatMap((prefix) =>
|
||||
flattenTranslationStrings(
|
||||
t(prefix, { returnObjects: true, defaultValue: {} }),
|
||||
),
|
||||
);
|
||||
contentCache.set(key, content);
|
||||
return content;
|
||||
}
|
||||
|
||||
/**
|
||||
* First content string of the section containing the query
|
||||
* (case-insensitive), or null. Substring only — fuzzy matching across whole
|
||||
* paragraphs of copy produces junk hits.
|
||||
*/
|
||||
export function findSettingsContentMatch(
|
||||
key: string,
|
||||
query: string,
|
||||
t: TFunction,
|
||||
): string | null {
|
||||
const normalizedQuery = query.toLocaleLowerCase();
|
||||
return (
|
||||
getSettingsSectionContent(key, t).find((text) =>
|
||||
text.toLocaleLowerCase().includes(normalizedQuery),
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { NavKey } from "@app/components/shared/config/types";
|
||||
|
||||
/**
|
||||
* A single, searchable setting *row* inside the settings modal.
|
||||
*
|
||||
* The existing in-modal SettingsSearchBar searches section content but can only
|
||||
* navigate to a whole section. This index lets the global super search deep-link
|
||||
* to an individual control: navigating to `/settings/{section}?focus={anchor}`,
|
||||
* where `anchor` is the DOM `id` placed on that control's row (see
|
||||
* AppConfigModal's focus-scroll effect and the `id=` attributes added to the
|
||||
* matching section components).
|
||||
*/
|
||||
export interface SettingsSearchEntry {
|
||||
/** Settings section this row lives in (nav key, e.g. "general"). */
|
||||
section: NavKey;
|
||||
/** DOM id on the control's row; used as the `?focus=` anchor. */
|
||||
anchor: string;
|
||||
/** i18n key for the display label. */
|
||||
labelKey: string;
|
||||
/** English fallback / default for the label. */
|
||||
labelFallback: string;
|
||||
/** Extra English terms to match against (synonyms, related words). */
|
||||
keywords?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Curated row-level entries for the high-value, user-facing settings sections.
|
||||
* Section-level results (every other tab) come from the nav sections directly,
|
||||
* so this list only needs the rows worth jumping straight to.
|
||||
*/
|
||||
export const SETTINGS_SEARCH_INDEX: SettingsSearchEntry[] = [
|
||||
// --- General > Appearance ---
|
||||
{
|
||||
section: "general",
|
||||
anchor: "setting-theme",
|
||||
labelKey: "settings.general.theme",
|
||||
labelFallback: "Theme",
|
||||
keywords: ["dark", "light", "mode", "appearance", "colour", "color"],
|
||||
},
|
||||
{
|
||||
section: "general",
|
||||
anchor: "setting-language",
|
||||
labelKey: "settings.general.language",
|
||||
labelFallback: "Language",
|
||||
keywords: ["locale", "translation", "i18n"],
|
||||
},
|
||||
// --- General > Behaviour ---
|
||||
{
|
||||
section: "general",
|
||||
anchor: "setting-tool-picker-mode",
|
||||
labelKey: "settings.general.defaultToolPickerMode",
|
||||
labelFallback: "Default tool picker mode",
|
||||
keywords: ["sidebar", "fullscreen", "tools", "panel"],
|
||||
},
|
||||
{
|
||||
section: "general",
|
||||
anchor: "setting-startup-view",
|
||||
labelKey: "settings.general.defaultStartupView",
|
||||
labelFallback: "Default view on launch",
|
||||
keywords: ["startup", "launch", "home", "reader", "automate"],
|
||||
},
|
||||
{
|
||||
section: "general",
|
||||
anchor: "setting-reader-zoom",
|
||||
labelKey: "settings.general.defaultViewerZoom",
|
||||
labelFallback: "Default reader zoom",
|
||||
keywords: ["zoom", "viewer", "fit width", "fit page", "magnification"],
|
||||
},
|
||||
{
|
||||
section: "general",
|
||||
anchor: "setting-hide-unavailable-tools",
|
||||
labelKey: "settings.general.hideUnavailableTools",
|
||||
labelFallback: "Hide unavailable tools",
|
||||
keywords: ["disabled", "greyed", "tools"],
|
||||
},
|
||||
{
|
||||
section: "general",
|
||||
anchor: "setting-hide-unavailable-conversions",
|
||||
labelKey: "settings.general.hideUnavailableConversions",
|
||||
labelFallback: "Hide unavailable conversions",
|
||||
keywords: ["disabled", "convert", "conversions"],
|
||||
},
|
||||
{
|
||||
section: "general",
|
||||
anchor: "setting-auto-unzip",
|
||||
labelKey: "settings.general.autoUnzip",
|
||||
labelFallback: "Auto-unzip API responses",
|
||||
keywords: ["zip", "extract", "unzip", "archive"],
|
||||
},
|
||||
{
|
||||
section: "general",
|
||||
anchor: "setting-auto-unzip-file-limit",
|
||||
labelKey: "settings.general.autoUnzipFileLimit",
|
||||
labelFallback: "Auto-unzip file limit",
|
||||
keywords: ["zip", "limit", "files", "extract"],
|
||||
},
|
||||
// --- Keyboard Shortcuts ---
|
||||
{
|
||||
section: "hotkeys",
|
||||
anchor: "setting-hotkeys-search",
|
||||
labelKey: "settings.hotkeys.title",
|
||||
labelFallback: "Keyboard Shortcuts",
|
||||
keywords: ["hotkey", "shortcut", "keybinding", "keyboard"],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,67 @@
|
||||
import { NavKey } from "@app/components/shared/config/types";
|
||||
|
||||
/**
|
||||
* A whole settings section (nav tab), described as pure data so the global
|
||||
* super search can offer section-level results.
|
||||
*
|
||||
* This is the single source of truth for *which settings sections are
|
||||
* searchable* in a given build. It is intentionally **component-free**: the
|
||||
* always-mounted top bar imports it (via `@app/data/settingsSectionRegistry`)
|
||||
* to feed the super search, and pulling the heavy settings component tree in
|
||||
* here would defeat the lazy-loaded settings modal (AppConfigModalLazy).
|
||||
*
|
||||
* Layering mirrors the nav builders (`configNavSections`): core lists the
|
||||
* always-present sections; higher layers shadow this module to add their own
|
||||
* (proprietary admin sections, saas cloud sections, …). A section only belongs
|
||||
* in a layer's registry if that layer's settings modal can actually render it —
|
||||
* otherwise search would deep-link to a tab that doesn't exist.
|
||||
*
|
||||
* Per-user visibility (admin / login) is expressed by the flags below and
|
||||
* applied by the super search at query time, mirroring the modal's own gating.
|
||||
*/
|
||||
export interface SettingsSectionEntry {
|
||||
/** Nav key; used for the `/settings/{key}` deep link. */
|
||||
key: NavKey;
|
||||
/** i18n key for the display label. */
|
||||
labelKey: string;
|
||||
/** English fallback / default for the label. */
|
||||
labelFallback: string;
|
||||
/** Extra English terms to match against (synonyms, related words). */
|
||||
keywords?: string[];
|
||||
/** Surface only when login mode is on (e.g. account, API keys). */
|
||||
requiresLogin?: boolean;
|
||||
/**
|
||||
* Admin-area section: the self-hosted modal surfaces it when the user is an
|
||||
* admin OR login mode is off (single-user self-host). Mirrors the proprietary
|
||||
* nav builder's `isAdmin || !loginEnabled` gate.
|
||||
*/
|
||||
adminArea?: boolean;
|
||||
}
|
||||
|
||||
/** Core (OSS) sections — always present in every build. */
|
||||
export const SETTINGS_SECTION_REGISTRY: SettingsSectionEntry[] = [
|
||||
{
|
||||
key: "general",
|
||||
labelKey: "settings.general.title",
|
||||
labelFallback: "General",
|
||||
keywords: ["theme", "language", "appearance", "preferences", "startup"],
|
||||
},
|
||||
{
|
||||
key: "hotkeys",
|
||||
labelKey: "settings.hotkeys.title",
|
||||
labelFallback: "Keyboard Shortcuts",
|
||||
keywords: ["hotkey", "shortcut", "keybinding", "keyboard"],
|
||||
},
|
||||
{
|
||||
key: "help",
|
||||
labelKey: "settings.help.label",
|
||||
labelFallback: "Tours",
|
||||
keywords: ["help", "tour", "guide", "support", "docs"],
|
||||
},
|
||||
{
|
||||
key: "legal",
|
||||
labelKey: "settings.legal.label",
|
||||
labelFallback: "Legal",
|
||||
keywords: ["legal", "terms", "privacy", "licenses"],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,423 @@
|
||||
import {
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import type React from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||
import { useNavigationActions } from "@app/contexts/NavigationContext";
|
||||
import { ViewerContext } from "@app/contexts/ViewerContext";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import { useFileActions } from "@app/contexts/file/fileHooks";
|
||||
import { fileStorage } from "@app/services/fileStorage";
|
||||
import { rankByFuzzy, idToWords } from "@app/utils/fuzzySearch";
|
||||
import type { StirlingFileStub } from "@app/types/fileContext";
|
||||
import type { ToolId } from "@app/types/toolId";
|
||||
import type { ToolRegistry } from "@app/data/toolsTaxonomy";
|
||||
import { SETTINGS_SEARCH_INDEX } from "@app/data/settingsSearchIndex";
|
||||
import { SETTINGS_SECTION_REGISTRY } from "@app/data/settingsSectionRegistry";
|
||||
import {
|
||||
buildMatchSnippet,
|
||||
findSettingsContentMatch,
|
||||
} from "@app/data/settingsContentSearch";
|
||||
import {
|
||||
PROCESSOR_SEARCH_INDEX,
|
||||
type ProcessorSearchEntry,
|
||||
} from "@app/data/processorSearchIndex";
|
||||
|
||||
export type SuperSearchGroupId = "files" | "tools" | "settings" | "processor";
|
||||
|
||||
export interface SuperSearchResult {
|
||||
/** Stable unique key across all groups. */
|
||||
key: string;
|
||||
/** Group id — the editor uses SuperSearchGroupId; other hosts use their own. */
|
||||
group: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
/** LocalIcon name (files/settings); tools provide a React node via `icon`. */
|
||||
iconName?: string;
|
||||
icon?: React.ReactNode;
|
||||
score: number;
|
||||
onSelect: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface SuperSearchGroup {
|
||||
id: string;
|
||||
label: string;
|
||||
results: SuperSearchResult[];
|
||||
}
|
||||
|
||||
/** Per-group result caps so the dropdown stays scannable. */
|
||||
const GROUP_LIMIT = 6;
|
||||
/** Group display order in the dropdown. */
|
||||
const GROUP_ORDER: SuperSearchGroupId[] = [
|
||||
"files",
|
||||
"tools",
|
||||
"settings",
|
||||
"processor",
|
||||
];
|
||||
|
||||
export interface UseSuperSearchResult {
|
||||
/** Non-empty groups, in display order. */
|
||||
groups: SuperSearchGroup[];
|
||||
/** All results flattened in display order (for keyboard navigation). */
|
||||
flatResults: SuperSearchResult[];
|
||||
/** True while the My Files store is loading for the first time. */
|
||||
loadingFiles: boolean;
|
||||
}
|
||||
|
||||
/** Visibility gates shared by the settings and Processor sources. */
|
||||
export interface SuperSearchGates {
|
||||
isAdmin: boolean;
|
||||
loginEnabled: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared sources. Every host bar (editor workbench, portal shell) builds its
|
||||
// results from these, so a query ranks identically everywhere — only the
|
||||
// select actions differ (in-app contexts vs cross-app navigation).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Loads the My Files stubs whenever the search surface is open. */
|
||||
export function useMyFilesStubs(active: boolean): {
|
||||
stubs: StirlingFileStub[];
|
||||
loadingFiles: boolean;
|
||||
} {
|
||||
const [stubs, setStubs] = useState<StirlingFileStub[]>([]);
|
||||
const [loadingFiles, setLoadingFiles] = useState(false);
|
||||
const loadedOnceRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
// Refresh whenever the surface opens so newly added files appear.
|
||||
let cancelled = false;
|
||||
if (!loadedOnceRef.current) setLoadingFiles(true);
|
||||
fileStorage
|
||||
.getLeafStirlingFileStubs()
|
||||
.then((all) => {
|
||||
if (cancelled) return;
|
||||
setStubs(all);
|
||||
loadedOnceRef.current = true;
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("[SuperSearch] Failed to load file stubs:", err);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoadingFiles(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [active]);
|
||||
|
||||
return { stubs, loadingFiles };
|
||||
}
|
||||
|
||||
export function rankFileResults(
|
||||
stubs: StirlingFileStub[],
|
||||
trimmed: string,
|
||||
openFile: (stub: StirlingFileStub) => void | Promise<void>,
|
||||
): SuperSearchResult[] {
|
||||
if (!trimmed) return [];
|
||||
return rankByFuzzy(stubs, trimmed, [(s) => s.name])
|
||||
.slice(0, GROUP_LIMIT)
|
||||
.map(({ item, score }) => ({
|
||||
key: `file:${item.id}`,
|
||||
group: "files",
|
||||
title: item.name,
|
||||
iconName: "insert-drive-file-rounded",
|
||||
score,
|
||||
onSelect: () => openFile(item),
|
||||
}));
|
||||
}
|
||||
|
||||
export function rankToolResults(
|
||||
registry: Partial<ToolRegistry>,
|
||||
trimmed: string,
|
||||
openTool: (id: ToolId) => void,
|
||||
): SuperSearchResult[] {
|
||||
if (!trimmed) return [];
|
||||
const entries = Object.entries(registry) as [
|
||||
ToolId,
|
||||
ToolRegistry[ToolId] | undefined,
|
||||
][];
|
||||
return rankByFuzzy(entries, trimmed, [
|
||||
([id]) => idToWords(id),
|
||||
([, v]) => v?.name ?? "",
|
||||
([, v]) => v?.description ?? "",
|
||||
([, v]) => v?.synonyms?.join(" ") ?? "",
|
||||
])
|
||||
.slice(0, GROUP_LIMIT)
|
||||
.map(({ item: [id, tool], score }) => ({
|
||||
key: `tool:${id}`,
|
||||
group: "tools",
|
||||
title: tool?.name ?? id,
|
||||
subtitle: tool?.description,
|
||||
icon: tool?.icon,
|
||||
score,
|
||||
onSelect: () => openTool(id),
|
||||
}));
|
||||
}
|
||||
|
||||
export function rankSettingsResults(
|
||||
trimmed: string,
|
||||
t: TFunction,
|
||||
gates: SuperSearchGates,
|
||||
openSettings: (section: string, anchor?: string) => void,
|
||||
): SuperSearchResult[] {
|
||||
if (!trimmed) return [];
|
||||
const { isAdmin, loginEnabled } = gates;
|
||||
|
||||
// Row-level entries (deep-link with ?focus=) take priority.
|
||||
const rows = rankByFuzzy(SETTINGS_SEARCH_INDEX, trimmed, [
|
||||
(e) => t(e.labelKey, e.labelFallback),
|
||||
(e) => e.labelFallback,
|
||||
(e) => e.keywords?.join(" ") ?? "",
|
||||
]).map(({ item, score }) => ({
|
||||
key: `setting:${item.section}:${item.anchor}`,
|
||||
group: "settings",
|
||||
title: t(item.labelKey, item.labelFallback),
|
||||
subtitle: t(`settings.${item.section}.title`, item.section),
|
||||
iconName: "settings-rounded",
|
||||
score: score + 1, // nudge rows above bare section matches
|
||||
onSelect: () => openSettings(item.section, item.anchor),
|
||||
}));
|
||||
|
||||
// Section-level entries (whole tab), gated like the modal nav. The registry
|
||||
// resolves per build (core / proprietary / saas / desktop), so this only
|
||||
// ever sees sections the current build's settings modal can actually show.
|
||||
const visibleSections = SETTINGS_SECTION_REGISTRY.filter((s) => {
|
||||
if (s.requiresLogin && !loginEnabled) return false;
|
||||
// Admin-area sections mirror the builder's `isAdmin || !loginEnabled` gate.
|
||||
if (s.adminArea && !(isAdmin || !loginEnabled)) return false;
|
||||
return true;
|
||||
});
|
||||
const sectionMatches = rankByFuzzy(visibleSections, trimmed, [
|
||||
(s) => t(s.labelKey, s.labelFallback),
|
||||
(s) => s.labelFallback,
|
||||
(s) => s.keywords?.join(" ") ?? "",
|
||||
]);
|
||||
const sections = sectionMatches.map(({ item, score }) => ({
|
||||
key: `setting-section:${item.key}`,
|
||||
group: "settings",
|
||||
title: t(item.labelKey, item.labelFallback),
|
||||
iconName: "settings-rounded",
|
||||
score,
|
||||
onSelect: () => openSettings(item.key),
|
||||
}));
|
||||
|
||||
// Content matches: sections whose rendered copy contains the query (the
|
||||
// in-modal settings search technique), so terms with no curated keyword
|
||||
// ("SMTP", a field label) still find their section. Ranked below every
|
||||
// label/keyword match; 3+ chars so a single letter doesn't match half the
|
||||
// modal.
|
||||
const labelMatchedKeys = new Set(sectionMatches.map(({ item }) => item.key));
|
||||
const contentMatches =
|
||||
trimmed.length < 3
|
||||
? []
|
||||
: visibleSections
|
||||
.filter((s) => !labelMatchedKeys.has(s.key))
|
||||
.flatMap((s) => {
|
||||
const match = findSettingsContentMatch(s.key, trimmed, t);
|
||||
if (!match) return [];
|
||||
return [
|
||||
{
|
||||
key: `setting-content:${s.key}`,
|
||||
group: "settings",
|
||||
title: t(s.labelKey, s.labelFallback),
|
||||
subtitle: buildMatchSnippet(match, trimmed),
|
||||
iconName: "settings-rounded",
|
||||
score: 20, // below the fuzzy thresholds (30/40)
|
||||
onSelect: () => openSettings(s.key),
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
return [...rows, ...sections, ...contentMatches]
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, GROUP_LIMIT);
|
||||
}
|
||||
|
||||
export function rankProcessorResults(
|
||||
trimmed: string,
|
||||
t: TFunction,
|
||||
gates: SuperSearchGates,
|
||||
selectEntry: (entry: ProcessorSearchEntry) => void,
|
||||
): SuperSearchResult[] {
|
||||
if (!trimmed || PROCESSOR_SEARCH_INDEX.length === 0) return [];
|
||||
// The portal is an admin surface; mirror the settings modal's admin gate
|
||||
// (a login-disabled single-user deployment has a full-access operator).
|
||||
if (!(gates.isAdmin || !gates.loginEnabled)) return [];
|
||||
return rankByFuzzy(PROCESSOR_SEARCH_INDEX, trimmed, [
|
||||
(e) => t(e.labelKey, e.labelFallback),
|
||||
(e) => e.labelFallback,
|
||||
(e) => e.keywords?.join(" ") ?? "",
|
||||
])
|
||||
.slice(0, GROUP_LIMIT)
|
||||
.map(({ item, score }) => ({
|
||||
key: `processor:${item.id}`,
|
||||
group: "processor",
|
||||
title: t(item.labelKey, item.labelFallback),
|
||||
iconName: "grid-view-rounded",
|
||||
score,
|
||||
onSelect: () => selectEntry(item),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Orders the sources into the shared group layout, dropping empties. Hosts
|
||||
* pass their own order so local results lead (the editor puts its own
|
||||
* files/tools first and Processor pages last; the portal the reverse).
|
||||
*/
|
||||
export function assembleSuperSearchGroups(
|
||||
byId: Partial<Record<SuperSearchGroupId, SuperSearchResult[]>>,
|
||||
t: TFunction,
|
||||
order: SuperSearchGroupId[] = GROUP_ORDER,
|
||||
): SuperSearchGroup[] {
|
||||
const labels: Record<SuperSearchGroupId, string> = {
|
||||
files: t("superSearch.group.files", "Files"),
|
||||
tools: t("superSearch.group.tools", "Tools"),
|
||||
settings: t("superSearch.group.settings", "Settings"),
|
||||
processor: t("superSearch.group.processor", "Processor"),
|
||||
};
|
||||
return order
|
||||
.map((id) => ({
|
||||
id,
|
||||
label: labels[id],
|
||||
results: byId[id] ?? [],
|
||||
}))
|
||||
.filter((g) => g.results.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* The editor's results provider: the shared sources wired to in-app select
|
||||
* actions (open file → viewer, select tool in the workbench, deep-link into
|
||||
* the settings modal, route into the Processor).
|
||||
*
|
||||
* @param query current search text
|
||||
* @param active whether the search surface is open; gates the My Files load
|
||||
*/
|
||||
export function useSuperSearch(
|
||||
query: string,
|
||||
active: boolean,
|
||||
): UseSuperSearchResult {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
toolRegistry,
|
||||
handleToolSelect,
|
||||
handleToolSelectForced,
|
||||
toolAvailability,
|
||||
} = useToolWorkflow();
|
||||
const { actions: navActions } = useNavigationActions();
|
||||
const { actions: fileActions } = useFileActions();
|
||||
const { config } = useAppConfig();
|
||||
// ViewerContext is only present once the viewer subtree mounts; treat as optional.
|
||||
const viewer = useContext(ViewerContext);
|
||||
|
||||
const trimmed = query.trim();
|
||||
const { stubs, loadingFiles } = useMyFilesStubs(active);
|
||||
|
||||
// --- Actions -----------------------------------------------------------
|
||||
const openFile = useCallback(
|
||||
async (stub: StirlingFileStub) => {
|
||||
try {
|
||||
// The file already lives in storage — load it as a stub so its id and
|
||||
// metadata are preserved (addFiles would persist a duplicate record).
|
||||
await fileActions.addStirlingFileStubs([stub], { selectFiles: true });
|
||||
navActions.setWorkbench("viewer");
|
||||
viewer?.setActiveFileId?.(stub.id);
|
||||
} catch (err) {
|
||||
console.error("[SuperSearch] Failed to open file:", stub.name, err);
|
||||
}
|
||||
},
|
||||
[fileActions, navActions, viewer],
|
||||
);
|
||||
|
||||
const openTool = useCallback(
|
||||
(id: ToolId) => {
|
||||
// Tools whose backend endpoint isn't served in this environment are
|
||||
// flagged unavailable; handleToolSelect silently no-ops them. For a
|
||||
// search ("take me to Repair") we still want the click to open the
|
||||
// tool's UI, so fall back to the forced path for those. Available tools
|
||||
// keep the normal path so the unsaved-changes guard still applies.
|
||||
const available = toolAvailability[id]?.available !== false;
|
||||
if (available) {
|
||||
handleToolSelect(id);
|
||||
} else {
|
||||
handleToolSelectForced(id);
|
||||
}
|
||||
},
|
||||
[handleToolSelect, handleToolSelectForced, toolAvailability],
|
||||
);
|
||||
|
||||
const openSettings = useCallback(
|
||||
(section: string, anchor?: string) => {
|
||||
const path = anchor
|
||||
? `/settings/${section}?focus=${encodeURIComponent(anchor)}`
|
||||
: `/settings/${section}`;
|
||||
navigate(path);
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
const selectProcessorEntry = useCallback(
|
||||
(item: ProcessorSearchEntry) => {
|
||||
if (item.externalUrl) {
|
||||
window.open(item.externalUrl, "_blank", "noopener,noreferrer");
|
||||
} else {
|
||||
navigate(item.path);
|
||||
}
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
// --- Assemble ----------------------------------------------------------
|
||||
const gates = useMemo<SuperSearchGates>(
|
||||
() => ({
|
||||
isAdmin: config?.isAdmin ?? false,
|
||||
loginEnabled: config?.enableLogin ?? false,
|
||||
}),
|
||||
[config],
|
||||
);
|
||||
|
||||
const groups = useMemo<SuperSearchGroup[]>(
|
||||
() =>
|
||||
assembleSuperSearchGroups(
|
||||
{
|
||||
files: rankFileResults(stubs, trimmed, openFile),
|
||||
tools: rankToolResults(toolRegistry, trimmed, openTool),
|
||||
settings: rankSettingsResults(trimmed, t, gates, openSettings),
|
||||
processor: rankProcessorResults(
|
||||
trimmed,
|
||||
t,
|
||||
gates,
|
||||
selectProcessorEntry,
|
||||
),
|
||||
},
|
||||
t,
|
||||
),
|
||||
[
|
||||
stubs,
|
||||
trimmed,
|
||||
openFile,
|
||||
toolRegistry,
|
||||
openTool,
|
||||
gates,
|
||||
openSettings,
|
||||
selectProcessorEntry,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
const flatResults = useMemo(() => groups.flatMap((g) => g.results), [groups]);
|
||||
|
||||
return { groups, flatResults, loadingFiles };
|
||||
}
|
||||
@@ -597,11 +597,6 @@ const MyFilesSidebarOverrides = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
<FileSidebar
|
||||
ref={ref}
|
||||
{...props}
|
||||
onSearchClick={() => {
|
||||
// Just focus the central search field; don't toggle collapse
|
||||
// (which on /files navigates back home).
|
||||
window.dispatchEvent(new Event("files-page:focus-search"));
|
||||
}}
|
||||
onUploadFiles={handleUpload}
|
||||
onPickGoogleDriveFiles={handleUpload}
|
||||
extraAction={{
|
||||
|
||||
@@ -565,25 +565,6 @@ test.describe("Files page", () => {
|
||||
test.describe("Side-rail integration with /files", () => {
|
||||
test.use({ autoGoto: false });
|
||||
|
||||
test("Rail Search focuses the central search field, no navigation", async ({
|
||||
page,
|
||||
}) => {
|
||||
await stubStorageApis(page);
|
||||
await seedFiles(page, [
|
||||
{ id: "alpha", name: "alpha.pdf", remoteStorageId: null },
|
||||
]);
|
||||
await gotoFilesPage(page);
|
||||
// Click the search row in the rail.
|
||||
await page.locator(".file-sidebar-search-row").click();
|
||||
// The central search input should be focused.
|
||||
const focused = await page.evaluate(
|
||||
() => document.activeElement?.getAttribute("aria-label") ?? "",
|
||||
);
|
||||
expect(focused).toMatch(/Search/i);
|
||||
// And we must still be on /files (i.e. didn't navigate home).
|
||||
await expect(page).toHaveURL(/\/files/);
|
||||
});
|
||||
|
||||
test("Rail New folder button visible on /files", async ({ page }) => {
|
||||
await stubStorageApis(page);
|
||||
await seedFiles(page, [
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import { test, expect } from "@app/tests/helpers/stub-test-base";
|
||||
import type { Route } from "@playwright/test";
|
||||
import path from "path";
|
||||
import { uploadFiles } from "@app/tests/helpers/ui-helpers";
|
||||
|
||||
const FIXTURES_DIR = path.join(__dirname, "../test-fixtures");
|
||||
const SAMPLE_PDF = path.join(FIXTURES_DIR, "sample.pdf");
|
||||
|
||||
/** Stub the two signing list endpoints with empty arrays. */
|
||||
async function stubSigningApis(page: import("@playwright/test").Page) {
|
||||
await page.route("**/api/v1/security/cert-sign/sign-requests", (r: Route) =>
|
||||
r.fulfill({ json: [] }),
|
||||
);
|
||||
await page.route("**/api/v1/security/cert-sign/sessions", (r: Route) =>
|
||||
r.fulfill({ json: [] }),
|
||||
);
|
||||
await page.route("**/api/v1/storage/teams/**", (r: Route) =>
|
||||
r.fulfill({ json: [] }),
|
||||
);
|
||||
await page.route("**/api/v1/storage/files", (r: Route) =>
|
||||
r.fulfill({ json: [] }),
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("SharedSign tool — gating", () => {
|
||||
test("shows 'not enabled' notice when storageGroupSigningEnabled is false", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Default stub has no storageGroupSigningEnabled — feature is off.
|
||||
await page.goto("/shared-sign", { waitUntil: "domcontentloaded" });
|
||||
const alert = page.getByRole("alert");
|
||||
await expect(alert).toBeVisible({ timeout: 10_000 });
|
||||
await expect(alert).toContainText(/not enabled|collaborative signing/i);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("SharedSign tool — feature enabled", () => {
|
||||
test.use({
|
||||
stubOptions: {
|
||||
enableLogin: true,
|
||||
},
|
||||
seedJwt: true,
|
||||
});
|
||||
|
||||
test("renders Active/Completed tabs and Request signatures button", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Override app-config to include storageGroupSigningEnabled.
|
||||
await page.route("**/api/v1/config/app-config", (r: Route) =>
|
||||
r.fulfill({
|
||||
json: {
|
||||
enableLogin: true,
|
||||
isAdmin: false,
|
||||
languages: ["en-US"],
|
||||
defaultLocale: "en-US",
|
||||
storageGroupSigningEnabled: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
await stubSigningApis(page);
|
||||
|
||||
await page.goto("/shared-sign", { waitUntil: "domcontentloaded" });
|
||||
|
||||
// Mantine SegmentedControl hides the underlying radio inputs via CSS;
|
||||
// assert on the visible label text instead.
|
||||
await expect(
|
||||
page
|
||||
.locator("label")
|
||||
.filter({ hasText: /^active$/i })
|
||||
.first(),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
await expect(
|
||||
page
|
||||
.locator("label")
|
||||
.filter({ hasText: /^completed$/i })
|
||||
.first(),
|
||||
).toBeVisible();
|
||||
|
||||
// "Request signatures" action button should be visible.
|
||||
const requestBtn = page.getByRole("button", {
|
||||
name: /request signatures/i,
|
||||
});
|
||||
await expect(requestBtn).toBeVisible();
|
||||
});
|
||||
|
||||
test("empty-state message shows when no sessions exist", async ({ page }) => {
|
||||
await page.route("**/api/v1/config/app-config", (r: Route) =>
|
||||
r.fulfill({
|
||||
json: {
|
||||
enableLogin: true,
|
||||
isAdmin: false,
|
||||
languages: ["en-US"],
|
||||
defaultLocale: "en-US",
|
||||
storageGroupSigningEnabled: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
await stubSigningApis(page);
|
||||
|
||||
await page.goto("/shared-sign", { waitUntil: "domcontentloaded" });
|
||||
|
||||
// Empty-state copy for the active tab.
|
||||
await expect(
|
||||
page.getByText(/no pending sign requests|no active sessions/i),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test("clicking 'Request signatures' shows the create-session wizard", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.route("**/api/v1/config/app-config", (r: Route) =>
|
||||
r.fulfill({
|
||||
json: {
|
||||
enableLogin: true,
|
||||
isAdmin: false,
|
||||
languages: ["en-US"],
|
||||
defaultLocale: "en-US",
|
||||
storageGroupSigningEnabled: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
await stubSigningApis(page);
|
||||
// Team members endpoint for participant picker.
|
||||
await page.route("**/api/v1/proprietary/team/**", (r: Route) =>
|
||||
r.fulfill({ json: [] }),
|
||||
);
|
||||
|
||||
await page.goto("/shared-sign", { waitUntil: "domcontentloaded" });
|
||||
|
||||
const requestBtn = page.getByRole("button", {
|
||||
name: /request signatures/i,
|
||||
});
|
||||
await expect(requestBtn).toBeVisible({ timeout: 10_000 });
|
||||
await requestBtn.click();
|
||||
|
||||
// Wizard should appear — "Back to sessions" back-button is the marker.
|
||||
await expect(
|
||||
page.getByRole("button", { name: /back to sessions/i }),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test("Completed tab shows empty state after switching", async ({ page }) => {
|
||||
await page.route("**/api/v1/config/app-config", (r: Route) =>
|
||||
r.fulfill({
|
||||
json: {
|
||||
enableLogin: true,
|
||||
isAdmin: false,
|
||||
languages: ["en-US"],
|
||||
defaultLocale: "en-US",
|
||||
storageGroupSigningEnabled: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
await stubSigningApis(page);
|
||||
|
||||
await page.goto("/shared-sign", { waitUntil: "domcontentloaded" });
|
||||
|
||||
// Mantine SegmentedControl: click the visible label, not the hidden radio input.
|
||||
const completedTab = page
|
||||
.locator("label")
|
||||
.filter({ hasText: /^completed$/i })
|
||||
.first();
|
||||
await expect(completedTab).toBeVisible({ timeout: 10_000 });
|
||||
await completedTab.click();
|
||||
|
||||
await expect(page.getByText(/no completed sessions/i)).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Viewer Share button — gating", () => {
|
||||
test("Share button absent when storageSharingEnabled is false", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Default app-config has no storageSharingEnabled.
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await uploadFiles(page, SAMPLE_PDF);
|
||||
|
||||
// The share button should not be in the DOM.
|
||||
await expect(
|
||||
page.getByRole("button", { name: /share/i }).first(),
|
||||
).not.toBeAttached({ timeout: 8_000 });
|
||||
});
|
||||
|
||||
test("Share button present when storageSharingEnabled is true", async ({
|
||||
page,
|
||||
}) => {
|
||||
// storageSharingEnabled is a pure config flag — the WorkbenchBar renders
|
||||
// the Share button based solely on it, with no login-required check.
|
||||
await page.route("**/api/v1/config/app-config", (r: Route) =>
|
||||
r.fulfill({
|
||||
json: {
|
||||
enableLogin: false,
|
||||
isAdmin: false,
|
||||
languages: ["en-US"],
|
||||
defaultLocale: "en-US",
|
||||
storageSharingEnabled: true,
|
||||
storageEnabled: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
await page.route("**/api/v1/storage/files", (r: Route) =>
|
||||
r.fulfill({ json: [] }),
|
||||
);
|
||||
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await uploadFiles(page, SAMPLE_PDF);
|
||||
|
||||
// Share button appears in the workbench toolbar once a file is open.
|
||||
await expect(
|
||||
page.getByRole("button", { name: /share/i }).first(),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
});
|
||||
@@ -1,78 +0,0 @@
|
||||
import { test, expect } from "@app/tests/helpers/stub-test-base";
|
||||
|
||||
test.describe("3. Tool Search", () => {
|
||||
test.describe("3.1 Search - Happy Path", () => {
|
||||
test("should filter tools in real time based on search input", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Step 1: Click on the search box
|
||||
const searchBox = page.getByPlaceholder(/search|cari/i).first();
|
||||
await searchBox.click();
|
||||
|
||||
// Step 2: Type "merge"
|
||||
await searchBox.fill("merge");
|
||||
|
||||
// Step 3: Verify search results filter to show relevant tools
|
||||
await expect(
|
||||
page.locator("text=/merge|menggabungkan/i").first(),
|
||||
).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Step 5: Clear the search field
|
||||
await searchBox.clear();
|
||||
|
||||
// Step 6: Verify all tools reappear (check for multiple categories)
|
||||
await expect(
|
||||
page.locator("text=/recommended|direkomendasikan/i").first(),
|
||||
).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("3.2 Search - No Results", () => {
|
||||
test("should handle queries with no matching tools gracefully", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Step 1: Click on the search box
|
||||
const searchBox = page.getByPlaceholder(/search|cari/i).first();
|
||||
await searchBox.click();
|
||||
|
||||
// Step 2: Type xyznonexistent123
|
||||
await searchBox.fill("xyznonexistent123");
|
||||
|
||||
// Step 3: Verify the search field accepted the input
|
||||
await expect(searchBox).toHaveValue("xyznonexistent123");
|
||||
|
||||
// The app uses fuzzy search with a fallback that shows all tools when nothing
|
||||
// matches, so we verify the search state is active (no "recommended" section)
|
||||
// and the page remains functional without errors.
|
||||
await expect(
|
||||
page.locator("text=/recommended|direkomendasikan/i"),
|
||||
).toHaveCount(0, { timeout: 5000 });
|
||||
|
||||
// Step 4: Clear the search field
|
||||
await searchBox.clear();
|
||||
|
||||
// Step 5: Verify all tools reappear (recommended section comes back)
|
||||
await expect(
|
||||
page.locator("text=/recommended|direkomendasikan/i").first(),
|
||||
).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("3.3 Search - Special Characters", () => {
|
||||
test("should sanitize search input against XSS", async ({ page }) => {
|
||||
// Step 1: Type XSS payload into the search box
|
||||
const searchBox = page.getByPlaceholder(/search|cari/i).first();
|
||||
await searchBox.fill("<script>alert(1)</script>");
|
||||
|
||||
// Step 2: Verify no script execution occurs (no alert dialog)
|
||||
// If an alert appeared, Playwright would throw an unhandled dialog error
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Step 3: Verify the search treats the input as plain text
|
||||
await expect(searchBox).toHaveValue("<script>alert(1)</script>");
|
||||
|
||||
// Step 4: Clear the search field
|
||||
await searchBox.clear();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Desktop inherits proprietary's app but must NOT ship the portal (see the
|
||||
* admin-route seam) — shadow the index back to core's empty list so the super
|
||||
* search never offers Processor destinations that don't exist on desktop.
|
||||
*/
|
||||
export type { ProcessorSearchEntry } from "@core/data/processorSearchIndex";
|
||||
export { PROCESSOR_SEARCH_INDEX } from "@core/data/processorSearchIndex";
|
||||
@@ -0,0 +1,42 @@
|
||||
import { type SettingsSectionEntry } from "@core/data/settingsSectionRegistry";
|
||||
|
||||
export type { SettingsSectionEntry };
|
||||
|
||||
/**
|
||||
* Desktop settings sections. The desktop modal reflows heavily by connection
|
||||
* mode (`configNavSections`): local mode shows only Preferences + Connection
|
||||
* Mode + Legal, while SaaS mode swaps in the cloud Plan/Team sections and hides
|
||||
* the self-hosted admin area. To stay safe across both modes without threading
|
||||
* the (async) connection state into the always-mounted search, this lists only
|
||||
* the sections guaranteed to render in every desktop mode.
|
||||
*
|
||||
* Cloud Plan/Team search on desktop-SaaS is a deliberate Tier-0 gap, not a
|
||||
* regression — better to omit them than to deep-link to a dead tab in local
|
||||
* mode.
|
||||
*/
|
||||
export const SETTINGS_SECTION_REGISTRY: SettingsSectionEntry[] = [
|
||||
{
|
||||
key: "general",
|
||||
labelKey: "settings.general.title",
|
||||
labelFallback: "General",
|
||||
keywords: ["theme", "language", "appearance", "preferences", "startup"],
|
||||
},
|
||||
{
|
||||
key: "hotkeys",
|
||||
labelKey: "settings.hotkeys.title",
|
||||
labelFallback: "Keyboard Shortcuts",
|
||||
keywords: ["hotkey", "shortcut", "keybinding", "keyboard"],
|
||||
},
|
||||
{
|
||||
key: "connectionMode",
|
||||
labelKey: "settings.connection.title",
|
||||
labelFallback: "Connection Mode",
|
||||
keywords: ["connection", "local", "cloud", "server", "sign in"],
|
||||
},
|
||||
{
|
||||
key: "legal",
|
||||
labelKey: "settings.legal.label",
|
||||
labelFallback: "Legal",
|
||||
keywords: ["legal", "terms", "privacy", "licenses"],
|
||||
},
|
||||
];
|
||||
@@ -1,13 +0,0 @@
|
||||
import { apiClient } from "@portal/api/http";
|
||||
|
||||
export interface QuickAction {
|
||||
group: "Jump to" | "Create" | "Theme";
|
||||
label: string;
|
||||
/** Keyboard hint shown to the right. */
|
||||
hint: string;
|
||||
}
|
||||
|
||||
/** GET /v1/search/quick-actions */
|
||||
export async function fetchQuickActions(): Promise<QuickAction[]> {
|
||||
return apiClient.local.json<QuickAction[]>("/v1/search/quick-actions");
|
||||
}
|
||||
@@ -1,17 +1,19 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Sidebar } from "@portal/components/Sidebar";
|
||||
import { PortalSearchBar } from "@portal/components/PortalSearchBar";
|
||||
import "@portal/components/AppShell.css";
|
||||
|
||||
/**
|
||||
* Two-column layout: fixed-width sidebar on the left, a scrolling main column on
|
||||
* the right. The Sidebar reads its state from context, so this shell stays
|
||||
* prop-free.
|
||||
* the right (topped by the global search bar). The Sidebar reads its state from
|
||||
* context, so this shell stays prop-free.
|
||||
*/
|
||||
export function AppShell({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="portal-shell">
|
||||
<Sidebar />
|
||||
<div className="portal-shell__main">
|
||||
<PortalSearchBar />
|
||||
<main className="portal-shell__view">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,39 +1,10 @@
|
||||
import { useEffect } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { ToolRegistryProvider } from "@app/contexts/ToolRegistryProvider";
|
||||
import { ErrorBoundary } from "@portal/components/ErrorBoundary";
|
||||
import { useUI } from "@portal/contexts/UIContext";
|
||||
import { AppShell } from "@portal/components/AppShell";
|
||||
import { SearchModal } from "@portal/components/SearchModal";
|
||||
import { PortalSettingsHost } from "@portal/components/PortalSettingsHost";
|
||||
import { ViewRouter } from "@portal/ViewRouter";
|
||||
|
||||
/**
|
||||
* Global keyboard shortcuts. Lives below the UIProvider so it can dispatch into
|
||||
* the overlay state. Currently just ⌘K / Ctrl+K to toggle the search palette.
|
||||
*/
|
||||
function GlobalShortcuts() {
|
||||
const { toggleSearch, closeSearch } = useUI();
|
||||
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
const isCmdK = (e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k";
|
||||
if (isCmdK) {
|
||||
e.preventDefault();
|
||||
toggleSearch();
|
||||
return;
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
closeSearch();
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [toggleSearch, closeSearch]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The routed view, wrapped in an error boundary so a single view crashing can't
|
||||
* white-screen the portal (the shell + nav stay alive). Keyed by route so
|
||||
@@ -49,23 +20,21 @@ function RoutedContent() {
|
||||
}
|
||||
|
||||
/**
|
||||
* The flavor-agnostic portal chrome: the shell (sidebar + header + routed view)
|
||||
* plus the global overlays that every flavor shares. Requires only the Tier and
|
||||
* UI contexts above it — both flavors provide those. Flavor-specific overlays
|
||||
* (e.g. the self-hosted account-link modal) are mounted by PortalProviders, not
|
||||
* here.
|
||||
* The flavor-agnostic portal chrome: the shell (sidebar + search bar + routed
|
||||
* view) plus the global overlays that every flavor shares. Requires only the
|
||||
* Tier and UI contexts above it — both flavors provide those. Flavor-specific
|
||||
* overlays (e.g. the self-hosted account-link modal) are mounted by
|
||||
* PortalProviders, not here.
|
||||
*/
|
||||
export function PortalChrome() {
|
||||
return (
|
||||
<>
|
||||
<GlobalShortcuts />
|
||||
{/* The pipeline builder reads the tool registry to list and configure operations. */}
|
||||
<ToolRegistryProvider>
|
||||
<AppShell>
|
||||
<RoutedContent />
|
||||
</AppShell>
|
||||
</ToolRegistryProvider>
|
||||
<SearchModal />
|
||||
<PortalSettingsHost />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
/* Slim strip at the top of the main column hosting the shared search bar. */
|
||||
.portal-searchbar {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: var(--space-2) var(--space-3) 0;
|
||||
}
|
||||
|
||||
.portal-searchbar .super-search {
|
||||
width: 100%;
|
||||
max-width: 34rem;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { AppConfigProvider } from "@app/contexts/AppConfigContext";
|
||||
import SuperSearch from "@app/components/shared/superSearch/SuperSearch";
|
||||
import { usePortalSearchResults } from "@portal/hooks/usePortalSearchResults";
|
||||
import "@portal/components/PortalSearchBar.css";
|
||||
|
||||
/**
|
||||
* The portal face of the global super search — the same bar the editor's
|
||||
* workbench shows, fed by the portal-wired results provider. Cmd/Ctrl+K
|
||||
* focuses it (the bar registers its own shortcut). The config provider
|
||||
* supplies the admin/login gates the settings and Processor sources share
|
||||
* with the editor bar.
|
||||
*/
|
||||
export function PortalSearchBar() {
|
||||
return (
|
||||
<AppConfigProvider bootstrapMode="non-blocking">
|
||||
<div className="portal-searchbar">
|
||||
<SuperSearch useResults={usePortalSearchResults} />
|
||||
</div>
|
||||
</AppConfigProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
.portal-search__input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
padding: 0.25rem 0.25rem 0.75rem;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
|
||||
.portal-search__input {
|
||||
flex: 1 1 auto;
|
||||
font: inherit;
|
||||
font-size: 0.9375rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.portal-search__input::placeholder {
|
||||
color: var(--color-text-placeholder);
|
||||
}
|
||||
|
||||
.portal-search__esc {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.6875rem;
|
||||
padding: 0.0625rem 0.375rem;
|
||||
border-radius: var(--radius-xs);
|
||||
background: var(--color-bg-muted);
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
|
||||
.portal-search__results {
|
||||
padding-top: 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
max-height: 24rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.portal-search__group-label {
|
||||
font-size: 0.6875rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--color-section-label);
|
||||
padding: 0 0.5rem 0.375rem;
|
||||
}
|
||||
|
||||
.portal-search__item {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 0.625rem;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-2);
|
||||
font-size: 0.8125rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.portal-search__item:hover {
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.portal-search__item-hint {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-5);
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { useEffect } from "react";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { SearchModal } from "@portal/components/SearchModal";
|
||||
import { useUI } from "@portal/contexts/UIContext";
|
||||
|
||||
function ForceOpen() {
|
||||
const { openSearch } = useUI();
|
||||
useEffect(() => {
|
||||
openSearch();
|
||||
}, [openSearch]);
|
||||
return null;
|
||||
}
|
||||
|
||||
const meta: Meta<typeof SearchModal> = {
|
||||
title: "Portal/Header/SearchModal",
|
||||
component: SearchModal,
|
||||
parameters: { layout: "fullscreen" },
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div style={{ minHeight: "100vh", background: "var(--color-bg)" }}>
|
||||
<ForceOpen />
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof SearchModal>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const EmptyCatalogue: Story = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
http.get("/v1/search/quick-actions", () => HttpResponse.json([])),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,125 +0,0 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Button, EmptyState, Modal, Skeleton } from "@app/ui";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useUI } from "@portal/contexts/UIContext";
|
||||
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
|
||||
import { fetchQuickActions, type QuickAction } from "@portal/api/search";
|
||||
import { SearchIcon } from "@portal/components/icons";
|
||||
import "@portal/components/SearchModal.css";
|
||||
|
||||
export function SearchModal() {
|
||||
const { t } = useTranslation();
|
||||
const { searchOpen, closeSearch } = useUI();
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
const state = useAsync<QuickAction[]>(() => fetchQuickActions(), []);
|
||||
const { data: actions } = state;
|
||||
const { isLoading } = useSectionFlags(state);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchOpen) {
|
||||
setQuery("");
|
||||
const t = setTimeout(() => inputRef.current?.focus(), 0);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
return undefined;
|
||||
}, [searchOpen]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!actions) return [] as QuickAction[];
|
||||
if (!query.trim()) return actions;
|
||||
const needle = query.trim().toLowerCase();
|
||||
return actions.filter((item) => item.label.toLowerCase().includes(needle));
|
||||
}, [actions, query]);
|
||||
|
||||
const groups = useMemo(
|
||||
() =>
|
||||
filtered.reduce<Record<string, QuickAction[]>>((acc, item) => {
|
||||
if (!acc[item.group]) acc[item.group] = [];
|
||||
acc[item.group].push(item);
|
||||
return acc;
|
||||
}, {}),
|
||||
[filtered],
|
||||
);
|
||||
|
||||
const groupKeys = Object.keys(groups);
|
||||
const isEmpty = !isLoading && groupKeys.length === 0;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={searchOpen}
|
||||
onClose={closeSearch}
|
||||
width="lg"
|
||||
ariaLabel={t("portal.search.ariaLabel")}
|
||||
>
|
||||
<div className="portal-search">
|
||||
<div className="portal-search__input-row">
|
||||
<SearchIcon size={16} />
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t("portal.search.placeholder")}
|
||||
aria-label={t("portal.search.ariaLabel")}
|
||||
className="portal-search__input"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<span className="portal-search__esc" aria-hidden>
|
||||
ESC
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="portal-search__results">
|
||||
{isLoading && (
|
||||
<div className="portal-search__loading">
|
||||
<Skeleton height="1rem" width="40%" />
|
||||
<Skeleton height="0.875rem" />
|
||||
<Skeleton height="0.875rem" width="80%" />
|
||||
<Skeleton height="0.875rem" width="65%" />
|
||||
</div>
|
||||
)}
|
||||
{isEmpty && (
|
||||
<EmptyState
|
||||
size="compact"
|
||||
title={
|
||||
query.trim()
|
||||
? t("portal.search.empty.noMatches", { query: query.trim() })
|
||||
: t("portal.search.empty.noActionsTitle")
|
||||
}
|
||||
description={
|
||||
query.trim()
|
||||
? t("portal.search.empty.noMatchesDescription")
|
||||
: t("portal.search.empty.noActionsDescription")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{!isLoading &&
|
||||
!isEmpty &&
|
||||
Object.entries(groups).map(([group, items]) => (
|
||||
<div key={group} className="portal-search__group">
|
||||
<div className="portal-search__group-label">{group}</div>
|
||||
{items.map((item) => (
|
||||
<Button
|
||||
key={item.label}
|
||||
variant="tertiary"
|
||||
justify="start"
|
||||
fullWidth
|
||||
className="portal-search__item"
|
||||
onClick={closeSearch}
|
||||
>
|
||||
<span className="portal-search__item-label">
|
||||
{item.label}
|
||||
</span>
|
||||
<span className="portal-search__item-hint">
|
||||
{item.hint}
|
||||
</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -7,11 +7,6 @@ import {
|
||||
} from "react";
|
||||
|
||||
interface UIContextValue {
|
||||
searchOpen: boolean;
|
||||
openSearch: () => void;
|
||||
closeSearch: () => void;
|
||||
toggleSearch: () => void;
|
||||
|
||||
assistantOpen: boolean;
|
||||
openAssistant: () => void;
|
||||
closeAssistant: () => void;
|
||||
@@ -47,7 +42,6 @@ interface UIContextValue {
|
||||
const UIContext = createContext<UIContextValue | null>(null);
|
||||
|
||||
export function UIProvider({ children }: { children: ReactNode }) {
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [assistantOpen, setAssistantOpen] = useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [settingsInitialSection, setSettingsInitialSection] = useState<
|
||||
@@ -63,11 +57,6 @@ export function UIProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const value = useMemo<UIContextValue>(
|
||||
() => ({
|
||||
searchOpen,
|
||||
openSearch: () => setSearchOpen(true),
|
||||
closeSearch: () => setSearchOpen(false),
|
||||
toggleSearch: () => setSearchOpen((o) => !o),
|
||||
|
||||
assistantOpen,
|
||||
openAssistant: () => setAssistantOpen(true),
|
||||
closeAssistant: () => setAssistantOpen(false),
|
||||
@@ -108,7 +97,6 @@ export function UIProvider({ children }: { children: ReactNode }) {
|
||||
},
|
||||
}),
|
||||
[
|
||||
searchOpen,
|
||||
assistantOpen,
|
||||
settingsOpen,
|
||||
settingsInitialSection,
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { withBasePath } from "@app/constants/app";
|
||||
import { getToolUrlPath } from "@app/data/toolsTaxonomy";
|
||||
import { useToolRegistry } from "@app/contexts/ToolRegistryContext";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import { rankByFuzzy } from "@app/utils/fuzzySearch";
|
||||
import type { ToolId } from "@app/types/toolId";
|
||||
import type { ProcessorSearchEntry } from "@app/data/processorSearchIndex";
|
||||
import {
|
||||
assembleSuperSearchGroups,
|
||||
rankProcessorResults,
|
||||
rankSettingsResults,
|
||||
rankToolResults,
|
||||
type SuperSearchGates,
|
||||
type SuperSearchGroup,
|
||||
type SuperSearchGroupId,
|
||||
type UseSuperSearchResult,
|
||||
} from "@app/hooks/useSuperSearch";
|
||||
import { useUI } from "@portal/contexts/UIContext";
|
||||
import { useTier } from "@portal/contexts/TierContext";
|
||||
import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext";
|
||||
import { EDITOR_IS_SAME_APP, EDITOR_URL } from "@portal/auth/editorUrl";
|
||||
import { fetchUsers, type Member } from "@portal/api/users";
|
||||
import { fetchPolicies, type CatalogueEntry } from "@portal/api/policies";
|
||||
import { fetchPipelines, type PipelineView } from "@portal/api/pipelines";
|
||||
import { fetchSources, type SourceView } from "@portal/api/sources";
|
||||
import {
|
||||
UsersIcon,
|
||||
PoliciesIcon,
|
||||
PipelinesIcon,
|
||||
SourcesIcon,
|
||||
} from "@portal/components/icons";
|
||||
|
||||
/** Processor pages lead in the portal; the editor bar orders its own first. */
|
||||
const PORTAL_GROUP_ORDER: SuperSearchGroupId[] = [
|
||||
"processor",
|
||||
"tools",
|
||||
"settings",
|
||||
];
|
||||
|
||||
/** Entity groups cap lower than the shared GROUP_LIMIT — with up to seven
|
||||
* groups in the portal dropdown, six rows each stops being scannable. */
|
||||
const ENTITY_GROUP_LIMIT = 4;
|
||||
|
||||
/**
|
||||
* Tool results live in the editor app, so selecting one is a full page load
|
||||
* there (the editor initialises its tool state from the URL on boot —
|
||||
* client-side routing can't reach that init once mounted).
|
||||
*/
|
||||
function editorHref(path: string): string {
|
||||
if (EDITOR_IS_SAME_APP) return withBasePath(path);
|
||||
return EDITOR_URL.replace(/\/$/, "") + path;
|
||||
}
|
||||
|
||||
interface PortalEntities {
|
||||
users: Member[];
|
||||
policies: CatalogueEntry[];
|
||||
pipelines: PipelineView[];
|
||||
sources: SourceView[];
|
||||
}
|
||||
|
||||
const NO_ENTITIES: PortalEntities = {
|
||||
users: [],
|
||||
policies: [],
|
||||
pipelines: [],
|
||||
sources: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Loads the portal's searchable entities whenever the search surface opens,
|
||||
* then ranking happens client-side per keystroke. allSettled so one failing
|
||||
* endpoint (e.g. policies on a backend without them) doesn't drop the rest.
|
||||
*/
|
||||
function usePortalEntities(active: boolean): PortalEntities {
|
||||
const { tier } = useTier();
|
||||
const [entities, setEntities] = useState<PortalEntities>(NO_ENTITIES);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
let cancelled = false;
|
||||
Promise.allSettled([
|
||||
fetchUsers(tier),
|
||||
fetchPolicies(),
|
||||
fetchPipelines(),
|
||||
fetchSources(),
|
||||
]).then(([users, policies, pipelines, sources]) => {
|
||||
if (cancelled) return;
|
||||
setEntities({
|
||||
users: users.status === "fulfilled" ? users.value.members : [],
|
||||
policies:
|
||||
policies.status === "fulfilled" ? policies.value.catalogue : [],
|
||||
pipelines:
|
||||
pipelines.status === "fulfilled" ? pipelines.value.pipelines : [],
|
||||
sources: sources.status === "fulfilled" ? sources.value.sources : [],
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [active, tier]);
|
||||
|
||||
return entities;
|
||||
}
|
||||
|
||||
/**
|
||||
* The portal's results provider for the shared super search bar: the same
|
||||
* sources the editor bar ranks minus files (a file only opens inside the
|
||||
* editor), with Processor pages leading and the portal's own entities —
|
||||
* users, policies, pipelines, sources — ranked between the pages and the
|
||||
* shared groups. Tools hand over to the editor, settings opens the portal's
|
||||
* settings modal, everything else navigates in-app.
|
||||
*/
|
||||
export function usePortalSearchResults(
|
||||
query: string,
|
||||
active: boolean,
|
||||
): UseSuperSearchResult {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { openSettings } = useUI();
|
||||
const { allTools } = useToolRegistry();
|
||||
const { config } = useAppConfig();
|
||||
|
||||
const trimmed = query.trim();
|
||||
const entities = usePortalEntities(active);
|
||||
|
||||
const openTool = useCallback((id: ToolId) => {
|
||||
window.location.assign(editorHref(getToolUrlPath(id)));
|
||||
}, []);
|
||||
|
||||
const openSettingsSection = useCallback(
|
||||
(section: string) => openSettings(section),
|
||||
[openSettings],
|
||||
);
|
||||
|
||||
const selectProcessorEntry = useCallback(
|
||||
(item: ProcessorSearchEntry) => {
|
||||
if (item.externalUrl) {
|
||||
window.open(item.externalUrl, "_blank", "noopener,noreferrer");
|
||||
} else {
|
||||
navigate(item.path);
|
||||
}
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
const gates = useMemo<SuperSearchGates>(
|
||||
() => ({
|
||||
isAdmin: config?.isAdmin ?? false,
|
||||
loginEnabled: config?.enableLogin ?? false,
|
||||
}),
|
||||
[config],
|
||||
);
|
||||
|
||||
const entityGroups = useMemo<SuperSearchGroup[]>(() => {
|
||||
if (!trimmed) return [];
|
||||
const groups: SuperSearchGroup[] = [];
|
||||
|
||||
const users = rankByFuzzy(entities.users, trimmed, [
|
||||
(m) => m.name,
|
||||
(m) => m.email,
|
||||
])
|
||||
.slice(0, ENTITY_GROUP_LIMIT)
|
||||
.map(({ item, score }) => ({
|
||||
key: `portal-user:${item.id}`,
|
||||
group: "portal-users",
|
||||
title: item.name,
|
||||
subtitle: item.email,
|
||||
icon: <UsersIcon />,
|
||||
score,
|
||||
onSelect: () => navigate(toPortalPath(VIEW_PATHS.users)),
|
||||
}));
|
||||
if (users.length > 0) {
|
||||
groups.push({
|
||||
id: "portal-users",
|
||||
label: t("portal.nav.users"),
|
||||
results: users,
|
||||
});
|
||||
}
|
||||
|
||||
const policies = rankByFuzzy(
|
||||
entities.policies.filter((e) => !e.category.comingSoon),
|
||||
trimmed,
|
||||
[(e) => e.category.label, (e) => e.category.desc],
|
||||
)
|
||||
.slice(0, ENTITY_GROUP_LIMIT)
|
||||
.map(({ item, score }) => ({
|
||||
key: `portal-policy:${item.category.id}`,
|
||||
group: "portal-policies",
|
||||
title: item.category.label,
|
||||
subtitle: item.category.desc,
|
||||
icon: <PoliciesIcon />,
|
||||
score,
|
||||
onSelect: () => navigate(toPortalPath(VIEW_PATHS.policies)),
|
||||
}));
|
||||
if (policies.length > 0) {
|
||||
groups.push({
|
||||
id: "portal-policies",
|
||||
label: t("portal.nav.policies"),
|
||||
results: policies,
|
||||
});
|
||||
}
|
||||
|
||||
const pipelines = rankByFuzzy(entities.pipelines, trimmed, [
|
||||
(p) => p.name,
|
||||
(p) => p.trigger,
|
||||
])
|
||||
.slice(0, ENTITY_GROUP_LIMIT)
|
||||
.map(({ item, score }) => ({
|
||||
key: `portal-pipeline:${item.id}`,
|
||||
group: "portal-pipelines",
|
||||
title: item.name,
|
||||
subtitle: item.trigger,
|
||||
icon: <PipelinesIcon />,
|
||||
score,
|
||||
onSelect: () =>
|
||||
navigate(`${toPortalPath(VIEW_PATHS.pipelines)}/${item.id}`),
|
||||
}));
|
||||
if (pipelines.length > 0) {
|
||||
groups.push({
|
||||
id: "portal-pipelines",
|
||||
label: t("portal.nav.pipelines"),
|
||||
results: pipelines,
|
||||
});
|
||||
}
|
||||
|
||||
const sources = rankByFuzzy(entities.sources, trimmed, [
|
||||
(s) => s.name,
|
||||
(s) => s.type,
|
||||
])
|
||||
.slice(0, ENTITY_GROUP_LIMIT)
|
||||
.map(({ item, score }) => ({
|
||||
key: `portal-source:${item.id}`,
|
||||
group: "portal-sources",
|
||||
title: item.name,
|
||||
subtitle: item.type,
|
||||
icon: <SourcesIcon />,
|
||||
score,
|
||||
onSelect: () =>
|
||||
navigate(`${toPortalPath(VIEW_PATHS.sources)}/${item.id}`),
|
||||
}));
|
||||
if (sources.length > 0) {
|
||||
groups.push({
|
||||
id: "portal-sources",
|
||||
label: t("portal.nav.sources"),
|
||||
results: sources,
|
||||
});
|
||||
}
|
||||
|
||||
return groups;
|
||||
}, [entities, trimmed, t, navigate]);
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const shared = assembleSuperSearchGroups(
|
||||
{
|
||||
tools: rankToolResults(allTools, trimmed, openTool),
|
||||
settings: rankSettingsResults(trimmed, t, gates, openSettingsSection),
|
||||
processor: rankProcessorResults(
|
||||
trimmed,
|
||||
t,
|
||||
gates,
|
||||
selectProcessorEntry,
|
||||
),
|
||||
},
|
||||
t,
|
||||
PORTAL_GROUP_ORDER,
|
||||
);
|
||||
// Entities slot in right after the Processor pages (index -1 + 1 = 0
|
||||
// puts them first when no page matched).
|
||||
const out = [...shared];
|
||||
out.splice(
|
||||
out.findIndex((g) => g.id === "processor") + 1,
|
||||
0,
|
||||
...entityGroups,
|
||||
);
|
||||
return out;
|
||||
}, [
|
||||
trimmed,
|
||||
allTools,
|
||||
openTool,
|
||||
gates,
|
||||
openSettingsSection,
|
||||
selectProcessorEntry,
|
||||
entityGroups,
|
||||
t,
|
||||
]);
|
||||
|
||||
const flatResults = useMemo(() => groups.flatMap((g) => g.results), [groups]);
|
||||
|
||||
return { groups, flatResults, loadingFiles: false };
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { assistantHandlers } from "@portal/mocks/handlers/assistant";
|
||||
import { authHandlers } from "@portal/mocks/handlers/auth";
|
||||
import { notificationsHandlers } from "@portal/mocks/handlers/notifications";
|
||||
import { searchHandlers } from "@portal/mocks/handlers/search";
|
||||
import { pipelinesHandlers } from "@portal/mocks/handlers/pipelines";
|
||||
import { sourcesHandlers } from "@portal/mocks/handlers/sources";
|
||||
import { infrastructureHandlers } from "@portal/mocks/handlers/infrastructure";
|
||||
@@ -22,7 +21,6 @@ export const handlers = [
|
||||
...authHandlers,
|
||||
...notificationsHandlers,
|
||||
...assistantHandlers,
|
||||
...searchHandlers,
|
||||
...pipelinesHandlers,
|
||||
...sourcesHandlers,
|
||||
...infrastructureHandlers,
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { QUICK_ACTIONS } from "@portal/mocks/search";
|
||||
|
||||
export const searchHandlers = [
|
||||
http.get("/v1/search/quick-actions", () => {
|
||||
return HttpResponse.json(QUICK_ACTIONS);
|
||||
}),
|
||||
];
|
||||
@@ -1,17 +0,0 @@
|
||||
/**
|
||||
* Mock quick-action catalogue for the ⌘K search palette. The QuickAction type
|
||||
* lives in api/search.ts (the backend contract); this module only builds fake
|
||||
* data for Storybook and tests.
|
||||
*/
|
||||
|
||||
import type { QuickAction } from "@portal/api/search";
|
||||
|
||||
export const QUICK_ACTIONS: QuickAction[] = [
|
||||
{ group: "Jump to", label: "Home", hint: "G H" },
|
||||
{ group: "Jump to", label: "Pipelines", hint: "G P" },
|
||||
{ group: "Jump to", label: "Sources", hint: "G S" },
|
||||
{ group: "Jump to", label: "Documents", hint: "G D" },
|
||||
{ group: "Create", label: "New pipeline", hint: "N P" },
|
||||
{ group: "Create", label: "New API key", hint: "N K" },
|
||||
{ group: "Theme", label: "Toggle dark / light", hint: "T" },
|
||||
];
|
||||
@@ -0,0 +1,94 @@
|
||||
import { PORTAL_BASENAME } from "@app/routes/portalBasename";
|
||||
import type { ProcessorSearchEntry } from "@core/data/processorSearchIndex";
|
||||
|
||||
export type { ProcessorSearchEntry };
|
||||
|
||||
// Mirrors the admin-route seam's gate: the portal route-set is only mounted in
|
||||
// dev and in builds made with VITE_INCLUDE_PORTAL=true, so the search must not
|
||||
// offer destinations that would 404 elsewhere.
|
||||
const includePortal =
|
||||
import.meta.env.VITE_INCLUDE_PORTAL === "true" || import.meta.env.DEV;
|
||||
|
||||
/**
|
||||
* The portal's in-app views. Deliberately a static mirror of the portal's nav
|
||||
* (labels via the same portal.nav.* keys its sidebar uses) rather than an
|
||||
* import from @portal/* — referencing the portal package here would pull the
|
||||
* lazy portal chunk into the main bundle.
|
||||
*/
|
||||
const VIEWS: ProcessorSearchEntry[] = [
|
||||
{
|
||||
id: "home",
|
||||
labelKey: "portal.nav.home",
|
||||
labelFallback: "Home",
|
||||
path: PORTAL_BASENAME,
|
||||
keywords: ["portal", "processor", "admin"],
|
||||
},
|
||||
{
|
||||
id: "users",
|
||||
labelKey: "portal.nav.users",
|
||||
labelFallback: "Users",
|
||||
path: `${PORTAL_BASENAME}/users`,
|
||||
keywords: ["team", "members", "roles", "admin"],
|
||||
},
|
||||
{
|
||||
id: "sources",
|
||||
labelKey: "portal.nav.sources",
|
||||
labelFallback: "Sources",
|
||||
path: `${PORTAL_BASENAME}/sources`,
|
||||
keywords: ["s3", "connections", "webhooks", "folders"],
|
||||
},
|
||||
{
|
||||
id: "policies",
|
||||
labelKey: "portal.nav.policies",
|
||||
labelFallback: "Policies",
|
||||
path: `${PORTAL_BASENAME}/policies`,
|
||||
keywords: ["enforcement", "redact", "compliance"],
|
||||
},
|
||||
{
|
||||
id: "pipelines",
|
||||
labelKey: "portal.nav.pipelines",
|
||||
labelFallback: "Pipelines",
|
||||
path: `${PORTAL_BASENAME}/pipelines`,
|
||||
keywords: ["automation", "workflows", "operations"],
|
||||
},
|
||||
{
|
||||
id: "documents",
|
||||
labelKey: "portal.nav.documents",
|
||||
labelFallback: "Documents",
|
||||
path: `${PORTAL_BASENAME}/documents`,
|
||||
keywords: ["audit", "files"],
|
||||
},
|
||||
{
|
||||
id: "components",
|
||||
labelKey: "portal.nav.components",
|
||||
labelFallback: "Components",
|
||||
path: `${PORTAL_BASENAME}/components`,
|
||||
keywords: ["sdk", "embed"],
|
||||
},
|
||||
{
|
||||
id: "infrastructure",
|
||||
labelKey: "portal.nav.infrastructure",
|
||||
labelFallback: "Infrastructure",
|
||||
path: `${PORTAL_BASENAME}/infrastructure`,
|
||||
keywords: ["deployment", "instances", "health"],
|
||||
},
|
||||
{
|
||||
id: "usage",
|
||||
labelKey: "portal.nav.usage",
|
||||
labelFallback: "Usage & Billing",
|
||||
path: `${PORTAL_BASENAME}/usage`,
|
||||
keywords: ["billing", "invoices", "plan", "wallet"],
|
||||
},
|
||||
{
|
||||
id: "docs",
|
||||
labelKey: "portal.nav.docs",
|
||||
labelFallback: "Developer Docs",
|
||||
path: "",
|
||||
externalUrl: "https://docs.stirlingpdf.com/",
|
||||
keywords: ["api", "documentation", "reference"],
|
||||
},
|
||||
];
|
||||
|
||||
export const PROCESSOR_SEARCH_INDEX: ProcessorSearchEntry[] = includePortal
|
||||
? VIEWS
|
||||
: [];
|
||||
@@ -0,0 +1,151 @@
|
||||
import {
|
||||
SETTINGS_SECTION_REGISTRY as CORE_SETTINGS_SECTION_REGISTRY,
|
||||
type SettingsSectionEntry,
|
||||
} from "@core/data/settingsSectionRegistry";
|
||||
|
||||
export type { SettingsSectionEntry };
|
||||
|
||||
/**
|
||||
* Self-hosted (proprietary) settings sections. Extends the core list with the
|
||||
* account tab, the developer API keys tab, and the admin area — mirroring the
|
||||
* sections added by the proprietary nav builder (`configNavSections`). Keep the
|
||||
* two in sync: a section here that the modal can't render would deep-link to a
|
||||
* dead tab, and a modal section missing here isn't searchable.
|
||||
*
|
||||
* Gating flags mirror the builder: `account`/`api-keys` need login; the admin
|
||||
* sections are surfaced when the user is an admin OR login mode is off.
|
||||
*/
|
||||
export const SETTINGS_SECTION_REGISTRY: SettingsSectionEntry[] = [
|
||||
...CORE_SETTINGS_SECTION_REGISTRY,
|
||||
{
|
||||
key: "account",
|
||||
labelKey: "account.accountSettings",
|
||||
labelFallback: "Account",
|
||||
keywords: ["profile", "email", "password", "user"],
|
||||
requiresLogin: true,
|
||||
},
|
||||
{
|
||||
key: "api-keys",
|
||||
labelKey: "settings.developer.apiKeys",
|
||||
labelFallback: "API Keys",
|
||||
keywords: ["api", "token", "developer", "key"],
|
||||
requiresLogin: true,
|
||||
},
|
||||
// --- Workspace ---
|
||||
{
|
||||
key: "people",
|
||||
labelKey: "settings.workspace.people",
|
||||
labelFallback: "People",
|
||||
keywords: ["members", "users", "invite", "add member", "roles"],
|
||||
adminArea: true,
|
||||
},
|
||||
{
|
||||
key: "teams",
|
||||
labelKey: "settings.workspace.teams",
|
||||
labelFallback: "Teams",
|
||||
keywords: ["team", "group", "create team"],
|
||||
adminArea: true,
|
||||
},
|
||||
// --- Configuration ---
|
||||
{
|
||||
key: "adminGeneral",
|
||||
labelKey: "settings.configuration.systemSettings",
|
||||
labelFallback: "System Settings",
|
||||
keywords: ["system", "config", "server"],
|
||||
adminArea: true,
|
||||
},
|
||||
{
|
||||
key: "adminFeatures",
|
||||
labelKey: "settings.configuration.features",
|
||||
labelFallback: "Features",
|
||||
keywords: ["features", "toggles", "flags"],
|
||||
adminArea: true,
|
||||
},
|
||||
{
|
||||
key: "adminStorageSharing",
|
||||
labelKey: "settings.configuration.storageSharing",
|
||||
labelFallback: "File Storage & Sharing",
|
||||
keywords: ["storage", "sharing", "files"],
|
||||
adminArea: true,
|
||||
},
|
||||
{
|
||||
key: "adminEndpoints",
|
||||
labelKey: "settings.configuration.endpoints",
|
||||
labelFallback: "Endpoints",
|
||||
keywords: ["endpoints", "api", "routes"],
|
||||
adminArea: true,
|
||||
},
|
||||
{
|
||||
key: "adminMcp",
|
||||
labelKey: "settings.configuration.mcp",
|
||||
labelFallback: "MCP Server",
|
||||
keywords: ["mcp", "server", "ai", "model context protocol"],
|
||||
adminArea: true,
|
||||
},
|
||||
{
|
||||
key: "adminDatabase",
|
||||
labelKey: "settings.configuration.database",
|
||||
labelFallback: "Database",
|
||||
keywords: ["database", "db", "backup"],
|
||||
adminArea: true,
|
||||
},
|
||||
{
|
||||
key: "adminAdvanced",
|
||||
labelKey: "settings.configuration.advanced",
|
||||
labelFallback: "Advanced",
|
||||
keywords: ["advanced", "expert"],
|
||||
adminArea: true,
|
||||
},
|
||||
// --- Security & Authentication ---
|
||||
{
|
||||
key: "adminSecurity",
|
||||
labelKey: "settings.securityAuth.security",
|
||||
labelFallback: "Security",
|
||||
keywords: ["security", "authentication", "auth", "password", "sessions"],
|
||||
adminArea: true,
|
||||
},
|
||||
{
|
||||
key: "adminConnections",
|
||||
labelKey: "settings.securityAuth.connections",
|
||||
labelFallback: "Connections",
|
||||
keywords: ["connections", "oauth", "sso", "integrations"],
|
||||
adminArea: true,
|
||||
},
|
||||
// --- Licensing & Analytics ---
|
||||
{
|
||||
key: "adminPlan",
|
||||
labelKey: "settings.licensingAnalytics.plan",
|
||||
labelFallback: "Plan",
|
||||
keywords: ["plan", "license", "billing", "subscription", "enterprise"],
|
||||
adminArea: true,
|
||||
},
|
||||
{
|
||||
key: "adminAudit",
|
||||
labelKey: "settings.licensingAnalytics.audit",
|
||||
labelFallback: "Audit",
|
||||
keywords: ["audit", "logs", "events", "history"],
|
||||
adminArea: true,
|
||||
},
|
||||
{
|
||||
key: "adminUsage",
|
||||
labelKey: "settings.licensingAnalytics.usageAnalytics",
|
||||
labelFallback: "Usage Analytics",
|
||||
keywords: ["usage", "analytics", "stats", "metrics"],
|
||||
adminArea: true,
|
||||
},
|
||||
// --- Policies & Privacy ---
|
||||
{
|
||||
key: "adminLegal",
|
||||
labelKey: "settings.policiesPrivacy.legal",
|
||||
labelFallback: "Legal",
|
||||
keywords: ["legal", "terms", "agreement"],
|
||||
adminArea: true,
|
||||
},
|
||||
{
|
||||
key: "adminPrivacy",
|
||||
labelKey: "settings.policiesPrivacy.privacy",
|
||||
labelFallback: "Privacy",
|
||||
keywords: ["privacy", "gdpr", "data"],
|
||||
adminArea: true,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,93 @@
|
||||
# PAYG settings screen — handover
|
||||
|
||||
Branch: `feat/payg-settings-screen`
|
||||
|
||||
## What this is
|
||||
|
||||
A **design mockup** of the Pay-as-you-go billing & usage settings screen. It
|
||||
renders and is interactive (inputs accept typing, dirty-state works), but it is
|
||||
**not wired to any backend** — all data is hard-coded and no action persists.
|
||||
|
||||
Files:
|
||||
|
||||
- `configSections/Payg.tsx` — the screen. Exports `PaygLeader` / `PaygMember`.
|
||||
- `configSections/Payg.css` — its styles.
|
||||
|
||||
Supporting (already in the diff): config-nav + modal wiring to surface the
|
||||
section, plus type additions (`paygEnabled`, `"payg"` nav key).
|
||||
|
||||
## How to preview locally
|
||||
|
||||
The real config flag isn't flowing yet, so there are temporary demo hooks:
|
||||
|
||||
- Append `?payg=1` to the URL (sticks in `localStorage` as `paygDemo=1`), or
|
||||
run `localStorage.setItem('paygDemo','1')`.
|
||||
- The proprietary build forces the **LEADER** variant so the cap editor and
|
||||
sub-caps render.
|
||||
|
||||
## What's mocked (replace with real wiring)
|
||||
|
||||
- **Data**: `usePaygMock(role)` in `Payg.tsx` hard-codes spend, cap, members,
|
||||
activity feed, account credit, and the Stripe portal URL.
|
||||
- **Cap preview**: the `previewUnits` math in `CapEditor` is a local
|
||||
calculation. Real impl calls `POST /api/v1/payg/cap-preview`
|
||||
(translates money → doc-units via the current Stripe price tier).
|
||||
- **Save / edit actions**: "Update cap", per-member "Set cap"/"Edit", and
|
||||
"View all" buttons have no handlers yet.
|
||||
- **Stripe portal link**: points at a mock URL.
|
||||
|
||||
## Known shortcuts to fix before merge
|
||||
|
||||
1. **Remove the demo blocks** — flagged inline with `DEMO` / "Remove before
|
||||
merging":
|
||||
- `proprietary/.../configNavSections.tsx` (import + pushed section)
|
||||
- `saas/.../AppConfigModal.tsx` (`paygDemo` / `?payg=1` override)
|
||||
PAYG is SaaS-only and must not ship in proprietary builds.
|
||||
2. **Real config flag**: `paygEnabled` should come from
|
||||
`ConfigController.java` / `ApplicationProperties`, not the demo override.
|
||||
3. **Real team-role lookup**: `isLeader` is currently proxied from
|
||||
`appConfig.isAdmin` (tenant admin ≠ team owner). Swap in a proper
|
||||
team-role check when team roles land.
|
||||
4. **Translation keys**: every `payg.*` string exists only as an inline
|
||||
English fallback. Add the keys to the translation JSON / locales.
|
||||
|
||||
## Backend integration guide (for whoever wires the API)
|
||||
|
||||
The screen is **ready to integrate** — the data contract is defined and there's
|
||||
a single swap point. It is not plug-and-play: the async lifecycle and mutations
|
||||
below are expected backend-engineer work, not pre-built.
|
||||
|
||||
### The contract & swap point
|
||||
|
||||
- `PaygSnapshot` (interface in `Payg.tsx`) **is the de-facto API spec** — build
|
||||
the read endpoint to return this shape.
|
||||
- `usePaygMock(role)` is the **single read swap point**. Replace it with a real
|
||||
data hook (e.g. `usePayg()`). Note it currently returns data *synchronously*
|
||||
via `useMemo`; a real hook is async, so you'll need to introduce
|
||||
**loading / error / empty (new-tenant) states** — no component handles those
|
||||
today, they all assume `snap` is present.
|
||||
|
||||
### Backend touchpoints (all currently stubbed, no handlers)
|
||||
|
||||
| UI action | Where | Needs |
|
||||
|-----------|-------|-------|
|
||||
| Read snapshot | `usePaygMock` | GET → `PaygSnapshot` |
|
||||
| Cap preview (live) | `CapEditor.previewUnits` | `POST /api/v1/payg/cap-preview` (debounced; money → doc-units via current Stripe price tier) |
|
||||
| Save cap | "Update cap" button | commit endpoint (cap_units, cap_source_money, currency, warn/degrade pct) |
|
||||
| Per-member sub-caps | "Set cap" / "Edit" | sub-cap upsert endpoint |
|
||||
| Activity pagination | "View all" | paginated activity endpoint |
|
||||
| Stripe billing | `stripePortalUrl` | **fetch a portal session on click** — the hardcoded URL won't work; real Stripe portal URLs are short-lived |
|
||||
|
||||
### Known latent bug — guard before real data flows
|
||||
|
||||
`previewUnits`, the hero `pct`, and the hero "spent" money all **divide by
|
||||
`capUnits` / `capSourceMoney`**. The mock uses non-zero values so it's hidden,
|
||||
but a brand-new tenant with no cap set = `0` → `NaN` / `$NaN` / broken usage
|
||||
bar. Add zero-guards when wiring real data.
|
||||
|
||||
## Design reference
|
||||
|
||||
Header comment in `Payg.tsx` summarises the framework: doc-units as the billing
|
||||
unit, customer-set cap in their own currency, `wallet_policy` fields, the
|
||||
FULL → WARNED (80%) → DEGRADED (100%) states, the four gates, and per-member
|
||||
sub-caps. See the PAYG design review doc for the full spec.
|
||||
@@ -0,0 +1,79 @@
|
||||
import { type SettingsSectionEntry } from "@core/data/settingsSectionRegistry";
|
||||
|
||||
export type { SettingsSectionEntry };
|
||||
|
||||
/**
|
||||
* SaaS web settings sections. The SaaS modal is composed differently from
|
||||
* self-hosted (see `saasConfigNavSections`): an Account area (Overview,
|
||||
* Passwords & Security, Team), Preferences, a Developer area (API Keys, MCP),
|
||||
* cloud Billing (Plan) and Legal. It deliberately does NOT list the self-hosted
|
||||
* admin sections, so search never deep-links to a tab the SaaS modal can't show.
|
||||
*
|
||||
* Entries are left ungated: the login/admin flags the super search reads come
|
||||
* from the local backend config and don't reflect SaaS auth, so gating here
|
||||
* would be unreliable. SaaS users are authenticated in practice; the anonymous
|
||||
* edge is a known Tier-0 simplification.
|
||||
*/
|
||||
export const SETTINGS_SECTION_REGISTRY: SettingsSectionEntry[] = [
|
||||
{
|
||||
key: "overview",
|
||||
labelKey: "config.account.overview.label",
|
||||
labelFallback: "Overview",
|
||||
keywords: ["account", "profile", "email", "avatar", "logout"],
|
||||
},
|
||||
{
|
||||
key: "security",
|
||||
labelKey: "config.account.security.title",
|
||||
labelFallback: "Passwords & Security",
|
||||
keywords: ["password", "2fa", "authentication", "sessions", "security"],
|
||||
},
|
||||
{
|
||||
key: "teams",
|
||||
labelKey: "config.team",
|
||||
labelFallback: "Team",
|
||||
keywords: ["team", "members", "invite", "seats"],
|
||||
},
|
||||
{
|
||||
key: "general",
|
||||
labelKey: "settings.general.title",
|
||||
labelFallback: "General",
|
||||
keywords: ["theme", "language", "appearance", "preferences", "startup"],
|
||||
},
|
||||
{
|
||||
key: "hotkeys",
|
||||
labelKey: "settings.hotkeys.title",
|
||||
labelFallback: "Keyboard Shortcuts",
|
||||
keywords: ["hotkey", "shortcut", "keybinding", "keyboard"],
|
||||
},
|
||||
{
|
||||
key: "api-keys",
|
||||
labelKey: "settings.developer.apiKeys",
|
||||
labelFallback: "API Keys",
|
||||
keywords: ["api", "token", "developer", "key"],
|
||||
},
|
||||
{
|
||||
key: "mcp",
|
||||
labelKey: "config.mcp.navLabel",
|
||||
labelFallback: "MCP Server",
|
||||
keywords: ["mcp", "server", "ai", "model context protocol"],
|
||||
},
|
||||
{
|
||||
key: "plan",
|
||||
labelKey: "config.plan",
|
||||
labelFallback: "Plan",
|
||||
keywords: [
|
||||
"billing",
|
||||
"subscription",
|
||||
"upgrade",
|
||||
"payment",
|
||||
"invoice",
|
||||
"wallet",
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "legal",
|
||||
labelKey: "settings.legal.label",
|
||||
labelFallback: "Legal",
|
||||
keywords: ["legal", "terms", "privacy", "licenses"],
|
||||
},
|
||||
];
|
||||
Reference in New Issue
Block a user