mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 13:20:08 +03:00
Compare commits
40
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db4903724c | ||
|
|
a1fb7c71c4 | ||
|
|
2e4bfc3a5b | ||
|
|
e65775319e | ||
|
|
9494ef9a26 | ||
|
|
23dc03832f | ||
|
|
b5f11c4efe | ||
|
|
cec414d11b | ||
|
|
5ca7e35ba9 | ||
|
|
144c8afb0c | ||
|
|
14834723c9 | ||
|
|
1d18b3db1a | ||
|
|
93ab2b4805 | ||
|
|
54805896d6 | ||
|
|
0a73467ce5 | ||
|
|
df056a8f2d | ||
|
|
34d9baa1be | ||
|
|
d86259c596 | ||
|
|
178c3c3331 | ||
|
|
39e0fb630c | ||
|
|
ed7a9d1a01 | ||
|
|
be69d071bc | ||
|
|
a8671ec1f7 | ||
|
|
9cb40ad6ff | ||
|
|
67fc78b6df | ||
|
|
b5b4bbc46b | ||
|
|
849899f58c | ||
|
|
a42fdfad00 | ||
|
|
770577edb6 | ||
|
|
eaed8feb42 | ||
|
|
a0f294e68e | ||
|
|
3b24b3479a | ||
|
|
fd1cf4468e | ||
|
|
04c5fbc590 | ||
|
|
d2f28e07ea | ||
|
|
2843418fb7 | ||
|
|
53377da75d | ||
|
|
93b1669f63 | ||
|
|
7dc164453b | ||
|
|
be85773852 |
@@ -107,6 +107,13 @@ tasks:
|
||||
- task: dev:_run
|
||||
vars: { MODE: prototypes, PORT: '{{.PORT}}', BACKEND_URL: '{{.BACKEND_URL}}', OPEN: '{{.OPEN}}' }
|
||||
|
||||
dev:portal:
|
||||
desc: "Start developer portal dev server"
|
||||
deps: [prepare]
|
||||
cmds:
|
||||
- task: dev:_run
|
||||
vars: { MODE: portal, PORT: '{{.PORT}}', BACKEND_URL: '{{.BACKEND_URL}}', OPEN: '{{.OPEN}}' }
|
||||
|
||||
# ============================================================
|
||||
# Build
|
||||
# ============================================================
|
||||
@@ -151,6 +158,24 @@ tasks:
|
||||
cmds:
|
||||
- npx vite build --mode prototypes
|
||||
|
||||
build:portal:
|
||||
desc: "Build developer portal"
|
||||
deps: [prepare]
|
||||
cmds:
|
||||
- npx vite build --mode portal
|
||||
|
||||
storybook:
|
||||
desc: "Start Storybook dev server"
|
||||
deps: [install]
|
||||
cmds:
|
||||
- npx storybook dev -p 6006 {{.CLI_ARGS}}
|
||||
|
||||
storybook:build:
|
||||
desc: "Build static Storybook"
|
||||
deps: [install]
|
||||
cmds:
|
||||
- npx storybook build {{.CLI_ARGS}}
|
||||
|
||||
# ============================================================
|
||||
# Code quality
|
||||
# ============================================================
|
||||
@@ -231,6 +256,12 @@ tasks:
|
||||
cmds:
|
||||
- npx tsc --noEmit --project src/prototypes/tsconfig.json
|
||||
|
||||
typecheck:portal:
|
||||
desc: "Typecheck developer portal build variant"
|
||||
deps: [prepare]
|
||||
cmds:
|
||||
- npx tsc --noEmit --project src/portal/tsconfig.json
|
||||
|
||||
typecheck:all:
|
||||
desc: "Typecheck all build variants"
|
||||
cmds:
|
||||
@@ -240,6 +271,7 @@ tasks:
|
||||
- task: typecheck:desktop
|
||||
- task: typecheck:scripts
|
||||
- task: typecheck:prototypes
|
||||
- task: typecheck:portal
|
||||
|
||||
# ============================================================
|
||||
# Quality Gate
|
||||
@@ -261,6 +293,7 @@ tasks:
|
||||
- task: format:check
|
||||
- task: build
|
||||
- task: test
|
||||
- task: storybook:build
|
||||
|
||||
# ============================================================
|
||||
# Test
|
||||
|
||||
@@ -183,26 +183,26 @@ Use this pattern for desktop-specific or proprietary-specific features WITHOUT r
|
||||
**Example - Desktop-specific footer:**
|
||||
|
||||
```typescript
|
||||
// core/components/rightRail/RightRailFooterExtensions.tsx (stub)
|
||||
interface RightRailFooterExtensionsProps {
|
||||
// core/components/workbenchBar/WorkbenchBarFooterExtensions.tsx (stub)
|
||||
interface WorkbenchBarFooterExtensionsProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function RightRailFooterExtensions(_props: RightRailFooterExtensionsProps) {
|
||||
export function WorkbenchBarFooterExtensions(_props: WorkbenchBarFooterExtensionsProps) {
|
||||
return null; // Stub - does nothing in web builds
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// desktop/components/rightRail/RightRailFooterExtensions.tsx (real implementation)
|
||||
// desktop/components/workbenchBar/WorkbenchBarFooterExtensions.tsx (real implementation)
|
||||
import { Box } from '@mantine/core';
|
||||
import { BackendHealthIndicator } from '@app/components/BackendHealthIndicator';
|
||||
|
||||
interface RightRailFooterExtensionsProps {
|
||||
interface WorkbenchBarFooterExtensionsProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function RightRailFooterExtensions({ className }: RightRailFooterExtensionsProps) {
|
||||
export function WorkbenchBarFooterExtensions({ className }: WorkbenchBarFooterExtensionsProps) {
|
||||
return (
|
||||
<Box className={className}>
|
||||
<BackendHealthIndicator />
|
||||
@@ -212,15 +212,15 @@ export function RightRailFooterExtensions({ className }: RightRailFooterExtensio
|
||||
```
|
||||
|
||||
```tsx
|
||||
// core/components/shared/RightRail.tsx (usage - works in ALL builds)
|
||||
import { RightRailFooterExtensions } from '@app/components/rightRail/RightRailFooterExtensions';
|
||||
// core/components/shared/WorkbenchBar.tsx (usage - works in ALL builds)
|
||||
import { WorkbenchBarFooterExtensions } from '@app/components/workbenchBar/WorkbenchBarFooterExtensions';
|
||||
|
||||
export function RightRail() {
|
||||
export function WorkbenchBar() {
|
||||
return (
|
||||
<div>
|
||||
{/* In web builds: renders nothing (stub returns null) */}
|
||||
{/* In desktop builds: renders BackendHealthIndicator */}
|
||||
<RightRailFooterExtensions className="right-rail-footer" />
|
||||
<WorkbenchBarFooterExtensions className="workbench-bar-footer" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
# production
|
||||
/build
|
||||
/dist
|
||||
/dist-portal
|
||||
/storybook-static
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
|
||||
@@ -2,6 +2,8 @@ dist/
|
||||
# Tauri/Cargo build output (binary assets named *.js etc. confuse Prettier)
|
||||
src-tauri/target/
|
||||
node_modules/
|
||||
src-tauri/target/
|
||||
src-tauri/gen/
|
||||
public/vendor/
|
||||
public/pdfjs*/
|
||||
public/js/thirdParty/
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { resolve } from "node:path";
|
||||
import type { StorybookConfig } from "@storybook/react-vite";
|
||||
|
||||
// Tell vite.config.ts to skip the editor's viteStaticCopy plugin (PDF assets,
|
||||
// fonts, locales — 188 items we don't need). Set before any Storybook import
|
||||
// triggers config evaluation.
|
||||
process.env.STIRLING_STORYBOOK = "true";
|
||||
|
||||
/**
|
||||
* Storybook 9 ships essentials, interactions, and docs as built-ins, so the
|
||||
* addon list is just the extras we want: theme switching + a11y auditing.
|
||||
*
|
||||
* Story files live next to their components in src/shared/ and src/portal/.
|
||||
* MDX docs pages live in src/portal/docs/.
|
||||
*/
|
||||
const config: StorybookConfig = {
|
||||
stories: [
|
||||
"../src/**/*.mdx",
|
||||
"../src/**/*.stories.@(ts|tsx)",
|
||||
],
|
||||
addons: ["@storybook/addon-themes", "@storybook/addon-a11y"],
|
||||
framework: {
|
||||
name: "@storybook/react-vite",
|
||||
options: {},
|
||||
},
|
||||
typescript: {
|
||||
reactDocgen: "react-docgen-typescript",
|
||||
},
|
||||
// Serve the MSW worker file from portal/public so storybook can intercept
|
||||
// network calls the same way the dev portal does.
|
||||
staticDirs: ["../portal/public"],
|
||||
viteFinal: async (config) => {
|
||||
// Storybook resolves @app/* and @shared/* via explicit aliases here rather
|
||||
// than via the per-mode tsconfig.*.vite.json — Storybook doesn't honour
|
||||
// the --mode flag the way our vite.config.ts expects, so the safer path is
|
||||
// to wire the aliases the storybook bundler actually uses.
|
||||
config.resolve = config.resolve ?? {};
|
||||
config.resolve.alias = {
|
||||
...(config.resolve.alias ?? {}),
|
||||
"@app": resolve(__dirname, "../src/portal"),
|
||||
"@shared": resolve(__dirname, "../src/shared"),
|
||||
};
|
||||
// Strip the editor's viteStaticCopy plugin — it pulls 188 PDF/locale
|
||||
// assets that the portal-and-design-system Storybook doesn't need.
|
||||
if (Array.isArray(config.plugins)) {
|
||||
config.plugins = config.plugins.filter((p) => {
|
||||
if (!p || typeof p !== "object" || Array.isArray(p)) return true;
|
||||
const name = (p as { name?: string }).name;
|
||||
return name !== "vite-plugin-static-copy";
|
||||
});
|
||||
}
|
||||
return config;
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { Decorator, Preview } from "@storybook/react-vite";
|
||||
import { initialize, mswLoader } from "msw-storybook-addon";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { useEffect } from "react";
|
||||
import { withThemeByDataAttribute } from "@storybook/addon-themes";
|
||||
|
||||
import {
|
||||
TierProvider,
|
||||
useTier,
|
||||
type Tier,
|
||||
} from "@app/contexts/TierContext";
|
||||
import { ThemeProvider } from "@app/contexts/ThemeContext";
|
||||
import { UIProvider } from "@app/contexts/UIContext";
|
||||
import { handlers } from "@app/mocks/handlers";
|
||||
|
||||
import "../src/shared/tokens/tokens.css";
|
||||
import "../src/shared/tokens/base.css";
|
||||
|
||||
// Start MSW once. Storybook runs in a browser so this uses the service worker.
|
||||
initialize(
|
||||
{ onUnhandledRequest: "bypass" },
|
||||
handlers,
|
||||
);
|
||||
|
||||
/**
|
||||
* Bridge between Storybook's `tier` global toolbar and the actual TierProvider.
|
||||
* Without this the toolbar would just change a label; with it, every story
|
||||
* that calls useTier() reflects the active toolbar value.
|
||||
*/
|
||||
function TierBridge({
|
||||
tier,
|
||||
children,
|
||||
}: {
|
||||
tier: Tier;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <TierProvider initialTier={tier}>{children}</TierProvider>;
|
||||
}
|
||||
|
||||
/** Forces the TierProvider to re-mount whenever the toolbar tier changes. */
|
||||
function TierKey({
|
||||
tier,
|
||||
children,
|
||||
}: {
|
||||
tier: Tier;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<TierBridge key={tier} tier={tier}>
|
||||
{children}
|
||||
</TierBridge>
|
||||
);
|
||||
}
|
||||
|
||||
/** Keeps useTheme() and the data-theme attribute in sync. */
|
||||
function ThemeWatcher() {
|
||||
useEffect(() => {
|
||||
// The addon-themes decorator already sets data-theme on <html>.
|
||||
// We just read it on mount so ThemeProvider picks it up.
|
||||
}, []);
|
||||
return null;
|
||||
}
|
||||
|
||||
const withProviders: Decorator = (Story, context) => {
|
||||
const tier = (context.globals.tier as Tier) ?? "pro";
|
||||
return (
|
||||
<MemoryRouter initialEntries={["/"]}>
|
||||
<ThemeProvider>
|
||||
<TierKey tier={tier}>
|
||||
<UIProvider>
|
||||
<ThemeWatcher />
|
||||
<Story />
|
||||
</UIProvider>
|
||||
</TierKey>
|
||||
</ThemeProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
};
|
||||
|
||||
const preview: Preview = {
|
||||
loaders: [mswLoader],
|
||||
parameters: {
|
||||
layout: "padded",
|
||||
controls: {
|
||||
matchers: { color: /(background|color)$/i, date: /Date$/i },
|
||||
},
|
||||
backgrounds: {
|
||||
default: "app",
|
||||
values: [
|
||||
{ name: "app", value: "var(--color-bg)" },
|
||||
{ name: "surface", value: "var(--color-surface)" },
|
||||
],
|
||||
},
|
||||
a11y: {
|
||||
// Run axe automatically against the story root; violations show in the
|
||||
// Accessibility panel. `context` replaced `element` in addon-a11y 9.x.
|
||||
context: "#storybook-root",
|
||||
config: {},
|
||||
options: {},
|
||||
test: "todo",
|
||||
},
|
||||
},
|
||||
globalTypes: {
|
||||
tier: {
|
||||
name: "Tier",
|
||||
description: "Subscription tier — drives useTier() everywhere",
|
||||
defaultValue: "pro",
|
||||
toolbar: {
|
||||
icon: "star",
|
||||
items: [
|
||||
{ value: "free", title: "Free" },
|
||||
{ value: "pro", title: "Pay-as-you-go" },
|
||||
{ value: "enterprise", title: "Enterprise" },
|
||||
],
|
||||
dynamicTitle: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
decorators: [
|
||||
withProviders,
|
||||
withThemeByDataAttribute({
|
||||
themes: { light: "light", dark: "dark" },
|
||||
defaultTheme: "light",
|
||||
attributeName: "data-theme",
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
export default preview;
|
||||
@@ -75,6 +75,40 @@ export default defineConfig(
|
||||
],
|
||||
},
|
||||
},
|
||||
// The shared/ layer is the seed of a future packages/shared-ui — it must
|
||||
// only depend on third-party packages and on itself. If it ever imports
|
||||
// from editor or portal layers, extraction to a standalone package later
|
||||
// becomes a rewrite instead of a `git mv`.
|
||||
{
|
||||
files: ["src/shared/**/*.{js,mjs,jsx,ts,tsx}"],
|
||||
rules: {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
patterns: [
|
||||
...baseRestrictedImportPatterns,
|
||||
{
|
||||
regex: "^@app/",
|
||||
message:
|
||||
"src/shared/ must not depend on editor or portal layers. Use @shared/* or third-party imports only.",
|
||||
},
|
||||
{
|
||||
regex: "^@core/",
|
||||
message: "src/shared/ must not depend on src/core/.",
|
||||
},
|
||||
{
|
||||
regex: "^@proprietary/",
|
||||
message: "src/shared/ must not depend on src/proprietary/.",
|
||||
},
|
||||
{
|
||||
regex: "^@tauri-apps/",
|
||||
message: "src/shared/ must remain web-compatible (no Tauri APIs).",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
// Folders that have been cleaned up and are now conformant - stricter rules enforced here
|
||||
{
|
||||
files: [
|
||||
@@ -82,6 +116,8 @@ export default defineConfig(
|
||||
"src/proprietary/**/*.{js,mjs,jsx,ts,tsx}",
|
||||
"src/saas/**/*.{js,mjs,jsx,ts,tsx}",
|
||||
"src/prototypes/**/*.{js,mjs,jsx,ts,tsx}",
|
||||
"src/portal/**/*.{js,mjs,jsx,ts,tsx}",
|
||||
"src/shared/**/*.{js,mjs,jsx,ts,tsx}",
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
|
||||
Generated
+1715
File diff suppressed because it is too large
Load Diff
@@ -102,6 +102,10 @@
|
||||
"@iconify-json/material-symbols": "^1.2.53",
|
||||
"@iconify/utils": "^3.1.0",
|
||||
"@playwright/test": "^1.55.0",
|
||||
"@storybook/addon-a11y": "^9.1.20",
|
||||
"@storybook/addon-docs": "^9.1.20",
|
||||
"@storybook/addon-themes": "^9.1.20",
|
||||
"@storybook/react-vite": "^9.1.20",
|
||||
"@tauri-apps/cli": "^2.9.6",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.8.0",
|
||||
@@ -125,6 +129,8 @@
|
||||
"jsdom": "^27.0.0",
|
||||
"license-checker": "^25.0.1",
|
||||
"madge": "^8.0.0",
|
||||
"msw": "^2.14.6",
|
||||
"msw-storybook-addon": "^2.0.7",
|
||||
"postcss": "^8.5.12",
|
||||
"postcss-cli": "^11.0.1",
|
||||
"postcss-preset-mantine": "^1.18.0",
|
||||
@@ -132,6 +138,7 @@
|
||||
"prettier": "^3.8.1",
|
||||
"puppeteer": "^24.25.0",
|
||||
"rollup-plugin-visualizer": "^7.0.1",
|
||||
"storybook": "^9.1.20",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.2",
|
||||
"typescript-eslint": "^8.44.1",
|
||||
@@ -147,5 +154,10 @@
|
||||
"@testing-library/user-event",
|
||||
"@vitest/coverage-v8"
|
||||
]
|
||||
},
|
||||
"msw": {
|
||||
"workerDirectory": [
|
||||
"portal\\public"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en-GB">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#0f172a" />
|
||||
<meta name="description" content="Stirling developer portal" />
|
||||
<title>Stirling — Developer Portal</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,31 @@
|
||||
/// <reference types="vite/client" />
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "@app/App";
|
||||
import { readMocksPreference } from "@app/mocks/preference";
|
||||
|
||||
import "@shared/tokens/tokens.css";
|
||||
import "@shared/tokens/base.css";
|
||||
|
||||
const root = document.getElementById("root");
|
||||
if (!root) throw new Error("No #root element");
|
||||
|
||||
// Start MSW before React mounts when the user has mocks enabled. The
|
||||
// preference defaults to ON in dev / OFF in production. The toggle in the
|
||||
// header flips it at runtime.
|
||||
async function bootstrap(): Promise<void> {
|
||||
if (readMocksPreference()) {
|
||||
// Dynamic import keeps MSW + every handler + every fixture out of any
|
||||
// chunk that doesn't need to actually run the worker.
|
||||
const { startMockWorker } = await import("@app/mocks/browser");
|
||||
await startMockWorker();
|
||||
}
|
||||
|
||||
createRoot(root!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
}
|
||||
|
||||
void bootstrap();
|
||||
@@ -0,0 +1,349 @@
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
|
||||
/**
|
||||
* Mock Service Worker.
|
||||
* @see https://github.com/mswjs/msw
|
||||
* - Please do NOT modify this file.
|
||||
*/
|
||||
|
||||
const PACKAGE_VERSION = '2.14.6'
|
||||
const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'
|
||||
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
|
||||
const activeClientIds = new Set()
|
||||
|
||||
addEventListener('install', function () {
|
||||
self.skipWaiting()
|
||||
})
|
||||
|
||||
addEventListener('activate', function (event) {
|
||||
event.waitUntil(self.clients.claim())
|
||||
})
|
||||
|
||||
addEventListener('message', async function (event) {
|
||||
const clientId = Reflect.get(event.source || {}, 'id')
|
||||
|
||||
if (!clientId || !self.clients) {
|
||||
return
|
||||
}
|
||||
|
||||
const client = await self.clients.get(clientId)
|
||||
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
|
||||
const allClients = await self.clients.matchAll({
|
||||
type: 'window',
|
||||
})
|
||||
|
||||
switch (event.data) {
|
||||
case 'KEEPALIVE_REQUEST': {
|
||||
sendToClient(client, {
|
||||
type: 'KEEPALIVE_RESPONSE',
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'INTEGRITY_CHECK_REQUEST': {
|
||||
sendToClient(client, {
|
||||
type: 'INTEGRITY_CHECK_RESPONSE',
|
||||
payload: {
|
||||
packageVersion: PACKAGE_VERSION,
|
||||
checksum: INTEGRITY_CHECKSUM,
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'MOCK_ACTIVATE': {
|
||||
activeClientIds.add(clientId)
|
||||
|
||||
sendToClient(client, {
|
||||
type: 'MOCKING_ENABLED',
|
||||
payload: {
|
||||
client: {
|
||||
id: client.id,
|
||||
frameType: client.frameType,
|
||||
},
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'CLIENT_CLOSED': {
|
||||
activeClientIds.delete(clientId)
|
||||
|
||||
const remainingClients = allClients.filter((client) => {
|
||||
return client.id !== clientId
|
||||
})
|
||||
|
||||
// Unregister itself when there are no more clients
|
||||
if (remainingClients.length === 0) {
|
||||
self.registration.unregister()
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
addEventListener('fetch', function (event) {
|
||||
const requestInterceptedAt = Date.now()
|
||||
|
||||
// Bypass navigation requests.
|
||||
if (event.request.mode === 'navigate') {
|
||||
return
|
||||
}
|
||||
|
||||
// Opening the DevTools triggers the "only-if-cached" request
|
||||
// that cannot be handled by the worker. Bypass such requests.
|
||||
if (
|
||||
event.request.cache === 'only-if-cached' &&
|
||||
event.request.mode !== 'same-origin'
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
// Bypass all requests when there are no active clients.
|
||||
// Prevents the self-unregistered worked from handling requests
|
||||
// after it's been terminated (still remains active until the next reload).
|
||||
if (activeClientIds.size === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const requestId = crypto.randomUUID()
|
||||
event.respondWith(handleRequest(event, requestId, requestInterceptedAt))
|
||||
})
|
||||
|
||||
/**
|
||||
* @param {FetchEvent} event
|
||||
* @param {string} requestId
|
||||
* @param {number} requestInterceptedAt
|
||||
*/
|
||||
async function handleRequest(event, requestId, requestInterceptedAt) {
|
||||
const client = await resolveMainClient(event)
|
||||
const requestCloneForEvents = event.request.clone()
|
||||
const response = await getResponse(
|
||||
event,
|
||||
client,
|
||||
requestId,
|
||||
requestInterceptedAt,
|
||||
)
|
||||
|
||||
// Send back the response clone for the "response:*" life-cycle events.
|
||||
// Ensure MSW is active and ready to handle the message, otherwise
|
||||
// this message will pend indefinitely.
|
||||
if (client && activeClientIds.has(client.id)) {
|
||||
const serializedRequest = await serializeRequest(requestCloneForEvents)
|
||||
|
||||
// Clone the response so both the client and the library could consume it.
|
||||
const responseClone = response.clone()
|
||||
|
||||
sendToClient(
|
||||
client,
|
||||
{
|
||||
type: 'RESPONSE',
|
||||
payload: {
|
||||
isMockedResponse: IS_MOCKED_RESPONSE in response,
|
||||
request: {
|
||||
id: requestId,
|
||||
...serializedRequest,
|
||||
},
|
||||
response: {
|
||||
type: responseClone.type,
|
||||
status: responseClone.status,
|
||||
statusText: responseClone.statusText,
|
||||
headers: Object.fromEntries(responseClone.headers.entries()),
|
||||
body: responseClone.body,
|
||||
},
|
||||
},
|
||||
},
|
||||
responseClone.body ? [serializedRequest.body, responseClone.body] : [],
|
||||
)
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the main client for the given event.
|
||||
* Client that issues a request doesn't necessarily equal the client
|
||||
* that registered the worker. It's with the latter the worker should
|
||||
* communicate with during the response resolving phase.
|
||||
* @param {FetchEvent} event
|
||||
* @returns {Promise<Client | undefined>}
|
||||
*/
|
||||
async function resolveMainClient(event) {
|
||||
const client = await self.clients.get(event.clientId)
|
||||
|
||||
if (activeClientIds.has(event.clientId)) {
|
||||
return client
|
||||
}
|
||||
|
||||
if (client?.frameType === 'top-level') {
|
||||
return client
|
||||
}
|
||||
|
||||
const allClients = await self.clients.matchAll({
|
||||
type: 'window',
|
||||
})
|
||||
|
||||
return allClients
|
||||
.filter((client) => {
|
||||
// Get only those clients that are currently visible.
|
||||
return client.visibilityState === 'visible'
|
||||
})
|
||||
.find((client) => {
|
||||
// Find the client ID that's recorded in the
|
||||
// set of clients that have registered the worker.
|
||||
return activeClientIds.has(client.id)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {FetchEvent} event
|
||||
* @param {Client | undefined} client
|
||||
* @param {string} requestId
|
||||
* @param {number} requestInterceptedAt
|
||||
* @returns {Promise<Response>}
|
||||
*/
|
||||
async function getResponse(event, client, requestId, requestInterceptedAt) {
|
||||
// Clone the request because it might've been already used
|
||||
// (i.e. its body has been read and sent to the client).
|
||||
const requestClone = event.request.clone()
|
||||
|
||||
function passthrough() {
|
||||
// Cast the request headers to a new Headers instance
|
||||
// so the headers can be manipulated with.
|
||||
const headers = new Headers(requestClone.headers)
|
||||
|
||||
// Remove the "accept" header value that marked this request as passthrough.
|
||||
// This prevents request alteration and also keeps it compliant with the
|
||||
// user-defined CORS policies.
|
||||
const acceptHeader = headers.get('accept')
|
||||
if (acceptHeader) {
|
||||
const values = acceptHeader.split(',').map((value) => value.trim())
|
||||
const filteredValues = values.filter(
|
||||
(value) => value !== 'msw/passthrough',
|
||||
)
|
||||
|
||||
if (filteredValues.length > 0) {
|
||||
headers.set('accept', filteredValues.join(', '))
|
||||
} else {
|
||||
headers.delete('accept')
|
||||
}
|
||||
}
|
||||
|
||||
return fetch(requestClone, { headers })
|
||||
}
|
||||
|
||||
// Bypass mocking when the client is not active.
|
||||
if (!client) {
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
// Bypass initial page load requests (i.e. static assets).
|
||||
// The absence of the immediate/parent client in the map of the active clients
|
||||
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
|
||||
// and is not ready to handle requests.
|
||||
if (!activeClientIds.has(client.id)) {
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
// Notify the client that a request has been intercepted.
|
||||
const serializedRequest = await serializeRequest(event.request)
|
||||
const clientMessage = await sendToClient(
|
||||
client,
|
||||
{
|
||||
type: 'REQUEST',
|
||||
payload: {
|
||||
id: requestId,
|
||||
interceptedAt: requestInterceptedAt,
|
||||
...serializedRequest,
|
||||
},
|
||||
},
|
||||
[serializedRequest.body],
|
||||
)
|
||||
|
||||
switch (clientMessage.type) {
|
||||
case 'MOCK_RESPONSE': {
|
||||
return respondWithMock(clientMessage.data)
|
||||
}
|
||||
|
||||
case 'PASSTHROUGH': {
|
||||
return passthrough()
|
||||
}
|
||||
}
|
||||
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Client} client
|
||||
* @param {any} message
|
||||
* @param {Array<Transferable>} transferrables
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
function sendToClient(client, message, transferrables = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const channel = new MessageChannel()
|
||||
|
||||
channel.port1.onmessage = (event) => {
|
||||
if (event.data && event.data.error) {
|
||||
return reject(event.data.error)
|
||||
}
|
||||
|
||||
resolve(event.data)
|
||||
}
|
||||
|
||||
client.postMessage(message, [
|
||||
channel.port2,
|
||||
...transferrables.filter(Boolean),
|
||||
])
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Response} response
|
||||
* @returns {Response}
|
||||
*/
|
||||
function respondWithMock(response) {
|
||||
// Setting response status code to 0 is a no-op.
|
||||
// However, when responding with a "Response.error()", the produced Response
|
||||
// instance will have status code set to 0. Since it's not possible to create
|
||||
// a Response instance with status code 0, handle that use-case separately.
|
||||
if (response.status === 0) {
|
||||
return Response.error()
|
||||
}
|
||||
|
||||
const mockedResponse = new Response(response.body, response)
|
||||
|
||||
Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
|
||||
value: true,
|
||||
enumerable: true,
|
||||
})
|
||||
|
||||
return mockedResponse
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Request} request
|
||||
*/
|
||||
async function serializeRequest(request) {
|
||||
return {
|
||||
url: request.url,
|
||||
mode: request.mode,
|
||||
method: request.method,
|
||||
headers: Object.fromEntries(request.headers.entries()),
|
||||
cache: request.cache,
|
||||
credentials: request.credentials,
|
||||
destination: request.destination,
|
||||
integrity: request.integrity,
|
||||
redirect: request.redirect,
|
||||
referrer: request.referrer,
|
||||
referrerPolicy: request.referrerPolicy,
|
||||
body: await request.arrayBuffer(),
|
||||
keepalive: request.keepalive,
|
||||
}
|
||||
}
|
||||
@@ -52,8 +52,8 @@ exportAndContinue = "Export & Continue"
|
||||
false = "False"
|
||||
fileNotSavedToDisk = "Not saved to disk"
|
||||
fileSavedToDisk = "File saved to disk"
|
||||
fileSelected = "Selected: {{filename}}"
|
||||
filesSelected = "{{count}} files selected"
|
||||
fileSelected = "{{filename}}"
|
||||
filesSelected = "{{count}} files"
|
||||
font = "Font"
|
||||
fontSizeTooltip = "Size of the page number text in points. Larger numbers create bigger text."
|
||||
fontTypeTooltip = "Font family for the page numbers. Choose based on your document style."
|
||||
@@ -82,7 +82,7 @@ multiPdfPrompt = "Select PDFs (2+)"
|
||||
never = "Never"
|
||||
no = "No"
|
||||
noFavourites = "No favourites added"
|
||||
noFileSelected = "No file selected. Please upload one."
|
||||
noFileSelected = "No file loaded. Please upload one."
|
||||
noFilesToUndo = "Cannot undo: no files were processed in the last operation"
|
||||
noOperationToUndo = "No operation to undo"
|
||||
notAuthenticatedMessage = "User not authenticated."
|
||||
@@ -1460,14 +1460,14 @@ inactive = "Inactive"
|
||||
|
||||
[adminOnboarding]
|
||||
adminTools = "Finally, we have advanced administration tools like <strong>Auditing</strong> to track system activity and <strong>Usage Analytics</strong> to monitor how your users interact with the platform."
|
||||
configButton = "Click the <strong>Config</strong> button to access all system settings and administrative controls."
|
||||
configButton = "Open <strong>Settings</strong> to access all system configuration and administrative controls."
|
||||
connectionsSection = "The <strong>Connections</strong> section supports various login methods including custom SSO and SAML providers like Google and GitHub, plus email integrations for notifications and communications."
|
||||
databaseSection = "For advanced production environments, we have settings to allow <strong>external database hookups</strong> so you can integrate with your existing infrastructure."
|
||||
settingsOverview = "This is the <strong>Settings Panel</strong>. Admin settings are organised by category for easy navigation."
|
||||
systemCustomization = "We have extensive ways to customise the UI: <strong>System Settings</strong> let you change the app name and languages, <strong>Features</strong> allows server certificate management, and <strong>Endpoints</strong> lets you enable or disable specific tools for your users."
|
||||
teamsAndUsers = "Manage <strong>Teams</strong> and individual users here. You can invite new users via email, shareable links, or create custom accounts for them yourself."
|
||||
welcome = "Welcome to the <strong>Admin Tour</strong>! Let's explore the powerful enterprise features and settings available to system administrators."
|
||||
wrapUp = "That's the admin tour! You've seen the enterprise features that make Stirling PDF a powerful, customisable solution for organisations. Access this tour anytime from the <strong>Help</strong> menu."
|
||||
wrapUp = "That's the admin tour! You've seen the enterprise features that make Stirling PDF a powerful, customisable solution for organisations. You can replay it anytime — just open <strong>Settings</strong> and find it here in the <strong>Tours</strong> section under Help."
|
||||
|
||||
[adminUserSettings]
|
||||
actions = "Actions"
|
||||
@@ -2724,6 +2724,7 @@ used = "used"
|
||||
[compare]
|
||||
addFilesHint = "Add PDFs in the Files step to enable selection."
|
||||
clearSelected = "Clear selected"
|
||||
clearSlot = "Remove file"
|
||||
cta = "Compare"
|
||||
header = "Compare PDFs"
|
||||
loading = "Comparing..."
|
||||
@@ -2749,7 +2750,7 @@ label = "Original document"
|
||||
placeholder = "Select the original PDF"
|
||||
|
||||
[compare.clear]
|
||||
confirm = "Clear and return"
|
||||
confirm = "Clear Selected"
|
||||
confirmBody = "This will close the current comparison and take you back to Active Files."
|
||||
confirmTitle = "Clear selected PDFs?"
|
||||
|
||||
@@ -2775,6 +2776,7 @@ title = "These PDFs look highly different"
|
||||
|
||||
[compare.edited]
|
||||
label = "Edited PDF"
|
||||
selectBaseFirst = "Select original PDF first"
|
||||
placeholder = "Select the edited PDF"
|
||||
|
||||
[compare.error]
|
||||
@@ -2856,6 +2858,7 @@ pageLabel = "Page"
|
||||
|
||||
[compare.swap]
|
||||
confirm = "Swap and Re-run"
|
||||
label = "Swap"
|
||||
confirmBody = "This will rerun the tool. Are you sure you want to swap the order of Original and Edited?"
|
||||
confirmTitle = "Re-run comparison?"
|
||||
|
||||
@@ -3105,7 +3108,7 @@ maintainAspectRatio = "Maintain Aspect Ratio"
|
||||
markdown = "Markdown"
|
||||
maxAttachmentSize = "Maximum attachment size (MB)"
|
||||
multiple = "Multiple"
|
||||
noFileSelected = "No file selected. Use the file panel to add files."
|
||||
noFileSelected = "No file loaded. Use the file panel to add files."
|
||||
odpExt = "OpenDocument Presentation (.odp)"
|
||||
odtExt = "OpenDocument Text (.odt)"
|
||||
officeDocs = "Office Documents (Word, Excel, PowerPoint)"
|
||||
@@ -3126,9 +3129,9 @@ pdfxDigitalSignatureWarning = "The PDF contains a digital signature. This will b
|
||||
pptExt = "PowerPoint (.pptx)"
|
||||
results = "Results"
|
||||
rtfExt = "Rich Text Format (.rtf)"
|
||||
selectedFiles = "Selected files"
|
||||
selectFilesPlaceholder = "Select files in the main view to get started"
|
||||
selectSourceFormatFirst = "Select a source format first"
|
||||
selectedFiles = "Active files"
|
||||
selectFilesPlaceholder = "Add files to the workbench to get started"
|
||||
selectSourceFormatFirst = "Choose a source format first"
|
||||
settings = "Settings"
|
||||
single = "Single"
|
||||
sourceFormatPlaceholder = "Source format"
|
||||
@@ -3283,7 +3286,7 @@ forRegularWork = "For regular PDF work:"
|
||||
[crop]
|
||||
autoCrop = "Auto-crop whitespace"
|
||||
header = "Crop PDF"
|
||||
noFileSelected = "Select a PDF file to begin cropping"
|
||||
noFileSelected = "Add a PDF file to begin cropping"
|
||||
reset = "Reset to full PDF"
|
||||
submit = "Apply Crop"
|
||||
title = "Crop"
|
||||
@@ -3488,6 +3491,8 @@ unlockAll = "Use for all ({{count}})"
|
||||
unlockAllPartialFail = "Wrong password for: {{names}}"
|
||||
unlockAllSuccess = "Unlocked {{count}} file(s)."
|
||||
unlockPrompt = "Unlock PDF to continue"
|
||||
viewerLocked = "This PDF is password-protected"
|
||||
viewerUnlock = "Unlock"
|
||||
|
||||
[encryptedPdfUnlock.password]
|
||||
label = "PDF password"
|
||||
@@ -3595,25 +3600,29 @@ or = "or"
|
||||
|
||||
[fileEditor]
|
||||
addFiles = "Add Files"
|
||||
pageCount = "{{count}} pages"
|
||||
pageCount_one = "{{count}} page"
|
||||
pageCount_other = "{{count}} pages"
|
||||
|
||||
[fileManager]
|
||||
active = "Active"
|
||||
addToUpload = "Add to Upload"
|
||||
changesNotUploaded = "Changes not uploaded"
|
||||
clearAll = "Clear All"
|
||||
clearSelection = "Clear Selection"
|
||||
clearSelection = "Clear"
|
||||
clickToUpload = "Click to upload files"
|
||||
closeAllFiles = "Close all files"
|
||||
closeFile = "Close File"
|
||||
cloudFile = "Cloud file"
|
||||
copyCreated = "Copy saved to this device."
|
||||
copyFailed = "Could not create a copy."
|
||||
delete = "Delete"
|
||||
deleteAll = "Delete All"
|
||||
deleteSelected = "Delete Selected"
|
||||
deselectAll = "Deselect All"
|
||||
deleteSelected = "Delete Files"
|
||||
deselectAll = "Uncheck All"
|
||||
details = "File Details"
|
||||
download = "Download"
|
||||
downloadSelected = "Download Selected"
|
||||
downloadSelected = "Download Files"
|
||||
dragDrop = "Drag & Drop files here"
|
||||
dropFilesHere = "Drop files here"
|
||||
failedToLoad = "Failed to load file to active set."
|
||||
@@ -3622,7 +3631,7 @@ fileFormat = "Format"
|
||||
fileHistory = "File History"
|
||||
fileName = "Name"
|
||||
fileSize = "Size"
|
||||
filesSelected = "files selected"
|
||||
filesSelected = "files"
|
||||
filesStored = "files stored"
|
||||
fileVersion = "Version"
|
||||
filterAll = "All"
|
||||
@@ -3648,7 +3657,7 @@ mobileUpload = "Mobile Upload"
|
||||
mobileUploadNotAvailable = "Mobile upload not enabled"
|
||||
myFiles = "My Files"
|
||||
noFiles = "No files available"
|
||||
noFileSelected = "No files selected"
|
||||
noFileSelected = "No files chosen"
|
||||
noFilesFound = "No files found matching your search"
|
||||
noRecentFiles = "No recent files found"
|
||||
openFile = "Open File"
|
||||
@@ -3671,18 +3680,19 @@ removeSharedPrompt = "This file is shared with you. You can remove it from this
|
||||
removeSharedServerOnlyBlockedPrompt = "This file is shared with you and stored only on the server."
|
||||
removeSharedServerOnlyPrompt = "This file is shared with you and stored only on the server. Remove it from your list?"
|
||||
restore = "Restore"
|
||||
saveSelected = "Save Selected"
|
||||
saveSelected = "Save Files"
|
||||
searchFiles = "Search files..."
|
||||
selectAll = "Select All"
|
||||
selectedCount = "{{count}} selected"
|
||||
selectedFiles = "Selected Files"
|
||||
selectAll = "Check All"
|
||||
selectedCount = "{{count}} checked"
|
||||
selectedFiles = "Files Added"
|
||||
share = "Share"
|
||||
shared = "Shared"
|
||||
sharedByYou = "Shared by you"
|
||||
sharedEditNoticeBody = "You do not have edit rights to the server version of this file. Any edits you make will be saved as a local copy."
|
||||
sharedEditNoticeConfirm = "Got it"
|
||||
sharedEditNoticeTitle = "Read-only server copy"
|
||||
sharedWithYou = "Shared with you"
|
||||
shareSelected = "Share Selected"
|
||||
shareSelected = "Share Files"
|
||||
sharing = "Sharing"
|
||||
showAll = "Show All"
|
||||
showHistory = "Show History"
|
||||
@@ -3699,24 +3709,59 @@ supportMessage = "Powered by browser database storage for unlimited capacity"
|
||||
synced = "Synced"
|
||||
title = "Upload PDF Files"
|
||||
toolChain = "Tools Applied"
|
||||
totalSelected = "Total Selected"
|
||||
totalSelected = "Total Files"
|
||||
unsupported = "Unsupported"
|
||||
unzip = "Unzip"
|
||||
updateOnServer = "Update on Server"
|
||||
uploadError = "Failed to upload some files."
|
||||
uploadSelected = "Upload Selected"
|
||||
uploadSelected = "Upload Files"
|
||||
uploadToServer = "Upload to Server"
|
||||
|
||||
[files]
|
||||
addFiles = "Add files"
|
||||
created = "Created"
|
||||
selectFromWorkbench = "Select files from the workbench or "
|
||||
selectMultipleFromWorkbench = "Select at least {{count}} files from the workbench or "
|
||||
selectFromWorkbench = "Add files to the workbench or "
|
||||
selectMultipleFromWorkbench = "Add at least {{count}} files to the workbench or "
|
||||
size = "File Size"
|
||||
title = "Files"
|
||||
upload = "Upload"
|
||||
uploadFiles = "Upload Files"
|
||||
|
||||
[fileSelectorPicker]
|
||||
empty = "No files available"
|
||||
placeholder = "Select file"
|
||||
search = "Filter files…"
|
||||
tabListLabel = "Saved files, workbench, upload"
|
||||
upload = "Upload from computer"
|
||||
|
||||
[fileSelectorPicker.sort]
|
||||
dateAsc = "Oldest first"
|
||||
dateDesc = "Newest first"
|
||||
dateLabel = "Latest"
|
||||
nameAsc = "A to Z"
|
||||
nameDesc = "Z to A"
|
||||
nameLabel = "A–Z"
|
||||
|
||||
[fileSelectorPicker.tabs]
|
||||
saved = "Saved files"
|
||||
upload = "Upload"
|
||||
workbench = "Workbench"
|
||||
|
||||
[fileSidebar]
|
||||
addFiles = "Add files"
|
||||
collapse = "Collapse sidebar"
|
||||
dropHint = "Open files to get started"
|
||||
expand = "Expand sidebar"
|
||||
files = "Files"
|
||||
googleDrive = "Google Drive"
|
||||
googleDriveDisabled = "Google Drive is not configured"
|
||||
noFiles = "No files yet"
|
||||
openFileManager = "Open file manager"
|
||||
openFromComputer = "Open from computer"
|
||||
openSettings = "Open settings"
|
||||
search = "Search"
|
||||
searchPlaceholder = "Search files..."
|
||||
|
||||
[fileToPDF]
|
||||
credit = "This service uses LibreOffice and Unoconv for file conversion."
|
||||
header = "Convert any file to PDF"
|
||||
@@ -4588,7 +4633,7 @@ goToFileEditor = "Go to file editor"
|
||||
submit = "Merge"
|
||||
tags = "merge,Page operations,Back end,server side"
|
||||
title = "Merge"
|
||||
viewerModeHint = "Merge needs 2 or more files. Head to the file editor to select them."
|
||||
viewerModeHint = "Merge needs 2 or more files. Head to the file editor to add them."
|
||||
|
||||
[merge.error]
|
||||
failed = "An error occurred while merging the PDFs."
|
||||
@@ -4611,7 +4656,7 @@ title = "Remove Digital Signature"
|
||||
ascending = "Ascending"
|
||||
dateModified = "Date Modified"
|
||||
descending = "Descending"
|
||||
description = "Files will be merged in the order they're selected. Drag to reorder or sort below."
|
||||
description = "Files will be merged in the order shown. Drag to reorder or sort below."
|
||||
filename = "File Name"
|
||||
label = "Sort By"
|
||||
sort = "Sort"
|
||||
@@ -4855,7 +4900,7 @@ toolInterface = "This is the <strong>Crop</strong> tool interface. As you can se
|
||||
viewer = "The <strong>Viewer</strong> lets you read and annotate your PDFs."
|
||||
viewSwitcher = "Use these controls to select how you want to view your PDFs."
|
||||
workbench = "This is the <strong>Workbench</strong> - the main area where you view and edit your PDFs."
|
||||
wrapUp = "You're all set! You've learnt about the main areas of the app and how to use them. Click the <strong>Help</strong> button whenever you like to see this tour again."
|
||||
wrapUp = "You're all set! You can replay this tour anytime — just open <strong>Settings</strong> and find it here in the <strong>Tours</strong> section under Help."
|
||||
|
||||
[onboarding.buttons]
|
||||
back = "Back"
|
||||
@@ -4920,7 +4965,6 @@ fileUpload = "Use the <strong>Files</strong> button to upload or pick a recent P
|
||||
leftPanel = "The left <strong>Tools</strong> panel lists everything you can do. Browse categories or search to find a tool quickly."
|
||||
pageEditorView = "Switch to the Page Editor to reorder, rotate, or delete pages."
|
||||
quickAccess = "Start at the <strong>Quick Access</strong> rail to jump between Reader, Automate, your files, and all the tours."
|
||||
rightRail = "The <strong>Right Rail</strong> holds quick actions to select files, change theme or language, and download results."
|
||||
topBar = "The top bar lets you swap between <strong>Viewer</strong>, <strong>Page Editor</strong>, and <strong>Active Files</strong>."
|
||||
wrapUp = "That is what is new in V2. Open the <strong>Tours</strong> menu anytime to replay this, the Tools tour, or the Admin tour."
|
||||
|
||||
@@ -4992,8 +5036,8 @@ text = "Foreground places the overlay on top of the page. Background places it b
|
||||
title = "Overlay Position"
|
||||
|
||||
[pageEdit]
|
||||
deselectAll = "Select None"
|
||||
selectAll = "Select All"
|
||||
deselectAll = "None"
|
||||
selectAll = "Check All"
|
||||
|
||||
[pageEditor]
|
||||
actualSize = "Actual Size"
|
||||
@@ -6521,9 +6565,10 @@ title = "High Contrast"
|
||||
text = "Completely invert all colours in the PDF, creating a negative-like effect. Useful for creating dark mode versions of documents or reducing eye strain in low-light conditions."
|
||||
title = "Invert All Colours"
|
||||
|
||||
[rightRail]
|
||||
[workbenchBar]
|
||||
annotations = "Annotations"
|
||||
applyRedactionsFirst = "Apply redactions first"
|
||||
closeAll = "Close All Files"
|
||||
closePdf = "Close PDF"
|
||||
closeSelected = "Close Selected Files"
|
||||
deleteSelected = "Delete Selected Pages"
|
||||
@@ -6553,6 +6598,7 @@ search = "Search PDF"
|
||||
selectAll = "Select All"
|
||||
selectByNumber = "Select by Page Numbers"
|
||||
selectLanguage = "Select language"
|
||||
share = "Share"
|
||||
toggleAnnotations = "Toggle Annotations Visibility"
|
||||
toggleAttachments = "Toggle Attachments"
|
||||
toggleBookmarks = "Toggle Bookmarks"
|
||||
@@ -6560,6 +6606,9 @@ toggleComments = "Comments"
|
||||
toggleLayers = "Toggle Layers"
|
||||
toggleSidebar = "Toggle Sidebar"
|
||||
toggleTheme = "Toggle Theme"
|
||||
activeFiles = "Active Files"
|
||||
multiTool = "Multi-Tool"
|
||||
viewer = "Viewer"
|
||||
|
||||
[rotate]
|
||||
rotateLeft = "Rotate Anticlockwise"
|
||||
@@ -6793,7 +6842,7 @@ defaultPdfEditorChecking = "Checking..."
|
||||
defaultPdfEditorInactive = "Another application is set as default"
|
||||
defaultPdfEditorSet = "Already Default"
|
||||
defaultStartupView = "Default view on launch"
|
||||
defaultStartupViewDescription = "Choose which tab is active in the left column when the app starts"
|
||||
defaultStartupViewDescription = "Choose which view is active when the app starts"
|
||||
defaultToolPickerMode = "Default tool picker mode"
|
||||
defaultToolPickerModeDescription = "Choose whether the tool picker opens in fullscreen or sidebar by default"
|
||||
defaultViewerZoom = "Default reader zoom"
|
||||
@@ -6803,8 +6852,14 @@ hideUnavailableConversions = "Hide unavailable conversions"
|
||||
hideUnavailableConversionsDescription = "Remove disabled conversion options in the Convert tool instead of showing them greyed out."
|
||||
hideUnavailableTools = "Hide unavailable tools"
|
||||
hideUnavailableToolsDescription = "Remove tools that have been disabled by your server instead of showing them greyed out."
|
||||
language = "Language"
|
||||
languageDescription = "Choose the display language"
|
||||
logout = "Log out"
|
||||
setAsDefault = "Set as Default"
|
||||
theme = "Theme"
|
||||
themeDark = "Dark"
|
||||
themeDescription = "Switch between light and dark mode"
|
||||
themeLight = "Light"
|
||||
title = "General"
|
||||
user = "User"
|
||||
|
||||
@@ -6979,6 +7034,20 @@ title = "Team"
|
||||
enableLoginFirst = "Enable login mode first"
|
||||
requiresEnterprise = "Requires Enterprise license"
|
||||
|
||||
[settings.help]
|
||||
label = "Tours"
|
||||
title = "Help"
|
||||
|
||||
[settings.help.adminTour]
|
||||
description = "Explore team management, system settings, and enterprise features."
|
||||
start = "Start"
|
||||
title = "Admin Tour"
|
||||
|
||||
[settings.help.toolsTour]
|
||||
description = "Walk through uploading files, picking a tool, and reviewing results."
|
||||
start = "Start"
|
||||
title = "Tools Tour"
|
||||
|
||||
[settings.workspace]
|
||||
people = "People"
|
||||
teams = "Teams"
|
||||
@@ -7576,8 +7645,8 @@ accessLimitedCommenter = "Comment access is coming soon. Ask the owner for edito
|
||||
accessLimitedTitle = "Limited access"
|
||||
accessLimitedViewer = "This link is view-only. Ask the owner for editor access if you need to download."
|
||||
addUser = "Add"
|
||||
bulkDescription = "Create one link to share all selected files with signed-in users."
|
||||
bulkTitle = "Share selected files"
|
||||
bulkDescription = "Create one link to share all checked files with signed-in users."
|
||||
bulkTitle = "Share checked files"
|
||||
commenterHint = "Commenting is coming soon."
|
||||
copied = "Link copied to clipboard"
|
||||
copy = "Copy"
|
||||
@@ -7596,7 +7665,7 @@ errorTitle = "Share failed"
|
||||
expiredBody = "This share link is invalid or has expired."
|
||||
expiredTitle = "Link expired"
|
||||
failure = "Unable to generate a share link. Please try again."
|
||||
fileCount = "{{count}} files selected"
|
||||
fileCount = "{{count}} files"
|
||||
fileLabel = "File"
|
||||
generate = "Generate Link"
|
||||
generated = "Share link generated"
|
||||
@@ -7652,12 +7721,12 @@ viewed = "Viewed"
|
||||
viewsCount = "Views: {{count}}"
|
||||
|
||||
[storageUpload]
|
||||
bulkDescription = "This uploads the selected files to your server storage."
|
||||
bulkTitle = "Upload selected files"
|
||||
bulkDescription = "This uploads the checked files to your server storage."
|
||||
bulkTitle = "Upload checked files"
|
||||
description = "This uploads the current file to server storage for your own access."
|
||||
errorTitle = "Upload failed"
|
||||
failure = "Upload failed. Please check your login and storage settings."
|
||||
fileCount = "{{count}} files selected"
|
||||
fileCount = "{{count}} files"
|
||||
fileLabel = "File"
|
||||
hint = "Public links and access modes are controlled by your server settings."
|
||||
more = " +{{count}} more"
|
||||
@@ -7817,19 +7886,26 @@ settings = "Settings"
|
||||
[tool]
|
||||
endpointUnavailable = "This tool is unavailable on your server."
|
||||
endpointUnavailableClickable = "Not available in this mode. Click to sign in."
|
||||
filesLoading = "Files are still loading, please wait."
|
||||
filesLoadingProgress = "{{loaded}} / {{total}} files loading..."
|
||||
invalidParams = "Fill in the required settings."
|
||||
noFiles = "Add a file to get started."
|
||||
scopeFiles = "files"
|
||||
scopeThisFile = "this file"
|
||||
selectFilesHint = "Select files in Active Files to run this tool"
|
||||
singleFileScope = "Only applying to: {{fileName}}"
|
||||
viewerMode = "Switch to the file editor to select multiple files."
|
||||
viewerMode = "Switch to the file editor to add multiple files."
|
||||
|
||||
[toolPanel]
|
||||
allTools = "All tools"
|
||||
alpha = "Alpha"
|
||||
backToTools = "Back to tools"
|
||||
collapse = "Collapse panel"
|
||||
comingSoon = "Coming soon:"
|
||||
expand = "Expand panel"
|
||||
placeholder = "Choose a tool to get started"
|
||||
premiumFeature = "Premium feature:"
|
||||
search = "Search tools"
|
||||
|
||||
[toolPanel.fullscreen]
|
||||
comingSoon = "Coming soon:"
|
||||
@@ -8380,6 +8456,7 @@ bullet3 = "Image will be resized to fit signature area"
|
||||
description = "Upload a pre-created signature image. Ideal if you have a scanned signature or company logo."
|
||||
title = "Upload Signature Image"
|
||||
|
||||
|
||||
[workspace]
|
||||
title = "Workspace"
|
||||
|
||||
|
||||
@@ -16,8 +16,7 @@ export function AppLayout({ children }: AppLayoutProps) {
|
||||
return (
|
||||
<>
|
||||
<style>{`
|
||||
.h-screen,
|
||||
.right-rail {
|
||||
.h-screen {
|
||||
height: 100% !important;
|
||||
}
|
||||
`}</style>
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
AppConfigRetryOptions,
|
||||
useAppConfig,
|
||||
} from "@app/contexts/AppConfigContext";
|
||||
import { RightRailProvider } from "@app/contexts/RightRailContext";
|
||||
import { WorkbenchBarProvider } from "@app/contexts/WorkbenchBarContext";
|
||||
import { ViewerProvider } from "@app/contexts/ViewerContext";
|
||||
import { SignatureProvider } from "@app/contexts/SignatureContext";
|
||||
import { AnnotationProvider } from "@app/contexts/AnnotationContext";
|
||||
@@ -139,13 +139,13 @@ export function AppProviders({
|
||||
<RedactionProvider>
|
||||
<FormFillProvider>
|
||||
<AnnotationProvider>
|
||||
<RightRailProvider>
|
||||
<WorkbenchBarProvider>
|
||||
<TourOrchestrationProvider>
|
||||
<AdminTourOrchestrationProvider>
|
||||
{children}
|
||||
</AdminTourOrchestrationProvider>
|
||||
</TourOrchestrationProvider>
|
||||
</RightRailProvider>
|
||||
</WorkbenchBarProvider>
|
||||
</AnnotationProvider>
|
||||
</FormFillProvider>
|
||||
</RedactionProvider>
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useState, useCallback, useEffect, useMemo } from "react";
|
||||
import { Modal } from "@mantine/core";
|
||||
import { Dropzone } from "@mantine/dropzone";
|
||||
import { StirlingFileStub } from "@app/types/fileContext";
|
||||
import type { FileId } from "@app/types/file";
|
||||
import { useFileManager } from "@app/hooks/useFileManager";
|
||||
import { useFilesModalContext } from "@app/contexts/FilesModalContext";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
} from "@app/services/googleDrivePickerService";
|
||||
import { loadScript } from "@app/utils/scriptLoader";
|
||||
import { useAllFiles } from "@app/contexts/FileContext";
|
||||
import { useFileActions } from "@app/contexts/file/fileHooks";
|
||||
|
||||
interface FileManagerProps {
|
||||
selectedTool?: Tool | null;
|
||||
@@ -28,6 +30,7 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
||||
closeFilesModal,
|
||||
onFileUpload,
|
||||
onRecentFileSelect,
|
||||
maxSelectable,
|
||||
} = useFilesModalContext();
|
||||
const { config } = useAppConfig();
|
||||
const [recentFiles, setRecentFiles] = useState<StirlingFileStub[]>([]);
|
||||
@@ -35,6 +38,7 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
|
||||
const { loadRecentFiles, handleRemoveFile, loading } = useFileManager();
|
||||
const { actions: fileActions } = useFileActions();
|
||||
|
||||
// Get active file IDs from FileContext to show which files are already loaded
|
||||
const { fileIds: activeFileIds } = useAllFiles();
|
||||
@@ -83,11 +87,24 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
||||
|
||||
const handleRemoveFileByIndex = useCallback(
|
||||
async (index: number) => {
|
||||
await handleRemoveFile(index, recentFiles, setRecentFiles);
|
||||
await handleRemoveFile(
|
||||
index,
|
||||
recentFiles,
|
||||
setRecentFiles,
|
||||
(fileId) => {
|
||||
fileActions.removeFiles([fileId], false);
|
||||
},
|
||||
refreshRecentFiles,
|
||||
);
|
||||
},
|
||||
[handleRemoveFile, recentFiles],
|
||||
[handleRemoveFile, recentFiles, fileActions, refreshRecentFiles],
|
||||
);
|
||||
|
||||
const handleBulkRemove = useCallback((fileIds: FileId[]) => {
|
||||
const idSet = new Set(fileIds as string[]);
|
||||
setRecentFiles((prev) => prev.filter((f) => !idSet.has(f.id as string)));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => setIsMobile(window.innerWidth < 1030);
|
||||
checkMobile();
|
||||
@@ -211,7 +228,9 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
||||
onClose={closeFilesModal}
|
||||
isFileSupported={isFileSupported}
|
||||
isOpen={isFilesModalOpen}
|
||||
maxSelectable={maxSelectable}
|
||||
onFileRemove={handleRemoveFileByIndex}
|
||||
onBulkRemove={handleBulkRemove}
|
||||
modalHeight={modalHeight}
|
||||
refreshRecentFiles={refreshRecentFiles}
|
||||
isLoading={loading}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { useRef, useState } from "react";
|
||||
import { Button, Group } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import { useFilesModalContext } from "@app/contexts/FilesModalContext";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import { Wordmark } from "@app/components/shared/Wordmark";
|
||||
@@ -69,7 +68,7 @@ const AddFileCard = ({
|
||||
/>
|
||||
|
||||
<div
|
||||
className={`${styles.addFileCard} w-[18rem] h-[22rem] select-none flex flex-col shadow-sm transition-all relative cursor-pointer`}
|
||||
className={`${styles.addFileCard} select-none flex flex-col transition-all relative cursor-pointer`}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
aria-label={t("fileEditor.addFiles", "Add files")}
|
||||
@@ -81,17 +80,6 @@ const AddFileCard = ({
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Header bar - matches FileEditorThumbnail structure */}
|
||||
<div className={`${styles.header} ${styles.addFileHeader}`}>
|
||||
<div className={styles.logoMark}>
|
||||
<AddIcon sx={{ color: "inherit", fontSize: "1.5rem" }} />
|
||||
</div>
|
||||
<div className={styles.headerIndex}>
|
||||
{t("fileEditor.addFiles", "Add Files")}
|
||||
</div>
|
||||
<div className={styles.kebab} />
|
||||
</div>
|
||||
|
||||
{/* Main content area */}
|
||||
<div className={styles.addFileContent}>
|
||||
{/* Stirling PDF Branding */}
|
||||
|
||||
@@ -328,42 +328,33 @@
|
||||
========================= */
|
||||
|
||||
.addFileCard {
|
||||
width: 260px;
|
||||
max-width: 260px;
|
||||
height: calc(310px - 0.5rem);
|
||||
margin: 0.5rem auto 0;
|
||||
background: var(--file-card-bg);
|
||||
border: 2px dashed var(--border-default);
|
||||
border-radius: 0.0625rem;
|
||||
border: 1.5px solid var(--border-default);
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--shadow-md);
|
||||
cursor: pointer;
|
||||
transition: all 0.18s ease;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
transition:
|
||||
box-shadow 0.18s ease,
|
||||
transform 0.2s ease,
|
||||
border-color 0.18s ease;
|
||||
overflow: hidden;
|
||||
margin-left: 0.5rem;
|
||||
margin-right: 0.5rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.addFileCard:hover {
|
||||
opacity: 1;
|
||||
border-color: var(--color-blue-500);
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
transform: translateY(-1px);
|
||||
border-color: var(--accent-interactive);
|
||||
}
|
||||
|
||||
.addFileCard:focus {
|
||||
outline: 2px solid var(--color-blue-500);
|
||||
.addFileCard:focus-visible {
|
||||
outline: 2px solid var(--accent-interactive);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.addFileHeader {
|
||||
background: var(--bg-subtle);
|
||||
color: var(--text-secondary);
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
.addFileCard:hover .addFileHeader {
|
||||
background: var(--color-blue-500);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.addFileContent {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { useState, useCallback, useRef, useMemo, useEffect } from "react";
|
||||
import { useState, useCallback, useMemo, useEffect } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { Text, Center, Box, LoadingOverlay, Stack } from "@mantine/core";
|
||||
import { Center, Box, LoadingOverlay } from "@mantine/core";
|
||||
import { Dropzone } from "@mantine/dropzone";
|
||||
import {
|
||||
useFileSelection,
|
||||
useFileState,
|
||||
useFileManagement,
|
||||
useFileActions,
|
||||
useFileContext,
|
||||
} from "@app/contexts/FileContext";
|
||||
import { useNavigationActions } from "@app/contexts/NavigationContext";
|
||||
import { useViewer } from "@app/contexts/ViewerContext";
|
||||
@@ -19,7 +18,6 @@ import FilePickerModal from "@app/components/shared/FilePickerModal";
|
||||
import { FileId, StirlingFile } from "@app/types/fileContext";
|
||||
import { alert } from "@app/components/toast";
|
||||
import { downloadFile } from "@app/services/downloadService";
|
||||
import { useFileEditorRightRailButtons } from "@app/components/fileEditor/fileEditorRightRailButtons";
|
||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||
|
||||
interface FileEditorProps {
|
||||
@@ -46,17 +44,13 @@ const FileEditor = ({
|
||||
const { state, selectors } = useFileState();
|
||||
const { addFiles, removeFiles, reorderFiles } = useFileManagement();
|
||||
const { actions: fileActions } = useFileActions();
|
||||
const { actions: fileContextActions } = useFileContext();
|
||||
const { clearAllFileErrors } = fileContextActions;
|
||||
const { selectedFileIds, setSelectedFiles } = useFileSelection();
|
||||
|
||||
// Extract needed values from state (memoized to prevent infinite loops)
|
||||
const activeStirlingFileStubs = useMemo(
|
||||
() => selectors.getStirlingFileStubs(),
|
||||
[state.files.byId, state.files.ids],
|
||||
);
|
||||
const selectedFileIds = state.ui.selectedFileIds;
|
||||
const totalItems = state.files.ids.length;
|
||||
const selectedCount = selectedFileIds.length;
|
||||
|
||||
// Get navigation actions
|
||||
const { actions: navActions } = useNavigationActions();
|
||||
@@ -64,9 +58,6 @@ const FileEditor = ({
|
||||
// Get viewer context for setting active file index and ID
|
||||
const { setActiveFileIndex, setActiveFileId } = useViewer();
|
||||
|
||||
// Get file selection context
|
||||
const { setSelectedFiles } = useFileSelection();
|
||||
|
||||
const [_status, _setStatus] = useState<string | null>(null);
|
||||
const [_error, _setError] = useState<string | null>(null);
|
||||
|
||||
@@ -93,7 +84,6 @@ const FileEditor = ({
|
||||
expandable: true,
|
||||
});
|
||||
}, []);
|
||||
const [selectionMode, setSelectionMode] = useState(toolMode);
|
||||
|
||||
// Current tool (for enforcing maxFiles limits)
|
||||
const { selectedTool } = useToolWorkflow();
|
||||
@@ -104,65 +94,7 @@ const FileEditor = ({
|
||||
return !toolMode || rawMax == null || rawMax < 0 ? Infinity : rawMax;
|
||||
}, [selectedTool?.maxFiles, toolMode]);
|
||||
|
||||
// Enable selection mode automatically in tool mode
|
||||
useEffect(() => {
|
||||
if (toolMode) {
|
||||
setSelectionMode(true);
|
||||
}
|
||||
}, [toolMode]);
|
||||
const [showFilePickerModal, setShowFilePickerModal] = useState(false);
|
||||
// Get selected file IDs from context (defensive programming)
|
||||
const contextSelectedIds = Array.isArray(selectedFileIds)
|
||||
? selectedFileIds
|
||||
: [];
|
||||
|
||||
// Create refs for frequently changing values to stabilize callbacks
|
||||
const contextSelectedIdsRef = useRef<FileId[]>([]);
|
||||
contextSelectedIdsRef.current = contextSelectedIds;
|
||||
|
||||
// Use activeStirlingFileStubs directly - no conversion needed
|
||||
const localSelectedIds = contextSelectedIds;
|
||||
|
||||
const handleSelectAllFiles = useCallback(() => {
|
||||
// Respect maxAllowed: if limited, select the last N files
|
||||
const allIds = state.files.ids;
|
||||
const idsToSelect = Number.isFinite(maxAllowed)
|
||||
? allIds.slice(-maxAllowed)
|
||||
: allIds;
|
||||
setSelectedFiles(idsToSelect);
|
||||
try {
|
||||
clearAllFileErrors();
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.warn("Failed to clear file errors on select all:", error);
|
||||
}
|
||||
}
|
||||
}, [state.files.ids, setSelectedFiles, clearAllFileErrors, maxAllowed]);
|
||||
|
||||
const handleDeselectAllFiles = useCallback(() => {
|
||||
setSelectedFiles([]);
|
||||
try {
|
||||
clearAllFileErrors();
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.warn("Failed to clear file errors on deselect:", error);
|
||||
}
|
||||
}
|
||||
}, [setSelectedFiles, clearAllFileErrors]);
|
||||
|
||||
const handleCloseSelectedFiles = useCallback(() => {
|
||||
if (selectedFileIds.length === 0) return;
|
||||
void removeFiles(selectedFileIds, false);
|
||||
setSelectedFiles([]);
|
||||
}, [selectedFileIds, removeFiles, setSelectedFiles]);
|
||||
|
||||
useFileEditorRightRailButtons({
|
||||
totalItems,
|
||||
selectedCount,
|
||||
onSelectAll: handleSelectAllFiles,
|
||||
onDeselectAll: handleDeselectAllFiles,
|
||||
onCloseSelected: handleCloseSelectedFiles,
|
||||
});
|
||||
|
||||
// Process uploaded files using context
|
||||
// ZIP extraction is now handled automatically in FileContext based on user preferences
|
||||
@@ -199,56 +131,6 @@ const FileEditor = ({
|
||||
[addFiles, showStatus, showError, selectors, maxAllowed, setSelectedFiles],
|
||||
);
|
||||
|
||||
const toggleFile = useCallback(
|
||||
(fileId: FileId) => {
|
||||
const currentSelectedIds = contextSelectedIdsRef.current;
|
||||
|
||||
const targetRecord = activeStirlingFileStubs.find((r) => r.id === fileId);
|
||||
if (!targetRecord) return;
|
||||
|
||||
const contextFileId = fileId; // No need to create a new ID
|
||||
const isSelected = currentSelectedIds.includes(contextFileId);
|
||||
|
||||
let newSelection: FileId[];
|
||||
|
||||
if (isSelected) {
|
||||
// Remove file from selection
|
||||
newSelection = currentSelectedIds.filter((id) => id !== contextFileId);
|
||||
} else {
|
||||
// Add file to selection
|
||||
// Determine max files allowed from the active tool (negative or undefined means unlimited)
|
||||
const rawMax = selectedTool?.maxFiles;
|
||||
const maxAllowed =
|
||||
!toolMode || rawMax == null || rawMax < 0 ? Infinity : rawMax;
|
||||
|
||||
if (maxAllowed === 1) {
|
||||
// Only one file allowed -> replace selection with the new file
|
||||
newSelection = [contextFileId];
|
||||
} else {
|
||||
// If at capacity, drop the oldest selected and append the new one
|
||||
if (
|
||||
Number.isFinite(maxAllowed) &&
|
||||
currentSelectedIds.length >= maxAllowed
|
||||
) {
|
||||
newSelection = [...currentSelectedIds.slice(1), contextFileId];
|
||||
} else {
|
||||
newSelection = [...currentSelectedIds, contextFileId];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update context (this automatically updates tool selection since they use the same action)
|
||||
setSelectedFiles(newSelection);
|
||||
},
|
||||
[
|
||||
setSelectedFiles,
|
||||
toolMode,
|
||||
_setStatus,
|
||||
activeStirlingFileStubs,
|
||||
selectedTool?.maxFiles,
|
||||
],
|
||||
);
|
||||
|
||||
// Enforce maxAllowed when tool changes or when an external action sets too many selected files
|
||||
useEffect(() => {
|
||||
if (Number.isFinite(maxAllowed) && selectedFileIds.length > maxAllowed) {
|
||||
@@ -495,16 +377,7 @@ const FileEditor = ({
|
||||
<Box p="md">
|
||||
{activeStirlingFileStubs.length === 0 ? (
|
||||
<Center h="60vh">
|
||||
<Stack align="center" gap="md">
|
||||
<Text size="lg" c="dimmed">
|
||||
📁
|
||||
</Text>
|
||||
<Text c="dimmed">No files loaded</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Upload PDF files, ZIP archives, or load from storage to get
|
||||
started
|
||||
</Text>
|
||||
</Stack>
|
||||
<AddFileCard onFileSelect={handleFileUpload} />
|
||||
</Center>
|
||||
) : (
|
||||
<div
|
||||
@@ -531,12 +404,8 @@ const FileEditor = ({
|
||||
file={record}
|
||||
index={index}
|
||||
totalFiles={activeStirlingFileStubs.length}
|
||||
selectedFiles={localSelectedIds}
|
||||
selectionMode={selectionMode}
|
||||
onToggleFile={toggleFile}
|
||||
onCloseFile={handleCloseFile}
|
||||
onViewFile={handleViewFile}
|
||||
_onSetStatus={showStatus}
|
||||
onReorderFiles={handleReorderFiles}
|
||||
onDownloadFile={handleDownloadFile}
|
||||
onUnzipFile={handleUnzipFile}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { StirlingFileStub } from "@app/types/fileContext";
|
||||
|
||||
interface FileEditorStatusDotProps {
|
||||
file: StirlingFileStub;
|
||||
}
|
||||
|
||||
export function FileEditorStatusDot(_props: FileEditorStatusDotProps) {
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
.card {
|
||||
width: 260px;
|
||||
max-width: 260px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
padding: 10px 10px 12px;
|
||||
position: relative;
|
||||
--file-text-height: 58px;
|
||||
--file-text-bottom: 12px;
|
||||
--thumb-text-gap: 6px;
|
||||
padding-bottom: calc(
|
||||
var(--file-text-bottom) + var(--file-text-height) + var(--thumb-text-gap)
|
||||
);
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Inner wrapper — centers [toolChain + thumbnail + hover menu] as a unit */
|
||||
.thumbInner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Groups toolchain bar + thumbnail so they center together inside thumbWrap */
|
||||
.thumbUnit {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
/* ToolChain bar sits directly above the thumbnail card — always rendered for consistent sizing */
|
||||
.toolChainBar {
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
height: 22px;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
line-height: 22px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.card:hover .toolChainBar,
|
||||
.card:focus-within .toolChainBar {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.thumbWrap {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
height: 310px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.thumbContainer {
|
||||
aspect-ratio: var(--thumb-aspect, 8.5 / 11);
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--shadow-md);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
background: var(--bg-surface);
|
||||
transition:
|
||||
box-shadow 0.15s ease,
|
||||
transform 0.2s ease;
|
||||
}
|
||||
|
||||
.thumbContainer:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.thumbImage {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
border-radius: 12px;
|
||||
background: var(--bg-surface);
|
||||
display: block;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Text block pinned below thumbnail */
|
||||
.fileText {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
right: 10px;
|
||||
bottom: var(--file-text-bottom);
|
||||
height: var(--file-text-height);
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
--file-name-line-height: 18px;
|
||||
--file-name-lines: 2;
|
||||
--file-meta-line-height: 14px;
|
||||
--file-meta-gap: 4px;
|
||||
}
|
||||
|
||||
.fileName {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
line-height: var(--file-name-line-height);
|
||||
height: calc(var(--file-name-lines) * var(--file-name-line-height));
|
||||
margin: 0;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: calc(var(--file-meta-line-height) + var(--file-meta-gap));
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
overflow-wrap: break-word;
|
||||
word-break: normal;
|
||||
}
|
||||
|
||||
.fileMeta {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
line-height: var(--file-meta-line-height);
|
||||
height: var(--file-meta-line-height);
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* CSS-hover driven menu visibility */
|
||||
.card [data-hover-action-menu-mode="cssHover"][data-force-visible="false"] {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.card:hover
|
||||
[data-hover-action-menu-mode="cssHover"][data-force-visible="false"],
|
||||
.card:focus-within
|
||||
[data-hover-action-menu-mode="cssHover"][data-force-visible="false"] {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Error overlay */
|
||||
.errorOverlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(220, 38, 38, 0.12);
|
||||
border-radius: 12px;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.errorPill {
|
||||
background: var(--mantine-color-red-6);
|
||||
color: white;
|
||||
border-radius: 12px;
|
||||
padding: 4px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Badges row pinned to top-left of thumbnail - always visible */
|
||||
.thumbBadges {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 6px;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
z-index: 3;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.thumbBadgesRight {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
z-index: 3;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.statusDot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 0 0 1.5px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.ownershipBadge {
|
||||
background: var(--mantine-color-blue-6);
|
||||
color: white;
|
||||
border-radius: 6px;
|
||||
padding: 2px 7px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.versionBadgeThumb {
|
||||
background: var(--mantine-color-blue-6);
|
||||
border-radius: 4px;
|
||||
padding: 1px 5px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
line-height: 16px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pinnedBadge {
|
||||
background: var(--mantine-color-yellow-6);
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
padding: 2px 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.dragHandle {
|
||||
position: absolute;
|
||||
bottom: 6px;
|
||||
right: 6px;
|
||||
cursor: grab;
|
||||
color: var(--text-secondary);
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.card:hover .dragHandle,
|
||||
.card:focus-within .dragHandle {
|
||||
opacity: 0.5;
|
||||
pointer-events: auto;
|
||||
}
|
||||
@@ -1,14 +1,12 @@
|
||||
import React, { useState, useCallback, useRef, useMemo } from "react";
|
||||
import {
|
||||
Text,
|
||||
ActionIcon,
|
||||
CheckboxIndicator,
|
||||
Tooltip,
|
||||
Modal,
|
||||
Button,
|
||||
Group,
|
||||
Stack,
|
||||
Loader,
|
||||
ActionIcon,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useIsMobile } from "@app/hooks/useIsMobile";
|
||||
import { alert } from "@app/components/toast";
|
||||
@@ -21,7 +19,6 @@ import UnarchiveIcon from "@mui/icons-material/Unarchive";
|
||||
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
|
||||
import LinkIcon from "@mui/icons-material/Link";
|
||||
import PushPinIcon from "@mui/icons-material/PushPin";
|
||||
import PushPinOutlinedIcon from "@mui/icons-material/PushPinOutlined";
|
||||
import LockOpenIcon from "@mui/icons-material/LockOpen";
|
||||
import DragIndicatorIcon from "@mui/icons-material/DragIndicator";
|
||||
import {
|
||||
@@ -31,11 +28,10 @@ import {
|
||||
import { StirlingFileStub } from "@app/types/fileContext";
|
||||
import { zipFileService } from "@app/services/zipFileService";
|
||||
|
||||
import styles from "@app/components/fileEditor/FileEditor.module.css";
|
||||
import styles from "@app/components/fileEditor/FileEditorThumbnail.module.css";
|
||||
import { useFileContext } from "@app/contexts/FileContext";
|
||||
import { useFileState } from "@app/contexts/file/fileHooks";
|
||||
import { FileId } from "@app/types/file";
|
||||
import { formatFileSize } from "@app/utils/fileUtils";
|
||||
import ToolChain from "@app/components/shared/ToolChain";
|
||||
import HoverActionMenu, {
|
||||
HoverAction,
|
||||
@@ -45,18 +41,17 @@ import { PrivateContent } from "@app/components/shared/PrivateContent";
|
||||
import UploadToServerModal from "@app/components/shared/UploadToServerModal";
|
||||
import ShareFileModal from "@app/components/shared/ShareFileModal";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import FileEditorFileName from "@app/components/fileEditor/FileEditorFileName";
|
||||
import { useFileThumbnail } from "@app/hooks/useFileThumbnail";
|
||||
import DocumentThumbnail from "@app/components/shared/filePreview/DocumentThumbnail";
|
||||
import { truncateCenter } from "@app/utils/textUtils";
|
||||
import { FileEditorStatusDot } from "@app/components/fileEditor/FileEditorStatusDot";
|
||||
|
||||
interface FileEditorThumbnailProps {
|
||||
file: StirlingFileStub;
|
||||
index: number;
|
||||
totalFiles: number;
|
||||
selectedFiles: FileId[];
|
||||
selectionMode: boolean;
|
||||
onToggleFile: (fileId: FileId) => void;
|
||||
onCloseFile: (fileId: FileId) => void;
|
||||
onViewFile: (fileId: FileId) => void;
|
||||
_onSetStatus: (status: string) => void;
|
||||
onReorderFiles?: (
|
||||
sourceFileId: FileId,
|
||||
targetFileId: FileId,
|
||||
@@ -70,12 +65,8 @@ interface FileEditorThumbnailProps {
|
||||
|
||||
const FileEditorThumbnail = ({
|
||||
file,
|
||||
index,
|
||||
selectedFiles,
|
||||
onToggleFile,
|
||||
onCloseFile,
|
||||
onViewFile,
|
||||
_onSetStatus,
|
||||
onReorderFiles,
|
||||
onDownloadFile,
|
||||
onUnzipFile,
|
||||
@@ -95,41 +86,41 @@ const FileEditorThumbnail = ({
|
||||
openEncryptedUnlockPrompt,
|
||||
} = useFileContext();
|
||||
const { state, selectors } = useFileState();
|
||||
const hasError = state.ui.errorFileIds.includes(file.id);
|
||||
|
||||
// ---- Drag state ----
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const dragElementRef = useRef<HTMLDivElement | null>(null);
|
||||
const [showHoverMenu, setShowHoverMenu] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
const [showCloseModal, setShowCloseModal] = useState(false);
|
||||
const [showUploadModal, setShowUploadModal] = useState(false);
|
||||
const [showShareModal, setShowShareModal] = useState(false);
|
||||
const [showSharedEditNotice, setShowSharedEditNotice] = useState(false);
|
||||
const sharedEditNoticeShownRef = useRef(false);
|
||||
|
||||
// Resolve the actual File object for pin/unpin operations
|
||||
const actualFile = useMemo(() => {
|
||||
return activeFiles.find((f) => f.fileId === file.id);
|
||||
}, [activeFiles, file.id]);
|
||||
const actualFile = useMemo(
|
||||
() => activeFiles.find((f) => f.fileId === file.id),
|
||||
[activeFiles, file.id],
|
||||
);
|
||||
const isPinned = actualFile ? isFilePinned(actualFile) : false;
|
||||
|
||||
// Check if this is a ZIP file
|
||||
const isZipFile = zipFileService.isZipFileStub(file);
|
||||
|
||||
const hasError = state.ui.errorFileIds.includes(file.id);
|
||||
const pageCount = file.processedFile?.totalPages || 0;
|
||||
const isEncrypted = Boolean(file.processedFile?.isEncrypted);
|
||||
const {
|
||||
isEncrypted,
|
||||
thumbnail: displayThumbnail,
|
||||
isGenerating: isThumbGenerating,
|
||||
} = useFileThumbnail(file);
|
||||
|
||||
// Aspect ratio from page dimensions, falling back to letter size
|
||||
const firstPage = file.processedFile?.pages?.[0];
|
||||
const firstPageRotation = firstPage?.rotation ?? 0;
|
||||
const isLandscape = firstPageRotation === 90 || firstPageRotation === 270;
|
||||
const thumbAspect = (() => {
|
||||
const w = firstPage?.width;
|
||||
const h = firstPage?.height;
|
||||
if (w && h && w > 0 && h > 0) {
|
||||
// width/height are effective (post-rotation) dims from PDFium, so use
|
||||
// them directly — no swapping needed.
|
||||
return `${w} / ${h}`;
|
||||
}
|
||||
return isLandscape ? "11 / 8.5" : "8.5 / 11";
|
||||
})();
|
||||
|
||||
const handleRef = useRef<HTMLSpanElement | null>(null);
|
||||
|
||||
// ---- Selection ----
|
||||
const isSelected = selectedFiles.includes(file.id);
|
||||
|
||||
// ---- Meta formatting ----
|
||||
const prettySize = useMemo(() => {
|
||||
return formatFileSize(file.size);
|
||||
}, [file.size]);
|
||||
const dragElementRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const extUpper = useMemo(() => {
|
||||
const m = /\.([a-z0-9]+)$/i.exec(file.name ?? "");
|
||||
@@ -143,6 +134,7 @@ const FileEditorThumbnail = ({
|
||||
|
||||
const isCBZ = extLower === "cbz";
|
||||
const isCBR = extLower === "cbr";
|
||||
|
||||
const uploadEnabled = config?.storageEnabled === true;
|
||||
const sharingEnabled =
|
||||
uploadEnabled && config?.storageSharingEnabled === true;
|
||||
@@ -178,7 +170,13 @@ const FileEditorThumbnail = ({
|
||||
}).format(d);
|
||||
}, [file.lastModified]);
|
||||
|
||||
// ---- Drag & drop wiring ----
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [showCloseModal, setShowCloseModal] = useState(false);
|
||||
const [showUploadModal, setShowUploadModal] = useState(false);
|
||||
const [showShareModal, setShowShareModal] = useState(false);
|
||||
const [showSharedEditNotice, setShowSharedEditNotice] = useState(false);
|
||||
const sharedEditNoticeShownRef = useRef(false);
|
||||
|
||||
const fileElementRef = useCallback(
|
||||
(element: HTMLDivElement | null) => {
|
||||
if (!element) return;
|
||||
@@ -191,14 +189,10 @@ const FileEditorThumbnail = ({
|
||||
type: "file",
|
||||
fileId: file.id,
|
||||
fileName: file.name,
|
||||
selectedFiles: [file.id], // Always drag only this file, ignore selection state
|
||||
selectedFiles: [file.id],
|
||||
}),
|
||||
onDragStart: () => {
|
||||
setIsDragging(true);
|
||||
},
|
||||
onDrop: () => {
|
||||
setIsDragging(false);
|
||||
},
|
||||
onDragStart: () => setIsDragging(true),
|
||||
onDrop: () => setIsDragging(false),
|
||||
});
|
||||
|
||||
const dropCleanup = dropTargetForElements({
|
||||
@@ -211,10 +205,7 @@ const FileEditorThumbnail = ({
|
||||
const sourceData = source.data;
|
||||
return sourceData.type === "file" && sourceData.fileId !== file.id;
|
||||
},
|
||||
onDragEnter: () => setIsDragOver(true),
|
||||
onDragLeave: () => setIsDragOver(false),
|
||||
onDrop: ({ source }) => {
|
||||
setIsDragOver(false);
|
||||
const sourceData = source.data;
|
||||
if (sourceData.type === "file" && onReorderFiles) {
|
||||
const sourceFileId = sourceData.fileId as FileId;
|
||||
@@ -229,13 +220,14 @@ const FileEditorThumbnail = ({
|
||||
dropCleanup();
|
||||
};
|
||||
},
|
||||
[file.id, file.name, selectedFiles, onReorderFiles],
|
||||
[file.id, file.name, onReorderFiles],
|
||||
);
|
||||
|
||||
// Handle close with confirmation
|
||||
const handleCloseWithConfirmation = useCallback(() => {
|
||||
setShowCloseModal(true);
|
||||
}, []);
|
||||
const handleCloseWithConfirmation = useCallback(
|
||||
() => setShowCloseModal(true),
|
||||
[],
|
||||
);
|
||||
const handleCancelClose = useCallback(() => setShowCloseModal(false), []);
|
||||
|
||||
const handleConfirmClose = useCallback(() => {
|
||||
onCloseFile(file.id);
|
||||
@@ -278,7 +270,6 @@ const FileEditorThumbnail = ({
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Then close
|
||||
onCloseFile(file.id);
|
||||
alert({
|
||||
alertType: "success",
|
||||
@@ -296,11 +287,6 @@ const FileEditorThumbnail = ({
|
||||
fileActions,
|
||||
]);
|
||||
|
||||
const handleCancelClose = useCallback(() => {
|
||||
setShowCloseModal(false);
|
||||
}, []);
|
||||
|
||||
// Build hover menu actions
|
||||
const hoverActions = useMemo<HoverAction[]>(
|
||||
() => [
|
||||
{
|
||||
@@ -312,6 +298,36 @@ const FileEditorThumbnail = ({
|
||||
onViewFile(file.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "pin",
|
||||
icon: <PushPinIcon style={{ fontSize: 20 }} />,
|
||||
label: isPinned
|
||||
? t("unpin", "Unpin File (replace after tool run)")
|
||||
: t("pin", "Pin File (keep active after tool run)"),
|
||||
dataTour: "file-card-pin",
|
||||
onClick: (e) => {
|
||||
e.stopPropagation();
|
||||
if (actualFile) {
|
||||
if (isPinned) {
|
||||
unpinFile(actualFile);
|
||||
alert({
|
||||
alertType: "neutral",
|
||||
title: `Unpinned ${file.name}`,
|
||||
expandable: false,
|
||||
durationMs: 3000,
|
||||
});
|
||||
} else {
|
||||
pinFile(actualFile);
|
||||
alert({
|
||||
alertType: "success",
|
||||
title: `Pinned ${file.name}`,
|
||||
expandable: false,
|
||||
durationMs: 3000,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "download",
|
||||
icon: <DownloadOutlinedIcon style={{ fontSize: 20 }} />,
|
||||
@@ -321,36 +337,32 @@ const FileEditorThumbnail = ({
|
||||
onDownloadFile(file.id);
|
||||
},
|
||||
},
|
||||
...(canUpload || canShare
|
||||
...(canUpload
|
||||
? [
|
||||
...(canUpload
|
||||
? [
|
||||
{
|
||||
id: "upload",
|
||||
icon: <CloudUploadIcon style={{ fontSize: 20 }} />,
|
||||
label: isUploaded
|
||||
? t("fileManager.updateOnServer", "Update on Server")
|
||||
: t("fileManager.uploadToServer", "Upload to Server"),
|
||||
onClick: (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setShowUploadModal(true);
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(canShare
|
||||
? [
|
||||
{
|
||||
id: "share",
|
||||
icon: <LinkIcon style={{ fontSize: 20 }} />,
|
||||
label: t("fileManager.share", "Share"),
|
||||
onClick: (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setShowShareModal(true);
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: "upload",
|
||||
icon: <CloudUploadIcon style={{ fontSize: 20 }} />,
|
||||
label: isUploaded
|
||||
? t("fileManager.updateOnServer", "Update on Server")
|
||||
: t("fileManager.uploadToServer", "Upload to Server"),
|
||||
onClick: (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setShowUploadModal(true);
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(canShare
|
||||
? [
|
||||
{
|
||||
id: "share",
|
||||
icon: <LinkIcon style={{ fontSize: 20 }} />,
|
||||
label: t("fileManager.share", "Share"),
|
||||
onClick: (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setShowShareModal(true);
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
@@ -389,7 +401,10 @@ const FileEditorThumbnail = ({
|
||||
isZipFile,
|
||||
isCBZ,
|
||||
isCBR,
|
||||
isPinned,
|
||||
actualFile,
|
||||
terminology,
|
||||
DownloadOutlinedIcon,
|
||||
onViewFile,
|
||||
onDownloadFile,
|
||||
onUnzipFile,
|
||||
@@ -397,13 +412,13 @@ const FileEditorThumbnail = ({
|
||||
canUpload,
|
||||
canShare,
|
||||
isUploaded,
|
||||
pinFile,
|
||||
unpinFile,
|
||||
],
|
||||
);
|
||||
|
||||
// ---- Card interactions ----
|
||||
const handleCardClick = () => {
|
||||
if (!isSupported) return;
|
||||
// Clear error state if file has an error (click to clear error)
|
||||
if (hasError) {
|
||||
try {
|
||||
fileActions.clearFileError(file.id);
|
||||
@@ -415,7 +430,6 @@ const FileEditorThumbnail = ({
|
||||
sharedEditNoticeShownRef.current = true;
|
||||
setShowSharedEditNotice(true);
|
||||
}
|
||||
onToggleFile(file.id);
|
||||
};
|
||||
|
||||
const handleCardDoubleClick = () => {
|
||||
@@ -423,12 +437,9 @@ const FileEditorThumbnail = ({
|
||||
onViewFile(file.id);
|
||||
};
|
||||
|
||||
// ---- Style helpers ----
|
||||
const getHeaderClassName = () => {
|
||||
if (hasError) return styles.headerError;
|
||||
if (!isSupported) return styles.headerUnsupported;
|
||||
return isSelected ? styles.headerSelected : styles.headerResting;
|
||||
};
|
||||
const metaLine = [dateLabel, extUpper ? `${extUpper} file` : "", pageLabel]
|
||||
.filter(Boolean)
|
||||
.join(" - ");
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -436,268 +447,130 @@ const FileEditorThumbnail = ({
|
||||
data-file-id={file.id}
|
||||
data-testid="file-thumbnail"
|
||||
data-tour="file-card-checkbox"
|
||||
data-selected={isSelected}
|
||||
data-supported={isSupported}
|
||||
className={`${styles.card} w-[18rem] h-[22rem] select-none flex flex-col shadow-sm transition-all relative`}
|
||||
style={
|
||||
{
|
||||
opacity: isDragging ? 0.4 : 1,
|
||||
outline: isDragOver
|
||||
? "3px dashed var(--mantine-color-blue-5, #3b82f6)"
|
||||
: undefined,
|
||||
outlineOffset: isDragOver ? "2px" : undefined,
|
||||
transform: isDragOver ? "scale(1.02)" : undefined,
|
||||
transition:
|
||||
"outline 120ms ease, transform 120ms ease, opacity 120ms ease",
|
||||
// Tag each card with a stable, unique view-transition-name so the
|
||||
// browser can animate the reorder (see FileEditor.handleReorderFiles,
|
||||
// which dispatches reorderFiles inside document.startViewTransition).
|
||||
viewTransitionName: `file-card-${file.id}`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={`${styles.card} select-none`}
|
||||
style={{ opacity: isDragging ? 0.9 : 1 }}
|
||||
tabIndex={0}
|
||||
role="listitem"
|
||||
aria-selected={isSelected}
|
||||
onClick={handleCardClick}
|
||||
onMouseEnter={() => setShowHoverMenu(true)}
|
||||
onMouseLeave={() => setShowHoverMenu(false)}
|
||||
onDoubleClick={handleCardDoubleClick}
|
||||
>
|
||||
{/* Header bar */}
|
||||
<div
|
||||
className={`${styles.header} ${getHeaderClassName()}`}
|
||||
data-has-error={hasError}
|
||||
>
|
||||
{/* Logo/checkbox area */}
|
||||
<div className={styles.logoMark}>
|
||||
{hasError ? (
|
||||
<div className={styles.errorPill}>
|
||||
<span>{t("error._value", "Error")}</span>
|
||||
</div>
|
||||
) : isSupported ? (
|
||||
<CheckboxIndicator
|
||||
checked={isSelected}
|
||||
onChange={() => onToggleFile(file.id)}
|
||||
color="var(--checkbox-checked-bg)"
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.unsupportedPill}>
|
||||
<span>{t("unsupported", "Unsupported")}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Centered index */}
|
||||
<div
|
||||
className={styles.headerIndex}
|
||||
aria-label={`Position ${index + 1}`}
|
||||
>
|
||||
{index + 1}
|
||||
</div>
|
||||
|
||||
{/* Action buttons group */}
|
||||
<div className={styles.headerActions}>
|
||||
{isEncrypted && (
|
||||
<Tooltip
|
||||
label={t(
|
||||
"encryptedPdfUnlock.unlockPrompt",
|
||||
"Unlock PDF to continue",
|
||||
<div className={styles.thumbInner}>
|
||||
{/* Thumbnail area */}
|
||||
<div className={styles.thumbWrap}>
|
||||
{/* thumbUnit groups toolchain + card so they center together, keeping text tight above the card */}
|
||||
<div className={styles.thumbUnit}>
|
||||
{/* Tool chain bar — always rendered for consistent height, content only when history exists */}
|
||||
<div className={styles.toolChainBar}>
|
||||
{file.toolHistory && file.toolHistory.length > 0 && (
|
||||
<ToolChain
|
||||
toolChain={file.toolHistory}
|
||||
displayStyle="text"
|
||||
size="xs"
|
||||
maxWidth="100%"
|
||||
color="var(--mantine-color-gray-7)"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={styles.thumbContainer}
|
||||
data-supported={isSupported}
|
||||
style={{ "--thumb-aspect": thumbAspect } as React.CSSProperties}
|
||||
>
|
||||
<ActionIcon
|
||||
aria-label={t(
|
||||
"encryptedPdfUnlock.unlockPrompt",
|
||||
"Unlock PDF to continue",
|
||||
)}
|
||||
variant="subtle"
|
||||
className={styles.headerIconButton}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openEncryptedUnlockPrompt(file.id);
|
||||
}}
|
||||
>
|
||||
<LockOpenIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* Pin/Unpin icon */}
|
||||
<Tooltip
|
||||
label={
|
||||
isPinned
|
||||
? t("unpin", "Unpin File (replace after tool run)")
|
||||
: t("pin", "Pin File (keep active after tool run)")
|
||||
}
|
||||
>
|
||||
<ActionIcon
|
||||
aria-label={
|
||||
isPinned
|
||||
? t("unpin", "Unpin File (replace after tool run)")
|
||||
: t("pin", "Pin File (keep active after tool run)")
|
||||
}
|
||||
variant="subtle"
|
||||
className={isPinned ? styles.pinned : styles.headerIconButton}
|
||||
data-tour="file-card-pin"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (actualFile) {
|
||||
if (isPinned) {
|
||||
unpinFile(actualFile);
|
||||
alert({
|
||||
alertType: "neutral",
|
||||
title: `Unpinned ${file.name}`,
|
||||
expandable: false,
|
||||
durationMs: 3000,
|
||||
});
|
||||
} else {
|
||||
pinFile(actualFile);
|
||||
alert({
|
||||
alertType: "success",
|
||||
title: `Pinned ${file.name}`,
|
||||
expandable: false,
|
||||
durationMs: 3000,
|
||||
});
|
||||
}
|
||||
{/* Error overlay */}
|
||||
{hasError && (
|
||||
<div className={styles.errorOverlay}>
|
||||
<span className={styles.errorPill}>
|
||||
{t("error._value", "Error")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Thumbnail image or loading state */}
|
||||
<DocumentThumbnail
|
||||
file={file}
|
||||
thumbnail={displayThumbnail || undefined}
|
||||
isEncrypted={isEncrypted}
|
||||
isLoading={
|
||||
!isEncrypted &&
|
||||
!displayThumbnail &&
|
||||
(isThumbGenerating ||
|
||||
file.type?.startsWith("application/pdf") ||
|
||||
file.type?.startsWith("image/"))
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isPinned ? (
|
||||
<PushPinIcon fontSize="small" />
|
||||
) : (
|
||||
<PushPinOutlinedIcon fontSize="small" />
|
||||
)}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title + meta line */}
|
||||
<div
|
||||
style={{
|
||||
padding: "0.5rem",
|
||||
textAlign: "center",
|
||||
background: "var(--file-card-bg)",
|
||||
marginTop: "0.5rem",
|
||||
marginBottom: "0.5rem",
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
size="lg"
|
||||
fw={700}
|
||||
className={styles.title}
|
||||
title={file.name}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: "0.25rem",
|
||||
}}
|
||||
>
|
||||
<FileEditorFileName file={file} />
|
||||
</Text>
|
||||
<Text
|
||||
size="sm"
|
||||
c="dimmed"
|
||||
className={styles.meta}
|
||||
lineClamp={3}
|
||||
title={`${extUpper || "FILE"} • ${prettySize}`}
|
||||
>
|
||||
{/* e.g., v2 - Jan 29, 2025 - PDF file - 3 Pages */}
|
||||
{`v${file.versionNumber} - `}
|
||||
{dateLabel}
|
||||
{extUpper ? ` - ${extUpper} file` : ""}
|
||||
{pageLabel ? ` - ${pageLabel}` : ""}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* Preview area */}
|
||||
<div
|
||||
className={`${styles.previewBox} mx-6 mb-4 relative flex-1`}
|
||||
style={
|
||||
isSupported || hasError
|
||||
? undefined
|
||||
: { filter: "grayscale(80%)", opacity: 0.6 }
|
||||
}
|
||||
>
|
||||
<div className={styles.previewPaper}>
|
||||
{file.thumbnailUrl ? (
|
||||
<PrivateContent>
|
||||
<img
|
||||
src={file.thumbnailUrl}
|
||||
alt={file.name}
|
||||
draggable={false}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={(e) => {
|
||||
const img = e.currentTarget;
|
||||
img.style.display = "none";
|
||||
img.parentElement?.setAttribute("data-thumb-missing", "true");
|
||||
}}
|
||||
style={{
|
||||
maxWidth: "80%",
|
||||
maxHeight: "80%",
|
||||
objectFit: "contain",
|
||||
borderRadius: 0,
|
||||
background: "#ffffff",
|
||||
border: "1px solid var(--border-default)",
|
||||
display: "block",
|
||||
marginLeft: "auto",
|
||||
marginRight: "auto",
|
||||
alignSelf: "start",
|
||||
iconSize="6rem"
|
||||
imgClassName={styles.thumbImage}
|
||||
onImageError={(e) => {
|
||||
e.currentTarget.style.display = "none";
|
||||
}}
|
||||
/>
|
||||
</PrivateContent>
|
||||
) : file.type?.startsWith("application/pdf") ? (
|
||||
<Stack
|
||||
align="center"
|
||||
justify="center"
|
||||
gap="xs"
|
||||
style={{ height: "100%" }}
|
||||
>
|
||||
<Loader size="sm" />
|
||||
<Text size="xs" c="dimmed">
|
||||
Loading thumbnail...
|
||||
</Text>
|
||||
</Stack>
|
||||
) : null}
|
||||
|
||||
{/* Badges — top-left: version, pin, ownership, encrypted */}
|
||||
<div className={styles.thumbBadges}>
|
||||
<span className={styles.versionBadgeThumb}>
|
||||
v{file.versionNumber}
|
||||
</span>
|
||||
{isPinned && (
|
||||
<span className={styles.pinnedBadge}>
|
||||
<PushPinIcon style={{ fontSize: 12 }} />
|
||||
</span>
|
||||
)}
|
||||
{isSharedFile && !isOwnedOrLocal && (
|
||||
<span className={styles.ownershipBadge}>
|
||||
{t("fileManager.sharedWithYou", "Shared")}
|
||||
</span>
|
||||
)}
|
||||
{isEncrypted && (
|
||||
<Tooltip
|
||||
label={t(
|
||||
"encryptedPdfUnlock.unlockPrompt",
|
||||
"Unlock PDF to continue",
|
||||
)}
|
||||
>
|
||||
<ActionIcon
|
||||
size="xs"
|
||||
variant="filled"
|
||||
color="yellow"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openEncryptedUnlockPrompt(file.id);
|
||||
}}
|
||||
style={{ pointerEvents: "auto" }}
|
||||
>
|
||||
<LockOpenIcon style={{ fontSize: 12 }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FileEditorStatusDot file={file} />
|
||||
</div>
|
||||
</div>
|
||||
{/* end thumbUnit */}
|
||||
|
||||
{/* Drag handle */}
|
||||
<span ref={handleRef} className={styles.dragHandle} aria-hidden>
|
||||
<DragIndicatorIcon fontSize="small" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Drag handle (span wrapper so we can attach a ref reliably) */}
|
||||
<span ref={handleRef} className={styles.dragHandle} aria-hidden>
|
||||
<DragIndicatorIcon fontSize="small" />
|
||||
</span>
|
||||
|
||||
{/* Tool chain display at bottom */}
|
||||
{file.toolHistory && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: "4px",
|
||||
left: "4px",
|
||||
right: "4px",
|
||||
padding: "4px 6px",
|
||||
textAlign: "center",
|
||||
fontWeight: 600,
|
||||
overflow: "hidden",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
<ToolChain
|
||||
toolChain={file.toolHistory}
|
||||
displayStyle="text"
|
||||
size="xs"
|
||||
maxWidth={"100%"}
|
||||
color="var(--mantine-color-gray-7)"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* Hover action menu — visibility driven by CSS on desktop, always shown on mobile */}
|
||||
<HoverActionMenu
|
||||
show={isMobile}
|
||||
actions={hoverActions}
|
||||
position="outside"
|
||||
visibility="cssHover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Hover Menu */}
|
||||
<HoverActionMenu
|
||||
show={showHoverMenu || isMobile}
|
||||
actions={hoverActions}
|
||||
position="outside"
|
||||
/>
|
||||
{/* File name + meta */}
|
||||
<div className={styles.fileText}>
|
||||
<p className={styles.fileName}>
|
||||
<PrivateContent>{truncateCenter(file.name, 40)}</PrivateContent>
|
||||
</p>
|
||||
<p className={styles.fileMeta}>{metaLine}</p>
|
||||
</div>
|
||||
|
||||
{/* Close Confirmation Modal */}
|
||||
<Modal
|
||||
@@ -714,7 +587,7 @@ const FileEditorThumbnail = ({
|
||||
{t("confirmCloseUnsaved", "This file has unsaved changes.")}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" fw={500}>
|
||||
{file.name}
|
||||
<PrivateContent>{file.name}</PrivateContent>
|
||||
</Text>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="light" onClick={handleCancelClose}>
|
||||
@@ -741,7 +614,7 @@ const FileEditorThumbnail = ({
|
||||
)}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" fw={500}>
|
||||
{file.name}
|
||||
<PrivateContent>{file.name}</PrivateContent>
|
||||
</Text>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="light" onClick={handleCancelClose}>
|
||||
@@ -759,6 +632,8 @@ const FileEditorThumbnail = ({
|
||||
)}
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
{/* Shared edit notice modal */}
|
||||
<Modal
|
||||
opened={showSharedEditNotice}
|
||||
onClose={() => setShowSharedEditNotice(false)}
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
useRightRailButtons,
|
||||
RightRailButtonWithAction,
|
||||
} from "@app/hooks/useRightRailButtons";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
|
||||
interface FileEditorRightRailButtonsParams {
|
||||
totalItems: number;
|
||||
selectedCount: number;
|
||||
onSelectAll: () => void;
|
||||
onDeselectAll: () => void;
|
||||
onCloseSelected: () => void;
|
||||
}
|
||||
|
||||
export function useFileEditorRightRailButtons({
|
||||
totalItems,
|
||||
selectedCount,
|
||||
onSelectAll,
|
||||
onDeselectAll,
|
||||
onCloseSelected,
|
||||
}: FileEditorRightRailButtonsParams) {
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
const buttons = useMemo<RightRailButtonWithAction[]>(
|
||||
() => [
|
||||
{
|
||||
id: "file-select-all",
|
||||
icon: <LocalIcon icon="select-all" width="1.5rem" height="1.5rem" />,
|
||||
tooltip: t("rightRail.selectAll", "Select All"),
|
||||
ariaLabel:
|
||||
typeof t === "function"
|
||||
? t("rightRail.selectAll", "Select All")
|
||||
: "Select All",
|
||||
section: "top" as const,
|
||||
order: 10,
|
||||
disabled: totalItems === 0 || selectedCount === totalItems,
|
||||
visible: totalItems > 0,
|
||||
onClick: onSelectAll,
|
||||
},
|
||||
{
|
||||
id: "file-deselect-all",
|
||||
icon: (
|
||||
<LocalIcon
|
||||
icon="crop-square-outline"
|
||||
width="1.5rem"
|
||||
height="1.5rem"
|
||||
/>
|
||||
),
|
||||
tooltip: t("rightRail.deselectAll", "Deselect All"),
|
||||
ariaLabel:
|
||||
typeof t === "function"
|
||||
? t("rightRail.deselectAll", "Deselect All")
|
||||
: "Deselect All",
|
||||
section: "top" as const,
|
||||
order: 20,
|
||||
disabled: selectedCount === 0,
|
||||
visible: totalItems > 0,
|
||||
onClick: onDeselectAll,
|
||||
},
|
||||
{
|
||||
id: "file-close-selected",
|
||||
icon: <LocalIcon icon="close-rounded" width="1.5rem" height="1.5rem" />,
|
||||
tooltip: t("rightRail.closeSelected", "Close Selected Files"),
|
||||
ariaLabel:
|
||||
typeof t === "function"
|
||||
? t("rightRail.closeSelected", "Close Selected Files")
|
||||
: "Close Selected Files",
|
||||
section: "top" as const,
|
||||
order: 30,
|
||||
disabled: selectedCount === 0,
|
||||
visible: totalItems > 0,
|
||||
onClick: onCloseSelected,
|
||||
},
|
||||
],
|
||||
[
|
||||
t,
|
||||
i18n.language,
|
||||
totalItems,
|
||||
selectedCount,
|
||||
onSelectAll,
|
||||
onDeselectAll,
|
||||
onCloseSelected,
|
||||
],
|
||||
);
|
||||
|
||||
useRightRailButtons(buttons);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ interface CompactFileDetailsProps {
|
||||
onPrevious: () => void;
|
||||
onNext: () => void;
|
||||
onOpenFiles: () => void;
|
||||
canCloseAll?: boolean;
|
||||
}
|
||||
|
||||
const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
|
||||
@@ -30,6 +31,7 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
|
||||
onPrevious,
|
||||
onNext,
|
||||
onOpenFiles,
|
||||
canCloseAll = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const hasSelection = selectedFiles.length > 0;
|
||||
@@ -94,7 +96,7 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text size="sm" fw={500} truncate>
|
||||
<PrivateContent>
|
||||
{currentFile ? currentFile.name : "No file selected"}
|
||||
{currentFile ? currentFile.name : "No file loaded"}
|
||||
</PrivateContent>
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
@@ -149,18 +151,21 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onOpenFiles}
|
||||
disabled={!hasSelection}
|
||||
disabled={!hasSelection && !canCloseAll}
|
||||
fullWidth
|
||||
style={{
|
||||
backgroundColor: hasSelection
|
||||
? "var(--btn-open-file)"
|
||||
: "var(--mantine-color-gray-4)",
|
||||
backgroundColor:
|
||||
hasSelection || canCloseAll
|
||||
? "var(--btn-open-file)"
|
||||
: "var(--mantine-color-gray-4)",
|
||||
color: "white",
|
||||
}}
|
||||
>
|
||||
{selectedFiles.length > 1
|
||||
? t("fileManager.openFiles", `Open ${selectedFiles.length} Files`)
|
||||
: t("fileManager.openFile", "Open File")}
|
||||
{canCloseAll
|
||||
? t("fileManager.closeAllFiles", "Close all files")
|
||||
: selectedFiles.length > 1
|
||||
? t("fileManager.openFiles", `Open ${selectedFiles.length} Files`)
|
||||
: t("fileManager.openFile", "Open File")}
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -12,7 +12,8 @@ interface FileDetailsProps {
|
||||
}
|
||||
|
||||
const FileDetails: React.FC<FileDetailsProps> = ({ compact = false }) => {
|
||||
const { selectedFiles, onOpenFiles, modalHeight } = useFileManagerContext();
|
||||
const { selectedFiles, onOpenFiles, modalHeight, activeFileIds } =
|
||||
useFileManagerContext();
|
||||
const { t } = useTranslation();
|
||||
const [currentFileIndex, setCurrentFileIndex] = useState(0);
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
@@ -21,6 +22,9 @@ const FileDetails: React.FC<FileDetailsProps> = ({ compact = false }) => {
|
||||
const currentFile =
|
||||
selectedFiles.length > 0 ? selectedFiles[currentFileIndex] : null;
|
||||
const hasSelection = selectedFiles.length > 0;
|
||||
const hasActiveFiles = activeFileIds.length > 0;
|
||||
// Enable "Close all files" when nothing is checked but files are open in workbench
|
||||
const canCloseAll = !hasSelection && hasActiveFiles;
|
||||
|
||||
// Use IndexedDB hook for the current file
|
||||
const { thumbnail: currentThumbnail } = useIndexedDBThumbnail(currentFile);
|
||||
@@ -71,6 +75,7 @@ const FileDetails: React.FC<FileDetailsProps> = ({ compact = false }) => {
|
||||
onPrevious={handlePrevious}
|
||||
onNext={handleNext}
|
||||
onOpenFiles={onOpenFiles}
|
||||
canCloseAll={canCloseAll}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -104,18 +109,21 @@ const FileDetails: React.FC<FileDetailsProps> = ({ compact = false }) => {
|
||||
<Button
|
||||
size="md"
|
||||
onClick={onOpenFiles}
|
||||
disabled={!hasSelection}
|
||||
disabled={!hasSelection && !canCloseAll}
|
||||
fullWidth
|
||||
style={{
|
||||
backgroundColor: hasSelection
|
||||
? "var(--btn-open-file)"
|
||||
: "var(--mantine-color-gray-4)",
|
||||
backgroundColor:
|
||||
hasSelection || canCloseAll
|
||||
? "var(--btn-open-file)"
|
||||
: "var(--mantine-color-gray-4)",
|
||||
color: "white",
|
||||
}}
|
||||
>
|
||||
{selectedFiles.length > 1
|
||||
? t("fileManager.openFiles", `Open ${selectedFiles.length} Files`)
|
||||
: t("fileManager.openFile", "Open File")}
|
||||
{canCloseAll
|
||||
? t("fileManager.closeAllFiles", "Close all files")
|
||||
: selectedFiles.length > 1
|
||||
? t("fileManager.openFiles", `Open ${selectedFiles.length} Files`)
|
||||
: t("fileManager.openFile", "Open File")}
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -11,7 +11,6 @@ interface FileHistoryGroupProps {
|
||||
onDownloadSingle: (file: StirlingFileStub) => void;
|
||||
onFileDoubleClick: (file: StirlingFileStub) => void;
|
||||
onHistoryFileRemove: (file: StirlingFileStub) => void;
|
||||
isFileSupported: (fileName: string) => boolean;
|
||||
}
|
||||
|
||||
const FileHistoryGroup: React.FC<FileHistoryGroupProps> = ({
|
||||
@@ -21,7 +20,6 @@ const FileHistoryGroup: React.FC<FileHistoryGroupProps> = ({
|
||||
onDownloadSingle,
|
||||
onFileDoubleClick,
|
||||
onHistoryFileRemove,
|
||||
isFileSupported,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -50,7 +48,6 @@ const FileHistoryGroup: React.FC<FileHistoryGroupProps> = ({
|
||||
key={`history-${historyFile.id}-${historyFile.versionNumber || 1}`}
|
||||
file={historyFile}
|
||||
isSelected={false} // History files are not selectable
|
||||
isSupported={isFileSupported(historyFile.name)}
|
||||
onSelect={() => {}} // No selection for history files
|
||||
onRemove={() => onHistoryFileRemove(historyFile)} // Remove specific history file
|
||||
onDownload={() => onDownloadSingle(historyFile)}
|
||||
|
||||
@@ -28,7 +28,6 @@ const FileListArea: React.FC<FileListAreaProps> = ({
|
||||
onHistoryFileRemove,
|
||||
onFileDoubleClick,
|
||||
onDownloadSingle,
|
||||
isFileSupported,
|
||||
isLoading,
|
||||
activeFileIds,
|
||||
} = useFileManagerContext();
|
||||
@@ -65,7 +64,6 @@ const FileListArea: React.FC<FileListAreaProps> = ({
|
||||
<FileListItem
|
||||
file={file}
|
||||
isSelected={selectedFilesSet.has(file.id)}
|
||||
isSupported={isFileSupported(file.name)}
|
||||
onSelect={(shiftKey) => onFileSelect(file, index, shiftKey)}
|
||||
onRemove={() => onFileRemove(index)}
|
||||
onDownload={() => onDownloadSingle(file)}
|
||||
@@ -82,7 +80,6 @@ const FileListArea: React.FC<FileListAreaProps> = ({
|
||||
onDownloadSingle={onDownloadSingle}
|
||||
onFileDoubleClick={onFileDoubleClick}
|
||||
onHistoryFileRemove={onHistoryFileRemove}
|
||||
isFileSupported={isFileSupported}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
|
||||
@@ -39,7 +39,6 @@ import { alert } from "@app/components/toast";
|
||||
interface FileListItemProps {
|
||||
file: StirlingFileStub;
|
||||
isSelected: boolean;
|
||||
isSupported: boolean;
|
||||
onSelect: (shiftKey?: boolean) => void;
|
||||
onRemove: () => void;
|
||||
onDownload?: () => void;
|
||||
@@ -53,7 +52,6 @@ interface FileListItemProps {
|
||||
const FileListItem: React.FC<FileListItemProps> = ({
|
||||
file,
|
||||
isSelected,
|
||||
isSupported,
|
||||
onSelect,
|
||||
onRemove,
|
||||
onDownload,
|
||||
@@ -187,15 +185,12 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
||||
<Box
|
||||
p="sm"
|
||||
style={{
|
||||
cursor: isHistoryFile || isActive ? "default" : "pointer",
|
||||
backgroundColor: isActive
|
||||
? "var(--file-active-bg)"
|
||||
: isSelected
|
||||
cursor: isHistoryFile ? "default" : "pointer",
|
||||
backgroundColor:
|
||||
isSelected || shouldShowHovered
|
||||
? "var(--mantine-color-gray-1)"
|
||||
: shouldShowHovered
|
||||
? "var(--mantine-color-gray-1)"
|
||||
: "var(--bg-file-list)",
|
||||
opacity: isSupported ? 1 : 0.5,
|
||||
: "var(--bg-file-list)",
|
||||
opacity: 1,
|
||||
transition: "background-color 0.15s ease",
|
||||
userSelect: "none",
|
||||
WebkitUserSelect: "none",
|
||||
@@ -206,9 +201,7 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
||||
? "3px solid var(--mantine-color-blue-4)"
|
||||
: "none", // Visual indicator for history
|
||||
}}
|
||||
onClick={
|
||||
isHistoryFile || isActive ? undefined : (e) => onSelect(e.shiftKey)
|
||||
}
|
||||
onClick={isHistoryFile ? undefined : (e) => onSelect(e.shiftKey)}
|
||||
onDoubleClick={onDoubleClick}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
@@ -218,16 +211,14 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
||||
<Box>
|
||||
{/* Checkbox for regular files only */}
|
||||
<Checkbox
|
||||
checked={isActive || isSelected}
|
||||
checked={isSelected}
|
||||
onChange={() => {}} // Handled by parent onClick
|
||||
size="sm"
|
||||
pl="sm"
|
||||
pr="xs"
|
||||
disabled={isActive}
|
||||
color={isActive ? "green" : undefined}
|
||||
styles={{
|
||||
input: {
|
||||
cursor: isActive ? "not-allowed" : "pointer",
|
||||
cursor: "pointer",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -10,28 +10,12 @@ import { useFileActionIcons } from "@app/hooks/useFileActionIcons";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import { useIsMobile } from "@app/hooks/useIsMobile";
|
||||
import MobileUploadModal from "@app/components/shared/MobileUploadModal";
|
||||
import { GoogleDriveIcon } from "@app/components/shared/CloudStorageIcons";
|
||||
|
||||
interface FileSourceButtonsProps {
|
||||
horizontal?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Google Drive icon component - displays the branded SVG icon
|
||||
* Shows grayscale when disabled
|
||||
*/
|
||||
const GoogleDriveIcon: React.FC<{ disabled?: boolean }> = ({ disabled }) => (
|
||||
<img
|
||||
src="/images/google-drive.svg"
|
||||
alt="Google Drive"
|
||||
style={{
|
||||
width: "20px",
|
||||
height: "20px",
|
||||
opacity: disabled ? 0.5 : 1,
|
||||
filter: disabled ? "grayscale(100%)" : "none",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
|
||||
horizontal = false,
|
||||
}) => {
|
||||
@@ -144,7 +128,7 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="var(--mantine-color-gray-6)"
|
||||
leftSection={<GoogleDriveIcon disabled={!isGoogleDriveEnabled} />}
|
||||
leftSection={<GoogleDriveIcon colored={isGoogleDriveEnabled} />}
|
||||
justify={horizontal ? "center" : "flex-start"}
|
||||
onClick={handleGoogleDriveClick}
|
||||
fullWidth={!horizontal}
|
||||
|
||||
@@ -1,3 +1,24 @@
|
||||
/* WorkbenchBar slide-in/out animation using CSS grid trick */
|
||||
.workbenchBarWrapper {
|
||||
display: grid;
|
||||
grid-template-rows: 1fr;
|
||||
transition: grid-template-rows 280ms ease;
|
||||
}
|
||||
|
||||
.workbenchBarWrapper[data-hidden="true"] {
|
||||
grid-template-rows: 0fr;
|
||||
}
|
||||
|
||||
.workbenchBarWrapper[data-no-transition="true"] {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
/* Direct child must have min-height: 0 so the row can collapse below content size */
|
||||
.workbenchBarInner {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.workbenchScrollable {
|
||||
overflow-y: auto !important;
|
||||
overflow-x: hidden !important;
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
import { Suspense, lazy, useCallback } from "react";
|
||||
import { useEffect, useState, Suspense, lazy } from "react";
|
||||
import { Box, Loader, Center } from "@mantine/core";
|
||||
import { useRainbowThemeContext } from "@app/components/shared/RainbowThemeProvider";
|
||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||
import { useFileHandler } from "@app/hooks/useFileHandler";
|
||||
import { useFileState, useFileActions } from "@app/contexts/FileContext";
|
||||
import { useFileState } from "@app/contexts/FileContext";
|
||||
import {
|
||||
useNavigationState,
|
||||
useNavigationActions,
|
||||
useNavigationGuard,
|
||||
} from "@app/contexts/NavigationContext";
|
||||
import { isBaseWorkbench } from "@app/types/workbench";
|
||||
import { useViewer } from "@app/contexts/ViewerContext";
|
||||
import { VIEWER_SUPPORTED_EXTENSIONS } from "@app/utils/fileUtils";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import { FileId } from "@app/types/file";
|
||||
import styles from "@app/components/layout/Workbench.module.css";
|
||||
|
||||
import TopControls from "@app/components/shared/TopControls";
|
||||
import WorkbenchBar from "@app/components/shared/WorkbenchBar";
|
||||
import LandingPage from "@app/components/shared/LandingPage";
|
||||
import Footer from "@app/components/shared/Footer";
|
||||
import DismissAllErrorsButton from "@app/components/shared/DismissAllErrorsButton";
|
||||
@@ -37,7 +35,6 @@ export default function Workbench() {
|
||||
|
||||
// Use context-based hooks to eliminate all prop drilling
|
||||
const { selectors } = useFileState();
|
||||
const { actions: fileActions } = useFileActions();
|
||||
const { workbench: currentView } = useNavigationState();
|
||||
const { actions: navActions } = useNavigationActions();
|
||||
const setCurrentView = navActions.setWorkbench;
|
||||
@@ -61,34 +58,15 @@ export default function Workbench() {
|
||||
const { toolRegistry } = useToolWorkflow();
|
||||
const selectedTool = selectedToolId ? toolRegistry[selectedToolId] : null;
|
||||
const { addFiles } = useFileHandler();
|
||||
const hasFiles = activeFiles.length > 0;
|
||||
|
||||
// Get active file index from ViewerContext
|
||||
const { activeFileIndex, setActiveFileIndex } = useViewer();
|
||||
|
||||
// Get navigation guard for unsaved changes check when switching files
|
||||
const { requestNavigation } = useNavigationGuard();
|
||||
|
||||
// Wrap file selection to check for unsaved changes before switching
|
||||
// requestNavigation will show the modal if there are unsaved changes, otherwise navigate immediately
|
||||
const handleFileSelect = useCallback(
|
||||
(index: number) => {
|
||||
// Don't do anything if selecting the same file
|
||||
if (index === activeFileIndex) return;
|
||||
|
||||
// requestNavigation handles the unsaved changes check internally
|
||||
requestNavigation(() => {
|
||||
setActiveFileIndex(index);
|
||||
});
|
||||
},
|
||||
[activeFileIndex, requestNavigation, setActiveFileIndex],
|
||||
);
|
||||
|
||||
const handleFileRemove = useCallback(
|
||||
async (fileId: FileId) => {
|
||||
await fileActions.removeFiles([fileId], false); // false = don't delete from IndexedDB, just remove from context
|
||||
},
|
||||
[fileActions],
|
||||
);
|
||||
// Enable bar transitions after first paint so the initial hidden state shows
|
||||
// without animating (landing page on load shouldn't animate the bar up).
|
||||
const [barTransitionEnabled, setBarTransitionEnabled] = useState(false);
|
||||
useEffect(() => {
|
||||
const raf = requestAnimationFrame(() => setBarTransitionEnabled(true));
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, []);
|
||||
|
||||
const handlePreviewClose = () => {
|
||||
setPreviewFile(null);
|
||||
@@ -130,7 +108,9 @@ export default function Workbench() {
|
||||
return (
|
||||
<FileEditor
|
||||
toolMode={!!selectedToolId}
|
||||
supportedExtensions={selectedTool?.supportedFormats || ["pdf"]}
|
||||
supportedExtensions={
|
||||
selectedTool?.supportedFormats || VIEWER_SUPPORTED_EXTENSIONS
|
||||
}
|
||||
{...(!selectedToolId && {
|
||||
onOpenPageEditor: () => {
|
||||
setCurrentView("pageEditor");
|
||||
@@ -150,8 +130,6 @@ export default function Workbench() {
|
||||
setSidebarsVisible={setSidebarsVisible}
|
||||
previewFile={previewFile}
|
||||
onClose={handlePreviewClose}
|
||||
activeFileIndex={activeFileIndex}
|
||||
setActiveFileIndex={setActiveFileIndex}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -195,7 +173,7 @@ export default function Workbench() {
|
||||
);
|
||||
|
||||
default:
|
||||
return <LandingPage />;
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -209,27 +187,23 @@ export default function Workbench() {
|
||||
: { backgroundColor: "var(--bg-background)" }
|
||||
}
|
||||
>
|
||||
{/* Top Controls */}
|
||||
{activeFiles.length > 0 &&
|
||||
!customWorkbenchViews.find((v) => v.workbenchId === currentView)
|
||||
?.hideTopControls && (
|
||||
<TopControls
|
||||
currentView={currentView}
|
||||
setCurrentView={setCurrentView}
|
||||
customViews={customWorkbenchViews}
|
||||
activeFiles={activeFiles.map((f) => {
|
||||
const stub = selectors.getStirlingFileStub(f.fileId);
|
||||
return {
|
||||
fileId: f.fileId,
|
||||
name: f.name,
|
||||
versionNumber: stub?.versionNumber,
|
||||
};
|
||||
})}
|
||||
currentFileIndex={activeFileIndex}
|
||||
onFileSelect={handleFileSelect}
|
||||
onFileRemove={handleFileRemove}
|
||||
/>
|
||||
)}
|
||||
{/* Workbench Bar - animates in/out based on file presence */}
|
||||
{!customWorkbenchViews.find((v) => v.workbenchId === currentView)
|
||||
?.hideTopControls && (
|
||||
<div
|
||||
className={styles.workbenchBarWrapper}
|
||||
data-hidden={String(!hasFiles)}
|
||||
data-no-transition={String(!barTransitionEnabled)}
|
||||
>
|
||||
<div className={styles.workbenchBarInner}>
|
||||
<WorkbenchBar
|
||||
currentView={currentView}
|
||||
setCurrentView={setCurrentView}
|
||||
hasFiles={hasFiles}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dismiss All Errors Button */}
|
||||
<DismissAllErrorsButton />
|
||||
|
||||
@@ -232,12 +232,15 @@ export default function Onboarding() {
|
||||
loadSampleFile: tourOrch.loadSampleFile,
|
||||
switchToActiveFiles: tourOrch.switchToActiveFiles,
|
||||
pinFile: tourOrch.pinFile,
|
||||
revealFileCardHoverMenu: tourOrch.revealFileCardHoverMenu,
|
||||
modifyCropSettings: tourOrch.modifyCropSettings,
|
||||
executeTool: tourOrch.executeTool,
|
||||
openFilesModal,
|
||||
openSettingsHelpSection: () =>
|
||||
adminTourOrch.navigateToSection("help"),
|
||||
},
|
||||
}),
|
||||
[t, tourOrch, closeFilesModal, openFilesModal],
|
||||
[t, tourOrch, adminTourOrch, closeFilesModal, openFilesModal],
|
||||
);
|
||||
|
||||
const whatsNewStepsConfig = useMemo(
|
||||
@@ -253,7 +256,6 @@ export default function Onboarding() {
|
||||
switchToViewer: tourOrch.switchToViewer,
|
||||
switchToPageEditor: tourOrch.switchToPageEditor,
|
||||
switchToActiveFiles: tourOrch.switchToActiveFiles,
|
||||
selectFirstFile: tourOrch.selectFirstFile,
|
||||
},
|
||||
}),
|
||||
[t, tourOrch, closeFilesModal, openFilesModal],
|
||||
|
||||
@@ -4,6 +4,10 @@ import {
|
||||
addGlowToElements,
|
||||
removeAllGlows,
|
||||
} from "@app/components/onboarding/tourGlow";
|
||||
import {
|
||||
waitForElement,
|
||||
waitForHighlightable,
|
||||
} from "@app/components/onboarding/tourUtils";
|
||||
|
||||
export enum AdminTourStep {
|
||||
WELCOME,
|
||||
@@ -57,7 +61,7 @@ export function createAdminStepsConfig({
|
||||
selector: '[data-tour="config-button"]',
|
||||
content: t(
|
||||
"adminOnboarding.configButton",
|
||||
"Click the <strong>Config</strong> button to access all system settings and administrative controls.",
|
||||
"Open <strong>Settings</strong> to access all system configuration and administrative controls.",
|
||||
),
|
||||
position: "right",
|
||||
padding: 10,
|
||||
@@ -204,15 +208,18 @@ export function createAdminStepsConfig({
|
||||
},
|
||||
},
|
||||
[AdminTourStep.WRAP_UP]: {
|
||||
selector: '[data-tour="help-button"]',
|
||||
selector: '[data-tour="admin-help-nav"]',
|
||||
content: t(
|
||||
"adminOnboarding.wrapUp",
|
||||
"That's the admin tour! You've seen the enterprise features that make Stirling PDF a powerful, customisable solution for organisations. Access this tour anytime from the <strong>Help</strong> menu.",
|
||||
"That's the admin tour! You've seen the enterprise features that make Stirling PDF a powerful, customisable solution for organisations. You can replay it anytime — just open <strong>Settings</strong> and find it here in the <strong>Tours</strong> section under Help.",
|
||||
),
|
||||
position: "right",
|
||||
padding: 10,
|
||||
action: () => {
|
||||
action: async () => {
|
||||
removeAllGlows();
|
||||
navigateToSection("help");
|
||||
await waitForElement('[data-tour="admin-help-nav"]', 5000);
|
||||
await waitForHighlightable('[data-tour="admin-help-nav"]', 5000);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Waits for a CSS selector to appear in the DOM using MutationObserver.
|
||||
* Resolves immediately if already present; resolves after timeoutMs if it
|
||||
* never appears (no throw — tour steps are best-effort).
|
||||
*/
|
||||
export function waitForElement(
|
||||
selector: string,
|
||||
timeoutMs = 7000,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (typeof document === "undefined") {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
if (document.querySelector(selector)) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
if (document.querySelector(selector)) {
|
||||
clearTimeout(timer);
|
||||
observer.disconnect();
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
observer.disconnect();
|
||||
resolve();
|
||||
}, timeoutMs);
|
||||
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
});
|
||||
}
|
||||
|
||||
const nudgeReactour = () => {
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
requestAnimationFrame(() => window.dispatchEvent(new Event("resize")));
|
||||
};
|
||||
|
||||
/**
|
||||
* Waits for a CSS selector to be present AND have a non-zero bounding box,
|
||||
* then nudges Reactour to recalculate its spotlight position.
|
||||
*
|
||||
* Uses MutationObserver to detect element insertion, then ResizeObserver to
|
||||
* detect when the element receives layout dimensions.
|
||||
*/
|
||||
export function waitForHighlightable(
|
||||
selector: string,
|
||||
timeoutMs = 7000,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (typeof document === "undefined") {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
let mutationObserver: MutationObserver | null = null;
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
|
||||
const cleanup = () => {
|
||||
mutationObserver?.disconnect();
|
||||
resizeObserver?.disconnect();
|
||||
};
|
||||
|
||||
const done = () => {
|
||||
clearTimeout(timer);
|
||||
cleanup();
|
||||
nudgeReactour();
|
||||
resolve();
|
||||
};
|
||||
|
||||
const timer = setTimeout(done, timeoutMs);
|
||||
|
||||
const watchLayout = (el: HTMLElement) => {
|
||||
if (el.getClientRects().length > 0) {
|
||||
done();
|
||||
return;
|
||||
}
|
||||
resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.contentRect.width > 0 || entry.contentRect.height > 0) {
|
||||
done();
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
resizeObserver.observe(el);
|
||||
};
|
||||
|
||||
const el = document.querySelector<HTMLElement>(selector);
|
||||
if (el) {
|
||||
watchLayout(el);
|
||||
return;
|
||||
}
|
||||
|
||||
mutationObserver = new MutationObserver(() => {
|
||||
const found = document.querySelector<HTMLElement>(selector);
|
||||
if (found) {
|
||||
mutationObserver!.disconnect();
|
||||
mutationObserver = null;
|
||||
watchLayout(found);
|
||||
}
|
||||
});
|
||||
|
||||
mutationObserver.observe(document.body, { childList: true, subtree: true });
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { StepType } from "@reactour/tour";
|
||||
import type { TFunction } from "i18next";
|
||||
import {
|
||||
waitForElement,
|
||||
waitForHighlightable,
|
||||
} from "@app/components/onboarding/tourUtils";
|
||||
|
||||
export enum TourStep {
|
||||
ALL_TOOLS,
|
||||
@@ -26,9 +30,11 @@ interface UserStepActions {
|
||||
loadSampleFile: () => void;
|
||||
switchToActiveFiles: () => void;
|
||||
pinFile: () => void;
|
||||
revealFileCardHoverMenu: () => void;
|
||||
modifyCropSettings: () => void;
|
||||
executeTool: () => void;
|
||||
openFilesModal: () => void;
|
||||
openSettingsHelpSection: () => void;
|
||||
}
|
||||
|
||||
interface CreateUserStepsConfigArgs {
|
||||
@@ -48,9 +54,11 @@ export function createUserStepsConfig({
|
||||
loadSampleFile,
|
||||
switchToActiveFiles,
|
||||
pinFile,
|
||||
revealFileCardHoverMenu,
|
||||
modifyCropSettings,
|
||||
executeTool,
|
||||
openFilesModal,
|
||||
openSettingsHelpSection,
|
||||
} = actions;
|
||||
|
||||
return {
|
||||
@@ -95,7 +103,7 @@ export function createUserStepsConfig({
|
||||
),
|
||||
position: "right",
|
||||
padding: 10,
|
||||
action: () => openFilesModal(),
|
||||
actionAfter: () => openFilesModal(),
|
||||
},
|
||||
[TourStep.FILE_SOURCES]: {
|
||||
selector: '[data-tour="file-sources"]',
|
||||
@@ -105,6 +113,10 @@ export function createUserStepsConfig({
|
||||
),
|
||||
position: "right",
|
||||
padding: 0,
|
||||
action: async () => {
|
||||
await waitForElement('[data-tour="file-sources"]', 5000);
|
||||
await waitForHighlightable('[data-tour="file-sources"]', 5000);
|
||||
},
|
||||
actionAfter: () => {
|
||||
loadSampleFile();
|
||||
closeFilesModal();
|
||||
@@ -184,16 +196,22 @@ export function createUserStepsConfig({
|
||||
),
|
||||
position: "left",
|
||||
padding: 10,
|
||||
action: () => pinFile(),
|
||||
action: () => revealFileCardHoverMenu(),
|
||||
actionAfter: () => pinFile(),
|
||||
},
|
||||
[TourStep.WRAP_UP]: {
|
||||
selector: '[data-tour="help-button"]',
|
||||
selector: '[data-tour="admin-help-nav"]',
|
||||
content: t(
|
||||
"onboarding.wrapUp",
|
||||
"You're all set! You've learnt about the main areas of the app and how to use them. Click the <strong>Help</strong> button whenever you like to see this tour again.",
|
||||
"You're all set! You can replay this tour anytime — just open <strong>Settings</strong> and find it here in the <strong>Tours</strong> section under Help.",
|
||||
),
|
||||
position: "right",
|
||||
padding: 10,
|
||||
action: async () => {
|
||||
openSettingsHelpSection();
|
||||
await waitForElement('[data-tour="admin-help-nav"]', 5000);
|
||||
await waitForHighlightable('[data-tour="admin-help-nav"]', 5000);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,58 +1,14 @@
|
||||
import type { StepType } from "@reactour/tour";
|
||||
import type { TFunction } from "i18next";
|
||||
|
||||
async function waitForElement(
|
||||
selector: string,
|
||||
timeoutMs = 7000,
|
||||
intervalMs = 100,
|
||||
): Promise<void> {
|
||||
if (typeof document === "undefined") return;
|
||||
const start = Date.now();
|
||||
// Immediate hit
|
||||
if (document.querySelector(selector)) return;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const check = () => {
|
||||
if (document.querySelector(selector) || Date.now() - start >= timeoutMs) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
setTimeout(check, intervalMs);
|
||||
};
|
||||
check();
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForHighlightable(
|
||||
selector: string,
|
||||
timeoutMs = 7000,
|
||||
intervalMs = 500,
|
||||
): Promise<void> {
|
||||
if (typeof document === "undefined") return;
|
||||
const start = Date.now();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const check = () => {
|
||||
const el = document.querySelector<HTMLElement>(selector);
|
||||
const isVisible = !!el && el.getClientRects().length > 0;
|
||||
if (isVisible || Date.now() - start >= timeoutMs) {
|
||||
// Nudge Reactour to recalc positions in case layout shifted
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
requestAnimationFrame(() => window.dispatchEvent(new Event("resize")));
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
setTimeout(check, intervalMs);
|
||||
};
|
||||
check();
|
||||
});
|
||||
}
|
||||
import {
|
||||
waitForElement,
|
||||
waitForHighlightable,
|
||||
} from "@app/components/onboarding/tourUtils";
|
||||
|
||||
export enum WhatsNewTourStep {
|
||||
QUICK_ACCESS,
|
||||
LEFT_PANEL,
|
||||
FILE_UPLOAD,
|
||||
RIGHT_RAIL,
|
||||
TOP_BAR,
|
||||
PAGE_EDITOR_VIEW,
|
||||
ACTIVE_FILES_VIEW,
|
||||
@@ -68,7 +24,6 @@ interface WhatsNewStepActions {
|
||||
switchToViewer: () => void;
|
||||
switchToPageEditor: () => void;
|
||||
switchToActiveFiles: () => void;
|
||||
selectFirstFile: () => void;
|
||||
}
|
||||
|
||||
interface CreateWhatsNewStepsConfigArgs {
|
||||
@@ -89,7 +44,6 @@ export function createWhatsNewStepsConfig({
|
||||
switchToViewer,
|
||||
switchToPageEditor,
|
||||
switchToActiveFiles,
|
||||
selectFirstFile,
|
||||
} = actions;
|
||||
|
||||
return {
|
||||
@@ -124,34 +78,15 @@ export function createWhatsNewStepsConfig({
|
||||
),
|
||||
position: "right",
|
||||
padding: 10,
|
||||
action: async () => {
|
||||
openFilesModal();
|
||||
await waitForElement('[data-tour="file-sources"]', 5000, 100);
|
||||
},
|
||||
actionAfter: async () => {
|
||||
openFilesModal();
|
||||
await waitForElement('[data-tour="file-sources"]', 5000);
|
||||
await Promise.resolve(loadSampleFile());
|
||||
closeFilesModal();
|
||||
switchToViewer();
|
||||
// wait for file render and top controls to mount
|
||||
await waitForElement('[data-tour="view-switcher"]', 7000, 100);
|
||||
await waitForHighlightable('[data-tour="view-switcher"]', 7000, 500);
|
||||
},
|
||||
},
|
||||
[WhatsNewTourStep.RIGHT_RAIL]: {
|
||||
selector: '[data-tour="right-rail-controls"]',
|
||||
highlightedSelectors: [
|
||||
'[data-tour="right-rail-controls"]',
|
||||
'[data-tour="right-rail-settings"]',
|
||||
],
|
||||
content: t(
|
||||
"onboarding.whatsNew.rightRail",
|
||||
"The <strong>Right Rail</strong> holds quick actions to select files, change theme or language, and download results.",
|
||||
),
|
||||
position: "left",
|
||||
padding: 10,
|
||||
action: async () => {
|
||||
await waitForElement('[data-tour="right-rail-controls"]', 7000, 100);
|
||||
selectFirstFile();
|
||||
await waitForElement('[data-tour="view-switcher"]', 7000);
|
||||
await waitForHighlightable('[data-tour="view-switcher"]', 7000);
|
||||
},
|
||||
},
|
||||
[WhatsNewTourStep.TOP_BAR]: {
|
||||
@@ -165,8 +100,8 @@ export function createWhatsNewStepsConfig({
|
||||
// Ensure the switcher has mounted before this step renders
|
||||
action: async () => {
|
||||
switchToViewer();
|
||||
await waitForElement('[data-tour="view-switcher"]', 7000, 100);
|
||||
await waitForHighlightable('[data-tour="view-switcher"]', 7000, 500);
|
||||
await waitForElement('[data-tour="view-switcher"]', 7000);
|
||||
await waitForHighlightable('[data-tour="view-switcher"]', 7000);
|
||||
},
|
||||
},
|
||||
[WhatsNewTourStep.PAGE_EDITOR_VIEW]: {
|
||||
@@ -179,8 +114,8 @@ export function createWhatsNewStepsConfig({
|
||||
padding: 8,
|
||||
action: async () => {
|
||||
switchToPageEditor();
|
||||
await waitForElement('[data-tour="view-switcher"]', 7000, 100);
|
||||
await waitForHighlightable('[data-tour="view-switcher"]', 7000, 500);
|
||||
await waitForElement('[data-tour="view-switcher"]', 7000);
|
||||
await waitForHighlightable('[data-tour="view-switcher"]', 7000);
|
||||
},
|
||||
},
|
||||
[WhatsNewTourStep.ACTIVE_FILES_VIEW]: {
|
||||
@@ -193,8 +128,8 @@ export function createWhatsNewStepsConfig({
|
||||
padding: 8,
|
||||
action: async () => {
|
||||
switchToActiveFiles();
|
||||
await waitForElement('[data-tour="view-switcher"]', 7000, 100);
|
||||
await waitForHighlightable('[data-tour="view-switcher"]', 7000, 500);
|
||||
await waitForElement('[data-tour="view-switcher"]', 7000);
|
||||
await waitForHighlightable('[data-tour="view-switcher"]', 7000);
|
||||
},
|
||||
},
|
||||
[WhatsNewTourStep.WRAP_UP]: {
|
||||
|
||||
@@ -17,7 +17,7 @@ import { GRID_CONSTANTS } from "@app/components/pageEditor/constants";
|
||||
import { useInitialPageDocument } from "@app/components/pageEditor/hooks/useInitialPageDocument";
|
||||
import { usePageDocument } from "@app/components/pageEditor/hooks/usePageDocument";
|
||||
import { usePageEditorState } from "@app/components/pageEditor/hooks/usePageEditorState";
|
||||
import { usePageEditorRightRailButtons } from "@app/components/pageEditor/pageEditorRightRailButtons";
|
||||
import { usePageEditorWorkbenchBarButtons } from "@app/components/pageEditor/pageEditorWorkbenchBarButtons";
|
||||
import { useFileColorMap } from "@app/components/pageEditor/hooks/useFileColorMap";
|
||||
import { useWheelZoom } from "@app/hooks/useWheelZoom";
|
||||
import { useEditedDocumentState } from "@app/components/pageEditor/hooks/useEditedDocumentState";
|
||||
@@ -168,6 +168,16 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
|
||||
}, [pageEditorFiles]);
|
||||
const activeFilesSignature = selectors.getFilesSignature();
|
||||
|
||||
// Check if there are any PDF files in FileContext (bypasses fileOrder, which is
|
||||
// populated asynchronously via effect). Used to avoid a one-render flash of
|
||||
// "No PDF files loaded" when PageEditor first mounts after files are added.
|
||||
const hasPdfFiles = useMemo(() => {
|
||||
return state.files.ids.some((id) => {
|
||||
const stub = selectors.getStirlingFileStub(id);
|
||||
return stub?.name?.toLowerCase().endsWith(".pdf") ?? false;
|
||||
});
|
||||
}, [state.files.ids, selectors]);
|
||||
|
||||
// UI state
|
||||
const globalProcessing = state.ui.isProcessing;
|
||||
|
||||
@@ -448,11 +458,10 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
|
||||
unregisterNavigationWarningHandlers,
|
||||
]);
|
||||
|
||||
// Derived values for right rail and usePageEditorRightRailButtons (must be after displayDocument)
|
||||
// Derived values for usePageEditorWorkbenchBarButtons (must be after displayDocument)
|
||||
const selectedPageCount = selectedPageIds.length;
|
||||
const activeFileIds = selectedFileIds;
|
||||
|
||||
usePageEditorRightRailButtons({
|
||||
usePageEditorWorkbenchBarButtons({
|
||||
totalPages,
|
||||
selectedPageCount,
|
||||
csvInput,
|
||||
@@ -466,8 +475,6 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
|
||||
onExportSelected,
|
||||
onSaveChanges: applyChanges,
|
||||
exportLoading,
|
||||
activeFileCount: activeFileIds.length,
|
||||
closePdf,
|
||||
});
|
||||
|
||||
// Export preview function - defined after export functions to avoid circular dependency
|
||||
@@ -677,23 +684,21 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
|
||||
>
|
||||
<LoadingOverlay visible={globalProcessing && !initialDocument} />
|
||||
|
||||
{!initialDocument &&
|
||||
!globalProcessing &&
|
||||
selectedFileIds.length === 0 && (
|
||||
<Center h="100%">
|
||||
<Stack align="center" gap="md">
|
||||
<Text size="lg" c="dimmed">
|
||||
📄
|
||||
</Text>
|
||||
<Text c="dimmed">No PDF files loaded</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Add files to start editing pages
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
)}
|
||||
{!initialDocument && !globalProcessing && !hasPdfFiles && (
|
||||
<Center h="100%">
|
||||
<Stack align="center" gap="md">
|
||||
<Text size="lg" c="dimmed">
|
||||
📄
|
||||
</Text>
|
||||
<Text c="dimmed">No PDF files loaded</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Add files to start editing pages
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
)}
|
||||
|
||||
{!initialDocument && globalProcessing && (
|
||||
{!initialDocument && (globalProcessing || hasPdfFiles) && (
|
||||
<Box p={0}>
|
||||
<SkeletonLoader type="controls" />
|
||||
<SkeletonLoader type="pageGrid" count={8} />
|
||||
|
||||
@@ -25,7 +25,6 @@ interface PageEditorControlsProps {
|
||||
onPageBreak: () => void;
|
||||
onPageBreakAll: () => void;
|
||||
|
||||
// Export functions (moved to right rail)
|
||||
onExportAll: () => void;
|
||||
exportLoading: boolean;
|
||||
|
||||
@@ -138,9 +137,7 @@ const PageEditorControls = ({
|
||||
disabled={!canUndo}
|
||||
variant="subtle"
|
||||
style={{
|
||||
color: canUndo
|
||||
? "var(--right-rail-icon)"
|
||||
: "var(--right-rail-icon-disabled)",
|
||||
color: canUndo ? "var(--text-secondary)" : "var(--text-muted)",
|
||||
}}
|
||||
radius="md"
|
||||
size="lg"
|
||||
@@ -154,9 +151,7 @@ const PageEditorControls = ({
|
||||
disabled={!canRedo}
|
||||
variant="subtle"
|
||||
style={{
|
||||
color: canRedo
|
||||
? "var(--right-rail-icon)"
|
||||
: "var(--right-rail-icon-disabled)",
|
||||
color: canRedo ? "var(--text-secondary)" : "var(--text-muted)",
|
||||
}}
|
||||
radius="md"
|
||||
size="lg"
|
||||
@@ -183,8 +178,8 @@ const PageEditorControls = ({
|
||||
style={{
|
||||
color:
|
||||
selectedPageIds.length > 0
|
||||
? "var(--right-rail-icon)"
|
||||
: "var(--right-rail-icon-disabled)",
|
||||
? "var(--text-secondary)"
|
||||
: "var(--text-muted)",
|
||||
}}
|
||||
radius="md"
|
||||
size="lg"
|
||||
@@ -200,8 +195,8 @@ const PageEditorControls = ({
|
||||
style={{
|
||||
color:
|
||||
selectedPageIds.length > 0
|
||||
? "var(--right-rail-icon)"
|
||||
: "var(--right-rail-icon-disabled)",
|
||||
? "var(--text-secondary)"
|
||||
: "var(--text-muted)",
|
||||
}}
|
||||
radius="md"
|
||||
size="lg"
|
||||
@@ -217,8 +212,8 @@ const PageEditorControls = ({
|
||||
style={{
|
||||
color:
|
||||
selectedPageIds.length > 0
|
||||
? "var(--right-rail-icon)"
|
||||
: "var(--right-rail-icon-disabled)",
|
||||
? "var(--text-secondary)"
|
||||
: "var(--text-muted)",
|
||||
}}
|
||||
radius="md"
|
||||
size="lg"
|
||||
@@ -234,8 +229,8 @@ const PageEditorControls = ({
|
||||
style={{
|
||||
color:
|
||||
selectedPageIds.length > 0
|
||||
? "var(--right-rail-icon)"
|
||||
: "var(--right-rail-icon-disabled)",
|
||||
? "var(--text-secondary)"
|
||||
: "var(--text-muted)",
|
||||
}}
|
||||
radius="md"
|
||||
size="lg"
|
||||
@@ -251,8 +246,8 @@ const PageEditorControls = ({
|
||||
style={{
|
||||
color:
|
||||
selectedPageIds.length > 0
|
||||
? "var(--right-rail-icon)"
|
||||
: "var(--right-rail-icon-disabled)",
|
||||
? "var(--text-secondary)"
|
||||
: "var(--text-muted)",
|
||||
}}
|
||||
radius="md"
|
||||
size="lg"
|
||||
|
||||
@@ -27,19 +27,18 @@ export default function PageSelectByNumberButton({
|
||||
return (
|
||||
<Tooltip
|
||||
content={label}
|
||||
position="left"
|
||||
position="bottom"
|
||||
offset={12}
|
||||
arrow
|
||||
portalTarget={document.body}
|
||||
>
|
||||
<div className={`right-rail-fade enter`}>
|
||||
<div>
|
||||
<Popover position="left" withArrow shadow="md" offset={8}>
|
||||
<Popover.Target>
|
||||
<div style={{ display: "inline-flex" }}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
disabled={disabled || totalPages === 0}
|
||||
aria-label={label}
|
||||
>
|
||||
|
||||
+14
-34
@@ -1,13 +1,13 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
useRightRailButtons,
|
||||
RightRailButtonWithAction,
|
||||
} from "@app/hooks/useRightRailButtons";
|
||||
useWorkbenchBarButtons,
|
||||
WorkbenchBarButtonWithAction,
|
||||
} from "@app/hooks/useWorkbenchBarButtons";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import PageSelectByNumberButton from "@app/components/pageEditor/PageSelectByNumberButton";
|
||||
|
||||
interface PageEditorRightRailButtonsParams {
|
||||
interface PageEditorWorkbenchBarButtonsParams {
|
||||
totalPages: number;
|
||||
selectedPageCount: number;
|
||||
csvInput: string;
|
||||
@@ -21,12 +21,10 @@ interface PageEditorRightRailButtonsParams {
|
||||
onExportSelected: () => void;
|
||||
onSaveChanges: () => void;
|
||||
exportLoading: boolean;
|
||||
activeFileCount: number;
|
||||
closePdf: () => void;
|
||||
}
|
||||
|
||||
export function usePageEditorRightRailButtons(
|
||||
params: PageEditorRightRailButtonsParams,
|
||||
export function usePageEditorWorkbenchBarButtons(
|
||||
params: PageEditorWorkbenchBarButtonsParams,
|
||||
) {
|
||||
const {
|
||||
totalPages,
|
||||
@@ -42,31 +40,27 @@ export function usePageEditorRightRailButtons(
|
||||
onExportSelected,
|
||||
onSaveChanges,
|
||||
exportLoading,
|
||||
activeFileCount,
|
||||
closePdf,
|
||||
} = params;
|
||||
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
// Lift i18n labels out of memo for clarity
|
||||
const selectAllLabel = t("rightRail.selectAll", "Select All");
|
||||
const deselectAllLabel = t("rightRail.deselectAll", "Deselect All");
|
||||
const selectAllLabel = t("workbenchBar.selectAll", "Select All");
|
||||
const deselectAllLabel = t("workbenchBar.deselectAll", "Deselect All");
|
||||
const selectByNumberLabel = t(
|
||||
"rightRail.selectByNumber",
|
||||
"workbenchBar.selectByNumber",
|
||||
"Select by Page Numbers",
|
||||
);
|
||||
const deleteSelectedLabel = t(
|
||||
"rightRail.deleteSelected",
|
||||
"workbenchBar.deleteSelected",
|
||||
"Delete Selected Pages",
|
||||
);
|
||||
const exportSelectedLabel = t(
|
||||
"rightRail.exportSelected",
|
||||
"workbenchBar.exportSelected",
|
||||
"Export Selected Pages",
|
||||
);
|
||||
const saveChangesLabel = t("rightRail.saveChanges", "Save Changes");
|
||||
const closePdfLabel = t("rightRail.closePdf", "Close PDF");
|
||||
|
||||
const buttons = useMemo<RightRailButtonWithAction[]>(() => {
|
||||
const saveChangesLabel = t("workbenchBar.saveChanges", "Save Changes");
|
||||
const buttons = useMemo<WorkbenchBarButtonWithAction[]>(() => {
|
||||
return [
|
||||
{
|
||||
id: "page-select-all",
|
||||
@@ -156,17 +150,6 @@ export function usePageEditorRightRailButtons(
|
||||
visible: totalPages > 0,
|
||||
onClick: onSaveChanges,
|
||||
},
|
||||
{
|
||||
id: "page-close-pdf",
|
||||
icon: <LocalIcon icon="close-rounded" width="1.5rem" height="1.5rem" />,
|
||||
tooltip: closePdfLabel,
|
||||
ariaLabel: closePdfLabel,
|
||||
section: "top" as const,
|
||||
order: 60,
|
||||
disabled: activeFileCount === 0,
|
||||
visible: activeFileCount > 0,
|
||||
onClick: closePdf,
|
||||
},
|
||||
];
|
||||
}, [
|
||||
t,
|
||||
@@ -177,7 +160,6 @@ export function usePageEditorRightRailButtons(
|
||||
deleteSelectedLabel,
|
||||
exportSelectedLabel,
|
||||
saveChangesLabel,
|
||||
closePdfLabel,
|
||||
totalPages,
|
||||
selectedPageCount,
|
||||
csvInput,
|
||||
@@ -191,9 +173,7 @@ export function usePageEditorRightRailButtons(
|
||||
onExportSelected,
|
||||
onSaveChanges,
|
||||
exportLoading,
|
||||
activeFileCount,
|
||||
closePdf,
|
||||
]);
|
||||
|
||||
useRightRailButtons(buttons);
|
||||
useWorkbenchBarButtons(buttons);
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
interface RightRailFooterExtensionsProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function RightRailFooterExtensions(
|
||||
_props: RightRailFooterExtensionsProps,
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
@@ -108,11 +108,26 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
|
||||
const runningEE = config?.runningEE ?? false;
|
||||
const loginEnabled = config?.enableLogin ?? false;
|
||||
|
||||
const handleClose = useCallback(async () => {
|
||||
const canProceed = await confirmIfDirty();
|
||||
if (!canProceed) return;
|
||||
|
||||
// Navigate back to home when closing modal
|
||||
navigate("/", { replace: true });
|
||||
onClose();
|
||||
}, [confirmIfDirty, navigate, onClose]);
|
||||
|
||||
// Synchronous wrapper for contexts (e.g. tour buttons) that need () => void
|
||||
const handleCloseSync = useCallback(() => {
|
||||
void handleClose();
|
||||
}, [handleClose]);
|
||||
|
||||
// Left navigation structure and icons
|
||||
const configNavSections = useConfigNavSections(
|
||||
isAdmin,
|
||||
runningEE,
|
||||
loginEnabled,
|
||||
handleCloseSync,
|
||||
);
|
||||
|
||||
const activeLabel = useMemo(() => {
|
||||
@@ -131,15 +146,6 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
|
||||
return null;
|
||||
}, [configNavSections, active]);
|
||||
|
||||
const handleClose = useCallback(async () => {
|
||||
const canProceed = await confirmIfDirty();
|
||||
if (!canProceed) return;
|
||||
|
||||
// Navigate back to home when closing modal
|
||||
navigate("/", { replace: true });
|
||||
onClose();
|
||||
}, [confirmIfDirty, navigate, onClose]);
|
||||
|
||||
const handleNavigation = useCallback(
|
||||
async (key: NavKey) => {
|
||||
const canProceed = await confirmIfDirty();
|
||||
|
||||
@@ -207,7 +207,7 @@ const BulkShareModal: React.FC<BulkShareModalProps> = ({
|
||||
)}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("storageShare.fileCount", "{{count}} files selected", {
|
||||
{t("storageShare.fileCount", "{{count}} files", {
|
||||
count: files.length,
|
||||
})}
|
||||
</Text>
|
||||
|
||||
@@ -116,7 +116,7 @@ const BulkUploadToServerModal: React.FC<BulkUploadToServerModalProps> = ({
|
||||
)}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("storageUpload.fileCount", "{{count}} files selected", {
|
||||
{t("storageUpload.fileCount", "{{count}} files", {
|
||||
count: files.length,
|
||||
})}
|
||||
</Text>
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import React from "react";
|
||||
|
||||
interface CloudIconProps extends React.SVGProps<SVGSVGElement> {
|
||||
colored?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Google Drive icon with brand-color hover support.
|
||||
* Pass `colored={true}` to render the official tri-color logo;
|
||||
* omit / pass `colored={false}` for a uniform muted/current-color version.
|
||||
*/
|
||||
export function GoogleDriveIcon({ colored, ...rest }: CloudIconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 87.3 78" width={18} height={18} {...rest}>
|
||||
<path
|
||||
d="M6.6 66.85l3.85 6.65c.8 1.4 1.95 2.5 3.3 3.3l13.75-23.8H0c0 1.55.4 3.1 1.2 4.5z"
|
||||
fill={colored ? "#0066DA" : "currentColor"}
|
||||
opacity={colored ? 1 : 0.5}
|
||||
/>
|
||||
<path
|
||||
d="M43.65 25L29.9 1.2c-1.35.8-2.5 1.9-3.3 3.3L1.2 52.35c-.8 1.4-1.2 2.95-1.2 4.5h27.5z"
|
||||
fill={colored ? "#00AC47" : "currentColor"}
|
||||
opacity={colored ? 1 : 0.4}
|
||||
/>
|
||||
<path
|
||||
d="M73.55 76.8c1.35-.8 2.5-1.9 3.3-3.3l1.6-2.75 7.65-13.25c.8-1.4 1.2-2.95 1.2-4.5H59.85L73.55 76.8z"
|
||||
fill={colored ? "#EA4335" : "currentColor"}
|
||||
opacity={colored ? 1 : 0.5}
|
||||
/>
|
||||
<path
|
||||
d="M43.65 25L57.4 1.2c-1.35-.8-2.9-1.2-4.5-1.2H34.4c-1.6 0-3.15.45-4.5 1.2z"
|
||||
fill={colored ? "#00832D" : "currentColor"}
|
||||
opacity={colored ? 1 : 0.45}
|
||||
/>
|
||||
<path
|
||||
d="M59.85 53H27.5l-13.75 23.8c1.35.8 2.9 1.2 4.5 1.2h50.8c1.6 0 3.15-.45 4.5-1.2z"
|
||||
fill={colored ? "#2684FC" : "currentColor"}
|
||||
opacity={colored ? 1 : 0.55}
|
||||
/>
|
||||
<path
|
||||
d="M73.4 26.5L60.7 4.5c-.8-1.4-1.95-2.5-3.3-3.3L43.65 25l16.2 28h27.45c0-1.55-.4-3.1-1.2-4.5z"
|
||||
fill={colored ? "#FFBA00" : "currentColor"}
|
||||
opacity={colored ? 1 : 0.5}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* OneDrive icon with brand-color hover support.
|
||||
* FOR FUTURE USE — OneDrive integration is not yet implemented.
|
||||
*/
|
||||
export function OneDriveIcon({ colored, ...rest }: CloudIconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width={18} height={18} {...rest}>
|
||||
<path
|
||||
d="M19.35 10.04A7.49 7.49 0 0012 4C9.11 4 6.6 5.64 5.35 8.04A5.994 5.994 0 000 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96z"
|
||||
fill={colored ? "#0078D4" : "currentColor"}
|
||||
opacity={colored ? 1 : 0.5}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dropbox icon with brand-color hover support.
|
||||
* FOR FUTURE USE — Dropbox integration is not yet implemented.
|
||||
*/
|
||||
export function DropboxIcon({ colored, ...rest }: CloudIconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" width={18} height={18} {...rest}>
|
||||
<path
|
||||
d="M8.01 4.555L4.005 7.11 8.01 9.665 4.005 12.22 0 9.651l4.005-2.555L0 4.555 4.005 2 8.01 4.555zm-4.026 8.487l4.006-2.555 4.005 2.555-4.005 2.555-4.006-2.555zm4.026-3.39l4.005-2.556L8.01 4.555 11.995 2 16 4.555l-4.005 2.555L16 9.665l-4.005 2.555L8.01 9.652z"
|
||||
fill={colored ? "#0061FF" : "currentColor"}
|
||||
opacity={colored ? 1 : 0.5}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -7,21 +7,18 @@ import {
|
||||
Badge,
|
||||
Button,
|
||||
Box,
|
||||
Image,
|
||||
ThemeIcon,
|
||||
ActionIcon,
|
||||
Tooltip,
|
||||
Loader,
|
||||
} from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
|
||||
import StorageIcon from "@mui/icons-material/Storage";
|
||||
import VisibilityIcon from "@mui/icons-material/Visibility";
|
||||
import EditIcon from "@mui/icons-material/Edit";
|
||||
|
||||
import { StirlingFileStub } from "@app/types/fileContext";
|
||||
import { getFileSize, getFileDate } from "@app/utils/fileUtils";
|
||||
import { useIndexedDBThumbnail } from "@app/hooks/useIndexedDBThumbnail";
|
||||
import { useFileThumbnail } from "@app/hooks/useFileThumbnail";
|
||||
import DocumentThumbnail from "@app/components/shared/filePreview/DocumentThumbnail";
|
||||
|
||||
interface FileCardProps {
|
||||
file: File;
|
||||
@@ -47,15 +44,15 @@ const FileCard = ({
|
||||
isSupported = true,
|
||||
}: FileCardProps) => {
|
||||
const { t } = useTranslation();
|
||||
// Use record thumbnail if available, otherwise fall back to IndexedDB lookup
|
||||
const { thumbnail: indexedDBThumb, isGenerating } =
|
||||
useIndexedDBThumbnail(fileStub);
|
||||
const thumb = fileStub?.thumbnailUrl || indexedDBThumb;
|
||||
const {
|
||||
isEncrypted,
|
||||
thumbnail: thumb,
|
||||
isGenerating,
|
||||
} = useFileThumbnail(fileStub);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
// Show loading state during hydration: PDF file without thumbnail yet
|
||||
const isPdf = file.type === "application/pdf";
|
||||
const isHydrating = isPdf && !thumb && !isGenerating;
|
||||
const isHydrating = isPdf && !isEncrypted && !thumb && !isGenerating;
|
||||
|
||||
return (
|
||||
<Card
|
||||
@@ -73,8 +70,7 @@ const FileCard = ({
|
||||
? "2px solid var(--mantine-color-blue-6)"
|
||||
: undefined,
|
||||
backgroundColor: isSelected ? "var(--mantine-color-blue-0)" : undefined,
|
||||
opacity: isSupported ? 1 : 0.5,
|
||||
filter: isSupported ? "none" : "grayscale(50%)",
|
||||
opacity: 1,
|
||||
}}
|
||||
onDoubleClick={onDoubleClick}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
@@ -146,51 +142,13 @@ const FileCard = ({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{thumb ? (
|
||||
<Image
|
||||
src={thumb}
|
||||
alt="PDF thumbnail"
|
||||
height={110}
|
||||
width={80}
|
||||
fit="contain"
|
||||
radius="sm"
|
||||
/>
|
||||
) : isGenerating || isHydrating ? (
|
||||
<Stack align="center" justify="center" gap="xs">
|
||||
<Loader size="sm" />
|
||||
<Text size="xs" c="dimmed">
|
||||
Loading...
|
||||
</Text>
|
||||
</Stack>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<ThemeIcon
|
||||
variant="light"
|
||||
color={file.size > 100 * 1024 * 1024 ? "orange" : "red"}
|
||||
size={60}
|
||||
radius="sm"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<PictureAsPdfIcon style={{ fontSize: 40 }} />
|
||||
</ThemeIcon>
|
||||
{file.size > 100 * 1024 * 1024 && (
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
Large File
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<DocumentThumbnail
|
||||
file={file}
|
||||
thumbnail={thumb || undefined}
|
||||
isEncrypted={isEncrypted}
|
||||
isLoading={isGenerating || isHydrating}
|
||||
iconSize="3rem"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Text fw={500} size="sm" lineClamp={1} ta="center">
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
export type FileDocVariant =
|
||||
| "pdf"
|
||||
| "spreadsheet"
|
||||
| "doc"
|
||||
| "image"
|
||||
| "archive"
|
||||
| "code"
|
||||
| "generic";
|
||||
|
||||
export const VARIANT_COLORS: Record<FileDocVariant, string> = {
|
||||
pdf: "#DC2626",
|
||||
spreadsheet: "#16a34a",
|
||||
doc: "#2563eb",
|
||||
image: "#7c3aed",
|
||||
archive: "#ea580c",
|
||||
code: "#0891b2",
|
||||
generic: "#71717a",
|
||||
};
|
||||
|
||||
export function FileDocIcon({
|
||||
color,
|
||||
variant,
|
||||
className,
|
||||
style,
|
||||
}: {
|
||||
color?: string;
|
||||
variant: FileDocVariant;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}) {
|
||||
color ??= VARIANT_COLORS[variant];
|
||||
const foldX = 10.5;
|
||||
const foldY = 5.5;
|
||||
|
||||
let interior: React.ReactNode;
|
||||
if (variant === "spreadsheet") {
|
||||
// Outer border of the grid
|
||||
const gx1 = 3,
|
||||
gy1 = 8,
|
||||
gx2 = 13,
|
||||
gy2 = 17;
|
||||
// Column divider x, row divider y positions
|
||||
const colX = 8;
|
||||
const row1Y = 11,
|
||||
row2Y = 14;
|
||||
interior = (
|
||||
<g opacity="0.7" stroke={color} strokeWidth="1" fill="none">
|
||||
<rect x={gx1} y={gy1} width={gx2 - gx1} height={gy2 - gy1} rx="0.5" />
|
||||
<line x1={colX} y1={gy1} x2={colX} y2={gy2} />
|
||||
<line x1={gx1} y1={row1Y} x2={gx2} y2={row1Y} />
|
||||
<line x1={gx1} y1={row2Y} x2={gx2} y2={row2Y} />
|
||||
</g>
|
||||
);
|
||||
} else if (variant === "image") {
|
||||
interior = (
|
||||
<>
|
||||
<polyline
|
||||
points="2.5,16 5.5,11.5 8,14 10,12 13.5,16"
|
||||
stroke={color}
|
||||
strokeWidth="1.1"
|
||||
opacity="0.6"
|
||||
fill="none"
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<circle
|
||||
cx="11"
|
||||
cy="9.5"
|
||||
r="1.3"
|
||||
stroke={color}
|
||||
strokeWidth="1"
|
||||
opacity="0.6"
|
||||
fill="none"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
} else if (variant === "code") {
|
||||
interior = (
|
||||
<>
|
||||
<polyline
|
||||
points="6,9.5 3.5,12.5 6,15.5"
|
||||
stroke={color}
|
||||
strokeWidth="1.3"
|
||||
opacity="0.7"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<polyline
|
||||
points="10,9.5 12.5,12.5 10,15.5"
|
||||
stroke={color}
|
||||
strokeWidth="1.3"
|
||||
opacity="0.7"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
} else if (variant === "archive") {
|
||||
interior = (
|
||||
<>
|
||||
<line
|
||||
x1="5"
|
||||
y1="9.5"
|
||||
x2="11"
|
||||
y2="9.5"
|
||||
stroke={color}
|
||||
strokeWidth="1.1"
|
||||
opacity="0.6"
|
||||
strokeDasharray="2,1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<line
|
||||
x1="5"
|
||||
y1="12"
|
||||
x2="11"
|
||||
y2="12"
|
||||
stroke={color}
|
||||
strokeWidth="1.1"
|
||||
opacity="0.6"
|
||||
strokeDasharray="2,1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<line
|
||||
x1="5"
|
||||
y1="14.5"
|
||||
x2="11"
|
||||
y2="14.5"
|
||||
stroke={color}
|
||||
strokeWidth="1.1"
|
||||
opacity="0.6"
|
||||
strokeDasharray="2,1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
// pdf, doc, generic — text lines
|
||||
interior = (
|
||||
<>
|
||||
<line
|
||||
x1="3.5"
|
||||
y1="9.5"
|
||||
x2="12.5"
|
||||
y2="9.5"
|
||||
stroke={color}
|
||||
strokeWidth="1.1"
|
||||
opacity="0.6"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<line
|
||||
x1="3.5"
|
||||
y1="12"
|
||||
x2="12.5"
|
||||
y2="12"
|
||||
stroke={color}
|
||||
strokeWidth="1.1"
|
||||
opacity="0.6"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<line
|
||||
x1="3.5"
|
||||
y1="14.5"
|
||||
x2="8.5"
|
||||
y2="14.5"
|
||||
stroke={color}
|
||||
strokeWidth="1.1"
|
||||
opacity="0.6"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
style={{ width: 16, height: 20, ...style }}
|
||||
viewBox="0 0 16 20"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{/* Document outline with top-right page fold */}
|
||||
<path
|
||||
d={`M 1.5,1 H ${foldX} L 15,${foldY} V 18.5 Q 15,19 14.5,19 H 1.5 Q 1,19 1,18.5 V 1.5 Q 1,1 1.5,1 Z`}
|
||||
stroke={color}
|
||||
strokeWidth="1.3"
|
||||
fill="none"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
{/* Fold corner triangle */}
|
||||
<path
|
||||
d={`M ${foldX},1 L ${foldX},${foldY} L 15,${foldY} Z`}
|
||||
stroke={color}
|
||||
strokeWidth="1.3"
|
||||
fill={color}
|
||||
fillOpacity="0.2"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
{interior}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -51,7 +51,7 @@ export const FileDropdownMenu: React.FC<FileDropdownMenuProps> = ({
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown
|
||||
style={{
|
||||
backgroundColor: "var(--right-rail-bg)",
|
||||
backgroundColor: "var(--bg-file-manager)",
|
||||
border: "1px solid var(--border-subtle)",
|
||||
borderRadius: "8px",
|
||||
boxShadow: "0 2px 8px rgba(0, 0, 0, 0.15)",
|
||||
|
||||
@@ -8,13 +8,11 @@ import {
|
||||
Checkbox,
|
||||
ScrollArea,
|
||||
Box,
|
||||
Image,
|
||||
Badge,
|
||||
ThemeIcon,
|
||||
SimpleGrid,
|
||||
} from "@mantine/core";
|
||||
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import DocumentThumbnail from "@app/components/shared/filePreview/DocumentThumbnail";
|
||||
import { FileId } from "@app/types/file";
|
||||
import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology";
|
||||
|
||||
@@ -200,26 +198,23 @@ const FilePickerModal = ({
|
||||
height: 80,
|
||||
border: "1px solid var(--mantine-color-gray-3)",
|
||||
borderRadius: 4,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: "var(--mantine-color-gray-0)",
|
||||
backgroundColor: "white",
|
||||
flexShrink: 0,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{file.thumbnail ? (
|
||||
<Image
|
||||
src={file.thumbnail}
|
||||
alt="PDF thumbnail"
|
||||
height={70}
|
||||
width={50}
|
||||
fit="contain"
|
||||
/>
|
||||
) : (
|
||||
<ThemeIcon variant="light" color="red" size={40}>
|
||||
<PictureAsPdfIcon style={{ fontSize: 24 }} />
|
||||
</ThemeIcon>
|
||||
)}
|
||||
<DocumentThumbnail
|
||||
file={file}
|
||||
thumbnail={
|
||||
file.processedFile?.isEncrypted
|
||||
? undefined
|
||||
: file.thumbnail
|
||||
}
|
||||
isEncrypted={Boolean(
|
||||
file.processedFile?.isEncrypted,
|
||||
)}
|
||||
iconSize="2rem"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* File info */}
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
/* ── Trigger ─────────────────────────────────────── */
|
||||
.trigger {
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--bg-surface);
|
||||
cursor: pointer;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
user-select: none;
|
||||
transition:
|
||||
border-color 0.12s ease,
|
||||
box-shadow 0.12s ease;
|
||||
}
|
||||
|
||||
.trigger:hover:not(.triggerDisabled) {
|
||||
border-color: var(--mantine-color-blue-5);
|
||||
}
|
||||
|
||||
.triggerDisabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.triggerOpen {
|
||||
border-color: var(--mantine-color-blue-5);
|
||||
box-shadow: 0 0 0 2px
|
||||
color-mix(in srgb, var(--mantine-color-blue-5) 20%, transparent);
|
||||
}
|
||||
|
||||
/* ── Popover dropdown ────────────────────────────── */
|
||||
.dropdown {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-md) !important;
|
||||
min-width: 340px;
|
||||
}
|
||||
|
||||
/* ── Header area (tabs + sort) ───────────────────── */
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.3rem 0.45rem;
|
||||
gap: 0.4rem;
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.tabGroup {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* One slim row: Saved files | Workbench | Upload (text only, no icon) */
|
||||
.slimTabBar {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 24px;
|
||||
max-height: 26px;
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-sm);
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--mantine-color-default-hover) 85%,
|
||||
transparent
|
||||
);
|
||||
padding: 1px;
|
||||
gap: 1px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.slimTab,
|
||||
.slimTabActive {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.15;
|
||||
letter-spacing: 0.01em;
|
||||
padding: 1px 0.35rem;
|
||||
border-radius: calc(var(--radius-sm) - 2px);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
transition:
|
||||
background 0.1s ease,
|
||||
color 0.1s ease;
|
||||
}
|
||||
|
||||
.slimTab {
|
||||
background: transparent;
|
||||
color: var(--mantine-color-dimmed);
|
||||
}
|
||||
|
||||
.slimTab:hover {
|
||||
color: var(--mantine-color-text);
|
||||
background: color-mix(in srgb, var(--mantine-color-body) 60%, transparent);
|
||||
}
|
||||
|
||||
.slimTabActive {
|
||||
background: var(--mantine-color-blue-filled);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.slimTabUpload {
|
||||
flex: 0 0 auto;
|
||||
margin: 0;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.15;
|
||||
padding: 1px 0.45rem;
|
||||
border-radius: calc(var(--radius-sm) - 2px);
|
||||
white-space: nowrap;
|
||||
background: transparent;
|
||||
color: var(--mantine-color-dimmed);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 2.75rem;
|
||||
}
|
||||
|
||||
.slimTabUpload:hover:not(:disabled) {
|
||||
color: var(--mantine-color-blue-filled);
|
||||
background: color-mix(in srgb, var(--mantine-color-blue-1) 35%, transparent);
|
||||
}
|
||||
|
||||
.slimTabUpload:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Sort button group ───────────────────────────── */
|
||||
.sortGroup {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
background: var(--mantine-color-default-hover);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.sortBtn {
|
||||
padding: 2px 8px;
|
||||
font-size: 0.7rem;
|
||||
border-radius: calc(var(--radius-sm) - 2px);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--mantine-color-dimmed);
|
||||
line-height: 1.6;
|
||||
transition:
|
||||
background 0.1s,
|
||||
color 0.1s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sortBtn:hover {
|
||||
color: var(--mantine-color-text);
|
||||
}
|
||||
|
||||
.sortBtnActive {
|
||||
background: var(--mantine-color-blue-filled);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.sortArrow {
|
||||
opacity: 0.85;
|
||||
font-size: 0.65rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* ── Search / filter row ─────────────────────────── */
|
||||
.searchRow {
|
||||
padding: 0.3rem 0.45rem;
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
height: 1.625rem;
|
||||
padding: 0 0.5rem;
|
||||
font-size: 0.7rem;
|
||||
border: 1px solid var(--border-subtle, var(--border-default));
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--mantine-color-default-hover);
|
||||
color: var(--mantine-color-text);
|
||||
outline: none;
|
||||
transition: border-color 0.12s ease;
|
||||
}
|
||||
|
||||
.searchInput::placeholder {
|
||||
color: var(--mantine-color-dimmed);
|
||||
}
|
||||
|
||||
.searchInput:focus {
|
||||
border-color: var(--mantine-color-blue-5);
|
||||
}
|
||||
|
||||
/* hide browser clear button in Safari */
|
||||
.searchInput::-webkit-search-cancel-button {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
/* ── Scrollable file list ────────────────────────── */
|
||||
.list {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* ── Individual file row ─────────────────────────── */
|
||||
.fileItem {
|
||||
width: 100%;
|
||||
padding: 0.625rem 0.875rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-top: 1px solid var(--border-default);
|
||||
text-align: left;
|
||||
transition: background 0.08s ease;
|
||||
}
|
||||
|
||||
.fileItem:first-child {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.fileItem:hover:not(:disabled) {
|
||||
background: var(--mantine-color-default-hover);
|
||||
}
|
||||
|
||||
.fileItem:disabled {
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.fileItemContent {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.fileName {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--mantine-color-text);
|
||||
}
|
||||
|
||||
.fileMeta {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--mantine-color-dimmed);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Empty / loading states ──────────────────────── */
|
||||
.emptyState {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2.5rem 1rem;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
color: var(--mantine-color-dimmed);
|
||||
}
|
||||
@@ -0,0 +1,632 @@
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Box, Popover, ScrollArea, Text, Loader } from "@mantine/core";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
createStirlingFile,
|
||||
createFileId,
|
||||
createNewStirlingFileStub,
|
||||
} from "@app/types/fileContext";
|
||||
import type { StirlingFile, StirlingFileStub } from "@app/types/fileContext";
|
||||
import type { FileId } from "@app/types/file";
|
||||
import { useAllFiles } from "@app/contexts/FileContext";
|
||||
import { useIndexedDB } from "@app/contexts/IndexedDBContext";
|
||||
import { useFileContext } from "@app/contexts/file/fileHooks";
|
||||
import { useFileManager } from "@app/hooks/useFileManager";
|
||||
import { fileStorage } from "@app/services/fileStorage";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import {
|
||||
parseContentDispositionFilename,
|
||||
extractLatestFilesFromBundle,
|
||||
} from "@app/services/shareBundleUtils";
|
||||
import { truncateCenter } from "@app/utils/textUtils";
|
||||
import { generateThumbnailForFile } from "@app/utils/thumbnailUtils";
|
||||
import styles from "@app/components/shared/FileSelectorPicker.module.css";
|
||||
import "@app/components/shared/FileSidebarFileItem.css";
|
||||
|
||||
const LS_TAB = "filePicker.tab";
|
||||
const LS_SORT = "filePicker.sort";
|
||||
const LS_SORT_DIR = "filePicker.sortDir";
|
||||
|
||||
function lsGet<const T extends readonly string[]>(
|
||||
key: string,
|
||||
fallback: T[number],
|
||||
valid: T,
|
||||
): T[number] {
|
||||
try {
|
||||
const v = localStorage.getItem(key);
|
||||
const allowed = valid as readonly string[];
|
||||
if (v && allowed.includes(v)) return v as T[number];
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function lsSet(key: string, value: string) {
|
||||
try {
|
||||
localStorage.setItem(key, value);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (!bytes) return "";
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function formatDate(ms: number | undefined): string {
|
||||
if (!ms) return "";
|
||||
return new Date(ms).toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
function buildMeta(stub: StirlingFileStub): string {
|
||||
const parts: string[] = [];
|
||||
const pages = stub.processedFile?.totalPages;
|
||||
if (pages) parts.push(`${pages} ${pages === 1 ? "page" : "pages"}`);
|
||||
const size = formatBytes(stub.size ?? 0);
|
||||
if (size) parts.push(size);
|
||||
const date = formatDate(stub.lastModified || stub.createdAt);
|
||||
if (date) parts.push(date);
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
export interface FileSelectorResult {
|
||||
stub: StirlingFileStub;
|
||||
stirlingFile: StirlingFile;
|
||||
}
|
||||
|
||||
export interface FileSelectorPickerProps {
|
||||
placeholder?: string;
|
||||
/** FileIds to hide from both lists (e.g. the other slot's current selection) */
|
||||
excludeIds?: string[];
|
||||
disabled?: boolean;
|
||||
/** Optional data-testid applied to the trigger box */
|
||||
testId?: string;
|
||||
/**
|
||||
* Called with the stub (for display) and the ready-to-use StirlingFile (for processing).
|
||||
* Files are NOT added to the workbench — data is loaded inline.
|
||||
*/
|
||||
onSelect: (result: FileSelectorResult) => void;
|
||||
}
|
||||
|
||||
export function FileSelectorPicker({
|
||||
placeholder,
|
||||
excludeIds = [],
|
||||
disabled = false,
|
||||
testId,
|
||||
onSelect,
|
||||
}: FileSelectorPickerProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<"workbench" | "saved">(() =>
|
||||
lsGet(LS_TAB, "saved", ["workbench", "saved"]),
|
||||
);
|
||||
const [sortBy, setSortBy] = useState<"date" | "name">(() =>
|
||||
lsGet(LS_SORT, "date", ["date", "name"]),
|
||||
);
|
||||
const [sortDir, setSortDir] = useState<"asc" | "desc">(() =>
|
||||
lsGet(LS_SORT_DIR, "desc", ["asc", "desc"]),
|
||||
);
|
||||
const [savedStubs, setSavedStubs] = useState<StirlingFileStub[]>([]);
|
||||
const [savedLoading, setSavedLoading] = useState(false);
|
||||
const [loadingId, setLoadingId] = useState<string | null>(null);
|
||||
const [uploadBusy, setUploadBusy] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [hoveredStub, setHoveredStub] = useState<{
|
||||
rect: DOMRect;
|
||||
stub: StirlingFileStub;
|
||||
} | null>(null);
|
||||
const [hoveredThumbnail, setHoveredThumbnail] = useState<string | null>(null);
|
||||
const thumbCancelRef = useRef<boolean>(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { fileStubs: workbenchStubs } = useAllFiles();
|
||||
const indexedDB = useIndexedDB();
|
||||
const { selectors } = useFileContext();
|
||||
const { loadRecentFiles } = useFileManager();
|
||||
|
||||
// Load thumbnail lazily when hovering over a file row
|
||||
useEffect(() => {
|
||||
if (!hoveredStub) {
|
||||
setHoveredThumbnail(null);
|
||||
return;
|
||||
}
|
||||
if (hoveredStub.stub.thumbnailUrl) {
|
||||
setHoveredThumbnail(hoveredStub.stub.thumbnailUrl);
|
||||
return;
|
||||
}
|
||||
thumbCancelRef.current = false;
|
||||
setHoveredThumbnail(null);
|
||||
(async () => {
|
||||
try {
|
||||
const file = await indexedDB.loadFile(hoveredStub.stub.id as FileId);
|
||||
if (!file || thumbCancelRef.current) return;
|
||||
const thumbnail = await generateThumbnailForFile(file);
|
||||
if (thumbCancelRef.current || !thumbnail) return;
|
||||
setHoveredThumbnail(thumbnail);
|
||||
void indexedDB.updateThumbnail(
|
||||
hoveredStub.stub.id as FileId,
|
||||
thumbnail,
|
||||
);
|
||||
} catch {
|
||||
// non-critical
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
thumbCancelRef.current = true;
|
||||
};
|
||||
}, [hoveredStub, indexedDB]);
|
||||
|
||||
const handleTabChange = useCallback((tab: "workbench" | "saved") => {
|
||||
lsSet(LS_TAB, tab);
|
||||
setActiveTab(tab);
|
||||
}, []);
|
||||
|
||||
const handleSortChange = useCallback(
|
||||
(sort: "date" | "name") => {
|
||||
if (sort === sortBy) {
|
||||
const newDir = sortDir === "asc" ? "desc" : "asc";
|
||||
lsSet(LS_SORT_DIR, newDir);
|
||||
setSortDir(newDir);
|
||||
} else {
|
||||
const defaultDir: "asc" | "desc" = sort === "name" ? "asc" : "desc";
|
||||
lsSet(LS_SORT, sort);
|
||||
lsSet(LS_SORT_DIR, defaultDir);
|
||||
setSortBy(sort);
|
||||
setSortDir(defaultDir);
|
||||
}
|
||||
},
|
||||
[sortBy, sortDir],
|
||||
);
|
||||
|
||||
// Sync tab/sort from localStorage whenever this picker opens.
|
||||
// Both slot pickers mount simultaneously so their useState initialisers run at
|
||||
// the same time — a tab change in one picker writes to localStorage but the
|
||||
// other picker's React state is stale. Re-reading on open fixes that.
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
setActiveTab(lsGet(LS_TAB, "saved", ["workbench", "saved"]));
|
||||
setSortBy(lsGet(LS_SORT, "date", ["date", "name"]));
|
||||
setSortDir(lsGet(LS_SORT_DIR, "desc", ["asc", "desc"]));
|
||||
}, [isOpen]);
|
||||
|
||||
// Load saved files when the saved tab is active and the picker is open
|
||||
useEffect(() => {
|
||||
if (activeTab !== "saved" || !isOpen) return;
|
||||
setSavedLoading(true);
|
||||
loadRecentFiles()
|
||||
.then(setSavedStubs)
|
||||
.finally(() => setSavedLoading(false));
|
||||
}, [activeTab, isOpen, loadRecentFiles]);
|
||||
|
||||
const workbenchIdSet = useMemo(
|
||||
() => new Set(workbenchStubs.map((s) => s.id)),
|
||||
[workbenchStubs],
|
||||
);
|
||||
|
||||
const displayStubs = useMemo(() => {
|
||||
const base = activeTab === "workbench" ? workbenchStubs : savedStubs;
|
||||
const q = searchQuery.trim().toLowerCase();
|
||||
const filtered = base.filter(
|
||||
(s) =>
|
||||
!excludeIds.includes(s.id) && (!q || s.name.toLowerCase().includes(q)),
|
||||
);
|
||||
const dir = sortDir === "asc" ? 1 : -1;
|
||||
return [...filtered].sort((a, b) =>
|
||||
sortBy === "name"
|
||||
? dir * a.name.localeCompare(b.name)
|
||||
: dir *
|
||||
((a.lastModified || a.createdAt || 0) -
|
||||
(b.lastModified || b.createdAt || 0)),
|
||||
);
|
||||
}, [
|
||||
activeTab,
|
||||
workbenchStubs,
|
||||
savedStubs,
|
||||
excludeIds,
|
||||
sortBy,
|
||||
sortDir,
|
||||
searchQuery,
|
||||
]);
|
||||
|
||||
const loadAndSelect = useCallback(
|
||||
async (stub: StirlingFileStub) => {
|
||||
if (loadingId) return;
|
||||
|
||||
// Workbench file — get StirlingFile directly from FileContext (no loading needed)
|
||||
if (workbenchIdSet.has(stub.id)) {
|
||||
const sf = selectors.getFile(stub.id as FileId);
|
||||
if (sf) {
|
||||
// Prefer the workbench stub (has thumbnail) over the saved stub (may not)
|
||||
const workbenchStub =
|
||||
selectors.getStirlingFileStub(stub.id as FileId) ?? stub;
|
||||
onSelect({ stub: workbenchStub, stirlingFile: sf });
|
||||
setIsOpen(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Saved file — load bytes without touching the workbench
|
||||
setLoadingId(stub.id);
|
||||
try {
|
||||
let stirlingFile: StirlingFile | null = null;
|
||||
|
||||
if (stub.remoteShareToken) {
|
||||
const res = await apiClient.get(
|
||||
`/api/v1/storage/share-links/${stub.remoteShareToken}`,
|
||||
{
|
||||
responseType: "blob",
|
||||
suppressErrorToast: true,
|
||||
skipAuthRedirect: true,
|
||||
} as any,
|
||||
);
|
||||
const ct =
|
||||
res.headers?.["content-type"] ||
|
||||
res.headers?.["Content-Type"] ||
|
||||
"";
|
||||
const disp =
|
||||
res.headers?.["content-disposition"] ||
|
||||
res.headers?.["Content-Disposition"] ||
|
||||
"";
|
||||
const files = await extractLatestFilesFromBundle(
|
||||
res.data as Blob,
|
||||
parseContentDispositionFilename(disp) || "shared-file",
|
||||
ct,
|
||||
);
|
||||
if (files[0])
|
||||
stirlingFile = createStirlingFile(files[0], createFileId());
|
||||
} else if (stub.remoteStorageId) {
|
||||
const res = await apiClient.get(
|
||||
`/api/v1/storage/files/${stub.remoteStorageId}/download`,
|
||||
{
|
||||
responseType: "blob",
|
||||
suppressErrorToast: true,
|
||||
skipAuthRedirect: true,
|
||||
} as any,
|
||||
);
|
||||
const ct =
|
||||
res.headers?.["content-type"] ||
|
||||
res.headers?.["Content-Type"] ||
|
||||
"";
|
||||
const disp =
|
||||
res.headers?.["content-disposition"] ||
|
||||
res.headers?.["Content-Disposition"] ||
|
||||
"";
|
||||
const files = await extractLatestFilesFromBundle(
|
||||
res.data as Blob,
|
||||
parseContentDispositionFilename(disp) || stub.name,
|
||||
ct,
|
||||
);
|
||||
if (files[0])
|
||||
stirlingFile = createStirlingFile(files[0], stub.id as FileId);
|
||||
} else {
|
||||
// Local IndexedDB file
|
||||
const localFile = await fileStorage.getStirlingFile(stub.id);
|
||||
if (localFile) stirlingFile = localFile;
|
||||
}
|
||||
|
||||
if (stirlingFile) {
|
||||
// Generate thumbnail on-the-fly if the stub doesn't already have one
|
||||
let resolvedStub = stub;
|
||||
if (!resolvedStub.thumbnailUrl) {
|
||||
try {
|
||||
const thumbnail = await generateThumbnailForFile(stirlingFile);
|
||||
if (thumbnail) {
|
||||
resolvedStub = { ...stub, thumbnailUrl: thumbnail };
|
||||
// Persist so subsequent opens don't regenerate
|
||||
void fileStorage.updateThumbnail(
|
||||
stirlingFile.fileId as FileId,
|
||||
thumbnail,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal — thumbnail simply won't show
|
||||
}
|
||||
}
|
||||
onSelect({ stub: resolvedStub, stirlingFile });
|
||||
setIsOpen(false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("FileSelectorPicker: failed to load file", err);
|
||||
} finally {
|
||||
setLoadingId(null);
|
||||
}
|
||||
},
|
||||
[loadingId, workbenchIdSet, selectors, onSelect],
|
||||
);
|
||||
|
||||
const handleUpload = useCallback(
|
||||
async (file: File | null) => {
|
||||
if (!file || uploadBusy || disabled) return;
|
||||
setUploadBusy(true);
|
||||
try {
|
||||
const id = createFileId();
|
||||
let stub = createNewStirlingFileStub(file, id);
|
||||
const stirlingFile = createStirlingFile(file, id);
|
||||
// Generate a first-page thumbnail for the uploaded file
|
||||
try {
|
||||
const thumbnail = await generateThumbnailForFile(file);
|
||||
if (thumbnail) stub = { ...stub, thumbnailUrl: thumbnail };
|
||||
} catch {
|
||||
// Non-fatal — thumbnail simply won't show
|
||||
}
|
||||
await fileStorage.storeStirlingFile(stirlingFile, stub);
|
||||
lsSet(LS_TAB, "saved");
|
||||
setActiveTab("saved");
|
||||
const refreshed = await loadRecentFiles();
|
||||
setSavedStubs(refreshed);
|
||||
onSelect({ stub, stirlingFile });
|
||||
setIsOpen(false);
|
||||
} catch (err) {
|
||||
console.error("FileSelectorPicker: upload failed", err);
|
||||
} finally {
|
||||
setUploadBusy(false);
|
||||
}
|
||||
},
|
||||
[disabled, loadRecentFiles, onSelect, uploadBusy],
|
||||
);
|
||||
|
||||
const triggerClass = [
|
||||
styles.trigger,
|
||||
disabled ? styles.triggerDisabled : "",
|
||||
isOpen ? styles.triggerOpen : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover
|
||||
opened={isOpen}
|
||||
onChange={setIsOpen}
|
||||
onClose={() => {
|
||||
setIsOpen(false);
|
||||
setSearchQuery("");
|
||||
}}
|
||||
position="bottom-start"
|
||||
withinPortal
|
||||
shadow="md"
|
||||
closeOnClickOutside
|
||||
clickOutsideEvents={["mousedown", "touchstart"]}
|
||||
>
|
||||
<Popover.Target>
|
||||
<Box
|
||||
className={triggerClass}
|
||||
style={{ width: "100%" }}
|
||||
data-testid={testId}
|
||||
onClick={() => {
|
||||
if (!disabled) setIsOpen((o) => !o);
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
onKeyDown={(e) => {
|
||||
if (!disabled && (e.key === "Enter" || e.key === " "))
|
||||
setIsOpen((o) => !o);
|
||||
}}
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="listbox"
|
||||
>
|
||||
<Text
|
||||
size="sm"
|
||||
c="dimmed"
|
||||
style={{
|
||||
flex: 1,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{placeholder ||
|
||||
t("fileSelectorPicker.placeholder", "Select file")}
|
||||
</Text>
|
||||
<AddIcon
|
||||
style={{
|
||||
fontSize: 18,
|
||||
color: "var(--mantine-color-dimmed)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Popover.Target>
|
||||
|
||||
<Popover.Dropdown className={styles.dropdown}>
|
||||
<div className={styles.header}>
|
||||
<div className={styles.tabGroup}>
|
||||
<div
|
||||
className={styles.slimTabBar}
|
||||
role="group"
|
||||
aria-label={t(
|
||||
"fileSelectorPicker.tabListLabel",
|
||||
"Saved files, workbench, upload",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={activeTab === "saved"}
|
||||
className={
|
||||
activeTab === "saved"
|
||||
? styles.slimTabActive
|
||||
: styles.slimTab
|
||||
}
|
||||
onClick={() => handleTabChange("saved")}
|
||||
>
|
||||
{t("fileSelectorPicker.tabs.saved", "Saved files")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={activeTab === "workbench"}
|
||||
className={
|
||||
activeTab === "workbench"
|
||||
? styles.slimTabActive
|
||||
: styles.slimTab
|
||||
}
|
||||
onClick={() => handleTabChange("workbench")}
|
||||
>
|
||||
{t("fileSelectorPicker.tabs.workbench", "Workbench")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="file-selector-upload-btn"
|
||||
className={styles.slimTabUpload}
|
||||
aria-label={t(
|
||||
"fileSelectorPicker.upload",
|
||||
"Upload from computer",
|
||||
)}
|
||||
title={t("fileSelectorPicker.upload", "Upload from computer")}
|
||||
disabled={disabled || uploadBusy}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
{uploadBusy ? (
|
||||
<Loader size={11} />
|
||||
) : (
|
||||
t("fileSelectorPicker.tabs.upload", "Upload")
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.sortGroup}>
|
||||
<button
|
||||
type="button"
|
||||
className={[
|
||||
styles.sortBtn,
|
||||
sortBy === "date" ? styles.sortBtnActive : "",
|
||||
].join(" ")}
|
||||
onClick={() => handleSortChange("date")}
|
||||
title={
|
||||
sortDir === "desc"
|
||||
? t("fileSelectorPicker.sort.dateDesc", "Newest first")
|
||||
: t("fileSelectorPicker.sort.dateAsc", "Oldest first")
|
||||
}
|
||||
>
|
||||
{t("fileSelectorPicker.sort.dateLabel", "Latest")}
|
||||
{sortBy === "date" && (
|
||||
<span className={styles.sortArrow}>
|
||||
{sortDir === "desc" ? " ↓" : " ↑"}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={[
|
||||
styles.sortBtn,
|
||||
sortBy === "name" ? styles.sortBtnActive : "",
|
||||
].join(" ")}
|
||||
onClick={() => handleSortChange("name")}
|
||||
title={
|
||||
sortDir === "asc"
|
||||
? t("fileSelectorPicker.sort.nameAsc", "A to Z")
|
||||
: t("fileSelectorPicker.sort.nameDesc", "Z to A")
|
||||
}
|
||||
>
|
||||
{t("fileSelectorPicker.sort.nameLabel", "A–Z")}
|
||||
{sortBy === "name" && (
|
||||
<span className={styles.sortArrow}>
|
||||
{sortDir === "asc" ? " ↑" : " ↓"}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.searchRow}>
|
||||
<input
|
||||
className={styles.searchInput}
|
||||
type="search"
|
||||
placeholder={t("fileSelectorPicker.search", "Filter files…")}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ScrollArea h={260} className={styles.list}>
|
||||
{savedLoading ? (
|
||||
<div className={styles.emptyState}>
|
||||
<Loader size="sm" />
|
||||
</div>
|
||||
) : displayStubs.length === 0 ? (
|
||||
<div className={styles.emptyState}>
|
||||
<Text size="sm">
|
||||
{t("fileSelectorPicker.empty", "No files available")}
|
||||
</Text>
|
||||
</div>
|
||||
) : (
|
||||
displayStubs.map((stub) => {
|
||||
const meta = buildMeta(stub);
|
||||
const isItemLoading = loadingId === stub.id;
|
||||
return (
|
||||
<button
|
||||
key={stub.id}
|
||||
type="button"
|
||||
className={styles.fileItem}
|
||||
onClick={() => void loadAndSelect(stub)}
|
||||
disabled={!!loadingId}
|
||||
onMouseEnter={(e) =>
|
||||
setHoveredStub({
|
||||
rect: e.currentTarget.getBoundingClientRect(),
|
||||
stub,
|
||||
})
|
||||
}
|
||||
onMouseLeave={() => setHoveredStub(null)}
|
||||
>
|
||||
<div className={styles.fileItemContent}>
|
||||
<span className={styles.fileName} title={stub.name}>
|
||||
{truncateCenter(stub.name, 48)}
|
||||
</span>
|
||||
{meta && <span className={styles.fileMeta}>{meta}</span>}
|
||||
</div>
|
||||
{isItemLoading && <Loader size="xs" />}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
{hoveredStub &&
|
||||
hoveredThumbnail &&
|
||||
createPortal(
|
||||
<div
|
||||
className="file-sidebar-thumb-tooltip"
|
||||
style={{
|
||||
top: hoveredStub.rect.top + hoveredStub.rect.height / 2,
|
||||
left: hoveredStub.rect.left - 170,
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={hoveredThumbnail}
|
||||
alt=""
|
||||
className="file-sidebar-thumb-img"
|
||||
/>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
{/* Hidden file input lives outside the Popover so it is always in the DOM.
|
||||
Tests can target it directly with setInputFiles without opening the popover. */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
data-testid={testId ? `${testId}-input` : "file-selector-upload-input"}
|
||||
accept=".pdf,application/pdf"
|
||||
style={{ display: "none" }}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0] ?? null;
|
||||
void handleUpload(file);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
/* ========== FILE SIDEBAR ========== */
|
||||
|
||||
.file-sidebar {
|
||||
background-color: var(--bg-toolbar);
|
||||
border-right: 1px solid var(--border-subtle);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
transition:
|
||||
width 0.22s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
min-width 0.22s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
max-width 0.22s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-sidebar-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ---- Header ---- */
|
||||
.file-sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 48px;
|
||||
padding: 0 14px;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
margin: 4px 4px 0 4px;
|
||||
flex-shrink: 0;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
/* Icons stay left-aligned during animation; overflow:hidden on inner clips text naturally */
|
||||
|
||||
.file-sidebar-header:hover {
|
||||
background-color: var(--bg-hover);
|
||||
}
|
||||
|
||||
.file-sidebar-menu-icon {
|
||||
color: var(--text-muted) !important;
|
||||
font-size: 18px !important;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-sidebar-brand-text {
|
||||
height: 22px;
|
||||
width: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ---- Search row ---- */
|
||||
.file-sidebar-search-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 32px;
|
||||
padding: 0 14px;
|
||||
gap: 0;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
margin: 0 4px;
|
||||
flex-shrink: 0;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.file-sidebar-search-row:not(.active):hover {
|
||||
background-color: var(--bg-hover);
|
||||
}
|
||||
|
||||
.file-sidebar-search-icon {
|
||||
color: var(--text-muted) !important;
|
||||
font-size: 18px !important;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-sidebar-search-close {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.file-sidebar-search-input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
margin-left: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.file-sidebar-search-input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.file-sidebar-search-label {
|
||||
margin-left: 12px;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ---- Scrollable content ---- */
|
||||
/* This is a flex column — action rows are fixed, only the file list scrolls */
|
||||
.file-sidebar-scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.file-sidebar-scroll::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
.file-sidebar-scroll::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.file-sidebar-scroll::-webkit-scrollbar-thumb {
|
||||
background: rgb(var(--border));
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* ---- Action rows (Open from Computer, etc.) ---- */
|
||||
.file-sidebar-action-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 32px;
|
||||
padding: 0 14px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
margin: 0 4px;
|
||||
gap: 0;
|
||||
transition: background-color 0.15s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-sidebar-action-row:hover {
|
||||
background-color: var(--bg-hover);
|
||||
}
|
||||
|
||||
.file-sidebar-action-icon {
|
||||
color: var(--text-muted) !important;
|
||||
font-size: 18px !important;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-sidebar-action-label {
|
||||
margin-left: 12px;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ---- Cloud storage rows ---- */
|
||||
.file-sidebar-cloud-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 32px;
|
||||
padding: 0 14px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
margin: 0 4px;
|
||||
gap: 0;
|
||||
transition: background-color 0.15s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-sidebar-cloud-row:not(.disabled):hover {
|
||||
background-color: var(--bg-hover);
|
||||
}
|
||||
|
||||
.file-sidebar-cloud-row.disabled {
|
||||
cursor: default;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.file-sidebar-cloud-icon-wrapper {
|
||||
position: relative;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-sidebar-cloud-icon-gray {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.file-sidebar-cloud-icon-color {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.file-sidebar-cloud-row:not(.disabled):hover .file-sidebar-cloud-icon-gray {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.file-sidebar-cloud-row:not(.disabled):hover .file-sidebar-cloud-icon-color {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ---- Files section ---- */
|
||||
.file-sidebar-files-section {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.file-sidebar-file-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
.file-sidebar-file-list::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
.file-sidebar-file-list::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.file-sidebar-file-list::-webkit-scrollbar-thumb {
|
||||
background: rgb(var(--border));
|
||||
border-radius: 2px;
|
||||
}
|
||||
.file-sidebar-file-list::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-muted);
|
||||
}
|
||||
|
||||
.file-sidebar-section-divider {
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
margin: 10px 14px 6px 14px;
|
||||
}
|
||||
|
||||
.file-sidebar-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 14px 6px 14px;
|
||||
margin: 4px 0 0 0;
|
||||
}
|
||||
|
||||
.file-sidebar-section-header {
|
||||
position: relative;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
padding-top: 10px;
|
||||
margin-top: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-sidebar-section-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.file-sidebar-section-count {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.file-sidebar-section-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
padding: 0;
|
||||
outline: none;
|
||||
transition:
|
||||
background-color 0.12s ease,
|
||||
color 0.12s ease,
|
||||
opacity 0.12s ease;
|
||||
}
|
||||
|
||||
.file-sidebar-section-btn:focus-visible {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.file-sidebar-section-btn:hover {
|
||||
background-color: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.file-sidebar-section-btn-external {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.file-sidebar-section-header:hover .file-sidebar-section-btn-external {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.file-sidebar-section-btn-add {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* ---- Sidebar content fade (for collapsed state) ---- */
|
||||
/* Elements are conditionally rendered so use animation (not transition) to fade in from 0.
|
||||
Delay until the width animation is nearly done (0.18s of 0.22s) to avoid squashed-text effect. */
|
||||
@keyframes sidebar-content-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-content-fade {
|
||||
animation: sidebar-content-in 0.12s ease 0.18s both;
|
||||
}
|
||||
|
||||
.file-sidebar[data-collapsed="true"] .sidebar-content-fade {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* ---- Bottom bar (user + settings) ---- */
|
||||
.file-sidebar-bottom-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
flex-shrink: 0;
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
/* Bottom bar settings icon tracks the right edge during collapse animation */
|
||||
|
||||
.file-sidebar-bottom-avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--mantine-color-blue-6, #3b82f6);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.file-sidebar-bottom-name {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.file-sidebar-bottom-bar[role="button"]:hover {
|
||||
background-color: var(--bg-hover);
|
||||
}
|
||||
|
||||
.file-sidebar-bottom-bar[role="button"]:focus-visible {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.file-sidebar-bottom-settings {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
color: var(--text-muted);
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.file-sidebar[data-collapsed="true"] .file-sidebar-bottom-settings {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.file-sidebar[data-collapsed="true"] .file-sidebar-bottom-bar {
|
||||
justify-content: center;
|
||||
padding: 8px 0;
|
||||
}
|
||||
@@ -0,0 +1,594 @@
|
||||
import React, {
|
||||
useState,
|
||||
useCallback,
|
||||
useRef,
|
||||
useEffect,
|
||||
forwardRef,
|
||||
} from "react";
|
||||
import { Loader } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useFileState, useFileActions } from "@app/contexts/file/fileHooks";
|
||||
import { useFilesModalContext } from "@app/contexts/FilesModalContext";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import { useGoogleDrivePicker } from "@app/hooks/useGoogleDrivePicker";
|
||||
import {
|
||||
useNavigationState,
|
||||
useNavigationActions,
|
||||
useNavigationGuard,
|
||||
} from "@app/contexts/NavigationContext";
|
||||
import { useViewer } from "@app/contexts/ViewerContext";
|
||||
import { useFileHandler } from "@app/hooks/useFileHandler";
|
||||
import { useIndexedDB } from "@app/contexts/IndexedDBContext";
|
||||
import { accountService } from "@app/services/accountService";
|
||||
import { GoogleDriveIcon } from "@app/components/shared/CloudStorageIcons";
|
||||
import { Wordmark } from "@app/components/shared/Wordmark";
|
||||
import type { StirlingFileStub } from "@app/types/fileContext";
|
||||
import MenuIcon from "@mui/icons-material/Menu";
|
||||
import SearchIcon from "@mui/icons-material/Search";
|
||||
import FolderOpenIcon from "@mui/icons-material/FolderOpen";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
|
||||
import SettingsIcon from "@mui/icons-material/Settings";
|
||||
import type { FileId } from "@app/types/file";
|
||||
import { FileItem } from "@app/components/shared/FileSidebarFileItem";
|
||||
import "@app/components/shared/FileSidebar.css";
|
||||
|
||||
const COLLAPSED_WIDTH = "3.5rem";
|
||||
const EXPANDED_WIDTH = "16.25rem"; // ~260px
|
||||
|
||||
export interface FileSidebarProps {
|
||||
collapsed?: boolean;
|
||||
onToggleCollapse?: () => void;
|
||||
onOpenSettings?: () => void;
|
||||
}
|
||||
|
||||
const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
function FileSidebar(
|
||||
{ collapsed = false, onToggleCollapse, onOpenSettings },
|
||||
ref,
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const [searchActive, setSearchActive] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const nativeFileInputRef = useRef<HTMLInputElement>(null);
|
||||
// State (not ref) so setting it triggers a re-render — avoids racing addFiles state updates.
|
||||
const [pendingViewFileId, setPendingViewFileId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const { openFilesModal } = useFilesModalContext();
|
||||
const { config } = useAppConfig();
|
||||
const {
|
||||
isEnabled: isGoogleDriveEnabled,
|
||||
openPicker: openGoogleDrivePicker,
|
||||
} = useGoogleDrivePicker();
|
||||
const { state } = useFileState();
|
||||
const { actions: fileActions } = useFileActions();
|
||||
const { actions: navActions } = useNavigationActions();
|
||||
const { workbench: currentWorkbench, selectedTool } = useNavigationState();
|
||||
const isMultiTool =
|
||||
currentWorkbench === "pageEditor" && selectedTool === "multiTool";
|
||||
const { requestNavigation } = useNavigationGuard();
|
||||
const { activeFileId, setActiveFileId } = useViewer();
|
||||
const { addFiles } = useFileHandler();
|
||||
const indexedDB = useIndexedDB();
|
||||
const [displayName, setDisplayName] = useState<string>("Guest");
|
||||
|
||||
useEffect(() => {
|
||||
if (!config?.enableLogin) return;
|
||||
accountService
|
||||
.getAccountData()
|
||||
.then((data) => {
|
||||
if (data?.username) setDisplayName(data.username);
|
||||
})
|
||||
.catch(() => {
|
||||
/* not logged in or security disabled */
|
||||
});
|
||||
}, [config?.enableLogin]);
|
||||
|
||||
// Leaf files = user-visible files (excludes intermediate tool outputs)
|
||||
const [allFileStubs, setAllFileStubs] = useState<StirlingFileStub[]>([]);
|
||||
const [stubsLoaded, setStubsLoaded] = useState(false);
|
||||
|
||||
const refreshStubs = useCallback(async () => {
|
||||
// Leaf files from IDB — same source as the file selection modal.
|
||||
const stubs = await indexedDB.loadLeafMetadata();
|
||||
const idbIds = new Set(stubs.map((s) => s.id as string));
|
||||
|
||||
// Also include workbench files not yet flushed to IDB.
|
||||
const pendingStubs = state.files.ids
|
||||
.map((id) => state.files.byId[id])
|
||||
.filter(
|
||||
(stub): stub is NonNullable<typeof stub> =>
|
||||
!!stub && stub.isLeaf !== false && !idbIds.has(stub.id as string),
|
||||
);
|
||||
|
||||
const allStubs = [...stubs, ...pendingStubs];
|
||||
setAllFileStubs(
|
||||
allStubs.sort((a, b) => (b.lastModified ?? 0) - (a.lastModified ?? 0)),
|
||||
);
|
||||
setStubsLoaded(true);
|
||||
}, [indexedDB, state.files.ids, state.files.byId]);
|
||||
|
||||
// Refresh on mount, workbench changes, or external IndexedDB writes
|
||||
useEffect(() => {
|
||||
refreshStubs();
|
||||
}, [refreshStubs, indexedDB.revision]);
|
||||
|
||||
// Once a pending file lands in state, open it in the viewer.
|
||||
useEffect(() => {
|
||||
if (!pendingViewFileId) return;
|
||||
const isInWorkbench = state.files.ids.some(
|
||||
(id) => (id as string) === pendingViewFileId,
|
||||
);
|
||||
if (isInWorkbench) {
|
||||
setPendingViewFileId(null);
|
||||
setActiveFileId(pendingViewFileId);
|
||||
navActions.setWorkbench("viewer");
|
||||
}
|
||||
}, [pendingViewFileId, state.files.ids, setActiveFileId, navActions]);
|
||||
|
||||
const filteredFileStubs = searchQuery.trim()
|
||||
? allFileStubs.filter((stub) =>
|
||||
stub.name.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
)
|
||||
: allFileStubs;
|
||||
|
||||
// Handle search activation
|
||||
const handleSearchClick = useCallback(() => {
|
||||
if (collapsed && onToggleCollapse) {
|
||||
onToggleCollapse();
|
||||
}
|
||||
setSearchActive(true);
|
||||
}, [collapsed, onToggleCollapse]);
|
||||
|
||||
const handleSearchClose = useCallback(() => {
|
||||
setSearchActive(false);
|
||||
setSearchQuery("");
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchActive && searchInputRef.current) {
|
||||
searchInputRef.current.focus();
|
||||
}
|
||||
}, [searchActive]);
|
||||
|
||||
// Handle Google Drive
|
||||
const handleGoogleDriveClick = useCallback(async () => {
|
||||
if (!isGoogleDriveEnabled) return;
|
||||
const files = await openGoogleDrivePicker({ multiple: true });
|
||||
if (files.length > 0) {
|
||||
await addFiles(files);
|
||||
if (!isMultiTool) {
|
||||
navActions.setWorkbench(files.length === 1 ? "viewer" : "fileEditor");
|
||||
}
|
||||
}
|
||||
}, [
|
||||
isGoogleDriveEnabled,
|
||||
openGoogleDrivePicker,
|
||||
addFiles,
|
||||
navActions,
|
||||
isMultiTool,
|
||||
]);
|
||||
|
||||
// Toggle file in/out of workbench
|
||||
const handleFileClick = useCallback(
|
||||
async (fileId: FileId) => {
|
||||
const stub = allFileStubs.find((s) => s.id === fileId);
|
||||
if (!stub) return;
|
||||
|
||||
const workbenchFileId = state.files.ids.find(
|
||||
(id) => (id as string) === (stub.id as string),
|
||||
);
|
||||
|
||||
if (workbenchFileId) {
|
||||
// If this is the file currently open in the viewer, route through the
|
||||
// navigation guard so the save modal fires when there are unsaved changes.
|
||||
const isCurrentlyViewed = workbenchFileId === viewedWorkbenchId;
|
||||
if (isCurrentlyViewed) {
|
||||
requestNavigation(() => {
|
||||
void fileActions.removeFiles([workbenchFileId], false);
|
||||
});
|
||||
return;
|
||||
}
|
||||
await fileActions.removeFiles([workbenchFileId], false);
|
||||
} else {
|
||||
// Re-add by stub to preserve its ID — addFiles() would create a new UUID + IDB entry.
|
||||
const workbenchCount = state.files.ids.length;
|
||||
|
||||
if (workbenchCount > 0 && currentWorkbench === "viewer") {
|
||||
navActions.setWorkbench("fileEditor");
|
||||
}
|
||||
|
||||
await fileActions.addStirlingFileStubs([stub]);
|
||||
|
||||
if (isMultiTool) {
|
||||
fileActions.setSelectedFiles([
|
||||
...state.ui.selectedFileIds,
|
||||
stub.id,
|
||||
]);
|
||||
} else {
|
||||
if (workbenchCount === 0) {
|
||||
navActions.setWorkbench("viewer");
|
||||
} else {
|
||||
navActions.setWorkbench("fileEditor");
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
allFileStubs,
|
||||
state.files.ids,
|
||||
state.ui.selectedFileIds,
|
||||
fileActions,
|
||||
navActions,
|
||||
currentWorkbench,
|
||||
activeFileId,
|
||||
requestNavigation,
|
||||
isMultiTool,
|
||||
],
|
||||
);
|
||||
|
||||
// Which file is currently open in the viewer — stable ID, never index-derived.
|
||||
const viewedWorkbenchId =
|
||||
currentWorkbench === "viewer" ? activeFileId : null;
|
||||
|
||||
const handleEyeClick = useCallback(
|
||||
async (fileId: FileId, _e: React.MouseEvent) => {
|
||||
const stub = allFileStubs.find((s) => s.id === fileId);
|
||||
if (!stub) return;
|
||||
|
||||
const isCurrentlyViewed = !!(
|
||||
viewedWorkbenchId &&
|
||||
(viewedWorkbenchId as string) === (stub.id as string)
|
||||
);
|
||||
|
||||
if (isCurrentlyViewed) {
|
||||
// Closing the currently-viewed file — guard against unsaved changes.
|
||||
navActions.setWorkbench("fileEditor");
|
||||
return;
|
||||
}
|
||||
|
||||
// Switching to a different file while viewer is open — guard against unsaved changes.
|
||||
const performSwitch = async () => {
|
||||
const alreadyInWorkbench = state.files.ids.some(
|
||||
(id) => (id as string) === (stub.id as string),
|
||||
);
|
||||
|
||||
if (!alreadyInWorkbench) {
|
||||
// Leave viewer before mutating workbench (prevents PSPDFKit crash).
|
||||
if (state.files.ids.length > 0 && currentWorkbench === "viewer") {
|
||||
navActions.setWorkbench("fileEditor");
|
||||
}
|
||||
await fileActions.addStirlingFileStubs([stub]);
|
||||
}
|
||||
|
||||
// Route through pendingViewFileId so both setActiveFileIndex + setWorkbench fire together.
|
||||
setPendingViewFileId(stub.id as string);
|
||||
};
|
||||
|
||||
if (currentWorkbench === "viewer" && viewedWorkbenchId) {
|
||||
requestNavigation(() => {
|
||||
void performSwitch();
|
||||
});
|
||||
} else {
|
||||
await performSwitch();
|
||||
}
|
||||
},
|
||||
[
|
||||
allFileStubs,
|
||||
viewedWorkbenchId,
|
||||
state.files.ids,
|
||||
fileActions,
|
||||
navActions,
|
||||
currentWorkbench,
|
||||
setPendingViewFileId,
|
||||
requestNavigation,
|
||||
],
|
||||
);
|
||||
|
||||
const handleNativeFilePick = useCallback(
|
||||
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files ?? []);
|
||||
if (files.length > 0) {
|
||||
await addFiles(files);
|
||||
if (!isMultiTool) {
|
||||
navActions.setWorkbench(
|
||||
files.length === 1 ? "viewer" : "fileEditor",
|
||||
);
|
||||
}
|
||||
}
|
||||
e.target.value = "";
|
||||
},
|
||||
[addFiles, navActions, isMultiTool],
|
||||
);
|
||||
|
||||
const shouldHideGoogleDrive =
|
||||
!isGoogleDriveEnabled && config?.hideDisabledToolsGoogleDrive;
|
||||
|
||||
const width = collapsed ? COLLAPSED_WIDTH : EXPANDED_WIDTH;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="file-sidebar"
|
||||
style={{ width, minWidth: width, maxWidth: width }}
|
||||
data-collapsed={collapsed}
|
||||
data-sidebar="file-sidebar"
|
||||
data-tour="quick-access-bar"
|
||||
>
|
||||
<div className="file-sidebar-inner">
|
||||
{/* Header: hamburger + branding */}
|
||||
<div
|
||||
className="file-sidebar-header"
|
||||
onClick={() => onToggleCollapse?.()}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => e.key === "Enter" && onToggleCollapse?.()}
|
||||
aria-label={
|
||||
collapsed
|
||||
? t("fileSidebar.expand", "Expand sidebar")
|
||||
: t("fileSidebar.collapse", "Collapse sidebar")
|
||||
}
|
||||
>
|
||||
<MenuIcon className="file-sidebar-menu-icon" />
|
||||
{!collapsed && (
|
||||
<Wordmark
|
||||
alt="Stirling PDF"
|
||||
className="file-sidebar-brand-text sidebar-content-fade"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search row */}
|
||||
{
|
||||
<div
|
||||
className={`file-sidebar-search-row${searchActive && !collapsed ? " active" : ""}`}
|
||||
onClick={!searchActive ? handleSearchClick : undefined}
|
||||
role={!searchActive ? "button" : undefined}
|
||||
tabIndex={!searchActive ? 0 : undefined}
|
||||
onKeyDown={
|
||||
!searchActive
|
||||
? (e) => e.key === "Enter" && handleSearchClick()
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{searchActive && !collapsed ? (
|
||||
<CloseIcon
|
||||
className="file-sidebar-search-icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSearchClose();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<SearchIcon className="file-sidebar-search-icon" />
|
||||
)}
|
||||
{!collapsed &&
|
||||
(searchActive ? (
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
className="file-sidebar-search-input sidebar-content-fade"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={t(
|
||||
"fileSidebar.searchPlaceholder",
|
||||
"Search files...",
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
) : (
|
||||
<span className="file-sidebar-search-label sidebar-content-fade">
|
||||
{t("fileSidebar.search", "Search")}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
|
||||
{/* Scrollable content */}
|
||||
<div className="file-sidebar-scroll">
|
||||
{/* Open from Computer + Google Drive */}
|
||||
{
|
||||
<>
|
||||
<div
|
||||
className="file-sidebar-action-row"
|
||||
data-testid="files-button"
|
||||
data-tour="files-button"
|
||||
onClick={() => {
|
||||
if (collapsed && onToggleCollapse) onToggleCollapse();
|
||||
openFilesModal();
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => e.key === "Enter" && openFilesModal()}
|
||||
>
|
||||
<FolderOpenIcon className="file-sidebar-action-icon" />
|
||||
{!collapsed && (
|
||||
<span className="file-sidebar-action-label sidebar-content-fade">
|
||||
{t("fileSidebar.openFromComputer", "Open from computer")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!shouldHideGoogleDrive && (
|
||||
<div
|
||||
className={`file-sidebar-cloud-row${!isGoogleDriveEnabled ? " disabled" : ""}`}
|
||||
onClick={handleGoogleDriveClick}
|
||||
role="button"
|
||||
tabIndex={isGoogleDriveEnabled ? 0 : -1}
|
||||
aria-disabled={!isGoogleDriveEnabled}
|
||||
title={
|
||||
!isGoogleDriveEnabled
|
||||
? t(
|
||||
"fileSidebar.googleDriveDisabled",
|
||||
"Google Drive is not configured",
|
||||
)
|
||||
: t("fileSidebar.googleDrive", "Open from Google Drive")
|
||||
}
|
||||
>
|
||||
<div className="file-sidebar-cloud-icon-wrapper">
|
||||
<GoogleDriveIcon
|
||||
className="file-sidebar-cloud-icon-gray"
|
||||
style={{ color: "var(--text-secondary)" }}
|
||||
/>
|
||||
{isGoogleDriveEnabled && (
|
||||
<GoogleDriveIcon
|
||||
colored
|
||||
className="file-sidebar-cloud-icon-color"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<span className="file-sidebar-action-label sidebar-content-fade">
|
||||
{t("fileSidebar.googleDrive", "Google Drive")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
|
||||
{/* Files section - always visible when expanded */}
|
||||
{!collapsed && (
|
||||
<div className="file-sidebar-files-section sidebar-content-fade">
|
||||
<div className="file-sidebar-section-header">
|
||||
<span className="file-sidebar-section-label">
|
||||
{t("fileSidebar.files", "Files")}
|
||||
</span>
|
||||
<button
|
||||
className="file-sidebar-section-btn file-sidebar-section-btn-external"
|
||||
onClick={() => openFilesModal()}
|
||||
title={t(
|
||||
"fileSidebar.openFileManager",
|
||||
"Open file manager",
|
||||
)}
|
||||
type="button"
|
||||
>
|
||||
<OpenInNewIcon sx={{ fontSize: "1rem" }} />
|
||||
</button>
|
||||
<button
|
||||
className="file-sidebar-section-btn file-sidebar-section-btn-add"
|
||||
onClick={() => nativeFileInputRef.current?.click()}
|
||||
title={t("fileSidebar.addFiles", "Add files")}
|
||||
type="button"
|
||||
>
|
||||
<AddIcon sx={{ fontSize: "1rem" }} />
|
||||
</button>
|
||||
<input
|
||||
ref={nativeFileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept=".pdf"
|
||||
style={{ display: "none" }}
|
||||
onChange={handleNativeFilePick}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!stubsLoaded ? (
|
||||
<div className="file-sidebar-loading">
|
||||
<Loader size="sm" color="var(--text-muted)" />
|
||||
</div>
|
||||
) : filteredFileStubs.length > 0 ? (
|
||||
<div className="file-sidebar-file-list">
|
||||
{filteredFileStubs.map((stub) => {
|
||||
const workbenchFileId = state.files.ids.find(
|
||||
(id) => (id as string) === (stub.id as string),
|
||||
);
|
||||
const isInWorkbench = !!workbenchFileId;
|
||||
// Both active and viewed-in-viewer are ID-based — never index-based.
|
||||
const isViewedInViewer = !!(
|
||||
viewedWorkbenchId &&
|
||||
viewedWorkbenchId === (stub.id as string)
|
||||
);
|
||||
const isActive = isViewedInViewer;
|
||||
// In-memory thumbnail may be fresher than the IndexedDB stub.
|
||||
// Encrypted files never get a raster thumbnail — use undefined
|
||||
// so the sidebar icon is shown instead of a stale canvas thumbnail.
|
||||
const isEncryptedFile =
|
||||
stub.processedFile?.isEncrypted === true;
|
||||
const thumbnailUrl = isEncryptedFile
|
||||
? undefined
|
||||
: (workbenchFileId
|
||||
? state.files.byId[workbenchFileId]?.thumbnailUrl
|
||||
: undefined) || stub.thumbnailUrl;
|
||||
return (
|
||||
<FileItem
|
||||
key={stub.id}
|
||||
fileId={stub.id}
|
||||
name={stub.name}
|
||||
size={stub.size}
|
||||
lastModified={stub.lastModified}
|
||||
isSelected={isInWorkbench}
|
||||
isActive={isActive}
|
||||
isViewedInViewer={isViewedInViewer}
|
||||
thumbnailUrl={thumbnailUrl}
|
||||
onClick={handleFileClick}
|
||||
onEyeClick={handleEyeClick}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
!searchActive && (
|
||||
<div className="file-sidebar-empty">
|
||||
<p className="file-sidebar-empty-text">
|
||||
{t("fileSidebar.noFiles", "No files yet")}
|
||||
</p>
|
||||
<p className="file-sidebar-empty-hint">
|
||||
{t("fileSidebar.dropHint", "Open files to get started")}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom bar: user name + settings */}
|
||||
<div
|
||||
className="file-sidebar-bottom-bar"
|
||||
onClick={onOpenSettings}
|
||||
role={onOpenSettings ? "button" : undefined}
|
||||
tabIndex={onOpenSettings ? 0 : undefined}
|
||||
onKeyDown={
|
||||
onOpenSettings
|
||||
? (e) => e.key === "Enter" && onOpenSettings()
|
||||
: undefined
|
||||
}
|
||||
data-testid={onOpenSettings ? "config-button" : undefined}
|
||||
data-tour={onOpenSettings ? "config-button" : undefined}
|
||||
aria-label={
|
||||
onOpenSettings
|
||||
? t("fileSidebar.openSettings", "Open settings")
|
||||
: undefined
|
||||
}
|
||||
title={
|
||||
onOpenSettings
|
||||
? t("fileSidebar.openSettings", "Open settings")
|
||||
: undefined
|
||||
}
|
||||
style={onOpenSettings ? { cursor: "pointer" } : undefined}
|
||||
>
|
||||
<div className="file-sidebar-bottom-avatar" title={displayName}>
|
||||
{displayName.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<span className="file-sidebar-bottom-name sidebar-content-fade">
|
||||
{displayName}
|
||||
</span>
|
||||
)}
|
||||
{onOpenSettings && !collapsed && (
|
||||
<div className="file-sidebar-bottom-settings">
|
||||
<SettingsIcon sx={{ fontSize: "1.1rem" }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default FileSidebar;
|
||||
@@ -0,0 +1,283 @@
|
||||
/* ---- File list ---- */
|
||||
.file-sidebar-file-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 0 8px 0 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.file-sidebar-file-list::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
.file-sidebar-file-list::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.file-sidebar-file-list::-webkit-scrollbar-thumb {
|
||||
background: rgb(var(--border));
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* ---- File item ---- */
|
||||
.file-sidebar-file-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.12s ease;
|
||||
}
|
||||
|
||||
.file-sidebar-file-item:hover:not(.selected) {
|
||||
background-color: rgba(59, 130, 246, 0.06);
|
||||
}
|
||||
|
||||
.file-sidebar-file-item.selected {
|
||||
background-color: rgba(59, 130, 246, 0.12);
|
||||
}
|
||||
|
||||
.file-sidebar-file-item.active:not(.selected) {
|
||||
background-color: rgba(59, 130, 246, 0.06);
|
||||
}
|
||||
|
||||
/* Icon wrapper */
|
||||
.file-sidebar-file-icon-wrapper {
|
||||
position: relative;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-sidebar-file-icon-hover-hide {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.file-sidebar-file-item:hover .file-sidebar-file-icon-hover-hide {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.file-sidebar-file-checkbox-hover {
|
||||
display: none;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 5px;
|
||||
border: 1.5px solid var(--border-hover, #52525b);
|
||||
}
|
||||
|
||||
.file-sidebar-file-item:hover .file-sidebar-file-checkbox-hover {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.file-sidebar-file-item.selected .file-sidebar-file-checkbox-hover {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.file-sidebar-file-icon {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
/* Checked state */
|
||||
.file-sidebar-file-check {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 5px;
|
||||
background-color: #3b82f6;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-sidebar-check-svg {
|
||||
color: white;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* File info */
|
||||
.file-sidebar-file-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.file-sidebar-file-name {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.file-sidebar-file-item.selected .file-sidebar-file-name {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.file-sidebar-file-meta {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ---- Eye button (open in viewer) ---- */
|
||||
.file-sidebar-eye-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
padding: 0;
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity 0.12s ease,
|
||||
color 0.12s ease;
|
||||
}
|
||||
|
||||
/* Show eye on hover (for any non-viewed file) */
|
||||
.file-sidebar-file-item:hover .file-sidebar-eye-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Pin the viewed item when scrolled out of view in either direction */
|
||||
.file-sidebar-file-item.viewed {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
z-index: 2;
|
||||
/* solid base + green tint overlay so content behind doesn't bleed through */
|
||||
background-color: var(--bg-toolbar);
|
||||
background-image: linear-gradient(
|
||||
rgba(34, 197, 94, 0.1),
|
||||
rgba(34, 197, 94, 0.1)
|
||||
);
|
||||
}
|
||||
|
||||
.file-sidebar-file-item.viewed .file-sidebar-file-name {
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.file-sidebar-file-item.viewed .file-sidebar-file-check {
|
||||
background-color: #22c55e;
|
||||
}
|
||||
|
||||
/* viewed takes precedence over selected */
|
||||
.file-sidebar-file-item.viewed.selected {
|
||||
background-color: var(--bg-toolbar);
|
||||
background-image: linear-gradient(
|
||||
rgba(34, 197, 94, 0.1),
|
||||
rgba(34, 197, 94, 0.1)
|
||||
);
|
||||
}
|
||||
|
||||
/* Always show eye for the currently viewed file */
|
||||
.file-sidebar-file-item.viewed .file-sidebar-eye-btn {
|
||||
opacity: 1;
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.file-sidebar-eye-btn:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Eye open: shown by default */
|
||||
.file-sidebar-eye-open {
|
||||
display: block !important;
|
||||
}
|
||||
.file-sidebar-eye-closed {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* On hover over a viewed item: swap to eye-closed */
|
||||
.file-sidebar-file-item.viewed:hover .file-sidebar-eye-open {
|
||||
display: none !important;
|
||||
}
|
||||
.file-sidebar-file-item.viewed:hover .file-sidebar-eye-closed {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
/* ---- Date group headers ---- */
|
||||
.file-sidebar-date-group-header {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
padding: 0 10px 2px 10px;
|
||||
margin-top: 8px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.file-sidebar-date-group-header:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* ---- Loading state ---- */
|
||||
.file-sidebar-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px 0;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* ---- Empty state ---- */
|
||||
.file-sidebar-empty {
|
||||
padding: 12px 16px 24px 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.file-sidebar-empty-text {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin: 0 0 2px 0;
|
||||
}
|
||||
|
||||
.file-sidebar-empty-hint {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
opacity: 0.7;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ---- Thumbnail tooltip (hover preview) ---- */
|
||||
.file-sidebar-thumb-tooltip {
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
transform: translateY(-50%);
|
||||
background: var(--bg-surface, #fff);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
|
||||
padding: 4px;
|
||||
width: 150px;
|
||||
pointer-events: none;
|
||||
animation: file-sidebar-thumb-in 0.12s ease;
|
||||
}
|
||||
|
||||
.file-sidebar-thumb-img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@keyframes file-sidebar-thumb-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-50%) scale(0.94);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(-50%) scale(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined";
|
||||
import VisibilityOffOutlinedIcon from "@mui/icons-material/VisibilityOffOutlined";
|
||||
import type { FileId } from "@app/types/file";
|
||||
import { FileDocIcon } from "@app/components/shared/FileDocIcon";
|
||||
import { getFileDocVariant } from "@app/components/shared/filePreview/getFileTypeIcon";
|
||||
import { useIndexedDB } from "@app/contexts/IndexedDBContext";
|
||||
import { useFileManagement } from "@app/contexts/FileContext";
|
||||
import { generateThumbnailForFile } from "@app/utils/thumbnailUtils";
|
||||
import { IMAGE_EXTENSIONS } from "@app/utils/fileUtils";
|
||||
import "@app/components/shared/FileSidebarFileItem.css";
|
||||
|
||||
const THUMBNAIL_SIZE_LIMIT = 100 * 1024 * 1024; // 100MB
|
||||
|
||||
/** Generate + persist a thumbnail for a sidebar file that doesn't have one yet. */
|
||||
function useLazyThumbnail(
|
||||
fileId: FileId,
|
||||
size: number,
|
||||
thumbnailUrl?: string,
|
||||
): string | undefined {
|
||||
const [thumb, setThumb] = useState<string | undefined>(thumbnailUrl);
|
||||
const attempted = useRef(false);
|
||||
const indexedDB = useIndexedDB();
|
||||
const { updateStirlingFileStub } = useFileManagement();
|
||||
|
||||
// Sync prop changes (e.g. thumbnail arrives after TTL bump)
|
||||
useEffect(() => {
|
||||
if (thumbnailUrl) setThumb(thumbnailUrl);
|
||||
}, [thumbnailUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (thumbnailUrl || attempted.current || size >= THUMBNAIL_SIZE_LIMIT)
|
||||
return;
|
||||
attempted.current = true;
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const file = await indexedDB.loadFile(fileId);
|
||||
if (!file || cancelled) return;
|
||||
const thumbnail = await generateThumbnailForFile(file);
|
||||
if (cancelled || !thumbnail) return;
|
||||
setThumb(thumbnail);
|
||||
void indexedDB.updateThumbnail(fileId, thumbnail);
|
||||
updateStirlingFileStub(fileId, { thumbnailUrl: thumbnail });
|
||||
} catch {
|
||||
// non-critical
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [fileId, size, thumbnailUrl, indexedDB, updateStirlingFileStub]);
|
||||
|
||||
return thumb;
|
||||
}
|
||||
|
||||
export function getFileExtension(name: string): string {
|
||||
const parts = name.split(".");
|
||||
return parts.length > 1 ? parts[parts.length - 1].toLowerCase() : "";
|
||||
}
|
||||
|
||||
export type DateGroup =
|
||||
| "today"
|
||||
| "yesterday"
|
||||
| "thisWeek"
|
||||
| "thisMonth"
|
||||
| "older";
|
||||
|
||||
export const DATE_GROUP_ORDER: DateGroup[] = [
|
||||
"today",
|
||||
"yesterday",
|
||||
"thisWeek",
|
||||
"thisMonth",
|
||||
"older",
|
||||
];
|
||||
|
||||
export function getDateGroup(lastModified: number | undefined): DateGroup {
|
||||
if (!lastModified) return "older";
|
||||
const now = new Date();
|
||||
const today = new Date(
|
||||
now.getFullYear(),
|
||||
now.getMonth(),
|
||||
now.getDate(),
|
||||
).getTime();
|
||||
const d = new Date(lastModified);
|
||||
const fileDay = new Date(
|
||||
d.getFullYear(),
|
||||
d.getMonth(),
|
||||
d.getDate(),
|
||||
).getTime();
|
||||
const daysAgo = (today - fileDay) / 86400000;
|
||||
if (daysAgo < 1) return "today";
|
||||
if (daysAgo < 2) return "yesterday";
|
||||
if (daysAgo < 7) return "thisWeek";
|
||||
if (daysAgo < 30) return "thisMonth";
|
||||
return "older";
|
||||
}
|
||||
|
||||
export function formatFileDate(lastModifiedTs: number): string {
|
||||
const lastModified = lastModifiedTs ? new Date(lastModifiedTs) : new Date();
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
const fileDay = new Date(
|
||||
lastModified.getFullYear(),
|
||||
lastModified.getMonth(),
|
||||
lastModified.getDate(),
|
||||
);
|
||||
|
||||
if (fileDay.getTime() === today.getTime()) {
|
||||
return lastModified
|
||||
.toLocaleTimeString("en-US", {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
hour12: true,
|
||||
})
|
||||
.toLowerCase();
|
||||
}
|
||||
if (fileDay.getTime() === yesterday.getTime()) {
|
||||
return "Yesterday";
|
||||
}
|
||||
const weekAgo = new Date(today);
|
||||
weekAgo.setDate(weekAgo.getDate() - 7);
|
||||
if (fileDay >= weekAgo) {
|
||||
return lastModified.toLocaleDateString("en-US", { weekday: "long" });
|
||||
}
|
||||
return lastModified.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
function CheckIcon({
|
||||
className,
|
||||
style,
|
||||
}: {
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
style={style}
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function getSidebarFileIcon(ext: string): React.ReactElement {
|
||||
const cls = "file-sidebar-file-icon file-sidebar-file-icon-hover-hide";
|
||||
return <FileDocIcon className={cls} variant={getFileDocVariant(ext)} />;
|
||||
}
|
||||
|
||||
export interface FileItemProps {
|
||||
fileId: FileId;
|
||||
name: string;
|
||||
size?: number;
|
||||
lastModified?: number;
|
||||
isSelected: boolean;
|
||||
isActive: boolean;
|
||||
isViewedInViewer: boolean;
|
||||
thumbnailUrl?: string;
|
||||
onClick: (fileId: FileId) => void;
|
||||
onEyeClick: (fileId: FileId, e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
export function FileItem({
|
||||
fileId,
|
||||
name,
|
||||
size,
|
||||
lastModified,
|
||||
isSelected,
|
||||
isActive,
|
||||
isViewedInViewer,
|
||||
thumbnailUrl,
|
||||
onClick,
|
||||
onEyeClick,
|
||||
}: FileItemProps) {
|
||||
const ext = getFileExtension(name);
|
||||
const dateLabel = lastModified ? formatFileDate(lastModified) : "";
|
||||
const typeLabel = ext ? ext.toUpperCase() : "File";
|
||||
|
||||
// Only use raster thumbnails for PDFs and images — everything else uses scalable SVG icons
|
||||
const useRasterThumb = ext === "pdf" || IMAGE_EXTENSIONS.has(ext);
|
||||
const resolvedThumbnail = useLazyThumbnail(
|
||||
fileId,
|
||||
size ?? 0,
|
||||
useRasterThumb ? thumbnailUrl : undefined,
|
||||
);
|
||||
|
||||
const itemRef = useRef<HTMLDivElement>(null);
|
||||
const [hoverRect, setHoverRect] = useState<DOMRect | null>(null);
|
||||
|
||||
const handleMouseEnter = useCallback(() => {
|
||||
setHoverRect(itemRef.current?.getBoundingClientRect() ?? null);
|
||||
}, []);
|
||||
|
||||
const handleMouseLeave = useCallback(() => setHoverRect(null), []);
|
||||
|
||||
// Reactive: tooltip appears as soon as both hover rect and thumbnail are ready
|
||||
const thumbPos =
|
||||
hoverRect && resolvedThumbnail
|
||||
? {
|
||||
top: hoverRect.top + hoverRect.height / 2,
|
||||
left: hoverRect.right + 10,
|
||||
}
|
||||
: null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={itemRef}
|
||||
className={`file-sidebar-file-item${isSelected ? " selected" : ""}${isActive ? " active" : ""}${isViewedInViewer ? " viewed" : ""}`}
|
||||
onClick={() => onClick(fileId)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => e.key === "Enter" && onClick(fileId)}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<div className="file-sidebar-file-icon-wrapper">
|
||||
{isSelected ? (
|
||||
<div className="file-sidebar-file-check">
|
||||
<CheckIcon className="file-sidebar-check-svg" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="file-sidebar-file-checkbox-hover" />
|
||||
{getSidebarFileIcon(ext)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="file-sidebar-file-info">
|
||||
<span
|
||||
className={`file-sidebar-file-name${isSelected ? " selected" : ""}`}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
<span className="file-sidebar-file-meta">
|
||||
{dateLabel}
|
||||
{dateLabel && typeLabel ? " · " : ""}
|
||||
{typeLabel}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
className="file-sidebar-eye-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEyeClick(fileId, e);
|
||||
}}
|
||||
tabIndex={-1}
|
||||
type="button"
|
||||
aria-label={isViewedInViewer ? "Close viewer" : "Open in viewer"}
|
||||
>
|
||||
<VisibilityOutlinedIcon
|
||||
className="file-sidebar-eye-open"
|
||||
sx={{ fontSize: "1.1rem" }}
|
||||
/>
|
||||
<VisibilityOffOutlinedIcon
|
||||
className="file-sidebar-eye-closed"
|
||||
sx={{ fontSize: "1.1rem" }}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{useRasterThumb &&
|
||||
thumbPos &&
|
||||
resolvedThumbnail &&
|
||||
createPortal(
|
||||
<div
|
||||
className="file-sidebar-thumb-tooltip"
|
||||
style={{ top: thumbPos.top, left: thumbPos.left }}
|
||||
>
|
||||
<img
|
||||
src={resolvedThumbnail}
|
||||
alt=""
|
||||
className="file-sidebar-thumb-img"
|
||||
/>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ export interface HoverAction {
|
||||
disabled?: boolean;
|
||||
color?: string;
|
||||
hidden?: boolean;
|
||||
dataTour?: string;
|
||||
}
|
||||
|
||||
interface HoverActionMenuProps {
|
||||
@@ -18,6 +19,7 @@ interface HoverActionMenuProps {
|
||||
actions: HoverAction[];
|
||||
position?: "inside" | "outside";
|
||||
className?: string;
|
||||
visibility?: "state" | "cssHover";
|
||||
}
|
||||
|
||||
const HoverActionMenu: React.FC<HoverActionMenuProps> = ({
|
||||
@@ -25,6 +27,7 @@ const HoverActionMenu: React.FC<HoverActionMenuProps> = ({
|
||||
actions,
|
||||
position = "inside",
|
||||
className = "",
|
||||
visibility = "state",
|
||||
}) => {
|
||||
const visibleActions = actions.filter((action) => !action.hidden);
|
||||
|
||||
@@ -32,10 +35,23 @@ const HoverActionMenu: React.FC<HoverActionMenuProps> = ({
|
||||
return null;
|
||||
}
|
||||
|
||||
const style: React.CSSProperties = { zIndex: Z_INDEX_HOVER_ACTION_MENU };
|
||||
if (visibility === "state") {
|
||||
style.opacity = show ? 1 : 0;
|
||||
style.pointerEvents = show ? "auto" : "none";
|
||||
} else if (show) {
|
||||
// Force visible (e.g. mobile) even when CSS-hover mode is active.
|
||||
style.opacity = 1;
|
||||
style.pointerEvents = "auto";
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${styles.hoverMenu} ${position === "outside" ? styles.outside : styles.inside} ${className}`}
|
||||
style={{ opacity: show ? 1 : 0, zIndex: Z_INDEX_HOVER_ACTION_MENU }}
|
||||
style={style}
|
||||
data-hover-action-menu="true"
|
||||
data-hover-action-menu-mode={visibility}
|
||||
data-force-visible={show ? "true" : "false"}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onMouseUp={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
@@ -48,7 +64,8 @@ const HoverActionMenu: React.FC<HoverActionMenuProps> = ({
|
||||
disabled={action.disabled}
|
||||
onClick={action.onClick}
|
||||
c={action.color}
|
||||
style={{ color: action.color || "var(--right-rail-icon)" }}
|
||||
style={{ color: action.color || "var(--text-secondary)" }}
|
||||
data-tour={action.dataTour}
|
||||
>
|
||||
{action.icon}
|
||||
</ActionIcon>
|
||||
|
||||
@@ -50,6 +50,7 @@ interface InfoBannerProps {
|
||||
buttonVariant?: "light" | "filled" | "white" | "outline" | "subtle";
|
||||
minHeight?: number | string;
|
||||
closeIconColor?: string;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,6 +76,7 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
|
||||
buttonVariant = "light",
|
||||
minHeight = 56,
|
||||
closeIconColor,
|
||||
compact = false,
|
||||
}) => {
|
||||
if (!show) {
|
||||
return null;
|
||||
@@ -85,13 +87,21 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
|
||||
onDismiss?.();
|
||||
};
|
||||
|
||||
const iconSize = compact ? "1rem" : "1.2rem";
|
||||
const textSize = compact ? "xs" : "sm";
|
||||
const buttonSize = compact ? "xs" : "xs";
|
||||
|
||||
return (
|
||||
<Paper
|
||||
p="sm"
|
||||
p={compact ? "xs" : "sm"}
|
||||
radius={0}
|
||||
style={{
|
||||
background: background ?? toneStyle.background,
|
||||
borderBottom: `1px solid ${borderColor ?? toneStyle.border}`,
|
||||
border: "none",
|
||||
borderBottom:
|
||||
borderColor === "transparent"
|
||||
? "none"
|
||||
: `1px solid ${borderColor ?? toneStyle.border}`,
|
||||
minHeight,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
@@ -105,22 +115,22 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
<Group
|
||||
gap="sm"
|
||||
gap={compact ? "xs" : "sm"}
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
>
|
||||
<LocalIcon
|
||||
icon={icon}
|
||||
width="1.2rem"
|
||||
height="1.2rem"
|
||||
width={iconSize}
|
||||
height={iconSize}
|
||||
style={{ color: iconColor ?? toneStyle.icon, flexShrink: 0 }}
|
||||
/>
|
||||
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Stack gap={compact ? 1 : 2} style={{ flex: 1, minWidth: 0 }}>
|
||||
{title && (
|
||||
<Text
|
||||
fw={600}
|
||||
size="sm"
|
||||
size={textSize}
|
||||
style={{ color: textColor ?? toneStyle.text }}
|
||||
>
|
||||
{title}
|
||||
@@ -128,9 +138,9 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
|
||||
)}
|
||||
<Text
|
||||
fw={title ? 400 : 500}
|
||||
size="sm"
|
||||
size={textSize}
|
||||
style={{ color: textColor ?? toneStyle.text }}
|
||||
lineClamp={2}
|
||||
lineClamp={compact ? 1 : 2}
|
||||
>
|
||||
{message}
|
||||
</Text>
|
||||
@@ -141,11 +151,15 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
|
||||
<Button
|
||||
variant={buttonVariant}
|
||||
color={buttonColor ?? toneStyle.buttonColor}
|
||||
size="xs"
|
||||
size={buttonSize}
|
||||
onClick={onButtonClick}
|
||||
loading={loading}
|
||||
leftSection={
|
||||
<LocalIcon icon={buttonIcon} width="0.9rem" height="0.9rem" />
|
||||
<LocalIcon
|
||||
icon={buttonIcon}
|
||||
width={compact ? "0.75rem" : "0.9rem"}
|
||||
height={compact ? "0.75rem" : "0.9rem"}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{buttonText}
|
||||
@@ -160,7 +174,11 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
|
||||
aria-label="Dismiss"
|
||||
style={closeIconColor ? { color: closeIconColor } : undefined}
|
||||
>
|
||||
<LocalIcon icon="close-rounded" width="1rem" height="1rem" />
|
||||
<LocalIcon
|
||||
icon="close-rounded"
|
||||
width={compact ? "0.85rem" : "1rem"}
|
||||
height={compact ? "0.85rem" : "1rem"}
|
||||
/>
|
||||
</ActionIcon>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
@@ -5,14 +5,11 @@
|
||||
|
||||
/* ── Hero text ───────────────────────────────────────────── */
|
||||
.landing-title {
|
||||
margin: 0;
|
||||
margin-top: 1.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
text-align: center;
|
||||
font-size: 2.125rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--text-primary);
|
||||
display: block;
|
||||
margin: 1.75rem auto 0.5rem;
|
||||
height: 3rem;
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.landing-subtitle {
|
||||
|
||||
@@ -8,6 +8,7 @@ import MobileUploadModal from "@app/components/shared/MobileUploadModal";
|
||||
import { openFilesFromDisk } from "@app/services/openFilesFromDisk";
|
||||
import { LandingDocumentStack } from "@app/components/shared/LandingDocumentStack";
|
||||
import { LandingActions } from "@app/components/shared/LandingActions";
|
||||
import { Wordmark } from "@app/components/shared/Wordmark";
|
||||
import "@app/components/shared/LandingPage.css";
|
||||
|
||||
const LandingPage = () => {
|
||||
@@ -79,9 +80,10 @@ const LandingPage = () => {
|
||||
>
|
||||
<LandingDocumentStack />
|
||||
|
||||
<h1 className="landing-title">
|
||||
{t("landing.heroTitle", "Stirling PDF")}
|
||||
</h1>
|
||||
<Wordmark
|
||||
alt={t("landing.heroTitle", "Stirling PDF")}
|
||||
className="landing-title"
|
||||
/>
|
||||
<p className="landing-subtitle">
|
||||
{t(
|
||||
"landing.heroSubtitle",
|
||||
|
||||
@@ -91,10 +91,10 @@ const LanguageItem: React.FC<LanguageItemProps> = ({
|
||||
? "light-dark(var(--mantine-color-blue-1), var(--mantine-color-blue-8))"
|
||||
: "transparent",
|
||||
color: disabled
|
||||
? "light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3))"
|
||||
? "var(--text-muted)"
|
||||
: isSelected
|
||||
? "light-dark(var(--mantine-color-blue-9), var(--mantine-color-white))"
|
||||
: "light-dark(var(--mantine-color-gray-7), var(--mantine-color-white))",
|
||||
? "light-dark(var(--mantine-color-blue-9), var(--mantine-color-blue-3))"
|
||||
: "var(--text-primary)",
|
||||
transition: "all 0.12s cubic-bezier(0.25, 0.46, 0.45, 0.94)",
|
||||
cursor: disabled ? "not-allowed" : "pointer",
|
||||
"&:hover": !disabled
|
||||
@@ -274,11 +274,11 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
data-testid="language-selector-button"
|
||||
title={!opened && tooltip ? tooltip : undefined}
|
||||
styles={{
|
||||
root: {
|
||||
color: "var(--right-rail-icon)",
|
||||
color: "var(--text-secondary)",
|
||||
"&:hover": {
|
||||
backgroundColor:
|
||||
"light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5))",
|
||||
@@ -292,14 +292,14 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
data-testid="language-selector-button"
|
||||
leftSection={
|
||||
<LocalIcon icon="language" width="1.5rem" height="1.5rem" />
|
||||
}
|
||||
styles={{
|
||||
root: {
|
||||
border: "none",
|
||||
color:
|
||||
"light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-1))",
|
||||
color: "var(--text-primary)",
|
||||
transition:
|
||||
"background-color 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94)",
|
||||
"&:hover": {
|
||||
|
||||
@@ -33,11 +33,11 @@ const MultiSelectControls = ({
|
||||
>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm">
|
||||
{selectedCount} {t("fileManager.filesSelected", "files selected")}
|
||||
{selectedCount} {t("fileManager.filesSelected", "files")}
|
||||
</Text>
|
||||
<Group>
|
||||
<Button size="xs" variant="light" onClick={onClearSelection}>
|
||||
{t("fileManager.clearSelection", "Clear Selection")}
|
||||
{t("fileManager.clearSelection", "Clear files")}
|
||||
</Button>
|
||||
|
||||
{onAddToUpload && (
|
||||
|
||||
@@ -197,7 +197,7 @@ export const PageEditorFileDropdown: React.FC<PageEditorFileDropdownProps> = ({
|
||||
<Menu.Dropdown
|
||||
className="ph-no-capture"
|
||||
style={{
|
||||
backgroundColor: "var(--right-rail-bg)",
|
||||
backgroundColor: "var(--bg-file-manager)",
|
||||
border: "1px solid var(--border-subtle)",
|
||||
borderRadius: "8px",
|
||||
boxShadow: "0 2px 8px rgba(0, 0, 0, 0.15)",
|
||||
|
||||
@@ -1,375 +0,0 @@
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { ActionIcon, Divider } from "@mantine/core";
|
||||
import "@app/components/shared/rightRail/RightRail.css";
|
||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||
import { useRightRail } from "@app/contexts/RightRailContext";
|
||||
import {
|
||||
useFileState,
|
||||
useFileSelection,
|
||||
useFileActions,
|
||||
} from "@app/contexts/FileContext";
|
||||
import { isStirlingFile } from "@app/types/fileContext";
|
||||
import { useNavigationState } from "@app/contexts/NavigationContext";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology";
|
||||
import { useFileActionIcons } from "@app/hooks/useFileActionIcons";
|
||||
|
||||
import LanguageSelector from "@app/components/shared/LanguageSelector";
|
||||
import { useRainbowThemeContext } from "@app/components/shared/RainbowThemeProvider";
|
||||
import { Tooltip } from "@app/components/shared/Tooltip";
|
||||
import { ViewerContext } from "@app/contexts/ViewerContext";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import { RightRailFooterExtensions } from "@app/components/rightRail/RightRailFooterExtensions";
|
||||
import DarkModeIcon from "@mui/icons-material/DarkMode";
|
||||
import LightModeIcon from "@mui/icons-material/LightMode";
|
||||
|
||||
import { useSidebarContext } from "@app/contexts/SidebarContext";
|
||||
import {
|
||||
RightRailButtonConfig,
|
||||
RightRailRenderContext,
|
||||
RightRailSection,
|
||||
} from "@app/types/rightRail";
|
||||
import { useRightRailTooltipSide } from "@app/hooks/useRightRailTooltipSide";
|
||||
import { downloadFile } from "@app/services/downloadService";
|
||||
|
||||
const SECTION_ORDER: RightRailSection[] = ["top", "middle", "bottom"];
|
||||
|
||||
function renderWithTooltip(
|
||||
node: React.ReactNode,
|
||||
tooltip: React.ReactNode | undefined,
|
||||
position: "left" | "right",
|
||||
offset: number,
|
||||
) {
|
||||
if (!tooltip) return node;
|
||||
|
||||
const portalTarget =
|
||||
typeof document !== "undefined" ? document.body : undefined;
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
content={tooltip}
|
||||
position={position}
|
||||
offset={offset}
|
||||
arrow
|
||||
portalTarget={portalTarget}
|
||||
>
|
||||
<div className="right-rail-tooltip-wrapper">{node}</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RightRail() {
|
||||
const { sidebarRefs } = useSidebarContext();
|
||||
const { position: tooltipPosition, offset: tooltipOffset } =
|
||||
useRightRailTooltipSide(sidebarRefs);
|
||||
const { t } = useTranslation();
|
||||
const terminology = useFileActionTerminology();
|
||||
const icons = useFileActionIcons();
|
||||
const viewerContext = React.useContext(ViewerContext);
|
||||
const { toggleTheme, themeMode } = useRainbowThemeContext();
|
||||
const { buttons, actions, allButtonsDisabled } = useRightRail();
|
||||
|
||||
const { pageEditorFunctions, toolPanelMode, leftPanelView } =
|
||||
useToolWorkflow();
|
||||
const disableForFullscreen =
|
||||
toolPanelMode === "fullscreen" && leftPanelView === "toolPicker";
|
||||
|
||||
const { workbench: currentView } = useNavigationState();
|
||||
|
||||
const { selectors } = useFileState();
|
||||
const { selectedFiles, selectedFileIds } = useFileSelection();
|
||||
const { actions: fileActions } = useFileActions();
|
||||
const activeFiles = selectors.getFiles();
|
||||
const pageEditorTotalPages = pageEditorFunctions?.totalPages ?? 0;
|
||||
const pageEditorSelectedCount =
|
||||
pageEditorFunctions?.selectedPageIds?.length ?? 0;
|
||||
|
||||
const totalItems = useMemo(() => {
|
||||
if (currentView === "pageEditor") return pageEditorTotalPages;
|
||||
return activeFiles.length;
|
||||
}, [currentView, pageEditorTotalPages, activeFiles.length]);
|
||||
|
||||
const selectedCount = useMemo(() => {
|
||||
if (currentView === "pageEditor") {
|
||||
return pageEditorSelectedCount;
|
||||
}
|
||||
return selectedFileIds.length;
|
||||
}, [currentView, pageEditorSelectedCount, selectedFileIds.length]);
|
||||
|
||||
const sectionsWithButtons = useMemo(() => {
|
||||
return SECTION_ORDER.map((section) => {
|
||||
const sectionButtons = buttons.filter(
|
||||
(btn) => (btn.section ?? "top") === section && (btn.visible ?? true),
|
||||
);
|
||||
return { section, buttons: sectionButtons };
|
||||
}).filter((entry) => entry.buttons.length > 0);
|
||||
}, [buttons]);
|
||||
|
||||
const renderButton = useCallback(
|
||||
(btn: RightRailButtonConfig) => {
|
||||
const action = actions[btn.id];
|
||||
const disabled = Boolean(
|
||||
btn.disabled || allButtonsDisabled || disableForFullscreen,
|
||||
);
|
||||
const isActive = Boolean(btn.active);
|
||||
|
||||
const triggerAction = () => {
|
||||
if (!disabled) action?.();
|
||||
};
|
||||
|
||||
if (btn.render) {
|
||||
const context: RightRailRenderContext = {
|
||||
id: btn.id,
|
||||
disabled,
|
||||
allButtonsDisabled,
|
||||
action,
|
||||
triggerAction,
|
||||
active: isActive,
|
||||
};
|
||||
return btn.render(context) ?? null;
|
||||
}
|
||||
|
||||
if (!btn.icon) return null;
|
||||
|
||||
const ariaLabel =
|
||||
btn.ariaLabel ||
|
||||
(typeof btn.tooltip === "string" ? (btn.tooltip as string) : undefined);
|
||||
const className = ["right-rail-icon", btn.className]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
const buttonNode = (
|
||||
<ActionIcon
|
||||
variant={isActive ? "filled" : "subtle"}
|
||||
color={isActive ? "blue" : undefined}
|
||||
radius="md"
|
||||
className={className}
|
||||
onClick={triggerAction}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel}
|
||||
aria-pressed={isActive ? true : undefined}
|
||||
data-active={isActive ? "true" : "false"}
|
||||
>
|
||||
{btn.icon}
|
||||
</ActionIcon>
|
||||
);
|
||||
|
||||
return renderWithTooltip(
|
||||
buttonNode,
|
||||
btn.tooltip,
|
||||
tooltipPosition,
|
||||
tooltipOffset,
|
||||
);
|
||||
},
|
||||
[
|
||||
actions,
|
||||
allButtonsDisabled,
|
||||
disableForFullscreen,
|
||||
tooltipPosition,
|
||||
tooltipOffset,
|
||||
],
|
||||
);
|
||||
|
||||
const handleExportAll = useCallback(
|
||||
async (forceNewFile = false) => {
|
||||
if (currentView === "viewer") {
|
||||
const buffer = await viewerContext?.exportActions?.saveAsCopy?.();
|
||||
if (!buffer) return;
|
||||
const fileToExport =
|
||||
selectedFiles.length > 0 ? selectedFiles[0] : activeFiles[0];
|
||||
if (!fileToExport) return;
|
||||
const stub = isStirlingFile(fileToExport)
|
||||
? selectors.getStirlingFileStub(fileToExport.fileId)
|
||||
: undefined;
|
||||
try {
|
||||
const result = await downloadFile({
|
||||
data: new Blob([buffer], { type: "application/pdf" }),
|
||||
filename: fileToExport.name,
|
||||
localPath: forceNewFile ? undefined : stub?.localFilePath,
|
||||
});
|
||||
if (!forceNewFile && !result.cancelled && stub && result.savedPath) {
|
||||
fileActions.updateStirlingFileStub(stub.id, {
|
||||
localFilePath: stub.localFilePath ?? result.savedPath,
|
||||
isDirty: false,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[RightRail] Failed to export viewer file:", error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentView === "pageEditor") {
|
||||
pageEditorFunctions?.onExportAll?.();
|
||||
return;
|
||||
}
|
||||
|
||||
const filesToExport =
|
||||
selectedFiles.length > 0 ? selectedFiles : activeFiles;
|
||||
|
||||
if (filesToExport.length > 0) {
|
||||
for (const file of filesToExport) {
|
||||
const stub = isStirlingFile(file)
|
||||
? selectors.getStirlingFileStub(file.fileId)
|
||||
: undefined;
|
||||
try {
|
||||
const result = await downloadFile({
|
||||
data: file,
|
||||
filename: file.name,
|
||||
localPath: forceNewFile ? undefined : stub?.localFilePath,
|
||||
});
|
||||
if (result.cancelled) continue;
|
||||
if (!forceNewFile && stub && result.savedPath) {
|
||||
fileActions.updateStirlingFileStub(stub.id, {
|
||||
localFilePath: stub.localFilePath ?? result.savedPath,
|
||||
isDirty: false,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[RightRail] Failed to export file:",
|
||||
file.name,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
currentView,
|
||||
selectedFiles,
|
||||
activeFiles,
|
||||
pageEditorFunctions,
|
||||
viewerContext,
|
||||
selectors,
|
||||
fileActions,
|
||||
],
|
||||
);
|
||||
|
||||
const downloadTooltip = useMemo(() => {
|
||||
if (currentView === "pageEditor") {
|
||||
return t("rightRail.exportAll", "Export PDF");
|
||||
}
|
||||
if (currentView === "viewer") {
|
||||
return terminology.download;
|
||||
}
|
||||
if (selectedCount > 0) {
|
||||
return terminology.downloadSelected;
|
||||
}
|
||||
return terminology.downloadAll;
|
||||
}, [currentView, selectedCount, t, terminology]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={sidebarRefs.rightRailRef}
|
||||
className="right-rail"
|
||||
data-sidebar="right-rail"
|
||||
>
|
||||
<div className="right-rail-inner">
|
||||
{sectionsWithButtons.map(({ section, buttons: sectionButtons }) => (
|
||||
<React.Fragment key={section}>
|
||||
<div className="right-rail-section" data-tour="right-rail-controls">
|
||||
{sectionButtons.map((btn, index) => {
|
||||
const content = renderButton(btn);
|
||||
if (!content) return null;
|
||||
return (
|
||||
<div
|
||||
key={btn.id}
|
||||
className="right-rail-button-wrapper"
|
||||
style={{ animationDelay: `${index * 50}ms` }}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Divider className="right-rail-divider" />
|
||||
</React.Fragment>
|
||||
))}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: "1rem",
|
||||
}}
|
||||
data-tour="right-rail-settings"
|
||||
>
|
||||
{renderWithTooltip(
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={toggleTheme}
|
||||
>
|
||||
{themeMode === "dark" ? (
|
||||
<LightModeIcon sx={{ fontSize: "1.5rem" }} />
|
||||
) : (
|
||||
<DarkModeIcon sx={{ fontSize: "1.5rem" }} />
|
||||
)}
|
||||
</ActionIcon>,
|
||||
t("rightRail.toggleTheme", "Toggle Theme"),
|
||||
tooltipPosition,
|
||||
tooltipOffset,
|
||||
)}
|
||||
|
||||
<LanguageSelector
|
||||
position="left-start"
|
||||
offset={6}
|
||||
compact
|
||||
tooltip={t("rightRail.language", "Language")}
|
||||
/>
|
||||
|
||||
{renderWithTooltip(
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={() => handleExportAll()}
|
||||
disabled={
|
||||
disableForFullscreen ||
|
||||
(currentView !== "viewer" &&
|
||||
(totalItems === 0 || allButtonsDisabled))
|
||||
}
|
||||
>
|
||||
<LocalIcon
|
||||
icon={icons.downloadIconName}
|
||||
width="1.5rem"
|
||||
height="1.5rem"
|
||||
/>
|
||||
</ActionIcon>,
|
||||
downloadTooltip,
|
||||
tooltipPosition,
|
||||
tooltipOffset,
|
||||
)}
|
||||
{icons.saveAsIconName &&
|
||||
renderWithTooltip(
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={() => handleExportAll(true)}
|
||||
disabled={
|
||||
disableForFullscreen ||
|
||||
(currentView !== "viewer" &&
|
||||
(totalItems === 0 || allButtonsDisabled))
|
||||
}
|
||||
>
|
||||
<LocalIcon
|
||||
icon={icons.saveAsIconName}
|
||||
width="1.5rem"
|
||||
height="1.5rem"
|
||||
/>
|
||||
</ActionIcon>,
|
||||
t("rightRail.saveAs", "Save As"),
|
||||
tooltipPosition,
|
||||
tooltipOffset,
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="right-rail-spacer" />
|
||||
|
||||
<RightRailFooterExtensions className="right-rail-footer" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -36,6 +36,8 @@ export interface TextInputProps {
|
||||
"aria-label"?: string;
|
||||
/** Focus event handler */
|
||||
onFocus?: () => void;
|
||||
/** Allow the icon to receive pointer events (e.g. when icon is a clickable button) */
|
||||
iconClickable?: boolean;
|
||||
}
|
||||
|
||||
export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(
|
||||
@@ -56,6 +58,7 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(
|
||||
readOnly = false,
|
||||
"aria-label": ariaLabel,
|
||||
onFocus,
|
||||
iconClickable = false,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
@@ -76,7 +79,10 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(
|
||||
{icon && (
|
||||
<span
|
||||
className={styles.icon}
|
||||
style={{ color: "var(--search-text-and-icon-color)" }}
|
||||
style={{
|
||||
pointerEvents: iconClickable ? "auto" : "none",
|
||||
left: "12px",
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
@@ -96,8 +102,6 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(
|
||||
aria-label={ariaLabel}
|
||||
onFocus={onFocus}
|
||||
style={{
|
||||
backgroundColor: "var(--input-bg)",
|
||||
color: "var(--search-text-and-icon-color)",
|
||||
paddingRight: shouldShowClearButton ? "40px" : "12px",
|
||||
paddingLeft: icon ? "40px" : "12px",
|
||||
}}
|
||||
@@ -108,7 +112,6 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(
|
||||
type="button"
|
||||
className={styles.clearButton}
|
||||
onClick={handleClear}
|
||||
style={{ color: "var(--search-text-and-icon-color)" }}
|
||||
aria-label="Clear input"
|
||||
>
|
||||
<LocalIcon icon="close-rounded" width="1.25rem" height="1.25rem" />
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { ActionIcon, Slider } from "@mantine/core";
|
||||
import { useViewer } from "@app/contexts/ViewerContext";
|
||||
import { useNavigationState } from "@app/contexts/NavigationContext";
|
||||
import ZoomInIcon from "@mui/icons-material/ZoomIn";
|
||||
import ZoomOutIcon from "@mui/icons-material/ZoomOut";
|
||||
|
||||
/**
|
||||
* Compact zoom controls rendered inline in the WorkbenchBar when the current workbench is "viewer".
|
||||
*/
|
||||
export function ViewerInlineControls() {
|
||||
const { workbench } = useNavigationState();
|
||||
const viewer = useViewer();
|
||||
|
||||
const [zoomPercent, setZoomPercent] = useState(100);
|
||||
|
||||
useEffect(() => {
|
||||
const zoomState = viewer.getZoomState();
|
||||
setZoomPercent(zoomState.zoomPercent || 100);
|
||||
|
||||
const unregister = viewer.registerImmediateZoomUpdate((pct) => {
|
||||
setZoomPercent(pct);
|
||||
});
|
||||
return () => unregister?.();
|
||||
}, [viewer.registerImmediateZoomUpdate]);
|
||||
|
||||
if (workbench !== "viewer") return null;
|
||||
|
||||
const sliderValue = Math.min(Math.max(zoomPercent, 20), 500);
|
||||
|
||||
return (
|
||||
<div className="viewer-inline-controls">
|
||||
{/* Divider */}
|
||||
<div className="workbench-bar-divider" />
|
||||
|
||||
{/* Zoom controls */}
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="workbench-bar-action-icon"
|
||||
onClick={() => viewer.zoomActions.zoomOut()}
|
||||
aria-label="Zoom out"
|
||||
>
|
||||
<ZoomOutIcon sx={{ fontSize: "1rem" }} />
|
||||
</ActionIcon>
|
||||
|
||||
<div className="viewer-inline-controls__slider-wrap">
|
||||
<Slider
|
||||
value={sliderValue}
|
||||
min={20}
|
||||
max={500}
|
||||
step={5}
|
||||
onChange={(val) => {
|
||||
viewer.zoomActions.setZoomLevel?.(val / 100);
|
||||
}}
|
||||
size="xs"
|
||||
styles={{
|
||||
root: { width: "6rem" },
|
||||
thumb: { width: 14, height: 14 },
|
||||
track: { height: 3 },
|
||||
}}
|
||||
label={null}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="workbench-bar-action-icon"
|
||||
onClick={() => viewer.zoomActions.zoomIn()}
|
||||
aria-label="Zoom in"
|
||||
>
|
||||
<ZoomInIcon sx={{ fontSize: "1rem" }} />
|
||||
</ActionIcon>
|
||||
|
||||
<span className="viewer-inline-controls__zoom-pct">
|
||||
{Math.round(zoomPercent)}%
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
/* ========== WORKBENCH BAR ========== */
|
||||
/* Horizontal toolbar at the top of the workbench area. */
|
||||
|
||||
.workbench-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
align-content: flex-start;
|
||||
min-height: 48px;
|
||||
padding: 0 8px;
|
||||
background-color: var(--bg-toolbar);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
flex-shrink: 0;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
/* ---- View switcher (left) ---- */
|
||||
.workbench-bar-views {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
order: 1;
|
||||
height: 48px;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.workbench-bar-view-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 0.15s ease,
|
||||
color 0.15s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.workbench-bar-view-btn:hover {
|
||||
background-color: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.workbench-bar-view-btn.active {
|
||||
background-color: var(--mantine-color-blue-light, rgba(59, 130, 246, 0.12));
|
||||
color: var(--mantine-color-blue-6, #3b82f6);
|
||||
}
|
||||
|
||||
.workbench-bar-view-btn svg {
|
||||
font-size: 16px !important;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.workbench-bar-view-label {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* ---- Center: tool buttons ---- */
|
||||
|
||||
/* Single-row: center sits between views and globals */
|
||||
.workbench-bar-center {
|
||||
order: 2;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
height: 48px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Two-row: center drops below views+globals */
|
||||
.workbench-bar[data-wrapped="true"] .workbench-bar-center {
|
||||
order: 3;
|
||||
flex: 0 0 100%;
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
padding: 4px 0;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
/* ---- Right: global buttons (theme / language / download) ---- */
|
||||
.workbench-bar-globals {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
order: 3;
|
||||
height: 48px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* In two-row mode globals moves to row 1 right side */
|
||||
.workbench-bar[data-wrapped="true"] .workbench-bar-globals {
|
||||
order: 2;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Shared action icon style — applies to both center and global buttons */
|
||||
.workbench-bar-action-icon {
|
||||
color: var(--text-secondary) !important;
|
||||
width: 28px !important;
|
||||
height: 28px !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.workbench-bar-action-icon svg,
|
||||
.workbench-bar-action-icon img {
|
||||
color: inherit !important;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.workbench-bar-action-icon:hover {
|
||||
color: var(--text-primary) !important;
|
||||
background-color: var(--bg-hover) !important;
|
||||
}
|
||||
|
||||
.workbench-bar-action-icon[data-variant="filled"] {
|
||||
background: var(--mantine-color-blue-6, #3b82f6) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.workbench-bar-action-icon[data-variant="filled"]:hover {
|
||||
background: var(--mantine-color-blue-7, #2563eb) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.workbench-bar-action-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.workbench-bar-tooltip-wrapper {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.workbench-bar-divider {
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
background-color: var(--border-subtle);
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
/* Extra breathing room around the export/close separator */
|
||||
.workbench-bar-globals-sep {
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
/* ---- Inline viewer controls (page nav + zoom) ---- */
|
||||
.viewer-inline-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.viewer-inline-controls__page {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
min-width: 3rem;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.viewer-inline-controls__slider-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.viewer-inline-controls__zoom-pct {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
min-width: 2.5rem;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,490 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { ActionIcon } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useWorkbenchBar } from "@app/contexts/WorkbenchBarContext";
|
||||
import {
|
||||
useFileState,
|
||||
useFileSelection,
|
||||
useFileActions,
|
||||
} from "@app/contexts/FileContext";
|
||||
import { isStirlingFile } from "@app/types/fileContext";
|
||||
import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology";
|
||||
import { useFileActionIcons } from "@app/hooks/useFileActionIcons";
|
||||
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 { downloadFile } from "@app/services/downloadService";
|
||||
import {
|
||||
WorkbenchBarButtonConfig,
|
||||
WorkbenchBarRenderContext,
|
||||
WorkbenchBarSection,
|
||||
} from "@app/types/workbenchBar";
|
||||
import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile";
|
||||
import FolderIcon from "@mui/icons-material/Folder";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import PrintIcon from "@mui/icons-material/Print";
|
||||
import "@app/components/shared/WorkbenchBar.css";
|
||||
|
||||
const SECTION_ORDER: WorkbenchBarSection[] = ["top", "middle", "bottom"];
|
||||
|
||||
interface ViewOption {
|
||||
value: WorkbenchType;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
interface WorkbenchBarProps {
|
||||
currentView: WorkbenchType;
|
||||
setCurrentView: (view: WorkbenchType) => void;
|
||||
hasFiles: boolean;
|
||||
}
|
||||
|
||||
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,
|
||||
hasFiles,
|
||||
}: WorkbenchBarProps) {
|
||||
const { t } = useTranslation();
|
||||
const { buttons, actions, allButtonsDisabled } = useWorkbenchBar();
|
||||
const {
|
||||
pageEditorFunctions,
|
||||
toolPanelMode,
|
||||
leftPanelView,
|
||||
customWorkbenchViews,
|
||||
} = useToolWorkflow();
|
||||
const { selectedTool } = useNavigationState();
|
||||
const isCustomView = !isBaseWorkbench(currentView);
|
||||
const disableForFullscreen =
|
||||
toolPanelMode === "fullscreen" && leftPanelView === "toolPicker";
|
||||
const terminology = useFileActionTerminology();
|
||||
const icons = useFileActionIcons();
|
||||
const viewerContext = React.useContext(ViewerContext);
|
||||
|
||||
const { selectors } = useFileState();
|
||||
const { selectedFiles, selectedFileIds } = useFileSelection();
|
||||
const { actions: fileActions } = useFileActions();
|
||||
const activeFiles = selectors.getFiles();
|
||||
const { activeFileId, setActiveFileId } = useViewer();
|
||||
const pageEditorTotalPages = pageEditorFunctions?.totalPages ?? 0;
|
||||
const pageEditorSelectedCount =
|
||||
pageEditorFunctions?.selectedPageIds?.length ?? 0;
|
||||
|
||||
const totalItems = useMemo(() => {
|
||||
if (currentView === "pageEditor") return pageEditorTotalPages;
|
||||
return activeFiles.length;
|
||||
}, [currentView, pageEditorTotalPages, activeFiles.length]);
|
||||
|
||||
const selectedCount = useMemo(() => {
|
||||
if (currentView === "pageEditor") return pageEditorSelectedCount;
|
||||
return selectedFileIds.length;
|
||||
}, [currentView, pageEditorSelectedCount, selectedFileIds.length]);
|
||||
|
||||
const sectionsWithButtons = useMemo(() => {
|
||||
return SECTION_ORDER.map((section) => {
|
||||
const sectionButtons = buttons.filter(
|
||||
(btn) => (btn.section ?? "top") === section && (btn.visible ?? true),
|
||||
);
|
||||
return { section, buttons: sectionButtons };
|
||||
}).filter((entry) => entry.buttons.length > 0);
|
||||
}, [buttons]);
|
||||
|
||||
const handleExportAll = useCallback(
|
||||
async (forceNewFile = false) => {
|
||||
if (currentView === "viewer") {
|
||||
const buffer = await viewerContext?.exportActions?.saveAsCopy?.();
|
||||
if (!buffer) return;
|
||||
const fileToExport =
|
||||
selectedFiles.length > 0 ? selectedFiles[0] : activeFiles[0];
|
||||
if (!fileToExport) return;
|
||||
const stub = isStirlingFile(fileToExport)
|
||||
? selectors.getStirlingFileStub(fileToExport.fileId)
|
||||
: undefined;
|
||||
try {
|
||||
const result = await downloadFile({
|
||||
data: new Blob([buffer], { type: "application/pdf" }),
|
||||
filename: fileToExport.name,
|
||||
localPath: forceNewFile ? undefined : stub?.localFilePath,
|
||||
});
|
||||
if (!forceNewFile && !result.cancelled && stub && result.savedPath) {
|
||||
fileActions.updateStirlingFileStub(stub.id, {
|
||||
localFilePath: stub.localFilePath ?? result.savedPath,
|
||||
isDirty: false,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[WorkbenchBar] Failed to export viewer file:", error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentView === "pageEditor") {
|
||||
pageEditorFunctions?.onExportAll?.();
|
||||
return;
|
||||
}
|
||||
|
||||
const filesToExport =
|
||||
selectedFiles.length > 0 ? selectedFiles : activeFiles;
|
||||
for (const file of filesToExport) {
|
||||
const stub = isStirlingFile(file)
|
||||
? selectors.getStirlingFileStub(file.fileId)
|
||||
: undefined;
|
||||
try {
|
||||
const result = await downloadFile({
|
||||
data: file,
|
||||
filename: file.name,
|
||||
localPath: forceNewFile ? undefined : stub?.localFilePath,
|
||||
});
|
||||
if (result.cancelled) continue;
|
||||
if (!forceNewFile && stub && result.savedPath) {
|
||||
fileActions.updateStirlingFileStub(stub.id, {
|
||||
localFilePath: stub.localFilePath ?? result.savedPath,
|
||||
isDirty: false,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[WorkbenchBar] Failed to export file:",
|
||||
file.name,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
currentView,
|
||||
selectedFiles,
|
||||
activeFiles,
|
||||
pageEditorFunctions,
|
||||
viewerContext,
|
||||
selectors,
|
||||
fileActions,
|
||||
],
|
||||
);
|
||||
|
||||
const handlePrint = useCallback(() => {
|
||||
viewerContext?.printActions?.print?.();
|
||||
}, [viewerContext]);
|
||||
|
||||
const handleClose = useCallback(async () => {
|
||||
if (currentView === "fileEditor") {
|
||||
await fileActions.clearAllFiles();
|
||||
} else if (currentView === "viewer") {
|
||||
const file =
|
||||
(activeFileId
|
||||
? activeFiles.find(
|
||||
(f) => isStirlingFile(f) && f.fileId === activeFileId,
|
||||
)
|
||||
: null) ?? activeFiles[0];
|
||||
const countBeforeRemove = activeFiles.length;
|
||||
if (file && isStirlingFile(file)) {
|
||||
// Pick the next file to show before removing, so the sidebar stays in sync.
|
||||
const remaining = activeFiles.filter(
|
||||
(f) => isStirlingFile(f) && f.fileId !== file.fileId,
|
||||
);
|
||||
const nextFile = remaining.find(isStirlingFile) ?? null;
|
||||
await fileActions.removeFiles([file.fileId], false);
|
||||
if (countBeforeRemove <= 1) {
|
||||
setCurrentView("fileEditor");
|
||||
} else if (nextFile) {
|
||||
setActiveFileId(nextFile.fileId);
|
||||
}
|
||||
} else if (countBeforeRemove <= 1) {
|
||||
setCurrentView("fileEditor");
|
||||
}
|
||||
} else if (currentView === "pageEditor") {
|
||||
pageEditorFunctions?.closePdf?.();
|
||||
}
|
||||
}, [
|
||||
currentView,
|
||||
fileActions,
|
||||
activeFiles,
|
||||
activeFileId,
|
||||
setActiveFileId,
|
||||
pageEditorFunctions,
|
||||
setCurrentView,
|
||||
]);
|
||||
|
||||
const downloadTooltip = useMemo(() => {
|
||||
if (currentView === "pageEditor")
|
||||
return t("workbenchBar.exportAll", "Export PDF");
|
||||
if (currentView === "viewer") return terminology.download;
|
||||
if (selectedCount > 0) return terminology.downloadSelected;
|
||||
return terminology.downloadAll;
|
||||
}, [currentView, selectedCount, t, terminology]);
|
||||
|
||||
const renderButton = useCallback(
|
||||
(btn: WorkbenchBarButtonConfig) => {
|
||||
const action = actions[btn.id];
|
||||
const disabled = Boolean(
|
||||
btn.disabled || allButtonsDisabled || disableForFullscreen,
|
||||
);
|
||||
const isActive = Boolean(btn.active);
|
||||
|
||||
const triggerAction = () => {
|
||||
if (!disabled) action?.();
|
||||
};
|
||||
|
||||
if (btn.render) {
|
||||
const context: WorkbenchBarRenderContext = {
|
||||
id: btn.id,
|
||||
disabled,
|
||||
allButtonsDisabled,
|
||||
action,
|
||||
triggerAction,
|
||||
active: isActive,
|
||||
};
|
||||
return btn.render(context) ?? null;
|
||||
}
|
||||
|
||||
if (!btn.icon) return null;
|
||||
|
||||
const ariaLabel =
|
||||
btn.ariaLabel ||
|
||||
(typeof btn.tooltip === "string" ? (btn.tooltip as string) : undefined);
|
||||
const buttonNode = (
|
||||
<ActionIcon
|
||||
variant={isActive ? "filled" : "subtle"}
|
||||
color={isActive ? "blue" : undefined}
|
||||
radius="md"
|
||||
className="workbench-bar-action-icon"
|
||||
onClick={triggerAction}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel}
|
||||
aria-pressed={isActive ? true : undefined}
|
||||
>
|
||||
{btn.icon}
|
||||
</ActionIcon>
|
||||
);
|
||||
return renderWithTooltip(buttonNode, btn.tooltip);
|
||||
},
|
||||
[actions, allButtonsDisabled, disableForFullscreen],
|
||||
);
|
||||
|
||||
// View options
|
||||
const viewOptions: ViewOption[] = [
|
||||
{
|
||||
value: "viewer",
|
||||
label: t("workbenchBar.viewer", "Viewer"),
|
||||
icon: <InsertDriveFileIcon fontSize="small" />,
|
||||
},
|
||||
{
|
||||
value: "fileEditor",
|
||||
label: t("workbenchBar.activeFiles", "Active Files"),
|
||||
icon: <FolderIcon fontSize="small" />,
|
||||
},
|
||||
...(selectedTool === "multiTool"
|
||||
? [
|
||||
{
|
||||
value: "pageEditor" as WorkbenchType,
|
||||
label: t("workbenchBar.multiTool", "Multi-Tool"),
|
||||
icon: (
|
||||
<LocalIcon
|
||||
icon="dashboard-customize-outline-rounded"
|
||||
width="1rem"
|
||||
height="1rem"
|
||||
/>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...customWorkbenchViews
|
||||
.filter((v) => v.data != null)
|
||||
.map((v) => ({
|
||||
value: v.workbenchId,
|
||||
label: v.label,
|
||||
icon: v.icon ?? <InsertDriveFileIcon fontSize="small" />,
|
||||
})),
|
||||
];
|
||||
|
||||
const barRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const bar = barRef.current;
|
||||
if (!bar) return;
|
||||
|
||||
const measure = () => {
|
||||
const viewsEl = bar.querySelector<HTMLElement>(".workbench-bar-views");
|
||||
const globalsEl = bar.querySelector<HTMLElement>(
|
||||
".workbench-bar-globals",
|
||||
);
|
||||
const centerEl = bar.querySelector<HTMLElement>(".workbench-bar-center");
|
||||
|
||||
const viewsWidth = viewsEl?.offsetWidth ?? 0;
|
||||
const globalsWidth = globalsEl?.offsetWidth ?? 0;
|
||||
const centerChildren = centerEl
|
||||
? (Array.from(centerEl.children) as HTMLElement[])
|
||||
: [];
|
||||
const centerWidth =
|
||||
centerChildren.reduce((sum, el) => sum + el.offsetWidth, 0) +
|
||||
Math.max(0, centerChildren.length - 1) * 2; // gap: 2px
|
||||
|
||||
const needed = viewsWidth + centerWidth + globalsWidth + 24; // 24px bar padding
|
||||
bar.dataset.wrapped = String(needed > bar.clientWidth);
|
||||
};
|
||||
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(bar);
|
||||
measure();
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={barRef}
|
||||
className="workbench-bar"
|
||||
data-wrapped="true"
|
||||
data-tour="workbench-bar"
|
||||
>
|
||||
{/* Left: View switcher */}
|
||||
<div className="workbench-bar-views" data-tour="view-switcher">
|
||||
{hasFiles &&
|
||||
viewOptions.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
className={`workbench-bar-view-btn${currentView === opt.value ? " active" : ""}`}
|
||||
onClick={() => setCurrentView(opt.value)}
|
||||
aria-pressed={currentView === opt.value}
|
||||
type="button"
|
||||
>
|
||||
{opt.icon}
|
||||
<span className="workbench-bar-view-label">{opt.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tool buttons — second row, only rendered when buttons exist */}
|
||||
{sectionsWithButtons.length > 0 && (
|
||||
<div className="workbench-bar-center">
|
||||
{sectionsWithButtons.map(
|
||||
({ section, buttons: sectionButtons }, idx) => (
|
||||
<React.Fragment key={section}>
|
||||
{idx > 0 && <div className="workbench-bar-divider" />}
|
||||
{sectionButtons.map((btn) => {
|
||||
const content = renderButton(btn);
|
||||
if (!content) return null;
|
||||
return (
|
||||
<div key={btn.id} className="workbench-bar-action-wrapper">
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Right: Global buttons — export group left, close anchored right */}
|
||||
<div className="workbench-bar-globals">
|
||||
{/* Print */}
|
||||
{currentView === "viewer" &&
|
||||
renderWithTooltip(
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="workbench-bar-action-icon"
|
||||
onClick={handlePrint}
|
||||
disabled={
|
||||
totalItems === 0 || allButtonsDisabled || disableForFullscreen
|
||||
}
|
||||
aria-label={t("workbenchBar.print", "Print PDF")}
|
||||
>
|
||||
<PrintIcon sx={{ fontSize: "1rem" }} />
|
||||
</ActionIcon>,
|
||||
t("workbenchBar.print", "Print PDF"),
|
||||
)}
|
||||
|
||||
{/* Download */}
|
||||
{renderWithTooltip(
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="workbench-bar-action-icon"
|
||||
onClick={() => handleExportAll()}
|
||||
disabled={
|
||||
disableForFullscreen || totalItems === 0 || allButtonsDisabled
|
||||
}
|
||||
>
|
||||
<LocalIcon
|
||||
icon={icons.downloadIconName}
|
||||
width="1rem"
|
||||
height="1rem"
|
||||
/>
|
||||
</ActionIcon>,
|
||||
downloadTooltip,
|
||||
)}
|
||||
|
||||
{/* Save As */}
|
||||
{icons.saveAsIconName &&
|
||||
renderWithTooltip(
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="workbench-bar-action-icon"
|
||||
onClick={() => handleExportAll(true)}
|
||||
disabled={
|
||||
disableForFullscreen || totalItems === 0 || allButtonsDisabled
|
||||
}
|
||||
>
|
||||
<LocalIcon
|
||||
icon={icons.saveAsIconName}
|
||||
width="1rem"
|
||||
height="1rem"
|
||||
/>
|
||||
</ActionIcon>,
|
||||
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="subtle"
|
||||
radius="md"
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { NavKey } from "@app/components/shared/config/types";
|
||||
import HotkeysSection from "@app/components/shared/config/configSections/HotkeysSection";
|
||||
import GeneralSection from "@app/components/shared/config/configSections/GeneralSection";
|
||||
import HelpSection from "@app/components/shared/config/configSections/HelpSection";
|
||||
|
||||
export interface ConfigNavItem {
|
||||
key: NavKey;
|
||||
@@ -34,6 +35,7 @@ export const useConfigNavSections = (
|
||||
_isAdmin: boolean = false,
|
||||
_runningEE: boolean = false,
|
||||
_loginEnabled: boolean = false,
|
||||
onRequestClose: () => void = () => {},
|
||||
): ConfigNavSection[] => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -55,6 +57,19 @@ export const useConfigNavSections = (
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t("settings.help.title", "Help"),
|
||||
items: [
|
||||
{
|
||||
key: "help",
|
||||
label: t("settings.help.label", "Tours"),
|
||||
icon: "help-rounded",
|
||||
component: (
|
||||
<HelpSection isAdmin={_isAdmin} onRequestClose={onRequestClose} />
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return sections;
|
||||
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { usePreferences } from "@app/contexts/PreferencesContext";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import { useRainbowThemeContext } from "@app/components/shared/RainbowThemeProvider";
|
||||
import LanguageSelector from "@app/components/shared/LanguageSelector";
|
||||
import type { ToolPanelMode } from "@app/constants/toolPanel";
|
||||
import type {
|
||||
StartupView,
|
||||
@@ -47,6 +49,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
const { t } = useTranslation();
|
||||
const { preferences, updatePreference } = usePreferences();
|
||||
const { config } = useAppConfig();
|
||||
const { toggleTheme, themeMode } = useRainbowThemeContext();
|
||||
const [fileLimitInput, setFileLimitInput] = useState<number | string>(
|
||||
preferences.autoUnzipFileLimit,
|
||||
);
|
||||
@@ -355,6 +358,67 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Appearance */}
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="md">
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<Text fw={500} size="sm">
|
||||
{t("settings.general.theme", "Theme")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t(
|
||||
"settings.general.themeDescription",
|
||||
"Switch between light and dark mode",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
<SegmentedControl
|
||||
value={themeMode === "rainbow" ? "dark" : themeMode}
|
||||
onChange={(val) => {
|
||||
if ((themeMode === "dark") !== (val === "dark")) toggleTheme();
|
||||
}}
|
||||
data={[
|
||||
{
|
||||
label: t("settings.general.themeLight", "Light"),
|
||||
value: "light",
|
||||
},
|
||||
{
|
||||
label: t("settings.general.themeDark", "Dark"),
|
||||
value: "dark",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<Text fw={500} size="sm">
|
||||
{t("settings.general.language", "Language")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t(
|
||||
"settings.general.languageDescription",
|
||||
"Choose the display language",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
<LanguageSelector position="bottom-end" offset={6} />
|
||||
</div>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="md">
|
||||
<div
|
||||
@@ -412,7 +476,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t(
|
||||
"settings.general.defaultStartupViewDescription",
|
||||
"Choose which tab is active in the left column when the app starts",
|
||||
"Choose which view is active when the app starts",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import React from "react";
|
||||
import { Button, Group, Paper, Stack, Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import { requestStartTour } from "@app/constants/events";
|
||||
|
||||
interface HelpSectionProps {
|
||||
isAdmin: boolean;
|
||||
onRequestClose: () => void;
|
||||
}
|
||||
|
||||
const HelpSection: React.FC<HelpSectionProps> = ({
|
||||
isAdmin,
|
||||
onRequestClose,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const startTour = (tourType: "tools" | "admin") => {
|
||||
onRequestClose();
|
||||
setTimeout(() => requestStartTour(tourType), 300);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="center">
|
||||
<div>
|
||||
<Text fw={600} size="sm">
|
||||
{t("settings.help.toolsTour.title", "Tools Tour")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t(
|
||||
"settings.help.toolsTour.description",
|
||||
"Walk through uploading files, picking a tool, and reviewing results.",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
leftSection={
|
||||
<LocalIcon
|
||||
icon="build-outline-rounded"
|
||||
width="1rem"
|
||||
height="1rem"
|
||||
/>
|
||||
}
|
||||
onClick={() => startTour("tools")}
|
||||
>
|
||||
{t("settings.help.toolsTour.start", "Start")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{isAdmin && (
|
||||
<Group justify="space-between" align="center">
|
||||
<div>
|
||||
<Text fw={600} size="sm">
|
||||
{t("settings.help.adminTour.title", "Admin Tour")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t(
|
||||
"settings.help.adminTour.description",
|
||||
"Explore team management, system settings, and enterprise features.",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
leftSection={
|
||||
<LocalIcon icon="person-rounded" width="1rem" height="1rem" />
|
||||
}
|
||||
onClick={() => startTour("admin")}
|
||||
>
|
||||
{t("settings.help.adminTour.start", "Start")}
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default HelpSection;
|
||||
@@ -30,6 +30,7 @@ export const VALID_NAV_KEYS = [
|
||||
"adminUsage",
|
||||
"adminEndpoints",
|
||||
"adminStorageSharing",
|
||||
"help",
|
||||
] as const;
|
||||
|
||||
// Derive the type from the array
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import React from "react";
|
||||
import { Box, Center, Image } from "@mantine/core";
|
||||
import { Box, Center, Loader, Stack, Text } from "@mantine/core";
|
||||
import LockIcon from "@mui/icons-material/Lock";
|
||||
import { getFileTypeIcon } from "@app/components/shared/filePreview/getFileTypeIcon";
|
||||
import { StirlingFileStub } from "@app/types/fileContext";
|
||||
import { PrivateContent } from "@app/components/shared/PrivateContent";
|
||||
import { detectFileExtension } from "@app/utils/fileUtils";
|
||||
|
||||
export interface DocumentThumbnailProps {
|
||||
file: File | StirlingFileStub | null;
|
||||
thumbnail?: string | null;
|
||||
isEncrypted?: boolean;
|
||||
isLoading?: boolean;
|
||||
iconSize?: string | number;
|
||||
imgClassName?: string;
|
||||
onImageError?: React.ReactEventHandler<HTMLImageElement>;
|
||||
style?: React.CSSProperties;
|
||||
onClick?: () => void;
|
||||
children?: React.ReactNode;
|
||||
@@ -15,14 +22,19 @@ export interface DocumentThumbnailProps {
|
||||
const DocumentThumbnail: React.FC<DocumentThumbnailProps> = ({
|
||||
file,
|
||||
thumbnail,
|
||||
isEncrypted = false,
|
||||
isLoading = false,
|
||||
iconSize = "4rem",
|
||||
imgClassName,
|
||||
onImageError,
|
||||
style = {},
|
||||
onClick,
|
||||
children,
|
||||
}) => {
|
||||
if (!file) return null;
|
||||
|
||||
const containerStyle = {
|
||||
position: "relative" as const,
|
||||
const containerStyle: React.CSSProperties = {
|
||||
position: "relative",
|
||||
cursor: onClick ? "pointer" : "default",
|
||||
transition: "opacity 0.2s ease",
|
||||
width: "100%",
|
||||
@@ -33,20 +45,29 @@ const DocumentThumbnail: React.FC<DocumentThumbnailProps> = ({
|
||||
...style,
|
||||
};
|
||||
|
||||
if (thumbnail) {
|
||||
if (thumbnail && !isEncrypted) {
|
||||
return (
|
||||
<Box style={containerStyle} onClick={onClick}>
|
||||
<PrivateContent>
|
||||
<Image
|
||||
<img
|
||||
src={thumbnail}
|
||||
alt={`Preview of ${file.name}`}
|
||||
fit="contain"
|
||||
style={{
|
||||
maxWidth: "100%",
|
||||
maxHeight: "100%",
|
||||
width: "auto",
|
||||
height: "auto",
|
||||
}}
|
||||
className={imgClassName}
|
||||
style={
|
||||
imgClassName
|
||||
? undefined
|
||||
: {
|
||||
maxWidth: "100%",
|
||||
maxHeight: "100%",
|
||||
width: "auto",
|
||||
height: "auto",
|
||||
objectFit: "contain",
|
||||
}
|
||||
}
|
||||
draggable={false}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={onImageError}
|
||||
/>
|
||||
</PrivateContent>
|
||||
{children}
|
||||
@@ -54,17 +75,93 @@ const DocumentThumbnail: React.FC<DocumentThumbnailProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (isEncrypted) {
|
||||
return (
|
||||
<Box style={containerStyle} onClick={onClick}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: "calc(100% - 8px)",
|
||||
height: "calc(100% - 8px)",
|
||||
gap: "0.5rem",
|
||||
border: "2px dashed var(--mantine-color-red-5)",
|
||||
borderRadius: "10px",
|
||||
}}
|
||||
>
|
||||
<LockIcon
|
||||
style={{ fontSize: iconSize, color: "var(--mantine-color-red-6)" }}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.65rem",
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.08em",
|
||||
textTransform: "uppercase",
|
||||
color: "var(--mantine-color-red-6)",
|
||||
background: "rgba(220,38,38,0.1)",
|
||||
padding: "2px 8px",
|
||||
borderRadius: "6px",
|
||||
}}
|
||||
>
|
||||
Locked
|
||||
</span>
|
||||
</div>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box style={containerStyle} onClick={onClick}>
|
||||
<Stack
|
||||
align="center"
|
||||
justify="center"
|
||||
gap="xs"
|
||||
style={{ height: "100%" }}
|
||||
>
|
||||
<Loader size="sm" />
|
||||
<Text size="xs" c="dimmed">
|
||||
Loading thumbnail...
|
||||
</Text>
|
||||
</Stack>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const ext = detectFileExtension(file.name ?? "").toUpperCase();
|
||||
|
||||
return (
|
||||
<Box style={containerStyle} onClick={onClick}>
|
||||
<Center
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
backgroundColor: "var(--mantine-color-gray-1)",
|
||||
borderRadius: "0.25rem",
|
||||
flexDirection: "column",
|
||||
gap: "0.75rem",
|
||||
}}
|
||||
>
|
||||
<PrivateContent>{getFileTypeIcon(file)}</PrivateContent>
|
||||
<PrivateContent>{getFileTypeIcon(file, iconSize)}</PrivateContent>
|
||||
{ext && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.7rem",
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.1em",
|
||||
textTransform: "uppercase",
|
||||
color: "var(--text-secondary)",
|
||||
background: "rgb(var(--border))",
|
||||
padding: "3px 10px",
|
||||
borderRadius: "6px",
|
||||
}}
|
||||
>
|
||||
{ext}
|
||||
</span>
|
||||
)}
|
||||
</Center>
|
||||
{children}
|
||||
</Box>
|
||||
|
||||
@@ -1,17 +1,84 @@
|
||||
import React from "react";
|
||||
import JavascriptIcon from "@mui/icons-material/Javascript";
|
||||
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
|
||||
import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile";
|
||||
import { FileDocIcon } from "@app/components/shared/FileDocIcon";
|
||||
import type { FileDocVariant } from "@app/components/shared/FileDocIcon";
|
||||
import type { StirlingFileStub } from "@app/types/fileContext";
|
||||
import { detectFileExtension } from "@app/utils/fileUtils";
|
||||
|
||||
export const SPREADSHEET_EXTS = new Set(["csv", "xls", "xlsx", "ods"]);
|
||||
export const DOC_EXTS = new Set([
|
||||
"md",
|
||||
"markdown",
|
||||
"txt",
|
||||
"doc",
|
||||
"docx",
|
||||
"odt",
|
||||
"rtf",
|
||||
]);
|
||||
export const IMAGE_EXTS = new Set([
|
||||
"png",
|
||||
"jpg",
|
||||
"jpeg",
|
||||
"gif",
|
||||
"webp",
|
||||
"svg",
|
||||
"bmp",
|
||||
"tiff",
|
||||
"tif",
|
||||
]);
|
||||
export const ARCHIVE_EXTS = new Set([
|
||||
"zip",
|
||||
"tar",
|
||||
"gz",
|
||||
"rar",
|
||||
"7z",
|
||||
"cbz",
|
||||
"cbr",
|
||||
]);
|
||||
export const CODE_EXTS = new Set([
|
||||
"js",
|
||||
"ts",
|
||||
"jsx",
|
||||
"tsx",
|
||||
"html",
|
||||
"css",
|
||||
"json",
|
||||
"xml",
|
||||
"yaml",
|
||||
"yml",
|
||||
]);
|
||||
|
||||
type FileLike = File | StirlingFileStub;
|
||||
|
||||
/**
|
||||
* Returns an appropriate file type icon for the provided file.
|
||||
* - Uses the real file type and extension to decide the icon.
|
||||
* - No any-casts; accepts File or StirlingFileStub.
|
||||
*/
|
||||
export function getFileDocVariant(ext: string, mime = ""): FileDocVariant {
|
||||
if (ext === "pdf" || mime === "application/pdf") return "pdf";
|
||||
if (
|
||||
SPREADSHEET_EXTS.has(ext) ||
|
||||
mime.includes("spreadsheet") ||
|
||||
mime === "text/csv"
|
||||
)
|
||||
return "spreadsheet";
|
||||
if (
|
||||
DOC_EXTS.has(ext) ||
|
||||
mime === "text/markdown" ||
|
||||
mime === "text/plain" ||
|
||||
mime.includes("word") ||
|
||||
mime.includes("opendocument.text")
|
||||
)
|
||||
return "doc";
|
||||
if (IMAGE_EXTS.has(ext) || mime.startsWith("image/")) return "image";
|
||||
if (ARCHIVE_EXTS.has(ext) || mime.includes("zip") || mime.includes("archive"))
|
||||
return "archive";
|
||||
if (
|
||||
CODE_EXTS.has(ext) ||
|
||||
mime.includes("javascript") ||
|
||||
mime.includes("json") ||
|
||||
mime.includes("xml") ||
|
||||
mime === "text/html"
|
||||
)
|
||||
return "code";
|
||||
return "generic";
|
||||
}
|
||||
|
||||
export function getFileTypeIcon(
|
||||
file: FileLike,
|
||||
size: number | string = "2rem",
|
||||
@@ -19,29 +86,10 @@ export function getFileTypeIcon(
|
||||
const name = (file?.name ?? "").toLowerCase();
|
||||
const mime = (file?.type ?? "").toLowerCase();
|
||||
const ext = detectFileExtension(name);
|
||||
|
||||
// JavaScript
|
||||
if (ext === "js" || mime.includes("javascript")) {
|
||||
return (
|
||||
<JavascriptIcon
|
||||
style={{ fontSize: size, color: "var(--mantine-color-gray-6)" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// PDF
|
||||
if (ext === "pdf" || mime === "application/pdf") {
|
||||
return (
|
||||
<PictureAsPdfIcon
|
||||
style={{ fontSize: size, color: "var(--mantine-color-gray-6)" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback generic
|
||||
return (
|
||||
<InsertDriveFileIcon
|
||||
style={{ fontSize: size, color: "var(--mantine-color-gray-6)" }}
|
||||
<FileDocIcon
|
||||
variant={getFileDocVariant(ext, mime)}
|
||||
style={{ width: size, height: "auto" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
# RightRail Component
|
||||
|
||||
A dynamic vertical toolbar on the right side of the application that supports both static buttons (Undo/Redo, Save, Print, Share) and dynamic buttons registered by tools.
|
||||
|
||||
## Structure
|
||||
|
||||
- **Top Section**: Dynamic buttons from tools (empty when none)
|
||||
- **Middle Section**: Grid, Cut, Undo, Redo
|
||||
- **Bottom Section**: Save, Print, Share
|
||||
|
||||
## Usage
|
||||
|
||||
### For Tools (Recommended)
|
||||
|
||||
```tsx
|
||||
import { useRightRailButtons } from '../hooks/useRightRailButtons';
|
||||
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
||||
|
||||
function MyTool() {
|
||||
const handleAction = useCallback(() => {
|
||||
// Your action here
|
||||
}, []);
|
||||
|
||||
useRightRailButtons([
|
||||
{
|
||||
id: 'my-action',
|
||||
icon: <PlayArrowIcon />,
|
||||
tooltip: 'Execute Action',
|
||||
onClick: handleAction,
|
||||
},
|
||||
]);
|
||||
|
||||
return <div>My Tool</div>;
|
||||
}
|
||||
```
|
||||
|
||||
### Multiple Buttons
|
||||
|
||||
```tsx
|
||||
useRightRailButtons([
|
||||
{
|
||||
id: 'primary',
|
||||
icon: <StarIcon />,
|
||||
tooltip: 'Primary Action',
|
||||
order: 1,
|
||||
onClick: handlePrimary,
|
||||
},
|
||||
{
|
||||
id: 'secondary',
|
||||
icon: <SettingsIcon />,
|
||||
tooltip: 'Secondary Action',
|
||||
order: 2,
|
||||
onClick: handleSecondary,
|
||||
},
|
||||
]);
|
||||
```
|
||||
|
||||
### Conditional Buttons
|
||||
|
||||
```tsx
|
||||
useRightRailButtons([
|
||||
// Always show
|
||||
{
|
||||
id: 'process',
|
||||
icon: <PlayArrowIcon />,
|
||||
tooltip: 'Process',
|
||||
disabled: isProcessing,
|
||||
onClick: handleProcess,
|
||||
},
|
||||
// Only show when condition met
|
||||
...(hasResults ? [{
|
||||
id: 'export',
|
||||
icon: <DownloadIcon />,
|
||||
tooltip: 'Export',
|
||||
onClick: handleExport,
|
||||
}] : []),
|
||||
]);
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
### Button Config
|
||||
|
||||
```typescript
|
||||
interface RightRailButtonWithAction {
|
||||
id: string; // Unique identifier
|
||||
icon?: React.ReactNode; // Icon component (omit when using render)
|
||||
tooltip?: React.ReactNode; // Hover tooltip / description
|
||||
active?: boolean; // Optional active state for highlight
|
||||
section?: 'top' | 'middle' | 'bottom'; // Section (default: 'top')
|
||||
order?: number; // Sort order (default: 0)
|
||||
disabled?: boolean; // Disabled state (default: false)
|
||||
visible?: boolean; // Visibility (default: true)
|
||||
render?: (ctx: RightRailRenderContext) => React.ReactNode; // Custom renderer
|
||||
onClick?: () => void; // Click handler (optional if using render)
|
||||
}
|
||||
|
||||
interface RightRailRenderContext {
|
||||
id: string;
|
||||
disabled: boolean;
|
||||
allButtonsDisabled: boolean;
|
||||
action?: () => void;
|
||||
triggerAction: () => void;
|
||||
active: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Rendering (Popovers, Multi-button Blocks)
|
||||
|
||||
```tsx
|
||||
useRightRailButtons([
|
||||
{
|
||||
id: 'viewer-search',
|
||||
tooltip: t('rightRail.search', 'Search PDF'),
|
||||
render: ({ disabled }) => (
|
||||
<Tooltip content={t('rightRail.search', 'Search PDF')}>
|
||||
<Popover position="left">
|
||||
<Popover.Target>
|
||||
<ActionIcon disabled={disabled}>
|
||||
<SearchIcon />
|
||||
</ActionIcon>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<SearchInterface />
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
]);
|
||||
```
|
||||
|
||||
## Built-in Features
|
||||
|
||||
- **Undo/Redo**: Automatically integrates with Page Editor
|
||||
- **Theme Support**: Light/dark mode with CSS variables
|
||||
- **Auto Cleanup**: Buttons unregister when tool unmounts
|
||||
|
||||
## Best Practices
|
||||
|
||||
- Use descriptive IDs: `'compress-optimize'`, `'ocr-process'`
|
||||
- Choose appropriate Material-UI icons
|
||||
- Keep tooltips concise: `'Compress PDF'`, `'Process with OCR'`
|
||||
- Use `useCallback` for click handlers to prevent re-registration
|
||||
- Reach for `render` when you need popovers or multi-control groups inside the rail
|
||||
@@ -1,170 +0,0 @@
|
||||
.right-rail {
|
||||
background-color: var(--right-rail-bg);
|
||||
width: 3.5rem;
|
||||
min-width: 3.5rem;
|
||||
max-width: 3.5rem;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
border-left: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.right-rail-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 0.5rem;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.right-rail-section {
|
||||
background-color: var(--right-rail-foreground);
|
||||
border-radius: 12px;
|
||||
padding: 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.right-rail-button-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
animation: rightRailButtonReveal 200ms ease forwards;
|
||||
transform-origin: top center;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.right-rail-tooltip-wrapper {
|
||||
display: inline-flex;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@keyframes rightRailButtonReveal {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scaleY(0.6) translateY(-6px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scaleY(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.right-rail-divider {
|
||||
width: 2.75rem;
|
||||
border: none;
|
||||
border-top: 1px solid var(--tool-subcategory-rule-color);
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.right-rail-icon {
|
||||
color: var(--right-rail-icon);
|
||||
}
|
||||
|
||||
.right-rail-icon[data-active="true"] {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.right-rail-icon[aria-disabled="true"],
|
||||
.right-rail-icon[disabled] {
|
||||
color: var(--right-rail-icon-disabled) !important;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
/* When all buttons are disabled via context */
|
||||
.right-rail--all-disabled .right-rail-icon {
|
||||
color: var(--right-rail-icon-disabled) !important;
|
||||
background-color: transparent !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
.right-rail-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Animated grow-down slot for buttons (mirrors current-tool-slot behavior) */
|
||||
.right-rail-slot {
|
||||
overflow: hidden;
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
transition:
|
||||
max-height 450ms ease-out,
|
||||
opacity 300ms ease-out;
|
||||
}
|
||||
|
||||
.right-rail-enter {
|
||||
animation: rightRailGrowDown 450ms ease-out;
|
||||
}
|
||||
|
||||
.right-rail-exit {
|
||||
animation: rightRailShrinkUp 450ms ease-out;
|
||||
}
|
||||
|
||||
.right-rail-slot.visible {
|
||||
max-height: 40rem; /* increased to fit additional controls + divider */
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@keyframes rightRailGrowDown {
|
||||
0% {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
max-height: 40rem;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rightRailShrinkUp {
|
||||
0% {
|
||||
max-height: 40rem;
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Remove bottom margin from close icon */
|
||||
.right-rail-slot .right-rail-icon {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Inline appear/disappear animation for page-number selector button */
|
||||
.right-rail-fade {
|
||||
transition-property: opacity, transform, max-height, visibility;
|
||||
transition-duration: 220ms, 220ms, 220ms, 0s;
|
||||
transition-timing-function: ease, ease, ease, linear;
|
||||
transition-delay: 0s, 0s, 0s, 0s;
|
||||
transform-origin: top center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.right-rail-fade.enter {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
max-height: 3rem;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.right-rail-fade.exit {
|
||||
opacity: 0;
|
||||
transform: scale(0.85);
|
||||
max-height: 0;
|
||||
visibility: hidden;
|
||||
/* delay visibility change so opacity/max-height can finish */
|
||||
transition-delay: 0s, 0s, 0s, 220ms;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -43,11 +43,11 @@ const CreateSessionPanel = ({
|
||||
<>
|
||||
<div className="quick-access-popout__section">
|
||||
<div className="quick-access-popout__label">
|
||||
{t("quickAccess.selectedFile", "Selected file")}
|
||||
{t("quickAccess.selectedFile", "File")}
|
||||
</div>
|
||||
<div className="quick-access-popout__row-title">
|
||||
{selectedFiles[0]?.name ||
|
||||
t("quickAccess.noFile", "No file selected")}
|
||||
t("quickAccess.noFile", "No file loaded")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--search-text-and-icon-color);
|
||||
}
|
||||
|
||||
.input {
|
||||
@@ -26,6 +27,8 @@
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
transition: box-shadow 0.2s ease;
|
||||
background-color: var(--input-bg);
|
||||
color: var(--search-text-and-icon-color);
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
@@ -62,6 +65,7 @@
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
transition: background-color 0.2s ease;
|
||||
color: var(--search-text-and-icon-color);
|
||||
}
|
||||
|
||||
.clearButton:hover {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useState, useRef } from "react";
|
||||
import { ActionIcon, ScrollArea, Switch } from "@mantine/core";
|
||||
import DoubleArrowIcon from "@mui/icons-material/DoubleArrow";
|
||||
import { useRef } from "react";
|
||||
import { ScrollArea, Switch } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ToolSearch from "@app/components/tools/toolPicker/ToolSearch";
|
||||
import FullscreenToolList from "@app/components/tools/FullscreenToolList";
|
||||
@@ -9,10 +8,8 @@ import { ToolId } from "@app/types/toolId";
|
||||
import { useFocusTrap } from "@app/hooks/useFocusTrap";
|
||||
import { LogoIcon } from "@app/components/shared/LogoIcon";
|
||||
import { Wordmark } from "@app/components/shared/Wordmark";
|
||||
import { Tooltip } from "@app/components/shared/Tooltip";
|
||||
import "@app/components/tools/ToolPanel.css";
|
||||
import { ToolPanelGeometry } from "@app/hooks/tools/useToolPanelGeometry";
|
||||
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from "@app/styles/zIndex";
|
||||
|
||||
interface FullscreenToolSurfaceProps {
|
||||
searchQuery: string;
|
||||
@@ -28,7 +25,6 @@ interface FullscreenToolSurfaceProps {
|
||||
onSelect: (id: ToolId) => void;
|
||||
onToggleDescriptions: () => void;
|
||||
onExitFullscreenMode: () => void;
|
||||
toggleLabel: string;
|
||||
geometry: ToolPanelGeometry | null;
|
||||
}
|
||||
|
||||
@@ -42,47 +38,17 @@ const FullscreenToolSurface = ({
|
||||
onSearchChange,
|
||||
onSelect,
|
||||
onToggleDescriptions,
|
||||
onExitFullscreenMode,
|
||||
toggleLabel,
|
||||
onExitFullscreenMode: _onExitFullscreenMode,
|
||||
geometry,
|
||||
}: FullscreenToolSurfaceProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [isExiting, setIsExiting] = useState(false);
|
||||
const surfaceRef = useRef<HTMLDivElement>(null);
|
||||
const isRTL =
|
||||
typeof document !== "undefined" && document.documentElement.dir === "rtl";
|
||||
|
||||
// Enable focus trap when surface is active
|
||||
useFocusTrap(surfaceRef, !isExiting);
|
||||
useFocusTrap(surfaceRef, true);
|
||||
|
||||
const brandAltText = t("home.mobile.brandAlt", "Stirling PDF logo");
|
||||
|
||||
const handleExit = () => {
|
||||
const prefersReducedMotion = window.matchMedia(
|
||||
"(prefers-reduced-motion: reduce)",
|
||||
).matches;
|
||||
|
||||
if (prefersReducedMotion) {
|
||||
onExitFullscreenMode();
|
||||
return;
|
||||
}
|
||||
|
||||
setIsExiting(true);
|
||||
const el = surfaceRef.current;
|
||||
if (!el) {
|
||||
onExitFullscreenMode();
|
||||
return;
|
||||
}
|
||||
// Rely on CSS animation end rather than duplicating timing in JS
|
||||
el.addEventListener(
|
||||
"animationend",
|
||||
() => {
|
||||
onExitFullscreenMode();
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
};
|
||||
|
||||
const style = geometry
|
||||
? {
|
||||
left: `${geometry.left}px`,
|
||||
@@ -103,10 +69,7 @@ const FullscreenToolSurface = ({
|
||||
)}
|
||||
data-tour="tool-panel"
|
||||
>
|
||||
<div
|
||||
ref={surfaceRef}
|
||||
className={`tool-panel__fullscreen-surface-inner ${isExiting ? "tool-panel__fullscreen-surface-inner--exiting" : ""}`}
|
||||
>
|
||||
<div ref={surfaceRef} className="tool-panel__fullscreen-surface-inner">
|
||||
<header className="tool-panel__fullscreen-header">
|
||||
<div className="tool-panel__fullscreen-brand">
|
||||
<LogoIcon className="tool-panel__fullscreen-brand-icon" />
|
||||
@@ -115,29 +78,6 @@ const FullscreenToolSurface = ({
|
||||
className="tool-panel__fullscreen-brand-text"
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: "0.5rem", alignItems: "center" }}>
|
||||
<Tooltip
|
||||
content={toggleLabel}
|
||||
position="bottom"
|
||||
arrow={true}
|
||||
openOnFocus={false}
|
||||
containerStyle={{ zIndex: Z_INDEX_OVER_FULLSCREEN_SURFACE }}
|
||||
>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="xl"
|
||||
size="md"
|
||||
onClick={handleExit}
|
||||
aria-label={toggleLabel}
|
||||
style={{ color: "var(--right-rail-icon)" }}
|
||||
>
|
||||
<DoubleArrowIcon
|
||||
fontSize="small"
|
||||
style={{ transform: isRTL ? undefined : "rotate(180deg)" }}
|
||||
/>
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="tool-panel__fullscreen-controls">
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
/* ---- Viewer mode mini-toolbar (top of ToolPanel) ---- */
|
||||
.tool-panel-viewer-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 4px 8px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
background-color: var(--bg-toolbar);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* CSS Custom Properties for Fullscreen Mode */
|
||||
.tool-panel__fullscreen-surface-inner {
|
||||
--fullscreen-bg-surface-1: color-mix(
|
||||
@@ -109,16 +120,8 @@
|
||||
var(--text-primary) 20%,
|
||||
var(--border-subtle)
|
||||
);
|
||||
--fullscreen-text-icon: color-mix(
|
||||
in srgb,
|
||||
var(--text-primary) 90%,
|
||||
var(--text-muted)
|
||||
);
|
||||
--fullscreen-text-icon-compact: color-mix(
|
||||
in srgb,
|
||||
var(--text-primary) 88%,
|
||||
var(--text-muted)
|
||||
);
|
||||
--fullscreen-text-icon: var(--text-primary);
|
||||
--fullscreen-text-icon-compact: var(--text-primary);
|
||||
}
|
||||
|
||||
.tool-panel {
|
||||
@@ -128,6 +131,49 @@
|
||||
max-width 0.3s ease;
|
||||
}
|
||||
|
||||
.tool-panel__collapsed-strip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
padding-top: 10px;
|
||||
gap: 8px;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tool-panel__expand-btn {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-secondary) !important;
|
||||
border-color: var(--border-subtle) !important;
|
||||
}
|
||||
|
||||
.tool-panel__expand-btn svg {
|
||||
color: var(--text-secondary) !important;
|
||||
}
|
||||
|
||||
.tool-panel__expand-btn:hover {
|
||||
color: var(--text-primary) !important;
|
||||
border-color: var(--border) !important;
|
||||
}
|
||||
|
||||
.tool-panel__collapsed-search-btn {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-secondary) !important;
|
||||
}
|
||||
|
||||
.tool-panel__collapsed-search-btn svg {
|
||||
color: var(--text-secondary) !important;
|
||||
}
|
||||
|
||||
.tool-panel__collapsed-search-btn:hover {
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
|
||||
.tool-panel__back-btn {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tool-panel--fullscreen-active {
|
||||
overflow: visible !important;
|
||||
}
|
||||
@@ -136,7 +182,9 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
height: 48px;
|
||||
padding: 0 0.5rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.tool-panel__search-row .search-input-container {
|
||||
@@ -469,6 +517,8 @@
|
||||
|
||||
.tool-panel__fullscreen-name {
|
||||
color: var(--text-primary);
|
||||
font-size: 13px !important;
|
||||
font-weight: 500 !important;
|
||||
}
|
||||
|
||||
.tool-panel__fullscreen-description {
|
||||
@@ -604,7 +654,7 @@
|
||||
|
||||
@keyframes tool-panel-fullscreen-slide-in {
|
||||
from {
|
||||
transform: translateX(-6%) scaleX(0.85);
|
||||
transform: translateX(6%) scaleX(0.85);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
@@ -619,14 +669,14 @@
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
transform: translateX(-6%) scaleX(0.85);
|
||||
transform: translateX(6%) scaleX(0.85);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes tool-panel-fullscreen-slide-in-rtl {
|
||||
from {
|
||||
transform: translateX(6%) scaleX(0.85);
|
||||
transform: translateX(-6%) scaleX(0.85);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
@@ -641,7 +691,7 @@
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
transform: translateX(6%) scaleX(0.85);
|
||||
transform: translateX(-6%) scaleX(0.85);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useRainbowThemeContext } from "@app/components/shared/RainbowThemeProvider";
|
||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||
import { usePreferences } from "@app/contexts/PreferencesContext";
|
||||
@@ -8,15 +8,18 @@ import ToolRenderer from "@app/components/tools/ToolRenderer";
|
||||
import ToolSearch from "@app/components/tools/toolPicker/ToolSearch";
|
||||
import { useSidebarContext } from "@app/contexts/SidebarContext";
|
||||
import rainbowStyles from "@app/styles/rainbow.module.css";
|
||||
import { ActionIcon, ScrollArea } from "@mantine/core";
|
||||
import { ActionIcon, Button, ScrollArea } from "@mantine/core";
|
||||
import { ToolId } from "@app/types/toolId";
|
||||
import { useIsMobile } from "@app/hooks/useIsMobile";
|
||||
import DoubleArrowIcon from "@mui/icons-material/DoubleArrow";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import FullscreenToolSurface from "@app/components/tools/FullscreenToolSurface";
|
||||
import { ToolPanelViewerBar } from "@app/components/tools/ToolPanelViewerBar";
|
||||
import { useToolPanelGeometry } from "@app/hooks/tools/useToolPanelGeometry";
|
||||
import { useRightRail } from "@app/contexts/RightRailContext";
|
||||
import { Tooltip } from "@app/components/shared/Tooltip";
|
||||
import { useWorkbenchBar } from "@app/contexts/WorkbenchBarContext";
|
||||
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
|
||||
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
|
||||
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
|
||||
import SearchIcon from "@mui/icons-material/Search";
|
||||
import "@app/components/tools/ToolPanel.css";
|
||||
|
||||
// No props needed - component uses context
|
||||
@@ -25,7 +28,7 @@ export default function ToolPanel() {
|
||||
const { t } = useTranslation();
|
||||
const { isRainbowMode } = useRainbowThemeContext();
|
||||
const { sidebarRefs } = useSidebarContext();
|
||||
const { toolPanelRef, quickAccessRef, rightRailRef } = sidebarRefs;
|
||||
const { toolPanelRef, quickAccessRef } = sidebarRefs;
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const {
|
||||
@@ -37,14 +40,18 @@ export default function ToolPanel() {
|
||||
setSearchQuery,
|
||||
selectedToolKey,
|
||||
handleToolSelect,
|
||||
handleBackToTools,
|
||||
setPreviewFile,
|
||||
toolPanelMode,
|
||||
setToolPanelMode,
|
||||
setLeftPanelView,
|
||||
setReaderMode,
|
||||
setSidebarsVisible,
|
||||
sidebarsVisible,
|
||||
readerMode,
|
||||
} = useToolWorkflow();
|
||||
|
||||
const { setAllRightRailButtonsDisabled } = useRightRail();
|
||||
const { setAllButtonsDisabled } = useWorkbenchBar();
|
||||
const { preferences, updatePreference } = usePreferences();
|
||||
|
||||
const isFullscreenMode = toolPanelMode === "fullscreen";
|
||||
@@ -54,41 +61,54 @@ export default function ToolPanel() {
|
||||
leftPanelView === "toolPicker" &&
|
||||
!isMobile &&
|
||||
toolPickerVisible;
|
||||
const isRTL =
|
||||
typeof document !== "undefined" && document.documentElement.dir === "rtl";
|
||||
|
||||
// Disable right rail buttons when fullscreen mode is active
|
||||
// Disable workbench bar buttons when fullscreen mode is active
|
||||
useEffect(() => {
|
||||
setAllRightRailButtonsDisabled(fullscreenExpanded);
|
||||
}, [fullscreenExpanded, setAllRightRailButtonsDisabled]);
|
||||
setAllButtonsDisabled(fullscreenExpanded);
|
||||
}, [fullscreenExpanded, setAllButtonsDisabled]);
|
||||
|
||||
const fullscreenGeometry = useToolPanelGeometry({
|
||||
enabled: fullscreenExpanded,
|
||||
toolPanelRef,
|
||||
quickAccessRef,
|
||||
rightRailRef,
|
||||
});
|
||||
|
||||
const toggleLabel = isFullscreenMode
|
||||
? t("toolPanel.toggle.sidebar", "Switch to sidebar mode")
|
||||
: t("toolPanel.toggle.fullscreen", "Switch to fullscreen mode");
|
||||
|
||||
const handleModeToggle = () => {
|
||||
const nextMode = isFullscreenMode ? "sidebar" : "fullscreen";
|
||||
setToolPanelMode(nextMode);
|
||||
|
||||
if (nextMode === "fullscreen" && leftPanelView !== "toolPicker") {
|
||||
setLeftPanelView("toolPicker");
|
||||
}
|
||||
const handleExpand = () => {
|
||||
if (readerMode) setReaderMode(false);
|
||||
if (leftPanelView === "hidden") setLeftPanelView("toolPicker");
|
||||
if (!sidebarsVisible) setSidebarsVisible(true);
|
||||
};
|
||||
|
||||
const handleCollapse = () => {
|
||||
setLeftPanelView("hidden");
|
||||
};
|
||||
|
||||
const [focusSearch, setFocusSearch] = useState(false);
|
||||
const focusSearchOnNextOpen = useRef(false);
|
||||
|
||||
const handleExpandAndSearch = () => {
|
||||
focusSearchOnNextOpen.current = true;
|
||||
handleExpand();
|
||||
};
|
||||
|
||||
// Once the panel becomes visible, consume the focus-search request
|
||||
useEffect(() => {
|
||||
if (isPanelVisible && focusSearchOnNextOpen.current) {
|
||||
focusSearchOnNextOpen.current = false;
|
||||
setFocusSearch(true);
|
||||
// Reset after one render so autoFocus doesn't re-fire on subsequent renders
|
||||
const id = setTimeout(() => setFocusSearch(false), 100);
|
||||
return () => clearTimeout(id);
|
||||
}
|
||||
}, [isPanelVisible]);
|
||||
|
||||
const computedWidth = () => {
|
||||
if (isMobile) {
|
||||
return "100%";
|
||||
}
|
||||
|
||||
if (!isPanelVisible) {
|
||||
return "0";
|
||||
return "3.5rem";
|
||||
}
|
||||
|
||||
return "18.5rem";
|
||||
@@ -109,7 +129,7 @@ export default function ToolPanel() {
|
||||
ref={toolPanelRef}
|
||||
data-sidebar="tool-panel"
|
||||
data-tour={fullscreenExpanded ? undefined : "tool-panel"}
|
||||
className={`tool-panel flex flex-col ${fullscreenExpanded ? "tool-panel--fullscreen-active" : "overflow-hidden"} bg-[var(--bg-toolbar)] border-r border-[var(--border-subtle)] transition-all duration-300 ease-out ${
|
||||
className={`tool-panel flex flex-col ${fullscreenExpanded ? "tool-panel--fullscreen-active" : "overflow-hidden"} bg-[var(--bg-toolbar)] border-l border-[var(--border-subtle)] transition-all duration-300 ease-out ${
|
||||
isRainbowMode ? rainbowStyles.rainbowPaper : ""
|
||||
} ${isMobile ? "h-full border-r-0" : "h-screen"} ${fullscreenExpanded ? "tool-panel--fullscreen" : ""}`}
|
||||
style={{
|
||||
@@ -117,21 +137,52 @@ export default function ToolPanel() {
|
||||
padding: "0",
|
||||
}}
|
||||
>
|
||||
{!fullscreenExpanded && (
|
||||
{!fullscreenExpanded && !isPanelVisible && !isMobile && (
|
||||
<div className="tool-panel__collapsed-strip">
|
||||
<ActionIcon
|
||||
variant="outline"
|
||||
color="gray.4"
|
||||
radius="xl"
|
||||
size="md"
|
||||
className="tool-panel__expand-btn"
|
||||
onClick={handleExpand}
|
||||
aria-label={t("toolPanel.expand", "Expand panel")}
|
||||
>
|
||||
<ChevronLeftIcon sx={{ fontSize: "1.1rem" }} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
radius="md"
|
||||
size="md"
|
||||
className="tool-panel__collapsed-search-btn"
|
||||
onClick={handleExpandAndSearch}
|
||||
aria-label={t("toolPanel.search", "Search tools")}
|
||||
style={{ marginTop: "8px" }}
|
||||
>
|
||||
<SearchIcon sx={{ fontSize: "1.25rem" }} />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!fullscreenExpanded && isPanelVisible && (
|
||||
<div
|
||||
style={{
|
||||
opacity: isMobile || isPanelVisible ? 1 : 0,
|
||||
opacity: 1,
|
||||
transition: "opacity 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
{/* Viewer mode tools — annotate, redact, form fill */}
|
||||
<ToolPanelViewerBar />
|
||||
|
||||
<div
|
||||
className="tool-panel__search-row"
|
||||
style={{
|
||||
backgroundColor: "var(--tool-panel-search-bg)",
|
||||
borderBottom: "1px solid var(--tool-panel-search-border-bottom)",
|
||||
backgroundColor: "transparent",
|
||||
borderBottom: "1px solid var(--border-subtle)",
|
||||
}}
|
||||
>
|
||||
<ToolSearch
|
||||
@@ -139,29 +190,19 @@ export default function ToolPanel() {
|
||||
onChange={setSearchQuery}
|
||||
toolRegistry={toolRegistry}
|
||||
mode="filter"
|
||||
autoFocus={focusSearch}
|
||||
/>
|
||||
{!isMobile && leftPanelView === "toolPicker" && (
|
||||
<Tooltip
|
||||
content={toggleLabel}
|
||||
position="bottom"
|
||||
arrow={true}
|
||||
openOnFocus={false}
|
||||
>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="xl"
|
||||
style={{ color: "var(--right-rail-icon)" }}
|
||||
onClick={handleModeToggle}
|
||||
aria-label={toggleLabel}
|
||||
className="tool-panel__mode-toggle"
|
||||
>
|
||||
<DoubleArrowIcon
|
||||
fontSize="small"
|
||||
style={{ transform: isRTL ? "scaleX(-1)" : undefined }}
|
||||
/>
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
<ActionIcon
|
||||
variant="outline"
|
||||
radius="xl"
|
||||
size="md"
|
||||
onClick={handleCollapse}
|
||||
aria-label={t("toolPanel.collapse", "Collapse panel")}
|
||||
className="tool-panel__expand-btn"
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
<ChevronRightIcon sx={{ fontSize: "1.1rem" }} />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
|
||||
{searchQuery.trim().length > 0 ? (
|
||||
@@ -185,6 +226,26 @@ export default function ToolPanel() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<div
|
||||
style={{
|
||||
borderBottom: "1px solid var(--border-subtle)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="light"
|
||||
color="blue"
|
||||
size="sm"
|
||||
fullWidth
|
||||
radius={0}
|
||||
leftSection={<ArrowBackIcon sx={{ fontSize: "0.9rem" }} />}
|
||||
onClick={handleBackToTools}
|
||||
aria-label={t("toolPanel.backToTools", "Back to tools")}
|
||||
styles={{ root: { justifyContent: "flex-start" } }}
|
||||
>
|
||||
{t("toolPanel.backToTools", "Back to tools")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<ScrollArea h="100%">
|
||||
{selectedToolKey ? (
|
||||
@@ -224,7 +285,6 @@ export default function ToolPanel() {
|
||||
)
|
||||
}
|
||||
onExitFullscreenMode={() => setToolPanelMode("sidebar")}
|
||||
toggleLabel={toggleLabel}
|
||||
geometry={fullscreenGeometry}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import React, { useCallback } from "react";
|
||||
import { ActionIcon } from "@mantine/core";
|
||||
import { useWorkbenchBar } from "@app/contexts/WorkbenchBarContext";
|
||||
import { useNavigationState } from "@app/contexts/NavigationContext";
|
||||
import { Tooltip } from "@app/components/shared/Tooltip";
|
||||
import type {
|
||||
WorkbenchBarButtonConfig,
|
||||
WorkbenchBarRenderContext,
|
||||
} from "@app/types/workbenchBar";
|
||||
|
||||
/**
|
||||
* Mini toolbar rendered at the top of the ToolPanel when in viewer mode.
|
||||
* Shows "tool-panel" section buttons — viewer mode tools like annotate, redact, form fill.
|
||||
*/
|
||||
export function ToolPanelViewerBar() {
|
||||
const { workbench } = useNavigationState();
|
||||
const { buttons, actions, allButtonsDisabled } = useWorkbenchBar();
|
||||
|
||||
const toolPanelButtons = buttons
|
||||
.filter((btn) => btn.section === "tool-panel" && (btn.visible ?? true))
|
||||
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||||
|
||||
const renderButton = useCallback(
|
||||
(btn: WorkbenchBarButtonConfig) => {
|
||||
const action = actions[btn.id];
|
||||
const disabled = Boolean(btn.disabled || allButtonsDisabled);
|
||||
const isActive = Boolean(btn.active);
|
||||
|
||||
const triggerAction = () => {
|
||||
if (!disabled) action?.();
|
||||
};
|
||||
|
||||
if (btn.render) {
|
||||
const context: WorkbenchBarRenderContext = {
|
||||
id: btn.id,
|
||||
disabled,
|
||||
allButtonsDisabled,
|
||||
action,
|
||||
triggerAction,
|
||||
active: isActive,
|
||||
};
|
||||
return btn.render(context) ?? null;
|
||||
}
|
||||
|
||||
if (!btn.icon) return null;
|
||||
|
||||
const ariaLabel =
|
||||
btn.ariaLabel ||
|
||||
(typeof btn.tooltip === "string" ? btn.tooltip : undefined);
|
||||
const buttonNode = (
|
||||
<ActionIcon
|
||||
variant={isActive ? "filled" : "subtle"}
|
||||
color={isActive ? "blue" : undefined}
|
||||
radius="md"
|
||||
className="workbench-bar-action-icon"
|
||||
onClick={triggerAction}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel}
|
||||
aria-pressed={isActive || undefined}
|
||||
>
|
||||
{btn.icon}
|
||||
</ActionIcon>
|
||||
);
|
||||
|
||||
if (!btn.tooltip) return buttonNode;
|
||||
return (
|
||||
<Tooltip
|
||||
content={btn.tooltip}
|
||||
position="bottom"
|
||||
offset={6}
|
||||
arrow
|
||||
portalTarget={document.body}
|
||||
>
|
||||
<div style={{ display: "inline-flex" }}>{buttonNode}</div>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
[actions, allButtonsDisabled],
|
||||
);
|
||||
|
||||
if (workbench !== "viewer" || toolPanelButtons.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="tool-panel-viewer-bar">
|
||||
{toolPanelButtons.map((btn) => {
|
||||
const content = renderButton(btn);
|
||||
if (!content) return null;
|
||||
return (
|
||||
<div
|
||||
key={btn.id}
|
||||
style={{ display: "inline-flex", alignItems: "center" }}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -65,12 +65,12 @@ const ToolPicker = ({
|
||||
isSearching ? filteredTools : emptyFilteredTools;
|
||||
const { searchGroups } = useToolSections(effectiveFilteredForSearch);
|
||||
const headerTextStyle: React.CSSProperties = {
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: 500,
|
||||
padding: "0.5rem 0 0.25rem 0.5rem",
|
||||
textTransform: "none",
|
||||
color: "var(--text-secondary, rgba(0, 0, 0, 0.6))",
|
||||
opacity: 0.7,
|
||||
fontSize: "0.68rem",
|
||||
fontWeight: 600,
|
||||
padding: "1rem 0 0.35rem 0.5rem",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.06em",
|
||||
color: "var(--text-muted)",
|
||||
};
|
||||
const toTitleCase = (s: string) =>
|
||||
s.replace(
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from "@mantine/core";
|
||||
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
|
||||
import CheckIcon from "@mui/icons-material/Check";
|
||||
import { useFileSelection } from "@app/contexts/FileContext";
|
||||
import { useViewScopedFiles } from "@app/hooks/tools/shared/useViewScopedFiles";
|
||||
import { useToolRegistry } from "@app/contexts/ToolRegistryContext";
|
||||
import { AutomationConfig, ExecutionStep } from "@app/types/automation";
|
||||
import { EXECUTION_STATUS } from "@app/constants/automation";
|
||||
@@ -29,7 +29,7 @@ export default function AutomationRun({
|
||||
automateOperation,
|
||||
}: AutomationRunProps) {
|
||||
const { t } = useTranslation();
|
||||
const { selectedFiles } = useFileSelection();
|
||||
const selectedFiles = useViewScopedFiles();
|
||||
const { regularTools } = useToolRegistry();
|
||||
const toolRegistry = regularTools;
|
||||
const cleanup = useResourceCleanup();
|
||||
|
||||
@@ -13,6 +13,9 @@ import {
|
||||
} from "@app/components/tools/compare/compare";
|
||||
import CompareNavigationDropdown from "@app/components/tools/compare/CompareNavigationDropdown";
|
||||
import { useIsMobile } from "@app/hooks/useIsMobile";
|
||||
import { truncateCenter } from "@app/utils/textUtils";
|
||||
|
||||
const COMPARE_PANE_TITLE_MAX_LEN = 48;
|
||||
|
||||
// utilities moved to compare.ts
|
||||
|
||||
@@ -107,9 +110,14 @@ const CompareDocumentPane = ({
|
||||
return (
|
||||
<div className="compare-pane">
|
||||
<div className="compare-header">
|
||||
<Group justify="space-between" align="center">
|
||||
<Text fw={600} size="lg">
|
||||
{title}
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Text
|
||||
fw={600}
|
||||
size="lg"
|
||||
style={{ minWidth: 0, flex: 1 }}
|
||||
title={title || undefined}
|
||||
>
|
||||
{title ? truncateCenter(title, COMPARE_PANE_TITLE_MAX_LEN) : title}
|
||||
</Text>
|
||||
<Group justify="flex-end" align="center" gap="sm" wrap="nowrap">
|
||||
{(changes.length > 0 || Boolean(dropdownPlaceholder)) && (
|
||||
|
||||
@@ -14,14 +14,14 @@ import {
|
||||
import { CompareResultData, CompareWorkbenchData } from "@app/types/compare";
|
||||
import ComparePixelWorkbenchView from "@app/components/tools/compare/ComparePixelWorkbenchView";
|
||||
import { useFileContext } from "@app/contexts/file/fileHooks";
|
||||
import { useRightRailButtons } from "@app/hooks/useRightRailButtons";
|
||||
import { useWorkbenchBarButtons } from "@app/hooks/useWorkbenchBarButtons";
|
||||
import CompareDocumentPane from "@app/components/tools/compare/CompareDocumentPane";
|
||||
import { useComparePagePreviews } from "@app/components/tools/compare/hooks/useComparePagePreviews";
|
||||
import { useComparePanZoom } from "@app/components/tools/compare/hooks/useComparePanZoom";
|
||||
import { useCompareHighlights } from "@app/components/tools/compare/hooks/useCompareHighlights";
|
||||
import { useCompareChangeNavigation } from "@app/components/tools/compare/hooks/useCompareChangeNavigation";
|
||||
import "@app/components/tools/compare/compareView.css";
|
||||
import { useCompareRightRailButtons } from "@app/components/tools/compare/hooks/useCompareRightRailButtons";
|
||||
import { useCompareWorkbenchBarButtons } from "@app/components/tools/compare/hooks/useCompareWorkbenchBarButtons";
|
||||
import {
|
||||
alert,
|
||||
updateToast,
|
||||
@@ -209,7 +209,7 @@ const CompareTextWorkbenchView = ({ data }: CompareTextWorkbenchViewProps) => {
|
||||
})
|
||||
);
|
||||
|
||||
const rightRailButtons = useCompareRightRailButtons({
|
||||
const workbenchBarButtons = useCompareWorkbenchBarButtons({
|
||||
layout,
|
||||
toggleLayout,
|
||||
isPanMode,
|
||||
@@ -230,7 +230,7 @@ const CompareTextWorkbenchView = ({ data }: CompareTextWorkbenchViewProps) => {
|
||||
comparisonScrollRef,
|
||||
});
|
||||
|
||||
useRightRailButtons(rightRailButtons);
|
||||
useWorkbenchBarButtons(workbenchBarButtons);
|
||||
|
||||
// Rendering progress toast for very large PDFs
|
||||
const LARGE_PAGE_THRESHOLD = 400; // show banner when one or both exceed threshold
|
||||
|
||||
@@ -23,10 +23,9 @@
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
padding: 1rem;
|
||||
padding-top: 3rem;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
/* Allow the custom workbench to shrink within flex parents (prevents pushing right rail off-screen) */
|
||||
/* Allow the custom workbench to shrink within flex parents */
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -522,6 +521,124 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Compare tool — file selection step (Original / Edited + swap column) */
|
||||
.compare-step-selection {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.compare-step-selection__clear-row {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Thumbnails + swap only — swap top aligns with first thumbnail, not “Original PDF” heading */
|
||||
.compare-step-selection__thumbs-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.compare-step-selection__thumbs-col {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Full-height light bar to the right of both cards (reference UI) */
|
||||
.compare-step-selection__swap {
|
||||
flex-shrink: 0;
|
||||
width: 36px;
|
||||
min-width: 36px;
|
||||
align-self: stretch;
|
||||
margin: 0;
|
||||
padding: 0.5rem 0.2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid
|
||||
color-mix(in srgb, var(--mantine-color-blue-4) 28%, var(--border-default));
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--mantine-color-blue-0) 92%,
|
||||
var(--mantine-color-body)
|
||||
);
|
||||
color: var(--mantine-color-blue-filled);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
transition:
|
||||
background 0.12s ease,
|
||||
border-color 0.12s ease;
|
||||
}
|
||||
|
||||
.compare-step-selection__swap:hover:not(:disabled) {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--mantine-color-blue-1) 75%,
|
||||
var(--mantine-color-body)
|
||||
);
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--mantine-color-blue-5) 35%,
|
||||
var(--border-default)
|
||||
);
|
||||
}
|
||||
|
||||
.compare-step-selection__swap:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.compare-step-selection__swap-icon {
|
||||
font-size: 1.35rem !important;
|
||||
color: var(--mantine-color-blue-filled);
|
||||
}
|
||||
|
||||
.compare-step-selection__swap-label {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.15;
|
||||
text-align: center;
|
||||
color: var(--mantine-color-blue-filled);
|
||||
writing-mode: horizontal-tb;
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="”dark”"] .compare-step-selection__swap {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--mantine-color-blue-9) 22%,
|
||||
var(--mantine-color-dark-6)
|
||||
);
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--mantine-color-blue-4) 40%,
|
||||
var(--border-default)
|
||||
);
|
||||
color: var(--mantine-color-blue-3);
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="”dark”"]
|
||||
.compare-step-selection__swap:hover:not(:disabled) {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--mantine-color-blue-9) 32%,
|
||||
var(--mantine-color-dark-6)
|
||||
);
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="”dark”"] .compare-step-selection__swap-icon,
|
||||
[data-mantine-color-scheme="”dark”"] .compare-step-selection__swap-label {
|
||||
color: var(--mantine-color-blue-3);
|
||||
}
|
||||
|
||||
/* Pixel compare mode */
|
||||
.compare-pixel-workbench {
|
||||
padding: 16px 24px 32px;
|
||||
|
||||
+7
-7
@@ -4,12 +4,12 @@ import { useTranslation } from "react-i18next";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import { alert } from "@app/components/toast";
|
||||
import type { ToastLocation } from "@app/components/toast/types";
|
||||
import type { RightRailButtonWithAction } from "@app/hooks/useRightRailButtons";
|
||||
import type { WorkbenchBarButtonWithAction } from "@app/hooks/useWorkbenchBarButtons";
|
||||
import { useIsMobile } from "@app/hooks/useIsMobile";
|
||||
|
||||
type Pane = "base" | "comparison";
|
||||
|
||||
export interface UseCompareRightRailButtonsOptions {
|
||||
export interface UseCompareWorkbenchBarButtonsOptions {
|
||||
layout: "side-by-side" | "stacked";
|
||||
toggleLayout: () => void;
|
||||
isPanMode: boolean;
|
||||
@@ -30,7 +30,7 @@ export interface UseCompareRightRailButtonsOptions {
|
||||
comparisonScrollRef?: React.RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
export const useCompareRightRailButtons = ({
|
||||
export const useCompareWorkbenchBarButtons = ({
|
||||
layout,
|
||||
toggleLayout,
|
||||
isPanMode,
|
||||
@@ -49,11 +49,11 @@ export const useCompareRightRailButtons = ({
|
||||
zoomLimits,
|
||||
baseScrollRef,
|
||||
comparisonScrollRef,
|
||||
}: UseCompareRightRailButtonsOptions): RightRailButtonWithAction[] => {
|
||||
}: UseCompareWorkbenchBarButtonsOptions): WorkbenchBarButtonWithAction[] => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
return useMemo<RightRailButtonWithAction[]>(
|
||||
return useMemo<WorkbenchBarButtonWithAction[]>(
|
||||
() => [
|
||||
{
|
||||
id: "compare-toggle-layout",
|
||||
@@ -212,6 +212,6 @@ export const useCompareRightRailButtons = ({
|
||||
);
|
||||
};
|
||||
|
||||
export type UseCompareRightRailButtonsReturn = ReturnType<
|
||||
typeof useCompareRightRailButtons
|
||||
export type UseCompareWorkbenchBarButtonsReturn = ReturnType<
|
||||
typeof useCompareWorkbenchBarButtons
|
||||
>;
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import { useTranslation } from "react-i18next";
|
||||
import RestartAltIcon from "@mui/icons-material/RestartAlt";
|
||||
import { CropParametersHook } from "@app/hooks/tools/crop/useCropParameters";
|
||||
import { useSelectedFiles } from "@app/contexts/file/fileHooks";
|
||||
import { useAllFiles } from "@app/contexts/FileContext";
|
||||
import CropAreaSelector from "@app/components/tools/crop/CropAreaSelector";
|
||||
import CropCoordinateInputs from "@app/components/tools/crop/CropCoordinateInputs";
|
||||
import { DEFAULT_CROP_AREA } from "@app/constants/cropConstants";
|
||||
@@ -34,17 +34,17 @@ const CONTAINER_SIZE = 250; // Fit within actual pane width
|
||||
|
||||
const CropSettings = ({ parameters, disabled = false }: CropSettingsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { selectedFiles, selectedFileStubs } = useSelectedFiles();
|
||||
const { files, fileStubs } = useAllFiles();
|
||||
|
||||
// Get the first selected file for preview
|
||||
// Get the first file for preview
|
||||
const selectedStub = useMemo(() => {
|
||||
return selectedFileStubs.length > 0 ? selectedFileStubs[0] : null;
|
||||
}, [selectedFileStubs]);
|
||||
return fileStubs.length > 0 ? fileStubs[0] : null;
|
||||
}, [fileStubs]);
|
||||
|
||||
// Get the first selected file for PDF processing
|
||||
// Get the first file for PDF processing
|
||||
const selectedFile = useMemo(() => {
|
||||
return selectedFiles.length > 0 ? selectedFiles[0] : null;
|
||||
}, [selectedFiles]);
|
||||
return files.length > 0 ? files[0] : null;
|
||||
}, [files]);
|
||||
|
||||
// Get thumbnail for the selected file
|
||||
const [thumbnail, setThumbnail] = useState<string | null>(null);
|
||||
|
||||
@@ -76,9 +76,7 @@ const CompactToolItem: React.FC<CompactToolItemProps> = ({
|
||||
) : null}
|
||||
<span className="tool-panel__fullscreen-list-body">
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
||||
<Text fw={600} size="sm" className="tool-panel__fullscreen-name">
|
||||
{tool.name}
|
||||
</Text>
|
||||
<Text className="tool-panel__fullscreen-name">{tool.name}</Text>
|
||||
{tool.versionStatus === "alpha" && (
|
||||
<Badge size="xs" variant="light" color="orange">
|
||||
{t("toolPanel.alpha", "Alpha")}
|
||||
|
||||
@@ -78,9 +78,7 @@ const DetailedToolItem: React.FC<DetailedToolItemProps> = ({
|
||||
) : null}
|
||||
<span className="tool-panel__fullscreen-body">
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
||||
<Text fw={600} size="sm" className="tool-panel__fullscreen-name">
|
||||
{tool.name}
|
||||
</Text>
|
||||
<Text className="tool-panel__fullscreen-name">{tool.name}</Text>
|
||||
{tool.versionStatus === "alpha" && (
|
||||
<Badge size="xs" variant="light" color="orange">
|
||||
{/* we can add more translations for different badges in future, like beta, etc. */}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
|
||||
import RotateLeftIcon from "@mui/icons-material/RotateLeft";
|
||||
import RotateRightIcon from "@mui/icons-material/RotateRight";
|
||||
import { RotateParametersHook } from "@app/hooks/tools/rotate/useRotateParameters";
|
||||
import { useSelectedFiles } from "@app/contexts/file/fileHooks";
|
||||
import { useAllFiles } from "@app/contexts/FileContext";
|
||||
import DocumentThumbnail from "@app/components/shared/filePreview/DocumentThumbnail";
|
||||
|
||||
interface RotateSettingsProps {
|
||||
@@ -17,12 +17,12 @@ const RotateSettings = ({
|
||||
disabled = false,
|
||||
}: RotateSettingsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { selectedFileStubs } = useSelectedFiles();
|
||||
const { fileStubs } = useAllFiles();
|
||||
|
||||
// Get the first selected file for preview
|
||||
// Get the first file for preview
|
||||
const selectedStub = useMemo(() => {
|
||||
return selectedFileStubs.length > 0 ? selectedFileStubs[0] : null;
|
||||
}, [selectedFileStubs]);
|
||||
return fileStubs.length > 0 ? fileStubs[0] : null;
|
||||
}, [fileStubs]);
|
||||
|
||||
// Get thumbnail for the selected file
|
||||
const [thumbnail, setThumbnail] = useState<string | null>(null);
|
||||
|
||||
@@ -59,17 +59,12 @@ const FileStatusIndicator = ({
|
||||
|
||||
const getPlaceholder = () => {
|
||||
if (minFiles === undefined || minFiles === 1) {
|
||||
return t(
|
||||
"files.selectFromWorkbench",
|
||||
"Select files from the workbench or ",
|
||||
);
|
||||
return t("files.selectFromWorkbench", "Add files to the workbench or ");
|
||||
} else {
|
||||
return t(
|
||||
"files.selectMultipleFromWorkbench",
|
||||
"Select at least {{count}} files from the workbench or ",
|
||||
{
|
||||
count: minFiles,
|
||||
},
|
||||
"Add at least {{count}} files to the workbench or ",
|
||||
{ count: minFiles },
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -171,14 +166,12 @@ const FileStatusIndicator = ({
|
||||
✓{" "}
|
||||
{selectedFiles.length === 1 ? (
|
||||
<PrivateContent>
|
||||
{t("fileSelected", "Selected: {{filename}}", {
|
||||
{t("fileSelected", "{{filename}}", {
|
||||
filename: selectedFiles[0]?.name,
|
||||
})}
|
||||
</PrivateContent>
|
||||
) : (
|
||||
t("filesSelected", "{{count}} files selected", {
|
||||
count: selectedFiles.length,
|
||||
})
|
||||
t("filesSelected", "{{count}} files", { count: selectedFiles.length })
|
||||
)}
|
||||
</Text>
|
||||
);
|
||||
|
||||
@@ -58,6 +58,10 @@ const OperationButton = ({
|
||||
"tool.endpointUnavailable",
|
||||
"This tool is unavailable on your server.",
|
||||
),
|
||||
filesLoading: t(
|
||||
"tool.filesLoading",
|
||||
"Files are still loading, please wait.",
|
||||
),
|
||||
noFiles: t("tool.noFiles", "Add a file to get started."),
|
||||
invalidParams: t("tool.invalidParams", "Fill in the required settings."),
|
||||
viewerMode: t(
|
||||
|
||||
@@ -120,6 +120,7 @@ function ReviewStepContent<TParams = unknown>({
|
||||
|
||||
{onUndo && (
|
||||
<Tooltip
|
||||
position="left"
|
||||
content={t(
|
||||
"undoOperationTooltip",
|
||||
"Click to undo the last operation and restore the original files",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Text } from "@mantine/core";
|
||||
import { Text, Box, Button } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import OperationButton, {
|
||||
OperationButtonProps,
|
||||
@@ -18,8 +18,7 @@ export interface ScopedOperationButtonProps extends OperationButtonProps {
|
||||
*
|
||||
* - Viewer mode (multiple files loaded): appends "(this file)" to button text and
|
||||
* shows a note naming the exact file that will be processed.
|
||||
* - File-editor mode with N>1 selected files: appends "(N files)" to button text.
|
||||
* - File-editor mode with 0 selected files: shows a hint to select files.
|
||||
* - N>1 files loaded: appends "(N files)" to button text.
|
||||
* - All other cases: no change to button text or layout.
|
||||
*/
|
||||
export function ScopedOperationButton({
|
||||
@@ -30,7 +29,14 @@ export function ScopedOperationButton({
|
||||
const { t } = useTranslation();
|
||||
const { workbench } = useNavigationState();
|
||||
const { activeFileIndex } = useViewer();
|
||||
const { files: allFiles } = useAllFiles();
|
||||
const { files: allFiles, fileIds } = useAllFiles();
|
||||
|
||||
// Disable until all files are hydrated — running early would silently skip unloaded files.
|
||||
const isFilesHydrating = fileIds.length > allFiles.length;
|
||||
const effectiveDisabledReason =
|
||||
isFilesHydrating && props.disabledReason !== "endpointUnavailable"
|
||||
? "filesLoading"
|
||||
: props.disabledReason;
|
||||
|
||||
const isViewerMode = workbench === "viewer";
|
||||
const isFileEditorMode = workbench === "fileEditor";
|
||||
@@ -56,6 +62,11 @@ export function ScopedOperationButton({
|
||||
? allFiles[activeFileIndex]?.name
|
||||
: null;
|
||||
|
||||
const pendingCount = fileIds.length - allFiles.length;
|
||||
const isBulkLoading = pendingCount > 1;
|
||||
const loadProgress = isBulkLoading
|
||||
? (allFiles.length / fileIds.length) * 100
|
||||
: 0;
|
||||
const showSelectFilesHint =
|
||||
!disableScopeHints &&
|
||||
isFileEditorMode &&
|
||||
@@ -64,7 +75,54 @@ export function ScopedOperationButton({
|
||||
|
||||
return (
|
||||
<>
|
||||
<OperationButton {...props} submitText={scopedText} />
|
||||
{isFilesHydrating ? (
|
||||
<Box mx="md" mt="md">
|
||||
<Button
|
||||
fullWidth
|
||||
disabled
|
||||
loading={!isBulkLoading}
|
||||
variant={props.variant ?? "filled"}
|
||||
color={props.color ?? "blue"}
|
||||
style={{
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
minHeight: "2.5rem",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
{isBulkLoading && (
|
||||
<>
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
width: `${loadProgress}%`,
|
||||
backgroundColor: "rgba(255,255,255,0.15)",
|
||||
transition: "width 0.3s ease",
|
||||
borderRadius: "inherit",
|
||||
}}
|
||||
/>
|
||||
<Text size="sm" style={{ position: "relative" }}>
|
||||
{t(
|
||||
"tool.filesLoadingProgress",
|
||||
"{{loaded}} / {{total}} files loading...",
|
||||
{
|
||||
loaded: allFiles.length,
|
||||
total: fileIds.length,
|
||||
},
|
||||
)}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</Box>
|
||||
) : (
|
||||
<OperationButton
|
||||
{...props}
|
||||
submitText={scopedText}
|
||||
disabledReason={effectiveDisabledReason}
|
||||
/>
|
||||
)}
|
||||
{viewerFileName && (
|
||||
<Text size="xs" c="dimmed" ta="center" mx="md" mt={2}>
|
||||
{t("tool.singleFileScope", "Only applying to: {{fileName}}", {
|
||||
|
||||
@@ -92,7 +92,7 @@
|
||||
border: 1px solid var(--mantine-color-gray-4);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: var(--right-rail-bg);
|
||||
background: var(--bg-file-manager);
|
||||
}
|
||||
|
||||
.showjs-toolbar {
|
||||
|
||||
@@ -201,13 +201,17 @@ const ToolButton: React.FC<ToolButtonProps> = ({
|
||||
handleUnlessSpecialClick(e, () => handleClick(id));
|
||||
};
|
||||
|
||||
const selectedStyles = isSelected
|
||||
? { backgroundColor: "#EAEAEA", color: "var(--tools-text-and-icon-color)" }
|
||||
: {};
|
||||
|
||||
const buttonElement = navProps ? (
|
||||
// For internal tools with URLs, render Button as an anchor for proper link behavior
|
||||
<Button
|
||||
component="a"
|
||||
href={navProps.href}
|
||||
onClick={navProps.onClick}
|
||||
variant={isSelected ? "filled" : "subtle"}
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
radius="md"
|
||||
fullWidth
|
||||
@@ -219,6 +223,7 @@ const ToolButton: React.FC<ToolButtonProps> = ({
|
||||
borderRadius: 0,
|
||||
color: "var(--tools-text-and-icon-color)",
|
||||
overflow: "visible",
|
||||
...selectedStyles,
|
||||
},
|
||||
label: { overflow: "visible" },
|
||||
}}
|
||||
@@ -233,7 +238,7 @@ const ToolButton: React.FC<ToolButtonProps> = ({
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={handleExternalClick}
|
||||
variant={isSelected ? "filled" : "subtle"}
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
radius="md"
|
||||
fullWidth
|
||||
@@ -245,6 +250,7 @@ const ToolButton: React.FC<ToolButtonProps> = ({
|
||||
borderRadius: 0,
|
||||
color: "var(--tools-text-and-icon-color)",
|
||||
overflow: "visible",
|
||||
...selectedStyles,
|
||||
},
|
||||
label: { overflow: "visible" },
|
||||
}}
|
||||
@@ -254,7 +260,7 @@ const ToolButton: React.FC<ToolButtonProps> = ({
|
||||
) : (
|
||||
// For unavailable tools, use regular button
|
||||
<Button
|
||||
variant={isSelected ? "filled" : "subtle"}
|
||||
variant="subtle"
|
||||
onClick={() => handleClick(id)}
|
||||
size="sm"
|
||||
radius="md"
|
||||
@@ -269,6 +275,7 @@ const ToolButton: React.FC<ToolButtonProps> = ({
|
||||
color: "var(--tools-text-and-icon-color)",
|
||||
cursor: visuallyUnavailable ? "not-allowed" : undefined,
|
||||
overflow: "visible",
|
||||
...selectedStyles,
|
||||
},
|
||||
label: { overflow: "visible" },
|
||||
}}
|
||||
@@ -292,7 +299,7 @@ const ToolButton: React.FC<ToolButtonProps> = ({
|
||||
{star}
|
||||
<Tooltip
|
||||
content={tooltipContent}
|
||||
position="right"
|
||||
position="left"
|
||||
arrow={true}
|
||||
delay={500}
|
||||
>
|
||||
|
||||
@@ -57,13 +57,16 @@
|
||||
|
||||
/* Compact tool buttons */
|
||||
.tool-button {
|
||||
font-size: 0.875rem; /* default 1rem - 0.125rem? We'll apply exact -0.25rem via calc below */
|
||||
padding-top: 0.375rem;
|
||||
padding-bottom: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
.tool-button .mantine-Button-label {
|
||||
font-size: 0.85rem;
|
||||
font-size: 0.84rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.tool-button-icon {
|
||||
|
||||
@@ -17,6 +17,7 @@ interface ToolSearchProps {
|
||||
selectedToolKey?: string | null;
|
||||
placeholder?: string;
|
||||
hideIcon?: boolean;
|
||||
iconOverride?: React.ReactNode;
|
||||
onFocus?: () => void;
|
||||
autoFocus?: boolean;
|
||||
}
|
||||
@@ -30,6 +31,7 @@ const ToolSearch = ({
|
||||
selectedToolKey,
|
||||
placeholder,
|
||||
hideIcon = false,
|
||||
iconOverride,
|
||||
onFocus,
|
||||
autoFocus = false,
|
||||
}: ToolSearchProps) => {
|
||||
@@ -96,10 +98,12 @@ const ToolSearch = ({
|
||||
placeholder || t("toolPicker.searchPlaceholder", "Search tools...")
|
||||
}
|
||||
icon={
|
||||
hideIcon ? undefined : (
|
||||
<LocalIcon icon="search-rounded" width="1.5rem" height="1.5rem" />
|
||||
)
|
||||
iconOverride ??
|
||||
(hideIcon ? undefined : (
|
||||
<LocalIcon icon="search-rounded" width="1.25rem" height="1.25rem" />
|
||||
))
|
||||
}
|
||||
iconClickable={!!iconOverride}
|
||||
autoComplete="off"
|
||||
onFocus={onFocus}
|
||||
/>
|
||||
|
||||
@@ -538,7 +538,7 @@ export function CommentsSidebar({
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: SIDEBAR_WIDTH,
|
||||
backgroundColor: "var(--right-rail-bg)",
|
||||
backgroundColor: "var(--bg-file-manager)",
|
||||
borderLeft: "1px solid var(--border-subtle)",
|
||||
zIndex: 998,
|
||||
display: "flex",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user