- 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.
790 lines
27 KiB
TypeScript
790 lines
27 KiB
TypeScript
"use client"
|
|
|
|
import { useEffect, useMemo, useState } from "react"
|
|
import { Icon } from "@iconify/react"
|
|
import {
|
|
Building2,
|
|
Copy,
|
|
Eye,
|
|
Link2,
|
|
Loader2,
|
|
Mail,
|
|
Pencil,
|
|
RefreshCw,
|
|
Shield,
|
|
SlidersHorizontal,
|
|
Trash2,
|
|
UserRound,
|
|
Users,
|
|
} from "lucide-react"
|
|
import { toast } from "sonner"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Checkbox } from "@/components/ui/checkbox"
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog"
|
|
import { Input } from "@/components/ui/input"
|
|
import { Label } from "@/components/ui/label"
|
|
import { Textarea } from "@/components/ui/textarea"
|
|
import { useSearchContacts } from "@/lib/api/hooks/use-contact-queries"
|
|
import { useDriveShares, useDriveMutations } from "@/lib/api/hooks/use-drive-queries"
|
|
import type { DriveFileInfo, DriveShare } from "@/lib/api/types"
|
|
import { displayFileName } from "@/lib/drive/display-file-name"
|
|
import {
|
|
FOLDER_SHARE_PERMISSION_OPTIONS,
|
|
folderPermissionsFromRole,
|
|
folderPermissionsToBitmask,
|
|
type FolderSharePermissionId,
|
|
type FolderSharePermissions,
|
|
} from "@/lib/drive/drive-share-permissions"
|
|
import {
|
|
NC_SHARE_TYPE,
|
|
SHARE_SECTION_LABELS,
|
|
groupSharesBySection,
|
|
shareAccessLabel,
|
|
shareLinkForCopy,
|
|
shareMetaLine,
|
|
shareOwnerLabel,
|
|
shareRecipientLabel,
|
|
type DriveShareMode,
|
|
type ShareListSection,
|
|
} from "@/lib/drive/drive-share-types"
|
|
import { DriveFileTypeIcon } from "@/lib/drive/drive-file-icon"
|
|
import { useDriveUIStore } from "@/lib/stores/drive-ui-store"
|
|
import {
|
|
DRIVE_BTN_GHOST,
|
|
DRIVE_BTN_PRIMARY,
|
|
DRIVE_CARD_ACTIVE,
|
|
DRIVE_CARD_IDLE,
|
|
DRIVE_DIALOG_CONTENT,
|
|
DRIVE_DIALOG_DIVIDER,
|
|
DRIVE_DIALOG_FOOTER,
|
|
DRIVE_DIALOG_HEADER,
|
|
DRIVE_DIALOG_OVERLAY,
|
|
DRIVE_FIELD_CLASS,
|
|
DRIVE_LABEL_CLASS,
|
|
DRIVE_PANEL_MUTED,
|
|
DRIVE_TEXT_PRIMARY,
|
|
DRIVE_TEXT_SECONDARY,
|
|
DRIVE_TEXT_TITLE,
|
|
DRIVE_TEXTAREA_CLASS,
|
|
} from "@/lib/drive/drive-dialog-styles"
|
|
import { cn } from "@/lib/utils"
|
|
|
|
function shareItemLabel(path: string) {
|
|
const trimmed = path.replace(/\/+$/, "")
|
|
const base = trimmed.slice(trimmed.lastIndexOf("/") + 1)
|
|
return displayFileName(base || path)
|
|
}
|
|
|
|
type SharePermissionMode = "viewer" | "editor" | "advanced"
|
|
|
|
const PERMISSION_MODE_OPTIONS: {
|
|
id: SharePermissionMode
|
|
label: string
|
|
description: string
|
|
icon: typeof Eye
|
|
folderOnly?: boolean
|
|
}[] = [
|
|
{
|
|
id: "viewer",
|
|
label: "Lecteur",
|
|
description: "Consultation uniquement",
|
|
icon: Eye,
|
|
},
|
|
{
|
|
id: "editor",
|
|
label: "Éditeur",
|
|
description: "Peut modifier le contenu",
|
|
icon: Pencil,
|
|
},
|
|
{
|
|
id: "advanced",
|
|
label: "Avancé",
|
|
description: "Définir chaque autorisation",
|
|
icon: SlidersHorizontal,
|
|
folderOnly: true,
|
|
},
|
|
]
|
|
|
|
const MODE_OPTIONS: {
|
|
id: DriveShareMode
|
|
label: string
|
|
description: string
|
|
icon: typeof Link2
|
|
}[] = [
|
|
{
|
|
id: "contact",
|
|
label: "Personne",
|
|
description: "Partage direct par e-mail ou compte",
|
|
icon: UserRound,
|
|
},
|
|
{
|
|
id: "internal",
|
|
label: "Lien interne",
|
|
description: "Réservé aux utilisateurs inscrits connectés",
|
|
icon: Users,
|
|
},
|
|
{
|
|
id: "public",
|
|
label: "Lien public",
|
|
description: "Accessible à toute personne disposant du lien",
|
|
icon: Link2,
|
|
},
|
|
]
|
|
|
|
function shareSectionIcon(section: ShareListSection) {
|
|
if (section === "people") return UserRound
|
|
if (section === "groups") return Building2
|
|
return Link2
|
|
}
|
|
|
|
function ShareEntryRow({
|
|
share,
|
|
onDelete,
|
|
deleting,
|
|
}: {
|
|
share: DriveShare
|
|
onDelete: () => void
|
|
deleting: boolean
|
|
}) {
|
|
const url = shareLinkForCopy(share)
|
|
const recipient = shareRecipientLabel(share)
|
|
const owner = shareOwnerLabel(share)
|
|
const meta = shareMetaLine(share)
|
|
const accessLabel = shareAccessLabel(share)
|
|
const isLink = share.share_type === NC_SHARE_TYPE.LINK
|
|
|
|
const copy = async () => {
|
|
if (!url) return
|
|
try {
|
|
await navigator.clipboard.writeText(url)
|
|
toast.success("Lien copié")
|
|
} catch {
|
|
toast.error("Impossible de copier le lien")
|
|
}
|
|
}
|
|
|
|
const primaryLine = recipient ?? (url && isLink ? url : accessLabel)
|
|
|
|
return (
|
|
<div className={cn(DRIVE_PANEL_MUTED, "rounded-xl px-3 py-3")}>
|
|
<div className="flex items-start gap-3">
|
|
<div className="min-w-0 flex-1 space-y-1.5">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<span className="inline-flex items-center rounded-md bg-[#e8f0fe] px-2 py-0.5 text-[11px] font-medium text-[#1967d2] dark:bg-[#1a377a]/50 dark:text-[#8ab4f8]">
|
|
{accessLabel}
|
|
</span>
|
|
{share.has_password ? (
|
|
<span className="inline-flex items-center gap-1 text-[11px] text-[#5f6368] dark:text-[#9aa0a6]">
|
|
<Shield className="h-3 w-3" aria-hidden />
|
|
Mot de passe
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
|
|
<p className={cn("text-sm font-medium leading-snug", DRIVE_TEXT_PRIMARY)}>
|
|
{primaryLine}
|
|
</p>
|
|
|
|
{url && isLink && recipient ? (
|
|
<p className={cn("truncate text-xs", DRIVE_TEXT_SECONDARY)}>{url}</p>
|
|
) : null}
|
|
|
|
{owner ? (
|
|
<p className={cn("text-xs", DRIVE_TEXT_SECONDARY)}>
|
|
Propriétaire · {owner}
|
|
</p>
|
|
) : null}
|
|
|
|
<p className={cn("text-xs capitalize", DRIVE_TEXT_SECONDARY)}>{meta}</p>
|
|
|
|
{share.note?.trim() ? (
|
|
<p className={cn("border-t pt-1.5 text-xs italic", DRIVE_DIALOG_DIVIDER, DRIVE_TEXT_SECONDARY)}>
|
|
« {share.note.trim()} »
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="flex shrink-0 items-start gap-0.5">
|
|
{url ? (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
className={DRIVE_BTN_GHOST}
|
|
aria-label="Copier le lien"
|
|
onClick={() => void copy()}
|
|
>
|
|
<Copy className="h-4 w-4" />
|
|
</Button>
|
|
) : null}
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
className={cn(DRIVE_TEXT_SECONDARY, "hover:bg-[#fce8e6] hover:text-[#d93025] dark:hover:bg-[#5c2b29]/50")}
|
|
aria-label="Supprimer le partage"
|
|
disabled={deleting}
|
|
onClick={onDelete}
|
|
>
|
|
{deleting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function ActiveSharesPanel({
|
|
shares,
|
|
loading,
|
|
error,
|
|
onRetry,
|
|
deletingShareId,
|
|
onDeleteShare,
|
|
}: {
|
|
shares: DriveShare[]
|
|
loading: boolean
|
|
error: boolean
|
|
onRetry: () => void
|
|
deletingShareId: string | null
|
|
onDeleteShare: (shareId: string) => void
|
|
}) {
|
|
const grouped = useMemo(() => groupSharesBySection(shares), [shares])
|
|
const sectionOrder: ShareListSection[] = ["links", "people", "groups"]
|
|
const hasShares = shares.length > 0
|
|
|
|
return (
|
|
<div className="space-y-3 border-t pt-4">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<p className={cn("text-sm font-medium", DRIVE_TEXT_PRIMARY)}>Accès existants</p>
|
|
{!loading ? (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
className={cn(DRIVE_BTN_GHOST, "h-8 px-2 text-xs")}
|
|
onClick={onRetry}
|
|
>
|
|
<RefreshCw className="h-3.5 w-3.5" aria-hidden />
|
|
Actualiser
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
|
|
{loading ? (
|
|
<div className={cn("flex items-center gap-2 text-sm", DRIVE_TEXT_SECONDARY)}>
|
|
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
|
|
Chargement des partages…
|
|
</div>
|
|
) : error ? (
|
|
<div className={cn(DRIVE_PANEL_MUTED, "space-y-2 rounded-xl px-3 py-3")}>
|
|
<p className={cn("text-sm", DRIVE_TEXT_PRIMARY)}>Impossible de charger les partages existants.</p>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
className={cn(DRIVE_BTN_GHOST, "h-8 px-2 text-xs")}
|
|
onClick={onRetry}
|
|
>
|
|
<RefreshCw className="h-3.5 w-3.5" aria-hidden />
|
|
Réessayer
|
|
</Button>
|
|
</div>
|
|
) : !hasShares ? (
|
|
<p className={cn("text-sm", DRIVE_TEXT_SECONDARY)}>
|
|
Aucun partage actif pour cet élément. Créez un lien ou invitez une personne ci-dessus.
|
|
</p>
|
|
) : (
|
|
<div className="max-h-52 space-y-4 overflow-y-auto pr-0.5">
|
|
{sectionOrder.map((section) => {
|
|
const items = grouped[section]
|
|
if (items.length === 0) return null
|
|
const SectionIcon = shareSectionIcon(section)
|
|
return (
|
|
<div key={section} className="space-y-2">
|
|
<p className={cn("flex items-center gap-1.5 text-xs font-medium uppercase tracking-wide", DRIVE_TEXT_SECONDARY)}>
|
|
<SectionIcon className="h-3.5 w-3.5" aria-hidden />
|
|
{SHARE_SECTION_LABELS[section]}
|
|
<span className="font-normal normal-case tracking-normal">({items.length})</span>
|
|
</p>
|
|
<div className="space-y-2">
|
|
{items.map((share) => (
|
|
<ShareEntryRow
|
|
key={share.id}
|
|
share={share}
|
|
deleting={deletingShareId === share.id}
|
|
onDelete={() => onDeleteShare(share.id)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function SharePermissionsPanel({
|
|
isFolder,
|
|
permissionMode,
|
|
folderPermissions,
|
|
onPermissionModeChange,
|
|
onFolderPermissionChange,
|
|
}: {
|
|
isFolder: boolean
|
|
permissionMode: SharePermissionMode
|
|
folderPermissions: FolderSharePermissions
|
|
onPermissionModeChange: (mode: SharePermissionMode) => void
|
|
onFolderPermissionChange: (id: FolderSharePermissionId, checked: boolean) => void
|
|
}) {
|
|
const advancedPermissionBits = folderPermissionsToBitmask(folderPermissions)
|
|
const modeOptions = PERMISSION_MODE_OPTIONS.filter((option) => isFolder || !option.folderOnly)
|
|
|
|
return (
|
|
<div className="space-y-2.5">
|
|
<div className={cn("grid gap-2", isFolder ? "grid-cols-3" : "grid-cols-2")}>
|
|
{modeOptions.map((option) => {
|
|
const IconComponent = option.icon
|
|
const selected = permissionMode === option.id
|
|
return (
|
|
<button
|
|
key={option.id}
|
|
type="button"
|
|
onClick={() => onPermissionModeChange(option.id)}
|
|
className={cn(
|
|
"flex cursor-pointer flex-col items-start gap-1 rounded-xl border px-3 py-3 text-left transition-colors",
|
|
selected ? DRIVE_CARD_ACTIVE : DRIVE_CARD_IDLE
|
|
)}
|
|
>
|
|
<span className="flex items-center gap-2">
|
|
<IconComponent
|
|
className={cn(
|
|
"h-4 w-4",
|
|
selected ? "text-[#1967d2] dark:text-[#8ab4f8]" : DRIVE_TEXT_SECONDARY
|
|
)}
|
|
aria-hidden
|
|
/>
|
|
<span
|
|
className={cn(
|
|
"text-sm font-medium",
|
|
selected ? "text-[#1967d2] dark:text-[#8ab4f8]" : DRIVE_TEXT_PRIMARY
|
|
)}
|
|
>
|
|
{option.label}
|
|
</span>
|
|
</span>
|
|
<span className={cn("text-xs", DRIVE_TEXT_SECONDARY)}>{option.description}</span>
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
{isFolder && permissionMode === "advanced" ? (
|
|
<div className={cn(DRIVE_PANEL_MUTED, "space-y-2 px-3 py-3")}>
|
|
{FOLDER_SHARE_PERMISSION_OPTIONS.map((option) => {
|
|
const checked = folderPermissions[option.id]
|
|
const checkboxId = `drive-share-perm-${option.id}`
|
|
return (
|
|
<div key={option.id} className="flex items-center gap-3 rounded-lg py-1">
|
|
<Checkbox
|
|
id={checkboxId}
|
|
checked={checked}
|
|
onCheckedChange={(value) =>
|
|
onFolderPermissionChange(option.id, value === true)
|
|
}
|
|
/>
|
|
<Label
|
|
htmlFor={checkboxId}
|
|
className={cn("cursor-pointer text-sm font-normal", DRIVE_TEXT_PRIMARY)}
|
|
>
|
|
{option.label}
|
|
</Label>
|
|
</div>
|
|
)
|
|
})}
|
|
{!folderPermissions.viewContent && folderPermissions.addFiles ? (
|
|
<p className={cn("border-t pt-2 text-xs leading-relaxed", DRIVE_DIALOG_DIVIDER, DRIVE_TEXT_SECONDARY)}>
|
|
Dépôt uniquement : les visiteurs pourront ajouter des fichiers sans voir le contenu
|
|
existant du dossier.
|
|
</p>
|
|
) : null}
|
|
{advancedPermissionBits === 0 ? (
|
|
<p className="text-xs text-[#d93025]" role="alert">
|
|
Sélectionnez au moins une autorisation.
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function ShareDialog() {
|
|
const path = useDriveUIStore((s) => s.sharePath)
|
|
const shareItemType = useDriveUIStore((s) => s.shareItemType)
|
|
const setSharePath = useDriveUIStore((s) => s.setSharePath)
|
|
const [mode, setMode] = useState<DriveShareMode>("public")
|
|
const [permissionMode, setPermissionMode] = useState<SharePermissionMode>("viewer")
|
|
const [folderPermissions, setFolderPermissions] = useState<FolderSharePermissions>(
|
|
() => folderPermissionsFromRole("viewer")
|
|
)
|
|
const [contactEmail, setContactEmail] = useState("")
|
|
const [contactQuery, setContactQuery] = useState("")
|
|
const [contactNote, setContactNote] = useState("")
|
|
const [recipientRegistered, setRecipientRegistered] = useState<boolean | null>(null)
|
|
const [deletingShareId, setDeletingShareId] = useState<string | null>(null)
|
|
|
|
const { data, isLoading: sharesLoading, isError: sharesError, refetch: refetchShares } = useDriveShares(
|
|
path ?? "",
|
|
Boolean(path)
|
|
)
|
|
const { createShare, deleteShare, lookupShareRecipient } = useDriveMutations()
|
|
const lookupRecipientEmail = lookupShareRecipient.mutateAsync
|
|
const { data: contactResults = [] } = useSearchContacts(contactQuery)
|
|
|
|
const itemLabel = useMemo(() => (path ? shareItemLabel(path) : ""), [path])
|
|
const isFolder = shareItemType === "directory"
|
|
const selectedMode = MODE_OPTIONS.find((m) => m.id === mode) ?? MODE_OPTIONS[0]
|
|
|
|
const filePreview = useMemo((): DriveFileInfo | null => {
|
|
if (!path) return null
|
|
return {
|
|
path,
|
|
name: itemLabel,
|
|
type: shareItemType ?? "file",
|
|
size: 0,
|
|
mime_type: "",
|
|
last_modified: "",
|
|
etag: "",
|
|
is_favorite: false,
|
|
}
|
|
}, [path, itemLabel, shareItemType])
|
|
|
|
useEffect(() => {
|
|
if (path) {
|
|
setMode("public")
|
|
setPermissionMode("viewer")
|
|
setFolderPermissions(folderPermissionsFromRole("viewer"))
|
|
setContactEmail("")
|
|
setContactQuery("")
|
|
setContactNote("")
|
|
setRecipientRegistered(null)
|
|
}
|
|
}, [path])
|
|
|
|
useEffect(() => {
|
|
const email = contactEmail.trim().toLowerCase()
|
|
if (mode !== "contact" || !email.includes("@")) {
|
|
setRecipientRegistered(null)
|
|
return
|
|
}
|
|
const timer = window.setTimeout(() => {
|
|
lookupRecipientEmail(email)
|
|
.then((res) => setRecipientRegistered(res.registered))
|
|
.catch(() => setRecipientRegistered(null))
|
|
}, 350)
|
|
return () => window.clearTimeout(timer)
|
|
}, [contactEmail, mode, lookupRecipientEmail])
|
|
|
|
const advancedPermissionBits = folderPermissionsToBitmask(folderPermissions)
|
|
const canCreateShare =
|
|
(mode !== "contact" || contactEmail.trim().includes("@")) &&
|
|
(!isFolder || permissionMode !== "advanced" || advancedPermissionBits > 0)
|
|
|
|
const setFolderPermission = (id: FolderSharePermissionId, checked: boolean) => {
|
|
setFolderPermissions((prev) => ({ ...prev, [id]: checked }))
|
|
}
|
|
|
|
const onPermissionModeChange = (nextMode: SharePermissionMode) => {
|
|
setPermissionMode(nextMode)
|
|
if (nextMode === "advanced") {
|
|
setFolderPermissions(folderPermissionsFromRole(permissionMode === "editor" ? "editor" : "viewer"))
|
|
}
|
|
}
|
|
|
|
const close = () => setSharePath(null, null)
|
|
|
|
const sharePayload = () => {
|
|
const base =
|
|
isFolder && permissionMode === "advanced"
|
|
? { path: path!, permissions: advancedPermissionBits }
|
|
: { path: path!, role: permissionMode === "editor" ? "editor" : "viewer" }
|
|
return base
|
|
}
|
|
|
|
const onShare = async () => {
|
|
if (!path || !canCreateShare) return
|
|
try {
|
|
const payload = sharePayload()
|
|
const share = await createShare.mutateAsync({
|
|
...payload,
|
|
mode,
|
|
...(mode === "contact"
|
|
? {
|
|
share_with: contactEmail.trim().toLowerCase(),
|
|
note: contactNote.trim() || undefined,
|
|
send_mail: true,
|
|
}
|
|
: {}),
|
|
})
|
|
|
|
if (mode === "contact") {
|
|
if (share.access_mode === "user" || share.share_type === NC_SHARE_TYPE.USER) {
|
|
toast.success("Partagé — visible dans « Partagés avec moi » du destinataire")
|
|
} else {
|
|
toast.success("Invitation envoyée par e-mail avec un lien public")
|
|
}
|
|
setContactEmail("")
|
|
setContactNote("")
|
|
setContactQuery("")
|
|
} else {
|
|
const link = shareLinkForCopy(share)
|
|
if (link) {
|
|
await navigator.clipboard.writeText(link)
|
|
toast.success(
|
|
mode === "internal"
|
|
? "Lien interne copié dans le presse-papiers"
|
|
: "Lien public copié dans le presse-papiers"
|
|
)
|
|
} else {
|
|
toast.success("Partage créé")
|
|
}
|
|
}
|
|
void refetchShares()
|
|
} catch {
|
|
toast.error("Partage impossible")
|
|
}
|
|
}
|
|
|
|
const onDeleteShare = async (shareId: string) => {
|
|
setDeletingShareId(shareId)
|
|
try {
|
|
await deleteShare.mutateAsync(shareId)
|
|
toast.success("Partage supprimé")
|
|
void refetchShares()
|
|
} catch {
|
|
toast.error("Suppression impossible")
|
|
} finally {
|
|
setDeletingShareId(null)
|
|
}
|
|
}
|
|
|
|
const existingShares = data?.shares ?? []
|
|
|
|
const actionLabel =
|
|
mode === "contact"
|
|
? "Partager"
|
|
: mode === "internal"
|
|
? "Créer le lien interne"
|
|
: "Créer le lien public"
|
|
|
|
return (
|
|
<Dialog open={Boolean(path)} onOpenChange={(open) => !open && close()}>
|
|
<DialogContent
|
|
overlayClassName={DRIVE_DIALOG_OVERLAY}
|
|
className={cn(DRIVE_DIALOG_CONTENT, "sm:max-w-[520px]")}
|
|
>
|
|
<DialogHeader className={cn(DRIVE_DIALOG_HEADER, "space-y-4")}>
|
|
<div className="flex items-start gap-3 pr-8">
|
|
{filePreview ? (
|
|
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-[#f1f3f4] dark:bg-[#35363a]">
|
|
<DriveFileTypeIcon file={filePreview} size="md" />
|
|
</div>
|
|
) : (
|
|
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-[#e8f0fe] text-[#1967d2] dark:bg-[#1a377a]/50 dark:text-[#8ab4f8]">
|
|
<Icon icon="mdi:link-variant" className="h-5 w-5" aria-hidden />
|
|
</div>
|
|
)}
|
|
<div className="min-w-0 flex-1">
|
|
<DialogTitle className={cn("text-base font-medium", DRIVE_TEXT_TITLE)}>
|
|
{isFolder ? "Partager le dossier" : "Partager le fichier"}
|
|
</DialogTitle>
|
|
<DialogDescription className={cn("mt-1 truncate text-sm", DRIVE_TEXT_SECONDARY)}>
|
|
{itemLabel}
|
|
</DialogDescription>
|
|
</div>
|
|
</div>
|
|
</DialogHeader>
|
|
|
|
<div className="max-h-[min(70vh,560px)] space-y-5 overflow-y-auto px-6 py-5">
|
|
<div className="grid grid-cols-3 gap-2">
|
|
{MODE_OPTIONS.map((option) => {
|
|
const IconComponent = option.icon
|
|
const selected = mode === option.id
|
|
return (
|
|
<button
|
|
key={option.id}
|
|
type="button"
|
|
onClick={() => setMode(option.id)}
|
|
className={cn(
|
|
"flex cursor-pointer flex-col items-start gap-1 rounded-xl border px-2.5 py-2.5 text-left transition-colors",
|
|
selected ? DRIVE_CARD_ACTIVE : DRIVE_CARD_IDLE
|
|
)}
|
|
>
|
|
<span className="flex items-center gap-1.5">
|
|
<IconComponent
|
|
className={cn(
|
|
"h-3.5 w-3.5",
|
|
selected ? "text-[#1967d2] dark:text-[#8ab4f8]" : DRIVE_TEXT_SECONDARY
|
|
)}
|
|
aria-hidden
|
|
/>
|
|
<span
|
|
className={cn(
|
|
"text-xs font-medium",
|
|
selected ? "text-[#1967d2] dark:text-[#8ab4f8]" : DRIVE_TEXT_PRIMARY
|
|
)}
|
|
>
|
|
{option.label}
|
|
</span>
|
|
</span>
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
<div className="space-y-3">
|
|
<div>
|
|
<p className={cn("text-sm font-medium", DRIVE_TEXT_PRIMARY)}>{selectedMode.label}</p>
|
|
<p className={cn("mt-1 text-xs leading-relaxed", DRIVE_TEXT_SECONDARY)}>
|
|
{mode === "public"
|
|
? `Toute personne disposant du lien pourra accéder à ${isFolder ? "ce dossier" : "ce fichier"} selon le rôle choisi.`
|
|
: mode === "internal"
|
|
? `Seuls les utilisateurs inscrits et connectés pourront ouvrir ce lien vers ${isFolder ? "ce dossier" : "ce fichier"}.`
|
|
: "Si le destinataire possède un compte, le fichier apparaît dans ses « Partagés avec moi ». Sinon, il reçoit un e-mail avec un lien public."}
|
|
</p>
|
|
</div>
|
|
|
|
{mode === "contact" ? (
|
|
<div className="space-y-3">
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="drive-share-contact-email" className={DRIVE_LABEL_CLASS}>
|
|
Adresse e-mail
|
|
</Label>
|
|
<Input
|
|
id="drive-share-contact-email"
|
|
type="email"
|
|
value={contactEmail}
|
|
onChange={(e) => {
|
|
setContactEmail(e.target.value)
|
|
setContactQuery(e.target.value)
|
|
}}
|
|
placeholder="nom@exemple.com"
|
|
autoComplete="off"
|
|
className={DRIVE_FIELD_CLASS}
|
|
/>
|
|
{contactQuery.length >= 2 && contactResults.length > 0 ? (
|
|
<div className="max-h-32 overflow-y-auto rounded-lg border border-[#dadce0] bg-[#f8f9fa] dark:border-[#5f6368]/40 dark:bg-[#35363a]">
|
|
{contactResults.slice(0, 6).map((c) =>
|
|
c.email ? (
|
|
<button
|
|
key={c.uid}
|
|
type="button"
|
|
className="flex w-full flex-col items-start gap-0.5 px-3 py-2 text-left hover:bg-[#f1f3f4] dark:hover:bg-[#3c4043]/50"
|
|
onClick={() => {
|
|
setContactEmail(c.email!)
|
|
setContactQuery("")
|
|
}}
|
|
>
|
|
<span className={cn("text-sm font-medium", DRIVE_TEXT_PRIMARY)}>{c.full_name}</span>
|
|
<span className={cn("text-xs", DRIVE_TEXT_SECONDARY)}>{c.email}</span>
|
|
</button>
|
|
) : null
|
|
)}
|
|
</div>
|
|
) : null}
|
|
{recipientRegistered === true ? (
|
|
<p className="flex items-center gap-1.5 text-xs text-[#188038] dark:text-[#81c995]">
|
|
<UserRound className="h-3.5 w-3.5" aria-hidden />
|
|
Compte inscrit — partage direct dans « Partagés avec moi »
|
|
</p>
|
|
) : null}
|
|
{recipientRegistered === false ? (
|
|
<p className={cn("flex items-center gap-1.5 text-xs", DRIVE_TEXT_SECONDARY)}>
|
|
<Mail className="h-3.5 w-3.5" aria-hidden />
|
|
Pas de compte — invitation par e-mail avec lien public
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="drive-share-contact-note" className={DRIVE_LABEL_CLASS}>
|
|
Message (optionnel)
|
|
</Label>
|
|
<Textarea
|
|
id="drive-share-contact-note"
|
|
value={contactNote}
|
|
onChange={(e) => setContactNote(e.target.value)}
|
|
placeholder="Ajouter un message pour le destinataire…"
|
|
rows={2}
|
|
className={DRIVE_TEXTAREA_CLASS}
|
|
/>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
<SharePermissionsPanel
|
|
isFolder={isFolder}
|
|
permissionMode={permissionMode}
|
|
folderPermissions={folderPermissions}
|
|
onPermissionModeChange={onPermissionModeChange}
|
|
onFolderPermissionChange={setFolderPermission}
|
|
/>
|
|
</div>
|
|
|
|
<ActiveSharesPanel
|
|
shares={existingShares}
|
|
loading={sharesLoading}
|
|
error={sharesError}
|
|
onRetry={() => void refetchShares()}
|
|
deletingShareId={deletingShareId}
|
|
onDeleteShare={(shareId) => void onDeleteShare(shareId)}
|
|
/>
|
|
</div>
|
|
|
|
<DialogFooter className={DRIVE_DIALOG_FOOTER}>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
className={DRIVE_BTN_GHOST}
|
|
onClick={close}
|
|
>
|
|
Annuler
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
className={DRIVE_BTN_PRIMARY}
|
|
disabled={createShare.isPending || !canCreateShare}
|
|
onClick={() => void onShare()}
|
|
>
|
|
{createShare.isPending ? (
|
|
<>
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
{mode === "contact" ? "Envoi…" : "Création…"}
|
|
</>
|
|
) : mode === "contact" ? (
|
|
<>
|
|
<Mail className="h-4 w-4" />
|
|
{actionLabel}
|
|
</>
|
|
) : (
|
|
<>
|
|
<Link2 className="h-4 w-4" />
|
|
{actionLabel}
|
|
</>
|
|
)}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|