From d42b7796443ca1c738097f4aebb74c49e475b37f Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Wed, 27 May 2026 12:52:46 +0100 Subject: [PATCH] Add server-side folders and files page UI (#6383) --- .github/workflows/PR-Auto-Deploy-V2.yml | 3 +- .gitignore | 3 + .../software/common/util/RequestUriUtils.java | 14 +- .../common/util/RequestUriUtilsTest.java | 15 +- .../web/ReactRoutingController.java | 9 +- .../exception/GlobalExceptionHandler.java | 42 +- .../FileFolderPlacementController.java | 91 + .../storage/controller/FolderController.java | 70 + .../proprietary/storage/model/Folder.java | 104 + .../proprietary/storage/model/StoredFile.java | 19 +- .../model/api/CreateFolderRequest.java | 43 + .../storage/model/api/FolderResponse.java | 38 + .../storage/model/api/StoredFileResponse.java | 7 + .../model/api/UpdateFolderRequest.java | 58 + .../storage/repository/FolderRepository.java | 35 + .../repository/StoredFileRepository.java | 7 + .../storage/service/FileStorageService.java | 1 + .../storage/service/FolderService.java | 417 ++++ .../storage/service/FolderServiceTest.java | 298 +++ docker/base/Dockerfile | 3 +- docker/embedded/Dockerfile | 3 +- docker/embedded/Dockerfile.fat | 3 +- docker/embedded/Dockerfile.ultra-lite | 4 +- .../docker-compose-latest-fat-security.yml | 2 +- docker/embedded/compose/test_cicd.yml | 6 + frontend/editor/playwright.config.ts | 16 +- .../public/locales/en-GB/translation.toml | 362 +++- .../src/core/components/AppProviders.tsx | 69 +- .../src/core/components/FileManager.tsx | 9 +- .../filesPage/DeleteFolderDialog.tsx | 130 ++ .../components/filesPage/FileDetailsPanel.tsx | 658 +++++++ .../core/components/filesPage/FileGrid.tsx | 1380 ++++++++++++++ .../components/filesPage/FileManagerView.tsx | 1698 +++++++++++++++++ .../components/filesPage/FileOriginBadge.tsx | 97 + .../core/components/filesPage/FilesPage.css | 1406 ++++++++++++++ .../filesPage/FolderAppearancePicker.tsx | 178 ++ .../components/filesPage/FolderNameDialog.tsx | 111 ++ .../components/filesPage/FolderThumbnail.tsx | 170 ++ .../components/filesPage/FolderTreePanel.css | 198 ++ .../components/filesPage/FolderTreePanel.tsx | 169 ++ .../filesPage/FolderTreeSidebar.tsx | 490 +++++ .../filesPage/MoveToFolderDialog.tsx | 357 ++++ .../src/core/components/filesPage/dragDrop.ts | 39 + .../core/components/filesPage/fileOrigin.ts | 24 + .../filesPage/filesPageReturnRoute.ts | 91 + .../core/components/filesPage/folderIcons.ts | 41 + .../components/filesPage/folderTreeWidth.ts | 83 + .../components/filesPage/useDropTarget.ts | 121 ++ .../src/core/components/layout/Workbench.tsx | 52 +- .../core/components/shared/AppConfigModal.tsx | 37 +- .../core/components/shared/FileSidebar.css | 56 +- .../core/components/shared/FileSidebar.tsx | 471 +++-- .../core/components/shared/QuickAccessBar.tsx | 8 +- .../core/components/shared/WorkbenchBar.css | 20 +- .../core/components/shared/WorkbenchBar.tsx | 61 +- .../editor/src/core/contexts/FileContext.tsx | 2 + .../src/core/contexts/FilesPageContext.tsx | 559 ++++++ .../src/core/contexts/FolderContext.test.tsx | 154 ++ .../src/core/contexts/FolderContext.tsx | 645 +++++++ .../src/core/contexts/IndexedDBContext.tsx | 86 +- .../src/core/contexts/file/fileActions.ts | 7 +- .../editor/src/core/hooks/useFileHandler.ts | 7 +- frontend/editor/src/core/pages/HomePage.css | 10 + frontend/editor/src/core/pages/HomePage.tsx | 468 +++-- .../editor/src/core/services/fileStorage.ts | 175 +- .../src/core/services/fileSyncService.ts | 498 +++++ .../editor/src/core/services/folderStorage.ts | 119 ++ .../src/core/services/folderSyncService.ts | 146 ++ .../indexedDBManager.migration.test.ts | 196 ++ .../src/core/services/indexedDBManager.ts | 163 +- .../src/core/tests/helpers/ui-helpers.ts | 32 +- .../live/encrypted-unlock-then-tool.spec.ts | 6 +- .../src/core/tests/stubbed/convert.spec.ts | 22 +- .../stubbed/encrypted-pdf-unlock.spec.ts | 16 +- .../stubbed/file-state-across-tools.spec.ts | 9 +- .../stubbed/files-page-screenshots.spec.ts | 554 ++++++ .../src/core/tests/stubbed/files-page.spec.ts | 819 ++++++++ .../tests/stubbed/pdf-text-search.spec.ts | 13 +- .../src/core/tests/stubbed/settings.spec.ts | 37 + .../tests/stubbed/tour-onboarding.spec.ts | 44 +- .../stubbed/unsaved-changes-guard.spec.ts | 4 +- frontend/editor/src/core/types/file.ts | 13 + frontend/editor/src/core/types/folder.ts | 104 + frontend/editor/src/core/types/workbench.ts | 1 + .../src/core/utils/homePageNavigation.ts | 6 + frontend/package-lock.json | 11 + frontend/package.json | 4 + scripts/init-without-ocr.sh | 8 +- .../features/folders_and_files.feature | 147 ++ .../steps/folders_step_definitions.py | 401 ++++ 90 files changed, 14720 insertions(+), 663 deletions(-) create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/storage/controller/FileFolderPlacementController.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/storage/controller/FolderController.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/storage/model/Folder.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/storage/model/api/CreateFolderRequest.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/storage/model/api/FolderResponse.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/storage/model/api/UpdateFolderRequest.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/storage/repository/FolderRepository.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/storage/service/FolderService.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/storage/service/FolderServiceTest.java create mode 100644 frontend/editor/src/core/components/filesPage/DeleteFolderDialog.tsx create mode 100644 frontend/editor/src/core/components/filesPage/FileDetailsPanel.tsx create mode 100644 frontend/editor/src/core/components/filesPage/FileGrid.tsx create mode 100644 frontend/editor/src/core/components/filesPage/FileManagerView.tsx create mode 100644 frontend/editor/src/core/components/filesPage/FileOriginBadge.tsx create mode 100644 frontend/editor/src/core/components/filesPage/FilesPage.css create mode 100644 frontend/editor/src/core/components/filesPage/FolderAppearancePicker.tsx create mode 100644 frontend/editor/src/core/components/filesPage/FolderNameDialog.tsx create mode 100644 frontend/editor/src/core/components/filesPage/FolderThumbnail.tsx create mode 100644 frontend/editor/src/core/components/filesPage/FolderTreePanel.css create mode 100644 frontend/editor/src/core/components/filesPage/FolderTreePanel.tsx create mode 100644 frontend/editor/src/core/components/filesPage/FolderTreeSidebar.tsx create mode 100644 frontend/editor/src/core/components/filesPage/MoveToFolderDialog.tsx create mode 100644 frontend/editor/src/core/components/filesPage/dragDrop.ts create mode 100644 frontend/editor/src/core/components/filesPage/fileOrigin.ts create mode 100644 frontend/editor/src/core/components/filesPage/filesPageReturnRoute.ts create mode 100644 frontend/editor/src/core/components/filesPage/folderIcons.ts create mode 100644 frontend/editor/src/core/components/filesPage/folderTreeWidth.ts create mode 100644 frontend/editor/src/core/components/filesPage/useDropTarget.ts create mode 100644 frontend/editor/src/core/contexts/FilesPageContext.tsx create mode 100644 frontend/editor/src/core/contexts/FolderContext.test.tsx create mode 100644 frontend/editor/src/core/contexts/FolderContext.tsx create mode 100644 frontend/editor/src/core/services/fileSyncService.ts create mode 100644 frontend/editor/src/core/services/folderStorage.ts create mode 100644 frontend/editor/src/core/services/folderSyncService.ts create mode 100644 frontend/editor/src/core/services/indexedDBManager.migration.test.ts create mode 100644 frontend/editor/src/core/tests/stubbed/files-page-screenshots.spec.ts create mode 100644 frontend/editor/src/core/tests/stubbed/files-page.spec.ts create mode 100644 frontend/editor/src/core/types/folder.ts create mode 100644 testing/cucumber/features/folders_and_files.feature create mode 100644 testing/cucumber/features/steps/folders_step_definitions.py diff --git a/.github/workflows/PR-Auto-Deploy-V2.yml b/.github/workflows/PR-Auto-Deploy-V2.yml index 0571d416c..71071262c 100644 --- a/.github/workflows/PR-Auto-Deploy-V2.yml +++ b/.github/workflows/PR-Auto-Deploy-V2.yml @@ -287,6 +287,7 @@ jobs: - /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/data:/usr/share/tessdata:rw - /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/config:/configs:rw - /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/logs:/logs:rw + - /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/storage:/storage:rw environment: DISABLE_ADDITIONAL_FEATURES: "false" SECURITY_ENABLELOGIN: "true" @@ -309,7 +310,7 @@ jobs: ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }} << ENDSSH # Create V2 PR-specific directories - mkdir -p /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/{data,config,logs} + mkdir -p /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/{data,config,logs,storage} # Move docker-compose file to correct location mv /tmp/docker-compose-v2.yml /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/docker-compose.yml diff --git a/.gitignore b/.gitignore index 40be55155..c67ec629c 100644 --- a/.gitignore +++ b/.gitignore @@ -274,3 +274,6 @@ docs/type3/signatures/ # Playwright MCP screenshots / traces .playwright-mcp/ *.playwright-mcp.png + +# Local screenshot artifacts from *-screenshots.spec.ts +frontend/screenshots/ diff --git a/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java b/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java index 28fe2d188..74c713b6d 100644 --- a/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java +++ b/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java @@ -83,7 +83,16 @@ public class RequestUriUtils { return false; } - // Blocklist of backend/non-frontend paths that should still go through filters + // Blocklist of backend/non-frontend paths that should still go through filters. + // + // `/files` was historically a backend route; it is now a frontend route + // owned by HomePage / FileManagerView. Direct-nav or refresh on /files + // (or /files/) was returning the Spring auth filter's 401 + // JSON instead of serving index.html, so the SPA never got a chance to + // mount and the user saw a raw error response. There are no `/files` + // backend mappings at the servlet root - the real storage endpoints + // live under `/api/v1/storage/files`, which is filtered out a few lines + // up by the `startsWith("/api/")` guard. String[] backendOnlyPrefixes = { "/register", "/pipeline", @@ -91,7 +100,6 @@ public class RequestUriUtils { "/pdfjs-legacy", "/fonts", "/images", - "/files", "/css", "/js", "/swagger", @@ -181,7 +189,7 @@ public class RequestUriUtils { || trimmedUri.startsWith( "/api/v1/mobile-scanner/") // Mobile scanner endpoints (no auth) || trimmedUri.startsWith("/v1/api-docs") - // Workflow participant endpoints — access controlled by share tokens, not login + // Workflow participant endpoints - access controlled by share tokens, not login || trimmedUri.startsWith("/api/v1/workflow/participant/") // Share-link SPA bootstrap; data APIs remain protected || trimmedUri.matches("^/share/[^/]+/?$"); diff --git a/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java b/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java index f1ee3fa36..4f0f3e372 100644 --- a/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java +++ b/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java @@ -98,6 +98,17 @@ class RequestUriUtilsTest { assertTrue(RequestUriUtils.isFrontendRoute("", "/split-pdf")); } + @Test + void testIsFrontendRoute_filesRouteOwnedByFrontend() { + // /files and /files/ are FileManagerView routes - they + // must fall through to the SPA index.html, not get blocked by the + // backend auth filter. Regression test for direct-nav/refresh on + // the file manager returning a 401 JSON. + assertTrue(RequestUriUtils.isFrontendRoute("", "/files")); + assertTrue( + RequestUriUtils.isFrontendRoute("", "/files/3331910a-4155-4f71-8111-e38c896bc458")); + } + @Test void testIsFrontendRoute_pathWithExtension() { assertFalse(RequestUriUtils.isFrontendRoute("", "/some/file.pdf")); @@ -183,7 +194,7 @@ class RequestUriUtilsTest { @Test void testIsPublicAuthEndpoint_shareRootNotPublic() { - // Avoid matching bare "/share" or "/share/" — must have a token segment + // Avoid matching bare "/share" or "/share/" - must have a token segment assertFalse(RequestUriUtils.isPublicAuthEndpoint("/share", "")); assertFalse(RequestUriUtils.isPublicAuthEndpoint("/share/", "")); } @@ -197,7 +208,7 @@ class RequestUriUtilsTest { @Test void testIsPublicAuthEndpoint_shareApiStillProtected() { - // Share-link data APIs must NOT be public — they enforce auth + access checks + // Share-link data APIs must NOT be public - they enforce auth + access checks assertFalse(RequestUriUtils.isPublicAuthEndpoint("/api/v1/storage/share-links/abc123", "")); assertFalse( RequestUriUtils.isPublicAuthEndpoint( diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java b/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java index 02fa9b816..7a0779a4e 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java @@ -160,14 +160,19 @@ public class ReactRoutingController { return ResponseEntity.ok().contentType(MediaType.TEXT_HTML).body(cachedCallbackHtml); } + // `files` was historically a backend static-asset directory and was therefore + // in the exclusion list - removing it lets /files and /files/ + // forward to the SPA index.html, which is what FileManagerView expects. + // (Real storage endpoints live under /api/v1/storage/files, already + // excluded by the leading `api` token in the same regex.) @GetMapping( - "/{path:^(?!api|static|robots\\.txt|favicon\\.ico|manifest.*\\.json|pipeline|pdfjs|pdfjs-legacy|pdfium|vendor|fonts|images|files|css|js|assets|locales|modern-logo|classic-logo|Login|og_images|samples)[^\\.]*$}") + "/{path:^(?!api|static|robots\\.txt|favicon\\.ico|manifest.*\\.json|pipeline|pdfjs|pdfjs-legacy|pdfium|vendor|fonts|images|css|js|assets|locales|modern-logo|classic-logo|Login|og_images|samples)[^\\.]*$}") public ResponseEntity forwardRootPaths(HttpServletRequest request) throws IOException { return serveIndexHtml(request); } @GetMapping( - "/{path:^(?!api|static|pipeline|pdfjs|pdfjs-legacy|pdfium|vendor|fonts|images|files|css|js|assets|locales|modern-logo|classic-logo|Login|og_images|samples)[^\\.]*}/{subpath:^(?!.*\\.).*$}") + "/{path:^(?!api|static|pipeline|pdfjs|pdfjs-legacy|pdfium|vendor|fonts|images|css|js|assets|locales|modern-logo|classic-logo|Login|og_images|samples)[^\\.]*}/{subpath:^(?!.*\\.).*$}") public ResponseEntity forwardNestedPaths(HttpServletRequest request) throws IOException { return serveIndexHtml(request); diff --git a/app/core/src/main/java/stirling/software/SPDF/exception/GlobalExceptionHandler.java b/app/core/src/main/java/stirling/software/SPDF/exception/GlobalExceptionHandler.java index ffe3fb0f4..6b43b66ff 100644 --- a/app/core/src/main/java/stirling/software/SPDF/exception/GlobalExceptionHandler.java +++ b/app/core/src/main/java/stirling/software/SPDF/exception/GlobalExceptionHandler.java @@ -22,6 +22,7 @@ import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.multipart.MaxUploadSizeExceededException; import org.springframework.web.multipart.support.MissingServletRequestPartException; +import org.springframework.web.server.ResponseStatusException; import org.springframework.web.servlet.NoHandlerFoundException; import jakarta.servlet.http.HttpServletRequest; @@ -196,12 +197,12 @@ public class GlobalExceptionHandler { /** * Checks whether the given IOException indicates that the client disconnected before the * response could be written (broken pipe, connection reset, etc.). When this happens there is - * no point in serialising a {@link ProblemDetail} body because the socket is already closed — + * no point in serialising a {@link ProblemDetail} body because the socket is already closed - * and attempting to do so may trigger a secondary {@code HttpMessageNotWritableException} if * the response Content-Type was already committed as a non-JSON type (e.g. image/png). */ private static boolean isClientDisconnectException(IOException ex) { - // Walk the causal chain — Jetty/Tomcat may wrap the low-level SocketException + // Walk the causal chain - Jetty/Tomcat may wrap the low-level SocketException Throwable current = ex; while (current != null) { String msg = current.getMessage(); @@ -1040,6 +1041,43 @@ public class GlobalExceptionHandler { * @param request the HTTP servlet request * @return ProblemDetail with appropriate HTTP status */ + /** + * Handle ResponseStatusException explicitly so its embedded HTTP status reaches the client + * instead of being swallowed by the {@code RuntimeException} catch-all (which would downgrade + * every controller-thrown 400/404/409 to a generic 500). Folder/file storage controllers and + * any other code that throws {@code ResponseStatusException} relies on this handler taking + * precedence. + */ + @ExceptionHandler(ResponseStatusException.class) + public ResponseEntity handleResponseStatusException( + ResponseStatusException ex, HttpServletRequest request) { + HttpStatus status = + HttpStatus.resolve(ex.getStatusCode().value()) != null + ? HttpStatus.valueOf(ex.getStatusCode().value()) + : HttpStatus.INTERNAL_SERVER_ERROR; + String reason = ex.getReason() != null ? ex.getReason() : status.getReasonPhrase(); + ProblemDetail problemDetail = createBaseProblemDetail(status, reason, request); + problemDetail.setType(URI.create("/errors/" + status.value())); + problemDetail.setTitle(status.getReasonPhrase()); + problemDetail.setProperty("title", status.getReasonPhrase()); + // 5xx is operator-relevant; 4xx is a normal client-rejection - log at the right level. + if (status.is5xxServerError()) { + log.error( + "ResponseStatusException {} at {}: {}", + status.value(), + request.getRequestURI(), + reason, + ex); + } else { + log.debug( + "ResponseStatusException {} at {}: {}", + status.value(), + request.getRequestURI(), + reason); + } + return ResponseEntity.status(status).contentType(PROBLEM_JSON).body(problemDetail); + } + @ExceptionHandler(RuntimeException.class) public ResponseEntity handleRuntimeException( RuntimeException ex, HttpServletRequest request) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/controller/FileFolderPlacementController.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/controller/FileFolderPlacementController.java new file mode 100644 index 000000000..b2f2cd093 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/controller/FileFolderPlacementController.java @@ -0,0 +1,91 @@ +package stirling.software.proprietary.storage.controller; + +import java.util.List; +import java.util.UUID; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.RequiredArgsConstructor; + +import stirling.software.proprietary.storage.service.FolderService; + +/** + * Folder placement endpoints for existing stored files. Thin adapter: validates the request shape, + * delegates the transaction to {@link FolderService}, then maps the result onto the HTTP status. + * Authentication, storage-gate, ownership checks, and the bulk cap all live on the service (where + * {@code @Transactional} also lives) so the JDBC connection isn't held through JSON serialization. + */ +@RestController +@RequestMapping("/api/v1/storage/files") +@RequiredArgsConstructor +public class FileFolderPlacementController { + + private static final int BULK_MOVE_MAX_FILES = 1000; + + private final FolderService folderService; + + /** Move a single file to a folder (or to root when folderId is null). */ + @PatchMapping("/{fileId}/folder") + public ResponseEntity moveFileToFolder( + @PathVariable Long fileId, @Valid @RequestBody FolderPlacement body) { + folderService.moveFileToFolder(fileId, body.getFolderId()); + return ResponseEntity.noContent().build(); + } + + /** + * Bulk move - fewer round-trips than calling the single endpoint N times. Returns 200 on full + * success, 207 (Multi-Status) when some files were skipped (typically because they don't belong + * to the caller). + */ + @PatchMapping("/folder") + public ResponseEntity bulkMove(@Valid @RequestBody BulkMoveRequest body) { + FolderService.BulkMoveResult result = + folderService.bulkMoveFilesToFolder(body.getFolderId(), body.getFileIds()); + HttpStatus status = + result.skippedFileIds().isEmpty() ? HttpStatus.OK : HttpStatus.MULTI_STATUS; + return ResponseEntity.status(status) + .body(new BulkMoveResponse(result.movedFileIds(), result.skippedFileIds())); + } + + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class FolderPlacement { + private UUID folderId; + } + + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class BulkMoveRequest { + private UUID folderId; + + @NotNull + @Size( + min = 1, + max = BULK_MOVE_MAX_FILES, + message = "fileIds must contain between 1 and 1000 entries") + private List fileIds; + } + + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class BulkMoveResponse { + private List movedFileIds; + private List skippedFileIds; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/controller/FolderController.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/controller/FolderController.java new file mode 100644 index 000000000..3b4485a08 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/controller/FolderController.java @@ -0,0 +1,70 @@ +package stirling.software.proprietary.storage.controller; + +import java.net.URI; +import java.util.List; +import java.util.UUID; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import jakarta.validation.Valid; + +import lombok.RequiredArgsConstructor; + +import stirling.software.proprietary.storage.model.api.CreateFolderRequest; +import stirling.software.proprietary.storage.model.api.FolderResponse; +import stirling.software.proprietary.storage.model.api.UpdateFolderRequest; +import stirling.software.proprietary.storage.service.FolderService; + +/** + * REST endpoints for user-owned folders. Phase A - no folder-level sharing yet (Phase 3). + * + *

All operations are scoped to the authenticated user; existing single-file storage endpoints in + * {@link FileStorageController} are left alone so the cert-signing and standard upload flows are + * unaffected. + */ +@RestController +@RequestMapping("/api/v1/storage/folders") +@RequiredArgsConstructor +public class FolderController { + + private final FolderService folderService; + + @GetMapping + public List listFolders() { + return folderService.listFolders(); + } + + @PostMapping + public ResponseEntity createFolder( + @Valid @RequestBody CreateFolderRequest request) { + FolderResponse response = folderService.createFolder(request); + // 201 Created with Location header - conventional REST. The idempotent re-return path + // (same id resubmitted) also lands here; treating it as 201 keeps wire semantics simple. + return ResponseEntity.status(HttpStatus.CREATED) + .location(URI.create("/api/v1/storage/folders/" + response.id())) + .body(response); + } + + @PatchMapping("/{folderId}") + public ResponseEntity updateFolder( + @PathVariable UUID folderId, @Valid @RequestBody UpdateFolderRequest request) { + return ResponseEntity.ok(folderService.updateFolder(folderId, request)); + } + + @DeleteMapping("/{folderId}") + public ResponseEntity deleteFolder(@PathVariable UUID folderId) { + List removed = folderService.deleteFolder(folderId); + return ResponseEntity.ok(new DeleteFolderResponse(removed)); + } + + public record DeleteFolderResponse(List removedFolderIds) {} +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/Folder.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/Folder.java new file mode 100644 index 000000000..5145b756a --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/Folder.java @@ -0,0 +1,104 @@ +package stirling.software.proprietary.storage.model; + +import java.io.Serializable; +import java.time.LocalDateTime; +import java.util.UUID; + +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.OnDelete; +import org.hibernate.annotations.OnDeleteAction; +import org.hibernate.annotations.UpdateTimestamp; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.Version; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import stirling.software.proprietary.security.model.User; + +/** + * A user-owned folder used by the file manager UI to organise stored files. Phase A entity - no + * folder-level sharing yet (Phase 3). + * + *

The id is a UUID rather than a numeric auto-increment so it round-trips with the + * client-generated {@code FolderId} and survives cross-device sync without re-keying. + */ +@Entity +@Table( + name = "folders", + indexes = { + @Index(name = "idx_folders_owner", columnList = "owner_id"), + @Index(name = "idx_folders_parent", columnList = "parent_folder_id"), + @Index(name = "idx_folders_owner_parent", columnList = "owner_id, parent_folder_id") + }) +@NoArgsConstructor +@Getter +@Setter +public class Folder implements Serializable { + + private static final long serialVersionUID = 1L; + + /** + * Dialect-portable UUID column. The previous {@code columnDefinition = "uuid"} was + * Postgres-specific and broke on H2/MariaDB. Hibernate's {@code UUID} mapping picks the right + * native type per dialect (BINARY(16) on H2/MariaDB, uuid on Postgres) when no explicit + * columnDefinition is set. + */ + @Id + @Column(name = "folder_id", nullable = false) + private UUID id; + + /** + * {@code OnDeleteAction.CASCADE} so deleting the owning {@code User} cascades to this row at + * the DB level - UserService.deleteUserRelatedData doesn't enumerate folders today, and leaving + * the FK without an action throws a constraint violation on user delete. + */ + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "owner_id", nullable = false) + @OnDelete(action = OnDeleteAction.CASCADE) + private User owner; + + /** + * Parent folder; null = root. {@code OnDeleteAction.CASCADE} so a backend-side parent delete + * cleans children automatically, matching the service-layer recursive-delete contract. + */ + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "parent_folder_id") + @OnDelete(action = OnDeleteAction.CASCADE) + private Folder parent; + + @Column(name = "name", nullable = false, length = 255) + private String name; + + @Column(name = "color", length = 32) + private String color; + + @Column(name = "icon", length = 64) + private String icon; + + /** + * Optimistic-locking version. Cross-PC sync without this lets last-write-win silently. The + * column is nullable so existing rows from a pre-version deployment can be backfilled by + * Hibernate's update-on-write rather than failing the ddl-auto upgrade. + */ + @Version + @Column(name = "version") + private Long version; + + @CreationTimestamp + @Column(name = "created_at", updatable = false) + private LocalDateTime createdAt; + + @UpdateTimestamp + @Column(name = "updated_at") + private LocalDateTime updatedAt; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StoredFile.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StoredFile.java index 0128a2dfd..cbf5316db 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StoredFile.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StoredFile.java @@ -6,6 +6,8 @@ import java.util.HashSet; import java.util.Set; import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.OnDelete; +import org.hibernate.annotations.OnDeleteAction; import org.hibernate.annotations.UpdateTimestamp; import jakarta.persistence.CascadeType; @@ -35,7 +37,8 @@ import stirling.software.proprietary.workflow.model.WorkflowSession; name = "stored_files", indexes = { @Index(name = "idx_stored_files_owner", columnList = "owner_id"), - @Index(name = "idx_stored_files_workflow", columnList = "workflow_session_id") + @Index(name = "idx_stored_files_workflow", columnList = "workflow_session_id"), + @Index(name = "idx_stored_files_folder", columnList = "folder_id") }) @NoArgsConstructor @Getter @@ -106,6 +109,20 @@ public class StoredFile implements Serializable { orphanRemoval = true) private Set shares = new HashSet<>(); + /** + * Optional folder placement for the file manager UI. Null = root. Hibernate ddl-auto will add + * this as a nullable column on upgrade so existing records continue to work untouched. + * + *

{@code OnDeleteAction.SET_NULL} so any backend that drops a folder row (admin script, + * future cleanup job, cascading user delete) cleanly orphans files to root rather than leaving + * dangling FK references. The application path ({@code FolderRepository.clearFolderForFiles}) + * still runs first as a belt-and-braces. + */ + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "folder_id") + @OnDelete(action = OnDeleteAction.SET_NULL) + private Folder folder; + @CreationTimestamp @Column(name = "created_at", updatable = false) private LocalDateTime createdAt; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/api/CreateFolderRequest.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/api/CreateFolderRequest.java new file mode 100644 index 000000000..1dafdf1c9 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/api/CreateFolderRequest.java @@ -0,0 +1,43 @@ +package stirling.software.proprietary.storage.model.api; + +import java.util.UUID; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class CreateFolderRequest { + + /** + * Client-generated UUID - lets the caller round-trip the same id it stored locally. Optional; + * the server generates one when missing. + */ + private UUID id; + + @NotBlank + @Size(max = 255) + private String name; + + private UUID parentFolderId; + + /** Hex colour string (#rrggbb or #rrggbbaa) - matches the frontend palette format. */ + @Size(max = 32) + @Pattern( + regexp = "^#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$", + message = "color must be a #RRGGBB or #RRGGBBAA hex value") + private String color; + + /** Icon identifier - lowercase alphanumerics, hyphens, underscores only. */ + @Size(max = 64) + @Pattern( + regexp = "^[a-z0-9_-]+$", + message = "icon must be a lowercase id (a-z, 0-9, '-' or '_')") + private String icon; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/api/FolderResponse.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/api/FolderResponse.java new file mode 100644 index 000000000..d1892b661 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/api/FolderResponse.java @@ -0,0 +1,38 @@ +package stirling.software.proprietary.storage.model.api; + +import java.time.LocalDateTime; +import java.util.UUID; + +import stirling.software.proprietary.storage.model.Folder; + +/** + * Outbound DTO for folder responses. Records are immutable, value-equality-based, and far less + * accident-prone than a {@code @Data} class with public setters. + */ +public record FolderResponse( + UUID id, + String name, + UUID parentFolderId, + String color, + String icon, + Long version, + LocalDateTime createdAt, + LocalDateTime updatedAt) { + + public static FolderResponse from(Folder folder) { + // {@code folder.getParent().getId()} on a lazy proxy returns the FK value cached at the + // join column WITHOUT initialising the proxy under standard Hibernate, so this does + // not N+1. If a future Hibernate update changes that, switch the JPQL list query to a + // constructor projection. + UUID parentId = folder.getParent() == null ? null : folder.getParent().getId(); + return new FolderResponse( + folder.getId(), + folder.getName(), + parentId, + folder.getColor(), + folder.getIcon(), + folder.getVersion(), + folder.getCreatedAt(), + folder.getUpdatedAt()); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/api/StoredFileResponse.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/api/StoredFileResponse.java index 1cf6a20c8..1d92745d6 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/api/StoredFileResponse.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/api/StoredFileResponse.java @@ -2,6 +2,7 @@ package stirling.software.proprietary.storage.model.api; import java.time.LocalDateTime; import java.util.List; +import java.util.UUID; import lombok.Builder; import lombok.Getter; @@ -22,4 +23,10 @@ public class StoredFileResponse { private final List sharedUsers; private final List shareLinks; private final String filePurpose; + + /** + * Optional folder placement (Phase A). Null when the file lives at the root or when the server + * build doesn't have the folders feature enabled - existing clients should treat null as root. + */ + private final UUID folderId; } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/api/UpdateFolderRequest.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/api/UpdateFolderRequest.java new file mode 100644 index 000000000..26f920819 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/api/UpdateFolderRequest.java @@ -0,0 +1,58 @@ +package stirling.software.proprietary.storage.model.api; + +import java.util.UUID; + +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * PATCH-style update - every field is optional. Send only the fields you want to change. + * + *

The {@code reparent} flag distinguishes "do not change parent" from "move to root" since + * {@code parentFolderId == null} alone is ambiguous in a sparse body. We use a boxed {@link + * Boolean} so a missing field deserialises to {@code null} (= "do not reparent") rather than to + * primitive {@code false}, removing a class of "I PATCHed only the name but the server reset my + * parent" footguns. + * + *

When the trimmed name is empty (e.g. {@code " "}) the service rejects the request with HTTP + * 400 - silent drops are too easy to mistake for a successful rename. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class UpdateFolderRequest { + + /** When provided, must contain at least one non-whitespace character. */ + @Size(max = 255) + @Pattern(regexp = "\\S.*", message = "name must not be blank") + private String name; + + private Boolean reparent; + private UUID parentFolderId; + + @Size(max = 32) + @Pattern( + regexp = "^(|#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?)$", + message = "color must be empty or a #RRGGBB / #RRGGBBAA hex value") + private String color; + + @Size(max = 64) + @Pattern( + regexp = "^([a-z0-9_-]+)?$", + message = "icon must be a lowercase id (a-z, 0-9, '-' or '_') or empty") + private String icon; + + /** + * Convenience accessor - treats null as "do not reparent". Named differently from the + * Lombok-generated {@code getReparent()} so callers don't accidentally use one for the other + * (the getter is nullable {@code Boolean}; this method collapses to primitive). + */ + @com.fasterxml.jackson.annotation.JsonIgnore + public boolean shouldReparent() { + return Boolean.TRUE.equals(reparent); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/repository/FolderRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/repository/FolderRepository.java new file mode 100644 index 000000000..141223d92 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/repository/FolderRepository.java @@ -0,0 +1,35 @@ +package stirling.software.proprietary.storage.repository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.storage.model.Folder; + +public interface FolderRepository extends JpaRepository { + + Optional findByIdAndOwner(UUID id, User owner); + + List findAllByOwnerOrderByName(User owner); + + long countByOwner(User owner); + + /** + * Clear the folder reference on every file currently inside any of the given folders. Used when + * a folder subtree is deleted - files fall back to the root rather than dangling. + * + *

{@code flushAutomatically + clearAutomatically} forces Hibernate to flush any cached dirty + * {@code StoredFile} entities before the bulk UPDATE runs, and clears the persistence context + * afterwards so a subsequent {@code deleteAllByIdInBatch} on the parent folders doesn't see + * stale entity state referencing the about-to-be-deleted folder. + */ + @Modifying(flushAutomatically = true, clearAutomatically = true) + @Query("UPDATE StoredFile sf SET sf.folder = null WHERE sf.folder.id IN :folderIds") + void clearFolderForFiles(@Param("folderIds") List folderIds); +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/repository/StoredFileRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/repository/StoredFileRepository.java index bebe65c68..47545ab58 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/storage/repository/StoredFileRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/repository/StoredFileRepository.java @@ -59,6 +59,13 @@ public interface StoredFileRepository extends JpaRepository { List findAllByOwner(User owner); + /** + * Bulk lookup used by the folder-placement controller. Returns only files owned by {@code + * owner}; ids that don't exist or that belong to another user are silently dropped so the + * caller can compute the "skipped" set by subtraction. + */ + List findAllByIdInAndOwner(List ids, User owner); + @Modifying @Transactional @Query( diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/service/FileStorageService.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/service/FileStorageService.java index ba94cab79..03c3a9178 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/storage/service/FileStorageService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/service/FileStorageService.java @@ -459,6 +459,7 @@ public class FileStorageService { file.getPurpose() != null ? file.getPurpose().name().toLowerCase(Locale.ROOT) : null) + .folderId(file.getFolder() != null ? file.getFolder().getId() : null) .build(); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/service/FolderService.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/service/FolderService.java new file mode 100644 index 000000000..c0f07f8ce --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/service/FolderService.java @@ -0,0 +1,417 @@ +package stirling.software.proprietary.storage.service; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.storage.model.Folder; +import stirling.software.proprietary.storage.model.StoredFile; +import stirling.software.proprietary.storage.model.api.CreateFolderRequest; +import stirling.software.proprietary.storage.model.api.FolderResponse; +import stirling.software.proprietary.storage.model.api.UpdateFolderRequest; +import stirling.software.proprietary.storage.repository.FolderRepository; +import stirling.software.proprietary.storage.repository.StoredFileRepository; + +/** + * Phase A folder operations. Each call is scoped to the authenticated user - folders are private to + * their owner. Folder-level sharing is a Phase 3 feature. + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class FolderService { + + /** + * Hard cap on folders per user. Beyond this {@link #createFolder} rejects with 409 - guards + * against per-account folder-explosion DoS and bounds the in-memory subtree walk in {@link + * #deleteFolder}. + */ + private static final long MAX_FOLDERS_PER_USER = 5_000L; + + /** + * Hard cap on chain depth from the root to any folder. Bounds the lazy-proxy walk in {@link + * #enforceDepthAndCycle} - otherwise a user could build a chain up to MAX_FOLDERS_PER_USER deep + * and force one Hibernate SELECT per ancestor on every reparent (5,000+ SELECTs == seconds of + * DB time per request, per-account weaponizable as DoS). + */ + private static final int MAX_FOLDER_DEPTH = 64; + + /** + * Hard cap on bulk-move payload size, mirroring the request-validation cap on {@code + * FileFolderPlacementController.BulkMoveRequest.fileIds}. Re-asserted at the service layer + * because controller-level @Valid bounds aren't enforced when the service is called directly + * (e.g. by future internal callers or tests). + */ + private static final int BULK_MOVE_MAX_FILES = 1000; + + private final FolderRepository folderRepository; + private final StoredFileRepository storedFileRepository; + private final ApplicationProperties applicationProperties; + + /** + * Gate every public method on storage being enabled, mirroring {@code + * FileStorageService.ensureStorageEnabled}. Without this, folder CRUD still works when {@code + * storage.enabled=false} or {@code security.enableLogin=false}, defeating the operator's intent + * to disable storage end-to-end. + */ + private void ensureStorageEnabled() { + if (!applicationProperties.getSecurity().isEnableLogin()) { + throw new ResponseStatusException( + HttpStatus.FORBIDDEN, "Storage requires login to be enabled"); + } + if (!applicationProperties.getStorage().isEnabled()) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Storage is disabled"); + } + } + + /** List every folder owned by the current user, alphabetical. */ + @Transactional(readOnly = true) + public List listFolders() { + ensureStorageEnabled(); + User user = requireAuthenticatedUser(); + return folderRepository.findAllByOwnerOrderByName(user).stream() + .map(FolderResponse::from) + .toList(); + } + + @Transactional + public FolderResponse createFolder(CreateFolderRequest request) { + ensureStorageEnabled(); + User user = requireAuthenticatedUser(); + // Reject self-parenting up-front. Without this, a client posting + // {id: X, parentFolderId: X} for a folder X they already own would silently + // get the existing folder back (idempotent path) and never learn that the + // parentFolderId they sent was ignored. For new ids the parent lookup would + // 404, but the message is misleading. + if (request.getId() != null && request.getId().equals(request.getParentFolderId())) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "A folder cannot be its own parent"); + } + Folder parent = resolveParent(request.getParentFolderId(), user, null); + + UUID id = request.getId() != null ? request.getId() : UUID.randomUUID(); + + // Idempotent: if this user already owns a folder with the supplied id, return it + // unchanged. Single fetch (the previous code did findByIdAndOwner twice with a race + // window between the two lookups). + java.util.Optional existing = folderRepository.findByIdAndOwner(id, user); + if (existing.isPresent()) { + return FolderResponse.from(existing.get()); + } + + // The id is a global primary key. If the id exists for a *different* user, surfacing 500 + // with a constraint-violation stack trace leaks far too much; convert to 409 Conflict so + // the caller can pick a fresh id. + if (folderRepository.existsById(id)) { + throw new ResponseStatusException( + HttpStatus.CONFLICT, + "A folder with this id already exists; choose a different id"); + } + + if (folderRepository.countByOwner(user) >= MAX_FOLDERS_PER_USER) { + throw new ResponseStatusException( + HttpStatus.CONFLICT, + "Folder limit reached (max " + MAX_FOLDERS_PER_USER + " per user)"); + } + + Folder folder = new Folder(); + folder.setId(id); + folder.setOwner(user); + folder.setParent(parent); + folder.setName(request.getName().trim()); + folder.setColor(request.getColor()); + folder.setIcon(request.getIcon()); + + // saveAndFlush forces the INSERT now so @CreationTimestamp populates + // createdAt/updatedAt before we build the response. Plain save defers + // the SQL until @Transactional commit, and the response would carry + // null timestamps that the frontend trust-boundary parser then rejects. + Folder saved = folderRepository.saveAndFlush(folder); + log.info( + "Folder created: user={} id={} parent={}", + user.getId(), + saved.getId(), + parent == null ? "root" : parent.getId()); + return FolderResponse.from(saved); + } + + @Transactional + public FolderResponse updateFolder(UUID id, UpdateFolderRequest request) { + ensureStorageEnabled(); + User user = requireAuthenticatedUser(); + Folder folder = requireOwnedFolder(id, user); + + if (request.getName() != null) { + String trimmed = request.getName().trim(); + if (trimmed.isEmpty()) { + // Bean validation should already catch this via @Pattern, but be explicit so + // an empty-after-trim payload reaches the user as a 400 instead of being + // silently dropped. + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "Folder name cannot be blank"); + } + folder.setName(trimmed); + } + + if (request.shouldReparent()) { + Folder newParent = resolveParent(request.getParentFolderId(), user, folder.getId()); + folder.setParent(newParent); + } + + if (request.getColor() != null) { + folder.setColor(request.getColor().isEmpty() ? null : request.getColor()); + } + + if (request.getIcon() != null) { + folder.setIcon(request.getIcon().isEmpty() ? null : request.getIcon()); + } + + // saveAndFlush so @UpdateTimestamp populates updatedAt before the + // response is serialized (same reason as createFolder). + return FolderResponse.from(folderRepository.saveAndFlush(folder)); + } + + /** + * Recursive delete. Returns the ids of every folder that was removed so the caller can purge + * them from its local cache. Files inside those folders are detached (folder_id set to null) - + * never deleted. + */ + @Transactional + public List deleteFolder(UUID id) { + ensureStorageEnabled(); + User user = requireAuthenticatedUser(); + Folder folder = requireOwnedFolder(id, user); + + // Build the parent → children map once. Project to id-only via the + // existing entity list (Hibernate already has the column loaded - + // we only access f.getParent().getId() on a managed proxy, which + // does NOT initialize the proxy because Hibernate has the FK + // value cached at the join column). + Map> childIdsByParent = new HashMap<>(); + for (Folder f : folderRepository.findAllByOwnerOrderByName(user)) { + UUID parentId = f.getParent() == null ? null : f.getParent().getId(); + childIdsByParent.computeIfAbsent(parentId, k -> new ArrayList<>()).add(f.getId()); + } + + // Iterative subtree collection - prior recursive form blew the JVM + // stack on deeply nested chains a malicious caller could create. + List removed = new ArrayList<>(); + Set seen = new HashSet<>(); + Deque stack = new ArrayDeque<>(); + stack.push(folder.getId()); + while (!stack.isEmpty()) { + UUID cur = stack.pop(); + if (!seen.add(cur)) continue; + removed.add(cur); + List children = childIdsByParent.get(cur); + if (children != null) { + for (UUID childId : children) stack.push(childId); + } + } + + if (!removed.isEmpty()) { + folderRepository.clearFolderForFiles(removed); + folderRepository.deleteAllByIdInBatch(removed); + log.info( + "Folder subtree deleted: user={} root={} count={}", + user.getId(), + folder.getId(), + removed.size()); + } + + return removed; + } + + /** + * Move a single owned file to a folder (or root when {@code folderId} is null). Owns its + * own @Transactional rather than relying on the caller so the JDBC connection is released as + * soon as the writes commit, not held through controller-side JSON serialization. + */ + @Transactional + public void moveFileToFolder(Long fileId, UUID folderId) { + ensureStorageEnabled(); + User user = requireAuthenticatedUser(); + StoredFile file = + storedFileRepository + .findByIdAndOwner(fileId, user) + .orElseThrow( + () -> + new ResponseStatusException( + HttpStatus.NOT_FOUND, + "File not found or not owned by current user")); + file.setFolder(resolveOwnedFolder(folderId, user)); + storedFileRepository.save(file); + } + + /** + * Bulk move that returns the moved + skipped split. Skipped == file ids the caller doesn't own + * (or that no longer exist); the controller surfaces this as 207 Multi-Status. + */ + @Transactional + public BulkMoveResult bulkMoveFilesToFolder(UUID folderId, List fileIds) { + ensureStorageEnabled(); + if (fileIds == null || fileIds.isEmpty()) { + return new BulkMoveResult(List.of(), List.of()); + } + if (fileIds.size() > BULK_MOVE_MAX_FILES) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "fileIds must contain between 1 and " + BULK_MOVE_MAX_FILES + " entries"); + } + User user = requireAuthenticatedUser(); + Folder target = resolveOwnedFolder(folderId, user); + + List owned = storedFileRepository.findAllByIdInAndOwner(fileIds, user); + Set ownedIds = new HashSet<>(owned.size()); + for (StoredFile f : owned) { + f.setFolder(target); + ownedIds.add(f.getId()); + } + // If the target folder was deleted concurrently between resolveOwnedFolder and the + // flush, the FK constraint fires as DataIntegrityViolationException. Surface that as + // 409 Conflict so the caller sees an actionable error instead of a 500 stack. + try { + storedFileRepository.saveAll(owned); + storedFileRepository.flush(); + } catch (DataIntegrityViolationException ex) { + throw new ResponseStatusException( + HttpStatus.CONFLICT, + "Target folder no longer exists; refresh and try again", + ex); + } + + List moved = owned.stream().map(StoredFile::getId).toList(); + List skipped = fileIds.stream().filter(id -> !ownedIds.contains(id)).toList(); + if (!skipped.isEmpty()) { + log.warn( + "bulkMove: user {} skipped {} of {} files (not owned or missing)", + user.getId(), + skipped.size(), + fileIds.size()); + } + return new BulkMoveResult(moved, skipped); + } + + /** Result of {@link #bulkMoveFilesToFolder}. Records are immutable + auto-serializable. */ + public record BulkMoveResult(List movedFileIds, List skippedFileIds) {} + + // ─── helpers ──────────────────────────────────────────────────── + + /** + * Resolve a placement-target folder. Distinct from {@link #resolveParent} because move targets + * don't carry the parent-cycle semantics - we only need the folder to exist AND belong to the + * caller. Returns null for null input (root). + */ + private Folder resolveOwnedFolder(UUID folderId, User user) { + if (folderId == null) return null; + return folderRepository + .findByIdAndOwner(folderId, user) + .orElseThrow( + () -> + new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Folder does not exist or is not owned by you")); + } + + private Folder requireOwnedFolder(UUID id, User user) { + return folderRepository + .findByIdAndOwner(id, user) + .orElseThrow( + () -> + new ResponseStatusException( + HttpStatus.NOT_FOUND, + "Folder not found or not owned by current user")); + } + + private Folder resolveParent(UUID parentId, User user, UUID forbidId) { + if (parentId == null) return null; + if (forbidId != null && parentId.equals(forbidId)) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "A folder cannot be its own parent"); + } + Folder parent = + folderRepository + .findByIdAndOwner(parentId, user) + .orElseThrow( + () -> + new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Parent folder does not exist or is not owned by you")); + // Reject before the child is created/moved if attaching it would push the chain past the + // depth cap. Done in one pass that also returns the cycle answer so we don't walk the + // lazy-proxy chain twice. + enforceDepthAndCycle(parent, user, forbidId); + return parent; + } + + /** + * Single pass that walks the parent chain to root and (a) rejects if attaching a child here + * would exceed MAX_FOLDER_DEPTH, (b) rejects if {@code forbidId} appears in the chain (cycle on + * reparent), (c) rejects on a broken graph, and (d) rejects if any ancestor is owned by a + * different user (defense-in-depth: callers always pass a parent already ownership-checked, but + * the parent chain is followed via lazy proxy without re-checking ownership at each hop, so any + * stray cross-owner edge in the database would otherwise leak ancestor folder ids through the + * cycle error message). The walk is hard-bounded at MAX_FOLDER_DEPTH so a corrupted database + * (chain longer than the API would allow) can never produce an unbounded SELECT loop. + */ + private void enforceDepthAndCycle(Folder candidateParent, User user, UUID forbidId) { + Folder cursor = candidateParent; + Set seen = new HashSet<>(); + int depth = 0; + while (cursor != null) { + if (cursor.getOwner() == null || !cursor.getOwner().getId().equals(user.getId())) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "Folder hierarchy is corrupted; contact support"); + } + if (forbidId != null && cursor.getId().equals(forbidId)) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Cannot move a folder inside one of its descendants"); + } + if (!seen.add(cursor.getId())) { + // broken graph (cycle in stored data) + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "Folder hierarchy is corrupted; contact support"); + } + depth += 1; + // candidateParent is at depth 1 from the new child's perspective. After the walk, + // `depth` equals the number of ancestors including candidateParent, which is the + // depth at which the new child would live. Reject before exceeding the cap. + if (depth >= MAX_FOLDER_DEPTH) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Folder nesting limit reached (max " + MAX_FOLDER_DEPTH + " levels)"); + } + cursor = cursor.getParent(); + } + } + + private User requireAuthenticatedUser() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null + || !authentication.isAuthenticated() + || !(authentication.getPrincipal() instanceof User user)) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Authentication required"); + } + return user; + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/storage/service/FolderServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/storage/service/FolderServiceTest.java new file mode 100644 index 000000000..13bcfbfda --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/storage/service/FolderServiceTest.java @@ -0,0 +1,298 @@ +package stirling.software.proprietary.storage.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.Optional; +import java.util.UUID; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.server.ResponseStatusException; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.storage.model.Folder; +import stirling.software.proprietary.storage.model.api.CreateFolderRequest; +import stirling.software.proprietary.storage.repository.FolderRepository; +import stirling.software.proprietary.storage.repository.StoredFileRepository; + +/** + * Unit tests for {@link FolderService}. Covers the regressions Connor flagged in PR #6383: + * + *

    + *
  • storage-enabled gate must be enforced (added in the same PR) + *
  • cross-user folder access must 404, not leak existence + *
  • cycle detection on reparent must 400 + *
  • depth cap must reject chains past MAX_FOLDER_DEPTH + *
  • per-user folder count cap must 409 + *
+ * + * Hibernate is mocked: this is a pure-Mockito unit test, not a slice test. Adequate for the + * service-layer behaviors above; full DB integration belongs in a separate {@code @DataJpaTest}. + */ +@ExtendWith(MockitoExtension.class) +class FolderServiceTest { + + @Mock private FolderRepository folderRepository; + @Mock private StoredFileRepository storedFileRepository; + @Mock private ApplicationProperties applicationProperties; + @Mock private ApplicationProperties.Security security; + @Mock private ApplicationProperties.Storage storage; + + private FolderService service; + private User user; + + @BeforeEach + void setUp() { + // Default to "storage enabled" so the unrelated tests don't have to repeat the wiring. + // Individual tests override with disabled state. + lenient().when(applicationProperties.getSecurity()).thenReturn(security); + lenient().when(applicationProperties.getStorage()).thenReturn(storage); + lenient().when(security.isEnableLogin()).thenReturn(true); + lenient().when(storage.isEnabled()).thenReturn(true); + + service = new FolderService(folderRepository, storedFileRepository, applicationProperties); + + user = new User(); + user.setId(42L); + user.setUsername("alice"); + SecurityContext ctx = SecurityContextHolder.createEmptyContext(); + ctx.setAuthentication( + new UsernamePasswordAuthenticationToken(user, null, java.util.List.of())); + SecurityContextHolder.setContext(ctx); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + void listFolders_rejects_when_login_disabled() { + when(security.isEnableLogin()).thenReturn(false); + assertThatThrownBy(() -> service.listFolders()) + .isInstanceOf(ResponseStatusException.class) + .satisfies( + e -> + assertThat(((ResponseStatusException) e).getStatusCode().value()) + .isEqualTo(403)); + } + + @Test + void listFolders_rejects_when_storage_disabled() { + when(storage.isEnabled()).thenReturn(false); + assertThatThrownBy(() -> service.listFolders()) + .isInstanceOf(ResponseStatusException.class) + .satisfies( + e -> + assertThat(((ResponseStatusException) e).getStatusCode().value()) + .isEqualTo(403)); + } + + @Test + void createFolder_under_unknown_parent_returns_400_without_leaking_existence() { + // Parent UUID exists for ANOTHER user; current-user lookup misses it. The repository + // returns Optional.empty() and the service must surface a generic 400, not a 404 that + // could be used to probe for existence by id-guessing. + UUID foreignParentId = UUID.randomUUID(); + when(folderRepository.findByIdAndOwner(eq(foreignParentId), eq(user))) + .thenReturn(Optional.empty()); + + CreateFolderRequest req = new CreateFolderRequest(); + req.setName("Child"); + req.setParentFolderId(foreignParentId); + + assertThatThrownBy(() -> service.createFolder(req)) + .isInstanceOf(ResponseStatusException.class) + .satisfies( + e -> { + ResponseStatusException rse = (ResponseStatusException) e; + assertThat(rse.getStatusCode().value()).isEqualTo(400); + assertThat(rse.getReason()).doesNotContain(foreignParentId.toString()); + }); + } + + @Test + void createFolder_409_when_user_at_folder_cap() { + // Stub out an existing-id miss so we reach the cap check (no Mockito unnecessary-stub + // warnings from the OTHER paths because we exit at the cap before the existsById call). + UUID newId = UUID.randomUUID(); + when(folderRepository.findByIdAndOwner(eq(newId), eq(user))).thenReturn(Optional.empty()); + when(folderRepository.existsById(eq(newId))).thenReturn(false); + when(folderRepository.countByOwner(eq(user))).thenReturn(5_000L); + + CreateFolderRequest req = new CreateFolderRequest(); + req.setName("Overflow"); + req.setId(newId); + + assertThatThrownBy(() -> service.createFolder(req)) + .isInstanceOf(ResponseStatusException.class) + .satisfies( + e -> + assertThat(((ResponseStatusException) e).getStatusCode().value()) + .isEqualTo(409)); + } + + @Test + void resolveParent_rejects_when_chain_exceeds_depth_cap() { + // Build a chain Hibernate-proxy-style: 64 ancestor stubs reachable via getParent(). The + // 65th createFolder attempt under the deepest existing folder should be rejected with + // 400 before any further work. + Folder root = makeFolder(UUID.randomUUID(), null); + Folder cursor = root; + for (int i = 0; i < 63; i++) { + Folder child = makeFolder(UUID.randomUUID(), cursor); + cursor = child; + } + // cursor is at depth 64 from root. Attempting to add another folder under cursor pushes + // the new child to depth 65 - past the cap. resolveParent walks cursor->root counting + // ancestors, which is exactly 64, and rejects. + Folder deepest = cursor; + when(folderRepository.findByIdAndOwner(eq(deepest.getId()), eq(user))) + .thenReturn(Optional.of(deepest)); + + CreateFolderRequest req = new CreateFolderRequest(); + req.setName("Too deep"); + req.setParentFolderId(deepest.getId()); + + assertThatThrownBy(() -> service.createFolder(req)) + .isInstanceOf(ResponseStatusException.class) + .satisfies( + e -> { + ResponseStatusException rse = (ResponseStatusException) e; + assertThat(rse.getStatusCode().value()).isEqualTo(400); + assertThat(rse.getReason()).containsIgnoringCase("nesting limit"); + }); + } + + @Test + void updateFolder_rejects_cycle_on_reparent() { + // A -> B -> C. Attempt to reparent A under C (i.e. set A.parent = C). C's chain to root + // includes B which includes A, so the cycle check must fire with 400. + Folder a = makeFolder(UUID.randomUUID(), null); + Folder b = makeFolder(UUID.randomUUID(), a); + Folder c = makeFolder(UUID.randomUUID(), b); + + when(folderRepository.findByIdAndOwner(eq(a.getId()), eq(user))).thenReturn(Optional.of(a)); + when(folderRepository.findByIdAndOwner(eq(c.getId()), eq(user))).thenReturn(Optional.of(c)); + + stirling.software.proprietary.storage.model.api.UpdateFolderRequest req = + new stirling.software.proprietary.storage.model.api.UpdateFolderRequest(); + req.setParentFolderId(c.getId()); + // shouldReparent() requires the explicit reparent flag - without it the + // parent change is silently skipped (PATCH-style semantics). + req.setReparent(true); + + assertThatThrownBy(() -> service.updateFolder(a.getId(), req)) + .isInstanceOf(ResponseStatusException.class) + .satisfies( + e -> { + ResponseStatusException rse = (ResponseStatusException) e; + assertThat(rse.getStatusCode().value()).isEqualTo(400); + assertThat(rse.getReason()).containsIgnoringCase("descendants"); + }); + } + + @Test + void updateFolder_rejects_when_folder_not_owned() { + // Owner mismatch surfaces as 404, NOT 403 - 403 would confirm the folder exists, leaking + // ids to probing users. Stays consistent with the createFolder-under-unknown-parent test + // above. + UUID foreignId = UUID.randomUUID(); + when(folderRepository.findByIdAndOwner(eq(foreignId), eq(user))) + .thenReturn(Optional.empty()); + + stirling.software.proprietary.storage.model.api.UpdateFolderRequest req = + new stirling.software.proprietary.storage.model.api.UpdateFolderRequest(); + req.setName("Renamed"); + + assertThatThrownBy(() -> service.updateFolder(foreignId, req)) + .isInstanceOf(ResponseStatusException.class) + .satisfies( + e -> + assertThat(((ResponseStatusException) e).getStatusCode().value()) + .isEqualTo(404)); + } + + @Test + void moveFileToFolder_rejects_when_target_folder_not_owned() { + // File belongs to current user but target folder belongs to someone else. Service must + // 400, not move the file. + UUID foreignFolderId = UUID.randomUUID(); + stirling.software.proprietary.storage.model.StoredFile file = + mock(stirling.software.proprietary.storage.model.StoredFile.class); + when(storedFileRepository.findByIdAndOwner(eq(100L), eq(user))) + .thenReturn(Optional.of(file)); + when(folderRepository.findByIdAndOwner(eq(foreignFolderId), eq(user))) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.moveFileToFolder(100L, foreignFolderId)) + .isInstanceOf(ResponseStatusException.class) + .satisfies( + e -> + assertThat(((ResponseStatusException) e).getStatusCode().value()) + .isEqualTo(400)); + } + + @Test + void bulkMove_rejects_oversized_payload() { + // Bypass the @Valid bound by calling the service directly - the cap must hold here too, + // not just at the controller's request validator. + java.util.List tooMany = new java.util.ArrayList<>(); + for (int i = 0; i < 1001; i++) tooMany.add((long) i); + + assertThatThrownBy(() -> service.bulkMoveFilesToFolder(null, tooMany)) + .isInstanceOf(ResponseStatusException.class) + .satisfies( + e -> + assertThat(((ResponseStatusException) e).getStatusCode().value()) + .isEqualTo(400)); + } + + @Test + void bulkMove_returns_moved_and_skipped_split() { + // Ownership filter on the repository returns a subset; the rest land in skippedFileIds. + Folder target = makeFolder(UUID.randomUUID(), null); + when(folderRepository.findByIdAndOwner(eq(target.getId()), eq(user))) + .thenReturn(Optional.of(target)); + + stirling.software.proprietary.storage.model.StoredFile fileA = + mock(stirling.software.proprietary.storage.model.StoredFile.class); + when(fileA.getId()).thenReturn(1L); + stirling.software.proprietary.storage.model.StoredFile fileB = + mock(stirling.software.proprietary.storage.model.StoredFile.class); + when(fileB.getId()).thenReturn(2L); + when(storedFileRepository.findAllByIdInAndOwner(any(), eq(user))) + .thenReturn(java.util.List.of(fileA, fileB)); + + FolderService.BulkMoveResult result = + service.bulkMoveFilesToFolder(target.getId(), java.util.List.of(1L, 2L, 3L, 4L)); + + assertThat(result.movedFileIds()).containsExactly(1L, 2L); + assertThat(result.skippedFileIds()).containsExactly(3L, 4L); + } + + // ─── helpers ──────────────────────────────────────────────────────────────── + + private Folder makeFolder(UUID id, Folder parent) { + Folder f = new Folder(); + f.setId(id); + f.setOwner(user); + f.setName("f-" + id.toString().substring(0, 8)); + f.setParent(parent); + return f; + } +} diff --git a/docker/base/Dockerfile b/docker/base/Dockerfile index 6275bac77..c3a2c0d55 100644 --- a/docker/base/Dockerfile +++ b/docker/base/Dockerfile @@ -630,9 +630,10 @@ RUN set -eux; \ RUN set -eux; \ mkdir -p /configs /configs/cache /configs/heap_dumps /logs /customFiles \ /pipeline/watchedFolders /pipeline/finishedFolders \ + /storage \ /tmp/stirling-pdf/heap_dumps; \ chown -R stirlingpdfuser:stirlingpdfgroup \ - /home/stirlingpdfuser /configs /logs /customFiles /pipeline \ + /home/stirlingpdfuser /configs /logs /customFiles /pipeline /storage \ /tmp/stirling-pdf; \ chmod 750 /tmp/stirling-pdf; \ chmod 750 /tmp/stirling-pdf/heap_dumps diff --git a/docker/embedded/Dockerfile b/docker/embedded/Dockerfile index 1462bec52..89913af09 100644 --- a/docker/embedded/Dockerfile +++ b/docker/embedded/Dockerfile @@ -84,7 +84,8 @@ RUN set -eux; \ ln -s /configs /app/configs; \ ln -s /customFiles /app/customFiles; \ ln -s /pipeline /app/pipeline; \ - chown -h stirlingpdfuser:stirlingpdfgroup /app/logs /app/configs /app/customFiles /app/pipeline; \ + ln -s /storage /app/storage; \ + chown -h stirlingpdfuser:stirlingpdfgroup /app/logs /app/configs /app/customFiles /app/pipeline /app/storage; \ chown stirlingpdfuser:stirlingpdfgroup /app; \ chmod 750 /tmp/stirling-pdf; \ chmod 750 /tmp/stirling-pdf/heap_dumps; \ diff --git a/docker/embedded/Dockerfile.fat b/docker/embedded/Dockerfile.fat index 81c10943f..f9754b472 100644 --- a/docker/embedded/Dockerfile.fat +++ b/docker/embedded/Dockerfile.fat @@ -80,7 +80,8 @@ RUN set -eux; \ ln -s /configs /app/configs; \ ln -s /customFiles /app/customFiles; \ ln -s /pipeline /app/pipeline; \ - chown -h stirlingpdfuser:stirlingpdfgroup /app/logs /app/configs /app/customFiles /app/pipeline; \ + ln -s /storage /app/storage; \ + chown -h stirlingpdfuser:stirlingpdfgroup /app/logs /app/configs /app/customFiles /app/pipeline /app/storage; \ chown stirlingpdfuser:stirlingpdfgroup /app; \ chmod 750 /tmp/stirling-pdf; \ chmod 750 /tmp/stirling-pdf/heap_dumps; \ diff --git a/docker/embedded/Dockerfile.ultra-lite b/docker/embedded/Dockerfile.ultra-lite index b748dbab8..d091d7b3f 100644 --- a/docker/embedded/Dockerfile.ultra-lite +++ b/docker/embedded/Dockerfile.ultra-lite @@ -99,11 +99,11 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a curl \ shadow \ util-linux && \ - mkdir -p $HOME /configs /logs /customFiles /pipeline/watchedFolders /pipeline/finishedFolders /tmp/stirling-pdf /tmp/stirling-pdf/heap_dumps && \ + mkdir -p $HOME /configs /logs /customFiles /pipeline/watchedFolders /pipeline/finishedFolders /storage /tmp/stirling-pdf /tmp/stirling-pdf/heap_dumps && \ mkdir -p /usr/share/fonts/opentype/noto && \ # User permissions addgroup -S stirlingpdfgroup && adduser -S stirlingpdfuser -G stirlingpdfgroup && \ - chown -R stirlingpdfuser:stirlingpdfgroup $HOME /configs /customFiles /pipeline /tmp/stirling-pdf + chown -R stirlingpdfuser:stirlingpdfgroup $HOME /configs /customFiles /pipeline /storage /tmp/stirling-pdf # Copy scripts and built artifacts after OS package layer to maximize cache reuse. COPY --chown=1000:1000 scripts/init-without-ocr.sh /scripts/init-without-ocr.sh diff --git a/docker/embedded/compose/docker-compose-latest-fat-security.yml b/docker/embedded/compose/docker-compose-latest-fat-security.yml index 7abd1acd6..7efa65a71 100644 --- a/docker/embedded/compose/docker-compose-latest-fat-security.yml +++ b/docker/embedded/compose/docker-compose-latest-fat-security.yml @@ -20,6 +20,7 @@ services: - ../../../stirling/latest/data:/usr/share/tessdata:rw - ../../../stirling/latest/config:/configs:rw - ../../../stirling/latest/logs:/logs:rw + - ../../../stirling/latest/storage:/storage:rw environment: DISABLE_ADDITIONAL_FEATURES: "false" SECURITY_ENABLELOGIN: "false" @@ -36,5 +37,4 @@ services: METRICS_ENABLED: "true" SYSTEM_GOOGLEVISIBILITY: "true" SHOW_SURVEY: "true" - STORAGE_LOCAL_BASEPATH: /configs/storage restart: unless-stopped diff --git a/docker/embedded/compose/test_cicd.yml b/docker/embedded/compose/test_cicd.yml index fe165c864..f42087291 100644 --- a/docker/embedded/compose/test_cicd.yml +++ b/docker/embedded/compose/test_cicd.yml @@ -16,6 +16,7 @@ services: - ../../../stirling/latest/data:/usr/share/tessdata:rw - ../../../stirling/latest/config:/configs:rw - ../../../stirling/latest/logs:/logs:rw + - ../../../stirling/latest/storage:/storage:rw environment: DISABLE_ADDITIONAL_FEATURES: "false" SECURITY_ENABLELOGIN: "true" @@ -31,4 +32,9 @@ services: SYSTEM_GOOGLEVISIBILITY: "true" SYSTEM_ENABLEMOBILESCANNER: "true" SECURITY_CUSTOMGLOBALAPIKEY: "123456789" + # Folder management + file-storage features the cucumber + # `folders_and_files.feature` suite needs to upload PDFs against. + # The folder endpoints and the storage upload endpoint short-circuit + # to 403 "Storage is disabled" when this is left at its default false. + STORAGE_ENABLED: "true" restart: on-failure:5 diff --git a/frontend/editor/playwright.config.ts b/frontend/editor/playwright.config.ts index 1817a1361..7a049e263 100644 --- a/frontend/editor/playwright.config.ts +++ b/frontend/editor/playwright.config.ts @@ -4,10 +4,10 @@ import { defineConfig, devices } from "@playwright/test"; * Stirling-PDF E2E Test Configuration * * The suite is split into two projects: - * - `stubbed` — backend-free specs that mock `/api/v1/*` via `page.route()`. + * - `stubbed` - backend-free specs that mock `/api/v1/*` via `page.route()`. * Safe to run in CI without the Spring Boot server. Lives in * `src/core/tests/stubbed/**`. - * - `live` — specs that require a real backend on `localhost:8080` + * - `live` - specs that require a real backend on `localhost:8080` * (auth, admin mutation, real tool round-trips). Lives in * `src/core/tests/live/**`. * @@ -35,7 +35,7 @@ export default defineConfig({ expect: { timeout: 10_000 }, use: { - baseURL: "http://localhost:5173", + baseURL: process.env.PLAYWRIGHT_BASE_URL ?? "http://localhost:5173", trace: "on-first-retry", screenshot: "only-on-failure", video: "on-first-retry", @@ -44,14 +44,14 @@ export default defineConfig({ }, projects: [ - // Stubbed — no backend required, chromium-only for CI speed + // Stubbed - no backend required, chromium-only for CI speed { name: "stubbed", testDir: "./src/core/tests/stubbed", use: chromiumViewport, }, - // Live setup — runs once before the live suite to perform the real + // Live setup - runs once before the live suite to perform the real // forced-password-change first-login flow against a freshly-booted // backend. The live project depends on it. { @@ -61,7 +61,7 @@ export default defineConfig({ use: chromiumViewport, }, - // Live backend — auth + admin-mutation + real-tool smoke + // Live backend - auth + admin-mutation + real-tool smoke { name: "live", testDir: "./src/core/tests/live", @@ -69,7 +69,7 @@ export default defineConfig({ dependencies: ["live-setup"], }, - // Enterprise — license-gated SSO/SAML/audit/teams against keycloak compose + // Enterprise - license-gated SSO/SAML/audit/teams against keycloak compose // Uses port 8080 directly (the docker compose stack publishes the // backend's built-in frontend there); the Vite dev server is bypassed // because the OAuth/SAML callback URLs are registered against 8080. @@ -98,7 +98,7 @@ export default defineConfig({ webServer: { // In CI, serve a pre-built `dist/` via `vite preview` so the heavy tool // pages don't pay vite's on-demand transform cost on first hit (which - // blew the 30s navigationTimeout under --workers=3 — see + // blew the 30s navigationTimeout under --workers=3 - see // all-tool-pages-load.spec.ts). Locally, keep `vite` dev for HMR. command: process.env.CI ? "npx vite preview --port 5173 --strictPort" diff --git a/frontend/editor/public/locales/en-GB/translation.toml b/frontend/editor/public/locales/en-GB/translation.toml index 5ae0cfc43..547a6cd67 100644 --- a/frontend/editor/public/locales/en-GB/translation.toml +++ b/frontend/editor/public/locales/en-GB/translation.toml @@ -1467,7 +1467,7 @@ settingsOverview = "This is the Settings Panel. Admin settings systemCustomization = "We have extensive ways to customise the UI: System Settings let you change the app name and languages, Features allows server certificate management, and Endpoints lets you enable or disable specific tools for your users." teamsAndUsers = "Manage Teams and individual users here. You can invite new users via email, shareable links, or create custom accounts for them yourself." welcome = "Welcome to the Admin Tour! 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. You can replay it anytime — just open Settings and find it here in the Tours section under Help." +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 Settings and find it here in the Tours section under Help." [adminUserSettings] actions = "Actions" @@ -1525,7 +1525,7 @@ comment = "Comment" comments = "Comments" contents = "Text" delete = "Delete" -desc = "Use highlight, pen, text, and notes. Changes stay live—no flattening required." +desc = "Use highlight, pen, text, and notes. Changes stay live-no flattening required." drawing = "Drawing" duplicate = "Duplicate" editCircle = "Edit Circle" @@ -1851,6 +1851,13 @@ label = "Open menu for {{title}}" [automate.files] placeholder = "Select files to process with this automation" +[automate.folderScanWarning] +advice = "You can still download the file (e.g. to inspect or hand-edit it), but the unsupported steps will need to be removed before the backend can run it." +cancel = "Cancel" +confirm = "Export anyway" +intro = "Folder scanning runs on the backend, so it can only execute tools that have a backend endpoint. The following step(s) in this automation do not, and will fail when the pipeline runs:" +title = "Some steps cannot run in folder scanning" + [automate.importModal] cancel = "Cancel" confirm = "Import" @@ -2082,9 +2089,9 @@ placeholder = "Number of pages" title = "Last N Pages" [bulkSelection.operators] -and = "AND: & or \"and\" — require both conditions (e.g., 1-50 & even)" -comma = "Comma: , or | — combine selections (e.g., 1-10, 20)" -not = "NOT: ! or \"not\" — exclude pages (e.g., 3n & not 30)" +and = "AND: & or \"and\" - require both conditions (e.g., 1-50 & even)" +comma = "Comma: , or | - combine selections (e.g., 1-10, 20)" +not = "NOT: ! or \"not\" - exclude pages (e.g., 3n & not 30)" text = "AND has higher precedence than comma. NOT applies within the document range." title = "Operators" @@ -2780,8 +2787,8 @@ title = "These PDFs look highly different" [compare.edited] label = "Edited PDF" -selectBaseFirst = "Select original PDF first" placeholder = "Select the edited PDF" +selectBaseFirst = "Select original PDF first" [compare.error] filesMissing = "Unable to locate the selected files. Please re-select them." @@ -2862,9 +2869,9 @@ 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?" +label = "Swap" [compare.toasts] unlinkedBody = "Tip: Arrow Up/Down scroll both panes; panning only moves the active pane." @@ -3184,7 +3191,7 @@ showPreferencesBtn = "Manage preferences" title = "How we use Cookies" [cookieBanner.popUp.description] -1 = "We use cookies and other technologies to make Stirling PDF work better for you—helping us improve our tools and keep building features you'll love." +1 = "We use cookies and other technologies to make Stirling PDF work better for you-helping us improve our tools and keep building features you'll love." 2 = "If you'd rather not, clicking 'No Thanks' will only enable the essential cookies needed to keep things running smoothly." [cookieBanner.preferencesModal] @@ -3197,16 +3204,16 @@ subtitle = "Cookie Usage" title = "Consent Preferences Center" [cookieBanner.preferencesModal.analytics] -description = "These cookies help us understand how our tools are being used, so we can focus on building the features our community values most. Rest assured—Stirling PDF cannot and will never track the content of the documents you work with." +description = "These cookies help us understand how our tools are being used, so we can focus on building the features our community values most. Rest assured-Stirling PDF cannot and will never track the content of the documents you work with." title = "Analytics" [cookieBanner.preferencesModal.description] 1 = "Stirling PDF uses cookies and similar technologies to enhance your experience and understand how our tools are used. This helps us improve performance, develop the features you care about, and provide ongoing support to our users." -2 = "Stirling PDF cannot—and will never—track or access the content of the documents you use." +2 = "Stirling PDF cannot-and will never-track or access the content of the documents you use." 3 = "Your privacy and trust are at the core of what we do." [cookieBanner.preferencesModal.necessary] -description = "These cookies are essential for the website to function properly. They enable core features like setting your privacy preferences, logging in, and filling out forms—which is why they can't be turned off." +description = "These cookies are essential for the website to function properly. They enable core features like setting your privacy preferences, logging in, and filling out forms-which is why they can't be turned off." [cookieBanner.preferencesModal.necessary.title] 1 = "Strictly Necessary Cookies" @@ -3759,13 +3766,209 @@ expand = "Expand sidebar" files = "Files" googleDrive = "Google Drive" googleDriveDisabled = "Google Drive is not configured" +leaveMyFiles = "Leave My Files" +myFiles = "My Files" noFiles = "No files yet" -openFileManager = "Open file manager" +openFileManager = "Browse all files & folders" openFromComputer = "Open from computer" openSettings = "Open settings" search = "Search" searchPlaceholder = "Search files..." +[filesPage] +addToWorkspace = "Add to workspace" +addToWorkspaceCount = "Add {{count}} to workspace" +allFiles = "All files" +back = "Back" +backToFolder = "Back to {{folder}}" +backToMyFiles = "Back to My Files" +breadcrumbs = "Folder path" +cancel = "Cancel" +clearSearch = "Clear search" +clearSelection = "Clear selection" +closeDetails = "Close details" +create = "Create" +cycleBlocked = "Can't move a folder into one of its own subfolders." +delete = "Delete" +deleteFolder = "Delete folder" +deleteFolderBody = "Delete folder \"{{name}}\"?" +deleteFolderConfirm = "Delete folder \"{{name}}\"? Files inside will be moved to All files. {{count}} file(s) affected." +deleteFolderContents = "Also delete {{count}} file(s) inside the folder" +deleteFolderContentsWarning = "Files will be permanently removed and cannot be recovered." +deleteFolderError = "Could not delete the folder. Try again." +deleteFolderKeepHint = "Files inside will be moved to All files." +deleteFolderTitle = "Delete folder?" +deselectAll = "Clear selection" +details = "Details" +detailsCount = "{{count}} files selected" +dismissError = "Dismiss" +download = "Download" +downloadAll = "Download all" +downloadVersion = "Download this version" +dropOverlay = "Drop files to upload" +dropOverlaySub = "Files start in Local. Use 'Move to' or 'Save to cloud' to organise them into a folder." +file = "File" +fileMenu = "File actions" +folder = "Folder" +folderItems = "{{count}} items" +folderMenu = "Folder actions" +inPath = "in {{path}}" +inWorkspace = "Open" +inWorkspaceAria = "Already in workspace" +loading = "Loading…" +localFoldersUnavailable = "Folders are cloud-only - save a file to the cloud to organise it." +moreActions = "More folder actions" +moveLocalToCloudBlocked = "Local-only files can't be moved into cloud folders. Save them to the cloud first." +moveSkippedRemote = "{{count}} file(s) couldn't be moved on the server (no permission or already deleted)." +moveTo = "Move to…" +myFiles = "My Files" +newFolder = "New folder" +newFolderStorageDisabled = "Server folder storage isn't enabled. Ask your admin to turn it on." +newFolderTabUnavailable = "Switch to All or Cloud to create folders." +newRootFolder = "New folder at root" +offlineNoFolderEdits = "Server folder sync unavailable - folder changes are disabled. Check sign-in and storage configuration." +open = "Open" +openInWorkbench = "Open in workbench" +openVersionInWorkspace = "Open in workspace" +originFilter = "Filter by source" +quickView = "Quick view" +refresh = "Refresh from server" +remove = "Delete" +removeConfirm = "Delete {{count}} file(s)? This cannot be undone." +removeVersion = "Remove this version" +rename = "Rename" +renameFolder = "Rename folder" +resizeFolderTree = "Resize folder tree (arrow keys, Shift for bigger steps; double-click to auto-fit)" +save = "Save" +saveToServer = "Save to server" +search = "Search" +searchPlaceholder = "Search this folder & subfolders" +selectAll = "Select all" +selectAllHint = "Click to select all. Tip: hold Ctrl (or Cmd) to add files one at a time, Shift to select a range." +selectedCount = "{{count}} selected" +selectFile = "Select file {{name}}" +shareDisabledHint = "File sharing isn't enabled on this server. Ask your admin to set storage.sharing.enabled=true to turn on share links and per-user access." +shareManage = "Manage sharing" +showDetails = "Show details" +summary = "{{count}} items" +syncFailed = "Folder sync failed: {{message}}" +syncPartial = "Folder sync partial: {{failed}} of {{total}} folders could not be merged." +tree = "Folders" +upload = "Upload" +uploadedToLocal = "Uploaded files start in Local. Use 'Save to cloud' to put them in a folder." +versionActions = "Version actions" +versionCollapse = "Collapse middle versions" +versionOrigin = "Original upload" +versionsCount = "{{count}} versions" +versionShowHidden = "Show {{count}} earlier versions" +viewVersion = "View this version" +appearance.colour = "Colour" +appearance.icon = "Icon" +appearance.title = "Appearance" +appearance.useColour = "Use colour {{c}}" +column.modified = "Modified" +column.name = "Name" +column.size = "Size" +column.type = "Type" +empty.hint = "Drop PDFs anywhere on this page to upload, or use the New folder button to organise your files." +empty.newFolderCta = "Create folder" +empty.title = "This folder is empty" +empty.uploadCta = "Upload files" +empty.cloud.hint = "Upload a file to start, or create a folder to organise." +empty.cloud.offlineHint = "Reconnect to load your cloud library." +empty.cloud.offlineTitle = "No cached cloud files" +empty.cloud.title = "No cloud files yet" +empty.imSharing.hint = "Invite a teammate to one of your files to see it listed here." +empty.imSharing.title = "Not sharing with anyone yet" +empty.local.hint = "Files saved without uploading stay here. Drop a file to add one." +empty.local.title = "No local-only files" +empty.recent.hint = "Files you open or edit will appear here." +empty.recent.title = "Nothing modified yet" +empty.shared.hint = "When someone shares a file via link, it appears here." +empty.shared.title = "Nothing shared with you" +empty.sharedByMe.hint = "Create a share link on any of your files to surface it here." +empty.sharedByMe.title = "No share links yet" +error.actionFailed = "Could not {{action}}." +error.actionFailedDetail = "Could not {{action}}: {{message}}" +error.deleteFolderFailed = "Could not delete folder." +error.deleteFolderFailedDetail = "Could not delete folder: {{message}}" +error.folderAppearanceFailed = "Could not update folder appearance." +error.folderAppearanceFailedDetail = "Could not update folder appearance: {{message}}" +error.moveFilesFailed = "Could not move files." +error.moveFilesFailedDetail = "Could not move files: {{message}}" +error.moveFolderFailed = "Could not move folder." +error.moveFolderFailedDetail = "Could not move folder: {{message}}" +error.removeFilesFailed = "Could not remove files." +error.removeFilesFailedDetail = "Could not remove files: {{message}}" +error.uploadFilesFailed = "Could not upload files." +error.uploadFilesFailedDetail = "Could not upload files: {{message}}" +field.added = "Added" +field.count = "Files" +field.folder = "Folder" +field.modified = "Modified" +field.name = "Name" +field.size = "Size" +field.toolHistory = "Tool history" +field.toolHistoryAtVersion = "Cumulative tool chain" +field.totalSize = "Total size" +field.type = "Type" +field.versionHistory = "Version journey" +folderName.cancel = "Cancel" +folderName.error = "Could not save folder. Try again." +folderName.label = "Folder name" +folderName.placeholder = "Folder name" +moveDialog.cancel = "Cancel" +moveDialog.confirm = "Move here" +moveDialog.error = "Could not move. Try again." +moveDialog.hint = "Pick a destination folder. Tip: you can also drag and drop files onto folders in the tree on the left." +moveDialog.newFolderCancel = "Discard" +moveDialog.newFolderCreate = "Create" +moveDialog.newFolderError = "Could not create folder. Try again." +moveDialog.newFolderLabel = "New folder name" +moveDialog.newFolderPlaceholder = "Folder name" +moveDialog.newFolderToggle = "Create new folder…" +moveDialog.title = "Move to folder" +origin.all = "All sources" +origin.cloud = "Cloud" +origin.cloudHint = "Stored on the Stirling server" +origin.local = "Local" +origin.localHint = "Only stored in this browser" +origin.shared = "Shared" +origin.sharedHint = "Shared with you via link" +sort.modifiedAsc = "Oldest first" +sort.modifiedDesc = "Recent first" +sort.nameAsc = "Name A→Z" +sort.nameDesc = "Name Z→A" +sort.sizeAsc = "Smallest first" +sort.sizeDesc = "Largest first" +syncError.client = "Folder sync failed." +syncError.network = "Could not reach the server." +syncError.server = "Server error during folder sync." +tabName.imSharing = "I'm sharing" +tabName.local = "Local" +tabName.recent = "Recent" +tabName.shared = "Shared with me" +tabName.sharedByMe = "Shared by me" +tabs.all = "All" +tabs.ariaLabel = "File views" +tabs.cloud = "Cloud" +tabs.imSharing = "I'm sharing" +tabs.local = "Local" +tabs.recent = "Recent" +tabs.shared = "Shared with me" +tabs.sharedByMe = "Shared by me" +treeMenu.actions = "Folder actions for {{name}}" +treeMenu.collapse = "Collapse folder" +treeMenu.delete = "Delete folder" +treeMenu.expand = "Expand folder" +treeMenu.newSubfolder = "New subfolder" +treeMenu.rename = "Rename" +typeFilter.allTypes = "All types" +typeFilter.label = "Filter by type" +viewMode.grid = "Grid view" +viewMode.label = "View mode" +viewMode.list = "List view" + [fileToPDF] credit = "This service uses LibreOffice and Unoconv for file conversion." header = "Convert any file to PDF" @@ -4904,7 +5107,7 @@ toolInterface = "This is the Crop tool interface. As you can se viewer = "The Viewer lets you read and annotate your PDFs." viewSwitcher = "Use these controls to select how you want to view your PDFs." workbench = "This is the Workbench - the main area where you view and edit your PDFs." -wrapUp = "You're all set! You can replay this tour anytime — just open Settings and find it here in the Tours section under Help." +wrapUp = "You're all set! You can replay this tour anytime - just open Settings and find it here in the Tours section under Help." [onboarding.buttons] back = "Back" @@ -5244,9 +5447,9 @@ description = "Use n in formulas for patterns." title = "Mathematical Functions" [pageSelection.tooltip.operators] -and = "AND: & or \"and\" — require both conditions (e.g., 1-50 & even)" -comma = "Comma: , or | — combine selections (e.g., 1-10, 20)" -not = "NOT: ! or \"not\" — exclude pages (e.g., 3n & not 30)" +and = "AND: & or \"and\" - require both conditions (e.g., 1-50 & even)" +comma = "Comma: , or | - combine selections (e.g., 1-10, 20)" +not = "NOT: ! or \"not\" - exclude pages (e.g., 3n & not 30)" text = "AND has higher precedence than comma. NOT applies within the document range." title = "Operators" @@ -5434,7 +5637,7 @@ modified = "Edited" unsaved = "Edited" [pdfTextEditor.disclaimer] -alpha = "This alpha viewer is still evolving—certain fonts, colours, transparency effects, and layout details may shift slightly. Please double-check the generated PDF before sharing." +alpha = "This alpha viewer is still evolving-certain fonts, colours, transparency effects, and layout details may shift slightly. Please double-check the generated PDF before sharing." heading = "Preview Limitations" previewVariance = "Some visuals (such as table borders, shapes, or annotation appearances) may not display exactly in the preview. The exported PDF keeps the original drawing commands whenever possible." textFocus = "This workspace focuses on editing text and repositioning embedded images. Complex page artwork, form widgets, and layered graphics are preserved for export but are not fully editable here." @@ -5512,7 +5715,7 @@ paragraph = "Paragraph page" sparse = "Sparse text" [pdfTextEditor.tooltip.alpha] -text = "This alpha viewer is still evolving—certain fonts, colours, transparency effects, and layout details may shift slightly. Please double-check the generated PDF before sharing." +text = "This alpha viewer is still evolving-certain fonts, colours, transparency effects, and layout details may shift slightly. Please double-check the generated PDF before sharing." title = "Alpha Viewer" [pdfTextEditor.tooltip.header] @@ -6569,51 +6772,6 @@ 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" -[workbenchBar] -annotations = "Annotations" -applyRedactionsFirst = "Apply redactions first" -closeAll = "Close All Files" -closePdf = "Close PDF" -closeSelected = "Close Selected Files" -deleteSelected = "Delete Selected Pages" -deselectAll = "Deselect All" -downloadAll = "Download All" -downloadSelected = "Download Selected Files" -draw = "Draw" -exitRedaction = "Exit Redaction Mode" -exportAll = "Export PDF" -exportSelected = "Export Selected Pages" -formFill = "Fill Form" -language = "Language" -panMode = "Pan Mode" -print = "Print PDF" -readAloud = "Read Aloud" -readAloudLanguage = "Language" -readAloudSpeed = "Speed" -redact = "Redact" -rotateLeft = "Rotate Left" -rotateRight = "Rotate Right" -ruler = "Ruler / Measure" -save = "Save" -saveAll = "Save All" -saveAs = "Save As" -saveChanges = "Save Changes" -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" -toggleComments = "Comments" -toggleLayers = "Toggle Layers" -toggleSidebar = "Toggle Sidebar" -toggleTheme = "Toggle Theme" -activeFiles = "Active Files" -multiTool = "Multi-Tool" -viewer = "Viewer" - [rotate] rotateLeft = "Rotate Anticlockwise" rotateRight = "Rotate Clockwise" @@ -6752,7 +6910,7 @@ useCase2 = "Split flatbed batches into separate files" useCase3 = "Break collages into individual photos" useCase4 = "Pull photos from documents" whatThisDoes = "What this does" -whatThisDoesDesc = "Automatically finds and extracts each photo from a scanned page or composite image—no manual cropping." +whatThisDoesDesc = "Automatically finds and extracts each photo from a scanned page or composite image-no manual cropping." whenToUse = "When to use" [search] @@ -6907,6 +7065,20 @@ auto = "Auto" fitPage = "Fit page" fitWidth = "Fit width" +[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.hotkeys] capturing = "Press keys… (Esc to cancel)" change = "Change shortcut" @@ -7038,20 +7210,6 @@ 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" @@ -8249,8 +8407,8 @@ csvStats = "{{rows}} rows · {{columns}} columns · {{size}}" emptyFile = "Empty file" fileTypeBadge = "{{type}} File" htmlPreview = "HTML preview" -htmlPreviewWarning = "HTML preview — external resources may not load · {{size}}" -invalidJson = "Invalid JSON — showing raw content" +htmlPreviewWarning = "HTML preview - external resources may not load · {{size}}" +invalidJson = "Invalid JSON - showing raw content" lineNumbers = "Line numbers" loading = "Loading..." renderMarkdown = "Render markdown" @@ -8460,6 +8618,50 @@ 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" +[workbenchBar] +activeFiles = "Active Files" +annotations = "Annotations" +applyRedactionsFirst = "Apply redactions first" +closeAll = "Close All Files" +closePdf = "Close PDF" +closeSelected = "Close Selected Files" +deleteSelected = "Delete Selected Pages" +deselectAll = "Deselect All" +downloadAll = "Download All" +downloadSelected = "Download Selected Files" +draw = "Draw" +exitRedaction = "Exit Redaction Mode" +exportAll = "Export PDF" +exportSelected = "Export Selected Pages" +formFill = "Fill Form" +language = "Language" +multiTool = "Multi-Tool" +panMode = "Pan Mode" +print = "Print PDF" +readAloud = "Read Aloud" +readAloudLanguage = "Language" +readAloudSpeed = "Speed" +redact = "Redact" +rotateLeft = "Rotate Left" +rotateRight = "Rotate Right" +ruler = "Ruler / Measure" +save = "Save" +saveAll = "Save All" +saveAs = "Save As" +saveChanges = "Save Changes" +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" +toggleComments = "Comments" +toggleLayers = "Toggle Layers" +toggleSidebar = "Toggle Sidebar" +toggleTheme = "Toggle Theme" +viewer = "Viewer" [workspace] title = "Workspace" diff --git a/frontend/editor/src/core/components/AppProviders.tsx b/frontend/editor/src/core/components/AppProviders.tsx index 4f6f5566a..3501658b9 100644 --- a/frontend/editor/src/core/components/AppProviders.tsx +++ b/frontend/editor/src/core/components/AppProviders.tsx @@ -32,6 +32,7 @@ import { useLogoAssets } from "@app/hooks/useLogoAssets"; import AppConfigLoader from "@app/components/shared/AppConfigLoader"; import { RedactionProvider } from "@app/contexts/RedactionContext"; import { FormFillProvider } from "@app/tools/formFill/FormFillContext"; +import { FolderProvider } from "@app/contexts/FolderContext"; // Component to initialize scarf tracking (must be inside AppConfigProvider) function ScarfTrackingInitializer() { @@ -125,39 +126,41 @@ export function AppProviders({ enableUrlSync={true} enablePersistence={true} > - - - - - - - - - - - - - - - - - - {children} - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + {children} + + + + + + + + + + + + + + + + diff --git a/frontend/editor/src/core/components/FileManager.tsx b/frontend/editor/src/core/components/FileManager.tsx index ea54093dc..16cded856 100644 --- a/frontend/editor/src/core/components/FileManager.tsx +++ b/frontend/editor/src/core/components/FileManager.tsx @@ -6,7 +6,6 @@ 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"; -import { Tool } from "@app/types/tool"; import MobileLayout from "@app/components/fileManager/MobileLayout"; import DesktopLayout from "@app/components/fileManager/DesktopLayout"; import DragOverlay from "@app/components/fileManager/DragOverlay"; @@ -20,8 +19,14 @@ import { loadScript } from "@app/utils/scriptLoader"; import { useAllFiles } from "@app/contexts/FileContext"; import { useFileActions } from "@app/contexts/file/fileHooks"; +/** + * Structural prop: anything that exposes an optional `supportedFormats` + * string array. Both `Tool` (from `@app/types/tool`) and `ToolRegistryEntry` + * (from `@app/data/toolsTaxonomy`) satisfy this, so callers can pass either + * without an `as any` cast. + */ interface FileManagerProps { - selectedTool?: Tool | null; + selectedTool?: { supportedFormats?: string[] } | null; } const FileManager: React.FC = ({ selectedTool }) => { diff --git a/frontend/editor/src/core/components/filesPage/DeleteFolderDialog.tsx b/frontend/editor/src/core/components/filesPage/DeleteFolderDialog.tsx new file mode 100644 index 000000000..e2d7edc43 --- /dev/null +++ b/frontend/editor/src/core/components/filesPage/DeleteFolderDialog.tsx @@ -0,0 +1,130 @@ +import React, { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Alert, + Button, + Checkbox, + Group, + Modal, + Stack, + Text, +} from "@mantine/core"; +import ErrorOutlineIcon from "@mui/icons-material/ErrorOutlined"; + +import { FolderRecord } from "@app/types/folder"; + +interface DeleteFolderDialogProps { + opened: boolean; + folder: FolderRecord | null; + /** Number of files inside the folder (and subtree). */ + fileCount: number; + onClose: () => void; + /** Confirm; `deleteContents` is true when the user opted in to delete files. */ + onConfirm: (deleteContents: boolean) => void | Promise; +} + +export function DeleteFolderDialog({ + opened, + folder, + fileCount, + onClose, + onConfirm, +}: DeleteFolderDialogProps) { + const { t } = useTranslation(); + const [deleteContents, setDeleteContents] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (opened) { + setDeleteContents(false); + setSubmitting(false); + setError(null); + } + }, [opened]); + + if (!folder) return null; + + return ( + + + + {t("filesPage.deleteFolderBody", 'Delete folder "{{name}}"?', { + name: folder.name, + })} + + {fileCount > 0 && ( + setDeleteContents(e.currentTarget.checked)} + disabled={submitting} + label={t( + "filesPage.deleteFolderContents", + "Also delete {{count}} file(s) inside the folder", + { count: fileCount }, + )} + /> + )} + {fileCount > 0 && ( + + {deleteContents + ? t( + "filesPage.deleteFolderContentsWarning", + "Files will be permanently removed and cannot be recovered.", + ) + : t( + "filesPage.deleteFolderKeepHint", + "Files inside will be moved to All files.", + )} + + )} + {error && ( + } + variant="light" + role="alert" + > + {error} + + )} + + + + + + + ); +} diff --git a/frontend/editor/src/core/components/filesPage/FileDetailsPanel.tsx b/frontend/editor/src/core/components/filesPage/FileDetailsPanel.tsx new file mode 100644 index 000000000..e7086f2fc --- /dev/null +++ b/frontend/editor/src/core/components/filesPage/FileDetailsPanel.tsx @@ -0,0 +1,658 @@ +import React, { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { ActionIcon, Badge, Button, Menu, Tooltip } from "@mantine/core"; +import CloseIcon from "@mui/icons-material/Close"; +import OpenInNewIcon from "@mui/icons-material/OpenInNew"; +import VisibilityIcon from "@mui/icons-material/Visibility"; +import DriveFileMoveIcon from "@mui/icons-material/DriveFileMove"; +import DeleteIcon from "@mui/icons-material/Delete"; +import DownloadIcon from "@mui/icons-material/Download"; +import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf"; +import HistoryIcon from "@mui/icons-material/History"; +import MoreVertIcon from "@mui/icons-material/MoreVert"; +import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; +import LinkIcon from "@mui/icons-material/Link"; +import CloudUploadIcon from "@mui/icons-material/CloudUpload"; + +import { FileId, ToolOperation } from "@app/types/file"; +import { ToolId } from "@app/types/toolId"; +import { FolderRecord } from "@app/types/folder"; +import { StirlingFileStub } from "@app/types/fileContext"; +import { formatFileSize, getFileDate } from "@app/utils/fileUtils"; +import { + downloadFileFromStorage, + downloadMultipleFiles, +} from "@app/utils/downloadUtils"; +import ToolChain from "@app/components/shared/ToolChain"; +import ShareManagementModal from "@app/components/shared/ShareManagementModal"; +import { useSharingEnabled } from "@app/hooks/useSharingEnabled"; +import { fileStorage } from "@app/services/fileStorage"; + +interface FileDetailsPanelProps { + selectedFileIds: FileId[]; + fileMap: Map; + currentFolder: FolderRecord | null; + onClose: () => void; + onAddToWorkspace: (fileIds: FileId[]) => void; + onQuickView: (fileId: FileId) => void; + onMove: (fileIds: FileId[]) => void; + onRemove: (fileIds: FileId[]) => void; + /** Save to server; only shown when at least one selected file is local-only. */ + onSaveToServer?: (files: StirlingFileStub[]) => void; +} + +export function FileDetailsPanel({ + selectedFileIds, + fileMap, + currentFolder, + onClose, + onAddToWorkspace, + onQuickView, + onMove, + onRemove, + onSaveToServer, +}: FileDetailsPanelProps) { + const { t } = useTranslation(); + const { sharingEnabled } = useSharingEnabled(); + const files = useMemo( + () => + selectedFileIds + .map((id) => fileMap.get(id)) + .filter((f): f is StirlingFileStub => Boolean(f)), + [selectedFileIds, fileMap], + ); + + // Hooks must run before any early return. + const [downloading, setDownloading] = useState(false); + const [shareModalOpen, setShareModalOpen] = useState(false); + // Version chain for the selected file; empty for v1 or multi-select. + const [versionChain, setVersionChain] = useState([]); + const singleFileForChain = files.length === 1 ? files[0] : null; + useEffect(() => { + if (!singleFileForChain) { + setVersionChain([]); + return; + } + let cancelled = false; + const rootId = (singleFileForChain.originalFileId ?? + singleFileForChain.id) as FileId; + fileStorage + .getHistoryChainStubs(rootId) + .then((chain) => { + if (!cancelled) setVersionChain(chain); + }) + .catch((err) => { + console.error("Failed to load version history", err); + if (!cancelled) setVersionChain([]); + }); + return () => { + cancelled = true; + }; + }, [singleFileForChain]); + + if (files.length === 0) { + return null; + } + + const single = files.length === 1 ? files[0]! : null; + const totalSize = files.reduce((sum, f) => sum + f.size, 0); + const ext = single ? (single.name.split(".").pop() ?? "").toUpperCase() : ""; + // Files still needing a server upload; drives Save-to-server visibility. + const localOnlyFiles = files.filter((f) => f.remoteStorageId == null); + + const handleDownload = async () => { + setDownloading(true); + try { + if (single) { + await downloadFileFromStorage(single); + } else { + await downloadMultipleFiles(files); + } + } catch (err) { + console.error("Download failed", err); + } finally { + setDownloading(false); + } + }; + + return ( + + ); +} + +function DetailField({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ); +} + +/** Tool that produced `version` from `prior`; null for v1. */ +function deltaToolFor( + version: StirlingFileStub, + prior: StirlingFileStub | null, +): ToolOperation | null { + if (!prior) return null; + const priorLen = prior.toolHistory?.length ?? 0; + const curr = version.toolHistory ?? []; + return curr[priorLen] ?? null; +} + +interface VersionTimelineProps { + /** Chain sorted oldest-first. */ + chain: StirlingFileStub[]; + /** Currently selected version. */ + currentId: FileId; + onQuickView: (fileId: FileId) => void; + onAddToWorkspace: (fileIds: FileId[]) => void; + onRemove: (fileIds: FileId[]) => void; +} + +/** Version timeline with per-row tool deltas and collapse-when-long. */ +function VersionTimeline({ + chain, + currentId, + onQuickView, + onAddToWorkspace, + onRemove, +}: VersionTimelineProps) { + const { t } = useTranslation(); + const [expandedIds, setExpandedIds] = useState>(new Set()); + const [showAllCollapsed, setShowAllCollapsed] = useState(false); + + // Newest-first ordering. + const ordered = useMemo( + () => + [...chain].sort( + (a, b) => (b.versionNumber ?? 1) - (a.versionNumber ?? 1), + ), + [chain], + ); + + // Index by versionNumber for prior-version lookup. + const byVersionNumber = useMemo(() => { + const map = new Map(); + for (const v of chain) { + map.set(v.versionNumber ?? 1, v); + } + return map; + }, [chain]); + + // Collapse middle when long: 3 newest + ellipsis + 2 oldest. + const COLLAPSE_THRESHOLD = 6; + const collapsible = ordered.length > COLLAPSE_THRESHOLD; + type Row = + | { kind: "version"; version: StirlingFileStub } + | { + kind: "ellipsis"; + hidden: number; + }; + const rows: Row[] = useMemo(() => { + if (!collapsible || showAllCollapsed) { + return ordered.map((v) => ({ kind: "version", version: v }) as Row); + } + const head = ordered + .slice(0, 3) + .map((v) => ({ kind: "version", version: v }) as Row); + const tail = ordered + .slice(-2) + .map((v) => ({ kind: "version", version: v }) as Row); + const hidden = ordered.length - 5; + return [...head, { kind: "ellipsis", hidden }, ...tail]; + }, [collapsible, showAllCollapsed, ordered]); + + const toggleExpand = (id: FileId) => { + setExpandedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + return ( +
+
+ + {t("filesPage.field.versionHistory", "Version journey")} + + {t("filesPage.versionsCount", "{{count}} versions", { + count: ordered.length, + })} + +
+
    + {rows.map((row, idx) => { + const isLast = idx === rows.length - 1; + if (row.kind === "ellipsis") { + return ( +
  1. +
    + + {!isLast && ( + + )} +
    + +
  2. + ); + } + const v = row.version; + const isActive = v.id === currentId; + const isExpanded = expandedIds.has(v.id); + const prior = byVersionNumber.get((v.versionNumber ?? 1) - 1) ?? null; + const delta = deltaToolFor(v, prior); + return ( +
  3. +
    + + {!isLast && ( + + )} +
    +
    + +
    + {formatFileSize(v.size)} + {v.lastModified ? ( + <> + · + + {getFileDate({ lastModified: v.lastModified })} + + + ) : null} + {!isActive && ( + <> + + + + e.stopPropagation()} + > + + + + + } + onClick={() => onQuickView(v.id)} + > + {t("filesPage.viewVersion", "View this version")} + + } + onClick={() => onAddToWorkspace([v.id])} + > + {t( + "filesPage.openVersionInWorkspace", + "Open in workspace", + )} + + } + onClick={() => { + void downloadFileFromStorage(v); + }} + > + {t( + "filesPage.downloadVersion", + "Download this version", + )} + + + } + onClick={() => onRemove([v.id])} + > + {t( + "filesPage.removeVersion", + "Remove this version", + )} + + + + + )} +
    + {isExpanded && ( + // Filename + full cumulative tool chain. +
    + + {v.toolHistory && v.toolHistory.length > 0 && ( +
    + + {t( + "filesPage.field.toolHistoryAtVersion", + "Cumulative tool chain", + )} + + +
    + )} +
    + )} +
    +
  4. + ); + })} +
+ {collapsible && showAllCollapsed && ( + + )} +
+ ); +} + +/** Translated tool name via `home.{toolId}.title`. */ +function ToolLabel({ toolId }: { toolId: ToolId }) { + const { t } = useTranslation(); + return {t(`home.${toolId}.title`, toolId)}; +} diff --git a/frontend/editor/src/core/components/filesPage/FileGrid.tsx b/frontend/editor/src/core/components/filesPage/FileGrid.tsx new file mode 100644 index 000000000..832d0463a --- /dev/null +++ b/frontend/editor/src/core/components/filesPage/FileGrid.tsx @@ -0,0 +1,1380 @@ +import React, { useCallback, useMemo, useRef } from "react"; +import { useTranslation } from "react-i18next"; +import { ActionIcon, Button, Checkbox, Menu, Tooltip } from "@mantine/core"; +import MoreVertIcon from "@mui/icons-material/MoreVert"; +import FolderIcon from "@mui/icons-material/Folder"; +import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf"; +import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile"; +import DriveFileMoveIcon from "@mui/icons-material/DriveFileMove"; +import DeleteIcon from "@mui/icons-material/Delete"; +import OpenInNewIcon from "@mui/icons-material/OpenInNew"; +import VisibilityIcon from "@mui/icons-material/Visibility"; +import DriveFileRenameOutlineIcon from "@mui/icons-material/DriveFileRenameOutline"; +import CloudUploadIcon from "@mui/icons-material/CloudUpload"; +import UploadFileIcon from "@mui/icons-material/UploadFile"; +import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder"; + +import { FileId } from "@app/types/file"; +import { FolderId, FolderRecord, ROOT_FOLDER_ID } from "@app/types/folder"; +import { useFolders } from "@app/contexts/FolderContext"; +import { StirlingFileStub } from "@app/types/fileContext"; +import { formatFileSize, getFileDate } from "@app/utils/fileUtils"; +import { + FILES_PAGE_DRAG_TYPE, + parseFilesPageDragPayload, + serialiseFilesPageDragPayload, +} from "@app/components/filesPage/dragDrop"; +import { useDropTarget } from "@app/components/filesPage/useDropTarget"; +import { getFileOrigin } from "@app/components/filesPage/fileOrigin"; +import { FileOriginBadge } from "@app/components/filesPage/FileOriginBadge"; +import { FolderThumbnail } from "@app/components/filesPage/FolderThumbnail"; +import { findFolderIcon } from "@app/components/filesPage/folderIcons"; +import { FolderAppearancePicker } from "@app/components/filesPage/FolderAppearancePicker"; +import type { FilesPageSortMode } from "@app/contexts/FilesPageContext"; + +export type FilesPageViewMode = "grid" | "list"; + +export interface FilesPageEntry { + kind: "folder" | "file"; + folder?: FolderRecord; + /** Number of files inside this folder (folder entries only). */ + folderFileCount?: number; + file?: StirlingFileStub; + /** Parent breadcrumb path for search results outside the current folder. */ + parentPath?: string; +} + +interface FileGridProps { + entries: FilesPageEntry[]; + selectedFileIds: Set; + /** Ids of files loaded in the active workspace. */ + activeWorkspaceFileIds?: Set; + viewMode: FilesPageViewMode; + onSelectFile: (id: FileId, shiftKey: boolean, ctrlKey: boolean) => void; + /** Replace the entire selection set. */ + onSetSelection?: (ids: Set) => void; + onOpenFolder: (id: FolderId) => void; + /** "Add to workspace". */ + onOpenFile: (file: StirlingFileStub) => void; + /** "Quick view". */ + onQuickView: (file: StirlingFileStub) => void; + onMoveFiles: ( + fileIds: FileId[], + targetFolderId: FolderId | null, + ) => void | Promise; + onMoveFolder: ( + folderId: FolderId, + newParentId: FolderId | null, + ) => void | Promise; + onRenameFolder: (folder: FolderRecord) => void; + onDeleteFolder: (folder: FolderRecord) => void; + onChangeFolderAppearance: ( + folderId: FolderId, + appearance: { color?: string; icon?: string | null }, + ) => void; + onRemoveFiles: (fileIds: FileId[]) => void; + onPromptMoveFiles: (fileIds: FileId[]) => void; + /** Per-file Save to server; hidden when file already has remoteStorageId. */ + onSaveToServer?: (file: StirlingFileStub) => void; + /** When supplied the list-view column headers become sortable. */ + sortMode?: FilesPageSortMode; + onChangeSortMode?: (mode: FilesPageSortMode) => void; + /** Drives the empty-state copy. */ + currentTab?: + | "all" + | "local" + | "cloud" + | "recent" + | "shared" + | "sharedByMe" + | "imSharing"; + /** Cloud reachability; switches the cloud empty-state copy. */ + serverReachable?: boolean; + /** Empty-state CTA handlers; if absent the matching button hides. */ + onEmptyUpload?: () => void; + onEmptyCreateFolder?: () => void; + /** Non-null disables the New folder CTA with this reason as tooltip. */ + newFolderDisabledReason?: string | null; +} + +export function FileGrid(props: FileGridProps & { loading?: boolean }) { + const { + viewMode, + entries, + loading, + currentTab, + serverReachable, + onEmptyUpload, + onEmptyCreateFolder, + newFolderDisabledReason, + } = props; + + if (loading && entries.length === 0) { + return ; + } + + if (entries.length === 0) { + return ( + + ); + } + + if (viewMode === "list") { + return ; + } + return ; +} + +function SkeletonGrid({ viewMode }: { viewMode: FilesPageViewMode }) { + // Six placeholders mirroring the card layout while IDB resolves. + const placeholders = Array.from({ length: 6 }); + if (viewMode === "list") { + return ( +
+ {placeholders.map((_, i) => ( +
+ + + + + + +
+ ))} +
+ ); + } + return ( +
+ {placeholders.map((_, i) => ( +
+
+
+
+
+
+
+ ))} +
+ ); +} + +interface EmptyStateProps { + /** Drives copy + iconography. */ + tab?: + | "all" + | "local" + | "cloud" + | "recent" + | "shared" + | "sharedByMe" + | "imSharing"; + /** Switches the cloud empty-state copy. */ + serverReachable?: boolean; + /** CTA handlers; absent => button hidden. */ + onUpload?: () => void; + onCreateFolder?: () => void; + /** Non-null disables New folder CTA with this reason. */ + newFolderDisabledReason?: string | null; +} + +function EmptyState({ + tab = "all", + serverReachable = true, + onUpload, + onCreateFolder, + newFolderDisabledReason, +}: EmptyStateProps) { + const { t } = useTranslation(); + const { titleKey, titleFallback, hintKey, hintFallback } = (() => { + switch (tab) { + case "local": + return { + titleKey: "filesPage.empty.local.title", + titleFallback: "No local-only files", + hintKey: "filesPage.empty.local.hint", + hintFallback: + "Files saved without uploading stay here. Drop a file to add one.", + }; + case "cloud": + return serverReachable + ? { + titleKey: "filesPage.empty.cloud.title", + titleFallback: "No cloud files yet", + hintKey: "filesPage.empty.cloud.hint", + hintFallback: + "Upload a file to start, or create a folder to organise.", + } + : { + titleKey: "filesPage.empty.cloud.offlineTitle", + titleFallback: "No cached cloud files", + hintKey: "filesPage.empty.cloud.offlineHint", + hintFallback: "Reconnect to load your cloud library.", + }; + case "recent": + return { + titleKey: "filesPage.empty.recent.title", + titleFallback: "Nothing modified yet", + hintKey: "filesPage.empty.recent.hint", + hintFallback: "Files you open or edit will appear here.", + }; + case "shared": + return { + titleKey: "filesPage.empty.shared.title", + titleFallback: "Nothing shared with you", + hintKey: "filesPage.empty.shared.hint", + hintFallback: "When someone shares a file via link, it appears here.", + }; + case "sharedByMe": + return { + titleKey: "filesPage.empty.sharedByMe.title", + titleFallback: "No share links yet", + hintKey: "filesPage.empty.sharedByMe.hint", + hintFallback: + "Create a share link on any of your files to surface it here.", + }; + case "imSharing": + return { + titleKey: "filesPage.empty.imSharing.title", + titleFallback: "Not sharing with anyone yet", + hintKey: "filesPage.empty.imSharing.hint", + hintFallback: + "Invite a teammate to one of your files to see it listed here.", + }; + case "all": + default: + return { + titleKey: "filesPage.empty.title", + titleFallback: "This folder is empty", + hintKey: "filesPage.empty.hint", + hintFallback: + "Drop PDFs anywhere on this page to upload, or use the New folder button to organise your files.", + }; + } + })(); + // Recent/Shared tabs are read-only filters; Local is cloud-only for folders. + const readOnlyTab = + tab === "recent" || + tab === "shared" || + tab === "sharedByMe" || + tab === "imSharing"; + const showUpload = Boolean(onUpload) && !readOnlyTab; + const showCreateFolder = + Boolean(onCreateFolder) && !readOnlyTab && tab !== "local"; + const showCtas = showUpload || showCreateFolder; + return ( +
+ + + +
{t(titleKey, titleFallback)}
+
{t(hintKey, hintFallback)}
+ {showCtas && ( +
+ {showUpload && ( + + )} + {showCreateFolder && + (newFolderDisabledReason ? ( + + {/* Wrap so tooltip hovers while button is disabled. */} + + + + + ) : ( + + ))} +
+ )} +
+ ); +} + +function GridView({ + entries, + selectedFileIds, + activeWorkspaceFileIds, + onSelectFile, + onOpenFolder, + onOpenFile, + onQuickView, + onMoveFiles, + onMoveFolder, + onRenameFolder, + onDeleteFolder, + onChangeFolderAppearance, + onRemoveFiles, + onPromptMoveFiles, + onSaveToServer, +}: FileGridProps) { + return ( +
+ {entries.map((entry) => { + if (entry.kind === "folder" && entry.folder) { + return ( + onOpenFolder(entry.folder!.id)} + onRename={() => onRenameFolder(entry.folder!)} + onDelete={() => onDeleteFolder(entry.folder!)} + onChangeAppearance={(appearance) => + onChangeFolderAppearance(entry.folder!.id, appearance) + } + onMoveFiles={(fileIds) => onMoveFiles(fileIds, entry.folder!.id)} + onMoveFolder={(folderId) => + onMoveFolder(folderId, entry.folder!.id) + } + /> + ); + } + if (entry.kind === "file" && entry.file) { + return ( + = 2} + onClick={(e) => + onSelectFile(entry.file!.id, e.shiftKey, e.metaKey || e.ctrlKey) + } + onDoubleClick={() => onOpenFile(entry.file!)} + onQuickView={() => onQuickView(entry.file!)} + onRemove={() => onRemoveFiles([entry.file!.id])} + onMove={() => { + const target = selectedFileIds.has(entry.file!.id) + ? Array.from(selectedFileIds) + : [entry.file!.id]; + onPromptMoveFiles(target); + }} + onSaveToServer={ + onSaveToServer ? () => onSaveToServer(entry.file!) : undefined + } + /> + ); + } + return null; + })} +
+ ); +} + +interface FolderCardProps { + folder: FolderRecord; + fileCount: number; + /** Subtitle for search results outside current folder. */ + parentPath?: string; + selectedFileIds: Set; + onOpen: () => void; + onRename: () => void; + onDelete: () => void; + onChangeAppearance: (appearance: { + color?: string; + icon?: string | null; + }) => void; + onMoveFiles: (fileIds: FileId[]) => void | Promise; + onMoveFolder: (folderId: FolderId) => void | Promise; +} + +function FolderCard({ + folder, + fileCount, + parentPath, + onOpen, + onRename, + onDelete, + onChangeAppearance, + onMoveFiles, + onMoveFolder, +}: FolderCardProps) { + const { t } = useTranslation(); + const { serverReachable, setError } = useFolders(); + const offlineHint = t( + "filesPage.offlineNoFolderEdits", + "Offline - folder changes are disabled.", + ); + const surfaceDrop = (err: unknown, label: string) => { + console.error(`[FolderCard] ${label}`, err); + setError( + err instanceof Error + ? `Could not ${label}: ${err.message}` + : `Could not ${label}.`, + ); + }; + const kebabRef = useRef(null); + const { handlers: dropHandlers, isOver: isDropTarget } = useDropTarget({ + dragType: FILES_PAGE_DRAG_TYPE, + onDrop: (e) => { + const payload = parseFilesPageDragPayload(e.dataTransfer); + if (!payload) return; + // Surface rejections instead of silent no-op on IDB failures. + if (payload.kind === "files") { + Promise.resolve(onMoveFiles(payload.fileIds)).catch((err) => + surfaceDrop(err, "move files into folder"), + ); + } else if (payload.kind === "folder") { + Promise.resolve(onMoveFolder(payload.folderId)).catch((err) => + surfaceDrop(err, "move folder"), + ); + } + }, + }); + + return ( +
{ + e.dataTransfer.setData( + FILES_PAGE_DRAG_TYPE, + serialiseFilesPageDragPayload({ + kind: "folder", + folderId: folder.id, + }), + ); + e.dataTransfer.effectAllowed = "move"; + }} + {...dropHandlers} + className={`files-page-card is-folder${ + isDropTarget ? " is-drop-target" : "" + }`} + onDoubleClick={onOpen} + onContextMenu={(e) => { + e.preventDefault(); + kebabRef.current?.click(); + }} + onKeyDown={(e) => { + if (e.key === "Enter") onOpen(); + }} + > +
+ +
+
+
+ {folder.name} +
+ {parentPath && ( +
+ {t("filesPage.inPath", "in {{path}}", { path: parentPath })} +
+ )} +
+ {fileCount === 0 + ? t("filesPage.folder", "Folder") + : t("filesPage.folderItems", "{{count}} items", { + count: fileCount, + })} +
+
+
+ + + e.stopPropagation()} + aria-label={t("filesPage.folderMenu", "Folder actions")} + > + + + + + } + onClick={onOpen} + > + {t("filesPage.open", "Open")} + + } + onClick={onRename} + disabled={!serverReachable} + title={!serverReachable ? offlineHint : undefined} + > + {t("filesPage.rename", "Rename")} + + + + {t("filesPage.appearance.title", "Appearance")} + + + + } + onClick={onDelete} + disabled={!serverReachable} + title={!serverReachable ? offlineHint : undefined} + > + {t("filesPage.deleteFolder", "Delete folder")} + + + +
+
+ ); +} + +interface FileCardProps { + file: StirlingFileStub; + isSelected: boolean; + isInWorkspace: boolean; + /** Subtitle for search results outside current folder. */ + parentPath?: string; + selectedFileIds: Set; + /** Shows the checkbox once 2+ files are selected. */ + multiSelectActive: boolean; + onClick: (e: React.MouseEvent) => void; + onDoubleClick: () => void; + onQuickView: () => void; + onRemove: () => void; + onMove: () => void; + /** Kebab Save to server; only fires when file is local-only. */ + onSaveToServer?: () => void; +} + +function FileCard({ + file, + parentPath, + isSelected, + isInWorkspace, + selectedFileIds, + multiSelectActive, + onClick, + onDoubleClick, + onQuickView, + onRemove, + onMove, + onSaveToServer, +}: FileCardProps) { + const { t } = useTranslation(); + const cardRef = useRef(null); + const fileSize = useMemo(() => formatFileSize(file.size), [file.size]); + const fileDate = useMemo( + () => getFileDate({ lastModified: file.lastModified }), + [file.lastModified], + ); + + const handleDragStart = useCallback( + (e: React.DragEvent) => { + const fileIds = isSelected ? Array.from(selectedFileIds) : [file.id]; + e.dataTransfer.setData( + FILES_PAGE_DRAG_TYPE, + serialiseFilesPageDragPayload({ kind: "files", fileIds }), + ); + e.dataTransfer.effectAllowed = "move"; + }, + [file.id, isSelected, selectedFileIds], + ); + + const extension = file.name.split(".").pop()?.toUpperCase() ?? ""; + const isPdf = extension === "PDF"; + + const kebabRef = useRef(null); + const handleContextMenu = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + // Right-click on unselected card selects first, then opens menu. + if (!isSelected) onClick(e); + kebabRef.current?.click(); + }, + [isSelected, onClick], + ); + + return ( +
{ + if (e.key === "Enter") onDoubleClick(); + }} + className={`files-page-card${isSelected ? " is-selected" : ""}${ + isInWorkspace ? " is-in-workspace" : "" + }`} + > + {isInWorkspace && ( + + + {t("filesPage.inWorkspace", "Open")} + + )} + {/* Checkbox only renders once the user is explicitly in multi-select + mode (2+ files chosen via Ctrl/Shift-click, or one file then + another). For single-select the highlight border on the card is + the only state indicator - avoids the always-on-checkbox + visual noise and matches the file-explorer model. */} + {multiSelectActive && ( +
+ { + // Synthesise ctrl-click so parent takes the toggle branch. + e.stopPropagation(); + onClick({ + ...e, + shiftKey: false, + ctrlKey: true, + metaKey: true, + } as unknown as React.MouseEvent); + }} + onChange={() => { + /* handled by onClick */ + }} + aria-label={t("filesPage.selectFile", "Select file {{name}}", { + name: file.name, + })} + /> +
+ )} +
+ {file.thumbnailUrl ? ( + // draggable={false} so card's onDragStart fires, not native image drag. + + ) : ( +
+ {isPdf ? ( + + ) : ( + + )} + {extension || "FILE"} +
+ )} +
+ +
+
+
+
+ {file.name} +
+ {parentPath && ( +
+ {t("filesPage.inPath", "in {{path}}", { path: parentPath })} +
+ )} +
+ {fileSize} + · + {fileDate} +
+
+
+ + + e.stopPropagation()} + aria-label={t("filesPage.fileMenu", "File actions")} + > + + + + + } + onClick={(e) => { + e.stopPropagation(); + onDoubleClick(); + }} + > + {t("filesPage.addToWorkspace", "Add to workspace")} + + } + onClick={(e) => { + e.stopPropagation(); + onQuickView(); + }} + > + {t("filesPage.quickView", "Quick view")} + + } + onClick={(e) => { + e.stopPropagation(); + onMove(); + }} + > + {t("filesPage.moveTo", "Move to…")} + + {/* Per-file Save to server; hidden when already on server. */} + {onSaveToServer && file.remoteStorageId == null && ( + } + onClick={(e) => { + e.stopPropagation(); + onSaveToServer(); + }} + > + {t("filesPage.saveToServer", "Save to server")} + + )} + + } + onClick={(e) => { + e.stopPropagation(); + onRemove(); + }} + > + {t("filesPage.remove", "Delete")} + + + +
+
+ ); +} + +function ListView({ + entries, + selectedFileIds, + activeWorkspaceFileIds, + onSelectFile, + onSetSelection, + onOpenFolder, + onOpenFile, + onQuickView, + onMoveFiles, + onMoveFolder, + onRenameFolder, + onDeleteFolder, + onSaveToServer, + onChangeFolderAppearance, + onRemoveFiles, + onPromptMoveFiles, + sortMode, + onChangeSortMode, +}: FileGridProps & { + sortMode?: FilesPageSortMode; + onChangeSortMode?: (next: FilesPageSortMode) => void; +}) { + const { t } = useTranslation(); + + // Tri-state header checkbox state - computed from current entries. + const visibleFileIds = useMemo( + () => + entries + .filter( + (e): e is FilesPageEntry & { file: StirlingFileStub } => + e.kind === "file" && !!e.file, + ) + .map((e) => e.file.id), + [entries], + ); + const allSelected = + visibleFileIds.length > 0 && + visibleFileIds.every((id) => selectedFileIds.has(id)); + const someSelected = + !allSelected && visibleFileIds.some((id) => selectedFileIds.has(id)); + + const sortIndicator = (asc: FilesPageSortMode, desc: FilesPageSortMode) => { + if (sortMode === asc) return " ↑"; + if (sortMode === desc) return " ↓"; + return ""; + }; + + const headerProps = (asc: FilesPageSortMode, desc: FilesPageSortMode) => ({ + role: "button", + tabIndex: onChangeSortMode ? 0 : undefined, + "data-sortable": onChangeSortMode ? "true" : undefined, + onClick: () => { + if (!onChangeSortMode) return; + onChangeSortMode(sortMode === asc ? desc : asc); + }, + onKeyDown: (e: React.KeyboardEvent) => { + if (!onChangeSortMode) return; + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onChangeSortMode(sortMode === asc ? desc : asc); + } + }, + }); + + return ( +
+
+ {onSetSelection && visibleFileIds.length > 0 ? ( + { + onSetSelection(allSelected ? new Set() : new Set(visibleFileIds)); + }} + aria-label={ + allSelected + ? t("filesPage.deselectAll", "Clear selection") + : t("filesPage.selectAll", "Select all") + } + /> + ) : ( +
+ {entries.map((entry) => { + if (entry.kind === "folder" && entry.folder) { + return ( + onOpenFolder(entry.folder!.id)} + onRename={() => onRenameFolder(entry.folder!)} + onDelete={() => onDeleteFolder(entry.folder!)} + onChangeAppearance={(appearance) => + onChangeFolderAppearance(entry.folder!.id, appearance) + } + onDropFiles={(fileIds) => onMoveFiles(fileIds, entry.folder!.id)} + onDropFolder={(folderId) => + onMoveFolder(folderId, entry.folder!.id) + } + /> + ); + } + if (entry.kind === "file" && entry.file) { + return ( + = 2} + onClick={(e) => + onSelectFile(entry.file!.id, e.shiftKey, e.metaKey || e.ctrlKey) + } + onOpen={() => onOpenFile(entry.file!)} + onQuickView={() => onQuickView(entry.file!)} + onRemove={() => onRemoveFiles([entry.file!.id])} + onMove={() => { + const target = selectedFileIds.has(entry.file!.id) + ? Array.from(selectedFileIds) + : [entry.file!.id]; + onPromptMoveFiles(target); + }} + onSaveToServer={ + onSaveToServer ? () => onSaveToServer(entry.file!) : undefined + } + /> + ); + } + return null; + })} +
+ ); +} + +interface FolderRowProps { + folder: FolderRecord; + fileCount: number; + parentPath?: string; + onOpen: () => void; + onRename: () => void; + onDelete: () => void; + onChangeAppearance: (appearance: { + color?: string; + icon?: string | null; + }) => void; + onDropFiles: (fileIds: FileId[]) => void | Promise; + onDropFolder: (folderId: FolderId) => void | Promise; +} + +function FolderRow({ + folder, + fileCount, + parentPath, + onOpen, + onRename, + onDelete, + onChangeAppearance, + onDropFiles, + onDropFolder, +}: FolderRowProps) { + const { t } = useTranslation(); + const { serverReachable, setError } = useFolders(); + const offlineHint = t( + "filesPage.offlineNoFolderEdits", + "Offline - folder changes are disabled.", + ); + const surfaceDrop = (err: unknown, label: string) => { + console.error(`[FolderRow] ${label}`, err); + setError( + err instanceof Error + ? t("filesPage.error.actionFailedDetail", { + action: label, + message: err.message, + defaultValue: `Could not ${label}: ${err.message}`, + }) + : t("filesPage.error.actionFailed", { + action: label, + defaultValue: `Could not ${label}.`, + }), + ); + }; + const kebabRef = useRef(null); + const { handlers: dropHandlers, isOver: isDropTarget } = useDropTarget({ + dragType: FILES_PAGE_DRAG_TYPE, + onDrop: (e) => { + const payload = parseFilesPageDragPayload(e.dataTransfer); + if (!payload) return; + if (payload.kind === "files") { + Promise.resolve(onDropFiles(payload.fileIds)).catch((err) => + surfaceDrop(err, "move files into folder"), + ); + } else if (payload.kind === "folder") { + Promise.resolve(onDropFolder(payload.folderId)).catch((err) => + surfaceDrop(err, "move folder"), + ); + } + }, + }); + return ( +
{ + e.dataTransfer.setData( + FILES_PAGE_DRAG_TYPE, + serialiseFilesPageDragPayload({ + kind: "folder", + folderId: folder.id, + }), + ); + e.dataTransfer.effectAllowed = "move"; + }} + {...dropHandlers} + onDoubleClick={onOpen} + onContextMenu={(e) => { + e.preventDefault(); + kebabRef.current?.click(); + }} + onKeyDown={(e) => { + if (e.key === "Enter") onOpen(); + }} + className={`files-page-list-row${isDropTarget ? " is-drop-target" : ""}`} + > +
+ ); +} + +interface FileRowProps { + file: StirlingFileStub; + isSelected: boolean; + isInWorkspace: boolean; + parentPath?: string; + selectedFileIds: Set; + /** Shows the checkbox once 2+ files are selected. */ + multiSelectActive: boolean; + onClick: (e: React.MouseEvent) => void; + onOpen: () => void; + onQuickView: () => void; + onRemove: () => void; + onMove: () => void; + /** Kebab Save to server; only fires when file is local-only. */ + onSaveToServer?: () => void; +} + +function FileRow({ + file, + isSelected, + isInWorkspace, + parentPath, + selectedFileIds, + multiSelectActive, + onClick, + onOpen, + onQuickView, + onRemove, + onMove, + onSaveToServer, +}: FileRowProps) { + const { t } = useTranslation(); + const kebabRef = useRef(null); + const fileSize = useMemo(() => formatFileSize(file.size), [file.size]); + const fileDate = useMemo( + () => getFileDate({ lastModified: file.lastModified }), + [file.lastModified], + ); + const ext = (file.name.split(".").pop() ?? "").toUpperCase(); + return ( +
{ + const fileIds = isSelected ? Array.from(selectedFileIds) : [file.id]; + e.dataTransfer.setData( + FILES_PAGE_DRAG_TYPE, + serialiseFilesPageDragPayload({ kind: "files", fileIds }), + ); + e.dataTransfer.effectAllowed = "move"; + }} + onClick={onClick} + onDoubleClick={onOpen} + onContextMenu={(e) => { + e.preventDefault(); + if (!isSelected) onClick(e); + kebabRef.current?.click(); + }} + onKeyDown={(e) => { + if (e.key === "Enter") onOpen(); + }} + className={`files-page-list-row${isSelected ? " is-selected" : ""}${ + isInWorkspace ? " is-in-workspace" : "" + }`} + > + {/* Checkbox only shows in multi-select mode (see FileCard). When the + checkbox is hidden the first grid column collapses, but the row's + CSS grid keeps the columns aligned via the named template, so no + empty cell shows. */} + {multiSelectActive ? ( + { + // Toggle this file in/out of the selection without modifier keys. + e.stopPropagation(); + onClick({ + ...e, + shiftKey: false, + ctrlKey: true, + metaKey: true, + } as unknown as React.MouseEvent); + }} + onChange={() => { + /* handled by onClick */ + }} + aria-label={t("filesPage.selectFile", "Select file {{name}}", { + name: file.name, + })} + /> + ) : ( + // Empty cell preserves grid column alignment. +
+ ); +} + +// Re-export root constant for caller convenience +export { ROOT_FOLDER_ID }; diff --git a/frontend/editor/src/core/components/filesPage/FileManagerView.tsx b/frontend/editor/src/core/components/filesPage/FileManagerView.tsx new file mode 100644 index 000000000..2c1233a60 --- /dev/null +++ b/frontend/editor/src/core/components/filesPage/FileManagerView.tsx @@ -0,0 +1,1698 @@ +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { useTranslation } from "react-i18next"; +import { useLocation, useNavigate } from "react-router-dom"; +import { + ActionIcon, + Button, + Drawer, + Group, + MultiSelect, + SegmentedControl, + Select, + Tooltip, +} from "@mantine/core"; +import { useMediaQuery } from "@mantine/hooks"; +import SearchIcon from "@mui/icons-material/Search"; +import CloseIcon from "@mui/icons-material/Close"; +import UploadFileIcon from "@mui/icons-material/UploadFile"; +import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder"; +import GridViewIcon from "@mui/icons-material/GridView"; +import ViewListIcon from "@mui/icons-material/ViewList"; +import DeleteIcon from "@mui/icons-material/Delete"; +import DriveFileMoveIcon from "@mui/icons-material/DriveFileMove"; +import OpenInNewIcon from "@mui/icons-material/OpenInNew"; +import VisibilityIcon from "@mui/icons-material/Visibility"; +import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; +import CloudUploadIcon from "@mui/icons-material/CloudUpload"; +import KeyboardArrowRightIcon from "@mui/icons-material/KeyboardArrowRight"; +import RefreshIcon from "@mui/icons-material/Refresh"; + +import { useSharingEnabled } from "@app/hooks/useSharingEnabled"; +import { useFolders } from "@app/contexts/FolderContext"; +import { useFileActions } from "@app/contexts/file/fileHooks"; +import { useAllFiles } from "@app/contexts/FileContext"; +import { useFileHandler } from "@app/hooks/useFileHandler"; +import { + useNavigationActions, + useNavigationGuard, +} from "@app/contexts/NavigationContext"; +import { useViewer } from "@app/contexts/ViewerContext"; +import { + FILES_PAGE_VIEW_MODES, + FilesPageOriginFilter, + FilesPageSortMode, + useFilesPage, +} from "@app/contexts/FilesPageContext"; +import { getFileOrigin } from "@app/components/filesPage/fileOrigin"; + +import { FileId } from "@app/types/file"; +import { StirlingFileStub } from "@app/types/fileContext"; +import { FolderId, ROOT_FOLDER_ID } from "@app/types/folder"; + +import { FileGrid, FilesPageEntry } from "@app/components/filesPage/FileGrid"; +import { FileDetailsPanel } from "@app/components/filesPage/FileDetailsPanel"; +import BulkUploadToServerModal from "@app/components/shared/BulkUploadToServerModal"; +import { MoveToFolderDialog } from "@app/components/filesPage/MoveToFolderDialog"; +import { FolderNameDialog } from "@app/components/filesPage/FolderNameDialog"; +import { DeleteFolderDialog } from "@app/components/filesPage/DeleteFolderDialog"; +import { materializeServerStubs } from "@app/services/fileSyncService"; +import { + FILES_PAGE_DRAG_TYPE, + parseFilesPageDragPayload, +} from "@app/components/filesPage/dragDrop"; +import { + clearFilesPageReturnRoute, + setFilesPageReturnRoute, +} from "@app/components/filesPage/filesPageReturnRoute"; +import "@app/components/filesPage/FilesPage.css"; + +export default function FileManagerView() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const location = useLocation(); + + // Hide Shared tab when storageSharingEnabled is false. + const { sharingEnabled } = useSharingEnabled(); + + // ≤800px hosts the details panel in a button-triggered Drawer. + const isCompactDetailsViewport = useMediaQuery("(max-width: 800px)") ?? false; + // Phones get a full-screen drawer; tablets get a smaller one. + const useFullScreenDrawer = useMediaQuery("(max-width: 640px)") ?? false; + const [mobileDetailsOpen, setMobileDetailsOpen] = useState(false); + // Save-to-server modal target. Bulk button uses local-only selection; + // per-file kebab uses [file]. Targets root; folder placement is via drop. + const [saveToServerTarget, setSaveToServerTarget] = useState< + StirlingFileStub[] | null + >(null); + const folders = useFolders(); + const { actions: fileActions } = useFileActions(); + const { fileIds: activeWorkspaceFileIds } = useAllFiles(); + const activeWorkspaceFileIdSet = useMemo( + () => new Set(activeWorkspaceFileIds.map((id) => id as string)), + [activeWorkspaceFileIds], + ); + const { addFiles } = useFileHandler(); + const { actions: navActions } = useNavigationActions(); + const { requestNavigation } = useNavigationGuard(); + const { setActiveFileId } = useViewer(); + + const filesPage = useFilesPage(); + const { + allFiles, + fileMap, + loading, + refresh, + selectedFileIds, + setSelectedFileIds, + clearSelection, + viewMode, + setViewMode, + sortMode, + setSortMode, + search, + setSearch, + originFilter, + setOriginFilter, + typeFilter, + setTypeFilter, + currentTab, + setCurrentTab, + folderNameDialog, + openNewFolderDialog, + openRenameFolderDialog, + closeFolderNameDialog, + submitFolderName, + moveDialog, + promptMoveFiles, + closeMoveDialog, + moveFilesTo, + moveFolderTo, + removeFiles, + promptDeleteFolder, + deleteFolder, + deleteFolderDialog, + closeDeleteFolderDialog, + setFolderAppearance, + } = filesPage; + + const setCurrentFolderId = folders.setCurrentFolderId; + const foldersById = folders.foldersById; + const currentFolderId = folders.currentFolderId; + + // Sync the URL into FolderContext. + useEffect(() => { + const match = location.pathname.match(/^\/files\/([^/]+)/); + const param = match?.[1] ?? null; + if (param === null) { + setCurrentFolderId(ROOT_FOLDER_ID); + } else if (foldersById.has(param as FolderId)) { + setCurrentFolderId(param as FolderId); + } else { + setCurrentFolderId(ROOT_FOLDER_ID); + } + }, [location.pathname, foldersById, setCurrentFolderId]); + + // Bounce off any share-related tab when sharing isn't enabled. + useEffect(() => { + if ( + !sharingEnabled && + (currentTab === "shared" || + currentTab === "sharedByMe" || + currentTab === "imSharing") + ) { + setCurrentTab("all"); + } + }, [sharingEnabled, currentTab, setCurrentTab]); + + // Push folder selection into the URL while still on /files. + useEffect(() => { + if (!window.location.pathname.startsWith("/files")) return; + const target = + currentFolderId === null ? "/files" : `/files/${currentFolderId}`; + if (window.location.pathname !== target) { + navigate(target, { replace: true }); + } + }, [currentFolderId, navigate]); + + // ─── visible items (current folder + sort + search) ───────────────────── + + /** currentFolderId + all descendants. Includes `null` when at root. */ + const subtreeFolderIds = useMemo(() => { + const set = new Set(); + set.add(currentFolderId); + const childMap = new Map(); + for (const f of folders.folders) { + const list = childMap.get(f.parentFolderId) ?? []; + list.push(f.id); + childMap.set(f.parentFolderId, list); + } + // Iterative DFS to avoid stack overflow on deep chains. + const stack: (FolderId | null)[] = [currentFolderId]; + while (stack.length > 0) { + const cur = stack.pop()!; + for (const childId of childMap.get(cur) ?? []) { + if (set.has(childId)) continue; + set.add(childId); + stack.push(childId); + } + } + return set; + }, [folders.folders, currentFolderId]); + + const visibleFolders = useMemo(() => { + // Folders only appear in cloud-rooted tabs. + if ( + currentTab === "local" || + currentTab === "recent" || + currentTab === "shared" || + currentTab === "sharedByMe" || + currentTab === "imSharing" + ) { + return []; + } + const lc = search.toLowerCase(); + const matched = folders.folders.filter((f) => { + if (search) { + // Subtree-wide name match; exclude the current folder itself. + return ( + f.id !== currentFolderId && + subtreeFolderIds.has(f.parentFolderId) && + f.name.toLowerCase().includes(lc) + ); + } + // Direct children only. + return f.parentFolderId === currentFolderId; + }); + return matched.sort((a, b) => + a.name.localeCompare(b.name, undefined, { sensitivity: "base" }), + ); + }, [folders.folders, currentFolderId, search, currentTab, subtreeFolderIds]); + + // Files in current folder, pre-filter. Drives the type-filter dropdown. + const filesInCurrentFolder = useMemo(() => { + // Tab overrides folder navigation for Local/Recent/Shared. + switch (currentTab) { + case "local": + // Local = files with no server copy. folderId is forced null on this + // path (cf. file.ts comment), but we check remoteStorageId too so + // stale local-folder rows from a pre-pivot DB don't slip through. + return allFiles.filter((f) => f.remoteStorageId == null); + case "cloud": + // Cloud bucket; search widens to subtree, else direct-folder match. + return allFiles.filter((f) => { + if (f.remoteStorageId == null) return false; + if (search) return subtreeFolderIds.has(f.folderId ?? null); + return (f.folderId ?? null) === (currentFolderId ?? null); + }); + case "recent": { + // Last 50 modified across local + cloud, folder context ignored. + const sorted = [...allFiles].sort( + (a, b) => (b.lastModified ?? 0) - (a.lastModified ?? 0), + ); + return sorted.slice(0, 50); + } + case "shared": + return allFiles.filter((f) => f.remoteOwnedByCurrentUser === false); + case "sharedByMe": + // Files I own that have at least one outgoing public share link. + return allFiles.filter( + (f) => + f.remoteOwnedByCurrentUser !== false && + f.remoteHasShareLinks === true, + ); + case "imSharing": + // Files I own that I've shared directly with specific users. + return allFiles.filter( + (f) => + f.remoteOwnedByCurrentUser !== false && + f.remoteHasUserShares === true, + ); + case "all": + default: + // Search widens to the subtree. + return allFiles.filter((f) => { + if (search) return subtreeFolderIds.has(f.folderId ?? null); + return (f.folderId ?? null) === (currentFolderId ?? null); + }); + } + }, [allFiles, currentFolderId, currentTab, search, subtreeFolderIds]); + + const availableTypes = useMemo(() => { + const set = new Set(); + for (const f of filesInCurrentFolder) { + const ext = (f.name.split(".").pop() ?? "").toUpperCase(); + if (ext) set.add(ext); + } + return Array.from(set).sort(); + }, [filesInCurrentFolder]); + + // Drop any active type filters that no longer appear in this folder + // (e.g. when the user navigates between folders). + useEffect(() => { + if (typeFilter.length === 0) return; + const stillValid = typeFilter.filter((t) => availableTypes.includes(t)); + if (stillValid.length !== typeFilter.length) { + setTypeFilter(stillValid); + } + }, [availableTypes, typeFilter, setTypeFilter]); + + const visibleFiles = useMemo(() => { + const filtered = filesInCurrentFolder + .filter((f) => + search ? f.name.toLowerCase().includes(search.toLowerCase()) : true, + ) + .filter((f) => + originFilter === "all" ? true : getFileOrigin(f) === originFilter, + ) + .filter((f) => { + if (typeFilter.length === 0) return true; + const ext = (f.name.split(".").pop() ?? "").toUpperCase(); + return typeFilter.includes(ext); + }); + const sorted = [...filtered]; + sorted.sort((a, b) => { + switch (sortMode) { + case "name-asc": + return a.name.localeCompare(b.name); + case "name-desc": + return b.name.localeCompare(a.name); + case "modified-asc": + return (a.lastModified ?? 0) - (b.lastModified ?? 0); + case "size-desc": + return (b.size ?? 0) - (a.size ?? 0); + case "size-asc": + return (a.size ?? 0) - (b.size ?? 0); + case "modified-desc": + default: + return (b.lastModified ?? 0) - (a.lastModified ?? 0); + } + }); + return sorted; + }, [filesInCurrentFolder, search, sortMode, originFilter, typeFilter]); + + /** + * Resolve a folder id to its breadcrumb path (e.g. "Receipts / 2024 / Q1"). + * Returns empty string for root / unknown. Used for the search-result + * "where does this live?" subtitle. + */ + const pathForFolderId = useCallback( + (folderId: FolderId | null | undefined): string => { + if (folderId == null) return ""; + const parts: string[] = []; + let cursor: FolderId | null = folderId; + const seen = new Set(); + while (cursor !== null) { + if (seen.has(cursor)) break; + seen.add(cursor); + const f = foldersById.get(cursor); + if (!f) break; + parts.unshift(f.name); + cursor = f.parentFolderId; + } + return parts.join(" / "); + }, + [foldersById], + ); + + const entries = useMemo(() => { + // When searching, items may come from anywhere in the subtree, so we + // expose a "parentPath" subtitle whenever the item's parent differs from + // currentFolderId. When no search is active, every item is in the + // current folder by definition and the subtitle is suppressed. + const inSearch = search.length > 0; + return [ + ...visibleFolders.map((folder) => ({ + kind: "folder", + folder, + folderFileCount: filesPage.fileCountsByFolder.get(folder.id) ?? 0, + parentPath: + inSearch && folder.parentFolderId !== currentFolderId + ? pathForFolderId(folder.parentFolderId) || undefined + : undefined, + })), + ...visibleFiles.map((file) => ({ + kind: "file", + file, + parentPath: + inSearch && (file.folderId ?? null) !== (currentFolderId ?? null) + ? pathForFolderId(file.folderId ?? null) || undefined + : undefined, + })), + ]; + }, [ + visibleFolders, + visibleFiles, + filesPage.fileCountsByFolder, + search, + currentFolderId, + pathForFolderId, + ]); + + // ─── selection ────────────────────────────────────────────────────────── + const lastClickedFileRef = useRef(null); + const handleSelectFile = useCallback( + (fileId: FileId, shift: boolean, ctrl: boolean) => { + setSelectedFileIds((prev) => { + const next = new Set(prev); + if (shift && lastClickedFileRef.current) { + const idx = visibleFiles.findIndex((f) => f.id === fileId); + const lastIdx = visibleFiles.findIndex( + (f) => f.id === lastClickedFileRef.current, + ); + if (idx >= 0 && lastIdx >= 0) { + const [a, b] = idx < lastIdx ? [idx, lastIdx] : [lastIdx, idx]; + for (let i = a; i <= b; i += 1) { + next.add(visibleFiles[i]!.id); + } + return next; + } + } + // Once the user has 2+ files selected they're explicitly in + // multi-select mode (they checked a box, or shift-range'd, or + // ctrl-clicked) - in that mode plain clicks toggle add/remove + // instead of collapsing the whole selection back to one file. + // This is the Google Drive pattern: the "selection mode" sticks + // until the user explicitly exits via the X clear button or by + // clicking the empty background of the grid. + const inMultiSelectMode = prev.size >= 2; + if (ctrl || inMultiSelectMode) { + if (next.has(fileId)) next.delete(fileId); + else next.add(fileId); + } else { + // 0 or 1 selected: plain click replaces (Finder/Explorer + // behaviour). Clicking the already-only-selected file + // deselects it, so single-file selection still toggles. + const isSoleSelection = prev.size === 1 && prev.has(fileId); + next.clear(); + if (!isSoleSelection) next.add(fileId); + } + return next; + }); + lastClickedFileRef.current = fileId; + }, + [visibleFiles, setSelectedFileIds], + ); + + // Background click on the content area clears the selection. + const handleContentBackgroundClick = useCallback( + (e: React.MouseEvent) => { + // Only react when the click target is the scroll container itself + // (not a card, row, or drop overlay). + if (e.target === e.currentTarget) { + clearSelection(); + } + }, + [clearSelection], + ); + + // ─── upload (drag-from-desktop or button) ─────────────────────────────── + const fileInputRef = useRef(null); + const [isDraggingExternal, setIsDraggingExternal] = useState(false); + const [refreshing, setRefreshing] = useState(false); + + const handleNativeUpload = useCallback( + async (files: File[]) => { + if (files.length === 0) return; + // skipWorkspaceDispatch: the user is in the file manager, not opening + // files for work. Persist to IDB so the file appears in the grid (via + // FilesPageContext's independent IDB scan) but DON'T pollute workspace + // state - otherwise the file pops up the next time the user navigates + // to /viewer or /tools, which reads as "auto-opened" and surprised + // people every time. The grid will repaint via refresh() below. + const added = await addFiles(files, { + selectFiles: false, + skipWorkspaceDispatch: true, + }); + const fileIds = added.map((f) => f.fileId); + const target = currentFolderId; + // Uploaded files land in Local (folderId stays null). + if ( + target !== null && + fileIds.length > 0 && + (currentTab === "all" || currentTab === "cloud") + ) { + folders.setError( + t( + "filesPage.uploadedToLocal", + "Uploaded files start in Local. Use 'Save to cloud' to put them in a folder.", + ), + ); + } + await refresh(); + }, + [addFiles, currentFolderId, currentTab, folders, refresh, t], + ); + + const onFileInputChange = useCallback( + async (e: React.ChangeEvent) => { + const list = Array.from(e.target.files ?? []); + e.target.value = ""; + if (list.length === 0) return; + await handleNativeUpload(list); + }, + [handleNativeUpload], + ); + + // ─── add to workspace vs quick view ───────────────────────────────────── + // addToWorkspace: commit; no back affordance. + // quickView: peek; "Back to My Files" pill in WorkbenchBar. + const openFilesInWorkbench = useCallback( + async (fileIds: FileId[], options: { trackReturn: boolean }) => { + const stubs = fileIds + .map((id) => fileMap.get(id)) + .filter((s): s is StirlingFileStub => Boolean(s)); + if (stubs.length === 0) return; + + const proceed = async () => { + if (options.trackReturn) { + const returnRoute = + currentFolderId === null ? "/files" : `/files/${currentFolderId}`; + const folderRecord = currentFolderId + ? (foldersById.get(currentFolderId) ?? null) + : null; + const returnLabel = folderRecord + ? folderRecord.name + : t("filesPage.myFiles", "My Files"); + setFilesPageReturnRoute(returnRoute, returnLabel); + } else { + clearFilesPageReturnRoute(); + } + + // Server-only stubs have no bytes in IDB; download + ingest first. + const materialized = await materializeServerStubs(stubs, { + addFiles: fileActions.addFilesWithOptions, + updateStub: fileActions.updateStirlingFileStub, + }); + if (materialized.length !== stubs.length) { + // At least one server download failed; refresh so the grid + // reflects any successful ingests and the user can retry. + await refresh(); + return; + } + + await fileActions.addStirlingFileStubs(materialized, { + selectFiles: false, + }); + // Branch on requested stubs so already-active files still activate. + if (materialized.length === 1) { + setActiveFileId(materialized[0]!.id); + navActions.setWorkbench("viewer"); + } else if (materialized.length > 1) { + navActions.setWorkbench("fileEditor"); + } + navigate("/"); + }; + + requestNavigation(() => { + void proceed(); + }); + }, + [ + fileMap, + fileActions, + setActiveFileId, + navActions, + navigate, + requestNavigation, + currentFolderId, + foldersById, + t, + ], + ); + + const handleAddToWorkspace = useCallback( + (fileIds: FileId[]) => + openFilesInWorkbench(fileIds, { trackReturn: false }), + [openFilesInWorkbench], + ); + + const handleQuickView = useCallback( + (fileId: FileId) => openFilesInWorkbench([fileId], { trackReturn: true }), + [openFilesInWorkbench], + ); + + const handleOpenFile = useCallback( + (file: StirlingFileStub) => { + // Double-click commits to workspace. + void handleAddToWorkspace([file.id]); + }, + [handleAddToWorkspace], + ); + + const handleOpenFolder = useCallback( + (id: FolderId) => { + folders.setCurrentFolderId(id); + clearSelection(); + }, + [folders, clearSelection], + ); + + // ─── full-page drag-and-drop for OS uploads ───────────────────────────── + const dropZoneRef = useRef(null); + useEffect(() => { + const node = dropZoneRef.current; + if (!node) return; + let counter = 0; + const isExternalFileDrag = (e: DragEvent) => + Array.from(e.dataTransfer?.types ?? []).includes("Files"); + + const onEnter = (e: DragEvent) => { + if (!isExternalFileDrag(e)) return; + e.preventDefault(); + counter += 1; + setIsDraggingExternal(true); + }; + const onOver = (e: DragEvent) => { + if (!isExternalFileDrag(e)) return; + e.preventDefault(); + }; + const onLeave = () => { + counter -= 1; + if (counter <= 0) { + counter = 0; + setIsDraggingExternal(false); + } + }; + const onDrop = (e: DragEvent) => { + if (!isExternalFileDrag(e)) return; + e.preventDefault(); + counter = 0; + setIsDraggingExternal(false); + const dropped = Array.from(e.dataTransfer?.files ?? []); + if (dropped.length > 0) { + handleNativeUpload(dropped).catch((err) => + folders.setError( + err instanceof Error + ? t("filesPage.error.uploadFilesFailedDetail", { + message: err.message, + defaultValue: `Could not upload files: ${err.message}`, + }) + : t( + "filesPage.error.uploadFilesFailed", + "Could not upload files.", + ), + ), + ); + } + }; + node.addEventListener("dragenter", onEnter); + node.addEventListener("dragover", onOver); + node.addEventListener("dragleave", onLeave); + node.addEventListener("drop", onDrop); + return () => { + node.removeEventListener("dragenter", onEnter); + node.removeEventListener("dragover", onOver); + node.removeEventListener("dragleave", onLeave); + node.removeEventListener("drop", onDrop); + }; + }, [handleNativeUpload]); + + // ─── close / exit ─────────────────────────────────────────────────────── + const handleClose = useCallback(() => { + // Drop the return-route hint so the workbench doesn't show a stale back. + clearFilesPageReturnRoute(); + navigate("/"); + }, [navigate]); + + // ─── keyboard shortcuts ───────────────────────────────────────────────── + const searchInputRef = useRef(null); + // External focus trigger (used by the FileSidebar rail Search button). + useEffect(() => { + const onFocus = () => searchInputRef.current?.focus(); + window.addEventListener("files-page:focus-search", onFocus); + return () => window.removeEventListener("files-page:focus-search", onFocus); + }, []); + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + const active = document.activeElement as HTMLElement | null; + const inInput = + active && + (active.tagName === "INPUT" || + active.tagName === "TEXTAREA" || + active.isContentEditable); + + // Cmd/Ctrl + A - select every visible file in the current folder. + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "a" && !inInput) { + e.preventDefault(); + setSelectedFileIds(new Set(visibleFiles.map((f) => f.id))); + return; + } + + // Delete / Backspace - remove selected files. + if ( + (e.key === "Delete" || e.key === "Backspace") && + !inInput && + selectedFileIds.size > 0 + ) { + e.preventDefault(); + removeFiles(Array.from(selectedFileIds)).catch((err) => + folders.setError( + err instanceof Error + ? t("filesPage.error.removeFilesFailedDetail", { + message: err.message, + defaultValue: `Could not remove files: ${err.message}`, + }) + : t( + "filesPage.error.removeFilesFailed", + "Could not remove files.", + ), + ), + ); + return; + } + + // "/" focuses the search field. + if (e.key === "/" && !inInput) { + e.preventDefault(); + searchInputRef.current?.focus(); + return; + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [visibleFiles, selectedFileIds, removeFiles, setSelectedFileIds]); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key !== "Escape") return; + const active = document.activeElement as HTMLElement | null; + if ( + active && + (active.tagName === "INPUT" || + active.tagName === "TEXTAREA" || + active.isContentEditable) + ) { + return; + } + const overlays = document.querySelectorAll(".mantine-Modal-overlay"); + for (const overlay of overlays) { + if ((overlay as HTMLElement).offsetWidth > 0) return; + } + // Esc-once cancels selection before closing the workbench. + if (selectedFileIds.size > 0) { + clearSelection(); + return; + } + handleClose(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [handleClose, selectedFileIds, clearSelection]); + + // ─── remove via context wrapper (clears selection state too) ──────────── + const handleRemoveFiles = useCallback( + async (fileIds: FileId[]) => { + await removeFiles(fileIds); + }, + [removeFiles], + ); + + // ─── derived UI bits ──────────────────────────────────────────────────── + const currentFolderRecord = currentFolderId + ? (foldersById.get(currentFolderId) ?? null) + : null; + const totalCount = entries.length; + const selectedFiles = useMemo( + () => Array.from(selectedFileIds), + [selectedFileIds], + ); + + // Local-only subset of selection; drives Save-to-server visibility. + const localOnlySelectedStubs = useMemo( + () => + selectedFiles + .map((id) => fileMap.get(id)) + .filter( + (s): s is StirlingFileStub => + Boolean(s) && s!.remoteStorageId == null, + ), + [selectedFiles, fileMap], + ); + + // null = New folder actionable; string = disabled tooltip reason. + const newFolderDisabledReason: string | null = useMemo(() => { + if (currentTab === "local") { + return t( + "filesPage.localFoldersUnavailable", + "Folders are cloud-only - save a file to the cloud to organise it.", + ); + } + if ( + currentTab === "recent" || + currentTab === "shared" || + currentTab === "sharedByMe" || + currentTab === "imSharing" + ) { + return t( + "filesPage.newFolderTabUnavailable", + "Switch to All or Cloud to create folders.", + ); + } + if (!folders.serverReachable) { + return t( + "filesPage.newFolderStorageDisabled", + "Server folder storage isn't enabled. Ask your admin to turn it on.", + ); + } + return null; + }, [currentTab, folders.serverReachable, t]); + + return ( +
+
+ {/* Breadcrumb only for folder-rooted tabs. */} + {(currentTab === "all" || currentTab === "cloud") && } + {(currentTab === "local" || + currentTab === "recent" || + currentTab === "shared" || + currentTab === "sharedByMe" || + currentTab === "imSharing") && ( +
+ {currentTab === "local" + ? t("filesPage.tabName.local", "Local") + : currentTab === "recent" + ? t("filesPage.tabName.recent", "Recent") + : currentTab === "shared" + ? t("filesPage.tabName.shared", "Shared with me") + : currentTab === "sharedByMe" + ? t("filesPage.tabName.sharedByMe", "Shared by me") + : t("filesPage.tabName.imSharing", "Sharing")} +
+ )} + {(() => { + // Both the inline desktop buttons and the mobile kebab menu need + // these handlers - extract once so we don't drift two copies. + const handleRefresh = async () => { + setRefreshing(true); + try { + // pullFromServer bumps the folder revision, which the + // FolderProvider's effect reacts to by re-running refresh() - + // no need to await folders.refresh() manually. + const result = await folders.pullFromServer(); + if (!result.ok && result.reason !== "endpoint-missing") { + folders.setError( + result.reason === "network" + ? t( + "filesPage.syncError.network", + "Could not reach the server.", + ) + : result.reason === "server" + ? t( + "filesPage.syncError.server", + "Server error during folder sync.", + ) + : t("filesPage.syncError.client", "Folder sync failed."), + ); + } + await refresh(); + } finally { + setRefreshing(false); + } + }; + return ( + <> + +
+ + + + + + {newFolderDisabledReason ? ( + + + + + + ) : ( + + )} + + +
+ + ); + })()} +
+ + {folders.error && ( +
+ {folders.error} + folders.setError(null)} + > + + +
+ )} + + {/* No offline banner: when the folder API is unreachable the user + still sees their cached local files (the IDB read survives), and + folder-mutation controls are individually disabled with their own + tooltips. Banner removed per UX feedback. */} + +
+
+ {/* Tab strip filters the file list; ARIA Tabs keyboard model. */} + {(() => { + const TAB_DEFS = [ + { id: "all", label: t("filesPage.tabs.all", "All") }, + { id: "recent", label: t("filesPage.tabs.recent", "Recent") }, + // Sharing tabs only when sharingEnabled. + ...(sharingEnabled + ? [ + { + id: "shared" as const, + label: t("filesPage.tabs.shared", "Shared with me"), + }, + { + id: "sharedByMe" as const, + label: t("filesPage.tabs.sharedByMe", "Shared by me"), + }, + { + id: "imSharing" as const, + label: t("filesPage.tabs.imSharing", "Sharing"), + }, + ] + : []), + ] as const; + const focusTab = (id: string) => { + const el = document.getElementById(`filesPage-tab-${id}`); + el?.focus(); + }; + return ( +
{ + const idx = TAB_DEFS.findIndex((t2) => t2.id === currentTab); + if (idx < 0) return; + let next: number; + if (e.key === "ArrowRight") + next = (idx + 1) % TAB_DEFS.length; + else if (e.key === "ArrowLeft") + next = (idx - 1 + TAB_DEFS.length) % TAB_DEFS.length; + else if (e.key === "Home") next = 0; + else if (e.key === "End") next = TAB_DEFS.length - 1; + else return; + e.preventDefault(); + const target = TAB_DEFS[next]!; + setCurrentTab(target.id); + focusTab(target.id); + }} + style={{ + display: "flex", + gap: "0.1rem", + padding: "0.2rem 1rem 0.2rem", + }} + > + {TAB_DEFS.map((tab) => ( + + ))} +
+ ); + })()} + +
+ + {loading + ? t("filesPage.loading", "Loading…") + : t("filesPage.summary", "{{count}} items", { + count: totalCount, + })} + {selectedFiles.length > 0 && ( + + {" "} + ·{" "} + {t("filesPage.selectedCount", "{{count}} selected", { + count: selectedFiles.length, + })} + + )} + + {(() => { + // Select all / Clear toggle over visible files. + if (visibleFiles.length === 0) return null; + const allSelected = visibleFiles.every((f) => + selectedFileIds.has(f.id), + ); + const someSelected = !allSelected && selectedFiles.length > 0; + return ( + + + + ); + })()} +
+ {selectedFiles.length > 0 && + (() => { + // Bulk-action labels; CSS collapses to icon-only below 900px. + const addLabel = + selectedFiles.length === 1 + ? t("filesPage.addToWorkspace", "Add to workspace") + : t( + "filesPage.addToWorkspaceCount", + "Add {{count}} to workspace", + { count: selectedFiles.length }, + ); + const moveLabel = t("filesPage.moveTo", "Move to…"); + const removeLabel = t("filesPage.remove", "Remove"); + const quickViewLabel = t("filesPage.quickView", "Quick view"); + return ( + // wrap="nowrap" keeps the row single-line. + + + + + {selectedFiles.length === 1 && ( + + + + )} + {/* Save to server; hidden when no local-only file selected. */} + {localOnlySelectedStubs.length > 0 && ( + + + + )} + {/* Show details button on compact viewports. */} + {selectedFiles.length === 1 && + isCompactDetailsViewport && ( + + + + )} + + + + + + + + clearSelection()} + aria-label={t( + "filesPage.clearSelection", + "Clear selection", + )} + > + + + + + ); + })()} + {selectedFiles.length > 0 && ( +
+
+ +
+ handleQuickView(file.id)} + onMoveFiles={moveFilesTo} + onMoveFolder={moveFolderTo} + onRenameFolder={openRenameFolderDialog} + onDeleteFolder={promptDeleteFolder} + onChangeFolderAppearance={(folderId, appearance) => { + setFolderAppearance(folderId, appearance).catch((err) => + folders.setError( + err instanceof Error + ? t("filesPage.error.folderAppearanceFailedDetail", { + message: err.message, + defaultValue: `Could not update folder appearance: ${err.message}`, + }) + : t( + "filesPage.error.folderAppearanceFailed", + "Could not update folder appearance.", + ), + ), + ); + }} + onRemoveFiles={handleRemoveFiles} + onPromptMoveFiles={promptMoveFiles} + onSaveToServer={(file) => setSaveToServerTarget([file])} + // Center-of-grid CTAs when the empty state shows - same + // handlers the corner header buttons use so behaviour + // (disabled tooltips, native file picker, dialog) is + // identical regardless of where the user clicks from. + onEmptyUpload={() => fileInputRef.current?.click()} + onEmptyCreateFolder={() => openNewFolderDialog()} + newFolderDisabledReason={newFolderDisabledReason} + /> + {isDraggingExternal && ( +
+ + + + + {t("filesPage.dropOverlay", "Drop files to upload")} + + + {/* Behavior contract: per handleNativeUpload above, all + newly-uploaded files start in Local (folderId stays + null) regardless of the current folder view. Saying + "will land in {folder}" was a lie; tell the truth + so the user reaches for Save-to-cloud / Move-to when + they actually want a folder placement. */} + {t( + "filesPage.dropOverlaySub", + "Files start in Local. Use 'Move to' or 'Save to cloud' to organise them into a folder.", + )} + +
+ )} +
+
+ + {/* Inline aside on desktop. */} + {selectedFiles.length > 0 && !isCompactDetailsViewport && ( + clearSelection()} + onAddToWorkspace={handleAddToWorkspace} + onQuickView={handleQuickView} + onMove={promptMoveFiles} + onRemove={handleRemoveFiles} + onSaveToServer={(files) => setSaveToServerTarget(files)} + /> + )} +
+ + {/* Drawer hosts the details panel on ≤800px viewports. */} + {isCompactDetailsViewport && ( + setMobileDetailsOpen(false)} + position="right" + size={useFullScreenDrawer ? "100%" : "sm"} + padding={0} + withCloseButton={false} + overlayProps={{ opacity: 0.45 }} + > + {mobileDetailsOpen && selectedFiles.length === 1 && ( + setMobileDetailsOpen(false)} + onAddToWorkspace={handleAddToWorkspace} + onQuickView={handleQuickView} + onMove={promptMoveFiles} + onRemove={handleRemoveFiles} + onSaveToServer={(files) => setSaveToServerTarget(files)} + /> + )} + + )} + + { + if (moveDialog.fileIds && moveDialog.fileIds.length > 0) { + await moveFilesTo(moveDialog.fileIds, target); + } else if (moveDialog.folderId) { + await moveFolderTo(moveDialog.folderId, target); + } + }} + // Inline-create folder; gated on serverReachable. + onCreateFolder={ + folders.serverReachable + ? (name, parentFolderId) => + folders.createFolder(name, parentFolderId) + : undefined + } + /> + + + + { + const target = deleteFolderDialog.folder; + if (!target) return; + try { + await deleteFolder(target, deleteContents); + } catch (err) { + folders.setError( + err instanceof Error + ? t("filesPage.error.deleteFolderFailedDetail", { + message: err.message, + defaultValue: `Could not delete folder: ${err.message}`, + }) + : t( + "filesPage.error.deleteFolderFailed", + "Could not delete folder.", + ), + ); + throw err; + } + }} + /> + + {/* Save-to-server modal; keyed on target so updates don't retarget. */} + s.id).join(",")}`} + opened={Boolean(saveToServerTarget && saveToServerTarget.length > 0)} + onClose={() => setSaveToServerTarget(null)} + files={saveToServerTarget ?? []} + onUploaded={refresh} + /> +
+ ); +} + +const SearchField = React.forwardRef< + HTMLInputElement, + { value: string; onChange: (v: string) => void } +>(function SearchField({ value, onChange }, ref) { + const { t } = useTranslation(); + return ( +
+ + onChange(e.currentTarget.value)} + placeholder={t( + "filesPage.searchPlaceholder", + "Search this folder & subfolders", + )} + aria-label={t("filesPage.search", "Search")} + /> + {value && ( + onChange("")} + aria-label={t("filesPage.clearSearch", "Clear search")} + > + + + )} +
+ ); +}); + +function Breadcrumbs() { + const { t } = useTranslation(); + const folders = useFolders(); + const filesPage = useFilesPage(); + const trail = folders.breadcrumbs; + return ( + + ); +} diff --git a/frontend/editor/src/core/components/filesPage/FileOriginBadge.tsx b/frontend/editor/src/core/components/filesPage/FileOriginBadge.tsx new file mode 100644 index 000000000..91a2bc0db --- /dev/null +++ b/frontend/editor/src/core/components/filesPage/FileOriginBadge.tsx @@ -0,0 +1,97 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { Tooltip } from "@mantine/core"; +import ComputerIcon from "@mui/icons-material/Computer"; +import CloudDoneIcon from "@mui/icons-material/CloudDone"; +import GroupIcon from "@mui/icons-material/Group"; + +import { FileOrigin } from "@app/components/filesPage/fileOrigin"; + +interface FileOriginBadgeProps { + origin: FileOrigin; + /** Compact (icon-only) vs full (icon + text). */ + compact?: boolean; +} + +const styles = { + base: { + display: "inline-flex", + alignItems: "center", + gap: "0.25rem", + padding: "0.1rem 0.4rem", + borderRadius: "999px", + fontSize: "0.68rem", + fontWeight: 600, + textTransform: "uppercase" as const, + letterSpacing: "0.04em", + lineHeight: 1.2, + }, + local: { + background: + "color-mix(in srgb, var(--text-muted, #6b7280) 16%, transparent)", + color: "var(--text-secondary)", + }, + cloud: { + background: + "color-mix(in srgb, var(--accent-interactive, #6366f1) 16%, transparent)", + color: "var(--accent-interactive, #6366f1)", + }, + shared: { + background: + "color-mix(in srgb, var(--mantine-color-orange-6, #f97316) 16%, transparent)", + color: "var(--mantine-color-orange-6, #f97316)", + }, +}; + +export function FileOriginBadge({ + origin, + compact = false, +}: FileOriginBadgeProps) { + const { t } = useTranslation(); + + const config = (() => { + switch (origin) { + case "cloud": + return { + label: t("filesPage.origin.cloud", "Cloud"), + icon: , + style: styles.cloud, + tooltip: t( + "filesPage.origin.cloudHint", + "Stored on the Stirling server", + ), + }; + case "shared-with-me": + return { + label: t("filesPage.origin.shared", "Shared"), + icon: , + style: styles.shared, + tooltip: t("filesPage.origin.sharedHint", "Shared with you via link"), + }; + case "local": + default: + return { + label: t("filesPage.origin.local", "Local"), + icon: , + style: styles.local, + tooltip: t( + "filesPage.origin.localHint", + "Only stored in this browser", + ), + }; + } + })(); + + const badge = ( + + {config.icon} + {!compact && config.label} + + ); + + return ( + + {badge} + + ); +} diff --git a/frontend/editor/src/core/components/filesPage/FilesPage.css b/frontend/editor/src/core/components/filesPage/FilesPage.css new file mode 100644 index 000000000..9b3b7b206 --- /dev/null +++ b/frontend/editor/src/core/components/filesPage/FilesPage.css @@ -0,0 +1,1406 @@ +.files-page { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + background: var(--bg-background); + color: var(--text-primary); + overflow: hidden; +} + +.files-page-header { + /* 3-column grid keeps the search bar position-stable regardless of + * breadcrumb length. Left column flexes for the breadcrumb (truncating + * via overflow:hidden), middle column holds the search, right column + * holds the action buttons right-aligned. */ + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(min(14rem, 100%), 40rem) minmax( + 0, + 1fr + ); + align-items: center; + gap: 1rem; + min-height: 48px; + padding: 0 0.75rem; + border-bottom: 1px solid var(--border-subtle); + background: var(--bg-toolbar); + flex-shrink: 0; +} + +.files-page-header-actions { + justify-self: end; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.files-page-breadcrumbs { + display: flex; + align-items: center; + gap: 0.25rem; + font-size: 0.95rem; + min-width: 0; + overflow-x: auto; + scrollbar-width: none; +} +.files-page-breadcrumbs::-webkit-scrollbar { + display: none; +} + +.files-page-breadcrumb { + background: none; + border: none; + padding: 0.25rem 0.5rem; + border-radius: 0.375rem; + color: var(--text-secondary); + cursor: pointer; + font-size: inherit; + font-weight: 500; + transition: + background-color 0.12s ease, + color 0.12s ease; + white-space: nowrap; + max-width: 18rem; + overflow: hidden; + text-overflow: ellipsis; +} + +.files-page-breadcrumb:hover { + background: var(--hover-bg); + color: var(--text-primary); +} + +.files-page-breadcrumb.is-current { + color: var(--text-primary); + background: var(--hover-bg); +} + +.files-page-breadcrumb-sep { + color: var(--text-muted); + font-size: 1rem !important; + width: 1rem; + height: 1rem; + opacity: 0.55; + flex-shrink: 0; +} + +.files-page-search { + display: flex; + align-items: center; + gap: 0.35rem; + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: 999px; + padding: 0.2rem 0.75rem; + /* Fills its grid cell; the cell's minmax(...) clamps to a sensible range. */ + width: 100%; + min-width: 0; +} + +.files-page-search input { + background: transparent; + border: none; + outline: none; + flex: 1; + color: var(--text-primary); + font-size: 0.9rem; +} + +.files-page-body { + display: flex; + flex: 1 1 auto; + min-height: 0; + overflow: hidden; +} + +.files-page-tree { + width: 17rem; + flex-shrink: 0; + border-right: 1px solid var(--border-subtle); + background: var(--bg-toolbar); + overflow-y: auto; + display: flex; + flex-direction: column; +} + +.files-page-tree[data-collapsed="true"] { + width: 3.25rem; +} + +.files-page-tree-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 0.75rem 0.5rem; + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-muted); +} + +/* Tree node styles moved to FolderTreePanel.css - that's where the + .files-page-tree-* elements actually render (the slide-out panel from + HomePage), so the styles need to load whether or not the standalone + /files route is mounted. */ + +.files-page-main { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + position: relative; +} + +.files-page-toolbar { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.55rem 1.25rem; + border-bottom: 1px solid var(--border-subtle); + background: var(--bg-background); + flex-shrink: 0; + flex-wrap: wrap; + min-height: 3rem; +} + +.files-page-toolbar-info { + font-size: 0.85rem; + color: var(--text-secondary); + font-weight: 500; +} + +/* Upload + New folder at the start of the toolbar. */ +.files-page-toolbar-create { + display: inline-flex; + align-items: center; + gap: 0.4rem; + flex-shrink: 0; +} + +.files-page-toolbar-info span { + color: var(--text-muted); +} + +.files-page-toolbar-actions { + margin-left: auto; + display: flex; + align-items: center; + gap: 0.5rem; + /* Allow this container to shrink below its intrinsic content width so + the bulk-action group + filter selects + view toggle can compete for + space without bursting out of the toolbar (which previously bled + under the right-hand details panel at wider viewports). */ + min-width: 0; +} + +/* Bulk-action buttons (Add to workspace, Quick view, Move to, Remove) + are ICON-ONLY at every viewport. Even at 1280px when both side panels + are visible, the main area is only ~616px wide - the labelled versions + of these 4 buttons plus the 2 filter selects + view toggle simply + don't fit. Each button has a Tooltip + aria-label, so the icon alone + is accessible. */ +.files-page-toolbar-actions .mantine-Button-root { + padding-left: 0.55rem; + padding-right: 0.55rem; + flex-shrink: 0; +} +.files-page-toolbar-actions .mantine-Button-section[data-position="left"] { + margin-right: 0; +} +.files-page-toolbar-actions .mantine-Button-label { + display: none; +} +/* Pin the view toggle: never let it clip off the right. flex-shrink:0 + keeps its width fixed; the rest of the row shrinks around it. */ +.files-page-toolbar-actions .mantine-SegmentedControl-root { + flex-shrink: 0; +} +/* Clip overflow on the toolbar itself as a safety net - the content + should always fit thanks to the rules above, but if a future addition + ever breaks that, this prevents the action group from bleeding into + sibling areas (notably the details panel on the right). */ +.files-page-toolbar { + overflow-x: hidden; +} + +.files-page-toolbar-divider { + width: 1px; + height: 1.4rem; + background: var(--border-subtle); + margin: 0 0.15rem; +} + +/* SegmentedControl in the toolbar uses icon-only options; this centers the + MUI icon inside Mantine's `.mantine-SegmentedControl-innerLabel` (which + defaults to `display: inline` and baseline-aligns its children, leaving + the icon visually pinned to the top of the label box). */ +.files-page-view-toggle-icon { + display: inline-flex; + align-items: center; + justify-content: center; + line-height: 0; + vertical-align: middle; +} + +/* Visually-hidden text that screen readers still announce. Lets us put + localised labels next to icon-only buttons (SegmentedControl options, + icon-only action rows) without growing the visible UI. Standard + "sr-only" / "visually-hidden" recipe. */ +.files-page-sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.files-page-content { + flex: 1 1 auto; + overflow: auto; + padding: 1.25rem; + position: relative; + scrollbar-width: thin; + scrollbar-color: var(--border-subtle) transparent; +} + +.files-page-content::-webkit-scrollbar { + width: 0.5rem; + height: 0.5rem; +} + +.files-page-content::-webkit-scrollbar-thumb { + background: var(--border-subtle); + border-radius: 999px; + border: 2px solid transparent; + background-clip: content-box; +} + +.files-page-content::-webkit-scrollbar-thumb:hover { + background: var(--text-muted); + background-clip: content-box; +} + +.files-page-tree-list, +.folder-tree-panel-inner, +.files-page-details-body { + scrollbar-width: thin; + scrollbar-color: var(--border-subtle) transparent; +} + +.files-page-tree-list::-webkit-scrollbar, +.folder-tree-panel-inner::-webkit-scrollbar, +.files-page-details-body::-webkit-scrollbar { + width: 0.4rem; +} + +.files-page-tree-list::-webkit-scrollbar-thumb, +.folder-tree-panel-inner::-webkit-scrollbar-thumb, +.files-page-details-body::-webkit-scrollbar-thumb { + background: var(--border-subtle); + border-radius: 999px; +} + +.files-page-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr)); + gap: 1rem; +} + +.files-page-list { + display: flex; + flex-direction: column; + border: 1px solid var(--border-subtle); + border-radius: 0.6rem; + overflow: hidden; + background: var(--bg-surface); +} + +.files-page-list-row { + display: grid; + grid-template-columns: 2.25rem minmax(0, 3fr) 1fr 1fr 1fr 2.5rem; + gap: 0.5rem; + align-items: center; + padding: 0.5rem 0.75rem; + border-bottom: 1px solid var(--border-subtle); + cursor: pointer; + font-size: 0.9rem; + transition: background-color 0.12s ease; +} + +.files-page-list-row:last-child { + border-bottom: none; +} + +.files-page-list-row.is-header { + background: var(--bg-toolbar); + color: var(--text-muted); + text-transform: uppercase; + font-size: 0.72rem; + letter-spacing: 0.05em; + cursor: default; + user-select: none; +} + +.files-page-list-row.is-header [data-sortable="true"] { + cursor: pointer; + padding: 0.2rem 0.4rem; + margin: -0.2rem -0.4rem; + border-radius: 0.3rem; + transition: + background-color 0.12s ease, + color 0.12s ease; +} + +.files-page-list-row.is-header [data-sortable="true"]:hover { + background: var(--hover-bg); + color: var(--text-primary); +} + +.files-page-list-row:not(.is-header):hover { + background: var(--hover-bg); +} + +.files-page-list-row.is-selected { + background: var(--hover-bg); +} + +.files-page-list-row.is-drop-target { + background: color-mix( + in srgb, + var(--accent-interactive, #6366f1) 12%, + transparent + ); + box-shadow: inset 0 0 0 1px var(--accent-interactive, #6366f1); +} + +.files-page-card { + position: relative; + display: flex; + flex-direction: column; + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: 0.85rem; + overflow: hidden; + cursor: pointer; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); + transition: + transform 0.16s cubic-bezier(0.32, 0.72, 0.32, 1), + border-color 0.14s ease, + box-shadow 0.18s ease; +} + +.files-page-card:hover { + transform: translateY(-2px); + border-color: color-mix( + in srgb, + var(--accent-interactive, #6366f1) 35%, + var(--border-subtle) + ); + box-shadow: + 0 4px 6px -1px rgba(0, 0, 0, 0.08), + 0 8px 24px -8px rgba(0, 0, 0, 0.18); +} + +.files-page-card:focus-visible { + outline: 2px solid var(--accent-interactive, #6366f1); + outline-offset: 2px; +} + +.files-page-card.is-selected { + border-color: var(--accent-interactive, #6366f1); + box-shadow: + 0 0 0 2px var(--accent-interactive, #6366f1), + 0 6px 18px -6px + color-mix(in srgb, var(--accent-interactive, #6366f1) 35%, transparent); +} + +.files-page-card.is-folder { + cursor: pointer; +} + +.files-page-card-thumb { + aspect-ratio: 4 / 3; + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--text-muted) 5%, var(--bg-surface)), + var(--bg-surface) + ); + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + position: relative; +} + +.files-page-card-thumb img { + width: 100%; + height: 100%; + object-fit: cover; + transition: transform 0.4s cubic-bezier(0.32, 0.72, 0.32, 1); +} + +.files-page-card:hover .files-page-card-thumb img { + transform: scale(1.03); +} + +.files-page-card-thumb-fallback { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.4rem; + color: var(--text-muted); + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.04em; +} + +.files-page-card-body { + padding: 0.65rem 0.85rem 0.7rem; + display: flex; + flex-direction: column; + gap: 0.2rem; + min-width: 0; +} + +.files-page-card-name { + font-size: 0.9rem; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--text-primary); + letter-spacing: -0.005em; +} + +.files-page-card-meta { + font-size: 0.76rem; + color: var(--text-muted); + display: flex; + align-items: center; + gap: 0.4rem; +} + +/* Parent-folder breadcrumb shown on cards/rows during recursive search so + the user can tell which folder each hit lives in without navigating. */ +.files-page-card-path { + font-size: 0.72rem; + color: var(--text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-top: 0.15rem; + font-style: italic; +} + +.files-page-card .files-page-card-selector { + position: absolute; + top: 0.5rem; + left: 0.5rem; + z-index: 2; + opacity: 0; + pointer-events: none; +} + +/* Reveal on hover, on focus-within (keyboard navigation), and when the card is selected. + :focus-within is what makes the checkbox + kebab discoverable without a pointer device. */ +.files-page-card:hover .files-page-card-selector, +.files-page-card:focus-within .files-page-card-selector, +.files-page-card.is-selected .files-page-card-selector { + opacity: 1; + pointer-events: auto; +} + +/* Style the Mantine checkbox so it reads as a soft pill rather than a + bare form control. */ +.files-page-card-selector :where(.mantine-Checkbox-input) { + background: var(--bg-surface); + border-color: var(--border-subtle); + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.18); +} + +.files-page-card-actions { + position: absolute; + top: 0.4rem; + right: 0.4rem; + opacity: 0; + transition: opacity 0.12s ease; + z-index: 4; +} + +/* Make the kebab a high-contrast pill that works on dark thumbnails, + white PDFs, and any background underneath. + Use stronger specificity + !important to win over Mantine's + variant=filled inline styles. */ +.files-page-card .files-page-card-actions button.mantine-ActionIcon-root { + background-color: var(--bg-raised, var(--bg-surface)) !important; + color: var(--text-primary) !important; + border: 1px solid var(--border-default, var(--border-subtle)) !important; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.25); + backdrop-filter: blur(6px); +} + +.files-page-card .files-page-card-actions button.mantine-ActionIcon-root:hover { + background-color: var(--hover-bg) !important; +} + +/* Row-view kebab: same high-contrast treatment so it's readable + against selected (tinted) and in-workspace rows. */ +.files-page-list-row .mantine-ActionIcon-root[aria-label$="actions"] { + color: var(--text-secondary); +} + +.files-page-list-row .mantine-ActionIcon-root[aria-label$="actions"]:hover { + background-color: var(--hover-bg) !important; + color: var(--text-primary); +} + +.files-page-card-origin { + position: absolute; + bottom: 0.4rem; + left: 0.4rem; + pointer-events: none; +} + +/* "Open" badge - file is currently loaded in the active workspace. + Solid pill with white text so it reads against any thumbnail + (white PDFs, dark covers, photos). */ +.files-page-card-open-badge { + position: absolute; + top: 0.5rem; + right: 0.5rem; + z-index: 3; + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.22rem 0.55rem; + border-radius: 999px; + background: var(--mantine-color-teal-6, #10b981); + color: #ffffff; + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + box-shadow: + 0 0 0 2px rgba(255, 255, 255, 0.85), + 0 2px 6px rgba(0, 0, 0, 0.18); + pointer-events: none; +} + +.files-page-card-open-dot { + width: 0.45rem; + height: 0.45rem; + border-radius: 50%; + background: #ffffff; + box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.35); + animation: files-page-open-pulse 1.8s ease-in-out infinite; +} + +@keyframes files-page-open-pulse { + 0%, + 100% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.6; + transform: scale(0.85); + } +} + +.files-page-card.is-in-workspace { + border-color: color-mix( + in srgb, + var(--mantine-color-teal-6, #10b981) 35%, + var(--border-subtle) + ); +} + +.files-page-card.is-in-workspace::before { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + pointer-events: none; + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--mantine-color-teal-6, #10b981) 6%, transparent) 0%, + transparent 40% + ); + z-index: 0; +} + +.files-page-row-open-pill { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.12rem 0.5rem; + border-radius: 999px; + background: var(--mantine-color-teal-6, #10b981); + color: #ffffff; + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + flex-shrink: 0; + margin-left: 0.25rem; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.12); +} + +.files-page-list-row.is-in-workspace { + background: color-mix( + in srgb, + var(--mantine-color-teal-6, #10b981) 4%, + transparent + ); +} + +.files-page-card:hover .files-page-card-actions, +.files-page-card:focus-within .files-page-card-actions, +.files-page-card.is-selected .files-page-card-actions { + opacity: 1; +} + +.files-page-card.is-folder .files-page-card-thumb { + aspect-ratio: 1.6 / 1; + transition: + filter 0.16s ease, + transform 0.18s cubic-bezier(0.32, 0.72, 0.32, 1); +} + +.files-page-card.is-folder:hover .files-page-card-thumb { + filter: brightness(1.04); +} + +.files-page-card.is-folder.is-drop-target { + transform: translateY(-3px); +} + +.files-page-card.is-folder.is-drop-target .files-page-card-thumb { + filter: brightness(1.08); + transform: scale(1.03); +} + +.files-page-card.is-drop-target { + background: color-mix( + in srgb, + var(--accent-interactive, #6366f1) 10%, + var(--bg-surface) + ); + box-shadow: 0 0 0 2px var(--accent-interactive, #6366f1); + border-color: var(--accent-interactive, #6366f1); +} + +@keyframes files-page-skeleton-pulse { + 0% { + background-position: 200% 0; + } + 100% { + background-position: -200% 0; + } +} + +.files-page-skeleton-bar { + display: block; + background: linear-gradient( + 90deg, + color-mix(in srgb, var(--text-muted) 12%, transparent) 25%, + color-mix(in srgb, var(--text-muted) 22%, transparent) 50%, + color-mix(in srgb, var(--text-muted) 12%, transparent) 75% + ); + background-size: 200% 100%; + animation: files-page-skeleton-pulse 1.4s ease-in-out infinite; + border-radius: 0.4rem; +} + +.files-page-skeleton-card { + pointer-events: none; + border-color: var(--border-subtle); + box-shadow: none; +} + +.files-page-skeleton-card:hover { + transform: none; + box-shadow: none; + border-color: var(--border-subtle); +} + +.files-page-skeleton-row { + pointer-events: none; +} + +.files-page-skeleton-row .files-page-skeleton-bar { + height: 0.7rem; +} + +.files-page-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + gap: 0.75rem; + color: var(--text-muted); + padding: 4rem 1.5rem; + text-align: center; +} + +.files-page-empty-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 5rem; + height: 5rem; + border-radius: 50%; + background: color-mix( + in srgb, + var(--accent-interactive, #6366f1) 12%, + transparent + ); + color: var(--accent-interactive, #6366f1); + margin-bottom: 0.25rem; +} + +.files-page-empty-title { + font-size: 1.15rem; + color: var(--text-primary); + font-weight: 600; + letter-spacing: -0.01em; +} + +.files-page-empty-hint { + font-size: 0.9rem; + color: var(--text-muted); + max-width: 30rem; + line-height: 1.5; +} + +/* Empty-state CTA row; stacks on phones. */ +.files-page-empty-actions { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + justify-content: center; + margin-top: 0.75rem; +} + +@media (max-width: 480px) { + .files-page-empty-actions { + flex-direction: column; + width: min(100%, 16rem); + } +} + +.files-page-details { + width: 22rem; + flex-shrink: 0; + border-left: 1px solid var(--border-subtle); + background: var(--bg-toolbar); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.files-page-details-header { + /* Tight vertical padding so the "DETAILS" label hugs the body content + below it - it's a quiet section header, not a banner, and the old + ~16px top + 18px body padding wasted nearly two character heights + of vertical space before the user saw the file thumbnail. */ + padding: 0.35rem 1.1rem; + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-muted); +} + +.files-page-details-header strong { + font-weight: 600; +} + +.files-page-details-body { + /* Smaller top padding to pair with the tightened header above. Side + and bottom padding stays at 1.1rem so the content still breathes + against the panel edges. */ + padding: 0.3rem 1.1rem 1.1rem; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 0.65rem; +} + +.files-page-details-thumb { + aspect-ratio: 4 / 3; + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: 0.6rem; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); + /* Inside an overflow-y:auto flex column, `aspect-ratio` alone doesn't + guarantee intrinsic height - flex-shrink:1 (default) can squash the + thumb to a 2px sliver when the column gets cramped (notably after + tightening the body padding above). Pin shrink so the thumb keeps + its 4:3 box. */ + flex-shrink: 0; +} + +.files-page-details-thumb img { + width: 100%; + height: 100%; + object-fit: contain; +} + +.files-page-details-fieldlist { + display: flex; + flex-direction: column; + gap: 0.55rem; + font-size: 0.85rem; + padding: 0.65rem 0.75rem; + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: 0.5rem; +} + +.files-page-details-field { + display: flex; + justify-content: space-between; + gap: 0.5rem; +} + +.files-page-details-field-label { + color: var(--text-muted); + font-size: 0.78rem; +} + +.files-page-details-field-value { + color: var(--text-primary); + text-align: right; + word-break: break-word; + font-weight: 500; +} + +.files-page-details-tool-history { + display: flex; + flex-direction: column; + gap: 0.3rem; + padding: 0.5rem 0.6rem; + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: 0.5rem; +} + +.files-page-details-tool-history-label { + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); +} + +/* Filename chip next to the title - "PDF", "DOCX", etc. + Mantine's rendered effectively invisible + in dark mode; project CSS vars track the active color scheme. */ +.files-page-details-ext-tag { + display: inline-flex; + align-items: center; + padding: 0.12rem 0.5rem; + border-radius: 0.4rem; + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + color: var(--text-secondary, var(--text-primary)); + font-size: 0.65rem; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + line-height: 1.2; +} + +/* Vertical timeline of version history. + Each row has a left rail (dot + connector) and a body (summary + meta + + expandable detail). The rail visually threads versions together + like a commit graph; clicking a row's summary toggles its expanded + detail (cumulative tool chain, full meta). Long chains (> 6) collapse + the middle behind a "Show N earlier versions" button. */ +.files-page-details-version-timeline { + display: flex; + flex-direction: column; + gap: 0.4rem; + padding: 0.55rem 0.7rem 0.7rem; + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: 0.5rem; +} +.files-page-details-version-timeline-label { + display: flex; + align-items: center; + gap: 0.35rem; + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); +} +.files-page-details-version-timeline-count { + margin-left: auto; + font-weight: 600; + color: var(--text-secondary, var(--text-muted)); + text-transform: none; + letter-spacing: 0; +} +.files-page-details-version-timeline-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; +} +.files-page-details-version-timeline-row, +.files-page-details-version-timeline-ellipsis { + display: flex; + gap: 0.6rem; + padding: 0.15rem 0; + position: relative; +} +.files-page-details-version-timeline-rail { + display: flex; + flex-direction: column; + align-items: center; + flex-shrink: 0; + width: 0.8rem; + padding-top: 0.45rem; +} +.files-page-details-version-timeline-rail-dot { + width: 0.55rem; + height: 0.55rem; + border-radius: 50%; + background: var(--bg-toolbar); + border: 2px solid var(--border-strong, var(--border-subtle)); + z-index: 1; + flex-shrink: 0; +} +.files-page-details-version-timeline-rail-dot.is-active { + background: var(--accent-interactive, #6366f1); + border-color: var(--accent-interactive, #6366f1); + box-shadow: 0 0 0 3px + color-mix(in srgb, var(--accent-interactive, #6366f1) 25%, transparent); +} +.files-page-details-version-timeline-rail-dot.is-ellipsis { + width: 0.35rem; + height: 0.35rem; + background: var(--text-muted); + border-color: transparent; +} +.files-page-details-version-timeline-rail-line { + width: 2px; + flex: 1; + background: var(--border-subtle); + min-height: 0.6rem; + margin-top: 2px; +} +.files-page-details-version-timeline-body { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.2rem; + padding: 0.25rem 0.3rem 0.4rem; + border-radius: 0.35rem; + transition: background-color 0.12s ease; +} +.files-page-details-version-timeline-row.is-active + .files-page-details-version-timeline-body { + background: color-mix( + in srgb, + var(--accent-interactive, #6366f1) 10%, + transparent + ); +} +.files-page-details-version-timeline-summary { + appearance: none; + background: none; + border: 0; + padding: 0; + margin: 0; + display: flex; + align-items: center; + gap: 0.4rem; + cursor: pointer; + text-align: left; + color: inherit; + font: inherit; +} +.files-page-details-version-timeline-summary:hover + .files-page-details-version-timeline-chevron { + color: var(--text-primary); +} +.files-page-details-version-timeline-delta { + font-size: 0.82rem; + color: var(--text-primary); + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + display: inline-flex; + align-items: baseline; + gap: 0.25rem; +} +.files-page-details-version-timeline-delta.is-origin { + font-weight: 400; + color: var(--text-muted); + font-style: italic; +} +.files-page-details-version-timeline-delta-plus { + color: var(--accent-interactive, #6366f1); + font-weight: 700; +} +.files-page-details-version-timeline-spacer { + flex: 1; +} +.files-page-details-version-timeline-chevron { + color: var(--text-muted); + transition: transform 0.15s ease; +} +.files-page-details-version-timeline-chevron.is-expanded { + transform: rotate(180deg); + color: var(--text-primary); +} +.files-page-details-version-timeline-meta-line { + display: flex; + align-items: center; + gap: 0.35rem; + font-size: 0.7rem; + color: var(--text-muted); +} +.files-page-details-version-timeline-expanded { + display: flex; + flex-direction: column; + gap: 0.4rem; + margin-top: 0.45rem; + padding-top: 0.5rem; + border-top: 1px dashed var(--border-subtle); +} +.files-page-details-version-timeline-toolchain { + display: flex; + flex-direction: column; + gap: 0.2rem; +} +.files-page-details-version-timeline-toolchain-label { + font-size: 0.65rem; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; +} +.files-page-details-version-timeline-ellipsis-btn, +.files-page-details-version-timeline-collapse-btn { + appearance: none; + background: none; + border: 1px dashed var(--border-subtle); + border-radius: 0.3rem; + padding: 0.25rem 0.5rem; + margin: 0.1rem 0; + font-size: 0.72rem; + color: var(--text-muted); + cursor: pointer; + text-align: left; + transition: + border-color 0.12s ease, + color 0.12s ease; +} +.files-page-details-version-timeline-ellipsis-btn:hover, +.files-page-details-version-timeline-collapse-btn:hover { + color: var(--text-primary); + border-color: var(--border-strong, var(--text-muted)); +} +.files-page-details-version-timeline-collapse-btn { + align-self: flex-start; + margin-top: 0.2rem; +} + +.files-page-drop-overlay { + position: absolute; + inset: 1rem; + background: color-mix( + in srgb, + var(--accent-interactive, #6366f1) 14%, + var(--bg-background) + ); + border: 2px dashed var(--accent-interactive, #6366f1); + border-radius: 1rem; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 1rem; + pointer-events: none; + color: var(--accent-interactive, #6366f1); + font-weight: 600; + font-size: 1.1rem; + z-index: 10; + animation: files-page-drop-overlay-pulse 1.6s ease-in-out infinite; + backdrop-filter: blur(2px); +} + +.files-page-drop-overlay-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 4.5rem; + height: 4.5rem; + border-radius: 50%; + background: color-mix( + in srgb, + var(--accent-interactive, #6366f1) 30%, + transparent + ); +} + +.files-page-drop-overlay-icon svg { + font-size: 2.5rem !important; + color: var(--bg-background); +} + +.files-page-drop-overlay-sub { + font-size: 0.85rem; + font-weight: 500; + color: color-mix( + in srgb, + var(--accent-interactive, #6366f1) 80%, + var(--text-secondary) + ); +} + +@keyframes files-page-drop-overlay-pulse { + 0%, + 100% { + border-color: var(--accent-interactive, #6366f1); + transform: scale(1); + } + 50% { + border-color: color-mix( + in srgb, + var(--accent-interactive, #6366f1) 60%, + transparent + ); + transform: scale(1.005); + } +} + +@media (max-width: 1024px) { + .files-page-tree { + width: 14rem; + } + .files-page-details { + width: 18rem; + } +} + +@media (max-width: 800px) { + /* Tree slot inside the inline layout can't fit at this width - the + tree itself is still reachable via the FolderTreePanel slide-out. + Details panel visibility is controlled by JSX (conditional Drawer) + at this width, not CSS, so we don't blanket-hide .files-page-details + here - that would also hide it when it lives inside the Drawer's + portaled overlay tree. */ + .files-page-tree { + display: none; + } + .files-page-content { + padding: 0.75rem; + } +} + +/* ── Tablet / narrow desktop (≤900px) ──────────────────────────────── + As soon as the right-hand details panel disappears (at 800px) and + even slightly before, the toolbar gets crowded - the bulk-action + strip ("Add to workspace", "Quick view", "Move to…", "Remove") + + filter selects + grid/list toggle is too wide to fit. This block + collapses bulk-action button labels to icon-only, shrinks the + filter selects, and pins the view toggle so it never clips off the + right edge. The header stays in desktop layout here (it has fewer + items and the side panels disappearing actually GIVES it room). */ +.files-page-header [data-desktop-hide="true"] { + /* The mobile-only overflow menu trigger. Hidden by default; shown + inside the mobile breakpoint below. Live as inline-flex so it + sits next to the Upload button without breaking the action row. */ + display: none; +} +@media (max-width: 900px) { + .files-page-toolbar { + /* nowrap so "7 items" + "Select all" sit on the same row as the + filter dropdowns and view-toggle instead of stacking on three + separate lines. Per-child min-width:0 lets them shrink as needed. + Used to only kick in at ≤640px which left a broken zone where + both side panels were hidden but the toolbar was still wrapping + to multiple rows. */ + flex-wrap: nowrap; + gap: 0.35rem; + padding: 0.35rem 0.5rem; + min-height: auto; + overflow-x: hidden; + } + .files-page-toolbar-info { + /* Was `flex-basis: 100%` which forced a row break. Let it share + the row, shrink hard if needed, and ellipsize so the count line + collapses gracefully (was overlapping the bulk-action buttons + at ~400px because no truncation rule existed). */ + flex: 0 1 auto; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .files-page-toolbar-actions { + flex-wrap: nowrap; + gap: 0.3rem; + } + .files-page-toolbar-actions .mantine-Select-root, + .files-page-toolbar-actions .mantine-Select-input { + /* Mantine Select defaults to ~12rem which is huge for narrow widths. + The inline `style={{ width: 140|160 }}` on the Selects gets + overridden here so they actually shrink. */ + min-width: 0; + max-width: 7.5rem; + width: auto !important; + } + /* The "Select all" / "Clear selection" toggle is a Mantine Button + directly inside .files-page-toolbar (NOT inside the actions group), + so the icon-only collapse from base styles doesn't apply to it. + Pin it to full text + nowrap so it never truncates to "Select al" + mid-word. */ + .files-page-toolbar > .mantine-Button-root { + flex-shrink: 0; + white-space: nowrap; + } +} + +/* ── Mobile (≤640px) ──────────────────────────────────────────────── + Tighten the chrome so the file manager fits inside the mobile + workspace slide. The slide-rail's bottom tab bar already provides + navigation, so the in-header Home/Apps/Close trio is duplicated + and the first to go. Same for "Upload" - the user can use the + centre drop overlay. */ +@media (max-width: 640px) { + /* Drop the 3-column grid on phones; flex-wrap lets the search slip onto + * its own row when chrome is too cramped to share. */ + .files-page-header { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + padding: 0 0.4rem; + overflow-x: hidden; + } + .files-page-header [data-mobile-hide="true"] { + display: none !important; + } + .files-page-header [data-desktop-hide="true"] { + display: inline-flex !important; + } + /* Mobile-hide for sub-toolbar create buttons. */ + .files-page-toolbar [data-mobile-hide="true"] { + display: none !important; + } + .files-page-header-actions { + margin-left: auto; + gap: 0.3rem; + flex-wrap: nowrap; + } + .files-page-search { + flex: 1 1 100%; + } + .files-page-breadcrumbs { + font-size: 0.85rem; + flex-wrap: nowrap; + overflow-x: auto; + min-width: 0; + } + .files-page-search { + /* Shrink hard so the search bar doesn't eat the whole action row. + Users still see the icon + a few chars of the placeholder. + `overflow: hidden` clips the input's natural intrinsic width so + placeholder text never leaks outside the rounded pill. */ + min-width: 0; + flex: 0 1 5.5rem; + max-width: 6.5rem; + overflow: hidden; + } + .files-page-search input { + /* `min-width: 0` lets flex actually shrink the input below its + default ~20-char intrinsic size - without this, the placeholder + extends beyond the parent's clip box and bleeds onto neighbours. */ + min-width: 0; + text-overflow: ellipsis; + } + /* Upload becomes an icon-only square button on mobile so the action + row stops getting clipped. Scoped to `.files-page-header-actions` + so the Back button at the header level keeps its visible "Back" + label (Back has no other on-screen indicator that it's about leaving). */ + .files-page-header-actions .mantine-Button-root { + padding-left: 0.55rem; + padding-right: 0.55rem; + } + .files-page-header-actions .mantine-Button-section[data-position="left"] { + margin-right: 0; + } + .files-page-header-actions .mantine-Button-label { + display: none; + } + /* Grid: single column on very narrow phones; two columns from ~440px */ + .files-page-grid { + grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr)); + gap: 0.75rem; + } + .files-page-content { + padding: 0.5rem; + } +} + +@media (max-width: 420px) { + /* Don't force 1fr - auto-fill with the 9rem floor already keeps two cards + per row on a 360px phone, which feels right for one-handed taps. The + real fix at this width is the list view, which had 6 columns that wouldn't + fit at all. */ + .files-page-grid { + grid-template-columns: repeat(auto-fill, minmax(8rem, 1fr)); + gap: 0.5rem; + } + /* List view at tiny widths becomes cramped - drop the size + type + modified + columns and let name + actions take the room. */ + .files-page-list-row { + grid-template-columns: 1.75rem minmax(0, 1fr) 2rem; + } + .files-page-list-row > :nth-child(3), + .files-page-list-row > :nth-child(4), + .files-page-list-row > :nth-child(5) { + display: none; + } +} + +/* Honour user preference for reduced motion (WCAG 2.3.3). Disables the + open-badge pulse, drop-overlay pulse + scale, and the card hover lift - + keeps focus rings and selection borders so state is still visible. */ +@media (prefers-reduced-motion: reduce) { + .files-page-card-open-dot, + .files-page-drop-overlay { + animation: none; + } + .files-page-card, + .files-page-card-thumb img { + transition: none; + } + .files-page-card:hover { + transform: none; + } + .files-page-card.is-folder.is-drop-target { + transform: none; + } + .files-page-card.is-folder.is-drop-target .files-page-card-thumb { + transform: none; + } +} diff --git a/frontend/editor/src/core/components/filesPage/FolderAppearancePicker.tsx b/frontend/editor/src/core/components/filesPage/FolderAppearancePicker.tsx new file mode 100644 index 000000000..c5f347903 --- /dev/null +++ b/frontend/editor/src/core/components/filesPage/FolderAppearancePicker.tsx @@ -0,0 +1,178 @@ +/** + * Inline colour + icon picker for a folder. Rendered inside the folder + * kebab menu (Mantine Menu.Item with a custom body) so the menu can + * still own close-on-outside-click behaviour. + */ + +import React from "react"; +import { useTranslation } from "react-i18next"; +import { Tooltip } from "@mantine/core"; + +import { FolderRecord, FOLDER_COLOR_PALETTE } from "@app/types/folder"; +import { + FOLDER_ICONS, + FolderIconOption, +} from "@app/components/filesPage/folderIcons"; + +interface FolderAppearancePickerProps { + folder: FolderRecord; + onChange: (next: { color?: string; icon?: string | null }) => void; + /** When true, all colour + icon buttons are unresponsive (e.g. while offline). */ + disabled?: boolean; +} + +export function FolderAppearancePicker({ + folder, + onChange, + disabled = false, +}: FolderAppearancePickerProps) { + const { t } = useTranslation(); + + return ( +
+
+
+ {FOLDER_COLOR_PALETTE.map((c) => ( +
+
+ +
+
+ {FOLDER_ICONS.map((icon) => ( + + onChange({ icon: icon.id === "none" ? null : icon.id }) + } + /> + ))} +
+
+
+ ); +} + +function Section({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) { + return ( +
+ + {label} + + {children} +
+ ); +} + +function IconButton({ + icon, + selected, + onClick, + disabled = false, +}: { + icon: FolderIconOption; + selected: boolean; + onClick: () => void; + disabled?: boolean; +}) { + return ( + + + + ); +} diff --git a/frontend/editor/src/core/components/filesPage/FolderNameDialog.tsx b/frontend/editor/src/core/components/filesPage/FolderNameDialog.tsx new file mode 100644 index 000000000..4197d652e --- /dev/null +++ b/frontend/editor/src/core/components/filesPage/FolderNameDialog.tsx @@ -0,0 +1,111 @@ +import React, { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Alert, Button, Group, Modal, Stack, TextInput } from "@mantine/core"; +import ErrorOutlineIcon from "@mui/icons-material/ErrorOutlined"; + +interface FolderNameDialogProps { + opened: boolean; + title: string; + initialName?: string; + submitLabel: string; + onClose: () => void; + onSubmit: (name: string) => void | Promise; +} + +export function FolderNameDialog({ + opened, + title, + initialName = "", + submitLabel, + onClose, + onSubmit, +}: FolderNameDialogProps) { + const { t } = useTranslation(); + const [value, setValue] = useState(initialName); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (opened) { + setValue(initialName); + setSubmitting(false); + setError(null); + } + }, [opened, initialName]); + + const submit = async () => { + const name = value.trim(); + if (!name) return; + setSubmitting(true); + setError(null); + try { + await onSubmit(name); + onClose(); + } catch (err) { + // Keep dialog open so the user can retry. Closing on error was a + // silent failure (the dialog vanished, but the folder was never + // created - user thinks success, sees no folder). + setError( + err instanceof Error + ? err.message + : t( + "filesPage.folderName.error", + "Could not save folder. Try again.", + ), + ); + } finally { + setSubmitting(false); + } + }; + + return ( + + + setValue(e.currentTarget.value)} + placeholder={t("filesPage.folderName.placeholder", "Folder name")} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + void submit(); + } + }} + maxLength={120} + aria-label={t("filesPage.folderName.label", "Folder name")} + /> + {error && ( + } + variant="light" + role="alert" + > + {error} + + )} + + + + + + + ); +} diff --git a/frontend/editor/src/core/components/filesPage/FolderThumbnail.tsx b/frontend/editor/src/core/components/filesPage/FolderThumbnail.tsx new file mode 100644 index 000000000..b8e68f9f7 --- /dev/null +++ b/frontend/editor/src/core/components/filesPage/FolderThumbnail.tsx @@ -0,0 +1,170 @@ +/** + * Stylised folder thumbnail. + * + * A custom SVG folder shape that takes its accent colour from + * `FolderRecord.color` and shows the contained-file count as a small badge + * in the corner. Renders proportionally inside whatever container it's + * placed in (file card thumb, list-row icon). + */ + +import React, { useId } from "react"; + +interface FolderThumbnailProps { + color?: string; + fileCount?: number; + /** Visual scale - "thumb" for cards, "row" for list rows, "tree" for nav. */ + size?: "thumb" | "row" | "tree"; + /** Optional glyph (emoji) overlaid in the centre of the front pocket. */ + iconGlyph?: string; +} + +const SIZE_PX: Record, number> = { + thumb: 96, + row: 22, + tree: 18, +}; + +export function FolderThumbnail({ + color, + fileCount, + size = "thumb", + iconGlyph, +}: FolderThumbnailProps) { + const accent = color ?? "var(--accent-interactive, #6366f1)"; + const px = SIZE_PX[size]; + const showBadge = size === "thumb" && (fileCount ?? 0) > 0; + // Per-instance unique ids - `${color}` previously embedded `#` and CSS + // function syntax in the id, which broke `url(#...)` references (Safari + // would parse the inner `#` as a new fragment start and the lookup + // would miss entirely, leaving the folder shape unfilled). + const reactId = useId(); + const backId = `${reactId}-back`; + const frontId = `${reactId}-front`; + + return ( + + ); +} diff --git a/frontend/editor/src/core/components/filesPage/FolderTreePanel.css b/frontend/editor/src/core/components/filesPage/FolderTreePanel.css new file mode 100644 index 000000000..2b75efe68 --- /dev/null +++ b/frontend/editor/src/core/components/filesPage/FolderTreePanel.css @@ -0,0 +1,198 @@ +/* Secondary navigator panel that slides out from the main FileSidebar + when the user enters the My Files workbench. + + Pattern: + - Outer panel is a flex item whose `width` transitions. The inner + content keeps a fixed natural width and the outer `overflow: hidden` + clips it during the slide so the user sees the panel grow from the + edge rather than the content shrinking. + - Inner content separately fades + nudges in for an attentive feel. + + Styled to mirror the main FileSidebar - same toolbar background, same + section-header treatment, same icon weight - so the two read as one + unified surface. */ + +.folder-tree-panel { + width: 0; + flex-shrink: 0; + background: var(--bg-toolbar); + border-right: 0 solid var(--border-subtle); + overflow: hidden; + height: 100%; + pointer-events: none; + position: relative; +} + +.folder-tree-panel[data-active="true"] { + width: var(--folder-tree-panel-width, 16rem); + border-right-width: 1px; + pointer-events: auto; +} + +.folder-tree-panel-inner { + display: flex; + flex-direction: column; + height: 100%; + width: 100%; + overflow-y: auto; + opacity: 0; + transform: translateX(-1rem); + /* Only fade/slide; width is driven by the inline custom property so + * dragging the resizer doesn't animate. */ + transition: + opacity 0.18s ease, + transform 0.26s cubic-bezier(0.22, 0.61, 0.36, 1); +} + +.folder-tree-panel[data-active="true"] .folder-tree-panel-inner { + opacity: 1; + transform: translateX(0); + transition-delay: 0.04s; +} + +/* Drag handle on the right edge. */ +.folder-tree-panel-resizer { + position: absolute; + top: 0; + right: -3px; + width: 6px; + height: 100%; + cursor: col-resize; + z-index: 2; + background: transparent; + transition: background-color 0.15s ease; +} + +.folder-tree-panel-resizer:hover, +.folder-tree-panel-resizer:focus-visible { + background: color-mix( + in srgb, + var(--accent-interactive, #6366f1) 35%, + transparent + ); + outline: none; +} + +/* Section header - matches .file-sidebar-section-header in FileSidebar.css */ +.folder-tree-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 18px 6px 18px; + margin-top: 4px; + flex-shrink: 0; +} + +.folder-tree-panel-title { + font-size: 13px; + font-weight: 600; + letter-spacing: 0.02em; + color: var(--text-muted); + text-transform: uppercase; +} + +/* Tree rows - mirror .file-sidebar-action-row from FileSidebar.css so the + slide-out folder navigator reads as a continuation of the main sidebar's + design language. Same row height, padding, font weight, muted icon + treatment. The hover/active state uses the same --hover-bg pill that + the sidebar's other rows do, with no heavy accent bar. */ + +.files-page-tree-list { + display: flex; + flex-direction: column; + padding: 4px 0 12px; +} + +.files-page-tree-node { + display: flex; + align-items: center; + height: 32px; + padding: 0 14px; + border-radius: 4px; + margin: 0 8px; + cursor: pointer; + user-select: none; + color: var(--text-secondary); + font-size: 14px; + position: relative; + transition: background-color 0.15s ease; + flex-shrink: 0; +} + +.files-page-tree-node:hover { + background: var(--hover-bg); +} + +.files-page-tree-node.is-active { + background: var(--hover-bg); + color: var(--text-primary); + font-weight: 500; +} + +.files-page-tree-node.is-drop-target { + background: color-mix( + in srgb, + var(--accent-interactive, #6366f1) 12%, + transparent + ); + box-shadow: inset 0 0 0 1px var(--accent-interactive, #6366f1); + color: var(--text-primary); +} + +.files-page-tree-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + color: var(--text-muted); + flex-shrink: 0; +} + +.files-page-tree-toggle svg { + font-size: 16px !important; +} + +.files-page-tree-spacer { + display: inline-block; + width: 16px; + height: 16px; + flex-shrink: 0; +} + +.files-page-tree-icon { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: var(--text-muted); + margin-left: 8px; + font-size: 18px; +} + +.files-page-tree-icon svg { + font-size: 18px !important; +} + +.files-page-tree-name { + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-left: 12px; +} + +.files-page-tree-count { + color: var(--text-muted); + font-size: 12px; + flex-shrink: 0; + margin-left: 8px; +} + +@media (max-width: 900px) { + /* Cap the user width on narrow viewports so the tree can't squeeze + * out the file grid. The custom property is still honoured but capped. */ + .folder-tree-panel[data-active="true"] { + width: min(var(--folder-tree-panel-width, 14rem), 14rem); + } +} diff --git a/frontend/editor/src/core/components/filesPage/FolderTreePanel.tsx b/frontend/editor/src/core/components/filesPage/FolderTreePanel.tsx new file mode 100644 index 000000000..eb7f19170 --- /dev/null +++ b/frontend/editor/src/core/components/filesPage/FolderTreePanel.tsx @@ -0,0 +1,169 @@ +/** Folder tree navigator panel rendered next to FileSidebar on /files. */ + +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { FolderTreeSidebar } from "@app/components/filesPage/FolderTreeSidebar"; +import { useFilesPage } from "@app/contexts/FilesPageContext"; +import { useFolders } from "@app/contexts/FolderContext"; +import { FileId } from "@app/types/file"; +import { FolderId, FolderRecord } from "@app/types/folder"; +import { + MIN_WIDTH, + MAX_WIDTH, + clamp, + computeAutoFitWidth, + loadPersistedWidth, + savePersistedWidth, +} from "@app/components/filesPage/folderTreeWidth"; + +import "@app/components/filesPage/FolderTreePanel.css"; + +interface FolderTreePanelProps { + active: boolean; +} + +export function FolderTreePanel({ active }: FolderTreePanelProps) { + const { t } = useTranslation(); + const { + fileCountsByFolder, + openNewFolderDialog, + openRenameFolderDialog, + promptDeleteFolder, + moveFilesTo, + } = useFilesPage(); + const folders = useFolders(); + const rootLabel = t("filesPage.allFiles", "All files"); + + const [width, setWidth] = useState(() => { + const persisted = loadPersistedWidth(); + return persisted ?? 256; + }); + const userSetRef = useRef(loadPersistedWidth() !== null); + + // Auto-fit to the longest folder name on first render and whenever the + // folder list grows; skipped once the user manually resizes. + useEffect(() => { + if (userSetRef.current) return; + const auto = computeAutoFitWidth(folders.folders, rootLabel); + setWidth(auto); + }, [folders.folders, rootLabel]); + + const dragStateRef = useRef<{ + startX: number; + startWidth: number; + } | null>(null); + + const onMouseMove = useCallback((e: MouseEvent) => { + const state = dragStateRef.current; + if (!state) return; + const next = clamp(state.startWidth + (e.clientX - state.startX)); + setWidth(next); + }, []); + + const onMouseUp = useCallback(() => { + const state = dragStateRef.current; + if (!state) return; + dragStateRef.current = null; + document.removeEventListener("mousemove", onMouseMove); + document.removeEventListener("mouseup", onMouseUp); + document.body.style.removeProperty("cursor"); + document.body.style.removeProperty("user-select"); + userSetRef.current = true; + setWidth((current) => { + savePersistedWidth(current); + return current; + }); + }, [onMouseMove]); + + const onMouseDown = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + dragStateRef.current = { startX: e.clientX, startWidth: width }; + document.addEventListener("mousemove", onMouseMove); + document.addEventListener("mouseup", onMouseUp); + document.body.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; + }, + [onMouseMove, onMouseUp, width], + ); + + const onKeyDown = useCallback( + (e: React.KeyboardEvent) => { + const step = e.shiftKey ? 32 : 8; + let next: number | null = null; + if (e.key === "ArrowLeft") next = clamp(width - step); + else if (e.key === "ArrowRight") next = clamp(width + step); + else if (e.key === "Home") next = MIN_WIDTH; + else if (e.key === "End") next = MAX_WIDTH; + if (next === null) return; + e.preventDefault(); + userSetRef.current = true; + setWidth(next); + savePersistedWidth(next); + }, + [width], + ); + + return ( +
+
+
+ + {t("filesPage.myFiles", "My Files")} + +
+ + + openRenameFolderDialog(folder) + } + onDeleteFolder={promptDeleteFolder} + onMoveFilesIntoFolder={async ( + targetId: FolderId | null, + fileIds: FileId[], + ) => { + if (fileIds.length === 0) return; + await moveFilesTo(fileIds, targetId); + }} + /> +
+ {active && ( +
{ + const auto = computeAutoFitWidth(folders.folders, rootLabel); + userSetRef.current = false; + setWidth(auto); + savePersistedWidth(auto); + }} + /> + )} +
+ ); +} diff --git a/frontend/editor/src/core/components/filesPage/FolderTreeSidebar.tsx b/frontend/editor/src/core/components/filesPage/FolderTreeSidebar.tsx new file mode 100644 index 000000000..72bd4186e --- /dev/null +++ b/frontend/editor/src/core/components/filesPage/FolderTreeSidebar.tsx @@ -0,0 +1,490 @@ +import React, { useCallback, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { ActionIcon, Menu } from "@mantine/core"; +import KeyboardArrowRightIcon from "@mui/icons-material/KeyboardArrowRight"; +import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; +import HomeIcon from "@mui/icons-material/Home"; +import DevicesOtherIcon from "@mui/icons-material/DevicesOther"; +import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder"; +import EditIcon from "@mui/icons-material/Edit"; +import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined"; +import MoreVertIcon from "@mui/icons-material/MoreVert"; +import { FolderThumbnail } from "@app/components/filesPage/FolderThumbnail"; + +import { useFolders } from "@app/contexts/FolderContext"; +import { FileId } from "@app/types/file"; +import { + FolderId, + FolderRecord, + FolderTreeNode, + ROOT_FOLDER_ID, +} from "@app/types/folder"; +import { useFilesPage } from "@app/contexts/FilesPageContext"; +import { + FILES_PAGE_DRAG_TYPE, + parseFilesPageDragPayload, + serialiseFilesPageDragPayload, +} from "@app/components/filesPage/dragDrop"; +import { useDropTarget } from "@app/components/filesPage/useDropTarget"; + +/** + * Hard cap on folder-tree render depth. The backend already enforces an + * application-level depth limit via cycle detection + folder-count cap, + * and React's render stack handles ~50 nested components comfortably, + * so this is purely defensive against a corrupted IDB cache producing + * a chain deeper than the server would allow. + */ +const MAX_TREE_DEPTH = 50; + +interface FolderTreeSidebarProps { + fileCounts: Map; + onRequestNewFolder: (parentId: FolderId | null) => void; + onRenameFolder: (folder: FolderRecord) => void; + onDeleteFolder: (folder: FolderRecord) => void; + /** + * Move the *dragged* files (from the drop payload) into the target folder. + * Earlier signature took only the folder id and the parent then used the + * current selection - which silently moved the wrong files whenever the + * user dragged something that wasn't in the selection. + */ + onMoveFilesIntoFolder: ( + folderId: FolderId | null, + fileIds: FileId[], + ) => Promise | void; +} + +// This component is always rendered inside FolderTreePanel, which supplies +// its own