- 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.
226 lines
6.4 KiB
TypeScript
226 lines
6.4 KiB
TypeScript
"use client"
|
||
|
||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||
import Link from "next/link"
|
||
import { Button } from "@/components/ui/button"
|
||
import { ArrowLeft } from "lucide-react"
|
||
import { resolvePublicShareEditReturnTo } from "@/lib/drive/public-share-url"
|
||
|
||
type DocEditorInstance = { destroyEditor: () => void }
|
||
|
||
declare global {
|
||
interface Window {
|
||
DocsAPI?: {
|
||
DocEditor: new (id: string, config: Record<string, unknown>) => DocEditorInstance
|
||
}
|
||
DocEditor?: { instances: Record<string, DocEditorInstance | undefined> }
|
||
}
|
||
}
|
||
|
||
let docsApiLoad: Promise<void> | null = null
|
||
|
||
function loadDocsApi(documentServerUrl: string): Promise<void> {
|
||
if (window.DocsAPI) return Promise.resolve()
|
||
if (docsApiLoad) return docsApiLoad
|
||
const base = documentServerUrl.replace(/\/$/, "") + "/"
|
||
docsApiLoad = new Promise((resolve, reject) => {
|
||
const script = document.createElement("script")
|
||
script.id = "onlyoffice-docs-api-public"
|
||
script.src = `${base}web-apps/apps/api/documents/api.js`
|
||
script.async = true
|
||
script.onload = () => resolve()
|
||
script.onerror = () => {
|
||
docsApiLoad = null
|
||
reject(new Error(`Error load DocsAPI from ${base}`))
|
||
}
|
||
document.body.appendChild(script)
|
||
})
|
||
return docsApiLoad
|
||
}
|
||
|
||
function destroyDocEditor(id: string) {
|
||
const inst = window.DocEditor?.instances?.[id]
|
||
if (inst) {
|
||
try {
|
||
inst.destroyEditor()
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
document.getElementById(id)?.replaceChildren()
|
||
}
|
||
|
||
function OnlyOfficeMount({
|
||
editorId,
|
||
documentServerUrl,
|
||
config,
|
||
onError,
|
||
}: {
|
||
editorId: string
|
||
documentServerUrl: string
|
||
config: Record<string, unknown>
|
||
onError: (message: string) => void
|
||
}) {
|
||
const configJson = JSON.stringify(config)
|
||
const onErrorRef = useRef(onError)
|
||
onErrorRef.current = onError
|
||
|
||
useEffect(() => {
|
||
let cancelled = false
|
||
const id = editorId
|
||
const parsed = JSON.parse(configJson) as Record<string, unknown>
|
||
const editorConfig: Record<string, unknown> = {
|
||
type: "desktop",
|
||
width: "100%",
|
||
height: "100%",
|
||
events: {
|
||
onError: (event: { data?: { errorDescription?: string; errorCode?: number } }) => {
|
||
const msg =
|
||
event?.data?.errorDescription ||
|
||
(event?.data?.errorCode != null
|
||
? `OnlyOffice error ${event.data.errorCode}`
|
||
: "Erreur OnlyOffice.")
|
||
onErrorRef.current(msg)
|
||
},
|
||
},
|
||
...parsed,
|
||
}
|
||
void loadDocsApi(documentServerUrl)
|
||
.then(() => {
|
||
if (cancelled || !window.DocsAPI) return
|
||
destroyDocEditor(id)
|
||
if (!window.DocEditor) window.DocEditor = { instances: {} }
|
||
const editor = new window.DocsAPI.DocEditor(id, editorConfig)
|
||
window.DocEditor.instances[id] = editor
|
||
})
|
||
.catch((err: unknown) => {
|
||
if (!cancelled) {
|
||
onErrorRef.current(
|
||
err instanceof Error ? err.message : "Impossible de charger OnlyOffice."
|
||
)
|
||
}
|
||
})
|
||
return () => {
|
||
cancelled = true
|
||
destroyDocEditor(id)
|
||
}
|
||
}, [editorId, documentServerUrl, configJson])
|
||
|
||
return <div id={editorId} className="h-full w-full min-h-0" />
|
||
}
|
||
|
||
export function PublicOfficeEditor({
|
||
token,
|
||
filePath,
|
||
password,
|
||
returnTo,
|
||
mode = "edit",
|
||
}: {
|
||
token: string
|
||
filePath: string
|
||
password?: string
|
||
returnTo?: string | null
|
||
mode?: "edit" | "view"
|
||
}) {
|
||
const instanceSeq = useRef(0)
|
||
const guestId = useRef(
|
||
typeof crypto !== "undefined" && "randomUUID" in crypto
|
||
? crypto.randomUUID()
|
||
: `guest-${Date.now()}`
|
||
)
|
||
const [config, setConfig] = useState<Record<string, unknown> | null>(null)
|
||
const [serverUrl, setServerUrl] = useState("")
|
||
const [editorId, setEditorId] = useState<string | null>(null)
|
||
const [error, setError] = useState<string | null>(null)
|
||
const backHref = useMemo(
|
||
() => resolvePublicShareEditReturnTo(token, returnTo, filePath),
|
||
[token, returnTo, filePath]
|
||
)
|
||
|
||
useEffect(() => {
|
||
let cancelled = false
|
||
setConfig(null)
|
||
setServerUrl("")
|
||
setEditorId(null)
|
||
setError(null)
|
||
|
||
void (async () => {
|
||
try {
|
||
const res = await fetch(
|
||
`/api/v1/drive/public/shares/${encodeURIComponent(token)}/office/session`,
|
||
{
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
path: filePath,
|
||
mode,
|
||
password: password ?? "",
|
||
guest_id: guestId.current,
|
||
}),
|
||
}
|
||
)
|
||
if (!res.ok) throw new Error("session_failed")
|
||
const data = (await res.json()) as {
|
||
config: Record<string, unknown>
|
||
serverUrl: string
|
||
}
|
||
if (cancelled) return
|
||
instanceSeq.current += 1
|
||
setConfig(data.config)
|
||
setServerUrl(data.serverUrl || process.env.NEXT_PUBLIC_ONLYOFFICE_URL || "")
|
||
setEditorId(`ultidrive-public-editor-${instanceSeq.current}`)
|
||
} catch {
|
||
if (!cancelled) setError("Impossible de charger l’éditeur.")
|
||
}
|
||
})()
|
||
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}, [token, filePath, password, mode])
|
||
|
||
const handleEditorError = useCallback((message: string) => {
|
||
setError(message)
|
||
}, [])
|
||
|
||
if (error) {
|
||
return (
|
||
<div className="flex h-dvh flex-col items-center justify-center gap-4">
|
||
<p className="text-destructive">{error}</p>
|
||
<Button asChild variant="outline">
|
||
<Link href={backHref}>
|
||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||
Retour
|
||
</Link>
|
||
</Button>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (!config || !editorId || !serverUrl) {
|
||
return <p className="p-8 text-center text-muted-foreground">Ouverture du document…</p>
|
||
}
|
||
|
||
return (
|
||
<div className="flex h-dvh flex-col">
|
||
<div className="flex h-12 shrink-0 items-center gap-2 border-b border-border px-3 ultidrive-editor-chrome">
|
||
<Button variant="ghost" size="sm" asChild>
|
||
<Link href={backHref}>
|
||
<ArrowLeft className="mr-1 h-4 w-4" />
|
||
Partage
|
||
</Link>
|
||
</Button>
|
||
<span className="truncate text-sm font-medium">{filePath.split("/").pop()}</span>
|
||
</div>
|
||
<div className="relative min-h-0 flex-1">
|
||
<OnlyOfficeMount
|
||
editorId={editorId}
|
||
documentServerUrl={serverUrl.replace(/\/$/, "")}
|
||
config={config}
|
||
onError={handleEditorError}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|