Merge branch 'main' into dropdown-lot-of-dropdowns

This commit is contained in:
brios
2026-08-24 14:20:25 +02:00
committed by GitHub
123 changed files with 2905 additions and 1136 deletions
+2 -13
View File
@@ -22,26 +22,15 @@ indent_size = 4
[*.html]
indent_size = 2
insert_final_newline = false
trim_trailing_whitespace = false
[{*.js,*.jsx,*.mjs,*.ts,*.tsx}]
[{*.js,*.jsx,*.mjs,*.ts,*.tsx,*.mts}]
indent_size = 2
[*.css]
# CSS files typically use an indent size of 2 spaces for better readability and alignment with community standards.
indent_size = 2
[*.{yml,yaml}]
# YAML files use an indent size of 2 spaces to maintain consistency with common YAML formatting practices.
indent_size = 2
insert_final_newline = false
trim_trailing_whitespace = false
[*.json]
# JSON files use an indent size of 2 spaces, which is the standard for JSON formatting.
indent_size = 2
[*.jsonc]
# JSONC (JSON with comments) files also follow the standard JSON formatting with an indent size of 2 spaces.
[*.{json,jsonc}]
indent_size = 2
+1 -1
View File
@@ -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: |
+1 -1
View File
@@ -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: |
+1 -1
View File
@@ -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: |
+1 -1
View File
@@ -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: |
+1 -1
View File
@@ -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: |
+1 -1
View File
@@ -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: |
+1 -1
View File
@@ -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: |
+1 -1
View File
@@ -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: |
+1 -1
View File
@@ -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: |
+1 -1
View File
@@ -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: |
+1 -1
View File
@@ -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: |
+1 -1
View File
@@ -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: |
+1 -1
View File
@@ -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: |
+3 -1
View File
@@ -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/*"
]
}
+5 -4
View File
@@ -375,13 +375,13 @@ tasks:
desc: "Auto-fix code formatting"
deps: [install]
cmds:
- npx prettier --write .
- npx oxfmt --write .
format:check:
desc: "Check code formatting"
deps: [install]
cmds:
- npx prettier --check .
- npx oxfmt --check .
fix:
desc: "Auto-fix lint and format"
@@ -554,6 +554,7 @@ tasks:
deps: [install, ":backend:swagger"]
cmds:
- npx tsx editor/scripts/generate-tool-api-types.mts --spec ../SwaggerDoc.json --output editor/src/core/types/toolApiTypes.ts --io-output editor/src/core/types/toolIO.ts
- task: format
sources:
- editor/scripts/generate-tool-api-types.mts
- ../SwaggerDoc.json
@@ -563,9 +564,9 @@ tasks:
tool-models:check:
desc: "Fail if committed tool API types are out of date"
deps: [install, ":backend:swagger"]
cmds:
- npx tsx editor/scripts/generate-tool-api-types.mts --spec ../SwaggerDoc.json --output editor/src/core/types/toolApiTypes.ts --io-output editor/src/core/types/toolIO.ts --check
- task: tool-models
- git diff --exit-code -- editor/src/core/types/toolApiTypes.ts editor/src/core/types/toolIO.ts
licenses:generate:
desc: "Generate frontend license report"
+2 -2
View File
@@ -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,10 @@ 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(
@@ -73,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;
}
@@ -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
+1 -1
View File
@@ -30,7 +30,7 @@ ext {
openSamlVersion = "5.2.1"
commonmarkVersion = "0.28.0"
googleJavaFormatVersion = "1.35.0"
logback = "1.6.1"
logback = "1.6.3"
commonsIoVersion = "2.22.0"
commonsLang3 = "3.20.0"
rhinoVersion = "1.9.1"
+33
View File
@@ -0,0 +1,33 @@
{
"printWidth": 80,
"tabWidth": 2,
"useTabs": false,
"endOfLine": "lf",
"sortPackageJson": false,
"ignorePatterns": [
"dist/",
"editor/dist/",
"editor/src-tauri/**/target/",
"editor/src-tauri/gen/",
"node_modules/",
"editor/public/vendor/",
"editor/public/mockServiceWorker.js",
"editor/public/og-metadata.json",
"editor/public/og-metadata.saas.json",
"editor/src/core/data/ogImageMap.json",
"editor/src/portal/generated/docsManifest.json",
"editor/public/pdfjs*/",
"editor/public/js/thirdParty/",
"editor/public/css/cookieconsent.css",
"storybook-static/",
"playwright-report/",
"editor/playwright-report/",
"test-results/",
"editor/test-results/",
"*.min.*",
"*.md",
"*.wxs",
"*.toml",
"editor/src/output.css"
]
}
-30
View File
@@ -1,30 +0,0 @@
dist/
editor/dist/
# Tauri/Cargo build output (binary assets named *.js etc. confuse Prettier).
# Match nested target/ dirs too - provisioner/ and thumbnail-handler/ each
# have their own Cargo workspace under src-tauri/.
editor/src-tauri/**/target/
editor/src-tauri/gen/
node_modules/
editor/public/vendor/
# Auto-generated by MSW (`msw init`); regenerated verbatim, not hand-formatted.
editor/public/mockServiceWorker.js
# Auto-generated OG/social-preview metadata (scripts/generate-og-metadata.mjs); regenerated verbatim.
editor/public/og-metadata.json
editor/public/og-metadata.saas.json
editor/src/core/data/ogImageMap.json
# Auto-generated portal docs manifest (scripts/sync-portal-docs.mts); regenerated verbatim.
editor/src/portal/generated/docsManifest.json
editor/public/pdfjs*/
editor/public/js/thirdParty/
editor/public/css/cookieconsent.css
# Build / test artifacts that may exist locally even though they're gitignored
storybook-static/
playwright-report/
editor/playwright-report/
test-results/
editor/test-results/
*.min.*
*.md
*.wxs
editor/src/output.css
-6
View File
@@ -1,6 +0,0 @@
{
"printWidth": 80,
"tabWidth": 2,
"useTabs": false,
"endOfLine": "lf"
}
+1 -1
View File
@@ -18,7 +18,7 @@ For desktop app development, see the [Tauri](#tauri) section below.
`frontend/` is a workspace containing one or more apps. Today it holds the
PDF editor under `frontend/editor/`; new apps (the developer portal, etc.)
will sit alongside it as siblings. Shared tooling — `package.json`, `node_modules`,
`.storybook/`, oxlint, Prettier — lives at `frontend/` so every app installs
`.storybook/`, oxlint, oxfmt — lives at `frontend/` so every app installs
once and lints with the same config.
## Environment Variables
+4 -1
View File
@@ -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"
@@ -10,7 +10,6 @@ import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { parseArgs } from "node:util";
import { compile, type JSONSchema } from "json-schema-to-typescript";
import * as prettier from "prettier";
// The API namespaces whose endpoints a pipeline can reference. `/api/v1/ai/tools/`
// is absent from the spec, so it cannot appear here. Extend this list when other
@@ -227,11 +226,7 @@ function collectToolIO(
return { table, dropped };
}
async function renderToolIO(
spec: Json,
table: Record<string, unknown>,
outputPath: string,
): Promise<string> {
function renderToolIO(spec: Json, table: Record<string, unknown>): string {
if (Object.keys(table).length === 0) {
throw new Error(
`No ${IO_EXTENSION} declarations in the spec. The backend publishes these from @ToolIO; regenerate with 'task backend:swagger'.`,
@@ -312,33 +307,12 @@ export function toolIOFor(
}
`;
const prettierConfig = await prettier.resolveConfig(outputPath);
return prettier.format(body, { ...prettierConfig, parser: "typescript" });
return body;
}
/** In check mode, fail when the committed file is out of date. */
function writeOrCheck(
outputPath: string,
formatted: string,
check: boolean,
task: string,
): void {
if (check) {
let current = "";
try {
current = readFileSync(outputPath, "utf-8");
} catch {
// Missing file counts as out of date.
}
if (current !== formatted) {
throw new Error(
`${outputPath} is out of date. Run '${task}' and commit the result.`,
);
}
return;
}
function writeOutput(outputPath: string, contents: string): void {
mkdirSync(dirname(outputPath), { recursive: true });
writeFileSync(outputPath, formatted, "utf-8");
writeFileSync(outputPath, contents, "utf-8");
}
async function main(): Promise<void> {
@@ -347,12 +321,11 @@ async function main(): Promise<void> {
spec: { type: "string" },
output: { type: "string" },
"io-output": { type: "string" },
check: { type: "boolean", default: false },
},
});
if (!values.spec || !values.output || !values["io-output"]) {
throw new Error(
"Usage: generate-tool-api-types.mts --spec <SwaggerDoc.json> --output <file.ts> --io-output <file.ts> [--check]",
"Usage: generate-tool-api-types.mts --spec <SwaggerDoc.json> --output <file.ts> --io-output <file.ts>",
);
}
const specPath = resolve(values.spec);
@@ -478,14 +451,9 @@ async function main(): Promise<void> {
`Dropped ${dropped.length} @ToolIO declaration(s) on paths that are not tool endpoints. Add the namespace to ALLOWED_PATH_PREFIXES if a pipeline can contain these steps:\n ${dropped.join("\n ")}`,
);
}
writeOrCheck(
ioOutputPath,
await renderToolIO(spec, ioDeclarations, ioOutputPath),
values.check ?? false,
"task frontend:tool-models",
);
writeOutput(ioOutputPath, renderToolIO(spec, ioDeclarations));
console.log(
`${values.check ? "Up to date" : "Generated"}: ${Object.keys(ioDeclarations).length} tool I/O declarations.`,
`Generated ${Object.keys(ioDeclarations).length} tool I/O declarations.`,
);
// Transitively inline every referenced component into `definitions`, rewriting its refs too.
@@ -508,7 +476,6 @@ async function main(): Promise<void> {
definitions,
fileFieldsByClass,
outputPath,
values.check ?? false,
skipped,
);
}
@@ -518,7 +485,6 @@ async function compileAndWrite(
definitions: Record<string, Json>,
fileFieldsByClass: Record<string, string[]>,
outputPath: string,
check: boolean,
skipped: string[],
): Promise<void> {
// json-schema-to-typescript only emits a named, exported interface per schema
@@ -597,17 +563,9 @@ async function compileAndWrite(
].join("\n");
const body = `${FILE_HEADER}\n\n${models}\n\n${footer}\n`;
const prettierConfig = await prettier.resolveConfig(outputPath);
const formatted = await prettier.format(body, {
...prettierConfig,
parser: "typescript",
});
writeOrCheck(outputPath, formatted, check, "task frontend:tool-models");
console.log(
`${check ? "Up to date" : "Generated"}: ${tools.length} tool endpoints.`,
);
if (!check && skipped.length > 0) {
writeOutput(outputPath, body);
console.log(`Generated ${tools.length} tool endpoints.`);
if (skipped.length > 0) {
console.log(
`Skipped ${skipped.length} POST endpoint(s) with no request body: ${skipped.join(", ")}`,
);
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 681 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 758 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

+3 -8
View File
@@ -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"
]
}
}
@@ -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",
)}
>
&times;
</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",
)}
>
&times;
</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}
@@ -153,7 +153,7 @@ const BulkShareModal: React.FC<BulkShareModalProps> = ({
if (onShared) {
await onShared();
}
} catch (error: any) {
} catch (error: unknown) {
console.error("Failed to generate share link:", error);
setErrorMessage(
t(
@@ -1,5 +1,6 @@
import { Stack, Card, Text, Flex } from "@mantine/core";
import { Tooltip } from "@app/components/shared/Tooltip";
import { TooltipTip } from "@app/types/tips";
import { useTranslation } from "react-i18next";
export interface CardOption<T = string> {
@@ -7,14 +8,14 @@ export interface CardOption<T = string> {
prefixKey: string;
nameKey: string;
tooltipKey?: string;
tooltipContent?: any[];
tooltipContent?: TooltipTip[];
}
export interface CardSelectorProps<T, K extends CardOption<T>> {
options: K[];
onSelect: (value: T) => void;
disabled?: boolean;
getTooltipContent?: (option: K) => any[];
getTooltipContent?: (option: K) => TooltipTip[];
}
const CardSelector = <T, K extends CardOption<T>>({
@@ -269,7 +269,7 @@ export function FileSelectorPicker({
responseType: "blob",
suppressErrorToast: true,
skipAuthRedirect: true,
} as any,
},
);
const ct = readResponseHeader(res.headers, "content-type");
const disp = readResponseHeader(res.headers, "content-disposition");
@@ -287,7 +287,7 @@ export function FileSelectorPicker({
responseType: "blob",
suppressErrorToast: true,
skipAuthRedirect: true,
} as any,
},
);
const ct = readResponseHeader(res.headers, "content-type");
const disp = readResponseHeader(res.headers, "content-disposition");
@@ -208,7 +208,7 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
const openWatchedFolders = useCallback(() => {
if (collapsed && onToggleCollapse) onToggleCollapse();
setCustomWorkbenchViewData(WATCHED_FOLDER_VIEW_ID, { folderId: null });
navActions.setWorkbench(WATCHED_FOLDER_WORKBENCH_ID as any);
navActions.setWorkbench(WATCHED_FOLDER_WORKBENCH_ID);
}, [collapsed, onToggleCollapse, setCustomWorkbenchViewData, navActions]);
// Clicking a file's membership dot jumps straight into that folder.
@@ -216,7 +216,7 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
(folderId: string) => {
if (collapsed && onToggleCollapse) onToggleCollapse();
setCustomWorkbenchViewData(WATCHED_FOLDER_VIEW_ID, { folderId });
navActions.setWorkbench(WATCHED_FOLDER_WORKBENCH_ID as any);
navActions.setWorkbench(WATCHED_FOLDER_WORKBENCH_ID);
},
[collapsed, onToggleCollapse, setCustomWorkbenchViewData, navActions],
);
@@ -1,4 +1,5 @@
import { useState } from "react";
import axios from "axios";
import { Modal, Stack, Text, PasswordInput, Alert } from "@mantine/core";
import { Button } from "@app/ui/Button";
import { useTranslation } from "react-i18next";
@@ -95,10 +96,13 @@ export default function FirstLoginModal({
setTimeout(() => {
onPasswordChanged();
}, 1500);
} catch (err: any) {
} catch (err: unknown) {
console.error("Failed to change password:", err);
const message = axios.isAxiosError<{ message?: string }>(err)
? err.response?.data?.message
: undefined;
setError(
err.response?.data?.message ||
message ||
t(
"firstLogin.passwordChangeFailed",
"Failed to change password. Please check your current password.",
@@ -1,4 +1,4 @@
import React, { CSSProperties, useMemo, useRef } from "react";
import React, { CSSProperties, useCallback, useMemo, useRef } from "react";
import { useAdjustFontSizeToFit } from "@app/components/shared/fitText/textFit";
type FitTextProps = {
@@ -28,8 +28,14 @@ const FitText: React.FC<FitTextProps> = ({
}) => {
const ref = useRef<HTMLElement | null>(null);
// Callback ref: an HTMLElement handler satisfies span's/div's differing ref
// types (callback refs are contravariant), so the tag can stay polymorphic.
const setRef = useCallback((node: HTMLElement | null) => {
ref.current = node;
}, []);
// Hook runs after mount and on size/text changes; uses observers internally
useAdjustFontSizeToFit(ref as any, {
useAdjustFontSizeToFit(ref, {
maxFontSizePx: fontSize,
minFontScale: minimumFontScale,
maxLines: lines,
@@ -38,7 +44,7 @@ const FitText: React.FC<FitTextProps> = ({
// Memoize the HTML tag to render (span/div) from the `as` prop so
// React doesn't create a new component function on each render.
const ElementTag: any = useMemo(() => as, [as]);
const ElementTag: React.ElementType = useMemo(() => as, [as]);
// For the / character, insert zero-width soft breaks to prefer wrapping at them
const displayText = useMemo(() => {
@@ -70,7 +76,7 @@ const FitText: React.FC<FitTextProps> = ({
return (
<ElementTag
ref={ref}
ref={setRef}
className={className}
style={{ ...clampStyles, ...style }}
>
@@ -165,7 +165,7 @@ const ShareFileModal: React.FC<ShareFileModalProps> = ({
if (onUploaded) {
await onUploaded();
}
} catch (error: any) {
} catch (error: unknown) {
console.error("Failed to generate share link:", error);
setErrorMessage(
t(
@@ -226,7 +226,7 @@ const ShareManagementModal: React.FC<ShareManagementModalProps> = ({
durationMs: 2500,
});
}
} catch (error: any) {
} catch (error: unknown) {
console.error("Failed to create share link:", error);
setErrorMessage(
t(
@@ -18,6 +18,18 @@ import { useLogoAssets } from "@app/hooks/useLogoAssets";
import styles from "@app/components/shared/tooltip/Tooltip.module.css";
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from "@app/styles/zIndex";
// The wrapped child's own event handlers, which Tooltip forwards to after
// running its own trigger logic. Kept partial since any given child may set none.
interface ForwardedHandlers {
onPointerEnter?: React.PointerEventHandler;
onPointerLeave?: React.PointerEventHandler;
onMouseDown?: React.MouseEventHandler;
onMouseUp?: React.MouseEventHandler;
onClick?: React.MouseEventHandler;
onFocus?: React.FocusEventHandler;
onBlur?: React.FocusEventHandler;
}
export interface TooltipProps {
sidebarTooltip?: boolean;
position?: "right" | "left" | "top" | "bottom";
@@ -218,7 +230,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
const handlePointerEnter = useCallback(
(e: React.PointerEvent) => {
if (!isPinned && !disabled) openWithDelay();
(children.props as any)?.onPointerEnter?.(e);
(children.props as ForwardedHandlers).onPointerEnter?.(e);
},
[isPinned, openWithDelay, children.props, disabled],
);
@@ -233,19 +245,19 @@ export const Tooltip: React.FC<TooltipProps> = ({
tooltipRef.current &&
tooltipRef.current.contains(related)
) {
(children.props as any)?.onPointerLeave?.(e);
(children.props as ForwardedHandlers).onPointerLeave?.(e);
return;
}
// Ignore transient leave between mousedown and click
if (clickPendingRef.current) {
(children.props as any)?.onPointerLeave?.(e);
(children.props as ForwardedHandlers).onPointerLeave?.(e);
return;
}
clearTimers();
if (allowAutoClose && !isPinned) setOpen(false);
(children.props as any)?.onPointerLeave?.(e);
(children.props as ForwardedHandlers).onPointerLeave?.(e);
},
[clearTimers, isPinned, setOpen, children.props, allowAutoClose],
);
@@ -253,7 +265,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
clickPendingRef.current = true;
(children.props as any)?.onMouseDown?.(e);
(children.props as ForwardedHandlers).onMouseDown?.(e);
},
[children.props],
);
@@ -262,7 +274,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
(e: React.MouseEvent) => {
// allow microtask turn so click can see this false
queueMicrotask(() => (clickPendingRef.current = false));
(children.props as any)?.onMouseUp?.(e);
(children.props as ForwardedHandlers).onMouseUp?.(e);
},
[children.props],
);
@@ -279,7 +291,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
return;
}
clickPendingRef.current = false;
(children.props as any)?.onClick?.(e);
(children.props as ForwardedHandlers).onClick?.(e);
},
[clearTimers, pinOnClick, open, setOpen, children.props],
);
@@ -288,7 +300,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
const handleFocus = useCallback(
(e: React.FocusEvent) => {
if (!isPinned && !disabled && openOnFocus) openWithDelay();
(children.props as any)?.onFocus?.(e);
(children.props as ForwardedHandlers).onFocus?.(e);
},
[isPinned, openWithDelay, children.props, disabled, openOnFocus],
);
@@ -301,12 +313,12 @@ export const Tooltip: React.FC<TooltipProps> = ({
tooltipRef.current &&
tooltipRef.current.contains(related)
) {
(children.props as any)?.onBlur?.(e);
(children.props as ForwardedHandlers).onBlur?.(e);
return;
}
clearTimers();
if (allowAutoClose && !isPinned) setOpen(false);
(children.props as any)?.onBlur?.(e);
(children.props as ForwardedHandlers).onBlur?.(e);
},
[isPinned, setOpen, children.props, allowAutoClose, clearTimers],
);
@@ -339,24 +351,30 @@ export const Tooltip: React.FC<TooltipProps> = ({
);
// Enhance child with handlers and ref
const childWithHandlers = React.cloneElement(children as any, {
ref: (node: HTMLElement | null) => {
triggerRef.current = node || null;
const originalRef = (children as any).ref;
if (typeof originalRef === "function") originalRef(node);
else if (originalRef && typeof originalRef === "object")
(originalRef as any).current = node;
const childWithHandlers = React.cloneElement(
children as React.ReactElement<Record<string, unknown>>,
{
ref: (node: HTMLElement | null) => {
triggerRef.current = node || null;
const originalRef = (
children as React.ReactElement & { ref?: React.Ref<HTMLElement> }
).ref;
if (typeof originalRef === "function") originalRef(node);
else if (originalRef && typeof originalRef === "object")
(originalRef as React.MutableRefObject<HTMLElement | null>).current =
node;
},
"aria-describedby": open ? tooltipIdRef.current : undefined,
onPointerEnter: handlePointerEnter,
onPointerLeave: handlePointerLeave,
onMouseDown: handleMouseDown,
onMouseUp: handleMouseUp,
onClick: handleClick,
onFocus: handleFocus,
onBlur: handleBlur,
onKeyDown: handleKeyDown,
},
"aria-describedby": open ? tooltipIdRef.current : undefined,
onPointerEnter: handlePointerEnter,
onPointerLeave: handlePointerLeave,
onMouseDown: handleMouseDown,
onMouseUp: handleMouseUp,
onClick: handleClick,
onFocus: handleFocus,
onBlur: handleBlur,
onKeyDown: handleKeyDown,
});
);
const shouldShowTooltip = open;
const shouldShowCloseButton = showCloseButton || isPinned;
@@ -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,101 @@
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;
const showPrint = currentView === "viewer";
const showFileActions = !isCustomView;
// Custom workbench views own their content, so none of these apply. The
// desktop cluster renders nothing at all in that case; without this the
// trigger would still be there, opening an empty dropdown.
if (!showPrint && !showFileActions) return null;
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>
{showPrint && (
<Menu.Item
leftSection={<PrintIcon sx={{ fontSize: "1.1rem" }} />}
disabled={exportDisabled}
onClick={onPrint}
>
{t("workbenchBar.print", "Print PDF")}
</Menu.Item>
)}
{showFileActions && (
<Menu.Item
leftSection={
<LocalIcon
icon={downloadIconName}
width="1.1rem"
height="1.1rem"
/>
}
disabled={exportDisabled}
onClick={() => void onExport()}
>
{downloadLabel}
</Menu.Item>
)}
{showFileActions && 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>
)}
{showFileActions && (
<>
<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,13 +275,16 @@ export default function RightSidebar() {
)}
</div>
</div>
)}
) : null}
<ToolPanel
allToolsView={allToolsView}
onShowAllTools={handleShowAllTools}
onToolSelect={handleToolSelectWithTransition}
compact={false}
/* Mobile keeps the workbench bar - and with it the super search -
on the other slide, so the list needs its own filter. */
showSearch={isMobile}
/>
</>
</div>
@@ -169,6 +169,31 @@
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;
}
}
/* In-panel tool filter. Aligned with .tool-picker__compact's inline padding so
the field lines up with the tool rows underneath it. */
.tool-panel__search {
flex-shrink: 0;
padding: 0.5rem var(--mantine-spacing-sm) 0;
}
.tool-panel__search .search-input-container {
margin: 0;
}
.tool-panel__compact-header-actions {
display: flex;
align-items: center;
@@ -4,6 +4,7 @@ import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import ToolPicker from "@app/components/tools/ToolPicker";
import SearchResults from "@app/components/tools/SearchResults";
import ToolRenderer from "@app/components/tools/ToolRenderer";
import ToolSearch from "@app/components/tools/toolPicker/ToolSearch";
import { ToolPanelViewerBar } from "@app/components/tools/ToolPanelViewerBar";
import { ToolId } from "@app/types/toolId";
@@ -20,6 +21,11 @@ interface ToolPanelProps {
onToolSelect?: (id: ToolId) => void;
/** Whether to render the compact (favourites + recommended only) view. */
compact?: boolean;
/**
* Render a tool filter at the head of the panel. Set where the workbench
* bar's super search is out of reach, so the list stays searchable in place.
*/
showSearch?: boolean;
}
/** Tool list and renderer for the right rail; rail chrome lives in RightSidebar. */
@@ -28,24 +34,44 @@ export default function ToolPanel({
onShowAllTools,
onToolSelect,
compact: compactProp,
showSearch = false,
}: ToolPanelProps) {
const { t } = useTranslation();
const {
leftPanelView,
searchQuery,
setSearchQuery,
filteredTools,
toolRegistry,
selectedToolKey,
handleToolSelect,
setPreviewFile,
} = useToolWorkflow();
const selectTool = onToolSelect ?? handleToolSelect;
// Only offer the filter over the list itself; once a tool is open the panel
// belongs to that tool. Deriving the results branch from the same flag keeps
// the input and what it filters from drifting apart.
const panelSearch = showSearch && leftPanelView === "toolPicker";
const searching = searchQuery.trim().length > 0;
return (
<>
{/* Viewer mode tools — annotate, redact, form fill */}
<ToolPanelViewerBar />
{allToolsView && searchQuery.trim().length > 0 ? (
{panelSearch && (
<div className="tool-panel__search">
<ToolSearch
value={searchQuery}
onChange={setSearchQuery}
toolRegistry={toolRegistry}
mode="filter"
/>
</div>
)}
{searching && (allToolsView || panelSearch) ? (
<div className="flex-1 flex flex-col overflow-y-auto">
<SearchResults
filteredTools={filteredTools}
@@ -59,7 +85,7 @@ export default function ToolPanel({
selectedToolKey={selectedToolKey}
onSelect={(id) => selectTool(id as ToolId)}
filteredTools={filteredTools}
isSearching={Boolean(searchQuery && searchQuery.trim().length > 0)}
isSearching={searching}
compact={compactProp ?? !allToolsView}
onShowAllTools={onShowAllTools}
/>
@@ -82,7 +82,7 @@ export default function AutomationCreation({
setConfigModalOpen(true);
};
const handleToolConfigSave = (parameters: Record<string, any>) => {
const handleToolConfigSave = (parameters: Record<string, unknown>) => {
if (configuraingToolIndex >= 0) {
updateTool(configuraingToolIndex, {
configured: true,
@@ -19,7 +19,7 @@ interface AutomationEntryProps {
/** Optional description for tooltip */
description?: string;
/** MUI Icon component for the badge */
badgeIcon?: React.ComponentType<any>;
badgeIcon?: React.ComponentType;
/** Array of tool operation names in the workflow */
operations: string[];
/** Click handler */
@@ -9,11 +9,12 @@ import { useToolRegistry } from "@app/contexts/ToolRegistryContext";
import { AutomationConfig, ExecutionStep } from "@app/types/automation";
import { EXECUTION_STATUS } from "@app/constants/automation";
import { useResourceCleanup } from "@app/utils/resourceManager";
import type { useAutomateOperation } from "@app/hooks/tools/automate/useAutomateOperation";
interface AutomationRunProps {
automation: AutomationConfig;
onComplete: () => void;
automateOperation?: any; // TODO: Type this properly when available
automateOperation?: ReturnType<typeof useAutomateOperation>;
}
export default function AutomationRun({
@@ -34,13 +35,13 @@ export default function AutomationRun({
// Use the operation hook's loading state
const isExecuting = automateOperation?.isLoading || false;
const hasResults =
automateOperation?.files.length > 0 ||
(automateOperation?.files.length ?? 0) > 0 ||
automateOperation?.downloadUrl !== null;
// Initialize execution steps from automation
useEffect(() => {
if (automation?.operations) {
const steps = automation.operations.map((op: any, index: number) => {
const steps = automation.operations.map((op, index) => {
const tool = toolRegistry[op.operation as keyof typeof toolRegistry];
return {
id: `${op.operation}-${index}`,
@@ -125,7 +126,7 @@ export default function AutomationRun({
// Mark all as completed and reset current step
setCurrentStepIndex(-1);
console.log(`✅ Automation completed successfully`);
} catch (error: any) {
} catch (error: unknown) {
console.error("Automation execution failed:", error);
setCurrentStepIndex(-1);
}
@@ -4,6 +4,7 @@ import { Stack, Text, ScrollArea } from "@mantine/core";
import {
ToolRegistryEntry,
ToolRegistry,
SubcategoryId,
getToolSupportsAutomate,
} from "@app/data/toolsTaxonomy";
import { useToolSections } from "@app/hooks/useToolSections";
@@ -81,9 +82,7 @@ export default function ToolSelector({
}, [filteredTools]);
// Use the same tool sections logic as the main ToolPicker
const { sections, searchGroups } = useToolSections(
transformedFilteredTools as any /* FIX ME */,
);
const { sections, searchGroups } = useToolSections(transformedFilteredTools);
// Determine what to display: search results or organized sections
const isSearching = searchTerm.trim().length > 0;
@@ -98,7 +97,9 @@ export default function ToolSelector({
return [
{
name: "Tools",
subcategoryId: "all" as any,
// Synthetic "all tools" group used only as a fallback when the
// taxonomy produces no sections; "all" is not a real SubcategoryId.
subcategoryId: "all" as unknown as SubcategoryId,
tools: baseFilteredTools.map(([key, tool]) => ({ id: key, tool })),
},
];
@@ -125,7 +126,7 @@ export default function ToolSelector({
displayGroups.map((subcategory) =>
renderToolButtons(
t,
subcategory as any,
subcategory,
null,
handleToolSelect,
!isSearching,
@@ -13,6 +13,7 @@ import {
useFileActions,
} from "@app/contexts/FileContext";
import { useFileWithUrl } from "@app/hooks/useFileWithUrl";
import { ZoomMode } from "@embedpdf/plugin-zoom/react";
import { useViewer } from "@app/contexts/ViewerContext";
import { LocalEmbedPDF } from "@app/components/viewer/LocalEmbedPDF";
import { PdfViewerToolbar } from "@app/components/viewer/PdfViewerToolbar";
@@ -430,7 +431,7 @@ const EmbedPdfViewerContent = ({
return;
case "0":
event.preventDefault();
zoomActions.requestZoom("fit-width");
zoomActions.requestZoom(ZoomMode.FitWidth);
return;
}
}
@@ -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}
@@ -118,7 +118,6 @@ export function SelectionAPIBridge() {
const buildApi = () => ({
copyToClipboard: () => selection.copyToClipboard(),
getSelectedText: () => selection.getSelectedText(),
getFormattedSelection: () => selection.getFormattedSelection(),
selectAll: async (totalPages: number) => {
const docId = activeDocumentId;
@@ -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>
);
@@ -6,6 +6,8 @@ import {
ZoomState,
} from "@app/contexts/viewer/viewerBridges";
import { PdfBookmarkObject, PdfAttachmentObject } from "@embedpdf/models";
import { ZoomLevel, Point } from "@embedpdf/plugin-zoom";
import { FormattedSelection } from "@embedpdf/plugin-selection";
export interface ScrollActions {
scrollToPage: (page: number, behavior?: "smooth" | "instant") => void;
@@ -19,7 +21,7 @@ export interface ZoomActions {
zoomIn: () => void;
zoomOut: () => void;
toggleMarqueeZoom: () => void;
requestZoom: (level: any, center?: any) => void;
requestZoom: (level: ZoomLevel, center?: Point) => void;
setZoomLevel: (factor: number) => void;
}
@@ -31,8 +33,7 @@ export interface PanActions {
export interface SelectionActions {
copyToClipboard: () => void;
getSelectedText: () => string;
getFormattedSelection: () => any;
getFormattedSelection: () => FormattedSelection[] | null;
selectAll: (totalPages: number) => Promise<boolean>;
selectWordAt: (pageIndex: number, x: number, y: number) => boolean;
}
@@ -51,7 +52,7 @@ export interface RotationActions {
}
export interface SearchActions {
search: (query: string) => Promise<any> | undefined;
search: (query: string) => Promise<unknown> | undefined;
next: () => void;
previous: () => void;
clear: () => void;
@@ -214,7 +215,7 @@ export function createViewerActions({
api.toggleMarqueeZoom();
}
},
requestZoom: (level: any, center?: any) => {
requestZoom: (level: ZoomLevel, center?: Point) => {
const api = registry.current.zoom?.api;
if (api?.requestZoom) {
api.requestZoom(level, center);
@@ -257,13 +258,6 @@ export function createViewerActions({
api.copyToClipboard();
}
},
getSelectedText: () => {
const api = registry.current.selection?.api;
if (api?.getSelectedText) {
return api.getSelectedText() ?? "";
}
return "";
},
getFormattedSelection: () => {
const api = registry.current.selection?.api;
if (api?.getFormattedSelection) {
@@ -1,5 +1,7 @@
import { SpreadMode } from "@embedpdf/plugin-spread/react";
import { PdfBookmarkObject, PdfAttachmentObject } from "@embedpdf/models";
import { ZoomLevel, Point } from "@embedpdf/plugin-zoom";
import { FormattedSelection } from "@embedpdf/plugin-selection";
export enum PdfPermissionFlag {
Print = 0x0004,
@@ -46,7 +48,7 @@ export interface ZoomAPIWrapper {
zoomIn: () => void;
zoomOut: () => void;
toggleMarqueeZoom: () => void;
requestZoom: (level: any, center?: any) => void;
requestZoom: (level: ZoomLevel, center?: Point) => void;
}
export interface PanAPIWrapper {
@@ -58,8 +60,7 @@ export interface PanAPIWrapper {
export interface SelectionAPIWrapper {
copyToClipboard: () => void;
getSelectedText: () => string | any;
getFormattedSelection: () => any;
getFormattedSelection: () => FormattedSelection[];
selectAll: (totalPages: number) => Promise<boolean>;
selectWordAt: (pageIndex: number, x: number, y: number) => boolean;
}
@@ -79,7 +80,7 @@ export interface RotationAPIWrapper {
}
export interface SearchAPIWrapper {
search: (query: string) => Promise<any>;
search: (query: string) => Promise<unknown>;
clear: () => void;
next: () => void;
previous: () => void;
+50 -10
View File
@@ -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;
}
+93 -46
View File
@@ -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");
@@ -11,11 +11,11 @@
*/
export function getApiBaseUrl(): string {
// Runtime override to fix hardcoded localhost in builds
if (
typeof window !== "undefined" &&
(window as any).STIRLING_PDF_API_BASE_URL
) {
return (window as any).STIRLING_PDF_API_BASE_URL;
if (typeof window !== "undefined") {
const override = window.STIRLING_PDF_API_BASE_URL;
if (override) {
return override;
}
}
return import.meta.env.VITE_API_BASE_URL;
@@ -17,7 +17,7 @@ export interface AuditEvent {
eventType: string;
username: string;
ipAddress: string;
details: Record<string, any>;
details: Record<string, unknown>;
}
export interface AuditEventsResponse {
@@ -8,7 +8,7 @@ export interface AutomationConfig {
description?: string;
operations: Array<{
operation: string;
parameters: any;
parameters: Record<string, unknown>;
}>;
createdAt: string;
updatedAt: string;
@@ -5,6 +5,7 @@ import {
ProcessingConfig,
ProcessingMetrics,
} from "@app/types/processing";
import type { PDFPageProxy } from "pdfjs-dist";
import { ProcessingCache } from "@app/services/processingCache";
import { FileHasher } from "@app/utils/fileHash";
import { FileAnalyzer } from "@app/services/fileAnalyzer";
@@ -415,7 +416,7 @@ export class EnhancedPDFProcessingService {
* Render a page thumbnail with specified quality
*/
private async renderPageThumbnail(
page: any,
page: PDFPageProxy,
quality: "low" | "medium" | "high",
): Promise<string> {
const scales = { low: 0.2, medium: 0.5, high: 0.8 }; // Reduced low quality for page editor
@@ -431,7 +432,7 @@ export class EnhancedPDFProcessingService {
throw new Error("Could not get canvas context");
}
await page.render({ canvasContext: context, viewport }).promise;
await page.render({ canvasContext: context, viewport, canvas }).promise;
return canvas.toDataURL("image/jpeg", 0.8); // Use JPEG for better compression
}

Some files were not shown because too many files have changed in this diff Show More