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

649 lines
21 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 { resolveDemoDrivePreview } from "@/lib/demo/demo-drive-preview"
import { useIsDemoDrive } from "@/lib/demo/demo-drive-context"
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 isDemoDrive = useIsDemoDrive()
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 [demoPreviewAsImage, setDemoPreviewAsImage] = 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)
setDemoPreviewAsImage(false)
setTextContent(null)
setSvgMarkup(null)
;(async () => {
try {
if (isDemoDrive && !publicShare && !file.mailAttachmentId) {
const resolved = resolveDemoDrivePreview(file, { width: 1600, height: 1200 })
if (!resolved) {
throw new Error("Aperçu indisponible en mode démo.")
}
if (cancelled) return
if (resolved.type === "text") {
if (blobUrlRef.current) {
URL.revokeObjectURL(blobUrlRef.current)
blobUrlRef.current = null
}
setBlobUrl(null)
setSvgMarkup(null)
setTextContent(resolved.content)
return
}
if (resolved.type === "svg") {
if (blobUrlRef.current) {
URL.revokeObjectURL(blobUrlRef.current)
blobUrlRef.current = null
}
setBlobUrl(null)
setSvgMarkup(resolved.markup)
return
}
setTextContent(null)
setSvgMarkup(null)
setBlobUrl(resolved.url)
setDemoPreviewAsImage(true)
return
}
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,
isDemoDrive,
])
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={demoPreviewAsImage ? "image" : kind}
blobUrl={blobUrl ?? ""}
name={file.name}
textContent={textContent}
svgMarkup={svgMarkup}
onImageError={() => setImgFailed(true)}
/>
) : null}
</div>
</div>
</DialogContent>
</Dialog>
</>
)
}