IKHSAN
ENID Baca dalam Bahasa Indonesia

homeio

Public

Forked from doctor-io/homeio

Branch
main
Latest commit
958d74616 October 2026

file-manager-sidebar.tsx

Open raw
"use client";

import { Cloud, HardDrive, Plus } from "@/components/icons/platform-icons";
import { OsIcon } from "@/components/icons/OsIcon";
import { DEVICE_ICONS, FILE_SIDEBAR_ICONS } from "@/components/icons/icon-assets";
import { cn } from "@/lib/utils";
import { useCurrentUser } from "@/hooks/useCurrentUser";
import { normalizePathForBackend } from "@/modules/files/components/file-manager-presenters";
import { FILES_MENU_SHELL, FILES_PANEL_SHELL } from "@/modules/files/components/file-manager-surface";
import { Users, Trash2, EjectIcon } from "@/components/icons/platform-icons";
import { useRef, useState, useEffect, type ReactNode } from "react";
import { FEATURE_FLAGS } from "@/lib/shared/feature-flags";

export type FileManagerSidebarItem = {
  name: string;
  icon: ReactNode;
  path: string[];
};

export type RemovableSidebarItem = FileManagerSidebarItem & {
  driveId: string;
  isMounted: boolean;
};

export type FileManagerSidebarSection = {
  title: string;
  items: FileManagerSidebarItem[];
};

export type CloudSidebarItem = FileManagerSidebarItem & { accountEmail: string };

type SidebarProps = {
  currentPath: string[];
  isSharedView: boolean;
  isTrashView: boolean;
  locationItems: FileManagerSidebarItem[];
  cloudItems: CloudSidebarItem[];
  removableItems: RemovableSidebarItem[];
  sidebarSections: FileManagerSidebarSection[];
  storageUsagePercent: number;
  storageUsageText: string;
  onNavigateToPath: (path: string[]) => void;
  onOpenNetworkDialog: () => void;
  onOpenGoogleDriveDialog: () => void;
  onOpenUsbDialog: () => void;
  onMountDrive: (driveId: string) => void;
  onEjectDrive: (driveId: string) => void;
};

function isPathActive(itemPath: string[], currentPath: string[]): boolean {
  return JSON.stringify(normalizePathForBackend(itemPath)) === JSON.stringify(currentPath);
}

