refactor(front): apply formatter

This commit is contained in:
Edouard Vanbelle
2026-07-20 12:28:54 +02:00
parent 7ffb7bf0ae
commit 569b67caac
2 changed files with 159 additions and 198 deletions
+146 -140
View File
@@ -695,7 +695,12 @@
const bandBottom = bandTop + rubberband.h; const bandBottom = bandTop + rubberband.h;
const rows = rlRoot.querySelectorAll<HTMLElement>('.file-item[data-item-id]'); const rows = rlRoot.querySelectorAll<HTMLElement>('.file-item[data-item-id]');
const nextSelection = new Set(rubberband.baseline); // Transient scratch set for computing the diff before mutating
// `selected`. `SvelteSet` (not plain `Set`) per the codebase's
// `svelte/prefer-svelte-reactivity` convention — the lint rule
// exists so a future refactor that stashes this in `$state`
// can't silently break reactivity.
const nextSelection = new SvelteSet(rubberband.baseline);
for (const row of rows) { for (const row of rows) {
const id = row.dataset.itemId; const id = row.dataset.itemId;
if (!id) continue; if (!id) continue;
@@ -1082,14 +1087,14 @@
ondragleave={onSystemDragLeave} ondragleave={onSystemDragLeave}
ondrop={onSystemDrop} ondrop={onSystemDrop}
> >
<div class="page-sticky-header"> <div class="page-sticky-header">
<h1 class="page-title">{title}</h1> <h1 class="page-title">{title}</h1>
{#if breadcrumb} {#if breadcrumb}
<div class="rl-breadcrumb">{@render breadcrumb()}</div> <div class="rl-breadcrumb">{@render breadcrumb()}</div>
{/if} {/if}
<ActionBar> <ActionBar>
{#snippet start()} {#snippet start()}
<!-- <!--
The action-bar left cluster has two states: The action-bar left cluster has two states:
1. `batchActions` — when the user has selected items, the 1. `batchActions` — when the user has selected items, the
page's batch buttons (Move / Delete / Restore / …) page's batch buttons (Move / Delete / Restore / …)
@@ -1103,149 +1108,150 @@
2. `actions` — the page's default cluster 2. `actions` — the page's default cluster
(Upload / New folder / Empty trash / Clear recent). (Upload / New folder / Empty trash / Clear recent).
--> -->
<div <div
class="action-buttons" class="action-buttons"
class:batch-selection-bar={selectable && selected.size > 0 && batchActions} class:batch-selection-bar={selectable && selected.size > 0 && batchActions}
> >
{#if selectable && selected.size > 0 && batchActions} {#if selectable && selected.size > 0 && batchActions}
<button <button
class="batch-bar-close" class="batch-bar-close"
title={t('common.clear', 'Clear selection')} title={t('common.clear', 'Clear selection')}
aria-label={t('common.clear', 'Clear selection')} aria-label={t('common.clear', 'Clear selection')}
data-testid="resource-list-batch-close-btn" data-testid="resource-list-batch-close-btn"
onclick={clearSelection} onclick={clearSelection}
> >
<Icon name="times" /> <Icon name="times" />
</button> </button>
<span class="batch-bar-count" <span class="batch-bar-count"
>{t('files.selected_count', { count: selected.size }, '{{count}} selected')}</span >{t('files.selected_count', { count: selected.size }, '{{count}} selected')}</span
> >
<div class="batch-bar-actions"> <div class="batch-bar-actions">
{@render batchActions(selectedItems)} {@render batchActions(selectedItems)}
</div> </div>
{:else if actions} {:else if actions}
{@render actions()} {@render actions()}
{/if} {/if}
</div> </div>
{/snippet} {/snippet}
{#snippet end()} {#snippet end()}
<DisplayModeControls <DisplayModeControls
groups={groupBys} groups={groupBys}
{groupBy} {groupBy}
{reversed} {reversed}
ongroup={selectGroup} ongroup={selectGroup}
ondirection={toggleDirection} ondirection={toggleDirection}
showViewMode={showViewToggle} showViewMode={showViewToggle}
{showDotfileToggle} {showDotfileToggle}
/> />
{/snippet} {/snippet}
</ActionBar> </ActionBar>
</div> </div>
{#if error} {#if error}
<EmptyState icon="exclamation-circle" title={error} error /> <EmptyState icon="exclamation-circle" title={error} error />
{:else if loading && isEmpty} {:else if loading && isEmpty}
<SkeletonList count={SKELETON.length} /> <SkeletonList count={SKELETON.length} />
{:else if isEmpty} {:else if isEmpty}
<EmptyState <EmptyState
icon={emptyIcon} icon={emptyIcon}
title={emptyText ?? t('common.empty', 'Nothing here yet.')} title={emptyText ?? t('common.empty', 'Nothing here yet.')}
hint={emptyHint} hint={emptyHint}
/> />
{:else} {:else}
<div class="files-container" bind:clientWidth={gridWidth}> <div class="files-container" bind:clientWidth={gridWidth}>
{#if grouped && filesStore.viewMode === 'list'} {#if grouped && filesStore.viewMode === 'list'}
<div class="files-list-view" style="--files-list-columns: {columns}"> <div class="files-list-view" style="--files-list-columns: {columns}">
{#if listHeaderOverride}{@render listHeaderOverride()}{:else}{@render listHeader()}{/if} {#if listHeaderOverride}{@render listHeaderOverride()}{:else}{@render listHeader()}{/if}
{#each sections as section (section.key)} {#each sections as section (section.key)}
<div class="rl-swimlane-header" role="rowheader"> <div class="rl-swimlane-header" role="rowheader">
<span class="rl-swimlane-header__label">{section.label}</span> <span class="rl-swimlane-header__label">{section.label}</span>
{#if bucketAction} {#if bucketAction}
<span class="rl-swimlane-header__action"> <span class="rl-swimlane-header__action">
{@render bucketAction(section.key)} {@render bucketAction(section.key)}
</span> </span>
{/if} {/if}
</div> </div>
<!-- Window each section's rows so a large grouped list (e.g. a big <!-- Window each section's rows so a large grouped list (e.g. a big
trash, grouped by remaining days) doesn't mount every row. --> trash, grouped by remaining days) doesn't mount every row. -->
<VirtualList items={section.rows} rowHeight={56} key={(e) => e.id} {row} /> <VirtualList items={section.rows} rowHeight={56} key={(e) => e.id} {row} />
{/each} {/each}
</div> </div>
{:else if grouped} {:else if grouped}
<!-- Grouped GRID: a vertical stack of (header + its own windowed card <!-- Grouped GRID: a vertical stack of (header + its own windowed card
grid) per section. The outer is a flex column, NOT `.files-grid-view` grid) per section. The outer is a flex column, NOT `.files-grid-view`
(which is itself a grid and would place each header/VirtualList into a (which is itself a grid and would place each header/VirtualList into a
cell) — the grid lives on each VirtualList's inner window via cell) — the grid lives on each VirtualList's inner window via
`windowClass`, exactly like the flat-grid arm. This was the last `windowClass`, exactly like the flat-grid arm. This was the last
unwindowed path: a grouped-by-default grid (trash) mounted every card unwindowed path: a grouped-by-default grid (trash) mounted every card
(benches/ROUND13.md §V1). --> (benches/ROUND13.md §V1). -->
<div class="rl-grouped-grid"> <div class="rl-grouped-grid">
{#each sections as section (section.key)} {#each sections as section (section.key)}
<div class="rl-swimlane-header rl-swimlane-header--grid" role="rowheader"> <div class="rl-swimlane-header rl-swimlane-header--grid" role="rowheader">
<span class="rl-swimlane-header__label">{section.label}</span> <span class="rl-swimlane-header__label">{section.label}</span>
{#if bucketAction} {#if bucketAction}
<span class="rl-swimlane-header__action"> <span class="rl-swimlane-header__action">
{@render bucketAction(section.key)} {@render bucketAction(section.key)}
</span> </span>
{/if} {/if}
</div> </div>
<VirtualList <VirtualList
items={section.rows} items={section.rows}
columns={gridCols} columns={gridCols}
rowHeight={240} rowHeight={240}
windowClass="files-grid-view" windowClass="files-grid-view"
key={(e) => e.id} key={(e) => e.id}
{row} {row}
/> />
{/each} {/each}
</div> </div>
{:else if filesStore.viewMode === 'list'} {:else if filesStore.viewMode === 'list'}
<!-- Flat list view: only the visible rows are mounted. The spacer keeps the <!-- Flat list view: only the visible rows are mounted. The spacer keeps the
full scroll height so the end-of-list sentinel still fires. --> full scroll height so the end-of-list sentinel still fires. -->
<div class="files-list-view" style="--files-list-columns: {columns}"> <div class="files-list-view" style="--files-list-columns: {columns}">
{#if listHeaderOverride}{@render listHeaderOverride()}{:else}{@render listHeader()}{/if} {#if listHeaderOverride}{@render listHeaderOverride()}{:else}{@render listHeader()}{/if}
<VirtualList items={visibleItems} rowHeight={56} key={(e) => e.id} {row} /> <VirtualList items={visibleItems} rowHeight={56} key={(e) => e.id} {row} />
</div> </div>
{:else} {:else}
<!-- Grid view: the windowed list's inner element IS the card grid. --> <!-- Grid view: the windowed list's inner element IS the card grid. -->
<VirtualList <VirtualList
items={visibleItems} items={visibleItems}
columns={gridCols} columns={gridCols}
rowHeight={240} rowHeight={240}
windowClass="files-grid-view" windowClass="files-grid-view"
key={(e) => e.id} key={(e) => e.id}
{row} {row}
/> />
{/if} {/if}
{#if hasMore} {#if hasMore}
<button <button
class="btn btn-secondary rl-more" class="btn btn-secondary rl-more"
data-testid="resource-list-load-more-btn" data-testid="resource-list-load-more-btn"
onclick={onloadmore} onclick={onloadmore}
disabled={loading} disabled={loading}
> >
{loading ? t('common.loading', 'Loading…') : t('common.load_more', 'Load more')} {loading ? t('common.loading', 'Loading…') : t('common.load_more', 'Load more')}
</button> </button>
{/if} {/if}
<!-- Infinite-scroll sentinel: auto-loads the next page as it nears the viewport. --> <!-- Infinite-scroll sentinel: auto-loads the next page as it nears the viewport. -->
<div bind:this={sentinel} class="rl-sentinel" aria-hidden="true"></div> <div bind:this={sentinel} class="rl-sentinel" aria-hidden="true"></div>
</div> </div>
{/if} {/if}
{#if rubberband} {#if rubberband}
<!-- Marquee selection rectangle. Positioned relative to `.rl-root` <!-- Marquee selection rectangle. Positioned relative to `.rl-root`
(which is `position: relative`); pointer-events off so the (which is `position: relative`); pointer-events off so the
live pointermove handler on window still sees the drag. --> live pointermove handler on window still sees the drag. -->
<div <div
class="rl-rubberband" class="rl-rubberband"
style:left="{rubberband.x}px" style:left="{rubberband.x}px"
style:top="{rubberband.y}px" style:top="{rubberband.y}px"
style:width="{rubberband.w}px" style:width="{rubberband.w}px"
style:height="{rubberband.h}px" style:height="{rubberband.h}px"
aria-hidden="true" aria-hidden="true"
></div> ></div>
{/if} {/if}
</div><!-- /.rl-root --> </div>
<!-- /.rl-root -->
{#snippet listHeader()} {#snippet listHeader()}
<div class="list-header"> <div class="list-header">
@@ -1,6 +1,4 @@
<script lang="ts"> <script lang="ts">
import SkeletonList from '$lib/components/SkeletonList.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import { errorMessage, errorToast } from '$lib/utils/errors'; import { errorMessage, errorToast } from '$lib/utils/errors';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
@@ -25,10 +23,8 @@
import { import {
deleteFile, deleteFile,
fileDownloadUrl, fileDownloadUrl,
fileThumbnailUrl,
moveFile, moveFile,
renameFile, renameFile,
thumbSizeForView,
uploadFileWithProgress uploadFileWithProgress
} from '$lib/api/endpoints/files'; } from '$lib/api/endpoints/files';
import { folderZipUrl } from '$lib/api/endpoints/folders'; import { folderZipUrl } from '$lib/api/endpoints/folders';
@@ -850,23 +846,6 @@
}); });
}); });
/**
* Whether the server can render a thumbnail preview for this file. Only images
* and videos have server-side thumbnails (see `ThumbnailService::is_supported_image`
* plus client-uploaded video frames); the backend does NOT rasterise PDFs or
* documents, so claiming it could left their tiles blank (the doomed <img>
* 404s and `onerror` hides it). Non-thumbnail files fall back to their colour
* type icon, which renders underneath the <img> regardless.
*/
function canThumbnail(file: FileItem): boolean {
const m = file.mime_type ?? '';
// PDF joins image/video: the client-side generator ported from
// the legacy vanilla frontend renders PDFs via pdf.js on the
// `<img onerror>` fallback path. Without this the img never
// mounts for PDFs and the fallback never fires.
return m.startsWith('image/') || m.startsWith('video/') || m === 'application/pdf';
}
// ── Multi-select + batch ──────────────────────────────────────────────── // ── Multi-select + batch ────────────────────────────────────────────────
// After the ResourceList migration the row-level selection UX (shift- // After the ResourceList migration the row-level selection UX (shift-
// range, ctrl-toggle, anchor tracking, header select-all) lives inside // range, ctrl-toggle, anchor tracking, header select-all) lives inside
@@ -1390,26 +1369,20 @@
input.value = ''; input.value = '';
} }
// Visual emptiness — reflects the filtered set, not the raw listing.
// When the folder contains only dotfiles that the user has chosen to
// hide, `visibleFolders + visibleFiles` is empty and the empty state
// renders; `hiddenCount` above lets the template surface a "you're
// hiding N items" hint so users aren't confused.
const isEmpty = $derived(visibleFolders.length === 0 && visibleFiles.length === 0);
// Client-side sort (flat, Drive-style). The listing endpoint returns the // Client-side sort (flat, Drive-style). The listing endpoint returns the
// folder contents unsorted; sorting here avoids a refetch per column click. // folder contents unsorted; sorting here avoids a refetch per column click.
//
// `reversed` is the single source of truth for direction — bound to
// ResourceList's `bind:reversed` below and read by the comparators.
// The legacy `sortDir: 1 | -1` value used by the comparators is a
// read-only `$derived` off `reversed` so we don't need two-way sync
// (the previous `$state` + two `$effect` mirror was fragile — a
// programmatic write to either side triggered an update on the
// other, and eslint's `prefer-writable-derived` rightly flagged it).
type SortField = 'name' | 'type' | 'size' | 'modified_at' | 'created_at'; type SortField = 'name' | 'type' | 'size' | 'modified_at' | 'created_at';
let sortField = $state<SortField>('name'); let sortField = $state<SortField>('name');
let sortDir = $state<1 | -1>(1); let reversed = $state(false);
const sortDir = $derived<1 | -1>(reversed ? -1 : 1);
function toggleSort(field: SortField) {
if (sortField === field) sortDir = (sortDir * -1) as 1 | -1;
else {
sortField = field;
sortDir = 1;
}
}
function cmpFolders(a: FolderItem, b: FolderItem): number { function cmpFolders(a: FolderItem, b: FolderItem): number {
let v: number; let v: number;
@@ -1485,18 +1458,6 @@
} }
]); ]);
// ResourceList's `reversed` is a boolean; the legacy sort uses `1 | -1`.
// Two-way binding: setting `rlReversed` writes back into `sortDir`, and
// any programmatic sort direction change (e.g. group-by picking) mirrors
// out.
let rlReversed = $state(false);
$effect(() => {
rlReversed = sortDir === -1;
});
$effect(() => {
sortDir = rlReversed ? -1 : 1;
});
// Bridge for <ResourceList>'s callbacks — the row's open/favorite/drag // Bridge for <ResourceList>'s callbacks — the row's open/favorite/drag
// props take one item; the legacy handlers take `(kind, id, name)`. // props take one item; the legacy handlers take `(kind, id, name)`.
function rlOnOpen(item: FileItem | FolderItem) { function rlOnOpen(item: FileItem | FolderItem) {
@@ -1547,8 +1508,6 @@
return () => window.removeEventListener('pointerdown', onDown); return () => window.removeEventListener('pointerdown', onDown);
}); });
const SKELETON = [0, 1, 2, 3, 4, 5, 6, 7];
// Reload whenever the route path changes. // Reload whenever the route path changes.
// //
// `load()` reads several reactive signals in its sync phase // `load()` reads several reactive signals in its sync phase
@@ -1640,7 +1599,7 @@
onsystemdrop={onDrop} onsystemdrop={onDrop}
groupBys={rlGroupBys} groupBys={rlGroupBys}
bind:groupBy bind:groupBy
bind:reversed={rlReversed} bind:reversed
onreload={(orderBy) => { onreload={(orderBy) => {
sortField = orderBy as SortField; sortField = orderBy as SortField;
}} }}
@@ -1735,17 +1694,13 @@
</div> </div>
{/if} {/if}
</div> </div>
<button <button class="btn btn-secondary" data-testid="files-new-folder-btn" onclick={onNewFolder}>
class="btn btn-secondary"
data-testid="files-new-folder-btn"
onclick={onNewFolder}
>
<Icon name="folder-plus" class="icon-mr" /> <Icon name="folder-plus" class="icon-mr" />
<span>{t('actions.new_folder', 'New folder')}</span> <span>{t('actions.new_folder', 'New folder')}</span>
</button> </button>
{/snippet} {/snippet}
{#snippet batchActions(sel)} {#snippet batchActions(_sel)}
<button <button
class="batch-btn" class="batch-btn"
title={t('files.add_favorites', 'Add to favorites')} title={t('files.add_favorites', 'Add to favorites')}