Merge branch 'main' into add_docker_update_automation
@@ -28,7 +28,7 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: |
|
||||
|
||||
@@ -197,7 +197,7 @@ jobs:
|
||||
|
||||
- name: Install uv
|
||||
if: always() && matrix.flavor == 'saas'
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: |
|
||||
|
||||
@@ -357,7 +357,7 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: |
|
||||
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: |
|
||||
|
||||
@@ -195,7 +195,7 @@ jobs:
|
||||
core.exportVariable("REFERENCE_FILE", referenceFilePath);
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: |
|
||||
|
||||
@@ -55,7 +55,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: |
|
||||
|
||||
@@ -70,7 +70,7 @@ jobs:
|
||||
sudo chmod +x /usr/local/bin/docker-compose
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: |
|
||||
|
||||
@@ -106,7 +106,7 @@ jobs:
|
||||
fi
|
||||
- name: Install uv
|
||||
if: always()
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: |
|
||||
|
||||
@@ -107,7 +107,7 @@ jobs:
|
||||
}
|
||||
- name: Install uv
|
||||
if: always()
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: |
|
||||
|
||||
@@ -872,7 +872,7 @@ jobs:
|
||||
# Gate publish on valid updater sigs. Runs after the review upload (so
|
||||
# artifacts survive for debugging) and before action-gh-release.
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: |
|
||||
|
||||
@@ -154,7 +154,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: |
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: |
|
||||
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: |
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
{
|
||||
"ignoredFiles": [
|
||||
"frontend/editor/src-tauri/icons/icon.png"
|
||||
"frontend/editor/src-tauri/icons/macos/*",
|
||||
"frontend/editor/src-tauri/icons/linux/*",
|
||||
"frontend/editor/src-tauri/icons/windows/*"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -4,7 +4,14 @@ import java.util.regex.Pattern;
|
||||
|
||||
public class RequestUriUtils {
|
||||
|
||||
private static final Pattern SHARE_LINK_PATTERN = Pattern.compile("^/share/[^/]+/?$");
|
||||
// Share tokens are 36-char lowercase UUIDs (UUID.randomUUID().toString()); match exactly
|
||||
private static final Pattern SHARE_LINK_PATTERN =
|
||||
Pattern.compile(
|
||||
"^/share/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/?$");
|
||||
// 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);
|
||||
@@ -69,7 +76,7 @@ public class RequestUriUtils {
|
||||
// cookie, so the server can't authenticate the navigation itself). The
|
||||
// portal gates access via its own auth gate + RequirePortalAccess, and its
|
||||
// data APIs stay protected, so serving the shell pre-auth is safe.
|
||||
if (normalizedUri.equals("/processor") || normalizedUri.startsWith("/processor/")) {
|
||||
if ("/processor".equals(normalizedUri) || normalizedUri.startsWith("/processor/")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -209,7 +216,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) {
|
||||
|
||||
@@ -206,12 +206,24 @@ class RequestUriUtilsTest {
|
||||
|
||||
@Test
|
||||
void testIsPublicAuthEndpoint_shareLinkTokenTrailingSlash() {
|
||||
assertTrue(RequestUriUtils.isPublicAuthEndpoint("/share/abc123/", ""));
|
||||
assertTrue(
|
||||
RequestUriUtils.isPublicAuthEndpoint(
|
||||
"/share/00dcac3a-fc7a-4989-9c4f-97745484d62f/", ""));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsPublicAuthEndpoint_shareLinkWithContextPath() {
|
||||
assertTrue(RequestUriUtils.isPublicAuthEndpoint("/app/share/abc123", "/app"));
|
||||
assertTrue(
|
||||
RequestUriUtils.isPublicAuthEndpoint(
|
||||
"/app/share/00dcac3a-fc7a-4989-9c4f-97745484d62f", "/app"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsPublicAuthEndpoint_shareLinkWithInvalidTokenLength() {
|
||||
assertFalse(RequestUriUtils.isPublicAuthEndpoint("/share/abc123", ""));
|
||||
assertFalse(
|
||||
RequestUriUtils.isPublicAuthEndpoint(
|
||||
"/share/00dcac3a-fc7a-4989-9c4f-97745484d62fa", ""));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -236,4 +248,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/<token>/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", ""));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,9 +71,9 @@ for (const [path, raw] of Object.entries(localeModules)) {
|
||||
}
|
||||
|
||||
if (!i18next.isInitialized) {
|
||||
// initImmediate: false → initialise synchronously from the inline resources
|
||||
// (there's no async backend here), so i18next is ready before the first story
|
||||
// renders. Without it the first render can beat init and stick on raw keys.
|
||||
// initAsync: false → initialise synchronously from the inline resources.
|
||||
// There is no asynchronous backend here, so i18next is ready before stories
|
||||
// render.
|
||||
void i18next.use(initReactI18next).init({
|
||||
lng: "en-US",
|
||||
fallbackLng: "en-US",
|
||||
@@ -81,7 +81,7 @@ if (!i18next.isInitialized) {
|
||||
resources,
|
||||
interpolation: { escapeValue: false },
|
||||
react: { useSuspense: false },
|
||||
initImmediate: false,
|
||||
initAsync: false,
|
||||
});
|
||||
} else {
|
||||
// Something initialised i18next first (e.g. the app's async TOML backend):
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
<meta charset="UTF-8" />
|
||||
<base href="%BASE_URL%" />
|
||||
<link rel="icon" href="modern-logo/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
|
||||
/>
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
|
||||
@@ -3969,6 +3969,7 @@ back = "Back"
|
||||
backToFolder = "Back to {{folder}}"
|
||||
backToMyFiles = "Back to My Files"
|
||||
breadcrumbs = "Folder path"
|
||||
bulkActions = "Actions"
|
||||
cancel = "Cancel"
|
||||
classification = "Classification"
|
||||
clearSelection = "Clear selection"
|
||||
@@ -4142,6 +4143,11 @@ totalSize = "Total size"
|
||||
type = "Type"
|
||||
versionHistory = "Version journey"
|
||||
|
||||
[filesPage.filters]
|
||||
activeCount = "{{count}} filters active"
|
||||
clearAll = "Clear filters"
|
||||
label = "Filters"
|
||||
|
||||
[filesPage.folderName]
|
||||
cancel = "Cancel"
|
||||
error = "Could not save folder. Try again."
|
||||
@@ -4176,6 +4182,7 @@ label = "Filter files by name"
|
||||
placeholder = "Filter files…"
|
||||
|
||||
[filesPage.sort]
|
||||
label = "Sort files"
|
||||
modifiedAsc = "Oldest first"
|
||||
modifiedDesc = "Recent first"
|
||||
nameAsc = "Name A→Z"
|
||||
@@ -9696,7 +9703,9 @@ toolNotAvailableLocally = "Your Stirling-PDF server is offline and \"{{endpoint}
|
||||
expired = "Your session has expired. Please refresh the page and try again."
|
||||
|
||||
[settings]
|
||||
backToSections = "All settings"
|
||||
close = "Close"
|
||||
title = "Settings"
|
||||
|
||||
[settings.ai]
|
||||
documents = "Documents & RAG"
|
||||
@@ -10635,6 +10644,7 @@ ariaLabel = "Super search"
|
||||
filtersAriaLabel = "Search filters"
|
||||
hint = "Type to search"
|
||||
placeholder = "Search Stirling"
|
||||
placeholderShort = "Search"
|
||||
showLess = "Show less"
|
||||
showMore = "Show {{count}} more"
|
||||
|
||||
@@ -11335,8 +11345,11 @@ columnDefault = "Column {{index}}"
|
||||
convertToPdf = "Convert to PDF"
|
||||
csvStats = "{{rows}} rows · {{columns}} columns · {{size}}"
|
||||
emptyFile = "Empty file"
|
||||
htmlHidePreview = "Hide preview"
|
||||
htmlPreview = "HTML preview"
|
||||
htmlPreviewMobileHidden = "HTML pages are laid out for desktop widths, so the preview is off by default here."
|
||||
htmlPreviewWarning = "HTML preview - external resources may not load · {{size}}"
|
||||
htmlShowPreview = "Show preview anyway"
|
||||
invalidJson = "Invalid JSON - showing raw content"
|
||||
lineNumbers = "Line numbers"
|
||||
loading = "Loading..."
|
||||
@@ -11683,6 +11696,7 @@ exportAll = "Export PDF"
|
||||
exportSelected = "Export Selected Pages"
|
||||
formFill = "Fill Form"
|
||||
hideToolbar = "Hide toolbar"
|
||||
moreActions = "More actions"
|
||||
multiTool = "Multi-Tool"
|
||||
panMode = "Pan Mode"
|
||||
print = "Print PDF"
|
||||
@@ -11703,6 +11717,8 @@ selectAll = "Select All"
|
||||
selectByNumber = "Select by Page Numbers"
|
||||
selectLanguage = "Select language"
|
||||
share = "Share"
|
||||
showAllTools = "Show all tools"
|
||||
showFewerTools = "Collapse toolbar"
|
||||
showToolbar = "Show toolbar"
|
||||
toggleAnnotations = "Toggle Annotations Visibility"
|
||||
toggleAttachments = "Toggle Attachments"
|
||||
|
||||
|
Before Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 681 B |
|
Before Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 758 B |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
@@ -29,14 +29,9 @@
|
||||
"publisher": "Stirling PDF Inc.",
|
||||
"targets": ["deb", "rpm", "appimage", "dmg", "app", "msi"],
|
||||
"icon": [
|
||||
"icons/icon.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico",
|
||||
"icons/16x16.png",
|
||||
"icons/32x32.png",
|
||||
"icons/64x64.png",
|
||||
"icons/128x128.png",
|
||||
"icons/192x192.png"
|
||||
"icons/windows/app.ico",
|
||||
"icons/macos/app.icns",
|
||||
"icons/macos/app-512.png"
|
||||
],
|
||||
"resources": ["libs/*.jar", "runtime/jre/**/*"],
|
||||
"fileAssociations": [
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"bundle": {
|
||||
"icon": [
|
||||
"icons/linux/app-512.png",
|
||||
"icons/linux/app-192.png",
|
||||
"icons/linux/app-128.png",
|
||||
"icons/linux/app-64.png",
|
||||
"icons/linux/app-32.png",
|
||||
"icons/linux/app-16.png"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -22,7 +22,7 @@ export function AppLayout({ children }: AppLayoutProps) {
|
||||
}
|
||||
`}</style>
|
||||
<div
|
||||
style={{ height: "100vh", display: "flex", flexDirection: "column" }}
|
||||
style={{ height: "100dvh", display: "flex", flexDirection: "column" }}
|
||||
>
|
||||
{banner}
|
||||
<div style={{ flex: 1, minHeight: 0, height: 0 }}>{children}</div>
|
||||
|
||||
@@ -978,7 +978,9 @@ function FileCard({
|
||||
)}
|
||||
<div className="files-page-card-meta">
|
||||
<span>{fileSize}</span>
|
||||
<span>·</span>
|
||||
<span className="files-page-card-meta-sep" aria-hidden="true">
|
||||
·
|
||||
</span>
|
||||
<span>{fileDate}</span>
|
||||
<PolicyBadges fileId={file.id as string} />
|
||||
</div>
|
||||
|
||||
@@ -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() {
|
||||
})()}
|
||||
|
||||
<div className="files-page-toolbar">
|
||||
<span className="files-page-toolbar-info">
|
||||
{loading
|
||||
? t("filesPage.loading", "Loading…")
|
||||
: t("filesPage.summary", "{{count}} items", {
|
||||
count: totalCount,
|
||||
})}
|
||||
{selectedFiles.length > 0 && (
|
||||
<span>
|
||||
{" "}
|
||||
·{" "}
|
||||
{t("filesPage.selectedCount", "{{count}} selected", {
|
||||
count: selectedFiles.length,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<FilesToolbarCount
|
||||
loading={loading}
|
||||
totalCount={totalCount}
|
||||
selectedCount={selectedFiles.length}
|
||||
selectionOnly={mobileSelection}
|
||||
/>
|
||||
{(() => {
|
||||
// Select all / Clear toggle over visible files.
|
||||
if (visibleFiles.length === 0) return null;
|
||||
@@ -1265,289 +1262,382 @@ export default function FileManagerView() {
|
||||
);
|
||||
})()}
|
||||
<div className="files-page-toolbar-actions">
|
||||
{selectedFiles.length > 0 &&
|
||||
(() => {
|
||||
// Bulk-action labels; CSS collapses to icon-only below 900px.
|
||||
const addLabel =
|
||||
{mobileSelection ? (
|
||||
<FilesToolbarBulkMenu
|
||||
selectedCount={selectedFiles.length}
|
||||
onAddToWorkspace={() => 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.
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Tooltip label={addLabel} withinPortal>
|
||||
<Button
|
||||
size="sm"
|
||||
leftSection={<OpenInNewIcon fontSize="small" />}
|
||||
onClick={() => handleAddToWorkspace(selectedFiles)}
|
||||
aria-label={addLabel}
|
||||
data-testid="add-to-workspace"
|
||||
>
|
||||
{addLabel}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
{/* 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.
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Tooltip label={addLabel} withinPortal>
|
||||
<Button
|
||||
size="sm"
|
||||
leftSection={<OpenInNewIcon fontSize="small" />}
|
||||
onClick={() =>
|
||||
handleAddToWorkspace(selectedFiles)
|
||||
}
|
||||
aria-label={addLabel}
|
||||
data-testid="add-to-workspace"
|
||||
>
|
||||
{addLabel}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
{/* 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 && (
|
||||
<Tooltip
|
||||
label={
|
||||
saveToServerDisabledReason ??
|
||||
t("filesPage.saveToServer", "Save to server")
|
||||
}
|
||||
withinPortal
|
||||
multiline={Boolean(saveToServerDisabledReason)}
|
||||
w={saveToServerDisabledReason ? 240 : undefined}
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
leftSection={<CloudUploadIcon fontSize="small" />}
|
||||
disabled={Boolean(saveToServerDisabledReason)}
|
||||
onClick={() =>
|
||||
setSaveToServerTarget(localOnlySelectedStubs)
|
||||
}
|
||||
style={{
|
||||
// Keep the tooltip hoverable while disabled.
|
||||
pointerEvents: saveToServerDisabledReason
|
||||
? "auto"
|
||||
: undefined,
|
||||
}}
|
||||
aria-label={t(
|
||||
"filesPage.saveToServer",
|
||||
"Save to server",
|
||||
{localOnlySelectedStubs.length > 0 && (
|
||||
<Tooltip
|
||||
label={
|
||||
saveToServerDisabledReason ??
|
||||
t("filesPage.saveToServer", "Save to server")
|
||||
}
|
||||
withinPortal
|
||||
multiline={Boolean(saveToServerDisabledReason)}
|
||||
w={saveToServerDisabledReason ? 240 : undefined}
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
leftSection={
|
||||
<CloudUploadIcon fontSize="small" />
|
||||
}
|
||||
disabled={Boolean(saveToServerDisabledReason)}
|
||||
onClick={() =>
|
||||
setSaveToServerTarget(localOnlySelectedStubs)
|
||||
}
|
||||
style={{
|
||||
// Keep the tooltip hoverable while disabled.
|
||||
pointerEvents: saveToServerDisabledReason
|
||||
? "auto"
|
||||
: undefined,
|
||||
}}
|
||||
aria-label={t(
|
||||
"filesPage.saveToServer",
|
||||
"Save to server",
|
||||
)}
|
||||
>
|
||||
{t("filesPage.saveToServer", "Save to server")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* Show details button on compact viewports. */}
|
||||
{selectedFiles.length === 1 &&
|
||||
isCompactDetailsViewport && (
|
||||
<Tooltip
|
||||
label={t(
|
||||
"filesPage.showDetails",
|
||||
"Show details",
|
||||
)}
|
||||
withinPortal
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
leftSection={
|
||||
<InfoOutlinedIcon fontSize="small" />
|
||||
}
|
||||
onClick={() => setMobileDetailsOpen(true)}
|
||||
aria-label={t(
|
||||
"filesPage.showDetails",
|
||||
"Show details",
|
||||
)}
|
||||
>
|
||||
{t("filesPage.showDetails", "Show details")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
>
|
||||
{t("filesPage.saveToServer", "Save to server")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* Show details button on compact viewports. */}
|
||||
{selectedFiles.length === 1 &&
|
||||
isCompactDetailsViewport && (
|
||||
<Tooltip
|
||||
label={t("filesPage.showDetails", "Show details")}
|
||||
withinPortal
|
||||
>
|
||||
<Tooltip label={moveLabel} withinPortal>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
leftSection={
|
||||
<InfoOutlinedIcon fontSize="small" />
|
||||
<DriveFileMoveIcon fontSize="small" />
|
||||
}
|
||||
onClick={() => setMobileDetailsOpen(true)}
|
||||
aria-label={t(
|
||||
"filesPage.showDetails",
|
||||
"Show details",
|
||||
)}
|
||||
onClick={() => promptMoveFiles(selectedFiles)}
|
||||
aria-label={moveLabel}
|
||||
>
|
||||
{t("filesPage.showDetails", "Show details")}
|
||||
{moveLabel}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip label={removeLabel} withinPortal>
|
||||
<Button
|
||||
size="sm"
|
||||
accent="danger"
|
||||
variant="secondary"
|
||||
leftSection={<DeleteIcon fontSize="small" />}
|
||||
onClick={() => handleRemoveFiles(selectedFiles)}
|
||||
aria-label={removeLabel}
|
||||
>
|
||||
{removeLabel}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
label={t(
|
||||
"filesPage.clearSelection",
|
||||
"Clear selection",
|
||||
)}
|
||||
withinPortal
|
||||
>
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
size="md"
|
||||
onClick={() => clearSelection()}
|
||||
aria-label={t(
|
||||
"filesPage.clearSelection",
|
||||
"Clear selection",
|
||||
)}
|
||||
>
|
||||
×
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
);
|
||||
})()}
|
||||
{selectedFiles.length > 0 && (
|
||||
<span
|
||||
className="files-page-toolbar-divider"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
{isMobile ? (
|
||||
/* Side by side these need ~480px and were truncating to
|
||||
stubs like "All sour"; collapsed they read in full. */
|
||||
<>
|
||||
<FilesToolbarFilterMenu
|
||||
originFilter={originFilter}
|
||||
onOriginChange={setOriginFilter}
|
||||
availableTypes={availableTypes}
|
||||
typeFilter={typeFilter}
|
||||
onTypeChange={setTypeFilter}
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
/>
|
||||
<FilesToolbarSortMenu
|
||||
value={sortMode}
|
||||
onChange={setSortMode}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Select
|
||||
size="xs"
|
||||
value={originFilter}
|
||||
onChange={(value) =>
|
||||
value &&
|
||||
setOriginFilter(value as FilesPageOriginFilter)
|
||||
}
|
||||
data={[
|
||||
{
|
||||
value: "all",
|
||||
label: t("filesPage.origin.all", "All sources"),
|
||||
},
|
||||
{
|
||||
value: "local",
|
||||
label: t("filesPage.origin.local", "Local"),
|
||||
},
|
||||
{
|
||||
value: "cloud",
|
||||
label: t("filesPage.origin.cloud", "Cloud"),
|
||||
},
|
||||
{
|
||||
value: "shared-with-me",
|
||||
label: t("filesPage.origin.shared", "Shared"),
|
||||
},
|
||||
]}
|
||||
style={{ width: 140 }}
|
||||
aria-label={t(
|
||||
"filesPage.originFilter",
|
||||
"Filter by source",
|
||||
)}
|
||||
<Tooltip label={moveLabel} withinPortal>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
leftSection={<DriveFileMoveIcon fontSize="small" />}
|
||||
onClick={() => promptMoveFiles(selectedFiles)}
|
||||
aria-label={moveLabel}
|
||||
>
|
||||
{moveLabel}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip label={removeLabel} withinPortal>
|
||||
<Button
|
||||
size="sm"
|
||||
accent="danger"
|
||||
variant="secondary"
|
||||
leftSection={<DeleteIcon fontSize="small" />}
|
||||
onClick={() => handleRemoveFiles(selectedFiles)}
|
||||
aria-label={removeLabel}
|
||||
>
|
||||
{removeLabel}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
label={t("filesPage.clearSelection", "Clear selection")}
|
||||
withinPortal
|
||||
>
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
size="md"
|
||||
onClick={() => clearSelection()}
|
||||
/>
|
||||
{availableTypes.length > 1 && (
|
||||
<MultiSelect
|
||||
size="xs"
|
||||
value={typeFilter}
|
||||
onChange={setTypeFilter}
|
||||
data={availableTypes.map((ext) => ({
|
||||
value: ext,
|
||||
label: ext,
|
||||
}))}
|
||||
placeholder={
|
||||
typeFilter.length === 0
|
||||
? t("filesPage.typeFilter.allTypes", "All types")
|
||||
: undefined
|
||||
}
|
||||
clearable
|
||||
hidePickedOptions
|
||||
searchable={false}
|
||||
style={{ width: 160 }}
|
||||
aria-label={t(
|
||||
"filesPage.clearSelection",
|
||||
"Clear selection",
|
||||
"filesPage.typeFilter.label",
|
||||
"Filter by type",
|
||||
)}
|
||||
>
|
||||
×
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
);
|
||||
})()}
|
||||
{selectedFiles.length > 0 && (
|
||||
<span
|
||||
className="files-page-toolbar-divider"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
/>
|
||||
)}
|
||||
<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}
|
||||
onChange={(value) =>
|
||||
value && setSortMode(value as FilesPageSortMode)
|
||||
}
|
||||
data={[
|
||||
{
|
||||
value: "modified-desc",
|
||||
label: t(
|
||||
"filesPage.sort.modifiedDesc",
|
||||
"Recent first",
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "modified-asc",
|
||||
label: t(
|
||||
"filesPage.sort.modifiedAsc",
|
||||
"Oldest first",
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "name-asc",
|
||||
label: t("filesPage.sort.nameAsc", "Name A→Z"),
|
||||
},
|
||||
{
|
||||
value: "name-desc",
|
||||
label: t("filesPage.sort.nameDesc", "Name Z→A"),
|
||||
},
|
||||
{
|
||||
value: "size-desc",
|
||||
label: t(
|
||||
"filesPage.sort.sizeDesc",
|
||||
"Largest first",
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "size-asc",
|
||||
label: t(
|
||||
"filesPage.sort.sizeAsc",
|
||||
"Smallest first",
|
||||
),
|
||||
},
|
||||
]}
|
||||
style={{ width: 160 }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<span
|
||||
className="files-page-toolbar-divider"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<SegmentedControl
|
||||
size="sm"
|
||||
value={viewMode}
|
||||
onChange={(v) => {
|
||||
// Mantine only emits values declared in `data[].value`, but
|
||||
// narrow defensively so a future third option can't silently
|
||||
// bypass the FilesPageViewMode contract. Derived from the
|
||||
// `as const` tuple so adding a mode anywhere in the code
|
||||
// base automatically widens the guard here.
|
||||
if (
|
||||
!(FILES_PAGE_VIEW_MODES as readonly string[]).includes(
|
||||
v,
|
||||
)
|
||||
)
|
||||
return;
|
||||
setViewMode(v as (typeof FILES_PAGE_VIEW_MODES)[number]);
|
||||
}}
|
||||
aria-label={t("filesPage.viewMode.label", "View mode")}
|
||||
options={[
|
||||
{
|
||||
value: "grid",
|
||||
label: (
|
||||
<span
|
||||
className="files-page-view-toggle-icon"
|
||||
title={t("filesPage.viewMode.grid", "Grid view")}
|
||||
>
|
||||
<GridViewIcon fontSize="small" />
|
||||
<span className="files-page-sr-only">
|
||||
{t("filesPage.viewMode.grid", "Grid view")}
|
||||
</span>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "list",
|
||||
label: (
|
||||
<span
|
||||
className="files-page-view-toggle-icon"
|
||||
title={t("filesPage.viewMode.list", "List view")}
|
||||
>
|
||||
<ViewListIcon fontSize="small" />
|
||||
<span className="files-page-sr-only">
|
||||
{t("filesPage.viewMode.list", "List view")}
|
||||
</span>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Select
|
||||
size="xs"
|
||||
value={originFilter}
|
||||
onChange={(value) =>
|
||||
value && setOriginFilter(value as FilesPageOriginFilter)
|
||||
}
|
||||
data={[
|
||||
{
|
||||
value: "all",
|
||||
label: t("filesPage.origin.all", "All sources"),
|
||||
},
|
||||
{
|
||||
value: "local",
|
||||
label: t("filesPage.origin.local", "Local"),
|
||||
},
|
||||
{
|
||||
value: "cloud",
|
||||
label: t("filesPage.origin.cloud", "Cloud"),
|
||||
},
|
||||
{
|
||||
value: "shared-with-me",
|
||||
label: t("filesPage.origin.shared", "Shared"),
|
||||
},
|
||||
]}
|
||||
style={{ width: 140 }}
|
||||
aria-label={t("filesPage.originFilter", "Filter by source")}
|
||||
/>
|
||||
{availableTypes.length > 1 && (
|
||||
<MultiSelect
|
||||
size="xs"
|
||||
value={typeFilter}
|
||||
onChange={setTypeFilter}
|
||||
data={availableTypes.map((ext) => ({
|
||||
value: ext,
|
||||
label: ext,
|
||||
}))}
|
||||
placeholder={
|
||||
typeFilter.length === 0
|
||||
? t("filesPage.typeFilter.allTypes", "All types")
|
||||
: undefined
|
||||
}
|
||||
clearable
|
||||
hidePickedOptions
|
||||
searchable={false}
|
||||
style={{ width: 160 }}
|
||||
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}
|
||||
onChange={(value) =>
|
||||
value && setSortMode(value as FilesPageSortMode)
|
||||
}
|
||||
data={[
|
||||
{
|
||||
value: "modified-desc",
|
||||
label: t("filesPage.sort.modifiedDesc", "Recent first"),
|
||||
},
|
||||
{
|
||||
value: "modified-asc",
|
||||
label: t("filesPage.sort.modifiedAsc", "Oldest first"),
|
||||
},
|
||||
{
|
||||
value: "name-asc",
|
||||
label: t("filesPage.sort.nameAsc", "Name A→Z"),
|
||||
},
|
||||
{
|
||||
value: "name-desc",
|
||||
label: t("filesPage.sort.nameDesc", "Name Z→A"),
|
||||
},
|
||||
{
|
||||
value: "size-desc",
|
||||
label: t("filesPage.sort.sizeDesc", "Largest first"),
|
||||
},
|
||||
{
|
||||
value: "size-asc",
|
||||
label: t("filesPage.sort.sizeAsc", "Smallest first"),
|
||||
},
|
||||
]}
|
||||
style={{ width: 160 }}
|
||||
/>
|
||||
<span className="files-page-toolbar-divider" aria-hidden="true" />
|
||||
<SegmentedControl
|
||||
size="sm"
|
||||
value={viewMode}
|
||||
onChange={(v) => {
|
||||
// Mantine only emits values declared in `data[].value`, but
|
||||
// narrow defensively so a future third option can't silently
|
||||
// bypass the FilesPageViewMode contract. Derived from the
|
||||
// `as const` tuple so adding a mode anywhere in the code
|
||||
// base automatically widens the guard here.
|
||||
if (!(FILES_PAGE_VIEW_MODES as readonly string[]).includes(v))
|
||||
return;
|
||||
setViewMode(v as (typeof FILES_PAGE_VIEW_MODES)[number]);
|
||||
}}
|
||||
aria-label={t("filesPage.viewMode.label", "View mode")}
|
||||
options={[
|
||||
{
|
||||
value: "grid",
|
||||
label: (
|
||||
<span
|
||||
className="files-page-view-toggle-icon"
|
||||
title={t("filesPage.viewMode.grid", "Grid view")}
|
||||
>
|
||||
<GridViewIcon fontSize="small" />
|
||||
<span className="files-page-sr-only">
|
||||
{t("filesPage.viewMode.grid", "Grid view")}
|
||||
</span>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "list",
|
||||
label: (
|
||||
<span
|
||||
className="files-page-view-toggle-icon"
|
||||
title={t("filesPage.viewMode.list", "List view")}
|
||||
>
|
||||
<ViewListIcon fontSize="small" />
|
||||
<span className="files-page-sr-only">
|
||||
{t("filesPage.viewMode.list", "List view")}
|
||||
</span>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 (
|
||||
<Menu shadow="md" width={230} position="bottom-end" withinPortal>
|
||||
<Menu.Target>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="files-page-toolbar-bulk-trigger"
|
||||
rightSection={<ExpandMoreIcon sx={{ fontSize: "1.1rem" }} />}
|
||||
aria-label={t("filesPage.bulkActions", "Actions")}
|
||||
>
|
||||
{t("filesPage.bulkActions", "Actions")}
|
||||
</Button>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<OpenInNewIcon sx={{ fontSize: "1.1rem" }} />}
|
||||
onClick={onAddToWorkspace}
|
||||
>
|
||||
{addLabel}
|
||||
</Menu.Item>
|
||||
{onSaveToServer && (
|
||||
<Menu.Item
|
||||
leftSection={<CloudUploadIcon sx={{ fontSize: "1.1rem" }} />}
|
||||
disabled={Boolean(saveToServerDisabledReason)}
|
||||
onClick={onSaveToServer}
|
||||
>
|
||||
{t("filesPage.saveToServer", "Save to server")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{onShowDetails && (
|
||||
<Menu.Item
|
||||
leftSection={<InfoOutlinedIcon sx={{ fontSize: "1.1rem" }} />}
|
||||
onClick={onShowDetails}
|
||||
>
|
||||
{t("filesPage.showDetails", "Show details")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Item
|
||||
leftSection={<DriveFileMoveIcon sx={{ fontSize: "1.1rem" }} />}
|
||||
onClick={onMove}
|
||||
>
|
||||
{t("filesPage.moveTo", "Move to…")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<DeleteIcon sx={{ fontSize: "1.1rem" }} />}
|
||||
onClick={onRemove}
|
||||
>
|
||||
{t("filesPage.remove", "Remove")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
||||
export default FilesToolbarBulkMenu;
|
||||
@@ -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 <span className="files-page-toolbar-info">{selected}</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="files-page-toolbar-info">
|
||||
{loading
|
||||
? t("filesPage.loading", "Loading…")
|
||||
: t("filesPage.summary", "{{count}} items", { count: totalCount })}
|
||||
{selectedCount > 0 && <span> · {selected}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default FilesToolbarCount;
|
||||
@@ -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 (
|
||||
<Popover width={260} position="bottom-end" shadow="md" withinPortal>
|
||||
<Popover.Target>
|
||||
<div>
|
||||
<Tooltip
|
||||
content={
|
||||
activeCount > 0
|
||||
? t(
|
||||
"filesPage.filters.activeCount",
|
||||
"{{count}} filters active",
|
||||
{
|
||||
count: activeCount,
|
||||
},
|
||||
)
|
||||
: label
|
||||
}
|
||||
position="bottom"
|
||||
>
|
||||
<ActionIcon
|
||||
variant={activeCount > 0 ? "primary" : "tertiary"}
|
||||
size="sm"
|
||||
aria-label={label}
|
||||
className="files-page-toolbar-icon-btn"
|
||||
>
|
||||
<TuneIcon sx={{ fontSize: "1.1rem" }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Stack gap="xs">
|
||||
<TextInput
|
||||
size="xs"
|
||||
value={search}
|
||||
onChange={(e) => onSearchChange(e.currentTarget.value)}
|
||||
placeholder={t("filesPage.search.placeholder", "Filter files…")}
|
||||
leftSection={<SearchIcon sx={{ fontSize: "1rem" }} />}
|
||||
rightSection={
|
||||
search ? (
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
onClick={() => onSearchChange("")}
|
||||
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")}
|
||||
/>
|
||||
<Select
|
||||
size="xs"
|
||||
value={originFilter}
|
||||
onChange={(value) =>
|
||||
value && onOriginChange(value as FilesPageOriginFilter)
|
||||
}
|
||||
data={[
|
||||
{ value: "all", label: t("filesPage.origin.all", "All sources") },
|
||||
{ value: "local", label: t("filesPage.origin.local", "Local") },
|
||||
{ value: "cloud", label: t("filesPage.origin.cloud", "Cloud") },
|
||||
{
|
||||
value: "shared-with-me",
|
||||
label: t("filesPage.origin.shared", "Shared"),
|
||||
},
|
||||
]}
|
||||
label={t("filesPage.originFilter", "Filter by source")}
|
||||
comboboxProps={{ withinPortal: false }}
|
||||
/>
|
||||
{availableTypes.length > 1 && (
|
||||
<MultiSelect
|
||||
size="xs"
|
||||
value={typeFilter}
|
||||
onChange={onTypeChange}
|
||||
data={availableTypes.map((ext) => ({ value: ext, label: ext }))}
|
||||
placeholder={
|
||||
typeFilter.length === 0
|
||||
? t("filesPage.typeFilter.allTypes", "All types")
|
||||
: undefined
|
||||
}
|
||||
clearable
|
||||
hidePickedOptions
|
||||
searchable={false}
|
||||
label={t("filesPage.typeFilter.label", "Filter by type")}
|
||||
comboboxProps={{ withinPortal: false }}
|
||||
/>
|
||||
)}
|
||||
{activeCount > 0 && (
|
||||
<Button variant="tertiary" size="sm" onClick={clearAll}>
|
||||
{t("filesPage.filters.clearAll", "Clear filters")}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export default FilesToolbarFilterMenu;
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Menu } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import CheckIcon from "@mui/icons-material/Check";
|
||||
import SwapVertIcon from "@mui/icons-material/SwapVert";
|
||||
|
||||
import { ActionIcon } from "@app/ui/ActionIcon";
|
||||
import { Tooltip } from "@app/components/shared/Tooltip";
|
||||
import type { FilesPageSortMode } from "@app/contexts/FilesPageContext";
|
||||
|
||||
interface FilesToolbarSortMenuProps {
|
||||
value: FilesPageSortMode;
|
||||
onChange: (mode: FilesPageSortMode) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort control collapsed to a single icon. The desktop Select needs 160px and
|
||||
* still truncated its longest label ("Recent first" → "Recent fi") once the
|
||||
* toolbar got tight, so on narrow viewports the options move into a menu where
|
||||
* they have room to read in full.
|
||||
*/
|
||||
export function FilesToolbarSortMenu({
|
||||
value,
|
||||
onChange,
|
||||
}: FilesToolbarSortMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const options: { value: FilesPageSortMode; label: string }[] = [
|
||||
{
|
||||
value: "modified-desc",
|
||||
label: t("filesPage.sort.modifiedDesc", "Recent first"),
|
||||
},
|
||||
{
|
||||
value: "modified-asc",
|
||||
label: t("filesPage.sort.modifiedAsc", "Oldest first"),
|
||||
},
|
||||
{ value: "name-asc", label: t("filesPage.sort.nameAsc", "Name A→Z") },
|
||||
{ value: "name-desc", label: t("filesPage.sort.nameDesc", "Name Z→A") },
|
||||
{
|
||||
value: "size-desc",
|
||||
label: t("filesPage.sort.sizeDesc", "Largest first"),
|
||||
},
|
||||
{ value: "size-asc", label: t("filesPage.sort.sizeAsc", "Smallest first") },
|
||||
];
|
||||
|
||||
const label = t("filesPage.sort.label", "Sort files");
|
||||
const current = options.find((o) => o.value === value)?.label ?? "";
|
||||
|
||||
return (
|
||||
<Menu shadow="md" width={200} position="bottom-end" withinPortal>
|
||||
<Menu.Target>
|
||||
<div>
|
||||
<Tooltip content={`${label} · ${current}`} position="bottom">
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
aria-label={`${label}: ${current}`}
|
||||
className="files-page-toolbar-icon-btn"
|
||||
>
|
||||
<SwapVertIcon sx={{ fontSize: "1.1rem" }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Label>{label}</Menu.Label>
|
||||
{options.map((option) => (
|
||||
<Menu.Item
|
||||
key={option.value}
|
||||
onClick={() => onChange(option.value)}
|
||||
leftSection={
|
||||
option.value === value ? (
|
||||
<CheckIcon sx={{ fontSize: "1rem" }} />
|
||||
) : (
|
||||
<span style={{ display: "inline-block", width: "1rem" }} />
|
||||
)
|
||||
}
|
||||
>
|
||||
{option.label}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
||||
export default FilesToolbarSortMenu;
|
||||
@@ -38,6 +38,13 @@
|
||||
background: var(--c-hover);
|
||||
}
|
||||
|
||||
@media (max-width: 64rem) {
|
||||
.workbenchBarReopenTab {
|
||||
width: 3rem;
|
||||
height: 1.375rem;
|
||||
}
|
||||
}
|
||||
|
||||
.workbenchBarWrapper {
|
||||
display: grid;
|
||||
grid-template-rows: 1fr;
|
||||
|
||||
@@ -39,41 +39,77 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Mobile: compact icon-only navigation */
|
||||
/* Mobile: two-level settings navigation */
|
||||
@media (max-width: 1024px) {
|
||||
.modal-container {
|
||||
height: 100vh !important;
|
||||
flex-direction: column;
|
||||
height: 100dvh !important;
|
||||
max-height: none !important;
|
||||
}
|
||||
|
||||
.modal-nav {
|
||||
width: 5rem; /* 80px - wider for larger icons */
|
||||
height: 100vh !important;
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: auto !important;
|
||||
max-height: none !important;
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.modal-nav-scroll {
|
||||
padding: 1rem 0.5rem;
|
||||
padding: 0.75rem 0.75rem 2rem;
|
||||
}
|
||||
|
||||
.modal-nav-section {
|
||||
margin-bottom: 1.5rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.modal-nav-section > .mantine-Text-root {
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
.modal-nav-item.mobile {
|
||||
padding: 1rem;
|
||||
justify-content: center;
|
||||
border-radius: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
padding: 0.75rem 0.625rem;
|
||||
min-height: 3rem;
|
||||
border-radius: 0.625rem;
|
||||
margin-bottom: 0.125rem;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.modal-nav-item .modal-nav-item-badge {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.modal-nav-chevron {
|
||||
flex-shrink: 0;
|
||||
color: var(--c-text-subtle);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
height: 100vh !important;
|
||||
height: auto !important;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
max-height: none !important;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1rem;
|
||||
padding-top: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 48rem) {
|
||||
.modal-body [id^="setting-"] {
|
||||
flex-direction: column;
|
||||
align-items: flex-start !important;
|
||||
gap: 0.625rem;
|
||||
}
|
||||
|
||||
.modal-body [id^="setting-"]:has(.mantine-Switch-root) {
|
||||
flex-direction: row;
|
||||
align-items: center !important;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-nav-scroll {
|
||||
@@ -241,6 +277,7 @@
|
||||
@media (max-width: 1024px) {
|
||||
.settings-sticky-footer {
|
||||
padding: 0.75rem 1rem;
|
||||
padding-bottom: calc(0.75rem + env(safe-area-inset-bottom, 0px));
|
||||
margin: 0 -1rem;
|
||||
margin-bottom: -1rem;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@ import React, {
|
||||
} from "react";
|
||||
import { Badge, Modal, Text, Tooltip, Group } from "@mantine/core";
|
||||
import { ActionIcon } from "@app/ui/ActionIcon";
|
||||
import { SettingsMobileBackButton } from "@app/components/shared/config/SettingsMobileBackButton";
|
||||
import { SettingsMobileNavHeader } from "@app/components/shared/config/SettingsMobileNavHeader";
|
||||
import { SettingsNavChevron } from "@app/components/shared/config/SettingsNavChevron";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
@@ -82,6 +85,7 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
|
||||
"general",
|
||||
);
|
||||
const isMobile = useIsMobile();
|
||||
const [mobilePane, setMobilePane] = useState<"nav" | "content">("nav");
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { config } = useAppConfig();
|
||||
@@ -122,6 +126,14 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
|
||||
}
|
||||
}, [opened]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!opened) return;
|
||||
const target = urlSync
|
||||
? getSectionFromPath(window.location.pathname)
|
||||
: initialSection;
|
||||
setMobilePane(target ? "content" : "nav");
|
||||
}, [opened, urlSync, initialSection]);
|
||||
|
||||
// Switch tab without forcing every `useLocation()` subscriber (HomePage and
|
||||
// its FileSidebar/Workbench/RightSidebar/FileManager tree) to re-render.
|
||||
//
|
||||
@@ -306,10 +318,17 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
|
||||
const canProceed = await confirmIfDirty();
|
||||
if (!canProceed) return;
|
||||
switchSection(key);
|
||||
setMobilePane("content");
|
||||
},
|
||||
[confirmIfDirty, switchSection],
|
||||
);
|
||||
|
||||
const handleMobileBack = useCallback(async () => {
|
||||
const canProceed = await confirmIfDirty();
|
||||
if (!canProceed) return;
|
||||
setMobilePane("nav");
|
||||
}, [confirmIfDirty]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
@@ -332,22 +351,28 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
|
||||
className={`modal-nav ${isMobile ? "mobile" : ""}`}
|
||||
style={{
|
||||
background: colors.navBg,
|
||||
borderRight: `1px solid ${colors.headerBorder}`,
|
||||
...(isMobile
|
||||
? { display: mobilePane === "nav" ? undefined : "none" }
|
||||
: { borderRight: `1px solid ${colors.headerBorder}` }),
|
||||
}}
|
||||
>
|
||||
<SettingsMobileNavHeader
|
||||
show={isMobile}
|
||||
onClose={handleClose}
|
||||
background={colors.navBg}
|
||||
borderColor={colors.headerBorder}
|
||||
/>
|
||||
<div className="modal-nav-scroll">
|
||||
{configNavSections.map((section) => (
|
||||
<div key={section.title} className="modal-nav-section">
|
||||
{!isMobile && (
|
||||
<Text
|
||||
size="xs"
|
||||
fw={600}
|
||||
c={colors.sectionTitle}
|
||||
style={{ textTransform: "uppercase", letterSpacing: 0.4 }}
|
||||
>
|
||||
{section.title}
|
||||
</Text>
|
||||
)}
|
||||
<Text
|
||||
size="xs"
|
||||
fw={600}
|
||||
c={colors.sectionTitle}
|
||||
style={{ textTransform: "uppercase", letterSpacing: 0.4 }}
|
||||
>
|
||||
{section.title}
|
||||
</Text>
|
||||
<div className="modal-nav-section-items">
|
||||
{section.items.map((item) => {
|
||||
const isActive = active === item.key;
|
||||
@@ -355,7 +380,7 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
|
||||
const color = isActive
|
||||
? colors.navItemActive
|
||||
: colors.navItem;
|
||||
const iconSize = isMobile ? 28 : 18;
|
||||
const iconSize = 18;
|
||||
const showPlanWarning =
|
||||
item.key === "adminPlan" &&
|
||||
licenseAlert.active &&
|
||||
@@ -383,47 +408,46 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
|
||||
icon={item.icon}
|
||||
width={iconSize}
|
||||
height={iconSize}
|
||||
style={{ color }}
|
||||
style={{ color, flexShrink: 0 }}
|
||||
/>
|
||||
{!isMobile && (
|
||||
<Group
|
||||
gap={4}
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
style={{ minWidth: 0, flex: 1 }}
|
||||
<Group
|
||||
gap={4}
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
style={{ minWidth: 0, flex: 1 }}
|
||||
>
|
||||
<Text
|
||||
size="sm"
|
||||
fw={500}
|
||||
truncate
|
||||
style={{ color, minWidth: 0, flex: 1 }}
|
||||
title={item.label}
|
||||
>
|
||||
<Text
|
||||
size="sm"
|
||||
fw={500}
|
||||
truncate
|
||||
style={{ color, minWidth: 0, flex: 1 }}
|
||||
title={item.label}
|
||||
{item.label}
|
||||
</Text>
|
||||
{item.badge && (
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color={item.badgeColor ?? "orange"}
|
||||
className="modal-nav-item-badge"
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
{item.label}
|
||||
</Text>
|
||||
{item.badge && (
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color={item.badgeColor ?? "orange"}
|
||||
className="modal-nav-item-badge"
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
{item.badge}
|
||||
</Badge>
|
||||
)}
|
||||
{showPlanWarning && (
|
||||
<LocalIcon
|
||||
icon="warning-rounded"
|
||||
width={14}
|
||||
height={14}
|
||||
style={{
|
||||
color: "var(--mantine-color-orange-7)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
{item.badge}
|
||||
</Badge>
|
||||
)}
|
||||
{showPlanWarning && (
|
||||
<LocalIcon
|
||||
icon="warning-rounded"
|
||||
width={14}
|
||||
height={14}
|
||||
style={{
|
||||
color: "var(--mantine-color-orange-7)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
<SettingsNavChevron show={isMobile} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -450,7 +474,15 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Right content */}
|
||||
<div className="modal-content" data-tour="settings-content-area">
|
||||
<div
|
||||
className="modal-content"
|
||||
data-tour="settings-content-area"
|
||||
style={
|
||||
isMobile && mobilePane !== "content"
|
||||
? { display: "none" }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div className="modal-content-scroll">
|
||||
{/* Sticky header with section title and small close button */}
|
||||
<div
|
||||
@@ -460,9 +492,15 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
|
||||
borderBottom: `1px solid ${colors.headerBorder}`,
|
||||
}}
|
||||
>
|
||||
<Text fw={700} size="lg">
|
||||
{activeLabel}
|
||||
</Text>
|
||||
<Group gap="xs" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<SettingsMobileBackButton
|
||||
show={isMobile}
|
||||
onClick={() => void handleMobileBack()}
|
||||
/>
|
||||
<Text fw={700} size="lg" truncate>
|
||||
{activeLabel}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<ActionIcon
|
||||
ref={closeButtonRef}
|
||||
|
||||
@@ -136,15 +136,15 @@
|
||||
}
|
||||
|
||||
/* Two-row: the tool row scrolls sideways instead of wrapping. */
|
||||
.workbench-bar[data-wrapped="true"] .workbench-bar-center {
|
||||
border-top: 1px solid var(--c-border-subtle);
|
||||
.workbench-bar[data-wrapped="true"] .workbench-bar-center-scroll {
|
||||
justify-content: flex-start;
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: thin;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.workbench-bar[data-wrapped="true"] .workbench-bar-center > * {
|
||||
.workbench-bar[data-wrapped="true"] .workbench-bar-center-scroll > * {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -152,16 +152,25 @@
|
||||
.workbench-bar-center {
|
||||
order: 4;
|
||||
flex: 0 0 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
/* 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(--c-border-subtle);
|
||||
}
|
||||
|
||||
.workbench-bar-center-scroll {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
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(--c-border-subtle);
|
||||
}
|
||||
|
||||
/* Retract / reopen handle for the viewer tool row. */
|
||||
@@ -297,3 +306,79 @@
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ---- Mobile layout (matches useIsMobile's 1024px) ---- */
|
||||
@media (max-width: 64rem) {
|
||||
.workbench-bar {
|
||||
margin: var(--nav-gutter) var(--nav-gutter) 0;
|
||||
}
|
||||
|
||||
.workbench-bar-action-icon {
|
||||
width: 40px !important;
|
||||
height: 40px !important;
|
||||
min-width: 40px !important;
|
||||
min-height: 40px !important;
|
||||
}
|
||||
|
||||
.workbench-bar-views,
|
||||
.workbench-bar-globals {
|
||||
height: auto;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.workbench-bar-center {
|
||||
padding: 2px 4px 2px 8px;
|
||||
}
|
||||
|
||||
.workbench-bar-center-scroll {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.workbench-bar[data-wrapped="true"] .workbench-bar-search {
|
||||
order: 2;
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.workbench-bar[data-wrapped="true"] .workbench-bar-globals {
|
||||
order: 3;
|
||||
}
|
||||
|
||||
.workbench-bar[data-wrapped="true"] .workbench-bar-center-scroll {
|
||||
scrollbar-width: none;
|
||||
/* Wider than one 40px icon plus its gap: a 2rem fade always landed
|
||||
mid-glyph, which read as a clipping bug rather than "scroll me". */
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to right,
|
||||
#000 calc(100% - 3.5rem),
|
||||
transparent
|
||||
);
|
||||
mask-image: linear-gradient(
|
||||
to right,
|
||||
#000 calc(100% - 3.5rem),
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
.workbench-bar[data-wrapped="true"]
|
||||
.workbench-bar-center--expanded
|
||||
.workbench-bar-center-scroll {
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
overflow-x: visible;
|
||||
-webkit-mask-image: none;
|
||||
mask-image: none;
|
||||
}
|
||||
|
||||
.workbench-bar[data-wrapped="true"]
|
||||
.workbench-bar-center--expanded
|
||||
.workbench-bar-divider {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.workbench-bar-toolbar-handle-expand {
|
||||
flex-shrink: 0;
|
||||
align-self: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@ import React, {
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
import { Group, Loader, Progress, Stack, Text } from "@mantine/core";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { ActionIcon } from "@app/ui/ActionIcon";
|
||||
import { SegmentedControl } from "@app/ui/SegmentedControl";
|
||||
@@ -31,7 +31,6 @@ import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||
import { useNavigationState } from "@app/contexts/NavigationContext";
|
||||
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 { useEditorSearchScopes } from "@app/hooks/useSuperSearch";
|
||||
@@ -53,10 +52,12 @@ import {
|
||||
} from "@app/types/workbenchBar";
|
||||
import InsertDriveFileOutlinedIcon from "@mui/icons-material/InsertDriveFileOutlined";
|
||||
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 WorkbenchBarDesktopActions from "@app/components/shared/workbenchBar/WorkbenchBarDesktopActions";
|
||||
import WorkbenchBarMobileActions from "@app/components/shared/workbenchBar/WorkbenchBarMobileActions";
|
||||
import WorkbenchBarToolbarHandle from "@app/components/shared/workbenchBar/WorkbenchBarToolbarHandle";
|
||||
import { renderWithTooltip } from "@app/components/shared/workbenchBar/workbenchBarTooltip";
|
||||
import { WorkbenchBarActionsProps } from "@app/components/shared/workbenchBar/types";
|
||||
import { useIsMobile } from "@app/hooks/useIsMobile";
|
||||
import "@app/components/shared/WorkbenchBar.css";
|
||||
|
||||
const SECTION_ORDER: WorkbenchBarSection[] = ["top", "middle", "bottom"];
|
||||
@@ -77,24 +78,6 @@ interface WorkbenchBarProps {
|
||||
onCollapseViewerToolbar?: (collapsed: boolean) => void;
|
||||
}
|
||||
|
||||
function renderWithTooltip(
|
||||
node: React.ReactNode,
|
||||
tooltip: React.ReactNode | undefined,
|
||||
) {
|
||||
if (!tooltip) return node;
|
||||
return (
|
||||
<Tooltip
|
||||
content={tooltip}
|
||||
position="bottom"
|
||||
offset={6}
|
||||
arrow
|
||||
portalTarget={typeof document !== "undefined" ? document.body : undefined}
|
||||
>
|
||||
<div className="workbench-bar-tooltip-wrapper">{node}</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export default function WorkbenchBar({
|
||||
currentView,
|
||||
setCurrentView,
|
||||
@@ -132,6 +115,8 @@ export default function WorkbenchBar({
|
||||
const icons = useFileActionIcons();
|
||||
const { sharingEnabled } = useSharingEnabled();
|
||||
const viewerContext = React.useContext(ViewerContext);
|
||||
const isMobile = useIsMobile();
|
||||
const [mobileToolsExpanded, setMobileToolsExpanded] = useState(false);
|
||||
|
||||
const selectors = useFileSelectors();
|
||||
const { selectedFiles, selectedFileIds } = useFileSelection();
|
||||
@@ -166,32 +151,6 @@ export default function WorkbenchBar({
|
||||
enforcingRun?.currentStep != null && enforcingRun.stepCount
|
||||
? Math.round((enforcingRun.currentStep / enforcingRun.stepCount) * 100)
|
||||
: undefined;
|
||||
const makeEnforcingTooltip = (action: string): React.ReactNode => (
|
||||
<Stack gap={6} py={2} w={200}>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<ShieldOutlinedIcon style={{ fontSize: 13 }} />
|
||||
<Text size="xs" fw={600}>
|
||||
{t(
|
||||
"policy.blockingAction",
|
||||
"{{action}} blocked while enforcing policy, please wait",
|
||||
{ action },
|
||||
)}
|
||||
</Text>
|
||||
</Group>
|
||||
{enforcingProgress != null ? (
|
||||
<Progress
|
||||
w="100%"
|
||||
size="xs"
|
||||
radius="xl"
|
||||
value={enforcingProgress}
|
||||
striped
|
||||
animated
|
||||
/>
|
||||
) : (
|
||||
<Loader size="xs" />
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
const pageEditorTotalPages = pageEditorFunctions?.totalPages ?? 0;
|
||||
const pageEditorSelectedCount =
|
||||
pageEditorFunctions?.selectedPageIds?.length ?? 0;
|
||||
@@ -365,6 +324,33 @@ export default function WorkbenchBar({
|
||||
return terminology.downloadAll;
|
||||
}, [currentView, selectedCount, t, terminology]);
|
||||
|
||||
const actionsDisabled =
|
||||
totalItems === 0 || allButtonsDisabled || disableForFullscreen;
|
||||
|
||||
// Shared by the mobile overflow menu and the desktop icon cluster so the two
|
||||
// stay in step; each renders the same actions in its own shape.
|
||||
const globalActionProps: WorkbenchBarActionsProps = {
|
||||
currentView,
|
||||
isCustomView,
|
||||
actionsDisabled,
|
||||
policyEnforcing,
|
||||
downloadLabel: downloadTooltip,
|
||||
downloadIconName: icons.downloadIconName,
|
||||
saveAsIconName: icons.saveAsIconName,
|
||||
onPrint: handlePrint,
|
||||
onExport: handleExportAll,
|
||||
onClose: handleClose,
|
||||
};
|
||||
|
||||
const toggleMobileTools = useCallback(
|
||||
() => setMobileToolsExpanded((v) => !v),
|
||||
[],
|
||||
);
|
||||
const handleRetractToolbar = useCallback(
|
||||
() => onCollapseViewerToolbar?.(true),
|
||||
[onCollapseViewerToolbar],
|
||||
);
|
||||
|
||||
const renderButton = useCallback(
|
||||
(btn: WorkbenchBarButtonConfig) => {
|
||||
const action = actions[btn.id];
|
||||
@@ -560,38 +546,44 @@ export default function WorkbenchBar({
|
||||
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
|
||||
className={`workbench-bar-center${
|
||||
isMobile && mobileToolsExpanded
|
||||
? " workbench-bar-center--expanded"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<div className="workbench-bar-center-scroll">
|
||||
{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>
|
||||
<WorkbenchBarToolbarHandle
|
||||
isMobile={isMobile}
|
||||
expanded={mobileToolsExpanded}
|
||||
onToggleExpanded={toggleMobileTools}
|
||||
onRetract={
|
||||
isViewer && onCollapseViewerToolbar
|
||||
? handleRetractToolbar
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -599,119 +591,17 @@ export default function WorkbenchBar({
|
||||
<div className="workbench-bar-globals">
|
||||
{/* Share (viewer only; opens the same modal as My Files "Manage sharing") */}
|
||||
{currentView === "viewer" && sharingEnabled && (
|
||||
<ViewerShareButton
|
||||
disabled={
|
||||
totalItems === 0 || allButtonsDisabled || disableForFullscreen
|
||||
}
|
||||
<ViewerShareButton disabled={actionsDisabled} />
|
||||
)}
|
||||
|
||||
{isMobile ? (
|
||||
<WorkbenchBarMobileActions {...globalActionProps} />
|
||||
) : (
|
||||
<WorkbenchBarDesktopActions
|
||||
{...globalActionProps}
|
||||
enforcingProgress={enforcingProgress}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Print */}
|
||||
{currentView === "viewer" &&
|
||||
renderWithTooltip(
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
hover={false}
|
||||
className="workbench-bar-action-icon"
|
||||
onClick={handlePrint}
|
||||
disabled={
|
||||
totalItems === 0 ||
|
||||
allButtonsDisabled ||
|
||||
disableForFullscreen ||
|
||||
policyEnforcing
|
||||
}
|
||||
aria-label={t("workbenchBar.print", "Print PDF")}
|
||||
>
|
||||
<PrintIcon sx={{ fontSize: "1rem" }} />
|
||||
</ActionIcon>,
|
||||
policyEnforcing
|
||||
? makeEnforcingTooltip(t("workbenchBar.print", "Print PDF"))
|
||||
: t("workbenchBar.print", "Print PDF"),
|
||||
)}
|
||||
|
||||
{/* Download (file-level action — not relevant in custom views) */}
|
||||
{!isCustomView &&
|
||||
renderWithTooltip(
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
hover={false}
|
||||
className="workbench-bar-action-icon"
|
||||
onClick={() => handleExportAll()}
|
||||
disabled={
|
||||
disableForFullscreen ||
|
||||
totalItems === 0 ||
|
||||
allButtonsDisabled ||
|
||||
policyEnforcing
|
||||
}
|
||||
aria-label={downloadTooltip}
|
||||
>
|
||||
<LocalIcon
|
||||
icon={icons.downloadIconName}
|
||||
width="1rem"
|
||||
height="1rem"
|
||||
/>
|
||||
</ActionIcon>,
|
||||
policyEnforcing
|
||||
? makeEnforcingTooltip(downloadTooltip)
|
||||
: downloadTooltip,
|
||||
)}
|
||||
|
||||
{/* Save As */}
|
||||
{!isCustomView &&
|
||||
icons.saveAsIconName &&
|
||||
renderWithTooltip(
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
hover={false}
|
||||
className="workbench-bar-action-icon"
|
||||
onClick={() => handleExportAll(true)}
|
||||
disabled={
|
||||
disableForFullscreen ||
|
||||
totalItems === 0 ||
|
||||
allButtonsDisabled ||
|
||||
policyEnforcing
|
||||
}
|
||||
aria-label={t("workbenchBar.saveAs", "Save As")}
|
||||
>
|
||||
<LocalIcon
|
||||
icon={icons.saveAsIconName}
|
||||
width="1rem"
|
||||
height="1rem"
|
||||
/>
|
||||
</ActionIcon>,
|
||||
policyEnforcing
|
||||
? makeEnforcingTooltip(t("workbenchBar.saveAs", "Save As"))
|
||||
: t("workbenchBar.saveAs", "Save As"),
|
||||
)}
|
||||
|
||||
{/* Separator: export group | close */}
|
||||
{!isCustomView && (
|
||||
<div className="workbench-bar-divider workbench-bar-globals-sep" />
|
||||
)}
|
||||
|
||||
{/* Close (context-aware: close all / close viewer file / close page editor) */}
|
||||
{!isCustomView &&
|
||||
renderWithTooltip(
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
hover={false}
|
||||
className="workbench-bar-action-icon"
|
||||
onClick={handleClose}
|
||||
disabled={
|
||||
totalItems === 0 || allButtonsDisabled || disableForFullscreen
|
||||
}
|
||||
aria-label={
|
||||
currentView === "fileEditor"
|
||||
? t("workbenchBar.closeAll", "Close All")
|
||||
: t("workbenchBar.closePdf", "Close PDF")
|
||||
}
|
||||
>
|
||||
<CloseIcon sx={{ fontSize: "1rem" }} />
|
||||
</ActionIcon>,
|
||||
currentView === "fileEditor"
|
||||
? t("workbenchBar.closeAll", "Close All")
|
||||
: t("workbenchBar.closePdf", "Close PDF"),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { SettingsMobileBackButton } from "@app/components/shared/config/SettingsMobileBackButton";
|
||||
|
||||
const meta = {
|
||||
title: "Shared/Config/SettingsMobileBackButton",
|
||||
component: SettingsMobileBackButton,
|
||||
parameters: { layout: "padded" },
|
||||
args: { show: true, onClick: () => {} },
|
||||
} satisfies Meta<typeof SettingsMobileBackButton>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const Hidden: Story = {
|
||||
args: { show: false },
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ArrowBackRoundedIcon from "@mui/icons-material/ArrowBackRounded";
|
||||
import { ActionIcon } from "@app/ui/ActionIcon";
|
||||
|
||||
interface SettingsMobileBackButtonProps {
|
||||
/** Only the mobile two-pane layout has a nav pane to go back to. */
|
||||
show: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
/** Returns the settings modal from a section back to the section list. */
|
||||
export function SettingsMobileBackButton({
|
||||
show,
|
||||
onClick,
|
||||
}: SettingsMobileBackButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!show) return null;
|
||||
|
||||
return (
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
onClick={onClick}
|
||||
aria-label={t("settings.backToSections", "All settings")}
|
||||
>
|
||||
<ArrowBackRoundedIcon sx={{ fontSize: "1.25rem" }} />
|
||||
</ActionIcon>
|
||||
);
|
||||
}
|
||||
|
||||
export default SettingsMobileBackButton;
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { SettingsMobileNavHeader } from "@app/components/shared/config/SettingsMobileNavHeader";
|
||||
import "@app/components/shared/AppConfigModal.css";
|
||||
|
||||
const meta = {
|
||||
title: "Shared/Config/SettingsMobileNavHeader",
|
||||
component: SettingsMobileNavHeader,
|
||||
parameters: { layout: "padded" },
|
||||
args: {
|
||||
show: true,
|
||||
onClose: () => {},
|
||||
background: "var(--c-bg-raised)",
|
||||
borderColor: "var(--c-border-subtle)",
|
||||
},
|
||||
} satisfies Meta<typeof SettingsMobileNavHeader>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const Hidden: Story = {
|
||||
args: { show: false },
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ActionIcon } from "@app/ui/ActionIcon";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
|
||||
interface SettingsMobileNavHeaderProps {
|
||||
/** Mobile shows nav and content as separate panes, so the nav needs its own header. */
|
||||
show: boolean;
|
||||
onClose: () => void;
|
||||
background: string;
|
||||
borderColor: string;
|
||||
}
|
||||
|
||||
/** Header for the settings nav pane: the modal title plus a close button. */
|
||||
export function SettingsMobileNavHeader({
|
||||
show,
|
||||
onClose,
|
||||
background,
|
||||
borderColor,
|
||||
}: SettingsMobileNavHeaderProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!show) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="modal-header modal-nav-header"
|
||||
style={{ background, borderBottom: `1px solid ${borderColor}` }}
|
||||
>
|
||||
<Text fw={700} size="lg">
|
||||
{t("settings.title", "Settings")}
|
||||
</Text>
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
onClick={onClose}
|
||||
aria-label={t("settings.close", "Close")}
|
||||
>
|
||||
<LocalIcon icon="close-rounded" width={18} height={18} />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SettingsMobileNavHeader;
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { SettingsNavChevron } from "@app/components/shared/config/SettingsNavChevron";
|
||||
import "@app/components/shared/AppConfigModal.css";
|
||||
|
||||
const meta = {
|
||||
title: "Shared/Config/SettingsNavChevron",
|
||||
component: SettingsNavChevron,
|
||||
parameters: { layout: "padded" },
|
||||
args: { show: true },
|
||||
} satisfies Meta<typeof SettingsNavChevron>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const Hidden: Story = {
|
||||
args: { show: false },
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import ChevronRightRoundedIcon from "@mui/icons-material/ChevronRightRounded";
|
||||
|
||||
interface SettingsNavChevronProps {
|
||||
/** Mobile nav items drill into a second pane, so they get an affordance. */
|
||||
show: boolean;
|
||||
}
|
||||
|
||||
/** Drill-in affordance on a settings nav item. */
|
||||
export function SettingsNavChevron({ show }: SettingsNavChevronProps) {
|
||||
if (!show) return null;
|
||||
|
||||
return (
|
||||
<ChevronRightRoundedIcon
|
||||
className="modal-nav-chevron"
|
||||
sx={{ fontSize: "1.25rem" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default SettingsNavChevron;
|
||||
@@ -44,7 +44,7 @@ export const useConfigNavSections = (
|
||||
key: "general",
|
||||
label: t("settings.general.title", "General"),
|
||||
icon: "settings-rounded",
|
||||
component: <GeneralSection />,
|
||||
component: <GeneralSection hideTitle />,
|
||||
},
|
||||
{
|
||||
key: "hotkeys",
|
||||
@@ -112,7 +112,7 @@ export const createConfigNavSections = (
|
||||
key: "general",
|
||||
label: "General",
|
||||
icon: "settings-rounded",
|
||||
component: <GeneralSection />,
|
||||
component: <GeneralSection hideTitle />,
|
||||
},
|
||||
{
|
||||
key: "hotkeys",
|
||||
|
||||
@@ -36,8 +36,11 @@
|
||||
border-color: var(--c-primary);
|
||||
}
|
||||
|
||||
/* Keyboard-shortcut hint pinned to the right of the input. */
|
||||
/* Keyboard-shortcut hint pinned to the right of the input. Hidden by default
|
||||
and opted back in below, so the two rules that must agree - showing the chip
|
||||
and reserving room for it - are keyed on one condition and cannot drift. */
|
||||
.super-search-kbd {
|
||||
display: none;
|
||||
position: absolute;
|
||||
right: 0.5rem;
|
||||
top: 50%;
|
||||
@@ -54,6 +57,15 @@
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
/* Only where the shortcut is pressable and the pill has room for it. Below the
|
||||
mobile breakpoint the chip used to sit over the input - it has no padding of
|
||||
its own - so the placeholder ran underneath it instead of ellipsising. */
|
||||
@media (min-width: 64.0625rem) and (not (pointer: coarse)) {
|
||||
.super-search-kbd {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
/* Portalled to <body>; coords are set inline from the input's viewport rect. */
|
||||
.super-search-dropdown {
|
||||
position: fixed;
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Chip } from "@app/ui/Chip";
|
||||
import { TextInput } from "@app/components/shared/TextInput";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import { isMacLike } from "@app/utils/hotkeys";
|
||||
import { useIsMobile } from "@app/hooks/useIsMobile";
|
||||
import {
|
||||
useSuperSearch,
|
||||
SuperSearchResult,
|
||||
@@ -373,6 +374,7 @@ export default function SuperSearch({
|
||||
}
|
||||
};
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
const shortcutHint = useMemo(() => (isMacLike() ? "⌘K" : "Ctrl+K"), []);
|
||||
|
||||
const toggleScope = useCallback(
|
||||
@@ -627,7 +629,11 @@ export default function SuperSearch({
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={setQuery}
|
||||
placeholder={t("superSearch.placeholder", "Search Stirling")}
|
||||
placeholder={
|
||||
isMobile
|
||||
? t("superSearch.placeholderShort", "Search")
|
||||
: t("superSearch.placeholder", "Search Stirling")
|
||||
}
|
||||
icon={
|
||||
<LocalIcon icon="search-rounded" width="1.1rem" height="1.1rem" />
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import PrintIcon from "@mui/icons-material/Print";
|
||||
import { ActionIcon } from "@app/ui/ActionIcon";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import {
|
||||
PolicyEnforcingTooltip,
|
||||
renderWithTooltip,
|
||||
} from "@app/components/shared/workbenchBar/workbenchBarTooltip";
|
||||
import { WorkbenchBarActionsProps } from "@app/components/shared/workbenchBar/types";
|
||||
|
||||
interface WorkbenchBarDesktopActionsProps extends WorkbenchBarActionsProps {
|
||||
/** Percentage through the enforcing policy run, when it reports steps. */
|
||||
enforcingProgress?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Desktop version of the workbench bar's global actions: print / export /
|
||||
* save-as sit as icons, with close split off behind a separator.
|
||||
*/
|
||||
export default function WorkbenchBarDesktopActions({
|
||||
currentView,
|
||||
isCustomView,
|
||||
actionsDisabled,
|
||||
policyEnforcing,
|
||||
downloadLabel,
|
||||
downloadIconName,
|
||||
saveAsIconName,
|
||||
onPrint,
|
||||
onExport,
|
||||
onClose,
|
||||
enforcingProgress,
|
||||
}: WorkbenchBarDesktopActionsProps) {
|
||||
const { t } = useTranslation();
|
||||
const exportDisabled = actionsDisabled || policyEnforcing;
|
||||
const closeLabel =
|
||||
currentView === "fileEditor"
|
||||
? t("workbenchBar.closeAll", "Close All")
|
||||
: t("workbenchBar.closePdf", "Close PDF");
|
||||
|
||||
// Policy enforcement replaces the plain label with a "why is this blocked" card.
|
||||
const tooltipFor = (label: string): React.ReactNode =>
|
||||
policyEnforcing ? (
|
||||
<PolicyEnforcingTooltip action={label} progress={enforcingProgress} />
|
||||
) : (
|
||||
label
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{currentView === "viewer" &&
|
||||
renderWithTooltip(
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
hover={false}
|
||||
className="workbench-bar-action-icon"
|
||||
onClick={onPrint}
|
||||
disabled={exportDisabled}
|
||||
aria-label={t("workbenchBar.print", "Print PDF")}
|
||||
>
|
||||
<PrintIcon sx={{ fontSize: "1rem" }} />
|
||||
</ActionIcon>,
|
||||
tooltipFor(t("workbenchBar.print", "Print PDF")),
|
||||
)}
|
||||
|
||||
{/* Download (file-level action — not relevant in custom views) */}
|
||||
{!isCustomView &&
|
||||
renderWithTooltip(
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
hover={false}
|
||||
className="workbench-bar-action-icon"
|
||||
onClick={() => onExport()}
|
||||
disabled={exportDisabled}
|
||||
aria-label={downloadLabel}
|
||||
>
|
||||
<LocalIcon icon={downloadIconName} width="1rem" height="1rem" />
|
||||
</ActionIcon>,
|
||||
tooltipFor(downloadLabel),
|
||||
)}
|
||||
|
||||
{!isCustomView &&
|
||||
saveAsIconName &&
|
||||
renderWithTooltip(
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
hover={false}
|
||||
className="workbench-bar-action-icon"
|
||||
onClick={() => onExport(true)}
|
||||
disabled={exportDisabled}
|
||||
aria-label={t("workbenchBar.saveAs", "Save As")}
|
||||
>
|
||||
<LocalIcon icon={saveAsIconName} width="1rem" height="1rem" />
|
||||
</ActionIcon>,
|
||||
tooltipFor(t("workbenchBar.saveAs", "Save As")),
|
||||
)}
|
||||
|
||||
{/* Separator: export group | close */}
|
||||
{!isCustomView && (
|
||||
<div className="workbench-bar-divider workbench-bar-globals-sep" />
|
||||
)}
|
||||
|
||||
{/* Close (context-aware: close all / close viewer file / close page editor) */}
|
||||
{!isCustomView &&
|
||||
renderWithTooltip(
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
hover={false}
|
||||
className="workbench-bar-action-icon"
|
||||
onClick={onClose}
|
||||
disabled={actionsDisabled}
|
||||
aria-label={closeLabel}
|
||||
>
|
||||
<CloseIcon sx={{ fontSize: "1rem" }} />
|
||||
</ActionIcon>,
|
||||
closeLabel,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Menu } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import MoreVertIcon from "@mui/icons-material/MoreVert";
|
||||
import PrintIcon from "@mui/icons-material/Print";
|
||||
import { ActionIcon } from "@app/ui/ActionIcon";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import { WorkbenchBarActionsProps } from "@app/components/shared/workbenchBar/types";
|
||||
|
||||
/**
|
||||
* Mobile version of the workbench bar's global actions: the icon row won't fit
|
||||
* on a phone, so print / export / save-as / close collapse into one overflow menu.
|
||||
*/
|
||||
export default function WorkbenchBarMobileActions({
|
||||
currentView,
|
||||
isCustomView,
|
||||
actionsDisabled,
|
||||
policyEnforcing,
|
||||
downloadLabel,
|
||||
downloadIconName,
|
||||
saveAsIconName,
|
||||
onPrint,
|
||||
onExport,
|
||||
onClose,
|
||||
}: WorkbenchBarActionsProps) {
|
||||
const { t } = useTranslation();
|
||||
const exportDisabled = actionsDisabled || policyEnforcing;
|
||||
|
||||
return (
|
||||
<Menu shadow="md" width={230} position="bottom-end">
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
hover={false}
|
||||
className="workbench-bar-action-icon"
|
||||
aria-label={t("workbenchBar.moreActions", "More actions")}
|
||||
>
|
||||
<MoreVertIcon sx={{ fontSize: "1.25rem" }} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
{currentView === "viewer" && (
|
||||
<Menu.Item
|
||||
leftSection={<PrintIcon sx={{ fontSize: "1.1rem" }} />}
|
||||
disabled={exportDisabled}
|
||||
onClick={onPrint}
|
||||
>
|
||||
{t("workbenchBar.print", "Print PDF")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{!isCustomView && (
|
||||
<Menu.Item
|
||||
leftSection={
|
||||
<LocalIcon
|
||||
icon={downloadIconName}
|
||||
width="1.1rem"
|
||||
height="1.1rem"
|
||||
/>
|
||||
}
|
||||
disabled={exportDisabled}
|
||||
onClick={() => void onExport()}
|
||||
>
|
||||
{downloadLabel}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{!isCustomView && saveAsIconName && (
|
||||
<Menu.Item
|
||||
leftSection={
|
||||
<LocalIcon icon={saveAsIconName} width="1.1rem" height="1.1rem" />
|
||||
}
|
||||
disabled={exportDisabled}
|
||||
onClick={() => void onExport(true)}
|
||||
>
|
||||
{t("workbenchBar.saveAs", "Save As")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{!isCustomView && (
|
||||
<>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
leftSection={<CloseIcon sx={{ fontSize: "1.1rem" }} />}
|
||||
disabled={actionsDisabled}
|
||||
onClick={() => void onClose()}
|
||||
>
|
||||
{currentView === "fileEditor"
|
||||
? t("workbenchBar.closeAll", "Close All")
|
||||
: t("workbenchBar.closePdf", "Close PDF")}
|
||||
</Menu.Item>
|
||||
</>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
|
||||
import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp";
|
||||
import { Button } from "@app/ui/Button";
|
||||
|
||||
interface WorkbenchBarToolbarHandleProps {
|
||||
isMobile: boolean;
|
||||
/** Mobile only: whether the tool row is showing every tool rather than scrolling. */
|
||||
expanded: boolean;
|
||||
onToggleExpanded: () => void;
|
||||
/** Desktop viewer only: retracts the whole tool row. Omit to render no handle. */
|
||||
onRetract?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle pinned to the right of the tool row. On mobile it expands the row from
|
||||
* a single scrolling line to a wrapped grid; on the desktop viewer it retracts
|
||||
* the row entirely.
|
||||
*/
|
||||
export default function WorkbenchBarToolbarHandle({
|
||||
isMobile,
|
||||
expanded,
|
||||
onToggleExpanded,
|
||||
onRetract,
|
||||
}: WorkbenchBarToolbarHandleProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="quiet"
|
||||
size="lg"
|
||||
className="workbench-bar-toolbar-handle workbench-bar-toolbar-handle-expand"
|
||||
onClick={onToggleExpanded}
|
||||
aria-expanded={expanded}
|
||||
aria-label={
|
||||
expanded
|
||||
? t("workbenchBar.showFewerTools", "Collapse toolbar")
|
||||
: t("workbenchBar.showAllTools", "Show all tools")
|
||||
}
|
||||
leftSection={
|
||||
expanded ? (
|
||||
<KeyboardArrowUpIcon sx={{ fontSize: "1.25rem" }} />
|
||||
) : (
|
||||
<KeyboardArrowDownIcon sx={{ fontSize: "1.25rem" }} />
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!onRetract) return null;
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="quiet"
|
||||
className="workbench-bar-toolbar-handle workbench-bar-toolbar-handle-retract"
|
||||
onClick={onRetract}
|
||||
aria-expanded
|
||||
aria-label={t("workbenchBar.hideToolbar", "Hide toolbar")}
|
||||
title={t("workbenchBar.hideToolbar", "Hide toolbar")}
|
||||
leftSection={<KeyboardArrowUpIcon sx={{ fontSize: "1rem" }} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { WorkbenchType } from "@app/types/workbench";
|
||||
|
||||
/** Shared shape for the workbench bar's file-level global actions (print,
|
||||
* export, save-as, close) so the mobile and desktop clusters stay in step. */
|
||||
export interface WorkbenchBarActionsProps {
|
||||
currentView: WorkbenchType;
|
||||
/** Custom workbench views own their content, so file actions don't apply. */
|
||||
isCustomView: boolean;
|
||||
/** No files to act on, or the bar is globally locked out. */
|
||||
actionsDisabled: boolean;
|
||||
/** A policy run is enforcing on a file the export would touch. */
|
||||
policyEnforcing: boolean;
|
||||
/** Context-aware label for the download/export action. */
|
||||
downloadLabel: string;
|
||||
downloadIconName: string;
|
||||
saveAsIconName?: string;
|
||||
onPrint: () => void;
|
||||
onExport: (forceNewFile?: boolean) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import React from "react";
|
||||
import { Group, Loader, Progress, Stack, Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined";
|
||||
import { Tooltip } from "@app/components/shared/Tooltip";
|
||||
|
||||
/** Wrap a bar control in the bar's standard tooltip, or pass it through when
|
||||
* the caller has no tooltip to show. */
|
||||
export function renderWithTooltip(
|
||||
node: React.ReactNode,
|
||||
tooltip: React.ReactNode | undefined,
|
||||
) {
|
||||
if (!tooltip) return node;
|
||||
return (
|
||||
<Tooltip
|
||||
content={tooltip}
|
||||
position="bottom"
|
||||
offset={6}
|
||||
arrow
|
||||
portalTarget={typeof document !== "undefined" ? document.body : undefined}
|
||||
>
|
||||
<div className="workbench-bar-tooltip-wrapper">{node}</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
interface PolicyEnforcingTooltipProps {
|
||||
/** The blocked action's label, e.g. "Print PDF". */
|
||||
action: string;
|
||||
/** Percentage through the enforcing run, when the run reports steps. */
|
||||
progress?: number;
|
||||
}
|
||||
|
||||
/** Tooltip body explaining that a file action is blocked by a policy run. */
|
||||
export function PolicyEnforcingTooltip({
|
||||
action,
|
||||
progress,
|
||||
}: PolicyEnforcingTooltipProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Stack gap={6} py={2} w={200}>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<ShieldOutlinedIcon style={{ fontSize: 13 }} />
|
||||
<Text size="xs" fw={600}>
|
||||
{t(
|
||||
"policy.blockingAction",
|
||||
"{{action}} blocked while enforcing policy, please wait",
|
||||
{ action },
|
||||
)}
|
||||
</Text>
|
||||
</Group>
|
||||
{progress != null ? (
|
||||
<Progress
|
||||
w="100%"
|
||||
size="xs"
|
||||
radius="xl"
|
||||
value={progress}
|
||||
striped
|
||||
animated
|
||||
/>
|
||||
) : (
|
||||
<Loader size="xs" />
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -237,7 +237,10 @@ export default function RightSidebar() {
|
||||
: t("toolPanel.goBack", "Go back")
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
) : showCloseButton || !isMobile ? (
|
||||
/* Without a back button this header is just the collapse control,
|
||||
which has no meaning on mobile - the slider switches panes. Drop
|
||||
it there so the tool list starts under the tabs. */
|
||||
<div className="tool-panel__compact-header">
|
||||
<span className="tool-panel__compact-title">
|
||||
{t("toolPanel.pdfTools", "PDF Tools")}
|
||||
@@ -272,7 +275,7 @@ export default function RightSidebar() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
<ToolPanel
|
||||
allToolsView={allToolsView}
|
||||
|
||||
@@ -169,6 +169,20 @@
|
||||
color: var(--c-text);
|
||||
}
|
||||
|
||||
/* On mobile the "Tools" tab sits directly above this panel, so the heading
|
||||
just repeats it. The header stays for its actions, but shrinks to fit them -
|
||||
at the desktop 52px it would read as an empty band once the title is gone. */
|
||||
@media (max-width: 64rem) {
|
||||
.tool-panel__compact-title {
|
||||
display: none;
|
||||
}
|
||||
.tool-panel__compact-header {
|
||||
min-height: 44px;
|
||||
padding-top: 0.25rem;
|
||||
padding-bottom: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.tool-panel__compact-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { Box, Center, Stack, Text } from "@mantine/core";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import ArticleIcon from "@mui/icons-material/Article";
|
||||
@@ -99,12 +99,17 @@ export function NonPdfViewer({ file }: NonPdfViewerProps) {
|
||||
return (
|
||||
<Stack
|
||||
gap={0}
|
||||
style={{
|
||||
height: "100%",
|
||||
flex: 1,
|
||||
overflow: "hidden",
|
||||
position: "relative",
|
||||
}}
|
||||
style={
|
||||
{
|
||||
height: "100%",
|
||||
flex: 1,
|
||||
overflow: "hidden",
|
||||
position: "relative",
|
||||
// The Convert button floats over the content; viewers that draw their
|
||||
// own top bar read this to keep their text clear of it.
|
||||
"--nonpdf-action-inset": isConvertAvailable ? "11rem" : "0rem",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<NonPdfBanner
|
||||
onConvertToPdf={isConvertAvailable ? handleConvertToPdf : undefined}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Box, Paper, Text } from "@mantine/core";
|
||||
import { Box, Center, Group, Paper, Stack, Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { useIsMobile } from "@app/hooks/useIsMobile";
|
||||
import { formatFileSize } from "@app/utils/fileUtils";
|
||||
|
||||
interface HtmlViewerProps {
|
||||
@@ -10,37 +12,79 @@ interface HtmlViewerProps {
|
||||
|
||||
export function HtmlViewer({ file }: HtmlViewerProps) {
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useIsMobile();
|
||||
const [objectUrl, setObjectUrl] = useState<string | null>(null);
|
||||
// Phones render a desktop-width document into ~400px, which reads as a blank
|
||||
// column, so the iframe is opt-in there. Derived rather than seeded into
|
||||
// state because useIsMobile resolves after first paint.
|
||||
const [optedIn, setOptedIn] = useState(false);
|
||||
const showPreview = !isMobile || optedIn;
|
||||
|
||||
useEffect(() => {
|
||||
if (!showPreview) return;
|
||||
const url = URL.createObjectURL(file);
|
||||
setObjectUrl(url);
|
||||
return () => URL.revokeObjectURL(url);
|
||||
}, [file]);
|
||||
}, [file, showPreview]);
|
||||
|
||||
return (
|
||||
<Box style={{ flex: 1, display: "flex", flexDirection: "column" }}>
|
||||
<Paper
|
||||
radius={0}
|
||||
p="xs"
|
||||
style={{
|
||||
borderBottom: "1px solid var(--mantine-color-gray-2)",
|
||||
flexShrink: 0,
|
||||
// Padding set here rather than via `p`, whose shorthand would reset
|
||||
// the right inset below. NonPdfViewer floats its "Convert to PDF"
|
||||
// button over this row, so keep the notice clear of it instead of
|
||||
// running underneath.
|
||||
padding: "0.5rem",
|
||||
paddingRight: "max(0.5rem, var(--nonpdf-action-inset, 0rem))",
|
||||
}}
|
||||
>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("viewer.nonPdf.htmlPreviewWarning", {
|
||||
size: formatFileSize(file.size),
|
||||
})}
|
||||
</Text>
|
||||
<Group gap="xs" wrap="nowrap" align="center">
|
||||
<Text size="xs" c="dimmed" style={{ minWidth: 0, flex: 1 }}>
|
||||
{t("viewer.nonPdf.htmlPreviewWarning", {
|
||||
size: formatFileSize(file.size),
|
||||
})}
|
||||
</Text>
|
||||
{/* Opting in used to be one-way: the only way back was closing and
|
||||
reopening the file. */}
|
||||
{isMobile && optedIn && (
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
onClick={() => setOptedIn(false)}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
{t("viewer.nonPdf.htmlHidePreview", "Hide preview")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Paper>
|
||||
{objectUrl && (
|
||||
<iframe
|
||||
src={objectUrl}
|
||||
title={t("viewer.nonPdf.htmlPreview")}
|
||||
sandbox="allow-scripts"
|
||||
style={{ flex: 1, border: "none", background: "#fff" }}
|
||||
/>
|
||||
{showPreview ? (
|
||||
objectUrl && (
|
||||
<iframe
|
||||
src={objectUrl}
|
||||
title={t("viewer.nonPdf.htmlPreview")}
|
||||
sandbox="allow-scripts"
|
||||
style={{ flex: 1, border: "none", background: "#fff" }}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<Center style={{ flex: 1, padding: "1.5rem" }}>
|
||||
<Stack align="center" gap="sm" style={{ maxWidth: "22rem" }}>
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{t(
|
||||
"viewer.nonPdf.htmlPreviewMobileHidden",
|
||||
"HTML pages are laid out for desktop widths, so the preview is off by default here.",
|
||||
)}
|
||||
</Text>
|
||||
<Button variant="secondary" onClick={() => setOptedIn(true)}>
|
||||
{t("viewer.nonPdf.htmlShowPreview", "Show preview anyway")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Center>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -36,7 +36,7 @@ i18n
|
||||
debug: process.env.NODE_ENV === "development",
|
||||
|
||||
// Ensure synchronous loading to prevent timing issues
|
||||
initImmediate: false,
|
||||
initAsync: false,
|
||||
|
||||
interpolation: {
|
||||
escapeValue: false, // React already escapes values
|
||||
|
||||
@@ -10,16 +10,9 @@
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-bottom: 1px solid var(--c-border-subtle);
|
||||
background: var(--c-bg-raised);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.mobile-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.mobile-brand {
|
||||
@@ -27,6 +20,7 @@
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mobile-brand-icon {
|
||||
@@ -49,6 +43,14 @@
|
||||
border-radius: 9999px;
|
||||
background: var(--c-bg);
|
||||
border: 1px solid var(--c-border-subtle);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 25rem) {
|
||||
.mobile-brand-text {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-toggle-button {
|
||||
@@ -74,10 +76,46 @@
|
||||
color: var(--c-text);
|
||||
}
|
||||
|
||||
.mobile-toggle-hint {
|
||||
.mobile-slider-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mobile-swipe-hint {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 0.35rem 0.85rem;
|
||||
border-radius: 9999px;
|
||||
background: color-mix(in srgb, var(--c-bg-raised) 88%, transparent);
|
||||
border: 1px solid var(--c-border-subtle);
|
||||
font-size: 0.7rem;
|
||||
color: var(--c-text-subtle);
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
z-index: 20;
|
||||
animation: mobile-swipe-hint-in 0.4s ease 1s both;
|
||||
}
|
||||
|
||||
@keyframes mobile-swipe-hint-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(-0.25rem);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.mobile-swipe-hint {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-slider {
|
||||
@@ -134,6 +172,7 @@
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
padding: 0.5rem;
|
||||
padding-bottom: calc(0.5rem + env(safe-area-inset-bottom, 0px));
|
||||
border-top: 1px solid var(--c-border-subtle);
|
||||
background: var(--c-bg-raised);
|
||||
gap: 0.5rem;
|
||||
@@ -177,4 +216,5 @@
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--c-text-subtle);
|
||||
margin-inline-start: 0.25rem;
|
||||
}
|
||||
|
||||
@@ -43,6 +43,15 @@ import { Button } from "@app/ui/Button";
|
||||
import "@app/pages/HomePage.css";
|
||||
|
||||
const SIDEBAR_COLLAPSED_STORAGE_KEY = "stirling.fileSidebarCollapsed";
|
||||
const SWIPE_HINT_SEEN_STORAGE_KEY = "stirling.mobileSwipeHintSeen";
|
||||
|
||||
function readSwipeHintSeen(): boolean {
|
||||
try {
|
||||
return window.localStorage.getItem(SWIPE_HINT_SEEN_STORAGE_KEY) === "true";
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function readPersistedSidebarCollapsed(): boolean {
|
||||
try {
|
||||
@@ -181,6 +190,9 @@ export default function HomePage() {
|
||||
if (typeof action.activeFileIndex === "number") {
|
||||
setActiveFileIndex(action.activeFileIndex);
|
||||
}
|
||||
if (isMobile) {
|
||||
setActiveMobileView("workbench");
|
||||
}
|
||||
}
|
||||
|
||||
prevFileCountRef.current = currentCount;
|
||||
@@ -190,6 +202,7 @@ export default function HomePage() {
|
||||
setActiveFileIndex,
|
||||
selectedToolKey,
|
||||
navigationState.workbench,
|
||||
isMobile,
|
||||
]);
|
||||
|
||||
const hideToolPanel =
|
||||
@@ -201,10 +214,44 @@ export default function HomePage() {
|
||||
|
||||
const brandAltText = t("home.mobile.brandAlt", "Stirling PDF logo");
|
||||
|
||||
const handleSelectMobileView = useCallback((view: MobileView) => {
|
||||
setActiveMobileView(view);
|
||||
const [showSwipeHint, setShowSwipeHint] = useState(
|
||||
() => !readSwipeHintSeen(),
|
||||
);
|
||||
const dismissSwipeHint = useCallback(() => {
|
||||
setShowSwipeHint((shown) => {
|
||||
if (shown) {
|
||||
try {
|
||||
window.localStorage.setItem(SWIPE_HINT_SEEN_STORAGE_KEY, "true");
|
||||
} catch {
|
||||
// private mode / quota: silently no-op
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMobile || !isTouch || !showSwipeHint) return;
|
||||
const timer = window.setTimeout(dismissSwipeHint, 8000);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [isMobile, isTouch, showSwipeHint, dismissSwipeHint]);
|
||||
|
||||
const handleSelectMobileView = useCallback(
|
||||
(view: MobileView) => {
|
||||
setActiveMobileView(view);
|
||||
dismissSwipeHint();
|
||||
},
|
||||
[dismissSwipeHint],
|
||||
);
|
||||
|
||||
// The /files URL pins the workbench to myFiles, so changing view while the
|
||||
// file manager is open does nothing until we navigate off it. Desktop leaves
|
||||
// via the sidebar's back arrow; mobile renders no sidebar, so without this the
|
||||
// bottom bar could not get out of My Files at all.
|
||||
const leaveMyFiles = useCallback(() => {
|
||||
if (navigationState.workbench === "myFiles") navigate(EDITOR_BASENAME);
|
||||
}, [navigationState.workbench, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isMobile) {
|
||||
const container = sliderRef.current;
|
||||
@@ -250,9 +297,10 @@ export default function HomePage() {
|
||||
const threshold = offsetWidth / 2;
|
||||
const nextView: MobileView =
|
||||
scrollLeft >= threshold ? "workbench" : "tools";
|
||||
setActiveMobileView((current) =>
|
||||
current === nextView ? current : nextView,
|
||||
);
|
||||
setActiveMobileView((current) => {
|
||||
if (current !== nextView) dismissSwipeHint();
|
||||
return current === nextView ? current : nextView;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
@@ -264,7 +312,7 @@ export default function HomePage() {
|
||||
cancelAnimationFrame(animationFrame);
|
||||
}
|
||||
};
|
||||
}, [isMobile]);
|
||||
}, [isMobile, dismissSwipeHint]);
|
||||
|
||||
// Automatically switch to workbench when read mode or multiTool is activated in mobile
|
||||
useEffect(() => {
|
||||
@@ -331,14 +379,9 @@ export default function HomePage() {
|
||||
other route. */}
|
||||
{navigationState.workbench !== "myFiles" && (
|
||||
<div className="mobile-toggle">
|
||||
<div className="mobile-header">
|
||||
<div className="mobile-brand">
|
||||
<LogoIcon className="mobile-brand-icon" />
|
||||
<Wordmark
|
||||
alt={brandAltText}
|
||||
className="mobile-brand-text"
|
||||
/>
|
||||
</div>
|
||||
<div className="mobile-brand">
|
||||
<LogoIcon className="mobile-brand-icon" />
|
||||
<Wordmark alt={brandAltText} className="mobile-brand-text" />
|
||||
</div>
|
||||
<div
|
||||
className="mobile-toggle-buttons"
|
||||
@@ -367,14 +410,6 @@ export default function HomePage() {
|
||||
{t("home.mobile.workspace", "Workspace")}
|
||||
</button>
|
||||
</div>
|
||||
{isTouch && (
|
||||
<span className="mobile-toggle-hint">
|
||||
{t(
|
||||
"home.mobile.swipeHint",
|
||||
"Swipe left or right to switch views",
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{navigationState.workbench === "myFiles" ? (
|
||||
@@ -388,34 +423,44 @@ export default function HomePage() {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div ref={sliderRef} className="mobile-slider">
|
||||
<div
|
||||
className="mobile-slide"
|
||||
aria-label={t(
|
||||
"home.mobile.toolsSlide",
|
||||
"Tool selection panel",
|
||||
)}
|
||||
>
|
||||
<div className="mobile-slide-content">
|
||||
<RightSidebar />
|
||||
<div className="mobile-slider-wrap">
|
||||
<div ref={sliderRef} className="mobile-slider">
|
||||
<div
|
||||
className="mobile-slide"
|
||||
aria-label={t(
|
||||
"home.mobile.toolsSlide",
|
||||
"Tool selection panel",
|
||||
)}
|
||||
>
|
||||
<div className="mobile-slide-content">
|
||||
<RightSidebar />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="mobile-slide"
|
||||
aria-label={t(
|
||||
"home.mobile.workbenchSlide",
|
||||
"Workspace panel",
|
||||
)}
|
||||
>
|
||||
<div className="mobile-slide-content">
|
||||
<div
|
||||
className="flex-1 min-h-0 flex"
|
||||
style={{ minWidth: 0 }}
|
||||
>
|
||||
<Workbench />
|
||||
<div
|
||||
className="mobile-slide"
|
||||
aria-label={t(
|
||||
"home.mobile.workbenchSlide",
|
||||
"Workspace panel",
|
||||
)}
|
||||
>
|
||||
<div className="mobile-slide-content">
|
||||
<div
|
||||
className="flex-1 min-h-0 flex"
|
||||
style={{ minWidth: 0 }}
|
||||
>
|
||||
<Workbench />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{isTouch && showSwipeHint && (
|
||||
<span className="mobile-swipe-hint" aria-hidden="true">
|
||||
{t(
|
||||
"home.mobile.swipeHint",
|
||||
"Swipe left or right to switch views",
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="mobile-bottom-bar">
|
||||
@@ -424,6 +469,7 @@ export default function HomePage() {
|
||||
className="mobile-bottom-button"
|
||||
aria-label={t("quickAccess.allTools", "Tools")}
|
||||
onClick={() => {
|
||||
leaveMyFiles();
|
||||
handleBackToTools();
|
||||
if (isMobile) {
|
||||
setActiveMobileView("tools");
|
||||
@@ -441,6 +487,7 @@ export default function HomePage() {
|
||||
className="mobile-bottom-button"
|
||||
aria-label={t("quickAccess.automate", "Automate")}
|
||||
onClick={() => {
|
||||
leaveMyFiles();
|
||||
handleToolSelect("automate");
|
||||
if (isMobile) {
|
||||
setActiveMobileView("tools");
|
||||
|
||||
@@ -471,7 +471,10 @@ test.describe("Files page", () => {
|
||||
.locator(".files-page-card:not(.is-folder)")
|
||||
.filter({ hasText: "phone-a.pdf" })
|
||||
.click();
|
||||
await page.getByRole("button", { name: /Show details/i }).click();
|
||||
// On a phone a selection swaps the toolbar for a contextual bar, so the
|
||||
// bulk actions - Show details among them - live behind one trigger.
|
||||
await page.locator(".files-page-toolbar-bulk-trigger").click();
|
||||
await page.getByRole("menuitem", { name: /Show details/i }).click();
|
||||
// Drawer opens, file name shown inside it.
|
||||
await expect(page.locator(".mantine-Drawer-content")).toBeVisible({
|
||||
timeout: 3_000,
|
||||
|
||||
@@ -11,6 +11,11 @@ import {
|
||||
type UpdateModeInfo,
|
||||
} from "@app/services/desktopUpdateService";
|
||||
|
||||
interface GeneralSectionProps {
|
||||
/** Forwarded to the core section; the settings modal header already names it. */
|
||||
hideTitle?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Desktop extension of GeneralSection.
|
||||
*
|
||||
@@ -20,7 +25,9 @@ import {
|
||||
* still rendered but disabled, with a "Managed by administrator" hint, so
|
||||
* managed-deployment users can see what policy is in effect.
|
||||
*/
|
||||
const GeneralSection: React.FC = () => {
|
||||
const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
hideTitle = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const install = useDesktopInstall();
|
||||
// In SaaS connection mode the cloud owns app versioning — hide the update
|
||||
@@ -96,6 +103,7 @@ const GeneralSection: React.FC = () => {
|
||||
</Alert>
|
||||
)}
|
||||
<CoreGeneralSection
|
||||
hideTitle={hideTitle}
|
||||
hideUpdateSection={
|
||||
isSaaSMode ||
|
||||
(updateModeInfo.mode === "disabled" && updateModeInfo.locked)
|
||||
|
||||
@@ -42,6 +42,7 @@ export function LoginLandingSetting() {
|
||||
return (
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<div
|
||||
id="setting-login-landing"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -57,7 +57,7 @@ export const useConfigNavSections = (
|
||||
if (preferencesSection) {
|
||||
preferencesSection.items = preferencesSection.items.map((item) =>
|
||||
item.key === "general"
|
||||
? { ...item, component: <GeneralWithLoginLanding /> }
|
||||
? { ...item, component: <GeneralWithLoginLanding hideTitle /> }
|
||||
: item,
|
||||
);
|
||||
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import React, { useCallback, useMemo, useState, useEffect } from "react";
|
||||
import { Modal, Text } from "@mantine/core";
|
||||
import { Group, Modal, Text } from "@mantine/core";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { ActionIcon } from "@app/ui/ActionIcon";
|
||||
import { useMediaQuery } from "@mantine/hooks";
|
||||
import { useIsMobile } from "@app/hooks/useIsMobile";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
import { isUserAnonymous } from "@app/auth/supabase";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import { SettingsMobileBackButton } from "@app/components/shared/config/SettingsMobileBackButton";
|
||||
import { SettingsMobileNavHeader } from "@app/components/shared/config/SettingsMobileNavHeader";
|
||||
import { SettingsNavChevron } from "@app/components/shared/config/SettingsNavChevron";
|
||||
import Overview from "@app/components/shared/config/configSections/Overview";
|
||||
import { createSaasConfigNavSections } from "@app/components/shared/config/saasConfigNavSections";
|
||||
import { consumePendingSettingsNav } from "@app/utils/appSettings";
|
||||
@@ -46,12 +49,13 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({
|
||||
extraSections,
|
||||
hiddenSectionKeys,
|
||||
}) => {
|
||||
const isMobile = useMediaQuery("(max-width: 1024px)");
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const { signOut, user } = useAuth();
|
||||
const { t } = useTranslation();
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [active, setActive] = useState<NavKey>("overview");
|
||||
const [mobilePane, setMobilePane] = useState<"nav" | "content">("nav");
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const location = useLocation();
|
||||
|
||||
@@ -60,7 +64,10 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({
|
||||
// Consume any section stashed by openAppSettings on mount to land on it.
|
||||
useEffect(() => {
|
||||
const pending = consumePendingSettingsNav();
|
||||
if (pending) setActive(pending);
|
||||
if (pending) {
|
||||
setActive(pending);
|
||||
setMobilePane("content");
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Check if user can access billing features (non-anonymous users only)
|
||||
@@ -70,6 +77,7 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({
|
||||
const detail = (ev as CustomEvent).detail as { key?: NavKey } | undefined;
|
||||
if (detail?.key) {
|
||||
setActive(detail.key);
|
||||
setMobilePane("content");
|
||||
}
|
||||
};
|
||||
window.addEventListener("appConfig:navigate", handler as EventListener);
|
||||
@@ -90,6 +98,7 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({
|
||||
if (!opened) return;
|
||||
if (initialSection) {
|
||||
setActive(initialSection);
|
||||
setMobilePane("content");
|
||||
return;
|
||||
}
|
||||
const match = stripBasePath(location.pathname).match(
|
||||
@@ -97,6 +106,9 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({
|
||||
);
|
||||
if (match) {
|
||||
setActive(match[1] as NavKey);
|
||||
setMobilePane("content");
|
||||
} else {
|
||||
setMobilePane("nav");
|
||||
}
|
||||
}, [opened, initialSection, location.pathname]);
|
||||
|
||||
@@ -225,34 +237,43 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({
|
||||
className={`modal-nav ${isMobile ? "mobile" : ""}`}
|
||||
style={{
|
||||
background: colors.navBg,
|
||||
borderRight: `1px solid ${colors.headerBorder}`,
|
||||
...(isMobile
|
||||
? { display: mobilePane === "nav" ? undefined : "none" }
|
||||
: { borderRight: `1px solid ${colors.headerBorder}` }),
|
||||
}}
|
||||
>
|
||||
<SettingsMobileNavHeader
|
||||
show={isMobile}
|
||||
onClose={onClose}
|
||||
background={colors.navBg}
|
||||
borderColor={colors.headerBorder}
|
||||
/>
|
||||
<div className="modal-nav-scroll">
|
||||
{configNavSections.map((section) => (
|
||||
<div key={section.title} className="modal-nav-section">
|
||||
{!isMobile && (
|
||||
<Text
|
||||
size="xs"
|
||||
fw={600}
|
||||
c={colors.sectionTitle}
|
||||
style={{ textTransform: "uppercase", letterSpacing: 0.4 }}
|
||||
>
|
||||
{section.title}
|
||||
</Text>
|
||||
)}
|
||||
<Text
|
||||
size="xs"
|
||||
fw={600}
|
||||
c={colors.sectionTitle}
|
||||
style={{ textTransform: "uppercase", letterSpacing: 0.4 }}
|
||||
>
|
||||
{section.title}
|
||||
</Text>
|
||||
<div className="modal-nav-section-items">
|
||||
{section.items.map((item) => {
|
||||
const isActive = active === item.key;
|
||||
const color = isActive
|
||||
? colors.navItemActive
|
||||
: colors.navItem;
|
||||
const iconSize = isMobile ? 28 : 18;
|
||||
const iconSize = 18;
|
||||
return (
|
||||
<div
|
||||
key={item.key}
|
||||
data-tour={`admin-${item.key}-nav`}
|
||||
onClick={() => setActive(item.key)}
|
||||
onClick={() => {
|
||||
setActive(item.key);
|
||||
setMobilePane("content");
|
||||
}}
|
||||
className={`modal-nav-item ${isMobile ? "mobile" : ""}`}
|
||||
style={{
|
||||
background: isActive
|
||||
@@ -264,13 +285,17 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({
|
||||
icon={item.icon}
|
||||
width={iconSize}
|
||||
height={iconSize}
|
||||
style={{ color }}
|
||||
style={{ color, flexShrink: 0 }}
|
||||
/>
|
||||
{!isMobile && (
|
||||
<Text size="sm" fw={500} style={{ color }}>
|
||||
{item.label}
|
||||
</Text>
|
||||
)}
|
||||
<Text
|
||||
size="sm"
|
||||
fw={500}
|
||||
truncate
|
||||
style={{ color, minWidth: 0, flex: 1 }}
|
||||
>
|
||||
{item.label}
|
||||
</Text>
|
||||
<SettingsNavChevron show={isMobile} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -281,7 +306,14 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Right content */}
|
||||
<div className="modal-content">
|
||||
<div
|
||||
className="modal-content"
|
||||
style={
|
||||
isMobile && mobilePane !== "content"
|
||||
? { display: "none" }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div className="modal-content-scroll">
|
||||
{/* Sticky header with section title and small close button */}
|
||||
<div
|
||||
@@ -291,20 +323,26 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({
|
||||
borderBottom: `1px solid ${colors.headerBorder}`,
|
||||
}}
|
||||
>
|
||||
<Text fw={700} size="lg">
|
||||
{activeLabel}
|
||||
{active === "plan" && notice ? (
|
||||
<span
|
||||
style={{
|
||||
marginLeft: 8,
|
||||
fontWeight: 600,
|
||||
color: "var(--mantine-color-yellow-7)",
|
||||
}}
|
||||
>
|
||||
– {notice}
|
||||
</span>
|
||||
) : null}
|
||||
</Text>
|
||||
<Group gap="xs" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<SettingsMobileBackButton
|
||||
show={isMobile}
|
||||
onClick={() => setMobilePane("nav")}
|
||||
/>
|
||||
<Text fw={700} size="lg" truncate>
|
||||
{activeLabel}
|
||||
{active === "plan" && notice ? (
|
||||
<span
|
||||
style={{
|
||||
marginLeft: 8,
|
||||
fontWeight: 600,
|
||||
color: "var(--mantine-color-yellow-7)",
|
||||
}}
|
||||
>
|
||||
– {notice}
|
||||
</span>
|
||||
) : null}
|
||||
</Text>
|
||||
</Group>
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
onClick={onClose}
|
||||
|
||||
@@ -62,10 +62,10 @@
|
||||
"autoprefixer": "^10.4.21",
|
||||
"axios": "^1.15.0",
|
||||
"d3": "^7.9.0",
|
||||
"i18next": "^25.10.10",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"i18next": "^26.3.6",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"jszip": "^3.10.1",
|
||||
"license-report": "^6.8.0",
|
||||
"license-report": "^6.8.5",
|
||||
"pdfjs-dist": "^5.4.149",
|
||||
"peerjs": "^1.5.5",
|
||||
"pixelmatch": "^7.1.0",
|
||||
@@ -74,7 +74,7 @@
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-easy-crop": "^5.5.6",
|
||||
"react-i18next": "^16.6.6",
|
||||
"react-i18next": "^17.0.11",
|
||||
"react-markdown": "^9.0.3",
|
||||
"react-rnd": "^10.5.2",
|
||||
"react-router-dom": "^7.9.1",
|
||||
@@ -574,16 +574,6 @@
|
||||
"keyv": "^5.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@cacheable/memory/node_modules/keyv": {
|
||||
"version": "5.6.0",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz",
|
||||
"integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@keyv/serialize": "^1.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@cacheable/utils": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz",
|
||||
@@ -595,16 +585,6 @@
|
||||
"keyv": "^5.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@cacheable/utils/node_modules/keyv": {
|
||||
"version": "5.6.0",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz",
|
||||
"integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@keyv/serialize": "^1.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@cantoo/pdf-lib": {
|
||||
"version": "2.8.2",
|
||||
"resolved": "https://registry.npmjs.org/@cantoo/pdf-lib/-/pdf-lib-2.8.2.tgz",
|
||||
@@ -3866,12 +3846,12 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@sindresorhus/is": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz",
|
||||
"integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==",
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-8.1.0.tgz",
|
||||
"integrity": "sha512-2SX/1jW6CIMAiebvVv5ZInoCEuWQmMyBoJXXGC6Vjakjp/fpxP5eHs7/V6WKuPEIbuK06+VpjH+vjLQhr98rDQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sindresorhus/is?sponsor=1"
|
||||
@@ -7510,15 +7490,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/cacheable-request": {
|
||||
"version": "13.0.18",
|
||||
"resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-13.0.18.tgz",
|
||||
"integrity": "sha512-rFWadDRKJs3s2eYdXlGggnBZKG7MTblkFBB0YllFds+UYnfogDp2wcR6JN97FhRkHTvq59n2vhNoHNZn29dh/Q==",
|
||||
"version": "13.0.19",
|
||||
"resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-13.0.19.tgz",
|
||||
"integrity": "sha512-SVXGH037+Mo1aIMO5B2UcleR43FGjFdN+M8JObSyEoQ2Mn4CODRWx28gN5jiTF0n5ItsgtIZfyargMNs8GX4kg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/http-cache-semantics": "^4.0.4",
|
||||
"@types/http-cache-semantics": "^4.2.0",
|
||||
"get-stream": "^9.0.1",
|
||||
"http-cache-semantics": "^4.2.0",
|
||||
"keyv": "^5.5.5",
|
||||
"keyv": "^5.6.0",
|
||||
"mimic-response": "^4.0.0",
|
||||
"normalize-url": "^8.1.1",
|
||||
"responselike": "^4.0.2"
|
||||
@@ -7527,25 +7507,6 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/cacheable-request/node_modules/keyv": {
|
||||
"version": "5.6.0",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz",
|
||||
"integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@keyv/serialize": "^1.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/cacheable/node_modules/keyv": {
|
||||
"version": "5.6.0",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz",
|
||||
"integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@keyv/serialize": "^1.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
@@ -7733,6 +7694,18 @@
|
||||
"devtools-protocol": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/chunk-data": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/chunk-data/-/chunk-data-0.1.0.tgz",
|
||||
"integrity": "sha512-zFyPtyC0SZ6Zu79b9sOYtXZcgrsXe0RpePrzRyj52hYVFG1+Rk6rBqjjOEk+GNQwc3PIX+86teQMok970pod1g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/cli-width": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz",
|
||||
@@ -9438,15 +9411,6 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/form-data-encoder": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-4.1.0.tgz",
|
||||
"integrity": "sha512-G6NsmEW15s0Uw9XnCg+33H3ViYRyiM0hMrMhhqQOR8NFc5GhYrI+6I3u7OTw7b91J2g8rtvMBZJDbcGb2YUniw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/fraction.js": {
|
||||
"version": "5.3.4",
|
||||
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
|
||||
@@ -9779,38 +9743,44 @@
|
||||
}
|
||||
},
|
||||
"node_modules/got": {
|
||||
"version": "14.6.6",
|
||||
"resolved": "https://registry.npmjs.org/got/-/got-14.6.6.tgz",
|
||||
"integrity": "sha512-QLV1qeYSo5l13mQzWgP/y0LbMr5Plr5fJilgAIwgnwseproEbtNym8xpLsDzeZ6MWXgNE6kdWGBjdh3zT/Qerg==",
|
||||
"version": "15.1.0",
|
||||
"resolved": "https://registry.npmjs.org/got/-/got-15.1.0.tgz",
|
||||
"integrity": "sha512-DG+DAAkRtno+oDr/GBsliAkhN9+zczOPM5qXk3efDZY3qvyRnZU+NwQlb/IOY6cSnxxTR9z3aHCcShJyjb0hJA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sindresorhus/is": "^7.0.1",
|
||||
"@sindresorhus/is": "^8.0.0",
|
||||
"byte-counter": "^0.1.0",
|
||||
"cacheable-lookup": "^7.0.0",
|
||||
"cacheable-request": "^13.0.12",
|
||||
"cacheable-request": "^13.0.18",
|
||||
"chunk-data": "^0.1.0",
|
||||
"decompress-response": "^10.0.0",
|
||||
"form-data-encoder": "^4.0.2",
|
||||
"http2-wrapper": "^2.2.1",
|
||||
"keyv": "^5.5.3",
|
||||
"lowercase-keys": "^3.0.0",
|
||||
"p-cancelable": "^4.0.1",
|
||||
"keyv": "^5.6.0",
|
||||
"lowercase-keys": "^4.0.1",
|
||||
"responselike": "^4.0.2",
|
||||
"type-fest": "^4.26.1"
|
||||
"type-fest": "^5.6.0",
|
||||
"uint8array-extras": "^1.5.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
"node": ">=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sindresorhus/got?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/got/node_modules/keyv": {
|
||||
"version": "5.6.0",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz",
|
||||
"integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==",
|
||||
"license": "MIT",
|
||||
"node_modules/got/node_modules/type-fest": {
|
||||
"version": "5.8.0",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.8.0.tgz",
|
||||
"integrity": "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==",
|
||||
"license": "(MIT OR CC0-1.0)",
|
||||
"dependencies": {
|
||||
"@keyv/serialize": "^1.1.1"
|
||||
"tagged-tag": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/graceful-fs": {
|
||||
@@ -10015,12 +9985,12 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/html-parse-stringify": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
|
||||
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-4.0.1.tgz",
|
||||
"integrity": "sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"void-elements": "3.1.0"
|
||||
"funding": {
|
||||
"url": "https://locize.com"
|
||||
}
|
||||
},
|
||||
"node_modules/html-tags": {
|
||||
@@ -10094,9 +10064,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "25.10.10",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.10.10.tgz",
|
||||
"integrity": "sha512-cqUW2Z3EkRx7NqSyywjkgCLK7KLCL6IFVFcONG7nVYIJ3ekZ1/N5jUsihHV6Bq37NfhgtczxJcxduELtjTwkuQ==",
|
||||
"version": "26.3.6",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz",
|
||||
"integrity": "sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -10112,11 +10082,8 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.29.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5 || ^6"
|
||||
"typescript": "^5 || ^6 || ^7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
@@ -10236,12 +10203,12 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ini": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz",
|
||||
"integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==",
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ini/-/ini-7.0.0.tgz",
|
||||
"integrity": "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "^20.17.0 || >=22.9.0"
|
||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/inline-style-parser": {
|
||||
@@ -10756,6 +10723,15 @@
|
||||
"setimmediate": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/keyv": {
|
||||
"version": "5.6.0",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz",
|
||||
"integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@keyv/serialize": "^1.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/kind-of": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
|
||||
@@ -10897,19 +10873,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/license-report": {
|
||||
"version": "6.8.2",
|
||||
"resolved": "https://registry.npmjs.org/license-report/-/license-report-6.8.2.tgz",
|
||||
"integrity": "sha512-eWzJujDhPm5bKTrolTBt8mvL6YW3c5SY1kpqnt7GmTLU01rOtzjqe3sevOQLF2dPY7dV+VnSZIWV774Cgqz/Eg==",
|
||||
"version": "6.8.5",
|
||||
"resolved": "https://registry.npmjs.org/license-report/-/license-report-6.8.5.tgz",
|
||||
"integrity": "sha512-mUOQD1C1yEHShuPeZBjv7y/EXn7kYEfq7roY1lQmVtYm4FCE81aMn65gGrtU7Cw+YRuwchkoMYegoF3wdeZNLQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@kessler/tableify": "^1.0.2",
|
||||
"debug": "^4.4.3",
|
||||
"eol": "^0.10.0",
|
||||
"find-up-simple": "^1.0.1",
|
||||
"got": "^14.6.6",
|
||||
"ini": "^6.0.0",
|
||||
"got": "^15.0.5",
|
||||
"ini": "^7.0.0",
|
||||
"rc": "^1.2.8",
|
||||
"semver": "^7.7.4",
|
||||
"semver": "^7.8.0",
|
||||
"tablemark": "^4.1.0",
|
||||
"text-table": "^0.2.0",
|
||||
"visit-values": "^2.0.0"
|
||||
@@ -11249,12 +11225,12 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lowercase-keys": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-3.0.0.tgz",
|
||||
"integrity": "sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==",
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-4.0.1.tgz",
|
||||
"integrity": "sha512-wI9Nui/L8VfADa/cr/7NQruaASk1k23/Uh1khQ02BCVYiiy8F4AhOGnQzJy3Fl/c44GnYSbZHv8g7EcG3kJ1Qg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
@@ -12684,15 +12660,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/p-cancelable": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-4.0.1.tgz",
|
||||
"integrity": "sha512-wBowNApzd45EIKdO1LaU+LrMBwAcjfPaYtVzV3lmfM3gf8Z4CHZsiIqlM8TZZ8okYvh5A1cP6gTfCRQtwUpaUg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.16"
|
||||
}
|
||||
},
|
||||
"node_modules/p-map": {
|
||||
"version": "7.0.4",
|
||||
"resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz",
|
||||
@@ -13846,19 +13813,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-i18next": {
|
||||
"version": "16.6.6",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.6.6.tgz",
|
||||
"integrity": "sha512-ZgL2HUoW34UKUkOV7uSQFE1CDnRPD+tCR3ywSuWH7u2iapnz86U8Bi3Vrs620qNDzCf1F47NxglCEkchCTDOHw==",
|
||||
"version": "17.0.11",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.11.tgz",
|
||||
"integrity": "sha512-cDtkXgxjuFTWUH6V+aQn1Ve5vDiUztCNPWW5GtSHDccsgRXO1nE6QFWCEmc1KAutrb3OUv87wFShJL5RhUwPXg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.29.2",
|
||||
"html-parse-stringify": "^3.0.1",
|
||||
"html-parse-stringify": "^4.0.1",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"i18next": ">= 25.10.9",
|
||||
"i18next": ">= 26.2.0",
|
||||
"react": ">= 16.8.0",
|
||||
"typescript": "^5 || ^6"
|
||||
"typescript": "^5 || ^6 || ^7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
@@ -14500,6 +14467,18 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/responselike/node_modules/lowercase-keys": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-3.0.0.tgz",
|
||||
"integrity": "sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/rettime": {
|
||||
"version": "0.11.11",
|
||||
"resolved": "https://registry.npmjs.org/rettime/-/rettime-0.11.11.tgz",
|
||||
@@ -14803,9 +14782,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||
"version": "7.8.5",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
@@ -16308,7 +16287,6 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz",
|
||||
"integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
@@ -16717,6 +16695,18 @@
|
||||
"tsc6": "bin/tsc6"
|
||||
}
|
||||
},
|
||||
"node_modules/uint8array-extras": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz",
|
||||
"integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.16.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||
@@ -17462,15 +17452,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/void-elements": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
|
||||
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vue": {
|
||||
"version": "3.5.39",
|
||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.39.tgz",
|
||||
|
||||
@@ -59,10 +59,10 @@
|
||||
"autoprefixer": "^10.4.21",
|
||||
"axios": "^1.15.0",
|
||||
"d3": "^7.9.0",
|
||||
"i18next": "^25.10.10",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"i18next": "^26.3.6",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"jszip": "^3.10.1",
|
||||
"license-report": "^6.8.0",
|
||||
"license-report": "^6.8.5",
|
||||
"pdfjs-dist": "^5.4.149",
|
||||
"peerjs": "^1.5.5",
|
||||
"pixelmatch": "^7.1.0",
|
||||
@@ -71,7 +71,7 @@
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-easy-crop": "^5.5.6",
|
||||
"react-i18next": "^16.6.6",
|
||||
"react-i18next": "^17.0.11",
|
||||
"react-markdown": "^9.0.3",
|
||||
"react-rnd": "^10.5.2",
|
||||
"react-router-dom": "^7.9.1",
|
||||
|
||||