- 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.
216 lines
6.7 KiB
TypeScript
216 lines
6.7 KiB
TypeScript
import { apiClient } from "@/lib/api/client"
|
|
import { displayFileName, fileNameExtension } from "@/lib/drive/display-file-name"
|
|
import { decodePathSegment } from "@/lib/drive/drive-url"
|
|
|
|
const EXT_MIME: Record<string, string> = {
|
|
jpg: "image/jpeg",
|
|
jpeg: "image/jpeg",
|
|
png: "image/png",
|
|
gif: "image/gif",
|
|
webp: "image/webp",
|
|
svg: "image/svg+xml",
|
|
bmp: "image/bmp",
|
|
avif: "image/avif",
|
|
heic: "image/heic",
|
|
heif: "image/heif",
|
|
mp4: "video/mp4",
|
|
webm: "video/webm",
|
|
mov: "video/quicktime",
|
|
mkv: "video/x-matroska",
|
|
ogv: "video/ogg",
|
|
m4v: "video/mp4",
|
|
pdf: "application/pdf",
|
|
mp3: "audio/mpeg",
|
|
wav: "audio/wav",
|
|
ogg: "audio/ogg",
|
|
flac: "audio/flac",
|
|
m4a: "audio/mp4",
|
|
aac: "audio/aac",
|
|
opus: "audio/opus",
|
|
weba: "audio/webm",
|
|
aiff: "audio/aiff",
|
|
mid: "audio/midi",
|
|
midi: "audio/midi",
|
|
ico: "image/x-icon",
|
|
tif: "image/tiff",
|
|
tiff: "image/tiff",
|
|
apng: "image/apng",
|
|
jfif: "image/jpeg",
|
|
}
|
|
|
|
function mimeFromFileName(name: string): string | undefined {
|
|
const ext = name.split(".").pop()?.toLowerCase() ?? ""
|
|
return EXT_MIME[ext]
|
|
}
|
|
|
|
const AMBIGUOUS_SERVER_MIMES = new Set([
|
|
"application/octet-stream",
|
|
"binary/octet-stream",
|
|
"text/plain",
|
|
"text/xml",
|
|
"application/xml",
|
|
])
|
|
|
|
function normalizeMime(value: string): string {
|
|
return value.toLowerCase().split(";")[0]?.trim() ?? ""
|
|
}
|
|
|
|
/** Pick MIME browsers accept for <img>/<video>/<audio> from server hints + extension. */
|
|
export function resolvePreviewMime(blobType: string, mimeType: string, fileName: string): string {
|
|
const fromExt = mimeFromFileName(fileName)
|
|
const blob = normalizeMime(blobType)
|
|
const hinted = normalizeMime(mimeType)
|
|
|
|
if (
|
|
fromExt === "image/svg+xml" ||
|
|
hinted.includes("svg") ||
|
|
blob.includes("svg") ||
|
|
fileName.toLowerCase().endsWith(".svg")
|
|
) {
|
|
return "image/svg+xml"
|
|
}
|
|
|
|
if (fromExt && (AMBIGUOUS_SERVER_MIMES.has(blob) || AMBIGUOUS_SERVER_MIMES.has(hinted) || !blob)) {
|
|
return fromExt
|
|
}
|
|
|
|
if (blob && !AMBIGUOUS_SERVER_MIMES.has(blob)) return blob
|
|
if (hinted && !AMBIGUOUS_SERVER_MIMES.has(hinted)) return hinted
|
|
return fromExt ?? blob ?? hinted ?? "application/octet-stream"
|
|
}
|
|
|
|
/** Nextcloud often returns application/octet-stream — browsers need a concrete type for <img>/<video>. */
|
|
export function blobForPreview(blob: Blob, mimeType: string, fileName: string): Blob {
|
|
const type = resolvePreviewMime(blob.type, mimeType, fileName)
|
|
if (!type || blob.type === type) return blob
|
|
return new Blob([blob], { type })
|
|
}
|
|
|
|
export async function fetchDrivePreviewBlob(file: {
|
|
path: string
|
|
name: string
|
|
mime_type: string
|
|
}): Promise<Blob> {
|
|
const raw = await apiClient.getBlob(driveDownloadApiPath(logicalDriveFilePath(file)))
|
|
return blobForPreview(raw, file.mime_type, file.name)
|
|
}
|
|
|
|
/** Logical client path for download (decode then re-encode; join name if path is parent dir). */
|
|
export function logicalDriveFilePath(file: { path: string; name: string }): string {
|
|
const decodeSeg = (seg: string) => {
|
|
try {
|
|
return decodeURIComponent(seg)
|
|
} catch {
|
|
return seg
|
|
}
|
|
}
|
|
let p = file.path.trim()
|
|
if (!p.startsWith("/")) p = `/${p}`
|
|
const segments = p.split("/").filter(Boolean).map(decodeSeg)
|
|
p = segments.length ? `/${segments.join("/")}` : "/"
|
|
const name = displayFileName(file.name)
|
|
if (!name) return p.replace(/\/+/g, "/")
|
|
|
|
const base = segments[segments.length - 1] ?? ""
|
|
const baseDecoded = displayFileName(base)
|
|
if (baseDecoded === name) return p.replace(/\/+/g, "/")
|
|
|
|
// WebDAV href basename is canonical; don't append a diverging display name.
|
|
const ext = fileNameExtension(name)
|
|
if (ext && baseDecoded.toLowerCase().endsWith(`.${ext}`)) {
|
|
return p.replace(/\/+/g, "/")
|
|
}
|
|
|
|
return `${p.replace(/\/$/, "")}/${name}`.replace(/\/+/g, "/")
|
|
}
|
|
|
|
/** API path for GET /drive/filter-corpus/* (recursive file index for client filters). */
|
|
export function driveFilterCorpusApiPath(folderPath: string): string {
|
|
if (folderPath === "/" || folderPath.trim() === "") return "/drive/filter-corpus"
|
|
const parts = folderPath
|
|
.replace(/^\/+/, "")
|
|
.split("/")
|
|
.filter(Boolean)
|
|
.map((seg) => encodeURIComponent(decodePathSegment(seg)))
|
|
return `/drive/filter-corpus/${parts.join("/")}`
|
|
}
|
|
|
|
/** API path for GET /drive/files/* (segments URL-encoded). */
|
|
export function driveFilesListApiPath(folderPath: string): string {
|
|
if (folderPath === "/" || folderPath.trim() === "") return "/drive/files/"
|
|
const parts = folderPath
|
|
.replace(/^\/+/, "")
|
|
.split("/")
|
|
.filter(Boolean)
|
|
.map((seg) => encodeURIComponent(decodePathSegment(seg)))
|
|
return `/drive/files/${parts.join("/")}`
|
|
}
|
|
|
|
/** API path for GET /drive/preview/* (segments URL-encoded). */
|
|
export function drivePreviewApiPath(filePath: string, width = 400, height = 300): string {
|
|
const parts = filePath
|
|
.replace(/^\/+/, "")
|
|
.split("/")
|
|
.filter(Boolean)
|
|
.map((seg) => encodeURIComponent(seg))
|
|
return `/drive/preview/${parts.join("/")}?w=${width}&h=${height}`
|
|
}
|
|
|
|
export async function fetchDriveServerPreview(
|
|
file: { path: string; name: string },
|
|
width = 400,
|
|
height = 300
|
|
): Promise<Blob> {
|
|
const logical = logicalDriveFilePath(file)
|
|
return apiClient.getBlob(drivePreviewApiPath(logical, width, height))
|
|
}
|
|
|
|
/** API path for GET /drive/download/* (segments URL-encoded). */
|
|
export function driveDownloadApiPath(filePath: string): string {
|
|
const parts = filePath
|
|
.replace(/^\/+/, "")
|
|
.split("/")
|
|
.filter(Boolean)
|
|
.map((seg) => encodeURIComponent(seg))
|
|
return `/drive/download/${parts.join("/")}`
|
|
}
|
|
|
|
export async function downloadDriveFile(
|
|
filePath: string,
|
|
suggestedName?: string,
|
|
fileNameForPath?: string
|
|
) {
|
|
const logical =
|
|
fileNameForPath != null
|
|
? logicalDriveFilePath({ path: filePath, name: fileNameForPath })
|
|
: filePath
|
|
const blob = await apiClient.getBlob(driveDownloadApiPath(logical))
|
|
const name =
|
|
displayFileName(suggestedName ?? filePath.split("/").filter(Boolean).pop() ?? "download")
|
|
const url = URL.createObjectURL(blob)
|
|
try {
|
|
const anchor = document.createElement("a")
|
|
anchor.href = url
|
|
anchor.download = name
|
|
anchor.rel = "noopener"
|
|
document.body.appendChild(anchor)
|
|
anchor.click()
|
|
anchor.remove()
|
|
} finally {
|
|
URL.revokeObjectURL(url)
|
|
}
|
|
}
|
|
|
|
/** Open file in a new tab (preview) with Bearer auth — avoids raw API URL in the browser. */
|
|
export async function openDriveFileInNewTab(filePath: string) {
|
|
const blob = await apiClient.getBlob(driveDownloadApiPath(filePath))
|
|
const url = URL.createObjectURL(blob)
|
|
const opened = window.open(url, "_blank", "noopener,noreferrer")
|
|
if (!opened) {
|
|
URL.revokeObjectURL(url)
|
|
await downloadDriveFile(filePath)
|
|
return
|
|
}
|
|
window.setTimeout(() => URL.revokeObjectURL(url), 60_000)
|
|
}
|