diff --git a/app/components/AuthWrapper.tsx b/app/components/AuthWrapper.tsx
index 70dc0ca..d54ed9d 100644
--- a/app/components/AuthWrapper.tsx
+++ b/app/components/AuthWrapper.tsx
@@ -1,155 +1,21 @@
"use client";
-import { useEffect, useState, Suspense } from "react";
-import { useSolidAuth } from "@ldo/solid-react";
-import { useSearchParams, useRouter, usePathname } from "next/navigation";
-import LoadingSpinner from "./shared/LoadingSpinner";
+import { useEffect, type ReactNode } from "react";
+import { useRouter } from "next/navigation";
+import { useSolidAuth } from "../lib/hooks/useSolidAuth";
+import FullPageLoader from "./shared/FullPageLoader";
-interface AuthWrapperProps {
- children: React.ReactNode;
-}
-
-// Check if there's any indication of a session in storage
-function hasSessionInStorage(): boolean {
- if (typeof window === "undefined") return false;
-
- try {
- const keys = Object.keys(localStorage);
- // Look for keys that might indicate a session exists
- return keys.some(key =>
- key.includes("solidClientAuthn") ||
- key.includes("solid-auth") ||
- key.includes("oidc") ||
- key.includes("session")
- );
- } catch {
- return false;
- }
-}
-
-function AuthWrapperContent({ children }: AuthWrapperProps) {
- const { session } = useSolidAuth();
- const searchParams = useSearchParams();
+export default function AuthWrapper({ children }: { children: ReactNode }) {
+ const { status } = useSolidAuth();
const router = useRouter();
- const pathname = usePathname();
- const [isCheckingSession, setIsCheckingSession] = useState(true);
- const [hasSessionIndicator, setHasSessionIndicator] = useState(() => hasSessionInStorage());
- const [wasLoggedIn, setWasLoggedIn] = useState(false);
-
- // Check if we're in the middle of an OAuth callback
- const isOAuthCallback = searchParams.has("code") || searchParams.has("state");
- const isLoginPage = pathname === "/login";
-
-
- const hasSessionData = !!(session.webId || session.sessionId || session.clientAppId);
useEffect(() => {
- if (session.isLoggedIn) {
- setWasLoggedIn(true);
- }
-
- // If we're in an OAuth callback, keep checking until session is established
- // Don't redirect until session is confirmed
- if (isOAuthCallback) {
- setIsCheckingSession(true);
-
- if (session.isLoggedIn) {
- setIsCheckingSession(false);
- // Redirect to home after successful OAuth (remove OAuth params from URL)
- const redirectTimer = setTimeout(() => {
- if (typeof window !== "undefined") {
- window.location.href = "/";
- }
- }, 200);
- return () => clearTimeout(redirectTimer);
- } else {
- // Session not yet established, keep waiting
- // Set a timeout to prevent infinite waiting (max 10 seconds)
- const maxWaitTimer = setTimeout(() => {
- setIsCheckingSession(false);
- }, 10000);
- return () => clearTimeout(maxWaitTimer);
- }
- }
-
-
- if (session.isLoggedIn) {
- setIsCheckingSession(false);
- if (isLoginPage) {
- router.replace("/");
- }
- return;
- }
-
-
- if (wasLoggedIn && !session.isLoggedIn) {
- setIsCheckingSession(false);
- if (!isLoginPage) {
- router.replace("/login");
- }
- return;
- }
+ if (status === "anonymous") router.replace("/login");
+ }, [status, router]);
- // If we have session data (webId, sessionId, etc.) but isLoggedIn is false,
- // the session is likely being restored - wait longer
- // Otherwise, if no session data and no storage indicator, show login quickly
- const shouldWaitForRestore = hasSessionData || hasSessionIndicator;
- const checkTimer = setTimeout(() => {
- setIsCheckingSession(false);
+ // Fail closed: children mount only once a session is confirmed, so nothing
+ // downstream can fetch before there is anything to authenticate with.
+ if (status !== "authenticated") return ;
- if (!session.isLoggedIn && !isLoginPage && !isOAuthCallback) {
- router.replace("/login");
- }
- }, shouldWaitForRestore ? 2000 : 200);
-
- return () => clearTimeout(checkTimer);
- }, [session.isLoggedIn, session.webId, session.sessionId, isOAuthCallback, hasSessionIndicator, hasSessionData, wasLoggedIn, isLoginPage, router]);
-
-
- if (isCheckingSession || isOAuthCallback) {
- return (
-
-
-
- );
- }
-
- // If OAuth callback is on login page, we're still processing - show loading
- // This handles the case where OAuth redirects back to /login
- if (isOAuthCallback && isLoginPage) {
- return (
-
-
-
- );
- }
-
- // If not authenticated and not on login page, redirect will happen in useEffect
- // If authenticated and on login page, redirect will happen in useEffect
- // For now, just show children (or nothing if redirecting)
- if (!session.isLoggedIn && !isLoginPage) {
- return null; // Redirecting to login
- }
-
- if (session.isLoggedIn && isLoginPage) {
- return null; // Redirecting to home
- }
-
- // User is authenticated and on correct page, show the app
return <>{children}>;
}
-
-
-export default function AuthWrapper({ children }: AuthWrapperProps) {
- return (
-
-
-
- }
- >
- {children}
-
- );
-}
diff --git a/app/components/DeleteConfirmDialog.tsx b/app/components/DeleteConfirmDialog.tsx
index fa1ed95..d3e8bcc 100644
--- a/app/components/DeleteConfirmDialog.tsx
+++ b/app/components/DeleteConfirmDialog.tsx
@@ -55,7 +55,7 @@ export default function DeleteConfirmDialog({
Are you sure you want to delete{" "}
- "{file.name}"?
+ “{file.name}”?
{file.type === "folder" && (
diff --git a/app/components/FileItemMenu.tsx b/app/components/FileItemMenu.tsx
index 0986ab1..440b29b 100644
--- a/app/components/FileItemMenu.tsx
+++ b/app/components/FileItemMenu.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useState, useRef, useEffect } from "react";
+import { useState, useRef } from "react";
import Button from "./shared/Button";
import {
EllipsisVerticalIcon,
@@ -15,6 +15,20 @@ import {
import { useClickOutside } from "../lib/hooks";
import { FileItemData } from "./FileItem";
+/** Roughly how tall the menu renders; used to decide which way it opens. */
+const ESTIMATED_MENU_HEIGHT = 250;
+
+/** Opens upward only when the menu would not fit below but would above. */
+function placementFor(button: HTMLElement | null): "top" | "bottom" {
+ if (!button) return "bottom";
+
+ const { top, bottom } = button.getBoundingClientRect();
+ const spaceBelow = window.innerHeight - bottom;
+ return spaceBelow < ESTIMATED_MENU_HEIGHT && top > ESTIMATED_MENU_HEIGHT
+ ? "top"
+ : "bottom";
+}
+
interface FileItemMenuProps {
file: FileItemData;
onRename?: (file: FileItemData) => void;
@@ -49,23 +63,6 @@ export default function FileItemMenu({
refs: [menuRef, menuButtonRef],
});
- useEffect(() => {
- if (showMenu && menuButtonRef.current) {
- const buttonRect = menuButtonRef.current.getBoundingClientRect();
- const viewportHeight = window.innerHeight;
- const spaceBelow = viewportHeight - buttonRect.bottom;
- const spaceAbove = buttonRect.top;
- const estimatedMenuHeight = 250; // Approximate height of the menu
-
- // If there's not enough space below but enough space above, show menu above
- if (spaceBelow < estimatedMenuHeight && spaceAbove > estimatedMenuHeight) {
- setMenuPosition("top");
- } else {
- setMenuPosition("bottom");
- }
- }
- }, [showMenu]);
-
const handleAction = (action: ((file: FileItemData) => void) | undefined) => {
if (action) {
action(file);
@@ -143,6 +140,7 @@ export default function FileItemMenu({
aria-expanded={showMenu}
onClick={(e) => {
e.stopPropagation();
+ if (!showMenu) setMenuPosition(placementFor(menuButtonRef.current));
setShowMenu(!showMenu);
}}
className={position === "top-right" ? "bg-white/90 hover:bg-white shadow-sm" : ""}
diff --git a/app/components/FileList.tsx b/app/components/FileList.tsx
index 74362ef..26d6750 100644
--- a/app/components/FileList.tsx
+++ b/app/components/FileList.tsx
@@ -7,7 +7,6 @@ import EmptyState from "./shared/EmptyState";
interface FileListProps {
files: FileItemData[];
- currentPath: string;
onFileSelect: (file: FileItemData) => void;
onFileDoubleClick: (file: FileItemData) => void;
onFileRename?: (file: FileItemData) => void;
@@ -25,7 +24,6 @@ const VIEW_STORAGE_KEY = "solid-file-manager-view";
export default function FileList({
files,
- currentPath,
onFileSelect,
onFileDoubleClick,
onFileRename,
diff --git a/app/components/FileManager.tsx b/app/components/FileManager.tsx
index 04f6f82..3f87ef9 100644
--- a/app/components/FileManager.tsx
+++ b/app/components/FileManager.tsx
@@ -1,5 +1,7 @@
"use client";
+import { getAuthFetch } from "../lib/auth/manager";
+
import { useState, useEffect, useRef, useCallback } from "react";
import toast from "react-hot-toast";
import { useSearchParams, useRouter } from "next/navigation";
@@ -14,7 +16,6 @@ import {
EyeIcon,
ShareIcon,
} from "@heroicons/react/24/outline";
-import AuthWrapper from "./AuthWrapper";
import Header from "./Header";
import Sidebar from "./Sidebar";
import Breadcrumb from "./Breadcrumb";
@@ -30,11 +31,11 @@ import FileUploadHandler from "./FileUploadHandler";
import ContextMenu, { ContextMenuAction } from "./ContextMenu";
import { FileItemData } from "./FileItem";
import LoadingSpinner from "./shared/LoadingSpinner";
+import FullPageLoader from "./shared/FullPageLoader";
import ErrorDisplay from "./shared/ErrorDisplay";
import { useSolidStorages, useBrowseStorage } from "../lib/hooks";
import {
buildBreadcrumbItems,
- getAuthenticatedSession,
copyFileResource,
copyFolderResource,
downloadFile,
@@ -43,7 +44,6 @@ import {
deleteFolderResource,
uploadFilesToContainer,
uploadFolderFilesToContainer,
- FolderUploadFile,
processDragDropItems,
hasFiles as hasFilesInDrag,
isUnsupportedFolderDrag,
@@ -172,10 +172,14 @@ export default function FileManager() {
setSelectedStorageId(matchingStorage.id);
setCurrentPath(urlParam === matchingStorage.url ? "/" : urlParam);
+ // Sync the address bar without navigating.
if (typeof window !== "undefined") {
const params = new URLSearchParams();
params.set("url", safeEncodeUrl(urlParam));
- router.replace(`/?${params.toString()}`, { scroll: false });
+ const search = `?${params.toString()}`;
+ if (window.location.search !== search) {
+ window.history.replaceState(null, "", `/${search}`);
+ }
}
setIsInitialized(true);
@@ -186,7 +190,7 @@ export default function FileManager() {
}
setIsInitialized(true);
- }, [searchParams, storages, isLoadingStorages, isInitialized, router]);
+ }, [searchParams, storages, isLoadingStorages, isInitialized]);
useEffect(() => {
return () => {
@@ -325,7 +329,7 @@ export default function FileManager() {
const toastId = toast.loading(`Copying "${file.name}"...`);
try {
- const { fetch: fetchFn } = getAuthenticatedSession();
+ const fetchFn = getAuthFetch();
if (file.type === "folder") {
await copyFolderResource(file, fetchFn);
} else {
@@ -372,7 +376,7 @@ export default function FileManager() {
);
try {
- const { fetch: fetchFn } = getAuthenticatedSession();
+ const fetchFn = getAuthFetch();
if (fileToDelete.type === "folder") {
await deleteFolderResource(fileToDelete.url, fetchFn);
@@ -415,7 +419,7 @@ export default function FileManager() {
);
try {
- const { fetch: fetchFn } = getAuthenticatedSession();
+ const fetchFn = getAuthFetch();
if (file.type === "folder") {
await downloadFolderAsZip(file.url, file.name, fetchFn);
@@ -509,8 +513,9 @@ export default function FileManager() {
let fetchFn: typeof fetch;
try {
- ({ fetch: fetchFn } = getAuthenticatedSession());
+ fetchFn = getAuthFetch();
} catch (error) {
+ console.error("No authenticated fetch available", error);
toast.error("Not authenticated");
return;
}
@@ -775,205 +780,188 @@ export default function FileManager() {
};
if (isLoadingStorages) {
- return (
-
-