- 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.
417 lines
12 KiB
TypeScript
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}
|
|
</>
|
|
)
|
|
}
|