- 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.
441 lines
17 KiB
TypeScript
441 lines
17 KiB
TypeScript
"use client"
|
|
|
|
import { useMemo, useState } from "react"
|
|
import Link from "next/link"
|
|
import { useRouter } from "next/navigation"
|
|
import {
|
|
Info,
|
|
HardDrive,
|
|
File,
|
|
FileText,
|
|
Image as ImageIcon,
|
|
ExternalLink,
|
|
} from "lucide-react"
|
|
import { toast } from "sonner"
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipTrigger,
|
|
} from "@/components/ui/tooltip"
|
|
import { cn } from "@/lib/utils"
|
|
import type { EmailAttachment, EmailAttachmentKind } from "@/lib/email-data"
|
|
import {
|
|
attachmentPreviewTooltip,
|
|
resolveAttachmentKind,
|
|
shouldUseAttachmentPillsInPreview,
|
|
} from "@/lib/attachment-display"
|
|
import { MAIL_TOOLTIP_CONTENT_CLASS } from "@/lib/mail-chrome-classes"
|
|
import { MailDriveFolderPicker } from "@/components/mail/mail-drive-folder-picker"
|
|
import { useSaveMessageAttachmentsToDrive } from "@/lib/api/hooks/use-mail-drive-save"
|
|
import {
|
|
mailDriveFileHref,
|
|
mailDriveFolderHref,
|
|
mailDriveFolderLabel,
|
|
mailDriveFolderPathLabel,
|
|
mailDriveSaveErrorMessage,
|
|
mailDriveSaveSuccessMessage,
|
|
} from "@/lib/mail/mail-drive"
|
|
import {
|
|
mailAttachmentPreviewable,
|
|
openMailAttachmentsPreview,
|
|
} from "@/lib/mail/mail-attachment-preview"
|
|
import {
|
|
MailAttachmentPillThumb,
|
|
MailAttachmentThumbnail,
|
|
} from "@/components/gmail/email-view/mail-attachment-thumbnail"
|
|
|
|
function MessageAttachmentCard({
|
|
attachment,
|
|
kind,
|
|
}: {
|
|
attachment: EmailAttachment
|
|
kind: EmailAttachmentKind
|
|
}) {
|
|
return (
|
|
<>
|
|
<MailAttachmentThumbnail attachment={attachment} />
|
|
<div className="flex min-h-[38px] items-center gap-2 border-t border-border bg-muted px-2 py-1.5">
|
|
{kind === "pdf" ? (
|
|
<FileText className="size-4 shrink-0 text-[#d93025]" strokeWidth={1.5} aria-hidden />
|
|
) : kind === "image" ? (
|
|
<ImageIcon className="size-4 shrink-0 text-[#1a73e8]" strokeWidth={1.5} aria-hidden />
|
|
) : (
|
|
<File className="size-4 shrink-0 text-[#5f6368]" strokeWidth={1.5} aria-hidden />
|
|
)}
|
|
<span className="min-w-0 flex-1 truncate text-xs leading-tight text-[#3c4043]">
|
|
{attachment.name}
|
|
</span>
|
|
</div>
|
|
</>
|
|
)
|
|
}
|
|
|
|
function DriveLocationBadge({ folderPath }: { folderPath: string }) {
|
|
const label = mailDriveFolderPathLabel(folderPath)
|
|
return (
|
|
<Link
|
|
href={mailDriveFolderHref(folderPath)}
|
|
className="inline-flex max-w-full min-w-0 items-center gap-1.5 rounded-md py-1 pl-1 pr-2 text-sm text-primary hover:bg-accent"
|
|
title={folderPath}
|
|
>
|
|
<HardDrive className="size-[18px] shrink-0" strokeWidth={1.5} aria-hidden />
|
|
<span className="min-w-0 truncate">{label}</span>
|
|
<ExternalLink className="size-3.5 shrink-0 opacity-70" aria-hidden />
|
|
</Link>
|
|
)
|
|
}
|
|
|
|
export function MessageAttachmentsSection({
|
|
messageId,
|
|
attachments,
|
|
}: {
|
|
messageId: string
|
|
attachments: EmailAttachment[]
|
|
}) {
|
|
const n = attachments.length
|
|
const router = useRouter()
|
|
const [pickerOpen, setPickerOpen] = useState(false)
|
|
const saveAll = useSaveMessageAttachmentsToDrive(messageId)
|
|
|
|
const savedCount = attachments.filter((a) => a.drivePath).length
|
|
const allSaved = n > 0 && savedCount === n
|
|
const noneSaved = savedCount === 0
|
|
const uniqueSaveFolders = useMemo(() => {
|
|
const folders = new Set(
|
|
attachments
|
|
.map((a) => a.drivePath)
|
|
.filter(Boolean)
|
|
.map((p) => {
|
|
const idx = p!.lastIndexOf("/")
|
|
return idx > 0 ? p!.slice(0, idx) : p!
|
|
})
|
|
)
|
|
return [...folders]
|
|
}, [attachments])
|
|
|
|
if (n === 0) return null
|
|
|
|
const summary = n === 1 ? "Une pièce jointe" : `${n} pièces jointes`
|
|
const asPills = shouldUseAttachmentPillsInPreview(attachments)
|
|
|
|
const openPreview = (index: number) => {
|
|
if (!attachments.some((a) => a.id)) {
|
|
toast.message("Pièce jointe non disponible")
|
|
return
|
|
}
|
|
if (!mailAttachmentPreviewable(attachments[index]!)) {
|
|
toast.message("Aperçu non disponible — téléchargez la pièce jointe")
|
|
return
|
|
}
|
|
openMailAttachmentsPreview(messageId, attachments, index)
|
|
}
|
|
|
|
const onSaveAll = async (folderPath: string) => {
|
|
try {
|
|
await saveAll.mutateAsync(folderPath)
|
|
setPickerOpen(false)
|
|
const folderLabel = mailDriveFolderPathLabel(folderPath)
|
|
toast.success(
|
|
n === 1
|
|
? mailDriveSaveSuccessMessage(folderPath)
|
|
: `${n} pièces jointes enregistrées dans ${folderLabel}`,
|
|
{
|
|
action: {
|
|
label: "Ouvrir le dossier",
|
|
onClick: () => router.push(mailDriveFolderHref(folderPath)),
|
|
},
|
|
}
|
|
)
|
|
} catch (err) {
|
|
toast.error(mailDriveSaveErrorMessage(err))
|
|
}
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<MailDriveFolderPicker
|
|
open={pickerOpen}
|
|
onOpenChange={setPickerOpen}
|
|
title={n === 1 ? "Enregistrer dans UltiDrive" : `Enregistrer ${n} pièces jointes`}
|
|
description="Choisissez un dossier dans votre Drive."
|
|
confirmLabel="Enregistrer ici"
|
|
pending={saveAll.isPending}
|
|
onConfirm={onSaveAll}
|
|
/>
|
|
<div className="mt-4 border-t border-border px-4 pb-4 pl-[68px] pt-4 max-sm:pl-4 max-sm:pr-4">
|
|
<div className="mb-3 flex min-w-0 flex-wrap items-center justify-between gap-x-3 gap-y-2">
|
|
<div className="flex min-w-0 max-w-[min(100%,28rem)] items-center gap-1 text-sm text-muted-foreground">
|
|
<span className="min-w-0 truncate">
|
|
{summary}
|
|
<span aria-hidden> · </span>
|
|
<span>Analysé par VirusTotal</span>
|
|
</span>
|
|
<Tooltip delayDuration={400}>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:bg-accent"
|
|
aria-label="Informations sur l'analyse VirusTotal des pièces jointes"
|
|
>
|
|
<Info className="size-4" strokeWidth={1.75} />
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom" className={cn(MAIL_TOOLTIP_CONTENT_CLASS, "max-w-xs text-xs")}>
|
|
VirusTotal analyse les pièces jointes et les compare à une base de signatures pour
|
|
repérer les virus et logiciels malveillants.
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
{allSaved && uniqueSaveFolders.length === 1 ? (
|
|
<DriveLocationBadge folderPath={uniqueSaveFolders[0]!} />
|
|
) : allSaved && uniqueSaveFolders.length > 1 ? (
|
|
<span className="flex shrink-0 items-center gap-2 text-sm text-muted-foreground">
|
|
<HardDrive className="size-[18px] shrink-0 text-primary" strokeWidth={1.5} aria-hidden />
|
|
Enregistré dans UltiDrive ({savedCount}/{n})
|
|
</span>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
className="flex shrink-0 items-center gap-2 rounded-md py-1 pl-1 pr-2 text-sm font-medium text-primary hover:bg-accent disabled:opacity-50"
|
|
aria-label="Ajouter à UltiDrive"
|
|
disabled={saveAll.isPending}
|
|
onClick={() => setPickerOpen(true)}
|
|
>
|
|
<HardDrive className="size-[18px] shrink-0" strokeWidth={1.5} aria-hidden />
|
|
{noneSaved
|
|
? "Ajouter à UltiDrive"
|
|
: `Ajouter le reste à UltiDrive (${n - savedCount})`}
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<div
|
|
className={
|
|
asPills
|
|
? "flex flex-wrap gap-2 pb-1"
|
|
: "flex flex-nowrap gap-3 overflow-x-auto overflow-y-hidden pb-1 [-webkit-overflow-scrolling:touch]"
|
|
}
|
|
role="list"
|
|
aria-label="Pièces jointes"
|
|
>
|
|
{attachments.map((att, index) => {
|
|
const kind = resolveAttachmentKind(att.name, att.kind)
|
|
const tip = attachmentPreviewTooltip(att.name, att.sizeBytes)
|
|
const previewable = mailAttachmentPreviewable(att)
|
|
if (asPills) {
|
|
return (
|
|
<div key={`${att.id ?? att.name}-${index}`} className="shrink-0" role="listitem">
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
type="button"
|
|
onClick={() => openPreview(index)}
|
|
className="inline-flex max-w-[min(100%,320px)] min-w-0 shrink-0 items-center gap-2 rounded-full border border-border bg-muted py-1.5 pl-2.5 pr-3 text-left text-sm text-foreground shadow-sm transition hover:border-border hover:bg-accent hover:shadow focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
|
|
>
|
|
<MailAttachmentPillThumb attachment={att} />
|
|
<span className="min-w-0 truncate font-medium">{att.name}</span>
|
|
{att.drivePath ? (
|
|
<HardDrive className="size-3.5 shrink-0 text-primary" aria-label="Dans UltiDrive" />
|
|
) : null}
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom" className={cn(MAIL_TOOLTIP_CONTENT_CLASS, "max-w-xs whitespace-pre-line text-xs")}>
|
|
{tip}
|
|
{previewable ? "\nCliquer pour prévisualiser" : ""}
|
|
{att.drivePath ? `\n${mailDriveFolderLabel(att.drivePath)}` : ""}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
)
|
|
}
|
|
return (
|
|
<div key={`${att.id ?? att.name}-${index}`} className="shrink-0" role="listitem">
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
type="button"
|
|
onClick={() => openPreview(index)}
|
|
className="flex w-[200px] flex-col overflow-hidden rounded border border-border bg-mail-surface text-left shadow-sm transition hover:border-border hover:shadow-md focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
|
|
>
|
|
<MessageAttachmentCard attachment={att} kind={kind} />
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom" className={cn(MAIL_TOOLTIP_CONTENT_CLASS, "max-w-xs whitespace-pre-line text-xs")}>
|
|
{tip}
|
|
{previewable ? "\nCliquer pour prévisualiser" : ""}
|
|
{att.drivePath ? `\n${mailDriveFolderLabel(att.drivePath)}` : ""}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
</>
|
|
)
|
|
}
|
|
|
|
export type ConversationAttachmentEntry = {
|
|
messageId: string
|
|
senderName: string
|
|
attachments: EmailAttachment[]
|
|
}
|
|
|
|
export function ConversationAttachmentsSection({
|
|
entries,
|
|
}: {
|
|
entries: ConversationAttachmentEntry[]
|
|
}) {
|
|
const flat = useMemo(() => {
|
|
const items: {
|
|
messageId: string
|
|
senderName: string
|
|
attachments: EmailAttachment[]
|
|
index: number
|
|
attachment: EmailAttachment
|
|
}[] = []
|
|
for (const entry of entries) {
|
|
entry.attachments.forEach((attachment, index) => {
|
|
items.push({
|
|
messageId: entry.messageId,
|
|
senderName: entry.senderName,
|
|
attachments: entry.attachments,
|
|
index,
|
|
attachment,
|
|
})
|
|
})
|
|
}
|
|
return items
|
|
}, [entries])
|
|
|
|
const n = flat.length
|
|
if (n === 0) return null
|
|
|
|
const summary =
|
|
n === 1
|
|
? "Une pièce jointe dans cette conversation"
|
|
: `${n} pièces jointes dans cette conversation`
|
|
const asPills = shouldUseAttachmentPillsInPreview(flat.map((item) => item.attachment))
|
|
|
|
const openPreview = (messageId: string, attachments: EmailAttachment[], index: number) => {
|
|
if (!attachments.some((a) => a.id)) {
|
|
toast.message("Pièce jointe non disponible")
|
|
return
|
|
}
|
|
const att = attachments[index]
|
|
if (!att || !mailAttachmentPreviewable(att)) {
|
|
toast.message("Aperçu non disponible — téléchargez la pièce jointe")
|
|
return
|
|
}
|
|
openMailAttachmentsPreview(messageId, attachments, index)
|
|
}
|
|
|
|
return (
|
|
<div className="mt-2 border-t border-border px-4 pb-4 pl-[68px] pt-4 max-sm:pl-4 max-sm:pr-4">
|
|
<div className="mb-3 flex min-w-0 flex-wrap items-center gap-x-3 gap-y-2">
|
|
<div className="flex min-w-0 max-w-[min(100%,28rem)] items-center gap-1 text-sm text-muted-foreground">
|
|
<span className="min-w-0 truncate">
|
|
{summary}
|
|
<span aria-hidden> · </span>
|
|
<span>Analysé par VirusTotal</span>
|
|
</span>
|
|
<Tooltip delayDuration={400}>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:bg-accent"
|
|
aria-label="Informations sur l'analyse VirusTotal des pièces jointes"
|
|
>
|
|
<Info className="size-4" strokeWidth={1.75} />
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom" className={cn(MAIL_TOOLTIP_CONTENT_CLASS, "max-w-xs text-xs")}>
|
|
VirusTotal analyse les pièces jointes et les compare à une base de signatures pour
|
|
repérer les virus et logiciels malveillants.
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
className={
|
|
asPills
|
|
? "flex flex-wrap gap-2 pb-1"
|
|
: "flex flex-nowrap gap-3 overflow-x-auto overflow-y-hidden pb-1 [-webkit-overflow-scrolling:touch]"
|
|
}
|
|
role="list"
|
|
aria-label="Pièces jointes de la conversation"
|
|
>
|
|
{flat.map((item, flatIndex) => {
|
|
const kind = resolveAttachmentKind(item.attachment.name, item.attachment.kind)
|
|
const previewable = mailAttachmentPreviewable(item.attachment)
|
|
const tip = [
|
|
item.senderName,
|
|
attachmentPreviewTooltip(item.attachment.name, item.attachment.sizeBytes),
|
|
previewable ? "Cliquer pour prévisualiser" : "",
|
|
item.attachment.drivePath ? mailDriveFolderLabel(item.attachment.drivePath) : "",
|
|
]
|
|
.filter(Boolean)
|
|
.join("\n")
|
|
if (asPills) {
|
|
return (
|
|
<div
|
|
key={`${item.messageId}-${item.attachment.id ?? item.attachment.name}-${flatIndex}`}
|
|
className="shrink-0"
|
|
role="listitem"
|
|
>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
type="button"
|
|
onClick={() => openPreview(item.messageId, item.attachments, item.index)}
|
|
className="inline-flex max-w-[min(100%,320px)] min-w-0 shrink-0 items-center gap-2 rounded-full border border-border bg-muted py-1.5 pl-2.5 pr-3 text-left text-sm text-foreground shadow-sm transition hover:border-border hover:bg-accent hover:shadow focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
|
|
>
|
|
<MailAttachmentPillThumb attachment={item.attachment} />
|
|
<span className="min-w-0 truncate font-medium">{item.attachment.name}</span>
|
|
{item.attachment.drivePath ? (
|
|
<HardDrive className="size-3.5 shrink-0 text-primary" aria-label="Dans UltiDrive" />
|
|
) : null}
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent
|
|
side="bottom"
|
|
className={cn(MAIL_TOOLTIP_CONTENT_CLASS, "max-w-xs whitespace-pre-line text-xs")}
|
|
>
|
|
{tip}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
)
|
|
}
|
|
return (
|
|
<div
|
|
key={`${item.messageId}-${item.attachment.id ?? item.attachment.name}-${flatIndex}`}
|
|
className="shrink-0"
|
|
role="listitem"
|
|
>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
type="button"
|
|
onClick={() => openPreview(item.messageId, item.attachments, item.index)}
|
|
className="flex w-[200px] flex-col overflow-hidden rounded border border-border bg-mail-surface text-left shadow-sm transition hover:border-border hover:shadow-md focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
|
|
>
|
|
<MessageAttachmentCard attachment={item.attachment} kind={kind} />
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent
|
|
side="bottom"
|
|
className={cn(MAIL_TOOLTIP_CONTENT_CLASS, "max-w-xs whitespace-pre-line text-xs")}
|
|
>
|
|
{tip}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|