From 71ea1a06e70a86afbdc6afcdf81af38ecb801b8c Mon Sep 17 00:00:00 2001 From: Precious Oritsedere Date: Fri, 25 Sep 2026 15:57:48 +0100 Subject: [PATCH 1/5] Add shadcn sidebar component --- components/ui/separator.tsx | 24 ++ components/ui/sidebar.tsx | 726 ++++++++++++++++++++++++++++++++++++ components/ui/skeleton.tsx | 13 + hooks/use-mobile.ts | 19 + package-lock.json | 24 +- 5 files changed, 794 insertions(+), 12 deletions(-) create mode 100644 components/ui/separator.tsx create mode 100644 components/ui/sidebar.tsx create mode 100644 components/ui/skeleton.tsx create mode 100644 hooks/use-mobile.ts diff --git a/components/ui/separator.tsx b/components/ui/separator.tsx new file mode 100644 index 0000000..692f8a9 --- /dev/null +++ b/components/ui/separator.tsx @@ -0,0 +1,24 @@ +"use client" + +import { Separator as SeparatorPrimitive } from "@base-ui/react/separator" +import { cn } from "@/lib/utils" + +function Separator({ + className, + orientation = "horizontal", + ...props +}: SeparatorPrimitive.Props) { + return ( + + ) +} + +export { Separator } diff --git a/components/ui/sidebar.tsx b/components/ui/sidebar.tsx new file mode 100644 index 0000000..96fb457 --- /dev/null +++ b/components/ui/sidebar.tsx @@ -0,0 +1,726 @@ +"use client" + +import * as React from "react" +import { mergeProps } from "@base-ui/react/merge-props" +import { useRender } from "@base-ui/react/use-render" +import { cva, type VariantProps } from "class-variance-authority" +import { cn } from "@/lib/utils" + +import { useIsMobile } from "@/hooks/use-mobile" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Separator } from "@/components/ui/separator" +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { Skeleton } from "@/components/ui/skeleton" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" +import { PanelLeftIcon } from "lucide-react" + +const SIDEBAR_COOKIE_NAME = "sidebar_state" +const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 +const SIDEBAR_WIDTH = "16rem" +const SIDEBAR_WIDTH_MOBILE = "18rem" +const SIDEBAR_WIDTH_ICON = "3rem" +const SIDEBAR_KEYBOARD_SHORTCUT = "b" + +type SidebarContextProps = { + state: "expanded" | "collapsed" + open: boolean + setOpen: (open: boolean) => void + openMobile: boolean + setOpenMobile: (open: boolean) => void + isMobile: boolean + toggleSidebar: () => void +} + +const SidebarContext = React.createContext(null) + +function useSidebar() { + const context = React.useContext(SidebarContext) + if (!context) { + throw new Error("useSidebar must be used within a SidebarProvider.") + } + + return context +} + +function SidebarProvider({ + defaultOpen = true, + open: openProp, + onOpenChange: setOpenProp, + className, + style, + children, + ...props +}: React.ComponentProps<"div"> & { + defaultOpen?: boolean + open?: boolean + onOpenChange?: (open: boolean) => void +}) { + const isMobile = useIsMobile() + const [openMobile, setOpenMobile] = React.useState(false) + + // This is the internal state of the sidebar. + // We use openProp and setOpenProp for control from outside the component. + const [_open, _setOpen] = React.useState(defaultOpen) + const open = openProp ?? _open + const setOpen = React.useCallback( + (value: boolean | ((value: boolean) => boolean)) => { + const openState = typeof value === "function" ? value(open) : value + if (setOpenProp) { + setOpenProp(openState) + } else { + _setOpen(openState) + } + + // This sets the cookie to keep the sidebar state. + document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}` + }, + [setOpenProp, open] + ) + + // Helper to toggle the sidebar. + const toggleSidebar = React.useCallback(() => { + return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open) + }, [isMobile, setOpen, setOpenMobile]) + + // Adds a keyboard shortcut to toggle the sidebar. + React.useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if ( + event.key === SIDEBAR_KEYBOARD_SHORTCUT && + (event.metaKey || event.ctrlKey) + ) { + event.preventDefault() + toggleSidebar() + } + } + + window.addEventListener("keydown", handleKeyDown) + return () => window.removeEventListener("keydown", handleKeyDown) + }, [toggleSidebar]) + + // We add a state so that we can do data-state="expanded" or "collapsed". + // This makes it easier to style the sidebar with Tailwind classes. + const state = open ? "expanded" : "collapsed" + + const contextValue = React.useMemo( + () => ({ + state, + open, + setOpen, + isMobile, + openMobile, + setOpenMobile, + toggleSidebar, + }), + [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar] + ) + + return ( + +
+ {children} +
+
+ ) +} + +function Sidebar({ + side = "left", + variant = "sidebar", + collapsible = "offcanvas", + className, + children, + dir, + ...props +}: React.ComponentProps<"div"> & { + side?: "left" | "right" + variant?: "sidebar" | "floating" | "inset" + collapsible?: "offcanvas" | "icon" | "none" +}) { + const { isMobile, state, openMobile, setOpenMobile } = useSidebar() + + if (collapsible === "none") { + return ( +
+ {children} +
+ ) + } + + if (isMobile) { + return ( + + + + Sidebar + Displays the mobile sidebar. + +
{children}
+
+
+ ) + } + + return ( +
+ {/* This is what handles the sidebar gap on desktop */} +
+ +
+ ) +} + +function SidebarTrigger({ + className, + onClick, + ...props +}: React.ComponentProps) { + const { toggleSidebar } = useSidebar() + + return ( + + ) +} + +function SidebarRail({ className, ...props }: React.ComponentProps<"button">) { + const { toggleSidebar } = useSidebar() + + return ( + -
- )} - -
- - -
+
+ +
+ +
); -} +} \ No newline at end of file diff --git a/app/components/Sidebar.tsx b/app/components/Sidebar.tsx index 561eb45..192254e 100644 --- a/app/components/Sidebar.tsx +++ b/app/components/Sidebar.tsx @@ -6,14 +6,17 @@ import GitHubLinks from "./shared/GitHubLinks"; import FolderTree from "./FolderTree"; import { SolidStorage } from "../lib/hooks/useSolidStorages"; import { - Sheet, - SheetContent, - SheetTitle, -} from "@/components/ui/sheet"; + Sidebar as SidebarPrimitive, + SidebarContent, + SidebarFooter, + SidebarHeader, + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + useSidebar, +} from "@/components/ui/sidebar"; -interface SidebarProps { - isOpen?: boolean; - onClose?: () => void; +interface AppSidebarProps { currentContainerUrl?: string | null; storages?: SolidStorage[]; onFolderNavigate?: (folderUrl: string) => void; @@ -22,93 +25,61 @@ interface SidebarProps { onFolderUploadClick?: () => void; } -function SidebarNav({ +export default function AppSidebar({ currentContainerUrl, storages, onFolderNavigate, onNewFolderClick, onFileUploadClick, onFolderUploadClick, -}: Omit) { - return ( - - ); -} +}: AppSidebarProps) { + const { isMobile, setOpenMobile } = useSidebar(); -export default function Sidebar({ - isOpen = false, - onClose, - currentContainerUrl, - storages, - onFolderNavigate, - onNewFolderClick, - onFileUploadClick, - onFolderUploadClick, -}: SidebarProps) { - const navProps = { - currentContainerUrl, - storages, - onFolderNavigate, - onNewFolderClick, - onFileUploadClick, - onFolderUploadClick, + const handleNavigate = (folderUrl: string) => { + onFolderNavigate?.(folderUrl); + if (isMobile) { + setOpenMobile(false); + } }; return ( - <> - {/* Desktop: always visible */} - + + + Solid File Manager + - {/* Mobile: Sheet drawer */} - { - if (!open) onClose?.(); - }} - > - - Navigation -
- Solid File Manager -
- -
-
- + + + + + My Storages + + {storages && onFolderNavigate ? ( + + ) : null} + + + + + + + +
); -} +} \ No newline at end of file diff --git a/app/components/file-manager/FileManagerContent.tsx b/app/components/file-manager/FileManagerContent.tsx index 8c11e8c..1d11fa5 100644 --- a/app/components/file-manager/FileManagerContent.tsx +++ b/app/components/file-manager/FileManagerContent.tsx @@ -29,6 +29,7 @@ import ContextMenu, { type ContextMenuAction } from "../ContextMenu"; import type { FileItemData } from "../FileItem"; import LoadingSpinner from "../shared/LoadingSpinner"; import ErrorDisplay from "../shared/ErrorDisplay"; +import { SidebarProvider, SidebarInset } from "@/components/ui/sidebar"; import { getAuthenticatedSession, uploadFilesToContainer, @@ -94,7 +95,6 @@ export default function FileManagerContent() { } = useFileManagerDialogs(); // UI-local states - const [sidebarOpen, setSidebarOpen] = useState(false); const [isDragActive, setIsDragActive] = useState(false); const dragCounterRef = useRef(0); const [contextMenuState, setContextMenuState] = useState(null); @@ -392,42 +392,38 @@ export default function FileManagerContent() { const isBrowsing = Boolean(selectedStorageId && isLoadingFiles); return ( -
-
setSidebarOpen(true)} - sidebarOpen={sidebarOpen} + { + navigateToFolder(folderUrl); + }} + onNewFolderClick={() => { + if (!ensureStorageSelected()) return; + dialogHandlers.openNewFolderDialog(); + }} + onFileUploadClick={() => { + if (!ensureStorageSelected()) return; + dialogHandlers.triggerFileUpload(); + }} + onFolderUploadClick={() => { + if (!ensureStorageSelected()) return; + dialogHandlers.triggerFolderUpload(); + }} /> -
- setSidebarOpen(false)} - currentContainerUrl={containerUrlToBrowse} - storages={storages} - onFolderNavigate={(folderUrl) => { - navigateToFolder(folderUrl); - setSidebarOpen(false); - }} - onNewFolderClick={() => { - if (!ensureStorageSelected()) return; - dialogHandlers.openNewFolderDialog(); - }} - onFileUploadClick={() => { - if (!ensureStorageSelected()) return; - dialogHandlers.triggerFileUpload(); - }} - onFolderUploadClick={() => { - if (!ensureStorageSelected()) return; - dialogHandlers.triggerFolderUpload(); - }} - /> + + +
@@ -474,7 +470,8 @@ export default function FileManagerContent() {
)}
-
+ + { invalidateContainers([destinationUrl]); - refresh(); // invalidates + refreshes current (source) folder + refresh(); }} /> )} -
+ ); } From ea0db035a5dec5783fff2542b7a5ae25ceb43c64 Mon Sep 17 00:00:00 2001 From: Precious Oritsedere Date: Fri, 25 Sep 2026 16:45:36 +0100 Subject: [PATCH 3/5] Style folder tree with shadcn SidebarMenu --- app/components/FolderTree.tsx | 78 ++++++++++++++++++----------------- 1 file changed, 41 insertions(+), 37 deletions(-) diff --git a/app/components/FolderTree.tsx b/app/components/FolderTree.tsx index 809c612..2f55c8a 100644 --- a/app/components/FolderTree.tsx +++ b/app/components/FolderTree.tsx @@ -5,6 +5,14 @@ import { ChevronRight, ChevronDown, Folder } from "lucide-react"; import { SolidStorage } from "../lib/hooks/useSolidStorages"; import { FolderTreeChild, folderUrlsEqual, ensureTrailingSlash, getAuthenticatedSession, fetchContainerListing, foldersFromListing } from "../lib/helpers"; import { getContainerListing, loadContainerListing, subscribeContainerCache, getContainerCacheVersion } from "../lib/cache"; +import { + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarMenuSub, + SidebarMenuSubItem, + SidebarMenuSkeleton, +} from "@/components/ui/sidebar"; interface FolderTreeProps { storages: SolidStorage[]; @@ -124,7 +132,7 @@ export default function FolderTree({ }, [expandedUrls, loadChildren]); // Render one folder row and its nested children when expanded. - const renderNode = useCallback((node: FolderTreeChild, depth: number) => { + const renderNode = useCallback((node: FolderTreeChild) => { const nodeUrl = ensureTrailingSlash(node.url); const isExpanded = expandedUrls.has(nodeUrl); const isLoading = loadingUrls.has(nodeUrl); @@ -133,16 +141,12 @@ export default function FolderTree({ const isCurrent = normalizedCurrentFolderUrl != null && folderUrlsEqual(normalizedCurrentFolderUrl, nodeUrl); return ( -
  • -
    + +
    - +
    {isExpanded && ( -
      + {isLoading && ( -
    • - Loading... -
    • + + + )} {!isLoading && hasError && ( -
    • - Failed to load folders -
    • + +

      + Failed to load folders +

      +
      )} - {!isLoading && !hasError && children.map((child) => renderNode(child, depth + 1))} -
    + {!isLoading && + !hasError && + children.map((child) => renderNode(child))} + )} -
  • - ) + + ); }, [ childrenByUrl, errorByUrl, @@ -212,12 +213,15 @@ export default function FolderTree({ ); if (rootNodes.length === 0) { - return

    No storages found

    + return ( +

    + No storages found +

    + ); } - return ( -
      - {rootNodes.map((node) => renderNode(node, 0))} -
    + + {rootNodes.map((node) => renderNode(node))} + ); } From 5d84e29ea06da498170576a7e02b32873f790321 Mon Sep 17 00:00:00 2001 From: Precious Oritsedere Date: Mon, 28 Sep 2026 14:24:05 +0100 Subject: [PATCH 4/5] Fix eslint failures blocking CI lint --- app/components/FolderTree.tsx | 2 ++ app/components/Header.tsx | 2 +- app/components/Sidebar.tsx | 2 +- app/components/file-manager/context/fileManagerContext.ts | 1 - 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/app/components/FolderTree.tsx b/app/components/FolderTree.tsx index 2f55c8a..c2aee94 100644 --- a/app/components/FolderTree.tsx +++ b/app/components/FolderTree.tsx @@ -38,6 +38,8 @@ export default function FolderTree({ // Children come from the shared cache, not a local copy. // cacheVersion makes this recompute when listings are written or invalidated. const childrenByUrl = useMemo(() => { + // cacheVersion is read so listings recompute after cache writes/invalidation. + void cacheVersion; const next: Record = {}; for (const url of expandedUrls) { const cached = getContainerListing(url); diff --git a/app/components/Header.tsx b/app/components/Header.tsx index 522bec9..d76e58e 100644 --- a/app/components/Header.tsx +++ b/app/components/Header.tsx @@ -14,4 +14,4 @@ export default function Header() { ); -} \ No newline at end of file +} diff --git a/app/components/Sidebar.tsx b/app/components/Sidebar.tsx index 192254e..6eb8dd2 100644 --- a/app/components/Sidebar.tsx +++ b/app/components/Sidebar.tsx @@ -82,4 +82,4 @@ export default function AppSidebar({ ); -} \ No newline at end of file +} diff --git a/app/components/file-manager/context/fileManagerContext.ts b/app/components/file-manager/context/fileManagerContext.ts index 963a7ad..3e1632b 100644 --- a/app/components/file-manager/context/fileManagerContext.ts +++ b/app/components/file-manager/context/fileManagerContext.ts @@ -8,7 +8,6 @@ import type { FileAction } from "../types/fileActions"; import type { ShareOperationResult } from "../hooks/useFileOperations"; import type { UseFileDialogsResult } from "../hooks/useFileDialogs"; import type { BreadcrumbItem } from "@/app/lib/helpers"; -import { invalidateContainerListing } from "@/app/lib/cache"; /** Navigation slice */ export interface FileManagerNavigationContextValue { From 48ed0d2011f03d3dda5b05bfab67a77358c23c9d Mon Sep 17 00:00:00 2001 From: Precious Oritsedere Date: Wed, 30 Sep 2026 15:26:42 +0100 Subject: [PATCH 5/5] Hide empty folder sub-menu border in sidebar tree --- app/components/FolderTree.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/components/FolderTree.tsx b/app/components/FolderTree.tsx index c2aee94..3b35d49 100644 --- a/app/components/FolderTree.tsx +++ b/app/components/FolderTree.tsx @@ -171,7 +171,7 @@ export default function FolderTree({ - {isExpanded && ( + {isExpanded && (isLoading || hasError || children.length > 0) && ( {isLoading && (