diff --git a/frontend/src/lib/components/ActionBar.svelte b/frontend/src/lib/components/ActionBar.svelte new file mode 100644 index 00000000..45812645 --- /dev/null +++ b/frontend/src/lib/components/ActionBar.svelte @@ -0,0 +1,37 @@ + + +
+ {#if start}{@render start()}{:else}
{/if} + {#if end}{@render end()}{/if} +
diff --git a/frontend/src/lib/components/DisplayModeControls.svelte b/frontend/src/lib/components/DisplayModeControls.svelte new file mode 100644 index 00000000..e2582da5 --- /dev/null +++ b/frontend/src/lib/components/DisplayModeControls.svelte @@ -0,0 +1,194 @@ + + + + +{#if anyVisible} +
+ {#if beforeGroupBy}{@render beforeGroupBy()}{/if} + {#if groups?.length} +
+ + {#if sortVisible} + + {/if} + {#if menuOpen} +
+ {#each groups as g (g.key)} + + {/each} +
+ {/if} +
+ {#if showViewMode}{/if} + {/if} + {#if showViewMode} + + + {/if} + {#if showDotfileToggle} + + {/if} +
+{/if} diff --git a/frontend/src/lib/components/ResourceList.svelte b/frontend/src/lib/components/ResourceList.svelte index 9818c4bc..6ec3ceae 100644 --- a/frontend/src/lib/components/ResourceList.svelte +++ b/frontend/src/lib/components/ResourceList.svelte @@ -69,10 +69,12 @@ import Icon from '$lib/icons/Icon.svelte'; import EmptyState from '$lib/components/EmptyState.svelte'; import SkeletonList from '$lib/components/SkeletonList.svelte'; - import ListToolbar from '$lib/components/ListToolbar.svelte'; + import ActionBar from '$lib/components/ActionBar.svelte'; + import DisplayModeControls from '$lib/components/DisplayModeControls.svelte'; import UserVignette from '$lib/components/UserVignette.svelte'; import VirtualList from '$lib/components/VirtualList.svelte'; import { t } from '$lib/i18n/index.svelte'; + import { ui } from '$lib/stores/ui.svelte'; import { files as filesStore } from '$lib/stores/files.svelte'; import { preferences } from '$lib/stores/preferences.svelte'; import { formatBytes } from '$lib/utils/format'; @@ -176,10 +178,62 @@ onfavorite?: (item: FileItem | FolderItem) => void; /** Selection changed (set of selected item ids). */ onselectionchange?: (ids: Set) => void; - actions?: Snippet<[FileItem | FolderItem]>; - toolbar?: Snippet; - /** Batch toolbar shown when items are selected; receives selected items. */ - batchToolbar?: Snippet<[Array]>; + /** + * Per-item action cell (renders at the end of a row). Kept as a + * distinct slot from the action-bar snippets below so callers + * that want an item-scoped affordance (a per-row overflow menu) + * don't have to piggyback on the bar. + */ + itemActions?: Snippet<[FileItem | FolderItem]>; + /** + * Action-bar left cluster — always-visible page action buttons + * (Upload / New folder / Empty trash / Clear recent / …). Swaps + * to `batchActions` when the selection is non-empty. Every + * section provides its own buttons; ResourceList doesn't ship + * any defaults. + */ + actions?: Snippet; + /** + * Action-bar left cluster when selection is non-empty — + * replaces `actions`. Receives the selected items so buttons + * can be scoped to the batch. Replaces the phase-1 + * `batchToolbar` floating strip pattern. + */ + batchActions?: Snippet<[Array]>; + /** + * Rendered next to the item name in each row. `/trash` uses + * this for its expiration badge; other sections omit it. + * ResourceList stays ignorant of what the badge means — the + * page decides. Empty return = no badge. + */ + rowBadge?: Snippet<[FileItem | FolderItem, ItemContext | undefined]>; + /** + * Rendered above the toolbar in the sticky header. Only + * `/files` wires this today; every other section leaves the + * snippet undefined so no breadcrumb strip appears. Kept as a + * snippet (not a boolean) so the page owns crumb rendering and + * their click / drag-drop behavior. + */ + breadcrumb?: Snippet; + /** + * When true, drops from the OS file system on the ResourceList + * wrapper are forwarded to `onsystemdrop` (upload path). When + * false (default), the wrapper still intercepts the OS drop — + * `preventDefault` so the browser doesn't navigate to the file + * — and fires a "wrong section" `ui.notify()` pointing the user + * at the Files section (the legacy behaviour). Item-drag drops + * (row → folder) are unaffected either way; those go through + * `onitemdrop` per the existing row hooks. + */ + enableSystemDrop?: boolean; + /** + * Called with the OS-dropped files when `enableSystemDrop` is + * true. The page keeps ownership of the upload code (walking + * webkitGetAsEntry trees, chunked uploader, etc.) — this + * component just delivers the payload. Ignored when + * `enableSystemDrop` is false. + */ + onsystemdrop?: (e: DragEvent) => void; /** * Render `` thumbnails on file rows and fall back to * client-side generation when the server doesn't have one @@ -266,9 +320,13 @@ onopen, onfavorite, onselectionchange, + itemActions, actions, - toolbar, - batchToolbar, + batchActions, + rowBadge, + breadcrumb, + enableSystemDrop = false, + onsystemdrop, enableThumbnails = true, isDraggable, isDropTarget, @@ -345,7 +403,7 @@ showType ? '120px' : '', showSize ? '110px' : '', showDate ? '160px' : '', - actions ? '120px' : '' + itemActions ? '120px' : '' ] .filter(Boolean) .join(' ') @@ -562,6 +620,63 @@ .filter(Boolean) .join('\n'); } + + // ── System-drop handling (OS files onto the wrapper) ─────────────────────── + // Two modes: + // + // * `enableSystemDrop = true`: the page has an upload code path + // ready (the `/files` browser). We `preventDefault` the browser's + // default (which would open the dragged file as a top-level + // navigation), highlight the drop zone, and hand the DragEvent + // off to the page via `onsystemdrop`. The page walks the entries + // (webkitGetAsEntry / DataTransferItemList) and drives the upload. + // + // * `enableSystemDrop = false` (default): the page has no upload + // path. Still `preventDefault` so the browser doesn't navigate + // away, but instead of forwarding, fire a `ui.notify()` that + // points the user at `/files` — this restores the legacy vanilla + // frontend's "wrong drop zone" behaviour so users don't wonder + // why their drag was silently ignored. + // + // Row-scoped drops (dragging an in-app row onto a folder row / the + // breadcrumb) are handled by the existing `onitemdrop` hooks and use + // a private `application/x-oxi-item` MIME so the `Files` type check + // below never matches them. + let systemDropOver = $state(false); + function isSystemDrag(e: DragEvent): boolean { + return !!e.dataTransfer?.types?.includes('Files'); + } + function onSystemDragEnter(e: DragEvent) { + if (!isSystemDrag(e)) return; + e.preventDefault(); + systemDropOver = true; + } + function onSystemDragOver(e: DragEvent) { + if (!isSystemDrag(e)) return; + e.preventDefault(); + if (e.dataTransfer) e.dataTransfer.dropEffect = enableSystemDrop ? 'copy' : 'none'; + } + function onSystemDragLeave(e: DragEvent) { + if (!isSystemDrag(e)) return; + systemDropOver = false; + } + function onSystemDrop(e: DragEvent) { + if (!isSystemDrag(e)) return; + e.preventDefault(); + systemDropOver = false; + if (enableSystemDrop && onsystemdrop) { + onsystemdrop(e); + } else if (!enableSystemDrop) { + ui.notify( + t( + 'resource_list.wrong_drop_zone_msg', + 'Uploads only work in Files — open the Files section and drop there.' + ), + 'warning', + 6000 + ); + } + } {#snippet row(item: FileItem | FolderItem)} @@ -644,6 +759,7 @@ {/if} {item.name} + {#if rowBadge}{@render rowBadge(item, ctx)}{/if} {#if showOwner}
@@ -687,51 +803,83 @@ }}> {/if} - {#if actions} -
{@render actions(item)}
+ {#if itemActions} +
{@render itemActions(item)}
{/if}
{/snippet} + + +

{title}

- + {#if breadcrumb} +
{@render breadcrumb()}
+ {/if} + {#snippet start()} -
{@render toolbar?.()}
+
+ + {#if selectable && selected.size > 0 && batchActions} + + {t('files.selected_count', { count: selected.size }, '{{count}} selected')} + {@render batchActions(selectedItems)} + {:else if actions} + {@render actions()} + {/if} +
{/snippet} -
+ {#snippet end()} + + {/snippet} +
-{#if selectable && selected.size > 0 && batchToolbar} -
- - {t('files.selected_count', { count: selected.size }, '{{count}} selected')} -
{@render batchToolbar(selectedItems)}
-
-{/if} - {#if error} {:else if loading && isEmpty} @@ -822,6 +970,7 @@
{/if} + {#snippet listHeader()}
@@ -842,7 +991,7 @@ {#if showType}
{t('files.col_type', 'Type')}
{/if} {#if showSize}
{t('files.col_size', 'Size')}
{/if} {#if showDate}
{dateLabel ?? t('files.col_modified', 'Date')}
{/if} - {#if onfavorite || actions}
{/if} + {#if onfavorite || itemActions}
{/if}
{/snippet} @@ -889,19 +1038,47 @@ width: 100%; } - /* ── Batch toolbar ── */ - .rl-batch { - display: flex; - align-items: center; - gap: var(--space-3); - padding: var(--space-2) var(--space-4); - margin-bottom: var(--space-3); - background: var(--color-accent-bg); - border: 1px solid var(--color-border); - border-radius: var(--radius-md); + /* ── OS-drop wrapper ── + `.rl-root` catches drops that miss a specific in-app drop target + (row → folder). Its highlight fires ONLY when + `enableSystemDrop && dragging` — the "wrong drop zone" toast path + deliberately leaves the surface unhighlighted so users don't get a + false accept cue. */ + .rl-root { + position: relative; } - .rl-batch__close { + .rl-root--drop-over::after { + content: ''; + position: absolute; + inset: 0; + border: 2px dashed var(--color-accent); + border-radius: var(--radius-md); + pointer-events: none; + } + + /* ── Breadcrumb strip inside the sticky header ── */ + .rl-breadcrumb { + display: flex; + align-items: center; + gap: var(--space-2); + margin-bottom: var(--space-2); + min-height: 28px; + } + + /* ── Row badge (trash expiration, etc.) ── */ + .name-cell__badge { + display: inline-flex; + align-items: center; + margin-left: var(--space-2); + } + + /* ── Selection controls inside the action bar ── + Replaces the deprecated `.rl-batch` floating strip. When items + are selected, the close button + count sit before the page's + `batchActions` snippet inside `.action-buttons`, so the whole + cluster reads as one row of the action bar. */ + .rl-batch-close { display: inline-flex; align-items: center; justify-content: center; @@ -914,22 +1091,15 @@ cursor: pointer; } - .rl-batch__close:hover { + .rl-batch-close:hover { background: var(--color-bg-hover); } - .rl-batch__count { + .rl-batch-count { font-weight: var(--weight-semibold); color: var(--color-text); } - .rl-batch__actions { - display: flex; - align-items: center; - gap: var(--space-2); - margin-left: auto; - } - /* ── Selection column ── */ .select-cell { display: flex; diff --git a/frontend/src/routes/favorites/+page.svelte b/frontend/src/routes/favorites/+page.svelte index d19afdac..9daa62f0 100644 --- a/frontend/src/routes/favorites/+page.svelte +++ b/frontend/src/routes/favorites/+page.svelte @@ -254,7 +254,7 @@ ]; // ── Selection + batch ───────────────────────────────────────────────────── - // Selected items arrive via the batchToolbar snippet param — + // Selected items arrive via the batchActions snippet param — // ResourceList already derives them (O(selection), not O(N)); a // host-side `items.filter(...)` shadow would re-run a second full scan // per selection toggle, and its id mirror is unnecessary (the component @@ -307,6 +307,7 @@ onopen={open} onfavorite={unfavorite} showOwner + showPath selectable {contextActions} {groupBys} @@ -317,7 +318,7 @@ load(true, orderBy, rev); }} > - {#snippet batchToolbar(sel)} + {#snippet batchActions(sel)} {/if} {/snippet} - {#snippet batchToolbar(sel)} + {#snippet batchActions(sel)} {/if} {/snippet} - {#snippet dateCell(_item, ctx)} + {#snippet batchActions(sel)} + + + {/snippet} + {#snippet rowBadge(_item, ctx)} {@const chip = expiryChip(ctx?.date)} {chip.label} {/snippet} + {#snippet dateCell(_item, ctx)} + {formatDate(ctx?.date)} + {/snippet} {#snippet bucketAction(bucketKey: string)} {#if showPerDriveEmpty} {@const driveId = driveIdFromBucketKey(bucketKey)} @@ -310,7 +336,7 @@ {/if} {/if} {/snippet} - {#snippet actions(item)} + {#snippet itemActions(item)}