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.
281 lines
9.1 KiB
TypeScript
281 lines
9.1 KiB
TypeScript
export type DriveRootKind = "personal" | "org" | "mount" | "shared"
|
|
|
|
export type DriveView =
|
|
| "files"
|
|
| "shared"
|
|
| "org"
|
|
| "mount"
|
|
| "recent"
|
|
| "starred"
|
|
| "trash"
|
|
| "search"
|
|
|
|
/** Decode one URL path segment (safe for names with literal `%`). */
|
|
export function decodePathSegment(segment: string): string {
|
|
try {
|
|
return decodeURIComponent(segment.replace(/\+/g, " "))
|
|
} catch {
|
|
return segment
|
|
}
|
|
}
|
|
|
|
function decodePathSegments(segments: string[]): string[] {
|
|
return segments.map(decodePathSegment)
|
|
}
|
|
|
|
export function encodePathSegments(segments: string[]): string {
|
|
return segments.map((s) => encodeURIComponent(decodePathSegment(s))).join("/")
|
|
}
|
|
|
|
export function driveRouteBase(routeRoot = "drive"): string {
|
|
return `/${routeRoot}`
|
|
}
|
|
|
|
export function buildDriveFolderHref(
|
|
view: Extract<DriveView, "files" | "shared" | "org" | "mount">,
|
|
segments: string[],
|
|
rootId?: string,
|
|
routeRoot = "drive"
|
|
): string {
|
|
const base = driveRouteBase(routeRoot)
|
|
const decoded = decodePathSegments(segments)
|
|
if (view === "org" && rootId) {
|
|
if (decoded.length === 0) return `${base}/org/${encodeURIComponent(rootId)}`
|
|
return `${base}/org/${encodeURIComponent(rootId)}/folders/${encodePathSegments(decoded)}`
|
|
}
|
|
if (view === "mount" && rootId) {
|
|
if (decoded.length === 0) return `${base}/mounts/${encodeURIComponent(rootId)}`
|
|
return `${base}/mounts/${encodeURIComponent(rootId)}/folders/${encodePathSegments(decoded)}`
|
|
}
|
|
if (decoded.length === 0) {
|
|
return view === "shared" ? `${base}/shared` : base
|
|
}
|
|
const prefix = view === "shared" ? `${base}/shared/folders` : `${base}/folders`
|
|
return `${prefix}/${encodePathSegments(decoded)}`
|
|
}
|
|
|
|
export interface DriveRouteState {
|
|
view: DriveView
|
|
pathSegments: string[]
|
|
page: number
|
|
fileId: string | null
|
|
query: string
|
|
rootId: string | null
|
|
}
|
|
|
|
export function parseDriveSegments(segments: string[] | undefined): DriveRouteState {
|
|
const parts = segments ?? []
|
|
if (parts.length === 0) {
|
|
return { view: "files", pathSegments: [], page: 1, fileId: null, query: "", rootId: null }
|
|
}
|
|
const head = parts[0]
|
|
if (head === "recent" || head === "starred" || head === "trash") {
|
|
return { view: head, pathSegments: [], page: 1, fileId: null, query: "", rootId: null }
|
|
}
|
|
if (head === "org" && parts[1]) {
|
|
const folderId = decodePathSegment(parts[1])
|
|
const folderParts = parts.slice(2)
|
|
if (folderParts[0] === "folders") {
|
|
folderParts.shift()
|
|
const pageIdx = folderParts.indexOf("page")
|
|
let page = 1
|
|
if (pageIdx >= 0 && folderParts[pageIdx + 1]) {
|
|
page = Math.max(1, parseInt(folderParts[pageIdx + 1], 10) || 1)
|
|
folderParts.splice(pageIdx, 2)
|
|
}
|
|
return {
|
|
view: "org",
|
|
pathSegments: decodePathSegments(folderParts),
|
|
page,
|
|
fileId: null,
|
|
query: "",
|
|
rootId: folderId,
|
|
}
|
|
}
|
|
return { view: "org", pathSegments: [], page: 1, fileId: null, query: "", rootId: folderId }
|
|
}
|
|
if (head === "mounts" && parts[1]) {
|
|
const mountId = decodePathSegment(parts[1])
|
|
const folderParts = parts.slice(2)
|
|
if (folderParts[0] === "folders") {
|
|
folderParts.shift()
|
|
const pageIdx = folderParts.indexOf("page")
|
|
let page = 1
|
|
if (pageIdx >= 0 && folderParts[pageIdx + 1]) {
|
|
page = Math.max(1, parseInt(folderParts[pageIdx + 1], 10) || 1)
|
|
folderParts.splice(pageIdx, 2)
|
|
}
|
|
return {
|
|
view: "mount",
|
|
pathSegments: decodePathSegments(folderParts),
|
|
page,
|
|
fileId: null,
|
|
query: "",
|
|
rootId: mountId,
|
|
}
|
|
}
|
|
return { view: "mount", pathSegments: [], page: 1, fileId: null, query: "", rootId: mountId }
|
|
}
|
|
if (head === "shared") {
|
|
const folderParts = parts.slice(1)
|
|
let page = 1
|
|
if (folderParts[0] === "folders") {
|
|
folderParts.shift()
|
|
const pageIdx = folderParts.indexOf("page")
|
|
if (pageIdx >= 0 && folderParts[pageIdx + 1]) {
|
|
page = Math.max(1, parseInt(folderParts[pageIdx + 1], 10) || 1)
|
|
folderParts.splice(pageIdx, 2)
|
|
}
|
|
return {
|
|
view: "shared",
|
|
pathSegments: decodePathSegments(folderParts),
|
|
page,
|
|
fileId: null,
|
|
query: "",
|
|
rootId: null,
|
|
}
|
|
}
|
|
return { view: "shared", pathSegments: [], page: 1, fileId: null, query: "", rootId: null }
|
|
}
|
|
if (head === "search") {
|
|
return { view: "search", pathSegments: [], page: 1, fileId: null, query: "", rootId: null }
|
|
}
|
|
if (head === "edit" && parts[1]) {
|
|
return {
|
|
view: "files",
|
|
pathSegments: [],
|
|
page: 1,
|
|
fileId: decodeURIComponent(parts[1]),
|
|
query: "",
|
|
rootId: null,
|
|
}
|
|
}
|
|
if (head === "folders") {
|
|
const folderParts = parts.slice(1)
|
|
let page = 1
|
|
const pageIdx = folderParts.indexOf("page")
|
|
if (pageIdx >= 0 && folderParts[pageIdx + 1]) {
|
|
page = Math.max(1, parseInt(folderParts[pageIdx + 1], 10) || 1)
|
|
folderParts.splice(pageIdx, 2)
|
|
}
|
|
return {
|
|
view: "files",
|
|
pathSegments: decodePathSegments(folderParts),
|
|
page,
|
|
fileId: null,
|
|
query: "",
|
|
rootId: null,
|
|
}
|
|
}
|
|
return {
|
|
view: "files",
|
|
pathSegments: decodePathSegments(parts),
|
|
page: 1,
|
|
fileId: null,
|
|
query: "",
|
|
rootId: null,
|
|
}
|
|
}
|
|
|
|
export function buildDrivePath(state: DriveRouteState, routeRoot = "drive"): string {
|
|
const base = driveRouteBase(routeRoot)
|
|
if (state.view === "recent") return `${base}/recent`
|
|
if (state.view === "starred") return `${base}/starred`
|
|
if (state.view === "trash") return `${base}/trash`
|
|
if (state.view === "search") return `${base}/search`
|
|
if (state.view === "org" && state.rootId) {
|
|
const folder = state.pathSegments.length
|
|
? `${base}/org/${encodeURIComponent(state.rootId)}/folders/${encodePathSegments(state.pathSegments)}`
|
|
: `${base}/org/${encodeURIComponent(state.rootId)}`
|
|
const pageSuffix = state.page > 1 ? `/page/${state.page}` : ""
|
|
return `${folder}${pageSuffix}`
|
|
}
|
|
if (state.view === "mount" && state.rootId) {
|
|
const folder = state.pathSegments.length
|
|
? `${base}/mounts/${encodeURIComponent(state.rootId)}/folders/${encodePathSegments(state.pathSegments)}`
|
|
: `${base}/mounts/${encodeURIComponent(state.rootId)}`
|
|
const pageSuffix = state.page > 1 ? `/page/${state.page}` : ""
|
|
return `${folder}${pageSuffix}`
|
|
}
|
|
if (state.view === "shared") {
|
|
const folder = state.pathSegments.length
|
|
? `${base}/shared/folders/${encodePathSegments(state.pathSegments)}`
|
|
: `${base}/shared`
|
|
const pageSuffix = state.page > 1 ? `/page/${state.page}` : ""
|
|
return `${folder}${pageSuffix}`
|
|
}
|
|
if (state.fileId && state.view === "files") {
|
|
return `${base}/edit/${encodeURIComponent(state.fileId)}`
|
|
}
|
|
const folder = state.pathSegments.length
|
|
? `${base}/folders/${encodePathSegments(state.pathSegments)}`
|
|
: base
|
|
const pageSuffix = state.page > 1 ? `/page/${state.page}` : ""
|
|
return `${folder}${pageSuffix}`
|
|
}
|
|
|
|
export function folderPathFromSegments(segments: string[]): string {
|
|
const decoded = decodePathSegments(segments)
|
|
if (decoded.length === 0) return "/"
|
|
return "/" + decoded.join("/")
|
|
}
|
|
|
|
export function parentFolderPathFromFilePath(filePath: string): string {
|
|
const normalized = filePath.replace(/^\/+/, "").replace(/\/+$/, "")
|
|
if (!normalized) return "/"
|
|
const parts = normalized.split("/")
|
|
if (parts.length <= 1) return "/"
|
|
return "/" + parts.slice(0, -1).join("/")
|
|
}
|
|
|
|
function isSafeDriveReturnPath(path: string): boolean {
|
|
if (!path.startsWith("/drive") && !path.startsWith("/demo/drive")) return false
|
|
if (path.startsWith("//")) return false
|
|
if (path.includes("://")) return false
|
|
return true
|
|
}
|
|
|
|
/** Stable Nextcloud file id segment (numeric). */
|
|
export function isDriveFileIdSegment(value: string): boolean {
|
|
return /^[1-9][0-9]*$/.test(value)
|
|
}
|
|
|
|
/** Rich-text editor URL — id-only, no returnTo query param. */
|
|
export function buildDriveDocsEditHref(fileId: string | number): string {
|
|
return `/drive/docs/${String(fileId)}/edit`
|
|
}
|
|
|
|
/** UltiDraw (Excalidraw) editor URL — id-only. */
|
|
export function buildDriveDrawEditHref(fileId: string | number): string {
|
|
return `/drive/draw/${String(fileId)}/edit`
|
|
}
|
|
|
|
export function buildDriveEditHref(filePath: string, returnTo?: string): string {
|
|
const params = new URLSearchParams()
|
|
if (returnTo && isSafeDriveReturnPath(returnTo)) {
|
|
params.set("returnTo", returnTo)
|
|
}
|
|
const base = `/drive/edit/${encodeURIComponent(filePath)}`
|
|
const qs = params.toString()
|
|
return qs ? `${base}?${qs}` : base
|
|
}
|
|
|
|
/** Resolve back link from editor: prefer session returnTo, else explicit returnTo, else parent folder. */
|
|
export function resolveDriveEditReturnTo(
|
|
returnTo: string | null | undefined,
|
|
filePath: string,
|
|
folderHref: (folderPath: string) => string,
|
|
sessionReturnTo?: string | null
|
|
): string {
|
|
if (sessionReturnTo && isSafeDriveReturnPath(sessionReturnTo)) return sessionReturnTo
|
|
if (returnTo && isSafeDriveReturnPath(returnTo)) return returnTo
|
|
return folderHref(parentFolderPathFromFilePath(filePath))
|
|
}
|
|
|
|
export function driveRootKindForView(view: DriveView): DriveRootKind {
|
|
if (view === "org") return "org"
|
|
if (view === "mount") return "mount"
|
|
if (view === "shared") return "shared"
|
|
return "personal"
|
|
}
|