export function FileManagerSidebar({
  currentPath,
  isSharedView,
  isTrashView,
  locationItems,
  cloudItems,
  removableItems,
  sidebarSections,
  storageUsagePercent,
  storageUsageText,
  onNavigateToPath,
  onOpenNetworkDialog,
  onOpenGoogleDriveDialog,
  onOpenUsbDialog,
  onMountDrive,
  onEjectDrive,
}: SidebarProps) {
  const currentUserQuery = useCurrentUser();
  const isDemoMode = currentUserQuery.data?.isDemoMode ?? false;
  const [addMenuOpen, setAddMenuOpen] = useState(false);
  const addMenuRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (!addMenuOpen) return;
    function handleClickOutside(e: MouseEvent) {
      if (addMenuRef.current && !addMenuRef.current.contains(e.target as Node)) {
        setAddMenuOpen(false);
      }
    }
    document.addEventListener("mousedown", handleClickOutside);
    return () => document.removeEventListener("mousedown", handleClickOutside);
  }, [addMenuOpen]);

  return (
    <aside className={cn("m-2 flex w-60 shrink-0 flex-col", FILES_PANEL_SHELL)}>
      <div className="flex-1 overflow-y-auto px-3 py-3.5">
        {sidebarSections.map((section) => {
          const isCloud = section.title === "Cloud";
          const items =
            section.title === "Locations" ? locationItems : isCloud ? cloudItems : section.items;
          if (isCloud && cloudItems.length === 0) return null;
          return (
            <div key={section.title} className="mb-3">
              <div className="mb-1.5 flex items-center justify-between px-3">
                <span className="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground/65">
                  {section.title}
                </span>
                {section.title === "Locations" && !isDemoMode && (
                  <div ref={addMenuRef} className="relative">
                    <button
                      onClick={() => setAddMenuOpen((v) => !v)}
                      title="Add location"
                      aria-label="Add location"
                      className="flex h-5 w-5 items-center justify-center rounded-full text-muted-foreground/50 transition-colors hover:bg-background/60 hover:text-foreground"
                    >
                      <Plus className="size-3" />
                    </button>
                    {addMenuOpen && (
                      <div className={cn("absolute right-0 top-full z-50 mt-1.5 w-44 overflow-hidden py-1", FILES_MENU_SHELL)}>
                        <button
                          onClick={() => { setAddMenuOpen(false); onOpenNetworkDialog(); }}
                          className="flex w-full items-center gap-3 px-3 py-2 text-[13px] text-muted-foreground transition-colors hover:bg-background/50 hover:text-foreground"
                        >
                          <OsIcon
                            src={FILE_SIDEBAR_ICONS.networkStorage}
                            className="size-4 shrink-0 object-contain"
                            fallback={<HardDrive className="size-3.5 shrink-0 text-muted-foreground/60" />}
                          />
                          Network Storage
                        </button>
                        <button
                          onClick={() => { setAddMenuOpen(false); onOpenUsbDialog(); }}
                          className="flex w-full items-center gap-3 px-3 py-2 text-[13px] text-muted-foreground transition-colors hover:bg-background/50 hover:text-foreground"
                        >
                          <OsIcon
                            src={DEVICE_ICONS.usb}
                            className="size-4 shrink-0 object-contain"
                            fallback={<HardDrive className="size-3.5 shrink-0 text-amber-400/70" />}
                          />
                          USB Drives
                        </button>
                        {FEATURE_FLAGS.GOOGLE_DRIVE && (
                          <button
                            onClick={() => { setAddMenuOpen(false); onOpenGoogleDriveDialog(); }}
                            className="flex w-full items-center gap-3 px-3 py-2 text-[13px] text-muted-foreground transition-colors hover:bg-background/50 hover:text-foreground"
                          >
                            <OsIcon
                              src={FILE_SIDEBAR_ICONS.googleDrive}
                              className="size-4 shrink-0 object-contain"
                              fallback={<Cloud className="size-3.5 shrink-0 text-sky-400/70" />}
                            />
                            Google Drive
                          </button>
                        )}
                      </div>
                    )}
                  </div>
                )}
              </div>

              <div className="flex flex-col gap-0.5">
                {items.map((item) => {
                  const isActive = isPathActive(item.path, currentPath);
                  return (
                    <button
                      key={item.name}
                      onClick={() => onNavigateToPath(item.path)}
                      className={cn(
                        "flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm transition-colors",
                        isActive
                          ? "bg-primary/15 text-foreground"
                          : "text-muted-foreground hover:bg-background/50 hover:text-foreground",
                      )}
                    >
                      <span className={cn("size-5 shrink-0", isActive ? "text-primary" : "text-muted-foreground/70")}>
                        {item.icon}
                      </span>
                      <span className="flex-1 truncate text-left text-[14px] font-medium leading-5">{item.name}</span>
                    </button>
                  );
                })}

                {section.title === "Locations" && removableItems.length > 0 && (
                  <>
                    <div className="mb-1 mt-2.5 px-3 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground/55">
                      Removable
                    </div>
                    {removableItems.map((item) => {
                      const isActive = item.isMounted && isPathActive(item.path, currentPath);
                      return (
                        <div key={item.driveId} className="flex items-center gap-0.5">
                          <button
                            onClick={() => item.isMounted ? onNavigateToPath(item.path) : onMountDrive(item.driveId)}
                            className={cn(
                              "flex flex-1 items-center gap-3 rounded-lg px-3 py-2.5 text-sm transition-colors",
                              isActive
                                ? "bg-primary/15 text-foreground"
                                : item.isMounted
                                  ? "text-muted-foreground hover:bg-background/50 hover:text-foreground"
                                  : "text-muted-foreground/40 hover:text-muted-foreground",
                            )}
                          >
                            <span className={cn("size-5 shrink-0", isActive ? "text-primary" : "text-muted-foreground/55")}>
                              {item.icon}
                            </span>
                            <span className="flex-1 truncate text-left text-[14px] font-medium leading-5">{item.name}</span>
                            {!item.isMounted && (
                              <span className="ml-auto shrink-0 text-[10px] text-muted-foreground/40">mount</span>
                            )}
                          </button>
                          {item.isMounted && (
                            <button
                              onClick={() => onEjectDrive(item.driveId)}
                              title="Eject"
                              className="rounded-md p-1 text-muted-foreground/40 transition-colors hover:bg-background/50 hover:text-foreground"
                            >
                              <EjectIcon className="size-3.5" />
                            </button>
                          )}
                        </div>
                      );
                    })}
                  </>
                )}
              </div>
            </div>
          );
        })}
      </div>

      <div className="shrink-0 border-t border-glass-border/60 px-4 py-3.5">
        <div className="mb-3 flex flex-col gap-0.5">
          <button
            onClick={() => onNavigateToPath(["Shared"])}
            className={cn(
              "flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm transition-colors",
              isSharedView
                ? "bg-primary/15 text-foreground"
                : "text-muted-foreground hover:bg-background/50 hover:text-foreground",
            )}
          >
            <OsIcon
              src={FILE_SIDEBAR_ICONS.shared}
              className="size-5 shrink-0 object-contain"
              fallback={<Users className={cn("size-4 shrink-0", isSharedView ? "text-primary" : "text-sky-400/70")} />}
            />
            <span className="text-[14px] font-medium leading-5">Shared</span>
          </button>
          <button
            onClick={() => onNavigateToPath(["Trash"])}
            className={cn(
              "flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm transition-colors",
              isTrashView
                ? "bg-primary/15 text-foreground"
                : "text-muted-foreground hover:bg-background/50 hover:text-foreground",
            )}
          >
            <OsIcon
              src={FILE_SIDEBAR_ICONS.trash}
              className="size-5 shrink-0 object-contain"
              fallback={<Trash2 className={cn("size-4 shrink-0", isTrashView ? "text-primary" : "text-status-red/60")} />}
            />
            <span className="text-[14px] font-medium leading-5">Trash</span>
          </button>
        </div>

        <div className="mb-1.5 flex items-center justify-between">
          <span className="text-[12px] font-medium text-muted-foreground/75">Storage</span>
          <span className="text-[12px] text-muted-foreground/60">{storageUsageText}</span>
        </div>
        <div className="h-1 overflow-hidden rounded-full bg-background/65">
          <div
            className="h-full rounded-full bg-primary/70 transition-all duration-300"
            style={{ width: `${storageUsagePercent}%` }}
          />
        </div>
      </div>
    </aside>
  );
}
bytes
12,416
lines
274
Type
tsx
Latest commit
16 October 2026