Some checks are pending
E2E / Playwright e2e (push) Waiting to run
- 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.
355 lines
11 KiB
TypeScript
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>
|
|
)
|
|
}
|