feat(ui): show a notification if user try to drop a file in another section than /files

This commit is contained in:
Edouard Vanbelle
2026-07-20 22:35:30 +02:00
parent c286eed3b2
commit 0cc77f7a36
19 changed files with 104 additions and 20 deletions
@@ -92,6 +92,8 @@
import DisplayModeControls from '$lib/components/DisplayModeControls.svelte';
import UserVignette from '$lib/components/UserVignette.svelte';
import VirtualList from '$lib/components/VirtualList.svelte';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { t } from '$lib/i18n/index.svelte';
import { ui } from '$lib/stores/ui.svelte';
import { files as filesStore } from '$lib/stores/files.svelte';
@@ -927,7 +929,13 @@
// element accepts drops — without it, `drop` never fires and
// the pointer shows the OS "no-drop" cursor.
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = enableSystemDrop ? 'copy' : 'none';
// `dropEffect = 'none'` would tell the browser to REJECT the
// drop before `drop` fires — the toast/notification path in
// `onSystemDrop` would never run for wrong-zone drops. Always
// accept at the pointer level; the drop handler decides
// whether to upload (`enableSystemDrop`) or fire the
// "go to Files" toast.
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
}
function onSystemDragLeave(e: DragEvent) {
if (!isSystemDrag(e)) return;
@@ -949,7 +957,19 @@
'Uploads only work in Files — open the Files section and drop there.'
),
'warning',
6000
6000,
true,
{
action: {
label: t('resource_list.wrong_drop_zone_action', 'Go to Files'),
// One-click recovery from a mis-drop: land the user in
// /files so they can re-drag from the OS. We don't
// re-attach the dropped files (browsers throw away
// DataTransfer once the drop event returns), so this
// is the best we can offer without a second drag.
onClick: () => goto(resolve('/files'))
}
}
);
}
}
@@ -12,6 +12,18 @@
{#each ui.toasts as toast (toast.id)}
<div class="toast toast--{toast.kind}" role="status" data-testid={`toaster-toast-${toast.id}`}>
<span class="toast__msg">{toast.message}</span>
{#if toast.action}
<button
class="toast__action"
data-testid={`toaster-action-btn-${toast.id}`}
onclick={() => {
toast.action?.onClick();
ui.dismiss(toast.id);
}}
>
{toast.action.label}
</button>
{/if}
<button
class="toast__close"
data-testid={`toaster-dismiss-btn-${toast.id}`}
@@ -73,6 +85,22 @@
flex: 1;
}
.toast__action {
flex-shrink: 0;
background: var(--color-accent);
color: var(--color-accent-contrast);
border: none;
border-radius: var(--radius-md);
padding: var(--space-1-5) var(--space-3);
font-size: var(--text-sm);
font-weight: var(--weight-medium);
cursor: pointer;
}
.toast__action:hover {
filter: brightness(0.95);
}
.toast__close {
background: none;
border: none;
+22 -2
View File
@@ -6,10 +6,19 @@
*/
export type ToastKind = 'info' | 'success' | 'error' | 'warning';
export interface ToastAction {
/** Button label — should be short (≤ 20 chars). */
label: string;
/** Invoked when the button is clicked; the toast auto-dismisses after. */
onClick: () => void;
}
export interface Toast {
id: number;
message: string;
kind: ToastKind;
/** Optional inline action (e.g. "Go to Files" on a wrong-drop-zone toast). */
action?: ToastAction;
}
export interface Notification {
@@ -74,10 +83,21 @@ class UiStore {
/**
* Raise a toast and record a notification. `at` is stamped from the clock at
* call time; pass `record: false` for purely transient messages.
*
* The optional `opts.action` renders an inline button in the toast (e.g.
* "Go to Files" on a wrong-drop-zone warning); the callback fires on
* click and the toast auto-dismisses right after so a caller doesn't have
* to manage the id.
*/
notify(message: string, kind: ToastKind = 'info', timeoutMs = 4000, record = true): number {
notify(
message: string,
kind: ToastKind = 'info',
timeoutMs = 4000,
record = true,
opts: { action?: ToastAction } = {}
): number {
const id = ++this.#seq;
this.toasts = [...this.toasts, { id, message, kind }];
this.toasts = [...this.toasts, { id, message, kind, action: opts.action }];
if (record) {
this.notifications = [
{ id, message, kind, at: Date.now(), read: false },