ultisuite-client/components/drive/drive-sidebar-mounts.tsx
R3D347HR4Y ad1370ea7e
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
feat: enhance configuration and add new demo layouts
- Introduced turbopack alias for canvas in next.config.mjs.
- Updated package.json scripts for development and branding tasks.
- Added new dependencies for Tiptap extensions.
- Implemented new demo layouts for agenda, contacts, drive, and mail applications.
- Enhanced globals.css for improved theming and splash screen animations.
- Added OAuth callback handling for drive mounts.
- Updated layout components to integrate new demo shells and improve structure.
2026-06-12 19:10:24 +02:00

267 lines
8.9 KiB
TypeScript

"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { ChevronRight, Link2 } from "lucide-react"
import { Icon } from "@iconify/react"
import { DriveAddMountDialog } from "@/components/drive/drive-add-mount-dialog"
import { DriveFolderIcon } from "@/lib/drive/drive-file-icon"
import {
DRIVE_ICON_BTN,
DRIVE_SIDEBAR_CARET_SLOT_CLASS,
DRIVE_SIDEBAR_ROW_BODY_CLASS,
DRIVE_SIDEBAR_ROW_CLASS,
} from "@/lib/drive/drive-chrome-classes"
import { mailNavRowClass } from "@/lib/mail-chrome-classes"
import { cn } from "@/lib/utils"
import { displayFileName } from "@/lib/drive/display-file-name"
import { driveFolderHref, mountRootKey } from "@/lib/drive/drive-sidebar-tree"
import { useDriveMountList, useDriveMountMutations, useDriveMounts } from "@/lib/api/hooks/use-drive-queries"
import type { DriveMount } from "@/lib/api/types"
import { useIsMobile } from "@/hooks/use-mobile"
import { useDriveUIStore } from "@/lib/stores/drive-ui-store"
import { openDriveMountOAuthPopup, buildDriveMountOAuthRedirectURI } from "@/lib/drive/drive-mount-oauth"
const INDENT_PX = 16
function mountIcon(backendType: string) {
switch (backendType) {
case "googledrive":
case "google":
return "logos:google-drive"
case "dropbox":
return "logos:dropbox"
case "onedrive":
case "microsoft":
return "logos:microsoft-onedrive"
case "webdav":
case "dav":
return "mdi:cloud-sync"
default:
return "mdi:harddisk"
}
}
function MountConnectButton({ mount }: { mount: DriveMount }) {
const { fetchOAuthURL } = useDriveMountMutations()
const [pending, setPending] = useState(false)
if (!mount.needs_oauth && mount.status !== "pending_oauth") {
return null
}
return (
<button
type="button"
className={cn(
"mr-1 flex h-7 shrink-0 cursor-pointer items-center gap-1 rounded-md px-2 text-[11px] text-primary",
DRIVE_ICON_BTN
)}
disabled={pending}
onClick={() => {
void (async () => {
setPending(true)
try {
const { oauth_url: oauthUrl } = await fetchOAuthURL(mount.id, buildDriveMountOAuthRedirectURI())
openDriveMountOAuthPopup(oauthUrl, mount.id)
} finally {
setPending(false)
}
})()
}}
>
<Link2 className="h-3.5 w-3.5" />
{pending ? "…" : "Connecter"}
</button>
)
}
function MountTree({
mount,
active,
pathSegments,
}: {
mount: DriveMount
active: boolean
pathSegments: string[]
}) {
const router = useRouter()
const isMobile = useIsMobile()
const expandedPaths = useDriveUIStore((s) => s.expandedSidebarPaths)
const toggleSidebarPath = useDriveUIStore((s) => s.toggleSidebarPath)
const rootKey = mountRootKey(mount.id)
const isExpanded = expandedPaths.has(rootKey)
const isRootSelected = active && pathSegments.length === 0
const rootHref = driveFolderHref("mount", "/", mount.id)
const mountReady = mount.status === "active"
const list = useDriveMountList(mount.id, "/", 1, isExpanded && mountReady)
const directories = list.data?.files.filter((f) => f.type === "directory") ?? []
return (
<div className="min-w-0">
<div className={cn(DRIVE_SIDEBAR_ROW_CLASS, mailNavRowClass({ isSelected: isRootSelected }))}>
<button
type="button"
aria-label={isExpanded ? "Replier" : "Déplier"}
className={cn(DRIVE_SIDEBAR_CARET_SLOT_CLASS, "cursor-pointer rounded-md", DRIVE_ICON_BTN)}
onClick={() => toggleSidebarPath(rootKey)}
disabled={!mountReady}
>
<ChevronRight className={cn("h-3.5 w-3.5 text-muted-foreground transition-transform", isExpanded && "rotate-90", !mountReady && "opacity-40")} />
</button>
<Link
href={rootHref}
className={cn(DRIVE_SIDEBAR_ROW_BODY_CLASS, "cursor-pointer", !mountReady && "pointer-events-none opacity-70")}
onClick={(event) => {
if (!mountReady) {
event.preventDefault()
return
}
event.preventDefault()
router.push(rootHref)
if (isMobile) useDriveUIStore.getState().setSidebarCollapsed(true)
}}
>
<Icon icon={mountIcon(mount.backend_type)} className="h-4 w-4 shrink-0" aria-hidden />
<span className="truncate">{mount.display_name}</span>
{mount.status === "error" ? (
<span className="ml-auto shrink-0 text-[10px] text-destructive">!</span>
) : null}
</Link>
<MountConnectButton mount={mount} />
</div>
{isExpanded && mountReady
? directories.map((child) => (
<MountFolderNode
key={child.path}
mount={mount}
folderPath={child.path}
depth={1}
active={active}
currentPath={pathSegments.length ? `/${pathSegments.join("/")}` : "/"}
/>
))
: null}
</div>
)
}
function MountFolderNode({
mount,
folderPath,
depth,
active,
currentPath,
}: {
mount: DriveMount
folderPath: string
depth: number
active: boolean
currentPath: string
}) {
const router = useRouter()
const isMobile = useIsMobile()
const expandedPaths = useDriveUIStore((s) => s.expandedSidebarPaths)
const toggleSidebarPath = useDriveUIStore((s) => s.toggleSidebarPath)
const isExpanded = expandedPaths.has(folderPath)
const isSelected = active && currentPath === folderPath
const href = driveFolderHref("mount", folderPath, mount.id)
const list = useDriveMountList(mount.id, folderPath, 1, isExpanded)
const directories = list.data?.files.filter((f) => f.type === "directory") ?? []
return (
<div className="min-w-0">
<div
className={cn(DRIVE_SIDEBAR_ROW_CLASS, mailNavRowClass({ isSelected }))}
style={{ paddingLeft: depth * INDENT_PX }}
>
<button
type="button"
className={cn(
DRIVE_SIDEBAR_CARET_SLOT_CLASS,
"cursor-pointer rounded-md",
directories.length > 0 ? DRIVE_ICON_BTN : "invisible"
)}
onClick={() => toggleSidebarPath(folderPath)}
>
<ChevronRight className={cn("h-3.5 w-3.5 text-muted-foreground transition-transform", isExpanded && "rotate-90")} />
</button>
<Link href={href} className={cn(DRIVE_SIDEBAR_ROW_BODY_CLASS, "cursor-pointer")} onClick={(e) => { e.preventDefault(); router.push(href); if (isMobile) useDriveUIStore.getState().setSidebarCollapsed(true) }}>
<DriveFolderIcon file={{ path: folderPath, name: displayFileName(folderPath.split("/").pop() ?? ""), type: "directory", size: 0, mime_type: "", last_modified: "", etag: "", is_favorite: false }} size="sm" />
<span className="truncate">{displayFileName(folderPath.split("/").pop() ?? folderPath)}</span>
</Link>
</div>
{isExpanded ? directories.map((child) => (
<MountFolderNode key={child.path} mount={mount} folderPath={child.path} depth={depth + 1} active={active} currentPath={currentPath} />
)) : null}
</div>
)
}
export function DriveSidebarMounts({
active,
pathSegments,
rootId,
}: {
active: boolean
pathSegments: string[]
rootId: string | null
}) {
const mounts = useDriveMounts()
const items = mounts.data ?? []
if (items.length === 0) {
return null
}
return (
<>
{items.map((mount) => (
<MountTree
key={mount.id}
mount={mount}
active={active && rootId === mount.id}
pathSegments={rootId === mount.id ? pathSegments : []}
/>
))}
</>
)
}
export function DriveConnectMountAction() {
const [addOpen, setAddOpen] = useState(false)
const { invalidate } = useDriveMountMutations()
useEffect(() => {
const onMessage = (event: MessageEvent) => {
if (event.origin !== window.location.origin) return
if (event.data?.type === "drive-mount-oauth-complete") {
invalidate()
}
}
window.addEventListener("message", onMessage)
return () => window.removeEventListener("message", onMessage)
}, [invalidate])
return (
<>
<button
type="button"
className={cn(
"flex w-full cursor-pointer items-center gap-2 rounded-lg border border-dashed border-border px-3 py-2 text-left text-sm text-muted-foreground transition-colors hover:bg-mail-nav-hover hover:text-foreground"
)}
onClick={() => setAddOpen(true)}
>
<span className="flex shrink-0 items-center gap-0.5" aria-hidden>
<Icon icon="logos:google-drive" className="size-3.5" />
<Icon icon="logos:dropbox" className="size-3.5" />
<Icon icon="logos:microsoft-onedrive" className="size-3.5" />
</span>
<span className="min-w-0 truncate">Monter un volume</span>
</button>
<DriveAddMountDialog open={addOpen} onOpenChange={setAddOpen} />
</>
)
}