mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +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 jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.config.EndpointConfiguration;
|
||||
@@ -91,6 +93,44 @@ public class ConfigController {
|
||||
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. */
|
||||
private Boolean isRunningEE() {
|
||||
// Use LicenseService for fresh license status if available
|
||||
@@ -107,7 +147,7 @@ public class ConfigController {
|
||||
}
|
||||
|
||||
@GetMapping("/app-config")
|
||||
public ResponseEntity<Map<String, Object>> getAppConfig() {
|
||||
public ResponseEntity<Map<String, Object>> getAppConfig(HttpServletRequest request) {
|
||||
Map<String, Object> configData = new HashMap<>();
|
||||
|
||||
try {
|
||||
@@ -124,17 +164,7 @@ public class ConfigController {
|
||||
configData.put("serverPort", appConfig.getServerPort());
|
||||
|
||||
String frontendUrl = applicationProperties.getSystem().getFrontendUrl();
|
||||
if ((frontendUrl == null || frontendUrl.isBlank())
|
||||
&& 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 : "");
|
||||
configData.put("frontendUrl", resolveFrontendUrl(request, appConfig));
|
||||
|
||||
// Add mobile scanner settings
|
||||
configData.put(
|
||||
|
||||
+71
@@ -15,10 +15,14 @@ import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import stirling.software.SPDF.config.EndpointConfiguration;
|
||||
import stirling.software.SPDF.config.EndpointConfiguration.DisableReason;
|
||||
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.System;
|
||||
import stirling.software.common.service.LicenseServiceInterface;
|
||||
import stirling.software.common.service.ServerCertificateServiceInterface;
|
||||
import stirling.software.common.service.UserServiceInterface;
|
||||
@@ -173,4 +177,71 @@ class ConfigControllerTest {
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
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."
|
||||
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."
|
||||
shareDisabledHint = "File sharing isn't enabled on this server. Ask your admin to enable it."
|
||||
shareManage = "Manage sharing"
|
||||
showDetails = "Show details"
|
||||
summary = "{{count}} items"
|
||||
@@ -3863,6 +3863,7 @@ syncPartial = "Folder sync partial: {{failed}} of {{total}} folders could not be
|
||||
tree = "Folders"
|
||||
upload = "Upload"
|
||||
uploadedToLocal = "Uploaded files start in Local. Use 'Save to cloud' to put them in a folder."
|
||||
uploadFromMobile = "Upload from Mobile"
|
||||
versionActions = "Version actions"
|
||||
versionCollapse = "Collapse middle versions"
|
||||
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.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"
|
||||
empty.sharedByMe.hint = "Create a share link or invite a teammate from any of your files to see it here."
|
||||
empty.sharedByMe.title = "You haven't shared any files yet"
|
||||
error.actionFailed = "Could not {{action}}."
|
||||
error.actionFailedDetail = "Could not {{action}}: {{message}}"
|
||||
error.deleteFolderFailed = "Could not delete folder."
|
||||
@@ -3951,7 +3950,6 @@ 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"
|
||||
@@ -3959,7 +3957,6 @@ 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"
|
||||
|
||||
@@ -289,7 +289,7 @@ export function FileDetailsPanel({
|
||||
<Tooltip
|
||||
label={t(
|
||||
"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}
|
||||
withinPortal
|
||||
|
||||
@@ -30,6 +30,7 @@ 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 { useLazyThumbnail } from "@app/hooks/useLazyThumbnail";
|
||||
import type { FilesPageSortMode } from "@app/contexts/FilesPageContext";
|
||||
|
||||
export type FilesPageViewMode = "grid" | "list";
|
||||
@@ -80,14 +81,7 @@ interface FileGridProps {
|
||||
sortMode?: FilesPageSortMode;
|
||||
onChangeSortMode?: (mode: FilesPageSortMode) => void;
|
||||
/** Drives the empty-state copy. */
|
||||
currentTab?:
|
||||
| "all"
|
||||
| "local"
|
||||
| "cloud"
|
||||
| "recent"
|
||||
| "shared"
|
||||
| "sharedByMe"
|
||||
| "imSharing";
|
||||
currentTab?: "all" | "local" | "cloud" | "recent" | "shared" | "sharedByMe";
|
||||
/** Cloud reachability; switches the cloud empty-state copy. */
|
||||
serverReachable?: boolean;
|
||||
/** Empty-state CTA handlers; if absent the matching button hides. */
|
||||
@@ -185,14 +179,7 @@ function SkeletonGrid({ viewMode }: { viewMode: FilesPageViewMode }) {
|
||||
|
||||
interface EmptyStateProps {
|
||||
/** Drives copy + iconography. */
|
||||
tab?:
|
||||
| "all"
|
||||
| "local"
|
||||
| "cloud"
|
||||
| "recent"
|
||||
| "shared"
|
||||
| "sharedByMe"
|
||||
| "imSharing";
|
||||
tab?: "all" | "local" | "cloud" | "recent" | "shared" | "sharedByMe";
|
||||
/** Switches the cloud empty-state copy. */
|
||||
serverReachable?: boolean;
|
||||
/** CTA handlers; absent => button hidden. */
|
||||
@@ -252,18 +239,10 @@ function EmptyState({
|
||||
case "sharedByMe":
|
||||
return {
|
||||
titleKey: "filesPage.empty.sharedByMe.title",
|
||||
titleFallback: "No share links yet",
|
||||
titleFallback: "You haven't shared any files 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.",
|
||||
"Create a share link or invite a teammate from any of your files to see it here.",
|
||||
};
|
||||
case "all":
|
||||
default:
|
||||
@@ -278,10 +257,7 @@ function EmptyState({
|
||||
})();
|
||||
// Recent/Shared tabs are read-only filters; Local is cloud-only for folders.
|
||||
const readOnlyTab =
|
||||
tab === "recent" ||
|
||||
tab === "shared" ||
|
||||
tab === "sharedByMe" ||
|
||||
tab === "imSharing";
|
||||
tab === "recent" || tab === "shared" || tab === "sharedByMe";
|
||||
const showUpload = Boolean(onUpload) && !readOnlyTab;
|
||||
const showCreateFolder =
|
||||
Boolean(onCreateFolder) && !readOnlyTab && tab !== "local";
|
||||
@@ -645,6 +621,11 @@ function FileCard({
|
||||
|
||||
const extension = file.name.split(".").pop()?.toUpperCase() ?? "";
|
||||
const isPdf = extension === "PDF";
|
||||
const resolvedThumbnail = useLazyThumbnail(
|
||||
file.id,
|
||||
file.size,
|
||||
file.thumbnailUrl,
|
||||
);
|
||||
|
||||
const kebabRef = useRef<HTMLButtonElement>(null);
|
||||
const handleContextMenu = useCallback(
|
||||
@@ -712,9 +693,9 @@ function FileCard({
|
||||
</div>
|
||||
)}
|
||||
<div className="files-page-card-thumb">
|
||||
{file.thumbnailUrl ? (
|
||||
{resolvedThumbnail ? (
|
||||
// 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">
|
||||
{isPdf ? (
|
||||
@@ -1189,6 +1170,11 @@ function FileRow({
|
||||
[file.lastModified],
|
||||
);
|
||||
const ext = (file.name.split(".").pop() ?? "").toUpperCase();
|
||||
const resolvedThumbnail = useLazyThumbnail(
|
||||
file.id,
|
||||
file.size,
|
||||
file.thumbnailUrl,
|
||||
);
|
||||
return (
|
||||
<div
|
||||
role="row"
|
||||
@@ -1253,9 +1239,9 @@ function FileRow({
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
{file.thumbnailUrl ? (
|
||||
{resolvedThumbnail ? (
|
||||
<img
|
||||
src={file.thumbnailUrl}
|
||||
src={resolvedThumbnail}
|
||||
alt=""
|
||||
// draggable={false} so row's onDragStart fires, not native image drag.
|
||||
draggable={false}
|
||||
|
||||
@@ -21,6 +21,7 @@ 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 QrCode2Icon from "@mui/icons-material/QrCode2";
|
||||
import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder";
|
||||
import GridViewIcon from "@mui/icons-material/GridView";
|
||||
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 { FileDetailsPanel } from "@app/components/filesPage/FileDetailsPanel";
|
||||
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 { FolderNameDialog } from "@app/components/filesPage/FolderNameDialog";
|
||||
import { DeleteFolderDialog } from "@app/components/filesPage/DeleteFolderDialog";
|
||||
@@ -98,6 +102,11 @@ export default function FileManagerView() {
|
||||
[activeWorkspaceFileIds],
|
||||
);
|
||||
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 { requestNavigation } = useNavigationGuard();
|
||||
const { setActiveFileId } = useViewer();
|
||||
@@ -162,9 +171,7 @@ export default function FileManagerView() {
|
||||
useEffect(() => {
|
||||
if (
|
||||
!sharingEnabled &&
|
||||
(currentTab === "shared" ||
|
||||
currentTab === "sharedByMe" ||
|
||||
currentTab === "imSharing")
|
||||
(currentTab === "shared" || currentTab === "sharedByMe")
|
||||
) {
|
||||
setCurrentTab("all");
|
||||
}
|
||||
@@ -211,8 +218,7 @@ export default function FileManagerView() {
|
||||
currentTab === "local" ||
|
||||
currentTab === "recent" ||
|
||||
currentTab === "shared" ||
|
||||
currentTab === "sharedByMe" ||
|
||||
currentTab === "imSharing"
|
||||
currentTab === "sharedByMe"
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
@@ -260,28 +266,37 @@ export default function FileManagerView() {
|
||||
case "shared":
|
||||
return allFiles.filter((f) => f.remoteOwnedByCurrentUser === false);
|
||||
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(
|
||||
(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,
|
||||
(f.remoteHasShareLinks === true || f.remoteHasUserShares === true),
|
||||
);
|
||||
case "all":
|
||||
default:
|
||||
// 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) => {
|
||||
if (search) return subtreeFolderIds.has(f.folderId ?? null);
|
||||
return (f.folderId ?? null) === (currentFolderId ?? null);
|
||||
const rawFolder = f.folderId ?? 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 set = new Set<string>();
|
||||
@@ -786,8 +801,7 @@ export default function FileManagerView() {
|
||||
if (
|
||||
currentTab === "recent" ||
|
||||
currentTab === "shared" ||
|
||||
currentTab === "sharedByMe" ||
|
||||
currentTab === "imSharing"
|
||||
currentTab === "sharedByMe"
|
||||
) {
|
||||
return t(
|
||||
"filesPage.newFolderTabUnavailable",
|
||||
@@ -811,8 +825,7 @@ export default function FileManagerView() {
|
||||
{(currentTab === "local" ||
|
||||
currentTab === "recent" ||
|
||||
currentTab === "shared" ||
|
||||
currentTab === "sharedByMe" ||
|
||||
currentTab === "imSharing") && (
|
||||
currentTab === "sharedByMe") && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.95rem",
|
||||
@@ -827,9 +840,7 @@ export default function FileManagerView() {
|
||||
? 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")}
|
||||
: t("filesPage.tabName.sharedByMe", "Shared by me")}
|
||||
</div>
|
||||
)}
|
||||
{(() => {
|
||||
@@ -922,6 +933,28 @@ export default function FileManagerView() {
|
||||
>
|
||||
{t("filesPage.upload", "Upload")}
|
||||
</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
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
@@ -987,10 +1020,6 @@ export default function FileManagerView() {
|
||||
id: "sharedByMe" as const,
|
||||
label: t("filesPage.tabs.sharedByMe", "Shared by me"),
|
||||
},
|
||||
{
|
||||
id: "imSharing" as const,
|
||||
label: t("filesPage.tabs.imSharing", "Sharing"),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
] as const;
|
||||
@@ -1571,6 +1600,16 @@ export default function FileManagerView() {
|
||||
files={saveToServerTarget ?? []}
|
||||
onUploaded={refresh}
|
||||
/>
|
||||
|
||||
<MobileUploadModal
|
||||
opened={mobileUploadModalOpen}
|
||||
onClose={() => setMobileUploadModalOpen(false)}
|
||||
onFilesReceived={(files) => {
|
||||
if (files.length > 0) {
|
||||
void addFiles(files);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,62 +1,14 @@
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined";
|
||||
import VisibilityOffOutlinedIcon from "@mui/icons-material/VisibilityOffOutlined";
|
||||
import type { FileId } from "@app/types/file";
|
||||
import { FileDocIcon } from "@app/components/shared/FileDocIcon";
|
||||
import { getFileDocVariant } from "@app/components/shared/filePreview/getFileTypeIcon";
|
||||
import { useIndexedDB } from "@app/contexts/IndexedDBContext";
|
||||
import { useFileManagement } from "@app/contexts/FileContext";
|
||||
import { generateThumbnailForFile } from "@app/utils/thumbnailUtils";
|
||||
import { useLazyThumbnail } from "@app/hooks/useLazyThumbnail";
|
||||
import { IMAGE_EXTENSIONS } from "@app/utils/fileUtils";
|
||||
import "@app/components/shared/FileSidebarFileItem.css";
|
||||
|
||||
const THUMBNAIL_SIZE_LIMIT = 100 * 1024 * 1024; // 100MB
|
||||
|
||||
/** Generate + persist a thumbnail for a sidebar file that doesn't have one yet. */
|
||||
function useLazyThumbnail(
|
||||
fileId: FileId,
|
||||
size: number,
|
||||
thumbnailUrl?: string,
|
||||
): string | undefined {
|
||||
const [thumb, setThumb] = useState<string | undefined>(thumbnailUrl);
|
||||
const attempted = useRef(false);
|
||||
const indexedDB = useIndexedDB();
|
||||
const { updateStirlingFileStub } = useFileManagement();
|
||||
|
||||
// Sync prop changes (e.g. thumbnail arrives after TTL bump)
|
||||
useEffect(() => {
|
||||
if (thumbnailUrl) setThumb(thumbnailUrl);
|
||||
}, [thumbnailUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (thumbnailUrl || attempted.current || size >= THUMBNAIL_SIZE_LIMIT)
|
||||
return;
|
||||
attempted.current = true;
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const file = await indexedDB.loadFile(fileId);
|
||||
if (!file || cancelled) return;
|
||||
const thumbnail = await generateThumbnailForFile(file);
|
||||
if (cancelled || !thumbnail) return;
|
||||
setThumb(thumbnail);
|
||||
void indexedDB.updateThumbnail(fileId, thumbnail);
|
||||
updateStirlingFileStub(fileId, { thumbnailUrl: thumbnail });
|
||||
} catch {
|
||||
// non-critical
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [fileId, size, thumbnailUrl, indexedDB, updateStirlingFileStub]);
|
||||
|
||||
return thumb;
|
||||
}
|
||||
|
||||
export function getFileExtension(name: string): string {
|
||||
const parts = name.split(".");
|
||||
return parts.length > 1 ? parts[parts.length - 1].toLowerCase() : "";
|
||||
|
||||
@@ -49,8 +49,7 @@ export type FilesPageTab =
|
||||
| "cloud"
|
||||
| "recent"
|
||||
| "shared"
|
||||
| "sharedByMe"
|
||||
| "imSharing";
|
||||
| "sharedByMe";
|
||||
|
||||
export interface FolderNameDialogState {
|
||||
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";
|
||||
|
||||
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";
|
||||
|
||||
export default function HomePage() {
|
||||
@@ -66,9 +89,12 @@ export default function HomePage() {
|
||||
const isProgrammaticScroll = useRef(false);
|
||||
const [configModalOpen, setConfigModalOpen] = useState(false);
|
||||
const location = useLocation();
|
||||
// Collapse the sidebar when mounting directly on /files.
|
||||
const [fileSidebarCollapsed, setFileSidebarCollapsed] = useState(() =>
|
||||
location.pathname.startsWith("/files"),
|
||||
// Persisted user preference for the FileSidebar collapsed state. Auto-
|
||||
// collapse on /files is layered on top in the transition effect below and
|
||||
// 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
|
||||
@@ -101,22 +127,17 @@ export default function HomePage() {
|
||||
activeFiles.length,
|
||||
]);
|
||||
|
||||
// Auto-collapse the FileSidebar on /files; snapshot prior state for restore.
|
||||
const previousSidebarCollapsedRef = useRef<boolean | null>(null);
|
||||
// Auto-collapse the FileSidebar while on /files; restore the user's persisted
|
||||
// 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);
|
||||
useEffect(() => {
|
||||
const prev = prevWorkbenchRef.current;
|
||||
const curr = navigationState.workbench;
|
||||
if (curr === "myFiles" && prev !== "myFiles") {
|
||||
previousSidebarCollapsedRef.current = fileSidebarCollapsed;
|
||||
if (!fileSidebarCollapsed) setFileSidebarCollapsed(true);
|
||||
} else if (
|
||||
curr !== "myFiles" &&
|
||||
prev === "myFiles" &&
|
||||
previousSidebarCollapsedRef.current !== null
|
||||
) {
|
||||
setFileSidebarCollapsed(previousSidebarCollapsedRef.current);
|
||||
previousSidebarCollapsedRef.current = null;
|
||||
} else if (curr !== "myFiles" && prev === "myFiles") {
|
||||
setFileSidebarCollapsed(readPersistedSidebarCollapsed());
|
||||
}
|
||||
prevWorkbenchRef.current = curr;
|
||||
// fileSidebarCollapsed read as snapshot on transition only.
|
||||
@@ -479,7 +500,11 @@ export default function HomePage() {
|
||||
navigate("/");
|
||||
return;
|
||||
}
|
||||
setFileSidebarCollapsed((c) => !c);
|
||||
setFileSidebarCollapsed((c) => {
|
||||
const next = !c;
|
||||
writePersistedSidebarCollapsed(next);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
onOpenSettings={() => setConfigModalOpen(true)}
|
||||
/>
|
||||
|
||||
@@ -193,7 +193,10 @@ export async function reconcileServerFiles(
|
||||
typeof updatedAtMs === "number" && Number.isFinite(updatedAtMs)
|
||||
? updatedAtMs
|
||||
: 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)"),
|
||||
).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();
|
||||
const sharedByMeCards = page.locator(".files-page-card:not(.is-folder)");
|
||||
await expect(sharedByMeCards).toHaveCount(1, { timeout: 3_000 });
|
||||
await expect(sharedByMeCards.first()).toContainText("link-shared.pdf");
|
||||
|
||||
// "I'm sharing" -> only 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");
|
||||
await expect(sharedByMeCards).toHaveCount(2, { timeout: 3_000 });
|
||||
await expect(sharedByMeCards).toContainText([
|
||||
"link-shared.pdf",
|
||||
"user-shared.pdf",
|
||||
]);
|
||||
|
||||
// "Shared with me" -> only from-someone-else.pdf
|
||||
await page.locator("#filesPage-tab-shared").click();
|
||||
|
||||
Reference in New Issue
Block a user