refator(ui): merge of ResourceList part1
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Two-slot action-bar layout used above every ResourceList surface.
|
||||
*
|
||||
* [ start-slot ] ← page actions / batch actions
|
||||
* [ end-slot ] ← <DisplayModeControls /> — group-by, sort, view, dotfile
|
||||
*
|
||||
* This is pure layout — no state, no visual variation per section.
|
||||
* It reuses the existing `.actions-bar` / `.action-buttons` classes
|
||||
* defined globally in `styles/ported/content.css` so it renders
|
||||
* identically to `<ListToolbar>` (the component it's replacing).
|
||||
*
|
||||
* Consumers pass whatever they want on either side; `<ResourceList>`
|
||||
* uses this internally to wire its `actions` / `batchActions` /
|
||||
* display-mode-controls snippets, and pages can also use it directly
|
||||
* when they need a bespoke layout that doesn't fit ResourceList's
|
||||
* default (e.g. `/files` upload split-button).
|
||||
*/
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
/** Left cluster — page action buttons (or batch actions on
|
||||
* selection). Omit to render an empty placeholder that still
|
||||
* reserves the space, so the end cluster stays right-aligned. */
|
||||
start?: Snippet;
|
||||
/** Right cluster — usually a `<DisplayModeControls />` instance,
|
||||
* but any content works. */
|
||||
end?: Snippet;
|
||||
}
|
||||
|
||||
let { start, end }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="actions-bar">
|
||||
{#if start}{@render start()}{:else}<div class="action-buttons"></div>{/if}
|
||||
{#if end}{@render end()}{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,194 @@
|
||||
<script lang="ts" module>
|
||||
/** Reuses the existing GroupOption shape from `<ListToolbar>` so
|
||||
* callers can pass the same `groupBys` arrays their pages already
|
||||
* define. Duplicated here so consumers can import a coherent set
|
||||
* without pulling in the legacy toolbar. */
|
||||
export interface GroupOption {
|
||||
key: string;
|
||||
label: string;
|
||||
icon?: string;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Right-hand cluster of display-mode controls: group-by menu,
|
||||
* sort-direction toggle, grid/list view toggle, hide-dotfiles eye.
|
||||
*
|
||||
* Every control is opt-in via its own `show…` prop so a section
|
||||
* without one (e.g. `/trash` has no dotfile toggle by design) can
|
||||
* omit the prop rather than pass an empty array or a no-op
|
||||
* callback. State bindings pass through — the parent still owns
|
||||
* `groupBy`, `reversed`, `viewMode`, etc.
|
||||
*
|
||||
* Style-wise this reuses the ported `.view-toggle` block + child
|
||||
* classes (buttons.css) so it renders identically to the
|
||||
* `<ListToolbar>` right cluster. That keeps every page's look
|
||||
* consistent across the ResourceList migration.
|
||||
*/
|
||||
import type { Snippet } from 'svelte';
|
||||
import Icon from '$lib/icons/Icon.svelte';
|
||||
import { t } from '$lib/i18n/index.svelte';
|
||||
import { files as filesStore } from '$lib/stores/files.svelte';
|
||||
import { preferences } from '$lib/stores/preferences.svelte';
|
||||
|
||||
interface Props {
|
||||
// ── Group-by ─────────────────────────────────────────────
|
||||
/** Group-by dimensions; omit or empty array = hide the control. */
|
||||
groups?: GroupOption[];
|
||||
/** Active group-by key (controlled by the parent). */
|
||||
groupBy?: string;
|
||||
/** Fired when a group-by dimension is chosen. */
|
||||
ongroup?: (key: string) => void;
|
||||
|
||||
// ── Sort direction ───────────────────────────────────────
|
||||
/** Whether the sort direction is reversed. */
|
||||
reversed?: boolean;
|
||||
/** Fired when the sort-direction toggle is clicked. */
|
||||
ondirection?: () => void;
|
||||
/** Show the sort-direction toggle. Defaults to `true` when
|
||||
* `groups` is non-empty (there's nothing to reverse otherwise). */
|
||||
showSort?: boolean;
|
||||
|
||||
// ── View mode (grid / list) ─────────────────────────────
|
||||
/** Show the grid/list view toggle. */
|
||||
showViewMode?: boolean;
|
||||
|
||||
// ── Dotfile visibility ──────────────────────────────────
|
||||
/** Show the hide-dotfiles eye toggle. Only makes sense on
|
||||
* algorithmic listings (files, recent); off by default. */
|
||||
showDotfileToggle?: boolean;
|
||||
|
||||
// ── Extension slot ──────────────────────────────────────
|
||||
/** Rendered immediately before the group-by button, still
|
||||
* inside `.view-toggle`. Kind-filter dropdowns and other
|
||||
* page-local controls that want to sit alongside the
|
||||
* built-ins land here. */
|
||||
beforeGroupBy?: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
groups,
|
||||
groupBy = '',
|
||||
ongroup,
|
||||
reversed = false,
|
||||
ondirection,
|
||||
showSort,
|
||||
showViewMode = false,
|
||||
showDotfileToggle = false,
|
||||
beforeGroupBy
|
||||
}: Props = $props();
|
||||
|
||||
// Sort toggle defaults ON when a group-by list is provided —
|
||||
// there's nothing to reverse without it.
|
||||
const sortVisible = $derived(showSort ?? (groups?.length ?? 0) > 0);
|
||||
|
||||
const active = $derived(groups?.find((g) => g.key === groupBy) ?? groups?.[0]);
|
||||
let menuOpen = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (!menuOpen) return;
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (!(e.target as HTMLElement).closest('.group-by-selector')) menuOpen = false;
|
||||
};
|
||||
window.addEventListener('pointerdown', onDown);
|
||||
return () => window.removeEventListener('pointerdown', onDown);
|
||||
});
|
||||
|
||||
function pick(key: string) {
|
||||
menuOpen = false;
|
||||
ongroup?.(key);
|
||||
}
|
||||
|
||||
// Hide the whole cluster if nothing is enabled — the ActionBar
|
||||
// then collapses to its start-only layout without an empty
|
||||
// right block occupying space.
|
||||
const anyVisible = $derived(
|
||||
(groups?.length ?? 0) > 0 || showViewMode || showDotfileToggle || !!beforeGroupBy
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if anyVisible}
|
||||
<div class="view-toggle" role="group" aria-label={t('view.label', 'View options')}>
|
||||
{#if beforeGroupBy}{@render beforeGroupBy()}{/if}
|
||||
{#if groups?.length}
|
||||
<div class="group-by-selector" data-testid="display-mode-groupby-menu">
|
||||
<button
|
||||
class="toggle-btn group-by-btn active"
|
||||
title={t('groupby.title', 'Group by')}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={menuOpen}
|
||||
data-testid="display-mode-groupby-btn"
|
||||
onclick={() => (menuOpen = !menuOpen)}
|
||||
>
|
||||
<Icon name={active?.icon ?? 'layer-group'} />
|
||||
<span class="group-by-label">{active?.label ?? ''}</span>
|
||||
</button>
|
||||
{#if sortVisible}
|
||||
<button
|
||||
class="toggle-btn sort-dir-btn"
|
||||
class:active={reversed}
|
||||
title={t('sortdir.title', 'Sort direction')}
|
||||
aria-label={t('sort.direction', 'Sort direction')}
|
||||
data-testid="display-mode-sort-direction-btn"
|
||||
onclick={() => ondirection?.()}
|
||||
>
|
||||
<Icon name="arrow-up" />
|
||||
</button>
|
||||
{/if}
|
||||
{#if menuOpen}
|
||||
<div class="group-by-menu">
|
||||
{#each groups as g (g.key)}
|
||||
<button
|
||||
class="group-by-option"
|
||||
class:active={groupBy === g.key}
|
||||
data-testid={`display-mode-groupby-${g.key}-item`}
|
||||
onclick={() => pick(g.key)}
|
||||
>
|
||||
<Icon name={g.icon ?? 'layer-group'} />
|
||||
{g.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if showViewMode}<span class="view-toggle-separator"></span>{/if}
|
||||
{/if}
|
||||
{#if showViewMode}
|
||||
<button
|
||||
class="toggle-btn"
|
||||
class:active={filesStore.viewMode === 'grid'}
|
||||
title={t('view.grid', 'Grid view')}
|
||||
aria-pressed={filesStore.viewMode === 'grid'}
|
||||
data-testid="display-mode-view-grid-btn"
|
||||
onclick={() => filesStore.setViewMode('grid')}
|
||||
>
|
||||
<Icon name="th" />
|
||||
</button>
|
||||
<button
|
||||
class="toggle-btn"
|
||||
class:active={filesStore.viewMode === 'list'}
|
||||
title={t('view.list', 'List view')}
|
||||
aria-pressed={filesStore.viewMode === 'list'}
|
||||
data-testid="display-mode-view-list-btn"
|
||||
onclick={() => filesStore.setViewMode('list')}
|
||||
>
|
||||
<Icon name="list" />
|
||||
</button>
|
||||
{/if}
|
||||
{#if showDotfileToggle}
|
||||
<button
|
||||
class="toggle-btn"
|
||||
class:active={preferences.hideDotfiles}
|
||||
title={preferences.hideDotfiles
|
||||
? t('view.show_dotfiles', 'Show hidden files')
|
||||
: t('view.hide_dotfiles', 'Hide hidden files')}
|
||||
aria-pressed={preferences.hideDotfiles}
|
||||
data-testid="display-mode-dotfile-toggle-btn"
|
||||
onclick={() => preferences.toggleHideDotfiles()}
|
||||
>
|
||||
<Icon name={preferences.hideDotfiles ? 'eye-slash' : 'eye'} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -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<string>) => void;
|
||||
actions?: Snippet<[FileItem | FolderItem]>;
|
||||
toolbar?: Snippet;
|
||||
/** Batch toolbar shown when items are selected; receives selected items. */
|
||||
batchToolbar?: Snippet<[Array<FileItem | FolderItem>]>;
|
||||
/**
|
||||
* 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<FileItem | FolderItem>]>;
|
||||
/**
|
||||
* 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 `<img>` 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
|
||||
);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet row(item: FileItem | FolderItem)}
|
||||
@@ -644,6 +759,7 @@
|
||||
{/if}
|
||||
</span>
|
||||
<span class="name-cell__text">{item.name}</span>
|
||||
{#if rowBadge}<span class="name-cell__badge">{@render rowBadge(item, ctx)}</span>{/if}
|
||||
</div>
|
||||
{#if showOwner}
|
||||
<div class="owner-cell">
|
||||
@@ -687,50 +803,82 @@
|
||||
}}><Icon name={isFav ? 'star' : 'star-outline'} /></button
|
||||
>
|
||||
{/if}
|
||||
{#if actions}
|
||||
<div class="action-cell">{@render actions(item)}</div>
|
||||
{#if itemActions}
|
||||
<div class="action-cell">{@render itemActions(item)}</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<!--
|
||||
`.rl-root` is the drop-target boundary for OS file drops. Every
|
||||
descendant listens through here — dragging in from the desktop over
|
||||
the sticky header or the content area both hit the same handler and
|
||||
either forward to the page's upload code (files browser) or fire the
|
||||
"wrong section" toast (everywhere else).
|
||||
|
||||
svelte-ignore a11y_no_static_element_interactions — the drop handlers
|
||||
are pointer-only fallbacks; keyboard users don't have a "drop a
|
||||
file" action, and the content inside is separately keyboard-nav'd.
|
||||
-->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="rl-root"
|
||||
class:rl-root--drop-over={systemDropOver && enableSystemDrop}
|
||||
ondragenter={onSystemDragEnter}
|
||||
ondragover={onSystemDragOver}
|
||||
ondragleave={onSystemDragLeave}
|
||||
ondrop={onSystemDrop}
|
||||
>
|
||||
<div class="page-sticky-header">
|
||||
<h1 class="page-title">{title}</h1>
|
||||
<ListToolbar
|
||||
groups={groupBys}
|
||||
{groupBy}
|
||||
{reversed}
|
||||
ongroup={selectGroup}
|
||||
ondirection={toggleDirection}
|
||||
{showViewToggle}
|
||||
{showDotfileToggle}
|
||||
>
|
||||
{#if breadcrumb}
|
||||
<div class="rl-breadcrumb">{@render breadcrumb()}</div>
|
||||
{/if}
|
||||
<ActionBar>
|
||||
{#snippet start()}
|
||||
<div class="action-buttons">{@render toolbar?.()}</div>
|
||||
{/snippet}
|
||||
</ListToolbar>
|
||||
</div>
|
||||
|
||||
{#if selectable && selected.size > 0 && batchToolbar}
|
||||
<div
|
||||
class="rl-batch"
|
||||
role="region"
|
||||
aria-label={t('files.selection', 'Selection')}
|
||||
data-testid="resource-list-batch-toolbar"
|
||||
>
|
||||
<div class="action-buttons">
|
||||
<!--
|
||||
The action-bar left cluster has two states:
|
||||
1. `batchActions` — when the user has selected items, the
|
||||
page's batch buttons (Move / Delete / Restore / …)
|
||||
replace the default cluster, prefixed with a "clear
|
||||
selection" close button + count label so the batch is
|
||||
dismissable without unchecking every row by hand.
|
||||
2. `actions` — the page's default cluster
|
||||
(Upload / New folder / Empty trash / Clear recent).
|
||||
-->
|
||||
{#if selectable && selected.size > 0 && batchActions}
|
||||
<button
|
||||
class="rl-batch__close"
|
||||
title={t('common.clear', 'Clear')}
|
||||
class="rl-batch-close"
|
||||
title={t('common.clear', 'Clear selection')}
|
||||
aria-label={t('common.clear', 'Clear selection')}
|
||||
data-testid="resource-list-batch-close-btn"
|
||||
onclick={clearSelection}
|
||||
>
|
||||
<Icon name="times" />
|
||||
</button>
|
||||
<span class="rl-batch__count"
|
||||
<span class="rl-batch-count"
|
||||
>{t('files.selected_count', { count: selected.size }, '{{count}} selected')}</span
|
||||
>
|
||||
<div class="rl-batch__actions">{@render batchToolbar(selectedItems)}</div>
|
||||
</div>
|
||||
{@render batchActions(selectedItems)}
|
||||
{:else if actions}
|
||||
{@render actions()}
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
{#snippet end()}
|
||||
<DisplayModeControls
|
||||
groups={groupBys}
|
||||
{groupBy}
|
||||
{reversed}
|
||||
ongroup={selectGroup}
|
||||
ondirection={toggleDirection}
|
||||
showViewMode={showViewToggle}
|
||||
{showDotfileToggle}
|
||||
/>
|
||||
{/snippet}
|
||||
</ActionBar>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<EmptyState icon="exclamation-circle" title={error} error />
|
||||
@@ -822,6 +970,7 @@
|
||||
<div bind:this={sentinel} class="rl-sentinel" aria-hidden="true"></div>
|
||||
</div>
|
||||
{/if}
|
||||
</div><!-- /.rl-root -->
|
||||
|
||||
{#snippet listHeader()}
|
||||
<div class="list-header">
|
||||
@@ -842,7 +991,7 @@
|
||||
{#if showType}<div>{t('files.col_type', 'Type')}</div>{/if}
|
||||
{#if showSize}<div>{t('files.col_size', 'Size')}</div>{/if}
|
||||
{#if showDate}<div>{dateLabel ?? t('files.col_modified', 'Date')}</div>{/if}
|
||||
{#if onfavorite || actions}<div></div>{/if}
|
||||
{#if onfavorite || itemActions}<div></div>{/if}
|
||||
</div>
|
||||
{/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;
|
||||
|
||||
@@ -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)}
|
||||
<Button
|
||||
icon="download"
|
||||
data-testid="favorites-batch-download-btn"
|
||||
|
||||
@@ -292,7 +292,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
|
||||
@@ -356,6 +356,7 @@
|
||||
onopen={open}
|
||||
onfavorite={toggleFavorite}
|
||||
showOwner
|
||||
showPath
|
||||
showDotfileToggle
|
||||
selectable
|
||||
{contextActions}
|
||||
@@ -367,14 +368,14 @@
|
||||
load(true, orderBy, rev);
|
||||
}}
|
||||
>
|
||||
{#snippet toolbar()}
|
||||
{#snippet actions()}
|
||||
{#if items.length > 0}
|
||||
<Button icon="broom" data-testid="recent-clear-btn" onclick={clearAll}
|
||||
>{t('recent.clear', 'Clear recent')}</Button
|
||||
>
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#snippet batchToolbar(sel)}
|
||||
{#snippet batchActions(sel)}
|
||||
<Button
|
||||
icon="download"
|
||||
data-testid="recent-batch-download-btn"
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
restoreTrashItem
|
||||
} from '$lib/api/endpoints/trash';
|
||||
import { dateBucket, sizeBucket, typeLabel } from '$lib/api/endpoints/favorites';
|
||||
import { formatDate } from '$lib/utils/display';
|
||||
import type { Drive, FileItem, FolderItem, TrashResourceItem } from '$lib/api/types';
|
||||
import Icon from '$lib/icons/Icon.svelte';
|
||||
import ResourceList, {
|
||||
@@ -268,8 +269,10 @@
|
||||
emptyText={t('trash.empty_state', 'Trash is empty')}
|
||||
hasMore={!!cursor}
|
||||
onloadmore={() => load(false, orderByForGroup())}
|
||||
selectable
|
||||
showPath
|
||||
pathLabel={t('trash.original_location', 'Original location')}
|
||||
dateLabel={t('trash.remaining', 'Remaining')}
|
||||
dateLabel={t('trash.expires_at', 'Expires at')}
|
||||
{groupBys}
|
||||
bind:groupBy
|
||||
bind:reversed
|
||||
@@ -278,7 +281,7 @@
|
||||
load(true, orderBy, rev);
|
||||
}}
|
||||
>
|
||||
{#snippet toolbar()}
|
||||
{#snippet actions()}
|
||||
{#if items.length > 0}
|
||||
<button class="btn btn-danger" data-testid="trash-empty-btn" onclick={purgeAll}>
|
||||
<Icon name="trash" />
|
||||
@@ -286,13 +289,36 @@
|
||||
</button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#snippet dateCell(_item, ctx)}
|
||||
{#snippet batchActions(sel)}
|
||||
<button
|
||||
class="btn-action"
|
||||
data-testid="trash-batch-restore-btn"
|
||||
title={t('trash.restore', 'Restore')}
|
||||
onclick={() => sel.forEach(restore)}
|
||||
>
|
||||
<Icon name="undo" />
|
||||
{t('trash.restore', 'Restore')}
|
||||
</button>
|
||||
<button
|
||||
class="btn-action btn-action--delete"
|
||||
data-testid="trash-batch-delete-btn"
|
||||
title={t('trash.delete', 'Delete permanently')}
|
||||
onclick={() => sel.forEach(purge)}
|
||||
>
|
||||
<Icon name="trash" />
|
||||
{t('trash.delete', 'Delete permanently')}
|
||||
</button>
|
||||
{/snippet}
|
||||
{#snippet rowBadge(_item, ctx)}
|
||||
{@const chip = expiryChip(ctx?.date)}
|
||||
<span class="expiry-chip expiry-chip--{chip.tier}">
|
||||
<Icon name={chip.icon} class="expiry-chip__icon" />
|
||||
{chip.label}
|
||||
</span>
|
||||
{/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)}
|
||||
<button
|
||||
class="btn-action"
|
||||
data-testid={`trash-restore-btn-${item.id}`}
|
||||
|
||||
@@ -461,7 +461,8 @@
|
||||
"trashed_time": "وقت الحذف"
|
||||
},
|
||||
"delete": "حذف نهائياً",
|
||||
"empty_action": "تفريغ سلة المهملات"
|
||||
"empty_action": "تفريغ سلة المهملات",
|
||||
"expires_at": "ينتهي في"
|
||||
},
|
||||
"daysRemaining": {
|
||||
"expired": "منتهية الصلاحية",
|
||||
@@ -1265,5 +1266,9 @@
|
||||
"group": {
|
||||
"members_empty": "لا يوجد أعضاء",
|
||||
"member_count": "{{n}} أعضاء"
|
||||
},
|
||||
"resource_list": {
|
||||
"location": "الموقع",
|
||||
"wrong_drop_zone_msg": "الرفع يعمل فقط في قسم الملفات — افتح قسم الملفات وأفلت العناصر هناك."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -461,7 +461,8 @@
|
||||
"trashed_time": "Löschzeit"
|
||||
},
|
||||
"delete": "Endgültig löschen",
|
||||
"empty_action": "Papierkorb leeren"
|
||||
"empty_action": "Papierkorb leeren",
|
||||
"expires_at": "Läuft ab"
|
||||
},
|
||||
"daysRemaining": {
|
||||
"expired": "Abgelaufen",
|
||||
@@ -1265,5 +1266,9 @@
|
||||
"group": {
|
||||
"members_empty": "Keine Mitglieder",
|
||||
"member_count": "{{n}} Mitglieder"
|
||||
},
|
||||
"resource_list": {
|
||||
"location": "Speicherort",
|
||||
"wrong_drop_zone_msg": "Uploads funktionieren nur in Dateien — öffne den Bereich Dateien und lege die Elemente dort ab."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -618,7 +618,8 @@
|
||||
"confirm_empty": "Empty the trash? This cannot be undone.",
|
||||
"delete": "Delete permanently",
|
||||
"empty_action": "Empty trash",
|
||||
"restored": "Restored"
|
||||
"restored": "Restored",
|
||||
"expires_at": "Expires at"
|
||||
},
|
||||
"daysRemaining": {
|
||||
"expired": "Expired",
|
||||
@@ -1663,5 +1664,9 @@
|
||||
"group": {
|
||||
"members_empty": "No members",
|
||||
"member_count": "{{n}} members"
|
||||
},
|
||||
"resource_list": {
|
||||
"location": "Location",
|
||||
"wrong_drop_zone_msg": "Uploads only work in Files — open the Files section and drop there."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -466,7 +466,8 @@
|
||||
"trashed_time": "Fecha de eliminación"
|
||||
},
|
||||
"delete": "Eliminar permanentemente",
|
||||
"empty_action": "Vaciar papelera"
|
||||
"empty_action": "Vaciar papelera",
|
||||
"expires_at": "Expira"
|
||||
},
|
||||
"daysRemaining": {
|
||||
"expired": "Caducado",
|
||||
@@ -1280,5 +1281,9 @@
|
||||
"group": {
|
||||
"members_empty": "Sin miembros",
|
||||
"member_count": "{{n}} miembros"
|
||||
},
|
||||
"resource_list": {
|
||||
"location": "Ubicación",
|
||||
"wrong_drop_zone_msg": "Las subidas solo funcionan en Archivos — abre la sección Archivos y suelta ahí los elementos."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -461,7 +461,8 @@
|
||||
"trashed_time": "زمان حذف"
|
||||
},
|
||||
"delete": "حذف دائمی",
|
||||
"empty_action": "Empty trash"
|
||||
"empty_action": "Empty trash",
|
||||
"expires_at": "انقضا در"
|
||||
},
|
||||
"daysRemaining": {
|
||||
"expired": "منقضی شده",
|
||||
@@ -1265,5 +1266,9 @@
|
||||
"group": {
|
||||
"members_empty": "بدون عضو",
|
||||
"member_count": "{{n}} عضو"
|
||||
},
|
||||
"resource_list": {
|
||||
"location": "مکان",
|
||||
"wrong_drop_zone_msg": "بارگذاری فقط در بخش پروندهها کار میکند — بخش پروندهها را باز کنید و آنجا رها کنید."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -461,7 +461,8 @@
|
||||
"trashed_time": "Date de suppression"
|
||||
},
|
||||
"delete": "Supprimer définitivement",
|
||||
"empty_action": "Vider la corbeille"
|
||||
"empty_action": "Vider la corbeille",
|
||||
"expires_at": "Expiration"
|
||||
},
|
||||
"daysRemaining": {
|
||||
"expired": "Expiré",
|
||||
@@ -1265,5 +1266,9 @@
|
||||
"group": {
|
||||
"members_empty": "Aucun membre",
|
||||
"member_count": "{{n}} membres"
|
||||
},
|
||||
"resource_list": {
|
||||
"location": "Emplacement",
|
||||
"wrong_drop_zone_msg": "Les envois ne fonctionnent que dans Fichiers — ouvrez la section Fichiers et déposez-y vos éléments."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -461,7 +461,8 @@
|
||||
"trashed_time": "हटाने का समय"
|
||||
},
|
||||
"delete": "स्थायी रूप से हटाएँ",
|
||||
"empty_action": "रद्दी खाली करें"
|
||||
"empty_action": "रद्दी खाली करें",
|
||||
"expires_at": "समाप्ति"
|
||||
},
|
||||
"daysRemaining": {
|
||||
"expired": "समाप्त",
|
||||
@@ -1265,5 +1266,9 @@
|
||||
"group": {
|
||||
"members_empty": "कोई सदस्य नहीं",
|
||||
"member_count": "{{n}} सदस्य"
|
||||
},
|
||||
"resource_list": {
|
||||
"location": "स्थान",
|
||||
"wrong_drop_zone_msg": "अपलोड केवल फ़ाइलें अनुभाग में काम करता है — फ़ाइलें अनुभाग खोलें और वहीं छोड़ें।"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -461,7 +461,8 @@
|
||||
"trashed_time": "Data di eliminazione"
|
||||
},
|
||||
"delete": "Elimina definitivamente",
|
||||
"empty_action": "Svuota il cestino"
|
||||
"empty_action": "Svuota il cestino",
|
||||
"expires_at": "Scadenza"
|
||||
},
|
||||
"daysRemaining": {
|
||||
"expired": "Scaduto",
|
||||
@@ -1265,5 +1266,9 @@
|
||||
"group": {
|
||||
"members_empty": "Nessun membro",
|
||||
"member_count": "{{n}} membri"
|
||||
},
|
||||
"resource_list": {
|
||||
"location": "Posizione",
|
||||
"wrong_drop_zone_msg": "I caricamenti funzionano solo in File — apri la sezione File e trascina lì gli elementi."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -461,7 +461,8 @@
|
||||
"trashed_time": "削除日時"
|
||||
},
|
||||
"delete": "完全に削除",
|
||||
"empty_action": "ゴミ箱を空にする"
|
||||
"empty_action": "ゴミ箱を空にする",
|
||||
"expires_at": "期限"
|
||||
},
|
||||
"daysRemaining": {
|
||||
"expired": "期限切れ",
|
||||
@@ -1265,5 +1266,9 @@
|
||||
"group": {
|
||||
"members_empty": "メンバーなし",
|
||||
"member_count": "{{n}} 人のメンバー"
|
||||
},
|
||||
"resource_list": {
|
||||
"location": "場所",
|
||||
"wrong_drop_zone_msg": "アップロードはファイル セクションでのみ機能します。ファイル セクションを開いてそこにドロップしてください。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -583,7 +583,8 @@
|
||||
"empty_action": "휴지통 비우기",
|
||||
"confirm_delete": "이 항목을 영구적으로 삭제하시겠습니까? 되돌릴 수 없습니다.",
|
||||
"confirm_empty": "휴지통을 비우시겠습니까? 되돌릴 수 없습니다.",
|
||||
"restored": "복원됨"
|
||||
"restored": "복원됨",
|
||||
"expires_at": "만료일"
|
||||
},
|
||||
"daysRemaining": {
|
||||
"expired": "만료됨",
|
||||
@@ -1659,5 +1660,9 @@
|
||||
"group": {
|
||||
"members_empty": "구성원 없음",
|
||||
"member_count": "구성원 {{n}}명"
|
||||
},
|
||||
"resource_list": {
|
||||
"location": "위치",
|
||||
"wrong_drop_zone_msg": "업로드는 파일 섹션에서만 작동합니다. 파일 섹션을 열고 거기에 놓아 주세요."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -461,7 +461,8 @@
|
||||
"trashed_time": "Verwijderd op"
|
||||
},
|
||||
"delete": "Permanent verwijderen",
|
||||
"empty_action": "Prullenbak legen"
|
||||
"empty_action": "Prullenbak legen",
|
||||
"expires_at": "Verloopt op"
|
||||
},
|
||||
"daysRemaining": {
|
||||
"expired": "Verlopen",
|
||||
@@ -1265,5 +1266,9 @@
|
||||
"group": {
|
||||
"members_empty": "Geen leden",
|
||||
"member_count": "{{n}} leden"
|
||||
},
|
||||
"resource_list": {
|
||||
"location": "Locatie",
|
||||
"wrong_drop_zone_msg": "Uploaden werkt alleen in Bestanden — open het onderdeel Bestanden en zet ze daar neer."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -461,7 +461,8 @@
|
||||
"trashed_time": "Czas usunięcia"
|
||||
},
|
||||
"delete": "Usuń trwale",
|
||||
"empty_action": "Opróżnij kosz"
|
||||
"empty_action": "Opróżnij kosz",
|
||||
"expires_at": "Wygasa"
|
||||
},
|
||||
"daysRemaining": {
|
||||
"expired": "Wygasł",
|
||||
@@ -1265,5 +1266,9 @@
|
||||
"group": {
|
||||
"members_empty": "Brak członków",
|
||||
"member_count": "{{n}} członków"
|
||||
},
|
||||
"resource_list": {
|
||||
"location": "Lokalizacja",
|
||||
"wrong_drop_zone_msg": "Przesyłanie działa tylko w Plikach — otwórz sekcję Pliki i upuść tam pliki."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -461,7 +461,8 @@
|
||||
"trashed_time": "Data de exclusão"
|
||||
},
|
||||
"delete": "Excluir permanentemente",
|
||||
"empty_action": "Esvaziar lixeira"
|
||||
"empty_action": "Esvaziar lixeira",
|
||||
"expires_at": "Expira em"
|
||||
},
|
||||
"daysRemaining": {
|
||||
"expired": "Expirado",
|
||||
@@ -1265,5 +1266,9 @@
|
||||
"group": {
|
||||
"members_empty": "Sem membros",
|
||||
"member_count": "{{n}} membros"
|
||||
},
|
||||
"resource_list": {
|
||||
"location": "Localização",
|
||||
"wrong_drop_zone_msg": "Os envios só funcionam em Ficheiros — abra a secção Ficheiros e largue-os aí."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -461,7 +461,8 @@
|
||||
"trashed_time": "Время удаления"
|
||||
},
|
||||
"delete": "Удалить навсегда",
|
||||
"empty_action": "Очистить корзину"
|
||||
"empty_action": "Очистить корзину",
|
||||
"expires_at": "Истекает"
|
||||
},
|
||||
"daysRemaining": {
|
||||
"expired": "Истёк",
|
||||
@@ -1265,5 +1266,9 @@
|
||||
"group": {
|
||||
"members_empty": "Нет участников",
|
||||
"member_count": "{{n}} участников"
|
||||
},
|
||||
"resource_list": {
|
||||
"location": "Расположение",
|
||||
"wrong_drop_zone_msg": "Загрузки работают только в разделе Файлы — откройте раздел Файлы и перетащите туда."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -461,7 +461,8 @@
|
||||
"trashed_time": "刪除時間"
|
||||
},
|
||||
"delete": "永久刪除",
|
||||
"empty_action": "清空回收站"
|
||||
"empty_action": "清空回收站",
|
||||
"expires_at": "到期時間"
|
||||
},
|
||||
"daysRemaining": {
|
||||
"expired": "已過期",
|
||||
@@ -1265,5 +1266,9 @@
|
||||
"group": {
|
||||
"members_empty": "無成員",
|
||||
"member_count": "{{n}} 位成員"
|
||||
},
|
||||
"resource_list": {
|
||||
"location": "位置",
|
||||
"wrong_drop_zone_msg": "上傳僅在「檔案」中有效 — 請開啟檔案區並在該處拖放。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -461,7 +461,8 @@
|
||||
"trashed_time": "删除时间"
|
||||
},
|
||||
"delete": "永久删除",
|
||||
"empty_action": "Empty trash"
|
||||
"empty_action": "Empty trash",
|
||||
"expires_at": "到期时间"
|
||||
},
|
||||
"daysRemaining": {
|
||||
"expired": "已过期",
|
||||
@@ -1265,5 +1266,9 @@
|
||||
"group": {
|
||||
"members_empty": "无成员",
|
||||
"member_count": "{{n}} 名成员"
|
||||
},
|
||||
"resource_list": {
|
||||
"location": "位置",
|
||||
"wrong_drop_zone_msg": "上传仅在 “文件” 中生效 — 请打开文件区并在那里拖放。"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user