ultisuite-client/components/drive/file-preview-dialog.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

611 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client"
import dynamic from "next/dynamic"
import { useRouter } from "next/navigation"
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"
import {
ChevronLeft,
ChevronRight,
Copy,
Download,
ExternalLink,
FolderInput,
HardDrive,
Loader2,
Star,
Trash2,
UserPlus,
X,
} from "lucide-react"
import Link from "next/link"
import { toast } from "sonner"
import {
Dialog,
DialogClose,
DialogContent,
DialogTitle,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { DriveMoveDialog, type DriveFolderPickerMode } from "@/components/drive/drive-move-dialog"
import { downloadDriveFile, fetchDrivePreviewBlob } from "@/lib/api/drive-download"
import { fetchPublicShareBlob } from "@/lib/api/public-share"
import { downloadPublicShareFile } from "@/lib/drive/open-public-share-item"
import { useDriveMutations } from "@/lib/api/hooks/use-drive-queries"
import { ApiRequestError } from "@/lib/api/client"
import { displayFileName } from "@/lib/drive/display-file-name"
import {
drivePreviewKind,
isSvgFile,
previewTargetToFileInfo,
type DrivePreviewKind,
} from "@/lib/drive/drive-preview"
import { SvgPreviewViewer } from "@/components/drive/svg-preview-viewer"
import { useDriveUIStore } from "@/lib/stores/drive-ui-store"
import { apiClient } from "@/lib/api/client"
import { MailDriveFolderPicker } from "@/components/mail/mail-drive-folder-picker"
import { useSaveAttachmentToDrive } from "@/lib/api/hooks/use-mail-drive-save"
import {
mailDriveFileHref,
mailDriveFolderHref,
mailDriveFolderLabel,
mailDriveSaveErrorMessage,
mailDriveSaveSuccessMessage,
} from "@/lib/mail/mail-drive"
import { cn } from "@/lib/utils"
const TEXT_PREVIEW_MAX_BYTES = 2 * 1024 * 1024
const PdfPreviewViewer = dynamic(
() =>
import("@/components/drive/pdf-preview-viewer").then((m) => m.PdfPreviewViewer),
{
ssr: false,
loading: () => (
<div className="flex h-full flex-col items-center justify-center gap-2 text-zinc-400">
<Loader2 className="h-10 w-10 animate-spin" aria-hidden />
<span className="text-sm">Ouverture du PDF</span>
</div>
),
},
)
const PREVIEW_ACTION_BTN =
"cursor-pointer text-zinc-300 hover:bg-white/10 hover:text-white disabled:pointer-events-none disabled:opacity-40"
function PreviewActionButton({
label,
onClick,
disabled,
className,
children,
}: {
label: string
onClick: () => void
disabled?: boolean
className?: string
children: ReactNode
}) {
return (
<Button
type="button"
variant="ghost"
size="icon"
className={cn(PREVIEW_ACTION_BTN, className)}
onClick={onClick}
disabled={disabled}
aria-label={label}
>
{children}
</Button>
)
}
function PreviewBody({
kind,
blobUrl,
name,
textContent,
svgMarkup,
onImageError,
}: {
kind: DrivePreviewKind
blobUrl: string
name: string
textContent?: string | null
svgMarkup?: string | null
onImageError?: () => void
}) {
if (kind === "text") {
return (
<pre className="h-full w-full overflow-auto rounded-md bg-zinc-900 p-4 text-left font-mono text-sm leading-relaxed whitespace-pre-wrap break-words text-zinc-100">
{textContent ?? ""}
</pre>
)
}
if (svgMarkup) {
return <SvgPreviewViewer markup={svgMarkup} name={name} />
}
if (kind === "image") {
return (
<img
src={blobUrl}
alt={displayFileName(name)}
className="max-h-full max-w-full object-contain"
onError={onImageError}
/>
)
}
if (kind === "video") {
return (
<video
src={blobUrl}
controls
autoPlay
className="max-h-full max-w-full rounded-md bg-black"
playsInline
>
<track kind="captions" />
</video>
)
}
if (kind === "audio") {
return (
<audio src={blobUrl} controls autoPlay className="w-full max-w-lg">
<track kind="captions" />
</audio>
)
}
return <PdfPreviewViewer key={blobUrl} blobUrl={blobUrl} name={name} />
}
export function FilePreviewDialog() {
const previewFiles = useDriveUIStore((s) => s.previewFiles)
const previewIndex = useDriveUIStore((s) => s.previewIndex)
const previewContext = useDriveUIStore((s) => s.previewContext)
const closePreview = useDriveUIStore((s) => s.closePreview)
const stepPreview = useDriveUIStore((s) => s.stepPreview)
const setSharePath = useDriveUIStore((s) => s.setSharePath)
const updatePreviewFavorite = useDriveUIStore((s) => s.updatePreviewFavorite)
const removePreviewFile = useDriveUIStore((s) => s.removePreviewFile)
const mutations = useDriveMutations()
const [folderPickerMode, setFolderPickerMode] = useState<DriveFolderPickerMode | null>(null)
const [mailSavePickerOpen, setMailSavePickerOpen] = useState(false)
const file = previewIndex >= 0 ? (previewFiles[previewIndex] ?? null) : null
const fileInfo = useMemo(() => (file ? previewTargetToFileInfo(file) : null), [file])
const allowShare = previewContext?.allowShare ?? true
const isTrash = previewContext?.isTrash ?? false
const publicShare = previewContext?.publicShare
const mailSource = previewContext?.mailSource ?? false
const mailMessageId = previewContext?.mailMessageId ?? file?.mailMessageId ?? ""
const router = useRouter()
const saveToDrive = useSaveAttachmentToDrive(mailMessageId)
const isMailAttachment = mailSource && Boolean(file?.mailAttachmentId)
const showWriteActions = !isTrash && !publicShare && !isMailAttachment
const mailDrivePath = isMailAttachment && file?.path.startsWith("/") ? file.path : undefined
const [blobUrl, setBlobUrl] = useState<string | null>(null)
const [textContent, setTextContent] = useState<string | null>(null)
const [svgMarkup, setSvgMarkup] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [imgFailed, setImgFailed] = useState(false)
const blobUrlRef = useRef<string | null>(null)
const kind = file ? drivePreviewKind(file) : null
const isSvg = file ? isSvgFile(file) : false
const hasPrev = previewIndex > 0
const hasNext = previewIndex >= 0 && previewIndex < previewFiles.length - 1
const positionLabel =
previewFiles.length > 1 ? `${previewIndex + 1} / ${previewFiles.length}` : null
const revokeBlobUrl = () => {
if (blobUrlRef.current) {
URL.revokeObjectURL(blobUrlRef.current)
blobUrlRef.current = null
}
setBlobUrl(null)
}
useEffect(() => {
if (!file || !kind) {
revokeBlobUrl()
setLoading(false)
setError(null)
setImgFailed(false)
setTextContent(null)
setSvgMarkup(null)
return
}
let cancelled = false
setLoading(true)
setError(null)
setImgFailed(false)
setTextContent(null)
setSvgMarkup(null)
;(async () => {
try {
const blob = file.mailAttachmentId
? await apiClient.getBlob(`/mail/attachments/${file.mailAttachmentId}`)
: publicShare
? await fetchPublicShareBlob(
publicShare.token,
{ path: file.path, name: file.name, mime_type: file.mime_type },
publicShare.password
)
: await fetchDrivePreviewBlob(file)
if (cancelled) return
if (kind === "text") {
if (blobUrlRef.current) {
URL.revokeObjectURL(blobUrlRef.current)
blobUrlRef.current = null
}
setBlobUrl(null)
setSvgMarkup(null)
if (blob.size > TEXT_PREVIEW_MAX_BYTES) {
setError("Fichier trop volumineux pour laperçu texte. Téléchargez-le.")
return
}
setTextContent(await blob.text())
return
}
if (isSvg) {
if (blobUrlRef.current) {
URL.revokeObjectURL(blobUrlRef.current)
blobUrlRef.current = null
}
setBlobUrl(null)
setSvgMarkup(await blob.text())
return
}
const url = URL.createObjectURL(blob)
const previous = blobUrlRef.current
blobUrlRef.current = url
setBlobUrl(url)
if (previous && previous !== url) {
URL.revokeObjectURL(previous)
}
} catch (err) {
if (!cancelled) {
const msg =
err instanceof ApiRequestError
? err.message
: "Impossible de charger laperçu."
setError(msg)
}
} finally {
if (!cancelled) setLoading(false)
}
})()
return () => {
cancelled = true
}
}, [
file?.path,
file?.mime_type,
file?.mailAttachmentId,
kind,
isSvg,
publicShare?.token,
publicShare?.password,
])
const previewReady =
kind === "text"
? textContent !== null
: isSvg
? svgMarkup !== null
: Boolean(blobUrl)
useEffect(() => {
if (previewFiles.length === 0) return
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "ArrowLeft") {
e.preventDefault()
stepPreview(-1)
} else if (e.key === "ArrowRight") {
e.preventDefault()
stepPreview(1)
}
}
window.addEventListener("keydown", onKeyDown)
return () => window.removeEventListener("keydown", onKeyDown)
}, [previewFiles.length, stepPreview])
useEffect(() => {
return () => revokeBlobUrl()
}, [])
const title = file ? displayFileName(file.name) : ""
const open = Boolean(file && kind)
const onShare = () => {
if (!file) return
setSharePath(file.path, "file")
}
const onToggleFavorite = async () => {
if (!file) return
const next = !file.is_favorite
try {
await mutations.favorite.mutateAsync({ path: file.path, favorite: next })
updatePreviewFavorite(file.path, next)
toast.success(next ? "Ajouté aux favoris" : "Retiré des favoris")
} catch {
toast.error("Impossible de modifier les favoris")
}
}
const onDelete = async () => {
if (!file) return
try {
await mutations.deleteFile.mutateAsync(file.path)
removePreviewFile(file.path)
toast.success("Supprimé")
} catch {
toast.error("Impossible de supprimer")
}
}
const favoriteLabel = file?.is_favorite ? "Retirer des favoris" : "Ajouter aux favoris"
const onMailDownload = () => {
if (!file?.mailAttachmentId) return
void apiClient.getBlob(`/mail/attachments/${file.mailAttachmentId}`).then((blob) => {
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = displayFileName(file.name)
a.click()
URL.revokeObjectURL(url)
})
}
const onMailSaveToDrive = async (folderPath: string) => {
if (!file?.mailAttachmentId || !mailMessageId) return
try {
const drivePath = await saveToDrive.mutateAsync({
attachmentId: file.mailAttachmentId,
folderPath,
})
useDriveUIStore.setState((state) => ({
previewFiles: state.previewFiles.map((f) =>
f.mailAttachmentId === file.mailAttachmentId
? { ...f, path: drivePath }
: f
),
}))
setMailSavePickerOpen(false)
toast.success(mailDriveSaveSuccessMessage(folderPath), {
action: {
label: "Ouvrir le dossier",
onClick: () => router.push(mailDriveFolderHref(folderPath)),
},
})
} catch (err) {
toast.error(mailDriveSaveErrorMessage(err))
}
}
return (
<>
<MailDriveFolderPicker
open={mailSavePickerOpen}
onOpenChange={setMailSavePickerOpen}
title="Enregistrer dans UltiDrive"
description="Choisissez un dossier dans votre Drive."
confirmLabel="Enregistrer ici"
pending={saveToDrive.isPending}
onConfirm={onMailSaveToDrive}
/>
<DriveMoveDialog
open={folderPickerMode !== null}
onOpenChange={(nextOpen) => {
if (!nextOpen) setFolderPickerMode(null)
}}
mode={folderPickerMode ?? "move"}
sources={fileInfo ? [fileInfo] : []}
onMoved={() => {
if (folderPickerMode === "move" && file) removePreviewFile(file.path)
setFolderPickerMode(null)
}}
/>
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen) closePreview()
}}
>
<DialogContent
aria-describedby={undefined}
showCloseButton={false}
overlayClassName="bg-black/90"
className={cn(
"flex h-[min(92dvh,920px)] w-[min(96vw,1280px)] max-w-none flex-col gap-0 overflow-hidden",
"border-0 bg-zinc-950 p-0 text-zinc-100 shadow-2xl sm:max-w-none"
)}
>
<div className="flex h-14 shrink-0 items-center gap-2 border-b border-white/10 px-4">
<DialogTitle className="min-w-0 flex-1 truncate text-left text-base font-medium text-zinc-100">
{title}
</DialogTitle>
{positionLabel ? (
<span className="shrink-0 text-sm tabular-nums text-zinc-400">{positionLabel}</span>
) : null}
{file ? (
<div className="flex shrink-0 items-center gap-0.5">
{showWriteActions && allowShare ? (
<PreviewActionButton label="Partager" onClick={onShare}>
<UserPlus className="h-5 w-5" />
</PreviewActionButton>
) : null}
{showWriteActions ? (
<PreviewActionButton
label={favoriteLabel}
onClick={() => void onToggleFavorite()}
disabled={mutations.favorite.isPending}
className={cn(
file.is_favorite && "text-amber-400 hover:bg-amber-400/10 hover:text-amber-300"
)}
>
<Star
className="h-5 w-5"
fill={file.is_favorite ? "currentColor" : "none"}
/>
</PreviewActionButton>
) : null}
{showWriteActions ? (
<>
<PreviewActionButton
label="Déplacer vers"
onClick={() => setFolderPickerMode("move")}
>
<FolderInput className="h-5 w-5" />
</PreviewActionButton>
<PreviewActionButton
label="Copier vers"
onClick={() => setFolderPickerMode("copy")}
className="max-sm:hidden"
>
<Copy className="h-5 w-5" />
</PreviewActionButton>
</>
) : null}
{isMailAttachment && !mailDrivePath ? (
<PreviewActionButton
label="Enregistrer dans UltiDrive"
onClick={() => setMailSavePickerOpen(true)}
disabled={saveToDrive.isPending}
>
<HardDrive className="h-5 w-5" />
</PreviewActionButton>
) : null}
{isMailAttachment && mailDrivePath ? (
<PreviewActionButton
label={`Emplacement : ${mailDriveFolderLabel(mailDrivePath)}`}
onClick={() => {}}
disabled
className="!w-auto max-w-[min(40vw,16rem)] px-2 opacity-100"
>
<Link
href={mailDriveFileHref(mailDrivePath)}
className="inline-flex min-w-0 items-center gap-1.5 text-zinc-300 hover:text-white"
onClick={(e) => e.stopPropagation()}
>
<ExternalLink className="h-4 w-4 shrink-0" />
<span className="truncate text-xs font-normal">
{mailDriveFolderLabel(mailDrivePath)}
</span>
</Link>
</PreviewActionButton>
) : null}
<PreviewActionButton
label="Télécharger"
onClick={() =>
void (isMailAttachment
? onMailDownload()
: publicShare
? downloadPublicShareFile(
publicShare.token,
{ path: file.path, name: file.name, mime_type: file.mime_type, type: "file", size: 0, last_modified: "", is_favorite: false, is_shared: false },
publicShare.password
)
: downloadDriveFile(file.path, file.name, file.name))
}
>
<Download className="h-5 w-5" />
</PreviewActionButton>
{showWriteActions ? (
<PreviewActionButton
label="Supprimer"
onClick={() => void onDelete()}
disabled={mutations.deleteFile.isPending}
className="hover:bg-red-500/10 hover:text-red-400"
>
<Trash2 className="h-5 w-5" />
</PreviewActionButton>
) : null}
<DialogClose asChild>
<Button
type="button"
variant="ghost"
size="icon"
className={PREVIEW_ACTION_BTN}
aria-label="Fermer"
>
<X className="h-5 w-5" />
</Button>
</DialogClose>
</div>
) : null}
</div>
<div className="relative flex min-h-0 flex-1 overflow-hidden">
{hasPrev ? (
<Button
type="button"
variant="ghost"
size="icon"
className="absolute left-2 top-1/2 z-10 h-10 w-10 -translate-y-1/2 cursor-pointer rounded-full bg-black/40 text-white hover:bg-black/60"
onClick={() => stepPreview(-1)}
aria-label="Fichier précédent"
>
<ChevronLeft className="h-6 w-6" />
</Button>
) : null}
{hasNext ? (
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-2 top-1/2 z-10 h-10 w-10 -translate-y-1/2 cursor-pointer rounded-full bg-black/40 text-white hover:bg-black/60"
onClick={() => stepPreview(1)}
aria-label="Fichier suivant"
>
<ChevronRight className="h-6 w-6" />
</Button>
) : null}
<div
className={cn(
"flex min-h-0 flex-1 overflow-hidden",
kind === "pdf" ? "flex-col p-0" : kind === "text" ? "flex-col p-4" : kind === "audio" ? "flex-col items-center justify-center p-8" : "items-center justify-center p-4"
)}
>
{loading ? (
<div className="flex flex-col items-center gap-2 text-zinc-400">
<Loader2 className="h-10 w-10 animate-spin" aria-hidden />
<span className="text-sm">Chargement</span>
</div>
) : null}
{!loading && error ? (
<p className="text-center text-sm text-zinc-400">{error}</p>
) : null}
{!loading && !error && imgFailed ? (
<p className="text-center text-sm text-zinc-400">
Aperçu non pris en charge par le navigateur (ex. HEIC). Téléchargez le fichier.
</p>
) : null}
{!loading && !error && !imgFailed && kind && file && previewReady ? (
<PreviewBody
kind={kind}
blobUrl={blobUrl ?? ""}
name={file.name}
textContent={textContent}
svgMarkup={svgMarkup}
onImageError={() => setImgFailed(true)}
/>
) : null}
</div>
</div>
</DialogContent>
</Dialog>
</>
)
}