mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
folder and file fixes (#6461)
This commit is contained in:
+42
-12
@@ -12,6 +12,8 @@ import org.springframework.web.bind.annotation.RequestParam;
|
|||||||
|
|
||||||
import io.swagger.v3.oas.annotations.Hidden;
|
import io.swagger.v3.oas.annotations.Hidden;
|
||||||
|
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
import stirling.software.SPDF.config.EndpointConfiguration;
|
import stirling.software.SPDF.config.EndpointConfiguration;
|
||||||
@@ -91,6 +93,44 @@ public class ConfigController {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the frontend URL the client should advertise to phones / share-link recipients.
|
||||||
|
* Priority: explicit system.frontendUrl, then the Host the user is already using to reach this
|
||||||
|
* server (works for Docker, reverse proxies, and bare-metal LANs), then a detected site-local
|
||||||
|
* IPv4, then empty.
|
||||||
|
*/
|
||||||
|
// visible for testing
|
||||||
|
String resolveFrontendUrl(HttpServletRequest request, AppConfig appConfig) {
|
||||||
|
String configured = applicationProperties.getSystem().getFrontendUrl();
|
||||||
|
if (configured != null && !configured.isBlank()) {
|
||||||
|
return configured;
|
||||||
|
}
|
||||||
|
if (request != null) {
|
||||||
|
String host = request.getServerName();
|
||||||
|
if (host != null && !host.isBlank() && !isLoopbackHost(host)) {
|
||||||
|
String scheme = request.getScheme();
|
||||||
|
int port = request.getServerPort();
|
||||||
|
boolean defaultPort =
|
||||||
|
("http".equals(scheme) && port == 80)
|
||||||
|
|| ("https".equals(scheme) && port == 443);
|
||||||
|
return defaultPort ? scheme + "://" + host : scheme + "://" + host + ":" + port;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String localIp = GeneralUtils.getLocalNetworkIp();
|
||||||
|
if (localIp != null) {
|
||||||
|
String scheme = appConfig.getBackendUrl().startsWith("https") ? "https" : "http";
|
||||||
|
return scheme + "://" + localIp + ":" + appConfig.getServerPort();
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isLoopbackHost(String host) {
|
||||||
|
return "localhost".equalsIgnoreCase(host)
|
||||||
|
|| "127.0.0.1".equals(host)
|
||||||
|
|| "::1".equals(host)
|
||||||
|
|| "0:0:0:0:0:0:0:1".equals(host);
|
||||||
|
}
|
||||||
|
|
||||||
/** Check if running Enterprise edition dynamically. */
|
/** Check if running Enterprise edition dynamically. */
|
||||||
private Boolean isRunningEE() {
|
private Boolean isRunningEE() {
|
||||||
// Use LicenseService for fresh license status if available
|
// Use LicenseService for fresh license status if available
|
||||||
@@ -107,7 +147,7 @@ public class ConfigController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/app-config")
|
@GetMapping("/app-config")
|
||||||
public ResponseEntity<Map<String, Object>> getAppConfig() {
|
public ResponseEntity<Map<String, Object>> getAppConfig(HttpServletRequest request) {
|
||||||
Map<String, Object> configData = new HashMap<>();
|
Map<String, Object> configData = new HashMap<>();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -124,17 +164,7 @@ public class ConfigController {
|
|||||||
configData.put("serverPort", appConfig.getServerPort());
|
configData.put("serverPort", appConfig.getServerPort());
|
||||||
|
|
||||||
String frontendUrl = applicationProperties.getSystem().getFrontendUrl();
|
String frontendUrl = applicationProperties.getSystem().getFrontendUrl();
|
||||||
if ((frontendUrl == null || frontendUrl.isBlank())
|
configData.put("frontendUrl", resolveFrontendUrl(request, appConfig));
|
||||||
&& Boolean.parseBoolean(
|
|
||||||
System.getProperty("STIRLING_PDF_TAURI_MODE", "false"))) {
|
|
||||||
String localIp = GeneralUtils.getLocalNetworkIp();
|
|
||||||
if (localIp != null) {
|
|
||||||
String scheme =
|
|
||||||
appConfig.getBackendUrl().startsWith("https") ? "https" : "http";
|
|
||||||
frontendUrl = scheme + "://" + localIp + ":" + appConfig.getServerPort();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
configData.put("frontendUrl", frontendUrl != null ? frontendUrl : "");
|
|
||||||
|
|
||||||
// Add mobile scanner settings
|
// Add mobile scanner settings
|
||||||
configData.put(
|
configData.put(
|
||||||
|
|||||||
+71
@@ -15,10 +15,14 @@ import org.springframework.context.ApplicationContext;
|
|||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
|
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
|
||||||
import stirling.software.SPDF.config.EndpointConfiguration;
|
import stirling.software.SPDF.config.EndpointConfiguration;
|
||||||
import stirling.software.SPDF.config.EndpointConfiguration.DisableReason;
|
import stirling.software.SPDF.config.EndpointConfiguration.DisableReason;
|
||||||
import stirling.software.SPDF.config.EndpointConfiguration.EndpointAvailability;
|
import stirling.software.SPDF.config.EndpointConfiguration.EndpointAvailability;
|
||||||
|
import stirling.software.common.configuration.AppConfig;
|
||||||
import stirling.software.common.model.ApplicationProperties;
|
import stirling.software.common.model.ApplicationProperties;
|
||||||
|
import stirling.software.common.model.ApplicationProperties.System;
|
||||||
import stirling.software.common.service.LicenseServiceInterface;
|
import stirling.software.common.service.LicenseServiceInterface;
|
||||||
import stirling.software.common.service.ServerCertificateServiceInterface;
|
import stirling.software.common.service.ServerCertificateServiceInterface;
|
||||||
import stirling.software.common.service.UserServiceInterface;
|
import stirling.software.common.service.UserServiceInterface;
|
||||||
@@ -173,4 +177,71 @@ class ConfigControllerTest {
|
|||||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||||
verify(endpointConfiguration).getAllEndpoints();
|
verify(endpointConfiguration).getAllEndpoints();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resolveFrontendUrl_prefersExplicitConfiguredValue() {
|
||||||
|
System sys = mock(System.class);
|
||||||
|
when(applicationProperties.getSystem()).thenReturn(sys);
|
||||||
|
when(sys.getFrontendUrl()).thenReturn("https://pdf.example.com");
|
||||||
|
|
||||||
|
// Request would say something else, but configured wins.
|
||||||
|
HttpServletRequest req = mock(HttpServletRequest.class);
|
||||||
|
AppConfig appConfig = mock(AppConfig.class);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
"https://pdf.example.com", configController.resolveFrontendUrl(req, appConfig));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resolveFrontendUrl_usesRequestHostWhenNotConfigured() {
|
||||||
|
System sys = mock(System.class);
|
||||||
|
when(applicationProperties.getSystem()).thenReturn(sys);
|
||||||
|
when(sys.getFrontendUrl()).thenReturn(null);
|
||||||
|
|
||||||
|
HttpServletRequest req = mock(HttpServletRequest.class);
|
||||||
|
when(req.getServerName()).thenReturn("192.168.1.100");
|
||||||
|
when(req.getScheme()).thenReturn("http");
|
||||||
|
when(req.getServerPort()).thenReturn(8080);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
"http://192.168.1.100:8080",
|
||||||
|
configController.resolveFrontendUrl(req, mock(AppConfig.class)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resolveFrontendUrl_elidesDefaultHttpsPort() {
|
||||||
|
System sys = mock(System.class);
|
||||||
|
when(applicationProperties.getSystem()).thenReturn(sys);
|
||||||
|
when(sys.getFrontendUrl()).thenReturn("");
|
||||||
|
|
||||||
|
HttpServletRequest req = mock(HttpServletRequest.class);
|
||||||
|
when(req.getServerName()).thenReturn("pdf.example.com");
|
||||||
|
when(req.getScheme()).thenReturn("https");
|
||||||
|
when(req.getServerPort()).thenReturn(443);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
"https://pdf.example.com",
|
||||||
|
configController.resolveFrontendUrl(req, mock(AppConfig.class)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resolveFrontendUrl_fallsThroughOnLoopbackHost() {
|
||||||
|
System sys = mock(System.class);
|
||||||
|
when(applicationProperties.getSystem()).thenReturn(sys);
|
||||||
|
when(sys.getFrontendUrl()).thenReturn(null);
|
||||||
|
|
||||||
|
HttpServletRequest req = mock(HttpServletRequest.class);
|
||||||
|
when(req.getServerName()).thenReturn("localhost");
|
||||||
|
|
||||||
|
AppConfig appConfig = mock(AppConfig.class);
|
||||||
|
when(appConfig.getBackendUrl()).thenReturn("http://localhost:8080");
|
||||||
|
when(appConfig.getServerPort()).thenReturn("8080");
|
||||||
|
|
||||||
|
// Detected IP (if any) wins over loopback request host. We can't assert the
|
||||||
|
// exact value (depends on the host running the test) but we can assert it
|
||||||
|
// never returns "localhost".
|
||||||
|
String result = configController.resolveFrontendUrl(req, appConfig);
|
||||||
|
assertNotNull(result);
|
||||||
|
assertFalse(result.contains("localhost"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3854,7 +3854,7 @@ 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."
|
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"
|
selectedCount = "{{count}} selected"
|
||||||
selectFile = "Select file {{name}}"
|
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."
|
shareDisabledHint = "File sharing isn't enabled on this server. Ask your admin to enable it."
|
||||||
shareManage = "Manage sharing"
|
shareManage = "Manage sharing"
|
||||||
showDetails = "Show details"
|
showDetails = "Show details"
|
||||||
summary = "{{count}} items"
|
summary = "{{count}} items"
|
||||||
@@ -3863,6 +3863,7 @@ syncPartial = "Folder sync partial: {{failed}} of {{total}} folders could not be
|
|||||||
tree = "Folders"
|
tree = "Folders"
|
||||||
upload = "Upload"
|
upload = "Upload"
|
||||||
uploadedToLocal = "Uploaded files start in Local. Use 'Save to cloud' to put them in a folder."
|
uploadedToLocal = "Uploaded files start in Local. Use 'Save to cloud' to put them in a folder."
|
||||||
|
uploadFromMobile = "Upload from Mobile"
|
||||||
versionActions = "Version actions"
|
versionActions = "Version actions"
|
||||||
versionCollapse = "Collapse middle versions"
|
versionCollapse = "Collapse middle versions"
|
||||||
versionOrigin = "Original upload"
|
versionOrigin = "Original upload"
|
||||||
@@ -3885,16 +3886,14 @@ 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.offlineHint = "Reconnect to load your cloud library."
|
||||||
empty.cloud.offlineTitle = "No cached cloud files"
|
empty.cloud.offlineTitle = "No cached cloud files"
|
||||||
empty.cloud.title = "No cloud files yet"
|
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.hint = "Files saved without uploading stay here. Drop a file to add one."
|
||||||
empty.local.title = "No local-only files"
|
empty.local.title = "No local-only files"
|
||||||
empty.recent.hint = "Files you open or edit will appear here."
|
empty.recent.hint = "Files you open or edit will appear here."
|
||||||
empty.recent.title = "Nothing modified yet"
|
empty.recent.title = "Nothing modified yet"
|
||||||
empty.shared.hint = "When someone shares a file via link, it appears here."
|
empty.shared.hint = "When someone shares a file via link, it appears here."
|
||||||
empty.shared.title = "Nothing shared with you"
|
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.hint = "Create a share link or invite a teammate from any of your files to see it here."
|
||||||
empty.sharedByMe.title = "No share links yet"
|
empty.sharedByMe.title = "You haven't shared any files yet"
|
||||||
error.actionFailed = "Could not {{action}}."
|
error.actionFailed = "Could not {{action}}."
|
||||||
error.actionFailedDetail = "Could not {{action}}: {{message}}"
|
error.actionFailedDetail = "Could not {{action}}: {{message}}"
|
||||||
error.deleteFolderFailed = "Could not delete folder."
|
error.deleteFolderFailed = "Could not delete folder."
|
||||||
@@ -3951,7 +3950,6 @@ sort.sizeDesc = "Largest first"
|
|||||||
syncError.client = "Folder sync failed."
|
syncError.client = "Folder sync failed."
|
||||||
syncError.network = "Could not reach the server."
|
syncError.network = "Could not reach the server."
|
||||||
syncError.server = "Server error during folder sync."
|
syncError.server = "Server error during folder sync."
|
||||||
tabName.imSharing = "I'm sharing"
|
|
||||||
tabName.local = "Local"
|
tabName.local = "Local"
|
||||||
tabName.recent = "Recent"
|
tabName.recent = "Recent"
|
||||||
tabName.shared = "Shared with me"
|
tabName.shared = "Shared with me"
|
||||||
@@ -3959,7 +3957,6 @@ tabName.sharedByMe = "Shared by me"
|
|||||||
tabs.all = "All"
|
tabs.all = "All"
|
||||||
tabs.ariaLabel = "File views"
|
tabs.ariaLabel = "File views"
|
||||||
tabs.cloud = "Cloud"
|
tabs.cloud = "Cloud"
|
||||||
tabs.imSharing = "I'm sharing"
|
|
||||||
tabs.local = "Local"
|
tabs.local = "Local"
|
||||||
tabs.recent = "Recent"
|
tabs.recent = "Recent"
|
||||||
tabs.shared = "Shared with me"
|
tabs.shared = "Shared with me"
|
||||||
|
|||||||
@@ -289,7 +289,7 @@ export function FileDetailsPanel({
|
|||||||
<Tooltip
|
<Tooltip
|
||||||
label={t(
|
label={t(
|
||||||
"filesPage.shareDisabledHint",
|
"filesPage.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.",
|
"File sharing isn't enabled on this server. Ask your admin to enable it.",
|
||||||
)}
|
)}
|
||||||
disabled={sharingEnabled}
|
disabled={sharingEnabled}
|
||||||
withinPortal
|
withinPortal
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import { FileOriginBadge } from "@app/components/filesPage/FileOriginBadge";
|
|||||||
import { FolderThumbnail } from "@app/components/filesPage/FolderThumbnail";
|
import { FolderThumbnail } from "@app/components/filesPage/FolderThumbnail";
|
||||||
import { findFolderIcon } from "@app/components/filesPage/folderIcons";
|
import { findFolderIcon } from "@app/components/filesPage/folderIcons";
|
||||||
import { FolderAppearancePicker } from "@app/components/filesPage/FolderAppearancePicker";
|
import { FolderAppearancePicker } from "@app/components/filesPage/FolderAppearancePicker";
|
||||||
|
import { useLazyThumbnail } from "@app/hooks/useLazyThumbnail";
|
||||||
import type { FilesPageSortMode } from "@app/contexts/FilesPageContext";
|
import type { FilesPageSortMode } from "@app/contexts/FilesPageContext";
|
||||||
|
|
||||||
export type FilesPageViewMode = "grid" | "list";
|
export type FilesPageViewMode = "grid" | "list";
|
||||||
@@ -80,14 +81,7 @@ interface FileGridProps {
|
|||||||
sortMode?: FilesPageSortMode;
|
sortMode?: FilesPageSortMode;
|
||||||
onChangeSortMode?: (mode: FilesPageSortMode) => void;
|
onChangeSortMode?: (mode: FilesPageSortMode) => void;
|
||||||
/** Drives the empty-state copy. */
|
/** Drives the empty-state copy. */
|
||||||
currentTab?:
|
currentTab?: "all" | "local" | "cloud" | "recent" | "shared" | "sharedByMe";
|
||||||
| "all"
|
|
||||||
| "local"
|
|
||||||
| "cloud"
|
|
||||||
| "recent"
|
|
||||||
| "shared"
|
|
||||||
| "sharedByMe"
|
|
||||||
| "imSharing";
|
|
||||||
/** Cloud reachability; switches the cloud empty-state copy. */
|
/** Cloud reachability; switches the cloud empty-state copy. */
|
||||||
serverReachable?: boolean;
|
serverReachable?: boolean;
|
||||||
/** Empty-state CTA handlers; if absent the matching button hides. */
|
/** Empty-state CTA handlers; if absent the matching button hides. */
|
||||||
@@ -185,14 +179,7 @@ function SkeletonGrid({ viewMode }: { viewMode: FilesPageViewMode }) {
|
|||||||
|
|
||||||
interface EmptyStateProps {
|
interface EmptyStateProps {
|
||||||
/** Drives copy + iconography. */
|
/** Drives copy + iconography. */
|
||||||
tab?:
|
tab?: "all" | "local" | "cloud" | "recent" | "shared" | "sharedByMe";
|
||||||
| "all"
|
|
||||||
| "local"
|
|
||||||
| "cloud"
|
|
||||||
| "recent"
|
|
||||||
| "shared"
|
|
||||||
| "sharedByMe"
|
|
||||||
| "imSharing";
|
|
||||||
/** Switches the cloud empty-state copy. */
|
/** Switches the cloud empty-state copy. */
|
||||||
serverReachable?: boolean;
|
serverReachable?: boolean;
|
||||||
/** CTA handlers; absent => button hidden. */
|
/** CTA handlers; absent => button hidden. */
|
||||||
@@ -252,18 +239,10 @@ function EmptyState({
|
|||||||
case "sharedByMe":
|
case "sharedByMe":
|
||||||
return {
|
return {
|
||||||
titleKey: "filesPage.empty.sharedByMe.title",
|
titleKey: "filesPage.empty.sharedByMe.title",
|
||||||
titleFallback: "No share links yet",
|
titleFallback: "You haven't shared any files yet",
|
||||||
hintKey: "filesPage.empty.sharedByMe.hint",
|
hintKey: "filesPage.empty.sharedByMe.hint",
|
||||||
hintFallback:
|
hintFallback:
|
||||||
"Create a share link on any of your files to surface it here.",
|
"Create a share link or invite a teammate from any of your files to see 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":
|
case "all":
|
||||||
default:
|
default:
|
||||||
@@ -278,10 +257,7 @@ function EmptyState({
|
|||||||
})();
|
})();
|
||||||
// Recent/Shared tabs are read-only filters; Local is cloud-only for folders.
|
// Recent/Shared tabs are read-only filters; Local is cloud-only for folders.
|
||||||
const readOnlyTab =
|
const readOnlyTab =
|
||||||
tab === "recent" ||
|
tab === "recent" || tab === "shared" || tab === "sharedByMe";
|
||||||
tab === "shared" ||
|
|
||||||
tab === "sharedByMe" ||
|
|
||||||
tab === "imSharing";
|
|
||||||
const showUpload = Boolean(onUpload) && !readOnlyTab;
|
const showUpload = Boolean(onUpload) && !readOnlyTab;
|
||||||
const showCreateFolder =
|
const showCreateFolder =
|
||||||
Boolean(onCreateFolder) && !readOnlyTab && tab !== "local";
|
Boolean(onCreateFolder) && !readOnlyTab && tab !== "local";
|
||||||
@@ -645,6 +621,11 @@ function FileCard({
|
|||||||
|
|
||||||
const extension = file.name.split(".").pop()?.toUpperCase() ?? "";
|
const extension = file.name.split(".").pop()?.toUpperCase() ?? "";
|
||||||
const isPdf = extension === "PDF";
|
const isPdf = extension === "PDF";
|
||||||
|
const resolvedThumbnail = useLazyThumbnail(
|
||||||
|
file.id,
|
||||||
|
file.size,
|
||||||
|
file.thumbnailUrl,
|
||||||
|
);
|
||||||
|
|
||||||
const kebabRef = useRef<HTMLButtonElement>(null);
|
const kebabRef = useRef<HTMLButtonElement>(null);
|
||||||
const handleContextMenu = useCallback(
|
const handleContextMenu = useCallback(
|
||||||
@@ -712,9 +693,9 @@ function FileCard({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="files-page-card-thumb">
|
<div className="files-page-card-thumb">
|
||||||
{file.thumbnailUrl ? (
|
{resolvedThumbnail ? (
|
||||||
// draggable={false} so card's onDragStart fires, not native image drag.
|
// draggable={false} so card's onDragStart fires, not native image drag.
|
||||||
<img src={file.thumbnailUrl} alt="" draggable={false} />
|
<img src={resolvedThumbnail} alt="" draggable={false} />
|
||||||
) : (
|
) : (
|
||||||
<div className="files-page-card-thumb-fallback">
|
<div className="files-page-card-thumb-fallback">
|
||||||
{isPdf ? (
|
{isPdf ? (
|
||||||
@@ -1189,6 +1170,11 @@ function FileRow({
|
|||||||
[file.lastModified],
|
[file.lastModified],
|
||||||
);
|
);
|
||||||
const ext = (file.name.split(".").pop() ?? "").toUpperCase();
|
const ext = (file.name.split(".").pop() ?? "").toUpperCase();
|
||||||
|
const resolvedThumbnail = useLazyThumbnail(
|
||||||
|
file.id,
|
||||||
|
file.size,
|
||||||
|
file.thumbnailUrl,
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
role="row"
|
role="row"
|
||||||
@@ -1253,9 +1239,9 @@ function FileRow({
|
|||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{file.thumbnailUrl ? (
|
{resolvedThumbnail ? (
|
||||||
<img
|
<img
|
||||||
src={file.thumbnailUrl}
|
src={resolvedThumbnail}
|
||||||
alt=""
|
alt=""
|
||||||
// draggable={false} so row's onDragStart fires, not native image drag.
|
// draggable={false} so row's onDragStart fires, not native image drag.
|
||||||
draggable={false}
|
draggable={false}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { useMediaQuery } from "@mantine/hooks";
|
|||||||
import SearchIcon from "@mui/icons-material/Search";
|
import SearchIcon from "@mui/icons-material/Search";
|
||||||
import CloseIcon from "@mui/icons-material/Close";
|
import CloseIcon from "@mui/icons-material/Close";
|
||||||
import UploadFileIcon from "@mui/icons-material/UploadFile";
|
import UploadFileIcon from "@mui/icons-material/UploadFile";
|
||||||
|
import QrCode2Icon from "@mui/icons-material/QrCode2";
|
||||||
import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder";
|
import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder";
|
||||||
import GridViewIcon from "@mui/icons-material/GridView";
|
import GridViewIcon from "@mui/icons-material/GridView";
|
||||||
import ViewListIcon from "@mui/icons-material/ViewList";
|
import ViewListIcon from "@mui/icons-material/ViewList";
|
||||||
@@ -58,6 +59,9 @@ import { FolderId, ROOT_FOLDER_ID } from "@app/types/folder";
|
|||||||
import { FileGrid, FilesPageEntry } from "@app/components/filesPage/FileGrid";
|
import { FileGrid, FilesPageEntry } from "@app/components/filesPage/FileGrid";
|
||||||
import { FileDetailsPanel } from "@app/components/filesPage/FileDetailsPanel";
|
import { FileDetailsPanel } from "@app/components/filesPage/FileDetailsPanel";
|
||||||
import BulkUploadToServerModal from "@app/components/shared/BulkUploadToServerModal";
|
import BulkUploadToServerModal from "@app/components/shared/BulkUploadToServerModal";
|
||||||
|
import MobileUploadModal from "@app/components/shared/MobileUploadModal";
|
||||||
|
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||||
|
import { useIsMobile } from "@app/hooks/useIsMobile";
|
||||||
import { MoveToFolderDialog } from "@app/components/filesPage/MoveToFolderDialog";
|
import { MoveToFolderDialog } from "@app/components/filesPage/MoveToFolderDialog";
|
||||||
import { FolderNameDialog } from "@app/components/filesPage/FolderNameDialog";
|
import { FolderNameDialog } from "@app/components/filesPage/FolderNameDialog";
|
||||||
import { DeleteFolderDialog } from "@app/components/filesPage/DeleteFolderDialog";
|
import { DeleteFolderDialog } from "@app/components/filesPage/DeleteFolderDialog";
|
||||||
@@ -98,6 +102,11 @@ export default function FileManagerView() {
|
|||||||
[activeWorkspaceFileIds],
|
[activeWorkspaceFileIds],
|
||||||
);
|
);
|
||||||
const { addFiles } = useFileHandler();
|
const { addFiles } = useFileHandler();
|
||||||
|
const { config: appConfig } = useAppConfig();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
const isMobileUploadAvailable =
|
||||||
|
Boolean(appConfig?.enableMobileScanner) && !isMobile;
|
||||||
|
const [mobileUploadModalOpen, setMobileUploadModalOpen] = useState(false);
|
||||||
const { actions: navActions } = useNavigationActions();
|
const { actions: navActions } = useNavigationActions();
|
||||||
const { requestNavigation } = useNavigationGuard();
|
const { requestNavigation } = useNavigationGuard();
|
||||||
const { setActiveFileId } = useViewer();
|
const { setActiveFileId } = useViewer();
|
||||||
@@ -162,9 +171,7 @@ export default function FileManagerView() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
!sharingEnabled &&
|
!sharingEnabled &&
|
||||||
(currentTab === "shared" ||
|
(currentTab === "shared" || currentTab === "sharedByMe")
|
||||||
currentTab === "sharedByMe" ||
|
|
||||||
currentTab === "imSharing")
|
|
||||||
) {
|
) {
|
||||||
setCurrentTab("all");
|
setCurrentTab("all");
|
||||||
}
|
}
|
||||||
@@ -211,8 +218,7 @@ export default function FileManagerView() {
|
|||||||
currentTab === "local" ||
|
currentTab === "local" ||
|
||||||
currentTab === "recent" ||
|
currentTab === "recent" ||
|
||||||
currentTab === "shared" ||
|
currentTab === "shared" ||
|
||||||
currentTab === "sharedByMe" ||
|
currentTab === "sharedByMe"
|
||||||
currentTab === "imSharing"
|
|
||||||
) {
|
) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -260,28 +266,37 @@ export default function FileManagerView() {
|
|||||||
case "shared":
|
case "shared":
|
||||||
return allFiles.filter((f) => f.remoteOwnedByCurrentUser === false);
|
return allFiles.filter((f) => f.remoteOwnedByCurrentUser === false);
|
||||||
case "sharedByMe":
|
case "sharedByMe":
|
||||||
// Files I own that have at least one outgoing public share link.
|
// Files I own that I've shared in any way - either with a public link
|
||||||
|
// or with a specific user. (Previously split across two visually
|
||||||
|
// identical tabs; merged here so the same idea lives in one place.)
|
||||||
return allFiles.filter(
|
return allFiles.filter(
|
||||||
(f) =>
|
(f) =>
|
||||||
f.remoteOwnedByCurrentUser !== false &&
|
f.remoteOwnedByCurrentUser !== false &&
|
||||||
f.remoteHasShareLinks === true,
|
(f.remoteHasShareLinks === true || f.remoteHasUserShares === 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":
|
case "all":
|
||||||
default:
|
default:
|
||||||
// Search widens to the subtree.
|
// Search widens to the subtree.
|
||||||
|
// Files with a dangling folderId (folder deleted, or stale local IDB
|
||||||
|
// row) fall back to root so they aren't permanently invisible.
|
||||||
return allFiles.filter((f) => {
|
return allFiles.filter((f) => {
|
||||||
if (search) return subtreeFolderIds.has(f.folderId ?? null);
|
const rawFolder = f.folderId ?? null;
|
||||||
return (f.folderId ?? null) === (currentFolderId ?? null);
|
const effectiveFolder =
|
||||||
|
rawFolder !== null && !foldersById.has(rawFolder)
|
||||||
|
? null
|
||||||
|
: rawFolder;
|
||||||
|
if (search) return subtreeFolderIds.has(effectiveFolder);
|
||||||
|
return effectiveFolder === (currentFolderId ?? null);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [allFiles, currentFolderId, currentTab, search, subtreeFolderIds]);
|
}, [
|
||||||
|
allFiles,
|
||||||
|
currentFolderId,
|
||||||
|
currentTab,
|
||||||
|
search,
|
||||||
|
subtreeFolderIds,
|
||||||
|
foldersById,
|
||||||
|
]);
|
||||||
|
|
||||||
const availableTypes = useMemo(() => {
|
const availableTypes = useMemo(() => {
|
||||||
const set = new Set<string>();
|
const set = new Set<string>();
|
||||||
@@ -786,8 +801,7 @@ export default function FileManagerView() {
|
|||||||
if (
|
if (
|
||||||
currentTab === "recent" ||
|
currentTab === "recent" ||
|
||||||
currentTab === "shared" ||
|
currentTab === "shared" ||
|
||||||
currentTab === "sharedByMe" ||
|
currentTab === "sharedByMe"
|
||||||
currentTab === "imSharing"
|
|
||||||
) {
|
) {
|
||||||
return t(
|
return t(
|
||||||
"filesPage.newFolderTabUnavailable",
|
"filesPage.newFolderTabUnavailable",
|
||||||
@@ -811,8 +825,7 @@ export default function FileManagerView() {
|
|||||||
{(currentTab === "local" ||
|
{(currentTab === "local" ||
|
||||||
currentTab === "recent" ||
|
currentTab === "recent" ||
|
||||||
currentTab === "shared" ||
|
currentTab === "shared" ||
|
||||||
currentTab === "sharedByMe" ||
|
currentTab === "sharedByMe") && (
|
||||||
currentTab === "imSharing") && (
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontSize: "0.95rem",
|
fontSize: "0.95rem",
|
||||||
@@ -827,9 +840,7 @@ export default function FileManagerView() {
|
|||||||
? t("filesPage.tabName.recent", "Recent")
|
? t("filesPage.tabName.recent", "Recent")
|
||||||
: currentTab === "shared"
|
: currentTab === "shared"
|
||||||
? t("filesPage.tabName.shared", "Shared with me")
|
? t("filesPage.tabName.shared", "Shared with me")
|
||||||
: currentTab === "sharedByMe"
|
: t("filesPage.tabName.sharedByMe", "Shared by me")}
|
||||||
? t("filesPage.tabName.sharedByMe", "Shared by me")
|
|
||||||
: t("filesPage.tabName.imSharing", "Sharing")}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{(() => {
|
{(() => {
|
||||||
@@ -922,6 +933,28 @@ export default function FileManagerView() {
|
|||||||
>
|
>
|
||||||
{t("filesPage.upload", "Upload")}
|
{t("filesPage.upload", "Upload")}
|
||||||
</Button>
|
</Button>
|
||||||
|
{isMobileUploadAvailable && (
|
||||||
|
<Tooltip
|
||||||
|
label={t(
|
||||||
|
"filesPage.uploadFromMobile",
|
||||||
|
"Upload from Mobile",
|
||||||
|
)}
|
||||||
|
withinPortal
|
||||||
|
>
|
||||||
|
<ActionIcon
|
||||||
|
size="lg"
|
||||||
|
variant="default"
|
||||||
|
radius="md"
|
||||||
|
onClick={() => setMobileUploadModalOpen(true)}
|
||||||
|
aria-label={t(
|
||||||
|
"filesPage.uploadFromMobile",
|
||||||
|
"Upload from Mobile",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<QrCode2Icon fontSize="small" />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
@@ -987,10 +1020,6 @@ export default function FileManagerView() {
|
|||||||
id: "sharedByMe" as const,
|
id: "sharedByMe" as const,
|
||||||
label: t("filesPage.tabs.sharedByMe", "Shared by me"),
|
label: t("filesPage.tabs.sharedByMe", "Shared by me"),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: "imSharing" as const,
|
|
||||||
label: t("filesPage.tabs.imSharing", "Sharing"),
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
] as const;
|
] as const;
|
||||||
@@ -1571,6 +1600,16 @@ export default function FileManagerView() {
|
|||||||
files={saveToServerTarget ?? []}
|
files={saveToServerTarget ?? []}
|
||||||
onUploaded={refresh}
|
onUploaded={refresh}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<MobileUploadModal
|
||||||
|
opened={mobileUploadModalOpen}
|
||||||
|
onClose={() => setMobileUploadModalOpen(false)}
|
||||||
|
onFilesReceived={(files) => {
|
||||||
|
if (files.length > 0) {
|
||||||
|
void addFiles(files);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,62 +1,14 @@
|
|||||||
import { useState, useCallback, useRef, useEffect } from "react";
|
import { useState, useCallback, useRef } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined";
|
import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined";
|
||||||
import VisibilityOffOutlinedIcon from "@mui/icons-material/VisibilityOffOutlined";
|
import VisibilityOffOutlinedIcon from "@mui/icons-material/VisibilityOffOutlined";
|
||||||
import type { FileId } from "@app/types/file";
|
import type { FileId } from "@app/types/file";
|
||||||
import { FileDocIcon } from "@app/components/shared/FileDocIcon";
|
import { FileDocIcon } from "@app/components/shared/FileDocIcon";
|
||||||
import { getFileDocVariant } from "@app/components/shared/filePreview/getFileTypeIcon";
|
import { getFileDocVariant } from "@app/components/shared/filePreview/getFileTypeIcon";
|
||||||
import { useIndexedDB } from "@app/contexts/IndexedDBContext";
|
import { useLazyThumbnail } from "@app/hooks/useLazyThumbnail";
|
||||||
import { useFileManagement } from "@app/contexts/FileContext";
|
|
||||||
import { generateThumbnailForFile } from "@app/utils/thumbnailUtils";
|
|
||||||
import { IMAGE_EXTENSIONS } from "@app/utils/fileUtils";
|
import { IMAGE_EXTENSIONS } from "@app/utils/fileUtils";
|
||||||
import "@app/components/shared/FileSidebarFileItem.css";
|
import "@app/components/shared/FileSidebarFileItem.css";
|
||||||
|
|
||||||
const THUMBNAIL_SIZE_LIMIT = 100 * 1024 * 1024; // 100MB
|
|
||||||
|
|
||||||
/** Generate + persist a thumbnail for a sidebar file that doesn't have one yet. */
|
|
||||||
function useLazyThumbnail(
|
|
||||||
fileId: FileId,
|
|
||||||
size: number,
|
|
||||||
thumbnailUrl?: string,
|
|
||||||
): string | undefined {
|
|
||||||
const [thumb, setThumb] = useState<string | undefined>(thumbnailUrl);
|
|
||||||
const attempted = useRef(false);
|
|
||||||
const indexedDB = useIndexedDB();
|
|
||||||
const { updateStirlingFileStub } = useFileManagement();
|
|
||||||
|
|
||||||
// Sync prop changes (e.g. thumbnail arrives after TTL bump)
|
|
||||||
useEffect(() => {
|
|
||||||
if (thumbnailUrl) setThumb(thumbnailUrl);
|
|
||||||
}, [thumbnailUrl]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (thumbnailUrl || attempted.current || size >= THUMBNAIL_SIZE_LIMIT)
|
|
||||||
return;
|
|
||||||
attempted.current = true;
|
|
||||||
let cancelled = false;
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
const file = await indexedDB.loadFile(fileId);
|
|
||||||
if (!file || cancelled) return;
|
|
||||||
const thumbnail = await generateThumbnailForFile(file);
|
|
||||||
if (cancelled || !thumbnail) return;
|
|
||||||
setThumb(thumbnail);
|
|
||||||
void indexedDB.updateThumbnail(fileId, thumbnail);
|
|
||||||
updateStirlingFileStub(fileId, { thumbnailUrl: thumbnail });
|
|
||||||
} catch {
|
|
||||||
// non-critical
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [fileId, size, thumbnailUrl, indexedDB, updateStirlingFileStub]);
|
|
||||||
|
|
||||||
return thumb;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getFileExtension(name: string): string {
|
export function getFileExtension(name: string): string {
|
||||||
const parts = name.split(".");
|
const parts = name.split(".");
|
||||||
return parts.length > 1 ? parts[parts.length - 1].toLowerCase() : "";
|
return parts.length > 1 ? parts[parts.length - 1].toLowerCase() : "";
|
||||||
|
|||||||
@@ -49,8 +49,7 @@ export type FilesPageTab =
|
|||||||
| "cloud"
|
| "cloud"
|
||||||
| "recent"
|
| "recent"
|
||||||
| "shared"
|
| "shared"
|
||||||
| "sharedByMe"
|
| "sharedByMe";
|
||||||
| "imSharing";
|
|
||||||
|
|
||||||
export interface FolderNameDialogState {
|
export interface FolderNameDialogState {
|
||||||
mode: "new" | "rename" | null;
|
mode: "new" | "rename" | null;
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import type { FileId } from "@app/types/file";
|
||||||
|
import { useFileManagement } from "@app/contexts/FileContext";
|
||||||
|
import { useIndexedDB } from "@app/contexts/IndexedDBContext";
|
||||||
|
import { generateThumbnailForFile } from "@app/utils/thumbnailUtils";
|
||||||
|
|
||||||
|
const THUMBNAIL_SIZE_LIMIT = 100 * 1024 * 1024; // 100MB
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the stub's thumbnail if present; otherwise pull bytes from IndexedDB,
|
||||||
|
* generate one, persist it, and update the stub. Server-only files with no
|
||||||
|
* cached bytes silently stay placeholder-only.
|
||||||
|
*/
|
||||||
|
export function useLazyThumbnail(
|
||||||
|
fileId: FileId,
|
||||||
|
size: number,
|
||||||
|
thumbnailUrl?: string,
|
||||||
|
): string | undefined {
|
||||||
|
const [thumb, setThumb] = useState<string | undefined>(thumbnailUrl);
|
||||||
|
const attempted = useRef(false);
|
||||||
|
const indexedDB = useIndexedDB();
|
||||||
|
const { updateStirlingFileStub } = useFileManagement();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (thumbnailUrl) setThumb(thumbnailUrl);
|
||||||
|
}, [thumbnailUrl]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (thumbnailUrl || attempted.current || size >= THUMBNAIL_SIZE_LIMIT)
|
||||||
|
return;
|
||||||
|
attempted.current = true;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const file = await indexedDB.loadFile(fileId);
|
||||||
|
if (!file || cancelled) return;
|
||||||
|
const thumbnail = await generateThumbnailForFile(file);
|
||||||
|
if (cancelled || !thumbnail) return;
|
||||||
|
setThumb(thumbnail);
|
||||||
|
void indexedDB.updateThumbnail(fileId, thumbnail);
|
||||||
|
updateStirlingFileStub(fileId, { thumbnailUrl: thumbnail });
|
||||||
|
} catch {
|
||||||
|
// non-critical
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [fileId, size, thumbnailUrl, indexedDB, updateStirlingFileStub]);
|
||||||
|
|
||||||
|
return thumb;
|
||||||
|
}
|
||||||
@@ -39,6 +39,29 @@ import type { FileSidebarProps } from "@app/components/shared/FileSidebar";
|
|||||||
|
|
||||||
import "@app/pages/HomePage.css";
|
import "@app/pages/HomePage.css";
|
||||||
|
|
||||||
|
const SIDEBAR_COLLAPSED_STORAGE_KEY = "stirling.fileSidebarCollapsed";
|
||||||
|
|
||||||
|
function readPersistedSidebarCollapsed(): boolean {
|
||||||
|
try {
|
||||||
|
return (
|
||||||
|
window.localStorage.getItem(SIDEBAR_COLLAPSED_STORAGE_KEY) === "true"
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writePersistedSidebarCollapsed(collapsed: boolean): void {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(
|
||||||
|
SIDEBAR_COLLAPSED_STORAGE_KEY,
|
||||||
|
String(collapsed),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// private mode / quota: silently no-op
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type MobileView = "tools" | "workbench";
|
type MobileView = "tools" | "workbench";
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
@@ -66,9 +89,12 @@ export default function HomePage() {
|
|||||||
const isProgrammaticScroll = useRef(false);
|
const isProgrammaticScroll = useRef(false);
|
||||||
const [configModalOpen, setConfigModalOpen] = useState(false);
|
const [configModalOpen, setConfigModalOpen] = useState(false);
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
// Collapse the sidebar when mounting directly on /files.
|
// Persisted user preference for the FileSidebar collapsed state. Auto-
|
||||||
const [fileSidebarCollapsed, setFileSidebarCollapsed] = useState(() =>
|
// collapse on /files is layered on top in the transition effect below and
|
||||||
location.pathname.startsWith("/files"),
|
// doesn't write to storage, so deep-linking to /files won't overwrite what
|
||||||
|
// the user actually chose last time.
|
||||||
|
const [fileSidebarCollapsed, setFileSidebarCollapsed] = useState(
|
||||||
|
readPersistedSidebarCollapsed,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Open the config modal whenever the URL is /settings/* (e.g. from the admin
|
// Open the config modal whenever the URL is /settings/* (e.g. from the admin
|
||||||
@@ -101,22 +127,17 @@ export default function HomePage() {
|
|||||||
activeFiles.length,
|
activeFiles.length,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Auto-collapse the FileSidebar on /files; snapshot prior state for restore.
|
// Auto-collapse the FileSidebar while on /files; restore the user's persisted
|
||||||
const previousSidebarCollapsedRef = useRef<boolean | null>(null);
|
// preference on leave. Auto-collapse doesn't write to storage so deep-linking
|
||||||
|
// to /files won't overwrite what the user actually chose.
|
||||||
const prevWorkbenchRef = useRef(navigationState.workbench);
|
const prevWorkbenchRef = useRef(navigationState.workbench);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const prev = prevWorkbenchRef.current;
|
const prev = prevWorkbenchRef.current;
|
||||||
const curr = navigationState.workbench;
|
const curr = navigationState.workbench;
|
||||||
if (curr === "myFiles" && prev !== "myFiles") {
|
if (curr === "myFiles" && prev !== "myFiles") {
|
||||||
previousSidebarCollapsedRef.current = fileSidebarCollapsed;
|
|
||||||
if (!fileSidebarCollapsed) setFileSidebarCollapsed(true);
|
if (!fileSidebarCollapsed) setFileSidebarCollapsed(true);
|
||||||
} else if (
|
} else if (curr !== "myFiles" && prev === "myFiles") {
|
||||||
curr !== "myFiles" &&
|
setFileSidebarCollapsed(readPersistedSidebarCollapsed());
|
||||||
prev === "myFiles" &&
|
|
||||||
previousSidebarCollapsedRef.current !== null
|
|
||||||
) {
|
|
||||||
setFileSidebarCollapsed(previousSidebarCollapsedRef.current);
|
|
||||||
previousSidebarCollapsedRef.current = null;
|
|
||||||
}
|
}
|
||||||
prevWorkbenchRef.current = curr;
|
prevWorkbenchRef.current = curr;
|
||||||
// fileSidebarCollapsed read as snapshot on transition only.
|
// fileSidebarCollapsed read as snapshot on transition only.
|
||||||
@@ -479,7 +500,11 @@ export default function HomePage() {
|
|||||||
navigate("/");
|
navigate("/");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setFileSidebarCollapsed((c) => !c);
|
setFileSidebarCollapsed((c) => {
|
||||||
|
const next = !c;
|
||||||
|
writePersistedSidebarCollapsed(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
onOpenSettings={() => setConfigModalOpen(true)}
|
onOpenSettings={() => setConfigModalOpen(true)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -193,7 +193,10 @@ export async function reconcileServerFiles(
|
|||||||
typeof updatedAtMs === "number" && Number.isFinite(updatedAtMs)
|
typeof updatedAtMs === "number" && Number.isFinite(updatedAtMs)
|
||||||
? updatedAtMs
|
? updatedAtMs
|
||||||
: stub.remoteStorageUpdatedAt,
|
: stub.remoteStorageUpdatedAt,
|
||||||
folderId: safeParseFolderId(serverFile.folderId) ?? stub.folderId,
|
// Server is authoritative for cloud-stored files. Don't fall back to
|
||||||
|
// stub.folderId on null - that would resurrect a stale folder pointer
|
||||||
|
// after the server SET_NULL'd it (e.g. owner deleted the folder).
|
||||||
|
folderId: safeParseFolderId(serverFile.folderId),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -758,17 +758,17 @@ test.describe("Files page", () => {
|
|||||||
page.locator(".files-page-card:not(.is-folder)"),
|
page.locator(".files-page-card:not(.is-folder)"),
|
||||||
).toHaveCount(4, { timeout: 5_000 });
|
).toHaveCount(4, { timeout: 5_000 });
|
||||||
|
|
||||||
// "Shared by me" -> only link-shared.pdf
|
// "Shared by me" -> link-shared.pdf AND user-shared.pdf
|
||||||
|
// (The previously-separate "Shared by me" / "I'm sharing" tabs are now
|
||||||
|
// merged into a single Shared-by-me view that shows both link shares
|
||||||
|
// and direct user shares.)
|
||||||
await page.locator("#filesPage-tab-sharedByMe").click();
|
await page.locator("#filesPage-tab-sharedByMe").click();
|
||||||
const sharedByMeCards = page.locator(".files-page-card:not(.is-folder)");
|
const sharedByMeCards = page.locator(".files-page-card:not(.is-folder)");
|
||||||
await expect(sharedByMeCards).toHaveCount(1, { timeout: 3_000 });
|
await expect(sharedByMeCards).toHaveCount(2, { timeout: 3_000 });
|
||||||
await expect(sharedByMeCards.first()).toContainText("link-shared.pdf");
|
await expect(sharedByMeCards).toContainText([
|
||||||
|
"link-shared.pdf",
|
||||||
// "I'm sharing" -> only user-shared.pdf
|
"user-shared.pdf",
|
||||||
await page.locator("#filesPage-tab-imSharing").click();
|
]);
|
||||||
const imSharingCards = page.locator(".files-page-card:not(.is-folder)");
|
|
||||||
await expect(imSharingCards).toHaveCount(1, { timeout: 3_000 });
|
|
||||||
await expect(imSharingCards.first()).toContainText("user-shared.pdf");
|
|
||||||
|
|
||||||
// "Shared with me" -> only from-someone-else.pdf
|
// "Shared with me" -> only from-someone-else.pdf
|
||||||
await page.locator("#filesPage-tab-shared").click();
|
await page.locator("#filesPage-tab-shared").click();
|
||||||
|
|||||||
Reference in New Issue
Block a user