mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
refactor(editor): one top bar in the file library, not two
The library kept a header of its own, so with the workbench bar now showing it had two stacked bars - and two search boxes, since both carried a SuperSearch. Its Refresh, New folder, Upload and Upload-from-Mobile controls register into the workbench bar through the same hook the viewer and page editor use, and the header goes. What stays in the view is the breadcrumb and tab name: that says where you are rather than doing anything, and the bar has nowhere to put it. The handlers passed to the bar are memoised. Registration re-runs whenever they change identity, so an arrow defined in the call loops until React gives up.
This commit is contained in:
@@ -22,8 +22,6 @@ 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";
|
||||
import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder";
|
||||
import GridViewIcon from "@mui/icons-material/GridView";
|
||||
import ViewListIcon from "@mui/icons-material/ViewList";
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
@@ -32,11 +30,11 @@ import OpenInNewIcon from "@mui/icons-material/OpenInNew";
|
||||
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
|
||||
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
|
||||
import KeyboardArrowRightIcon from "@mui/icons-material/KeyboardArrowRight";
|
||||
import RefreshIcon from "@mui/icons-material/Refresh";
|
||||
import { FilesToolbarBulkMenu } from "@app/components/filesPage/FilesToolbarBulkMenu";
|
||||
import { FilesToolbarCount } from "@app/components/filesPage/FilesToolbarCount";
|
||||
import { FilesToolbarFilterMenu } from "@app/components/filesPage/FilesToolbarFilterMenu";
|
||||
import { FilesToolbarSortMenu } from "@app/components/filesPage/FilesToolbarSortMenu";
|
||||
import { useFileLibraryWorkbenchBarButtons } from "@app/components/filesPage/useFileLibraryWorkbenchBarButtons";
|
||||
|
||||
import { stripBasePath } from "@app/constants/app";
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
@@ -63,8 +61,6 @@ 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 { useEditorSearchScopes } from "@app/hooks/useSuperSearch";
|
||||
import { FileDetailsPanel } from "@app/components/filesPage/FileDetailsPanel";
|
||||
import BulkUploadToServerModal from "@app/components/shared/BulkUploadToServerModal";
|
||||
import MobileUploadModal from "@app/components/shared/MobileUploadModal";
|
||||
@@ -92,7 +88,6 @@ export default function FileManagerView() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const searchScopes = useEditorSearchScopes();
|
||||
|
||||
// Hide Shared tab when storageSharingEnabled is false.
|
||||
const { sharingEnabled } = useSharingEnabled();
|
||||
@@ -914,6 +909,32 @@ export default function FileManagerView() {
|
||||
);
|
||||
|
||||
// null = New folder actionable; string = disabled tooltip reason.
|
||||
// Lifted out of the header that used to render it: the workbench bar owns these
|
||||
// controls now, and a handler defined inside JSX cannot be handed to it.
|
||||
const handleRefresh = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
// pullFromServer bumps the folder revision, which the FolderProvider's effect
|
||||
// reacts to by re-running refresh() - no need to await folders.refresh() here.
|
||||
const result = await folders.pullFromServer();
|
||||
if (!result.ok && result.reason !== "endpoint-missing") {
|
||||
folders.setError(
|
||||
result.reason === "network"
|
||||
? t("filesPage.syncError.network", "Could not reach the server.")
|
||||
: result.reason === "server"
|
||||
? t(
|
||||
"filesPage.syncError.server",
|
||||
"Server error during folder sync.",
|
||||
)
|
||||
: t("filesPage.syncError.client", "Folder sync failed."),
|
||||
);
|
||||
}
|
||||
await refresh();
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [folders, refresh, t]);
|
||||
|
||||
const newFolderDisabledReason: string | null = useMemo(() => {
|
||||
// Guests can't use cloud folders at all - say so before any tab/storage
|
||||
// hint, since switching tabs wouldn't help them.
|
||||
@@ -945,23 +966,35 @@ export default function FileManagerView() {
|
||||
return null;
|
||||
}, [signInRequiredReason, currentTab, folders.serverReachable, t]);
|
||||
|
||||
// Stable identities: the bar re-registers whenever these change, and a fresh arrow
|
||||
// per render turns that into an endless register -> render -> register loop.
|
||||
const openFilePicker = useCallback(() => fileInputRef.current?.click(), []);
|
||||
const openMobileUpload = useCallback(
|
||||
() => setMobileUploadModalOpen(true),
|
||||
[],
|
||||
);
|
||||
|
||||
useFileLibraryWorkbenchBarButtons({
|
||||
onRefresh: handleRefresh,
|
||||
refreshing,
|
||||
refreshDisabledReason: signInRequiredReason,
|
||||
onNewFolder: openNewFolderDialog,
|
||||
newFolderDisabledReason,
|
||||
onUpload: openFilePicker,
|
||||
onUploadFromMobile: isMobileUploadAvailable ? openMobileUpload : undefined,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="files-page" ref={dropZoneRef}>
|
||||
<header className="files-page-header">
|
||||
{/* Breadcrumb only for folder-rooted tabs. */}
|
||||
{/* Folder context, not a bar: the controls that used to sit beside this are
|
||||
registered into the workbench bar the other views use. */}
|
||||
<div className="files-page-location">
|
||||
{(currentTab === "all" || currentTab === "cloud") && <Breadcrumbs />}
|
||||
{(currentTab === "local" ||
|
||||
currentTab === "recent" ||
|
||||
currentTab === "shared" ||
|
||||
currentTab === "sharedByMe") && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.95rem",
|
||||
fontWeight: 600,
|
||||
padding: "0.25rem 0.5rem",
|
||||
color: "var(--c-text)",
|
||||
}}
|
||||
>
|
||||
<span className="files-page-location-name">
|
||||
{currentTab === "local"
|
||||
? t("filesPage.tabName.local", "Local")
|
||||
: currentTab === "recent"
|
||||
@@ -969,132 +1002,16 @@ export default function FileManagerView() {
|
||||
: currentTab === "shared"
|
||||
? t("filesPage.tabName.shared", "Shared with me")
|
||||
: t("filesPage.tabName.sharedByMe", "Shared by me")}
|
||||
</div>
|
||||
</span>
|
||||
)}
|
||||
{(() => {
|
||||
// Both the inline desktop buttons and the mobile kebab menu need
|
||||
// these handlers - extract once so we don't drift two copies.
|
||||
const handleRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
// pullFromServer bumps the folder revision, which the
|
||||
// FolderProvider's effect reacts to by re-running refresh() -
|
||||
// no need to await folders.refresh() manually.
|
||||
const result = await folders.pullFromServer();
|
||||
if (!result.ok && result.reason !== "endpoint-missing") {
|
||||
folders.setError(
|
||||
result.reason === "network"
|
||||
? t(
|
||||
"filesPage.syncError.network",
|
||||
"Could not reach the server.",
|
||||
)
|
||||
: result.reason === "server"
|
||||
? t(
|
||||
"filesPage.syncError.server",
|
||||
"Server error during folder sync.",
|
||||
)
|
||||
: t("filesPage.syncError.client", "Folder sync failed."),
|
||||
);
|
||||
}
|
||||
await refresh();
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<div className="files-page-header-search">
|
||||
<SuperSearch scopes={searchScopes} />
|
||||
</div>
|
||||
<div className="files-page-header-actions">
|
||||
<Tooltip
|
||||
label={
|
||||
signInRequiredReason ??
|
||||
t("filesPage.refresh", "Refresh from server")
|
||||
}
|
||||
withinPortal
|
||||
>
|
||||
<ActionIcon
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
loading={refreshing}
|
||||
disabled={refreshing || Boolean(signInRequiredReason)}
|
||||
aria-busy={refreshing}
|
||||
onClick={handleRefresh}
|
||||
aria-label={t("filesPage.refresh", "Refresh from server")}
|
||||
>
|
||||
<RefreshIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
{newFolderDisabledReason ? (
|
||||
<Tooltip
|
||||
label={newFolderDisabledReason}
|
||||
withinPortal
|
||||
multiline
|
||||
w={220}
|
||||
>
|
||||
<span style={{ display: "inline-flex" }}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
leftSection={<CreateNewFolderIcon fontSize="small" />}
|
||||
disabled
|
||||
style={{ pointerEvents: "auto" }}
|
||||
>
|
||||
{t("filesPage.newFolder", "New folder")}
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
leftSection={<CreateNewFolderIcon fontSize="small" />}
|
||||
onClick={() => openNewFolderDialog()}
|
||||
>
|
||||
{t("filesPage.newFolder", "New folder")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
leftSection={<UploadFileIcon fontSize="small" />}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
{t("filesPage.upload", "Upload")}
|
||||
</Button>
|
||||
{isMobileUploadAvailable && (
|
||||
<Tooltip
|
||||
label={t(
|
||||
"filesPage.uploadFromMobile",
|
||||
"Upload from Mobile",
|
||||
)}
|
||||
withinPortal
|
||||
>
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => setMobileUploadModalOpen(true)}
|
||||
aria-label={t(
|
||||
"filesPage.uploadFromMobile",
|
||||
"Upload from Mobile",
|
||||
)}
|
||||
>
|
||||
<QrCode2Icon fontSize="small" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
style={{ display: "none" }}
|
||||
onChange={onFileInputChange}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</header>
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
style={{ display: "none" }}
|
||||
onChange={onFileInputChange}
|
||||
/>
|
||||
|
||||
{folders.error && (
|
||||
<div
|
||||
|
||||
@@ -8,57 +8,21 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.files-page-header {
|
||||
/* 3-column grid keeps the search bar position-stable regardless of
|
||||
* breadcrumb length. Left column flexes for the breadcrumb (truncating
|
||||
* via overflow:hidden), middle column holds the search, right column
|
||||
* holds the action buttons right-aligned. */
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(min(14rem, 100%), 40rem) minmax(
|
||||
0,
|
||||
1fr
|
||||
);
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
min-height: 48px;
|
||||
padding: 0 0.75rem;
|
||||
border-bottom: 1px solid var(--c-border-subtle);
|
||||
background: var(--c-bg-raised);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.files-page-header-actions {
|
||||
justify-self: end;
|
||||
/* Where you are in the library. Not a bar - the controls that used to share this
|
||||
row are registered into the workbench bar, and the search lives there too. */
|
||||
.files-page-location {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.files-page-header-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 2rem;
|
||||
padding: 0.25rem 0.75rem 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.files-page-header-search .super-search {
|
||||
flex: 0 1 24rem;
|
||||
width: min(100%, 24rem);
|
||||
max-width: 24rem;
|
||||
}
|
||||
|
||||
.files-page-header-search .super-search input {
|
||||
background-color: transparent;
|
||||
padding-top: 4px;
|
||||
padding-bottom: 4px;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"]
|
||||
.files-page-header-search
|
||||
.super-search
|
||||
input {
|
||||
background-color: transparent;
|
||||
.files-page-location-name {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
padding: 0.25rem 0.5rem;
|
||||
color: var(--c-text);
|
||||
}
|
||||
|
||||
.files-page-breadcrumbs {
|
||||
@@ -1317,12 +1281,6 @@
|
||||
filter selects, and pins the view toggle so it never clips off the
|
||||
right edge. The header stays in desktop layout here (it has fewer
|
||||
items and the side panels disappearing actually GIVES it room). */
|
||||
.files-page-header [data-desktop-hide="true"] {
|
||||
/* The mobile-only overflow menu trigger. Hidden by default; shown
|
||||
inside the mobile breakpoint below. Live as inline-flex so it
|
||||
sits next to the Upload button without breaking the action row. */
|
||||
display: none;
|
||||
}
|
||||
@media (max-width: 1024px) {
|
||||
.files-page-toolbar {
|
||||
/* nowrap so "7 items" + "Select all" sit on the same row as the
|
||||
@@ -1395,31 +1353,6 @@
|
||||
search takes whatever is left. Ends at the app's mobile breakpoint so
|
||||
it matches the layout HomePage is already rendering. */
|
||||
@media (max-width: 1024px) {
|
||||
.files-page-header {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.25rem 0.4rem;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
.files-page-header-search {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
/* Undo the fixed 24rem basis so the pill tracks the row's spare width. */
|
||||
.files-page-header-search .super-search {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
}
|
||||
.files-page-header-actions {
|
||||
flex: 0 0 auto;
|
||||
margin-left: auto;
|
||||
gap: 0.25rem;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
.files-page-breadcrumbs {
|
||||
flex: 0 1 auto;
|
||||
font-size: 0.85rem;
|
||||
@@ -1427,17 +1360,6 @@
|
||||
overflow-x: auto;
|
||||
min-width: 0;
|
||||
}
|
||||
/* Icon-only actions: the labels are what pushed Upload past the edge. */
|
||||
.files-page-header-actions .mantine-Button-root {
|
||||
padding-left: 0.55rem;
|
||||
padding-right: 0.55rem;
|
||||
}
|
||||
.files-page-header-actions .mantine-Button-section[data-position="left"] {
|
||||
margin-right: 0;
|
||||
}
|
||||
.files-page-header-actions .mantine-Button-label {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder";
|
||||
import QrCode2Icon from "@mui/icons-material/QrCode2";
|
||||
import RefreshIcon from "@mui/icons-material/Refresh";
|
||||
import UploadFileIcon from "@mui/icons-material/UploadFile";
|
||||
import {
|
||||
useWorkbenchBarButtons,
|
||||
type WorkbenchBarButtonWithAction,
|
||||
} from "@app/hooks/useWorkbenchBarButtons";
|
||||
|
||||
export interface FileLibraryWorkbenchBarButtonsOptions {
|
||||
onRefresh: () => void;
|
||||
refreshing: boolean;
|
||||
/** Set when refreshing needs a session the user does not have; also the tooltip. */
|
||||
refreshDisabledReason: ReactNode;
|
||||
onNewFolder: () => void;
|
||||
/** Set when the server ships no folder storage; also the tooltip. */
|
||||
newFolderDisabledReason: ReactNode;
|
||||
onUpload: () => void;
|
||||
onUploadFromMobile?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The library's own actions, in the bar every other view uses. It had a header of
|
||||
* its own carrying these plus a second search box, which read as two stacked bars.
|
||||
*/
|
||||
export function useFileLibraryWorkbenchBarButtons({
|
||||
onRefresh,
|
||||
refreshing,
|
||||
refreshDisabledReason,
|
||||
onNewFolder,
|
||||
newFolderDisabledReason,
|
||||
onUpload,
|
||||
onUploadFromMobile,
|
||||
}: FileLibraryWorkbenchBarButtonsOptions): void {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const buttons = useMemo<WorkbenchBarButtonWithAction[]>(
|
||||
() => [
|
||||
{
|
||||
id: "fileLibraryRefresh",
|
||||
icon: <RefreshIcon />,
|
||||
tooltip:
|
||||
refreshDisabledReason ??
|
||||
t("filesPage.refresh", "Refresh from server"),
|
||||
ariaLabel: t("filesPage.refresh", "Refresh from server"),
|
||||
section: "top",
|
||||
order: 10,
|
||||
disabled: refreshing || Boolean(refreshDisabledReason),
|
||||
onClick: onRefresh,
|
||||
},
|
||||
{
|
||||
id: "fileLibraryNewFolder",
|
||||
icon: <CreateNewFolderIcon />,
|
||||
tooltip:
|
||||
newFolderDisabledReason ?? t("filesPage.newFolder", "New folder"),
|
||||
ariaLabel: t("filesPage.newFolder", "New folder"),
|
||||
section: "top",
|
||||
order: 20,
|
||||
disabled: Boolean(newFolderDisabledReason),
|
||||
onClick: onNewFolder,
|
||||
},
|
||||
{
|
||||
id: "fileLibraryUpload",
|
||||
icon: <UploadFileIcon />,
|
||||
tooltip: t("filesPage.upload", "Upload"),
|
||||
ariaLabel: t("filesPage.upload", "Upload"),
|
||||
section: "top",
|
||||
order: 30,
|
||||
onClick: onUpload,
|
||||
},
|
||||
// Only where a phone can pair with this session.
|
||||
...(onUploadFromMobile
|
||||
? [
|
||||
{
|
||||
id: "fileLibraryUploadFromMobile",
|
||||
icon: <QrCode2Icon />,
|
||||
tooltip: t("filesPage.uploadFromMobile", "Upload from Mobile"),
|
||||
ariaLabel: t("filesPage.uploadFromMobile", "Upload from Mobile"),
|
||||
section: "top" as const,
|
||||
order: 40,
|
||||
onClick: onUploadFromMobile,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
[
|
||||
t,
|
||||
onRefresh,
|
||||
refreshing,
|
||||
refreshDisabledReason,
|
||||
onNewFolder,
|
||||
newFolderDisabledReason,
|
||||
onUpload,
|
||||
onUploadFromMobile,
|
||||
],
|
||||
);
|
||||
|
||||
useWorkbenchBarButtons(buttons);
|
||||
}
|
||||
Reference in New Issue
Block a user