feat(sidebar): permit drop in trash / favorite
This commit is contained in:
@@ -5,8 +5,10 @@
|
|||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { logout } from '$lib/api/endpoints/auth';
|
import { logout } from '$lib/api/endpoints/auth';
|
||||||
import { searchFiles } from '$lib/api/endpoints/search';
|
import { searchFiles } from '$lib/api/endpoints/search';
|
||||||
import { fileInlineUrl } from '$lib/api/endpoints/files';
|
import { fileInlineUrl, deleteFile } from '$lib/api/endpoints/files';
|
||||||
import type { FileItem, FolderItem } from '$lib/api/types';
|
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 { lazyComponent } from '$lib/composables/lazyComponent.svelte';
|
||||||
import DrivePicker from '$lib/components/DrivePicker.svelte';
|
import DrivePicker from '$lib/components/DrivePicker.svelte';
|
||||||
import Icon from '$lib/icons/Icon.svelte';
|
import Icon from '$lib/icons/Icon.svelte';
|
||||||
@@ -14,10 +16,12 @@
|
|||||||
import { userInitials, avatarColorIndex } from '$lib/utils/avatar';
|
import { userInitials, avatarColorIndex } from '$lib/utils/avatar';
|
||||||
import { i18n, LANGUAGES, setLocale, t, type Locale } from '$lib/i18n/index.svelte';
|
import { i18n, LANGUAGES, setLocale, t, type Locale } from '$lib/i18n/index.svelte';
|
||||||
import { apiFetch } from '$lib/api/client';
|
import { apiFetch } from '$lib/api/client';
|
||||||
|
import { dialogs } from '$lib/stores/dialogs.svelte';
|
||||||
import { preferences } from '$lib/stores/preferences.svelte';
|
import { preferences } from '$lib/stores/preferences.svelte';
|
||||||
import { session } from '$lib/stores/session.svelte';
|
import { session } from '$lib/stores/session.svelte';
|
||||||
import { theme, type Theme } from '$lib/stores/theme.svelte';
|
import { theme, type Theme } from '$lib/stores/theme.svelte';
|
||||||
import { ui } from '$lib/stores/ui.svelte';
|
import { ui } from '$lib/stores/ui.svelte';
|
||||||
|
import { errorToast } from '$lib/utils/errors';
|
||||||
import { formatBytes } from '$lib/utils/format';
|
import { formatBytes } from '$lib/utils/format';
|
||||||
|
|
||||||
let { children }: { children: Snippet } = $props();
|
let { children }: { children: Snippet } = $props();
|
||||||
@@ -70,6 +74,119 @@
|
|||||||
return page.url.pathname === href || page.url.pathname.startsWith(`${href}/`);
|
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 sidebarOpen = $state(false);
|
||||||
let notifOpen = $state(false);
|
let notifOpen = $state(false);
|
||||||
let menuOpen = $state(false);
|
let menuOpen = $state(false);
|
||||||
@@ -332,13 +449,18 @@
|
|||||||
|
|
||||||
<nav class="nav-menu" aria-label={t('nav.primary', 'Primary')}>
|
<nav class="nav-menu" aria-label={t('nav.primary', 'Primary')}>
|
||||||
{#each LINKS as link (link.href)}
|
{#each LINKS as link (link.href)}
|
||||||
|
{@const isDropTarget = link.href === '/favorites' || link.href === '/trash'}
|
||||||
<a
|
<a
|
||||||
class="nav-item"
|
class="nav-item"
|
||||||
class:active={active(link.href)}
|
class:active={active(link.href)}
|
||||||
|
class:nav-item--drop-target={isDropTarget && sidebarDropHref === link.href}
|
||||||
href={resolve(link.href)}
|
href={resolve(link.href)}
|
||||||
data-section={link.section}
|
data-section={link.section}
|
||||||
data-testid={`appshell-nav-${link.href.replace(/^\//, '')}-link`}
|
data-testid={`appshell-nav-${link.href.replace(/^\//, '')}-link`}
|
||||||
onclick={() => (sidebarOpen = false)}
|
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} />
|
<Icon name={link.icon} />
|
||||||
<span>{link.label}</span>
|
<span>{link.label}</span>
|
||||||
|
|||||||
@@ -799,6 +799,24 @@
|
|||||||
return picked.map((p) => p.item);
|
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.
|
// Drop selection ids that are no longer present after a reload.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
// With nothing selected (the common case) the loop never runs — skip
|
// 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. */
|
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 i,
|
||||||
.nav-item .nav-icon,
|
.nav-item .nav-icon,
|
||||||
.nav-item .oxi-icon {
|
.nav-item .oxi-icon {
|
||||||
|
|||||||
Reference in New Issue
Block a user