Compare commits

...
Author SHA1 Message Date
Reece db4903724c Initial setup 2026-05-19 14:53:51 +01:00
Reece a1fb7c71c4 fix(branding): use Wordmark SVG in FileSidebar and LandingPage
Replaces plain-text "Stirling PDF" renders with the <Wordmark>
component so brand surfaces stay consistent with AddFileCard.tsx.
2026-05-12 17:34:37 +01:00
EthanHealy01 2e4bfc3a5b tests 2026-05-08 13:50:36 +01:00
EthanHealy01 e65775319e merge main 2026-05-08 12:36:23 +01:00
EthanHealy01 9494ef9a26 Chore/remove dead right rail and convert to workbench bar (#6327)
Remove dead right rail and convert to workbench bar
2026-05-08 10:46:41 +01:00
EthanHealy01 23dc03832f Fixes/tour and desktop improvements (#6320)
Added back the desktop file isDirty indicator
<img width="414" height="385" alt="Screenshot 2026-05-07 at 1 36 19 PM"
src="https://github.com/user-attachments/assets/d2d79c46-469d-4bb2-9540-e86183dcd372"
/>

Also added back the tours and added tests to ensure we don't delete
parts of them in future by mistake
<img width="2056" height="1069" alt="Screenshot 2026-05-07 at 1 36
31 PM"
src="https://github.com/user-attachments/assets/987741a0-01aa-4a0e-9e06-584a52cc1e43"
/>
<img width="2056" height="1066" alt="Screenshot 2026-05-07 at 1 36
43 PM"
src="https://github.com/user-attachments/assets/48ea369c-816c-485d-889a-6ecce71b1855"
/>
2026-05-07 16:19:55 +01:00
EthanHealy01 b5f11c4efe Fixes to UI redesign staging (#6315)
- Upgrade banner is thinner
- Full screen tools list removed the button to toggle the setting and
minimize the view
- Language selector button fixed in dark mode
- Consolidation to thumbnail logic and improved rendering of fallback
thumbnails for non PDF/non image files
- Changed icons in the left side file selector to match design spec more
- Changed file picker modal to use the same icons as fallbacks as the
left sidebar file picker and the active files
- Added scrollbar to the left sidebar files list
2026-05-06 16:45:21 +01:00
EthanHealy01 cec414d11b Merge branch 'main' into UIRedesignStaging 2026-05-06 01:23:48 +01:00
EthanHealy01 5ca7e35ba9 linter 2026-05-05 19:18:32 +01:00
EthanHealy01 144c8afb0c Merge branch 'main' into UIRedesignStaging 2026-05-05 19:01:22 +01:00
EthanHealy01 14834723c9 forgot import 2026-05-05 18:54:15 +01:00
EthanHealy01 1d18b3db1a merge main 2026-05-05 18:49:48 +01:00
EthanHealy01 93ab2b4805 merge main 2026-05-05 11:54:46 +01:00
EthanHealy01 54805896d6 Merge remote-tracking branch 'origin/main' into UIRedesignStaging 2026-05-01 18:54:21 +01:00
EthanHealy01 0a73467ce5 Revert "fix: viewer stays on signed file instead of jumping to file A after tool run"
This reverts commit df056a8f2d.
2026-05-01 14:35:11 +01:00
EthanHealy01 df056a8f2d fix: viewer stays on signed file instead of jumping to file A after tool run
Remove the useEffect that cleared activeFileId when its file left the workbench.
That effect raced with tool operations: CONSUME_FILES atomically removes the input
and adds the output in one dispatch, but the clearing effect fired between the
dispatch and the tool's setActiveFileId(newId) call, clobbering the correct new ID
with null. activeFileIndex already falls back to 0 for stale/null IDs, so the
clearing effect was not needed for correctness.
2026-05-01 14:32:48 +01:00
EthanHealy01 34d9baa1be fix: move close icon from hamburger to search icon when search is active 2026-05-01 14:23:15 +01:00
EthanHealy01 d86259c596 Merge branch 'main' into UIRedesignStaging 2026-05-01 14:13:58 +01:00
EthanHealy01 178c3c3331 prettier 2026-05-01 14:04:33 +01:00
EthanHealy01 39e0fb630c fix: stay on multi-tool after first file upload
getStartupNavigationAction was unconditionally redirecting to viewer/
fileEditor on 0→N file transitions, overriding the multi-tool workbench.
Exempt multiTool the same way pdfTextEditor already is.
2026-05-01 12:17:14 +01:00
EthanHealy01 ed7a9d1a01 Merge branch 'main' into UIRedesignStaging 2026-05-01 12:10:38 +01:00
EthanHealy01 be69d071bc test(e2e): fix Firefox toggle click in settings spec
Click the visible label wrapper instead of force-clicking the hidden
checkbox input, which Firefox does not register as a state change.
2026-05-01 12:07:55 +01:00
EthanHealy01 a8671ec1f7 test(e2e): fix all stubbed Playwright test failures
Updated stubbed specs to match the redesigned UI: fixed locators for
WorkbenchBar buttons, FileSidebar search, navigation, settings dialog,
language selector, and in-document PDF search. All 130 stubbed tests
now pass (7 intentional skips for build-conditional features).
2026-05-01 10:55:19 +01:00
EthanHealy01 9cb40ad6ff Add missing workbenchBar.multiTool translation key 2026-04-30 21:31:07 +01:00
EthanHealy01 67fc78b6df Fix lint, formatting, and TOML parse errors post-merge
- Remove unused meta variable and getFileTypeMeta import from NonPdfViewer
- Fix TOML: move compare.edited.selectBaseFirst and compare.swap.label from
  dotted-key form in [compare] to their proper subsections
- Run Prettier on 7 files flagged by format check
2026-04-30 21:29:11 +01:00
EthanHealy01 b5b4bbc46b Merge branch 'main' into UIRedesignStaging
Resolved conflict in en-GB/translation.toml:
- Accept main's reorganised/alphabetised structure
- Restore UIRedesign wording (checked/add vs selected/select terminology)
- Add sections new to UIRedesign: fileSelectorPicker, fileSidebar, workbenchBar
- Add missing keys: rightRail.closeAll/share, tool.filesLoading/filesLoadingProgress,
  settings.general theme/language keys, fileEditor.pageCount, encryptedPdfUnlock viewer keys
2026-04-30 21:24:27 +01:00
EthanHealy01 849899f58c Merge remote-tracking branch 'origin/UIRedesignStaging' into UIRedesignStaging 2026-04-30 21:17:08 +01:00
EthanHealy01 a42fdfad00 UI redesign: multi-tool mode, workbench bar animation, PDFium rendering fixes
- Add multi-tool mode support across FileSidebar, FilesModalContext, PageEditorContext,
  WorkbenchBar, and ToolWorkflowContext — adding files stays in pageEditor view and
  auto-selects files; left panel hides/shows on workbench switch
- Animate WorkbenchBar in/out via CSS grid-template-rows trick when files are added/removed;
  suppress initial transition with rAF flag
- Fix PDFium pixel format (RGBA not BGRA) and rotation logic (effective dims already
  post-rotation; counter-rotate when applyRotation=false)
- Fix PageEditor flash of "No PDF files loaded" by checking file context directly
- Redesign AddFileCard: solid border, rounded corners, shadow hover, focus-visible ring
- Show AddFileCard in FileEditor empty state instead of text stack
- Add date grouping helpers (today/yesterday/thisWeek/thisMonth/older) to FileSidebar
- Redesign FilePdfIcon: solid filled with folded corner, red colour
- Simplify NonPdfBanner: remove file-type badge, keep only convert-to-PDF button
- Fix thumbnail precedence in useToolOperation (prefer metadata.thumbnailUrl)
- Fix fileActions stub dispatch to trigger selector re-render after ADD_FILES
- Switch Annotate to useAllFiles and Automate/AutomationRun to useViewScopedFiles
2026-04-30 21:16:58 +01:00
EthanHealy01 770577edb6 Merge branch 'main' into UIRedesignStaging 2026-04-24 11:00:53 +01:00
EthanHealy01 eaed8feb42 aikido change requests 2026-04-23 18:59:36 +01:00
EthanHealy01 a0f294e68e Merge branch 'main' into UIRedesignStaging 2026-04-23 14:36:38 +01:00
EthanHealy01 3b24b3479a Merge branch 'main' into UIRedesignStaging 2026-04-23 13:05:14 +01:00
EthanHealy01 fd1cf4468e prettier 2026-04-21 22:38:27 +01:00
EthanHealy01 04c5fbc590 change stuff for playwright test and remove test for drag and drop compare upload which no longer exists 2026-04-21 21:26:19 +01:00
EthanHealy01 d2f28e07ea Merge branch 'main' into UIRedesignStaging 2026-04-21 18:50:19 +01:00
EthanHealy01 2843418fb7 frontend validatoin 2026-04-21 18:47:23 +01:00
EthanHealy01 53377da75d test 2026-04-21 18:09:22 +01:00
EthanHealy01 93b1669f63 Merge branch 'main' of https://github.com/Stirling-Tools/Stirling-PDF into UIRedesignStaging 2026-04-21 16:35:51 +01:00
EthanHealy01 7dc164453b UIUX/UI redesign (#6107)
# PR: feature/UIRedesign → UIRedesignStaging

## Overview

Major layout restructure: the old right-rail icon column and top
file-tab strip are replaced by a new horizontal `WorkbenchBar` and a new
persistent `FileSidebar` on the left. Alongside those headline changes,
a range of bugs are fixed around viewer state, file management, and
thumbnail caching.

---

## Changes

### 1. Page layout restructured

**Before:** `QuickAccessBar` above content, `ToolPanel` on the left,
`RightRail` vertical icon column on the right.

**After:** `FileSidebar` on the left, `WorkbenchBar` across the top,
`ToolPanel` on the right. The right rail is gone.

---

### 2. `FileSidebar` — new persistent left sidebar

**Why:** The only way to see saved files was opening the file manager
modal. The sidebar gives quick access to everything in IndexedDB without
a modal.

**Before:** No persistent sidebar. Files managed exclusively via the
modal.

**After:**
- Collapsible strip on the left (collapses to a narrow icon strip,
expands to a file list).
- Shows all saved files, sorted by last modified. Files not yet
persisted to IndexedDB also appear.
- Click a file to toggle it on/off the workbench. If the file is open in
the viewer with unsaved changes, the save prompt fires first.
- Search bar filters by filename.
- Add files from computer or Google Drive directly from the sidebar.
- Thumbnail appears on hover, loaded lazily and cached for future
visits.
- The currently-viewed file is pinned at the top of the list with a
green highlight.
- List updates automatically when files are added or deleted anywhere in
the app.

---

### 3. `WorkbenchBar` replaces `TopControls` + `RightRail`

**Why:** `TopControls` only showed when files existed and only displayed
file tabs. The right rail was a separate vertical column. Neither gave a
coherent home for view switching, tool actions, and file operations.

**Before:** File tabs in a strip at the top; download/close/view buttons
in a separate vertical column on the right.

**After:**
- Always visible bar across the top.
- Left: Viewer / Active Files view switcher (hidden when no files are
open).
- Centre: Tool-specific action buttons (the same buttons that previously
lived in the right rail), plus zoom controls when in viewer mode.
- Right: Print (viewer only), Close (closes the current file in viewer
mode, closes all files in file editor mode), Download, Save As.
- Adapts to narrow widths by wrapping into two rows.


---

### 4. Viewer — active file never loses track after tool operations

**Why:** After running a tool on a file, the viewer would sometimes jump
to showing a different file.

**Before:** The "active file index" and "active file ID" were tracked as
two separate pieces of state that could desync when files were reordered
after a tool ran.

**After:** The index is always derived from the ID, so they can never
disagree. The viewer stays on the correct file after any operation.


### 5. Auto-navigate after file upload

**Before:** After uploading files via the modal, the user was left on
whatever view was active — no automatic navigation.

**After:** Single file uploaded → navigates to viewer. Multiple files
uploaded → navigates to Active Files.

---

### 6. Run button shows loading progress while files are still loading

**Why:** Opening a tool immediately after uploading many files could
silently skip files that hadn't finished loading yet.

**Before:** Run button was enabled immediately; files still being loaded
from storage were silently skipped.

**After:** While files are loading, the run button is replaced with a
disabled progress indicator. For multiple pending files it shows "2 / 5
files loading…". Once all files are ready, the button becomes active.

---

### 7. Bulk delete in file manager

**Why:** Deleting many files at once was slow and caused visible flicker
as each file was removed one by one.

**Before:** Each selected file went through the full single-delete flow
sequentially.

**After:** Local files are deleted in a single batch operation — one UI
update, one IndexedDB transaction. Server-connected files still go
through the sequential flow since each may need a confirmation prompt.

---

### 8. Deselecting a dirty file in the modal no longer removes it from
the workbench

**Before:** Deselecting a file in the file manager and clicking "Open"
would remove it from the workbench even if it had unsaved changes.

**After:** Files with unsaved changes are skipped when removing
deselected files.

---

### 9. File manager — "Close all files" button

**Before:** With nothing selected in the file manager, the "Open File"
button was disabled.

**After:** When nothing is selected but files are open, the button
changes to "Close all files" and becomes enabled.

---

### 10. File picker — search and hover thumbnails

**Before:** No way to filter the file list in the picker dropdown. No
visual preview.

**After:** Search input filters files by name. Hovering a row shows a
thumbnail preview.

---

### 11. Pan and ruler modes are now mutually exclusive

**Before:** Enabling the pan tool while the ruler was active left the
ruler in a broken state, and vice versa.

**After:** Activating either tool automatically deactivates the other.

---

### 12. Tool icons switched to outline style

All tool icons in the picker are now the outline variant instead of the
filled/solid variant, for a lighter, more consistent look.

---

### 13. Settings — language and theme options added to General settings

Language and theme (light/dark) controls are now available in Settings →
General.

---

## Test Script

---

### Sidebar and file management

1. **Sidebar toggle:** Confirm the left sidebar strip is visible on
load. Click expand — confirm the file list appears. Click collapse —
confirm it shrinks back to the strip.
2. **Upload and see files:** Upload 3 PDFs. Confirm all three appear in
the sidebar list without needing to open any modal.
3. **Sidebar search:** Type part of a filename in the sidebar search
box. Confirm the list filters. Clear it — confirm all files return.
4. **Toggle files on workbench from sidebar:** Click a file in the
sidebar that isn't on the workbench — confirm it opens. Click it again —
confirm it closes.
5. **Save prompt via sidebar:** Open a file in the viewer and make a
change (e.g. annotate). Click that file in the sidebar to close it —
confirm the unsaved-changes save prompt appears.
6. **Thumbnail on hover:** Hover a file row in the sidebar — confirm a
thumbnail preview appears.
7. **Currently viewed file pinned:** With multiple files open, confirm
the one you're viewing is highlighted at the top of the sidebar list.
8. **Sidebar reflects deletions:** Delete a file via the file manager
modal. Confirm the sidebar updates automatically without a page reload.

---

### Workbench bar

9. **No files state:** With no files open, confirm the workbench bar is
visible but no view tabs are shown.
10. **View tabs appear:** Upload a file — confirm "Viewer" and "Active
Files" tabs appear. Switch between them.
11. **Close button behaviour:** In Active Files with multiple files,
click Close — confirm all files close. Open a single file in viewer
mode, click Close — confirm only that file closes.
12. **Download:** Open a file in the viewer, click Download — confirm
the file saves.
13. **Zoom controls in viewer:** Confirm zoom in/out and a slider appear
in the bar's centre when in viewer mode. Drag the slider — confirm the
PDF zooms live. Switch to Active Files — confirm the controls disappear.

---

### Tool panel

14. **Collapse/expand:** Collapse the tool panel. Confirm a narrow strip
remains (not blank space). Click the expand icon — panel opens. Collapse
again, click the search icon — confirm the panel opens with the search
field already focused.
15. **Back button in tool:** Open a tool (e.g. Compress). Confirm the
search bar icon changes to a back arrow. Click it — confirm you return
to the tool list.
16. **Viewer tool mini bar:** Open a PDF in the viewer. Confirm a mini
toolbar appears at the top of the panel with viewer-specific buttons
(annotate, redact, form fill, etc.). Switch to Active Files — confirm it
disappears.

---

### Running tools with files loading

17. **Loading indicator on run button:** Upload 5+ PDFs and immediately
open a tool (e.g. Compress or Merge). If files are still loading,
confirm the run button shows a loading progress indicator and is
disabled. Wait until loading finishes — confirm the button becomes
active.

---

### Viewer correctness

18. **Active file stays put after tool run:** Open 3 PDFs. Navigate to
file #2 in the viewer. Run a tool (e.g. rotate) on file #2. Confirm the
viewer stays on file #2 after the operation completes.
19. **Pan / ruler mutual exclusion:** Open a PDF. Enable pan. Enable the
ruler — confirm pan turns off. Re-enable pan — confirm the ruler turns
off.

---

### File manager

20. **Bulk delete:** Open the file manager, select 3+ local files, click
Delete Selected. Confirm all disappear in one go with no flicker, and
the sidebar updates.
21. **Dirty file not removed on deselect:** Open a PDF, make a change.
Open the file manager, deselect that file, click Open. Confirm the file
is still on the workbench.
22. **Close all from modal:** Have files open. Open the file manager,
deselect everything — confirm the button changes to "Close all files".
Click it — confirm all files close.

---

### File picker (inside tools)

23. **Search in picker:** Open a tool with a file picker (e.g. Merge,
Compare). Open the picker dropdown. Type a partial name — confirm the
list filters. Hover a row — confirm a thumbnail appears.

---

### Other flows

24. **Auto-navigate after upload:** Upload a single PDF via the modal —
confirm you land in the viewer. Upload multiple at once — confirm you
land in Active Files.
25. **Landscape PDF aspect ratio:** Upload a landscape PDF. In Active
Files view, confirm its thumbnail card is wider than tall.
26. **Compare tool clear label:** Open the Compare tool. Click to clear
the selection. Confirm the confirm button reads "Clear Selected".
27. **Settings — language and theme:** Open Settings → General. Confirm
Language and Theme controls exist. Toggle theme — confirm it switches.
28. **Tool icons:** Open the tool picker and confirm all tool icons use
the outline/unfilled style.


<img width="2056" height="1077" alt="Screenshot 2026-04-14 at 5 08
36 PM"
src="https://github.com/user-attachments/assets/b68243cb-70f6-4aac-b4e8-76ca6c617be2"
/>

<img width="2056" height="1080" alt="Screenshot 2026-04-14 at 5 08
51 PM"
src="https://github.com/user-attachments/assets/b3c1525b-6f30-4abc-b85a-ce2368e376c7"
/>

<img width="2056" height="1081" alt="Screenshot 2026-04-14 at 5 09
10 PM"
src="https://github.com/user-attachments/assets/894bb5a4-dd4b-49ae-a7da-2ee343c6c656"
/>

<img width="2056" height="1080" alt="Screenshot 2026-04-14 at 5 09
31 PM"
src="https://github.com/user-attachments/assets/9d4dd1e4-026d-4736-891d-0ae6647318d4"
/>
2026-04-21 15:48:02 +01:00
EthanHealy01 be85773852 remove selected and de-selected logic and change the compare tool to accomadate (#6095)
# Changes
         
1. Files are no longer selected/deselected, they're just on the
workbench or not.
2. Compare tool logic was changed to accommodate the changes in file
selection
3. Added a re-usable file selector component that allows you to select
files from the workbench / saved files / upload files and used it in the
compare tool.
<img width="2056" height="1081" alt="Screenshot 2026-04-10 at 5 28
07 PM"
src="https://github.com/user-attachments/assets/e04f1c0e-91fc-4564-a6f1-b9b992ca4f70"
/>

3. Files on active files are cleaner looking, on hover shows buttons,
tool runs, versions
        
<img width="325" height="378" alt="Screenshot 2026-04-10 at 4 56 52 PM"
src="https://github.com/user-attachments/assets/d29d88fe-2c72-4c5f-9650-1376085bcff6"
/>
<img width="286" height="363" alt="Screenshot 2026-04-10 at 4 57 05 PM"
src="https://github.com/user-attachments/assets/387c1709-de22-4cc8-b658-9f4c9f192af1"
/>


# How to test
                                                            
## Compare tool
  1. Open the Compare tool with no files on the workbench
3. Click the Original PDF picker → confirm the popover opens with Saved
Files and Workbench tabs
5. Switch to Saved Files, pick a file → confirm it appears as a card but
does not appear in the workbench file list
6. Pick a second file for Edited PDF → confirm the Compare button
becomes active
7. Hit Compare → confirm results appear in the workbench view
8. Hit Swap → confirm a re-run is triggered and results update correctly
9. Use the upload button → confirm a thumbnail appears on the card
immediately
10. Clear selected → confirm both slots reset and the workbench view
closes
## FileSelectorPicker                                        
1. Open either slot picker and switch between Saved Files / Workbench
tabs — confirm the active tab is remembered when you open the other
slot's picker
  3. Sort by A–Z then reopen — confirm sort preference persists
4. Upload a PDF via the Upload tab button — confirm thumbnail shows in
the card, file appears in Saved Files on next open
5. With a file in the other slot, confirm it is hidden from both tab
lists (excludeIds)
## File editor
1. Load 3+ files, run any tool (e.g. Compress) — confirm it runs on all
files without needing to select any
  2. Confirm Close All removes all files from the workbench
2026-04-14 17:03:59 +01:00
402 changed files with 31983 additions and 4286 deletions
+33
View File
@@ -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
+10 -10
View File
@@ -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>
);
}
+2
View File
@@ -11,6 +11,8 @@
# production
/build
/dist
/dist-portal
/storybook-static
# misc
.DS_Store
+2
View File
@@ -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/
+56
View File
@@ -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;
+129
View File
@@ -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;
+36
View File
@@ -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: {
+1715
View File
File diff suppressed because it is too large Load Diff
+12
View File
@@ -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"
]
}
}
+15
View File
@@ -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>
+31
View File
@@ -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();
+349
View File
@@ -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,
}
}
+118 -41
View File
@@ -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 = "AZ"
[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"
+1 -2
View File
@@ -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>
+21 -2
View File
@@ -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}
>
@@ -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", "AZ")}
{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;
}
}
+113 -53
View File
@@ -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;
@@ -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