feat(sidebar): permit drop in trash / favorite

This commit is contained in:
Edouard Vanbelle
2026-07-22 23:10:47 +02:00
parent 50fd81e30e
commit 825a5d1b67
3 changed files with 153 additions and 2 deletions
+124 -2
View File
@@ -5,8 +5,10 @@
import { page } from '$app/state';
import { logout } from '$lib/api/endpoints/auth';
import { searchFiles } from '$lib/api/endpoints/search';
import { fileInlineUrl } from '$lib/api/endpoints/files';
import type { FileItem, FolderItem } from '$lib/api/types';
import { fileInlineUrl, deleteFile } from '$lib/api/endpoints/files';
import { deleteFolder } from '$lib/api/endpoints/folders';
import { addFavorite } from '$lib/api/endpoints/favorites';
import type { FileItem, FolderItem, ItemType } from '$lib/api/types';
import { lazyComponent } from '$lib/composables/lazyComponent.svelte';
import DrivePicker from '$lib/components/DrivePicker.svelte';
import Icon from '$lib/icons/Icon.svelte';
@@ -14,10 +16,12 @@
import { userInitials, avatarColorIndex } from '$lib/utils/avatar';
import { i18n, LANGUAGES, setLocale, t, type Locale } from '$lib/i18n/index.svelte';
import { apiFetch } from '$lib/api/client';
import { dialogs } from '$lib/stores/dialogs.svelte';
import { preferences } from '$lib/stores/preferences.svelte';
import { session } from '$lib/stores/session.svelte';
import { theme, type Theme } from '$lib/stores/theme.svelte';
import { ui } from '$lib/stores/ui.svelte';
import { errorToast } from '$lib/utils/errors';
import { formatBytes } from '$lib/utils/format';
let { children }: { children: Snippet } = $props();
@@ -70,6 +74,119 @@
return page.url.pathname === href || page.url.pathname.startsWith(`${href}/`);
}
// ── Sidebar drop targets ─────────────────────────────────────────────────
// The row-drag on `/files` (and other resource surfaces) sets a
// `application/x-oxi-item` MIME with a JSON array of `{ id, name, kind }`.
// The Favorites and Trash sidebar links accept a drop of that shape:
// – Favorites: batch-add each item as a favorite (idempotent server-side).
// – Trash: yes/no confirm dialog, then batch-delete on approval.
// Every other sidebar link stays inert (no dragover ⇒ no drop cursor).
const SIDEBAR_DRAG_TYPE = 'application/x-oxi-item';
interface DragItem {
id: string;
name: string;
kind: ItemType;
}
let sidebarDropHref = $state<string | null>(null);
function sidebarOnDragOver(e: DragEvent, href: string) {
if (!e.dataTransfer?.types.includes(SIDEBAR_DRAG_TYPE)) return;
e.preventDefault();
// Favorites is a pure add — signal "copy". Trash is destructive — signal
// "move" so the cursor doesn't imply a benign copy for something that
// actually removes the row from its source view.
e.dataTransfer.dropEffect = href === '/favorites' ? 'copy' : 'move';
sidebarDropHref = href;
}
function sidebarOnDragLeave(href: string) {
if (sidebarDropHref === href) sidebarDropHref = null;
}
function parseDragItems(e: DragEvent): DragItem[] {
const raw = e.dataTransfer?.getData(SIDEBAR_DRAG_TYPE);
if (!raw) return [];
try {
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) return [];
return parsed.filter(
(it): it is DragItem =>
!!it &&
typeof it === 'object' &&
typeof (it as DragItem).id === 'string' &&
typeof (it as DragItem).name === 'string' &&
((it as DragItem).kind === 'file' || (it as DragItem).kind === 'folder')
);
} catch {
return [];
}
}
// Small bounded fan-out — same shape as `/files`'s `mapLimit` but
// inlined so AppShell doesn't grow a shared-util dependency for two
// call sites. A drop is usually small (single row or a page of
// selection), so 6 concurrent requests is plenty.
async function fanout<T>(items: T[], limit: number, fn: (item: T) => Promise<void>) {
let next = 0;
const worker = async () => {
while (next < items.length) {
const i = next++;
try {
await fn(items[i]);
} catch (e) {
errorToast(e);
}
}
};
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
}
async function sidebarOnDrop(e: DragEvent, href: string) {
// Narrow the sidebar-link href to the two accepting targets. Called
// from the row template, which can't type-narrow the loop variable
// through the arrow-function closure without a local capture.
if (href !== '/favorites' && href !== '/trash') return;
sidebarDropHref = null;
if (!e.dataTransfer?.types.includes(SIDEBAR_DRAG_TYPE)) return;
e.preventDefault();
const items = parseDragItems(e);
if (items.length === 0) return;
if (href === '/favorites') {
await fanout(items, 6, async (it) => {
await addFavorite(it.kind, it.id);
// Broadcast so a mounted resource-list source page (e.g. /files,
// /recent, /shared-with-me) can flip the row's `is_favorite` in
// place without a re-fetch. ResourceList listens for the same
// event and patches its `items` prop. Uses the existing
// window-event pattern (see `oxicloud:upload-files`).
window.dispatchEvent(
new CustomEvent('oxicloud:favorite-changed', {
detail: { id: it.id, is_favorite: true }
})
);
});
ui.notify(
t('favorites.added_n', { n: items.length }, 'Added {{n}} item(s) to favorites'),
'success'
);
return;
}
// Trash: destructive → gate on confirm.
const ok = await dialogs.confirm({
title: t('files.batch_delete', 'Delete selected'),
message:
items.length === 1
? t('files.confirm_delete', { name: items[0].name }, 'Move "{{name}}" to trash?')
: t('files.confirm_batch_delete', { n: items.length }, 'Move {{n}} items to trash?'),
confirmText: t('common.delete', 'Delete'),
danger: true
});
if (!ok) return;
await fanout(items, 6, (it) => (it.kind === 'file' ? deleteFile(it.id) : deleteFolder(it.id)));
}
let sidebarOpen = $state(false);
let notifOpen = $state(false);
let menuOpen = $state(false);
@@ -332,13 +449,18 @@
<nav class="nav-menu" aria-label={t('nav.primary', 'Primary')}>
{#each LINKS as link (link.href)}
{@const isDropTarget = link.href === '/favorites' || link.href === '/trash'}
<a
class="nav-item"
class:active={active(link.href)}
class:nav-item--drop-target={isDropTarget && sidebarDropHref === link.href}
href={resolve(link.href)}
data-section={link.section}
data-testid={`appshell-nav-${link.href.replace(/^\//, '')}-link`}
onclick={() => (sidebarOpen = false)}
ondragover={isDropTarget ? (e) => sidebarOnDragOver(e, link.href) : undefined}
ondragleave={isDropTarget ? () => sidebarOnDragLeave(link.href) : undefined}
ondrop={isDropTarget ? (e) => void sidebarOnDrop(e, link.href) : undefined}
>
<Icon name={link.icon} />
<span>{link.label}</span>
@@ -799,6 +799,24 @@
return picked.map((p) => p.item);
});
// Listen for out-of-band `is_favorite` changes broadcast by other
// surfaces — the sidebar drop-on-Favorites target in `AppShell` is
// today's only emitter, but any future "toggle from elsewhere"
// (bulk import, notification action, …) can dispatch the same
// event and every mounted resource list will patch its row without
// needing the source page to re-fetch. Matches the existing
// `oxicloud:upload-files` window-event pattern.
$effect(() => {
function onFavoriteChanged(e: Event) {
const detail = (e as CustomEvent<{ id: string; is_favorite: boolean }>).detail;
if (!detail) return;
const item = items.find((i) => i.id === detail.id);
if (item) item.is_favorite = detail.is_favorite;
}
window.addEventListener('oxicloud:favorite-changed', onFavoriteChanged);
return () => window.removeEventListener('oxicloud:favorite-changed', onFavoriteChanged);
});
// Drop selection ids that are no longer present after a reload.
$effect(() => {
// With nothing selected (the common case) the loop never runs — skip
@@ -155,6 +155,17 @@
on every row so removing the accent causes no horizontal text shift. */
}
/* Drop-target highlight for the Favorites / Trash sidebar links —
applied only while a row-drag with the `application/x-oxi-item`
MIME is hovering the item. Matches the `.file-item.drop-target`
scrim used inside the resource list, so drag-drop feedback reads
as one consistent visual language. */
.nav-item.nav-item--drop-target {
background-color: var(--color-warning-ring);
outline: 2px dashed var(--color-warning-border);
outline-offset: -2px;
}
.nav-item i,
.nav-item .nav-icon,
.nav-item .oxi-icon {