86268049ff
feat(fileDto, folderDto): add is_favorite + is_shared
2270 lines
81 KiB
Svelte
2270 lines
81 KiB
Svelte
<script lang="ts">
|
|
import { errorMessage, errorToast } from '$lib/utils/errors';
|
|
import { goto } from '$app/navigation';
|
|
import { resolve } from '$app/paths';
|
|
import { page } from '$app/state';
|
|
import { untrack } from 'svelte';
|
|
import { SvelteSet } from 'svelte/reactivity';
|
|
import Icon from '$lib/icons/Icon.svelte';
|
|
import {
|
|
createFolder,
|
|
deleteFolder,
|
|
fetchFolderPage,
|
|
getFolder,
|
|
getFolderName,
|
|
invalidateFolderCache,
|
|
moveFolder,
|
|
rememberFolderName,
|
|
renameFolder,
|
|
type FolderListing
|
|
} from '$lib/api/endpoints/folders';
|
|
import {
|
|
deleteFile,
|
|
fileDownloadUrl,
|
|
moveFile,
|
|
renameFile,
|
|
uploadFileWithProgress
|
|
} from '$lib/api/endpoints/files';
|
|
import { folderZipUrl } from '$lib/api/endpoints/folders';
|
|
import {
|
|
instantUploadOwned,
|
|
resolveOwnedHashes,
|
|
tryDeltaUpload
|
|
} from '$lib/api/endpoints/deltaUpload';
|
|
import { addFavorite, removeFavorite } from '$lib/api/endpoints/favorites';
|
|
import { canEditWithWopi, getEditorUrlWithFallback } from '$lib/api/endpoints/wopi';
|
|
import { addTracks, createPlaylist, listPlaylists } from '$lib/api/endpoints/music';
|
|
import { copyFiles, copyFolders } from '$lib/api/endpoints/batch';
|
|
import { apiFetch } from '$lib/api/client';
|
|
import { getCsrfHeaders } from '$lib/api/csrf';
|
|
import { countHidden, filterDotfiles } from '$lib/utils/dotfileFilter';
|
|
import { preferences } from '$lib/stores/preferences.svelte';
|
|
import type { FileItem, FolderItem, ItemType } from '$lib/api/types';
|
|
import ReadOnlyBanner from '$lib/components/ReadOnlyBanner.svelte';
|
|
import ResourceList, {
|
|
isFile,
|
|
type GroupByDef as RLGroupByDef
|
|
} from '$lib/components/ResourceList.svelte';
|
|
import { lazyComponent } from '$lib/composables/lazyComponent.svelte';
|
|
import { t } from '$lib/i18n/index.svelte';
|
|
import { confirmDialog, promptDialog } from '$lib/stores/dialogs.svelte';
|
|
import { drives as drivesStore, driveIcon } from '$lib/stores/drives.svelte';
|
|
import { files as filesStore } from '$lib/stores/files.svelte';
|
|
import { session } from '$lib/stores/session.svelte';
|
|
import { ui } from '$lib/stores/ui.svelte';
|
|
import { dateBucket, sizeBucket, typeLabel } from '$lib/stores/files.svelte';
|
|
import { replaceSet } from '$lib/utils/sets';
|
|
|
|
// File preview and the WOPI editor are heavy and only appear on demand, so
|
|
// their modules load the first time the user opens one (see the effects that
|
|
// call `.load()` when `viewerOpen` / `wopiOpen` flip true).
|
|
const fileViewer = lazyComponent(() => import('$lib/components/FileViewer.svelte'));
|
|
const wopiEditor = lazyComponent(() => import('$lib/components/WopiEditor.svelte'));
|
|
const moveDialog = lazyComponent(() => import('$lib/components/MoveDialog.svelte'));
|
|
const shareDialog = lazyComponent(() => import('$lib/components/ShareDialog.svelte'));
|
|
|
|
// The URL rest param is the trail of folder ids from home's children down.
|
|
// /files → home root; /files/a/b → folder b inside a inside home.
|
|
const pathSegments = $derived((page.params.path ?? '').split('/').filter((s) => s.length > 0));
|
|
|
|
// First-crumb icon mirrors the drive at pathSegments[0]: `home` for the
|
|
// default-personal, `folder` for a secondary personal, `users` for a
|
|
// shared drive. Falls back to `home` while the drives list is loading
|
|
// or when the URL's leading segment isn't a known drive root (deep-link
|
|
// into a sub-folder bypasses drive identification — same limitation as
|
|
// the breadcrumb name resolution).
|
|
const rootIcon = $derived.by(() => {
|
|
const drive = drivesStore.findByRootFolderId(pathSegments[0] ?? null);
|
|
return drive ? driveIcon(drive) : 'home';
|
|
});
|
|
|
|
// The drive whose content the user is currently browsing.
|
|
//
|
|
// Priorities (first match wins):
|
|
// 1. `currentFolderDriveId` — set by `load()` after a `getFolder`
|
|
// fetch on the current folder. Authoritative for deep-links
|
|
// too (the URL's leading segment might not be a drive root).
|
|
// 2. `listing.folders[0]?.drive_id` — fast-path when the folder
|
|
// has at least one subfolder; avoids the extra round-trip on
|
|
// the initial `applyListing` before `getFolder` returns.
|
|
// (`FileDto` doesn't carry `drive_id` today, so we can't use
|
|
// files as a fallback source; folders alone.)
|
|
// 3. `drivesStore.findByRootFolderId(pathSegments[0])` — legacy
|
|
// fallback for the common "sidebar picker → drive root URL"
|
|
// navigation, unchanged from `rootIcon` above.
|
|
//
|
|
// Feeds the read-only freeze banner further down: when this drive's
|
|
// `policies.read_only` is on, mutation controls elsewhere in the app
|
|
// will fail against the backend engine gate; the banner is the
|
|
// affordance that tells the user why.
|
|
let currentFolderDriveId = $state<string | null>(null);
|
|
const currentDrive = $derived.by(() => {
|
|
if (currentFolderDriveId) {
|
|
const d = drivesStore.findById(currentFolderDriveId);
|
|
if (d) return d;
|
|
}
|
|
const listingDriveId = listing.folders[0]?.drive_id ?? null;
|
|
if (listingDriveId) return drivesStore.findById(listingDriveId);
|
|
return drivesStore.findByRootFolderId(pathSegments[0] ?? null);
|
|
});
|
|
|
|
let listing = $state<FolderListing>({ folders: [], files: [] });
|
|
// Server-order accumulator — items in the exact sequence the backend
|
|
// returned across pages, honouring `sortField`+`reversed` on the wire.
|
|
// Under order_by=name/type/size the server puts folders first then files;
|
|
// under modified_at/created_at they interleave. `rlItems` reads this
|
|
// directly so ResourceList renders in server order without a re-sort.
|
|
let orderedItems = $state<Array<FileItem | FolderItem>>([]);
|
|
// Cursor for the NEXT page. `undefined` after the final page has landed
|
|
// (or before the first fetch). Bound to ResourceList's `hasMore`.
|
|
let pageCursor = $state<string | undefined>(undefined);
|
|
// Guard so a fast-firing onloadmore (double intersection tick) can't
|
|
// enqueue two concurrent next-page fetches on the same cursor.
|
|
let loadingMore = $state(false);
|
|
|
|
// ── Transient "New elements" swimlane ────────────────────────────
|
|
// Server sort is authoritative, so an uploaded file lands wherever
|
|
// its sort key places it — possibly halfway down the list or on an
|
|
// unloaded page. To confirm "your upload worked", we detect items
|
|
// that appeared on the current page after a mutation (upload / new
|
|
// folder / dropped tree) and hoist them into a first-class swimlane
|
|
// at the top of the list. The swimlane clears on next folder nav.
|
|
//
|
|
// Detection strategy: caller of `reloadAndTrackNew()` snapshots the
|
|
// current id set, runs `reload()`, and diffs the freshly-loaded
|
|
// page 1 against the snapshot — every new id joins `newlyAdded`.
|
|
//
|
|
// Known limitation: server-sort places the new item on an unloaded
|
|
// page (e.g. sort=size on a 5 000-item folder and the new file is
|
|
// mid-size). It won't appear in the swimlane until the user scrolls
|
|
// far enough for that page to load. Threading the returned FileItem
|
|
// out of the 5-layer upload stack would fix this — deferred until
|
|
// the diff approach proves insufficient.
|
|
const newlyAdded = new SvelteSet<string>();
|
|
|
|
// Dotfile hide filter is now applied inside `rlItems` (below) directly
|
|
// on the server-ordered accumulator, so a single filter pass feeds
|
|
// ResourceList. Selection / batch ops iterate ResourceList's own
|
|
// selection set, which already excludes hidden rows. Direct lookups
|
|
// by id (deep-links via `?file=<uuid>`) still go through
|
|
// `listing.files` so hidden files remain reachable via their own URL
|
|
// — same UX as macOS Finder.
|
|
// Count of items suppressed by the filter — surfaced in the
|
|
// empty-state hint when the folder isn't visually empty but
|
|
// contains only dotfiles the user has hidden, so a "why is this
|
|
// empty?" question is answerable at a glance.
|
|
const hiddenCount = $derived(
|
|
preferences.hideDotfiles ? countHidden(listing.folders) + countHidden(listing.files) : 0
|
|
);
|
|
let crumbs = $state<Array<{ id: string; name: string }>>([]);
|
|
let currentId = $state<string | null>(null);
|
|
let loading = $state(false);
|
|
// Skeleton is delayed ~100ms behind `loading` so fast loads don't flash it.
|
|
let showSkeleton = $state(false);
|
|
let error = $state<string | null>(null);
|
|
let fileInput = $state<HTMLInputElement | null>(null);
|
|
let uploading = $state(false);
|
|
|
|
interface ActionTarget {
|
|
id: string;
|
|
name: string;
|
|
kind: ItemType;
|
|
}
|
|
let moveOpen = $state(false);
|
|
let moveMode = $state<'move' | 'copy'>('move');
|
|
let shareOpen = $state(false);
|
|
let actionTarget = $state<ActionTarget | null>(null);
|
|
let moveItems = $state<ActionTarget[] | null>(null);
|
|
|
|
// Favorite / shared state now lives inline on every `FileItem` /
|
|
// `FolderItem` DTO (`is_favorite`, `is_shared` — see
|
|
// `frontend/src/lib/api/types.ts`). Populated by the backend
|
|
// listing SQL (per-row `EXISTS`) and single-item enrichment
|
|
// helper. The row-badge snippet and star gate read these
|
|
// fields directly; the toggle path mutates the item in place
|
|
// inside `orderedItems`. No more `SvelteSet` shadowing.
|
|
|
|
function openMove(kind: ItemType, id: string, name: string) {
|
|
actionTarget = { id, name, kind };
|
|
moveItems = null;
|
|
moveMode = 'move';
|
|
moveOpen = true;
|
|
}
|
|
function openCopy(kind: ItemType, id: string, name: string) {
|
|
actionTarget = { id, name, kind };
|
|
moveItems = null;
|
|
moveMode = 'copy';
|
|
moveOpen = true;
|
|
}
|
|
function openShare(kind: ItemType, id: string, name: string) {
|
|
actionTarget = { id, name, kind };
|
|
shareOpen = true;
|
|
}
|
|
|
|
async function toggleFavorite(kind: ItemType, id: string) {
|
|
// Server-authoritative `is_favorite` lives on every
|
|
// `FileItem`/`FolderItem` DTO. Read → optimistic flip → server
|
|
// call → revert on failure. All state changes happen in-place
|
|
// on the item inside `orderedItems`; there is no shadow set.
|
|
const item = orderedItems.find((it) => it.id === id);
|
|
if (!item) return; // row scrolled off / navigated away mid-click
|
|
const wasFav = item.is_favorite;
|
|
item.is_favorite = !wasFav;
|
|
try {
|
|
if (wasFav) await removeFavorite(kind, id);
|
|
else await addFavorite(kind, id);
|
|
} catch (e) {
|
|
errorToast(e);
|
|
item.is_favorite = wasFav;
|
|
}
|
|
}
|
|
|
|
async function buildCrumbs(segments: string[]): Promise<Array<{ id: string; name: string }>> {
|
|
// Names come from the cache first (every listing names its children, so
|
|
// step-by-step navigation needs zero requests); only ids we've never seen
|
|
// — a cold deep-link's ancestors — are fetched, in parallel.
|
|
return Promise.all(
|
|
segments.map(async (id) => {
|
|
const known = getFolderName(id);
|
|
if (known !== undefined) return { id, name: known };
|
|
try {
|
|
const f = await getFolder(id);
|
|
return { id, name: f.name };
|
|
} catch {
|
|
return { id, name: '…' };
|
|
}
|
|
})
|
|
);
|
|
}
|
|
|
|
// Bumped on every load; a stale in-flight response checks this before it
|
|
// writes state, so a fast navigation can't be clobbered by an older fetch.
|
|
let loadSeq = 0;
|
|
|
|
/**
|
|
* Load the current folder's listing.
|
|
*
|
|
* @param reset Fresh load (folder nav / sort change / manual reload):
|
|
* clears cursor+accumulator, redoes canonicalization +
|
|
* breadcrumbs, then fetches page 1.
|
|
*
|
|
* Append (from `loadMore()` on scroll-bottom): skips
|
|
* preconditions, fetches the NEXT page using the stored
|
|
* cursor and appends to `listing`+`orderedItems`.
|
|
*
|
|
* Server-side sort: `orderBy=sortField, reverse=reversed` are passed on
|
|
* every page request so items arrive already in the requested order —
|
|
* client-side sort was removed and `rlItems` reads `orderedItems`
|
|
* verbatim. Sort/group changes trigger `load(true)` via `$effect`.
|
|
*/
|
|
async function load(reset: boolean = true) {
|
|
error = null;
|
|
const seq = ++loadSeq;
|
|
|
|
let folderId: string;
|
|
let skeletonTimer: ReturnType<typeof setTimeout> | undefined;
|
|
if (reset) {
|
|
// External users have no home folder; send them to shared-with-me.
|
|
if (session.isExternalUser && pathSegments.length === 0) {
|
|
await goto(resolve('/shared-with-me'), { replaceState: true });
|
|
return;
|
|
}
|
|
const home = await session.loadHomeFolder();
|
|
|
|
// Canonicalize bare `/files` → `/files/<last-chosen-drive-root>` (or
|
|
// the default drive's root when there's no memory yet). Keeps the URL
|
|
// explicit, the breadcrumb populated, and the drive picker correctly
|
|
// highlighted. The DrivePicker writes `oxi-last-drive-root` on click.
|
|
if (pathSegments.length === 0) {
|
|
const last =
|
|
typeof localStorage !== 'undefined' ? localStorage.getItem('oxi-last-drive-root') : null;
|
|
const target = last ?? home;
|
|
if (target) {
|
|
await goto(resolve(`/files/${target}`), { replaceState: true });
|
|
return;
|
|
}
|
|
}
|
|
|
|
const resolvedId = pathSegments.at(-1) ?? home;
|
|
if (!resolvedId) {
|
|
error = t('files.no_home', 'No home folder available.');
|
|
return;
|
|
}
|
|
folderId = resolvedId;
|
|
currentId = folderId;
|
|
filesStore.currentFolder = folderId;
|
|
|
|
// Reset paging state: previous folder's cursor is meaningless here,
|
|
// and mixing its rows with the new folder's would flash a wrong list.
|
|
pageCursor = undefined;
|
|
listing = { folders: [], files: [] };
|
|
orderedItems = [];
|
|
loading = true;
|
|
|
|
// Delayed skeleton so fast loads don't flash it.
|
|
skeletonTimer = setTimeout(() => {
|
|
if (loading) showSkeleton = true;
|
|
}, 100);
|
|
|
|
// Breadcrumbs resolve independently so they never block the grid paint.
|
|
void buildCrumbs(pathSegments).then((trail) => {
|
|
if (seq === loadSeq) crumbs = trail;
|
|
});
|
|
|
|
// Resolve the current folder's drive_id so the read-only banner
|
|
// works even on deep-links into a sub-folder. Guarded by `seq`.
|
|
void getFolder(folderId)
|
|
.then((folder) => {
|
|
if (seq === loadSeq) currentFolderDriveId = folder.drive_id;
|
|
})
|
|
.catch(() => {
|
|
// Fallback chain in `currentDrive` still gives us a
|
|
// best-effort drive resolution.
|
|
});
|
|
} else {
|
|
// Append path: reuse `currentId`. `pageCursor === undefined` means
|
|
// we've already reached the last page; treat as no-op.
|
|
if (!currentId || pageCursor === undefined) return;
|
|
folderId = currentId;
|
|
}
|
|
|
|
try {
|
|
const page = await fetchFolderPage(folderId, {
|
|
orderBy: sortField,
|
|
reverse: reversed,
|
|
cursor: reset ? undefined : pageCursor
|
|
});
|
|
if (seq !== loadSeq) return; // superseded by a newer navigation
|
|
if (reset) {
|
|
listing = {
|
|
folders: page.folders,
|
|
files: page.files
|
|
};
|
|
orderedItems = page.items;
|
|
} else {
|
|
listing = {
|
|
folders: [...listing.folders, ...page.folders],
|
|
files: [...listing.files, ...page.files]
|
|
};
|
|
orderedItems = [...orderedItems, ...page.items];
|
|
}
|
|
pageCursor = page.nextCursor;
|
|
error = null;
|
|
} catch (e) {
|
|
if (seq !== loadSeq) return;
|
|
const status = (e as { status?: number })?.status;
|
|
error =
|
|
status === 403
|
|
? t('errors.forbidden', 'Could not load files')
|
|
: e instanceof Error
|
|
? e.message
|
|
: String(e);
|
|
} finally {
|
|
if (skeletonTimer !== undefined) clearTimeout(skeletonTimer);
|
|
if (seq === loadSeq && reset) {
|
|
loading = false;
|
|
showSkeleton = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetch and append the next page. Invoked by ResourceList's
|
|
* IntersectionObserver when the bottom sentinel enters the viewport.
|
|
* The `loadingMore` guard collapses a double-fire (the observer can
|
|
* tick twice on the same intersection edge).
|
|
*/
|
|
async function loadMore() {
|
|
if (loadingMore || pageCursor === undefined) return;
|
|
loadingMore = true;
|
|
try {
|
|
await load(false);
|
|
} finally {
|
|
loadingMore = false;
|
|
}
|
|
}
|
|
|
|
/** Data changed — drop cached listings and reload the current folder fresh. */
|
|
async function reload() {
|
|
invalidateFolderCache();
|
|
await load(true);
|
|
}
|
|
|
|
/**
|
|
* Reload + populate the "new elements" swimlane with anything that
|
|
* appeared on page 1 after the mutation.
|
|
*
|
|
* Called from mutation paths that ADD items (upload / dropped tree /
|
|
* create-folder). Renames, deletes, moves use plain `reload()`
|
|
* — nothing new to hoist.
|
|
*/
|
|
async function reloadAndTrackNew(): Promise<void> {
|
|
const before = new SvelteSet<string>();
|
|
for (const it of orderedItems) before.add(it.id);
|
|
await reload();
|
|
// `reload()` resets `pageCursor` + fetches page 1 fresh, so
|
|
// `orderedItems` is now the freshly-loaded page. Every id that
|
|
// wasn't there before this reload joins the swimlane.
|
|
newlyAdded.clear();
|
|
for (const it of orderedItems) if (!before.has(it.id)) newlyAdded.add(it.id);
|
|
// Scroll the page back to the top so the freshly-hoisted "New
|
|
// elements" swimlane is visible without the user having to hunt
|
|
// for it — the whole point of the swimlane is to confirm "your
|
|
// upload landed". Only fires when we actually detected new items,
|
|
// so a bare reload doesn't yank the user's scroll position.
|
|
// Smooth scroll for the visual continuity — instant would feel
|
|
// like the page reloaded. `scrollTo` at (0, 0) is a no-op if
|
|
// the user was already at the top; no jitter cost.
|
|
if (newlyAdded.size > 0 && typeof window !== 'undefined') {
|
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
}
|
|
}
|
|
|
|
function openFolder(folder: FolderItem) {
|
|
goto(resolve(`/files/${[...pathSegments, folder.id].join('/')}`));
|
|
}
|
|
|
|
async function onNewFolder() {
|
|
const name = await promptDialog({
|
|
title: t('files.new_folder', 'New folder'),
|
|
placeholder: t('files.new_folder_prompt', 'New folder name'),
|
|
confirmText: t('common.create', 'Create')
|
|
});
|
|
if (!name) return;
|
|
try {
|
|
await createFolder(name, currentId);
|
|
await reloadAndTrackNew();
|
|
// Vanish-warning: user just made a `.folder` and it's
|
|
// already hidden by their preference — otherwise the new
|
|
// folder would appear to have not been created. Third hook
|
|
// point in the "creating a dotfile while hide is on" family
|
|
// (upload + rename cover the other two).
|
|
if (preferences.hideDotfiles && name.startsWith('.')) {
|
|
ui.notify(
|
|
t(
|
|
'files.new_folder_dotfile_hidden',
|
|
{ name },
|
|
"Created folder '{{name}}' — hidden by your dotfile preference."
|
|
),
|
|
'info'
|
|
);
|
|
}
|
|
} catch (e) {
|
|
errorToast(e);
|
|
}
|
|
}
|
|
|
|
// Upload at most this many files concurrently. Kept low so we stay well under
|
|
// the browser's ~6 connections-per-host budget — leaving headroom for the
|
|
// session-refresh/poll requests and (for genuinely huge files) a delta worker,
|
|
// which itself opens several connections. Over-subscribing here is what made
|
|
// small uploads queue until the watchdog cancelled them ("stuck at N%").
|
|
const UPLOAD_CONCURRENCY = 2;
|
|
|
|
/** Outer backstop deadline (ms). The plain-upload path already self-aborts on
|
|
* a stalled connection (see `uploadFileWithProgress`); this only catches a
|
|
* wedged delta worker or by-hash request so a lane can never hang forever. */
|
|
const FILE_BACKSTOP_MS = 4 * 60_000;
|
|
|
|
/** Reject after `ms` if `p` hasn't settled. */
|
|
function withTimeout<T>(p: Promise<T>, ms: number): Promise<T> {
|
|
return new Promise((resolve, reject) => {
|
|
const timer = setTimeout(() => reject(new Error('upload timed out')), ms);
|
|
p.then(
|
|
(v) => {
|
|
clearTimeout(timer);
|
|
resolve(v);
|
|
},
|
|
(e) => {
|
|
clearTimeout(timer);
|
|
reject(e);
|
|
}
|
|
);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Upload one file through the best available path, returning the bytes saved
|
|
* by deduplication (0 when the body was sent in full). Order:
|
|
* 1. Instant by-hash upload — zero bytes when `ownedHash` is set (the batch
|
|
* check found the server already has this exact blob).
|
|
* 2. Delta upload — sub-file CDC dedup for large files (>= 8 MB).
|
|
* 3. Plain byte upload — fallback when neither applies.
|
|
* Throws on a hard failure; the error carries `isQuota` so the batch can stop
|
|
* early when the disk is full.
|
|
*/
|
|
async function uploadOneFile(
|
|
folderId: string | null,
|
|
file: File,
|
|
report: (frac: number) => void,
|
|
ownedHash: string | null
|
|
): Promise<number> {
|
|
const dedup =
|
|
(ownedHash && folderId ? await instantUploadOwned(folderId, file, ownedHash) : null) ??
|
|
(await tryDeltaUpload(file, folderId, (pct) => report(pct / 100)));
|
|
if (dedup) {
|
|
if (!dedup.ok) {
|
|
const err = new Error(dedup.errorMsg ?? 'upload failed') as Error & { isQuota?: boolean };
|
|
err.isQuota = dedup.isQuotaError ?? false;
|
|
throw err;
|
|
}
|
|
return dedup.savedBytes ?? 0;
|
|
}
|
|
await uploadFileWithProgress(folderId, file, report);
|
|
return 0;
|
|
}
|
|
|
|
/**
|
|
* Upload one file, retrying once on a transient failure. A connection the
|
|
* watchdog aborted, or an upload the server actually committed before the
|
|
* client gave up, both recover here: the backend treats a re-upload of
|
|
* byte-identical content as success (idempotent), so the retry is a clean
|
|
* no-op for anything that already landed and a real second chance for the
|
|
* rest. A quota error is never retried — the disk won't free up mid-batch.
|
|
*/
|
|
async function uploadWithRetry(
|
|
folderId: string | null,
|
|
file: File,
|
|
report: (frac: number) => void,
|
|
ownedHash: string | null
|
|
): Promise<number> {
|
|
try {
|
|
return await withTimeout(uploadOneFile(folderId, file, report, ownedHash), FILE_BACKSTOP_MS);
|
|
} catch (e) {
|
|
if ((e as { isQuota?: boolean } | null)?.isQuota) throw e;
|
|
report(0); // reset this file's progress for the second attempt
|
|
return await withTimeout(uploadOneFile(folderId, file, report, ownedHash), FILE_BACKSTOP_MS);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Probe whether a file's content can actually be read. FIFOs / sockets /
|
|
* device files — e.g. the `supervise/control` named pipes in a copied
|
|
* s6/runit service tree — report a size but BLOCK FOREVER on read; uploading
|
|
* one would hang its lane until the watchdog (~30 s). We read only the first
|
|
* chunk, raced against a short timeout: a normal file (even 0-byte) yields
|
|
* immediately, a pipe stalls and is reported unreadable so we can skip it fast.
|
|
*/
|
|
async function isReadable(file: File, timeoutMs = 3000): Promise<boolean> {
|
|
if (typeof file.stream !== 'function') return true; // can't probe → let the watchdog catch it
|
|
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
|
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
try {
|
|
reader = file.stream().getReader();
|
|
const firstChunk = reader.read().then(() => true);
|
|
const timedOut = new Promise<false>((res) => {
|
|
timer = setTimeout(() => res(false), timeoutMs);
|
|
});
|
|
return await Promise.race([firstChunk, timedOut]);
|
|
} catch {
|
|
return false; // read threw → not a normal, uploadable file
|
|
} finally {
|
|
clearTimeout(timer);
|
|
reader?.cancel().catch(() => {});
|
|
}
|
|
}
|
|
|
|
/** Map `fn` over `items` with at most `limit` concurrent calls, preserving order. */
|
|
async function mapLimit<T, R>(
|
|
items: T[],
|
|
limit: number,
|
|
fn: (item: T) => Promise<R>
|
|
): Promise<R[]> {
|
|
const out = new Array<R>(items.length);
|
|
let next = 0;
|
|
const worker = async () => {
|
|
while (next < items.length) {
|
|
const i = next++;
|
|
out[i] = await fn(items[i]);
|
|
}
|
|
};
|
|
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
|
return out;
|
|
}
|
|
|
|
/** Split items into the readable ones and the unreadable (FIFO/socket/…) ones. */
|
|
async function partitionReadable<T>(
|
|
items: T[],
|
|
fileOf: (item: T) => File
|
|
): Promise<{ readable: T[]; skipped: T[] }> {
|
|
const ok = await mapLimit(items, 24, (it) => isReadable(fileOf(it)));
|
|
const readable: T[] = [];
|
|
const skipped: T[] = [];
|
|
items.forEach((it, i) => (ok[i] ? readable : skipped).push(it));
|
|
return { readable, skipped };
|
|
}
|
|
|
|
/**
|
|
* Upload `items` ({file, folderId}) with bounded concurrency, a per-file
|
|
* deadline and live aggregate progress. A stuck or failing file no longer
|
|
* freezes the batch: it blocks only its own lane (the rest keep going), is
|
|
* retried once, and only then counted as failed. Quota exhaustion stops the
|
|
* run early. Returns the bytes deduplicated and the count of files that failed.
|
|
*/
|
|
async function uploadAll(
|
|
items: { file: File; folderId: string | null }[],
|
|
nid: number,
|
|
label: (done: number) => string
|
|
): Promise<{ savedBytes: number; failures: number }> {
|
|
const total = items.length;
|
|
const owned = await resolveOwnedHashes(items.map((it) => it.file));
|
|
const frac = new Array<number>(total).fill(0);
|
|
let progressSum = 0;
|
|
let savedBytes = 0;
|
|
let failures = 0;
|
|
let next = 0;
|
|
|
|
const refresh = () => {
|
|
ui.updateProgress(
|
|
nid,
|
|
Math.round((progressSum / total) * 100),
|
|
label(Math.round(progressSum))
|
|
);
|
|
};
|
|
|
|
const worker = async () => {
|
|
while (next < total) {
|
|
const i = next++;
|
|
const { file, folderId } = items[i];
|
|
const report = (f: number) => {
|
|
if (!Number.isNaN(f)) {
|
|
const updated = Math.min(1, f);
|
|
progressSum += updated - frac[i];
|
|
frac[i] = updated;
|
|
}
|
|
refresh();
|
|
};
|
|
try {
|
|
savedBytes += await uploadWithRetry(folderId, file, report, owned.get(file) ?? null);
|
|
} catch (e) {
|
|
failures++;
|
|
// A full disk won't recover within this batch — stop pulling new
|
|
// work so we don't fire hundreds of doomed uploads.
|
|
if ((e as { isQuota?: boolean } | null)?.isQuota) next = total;
|
|
} finally {
|
|
progressSum += 1 - frac[i];
|
|
frac[i] = 1;
|
|
refresh();
|
|
}
|
|
}
|
|
};
|
|
|
|
await Promise.all(Array.from({ length: Math.min(UPLOAD_CONCURRENCY, total) }, worker));
|
|
return { savedBytes, failures };
|
|
}
|
|
|
|
/** Resolve the upload's bell notification: success, partial, skipped, or failure.
|
|
* `total` is the count of *readable* files actually attempted; `skipped` is the
|
|
* non-regular files (FIFOs/sockets/…) that were filtered out before uploading. */
|
|
function finishUpload(
|
|
nid: number,
|
|
savedBytes: number,
|
|
failures: number,
|
|
total: number,
|
|
skipped = 0
|
|
) {
|
|
const ok = total - failures;
|
|
if (failures === 0 && skipped === 0) {
|
|
ui.finishProgress(nid, uploadDoneMessage(savedBytes), 'success');
|
|
} else if (failures === 0) {
|
|
// Every readable file uploaded; the rest were non-regular files
|
|
// (FIFOs/sockets/devices) that can't be read and were skipped.
|
|
ui.finishProgress(
|
|
nid,
|
|
t(
|
|
'files.uploaded_skipped',
|
|
{ ok, skipped },
|
|
`${ok} uploaded · ${skipped} skipped (not regular files)`
|
|
),
|
|
ok > 0 ? 'success' : 'warning'
|
|
);
|
|
} else if (ok > 0) {
|
|
ui.finishProgress(
|
|
nid,
|
|
t('files.uploaded_partial', { ok, failed: failures }, `${ok} uploaded, ${failures} failed`),
|
|
'warning'
|
|
);
|
|
} else {
|
|
ui.finishProgress(nid, t('files.upload_failed', 'Upload failed'), 'error');
|
|
}
|
|
}
|
|
|
|
/** Final bell message for a fully-successful upload, noting deduplicated bytes. */
|
|
function uploadDoneMessage(savedBytes: number): string {
|
|
if (savedBytes <= 0) return t('files.uploaded', 'Upload complete');
|
|
const mb = (savedBytes / (1024 * 1024)).toFixed(1);
|
|
return t('files.uploaded_saved', { mb }, `Upload complete — ${mb} MB deduplicated`);
|
|
}
|
|
|
|
/**
|
|
* Upload a batch of files into the current folder, reporting aggregate
|
|
* progress through a single bell notification with a progress bar.
|
|
*/
|
|
async function uploadBatch(files: File[]) {
|
|
if (files.length === 0) return;
|
|
uploading = true;
|
|
const nid = ui.startProgress(
|
|
t('files.uploading_n', { done: 0, total: files.length }, `Uploading 0/${files.length} files…`)
|
|
);
|
|
try {
|
|
// Filter out non-regular files (FIFOs/sockets/devices) up front so they
|
|
// can't hang a lane — progress then runs over only what's uploadable.
|
|
const { readable, skipped } = await partitionReadable(files, (f) => f);
|
|
const total = readable.length;
|
|
const label = (done: number) =>
|
|
total === 1
|
|
? t('files.uploading_file', { name: readable[0].name }, `Uploading ${readable[0].name}…`)
|
|
: t('files.uploading_n', { done, total }, `Uploading ${done}/${total} files…`);
|
|
if (total > 0) {
|
|
const { savedBytes, failures } = await uploadAll(
|
|
readable.map((file) => ({ file, folderId: currentId })),
|
|
nid,
|
|
label
|
|
);
|
|
finishUpload(nid, savedBytes, failures, total, skipped.length);
|
|
} else {
|
|
finishUpload(nid, 0, 0, 0, skipped.length);
|
|
}
|
|
await reloadAndTrackNew();
|
|
// Storage usage changed server-side — pull the fresh figure so the
|
|
// "Almacenamiento" bar moves off its login value instead of 0%.
|
|
void session.refresh();
|
|
// Vanish-warning: if hide-dotfiles is on and any uploaded
|
|
// files start with `.`, the successfully-uploaded rows are
|
|
// invisible in the grid the moment they land. Fire a
|
|
// single grouped nudge so users don't think the upload
|
|
// failed. Only fires when the preference is on AND at
|
|
// least one uploaded file matched. Bell notification
|
|
// stays quiet (already covers success/failure counts).
|
|
if (preferences.hideDotfiles) {
|
|
const hidden = files.filter((f) => f.name.startsWith('.')).length;
|
|
if (hidden > 0) {
|
|
ui.notify(
|
|
t(
|
|
'files.upload_dotfile_hidden',
|
|
{ n: hidden },
|
|
'{{n}} file(s) uploaded but hidden by your dotfile preference.'
|
|
),
|
|
'info'
|
|
);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
ui.finishProgress(nid, errorMessage(err), 'error');
|
|
} finally {
|
|
uploading = false;
|
|
}
|
|
}
|
|
|
|
async function onUpload(e: Event) {
|
|
const input = e.target as HTMLInputElement;
|
|
if (!input.files?.length) return;
|
|
await uploadBatch(Array.from(input.files));
|
|
input.value = '';
|
|
}
|
|
|
|
async function onDrop(e: DragEvent) {
|
|
e.preventDefault();
|
|
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<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) {
|
|
const name = await promptDialog({
|
|
title: t('common.rename', 'Rename'),
|
|
defaultValue: current,
|
|
confirmText: t('common.save', 'Save')
|
|
});
|
|
if (!name || name === current) return;
|
|
try {
|
|
if (kind === 'file') await renameFile(id, name);
|
|
else {
|
|
await renameFolder(id, name);
|
|
rememberFolderName(id, name); // keep breadcrumbs current immediately
|
|
}
|
|
await reload();
|
|
// Vanish-warning: the file didn't start with `.` before but
|
|
// does now, AND the user has hide-dotfiles on → the row is
|
|
// about to disappear from the grid. Toast so the operation
|
|
// doesn't feel like a silent failure. Only fires on the
|
|
// transition (`.env` renamed to `.env2` doesn't need the
|
|
// nudge — it was already hidden). No toast when hide is off
|
|
// because nothing vanished.
|
|
if (preferences.hideDotfiles && name.startsWith('.') && !current.startsWith('.')) {
|
|
ui.notify(
|
|
t(
|
|
'files.rename_dotfile_hidden',
|
|
{ name },
|
|
"Renamed to '{{name}}' — now hidden by your preference."
|
|
),
|
|
'info'
|
|
);
|
|
}
|
|
} catch (e) {
|
|
errorToast(e);
|
|
}
|
|
}
|
|
|
|
async function deleteItem(kind: 'file' | 'folder', id: string, name: string) {
|
|
const ok = await confirmDialog({
|
|
title: t('common.delete', 'Delete'),
|
|
message: t('files.confirm_delete', { name }, 'Move "{{name}}" to trash?'),
|
|
confirmText: t('common.delete', 'Delete'),
|
|
danger: true
|
|
});
|
|
if (!ok) return;
|
|
try {
|
|
if (kind === 'file') await deleteFile(id);
|
|
else await deleteFolder(id);
|
|
await reload();
|
|
void session.refresh();
|
|
} catch (e) {
|
|
errorToast(e);
|
|
}
|
|
}
|
|
|
|
let viewerOpen = $state(false);
|
|
let viewerFile = $state<FileItem | null>(null);
|
|
|
|
function openFile(file: FileItem) {
|
|
// Drive the viewer from the URL (?file=<id>) 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);
|
|
// Same-origin URL object built from page.url (already resolved); resolve()
|
|
// only accepts a route string, so it can't type a dynamic URL instance.
|
|
// eslint-disable-next-line svelte/no-navigation-without-resolve
|
|
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 the user closes the viewer (X / Esc / backdrop), drop the
|
|
// `?file=` param (replaceState, so closing doesn't add a history entry). Only
|
|
// act on a genuine open→closed transition: on a cold deep link the viewer
|
|
// starts closed *with* the param while the listing is still loading, and
|
|
// stripping it there would race the URL→viewer effect above and the preview
|
|
// would never open.
|
|
let viewerWasOpen = false;
|
|
$effect(() => {
|
|
const open = viewerOpen;
|
|
const hasParam = page.url.searchParams.get('file') !== null;
|
|
untrack(() => {
|
|
if (viewerWasOpen && !open && hasParam) {
|
|
const url = new URL(page.url);
|
|
url.searchParams.delete('file');
|
|
// Same-origin URL object (see note above); resolve() can't type it.
|
|
// eslint-disable-next-line svelte/no-navigation-without-resolve
|
|
void goto(url, { keepFocus: true, noScroll: true, replaceState: true });
|
|
}
|
|
viewerWasOpen = open;
|
|
});
|
|
});
|
|
|
|
// ── Multi-select + batch ────────────────────────────────────────────────
|
|
// After the ResourceList migration the row-level selection UX (shift-
|
|
// range, ctrl-toggle, anchor tracking, header select-all) lives inside
|
|
// `<ResourceList>` and mirrors state OUT via `onselectionchange`. This
|
|
// SvelteSet is the local reflection the batch action functions consume;
|
|
// it stays a plain in-place `SvelteSet` (per benches ROUND11 §S2) so
|
|
// batch buttons see the same set as the row template does.
|
|
const selected = new SvelteSet<string>();
|
|
|
|
function clearSelection() {
|
|
selected.clear();
|
|
}
|
|
|
|
/**
|
|
* Download the whole selection as a single zip via POST /api/batch/download —
|
|
* 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[] = [];
|
|
// One O(M) pass over the listing instead of an O(N·M) `some` per id.
|
|
const folderIdSet = new Set(listing.folders.map((f) => f.id));
|
|
const fileIdSet = new Set(listing.files.map((f) => f.id));
|
|
for (const id of selected) {
|
|
if (folderIdSet.has(id)) folderIds.push(id);
|
|
else if (fileIdSet.has(id)) fileIds.push(id);
|
|
}
|
|
if (fileIds.length === 0 && folderIds.length === 0) return;
|
|
|
|
// Single file, no folders → direct download (preserves the real name).
|
|
if (fileIds.length === 1 && folderIds.length === 0) {
|
|
const file = listing.files.find((f) => f.id === fileIds[0]);
|
|
if (file) {
|
|
const a = document.createElement('a');
|
|
a.href = fileDownloadUrl(file.id);
|
|
a.download = file.name;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
a.remove();
|
|
}
|
|
return;
|
|
}
|
|
|
|
const zipName = batchZipName();
|
|
try {
|
|
const res = await apiFetch('/api/batch/download', {
|
|
method: 'POST',
|
|
credentials: 'same-origin',
|
|
headers: { 'Content-Type': 'application/json', ...getCsrfHeaders() },
|
|
body: JSON.stringify({ file_ids: fileIds, folder_ids: folderIds })
|
|
});
|
|
if (!res.ok) throw new Error(`Server returned ${res.status}`);
|
|
const blob = await res.blob();
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = zipName;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
a.remove();
|
|
URL.revokeObjectURL(url);
|
|
} catch (e) {
|
|
errorToast(e);
|
|
}
|
|
}
|
|
|
|
/** Batch add the selection to favorites — single /api/favorites/batch call. */
|
|
async function batchFavorites() {
|
|
// Build an id → item index so the "already favorite" filter is
|
|
// O(1) per selection member instead of an O(N·M) scan. Reused
|
|
// after success to flip `is_favorite` in place on each row.
|
|
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- ephemeral local index, discarded before any reactive read
|
|
const byId = new Map<string, FileItem | FolderItem>();
|
|
for (const it of orderedItems) byId.set(it.id, it);
|
|
const items = selectionTargets().filter((it) => !(byId.get(it.id)?.is_favorite ?? false));
|
|
if (items.length === 0) {
|
|
ui.notify(t('files.already_favorites', 'All selected items are already favorites'), 'info');
|
|
clearSelection();
|
|
return;
|
|
}
|
|
try {
|
|
const res = await apiFetch('/api/favorites/batch', {
|
|
method: 'POST',
|
|
credentials: 'same-origin',
|
|
headers: { 'Content-Type': 'application/json', ...getCsrfHeaders() },
|
|
body: JSON.stringify({
|
|
items: items.map((it) => ({ item_id: it.id, item_type: it.kind }))
|
|
})
|
|
});
|
|
if (!res.ok) throw new Error(`Server returned ${res.status}`);
|
|
for (const it of items) {
|
|
const row = byId.get(it.id);
|
|
if (row) row.is_favorite = true;
|
|
}
|
|
ui.notify(t('files.added_favorites', 'Added to favorites'), 'success');
|
|
clearSelection();
|
|
} catch (e) {
|
|
errorToast(e);
|
|
}
|
|
}
|
|
|
|
function selectionTargets(): ActionTarget[] {
|
|
// One O(M) index build instead of an O(N·M) `find` per selected id.
|
|
// Folders win id collisions, matching the old folder-first probe.
|
|
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- ephemeral local index, discarded before any reactive read
|
|
const byId = new Map<string, ActionTarget>();
|
|
for (const f of listing.files) byId.set(f.id, { id: f.id, name: f.name, kind: 'file' });
|
|
for (const f of listing.folders) byId.set(f.id, { id: f.id, name: f.name, kind: 'folder' });
|
|
return [...selected]
|
|
.map((id) => byId.get(id) ?? null)
|
|
.filter((x): x is ActionTarget => x !== null);
|
|
}
|
|
|
|
function batchMove() {
|
|
const items = selectionTargets();
|
|
if (items.length) {
|
|
moveItems = items;
|
|
moveMode = 'move';
|
|
moveOpen = true;
|
|
}
|
|
}
|
|
|
|
function batchCopy() {
|
|
const items = selectionTargets();
|
|
if (items.length) {
|
|
moveItems = items;
|
|
moveMode = 'copy';
|
|
moveOpen = true;
|
|
}
|
|
}
|
|
|
|
function onKeydown(e: KeyboardEvent) {
|
|
const tag = (e.target as HTMLElement)?.tagName;
|
|
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
|
|
if (e.key === 'Escape' && selected.size) {
|
|
clearSelection();
|
|
} else if (e.key === 'Delete' && selected.size) {
|
|
// Delete only — Backspace was dropped: it triggered accidental deletes.
|
|
e.preventDefault();
|
|
void batchDelete();
|
|
}
|
|
// Ctrl+A "select all" moved to the list-header checkbox owned by
|
|
// ResourceList — the row-level selection UX now lives entirely
|
|
// there, so the shortcut is served by clicking that box. Kept the
|
|
// binding surface here for the still-page-level Escape / Delete
|
|
// gestures that reference the local `selected` mirror.
|
|
}
|
|
|
|
async function batchDelete() {
|
|
const ids = [...selected];
|
|
const ok = await confirmDialog({
|
|
title: t('files.batch_delete', 'Delete selected'),
|
|
message: t('files.confirm_batch_delete', { n: ids.length }, 'Move {{n}} items to trash?'),
|
|
confirmText: t('common.delete', 'Delete'),
|
|
danger: true
|
|
});
|
|
if (!ok) return;
|
|
// Bounded fan-out instead of a serial await per item: 100 deletes at
|
|
// ~30 ms RTT collapse from ~3 s of waterfall to a few round-trip
|
|
// windows. Failures toast individually and the rest still proceed,
|
|
// exactly like the old serial loop.
|
|
const folderIdSet = new Set(listing.folders.map((f) => f.id));
|
|
await mapLimit(ids, 6, async (id) => {
|
|
try {
|
|
if (folderIdSet.has(id)) await deleteFolder(id);
|
|
else await deleteFile(id);
|
|
} catch (e) {
|
|
errorToast(e);
|
|
}
|
|
});
|
|
clearSelection();
|
|
await reload();
|
|
void session.refresh();
|
|
}
|
|
|
|
// ── Drag-to-move ─────────────────────────────────────────────────────────
|
|
const DRAG_TYPE = 'application/x-oxi-item';
|
|
let dropFolderId = $state<string | null>(null);
|
|
// Highlighted breadcrumb crumb during an OxiCloud drag. Holds the
|
|
// crumb's folder id, or the sentinel `'__home__'` for the home link
|
|
// (which doesn't have a stable folder id — depends on the caller's
|
|
// home folder resolution).
|
|
const CRUMB_HOME_ID = '__home__';
|
|
let dropCrumbId = $state<string | null>(null);
|
|
|
|
// Copy-vs-move on drop.
|
|
//
|
|
// Cursor visual: `effectAllowed = 'copyMove'` set at dragstart AND
|
|
// the dragover handlers touching only `preventDefault()` (no
|
|
// `dropEffect` write) leaves the browser free to negotiate the
|
|
// cursor glyph from OS-native modifier keys (macOS Option, Win/Linux
|
|
// Ctrl) — arrow-with-plus for copy, plain arrow for move.
|
|
//
|
|
// Routing: at DROP time we read the modifier keys DIRECTLY on the
|
|
// event (DragEvent extends MouseEvent, so `altKey`/`metaKey`/`ctrlKey`
|
|
// are all live). This is deliberately independent of
|
|
// `dataTransfer.dropEffect` — that value isn't reliably updated by
|
|
// every browser for JS-initiated drags, but the raw modifier bits
|
|
// on the drop event ARE. One source of truth, one path.
|
|
//
|
|
// OS convention:
|
|
// * macOS: ⌥ Option (altKey) → copy — Finder convention. ⌘
|
|
// (metaKey) accepted too as a forgiving fallback.
|
|
// * Windows / Linux: Ctrl (ctrlKey) → copy — Explorer / Nautilus
|
|
// convention.
|
|
const IS_MAC =
|
|
typeof navigator !== 'undefined' &&
|
|
/Mac|iPhone|iPad|iPod/i.test(navigator.platform || navigator.userAgent || '');
|
|
function isCopyDrop(e: DragEvent): boolean {
|
|
return IS_MAC ? e.altKey || e.metaKey : e.ctrlKey;
|
|
}
|
|
|
|
/**
|
|
* Begin dragging an item. When the dragged row is part of the current
|
|
* selection, the whole selection travels (mirrors ui.js' multi-item drag);
|
|
* otherwise just the single item moves.
|
|
*/
|
|
function onItemDragStart(e: DragEvent, kind: ItemType, id: string, name: string) {
|
|
const items: ActionTarget[] =
|
|
selected.has(id) && selected.size > 1 ? selectionTargets() : [{ id, name, kind }];
|
|
e.dataTransfer?.setData(DRAG_TYPE, JSON.stringify(items));
|
|
if (e.dataTransfer) {
|
|
// `copyMove` advertises both operations; the drop-target's
|
|
// `dropEffect` (set on dragover by our handlers based on
|
|
// modifier key) picks the effective operation and the OS
|
|
// cursor reflects it (arrow-with-plus for copy, plain for
|
|
// move). Was `'move'`, which forced the cursor to always
|
|
// show move even when Ctrl/Cmd was held.
|
|
e.dataTransfer.effectAllowed = 'copyMove';
|
|
// Custom drag ghost for both single and multi-item drags —
|
|
// consistent UX. The `.dragged-items-badge` always shows
|
|
// the count (reads "1" on a single-item drag) so the user
|
|
// gets the same visual feedback shape regardless of how
|
|
// many rows they're moving.
|
|
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);
|
|
// Transient query-string builder for a one-off download URL — not reactive
|
|
// state, so a plain URLSearchParams is correct here.
|
|
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
|
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[] {
|
|
const raw = e.dataTransfer?.getData(DRAG_TYPE);
|
|
if (!raw) return [];
|
|
try {
|
|
const parsed = JSON.parse(raw);
|
|
return Array.isArray(parsed) ? parsed : [parsed];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
async function moveInto(targetFolderId: string, e: DragEvent) {
|
|
const items = dragPayload(e).filter((it) => it.id !== targetFolderId);
|
|
if (items.length === 0) return;
|
|
// Bounded fan-out (was a serial await per item). Every item is
|
|
// attempted; on any failure the first error is surfaced and the
|
|
// selection is kept so the drop can be retried, like the old loop.
|
|
const failures = (
|
|
await mapLimit(items, 6, async (it) => {
|
|
try {
|
|
if (it.kind === 'file') await moveFile(it.id, targetFolderId);
|
|
else await moveFolder(it.id, targetFolderId);
|
|
return null;
|
|
} catch (err) {
|
|
return err ?? new Error('move failed');
|
|
}
|
|
})
|
|
).filter((err) => err !== null);
|
|
if (failures.length > 0) {
|
|
errorToast(failures[0]);
|
|
return;
|
|
}
|
|
clearSelection();
|
|
await reload();
|
|
}
|
|
|
|
/**
|
|
* Copy variant of `moveInto` used when the drop happens with the copy
|
|
* modifier held (Ctrl on Win/Linux, ⌘ on macOS). Files and folders go
|
|
* through the batch copy endpoints (`copyFiles` / `copyFolders`) —
|
|
* there's no per-item copy in the REST API today, only batch.
|
|
*/
|
|
async function copyInto(targetFolderId: string, e: DragEvent) {
|
|
const items = dragPayload(e).filter((it) => it.id !== targetFolderId);
|
|
if (items.length === 0) return;
|
|
const fileIds = items.filter((it) => it.kind === 'file').map((it) => it.id);
|
|
const folderIds = items.filter((it) => it.kind === 'folder').map((it) => it.id);
|
|
try {
|
|
// Two batch calls in parallel — the endpoints are independent
|
|
// and the aggregate error surface is a single toast anyway.
|
|
await Promise.all([
|
|
copyFiles(fileIds, targetFolderId),
|
|
copyFolders(folderIds, targetFolderId)
|
|
]);
|
|
} catch (err) {
|
|
errorToast(err);
|
|
return;
|
|
}
|
|
clearSelection();
|
|
await reloadAndTrackNew();
|
|
}
|
|
|
|
function onFolderDrop(e: DragEvent, folder: FolderItem) {
|
|
if (!e.dataTransfer?.types.includes(DRAG_TYPE)) return; // external file drop → page dropzone
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
dropFolderId = null;
|
|
if (isCopyDrop(e)) void copyInto(folder.id, e);
|
|
else void moveInto(folder.id, e);
|
|
}
|
|
|
|
function onCrumbDrop(e: DragEvent, folderId: string) {
|
|
if (!e.dataTransfer?.types.includes(DRAG_TYPE)) return;
|
|
e.preventDefault();
|
|
if (isCopyDrop(e)) void copyInto(folderId, e);
|
|
else void moveInto(folderId, e);
|
|
}
|
|
|
|
// ── Right-click context menu ─────────────────────────────────────────────
|
|
let ctxOpen = $state(false);
|
|
let ctxX = $state(0);
|
|
let ctxY = $state(0);
|
|
let ctxTarget = $state<ActionTarget | null>(null);
|
|
|
|
function openContext(e: MouseEvent, kind: ItemType, id: string, name: string) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
ctxTarget = { id, name, kind };
|
|
// Clamp to viewport so the menu never overflows offscreen.
|
|
ctxX = Math.min(e.clientX, window.innerWidth - 200);
|
|
ctxY = Math.min(e.clientY, window.innerHeight - 320);
|
|
ctxOpen = true;
|
|
}
|
|
function closeContext() {
|
|
ctxOpen = false;
|
|
ctxTarget = null;
|
|
}
|
|
|
|
// ── WOPI office editor (context-menu entries) ────────────────────────────
|
|
let wopiOpen = $state(false);
|
|
let wopiAction = $state<'edit' | 'view'>('edit');
|
|
let wopiFile = $state<{ id: string; name: string } | null>(null);
|
|
|
|
// Pull in the on-demand modules the moment they're first needed; after that
|
|
// the chunk is cached and the component stays mounted (controlled by `open`).
|
|
$effect(() => {
|
|
if (viewerOpen) void fileViewer.load();
|
|
if (wopiOpen) void wopiEditor.load();
|
|
if (moveOpen) void moveDialog.load();
|
|
if (shareOpen) void shareDialog.load();
|
|
});
|
|
// Editability of the current context-menu target file, resolved async.
|
|
let ctxCanEditWopi = $state(false);
|
|
|
|
// Probe WOPI editability whenever the context target changes to a file. Image
|
|
// files use the inline viewer, so they never show the editor entries.
|
|
$effect(() => {
|
|
const tg = ctxTarget;
|
|
ctxCanEditWopi = false;
|
|
if (!tg || tg.kind !== 'file') return;
|
|
const f = listing.files.find((x) => x.id === tg.id);
|
|
if (!f || (f.mime_type ?? '').startsWith('image/')) return;
|
|
void canEditWithWopi(tg.name).then((ok) => {
|
|
// Guard against a stale resolve after the menu moved to another target.
|
|
if (ctxTarget?.id === tg.id) ctxCanEditWopi = ok;
|
|
});
|
|
});
|
|
|
|
function openWopi(id: string, name: string, action: 'edit' | 'view') {
|
|
wopiFile = { id, name };
|
|
wopiAction = action;
|
|
wopiOpen = true;
|
|
}
|
|
|
|
async function openWopiTab(id: string, name: string) {
|
|
// New-tab editing posts the token to /wopi/edit; open a blank tab first so
|
|
// the editor URL fetch (async) isn't treated as a popup by the browser.
|
|
const win = window.open('', '_blank');
|
|
try {
|
|
const data = await getEditorUrlWithFallback(id, name, 'edit');
|
|
const url = `/wopi/edit/${encodeURIComponent(id)}?access_token=${encodeURIComponent(data.access_token)}`;
|
|
if (win) win.location.href = url;
|
|
else window.open(url, '_blank');
|
|
} catch (e) {
|
|
win?.close();
|
|
errorToast(e);
|
|
}
|
|
}
|
|
|
|
// ── Add audio file to a playlist ──────────────────────────────────────────
|
|
function isAudio(file: FileItem | undefined): boolean {
|
|
return (file?.mime_type ?? '').startsWith('audio/');
|
|
}
|
|
|
|
/**
|
|
* Prompt for a playlist (by name) and add the file to it; an unknown name
|
|
* creates a new playlist. Mirrors contextMenus.showPlaylistDialog without the
|
|
* bespoke modal — kept lightweight via the shared prompt dialog.
|
|
*/
|
|
async function addToPlaylist(file: FileItem) {
|
|
let playlists: Awaited<ReturnType<typeof listPlaylists>>;
|
|
try {
|
|
playlists = await listPlaylists();
|
|
} catch (e) {
|
|
errorToast(e);
|
|
return;
|
|
}
|
|
const existing = playlists.map((p) => p.name).join(', ');
|
|
const name = await promptDialog({
|
|
title: t('music.add_to_playlist', 'Add to playlist'),
|
|
message: existing
|
|
? t(
|
|
'music.pick_or_create',
|
|
{ list: existing },
|
|
'Existing: {{list}}. Type a name to add or create.'
|
|
)
|
|
: t('music.create_playlist_hint', 'Type a playlist name to create one.'),
|
|
placeholder: t('music.playlist_name', 'Playlist name'),
|
|
confirmText: t('common.add', 'Add')
|
|
});
|
|
if (!name) return;
|
|
try {
|
|
const match = playlists.find((p) => p.name.toLowerCase() === name.toLowerCase());
|
|
const playlist = match ?? (await createPlaylist(name));
|
|
await addTracks(playlist.id, [file.id]);
|
|
ui.notify(t('music.added_to_playlist', 'Added to playlist'), 'success');
|
|
} catch (e) {
|
|
errorToast(e);
|
|
}
|
|
}
|
|
|
|
// ── Open the parent folder of a file ──────────────────────────────────────
|
|
function openParentFolder(file: FileItem) {
|
|
// The current view already lists files inside their folder; navigate to the
|
|
// file's own folder id (handles deep-link / search contexts where the file's
|
|
// folder differs from the current path).
|
|
goto(resolve(`/files/${file.folder_id}`));
|
|
}
|
|
|
|
// ── Download a folder as a zip archive ────────────────────────────────────
|
|
function downloadFolderZip(folder: { id: string; name: string }) {
|
|
const a = document.createElement('a');
|
|
a.href = folderZipUrl(folder.id);
|
|
a.download = `${folder.name}.zip`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
a.remove();
|
|
}
|
|
|
|
// ── Recursive folder upload ──────────────────────────────────────────────
|
|
let folderInput = $state<HTMLInputElement | null>(null);
|
|
|
|
/**
|
|
* 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;
|
|
// Same bell progress notification as uploadBatch, so folder uploads show
|
|
// live progress + a final result instead of staying silent until the end.
|
|
const nid = ui.startProgress(
|
|
t(
|
|
'files.uploading_n',
|
|
{ done: 0, total: entries.length },
|
|
`Uploading 0/${entries.length} files…`
|
|
)
|
|
);
|
|
try {
|
|
// Drop non-regular files (FIFOs/sockets/devices) before building the tree
|
|
// so they neither create empty folders nor hang a lane on read.
|
|
const { readable, skipped } = await partitionReadable(entries, (e) => e.file);
|
|
const total = readable.length;
|
|
const label = (done: number) =>
|
|
t('files.uploading_n', { done, total }, `Uploading ${done}/${total} files…`);
|
|
if (total === 0) {
|
|
finishUpload(nid, 0, 0, 0, skipped.length);
|
|
await reload();
|
|
return;
|
|
}
|
|
|
|
// Map each relative directory path to its created folder id; '' = current.
|
|
// Local computation scratch map (discarded after upload) — not reactive state.
|
|
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
|
const dirIds = new Map<string, string | null>([['', currentId]]);
|
|
|
|
async function ensureDir(relDir: string): Promise<string | null> {
|
|
if (dirIds.has(relDir)) return dirIds.get(relDir) ?? null;
|
|
const parts = relDir.split('/');
|
|
const name = parts.pop() as string;
|
|
const parentId = await ensureDir(parts.join('/'));
|
|
const created = await createFolder(name, parentId);
|
|
dirIds.set(relDir, created.id);
|
|
return created.id;
|
|
}
|
|
|
|
// Create the folder tree first (sequentially — folders are few, and
|
|
// concurrent creation of the same dir would race), then upload the
|
|
// files into it with bounded concurrency.
|
|
const items: { file: File; folderId: string | null }[] = [];
|
|
for (const { file, relativePath } of readable) {
|
|
const segs = relativePath.split('/');
|
|
segs.pop(); // drop the filename, keep the directory trail
|
|
items.push({ file, folderId: await ensureDir(segs.join('/')) });
|
|
}
|
|
|
|
const { savedBytes, failures } = await uploadAll(items, nid, label);
|
|
finishUpload(nid, savedBytes, failures, total, skipped.length);
|
|
await reloadAndTrackNew();
|
|
void session.refresh();
|
|
} catch (err) {
|
|
ui.finishProgress(nid, errorMessage(err), 'error');
|
|
} finally {
|
|
uploading = false;
|
|
}
|
|
}
|
|
|
|
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 = '';
|
|
}
|
|
|
|
// Client-side sort (flat, Drive-style). The listing endpoint returns the
|
|
// folder contents unsorted; sorting here avoids a refetch per column click.
|
|
//
|
|
// `reversed` is the single source of truth for direction — bound to
|
|
// ResourceList's `bind:reversed` below and read by the comparators.
|
|
// The legacy `sortDir: 1 | -1` value used by the comparators is a
|
|
// read-only `$derived` off `reversed` so we don't need two-way sync
|
|
// (the previous `$state` + two `$effect` mirror was fragile — a
|
|
// programmatic write to either side triggered an update on the
|
|
// other, and eslint's `prefer-writable-derived` rightly flagged it).
|
|
type SortField = 'name' | 'type' | 'size' | 'modified_at' | 'created_at';
|
|
let sortField = $state<SortField>('name');
|
|
let reversed = $state(false);
|
|
|
|
// Server does the sort (order_by=sortField, reverse=reversed on every
|
|
// page request), so ResourceList reads `orderedItems` in server order
|
|
// straight through the dotfile filter. No client-side comparator
|
|
// necessary. Under order_by=name/type/size the server puts folders
|
|
// first then files; under modified_at/created_at they interleave —
|
|
// preserving the accumulator order is what surfaces that correctly.
|
|
//
|
|
// Hoist step: items in `newlyAdded` (populated by `reloadAndTrackNew`
|
|
// after an upload / create / dropped tree) are pulled OUT of their
|
|
// natural-order position and PREPENDED to the list, so the
|
|
// "__new__" bucket rendered by the composed groupBy below appears
|
|
// at the top of the swimlanes regardless of what sort/group the
|
|
// user has active. First-appearance bucketing in
|
|
// `buildResourceSections` keys off the item order in the input list.
|
|
const rlItems = $derived.by<Array<FileItem | FolderItem>>(() => {
|
|
const filtered = filterDotfiles(orderedItems, preferences.hideDotfiles);
|
|
if (newlyAdded.size === 0) return filtered;
|
|
const hoisted: Array<FileItem | FolderItem> = [];
|
|
const rest: Array<FileItem | FolderItem> = [];
|
|
for (const it of filtered) {
|
|
if (newlyAdded.has(it.id)) hoisted.push(it);
|
|
else rest.push(it);
|
|
}
|
|
return [...hoisted, ...rest];
|
|
});
|
|
|
|
// Group-by state (bound to <ResourceList>). Kept as a `string` prop
|
|
// value; the current `sortField` mirrors from the picked group's
|
|
// `orderBy` so a group-by change also drives the sort.
|
|
let groupBy = $state<string>('');
|
|
|
|
// Same swimlane keys as the bespoke groups above (Type / Size /
|
|
// modifiedAt / createdAt). The `orderBy` values are what the
|
|
// GROUP_BYS toolbar emits, so <ResourceList>'s onreload gets the
|
|
// legacy `sortField` name and can drive the same sort path.
|
|
//
|
|
// Every dimension composes a `__new__` branch on top of its natural
|
|
// `bucketOf` so that whenever the transient "new elements" swimlane
|
|
// is active, hoisted items get their own bucket-first-in-order
|
|
// regardless of the user's chosen group. On the default `''` (flat)
|
|
// dimension the wrapped `bucketOf` returns the empty string for
|
|
// non-new items — that renders as one unlabeled section (header
|
|
// suppressed by ResourceList when `label === ''`), preserving the
|
|
// current flat-list look with just the "New elements" header on
|
|
// top. `labelForNew` renders the localised header.
|
|
const NEW_KEY = '__new__';
|
|
const labelForNew = $derived(t('files.new_elements', 'New elements'));
|
|
const wrapNew =
|
|
<T extends FileItem | FolderItem>(inner?: (item: T) => string | null) =>
|
|
(item: T): string | null => {
|
|
if (newlyAdded.has(item.id)) return NEW_KEY;
|
|
return inner ? inner(item) : '';
|
|
};
|
|
const wrapLabel =
|
|
(inner?: (key: string) => string) =>
|
|
(key: string): string => {
|
|
if (key === NEW_KEY) return labelForNew;
|
|
return inner ? inner(key) : key;
|
|
};
|
|
const rlGroupBys = $derived<RLGroupByDef[]>([
|
|
{
|
|
key: '',
|
|
label: t('files.name', 'Name'),
|
|
orderBy: 'name',
|
|
icon: 'arrow-up-a-z',
|
|
// Only synthesize a bucketOf when the swimlane is active; when
|
|
// no new items exist we want the plain flat-list rendering
|
|
// (no bucketing pass at all).
|
|
bucketOf: newlyAdded.size > 0 ? wrapNew() : undefined,
|
|
labelOf: newlyAdded.size > 0 ? wrapLabel() : undefined
|
|
},
|
|
{
|
|
key: 'type',
|
|
label: t('groupby.type', 'Type'),
|
|
orderBy: 'type',
|
|
icon: 'layer-group',
|
|
bucketOf: wrapNew((item) =>
|
|
isFile(item) ? typeLabel(item.category) : t('files.file_types.folder', 'Folders')
|
|
),
|
|
labelOf: wrapLabel((k) => k)
|
|
},
|
|
{
|
|
key: 'size',
|
|
label: t('groupby.size', 'Size'),
|
|
orderBy: 'size',
|
|
icon: 'layer-group',
|
|
bucketOf: wrapNew((item) => (isFile(item) ? sizeBucket(item.size ?? 0) : sizeBucket(-1))),
|
|
labelOf: wrapLabel((k) => k)
|
|
},
|
|
{
|
|
key: 'modifiedAt',
|
|
label: t('groupby.modifiedAt', 'Modified date'),
|
|
orderBy: 'modified_at',
|
|
icon: 'layer-group',
|
|
bucketOf: wrapNew((item) => dateBucket(item.modified_at)),
|
|
labelOf: wrapLabel((k) => k)
|
|
},
|
|
{
|
|
key: 'createdAt',
|
|
label: t('groupby.createdAt', 'Created date'),
|
|
orderBy: 'created_at',
|
|
icon: 'layer-group',
|
|
bucketOf: wrapNew((item) => dateBucket(item.created_at)),
|
|
labelOf: wrapLabel((k) => k)
|
|
}
|
|
]);
|
|
|
|
// Bridge for <ResourceList>'s callbacks — the row's open/favorite/drag
|
|
// props take one item; the legacy handlers take `(kind, id, name)`.
|
|
function rlOnOpen(item: FileItem | FolderItem) {
|
|
if (isFile(item)) openFile(item);
|
|
else openFolder(item);
|
|
}
|
|
function rlOnFavorite(item: FileItem | FolderItem) {
|
|
void toggleFavorite(isFile(item) ? 'file' : 'folder', item.id);
|
|
}
|
|
function rlOnContextMenu(e: MouseEvent, item: FileItem | FolderItem) {
|
|
openContext(e, isFile(item) ? 'file' : 'folder', item.id, item.name);
|
|
}
|
|
// Row-drag: everything is draggable; only folders accept drops.
|
|
function rlIsDraggable(_item: FileItem | FolderItem): boolean {
|
|
return true;
|
|
}
|
|
function rlIsDropTarget(item: FileItem | FolderItem): boolean {
|
|
return !isFile(item);
|
|
}
|
|
function rlOnItemDragStart(e: DragEvent, item: FileItem | FolderItem) {
|
|
onItemDragStart(e, isFile(item) ? 'file' : 'folder', item.id, item.name);
|
|
}
|
|
function rlOnItemDragOver(e: DragEvent, item: FileItem | FolderItem) {
|
|
if (isFile(item)) return;
|
|
if (e.dataTransfer?.types.includes(DRAG_TYPE)) {
|
|
// preventDefault is what accepts the drop. Leaving
|
|
// `dropEffect` untouched lets the browser negotiate it
|
|
// from OS-native modifier keys.
|
|
e.preventDefault();
|
|
dropFolderId = item.id;
|
|
}
|
|
}
|
|
function rlOnItemDragLeave(_e: DragEvent, item: FileItem | FolderItem) {
|
|
if (dropFolderId === item.id) dropFolderId = null;
|
|
}
|
|
function rlOnItemDrop(e: DragEvent, item: FileItem | FolderItem) {
|
|
if (isFile(item)) return;
|
|
onFolderDrop(e, item);
|
|
}
|
|
|
|
// ── Upload split-button popup state ─────────────────────────────────────
|
|
let uploadMenuOpen = $state(false);
|
|
|
|
// Close the upload popup when clicking outside of it.
|
|
$effect(() => {
|
|
if (!uploadMenuOpen) return;
|
|
const onDown = (e: MouseEvent) => {
|
|
if (!(e.target as HTMLElement).closest('.upload-dropdown')) uploadMenuOpen = false;
|
|
};
|
|
window.addEventListener('pointerdown', onDown);
|
|
return () => window.removeEventListener('pointerdown', onDown);
|
|
});
|
|
|
|
// Reload whenever the route path OR the server sort dimension/direction
|
|
// changes.
|
|
//
|
|
// `load()` reads several reactive signals in its sync phase
|
|
// (session.isExternalUser, session.homeFolderId, plus whatever
|
|
// its awaited callees touch). Naively calling `void load()` here
|
|
// tracks all of those as dependencies of this effect — and
|
|
// `session.loadHomeFolder()`'s own writes to `homeFolderId`
|
|
// during its resolution then re-trigger the effect, firing a
|
|
// second and third `load()` before the first has settled. Wrap
|
|
// in `untrack` so the ONLY dependencies are the three we WANT
|
|
// to reload on: pathSegments, sortField, reversed.
|
|
$effect(() => {
|
|
void pathSegments;
|
|
void sortField;
|
|
void reversed;
|
|
untrack(() => {
|
|
// Route/sort change → drop the transient "new elements"
|
|
// swimlane. It's a per-folder confirmation of "here's what
|
|
// you just added"; carrying it across folders would surface
|
|
// stale ids that don't belong to the new listing.
|
|
newlyAdded.clear();
|
|
void load(true);
|
|
});
|
|
});
|
|
|
|
// The command palette's "Upload files" action navigates here then dispatches
|
|
// this event so the hidden file picker opens (the input lives on this page).
|
|
$effect(() => {
|
|
const open = () => fileInput?.click();
|
|
window.addEventListener('oxicloud:upload-files', open);
|
|
return () => window.removeEventListener('oxicloud:upload-files', open);
|
|
});
|
|
</script>
|
|
|
|
<svelte:head><title>{t('nav.files', 'Files')} · OxiCloud</title></svelte:head>
|
|
|
|
<svelte:window onkeydown={onKeydown} />
|
|
|
|
<div class="files-page" data-testid="files-dropzone">
|
|
<!-- Read-only freeze banner, placed inside the listing container so it
|
|
lives in the same visual/scroll flow as the file list (previously
|
|
rendered above the page container, which visually detached it from
|
|
the listing). Scrolls with the content — sticky header below stays
|
|
pinned. Shows when either any item in the current listing surfaces
|
|
a `drive_id` for a frozen drive, or (empty folder fallback) the
|
|
URL's leading segment resolves to one. -->
|
|
{#if currentDrive?.policies?.read_only}
|
|
<ReadOnlyBanner driveName={currentDrive.name} />
|
|
{/if}
|
|
|
|
<!-- Hidden upload inputs stay mounted even while the batch bar is shown.
|
|
Kept OUTSIDE ResourceList so the split-button dropdown in the
|
|
`actions` snippet can click() them without ResourceList's internal
|
|
scoping getting in the way. -->
|
|
<input
|
|
bind:this={fileInput}
|
|
type="file"
|
|
multiple
|
|
hidden
|
|
data-testid="files-upload-file-input"
|
|
onchange={onUpload}
|
|
/>
|
|
<input
|
|
bind:this={folderInput}
|
|
type="file"
|
|
multiple
|
|
hidden
|
|
webkitdirectory
|
|
data-testid="files-upload-folder-input"
|
|
onchange={onUploadFolder}
|
|
/>
|
|
|
|
<ResourceList
|
|
title={t('nav.files', 'Files')}
|
|
items={rlItems}
|
|
emptyText={hiddenCount > 0
|
|
? t('files.empty_hidden_title', { n: hiddenCount }, '{{n}} hidden item(s) in this folder')
|
|
: t('files.empty_title', 'This folder is empty')}
|
|
emptyHint={hiddenCount > 0
|
|
? t(
|
|
'files.empty_hidden_hint',
|
|
"Files whose name starts with '.' are hidden. Toggle the setting to see them."
|
|
)
|
|
: t('files.empty_hint', 'Drop files here or use the Upload button to add files.')}
|
|
emptyIcon={hiddenCount > 0 ? 'eye-slash' : undefined}
|
|
loading={showSkeleton}
|
|
error={error ?? undefined}
|
|
selectable
|
|
shiftRangeSelect
|
|
showOwner
|
|
showType
|
|
showDate
|
|
dateLabel={t('files.col_modified', 'Modified')}
|
|
showPath={false}
|
|
showDotfileToggle
|
|
enableSystemDrop
|
|
onsystemdrop={onDrop}
|
|
groupBys={rlGroupBys}
|
|
bind:groupBy
|
|
bind:reversed
|
|
hasMore={pageCursor !== undefined}
|
|
onloadmore={loadMore}
|
|
onreload={(orderBy) => {
|
|
sortField = orderBy as SortField;
|
|
}}
|
|
onopen={rlOnOpen}
|
|
onfavorite={rlOnFavorite}
|
|
onshared={(item) => openShare(isFile(item) ? 'file' : 'folder', item.id, item.name)}
|
|
oncontextmenu={rlOnContextMenu}
|
|
onselectionchange={(ids) => replaceSet(selected, ids)}
|
|
isDraggable={rlIsDraggable}
|
|
isDropTarget={rlIsDropTarget}
|
|
dropTargetId={dropFolderId}
|
|
onitemdragstart={rlOnItemDragStart}
|
|
onitemdragover={rlOnItemDragOver}
|
|
onitemdragleave={rlOnItemDragLeave}
|
|
onitemdrop={rlOnItemDrop}
|
|
>
|
|
{#snippet emptyAction()}
|
|
<!-- Surfaces only when the folder isn't really empty — it's just
|
|
filtered because the user chose to hide dotfiles. Clicking
|
|
flips the app-wide `preferences.hideDotfiles` back off,
|
|
re-populating the list without a hunt through settings. -->
|
|
{#if hiddenCount > 0}
|
|
<button
|
|
class="btn btn-secondary"
|
|
onclick={() => preferences.setHideDotfiles(false)}
|
|
data-testid="files-show-hidden-btn"
|
|
>
|
|
<Icon name="eye" />
|
|
{t('files.show_hidden', 'Show hidden files')}
|
|
</button>
|
|
{/if}
|
|
{/snippet}
|
|
|
|
{#snippet breadcrumb()}
|
|
<nav class="breadcrumb" aria-label="Breadcrumb">
|
|
<!-- Persistent home link → the root listing (bare /files canonicalizes to
|
|
the user's drive root). `buildCrumbs` returns only the path folders,
|
|
so this is the single always-present "go home" affordance. Both the
|
|
home link and every crumb accept row drops via the same
|
|
`application/x-oxi-item` MIME the item-drag uses. The
|
|
`.drop-target` class visually highlights the crumb during a
|
|
hover-over so the user sees WHICH crumb the drop will land on. -->
|
|
<a
|
|
href={resolve('/files')}
|
|
class="breadcrumb-item breadcrumb-home breadcrumb-link"
|
|
class:drop-target={dropCrumbId === CRUMB_HOME_ID}
|
|
title={t('breadcrumb.home', 'Home')}
|
|
data-testid="files-breadcrumb-home-link"
|
|
ondragover={(e) => e.dataTransfer?.types.includes(DRAG_TYPE) && e.preventDefault()}
|
|
ondragenter={(e) => {
|
|
if (e.dataTransfer?.types.includes(DRAG_TYPE)) dropCrumbId = CRUMB_HOME_ID;
|
|
}}
|
|
ondragleave={() => {
|
|
if (dropCrumbId === CRUMB_HOME_ID) dropCrumbId = null;
|
|
}}
|
|
ondrop={(e) => {
|
|
dropCrumbId = null;
|
|
if (session.homeFolderId) onCrumbDrop(e, session.homeFolderId);
|
|
}}
|
|
>
|
|
<Icon name={rootIcon} />
|
|
</a>
|
|
{#each crumbs as c, i (c.id)}
|
|
<span class="breadcrumb-separator">></span>
|
|
{#if i === crumbs.length - 1}
|
|
<span class="breadcrumb-item breadcrumb-current">{c.name}</span>
|
|
{:else}
|
|
<a
|
|
href={resolve(`/files/${pathSegments.slice(0, i + 1).join('/')}`)}
|
|
class="breadcrumb-item breadcrumb-link"
|
|
class:drop-target={dropCrumbId === c.id}
|
|
data-testid={`files-breadcrumb-${c.id}`}
|
|
ondragover={(e) => e.dataTransfer?.types.includes(DRAG_TYPE) && e.preventDefault()}
|
|
ondragenter={(e) => {
|
|
if (e.dataTransfer?.types.includes(DRAG_TYPE)) dropCrumbId = c.id;
|
|
}}
|
|
ondragleave={() => {
|
|
if (dropCrumbId === c.id) dropCrumbId = null;
|
|
}}
|
|
ondrop={(e) => {
|
|
dropCrumbId = null;
|
|
onCrumbDrop(e, c.id);
|
|
}}
|
|
>
|
|
{c.name}
|
|
</a>
|
|
{/if}
|
|
{/each}
|
|
</nav>
|
|
{/snippet}
|
|
|
|
{#snippet actions()}
|
|
<div class="upload-dropdown" data-testid="files-upload-dropdown">
|
|
<button
|
|
class="btn btn-primary"
|
|
data-testid="files-upload-btn"
|
|
onclick={() => (uploadMenuOpen = !uploadMenuOpen)}
|
|
disabled={uploading}
|
|
aria-haspopup="true"
|
|
aria-expanded={uploadMenuOpen}
|
|
>
|
|
<Icon name="cloud-upload-alt" class="icon-mr" />
|
|
<span
|
|
>{uploading ? t('files.uploading', 'Uploading…') : t('actions.upload', 'Upload')}</span
|
|
>
|
|
<Icon name="caret-down" class="upload-caret" />
|
|
</button>
|
|
{#if uploadMenuOpen}
|
|
<div class="upload-dropdown-menu" data-testid="files-upload-menu">
|
|
<button
|
|
class="upload-dropdown-item"
|
|
data-testid="files-upload-files-item"
|
|
onclick={() => {
|
|
uploadMenuOpen = false;
|
|
fileInput?.click();
|
|
}}
|
|
>
|
|
<Icon name="file" />
|
|
<span>{t('actions.upload_files', 'Upload files')}</span>
|
|
</button>
|
|
<button
|
|
class="upload-dropdown-item"
|
|
data-testid="files-upload-folder-item"
|
|
onclick={() => {
|
|
uploadMenuOpen = false;
|
|
folderInput?.click();
|
|
}}
|
|
>
|
|
<Icon name="folder-open" />
|
|
<span>{t('actions.upload_folder', 'Upload folder')}</span>
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
<button class="btn btn-secondary" data-testid="files-new-folder-btn" onclick={onNewFolder}>
|
|
<Icon name="folder-plus" class="icon-mr" />
|
|
<span>{t('actions.new_folder', 'New folder')}</span>
|
|
</button>
|
|
{/snippet}
|
|
|
|
{#snippet batchActions(_sel)}
|
|
<button
|
|
class="batch-btn"
|
|
title={t('files.add_favorites', 'Add to favorites')}
|
|
data-testid="files-batch-favorite-btn"
|
|
onclick={() => void batchFavorites()}
|
|
>
|
|
<Icon name="star" />
|
|
<span>{t('files.add_favorites', 'Add to favorites')}</span>
|
|
</button>
|
|
<button
|
|
class="batch-btn"
|
|
title={t('files.move', 'Move')}
|
|
data-testid="files-batch-move-btn"
|
|
onclick={batchMove}
|
|
>
|
|
<Icon name="arrows-alt" />
|
|
<span>{t('files.move', 'Move')}</span>
|
|
</button>
|
|
<button
|
|
class="batch-btn"
|
|
title={t('files.copy', 'Copy')}
|
|
data-testid="files-batch-copy-btn"
|
|
onclick={batchCopy}
|
|
>
|
|
<Icon name="copy" />
|
|
<span>{t('files.copy', 'Copy')}</span>
|
|
</button>
|
|
<button
|
|
class="batch-btn"
|
|
title={t('common.download', 'Download')}
|
|
data-testid="files-batch-download-btn"
|
|
onclick={() => void batchDownload()}
|
|
>
|
|
<Icon name="download" />
|
|
<span>{t('common.download', 'Download')}</span>
|
|
</button>
|
|
<button
|
|
class="batch-btn batch-btn-danger"
|
|
title={t('common.delete', 'Delete')}
|
|
data-testid="files-batch-delete-btn"
|
|
onclick={batchDelete}
|
|
>
|
|
<Icon name="trash" />
|
|
<span>{t('common.delete', 'Delete')}</span>
|
|
</button>
|
|
{/snippet}
|
|
</ResourceList>
|
|
</div>
|
|
|
|
{#if moveDialog.component}
|
|
{@const MoveDialog = moveDialog.component}
|
|
<MoveDialog
|
|
bind:open={moveOpen}
|
|
item={actionTarget}
|
|
items={moveItems}
|
|
mode={moveMode}
|
|
onmoved={() => {
|
|
clearSelection();
|
|
void reload();
|
|
}}
|
|
/>
|
|
{/if}
|
|
{#if shareDialog.component}
|
|
{@const ShareDialog = shareDialog.component}
|
|
<ShareDialog
|
|
bind:open={shareOpen}
|
|
item={actionTarget}
|
|
onshared={(id) => {
|
|
// Optimistic in-place flip so the shared chip appears on
|
|
// the row without waiting for the next listing refetch.
|
|
const row = orderedItems.find((it) => it.id === id);
|
|
if (row) row.is_shared = true;
|
|
}}
|
|
/>
|
|
{/if}
|
|
{#if fileViewer.component}
|
|
{@const FileViewer = fileViewer.component}
|
|
<FileViewer bind:open={viewerOpen} file={viewerFile} />
|
|
{/if}
|
|
{#if wopiEditor.component}
|
|
{@const WopiEditor = wopiEditor.component}
|
|
<WopiEditor
|
|
bind:open={wopiOpen}
|
|
fileId={wopiFile?.id ?? null}
|
|
fileName={wopiFile?.name ?? ''}
|
|
action={wopiAction}
|
|
/>
|
|
{/if}
|
|
|
|
{#if ctxOpen && ctxTarget}
|
|
<div
|
|
class="ctx-scrim"
|
|
role="presentation"
|
|
data-testid="files-context-menu-scrim"
|
|
onclick={closeContext}
|
|
oncontextmenu={(e) => e.preventDefault()}
|
|
></div>
|
|
<div
|
|
class="ctx-menu"
|
|
style:left="{ctxX}px"
|
|
style:top="{ctxY}px"
|
|
role="menu"
|
|
data-testid="files-context-menu"
|
|
>
|
|
{#if ctxTarget.kind === 'folder'}
|
|
<button
|
|
class="ctx-item"
|
|
role="menuitem"
|
|
data-testid="files-ctx-folder-open-item"
|
|
onclick={() => {
|
|
const id = ctxTarget!.id;
|
|
closeContext();
|
|
goto(resolve(`/files/${[...pathSegments, id].join('/')}`));
|
|
}}><Icon name="folder-open" /> {t('files.open', 'Open')}</button
|
|
>
|
|
<button
|
|
class="ctx-item"
|
|
role="menuitem"
|
|
data-testid="files-ctx-download-zip-item"
|
|
onclick={() => {
|
|
const tg = ctxTarget!;
|
|
closeContext();
|
|
downloadFolderZip({ id: tg.id, name: tg.name });
|
|
}}><Icon name="download" /> {t('files.download_zip', 'Download as ZIP')}</button
|
|
>
|
|
{:else}
|
|
<button
|
|
class="ctx-item"
|
|
role="menuitem"
|
|
data-testid="files-ctx-file-open-item"
|
|
onclick={() => {
|
|
const f = listing.files.find((x) => x.id === ctxTarget!.id);
|
|
closeContext();
|
|
if (f) openFile(f);
|
|
}}><Icon name="eye" /> {t('files.open', 'Open')}</button
|
|
>
|
|
{#if ctxCanEditWopi}
|
|
<button
|
|
class="ctx-item"
|
|
role="menuitem"
|
|
data-testid="files-ctx-edit-item"
|
|
onclick={() => {
|
|
const tg = ctxTarget!;
|
|
closeContext();
|
|
openWopi(tg.id, tg.name, 'edit');
|
|
}}><Icon name="pen" /> {t('files.edit', 'Edit')}</button
|
|
>
|
|
<button
|
|
class="ctx-item"
|
|
role="menuitem"
|
|
data-testid="files-ctx-edit-new-tab-item"
|
|
onclick={() => {
|
|
const tg = ctxTarget!;
|
|
closeContext();
|
|
void openWopiTab(tg.id, tg.name);
|
|
}}><Icon name="external-link-alt" /> {t('files.edit_new_tab', 'Edit in new tab')}</button
|
|
>
|
|
{/if}
|
|
<a
|
|
class="ctx-item"
|
|
role="menuitem"
|
|
href={fileDownloadUrl(ctxTarget.id)}
|
|
rel="external"
|
|
download
|
|
data-testid="files-ctx-download-item"
|
|
onclick={closeContext}><Icon name="download" /> {t('common.download', 'Download')}</a
|
|
>
|
|
<button
|
|
class="ctx-item"
|
|
role="menuitem"
|
|
data-testid="files-ctx-open-parent-item"
|
|
onclick={() => {
|
|
const f = listing.files.find((x) => x.id === ctxTarget!.id);
|
|
closeContext();
|
|
if (f) openParentFolder(f);
|
|
}}><Icon name="folder-open" /> {t('files.open_parent', 'Open parent folder')}</button
|
|
>
|
|
{#if isAudio(listing.files.find((x) => x.id === ctxTarget!.id))}
|
|
<button
|
|
class="ctx-item"
|
|
role="menuitem"
|
|
data-testid="files-ctx-add-playlist-item"
|
|
onclick={() => {
|
|
const f = listing.files.find((x) => x.id === ctxTarget!.id);
|
|
closeContext();
|
|
if (f) void addToPlaylist(f);
|
|
}}><Icon name="music" /> {t('music.add_to_playlist', 'Add to playlist')}</button
|
|
>
|
|
{/if}
|
|
{/if}
|
|
<button
|
|
class="ctx-item"
|
|
role="menuitem"
|
|
data-testid="files-ctx-share-item"
|
|
onclick={() => {
|
|
const tg = ctxTarget!;
|
|
closeContext();
|
|
openShare(tg.kind, tg.id, tg.name);
|
|
}}><Icon name="share-alt" /> {t('files.share', 'Share')}</button
|
|
>
|
|
<button
|
|
class="ctx-item"
|
|
role="menuitem"
|
|
data-testid="files-ctx-move-item"
|
|
onclick={() => {
|
|
const tg = ctxTarget!;
|
|
closeContext();
|
|
openMove(tg.kind, tg.id, tg.name);
|
|
}}><Icon name="arrows-alt" /> {t('files.move', 'Move')}</button
|
|
>
|
|
<button
|
|
class="ctx-item"
|
|
role="menuitem"
|
|
data-testid="files-ctx-copy-item"
|
|
onclick={() => {
|
|
const tg = ctxTarget!;
|
|
closeContext();
|
|
openCopy(tg.kind, tg.id, tg.name);
|
|
}}><Icon name="copy" /> {t('files.copy', 'Copy')}</button
|
|
>
|
|
<button
|
|
class="ctx-item"
|
|
role="menuitem"
|
|
data-testid="files-ctx-favorite-item"
|
|
onclick={() => {
|
|
const tg = ctxTarget!;
|
|
closeContext();
|
|
void toggleFavorite(tg.kind, tg.id);
|
|
}}
|
|
>
|
|
<Icon name="star" />
|
|
{orderedItems.find((it) => it.id === ctxTarget!.id)?.is_favorite
|
|
? t('files.unfavorite', 'Remove favorite')
|
|
: t('files.favorite', 'Add favorite')}
|
|
</button>
|
|
<button
|
|
class="ctx-item"
|
|
role="menuitem"
|
|
data-testid="files-ctx-rename-item"
|
|
onclick={() => {
|
|
const tg = ctxTarget!;
|
|
closeContext();
|
|
void renameItem(tg.kind, tg.id, tg.name);
|
|
}}><Icon name="pen" /> {t('common.rename', 'Rename')}</button
|
|
>
|
|
<button
|
|
class="ctx-item ctx-item--danger"
|
|
role="menuitem"
|
|
data-testid="files-ctx-delete-item"
|
|
onclick={() => {
|
|
const tg = ctxTarget!;
|
|
closeContext();
|
|
void deleteItem(tg.kind, tg.id, tg.name);
|
|
}}><Icon name="trash" /> {t('common.delete', 'Delete')}</button
|
|
>
|
|
</div>
|
|
{/if}
|
|
|
|
<style>
|
|
.files-page {
|
|
min-height: 100%;
|
|
}
|
|
|
|
/* Scrim + menu must sit ABOVE `.page-sticky-header` (breadcrumb +
|
|
action bar, `z-index: var(--z-sticky)` = 100) so a click on the
|
|
breadcrumb or the action bar closes the menu. Otherwise the
|
|
sticky header covers the scrim and swallows the outside-click.
|
|
Aligns with ResourceList's built-in menu (`.rl-ctx-scrim` uses
|
|
`1000` / `.rl-ctx-menu` uses `1001`). */
|
|
.ctx-scrim {
|
|
position: fixed;
|
|
inset: 0;
|
|
z-index: 1000;
|
|
}
|
|
|
|
.ctx-menu {
|
|
position: fixed;
|
|
z-index: 1001;
|
|
min-width: 12rem;
|
|
padding: var(--space-1);
|
|
background: var(--color-bg-surface);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-md);
|
|
box-shadow: var(--shadow-lg, 0 10px 30px var(--color-overlay-shadow));
|
|
}
|
|
|
|
.ctx-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.6rem;
|
|
width: 100%;
|
|
padding: 0.5rem 0.6rem;
|
|
border: none;
|
|
background: none;
|
|
color: var(--color-text);
|
|
cursor: pointer;
|
|
text-align: left;
|
|
text-decoration: none;
|
|
border-radius: var(--radius-sm);
|
|
font-size: var(--text-sm);
|
|
}
|
|
|
|
.ctx-item:hover {
|
|
background: var(--color-bg-hover);
|
|
}
|
|
|
|
.ctx-item--danger {
|
|
/* Danger *foreground* on the light menu surface — must be the red accent,
|
|
not --color-danger-text (which is white, for text ON a red fill, and
|
|
rendered near-invisible here). Mirrors the user-menu logout red. */
|
|
color: var(--color-danger-alt);
|
|
}
|
|
</style>
|