ultisuite-client/components/drive/drive-file-context-menu.tsx
R3D347HR4Y 6ec95262af Add OnlyOffice integration and update project configurations
- Updated .env.example to include configuration for OnlyOffice Document Server.
- Modified the workspace configuration to remove the drive-suite path.
- Adjusted TypeScript environment imports for consistency.
- Enhanced Next.js configuration to disable canvas in Webpack.
- Updated package.json to include new dependencies for OnlyOffice and PDF.js.
- Added global styles for OnlyOffice theme integration in the CSS.
- Created new layout and page components for the Drive feature, including public sharing and editing functionalities.
- Updated metadata handling across various layouts to reflect the new app structure.
2026-06-07 15:49:21 +02:00

417 lines
12 KiB
TypeScript

"use client"
import type { ReactNode } from "react"
import { useCallback, useMemo, useRef, useState } from "react"
import { toast } from "sonner"
import {
ContextMenu,
ContextMenuContent,
ContextMenuTrigger,
} from "@/components/ui/context-menu"
import { Sheet, SheetContent, SheetDescription, SheetTitle } from "@/components/ui/sheet"
import { DriveMoveDialog, type DriveFolderPickerMode } from "@/components/drive/drive-move-dialog"
import { useDriveMutations } from "@/lib/api/hooks/use-drive-queries"
import type { DriveFileInfo } from "@/lib/api/types"
import { displayFileName } from "@/lib/drive/display-file-name"
import { resolveRenameName } from "@/lib/drive/drive-default-name"
import { useLongPress } from "@/hooks/use-long-press"
import { useIsMobile } from "@/hooks/use-mobile"
import { mergeDriveCardRefs, useDriveCardRefRegistrar } from "@/components/drive/drive-card-ref-context"
import { useDriveDragSource } from "@/lib/hooks/use-drive-drag-source"
import { useDriveDropTarget } from "@/lib/hooks/use-drive-drop-target"
import {
guardDriveMenuPointer,
isCardOpenSuppressed,
isFromDriveMenu,
} from "@/lib/drive/drive-menu-guard"
import { useDriveUIStore } from "@/lib/stores/drive-ui-store"
import { DriveNameDialog } from "@/components/drive/drive-name-dialog"
import { DriveFileMenuActions } from "@/components/drive/drive-file-menu-actions"
import { MAIL_MENU_SURFACE_CLASS } from "@/lib/mail-chrome-classes"
import { cn } from "@/lib/utils"
export const DRIVE_MENU_SURFACE_CLASS = cn(
MAIL_MENU_SURFACE_CLASS,
"z-50 min-w-[12rem] p-1"
)
export const DRIVE_CARD_HIGHLIGHT_CLASS = "bg-mail-active"
export const DRIVE_DROP_TARGET_CLASS =
"ring-2 ring-[#1a73e8] ring-inset bg-[#e8f0fe] dark:bg-primary/20"
const MOBILE_TAP_SLOP_PX = 10
export function DriveFileContextMenu({
file,
allItems,
isTrash,
allowShare = true,
writable = true,
hideFavorite = false,
disableDnd = false,
mutations: mutationsProp,
onDownloadRequest,
onOpen,
children,
className,
variant = "default",
registerRef,
onItemClick,
onContextMenuActiveChange,
}: {
file: DriveFileInfo
allItems: DriveFileInfo[]
isTrash?: boolean
allowShare?: boolean
writable?: boolean
hideFavorite?: boolean
disableDnd?: boolean
mutations?: ReturnType<typeof useDriveMutations>
onDownloadRequest?: () => void
onOpen: () => void
children: ReactNode
className?: string
variant?: "default" | "grid"
registerRef?: (el: HTMLDivElement | null) => void
onItemClick?: (file: DriveFileInfo, e: React.MouseEvent) => void
/** Visual highlight while right-click menu is open (grid cards). */
onContextMenuActiveChange?: (active: boolean) => void
}) {
const isMobile = useIsMobile()
const isGrid = variant === "grid"
const registerCardRef = useDriveCardRefRegistrar()
const dndEnabled = !isMobile && !isTrash && !disableDnd
const isFolder = file.type === "directory"
const { dragProps } = useDriveDragSource({ file, allItems, disabled: !dndEnabled })
const { dropProps, canDrop, isOver } = useDriveDropTarget({
folderPath: file.path,
disabled: !dndEnabled || !isFolder,
})
const [sheetOpen, setSheetOpen] = useState(false)
const [renameOpen, setRenameOpen] = useState(false)
const [contextMenuOpen, setContextMenuOpen] = useState(false)
const [folderPickerMode, setFolderPickerMode] = useState<DriveFolderPickerMode | null>(null)
const selectedPaths = useDriveUIStore((s) => s.selectedPaths)
const selectionMode = useDriveUIStore((s) => s.selectionMode)
const toggleSelect = useDriveUIStore((s) => s.toggleSelect)
const enterSelectionMode = useDriveUIStore((s) => s.enterSelectionMode)
const setSharePath = useDriveUIStore((s) => s.setSharePath)
const mutationsDefault = useDriveMutations()
const mutations = mutationsProp ?? mutationsDefault
const openRenameDialog = useCallback(() => {
guardDriveMenuPointer()
window.setTimeout(() => setRenameOpen(true), 0)
}, [])
/** Context menu / long-press sheet: always the file under cursor, never bulk selection. */
const targets = useMemo(() => [file], [file])
const openSheet = useCallback(() => setSheetOpen(true), [])
const longPress = useLongPress(openSheet, { disabled: !isMobile })
const touchStartRef = useRef<{ x: number; y: number } | null>(null)
const touchMovedRef = useRef(false)
const handleMobilePointerDown = useCallback(
(e: React.PointerEvent) => {
touchStartRef.current = { x: e.clientX, y: e.clientY }
touchMovedRef.current = false
longPress.onPointerDown(e)
},
[longPress]
)
const handleMobilePointerMove = useCallback((e: React.PointerEvent) => {
const start = touchStartRef.current
if (!start) return
const dx = e.clientX - start.x
const dy = e.clientY - start.y
if (dx * dx + dy * dy > MOBILE_TAP_SLOP_PX * MOBILE_TAP_SLOP_PX) {
touchMovedRef.current = true
}
}, [])
const handleMobilePointerEnd = useCallback(() => {
touchStartRef.current = null
longPress.onPointerUp()
}, [longPress])
const handleMobileClickCapture = useCallback(
(e: React.MouseEvent) => {
longPress.onClickCapture(e)
if (touchMovedRef.current) {
e.preventDefault()
e.stopPropagation()
touchMovedRef.current = false
}
},
[longPress]
)
const handleRename = async (input: string) => {
const target = targets[0]
if (!target) return
const newName = resolveRenameName(target, input)
if (displayFileName(target.name) === newName) {
return
}
try {
await mutations.rename.mutateAsync({ path: target.path, new_name: newName })
toast.success("Renommé")
} catch {
toast.error("Impossible de renommer")
throw new Error("rename failed")
}
}
const shouldIgnoreCardAction = (target: EventTarget | null) =>
isFromDriveMenu(target) || isCardOpenSuppressed()
const handleMobileTap = (e: React.MouseEvent) => {
if (shouldIgnoreCardAction(e.target)) {
e.preventDefault()
e.stopPropagation()
return
}
e.preventDefault()
e.stopPropagation()
if (selectionMode) {
toggleSelect(file.path, !selectedPaths.has(file.path))
return
}
onOpen()
}
const touchProps = isMobile
? {
onContextMenu: (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
openSheet()
},
onPointerDown: handleMobilePointerDown,
onPointerMove: handleMobilePointerMove,
onPointerUp: handleMobilePointerEnd,
onPointerLeave: handleMobilePointerEnd,
onPointerCancel: handleMobilePointerEnd,
onClickCapture: handleMobileClickCapture,
}
: {}
const handlePointerDownSelect = (e: React.PointerEvent) => {
if (e.pointerType === "mouse" && (e.ctrlKey || e.metaKey)) {
e.preventDefault()
toggleSelect(file.path, !selectedPaths.has(file.path))
}
if (isMobile) {
handleMobilePointerDown(e)
}
}
const handleClickSelect = (e: React.MouseEvent) => {
if (shouldIgnoreCardAction(e.target)) {
e.preventDefault()
e.stopPropagation()
return
}
if (e.ctrlKey || e.metaKey) {
e.preventDefault()
e.stopPropagation()
toggleSelect(file.path, !selectedPaths.has(file.path))
}
}
const handleGridClick = (e: React.MouseEvent) => {
if (shouldIgnoreCardAction(e.target)) {
e.preventDefault()
e.stopPropagation()
return
}
onItemClick?.(file, e)
}
const handleOpenDoubleClick = (e: React.MouseEvent) => {
if (shouldIgnoreCardAction(e.target)) {
e.preventDefault()
e.stopPropagation()
return
}
e.preventDefault()
e.stopPropagation()
onOpen()
}
const handleContextMenuOpenChange = useCallback(
(open: boolean) => {
if (open) {
setContextMenuOpen(true)
onContextMenuActiveChange?.(true)
return
}
setContextMenuOpen(false)
onContextMenuActiveChange?.(false)
guardDriveMenuPointer()
},
[onContextMenuActiveChange]
)
const closeContextMenu = useCallback(() => {
handleContextMenuOpenChange(false)
}, [handleContextMenuOpenChange])
const mergedRegisterRef = mergeDriveCardRefs(file.path, registerRef, registerCardRef)
const isSelected = selectedPaths.has(file.path)
const trigger = (
<div
ref={mergedRegisterRef}
data-drive-card
data-path={file.path}
className={cn(
className,
!isGrid && isMobile && longPress.ackClassName,
isFolder && isOver && canDrop && DRIVE_DROP_TARGET_CLASS,
isSelected && DRIVE_CARD_HIGHLIGHT_CLASS
)}
onClick={
isMobile
? handleMobileTap
: onItemClick
? handleGridClick
: handleClickSelect
}
onDoubleClick={isMobile ? undefined : onItemClick ? handleOpenDoubleClick : undefined}
{...dragProps}
{...dropProps}
{...(!isGrid && isMobile
? {
...touchProps,
onPointerDown: handlePointerDownSelect,
}
: isMobile
? touchProps
: {})}
>
{children}
</div>
)
const renameTarget = targets.length === 1 ? targets[0] : null
const openFolderPicker = (mode: DriveFolderPickerMode) => {
setSheetOpen(false)
window.setTimeout(() => setFolderPickerMode(mode), 0)
}
const menuActionsProps = {
targets,
isTrash,
allowShare,
writable,
hideFavorite,
onOpen,
setSharePath,
mutations,
onRenameRequest: openRenameDialog,
onMoveRequest: isTrash || disableDnd ? undefined : () => openFolderPicker("move"),
onCopyRequest: isTrash || disableDnd ? undefined : () => openFolderPicker("copy"),
onDownloadRequest,
onEnterSelectionMode: isTrash
? undefined
: () => {
enterSelectionMode(file.path)
},
}
const menus = (
<>
<DriveNameDialog
open={renameOpen}
onOpenChange={setRenameOpen}
title="Renommer"
defaultValue={renameTarget ? displayFileName(renameTarget.name) : ""}
confirmLabel="Renommer"
onConfirm={handleRename}
/>
<DriveMoveDialog
open={folderPickerMode !== null}
onOpenChange={(open) => {
if (!open) setFolderPickerMode(null)
}}
mode={folderPickerMode ?? "move"}
sources={targets}
/>
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
<SheetContent
side="bottom"
hideClose
className="gap-0 overflow-hidden rounded-t-2xl border-border px-0 pb-[max(1rem,env(safe-area-inset-bottom))] pt-2"
>
<SheetTitle className="sr-only">
{targets.length > 1
? `${targets.length} éléments`
: displayFileName(file.name)}
</SheetTitle>
<SheetDescription className="sr-only">
{targets.length > 1
? `Actions pour ${targets.length} éléments sélectionnés.`
: `Actions pour ${displayFileName(file.name)}.`}
</SheetDescription>
<p className="truncate px-4 pb-2 text-sm font-medium text-muted-foreground">
{targets.length > 1
? `${targets.length} éléments sélectionnés`
: displayFileName(file.name)}
</p>
<div className="flex flex-col border-t border-border">
<DriveFileMenuActions
variant="sheet"
{...menuActionsProps}
onClose={() => setSheetOpen(false)}
onRenameRequest={() => {
setSheetOpen(false)
openRenameDialog()
}}
/>
</div>
</SheetContent>
</Sheet>
</>
)
if (isMobile) {
return (
<>
{trigger}
{menus}
</>
)
}
const contextMenu = (
<ContextMenu open={contextMenuOpen} onOpenChange={handleContextMenuOpenChange}>
<ContextMenuTrigger asChild>{trigger}</ContextMenuTrigger>
<ContextMenuContent
data-drive-menu-surface
className={cn(DRIVE_MENU_SURFACE_CLASS, "w-52")}
onCloseAutoFocus={(e) => e.preventDefault()}
>
<DriveFileMenuActions
variant="context"
{...menuActionsProps}
onClose={closeContextMenu}
/>
</ContextMenuContent>
</ContextMenu>
)
return (
<>
{contextMenu}
{menus}
</>
)
}