ultisuite-client/components/gmail/compose/compose-bottom-toolbar.tsx
2026-05-25 13:52:40 +02:00

535 lines
17 KiB
TypeScript

"use client"
import { useState, useCallback } from "react"
import { type Editor } from "@tiptap/react"
import {
ChevronDown,
Paperclip,
Link as LinkIcon,
HardDrive,
Image as ImageIcon,
Lock,
PenTool,
MoreVertical,
Trash2,
Type,
Clock,
Send,
} from "lucide-react"
import {
type ComposeState,
useComposeActions,
} from "@/lib/compose-context"
import { cn, getNextLocalWallClockDate } from "@/lib/utils"
import {
MAIL_COMPOSE_BOTTOM_ICON_BTN,
MAIL_COMPOSE_BOTTOM_ICON_BTN_ACTIVE,
MAIL_COMPOSE_MENU_SELECTED_CLASS,
MAIL_COMPOSE_POPOVER_CLASS,
MAIL_COMPOSE_PRIMARY_SEND_BTN,
MAIL_MENU_SURFACE_CLASS,
} from "@/lib/mail-chrome-classes"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { useMailSignaturesStore } from "@/lib/stores/mail-signatures-store"
import { COMPOSE_PORTAL_Z, insertSignatureHtml } from "./compose-shared"
import { ComposeEmojiButton } from "./compose-emoji-picker"
function ComposeLinkButton({
editor,
}: {
editor: Editor | null
}) {
const [open, setOpen] = useState(false)
const [url, setUrl] = useState("")
const [text, setText] = useState("")
if (!editor) return null
const isLinkActive = editor.isActive("link")
const handleToggle = () => {
if (isLinkActive) {
editor.chain().focus().extendMarkRange("link").unsetLink().run()
return
}
setOpen(true)
}
const handleOpen = (isOpen: boolean) => {
if (isOpen) {
const { from, to, empty } = editor.state.selection
if (isLinkActive) {
const attrs = editor.getAttributes("link")
setUrl(attrs.href || "")
const selectedText = editor.state.doc.textBetween(from, to, " ")
setText(selectedText)
} else if (!empty) {
const selectedText = editor.state.doc.textBetween(from, to, " ")
setText(selectedText)
setUrl("")
} else {
setText("")
setUrl("")
}
}
setOpen(isOpen)
}
const handleInsert = () => {
if (!url.trim()) return
const href = url.match(/^https?:\/\//) ? url : `https://${url}`
const { empty } = editor.state.selection
if (empty && !isLinkActive) {
const displayText = text.trim() || href
editor
.chain()
.focus()
.insertContent(`<a href="${href}">${displayText}</a>`)
.run()
} else {
if (text.trim() && text.trim() !== editor.state.doc.textBetween(
editor.state.selection.from,
editor.state.selection.to,
" "
)) {
editor
.chain()
.focus()
.deleteSelection()
.insertContent(`<a href="${href}">${text.trim()}</a>`)
.run()
} else {
editor
.chain()
.focus()
.extendMarkRange("link")
.setLink({ href })
.run()
}
}
setOpen(false)
setUrl("")
setText("")
}
const handleRemoveLink = () => {
editor.chain().focus().extendMarkRange("link").unsetLink().run()
setOpen(false)
setUrl("")
setText("")
}
return (
<Popover open={open} onOpenChange={handleOpen}>
<PopoverTrigger asChild>
<button
type="button"
onClick={(e) => {
if (isLinkActive) {
e.preventDefault()
handleToggle()
}
}}
className={cn(
MAIL_COMPOSE_BOTTOM_ICON_BTN,
isLinkActive && MAIL_COMPOSE_BOTTOM_ICON_BTN_ACTIVE
)}
title={isLinkActive ? "Supprimer le lien" : "Insérer un lien"}
>
<LinkIcon className="h-[18px] w-[18px]" />
</button>
</PopoverTrigger>
<PopoverContent
align="start"
side="top"
className={cn("w-[340px]", MAIL_COMPOSE_POPOVER_CLASS, COMPOSE_PORTAL_Z)}
onOpenAutoFocus={(e) => e.preventDefault()}
>
<div className="flex flex-col gap-2.5">
<div className="text-sm font-medium text-foreground">
{isLinkActive ? "Modifier le lien" : "Insérer un lien"}
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs text-muted-foreground">Texte à afficher</label>
<input
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Texte du lien"
className="h-8 rounded border border-border bg-mail-surface px-2 text-sm text-foreground outline-none focus:border-ring focus:ring-1 focus:ring-ring"
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs text-muted-foreground">URL</label>
<input
type="text"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://example.com"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
handleInsert()
}
}}
className="h-8 rounded border border-border bg-mail-surface px-2 text-sm text-foreground outline-none focus:border-ring focus:ring-1 focus:ring-ring"
autoFocus
/>
</div>
<div className="flex items-center justify-between pt-1">
{isLinkActive ? (
<button
type="button"
onClick={handleRemoveLink}
className="text-sm text-destructive hover:text-destructive/90 transition-colors"
>
Supprimer le lien
</button>
) : (
<span />
)}
<div className="flex gap-2">
<button
type="button"
onClick={() => setOpen(false)}
className="rounded px-3 py-1.5 text-sm text-muted-foreground hover:bg-accent transition-colors"
>
Annuler
</button>
<button
type="button"
onClick={handleInsert}
disabled={!url.trim()}
className={cn("rounded px-3 py-1.5 text-sm font-medium disabled:opacity-50", MAIL_COMPOSE_PRIMARY_SEND_BTN)}
>
{isLinkActive ? "Modifier" : "Insérer"}
</button>
</div>
</div>
</div>
</PopoverContent>
</Popover>
)
}
export function ComposeSignatureButton({
editor,
compose,
}: {
editor: Editor | null
compose: ComposeState
}) {
const signatures = useMailSignaturesStore((s) => s.signatures)
const { updateCompose } = useComposeActions()
const replaceSignature = useCallback(
(sigId: string | null) => {
if (!editor) return
const newHtml = insertSignatureHtml(editor.getHTML(), sigId)
editor.commands.setContent(newHtml)
updateCompose(compose.id, { bodyHtml: newHtml, signatureId: sigId })
},
[editor, compose.id, updateCompose]
)
const toggleAutoInsert = useCallback(() => {
const newVal = !compose.autoInsertSignature
updateCompose(compose.id, { autoInsertSignature: newVal })
if (!newVal) {
replaceSignature(null)
} else {
const sigId = compose.from.defaultSignatureId
if (sigId) replaceSignature(sigId)
}
}, [compose.autoInsertSignature, compose.from.defaultSignatureId, compose.id, updateCompose, replaceSignature])
if (!editor) return null
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<button
type="button"
className={MAIL_COMPOSE_BOTTOM_ICON_BTN}
title="Insérer une signature"
>
<PenTool className="h-[18px] w-[18px]" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
side="top"
className={cn(MAIL_MENU_SURFACE_CLASS, "min-w-[220px]", COMPOSE_PORTAL_Z)}
>
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault()
toggleAutoInsert()
}}
className="gap-2"
>
<span className="flex h-4 w-4 items-center justify-center">
{compose.autoInsertSignature && <span className="text-xs"></span>}
</span>
Insérer automatiquement
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => replaceSignature(null)}
className={cn("gap-2", !compose.signatureId && MAIL_COMPOSE_MENU_SELECTED_CLASS)}
>
<span className="flex h-4 w-4 items-center justify-center">
{!compose.signatureId && <span className="text-xs"></span>}
</span>
Aucune signature
</DropdownMenuItem>
{signatures.map((sig) => (
<DropdownMenuItem
key={sig.id}
onSelect={() => replaceSignature(sig.id)}
className={cn("gap-2", compose.signatureId === sig.id && MAIL_COMPOSE_MENU_SELECTED_CLASS)}
>
<span className="flex h-4 w-4 items-center justify-center">
{compose.signatureId === sig.id && <span className="text-xs"></span>}
</span>
{sig.name}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}
export interface ComposeBottomToolbarProps {
compose: ComposeState
editor: Editor | null
isEditingScheduled: boolean
showFormatting: boolean
sendMenuOpen: boolean
setShowFormatting: (v: boolean | ((prev: boolean) => boolean)) => void
setSendMenuOpen: (v: boolean) => void
handleSend: () => void
saveScheduledEdit: () => void | Promise<void>
sendScheduledFromEditNow: () => void | Promise<void>
applyScheduledPlanAt: (sendAt: Date) => void | Promise<void>
submitScheduledSendAt: (sendAt: Date) => void | Promise<void>
handleClose: () => void
fileInputRef: React.RefObject<HTMLInputElement | null>
imageInputRef: React.RefObject<HTMLInputElement | null>
}
export function ComposeBottomToolbar(props: ComposeBottomToolbarProps) {
const {
compose,
editor,
isEditingScheduled,
showFormatting,
sendMenuOpen,
setShowFormatting,
setSendMenuOpen,
handleSend,
saveScheduledEdit,
sendScheduledFromEditNow,
applyScheduledPlanAt,
submitScheduledSendAt,
handleClose,
fileInputRef,
imageInputRef,
} = props
return (
<div className="flex shrink-0 items-center gap-1 border-t border-border px-2 py-1.5">
{/* Send / save + dropdown */}
<div className="flex items-center">
{isEditingScheduled ? (
<>
<button
type="button"
onClick={() => void saveScheduledEdit()}
className={cn("rounded-l-full px-5 text-sm font-medium", MAIL_COMPOSE_PRIMARY_SEND_BTN)}
>
Enregistrer
</button>
<DropdownMenu modal={false} open={sendMenuOpen} onOpenChange={setSendMenuOpen}>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn("rounded-r-full border-l border-primary-foreground/30 px-1.5", MAIL_COMPOSE_PRIMARY_SEND_BTN)}
>
<ChevronDown className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className={cn("min-w-[220px]", COMPOSE_PORTAL_Z)}>
<DropdownMenuItem
onSelect={() => {
void sendScheduledFromEditNow()
}}
>
<Send className="h-4 w-4 text-muted-foreground" strokeWidth={1.5} />
Envoyer maintenant
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger className="[&>svg:last-child]:text-muted-foreground">
<Clock className="h-4 w-4 text-muted-foreground" strokeWidth={1.5} />
Planifier
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className={cn("min-w-[220px]", COMPOSE_PORTAL_Z)}>
<DropdownMenuItem
onSelect={() => {
void applyScheduledPlanAt(
new Date(Date.now() + 60 * 60 * 1000)
)
}}
>
<Clock className="h-4 w-4 text-muted-foreground" strokeWidth={1.5} />
Envoyer dans une heure
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
void applyScheduledPlanAt(
getNextLocalWallClockDate(9, 0)
)
}}
>
<Clock className="h-4 w-4 text-muted-foreground" strokeWidth={1.5} />
Envoyer à 9h
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuContent>
</DropdownMenu>
</>
) : (
<>
<button
type="button"
onClick={handleSend}
className={cn("rounded-l-full px-5 text-sm font-medium", MAIL_COMPOSE_PRIMARY_SEND_BTN)}
>
Envoyer
</button>
<DropdownMenu modal={false} open={sendMenuOpen} onOpenChange={setSendMenuOpen}>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn("rounded-r-full border-l border-primary-foreground/30 px-1.5", MAIL_COMPOSE_PRIMARY_SEND_BTN)}
>
<ChevronDown className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className={cn("min-w-[220px]", COMPOSE_PORTAL_Z)}>
<DropdownMenuItem
onSelect={() => {
void submitScheduledSendAt(
new Date(Date.now() + 60 * 60 * 1000)
)
}}
>
<Clock className="h-4 w-4 text-muted-foreground" />
Envoyer dans une heure
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
void submitScheduledSendAt(
getNextLocalWallClockDate(9, 0)
)
}}
>
<Clock className="h-4 w-4 text-muted-foreground" />
Envoyer à 9h
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setSendMenuOpen(false)}>
<Clock className="h-4 w-4 text-muted-foreground" />
Programmer l&apos;envoi
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
)}
</div>
{/* Toolbar icons */}
<div className="flex items-center gap-0.5 ml-1">
<button
type="button"
onClick={() => setShowFormatting(!showFormatting)}
className={cn(
MAIL_COMPOSE_BOTTOM_ICON_BTN,
showFormatting && MAIL_COMPOSE_BOTTOM_ICON_BTN_ACTIVE
)}
title="Options de mise en forme"
>
<Type className="h-[18px] w-[18px]" />
</button>
<button
type="button"
className={MAIL_COMPOSE_BOTTOM_ICON_BTN}
title="Joindre des fichiers"
onClick={() => fileInputRef.current?.click()}
>
<Paperclip className="h-[18px] w-[18px]" />
</button>
<ComposeLinkButton editor={editor} />
<ComposeEmojiButton editor={editor} />
<button
type="button"
className={MAIL_COMPOSE_BOTTOM_ICON_BTN}
title="Insérer des fichiers avec Google Drive"
>
<HardDrive className="h-[18px] w-[18px]" />
</button>
<button
type="button"
className={MAIL_COMPOSE_BOTTOM_ICON_BTN}
title="Insérer une photo"
onClick={() => imageInputRef.current?.click()}
>
<ImageIcon className="h-[18px] w-[18px]" />
</button>
<button
type="button"
className={MAIL_COMPOSE_BOTTOM_ICON_BTN}
title="Activer le mode confidentiel"
>
<Lock className="h-[18px] w-[18px]" />
</button>
<ComposeSignatureButton editor={editor} compose={compose} />
<button
type="button"
className={MAIL_COMPOSE_BOTTOM_ICON_BTN}
title="Plus d'options"
>
<MoreVertical className="h-[18px] w-[18px]" />
</button>
</div>
<div className="flex-1" />
<button
type="button"
onClick={handleClose}
className={MAIL_COMPOSE_BOTTOM_ICON_BTN}
title="Supprimer le brouillon"
>
<Trash2 className="h-[18px] w-[18px]" />
</button>
</div>
)
}