From f567c0be48367feada9ceed9d94b0452215dd92d Mon Sep 17 00:00:00 2001 From: DioCrafts Date: Sat, 20 Jun 2026 00:15:29 +0200 Subject: [PATCH 1/2] feat(files): bookmarkable file preview URL + multi-drag count ghost (#500) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two parity gaps from the VanillaJS → Svelte migration (issue #500): 1. URL anchor when viewing a file. Opening a file now writes `?file=` to the URL, so a preview is bookmarkable, reload-restorable, and Back/Forward open/close it. The viewer is driven from the URL via two effects (URL→viewer with `untrack` so a user close can't be re-opened; viewer→URL to drop the param on close with replaceState). Replaces the write-less, load-only `maybeOpenDeepLink` (the `?file` reader that was effectively dead because nothing ever set the param). 2. Multi-selection drag ghost. Dragging more than one item now sets a custom drag image: a stack of the first few rows plus a count badge, reusing the already-ported but orphaned `.drag-preview`/`.dragged-items`/ `.dragged-items-badge` styles. Previously a multi-item drag showed only the browser's default single-row ghost with no count. Also adds `static/geo/` to .prettierignore (the bundled minified world basemap from #499 is a data asset and must stay byte-faithful — it was failing `prettier --check`, blocking the gate). Frontend gate green: svelte-check 0/0, eslint, stylelint, prettier, 47 Vitest. Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/.prettierignore | 1 + .../src/routes/files/[...path]/+page.svelte | 96 +++++++++++++++---- 2 files changed, 80 insertions(+), 17 deletions(-) diff --git a/frontend/.prettierignore b/frontend/.prettierignore index f0175abb..d199756b 100644 --- a/frontend/.prettierignore +++ b/frontend/.prettierignore @@ -15,3 +15,4 @@ static/locales/ static/vendors/ static/workers/ static/basemaps/ +static/geo/ diff --git a/frontend/src/routes/files/[...path]/+page.svelte b/frontend/src/routes/files/[...path]/+page.svelte index 7c46f511..27e42acd 100644 --- a/frontend/src/routes/files/[...path]/+page.svelte +++ b/frontend/src/routes/files/[...path]/+page.svelte @@ -4,6 +4,7 @@ import { errorMessage, errorToast } from '$lib/utils/errors'; import { goto } from '$app/navigation'; import { page } from '$app/state'; + import { untrack } from 'svelte'; import Icon from '$lib/icons/Icon.svelte'; import { cacheFolder, @@ -201,7 +202,6 @@ } // 304 → the cached copy already on screen is current. error = null; - maybeOpenDeepLink(); } catch (e) { if (seq !== loadSeq) return; // With a cached view already shown, keep it on a transient failure. @@ -229,19 +229,6 @@ await load(); } - /** - * Deep-link auto-open: when the URL carries `?file=` and that file is in - * the freshly loaded listing, open it in the viewer (ported from - * filesView.js' `app.viewFile` handling). Best-effort — a missing/unlisted - * file is silently ignored. - */ - function maybeOpenDeepLink() { - const fileId = page.url.searchParams.get('file'); - if (!fileId) return; - const file = listing.files.find((f) => f.id === fileId); - if (file) openFile(file); - } - function openFolder(folder: FolderItem) { goto(`/files/${[...pathSegments, folder.id].join('/')}`); } @@ -368,10 +355,46 @@ let viewerFile = $state(null); function openFile(file: FileItem) { - viewerFile = file; - viewerOpen = true; + // Drive the viewer from the URL (?file=) so a preview is bookmarkable, + // reload-restorable, and Back/Forward open/close it. The effect below + // reflects the param into viewerOpen/viewerFile. + const url = new URL(page.url); + url.searchParams.set('file', file.id); + void goto(url, { keepFocus: true, noScroll: true }); } + // ── File-preview deep link (?file=) ────────────────────────────────── + // URL → viewer. Runs on navigation, on popstate (Back/Forward), and once the + // listing for an initial deep link arrives. `untrack` stops it re-firing on + // viewer-state changes, so a user-initiated close can't be re-opened here. + $effect(() => { + const fileId = page.url.searchParams.get('file'); + const files = listing.files; + untrack(() => { + if (!fileId) { + if (viewerOpen) viewerOpen = false; + return; + } + if (viewerOpen && viewerFile?.id === fileId) return; + const f = files.find((x) => x.id === fileId); + if (f) { + viewerFile = f; + viewerOpen = true; + } + }); + }); + + // viewer → URL: when closed from within (X / Esc / backdrop), drop the param + // (replaceState, so closing doesn't add a history entry). + $effect(() => { + const hasParam = page.url.searchParams.get('file') !== null; + if (!viewerOpen && hasParam) { + const url = new URL(page.url); + url.searchParams.delete('file'); + void goto(url, { keepFocus: true, noScroll: true, replaceState: true }); + } + }); + /** * Whether the server can render a thumbnail preview for this file. Images and * videos always have one; PDFs (and other thumbnail-capable docs) do too, so @@ -593,7 +616,46 @@ const items: ActionTarget[] = selected.has(id) && selected.size > 1 ? selectionTargets() : [{ id, name, kind }]; e.dataTransfer?.setData(DRAG_TYPE, JSON.stringify(items)); - if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move'; + if (e.dataTransfer) { + e.dataTransfer.effectAllowed = 'move'; + if (items.length > 1) showDragGhost(e.dataTransfer, items); + } + } + + /** + * Custom drag image for a multi-item drag: a stack of the first few rows plus + * a count badge (ported from ui.js). Reuses the .drag-preview / .dragged-items + * / .dragged-items-badge styles already in resourceList.css. + */ + function showDragGhost(dt: DataTransfer, items: ActionTarget[]) { + const MAX = 4; + const preview = document.createElement('div'); + preview.className = 'drag-preview'; + + const stack = document.createElement('div'); + stack.className = 'dragged-items'; + for (const [i, it] of items.slice(0, MAX).entries()) { + const row = document.createElement('div'); + row.className = 'file-item'; + if (i === MAX - 1 && items.length > MAX) row.classList.add('fading'); + const icon = document.createElement('div'); + icon.className = 'file-icon'; + icon.textContent = it.kind === 'folder' ? '📁' : '📄'; + const label = document.createElement('div'); + label.textContent = it.name; + row.append(icon, label); + stack.appendChild(row); + } + + const badge = document.createElement('div'); + badge.className = 'dragged-items-badge'; + badge.textContent = String(items.length); + + preview.append(stack, badge); + document.body.appendChild(preview); + dt.setDragImage(preview, 0, 0); + // The browser snapshots the drag image synchronously; drop the node next tick. + setTimeout(() => preview.remove(), 0); } function dragPayload(e: DragEvent): ActionTarget[] { From aee8b6179a11d334c5e972a6fc6d8eff7479031f Mon Sep 17 00:00:00 2001 From: DioCrafts Date: Sat, 20 Jun 2026 00:29:47 +0200 Subject: [PATCH 2/2] feat(files): drag-out-to-OS download + folder drag-drop upload (#500) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Last parity gap from the VanillaJS → Svelte migration (issue #500), frontend-only — the backend endpoints already exist. - Drag-out download: onItemDragStart now also sets the DataTransfer `DownloadURL` type, so dragging a row/selection onto the OS desktop downloads it — a single file directly (GET /api/files/{id}), a folder as a zip (GET /api/folders/{id}/download?format=zip), and a multi-selection as one server-zipped archive via the GET twin GET /api/batch/download (DownloadURL can only point at a GET URL). The zip name is shared with the in-app batch download via a new batchZipName() helper. - Folder drag-drop upload: onDrop now walks dropped directory trees with webkitGetAsEntry/createReader into {file, relativePath} rows and recreates the tree server-side, instead of dropping only a folder's top-level files. The recursive-upload core is extracted into uploadTree() and shared with the folder picker (onUploadFolder), so both paths behave identically. Plain multi-file drops keep the existing flat fast-path. Stacked on #502 (shares onItemDragStart). Frontend gate green (svelte-check 0/0, eslint, stylelint, prettier) + 47 Vitest. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/routes/files/[...path]/+page.svelte | 133 +++++++++++++++--- 1 file changed, 113 insertions(+), 20 deletions(-) diff --git a/frontend/src/routes/files/[...path]/+page.svelte b/frontend/src/routes/files/[...path]/+page.svelte index 27e42acd..04815336 100644 --- a/frontend/src/routes/files/[...path]/+page.svelte +++ b/frontend/src/routes/files/[...path]/+page.svelte @@ -310,9 +310,59 @@ async function onDrop(e: DragEvent) { e.preventDefault(); dragOver = false; - const dropped = e.dataTransfer?.files; - if (!dropped?.length) return; - await uploadBatch(Array.from(dropped)); + const dt = e.dataTransfer; + if (!dt) return; + // A dropped folder isn't expanded into `.files`, so walk the dropped entry + // tree (webkitGetAsEntry) when one is present and recreate it server-side; + // otherwise fall back to the flat file list. + const tree = await collectDroppedEntries(dt); + if (tree) await uploadTree(tree); + else if (dt.files?.length) await uploadBatch(Array.from(dt.files)); + } + + /** + * Expand dropped OS entries into `{file, relativePath}` rows, walking any + * directory tree via the (non-standard but ubiquitous) `webkitGetAsEntry` / + * `createReader` API. Returns `null` when nothing dropped was a directory, so + * the caller takes the simpler flat-`FileList` path. + */ + async function collectDroppedEntries( + dt: DataTransfer + ): Promise<{ file: File; relativePath: string }[] | null> { + // `webkitGetAsEntry()` must be read synchronously while the event is live. + const roots: FileSystemEntry[] = []; + let sawDir = false; + for (const item of Array.from(dt.items)) { + const entry = item.webkitGetAsEntry(); + if (entry) { + roots.push(entry); + if (entry.isDirectory) sawDir = true; + } + } + if (!sawDir) return null; + + const out: { file: File; relativePath: string }[] = []; + async function walk(entry: FileSystemEntry, prefix: string): Promise { + if (entry.isFile) { + const file = await new Promise((resolve, reject) => + (entry as FileSystemFileEntry).file(resolve, reject) + ); + out.push({ file, relativePath: prefix + entry.name }); + } else if (entry.isDirectory) { + const reader = (entry as FileSystemDirectoryEntry).createReader(); + const dirPrefix = `${prefix}${entry.name}/`; + // readEntries yields in batches; loop until it returns an empty one. + for (;;) { + const batch = await new Promise((resolve, reject) => + reader.readEntries(resolve, reject) + ); + if (batch.length === 0) break; + for (const child of batch) await walk(child, dirPrefix); + } + } + } + for (const root of roots) await walk(root, ''); + return out; } async function renameItem(kind: 'file' | 'folder', id: string, current: string) { @@ -464,6 +514,12 @@ * folders are included (the old per-item loop silently skipped them). A lone * file still streams directly so it keeps its original name/extension. */ + /** Name for a server-zipped multi-item archive (matches the legacy format). */ + function batchZipName(): string { + const stamp = new Date().toISOString().replace('T', ' ').replace(/\..*/, '').replace(/:/g, '-'); + return `oxicloud ${stamp}.zip`; + } + async function batchDownload() { const fileIds: string[] = []; const folderIds: string[] = []; @@ -487,8 +543,7 @@ return; } - const stamp = new Date().toISOString().replace('T', ' ').replace(/\..*/, '').replace(/:/g, '-'); - const zipName = `oxicloud ${stamp}.zip`; + const zipName = batchZipName(); try { const res = await apiFetch('/api/batch/download', { method: 'POST', @@ -619,9 +674,38 @@ if (e.dataTransfer) { e.dataTransfer.effectAllowed = 'move'; if (items.length > 1) showDragGhost(e.dataTransfer, items); + // Drag-out-to-OS download: the OS reads `DownloadURL` (a GET URL) and + // downloads the dragged item(s) — a single file directly, a folder or a + // multi-selection as one server-zipped archive. + const dl = dragDownloadDescriptor(items); + if (dl) { + e.dataTransfer.setData( + 'DownloadURL', + `application/octet-stream:${dl.name}:${location.origin}${dl.url}` + ); + } } } + /** `{ name, GET url }` for the drag-out download of the current drag set. */ + function dragDownloadDescriptor(items: ActionTarget[]): { name: string; url: string } | null { + if (items.length === 0) return null; + if (items.length === 1) { + const it = items[0]; + return it.kind === 'folder' + ? { name: `${it.name}.zip`, url: folderZipUrl(it.id) } + : { name: it.name, url: fileDownloadUrl(it.id) }; + } + // Multi-selection → one archive via the GET twin of POST /api/batch/download + // (DownloadURL can only point at a GET URL); file_ids/folder_ids are CSV. + const fileIds = items.filter((i) => i.kind === 'file').map((i) => i.id); + const folderIds = items.filter((i) => i.kind === 'folder').map((i) => i.id); + const params = new URLSearchParams(); + if (fileIds.length) params.set('file_ids', fileIds.join(',')); + if (folderIds.length) params.set('folder_ids', folderIds.join(',')); + return { name: batchZipName(), url: `/api/batch/download?${params.toString()}` }; + } + /** * Custom drag image for a multi-item drag: a stack of the first few rows plus * a count badge (ported from ui.js). Reuses the .drag-preview / .dragged-items @@ -823,33 +907,29 @@ // ── Recursive folder upload ────────────────────────────────────────────── let folderInput = $state(null); - async function onUploadFolder(e: Event) { - const input = e.target as HTMLInputElement; - const files = input.files ? Array.from(input.files) : []; - if (files.length === 0) return; + /** + * Upload files that carry a relative directory path, recreating the folder + * tree under the current folder. Shared by the folder picker and folder drops. + */ + async function uploadTree(entries: { file: File; relativePath: string }[]) { + if (entries.length === 0) return; uploading = true; try { - // Map each relative directory path to its created folder id, so files - // land in the right place. The root maps to the current folder. + // Map each relative directory path to its created folder id; '' = current. const dirIds = new Map([['', currentId]]); async function ensureDir(relDir: string): Promise { if (dirIds.has(relDir)) return dirIds.get(relDir) ?? null; const parts = relDir.split('/'); const name = parts.pop() as string; - const parentRel = parts.join('/'); - const parentId = await ensureDir(parentRel); + const parentId = await ensureDir(parts.join('/')); const created = await createFolder(name, parentId); dirIds.set(relDir, created.id); return created.id; } - for (const file of files) { - // webkitRelativePath: "chosenDir/sub/.../file.ext" — recreate the whole - // tree (including the chosen folder) under the current folder. - const rel = - (file as File & { webkitRelativePath?: string }).webkitRelativePath ?? file.name; - const segs = rel.split('/'); + for (const { file, relativePath } of entries) { + const segs = relativePath.split('/'); segs.pop(); // drop the filename, keep the directory trail const dirId = await ensureDir(segs.join('/')); await uploadFile(dirId, file); @@ -860,10 +940,23 @@ errorToast(err); } finally { uploading = false; - input.value = ''; } } + async function onUploadFolder(e: Event) { + const input = e.target as HTMLInputElement; + const files = input.files ? Array.from(input.files) : []; + // webkitRelativePath: "chosenDir/sub/.../file.ext" — recreate the whole tree. + await uploadTree( + files.map((file) => ({ + file, + relativePath: + (file as File & { webkitRelativePath?: string }).webkitRelativePath ?? file.name + })) + ); + input.value = ''; + } + const isEmpty = $derived(listing.folders.length === 0 && listing.files.length === 0); const viewClass = $derived( filesStore.viewMode === 'grid' ? 'files-grid-view' : 'files-list-view'