ultisuite-client/components/drive/public-share-view.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

355 lines
11 KiB
TypeScript

"use client"
import dynamic from "next/dynamic"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { useEffect, useState, type ReactNode } from "react"
import { ChevronRight, Download, FolderOpen, Loader2, Lock } from "lucide-react"
import { DocsLoadingSplash } from "@/components/drive/richtext/docs-loading-splash"
import { toast } from "sonner"
import { PublicShareFolderView } from "@/components/drive/public-share-folder-view"
import { FilePreviewDialog } from "@/components/drive/file-preview-dialog"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
fetchPublicShareBlob,
publicShareDownloadApiPath,
publicShareHref,
publicShareOwnerLabel,
type PublicShareView,
} from "@/lib/api/public-share"
import type { DriveFileInfo } from "@/lib/api/types"
import { drivePreviewKind, isSvgFile } from "@/lib/drive/drive-preview"
import { SvgPreviewViewer } from "@/components/drive/svg-preview-viewer"
import { PUBLIC_SHARE_INSET_X } from "@/lib/drive/drive-chrome-classes"
import {
shouldOpenInOnlyOffice,
shouldOpenInRichTextEditor,
shouldOpenInUltidrawEditor,
} from "@/lib/drive/drive-preview"
import {
sharePermCanEdit,
} from "@/lib/drive/drive-share-permissions"
import { buildPublicShareEditHref, persistPublicShareRootType } from "@/lib/drive/public-share-url"
import { suitePublicAsset } from "@/lib/suite/suite-public-asset"
import {
SUITE_APP_LOGO_LOCKUP_CLASS,
SUITE_APP_LOGO_MARK_CLASS,
SUITE_APP_LOGO_TEXT_CLASS,
} from "@/lib/suite/suite-chrome-classes"
import { SuiteThemeShell } from "@/components/suite/suite-theme-shell"
import { cn } from "@/lib/utils"
import { filterHiddenDriveSidecars } from "@/lib/drive/drive-hidden-files"
const PdfPreviewViewer = dynamic(
() => import("@/components/drive/pdf-preview-viewer").then((m) => m.PdfPreviewViewer),
{ ssr: false }
)
function PublicShareBreadcrumb({
token,
rootShareName,
path,
}: {
token: string
rootShareName: string
path: string
}) {
const parts = path.replace(/^\/+|\/+$/g, "").split("/").filter(Boolean)
const crumbs: { label: string; href: string }[] = [
{ label: rootShareName || "Partage", href: publicShareHref(token) },
]
let acc = ""
for (const part of parts) {
acc += `/${part}`
crumbs.push({ label: part, href: publicShareHref(token, acc) })
}
return (
<nav
className="flex flex-wrap items-center gap-1 text-sm"
aria-label="Fil d'Ariane"
>
{crumbs.map((crumb, i) => (
<span key={crumb.href} className="flex items-center gap-1">
{i > 0 ? <ChevronRight className="h-4 w-4 text-muted-foreground" aria-hidden /> : null}
{i === crumbs.length - 1 ? (
<span className="font-medium text-[#3c4043] dark:text-[#e8eaed]">{crumb.label}</span>
) : (
<Link href={crumb.href} className="text-[#1967d2] hover:underline dark:text-[#8ab4f8]">
{crumb.label}
</Link>
)}
</span>
))}
</nav>
)
}
function usePublicShareRootName(token: string, path: string, currentName: string) {
const storageKey = `public-share-root:${token}`
const [rootName, setRootName] = useState(currentName)
useEffect(() => {
if (typeof window === "undefined") return
if (path === "/" && currentName) {
sessionStorage.setItem(storageKey, currentName)
setRootName(currentName)
return
}
const stored = sessionStorage.getItem(storageKey)
if (stored) setRootName(stored)
}, [token, path, currentName, storageKey])
return rootName
}
function PublicFilePreview({
token,
file,
password,
}: {
token: string
file: DriveFileInfo
password?: string
}) {
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(true)
const kind = drivePreviewKind(file)
const isSvg = isSvgFile(file)
useEffect(() => {
let cancelled = false
let objectUrl: string | null = null
setLoading(true)
void fetchPublicShareBlob(token, file, password)
.then(async (blob) => {
if (cancelled) return
if (kind === "text") {
setTextContent(await blob.text())
setBlobUrl(null)
setSvgMarkup(null)
} else if (isSvg) {
setSvgMarkup(await blob.text())
setBlobUrl(null)
setTextContent(null)
} else {
objectUrl = URL.createObjectURL(blob)
setBlobUrl(objectUrl)
setTextContent(null)
setSvgMarkup(null)
}
})
.catch(() => toast.error("Aperçu indisponible"))
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
if (objectUrl) URL.revokeObjectURL(objectUrl)
}
}, [token, file, password, kind, isSvg])
if (loading) {
return (
<div className="flex h-[min(60vh,520px)] items-center justify-center rounded-2xl border border-border bg-[#f8f9fa] dark:bg-[#35363a]">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
)
}
if (isSvg && svgMarkup) {
return (
<div className="flex max-h-[min(70vh,640px)] min-h-[240px] items-stretch overflow-hidden rounded-2xl border border-border bg-[#f8f9fa] dark:bg-[#35363a]">
<SvgPreviewViewer markup={svgMarkup} name={file.name} className="min-h-[240px]" />
</div>
)
}
if (kind === "image" && blobUrl) {
return (
<div className="flex max-h-[min(70vh,640px)] items-center justify-center overflow-hidden rounded-2xl border border-border bg-[#f8f9fa] p-4 dark:bg-[#35363a]">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={blobUrl} alt={file.name} className="max-h-full max-w-full object-contain" />
</div>
)
}
if (kind === "video" && blobUrl) {
return (
<div className="overflow-hidden rounded-2xl border border-border bg-black">
<video src={blobUrl} controls className="max-h-[min(70vh,640px)] w-full" />
</div>
)
}
if (kind === "audio" && blobUrl) {
return (
<div className="flex items-center justify-center rounded-2xl border border-border bg-[#f8f9fa] px-8 py-12 dark:bg-[#35363a]">
<audio src={blobUrl} controls className="w-full max-w-lg" />
</div>
)
}
if (kind === "pdf" && blobUrl) {
return (
<div className="h-[min(70vh,640px)] overflow-hidden rounded-2xl border border-border">
<PdfPreviewViewer blobUrl={blobUrl} name={file.name} />
</div>
)
}
if (kind === "text" && textContent != null) {
return (
<pre className="max-h-[min(60vh,520px)] overflow-auto rounded-2xl border border-border bg-[#f8f9fa] p-4 text-sm dark:bg-[#35363a]">
{textContent}
</pre>
)
}
return (
<div className="rounded-2xl border border-dashed border-border bg-[#f8f9fa] px-6 py-10 text-center dark:bg-[#35363a]">
<p className="text-sm text-muted-foreground">Aperçu non disponible pour ce type de fichier.</p>
</div>
)
}
export function PublicShareViewPanel({
token,
path,
data,
password,
}: {
token: string
path: string
data: PublicShareView
password?: string
}) {
const router = useRouter()
const file = data.item_type === "file" ? data.file : null
const files = data.item_type === "folder" ? filterHiddenDriveSidecars(data.files ?? []) : []
const rootShareName = usePublicShareRootName(token, path, data.name)
const sharedByLabel = publicShareOwnerLabel(data)
const permissions = data.permissions ?? 1
const canEdit = sharePermCanEdit(permissions)
useEffect(() => {
persistPublicShareRootType(token, data.item_type)
}, [token, data.item_type])
useEffect(() => {
if (!file) return
const isEditorFile =
shouldOpenInRichTextEditor(file) ||
shouldOpenInUltidrawEditor(file) ||
shouldOpenInOnlyOffice(file)
if (!isEditorFile) return
const returnTo =
typeof window !== "undefined"
? window.location.pathname + window.location.search
: undefined
const editor = shouldOpenInUltidrawEditor(file)
? "ultidraw"
: shouldOpenInRichTextEditor(file)
? "richtext"
: "office"
router.replace(
buildPublicShareEditHref(
token,
file.path,
returnTo,
canEdit ? "edit" : "view",
file.name,
editor,
data.item_type
)
)
}, [canEdit, data.item_type, file, router, token])
const downloadCurrent = () => {
if (!file) return
const url = publicShareDownloadApiPath(token, file.path, password)
const anchor = document.createElement("a")
anchor.href = url
anchor.download = file.name
anchor.rel = "noopener"
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
}
if (
file &&
(shouldOpenInRichTextEditor(file) ||
shouldOpenInUltidrawEditor(file) ||
shouldOpenInOnlyOffice(file))
) {
return <DocsLoadingSplash phase="opening" title={file.name} />
}
return (
<div
className={cn(
"mx-auto flex min-h-0 w-full max-w-5xl flex-1 flex-col pb-4",
PUBLIC_SHARE_INSET_X,
"pt-3"
)}
>
<div className="mb-3 flex shrink-0 flex-wrap items-center justify-between gap-3">
<div className="min-w-0 space-y-1">
<p className="text-xs text-muted-foreground">
Partagé par <span className="font-medium text-[#3c4043] dark:text-[#e8eaed]">{sharedByLabel}</span>
</p>
{data.item_type === "folder" ? (
<PublicShareBreadcrumb token={token} rootShareName={rootShareName} path={path} />
) : null}
</div>
{file ? (
<Button type="button" variant="outline" size="sm" className="gap-2 shrink-0" onClick={downloadCurrent}>
<Download className="h-4 w-4" />
Télécharger
</Button>
) : null}
</div>
{data.item_type === "folder" ? (
<PublicShareFolderView
token={token}
folderPath={path}
files={files}
permissions={data.permissions ?? 1}
password={password}
folderTitle={data.name}
/>
) : file ? (
<PublicFilePreview token={token} file={file} password={password} />
) : null}
<FilePreviewDialog />
</div>
)
}
export function PublicShareChrome({ children }: { children: ReactNode }) {
return (
<SuiteThemeShell>
<div className="ultimail-app flex h-dvh flex-col overflow-hidden bg-app-canvas" data-drive-app>
<header className="flex h-16 shrink-0 items-center border-b border-border bg-mail-surface px-4 sm:px-6">
<Link href="/drive" className={SUITE_APP_LOGO_LOCKUP_CLASS}>
<img
src={suitePublicAsset("/ultidrive-mark.svg")}
alt=""
className={SUITE_APP_LOGO_MARK_CLASS}
draggable={false}
aria-hidden
/>
<span className={SUITE_APP_LOGO_TEXT_CLASS}>UltiDrive</span>
</Link>
<span className="ml-3 hidden text-sm text-muted-foreground sm:inline">
<FolderOpen className="mr-1 inline h-4 w-4 align-[-2px]" aria-hidden />
Lien de partage
</span>
</header>
<main className="flex min-h-0 flex-1 flex-col overflow-hidden">{children}</main>
</div>
</SuiteThemeShell>
)
}