2026-06-17 17:06:30 -06:00
|
|
|
<script lang="ts">
|
2026-06-17 22:30:56 -06:00
|
|
|
import SkeletonList from '$lib/components/SkeletonList.svelte';
|
|
|
|
|
import EmptyState from '$lib/components/EmptyState.svelte';
|
|
|
|
|
import { errorMessage, errorToast } from '$lib/utils/errors';
|
2026-06-17 17:06:30 -06:00
|
|
|
import { goto } from '$app/navigation';
|
|
|
|
|
import { page } from '$app/state';
|
2026-06-20 00:15:29 +02:00
|
|
|
import { untrack } from 'svelte';
|
2026-06-17 17:06:30 -06:00
|
|
|
import Icon from '$lib/icons/Icon.svelte';
|
|
|
|
|
import {
|
2026-06-19 15:39:08 +00:00
|
|
|
cacheFolder,
|
2026-06-17 17:06:30 -06:00
|
|
|
createFolder,
|
|
|
|
|
deleteFolder,
|
2026-06-19 15:39:08 +00:00
|
|
|
fetchFolderListing,
|
|
|
|
|
getCachedFolder,
|
2026-06-17 17:06:30 -06:00
|
|
|
getFolder,
|
2026-06-19 16:12:20 +00:00
|
|
|
getFolderName,
|
2026-06-19 15:39:08 +00:00
|
|
|
invalidateFolderCache,
|
2026-06-17 22:07:18 -06:00
|
|
|
moveFolder,
|
2026-06-19 16:12:20 +00:00
|
|
|
rememberFolderName,
|
2026-06-17 17:06:30 -06:00
|
|
|
renameFolder,
|
|
|
|
|
type FolderListing
|
|
|
|
|
} from '$lib/api/endpoints/folders';
|
|
|
|
|
import {
|
|
|
|
|
deleteFile,
|
|
|
|
|
fileDownloadUrl,
|
2026-06-17 22:07:18 -06:00
|
|
|
fileThumbnailUrl,
|
|
|
|
|
moveFile,
|
2026-06-17 17:06:30 -06:00
|
|
|
renameFile,
|
2026-06-17 22:07:18 -06:00
|
|
|
uploadFileWithProgress
|
2026-06-17 17:06:30 -06:00
|
|
|
} from '$lib/api/endpoints/files';
|
2026-06-17 22:07:18 -06:00
|
|
|
import { folderZipUrl } from '$lib/api/endpoints/folders';
|
2026-06-20 16:33:08 +02:00
|
|
|
import {
|
|
|
|
|
instantUploadOwned,
|
|
|
|
|
resolveOwnedHashes,
|
|
|
|
|
tryDeltaUpload
|
|
|
|
|
} from '$lib/api/endpoints/deltaUpload';
|
2026-06-19 15:00:14 +00:00
|
|
|
import { addFavorite, removeFavorite } from '$lib/api/endpoints/favorites';
|
2026-06-17 22:07:18 -06:00
|
|
|
import { canEditWithWopi, getEditorUrlWithFallback } from '$lib/api/endpoints/wopi';
|
|
|
|
|
import { addTracks, createPlaylist, listPlaylists } from '$lib/api/endpoints/music';
|
|
|
|
|
import { apiFetch } from '$lib/api/client';
|
|
|
|
|
import { getCsrfHeaders } from '$lib/api/csrf';
|
|
|
|
|
import type { FileItem, FolderItem, ItemType } from '$lib/api/types';
|
|
|
|
|
import ListToolbar from '$lib/components/ListToolbar.svelte';
|
2026-06-19 14:48:37 +00:00
|
|
|
import VirtualList from '$lib/components/VirtualList.svelte';
|
2026-06-17 22:07:18 -06:00
|
|
|
import MoveDialog from '$lib/components/MoveDialog.svelte';
|
|
|
|
|
import ShareDialog from '$lib/components/ShareDialog.svelte';
|
2026-06-19 22:56:00 +00:00
|
|
|
import { lazyComponent } from '$lib/composables/lazyComponent.svelte';
|
2026-06-17 17:06:30 -06:00
|
|
|
import { t } from '$lib/i18n/index.svelte';
|
2026-06-17 22:07:18 -06:00
|
|
|
import { confirmDialog, promptDialog } from '$lib/stores/dialogs.svelte';
|
2026-06-20 01:28:52 +02:00
|
|
|
import { drives as drivesStore, driveIcon } from '$lib/stores/drives.svelte';
|
2026-06-17 17:06:30 -06:00
|
|
|
import { files as filesStore } from '$lib/stores/files.svelte';
|
|
|
|
|
import { session } from '$lib/stores/session.svelte';
|
|
|
|
|
import { ui } from '$lib/stores/ui.svelte';
|
2026-06-17 22:07:18 -06:00
|
|
|
import {
|
|
|
|
|
dateBucket,
|
|
|
|
|
ownerLabel,
|
|
|
|
|
relativeTimeAgo,
|
|
|
|
|
sizeBucket,
|
|
|
|
|
typeLabel
|
|
|
|
|
} from '$lib/stores/files.svelte';
|
2026-06-17 17:06:30 -06:00
|
|
|
import { formatBytes } from '$lib/utils/format';
|
2026-06-20 16:33:08 +02:00
|
|
|
import { formatDate, iconNameFromClass, fileIconKindClass } from '$lib/utils/display';
|
2026-06-19 14:48:37 +00:00
|
|
|
import { gridColumns } from '$lib/utils/grid';
|
2026-06-17 17:06:30 -06:00
|
|
|
|
2026-06-19 22:56:00 +00:00
|
|
|
// 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'));
|
|
|
|
|
|
2026-06-17 17:06:30 -06:00
|
|
|
// 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));
|
|
|
|
|
|
2026-06-20 01:28:52 +02:00
|
|
|
// 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';
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-19 15:21:01 +00:00
|
|
|
let listing = $state<FolderListing>({ folders: [], files: [], favoriteIds: [], sharedIds: [] });
|
2026-06-17 17:06:30 -06:00
|
|
|
let crumbs = $state<Array<{ id: string; name: string }>>([]);
|
|
|
|
|
let currentId = $state<string | null>(null);
|
|
|
|
|
let loading = $state(false);
|
2026-06-17 22:07:18 -06:00
|
|
|
// Skeleton is delayed ~100ms behind `loading` so fast loads don't flash it.
|
|
|
|
|
let showSkeleton = $state(false);
|
2026-06-17 17:06:30 -06:00
|
|
|
let error = $state<string | null>(null);
|
|
|
|
|
let fileInput = $state<HTMLInputElement | null>(null);
|
|
|
|
|
let uploading = $state(false);
|
|
|
|
|
let dragOver = $state(false);
|
|
|
|
|
|
2026-06-17 22:07:18 -06:00
|
|
|
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);
|
|
|
|
|
|
2026-06-19 15:21:01 +00:00
|
|
|
// Favorite + shared badge sets for the current folder, seeded directly from
|
|
|
|
|
// the listing response (server-computed, scoped to these items — no extra
|
|
|
|
|
// per-navigation fetch) and updated optimistically on mutation.
|
|
|
|
|
let favoriteIds = $state<Set<string>>(new Set());
|
|
|
|
|
let sharedIds = $state<Set<string>>(new Set());
|
|
|
|
|
|
2026-06-17 22:07:18 -06:00
|
|
|
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) {
|
2026-06-19 15:21:01 +00:00
|
|
|
const isFav = favoriteIds.has(id);
|
2026-06-19 15:00:14 +00:00
|
|
|
// Optimistic toggle, reverted on failure.
|
2026-06-19 15:21:01 +00:00
|
|
|
const next = new Set(favoriteIds);
|
|
|
|
|
if (isFav) next.delete(id);
|
|
|
|
|
else next.add(id);
|
|
|
|
|
favoriteIds = next;
|
2026-06-17 22:07:18 -06:00
|
|
|
try {
|
|
|
|
|
if (isFav) await removeFavorite(kind, id);
|
|
|
|
|
else await addFavorite(kind, id);
|
|
|
|
|
} catch (e) {
|
2026-06-17 22:30:56 -06:00
|
|
|
errorToast(e);
|
2026-06-19 15:21:01 +00:00
|
|
|
const reverted = new Set(favoriteIds);
|
|
|
|
|
if (isFav) reverted.add(id);
|
|
|
|
|
else reverted.delete(id);
|
|
|
|
|
favoriteIds = reverted;
|
2026-06-17 22:07:18 -06:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-17 17:06:30 -06:00
|
|
|
async function buildCrumbs(segments: string[]): Promise<Array<{ id: string; name: string }>> {
|
2026-06-19 16:12:20 +00:00
|
|
|
// 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: '…' };
|
|
|
|
|
}
|
|
|
|
|
})
|
2026-06-17 17:06:30 -06:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-19 15:39:08 +00:00
|
|
|
// 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;
|
|
|
|
|
|
|
|
|
|
function applyListing(data: FolderListing) {
|
|
|
|
|
listing = data;
|
|
|
|
|
favoriteIds = new Set(data.favoriteIds);
|
|
|
|
|
sharedIds = new Set(data.sharedIds);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-17 17:06:30 -06:00
|
|
|
async function load() {
|
|
|
|
|
error = null;
|
2026-06-19 15:39:08 +00:00
|
|
|
const seq = ++loadSeq;
|
|
|
|
|
|
|
|
|
|
// External users have no home folder; send them to shared-with-me.
|
|
|
|
|
if (session.isExternalUser && pathSegments.length === 0) {
|
|
|
|
|
await goto('/shared-with-me', { replaceState: true });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const home = await session.loadHomeFolder();
|
2026-06-20 01:28:52 +02:00
|
|
|
|
|
|
|
|
// 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(`/files/${target}`, { replaceState: true });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-19 15:39:08 +00:00
|
|
|
const folderId = pathSegments.at(-1) ?? home;
|
|
|
|
|
if (!folderId) {
|
|
|
|
|
error = t('files.no_home', 'No home folder available.');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
currentId = folderId;
|
|
|
|
|
filesStore.currentFolder = folderId;
|
|
|
|
|
|
|
|
|
|
// Stale-while-revalidate: paint a previously-visited folder instantly,
|
|
|
|
|
// then revalidate with If-None-Match (304 = keep what's shown).
|
|
|
|
|
const cached = getCachedFolder(folderId);
|
|
|
|
|
if (cached) {
|
|
|
|
|
applyListing(cached.listing);
|
|
|
|
|
loading = false;
|
|
|
|
|
showSkeleton = false;
|
|
|
|
|
} else {
|
|
|
|
|
loading = true;
|
|
|
|
|
}
|
|
|
|
|
// Delayed skeleton, only when there's nothing cached to show yet.
|
2026-06-17 22:07:18 -06:00
|
|
|
const skeletonTimer = setTimeout(() => {
|
|
|
|
|
if (loading) showSkeleton = true;
|
|
|
|
|
}, 100);
|
2026-06-19 15:39:08 +00:00
|
|
|
|
|
|
|
|
// Breadcrumbs resolve independently so they never block the grid paint.
|
2026-06-20 01:28:52 +02:00
|
|
|
// Bare `/files` was canonicalized above to `/files/<id>` so pathSegments
|
|
|
|
|
// is always non-empty here for internal users.
|
2026-06-19 15:39:08 +00:00
|
|
|
void buildCrumbs(pathSegments).then((trail) => {
|
|
|
|
|
if (seq === loadSeq) crumbs = trail;
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-17 17:06:30 -06:00
|
|
|
try {
|
2026-06-19 15:39:08 +00:00
|
|
|
const res = await fetchFolderListing(folderId, { etag: cached?.etag });
|
|
|
|
|
if (seq !== loadSeq) return; // superseded by a newer navigation
|
|
|
|
|
if (res.status === 200 && res.listing) {
|
|
|
|
|
applyListing(res.listing);
|
|
|
|
|
cacheFolder(folderId, res.listing, res.etag);
|
2026-06-17 17:06:30 -06:00
|
|
|
}
|
2026-06-19 15:39:08 +00:00
|
|
|
// 304 → the cached copy already on screen is current.
|
|
|
|
|
error = null;
|
2026-06-17 17:06:30 -06:00
|
|
|
} catch (e) {
|
2026-06-19 15:39:08 +00:00
|
|
|
if (seq !== loadSeq) return;
|
|
|
|
|
// With a cached view already shown, keep it on a transient failure.
|
|
|
|
|
if (!cached) {
|
|
|
|
|
const status = (e as { status?: number })?.status;
|
|
|
|
|
error =
|
|
|
|
|
status === 403
|
|
|
|
|
? t('errors.forbidden', 'Could not load files')
|
|
|
|
|
: e instanceof Error
|
|
|
|
|
? e.message
|
|
|
|
|
: String(e);
|
|
|
|
|
}
|
2026-06-17 17:06:30 -06:00
|
|
|
} finally {
|
2026-06-17 22:07:18 -06:00
|
|
|
clearTimeout(skeletonTimer);
|
2026-06-19 15:39:08 +00:00
|
|
|
if (seq === loadSeq) {
|
|
|
|
|
loading = false;
|
|
|
|
|
showSkeleton = false;
|
|
|
|
|
}
|
2026-06-17 17:06:30 -06:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-19 15:39:08 +00:00
|
|
|
/** Data changed — drop cached listings and reload the current folder fresh. */
|
|
|
|
|
async function reload() {
|
|
|
|
|
invalidateFolderCache();
|
|
|
|
|
await load();
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-17 17:06:30 -06:00
|
|
|
function openFolder(folder: FolderItem) {
|
|
|
|
|
goto(`/files/${[...pathSegments, folder.id].join('/')}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function crumbHref(index: number): string {
|
|
|
|
|
return `/files/${pathSegments.slice(0, index + 1).join('/')}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function onNewFolder() {
|
2026-06-17 22:07:18 -06:00
|
|
|
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')
|
|
|
|
|
});
|
2026-06-17 17:06:30 -06:00
|
|
|
if (!name) return;
|
|
|
|
|
try {
|
|
|
|
|
await createFolder(name, currentId);
|
2026-06-19 15:39:08 +00:00
|
|
|
await reload();
|
2026-06-17 17:06:30 -06:00
|
|
|
} catch (e) {
|
2026-06-17 22:30:56 -06:00
|
|
|
errorToast(e);
|
2026-06-17 17:06:30 -06:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-21 02:33:16 +02:00
|
|
|
// 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;
|
2026-06-20 17:03:30 +02:00
|
|
|
|
2026-06-20 18:33:53 +02:00
|
|
|
/** 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;
|
2026-06-20 17:03:30 +02:00
|
|
|
|
|
|
|
|
/** 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);
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-20 16:33:08 +02:00
|
|
|
/**
|
|
|
|
|
* 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.
|
2026-06-20 17:03:30 +02:00
|
|
|
* Throws on a hard failure; the error carries `isQuota` so the batch can stop
|
|
|
|
|
* early when the disk is full.
|
2026-06-20 16:33:08 +02:00
|
|
|
*/
|
|
|
|
|
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) {
|
2026-06-20 17:03:30 +02:00
|
|
|
if (!dedup.ok) {
|
|
|
|
|
const err = new Error(dedup.errorMsg ?? 'upload failed') as Error & { isQuota?: boolean };
|
|
|
|
|
err.isQuota = dedup.isQuotaError ?? false;
|
|
|
|
|
throw err;
|
|
|
|
|
}
|
2026-06-20 16:33:08 +02:00
|
|
|
return dedup.savedBytes ?? 0;
|
|
|
|
|
}
|
|
|
|
|
await uploadFileWithProgress(folderId, file, report);
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-20 18:56:05 +02:00
|
|
|
/**
|
|
|
|
|
* 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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-20 17:03:30 +02:00
|
|
|
/**
|
|
|
|
|
* Upload `items` ({file, folderId}) with bounded concurrency, a per-file
|
|
|
|
|
* deadline and live aggregate progress. A stuck or failing file no longer
|
2026-06-20 18:56:05 +02:00
|
|
|
* 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.
|
2026-06-20 17:03:30 +02:00
|
|
|
*/
|
|
|
|
|
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 savedBytes = 0;
|
|
|
|
|
let failures = 0;
|
|
|
|
|
let next = 0;
|
|
|
|
|
|
|
|
|
|
const refresh = () => {
|
|
|
|
|
let sum = 0;
|
|
|
|
|
for (const x of frac) sum += x;
|
|
|
|
|
ui.updateProgress(nid, Math.round((sum / total) * 100), label(Math.round(sum)));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const worker = async () => {
|
|
|
|
|
while (next < total) {
|
|
|
|
|
const i = next++;
|
|
|
|
|
const { file, folderId } = items[i];
|
2026-06-20 18:56:05 +02:00
|
|
|
const report = (f: number) => {
|
|
|
|
|
if (!Number.isNaN(f)) frac[i] = Math.min(1, f);
|
|
|
|
|
refresh();
|
|
|
|
|
};
|
2026-06-20 17:03:30 +02:00
|
|
|
try {
|
2026-06-20 18:56:05 +02:00
|
|
|
savedBytes += await uploadWithRetry(folderId, file, report, owned.get(file) ?? null);
|
2026-06-20 17:03:30 +02:00
|
|
|
} 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 {
|
|
|
|
|
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, or failure. */
|
|
|
|
|
function finishUpload(nid: number, savedBytes: number, failures: number, total: number) {
|
|
|
|
|
if (failures === 0) {
|
|
|
|
|
ui.finishProgress(nid, uploadDoneMessage(savedBytes), 'success');
|
|
|
|
|
} else if (failures < total) {
|
|
|
|
|
ui.finishProgress(
|
|
|
|
|
nid,
|
|
|
|
|
t(
|
|
|
|
|
'files.uploaded_partial',
|
|
|
|
|
{ ok: total - failures, failed: failures },
|
|
|
|
|
`${total - failures} 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. */
|
2026-06-20 16:33:08 +02:00
|
|
|
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`);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-17 22:07:18 -06:00
|
|
|
/**
|
|
|
|
|
* 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;
|
2026-06-17 17:06:30 -06:00
|
|
|
uploading = true;
|
2026-06-17 22:07:18 -06:00
|
|
|
const total = files.length;
|
|
|
|
|
const label = (done: number) =>
|
|
|
|
|
total === 1
|
|
|
|
|
? t('files.uploading_file', { name: files[0].name }, `Uploading ${files[0].name}…`)
|
|
|
|
|
: t('files.uploading_n', { done, total }, `Uploading ${done}/${total} files…`);
|
|
|
|
|
const nid = ui.startProgress(label(0));
|
2026-06-17 17:06:30 -06:00
|
|
|
try {
|
2026-06-20 17:03:30 +02:00
|
|
|
const { savedBytes, failures } = await uploadAll(
|
|
|
|
|
files.map((file) => ({ file, folderId: currentId })),
|
|
|
|
|
nid,
|
|
|
|
|
label
|
|
|
|
|
);
|
|
|
|
|
finishUpload(nid, savedBytes, failures, total);
|
2026-06-19 15:39:08 +00:00
|
|
|
await reload();
|
2026-06-20 16:33:08 +02:00
|
|
|
// Storage usage changed server-side — pull the fresh figure so the
|
|
|
|
|
// "Almacenamiento" bar moves off its login value instead of 0%.
|
|
|
|
|
void session.refresh();
|
2026-06-17 17:06:30 -06:00
|
|
|
} catch (err) {
|
2026-06-17 22:30:56 -06:00
|
|
|
ui.finishProgress(nid, errorMessage(err), 'error');
|
2026-06-17 17:06:30 -06:00
|
|
|
} finally {
|
|
|
|
|
uploading = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-17 22:07:18 -06:00
|
|
|
async function onUpload(e: Event) {
|
|
|
|
|
const input = e.target as HTMLInputElement;
|
|
|
|
|
if (!input.files?.length) return;
|
|
|
|
|
await uploadBatch(Array.from(input.files));
|
|
|
|
|
input.value = '';
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-17 17:06:30 -06:00
|
|
|
async function onDrop(e: DragEvent) {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
dragOver = false;
|
2026-06-20 00:29:47 +02:00
|
|
|
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;
|
2026-06-17 17:06:30 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function renameItem(kind: 'file' | 'folder', id: string, current: string) {
|
2026-06-17 22:07:18 -06:00
|
|
|
const name = await promptDialog({
|
|
|
|
|
title: t('common.rename', 'Rename'),
|
|
|
|
|
defaultValue: current,
|
|
|
|
|
confirmText: t('common.save', 'Save')
|
|
|
|
|
});
|
2026-06-17 17:06:30 -06:00
|
|
|
if (!name || name === current) return;
|
|
|
|
|
try {
|
|
|
|
|
if (kind === 'file') await renameFile(id, name);
|
2026-06-19 16:12:20 +00:00
|
|
|
else {
|
|
|
|
|
await renameFolder(id, name);
|
|
|
|
|
rememberFolderName(id, name); // keep breadcrumbs current immediately
|
|
|
|
|
}
|
2026-06-19 15:39:08 +00:00
|
|
|
await reload();
|
2026-06-17 17:06:30 -06:00
|
|
|
} catch (e) {
|
2026-06-17 22:30:56 -06:00
|
|
|
errorToast(e);
|
2026-06-17 17:06:30 -06:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function deleteItem(kind: 'file' | 'folder', id: string, name: string) {
|
2026-06-17 22:07:18 -06:00
|
|
|
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;
|
2026-06-17 17:06:30 -06:00
|
|
|
try {
|
|
|
|
|
if (kind === 'file') await deleteFile(id);
|
|
|
|
|
else await deleteFolder(id);
|
2026-06-19 15:39:08 +00:00
|
|
|
await reload();
|
2026-06-20 16:33:08 +02:00
|
|
|
void session.refresh();
|
2026-06-17 17:06:30 -06:00
|
|
|
} catch (e) {
|
2026-06-17 22:30:56 -06:00
|
|
|
errorToast(e);
|
2026-06-17 17:06:30 -06:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-17 22:07:18 -06:00
|
|
|
let viewerOpen = $state(false);
|
|
|
|
|
let viewerFile = $state<FileItem | null>(null);
|
|
|
|
|
|
2026-06-17 17:06:30 -06:00
|
|
|
function openFile(file: FileItem) {
|
2026-06-20 00:15:29 +02:00
|
|
|
// 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);
|
|
|
|
|
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 });
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-06-17 22:07:18 -06:00
|
|
|
|
|
|
|
|
/**
|
2026-06-20 16:33:08 +02:00
|
|
|
* Whether the server can render a thumbnail preview for this file. Only images
|
|
|
|
|
* and videos have server-side thumbnails (see `ThumbnailService::is_supported_image`
|
|
|
|
|
* plus client-uploaded video frames); the backend does NOT rasterise PDFs or
|
|
|
|
|
* documents, so claiming it could left their tiles blank (the doomed <img>
|
|
|
|
|
* 404s and `onerror` hides it). Non-thumbnail files fall back to their colour
|
|
|
|
|
* type icon, which renders underneath the <img> regardless.
|
2026-06-17 22:07:18 -06:00
|
|
|
*/
|
|
|
|
|
function canThumbnail(file: FileItem): boolean {
|
|
|
|
|
const m = file.mime_type ?? '';
|
2026-06-20 16:33:08 +02:00
|
|
|
return m.startsWith('image/') || m.startsWith('video/');
|
2026-06-17 22:07:18 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Multi-select + batch ────────────────────────────────────────────────
|
|
|
|
|
let selected = $state<Set<string>>(new Set());
|
|
|
|
|
// Anchor row id for shift-click range selection.
|
|
|
|
|
let selectionAnchor = $state<string | null>(null);
|
|
|
|
|
|
|
|
|
|
function toggleSelected(id: string) {
|
|
|
|
|
const next = new Set(selected);
|
|
|
|
|
if (next.has(id)) next.delete(id);
|
|
|
|
|
else next.add(id);
|
|
|
|
|
selected = next;
|
|
|
|
|
selectionAnchor = id;
|
|
|
|
|
}
|
|
|
|
|
function clearSelection() {
|
|
|
|
|
selected = new Set();
|
|
|
|
|
selectionAnchor = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Row click selection mirroring static/js/components/resourceList.js:
|
|
|
|
|
* - Shift+click selects the contiguous range from the anchor to this row.
|
|
|
|
|
* - Ctrl/Cmd+click toggles this row without clearing the rest.
|
|
|
|
|
* - A plain click (no modifier) opens the item — handled by the caller.
|
|
|
|
|
* Returns true when the click was consumed as a selection gesture.
|
|
|
|
|
*/
|
|
|
|
|
function handleSelectionClick(e: MouseEvent, id: string): boolean {
|
|
|
|
|
if (e.shiftKey && selectionAnchor) {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
const a = orderedIds.indexOf(selectionAnchor);
|
|
|
|
|
const b = orderedIds.indexOf(id);
|
|
|
|
|
if (a !== -1 && b !== -1) {
|
|
|
|
|
const [lo, hi] = a < b ? [a, b] : [b, a];
|
|
|
|
|
selected = new Set([...selected, ...orderedIds.slice(lo, hi + 1)]);
|
|
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
if (e.ctrlKey || e.metaKey) {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
toggleSelected(id);
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const selectedCount = $derived(selected.size);
|
|
|
|
|
const totalCount = $derived(listing.folders.length + listing.files.length);
|
|
|
|
|
|
|
|
|
|
function toggleSelectAll() {
|
|
|
|
|
if (selected.size === totalCount) clearSelection();
|
|
|
|
|
else selected = new Set([...listing.folders, ...listing.files].map((i) => i.id));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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.
|
|
|
|
|
*/
|
2026-06-20 00:29:47 +02:00
|
|
|
/** 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`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-17 22:07:18 -06:00
|
|
|
async function batchDownload() {
|
|
|
|
|
const fileIds: string[] = [];
|
|
|
|
|
const folderIds: string[] = [];
|
|
|
|
|
for (const id of selected) {
|
|
|
|
|
if (listing.folders.some((f) => f.id === id)) folderIds.push(id);
|
|
|
|
|
else if (listing.files.some((f) => f.id === 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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-20 00:29:47 +02:00
|
|
|
const zipName = batchZipName();
|
2026-06-17 22:07:18 -06:00
|
|
|
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) {
|
2026-06-17 22:30:56 -06:00
|
|
|
errorToast(e);
|
2026-06-17 22:07:18 -06:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Batch add the selection to favorites — single /api/favorites/batch call. */
|
|
|
|
|
async function batchFavorites() {
|
2026-06-19 15:21:01 +00:00
|
|
|
const items = selectionTargets().filter((it) => !favoriteIds.has(it.id));
|
2026-06-17 22:07:18 -06:00
|
|
|
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}`);
|
2026-06-19 15:21:01 +00:00
|
|
|
favoriteIds = new Set([...favoriteIds, ...items.map((it) => it.id)]);
|
2026-06-17 22:07:18 -06:00
|
|
|
ui.notify(t('files.added_favorites', 'Added to favorites'), 'success');
|
|
|
|
|
clearSelection();
|
|
|
|
|
} catch (e) {
|
2026-06-17 22:30:56 -06:00
|
|
|
errorToast(e);
|
2026-06-17 22:07:18 -06:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function selectionTargets(): ActionTarget[] {
|
|
|
|
|
return [...selected]
|
|
|
|
|
.map((id) => {
|
|
|
|
|
const folder = listing.folders.find((f) => f.id === id);
|
|
|
|
|
if (folder) return { id, name: folder.name, kind: 'folder' as ItemType };
|
|
|
|
|
const file = listing.files.find((f) => f.id === id);
|
|
|
|
|
return file ? { id, name: file.name, kind: 'file' as ItemType } : 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.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'a') {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
toggleSelectAll();
|
|
|
|
|
} else 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();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
for (const id of ids) {
|
|
|
|
|
const folder = listing.folders.find((f) => f.id === id);
|
|
|
|
|
try {
|
|
|
|
|
if (folder) await deleteFolder(id);
|
|
|
|
|
else await deleteFile(id);
|
|
|
|
|
} catch (e) {
|
2026-06-17 22:30:56 -06:00
|
|
|
errorToast(e);
|
2026-06-17 22:07:18 -06:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
clearSelection();
|
2026-06-19 15:39:08 +00:00
|
|
|
await reload();
|
2026-06-20 16:33:08 +02:00
|
|
|
void session.refresh();
|
2026-06-17 22:07:18 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Drag-to-move ─────────────────────────────────────────────────────────
|
|
|
|
|
const DRAG_TYPE = 'application/x-oxi-item';
|
|
|
|
|
let dropFolderId = $state<string | null>(null);
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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));
|
2026-06-20 00:15:29 +02:00
|
|
|
if (e.dataTransfer) {
|
|
|
|
|
e.dataTransfer.effectAllowed = 'move';
|
|
|
|
|
if (items.length > 1) showDragGhost(e.dataTransfer, items);
|
2026-06-20 00:29:47 +02:00
|
|
|
// 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) };
|
2026-06-20 00:15:29 +02:00
|
|
|
}
|
2026-06-20 00:29:47 +02:00
|
|
|
// 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()}` };
|
2026-06-20 00:15:29 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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);
|
2026-06-17 22:07:18 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
try {
|
|
|
|
|
for (const it of items) {
|
|
|
|
|
if (it.kind === 'file') await moveFile(it.id, targetFolderId);
|
|
|
|
|
else await moveFolder(it.id, targetFolderId);
|
|
|
|
|
}
|
|
|
|
|
clearSelection();
|
2026-06-19 15:39:08 +00:00
|
|
|
await reload();
|
2026-06-17 22:07:18 -06:00
|
|
|
} catch (err) {
|
2026-06-17 22:30:56 -06:00
|
|
|
errorToast(err);
|
2026-06-17 22:07:18 -06:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
void moveInto(folder.id, e);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function onCrumbDrop(e: DragEvent, folderId: string) {
|
|
|
|
|
if (!e.dataTransfer?.types.includes(DRAG_TYPE)) return;
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
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);
|
2026-06-19 22:56:00 +00:00
|
|
|
|
|
|
|
|
// 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();
|
|
|
|
|
});
|
2026-06-17 22:07:18 -06:00
|
|
|
// 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();
|
2026-06-17 22:30:56 -06:00
|
|
|
errorToast(e);
|
2026-06-17 22:07:18 -06:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── 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) {
|
2026-06-17 22:30:56 -06:00
|
|
|
errorToast(e);
|
2026-06-17 22:07:18 -06:00
|
|
|
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) {
|
2026-06-17 22:30:56 -06:00
|
|
|
errorToast(e);
|
2026-06-17 22:07:18 -06:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── 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(`/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);
|
|
|
|
|
|
2026-06-20 00:29:47 +02:00
|
|
|
/**
|
|
|
|
|
* 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;
|
2026-06-17 22:07:18 -06:00
|
|
|
uploading = true;
|
2026-06-20 16:33:08 +02:00
|
|
|
const total = entries.length;
|
|
|
|
|
const label = (done: number) =>
|
|
|
|
|
t('files.uploading_n', { done, total }, `Uploading ${done}/${total} files…`);
|
|
|
|
|
// 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(label(0));
|
2026-06-17 22:07:18 -06:00
|
|
|
try {
|
2026-06-20 00:29:47 +02:00
|
|
|
// Map each relative directory path to its created folder id; '' = current.
|
2026-06-17 22:07:18 -06:00
|
|
|
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;
|
2026-06-20 00:29:47 +02:00
|
|
|
const parentId = await ensureDir(parts.join('/'));
|
2026-06-17 22:07:18 -06:00
|
|
|
const created = await createFolder(name, parentId);
|
|
|
|
|
dirIds.set(relDir, created.id);
|
|
|
|
|
return created.id;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-20 17:03:30 +02:00
|
|
|
// 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 entries) {
|
2026-06-20 00:29:47 +02:00
|
|
|
const segs = relativePath.split('/');
|
2026-06-17 22:07:18 -06:00
|
|
|
segs.pop(); // drop the filename, keep the directory trail
|
2026-06-20 17:03:30 +02:00
|
|
|
items.push({ file, folderId: await ensureDir(segs.join('/')) });
|
2026-06-17 22:07:18 -06:00
|
|
|
}
|
2026-06-20 17:03:30 +02:00
|
|
|
|
|
|
|
|
const { savedBytes, failures } = await uploadAll(items, nid, label);
|
|
|
|
|
finishUpload(nid, savedBytes, failures, total);
|
2026-06-19 15:39:08 +00:00
|
|
|
await reload();
|
2026-06-20 16:33:08 +02:00
|
|
|
void session.refresh();
|
2026-06-17 22:07:18 -06:00
|
|
|
} catch (err) {
|
2026-06-20 16:33:08 +02:00
|
|
|
ui.finishProgress(nid, errorMessage(err), 'error');
|
2026-06-17 22:07:18 -06:00
|
|
|
} finally {
|
|
|
|
|
uploading = false;
|
|
|
|
|
}
|
2026-06-17 17:06:30 -06:00
|
|
|
}
|
|
|
|
|
|
2026-06-20 00:29:47 +02:00
|
|
|
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 = '';
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-17 17:06:30 -06:00
|
|
|
const isEmpty = $derived(listing.folders.length === 0 && listing.files.length === 0);
|
|
|
|
|
const viewClass = $derived(
|
|
|
|
|
filesStore.viewMode === 'grid' ? 'files-grid-view' : 'files-list-view'
|
|
|
|
|
);
|
|
|
|
|
|
2026-06-17 22:07:18 -06:00
|
|
|
// Client-side sort (flat, Drive-style). The listing endpoint returns the
|
|
|
|
|
// folder contents unsorted; sorting here avoids a refetch per column click.
|
|
|
|
|
type SortField = 'name' | 'type' | 'size' | 'modified_at' | 'created_at';
|
|
|
|
|
let sortField = $state<SortField>('name');
|
|
|
|
|
let sortDir = $state<1 | -1>(1);
|
|
|
|
|
|
|
|
|
|
function toggleSort(field: SortField) {
|
|
|
|
|
if (sortField === field) sortDir = (sortDir * -1) as 1 | -1;
|
|
|
|
|
else {
|
|
|
|
|
sortField = field;
|
|
|
|
|
sortDir = 1;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function cmpFolders(a: FolderItem, b: FolderItem): number {
|
|
|
|
|
let v: number;
|
|
|
|
|
if (sortField === 'modified_at') v = a.modified_at - b.modified_at;
|
|
|
|
|
else if (sortField === 'created_at') v = a.created_at - b.created_at;
|
|
|
|
|
// Folders have no size; fall back to name for size/type so they stay stable.
|
|
|
|
|
else v = a.name.localeCompare(b.name);
|
|
|
|
|
return v * sortDir;
|
|
|
|
|
}
|
|
|
|
|
function cmpFiles(a: FileItem, b: FileItem): number {
|
|
|
|
|
let v: number;
|
|
|
|
|
if (sortField === 'size') v = (a.size ?? 0) - (b.size ?? 0);
|
|
|
|
|
else if (sortField === 'modified_at') v = a.modified_at - b.modified_at;
|
|
|
|
|
else if (sortField === 'created_at') v = a.created_at - b.created_at;
|
|
|
|
|
else if (sortField === 'type') v = (a.category ?? '').localeCompare(b.category ?? '');
|
|
|
|
|
else v = a.name.localeCompare(b.name);
|
|
|
|
|
return v * sortDir;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const sortedFolders = $derived([...listing.folders].sort(cmpFolders));
|
|
|
|
|
const sortedFiles = $derived([...listing.files].sort(cmpFiles));
|
|
|
|
|
|
|
|
|
|
/** Flat id order matching how rows are displayed (folders then files). */
|
|
|
|
|
const orderedIds = $derived([...sortedFolders.map((f) => f.id), ...sortedFiles.map((f) => f.id)]);
|
|
|
|
|
|
2026-06-19 14:48:37 +00:00
|
|
|
// Folders-then-files as one ordered, discriminated list so the (flat) view can
|
|
|
|
|
// be windowed by a single VirtualList. Content width drives the grid columns.
|
|
|
|
|
type Entry = { kind: 'folder'; folder: FolderItem } | { kind: 'file'; file: FileItem };
|
|
|
|
|
const entries = $derived<Entry[]>([
|
|
|
|
|
...sortedFolders.map((folder) => ({ kind: 'folder' as const, folder })),
|
|
|
|
|
...sortedFiles.map((file) => ({ kind: 'file' as const, file }))
|
|
|
|
|
]);
|
|
|
|
|
const entryKey = (e: Entry): string => (e.kind === 'folder' ? e.folder.id : e.file.id);
|
|
|
|
|
let gridWidth = $state(0);
|
|
|
|
|
|
2026-06-17 22:07:18 -06:00
|
|
|
// ── Group-by / swimlanes ─────────────────────────────────────────────────
|
|
|
|
|
// Mirrors GROUP_BY_DEFS in static/js/app/filesView.js: a flat list ('') plus
|
|
|
|
|
// Type / Size / Modified date / Created date dimensions. Folders always group
|
|
|
|
|
// into their own lane (Folder / "Folders" size sentinel) ahead of the files.
|
|
|
|
|
type GroupBy = '' | 'type' | 'size' | 'modifiedAt' | 'createdAt';
|
|
|
|
|
let groupBy = $state<GroupBy>('');
|
|
|
|
|
|
|
|
|
|
interface ResourceGroup {
|
|
|
|
|
key: string;
|
|
|
|
|
label: string;
|
|
|
|
|
folders: FolderItem[];
|
|
|
|
|
files: FileItem[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function folderGroupKey(folder: FolderItem): string {
|
|
|
|
|
if (groupBy === 'type') return t('files.file_types.folder', 'Folders');
|
|
|
|
|
if (groupBy === 'size') return sizeBucket(-1);
|
|
|
|
|
if (groupBy === 'modifiedAt') return dateBucket(folder.modified_at);
|
|
|
|
|
if (groupBy === 'createdAt') return dateBucket(folder.created_at);
|
|
|
|
|
return '';
|
|
|
|
|
}
|
|
|
|
|
function fileGroupKey(file: FileItem): string {
|
|
|
|
|
if (groupBy === 'type') return typeLabel(file.category);
|
|
|
|
|
if (groupBy === 'size') return sizeBucket(file.size ?? 0);
|
|
|
|
|
if (groupBy === 'modifiedAt') return dateBucket(file.modified_at);
|
|
|
|
|
if (groupBy === 'createdAt') return dateBucket(file.created_at);
|
|
|
|
|
return '';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Grouped rendering: ordered lanes preserving the sorted folder-then-file order
|
|
|
|
|
// within each lane. Lanes appear in first-seen order (folders precede files).
|
|
|
|
|
const groups = $derived.by<ResourceGroup[]>(() => {
|
|
|
|
|
if (groupBy === '') return [];
|
|
|
|
|
const map = new Map<string, ResourceGroup>();
|
|
|
|
|
const ensure = (key: string): ResourceGroup => {
|
|
|
|
|
let g = map.get(key);
|
|
|
|
|
if (!g) {
|
|
|
|
|
g = { key, label: key, folders: [], files: [] };
|
|
|
|
|
map.set(key, g);
|
|
|
|
|
}
|
|
|
|
|
return g;
|
|
|
|
|
};
|
|
|
|
|
for (const folder of sortedFolders) ensure(folderGroupKey(folder)).folders.push(folder);
|
|
|
|
|
for (const file of sortedFiles) ensure(fileGroupKey(file)).files.push(file);
|
|
|
|
|
return [...map.values()];
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ── Toolbar controls (upload split-button + group-by popup menu) ─────────
|
|
|
|
|
// The group-by popup + sort-direction + view toggle live in the shared
|
|
|
|
|
// <ListToolbar>; this page only owns the upload split-button dropdown.
|
|
|
|
|
let uploadMenuOpen = $state(false);
|
|
|
|
|
|
|
|
|
|
interface GroupByDef {
|
|
|
|
|
key: GroupBy;
|
|
|
|
|
label: string;
|
|
|
|
|
icon: string;
|
|
|
|
|
/** Sort field implied by this dimension (the old group-by drove order_by). */
|
|
|
|
|
sort?: SortField;
|
|
|
|
|
}
|
|
|
|
|
// Mirrors the old GROUP_BY_DEFS: "Name" is the default (flat, sorted by name)
|
|
|
|
|
// entry — there is no "None" option — followed by the swimlane dimensions.
|
|
|
|
|
const GROUP_BYS = $derived<GroupByDef[]>([
|
|
|
|
|
{ key: '', label: t('files.name', 'Name'), icon: 'arrow-up-a-z', sort: 'name' },
|
|
|
|
|
{ key: 'type', label: t('groupby.type', 'Type'), icon: 'layer-group', sort: 'type' },
|
|
|
|
|
{ key: 'size', label: t('groupby.size', 'Size'), icon: 'layer-group', sort: 'size' },
|
|
|
|
|
{
|
|
|
|
|
key: 'modifiedAt',
|
|
|
|
|
label: t('groupby.modifiedAt', 'Modified date'),
|
|
|
|
|
icon: 'layer-group',
|
|
|
|
|
sort: 'modified_at'
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
key: 'createdAt',
|
|
|
|
|
label: t('groupby.createdAt', 'Created date'),
|
|
|
|
|
icon: 'layer-group',
|
|
|
|
|
sort: 'created_at'
|
|
|
|
|
}
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
/** Group-by chosen in the toolbar — also sets the matching sort field. */
|
|
|
|
|
function onPickGroup(key: string) {
|
|
|
|
|
groupBy = key as GroupBy;
|
|
|
|
|
const def = GROUP_BYS.find((g) => g.key === key);
|
|
|
|
|
if (def?.sort) sortField = def.sort;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const SKELETON = [0, 1, 2, 3, 4, 5, 6, 7];
|
|
|
|
|
|
2026-06-17 17:06:30 -06:00
|
|
|
// Reload whenever the route path changes.
|
|
|
|
|
$effect(() => {
|
|
|
|
|
// reference pathSegments so the effect re-runs on navigation
|
|
|
|
|
void pathSegments;
|
|
|
|
|
void load();
|
|
|
|
|
});
|
2026-06-17 22:07:18 -06:00
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
});
|
2026-06-17 17:06:30 -06:00
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<svelte:head><title>{t('nav.files', 'Files')} · OxiCloud</title></svelte:head>
|
|
|
|
|
|
2026-06-17 22:07:18 -06:00
|
|
|
<svelte:window onkeydown={onKeydown} />
|
|
|
|
|
|
2026-06-17 17:06:30 -06:00
|
|
|
<div
|
|
|
|
|
class="files-page"
|
|
|
|
|
class:dropzone-active={dragOver}
|
|
|
|
|
role="region"
|
|
|
|
|
aria-label={t('nav.files', 'Files')}
|
|
|
|
|
ondragover={(e) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
dragOver = true;
|
|
|
|
|
}}
|
|
|
|
|
ondragleave={() => (dragOver = false)}
|
|
|
|
|
ondrop={onDrop}
|
|
|
|
|
>
|
|
|
|
|
<div class="page-sticky-header">
|
2026-06-17 22:07:18 -06:00
|
|
|
<!-- Hidden upload inputs stay mounted even while the batch bar is shown. -->
|
|
|
|
|
<input bind:this={fileInput} type="file" multiple hidden onchange={onUpload} />
|
|
|
|
|
<input
|
|
|
|
|
bind:this={folderInput}
|
|
|
|
|
type="file"
|
|
|
|
|
multiple
|
|
|
|
|
hidden
|
|
|
|
|
webkitdirectory
|
|
|
|
|
onchange={onUploadFolder}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
<ListToolbar
|
|
|
|
|
groups={GROUP_BYS}
|
|
|
|
|
{groupBy}
|
|
|
|
|
reversed={sortDir === -1}
|
|
|
|
|
ongroup={onPickGroup}
|
|
|
|
|
ondirection={() => (sortDir = (sortDir * -1) as 1 | -1)}
|
|
|
|
|
>
|
|
|
|
|
{#snippet start()}
|
|
|
|
|
{#if selectedCount > 0}
|
|
|
|
|
<div class="action-buttons batch-selection-bar">
|
|
|
|
|
<div class="list-header-checkbox">
|
|
|
|
|
<button
|
|
|
|
|
class="batch-bar-close"
|
|
|
|
|
title={t('files.cancel_selection', 'Cancel selection')}
|
|
|
|
|
aria-label={t('files.cancel_selection', 'Cancel selection')}
|
|
|
|
|
onclick={clearSelection}
|
|
|
|
|
>
|
|
|
|
|
<Icon name="times" />
|
|
|
|
|
</button>
|
|
|
|
|
<span class="batch-bar-count"
|
2026-06-20 14:42:10 +02:00
|
|
|
>{t('files.selected_count', { count: selectedCount }, '{{count}} selected')}</span
|
2026-06-17 22:07:18 -06:00
|
|
|
>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="batch-selection-info">
|
|
|
|
|
<div class="batch-bar-actions">
|
|
|
|
|
<button
|
|
|
|
|
class="batch-btn"
|
|
|
|
|
title={t('files.add_favorites', 'Add to favorites')}
|
|
|
|
|
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')} onclick={batchMove}>
|
|
|
|
|
<Icon name="arrows-alt" />
|
|
|
|
|
<span>{t('files.move', 'Move')}</span>
|
|
|
|
|
</button>
|
|
|
|
|
<button class="batch-btn" title={t('files.copy', 'Copy')} onclick={batchCopy}>
|
|
|
|
|
<Icon name="copy" />
|
|
|
|
|
<span>{t('files.copy', 'Copy')}</span>
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
class="batch-btn"
|
|
|
|
|
title={t('common.download', 'Download')}
|
|
|
|
|
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')}
|
|
|
|
|
onclick={batchDelete}
|
|
|
|
|
>
|
|
|
|
|
<Icon name="trash" />
|
|
|
|
|
<span>{t('common.delete', 'Delete')}</span>
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
{:else}
|
|
|
|
|
<div class="action-buttons">
|
|
|
|
|
<div class="upload-dropdown">
|
|
|
|
|
<button
|
|
|
|
|
class="btn btn-primary"
|
|
|
|
|
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">
|
|
|
|
|
<button
|
|
|
|
|
class="upload-dropdown-item"
|
|
|
|
|
onclick={() => {
|
|
|
|
|
uploadMenuOpen = false;
|
|
|
|
|
fileInput?.click();
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<Icon name="file" />
|
|
|
|
|
<span>{t('actions.upload_files', 'Upload files')}</span>
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
class="upload-dropdown-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" onclick={onNewFolder}>
|
|
|
|
|
<Icon name="folder-plus" class="icon-mr" />
|
|
|
|
|
<span>{t('actions.new_folder', 'New folder')}</span>
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
{/if}
|
|
|
|
|
{/snippet}
|
|
|
|
|
</ListToolbar>
|
|
|
|
|
|
2026-06-17 17:06:30 -06:00
|
|
|
<nav class="breadcrumb" aria-label="Breadcrumb">
|
|
|
|
|
{#each crumbs as c, i (c.id)}
|
2026-06-20 01:28:52 +02:00
|
|
|
{#if i > 0}
|
|
|
|
|
<span class="breadcrumb-separator">></span>
|
|
|
|
|
{/if}
|
2026-06-17 22:07:18 -06:00
|
|
|
{#if i === crumbs.length - 1}
|
2026-06-20 01:28:52 +02:00
|
|
|
<span class="breadcrumb-item breadcrumb-current" class:breadcrumb-home={i === 0}>
|
|
|
|
|
{#if i === 0}<Icon name={rootIcon} />{/if}
|
|
|
|
|
{c.name}
|
|
|
|
|
</span>
|
2026-06-17 22:07:18 -06:00
|
|
|
{:else}
|
|
|
|
|
<a
|
|
|
|
|
href={crumbHref(i)}
|
|
|
|
|
class="breadcrumb-item breadcrumb-link"
|
2026-06-20 01:28:52 +02:00
|
|
|
class:breadcrumb-home={i === 0}
|
|
|
|
|
title={i === 0 ? t('breadcrumb.home', 'Home') : undefined}
|
2026-06-17 22:07:18 -06:00
|
|
|
ondragover={(e) => e.dataTransfer?.types.includes(DRAG_TYPE) && e.preventDefault()}
|
2026-06-20 01:28:52 +02:00
|
|
|
ondrop={(e) => onCrumbDrop(e, c.id)}
|
2026-06-17 22:07:18 -06:00
|
|
|
>
|
2026-06-20 01:28:52 +02:00
|
|
|
{#if i === 0}<Icon name={rootIcon} />{/if}
|
|
|
|
|
{c.name}
|
|
|
|
|
</a>
|
2026-06-17 22:07:18 -06:00
|
|
|
{/if}
|
2026-06-17 17:06:30 -06:00
|
|
|
{/each}
|
|
|
|
|
</nav>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{#if error}
|
2026-06-17 22:30:56 -06:00
|
|
|
<EmptyState title={error} error />
|
2026-06-17 22:07:18 -06:00
|
|
|
{:else if showSkeleton && isEmpty}
|
2026-06-17 22:30:56 -06:00
|
|
|
<SkeletonList count={SKELETON.length} />
|
2026-06-17 17:06:30 -06:00
|
|
|
{:else if isEmpty}
|
2026-06-17 22:30:56 -06:00
|
|
|
<EmptyState
|
|
|
|
|
title={t('files.empty_title', 'This folder is empty')}
|
|
|
|
|
hint={t('files.empty_hint', 'Drop files here or use the Upload button to add files.')}
|
|
|
|
|
/>
|
2026-06-17 17:06:30 -06:00
|
|
|
{:else}
|
2026-06-19 14:48:37 +00:00
|
|
|
<div class="files-container" bind:clientWidth={gridWidth}>
|
|
|
|
|
{#if groupBy !== ''}
|
|
|
|
|
<div class={viewClass}>
|
|
|
|
|
{@render fileListHeader()}
|
2026-06-17 22:07:18 -06:00
|
|
|
{#each groups as group (group.key)}
|
|
|
|
|
<div class="resource-list__swimlane-header">{group.label}</div>
|
|
|
|
|
{#each group.folders as folder (folder.id)}
|
|
|
|
|
{@render folderRow(folder)}
|
|
|
|
|
{/each}
|
|
|
|
|
{#each group.files as file (file.id)}
|
|
|
|
|
{@render fileRow(file)}
|
|
|
|
|
{/each}
|
|
|
|
|
{/each}
|
2026-06-19 14:48:37 +00:00
|
|
|
</div>
|
|
|
|
|
{:else if filesStore.viewMode === 'list'}
|
|
|
|
|
<!-- Flat list: only the rows near the viewport are mounted. -->
|
|
|
|
|
<div class="files-list-view">
|
|
|
|
|
{@render fileListHeader()}
|
|
|
|
|
<VirtualList items={entries} rowHeight={56} key={entryKey} row={entryRow} />
|
|
|
|
|
</div>
|
|
|
|
|
{:else}
|
|
|
|
|
<!-- Grid: the windowed list's inner element IS the card grid. -->
|
|
|
|
|
<VirtualList
|
|
|
|
|
items={entries}
|
|
|
|
|
columns={gridColumns(gridWidth)}
|
|
|
|
|
rowHeight={240}
|
|
|
|
|
windowClass="files-grid-view"
|
|
|
|
|
key={entryKey}
|
|
|
|
|
row={entryRow}
|
|
|
|
|
/>
|
|
|
|
|
{/if}
|
2026-06-17 17:06:30 -06:00
|
|
|
</div>
|
|
|
|
|
{/if}
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-06-19 14:48:37 +00:00
|
|
|
{#snippet fileListHeader()}
|
|
|
|
|
<div class="list-header">
|
|
|
|
|
<div class="list-header-checkbox">
|
|
|
|
|
<input
|
|
|
|
|
type="checkbox"
|
|
|
|
|
aria-label={t('files.select_all', 'Select all')}
|
|
|
|
|
checked={selectedCount > 0 && selectedCount === totalCount}
|
|
|
|
|
indeterminate={selectedCount > 0 && selectedCount < totalCount}
|
|
|
|
|
onchange={toggleSelectAll}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
{#each [{ f: 'name', l: t('files.col_name', 'Name') }, { f: 'owner', l: t('files.col_owner', 'Owner') }, { f: 'type', l: t('files.col_type', 'Type') }, { f: 'size', l: t('files.col_size', 'Size') }, { f: 'modified_at', l: t('files.col_modified', 'Modified') }] as col (col.f)}
|
|
|
|
|
{#if col.f === 'owner'}
|
|
|
|
|
<div class="list-header-owner">{col.l}</div>
|
|
|
|
|
{:else}
|
|
|
|
|
<button
|
|
|
|
|
class="list-header-sort"
|
|
|
|
|
class:is-active={sortField === col.f}
|
|
|
|
|
data-sort-field={col.f}
|
|
|
|
|
onclick={() => toggleSort(col.f as SortField)}
|
|
|
|
|
>
|
|
|
|
|
{col.l}
|
|
|
|
|
{#if sortField === col.f}
|
|
|
|
|
<Icon
|
|
|
|
|
name={sortDir === 1 ? 'arrow-down' : 'arrow-up'}
|
|
|
|
|
class="list-header-sort__arrow"
|
|
|
|
|
/>
|
|
|
|
|
{/if}
|
|
|
|
|
</button>
|
|
|
|
|
{/if}
|
|
|
|
|
{/each}
|
|
|
|
|
<div></div>
|
|
|
|
|
</div>
|
|
|
|
|
{/snippet}
|
|
|
|
|
|
|
|
|
|
{#snippet entryRow(e: Entry)}
|
|
|
|
|
{#if e.kind === 'folder'}
|
|
|
|
|
{@render folderRow(e.folder)}
|
|
|
|
|
{:else}
|
|
|
|
|
{@render fileRow(e.file)}
|
|
|
|
|
{/if}
|
|
|
|
|
{/snippet}
|
|
|
|
|
|
2026-06-17 22:07:18 -06:00
|
|
|
{#snippet folderRow(folder: FolderItem)}
|
|
|
|
|
<div
|
|
|
|
|
class="file-item"
|
|
|
|
|
class:selected={selected.has(folder.id)}
|
|
|
|
|
class:drop-target={dropFolderId === folder.id}
|
|
|
|
|
role="button"
|
|
|
|
|
tabindex="0"
|
|
|
|
|
draggable="true"
|
|
|
|
|
ondragstart={(e) => onItemDragStart(e, 'folder', folder.id, folder.name)}
|
|
|
|
|
ondragover={(e) => {
|
|
|
|
|
if (e.dataTransfer?.types.includes(DRAG_TYPE)) {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
dropFolderId = folder.id;
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
ondragleave={() => {
|
|
|
|
|
if (dropFolderId === folder.id) dropFolderId = null;
|
|
|
|
|
}}
|
|
|
|
|
ondrop={(e) => onFolderDrop(e, folder)}
|
|
|
|
|
ondblclick={() => openFolder(folder)}
|
|
|
|
|
onclick={(e) => {
|
|
|
|
|
if (!handleSelectionClick(e, folder.id)) openFolder(folder);
|
|
|
|
|
}}
|
|
|
|
|
oncontextmenu={(e) => openContext(e, 'folder', folder.id, folder.name)}
|
|
|
|
|
onkeydown={(e) => e.key === 'Enter' && openFolder(folder)}
|
|
|
|
|
>
|
|
|
|
|
<div class="checkbox-cell">
|
|
|
|
|
<input
|
|
|
|
|
type="checkbox"
|
|
|
|
|
checked={selected.has(folder.id)}
|
|
|
|
|
aria-label={folder.name}
|
|
|
|
|
onclick={(e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
toggleSelected(folder.id);
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="name-cell">
|
2026-06-20 16:33:08 +02:00
|
|
|
<div class="file-icon file-icon--folder"><Icon name="folder" /></div>
|
2026-06-17 22:07:18 -06:00
|
|
|
<span title={folder.name}>{folder.name}</span>
|
2026-06-19 15:21:01 +00:00
|
|
|
{#if favoriteIds.has(folder.id)}<div
|
2026-06-17 22:07:18 -06:00
|
|
|
class="item-badge item-badge--fav"
|
|
|
|
|
title={t('files.favorited', 'Favorite')}
|
|
|
|
|
>
|
|
|
|
|
<Icon name="star" />
|
|
|
|
|
</div>{/if}
|
2026-06-19 15:21:01 +00:00
|
|
|
{#if sharedIds.has(folder.id)}<div
|
2026-06-17 22:07:18 -06:00
|
|
|
class="file-badge file-badge-shared"
|
|
|
|
|
title={t('files.shared', 'Shared')}
|
|
|
|
|
>
|
|
|
|
|
<Icon name="oxiexport" />
|
|
|
|
|
</div>{/if}
|
|
|
|
|
</div>
|
|
|
|
|
<div class="grid-meta">
|
|
|
|
|
<span class="grid-meta__date">{relativeTimeAgo(folder.modified_at)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="owner-cell">{ownerLabel(folder.owner_id, session.user?.id ?? null)}</div>
|
|
|
|
|
<div class="type-cell">{t('files.file_types.folder', 'Folder')}</div>
|
|
|
|
|
<div class="size-cell">—</div>
|
|
|
|
|
<div class="date-cell">{formatDate(folder.modified_at)}</div>
|
|
|
|
|
<div class="action-cell">
|
|
|
|
|
<button
|
|
|
|
|
class="favorite-star"
|
2026-06-19 15:21:01 +00:00
|
|
|
class:active={favoriteIds.has(folder.id)}
|
|
|
|
|
title={favoriteIds.has(folder.id)
|
2026-06-17 22:07:18 -06:00
|
|
|
? t('files.unfavorite', 'Remove favorite')
|
|
|
|
|
: t('files.favorite', 'Add favorite')}
|
2026-06-19 15:21:01 +00:00
|
|
|
aria-pressed={favoriteIds.has(folder.id)}
|
2026-06-17 22:07:18 -06:00
|
|
|
onclick={(e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
void toggleFavorite('folder', folder.id);
|
2026-06-19 15:21:01 +00:00
|
|
|
}}><Icon name={favoriteIds.has(folder.id) ? 'star' : 'star-outline'} /></button
|
2026-06-17 22:07:18 -06:00
|
|
|
>
|
|
|
|
|
<button
|
|
|
|
|
class="btn-action"
|
|
|
|
|
title={t('files.share', 'Share')}
|
|
|
|
|
onclick={(e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
openShare('folder', folder.id, folder.name);
|
|
|
|
|
}}><Icon name="link" /></button
|
|
|
|
|
>
|
|
|
|
|
<button
|
|
|
|
|
class="btn-action"
|
|
|
|
|
title={t('files.move', 'Move')}
|
|
|
|
|
onclick={(e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
openMove('folder', folder.id, folder.name);
|
|
|
|
|
}}><Icon name="arrows-alt" /></button
|
|
|
|
|
>
|
|
|
|
|
<button
|
|
|
|
|
class="btn-action"
|
|
|
|
|
title={t('common.rename', 'Rename')}
|
|
|
|
|
onclick={(e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
renameItem('folder', folder.id, folder.name);
|
|
|
|
|
}}><Icon name="pen" /></button
|
|
|
|
|
>
|
|
|
|
|
<button
|
|
|
|
|
class="btn-action btn-action--delete"
|
|
|
|
|
title={t('common.delete', 'Delete')}
|
|
|
|
|
onclick={(e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
deleteItem('folder', folder.id, folder.name);
|
|
|
|
|
}}><Icon name="trash" /></button
|
|
|
|
|
>
|
|
|
|
|
<button
|
|
|
|
|
class="file-actions"
|
|
|
|
|
title={t('files.more_actions', 'More actions')}
|
|
|
|
|
aria-label={t('files.more_actions', 'More actions')}
|
|
|
|
|
aria-haspopup="menu"
|
|
|
|
|
onclick={(e) => openContext(e, 'folder', folder.id, folder.name)}
|
|
|
|
|
><Icon name="ellipsis-v" /></button
|
|
|
|
|
>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
{/snippet}
|
|
|
|
|
|
|
|
|
|
{#snippet fileRow(file: FileItem)}
|
2026-06-20 16:33:08 +02:00
|
|
|
{@const iconName = iconNameFromClass(file.icon_class)}
|
2026-06-17 22:07:18 -06:00
|
|
|
<div
|
|
|
|
|
class="file-item"
|
|
|
|
|
class:selected={selected.has(file.id)}
|
|
|
|
|
role="button"
|
|
|
|
|
tabindex="0"
|
|
|
|
|
draggable="true"
|
|
|
|
|
ondragstart={(e) => onItemDragStart(e, 'file', file.id, file.name)}
|
|
|
|
|
ondblclick={() => openFile(file)}
|
|
|
|
|
onclick={(e) => {
|
|
|
|
|
if (!handleSelectionClick(e, file.id)) openFile(file);
|
|
|
|
|
}}
|
|
|
|
|
oncontextmenu={(e) => openContext(e, 'file', file.id, file.name)}
|
|
|
|
|
onkeydown={(e) => e.key === 'Enter' && openFile(file)}
|
|
|
|
|
>
|
|
|
|
|
<div class="checkbox-cell">
|
|
|
|
|
<input
|
|
|
|
|
type="checkbox"
|
|
|
|
|
checked={selected.has(file.id)}
|
|
|
|
|
aria-label={file.name}
|
|
|
|
|
onclick={(e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
toggleSelected(file.id);
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="name-cell">
|
2026-06-20 16:33:08 +02:00
|
|
|
<div class="file-icon {fileIconKindClass(iconName)}">
|
|
|
|
|
<!-- Colour type icon is always rendered; a successful thumbnail covers it
|
|
|
|
|
edge-to-edge, and a failed one (onerror hides the <img>) reveals it. -->
|
|
|
|
|
<Icon name={iconName} />
|
2026-06-17 22:07:18 -06:00
|
|
|
{#if canThumbnail(file)}
|
|
|
|
|
<img
|
|
|
|
|
class="file-thumb"
|
|
|
|
|
src={fileThumbnailUrl(file.id)}
|
|
|
|
|
alt=""
|
|
|
|
|
loading="lazy"
|
|
|
|
|
onerror={(e) => ((e.currentTarget as HTMLImageElement).style.display = 'none')}
|
|
|
|
|
/>
|
|
|
|
|
{/if}
|
|
|
|
|
</div>
|
|
|
|
|
<span title={file.name}>{file.name}</span>
|
2026-06-19 15:21:01 +00:00
|
|
|
{#if favoriteIds.has(file.id)}<div
|
2026-06-17 22:07:18 -06:00
|
|
|
class="item-badge item-badge--fav"
|
|
|
|
|
title={t('files.favorited', 'Favorite')}
|
|
|
|
|
>
|
|
|
|
|
<Icon name="star" />
|
|
|
|
|
</div>{/if}
|
2026-06-19 15:21:01 +00:00
|
|
|
{#if sharedIds.has(file.id)}<div
|
2026-06-17 22:07:18 -06:00
|
|
|
class="file-badge file-badge-shared"
|
|
|
|
|
title={t('files.shared', 'Shared')}
|
|
|
|
|
>
|
|
|
|
|
<Icon name="oxiexport" />
|
|
|
|
|
</div>{/if}
|
|
|
|
|
</div>
|
|
|
|
|
<div class="grid-meta">
|
|
|
|
|
<span class="grid-meta__date">{relativeTimeAgo(file.modified_at)}</span>
|
|
|
|
|
{#if file.size != null}<span class="grid-meta__size">{formatBytes(file.size)}</span>{/if}
|
|
|
|
|
</div>
|
|
|
|
|
<div class="owner-cell">{ownerLabel(file.owner_id, session.user?.id ?? null)}</div>
|
|
|
|
|
<div class="type-cell">{typeLabel(file.category)}</div>
|
|
|
|
|
<div class="size-cell">{file.size != null ? formatBytes(file.size) : ''}</div>
|
|
|
|
|
<div class="date-cell">{formatDate(file.modified_at)}</div>
|
|
|
|
|
<div class="action-cell">
|
|
|
|
|
<button
|
|
|
|
|
class="favorite-star"
|
2026-06-19 15:21:01 +00:00
|
|
|
class:active={favoriteIds.has(file.id)}
|
|
|
|
|
title={favoriteIds.has(file.id)
|
2026-06-17 22:07:18 -06:00
|
|
|
? t('files.unfavorite', 'Remove favorite')
|
|
|
|
|
: t('files.favorite', 'Add favorite')}
|
2026-06-19 15:21:01 +00:00
|
|
|
aria-pressed={favoriteIds.has(file.id)}
|
2026-06-17 22:07:18 -06:00
|
|
|
onclick={(e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
void toggleFavorite('file', file.id);
|
2026-06-19 15:21:01 +00:00
|
|
|
}}><Icon name={favoriteIds.has(file.id) ? 'star' : 'star-outline'} /></button
|
2026-06-17 22:07:18 -06:00
|
|
|
>
|
|
|
|
|
<button
|
|
|
|
|
class="btn-action"
|
|
|
|
|
title={t('files.share', 'Share')}
|
|
|
|
|
onclick={(e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
openShare('file', file.id, file.name);
|
|
|
|
|
}}><Icon name="link" /></button
|
|
|
|
|
>
|
|
|
|
|
<button
|
|
|
|
|
class="btn-action"
|
|
|
|
|
title={t('files.move', 'Move')}
|
|
|
|
|
onclick={(e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
openMove('file', file.id, file.name);
|
|
|
|
|
}}><Icon name="arrows-alt" /></button
|
|
|
|
|
>
|
|
|
|
|
<a
|
|
|
|
|
class="btn-action"
|
|
|
|
|
href={fileDownloadUrl(file.id)}
|
|
|
|
|
download
|
|
|
|
|
title={t('common.download', 'Download')}
|
|
|
|
|
onclick={(e) => e.stopPropagation()}><Icon name="download" /></a
|
|
|
|
|
>
|
|
|
|
|
<button
|
|
|
|
|
class="btn-action"
|
|
|
|
|
title={t('common.rename', 'Rename')}
|
|
|
|
|
onclick={(e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
renameItem('file', file.id, file.name);
|
|
|
|
|
}}><Icon name="pen" /></button
|
|
|
|
|
>
|
|
|
|
|
<button
|
|
|
|
|
class="btn-action btn-action--delete"
|
|
|
|
|
title={t('common.delete', 'Delete')}
|
|
|
|
|
onclick={(e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
deleteItem('file', file.id, file.name);
|
|
|
|
|
}}><Icon name="trash" /></button
|
|
|
|
|
>
|
|
|
|
|
<button
|
|
|
|
|
class="file-actions"
|
|
|
|
|
title={t('files.more_actions', 'More actions')}
|
|
|
|
|
aria-label={t('files.more_actions', 'More actions')}
|
|
|
|
|
aria-haspopup="menu"
|
|
|
|
|
onclick={(e) => openContext(e, 'file', file.id, file.name)}
|
|
|
|
|
><Icon name="ellipsis-v" /></button
|
|
|
|
|
>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
{/snippet}
|
|
|
|
|
|
|
|
|
|
<MoveDialog
|
|
|
|
|
bind:open={moveOpen}
|
|
|
|
|
item={actionTarget}
|
|
|
|
|
items={moveItems}
|
|
|
|
|
mode={moveMode}
|
|
|
|
|
onmoved={() => {
|
|
|
|
|
clearSelection();
|
2026-06-19 15:39:08 +00:00
|
|
|
void reload();
|
2026-06-17 22:07:18 -06:00
|
|
|
}}
|
|
|
|
|
/>
|
2026-06-19 15:21:01 +00:00
|
|
|
<ShareDialog
|
|
|
|
|
bind:open={shareOpen}
|
|
|
|
|
item={actionTarget}
|
|
|
|
|
onshared={(id) => (sharedIds = new Set(sharedIds).add(id))}
|
|
|
|
|
/>
|
2026-06-19 22:56:00 +00:00
|
|
|
{#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}
|
2026-06-17 22:07:18 -06:00
|
|
|
|
|
|
|
|
{#if ctxOpen && ctxTarget}
|
|
|
|
|
<div
|
|
|
|
|
class="ctx-scrim"
|
|
|
|
|
role="presentation"
|
|
|
|
|
onclick={closeContext}
|
|
|
|
|
oncontextmenu={(e) => e.preventDefault()}
|
|
|
|
|
></div>
|
|
|
|
|
<div class="ctx-menu" style:left="{ctxX}px" style:top="{ctxY}px" role="menu">
|
|
|
|
|
{#if ctxTarget.kind === 'folder'}
|
|
|
|
|
<button
|
|
|
|
|
class="ctx-item"
|
|
|
|
|
role="menuitem"
|
|
|
|
|
onclick={() => {
|
|
|
|
|
const id = ctxTarget!.id;
|
|
|
|
|
closeContext();
|
|
|
|
|
goto(`/files/${[...pathSegments, id].join('/')}`);
|
|
|
|
|
}}><Icon name="folder-open" /> {t('files.open', 'Open')}</button
|
|
|
|
|
>
|
|
|
|
|
<button
|
|
|
|
|
class="ctx-item"
|
|
|
|
|
role="menuitem"
|
|
|
|
|
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"
|
|
|
|
|
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"
|
|
|
|
|
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"
|
|
|
|
|
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)}
|
|
|
|
|
download
|
|
|
|
|
onclick={closeContext}><Icon name="download" /> {t('common.download', 'Download')}</a
|
|
|
|
|
>
|
|
|
|
|
<button
|
|
|
|
|
class="ctx-item"
|
|
|
|
|
role="menuitem"
|
|
|
|
|
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"
|
|
|
|
|
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"
|
|
|
|
|
onclick={() => {
|
|
|
|
|
const tg = ctxTarget!;
|
|
|
|
|
closeContext();
|
|
|
|
|
openShare(tg.kind, tg.id, tg.name);
|
|
|
|
|
}}><Icon name="link" /> {t('files.share', 'Share')}</button
|
|
|
|
|
>
|
|
|
|
|
<button
|
|
|
|
|
class="ctx-item"
|
|
|
|
|
role="menuitem"
|
|
|
|
|
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"
|
|
|
|
|
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"
|
|
|
|
|
onclick={() => {
|
|
|
|
|
const tg = ctxTarget!;
|
|
|
|
|
closeContext();
|
|
|
|
|
void toggleFavorite(tg.kind, tg.id);
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<Icon name="star" />
|
2026-06-19 15:21:01 +00:00
|
|
|
{favoriteIds.has(ctxTarget.id)
|
2026-06-17 22:07:18 -06:00
|
|
|
? t('files.unfavorite', 'Remove favorite')
|
|
|
|
|
: t('files.favorite', 'Add favorite')}
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
class="ctx-item"
|
|
|
|
|
role="menuitem"
|
|
|
|
|
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"
|
|
|
|
|
onclick={() => {
|
|
|
|
|
const tg = ctxTarget!;
|
|
|
|
|
closeContext();
|
|
|
|
|
void deleteItem(tg.kind, tg.id, tg.name);
|
|
|
|
|
}}><Icon name="trash" /> {t('common.delete', 'Delete')}</button
|
|
|
|
|
>
|
|
|
|
|
</div>
|
|
|
|
|
{/if}
|
|
|
|
|
|
2026-06-17 17:06:30 -06:00
|
|
|
<style>
|
|
|
|
|
.files-page {
|
|
|
|
|
min-height: 100%;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.files-page.dropzone-active {
|
|
|
|
|
outline: 2px dashed var(--color-accent);
|
|
|
|
|
outline-offset: -8px;
|
|
|
|
|
border-radius: var(--radius-xl);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.page-sticky-header {
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
gap: var(--space-3);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-17 22:07:18 -06:00
|
|
|
.action-cell {
|
2026-06-17 17:06:30 -06:00
|
|
|
display: flex;
|
2026-06-17 22:07:18 -06:00
|
|
|
gap: var(--space-1);
|
|
|
|
|
justify-content: flex-end;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.btn-action--delete:hover {
|
|
|
|
|
color: var(--color-danger-text);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.btn-action {
|
|
|
|
|
text-decoration: none;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* Owner column header — non-sortable, so it's a plain div rather than a
|
|
|
|
|
sort button. Inherits the header row's weight/colour. */
|
|
|
|
|
.list-header-owner {
|
|
|
|
|
min-width: 0;
|
2026-06-17 17:06:30 -06:00
|
|
|
overflow: hidden;
|
2026-06-17 22:07:18 -06:00
|
|
|
text-overflow: ellipsis;
|
|
|
|
|
white-space: nowrap;
|
2026-06-17 17:06:30 -06:00
|
|
|
}
|
|
|
|
|
|
2026-06-17 22:07:18 -06:00
|
|
|
.item-badge {
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
margin-left: var(--space-1);
|
|
|
|
|
font-size: 0.75rem;
|
2026-06-17 17:06:30 -06:00
|
|
|
color: var(--color-text-muted);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-17 22:07:18 -06:00
|
|
|
.item-badge--fav {
|
|
|
|
|
color: var(--color-warning-text, var(--color-accent));
|
2026-06-17 17:06:30 -06:00
|
|
|
}
|
|
|
|
|
|
2026-06-17 22:07:18 -06:00
|
|
|
.ctx-scrim {
|
|
|
|
|
position: fixed;
|
|
|
|
|
inset: 0;
|
|
|
|
|
z-index: 90;
|
2026-06-17 17:06:30 -06:00
|
|
|
}
|
|
|
|
|
|
2026-06-17 22:07:18 -06:00
|
|
|
.ctx-menu {
|
|
|
|
|
position: fixed;
|
|
|
|
|
z-index: 100;
|
|
|
|
|
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));
|
2026-06-17 17:06:30 -06:00
|
|
|
}
|
|
|
|
|
|
2026-06-17 22:07:18 -06:00
|
|
|
.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;
|
2026-06-17 17:06:30 -06:00
|
|
|
text-decoration: none;
|
2026-06-17 22:07:18 -06:00
|
|
|
border-radius: var(--radius-sm);
|
|
|
|
|
font-size: var(--text-sm);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.ctx-item:hover {
|
|
|
|
|
background: var(--color-bg-hover);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.ctx-item--danger {
|
2026-06-20 18:15:30 +02:00
|
|
|
/* 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);
|
2026-06-17 17:06:30 -06:00
|
|
|
}
|
|
|
|
|
</style>
|