Merge pull request #502 from AtalayaLabs/claude/files-url-anchor-and-drag-ghost
feat(files): bookmarkable file-preview URL + multi-drag count ghost (#500)
This commit is contained in:
@@ -15,3 +15,4 @@ static/locales/
|
|||||||
static/vendors/
|
static/vendors/
|
||||||
static/workers/
|
static/workers/
|
||||||
static/basemaps/
|
static/basemaps/
|
||||||
|
static/geo/
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import { errorMessage, errorToast } from '$lib/utils/errors';
|
import { errorMessage, errorToast } from '$lib/utils/errors';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
|
import { untrack } from 'svelte';
|
||||||
import Icon from '$lib/icons/Icon.svelte';
|
import Icon from '$lib/icons/Icon.svelte';
|
||||||
import {
|
import {
|
||||||
cacheFolder,
|
cacheFolder,
|
||||||
@@ -201,7 +202,6 @@
|
|||||||
}
|
}
|
||||||
// 304 → the cached copy already on screen is current.
|
// 304 → the cached copy already on screen is current.
|
||||||
error = null;
|
error = null;
|
||||||
maybeOpenDeepLink();
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (seq !== loadSeq) return;
|
if (seq !== loadSeq) return;
|
||||||
// With a cached view already shown, keep it on a transient failure.
|
// With a cached view already shown, keep it on a transient failure.
|
||||||
@@ -229,19 +229,6 @@
|
|||||||
await load();
|
await load();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Deep-link auto-open: when the URL carries `?file=<id>` 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) {
|
function openFolder(folder: FolderItem) {
|
||||||
goto(`/files/${[...pathSegments, folder.id].join('/')}`);
|
goto(`/files/${[...pathSegments, folder.id].join('/')}`);
|
||||||
}
|
}
|
||||||
@@ -323,9 +310,59 @@
|
|||||||
async function onDrop(e: DragEvent) {
|
async function onDrop(e: DragEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
dragOver = false;
|
dragOver = false;
|
||||||
const dropped = e.dataTransfer?.files;
|
const dt = e.dataTransfer;
|
||||||
if (!dropped?.length) return;
|
if (!dt) return;
|
||||||
await uploadBatch(Array.from(dropped));
|
// 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<void> {
|
||||||
|
if (entry.isFile) {
|
||||||
|
const file = await new Promise<File>((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<FileSystemEntry[]>((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) {
|
async function renameItem(kind: 'file' | 'folder', id: string, current: string) {
|
||||||
@@ -368,10 +405,46 @@
|
|||||||
let viewerFile = $state<FileItem | null>(null);
|
let viewerFile = $state<FileItem | null>(null);
|
||||||
|
|
||||||
function openFile(file: FileItem) {
|
function openFile(file: FileItem) {
|
||||||
viewerFile = file;
|
// Drive the viewer from the URL (?file=<id>) so a preview is bookmarkable,
|
||||||
viewerOpen = true;
|
// 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=<id>) ──────────────────────────────────
|
||||||
|
// 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
|
* 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
|
* videos always have one; PDFs (and other thumbnail-capable docs) do too, so
|
||||||
@@ -441,6 +514,12 @@
|
|||||||
* folders are included (the old per-item loop silently skipped them). A lone
|
* folders are included (the old per-item loop silently skipped them). A lone
|
||||||
* file still streams directly so it keeps its original name/extension.
|
* 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() {
|
async function batchDownload() {
|
||||||
const fileIds: string[] = [];
|
const fileIds: string[] = [];
|
||||||
const folderIds: string[] = [];
|
const folderIds: string[] = [];
|
||||||
@@ -464,8 +543,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const stamp = new Date().toISOString().replace('T', ' ').replace(/\..*/, '').replace(/:/g, '-');
|
const zipName = batchZipName();
|
||||||
const zipName = `oxicloud ${stamp}.zip`;
|
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch('/api/batch/download', {
|
const res = await apiFetch('/api/batch/download', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -593,7 +671,75 @@
|
|||||||
const items: ActionTarget[] =
|
const items: ActionTarget[] =
|
||||||
selected.has(id) && selected.size > 1 ? selectionTargets() : [{ id, name, kind }];
|
selected.has(id) && selected.size > 1 ? selectionTargets() : [{ id, name, kind }];
|
||||||
e.dataTransfer?.setData(DRAG_TYPE, JSON.stringify(items));
|
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);
|
||||||
|
// 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
|
||||||
|
* / .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[] {
|
function dragPayload(e: DragEvent): ActionTarget[] {
|
||||||
@@ -761,33 +907,29 @@
|
|||||||
// ── Recursive folder upload ──────────────────────────────────────────────
|
// ── Recursive folder upload ──────────────────────────────────────────────
|
||||||
let folderInput = $state<HTMLInputElement | null>(null);
|
let folderInput = $state<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
async function onUploadFolder(e: Event) {
|
/**
|
||||||
const input = e.target as HTMLInputElement;
|
* Upload files that carry a relative directory path, recreating the folder
|
||||||
const files = input.files ? Array.from(input.files) : [];
|
* tree under the current folder. Shared by the folder picker and folder drops.
|
||||||
if (files.length === 0) return;
|
*/
|
||||||
|
async function uploadTree(entries: { file: File; relativePath: string }[]) {
|
||||||
|
if (entries.length === 0) return;
|
||||||
uploading = true;
|
uploading = true;
|
||||||
try {
|
try {
|
||||||
// Map each relative directory path to its created folder id, so files
|
// Map each relative directory path to its created folder id; '' = current.
|
||||||
// land in the right place. The root maps to the current folder.
|
|
||||||
const dirIds = new Map<string, string | null>([['', currentId]]);
|
const dirIds = new Map<string, string | null>([['', currentId]]);
|
||||||
|
|
||||||
async function ensureDir(relDir: string): Promise<string | null> {
|
async function ensureDir(relDir: string): Promise<string | null> {
|
||||||
if (dirIds.has(relDir)) return dirIds.get(relDir) ?? null;
|
if (dirIds.has(relDir)) return dirIds.get(relDir) ?? null;
|
||||||
const parts = relDir.split('/');
|
const parts = relDir.split('/');
|
||||||
const name = parts.pop() as string;
|
const name = parts.pop() as string;
|
||||||
const parentRel = parts.join('/');
|
const parentId = await ensureDir(parts.join('/'));
|
||||||
const parentId = await ensureDir(parentRel);
|
|
||||||
const created = await createFolder(name, parentId);
|
const created = await createFolder(name, parentId);
|
||||||
dirIds.set(relDir, created.id);
|
dirIds.set(relDir, created.id);
|
||||||
return created.id;
|
return created.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const file of files) {
|
for (const { file, relativePath } of entries) {
|
||||||
// webkitRelativePath: "chosenDir/sub/.../file.ext" — recreate the whole
|
const segs = relativePath.split('/');
|
||||||
// tree (including the chosen folder) under the current folder.
|
|
||||||
const rel =
|
|
||||||
(file as File & { webkitRelativePath?: string }).webkitRelativePath ?? file.name;
|
|
||||||
const segs = rel.split('/');
|
|
||||||
segs.pop(); // drop the filename, keep the directory trail
|
segs.pop(); // drop the filename, keep the directory trail
|
||||||
const dirId = await ensureDir(segs.join('/'));
|
const dirId = await ensureDir(segs.join('/'));
|
||||||
await uploadFile(dirId, file);
|
await uploadFile(dirId, file);
|
||||||
@@ -798,10 +940,23 @@
|
|||||||
errorToast(err);
|
errorToast(err);
|
||||||
} finally {
|
} finally {
|
||||||
uploading = false;
|
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 isEmpty = $derived(listing.folders.length === 0 && listing.files.length === 0);
|
||||||
const viewClass = $derived(
|
const viewClass = $derived(
|
||||||
filesStore.viewMode === 'grid' ? 'files-grid-view' : 'files-list-view'
|
filesStore.viewMode === 'grid' ? 'files-grid-view' : 'files-list-view'
|
||||||
|
|||||||
Reference in New Issue
Block a user