diff --git a/app/components/FileList.tsx b/app/components/FileList.tsx index 74362ef..be8cc14 100644 --- a/app/components/FileList.tsx +++ b/app/components/FileList.tsx @@ -25,7 +25,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..41f4892 100644 --- a/app/components/FileManager.tsx +++ b/app/components/FileManager.tsx @@ -1,780 +1,17 @@ "use client"; -import { useState, useEffect, useRef, useCallback } from "react"; -import toast from "react-hot-toast"; -import { useSearchParams, useRouter } from "next/navigation"; -import { - FolderPlusIcon, - ArrowUpTrayIcon, - PencilIcon, - ArrowDownTrayIcon, - DocumentDuplicateIcon, - ArrowRightCircleIcon, - TrashIcon, - EyeIcon, - ShareIcon, -} from "@heroicons/react/24/outline"; import AuthWrapper from "./AuthWrapper"; -import Header from "./Header"; -import Sidebar from "./Sidebar"; -import Breadcrumb from "./Breadcrumb"; -import FileList from "./FileList"; -import NewFolderDialog from "./NewFolderDialog"; -import RenameDialog from "./RenameDialog"; -import PreviewModal from "./PreviewModal"; -import MoveDialog from "./MoveDialog"; -import DeleteConfirmDialog from "./DeleteConfirmDialog"; -import ShareDialog, { AccessLevel } from "./ShareDialog"; -import ShareSuccessModal from "./ShareSuccessModal"; -import FileUploadHandler from "./FileUploadHandler"; -import ContextMenu, { ContextMenuAction } from "./ContextMenu"; -import { FileItemData } from "./FileItem"; import LoadingSpinner from "./shared/LoadingSpinner"; import ErrorDisplay from "./shared/ErrorDisplay"; -import { useSolidStorages, useBrowseStorage } from "../lib/hooks"; -import { - buildBreadcrumbItems, - getAuthenticatedSession, - copyFileResource, - copyFolderResource, - downloadFile, - downloadFolderAsZip, - deleteFileResource, - deleteFolderResource, - uploadFilesToContainer, - uploadFolderFilesToContainer, - FolderUploadFile, - processDragDropItems, - hasFiles as hasFilesInDrag, - isUnsupportedFolderDrag, -} from "../lib/helpers"; -import { shareResourceWithAcp } from "../lib/helpers/acpUtils"; -import { - getUrlFromSearchParams, - getUrlFromStorage, - saveUrlToStorage, - removeUrlFromStorage, - safeEncodeUrl, -} from "../lib/helpers/urlStateUtils"; - -type ContextMenuState = - | { - type: "new"; - position: { x: number; y: number }; - } - | { - type: "file"; - position: { x: number; y: number }; - file: FileItemData; - }; +import { useSolidStorages } from "../lib/hooks"; +import { FileManagerProvider } from "./file-manager"; +import FileManagerContent from "./file-manager/FileManagerContent"; +/** Loads storages, then mounts the file-manager provider and UI. */ export default function FileManager() { - const searchParams = useSearchParams(); - const router = useRouter(); - const { storages, isLoading: isLoadingStorages, error: storagesError } = useSolidStorages(); - const [selectedStorageId, setSelectedStorageId] = useState(null); - const [currentPath, setCurrentPath] = useState("/"); - const [selectedFileIds, setSelectedFileIds] = useState([]); - const [isInitialized, setIsInitialized] = useState(false); - const [refreshKey, setRefreshKey] = useState(0); - const [sidebarOpen, setSidebarOpen] = useState(false); - const [showNewFolderDialog, setShowNewFolderDialog] = useState(false); - const [fileUploadTrigger, setFileUploadTrigger] = useState(0); - const [isDragActive, setIsDragActive] = useState(false); - const dragCounterRef = useRef(0); - const [folderUploadTrigger, setFolderUploadTrigger] = useState(0); - const refreshTimeoutRef = useRef | null>(null); - const [showRenameDialog, setShowRenameDialog] = useState(false); - const [fileToRename, setFileToRename] = useState(null); - const [showPreviewModal, setShowPreviewModal] = useState(false); - const [fileToPreview, setFileToPreview] = useState(null); - const [showMoveDialog, setShowMoveDialog] = useState(false); - const [fileToMove, setFileToMove] = useState(null); - const [showDeleteDialog, setShowDeleteDialog] = useState(false); - const [fileToDelete, setFileToDelete] = useState(null); - const [isDeleting, setIsDeleting] = useState(false); - const [showShareDialog, setShowShareDialog] = useState(false); - const [fileToShare, setFileToShare] = useState(null); - const [contextMenuState, setContextMenuState] = useState(null); - const [showShareSuccessModal, setShowShareSuccessModal] = useState(false); - const [sharedResourceUrl, setSharedResourceUrl] = useState(""); - const [sharedResourceName, setSharedResourceName] = useState(""); - - const closeContextMenu = () => setContextMenuState(null); - - const handleBlankContextMenu = (event: React.MouseEvent) => { - event.preventDefault(); - setContextMenuState({ - type: "new", - position: { x: event.clientX, y: event.clientY }, - }); - }; - - const handleFileContextMenu = (file: FileItemData, event: React.MouseEvent) => { - event.preventDefault(); - setContextMenuState({ - type: "file", - file, - position: { x: event.clientX, y: event.clientY }, - }); - }; - - // Handle URL changes from browser back/forward buttons - useEffect(() => { - if (isLoadingStorages || storages.length === 0 || !isInitialized) { - return; - } - - const urlParam = getUrlFromSearchParams(); - - if (!urlParam) { - // No URL in params - reset to root if we have a storage selected - if (selectedStorageId) { - const storage = storages.find((s) => s.id === selectedStorageId); - if (storage) { - setCurrentPath("/"); - removeUrlFromStorage(); - } - } - return; - } - - // URL changed - update state to match - const matchingStorage = storages.find((s) => urlParam === s.url || urlParam.startsWith(s.url)); - - if (matchingStorage) { - setSelectedStorageId(matchingStorage.id); - setCurrentPath(urlParam === matchingStorage.url ? "/" : urlParam); - saveUrlToStorage(urlParam); - } - }, [searchParams, storages, isLoadingStorages, isInitialized, selectedStorageId]); - - useEffect(() => { - if (isLoadingStorages || storages.length === 0 || isInitialized) { - return; - } - - // Get URL from search params first, then fallback to sessionStorage - const urlParam = getUrlFromSearchParams() || getUrlFromStorage(); - - if (!urlParam) { - setIsInitialized(true); - return; - } - - saveUrlToStorage(urlParam); - - try { - // Find which storage this URL belongs to - const matchingStorage = storages.find((s) => urlParam === s.url || urlParam.startsWith(s.url)); - - if (matchingStorage) { - setSelectedStorageId(matchingStorage.id); - setCurrentPath(urlParam === matchingStorage.url ? "/" : urlParam); - - if (typeof window !== "undefined") { - const params = new URLSearchParams(); - params.set("url", safeEncodeUrl(urlParam)); - router.replace(`/?${params.toString()}`, { scroll: false }); - } - - setIsInitialized(true); - return; - } - } catch (e) { - console.error("Failed to set initial URL:", e); - } - - setIsInitialized(true); - }, [searchParams, storages, isLoadingStorages, isInitialized, router]); - - useEffect(() => { - return () => { - if (refreshTimeoutRef.current) { - clearTimeout(refreshTimeoutRef.current); - } - }; - }, []); - - useEffect(() => { - if (!contextMenuState) { - return; - } - - const handleClick = () => setContextMenuState(null); - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape") { - setContextMenuState(null); - } - }; - - document.addEventListener("click", handleClick); - document.addEventListener("keydown", handleKeyDown); - - return () => { - document.removeEventListener("click", handleClick); - document.removeEventListener("keydown", handleKeyDown); - }; - }, [contextMenuState]); - - const updateUrl = (url: string | null, addToHistory: boolean = true) => { - if (!url || url === "/") { - removeUrlFromStorage(); - if (typeof window !== "undefined" && window.location.search) { - if (addToHistory) { - router.push("/", { scroll: false }); - } else { - router.replace("/", { scroll: false }); - } - } - return; - } - - const params = new URLSearchParams(); - params.set("url", safeEncodeUrl(url)); - saveUrlToStorage(url); - - if (addToHistory) { - router.push(`/?${params.toString()}`, { scroll: false }); - } else { - router.replace(`/?${params.toString()}`, { scroll: false }); - } - }; - - const containerUrlToBrowse = selectedStorageId - ? currentPath === "/" - ? storages.find((s) => s.id === selectedStorageId)?.url || null - : currentPath - : null; - - const { files: browsedFiles, isLoading: isLoadingFiles, error: browseError } = useBrowseStorage(containerUrlToBrowse, refreshKey); - - const triggerContainerRefresh = useCallback(() => { - const currentContainerUrl = selectedStorageId - ? currentPath === "/" - ? storages.find((s) => s.id === selectedStorageId)?.url || null - : currentPath - : null; - - if (!currentContainerUrl) { - return; - } - - // Clear any existing timeout - if (refreshTimeoutRef.current) { - clearTimeout(refreshTimeoutRef.current); - } - - // Single refresh after a delay to give server time to process - refreshTimeoutRef.current = setTimeout(() => { - setRefreshKey((prev) => prev + 1); - refreshTimeoutRef.current = null; - }, 1000); - }, [selectedStorageId, currentPath, storages]); - - const handleFolderCreated = () => { - triggerContainerRefresh(); - }; - - const handleFileUploaded = () => { - triggerContainerRefresh(); - }; - - const ensureStorageSelected = () => { - if (!containerUrlToBrowse) { - toast.error("Please select a storage first"); - return false; - } - return true; - }; - - const triggerFileUploadDialog = () => { - if (!ensureStorageSelected()) return; - setFileUploadTrigger((prev) => prev + 1); - }; - - const triggerFolderUploadDialog = () => { - if (!ensureStorageSelected()) return; - setFolderUploadTrigger((prev) => prev + 1); - }; - - const openNewFolderDialog = () => { - if (!ensureStorageSelected()) return; - setShowNewFolderDialog(true); - }; - - const handleRename = (file: FileItemData) => { - setFileToRename(file); - setShowRenameDialog(true); - }; - - const handleRenamed = (newUrl: string) => { - - if (fileToRename && currentPath === fileToRename.url) { - setCurrentPath(newUrl); - updateUrl(newUrl, false); - } - // Trigger refresh to update file list immediately - setRefreshKey((prev) => prev + 1); - }; - - const handleCopy = async (file: FileItemData) => { - if (!file) { - return; - } - - const toastId = toast.loading(`Copying "${file.name}"...`); - try { - const { fetch: fetchFn } = getAuthenticatedSession(); - if (file.type === "folder") { - await copyFolderResource(file, fetchFn); - } else { - await copyFileResource(file, fetchFn); - } - toast.success(`Copied "${file.name}"`, { id: toastId }); - setRefreshKey((prev) => prev + 1); - } catch (error) { - console.error("Failed to copy resource:", error); - toast.error( - error instanceof Error ? `Failed to copy: ${error.message}` : "Failed to copy resource", - { id: toastId } - ); - } - }; - - const handlePreview = (file: FileItemData) => { - setFileToPreview(file); - setShowPreviewModal(true); - }; - - const handleMove = (file: FileItemData) => { - setFileToMove(file); - setShowMoveDialog(true); - }; - - const handleMoved = () => { - setRefreshKey((prev) => prev + 1); - }; - - const handleDelete = (file: FileItemData) => { - setFileToDelete(file); - setShowDeleteDialog(true); - }; - - const handleDeleteConfirm = async () => { - if (!fileToDelete) { - return; - } - - setIsDeleting(true); - const toastId = toast.loading( - `Deleting "${fileToDelete.name}"...` - ); - - try { - const { fetch: fetchFn } = getAuthenticatedSession(); - - if (fileToDelete.type === "folder") { - await deleteFolderResource(fileToDelete.url, fetchFn); - } else { - await deleteFileResource(fileToDelete.url, fetchFn); - } - - toast.success(`Deleted "${fileToDelete.name}"`, { id: toastId }); - - // Clear selected files if the deleted file was selected - setSelectedFileIds((prev) => prev.filter((id) => id !== fileToDelete.id)); - - setShowDeleteDialog(false); - setFileToDelete(null); - - // Wait a bit for server to process deletion, then trigger single refresh - setTimeout(() => { - setRefreshKey((prev) => prev + 1); - }, 1000); - } catch (error) { - console.error("Failed to delete resource:", error); - toast.error( - error instanceof Error - ? `Failed to delete: ${error.message}` - : "Failed to delete resource", - { id: toastId } - ); - } finally { - setIsDeleting(false); - } - }; - - const handleDownload = async (file: FileItemData) => { - if (!file) { - return; - } - - const toastId = toast.loading( - file.type === "folder" ? `Preparing "${file.name}" for download...` : `Downloading "${file.name}"...` - ); - - try { - const { fetch: fetchFn } = getAuthenticatedSession(); - - if (file.type === "folder") { - await downloadFolderAsZip(file.url, file.name, fetchFn); - toast.success(`Downloaded "${file.name}.zip"`, { id: toastId }); - } else { - await downloadFile(file.url, file.name, fetchFn); - toast.success(`Downloaded "${file.name}"`, { id: toastId }); - } - } catch (error) { - console.error("Failed to download resource:", error); - toast.error( - error instanceof Error - ? `Failed to download: ${error.message}` - : "Failed to download resource", - { id: toastId } - ); - } - }; + const { storages, isLoading, error } = useSolidStorages(); - const handleShare = (file: FileItemData) => { - setFileToShare(file); - setShowShareDialog(true); - }; - - const handleShareConfirm = async (webIds: string[], accessLevel: AccessLevel) => { - if (!fileToShare) { - return; - } - - const toastId = toast.loading(`Sharing "${fileToShare.name}"...`); - try { - // Get the resource URL - ensure it has a trailing slash for containers - let resourceUrl = fileToShare.url; - if (fileToShare.type === "folder" && !resourceUrl.endsWith("/")) { - resourceUrl += "/"; - } - - await shareResourceWithAcp(resourceUrl, webIds, accessLevel); - - toast.success(`Successfully shared "${fileToShare.name}"`, { id: toastId }); - setShowShareDialog(false); - - // Show success modal with resource URL - setSharedResourceUrl(resourceUrl); - setSharedResourceName(fileToShare.name); - setShowShareSuccessModal(true); - setFileToShare(null); - } catch (error) { - console.error("Failed to share resource:", error); - toast.error( - error instanceof Error - ? `Failed to share: ${error.message}` - : "Failed to share resource", - { id: toastId } - ); - } - }; - - const handleDragEnter = (event: React.DragEvent) => { - if (!hasFilesInDrag(event)) return; - event.preventDefault(); - dragCounterRef.current += 1; - setIsDragActive(true); - }; - - const handleDragLeave = (event: React.DragEvent) => { - if (!hasFilesInDrag(event)) return; - event.preventDefault(); - dragCounterRef.current = Math.max(0, dragCounterRef.current - 1); - if (dragCounterRef.current === 0) { - setIsDragActive(false); - } - }; - - const handleDragOver = (event: React.DragEvent) => { - if (!hasFilesInDrag(event)) return; - event.preventDefault(); - event.dataTransfer.dropEffect = "copy"; - }; - - const handleDrop = async (event: React.DragEvent) => { - if (!hasFilesInDrag(event)) return; - event.preventDefault(); - dragCounterRef.current = 0; - setIsDragActive(false); - - if (!containerUrlToBrowse) { - toast.error("Please select a storage first"); - return; - } - - let fetchFn: typeof fetch; - try { - ({ fetch: fetchFn } = getAuthenticatedSession()); - } catch (error) { - toast.error("Not authenticated"); - return; - } - - // Process drag-and-drop items (handles both files and folders) - const { singleFiles, folderFiles } = await processDragDropItems(event); - - // Check for unsupported folder drag (only if no files were processed and File System Access API wasn't used) - if (singleFiles.length === 0 && folderFiles.length === 0 && isUnsupportedFolderDrag(event)) { - toast.error( - "Folder drag-and-drop is not supported in this browser. Please use the 'Folder Upload' button in the menu." - ); - return; - } - - let uploadedSomething = false; - - if (singleFiles.length > 0) { - try { - const { uploadedFiles, failedFiles } = await uploadFilesToContainer( - singleFiles, - containerUrlToBrowse, - fetchFn - ); - - if (uploadedFiles.length > 0) { - uploadedSomething = true; - const message = - uploadedFiles.length === 1 - ? `File uploaded successfully` - : `${uploadedFiles.length} files uploaded successfully`; - toast.success(message); - } - - if (failedFiles.length > 0) { - const message = - failedFiles.length === 1 - ? `Failed to upload "${failedFiles[0]}"` - : `Failed to upload ${failedFiles.length} files`; - toast.error(message); - } - } catch (error) { - console.error("Upload error:", error); - toast.error("Failed to upload files"); - } - } - - if (folderFiles.length > 0) { - try { - const { uploadedFiles, failedFiles } = await uploadFolderFilesToContainer( - folderFiles, - containerUrlToBrowse, - fetchFn - ); - - if (uploadedFiles.length > 0) { - uploadedSomething = true; - const message = - uploadedFiles.length === 1 - ? `File uploaded successfully` - : `${uploadedFiles.length} files uploaded successfully`; - toast.success(message); - } - - if (failedFiles.length > 0) { - const message = - failedFiles.length === 1 - ? `Failed to upload "${failedFiles[0]}"` - : `Failed to upload ${failedFiles.length} files`; - toast.error(message); - } - } catch (error) { - console.error("Upload error:", error); - toast.error("Failed to upload folder"); - } - } - - if (uploadedSomething) { - // Wait a bit for the server to process the upload - await new Promise((resolve) => setTimeout(resolve, 300)); - // Trigger refresh with retry mechanism - triggerContainerRefresh(); - } - }; - - const storageFiles: FileItemData[] = storages.map((storage) => ({ - id: storage.id, - name: storage.name, - type: "folder" as const, - url: storage.url, - })); - - const displayFiles = selectedStorageId ? browsedFiles : storageFiles; - - // Get all available folders for move dialog (storages + browsed folders) - const availableFolders: FileItemData[] = [ - ...storageFiles, - ...(selectedStorageId ? browsedFiles.filter((f) => f.type === "folder") : []), - ]; - - // Get current location URL for move dialog - const getCurrentLocationUrl = (): string => { - if (!selectedStorageId) { - return ""; - } - if (currentPath === "/") { - const storage = storages.find((s) => s.id === selectedStorageId); - return storage?.url || ""; - } - return currentPath; - }; - - const selectedStorage = storages.find((s) => s.id === selectedStorageId); - const breadcrumbItems = buildBreadcrumbItems( - selectedStorageId, - selectedStorage?.url, - selectedStorage?.name, - currentPath - ); - - const handleFileDoubleClick = (file: FileItemData) => { - if (file.type === "folder") { - const isStorage = storages.some(s => s.id === file.id); - - if (!selectedStorageId && isStorage) { - setSelectedStorageId(file.id); - setCurrentPath("/"); - setSelectedFileIds([]); - updateUrl(file.url, true); - } else if (selectedStorageId) { - setCurrentPath(file.url); - setSelectedFileIds([]); - updateUrl(file.url, true); - } - } else { - // Open preview modal - handlePreview(file); - } - }; - - const newContextMenuActions: ContextMenuAction[] = [ - { - label: "New Folder", - icon: FolderPlusIcon, - onClick: () => { - closeContextMenu(); - openNewFolderDialog(); - }, - }, - { - label: "File Upload", - icon: ArrowUpTrayIcon, - onClick: () => { - closeContextMenu(); - triggerFileUploadDialog(); - }, - }, - { - label: "Folder Upload", - icon: FolderPlusIcon, - onClick: () => { - closeContextMenu(); - triggerFolderUploadDialog(); - }, - }, - ]; - - const getFileContextMenuActions = (file: FileItemData): ContextMenuAction[] => { - const actions: ContextMenuAction[] = []; - - if (file.type === "file") { - actions.push({ - label: "Preview", - icon: EyeIcon, - onClick: () => { - closeContextMenu(); - handlePreview(file); - }, - }); - } - - actions.push( - { - label: "Rename", - icon: PencilIcon, - onClick: () => { - closeContextMenu(); - handleRename(file); - }, - }, - { - label: "Download", - icon: ArrowDownTrayIcon, - onClick: () => { - closeContextMenu(); - handleDownload(file); - }, - }, - { - label: "Copy", - icon: DocumentDuplicateIcon, - onClick: () => { - closeContextMenu(); - handleCopy(file); - }, - }, - { - label: "Share", - icon: ShareIcon, - onClick: () => { - closeContextMenu(); - handleShare(file); - }, - } - ); - - if (file.type === "file") { - actions.push({ - label: "Move", - icon: ArrowRightCircleIcon, - onClick: () => { - closeContextMenu(); - handleMove(file); - }, - }); - } - - actions.push({ - label: "Delete", - icon: TrashIcon, - danger: true, - onClick: () => { - closeContextMenu(); - handleDelete(file); - }, - }); - - return actions; - }; - - const handleFileSelect = (file: FileItemData) => { - setSelectedFileIds([file.id]) - }; - - const handleBreadcrumbNavigate = (path: string) => { - if (path === "/") { - setSelectedStorageId(null); - setCurrentPath("/"); - setSelectedFileIds([]); - updateUrl(null, true); - } else { - const selectedStorage = storages.find((s) => s.id === selectedStorageId); - if (selectedStorage && path === selectedStorage.url) { - setCurrentPath("/"); - updateUrl(selectedStorage.url, true); - } else { - setCurrentPath(path); - updateUrl(path, true); - } - setSelectedFileIds([]); - } - }; - - if (isLoadingStorages) { + if (isLoading) { return (
@@ -784,34 +21,18 @@ export default function FileManager() { ); } - const isBrowsing = selectedStorageId && isLoadingFiles; - - if (storagesError) { + if (error) { return ( window.location.reload()} /> ); } - if (browseError && selectedStorageId) { - return ( - - { - setCurrentPath("/"); - }} - /> - - ); - } - if (storages.length === 0) { return ( @@ -829,151 +50,9 @@ export default function FileManager() { return ( -
-
setSidebarOpen(true)} - sidebarOpen={sidebarOpen} - /> -
- setSidebarOpen(false)} - activeTab="my-storages" - currentContainerUrl={containerUrlToBrowse} - onNewFolderClick={() => setShowNewFolderDialog(true)} - onFileUploadClick={() => setFileUploadTrigger((prev) => prev + 1)} - onFolderUploadClick={() => setFolderUploadTrigger((prev) => prev + 1)} - /> -
-
- -
- {isBrowsing ? ( -
- -
- ) : ( -
- -
- )} -
-
- - setShowNewFolderDialog(false)} - currentContainerUrl={containerUrlToBrowse} - onFolderCreated={handleFolderCreated} - /> - { - setShowRenameDialog(false); - setFileToRename(null); - }} - file={fileToRename} - onRenamed={handleRenamed} - /> - { - setShowPreviewModal(false); - setFileToPreview(null); - }} - file={fileToPreview} - /> - { - setShowMoveDialog(false); - setFileToMove(null); - }} - file={fileToMove} - availableFolders={availableFolders} - currentLocationUrl={getCurrentLocationUrl()} - onMoved={handleMoved} - /> - { - setShowDeleteDialog(false); - setFileToDelete(null); - }} - file={fileToDelete} - onConfirm={handleDeleteConfirm} - isDeleting={isDeleting} - /> - { - setShowShareDialog(false); - setFileToShare(null); - }} - file={fileToShare} - onShare={handleShareConfirm} - /> - setShowShareSuccessModal(false)} - resourceUrl={sharedResourceUrl} - resourceName={sharedResourceName} - onOpenInApp={(url) => { - updateUrl(url, true); - }} - /> - {isDragActive && ( -
-
-

Drop files or folders to upload

-

- They will be uploaded to the current folder -

-
-
- )} - - - {contextMenuState && ( - - )} -
+ + +
); } diff --git a/app/components/FolderTree.tsx b/app/components/FolderTree.tsx new file mode 100644 index 0000000..f755038 --- /dev/null +++ b/app/components/FolderTree.tsx @@ -0,0 +1,223 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; +import { ChevronRightIcon, ChevronDownIcon, FolderIcon } from "@heroicons/react/24/outline"; +import { SolidStorage } from "../lib/hooks/useSolidStorages"; +import { FolderTreeChild, folderUrlsEqual, ensureTrailingSlash, getAuthenticatedSession, fetchContainerListing, foldersFromListing } from "../lib/helpers"; +import { getContainerListing, loadContainerListing, subscribeContainerCache, getContainerCacheVersion } from "../lib/cache"; + +interface FolderTreeProps { + storages: SolidStorage[]; + currentFolderUrl?: string | null; + onNavigate: (folderUrl: string) => void; +} + +export default function FolderTree({ + storages, + currentFolderUrl, + onNavigate +}: FolderTreeProps) { + const [expandedUrls, setExpandedUrls] = useState>(new Set()); + const [loadingUrls, setLoadingUrls] = useState>(new Set()); + const [errorByUrl, setErrorByUrl] = useState>({}); + + const cacheVersion = useSyncExternalStore( + subscribeContainerCache, + getContainerCacheVersion, + getContainerCacheVersion, + ) + + // Children come from the shared cache, not a local copy. + // cacheVersion makes this recompute when listings are written or invalidated. + const childrenByUrl = useMemo(() => { + const next: Record = {}; + for (const url of expandedUrls) { + const cached = getContainerListing(url); + if (cached) { + next[url] = foldersFromListing(cached); + } + } + return next; + }, [cacheVersion, expandedUrls]) + + const expandedUrlsRef = useRef(expandedUrls); + expandedUrlsRef.current = expandedUrls; + + // Keep the current folder URL in one shape so highlight checks stay reliable. + const normalizedCurrentFolderUrl = useMemo(() => ( + currentFolderUrl ? ensureTrailingSlash(currentFolderUrl) : null + ), [currentFolderUrl]); + + // Load child folders via the shared container cache (same data as main browse). + const loadChildren = useCallback(async (folderUrl: string) => { + const normalizedUrl = ensureTrailingSlash(folderUrl); + + if (getContainerListing(normalizedUrl)) { + return; + } + + setLoadingUrls((prev) => { + const next = new Set(prev); + next.add(normalizedUrl); + return next; + }); + + setErrorByUrl((prev) => { + if (!(normalizedUrl in prev)) return prev; + const next = { ...prev }; + delete next[normalizedUrl]; + return next; + }); + + try { + const { fetch } = getAuthenticatedSession(); + await loadContainerListing( + normalizedUrl, + () => fetchContainerListing(normalizedUrl, fetch), + ); + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to load folders"; + setErrorByUrl((prev) => ({ ...prev, [normalizedUrl]: message })); + } finally { + setLoadingUrls((prev) => { + const next = new Set(prev); + next.delete(normalizedUrl); + return next; + }); + } + }, []); + + + // After cache invalidation, refetch any expanded folder that no longer has a listing. + useEffect(() => { + return subscribeContainerCache(() => { + for (const url of expandedUrlsRef.current) { + if (!getContainerListing(url)) { + void loadChildren(url) + } + } + }) + }, [loadChildren]) + + // Open or close a folder branch. Fetch children only when opening. + const toggleExpand = useCallback(async (folderUrl: string) => { + const normalizedUrl = ensureTrailingSlash(folderUrl); + + const isExpanded = expandedUrls.has(normalizedUrl); + if (isExpanded) { + setExpandedUrls((prev) => { + const next = new Set(prev); + next.delete(normalizedUrl); + return next; + }); + return; + } + + setExpandedUrls((prev) => { + const next = new Set(prev); + next.add(normalizedUrl); + return next; + }); + + await loadChildren(normalizedUrl); + }, [expandedUrls, loadChildren]); + + // Render one folder row and its nested children when expanded. + const renderNode = useCallback((node: FolderTreeChild, depth: number) => { + const nodeUrl = ensureTrailingSlash(node.url); + const isExpanded = expandedUrls.has(nodeUrl); + const isLoading = loadingUrls.has(nodeUrl); + const children = childrenByUrl[nodeUrl] || []; + const hasError = Boolean(errorByUrl[nodeUrl]); + const isCurrent = normalizedCurrentFolderUrl != null && folderUrlsEqual(normalizedCurrentFolderUrl, nodeUrl); + + return ( +
  • +
    + + + +
    + + {isExpanded && ( +
      + {isLoading && ( +
    • + Loading... +
    • + )} + + {!isLoading && hasError && ( +
    • + Failed to load folders +
    • + )} + + {!isLoading && !hasError && children.map((child) => renderNode(child, depth + 1))} +
    + )} +
  • + ) + }, [ + childrenByUrl, + errorByUrl, + expandedUrls, + loadingUrls, + normalizedCurrentFolderUrl, + onNavigate, + toggleExpand, + ], + ); + + // Turn storage roots into the same shape used by child folder nodes. + const rootNodes: FolderTreeChild[] = useMemo( + () => + storages.map((storage) => ({ + url: ensureTrailingSlash(storage.url), + name: storage.name || storage.url, + })), [storages], + ); + + if (rootNodes.length === 0) { + return

    No storages found

    + } + + return ( +
      + {rootNodes.map((node) => renderNode(node, 0))} +
    + ); +} diff --git a/app/components/Header.tsx b/app/components/Header.tsx index dcd219b..ef5ecb3 100644 --- a/app/components/Header.tsx +++ b/app/components/Header.tsx @@ -2,7 +2,6 @@ import { useState } from "react"; import Image from "next/image"; -import Button from "./shared/Button"; import Input from "./shared/Input"; import ProfileIcon from "./ProfileIcon"; import { @@ -15,7 +14,7 @@ interface HeaderProps { sidebarOpen?: boolean; } -export default function Header({ onMenuClick, sidebarOpen = false }: HeaderProps) { +export default function Header({ onMenuClick }: HeaderProps) { const [searchQuery, setSearchQuery] = useState(""); return ( diff --git a/app/components/MoveDialog.tsx b/app/components/MoveDialog.tsx index 672ab20..6229df4 100644 --- a/app/components/MoveDialog.tsx +++ b/app/components/MoveDialog.tsx @@ -6,9 +6,9 @@ import Button from "./shared/Button"; import { getSolidDataset, getContainedResourceUrlAll, UrlString } from "@inrupt/solid-client"; import toast from "react-hot-toast"; import { FileItemData } from "./FileItem"; -import { - moveFileResource, - getAuthenticatedSession, +import { + moveFileResource, + getAuthenticatedSession, decodeResourceNameFromUrl, ensureTrailingSlash, } from "../lib/helpers"; @@ -21,7 +21,7 @@ interface MoveDialogProps { file: FileItemData | null; availableFolders: FileItemData[]; currentLocationUrl: string; - onMoved?: () => void; + onMoved?: (destinationUrl: string) => void; } export default function MoveDialog({ @@ -57,7 +57,7 @@ export default function MoveDialog({ if (resourceUrl.endsWith("/")) { // It's a folder const folderName = decodeResourceNameFromUrl(resourceUrl); - + folders.push({ id: resourceUrl, name: folderName, @@ -127,13 +127,13 @@ export default function MoveDialog({ try { const { fetch: fetchFn } = getAuthenticatedSession(); await moveFileResource(file, selectedFolderUrl, fetchFn); - + toast.success(`Moved "${file.name}"`); onClose(); - + // Notify parent to refresh if (onMoved) { - onMoved(); + onMoved(selectedFolderUrl); } } catch (error) { console.error("Failed to move file:", error); @@ -195,16 +195,16 @@ export default function MoveDialog({
    - {currentLocationUrl - ? availableFolders.find(f => f.url === currentLocationUrl)?.name || - (() => { - try { - const url = new URL(currentLocationUrl); - return url.pathname.split("/").filter(Boolean).pop() || currentLocationUrl; - } catch { - return currentLocationUrl; - } - })() + {currentLocationUrl + ? availableFolders.find(f => f.url === currentLocationUrl)?.name || + (() => { + try { + const url = new URL(currentLocationUrl); + return url.pathname.split("/").filter(Boolean).pop() || currentLocationUrl; + } catch { + return currentLocationUrl; + } + })() : "My Storages"}
    @@ -230,11 +230,10 @@ export default function MoveDialog({ key={folder.id} type="button" onClick={() => setSelectedFolderUrl(folder.url)} - className={`w-full flex items-center gap-3 px-4 py-3 text-left hover:bg-gray-50 transition-colors ${ - selectedFolderUrl === folder.url + className={`w-full flex items-center gap-3 px-4 py-3 text-left hover:bg-gray-50 transition-colors ${selectedFolderUrl === folder.url ? "bg-[#F3EDFF] border-l-4 border-[#7B42F6]" : "border-l-4 border-transparent" - }`} + }`} > {folder.name} diff --git a/app/components/NewMenuButton.tsx b/app/components/NewMenuButton.tsx index 7aad943..57cd528 100644 --- a/app/components/NewMenuButton.tsx +++ b/app/components/NewMenuButton.tsx @@ -13,7 +13,6 @@ interface NewMenuButtonProps { } export default function NewMenuButton({ - currentContainerUrl, onNewFolderClick, onFileUploadClick, onFolderUploadClick, diff --git a/app/components/PreviewModal.tsx b/app/components/PreviewModal.tsx index 84596ad..0e8ebfb 100644 --- a/app/components/PreviewModal.tsx +++ b/app/components/PreviewModal.tsx @@ -243,6 +243,8 @@ export default function PreviewModal({ } return (
    + {/* Solid preview URLs are authenticated/cross-origin; next/image is not suitable here */} + {/* eslint-disable-next-line @next/next/no-img-element */} {file.name} { try { await logout(); + clearContainerCache(); // Redirect to login page after logout window.location.href = "/login"; } catch (error) { @@ -79,6 +81,8 @@ export default function ProfileIcon() { aria-expanded={showDropdown} > {profile?.photoUrl ? ( + // Solid profile photos are often cross-origin; next/image is not suitable here + // eslint-disable-next-line @next/next/no-img-element {profile.name { - if (!resourceUrl) return ""; - try { - return new URL(resourceUrl).origin; - } catch { - return ""; - } - }; - - const serverOrigin = getServerOrigin(); - return ( void; - activeTab?: string; currentContainerUrl?: string | null; + storages?: SolidStorage[]; + onFolderNavigate?: (folderUrl: string) => void; onNewFolderClick?: () => void; onFileUploadClick?: () => void; onFolderUploadClick?: () => void; @@ -20,8 +23,9 @@ interface SidebarProps { export default function Sidebar({ isOpen = true, onClose, - activeTab = "my-storages", currentContainerUrl, + storages, + onFolderNavigate, onNewFolderClick, onFileUploadClick, onFolderUploadClick, @@ -40,9 +44,6 @@ export default function Sidebar({ refs: [sidebarRef], }); - const navigationTabs = [ - { id: "my-storages", label: "My Storages" }, - ]; return ( <> @@ -58,9 +59,8 @@ export default function Sidebar({ {/* Sidebar */}