From f5cf5f1077531d36ed2243cb1fa8fd80b5354764 Mon Sep 17 00:00:00 2001 From: "stirlingbot[bot]" <195170888+stirlingbot[bot]@users.noreply.github.com> Date: Fri, 21 Aug 2026 20:50:12 +0000 Subject: [PATCH 1/9] Update Frontend 3rd Party Licenses (#7616) Auto-generated by stirlingbot[bot] This PR updates the frontend license report based on changes to package.json dependencies. Signed-off-by: stirlingbot[bot] Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com> --- frontend/editor/src/assets/3rdPartyLicenses.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/editor/src/assets/3rdPartyLicenses.json b/frontend/editor/src/assets/3rdPartyLicenses.json index 4b4af5de66..83d9af793b 100644 --- a/frontend/editor/src/assets/3rdPartyLicenses.json +++ b/frontend/editor/src/assets/3rdPartyLicenses.json @@ -374,7 +374,7 @@ { "moduleName": "i18next", "moduleUrl": "https://github.com/i18next/i18next", - "moduleVersion": "25.10.10", + "moduleVersion": "26.3.6", "moduleLicense": "MIT", "moduleLicenseUrl": "https://opensource.org/licenses/MIT" }, @@ -458,7 +458,7 @@ { "moduleName": "react-i18next", "moduleUrl": "https://github.com/i18next/react-i18next", - "moduleVersion": "16.6.6", + "moduleVersion": "17.0.11", "moduleLicense": "MIT", "moduleLicenseUrl": "https://opensource.org/licenses/MIT" }, From 1cb914023cecce56f7630b130154b0796127b880 Mon Sep 17 00:00:00 2001 From: admiralXS <794005572@qq.com> Date: Fri, 21 Aug 2026 21:47:57 +0000 Subject: [PATCH 2/9] fix: allow anonymous access to /invite/:token accept page (#7612) ## Problem In the self-hosted build with login enabled, admin-generated invite links point to the SPA route `/invite/`, but that route is not covered by the anonymous whitelist. Anonymous users get 401 / redirected to `/login` before the React app can mount - even though the APIs the page calls (`/api/v1/invite/validate`, `/api/v1/invite/accept`) are already whitelisted. Since accepting an invite is how a *new* account is created, requiring authentication first makes the feature unusable. ## Fix Add `INVITE_LINK_PATTERN` (`^/invite/[^/]+/?$`) in `RequestUriUtils.java`, matched at the end of `isPublicAuthEndpoint()` - mirroring the existing `SHARE_LINK_PATTERN` handling. The invite data APIs remain protected by their own token validation; only the SPA bootstrap page becomes anonymously reachable. ## Tests Added unit tests in `RequestUriUtilsTest.java` mirroring the share-link tests: - `/invite/` (with/without trailing slash, with context path) ? public - bare `/invite` and `/invite/` ? NOT public (token segment required) - `/invite//foo` nested paths ? NOT public - `/inviteX` prefix over-match ? NOT public ## Verification Pattern behavior validated against all test cases above. Live-tested on 2.14.3 self-hosted: anonymous `GET /invite/` returned 401 before the fix; the whitelisted accept flow itself (`validate` + `accept` APIs) works anonymously end-to-end. --- .../software/common/util/RequestUriUtils.java | 8 +- .../common/util/RequestUriUtilsTest.java | 82 +++++++++++++++++++ 2 files changed, 89 insertions(+), 1 deletion(-) diff --git a/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java b/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java index f85880df5d..7904156262 100644 --- a/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java +++ b/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java @@ -5,6 +5,10 @@ import java.util.regex.Pattern; public class RequestUriUtils { private static final Pattern SHARE_LINK_PATTERN = Pattern.compile("^/share/[^/]+/?$"); + // Invite tokens are 36-char lowercase UUIDs (UUID.randomUUID().toString()); match exactly + private static final Pattern INVITE_LINK_PATTERN = + Pattern.compile( + "^/invite/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/?$"); public static boolean isStaticResource(String requestURI) { return isStaticResource("", requestURI); @@ -209,7 +213,9 @@ public class RequestUriUtils { // Workflow participant endpoints - access controlled by share tokens, not login || trimmedUri.startsWith("/api/v1/workflow/participant/") // Share-link SPA bootstrap; data APIs remain protected - || SHARE_LINK_PATTERN.matcher(trimmedUri).matches(); + || SHARE_LINK_PATTERN.matcher(trimmedUri).matches() + // Invite-accept SPA bootstrap; data APIs remain protected + || INVITE_LINK_PATTERN.matcher(trimmedUri).matches(); } private static String stripContextPath(String contextPath, String requestURI) { diff --git a/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java b/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java index 0e399c1fae..0449514747 100644 --- a/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java +++ b/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java @@ -236,4 +236,86 @@ class RequestUriUtilsTest { RequestUriUtils.isPublicAuthEndpoint( "/api/v1/storage/share-links/abc123/metadata", "")); } + + // --- invite-accept SPA bootstrap --- + + private static final String INVITE_TOKEN = "06a20e7e-2e35-4e26-be7d-2dce14f28f12"; + + @Test + void testIsPublicAuthEndpoint_inviteLinkToken() { + assertTrue(RequestUriUtils.isPublicAuthEndpoint("/invite/" + INVITE_TOKEN, "")); + } + + @Test + void testIsPublicAuthEndpoint_inviteLinkTokenTrailingSlash() { + assertTrue(RequestUriUtils.isPublicAuthEndpoint("/invite/" + INVITE_TOKEN + "/", "")); + } + + @Test + void testIsPublicAuthEndpoint_inviteLinkWithContextPath() { + assertTrue(RequestUriUtils.isPublicAuthEndpoint("/app/invite/" + INVITE_TOKEN, "/app")); + } + + @Test + void testIsPublicAuthEndpoint_inviteRootNotPublic() { + // Avoid matching bare "/invite" or "/invite/" - must have a token segment + assertFalse(RequestUriUtils.isPublicAuthEndpoint("/invite", "")); + assertFalse(RequestUriUtils.isPublicAuthEndpoint("/invite/", "")); + } + + @Test + void testIsPublicAuthEndpoint_inviteNestedPathNotPublic() { + // Guard against future additions like /invite//foo becoming accidentally public + assertFalse(RequestUriUtils.isPublicAuthEndpoint("/invite/" + INVITE_TOKEN + "/foo", "")); + } + + @Test + void testIsPublicAuthEndpoint_invitePrefixDoesNotOvermatch() { + // "/inviteX" must not match the invite pattern + assertFalse(RequestUriUtils.isPublicAuthEndpoint("/inviteX", "")); + } + + @Test + void testIsPublicAuthEndpoint_inviteNonUuidTokenNotPublic() { + // Only exactly-shaped 36-char lowercase UUID tokens are treated as invite links + assertFalse(RequestUriUtils.isPublicAuthEndpoint("/invite/abc123", "")); + } + + @Test + void testIsPublicAuthEndpoint_inviteUppercaseUuidNotPublic() { + // Tokens are generated lowercase by UUID.randomUUID().toString() + assertFalse( + RequestUriUtils.isPublicAuthEndpoint( + "/invite/06A20E7E-2E35-4E26-BE7D-2DCE14F28F12", "")); + } + + @Test + void testIsPublicAuthEndpoint_inviteWrongLengthNotPublic() { + // 35-char and 37-char UUID-like tokens are not valid UUIDs + assertFalse( + RequestUriUtils.isPublicAuthEndpoint( + "/invite/06a20e7e-2e35-4e26-be7d-2dce14f28f1", "")); + assertFalse( + RequestUriUtils.isPublicAuthEndpoint( + "/invite/06a20e7e-2e35-4e26-be7d-2dce14f28f122", "")); + } + + @Test + void testIsPublicAuthEndpoint_inviteWrongGroupingNotPublic() { + // Groups of 8-4-4-4-4 must not be shifted around (e.g. 4-4-4-4-8) + assertFalse( + RequestUriUtils.isPublicAuthEndpoint( + "/invite/06a2-0e7e-2e35-4e26-be7d2dce14f28f12", "")); + } + + @Test + void testIsPublicAuthEndpoint_inviteTokenInvalidCharsNotPublic() { + // Hex-only; anything outside [0-9a-f] or the UUID hyphens is rejected + assertFalse(RequestUriUtils.isPublicAuthEndpoint("/invite/abc$123", "")); + assertFalse(RequestUriUtils.isPublicAuthEndpoint("/invite/abc..123", "")); + assertFalse(RequestUriUtils.isPublicAuthEndpoint("/invite/abc%2F123", "")); + assertFalse( + RequestUriUtils.isPublicAuthEndpoint( + "/invite/06a20e7e-2e35-4e26-be7d-2dce14f28f1g", "")); + } } From 41e4b67f1dc13473b54ce0cf9ddf4e7ae0057172 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 22 Aug 2026 14:13:02 +0000 Subject: [PATCH 3/9] build(deps): bump the simple-java-mail group across 2 directories with 2 updates (#7621) Bumps the simple-java-mail group with 1 update in the / directory: [org.simplejavamail:simple-java-mail](https://github.com/bbottema/simple-java-mail). Bumps the simple-java-mail group with 1 update in the /app/common directory: [org.simplejavamail:simple-java-mail](https://github.com/bbottema/simple-java-mail). Updates `org.simplejavamail:simple-java-mail` from 9.3.1 to 9.3.2
Release notes

Sourced from org.simplejavamail:simple-java-mail's releases.

v9.3.2

Fixed #702: clarified that RecipientBuilder accepts one address, while RecipientsBuilder handles comma- or semicolon-delimited address lists; see the recipient builder examples.

Changelog

Sourced from org.simplejavamail:simple-java-mail's changelog.

Commits

Updates `org.simplejavamail:outlook-module` from 9.3.1 to 9.3.2 Updates `org.simplejavamail:simple-java-mail` from 9.3.1 to 9.3.2
Release notes

Sourced from org.simplejavamail:simple-java-mail's releases.

v9.3.2

Fixed #702: clarified that RecipientBuilder accepts one address, while RecipientsBuilder handles comma- or semicolon-delimited address lists; see the recipient builder examples.

Changelog

Sourced from org.simplejavamail:simple-java-mail's changelog.

Commits

Updates `org.simplejavamail:outlook-module` from 9.3.1 to 9.3.2 Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore ` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- app/common/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/common/build.gradle b/app/common/build.gradle index 0661a51df7..bb956504cc 100644 --- a/app/common/build.gradle +++ b/app/common/build.gradle @@ -21,8 +21,8 @@ dependencies { api 'org.snakeyaml:snakeyaml-engine:3.0.1' api "org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.3" // Simple Java Mail for EML/MSG parsing (replaces direct Angus Mail usage) - api 'org.simplejavamail:simple-java-mail:9.3.1' - api 'org.simplejavamail:outlook-module:9.3.1' // MSG file support + api 'org.simplejavamail:simple-java-mail:9.3.2' + api 'org.simplejavamail:outlook-module:9.3.2' // MSG file support api 'jakarta.mail:jakarta.mail-api:2.1.5' runtimeOnly 'org.eclipse.angus:angus-mail:2.0.5' From 4457260c60b14b52c3b36155134a0b2162ec1e93 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Sat, 22 Aug 2026 18:47:14 +0100 Subject: [PATCH 4/9] Make the editor and settings menu mobile friendly-er (#7518) Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> --- frontend/editor/index.html | 5 +- .../public/locales/en-US/translation.toml | 16 + .../editor/src/core/components/AppLayout.tsx | 2 +- .../core/components/filesPage/FileGrid.tsx | 4 +- .../components/filesPage/FileManagerView.tsx | 656 ++++++++++-------- .../core/components/filesPage/FilesPage.css | 108 ++- .../filesPage/FilesToolbarBulkMenu.tsx | 104 +++ .../filesPage/FilesToolbarCount.tsx | 41 ++ .../filesPage/FilesToolbarFilterMenu.tsx | 149 ++++ .../filesPage/FilesToolbarSortMenu.tsx | 86 +++ .../components/layout/Workbench.module.css | 7 + .../core/components/shared/AppConfigModal.css | 63 +- .../core/components/shared/AppConfigModal.tsx | 146 ++-- .../core/components/shared/WorkbenchBar.css | 99 ++- .../core/components/shared/WorkbenchBar.tsx | 276 +++----- .../SettingsMobileBackButton.stories.tsx | 17 + .../config/SettingsMobileBackButton.tsx | 31 + .../SettingsMobileNavHeader.stories.tsx | 23 + .../shared/config/SettingsMobileNavHeader.tsx | 44 ++ .../config/SettingsNavChevron.stories.tsx | 18 + .../shared/config/SettingsNavChevron.tsx | 20 + .../shared/config/configNavSections.tsx | 4 +- .../shared/superSearch/SuperSearch.css | 14 +- .../shared/superSearch/SuperSearch.tsx | 8 +- .../WorkbenchBarDesktopActions.tsx | 121 ++++ .../WorkbenchBarMobileActions.tsx | 94 +++ .../WorkbenchBarToolbarHandle.tsx | 67 ++ .../components/shared/workbenchBar/types.ts | 20 + .../workbenchBar/workbenchBarTooltip.tsx | 66 ++ .../core/components/tools/RightSidebar.tsx | 7 +- .../src/core/components/tools/ToolPanel.css | 14 + .../core/components/viewer/NonPdfViewer.tsx | 19 +- .../components/viewer/nonpdf/HtmlViewer.tsx | 74 +- frontend/editor/src/core/pages/HomePage.css | 60 +- frontend/editor/src/core/pages/HomePage.tsx | 139 ++-- .../src/core/tests/stubbed/files-page.spec.ts | 5 +- .../config/configSections/GeneralSection.tsx | 10 +- .../shared/config/LoginLandingSetting.tsx | 1 + .../shared/config/configNavSections.tsx | 2 +- .../saas/components/shared/AppConfigModal.tsx | 114 ++- 40 files changed, 2045 insertions(+), 709 deletions(-) create mode 100644 frontend/editor/src/core/components/filesPage/FilesToolbarBulkMenu.tsx create mode 100644 frontend/editor/src/core/components/filesPage/FilesToolbarCount.tsx create mode 100644 frontend/editor/src/core/components/filesPage/FilesToolbarFilterMenu.tsx create mode 100644 frontend/editor/src/core/components/filesPage/FilesToolbarSortMenu.tsx create mode 100644 frontend/editor/src/core/components/shared/config/SettingsMobileBackButton.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/config/SettingsMobileBackButton.tsx create mode 100644 frontend/editor/src/core/components/shared/config/SettingsMobileNavHeader.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/config/SettingsMobileNavHeader.tsx create mode 100644 frontend/editor/src/core/components/shared/config/SettingsNavChevron.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/config/SettingsNavChevron.tsx create mode 100644 frontend/editor/src/core/components/shared/workbenchBar/WorkbenchBarDesktopActions.tsx create mode 100644 frontend/editor/src/core/components/shared/workbenchBar/WorkbenchBarMobileActions.tsx create mode 100644 frontend/editor/src/core/components/shared/workbenchBar/WorkbenchBarToolbarHandle.tsx create mode 100644 frontend/editor/src/core/components/shared/workbenchBar/types.ts create mode 100644 frontend/editor/src/core/components/shared/workbenchBar/workbenchBarTooltip.tsx diff --git a/frontend/editor/index.html b/frontend/editor/index.html index 427b6d1449..1e782db2de 100644 --- a/frontend/editor/index.html +++ b/frontend/editor/index.html @@ -4,7 +4,10 @@ - +
{banner}
{children}
diff --git a/frontend/editor/src/core/components/filesPage/FileGrid.tsx b/frontend/editor/src/core/components/filesPage/FileGrid.tsx index 7fd2eda133..40e17dc502 100644 --- a/frontend/editor/src/core/components/filesPage/FileGrid.tsx +++ b/frontend/editor/src/core/components/filesPage/FileGrid.tsx @@ -978,7 +978,9 @@ function FileCard({ )}
{fileSize} - · + {fileDate}
diff --git a/frontend/editor/src/core/components/filesPage/FileManagerView.tsx b/frontend/editor/src/core/components/filesPage/FileManagerView.tsx index e87b348780..36ffb9c4dc 100644 --- a/frontend/editor/src/core/components/filesPage/FileManagerView.tsx +++ b/frontend/editor/src/core/components/filesPage/FileManagerView.tsx @@ -33,6 +33,10 @@ 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 { stripBasePath } from "@app/constants/app"; import { useAuth } from "@app/auth/UseSession"; @@ -893,6 +897,9 @@ export default function FileManagerView() { () => Array.from(selectedFileIds), [selectedFileIds], ); + // A phone with files selected shows a contextual selection bar instead of the + // full toolbar - five bulk buttons plus filters cannot fit the width. + const mobileSelection = isMobile && selectedFiles.length > 0; // Local-only subset of selection; drives Save-to-server visibility. const localOnlySelectedStubs = useMemo( @@ -1210,22 +1217,12 @@ export default function FileManagerView() { })()}
- - {loading - ? t("filesPage.loading", "Loading…") - : t("filesPage.summary", "{{count}} items", { - count: totalCount, - })} - {selectedFiles.length > 0 && ( - - {" "} - ·{" "} - {t("filesPage.selectedCount", "{{count}} selected", { - count: selectedFiles.length, - })} - - )} - + {(() => { // Select all / Clear toggle over visible files. if (visibleFiles.length === 0) return null; @@ -1265,289 +1262,382 @@ export default function FileManagerView() { ); })()}
- {selectedFiles.length > 0 && - (() => { - // Bulk-action labels; CSS collapses to icon-only below 900px. - const addLabel = + {mobileSelection ? ( + handleAddToWorkspace(selectedFiles)} + onSaveToServer={ + localOnlySelectedStubs.length > 0 + ? () => setSaveToServerTarget(localOnlySelectedStubs) + : undefined + } + saveToServerDisabledReason={ + saveToServerDisabledReason ?? undefined + } + onShowDetails={ selectedFiles.length === 1 - ? t("filesPage.addToWorkspace", "Add to workspace") - : t( - "filesPage.addToWorkspaceCount", - "Add {{count}} to workspace", - { count: selectedFiles.length }, - ); - const moveLabel = t("filesPage.moveTo", "Move to…"); - const removeLabel = t("filesPage.remove", "Remove"); - return ( - // wrap="nowrap" keeps the row single-line. - - - - - {/* Save to server; shown whenever local-only files are + ? () => setMobileDetailsOpen(true) + : undefined + } + onMove={() => promptMoveFiles(selectedFiles)} + onRemove={() => handleRemoveFiles(selectedFiles)} + /> + ) : ( + <> + {selectedFiles.length > 0 && + (() => { + // Bulk-action labels; CSS collapses to icon-only below 900px. + const addLabel = + selectedFiles.length === 1 + ? t("filesPage.addToWorkspace", "Add to workspace") + : t( + "filesPage.addToWorkspaceCount", + "Add {{count}} to workspace", + { count: selectedFiles.length }, + ); + const moveLabel = t("filesPage.moveTo", "Move to…"); + const removeLabel = t("filesPage.remove", "Remove"); + return ( + // wrap="nowrap" keeps the row single-line. + + + + + {/* Save to server; shown whenever local-only files are selected. When storage is off it stays visible but disabled, tooltip pointing at the admin. */} - {localOnlySelectedStubs.length > 0 && ( - - + + )} + {/* Show details button on compact viewports. */} + {selectedFiles.length === 1 && + isCompactDetailsViewport && ( + + + )} - > - {t("filesPage.saveToServer", "Save to server")} - - - )} - {/* Show details button on compact viewports. */} - {selectedFiles.length === 1 && - isCompactDetailsViewport && ( - + + + + + + clearSelection()} + aria-label={t( + "filesPage.clearSelection", + "Clear selection", + )} + > + × + + + + ); + })()} + {selectedFiles.length > 0 && ( +
diff --git a/frontend/editor/src/core/components/filesPage/FilesPage.css b/frontend/editor/src/core/components/filesPage/FilesPage.css index aa9d8248aa..6d4070edb6 100644 --- a/frontend/editor/src/core/components/filesPage/FilesPage.css +++ b/frontend/editor/src/core/components/filesPage/FilesPage.css @@ -215,6 +215,17 @@ .files-page-toolbar-actions .mantine-Button-label { display: none; } +/* Exception: the bulk-actions trigger IS its label. Collapsed to an icon it + would read as a bare chevron with nothing to say what it opens. */ +.files-page-toolbar-actions + .files-page-toolbar-bulk-trigger + .mantine-Button-label { + display: inline; +} +.files-page-toolbar-actions .files-page-toolbar-bulk-trigger { + padding-left: 0.75rem; + padding-right: 0.5rem; +} /* Pin the view toggle: never let it clip off the right. flex-shrink:0 keeps its width fixed; the rest of the row shrinks around it. */ .files-page-toolbar-actions .mantine-SegmentedControl-root { @@ -476,6 +487,23 @@ gap: 0.4rem; } +/* Narrow cards: let the values wrap as whole units onto their own lines rather + than breaking mid-value ("239.26 / KB") around a stranded separator. */ +@media (max-width: 64rem) { + .files-page-card-meta { + flex-wrap: wrap; + align-items: baseline; + column-gap: 0.4rem; + row-gap: 0.05rem; + } + .files-page-card-meta > span { + white-space: nowrap; + } + .files-page-card-meta-sep { + display: none; + } +} + /* Parent-folder breadcrumb shown on cards/rows during recursive search so the user can tell which folder each hit lives in without navigating. */ .files-page-card-path { @@ -1295,30 +1323,38 @@ sits next to the Upload button without breaking the action row. */ display: none; } -@media (max-width: 900px) { +@media (max-width: 1024px) { .files-page-toolbar { /* nowrap so "7 items" + "Select all" sit on the same row as the filter dropdowns and view-toggle instead of stacking on three - separate lines. Per-child min-width:0 lets them shrink as needed. - Used to only kick in at ≤640px which left a broken zone where - both side panels were hidden but the toolbar was still wrapping - to multiple rows. */ + separate lines. Runs to the app's mobile breakpoint: capping it at + 900px left 901-1024px wrapping to two rows, which is the band the + mobile layout actually renders in. + + Scrolls rather than clips. With a selection active the bulk-action + strip cannot fit any phone width, and `overflow-x: hidden` put those + buttons permanently out of reach behind the edge. */ flex-wrap: nowrap; gap: 0.35rem; padding: 0.35rem 0.5rem; min-height: auto; - overflow-x: hidden; + overflow-x: auto; + scrollbar-width: none; + } + .files-page-toolbar::-webkit-scrollbar { + display: none; } .files-page-toolbar-info { - /* Was `flex-basis: 100%` which forced a row break. Let it share - the row, shrink hard if needed, and ellipsize so the count line - collapses gracefully (was overlapping the bulk-action buttons - at ~400px because no truncation rule existed). */ - flex: 0 1 auto; + /* The toolbar's only status text. Pinned, because against nowrap + siblings it lost every shrink round and rendered as "3 i". */ + flex-shrink: 0; min-width: 0; white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + } + /* Filter and sort collapse to icon triggers here (see FilesToolbar*Menu); + they are the whole control, so they never shrink. */ + .files-page-toolbar-icon-btn { + flex-shrink: 0; } .files-page-toolbar-actions { flex-wrap: nowrap; @@ -1350,41 +1386,48 @@ navigation, so the in-header Home/Apps/Close trio is duplicated and the first to go. Same for "Upload" - the user can use the centre drop overlay. */ -@media (max-width: 640px) { - /* Drop the 3-column grid on phones; flex-wrap lets the search slip onto - * its own row when chrome is too cramped to share. */ +/* ── Mobile + tablet chrome (≤1024px = useIsMobile) ────────────────── + The desktop header is a 3-column grid whose middle track can grow to + 40rem. Below ~1024px that track eats the row: the breadcrumb column + collapsed to ~36px (wrapping "All files" to two lines) and the action + column overflowed, pushing Upload off the right edge. One flex row + instead - breadcrumb and actions keep their intrinsic width and the + 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: wrap; + flex-wrap: nowrap; + align-items: center; gap: 0.4rem; - padding: 0 0.4rem; + padding: 0.25rem 0.4rem; overflow-x: hidden; } - .files-page-header [data-mobile-hide="true"] { - display: none !important; + .files-page-header-search { + flex: 1 1 auto; + min-width: 0; + justify-content: flex-start; } - .files-page-header [data-desktop-hide="true"] { - display: inline-flex !important; - } - /* Mobile-hide for sub-toolbar create buttons. */ - .files-page-toolbar [data-mobile-hide="true"] { - display: none !important; + /* 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.3rem; + gap: 0.25rem; flex-wrap: nowrap; } .files-page-breadcrumbs { + flex: 0 1 auto; font-size: 0.85rem; flex-wrap: nowrap; overflow-x: auto; min-width: 0; } - /* Upload becomes an icon-only square button on mobile so the action - row stops getting clipped. Scoped to `.files-page-header-actions` - so the Back button at the header level keeps its visible "Back" - label (Back has no other on-screen indicator that it's about leaving). */ + /* 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; @@ -1395,6 +1438,9 @@ .files-page-header-actions .mantine-Button-label { display: none; } +} + +@media (max-width: 640px) { /* Grid: single column on very narrow phones; two columns from ~440px */ .files-page-grid { grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr)); diff --git a/frontend/editor/src/core/components/filesPage/FilesToolbarBulkMenu.tsx b/frontend/editor/src/core/components/filesPage/FilesToolbarBulkMenu.tsx new file mode 100644 index 0000000000..3e344ac7b0 --- /dev/null +++ b/frontend/editor/src/core/components/filesPage/FilesToolbarBulkMenu.tsx @@ -0,0 +1,104 @@ +import { Menu } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import CloudUploadIcon from "@mui/icons-material/CloudUpload"; +import DeleteIcon from "@mui/icons-material/Delete"; +import DriveFileMoveIcon from "@mui/icons-material/DriveFileMove"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; +import OpenInNewIcon from "@mui/icons-material/OpenInNew"; + +import { Button } from "@app/ui/Button"; + +interface FilesToolbarBulkMenuProps { + selectedCount: number; + onAddToWorkspace: () => void; + /** Local-only files in the selection; omit when there are none to upload. */ + onSaveToServer?: () => void; + /** Set when storage is off - the item stays listed but disabled. */ + saveToServerDisabledReason?: string; + onShowDetails?: () => void; + onMove: () => void; + onRemove: () => void; +} + +/** + * Bulk actions behind one trigger. The full strip is five buttons wide, which + * no phone can hold alongside the count and the clear control, so rather than + * letting the row scroll them off the edge they collapse into a menu where + * every action keeps its label. + */ +export function FilesToolbarBulkMenu({ + selectedCount, + onAddToWorkspace, + onSaveToServer, + saveToServerDisabledReason, + onShowDetails, + onMove, + onRemove, +}: FilesToolbarBulkMenuProps) { + const { t } = useTranslation(); + + const addLabel = + selectedCount === 1 + ? t("filesPage.addToWorkspace", "Add to workspace") + : t("filesPage.addToWorkspaceCount", "Add {{count}} to workspace", { + count: selectedCount, + }); + + return ( + + + + + + } + onClick={onAddToWorkspace} + > + {addLabel} + + {onSaveToServer && ( + } + disabled={Boolean(saveToServerDisabledReason)} + onClick={onSaveToServer} + > + {t("filesPage.saveToServer", "Save to server")} + + )} + {onShowDetails && ( + } + onClick={onShowDetails} + > + {t("filesPage.showDetails", "Show details")} + + )} + } + onClick={onMove} + > + {t("filesPage.moveTo", "Move to…")} + + + } + onClick={onRemove} + > + {t("filesPage.remove", "Remove")} + + + + ); +} + +export default FilesToolbarBulkMenu; diff --git a/frontend/editor/src/core/components/filesPage/FilesToolbarCount.tsx b/frontend/editor/src/core/components/filesPage/FilesToolbarCount.tsx new file mode 100644 index 0000000000..d808638d63 --- /dev/null +++ b/frontend/editor/src/core/components/filesPage/FilesToolbarCount.tsx @@ -0,0 +1,41 @@ +import { useTranslation } from "react-i18next"; + +interface FilesToolbarCountProps { + loading: boolean; + totalCount: number; + selectedCount: number; + /** + * Selection-bar mode: report only the selection. A phone spends the room on + * the actions rather than on "3 items · 3 selected". + */ + selectionOnly: boolean; +} + +/** Status text at the head of the files toolbar. */ +export function FilesToolbarCount({ + loading, + totalCount, + selectedCount, + selectionOnly, +}: FilesToolbarCountProps) { + const { t } = useTranslation(); + + const selected = t("filesPage.selectedCount", "{{count}} selected", { + count: selectedCount, + }); + + if (selectionOnly) { + return {selected}; + } + + return ( + + {loading + ? t("filesPage.loading", "Loading…") + : t("filesPage.summary", "{{count}} items", { count: totalCount })} + {selectedCount > 0 && · {selected}} + + ); +} + +export default FilesToolbarCount; diff --git a/frontend/editor/src/core/components/filesPage/FilesToolbarFilterMenu.tsx b/frontend/editor/src/core/components/filesPage/FilesToolbarFilterMenu.tsx new file mode 100644 index 0000000000..81d84daf67 --- /dev/null +++ b/frontend/editor/src/core/components/filesPage/FilesToolbarFilterMenu.tsx @@ -0,0 +1,149 @@ +import { MultiSelect, Popover, Select, Stack, TextInput } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import CloseIcon from "@mui/icons-material/Close"; +import SearchIcon from "@mui/icons-material/Search"; +import TuneIcon from "@mui/icons-material/Tune"; + +import { ActionIcon } from "@app/ui/ActionIcon"; +import { Button } from "@app/ui/Button"; +import { Tooltip } from "@app/components/shared/Tooltip"; +import type { FilesPageOriginFilter } from "@app/contexts/FilesPageContext"; + +interface FilesToolbarFilterMenuProps { + originFilter: FilesPageOriginFilter; + onOriginChange: (value: FilesPageOriginFilter) => void; + availableTypes: string[]; + typeFilter: string[]; + onTypeChange: (value: string[]) => void; + search: string; + onSearchChange: (value: string) => void; +} + +/** + * Source, type and name filters collapsed behind one icon. Side by side these + * three need ~480px, so on narrow viewports they were each truncated to + * unreadable stubs ("All sour"). In the popover they get their full width back, + * and a dot on the trigger keeps an active filter discoverable while hidden. + */ +export function FilesToolbarFilterMenu({ + originFilter, + onOriginChange, + availableTypes, + typeFilter, + onTypeChange, + search, + onSearchChange, +}: FilesToolbarFilterMenuProps) { + const { t } = useTranslation(); + + const activeCount = + (originFilter !== "all" ? 1 : 0) + + (typeFilter.length > 0 ? 1 : 0) + + (search.trim() !== "" ? 1 : 0); + const label = t("filesPage.filters.label", "Filters"); + + const clearAll = () => { + onOriginChange("all"); + onTypeChange([]); + onSearchChange(""); + }; + + return ( + + +
+ 0 + ? t( + "filesPage.filters.activeCount", + "{{count}} filters active", + { + count: activeCount, + }, + ) + : label + } + position="bottom" + > + 0 ? "primary" : "tertiary"} + size="sm" + aria-label={label} + className="files-page-toolbar-icon-btn" + > + + + +
+
+ + + onSearchChange(e.currentTarget.value)} + placeholder={t("filesPage.search.placeholder", "Filter files…")} + leftSection={} + rightSection={ + search ? ( + onSearchChange("")} + aria-label={t("filesPage.search.clear", "Clear filter")} + > + + + ) : null + } + aria-label={t("filesPage.search.label", "Filter files by name")} + /> +