diff --git a/frontend/src/lib/components/ResourceList.svelte b/frontend/src/lib/components/ResourceList.svelte index 6ec3ceae..496bf8a2 100644 --- a/frontend/src/lib/components/ResourceList.svelte +++ b/frontend/src/lib/components/ResourceList.svelte @@ -178,6 +178,16 @@ onfavorite?: (item: FileItem | FolderItem) => void; /** Selection changed (set of selected item ids). */ onselectionchange?: (ids: Set) => void; + /** + * Right-click / long-press handler. When provided, ResourceList + * forwards the row's `contextmenu` event to this callback and + * SKIPS its built-in menu — the page renders and positions its + * own. Useful when the page needs conditional entries (WOPI + * editability, audio-only actions) that don't fit the flat + * `contextActions` array. If both `oncontextmenu` and + * `contextActions` are provided, `oncontextmenu` wins. + */ + oncontextmenu?: (e: MouseEvent, item: FileItem | FolderItem) => void; /** * Per-item action cell (renders at the end of a row). Kept as a * distinct slot from the action-bar snippets below so callers @@ -320,6 +330,7 @@ onopen, onfavorite, onselectionchange, + oncontextmenu: onContextMenuOverride, itemActions, actions, batchActions, @@ -393,6 +404,13 @@ let gridWidth = $state(0); const gridCols = $derived(gridColumns(gridWidth)); + // Whether an action-cell renders per row — matches the row-template + // gate below. Feeds both the list-view column track and the header + // row's trailing placeholder so the layout stays in sync. + const hasActionCell = $derived( + !!onfavorite || !!itemActions || !!onContextMenuOverride || !!contextActions?.length + ); + // Build the list-view column track from the enabled cells. const columns = $derived( [ @@ -403,7 +421,7 @@ showType ? '120px' : '', showSize ? '110px' : '', showDate ? '160px' : '', - itemActions ? '120px' : '' + hasActionCell ? '120px' : '' ] .filter(Boolean) .join(' ') @@ -693,8 +711,8 @@
onopen(item) : undefined} onkeydown={onopen ? (e) => e.key === 'Enter' && onopen(item) : undefined} - oncontextmenu={contextActions?.length ? (e) => openContext(e, item) : undefined} + oncontextmenu={onContextMenuOverride + ? (e) => onContextMenuOverride(e, item) + : contextActions?.length + ? (e) => openContext(e, item) + : undefined} > {#if selectable} - {/snippet} @@ -836,20 +888,27 @@ {/if} {#snippet start()} -
- + +
0 && batchActions} + > {#if selectable && selected.size > 0 && batchActions} - {t('files.selected_count', { count: selected.size }, '{{count}} selected')} - {@render batchActions(selectedItems)} +
+ {@render batchActions(selectedItems)} +
{:else if actions} {@render actions()} {/if} @@ -975,7 +1036,7 @@ {#snippet listHeader()}
{#if selectable} -
+
{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 || itemActions}
{/if} + {#if hasActionCell}
{/if}
{/snippet} @@ -1073,51 +1134,6 @@ 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; - width: 28px; - height: 28px; - border: none; - border-radius: var(--radius-sm); - background: transparent; - color: var(--color-text-secondary); - cursor: pointer; - } - - .rl-batch-close:hover { - background: var(--color-bg-hover); - } - - .rl-batch-count { - font-weight: var(--weight-semibold); - color: var(--color-text); - } - - /* ── Selection column ── */ - .select-cell { - display: flex; - align-items: center; - justify-content: center; - } - - .file-item--selected { - background: var(--color-accent-bg); - } - - /* Drop-target highlight — mirrors the legacy files browser's cue when - dragging a row over a folder row. */ - .file-item--drop-target { - outline: 2px dashed var(--color-accent); - outline-offset: -2px; - } - /* ── Owner vignette ── */ .owner-cell { display: flex; @@ -1138,29 +1154,6 @@ white-space: nowrap; } - /* ── Favorite star ── */ - .rl-star { - display: inline-flex; - align-items: center; - justify-content: center; - width: 32px; - height: 32px; - border: none; - border-radius: var(--radius-sm); - background: transparent; - color: var(--color-text-faint); - cursor: pointer; - } - - .rl-star:hover { - background: var(--color-bg-hover); - color: var(--color-text-secondary); - } - - .rl-star--on { - color: var(--color-warning-text-amber); - } - /* ── Swimlane section header ── */ .rl-swimlane-header { grid-column: 1 / -1; diff --git a/frontend/src/lib/styles/ported/batchToolbar.css b/frontend/src/lib/styles/ported/batchToolbar.css index 1f56c462..fe995745 100644 --- a/frontend/src/lib/styles/ported/batchToolbar.css +++ b/frontend/src/lib/styles/ported/batchToolbar.css @@ -21,13 +21,11 @@ margin-right: var(--space-3); height: 60px; transform: translateY(-8px); - transition: - opacity 0.2s, - max-height 0.25s, - transform 0.2s, - margin 0.2s, - padding 0.2s; pointer-events: auto; + /* Note: previous versions of this rule animated the bar's + appearance (opacity / max-height / transform / margin / padding + transitions on the class-add). Dropped intentionally — the bar + just appears/disappears with the selection state now. */ } .batch-bar-close { diff --git a/frontend/src/routes/files/[...path]/+page.svelte b/frontend/src/routes/files/[...path]/+page.svelte index 50e61c3d..dd1708ca 100644 --- a/frontend/src/routes/files/[...path]/+page.svelte +++ b/frontend/src/routes/files/[...path]/+page.svelte @@ -45,9 +45,11 @@ import { countHidden, filterDotfiles } from '$lib/utils/dotfileFilter'; import { preferences } from '$lib/stores/preferences.svelte'; import type { FileItem, FolderItem, ItemType } from '$lib/api/types'; - import ListToolbar from '$lib/components/ListToolbar.svelte'; import ReadOnlyBanner from '$lib/components/ReadOnlyBanner.svelte'; - import VirtualList from '$lib/components/VirtualList.svelte'; + import ResourceList, { + isFile, + type GroupByDef as RLGroupByDef + } from '$lib/components/ResourceList.svelte'; import { lazyComponent } from '$lib/composables/lazyComponent.svelte'; import { t } from '$lib/i18n/index.svelte'; import { confirmDialog, promptDialog } from '$lib/stores/dialogs.svelte'; @@ -55,22 +57,8 @@ import { files as filesStore } from '$lib/stores/files.svelte'; import { session } from '$lib/stores/session.svelte'; import { ui } from '$lib/stores/ui.svelte'; - import { - dateBucket, - ownerLabel, - relativeTimeAgo, - sizeBucket, - typeLabel - } from '$lib/stores/files.svelte'; - import { formatBytes } from '$lib/utils/format'; + import { dateBucket, sizeBucket, typeLabel } from '$lib/stores/files.svelte'; import { replaceSet } from '$lib/utils/sets'; - import { formatDate, iconNameFromClass, fileIconKindClass } from '$lib/utils/display'; - import { gridColumns } from '$lib/utils/grid'; - import { - canThumbnailClientSide, - preloadPdf, - queueGenerate as queueThumbnailGenerate - } from '$lib/utils/thumbnail'; // File preview and the WOPI editor are heavy and only appear on demand, so // their modules load the first time the user opens one (see the effects that @@ -152,7 +140,6 @@ let error = $state(null); let fileInput = $state(null); let uploading = $state(false); - let dragOver = $state(false); interface ActionTarget { id: string; @@ -696,7 +683,6 @@ async function onDrop(e: DragEvent) { e.preventDefault(); - dragOver = false; const dt = e.dataTransfer; if (!dt) return; // A dropped folder isn't expanded into `.files`, so walk the dropped entry @@ -882,63 +868,16 @@ } // ── Multi-select + batch ──────────────────────────────────────────────── - // In-place `SvelteSet`: a toggle is O(1) (no full-set copy) and spares - // the other selected rows' `has()` readers — decisive when refining a - // select-all (selectionPatterns.bench.test.ts). + // After the ResourceList migration the row-level selection UX (shift- + // range, ctrl-toggle, anchor tracking, header select-all) lives inside + // `` and mirrors state OUT via `onselectionchange`. This + // SvelteSet is the local reflection the batch action functions consume; + // it stays a plain in-place `SvelteSet` (per benches ROUND11 §S2) so + // batch buttons see the same set as the row template does. const selected = new SvelteSet(); - // Anchor row id for shift-click range selection. - let selectionAnchor = $state(null); - function toggleSelected(id: string) { - if (selected.has(id)) selected.delete(id); - else selected.add(id); - selectionAnchor = id; - } function clearSelection() { selected.clear(); - selectionAnchor = null; - } - - /** - * Row click selection mirroring static/js/components/resourceList.js: - * - Shift+click selects the contiguous range from the anchor to this row. - * - Ctrl/Cmd+click toggles this row without clearing the rest. - * - A plain click (no modifier) opens the item — handled by the caller. - * Returns true when the click was consumed as a selection gesture. - */ - function handleSelectionClick(e: MouseEvent, id: string): boolean { - if (e.shiftKey && selectionAnchor) { - e.preventDefault(); - const a = orderedIds.indexOf(selectionAnchor); - const b = orderedIds.indexOf(id); - if (a !== -1 && b !== -1) { - const [lo, hi] = a < b ? [a, b] : [b, a]; - for (let i = lo; i <= hi; i++) selected.add(orderedIds[i]); - } - return true; - } - if (e.ctrlKey || e.metaKey) { - e.preventDefault(); - toggleSelected(id); - return true; - } - return false; - } - - const selectedCount = $derived(selected.size); - const totalCount = $derived(visibleFolders.length + visibleFiles.length); - - function toggleSelectAll() { - if (selected.size === totalCount) { - clearSelection(); - } else { - // Select-all only picks what the user can see — dotfiles hidden - // by the current filter are excluded so "select all → delete" - // can't accidentally sweep up hidden files the user never saw. - selected.clear(); - for (const i of visibleFolders) selected.add(i.id); - for (const i of visibleFiles) selected.add(i.id); - } } /** @@ -1060,16 +999,18 @@ function onKeydown(e: KeyboardEvent) { const tag = (e.target as HTMLElement)?.tagName; if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return; - if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'a') { - e.preventDefault(); - toggleSelectAll(); - } else if (e.key === 'Escape' && selected.size) { + if (e.key === 'Escape' && selected.size) { clearSelection(); } else if (e.key === 'Delete' && selected.size) { // Delete only — Backspace was dropped: it triggered accidental deletes. e.preventDefault(); void batchDelete(); } + // Ctrl+A "select all" moved to the list-header checkbox owned by + // ResourceList — the row-level selection UX now lives entirely + // there, so the shortcut is served by clicking that box. Kept the + // binding surface here for the still-page-level Escape / Delete + // gestures that reference the local `selected` mirror. } async function batchDelete() { @@ -1488,134 +1429,113 @@ return v * sortDir; } - // `visibleFolders` / `visibleFiles` are declared up-top (near - // `listing`) because `totalCount` and `isEmpty` reference them - // before this block; only the sorted copies live here so they - // stay next to the sort comparators. + // Sorted (folders-then-files) merged into one `Array` + // that renders directly. Order matches the un-migrated + // layout: folders precede files, sort key applied within each cohort. The + // bespoke `Entry` discriminator + swimlane bucketing that used to live + // here is gone — ResourceList does swimlane bucketing itself via + // `rlGroupBys` below. const sortedFolders = $derived([...visibleFolders].sort(cmpFolders)); const sortedFiles = $derived([...visibleFiles].sort(cmpFiles)); + const rlItems = $derived>([...sortedFolders, ...sortedFiles]); - /** Flat id order matching how rows are displayed (folders then files). */ - const orderedIds = $derived([...sortedFolders.map((f) => f.id), ...sortedFiles.map((f) => f.id)]); + // Group-by state (bound to ). Kept as a `string` prop + // value; the current `sortField` mirrors from the picked group's + // `orderBy` so a group-by change also drives the sort. + let groupBy = $state(''); - // Folders-then-files as one ordered, discriminated list so the (flat) view can - // be windowed by a single VirtualList. Content width drives the grid columns. - type Entry = { kind: 'folder'; folder: FolderItem } | { kind: 'file'; file: FileItem }; - const entries = $derived([ - ...sortedFolders.map((folder) => ({ kind: 'folder' as const, folder })), - ...sortedFiles.map((file) => ({ kind: 'file' as const, file })) - ]); - const entryKey = (e: Entry): string => (e.kind === 'folder' ? e.folder.id : e.file.id); - let gridWidth = $state(0); - - // ── Group-by / swimlanes ───────────────────────────────────────────────── - // Mirrors GROUP_BY_DEFS in static/js/app/filesView.js: a flat list ('') plus - // Type / Size / Modified date / Created date dimensions. Folders always group - // into their own lane (Folder / "Folders" size sentinel) ahead of the files. - type GroupBy = '' | 'type' | 'size' | 'modifiedAt' | 'createdAt'; - let groupBy = $state(''); - - interface ResourceGroup { - key: string; - label: string; - folders: FolderItem[]; - files: FileItem[]; - } - - function folderGroupKey(folder: FolderItem): string { - if (groupBy === 'type') return t('files.file_types.folder', 'Folders'); - if (groupBy === 'size') return sizeBucket(-1); - if (groupBy === 'modifiedAt') return dateBucket(folder.modified_at); - if (groupBy === 'createdAt') return dateBucket(folder.created_at); - return ''; - } - function fileGroupKey(file: FileItem): string { - if (groupBy === 'type') return typeLabel(file.category); - if (groupBy === 'size') return sizeBucket(file.size ?? 0); - if (groupBy === 'modifiedAt') return dateBucket(file.modified_at); - if (groupBy === 'createdAt') return dateBucket(file.created_at); - return ''; - } - - // Grouped rendering: ordered lanes preserving the sorted folder-then-file order - // within each lane. Lanes appear in first-seen order (folders precede files). - const groups = $derived.by(() => { - if (groupBy === '') return []; - // Transient grouping map, local to this derivation and discarded once the - // array is built — must stay a plain Map (a reactive one created inside a - // $derived would be unsafe state). - // eslint-disable-next-line svelte/prefer-svelte-reactivity - const map = new Map(); - const ensure = (key: string): ResourceGroup => { - let g = map.get(key); - if (!g) { - g = { key, label: key, folders: [], files: [] }; - map.set(key, g); - } - return g; - }; - for (const folder of sortedFolders) ensure(folderGroupKey(folder)).folders.push(folder); - for (const file of sortedFiles) ensure(fileGroupKey(file)).files.push(file); - return [...map.values()]; - }); - - // Each group's folders + files folded into ONE ordered `Entry` stream - // (folders first, then files — the exact render order the un-windowed - // `{#each folders}{#each files}` produced), so each swimlane can feed a - // windowed instead of mounting every row/card - // (benches/ROUND13.md §V1). `buildFileRows` is intentionally identity- - // and order-only: it must NOT read favoriteIds/sharedIds/selection, or a - // single star/select toggle would rebuild every swimlane (the ROUND11 - // §S2 fine-grained-star invariant). - const groupedEntries = $derived( - groups.map((g) => ({ - key: g.key, - label: g.label, - entries: [ - ...g.folders.map((folder) => ({ kind: 'folder' as const, folder })), - ...g.files.map((file) => ({ kind: 'file' as const, file })) - ] as Entry[] - })) - ); - - // ── Toolbar controls (upload split-button + group-by popup menu) ───────── - // The group-by popup + sort-direction + view toggle live in the shared - // ; this page only owns the upload split-button dropdown. - let uploadMenuOpen = $state(false); - - interface GroupByDef { - key: GroupBy; - label: string; - icon: string; - /** Sort field implied by this dimension (the old group-by drove order_by). */ - sort?: SortField; - } - // Mirrors the old GROUP_BY_DEFS: "Name" is the default (flat, sorted by name) - // entry — there is no "None" option — followed by the swimlane dimensions. - const GROUP_BYS = $derived([ - { key: '', label: t('files.name', 'Name'), icon: 'arrow-up-a-z', sort: 'name' }, - { key: 'type', label: t('groupby.type', 'Type'), icon: 'layer-group', sort: 'type' }, - { key: 'size', label: t('groupby.size', 'Size'), icon: 'layer-group', sort: 'size' }, + // Same swimlane keys as the bespoke groups above (Type / Size / + // modifiedAt / createdAt). The `orderBy` values are what the + // GROUP_BYS toolbar emits, so 's onreload gets the + // legacy `sortField` name and can drive the same sort path. + const rlGroupBys = $derived([ + { key: '', label: t('files.name', 'Name'), orderBy: 'name', icon: 'arrow-up-a-z' }, + { + key: 'type', + label: t('groupby.type', 'Type'), + orderBy: 'type', + icon: 'layer-group', + bucketOf: (item) => + isFile(item) ? typeLabel(item.category) : t('files.file_types.folder', 'Folders'), + labelOf: (k) => k + }, + { + key: 'size', + label: t('groupby.size', 'Size'), + orderBy: 'size', + icon: 'layer-group', + bucketOf: (item) => (isFile(item) ? sizeBucket(item.size ?? 0) : sizeBucket(-1)), + labelOf: (k) => k + }, { key: 'modifiedAt', label: t('groupby.modifiedAt', 'Modified date'), + orderBy: 'modified_at', icon: 'layer-group', - sort: 'modified_at' + bucketOf: (item) => dateBucket(item.modified_at), + labelOf: (k) => k }, { key: 'createdAt', label: t('groupby.createdAt', 'Created date'), + orderBy: 'created_at', icon: 'layer-group', - sort: 'created_at' + bucketOf: (item) => dateBucket(item.created_at), + labelOf: (k) => k } ]); - /** Group-by chosen in the toolbar — also sets the matching sort field. */ - function onPickGroup(key: string) { - groupBy = key as GroupBy; - const def = GROUP_BYS.find((g) => g.key === key); - if (def?.sort) sortField = def.sort; + // 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 's callbacks — the row's open/favorite/drag + // props take one item; the legacy handlers take `(kind, id, name)`. + function rlOnOpen(item: FileItem | FolderItem) { + if (isFile(item)) openFile(item); + else openFolder(item); } + function rlOnFavorite(item: FileItem | FolderItem) { + void toggleFavorite(isFile(item) ? 'file' : 'folder', item.id); + } + function rlOnContextMenu(e: MouseEvent, item: FileItem | FolderItem) { + openContext(e, isFile(item) ? 'file' : 'folder', item.id, item.name); + } + // Row-drag: everything is draggable; only folders accept drops. + function rlIsDraggable(_item: FileItem | FolderItem): boolean { + return true; + } + function rlIsDropTarget(item: FileItem | FolderItem): boolean { + return !isFile(item); + } + function rlOnItemDragStart(e: DragEvent, item: FileItem | FolderItem) { + onItemDragStart(e, isFile(item) ? 'file' : 'folder', item.id, item.name); + } + function rlOnItemDragOver(e: DragEvent, item: FileItem | FolderItem) { + if (isFile(item)) return; + if (e.dataTransfer?.types.includes(DRAG_TYPE)) { + e.preventDefault(); + dropFolderId = item.id; + } + } + function rlOnItemDragLeave(_e: DragEvent, item: FileItem | FolderItem) { + if (dropFolderId === item.id) dropFolderId = null; + } + function rlOnItemDrop(e: DragEvent, item: FileItem | FolderItem) { + if (isFile(item)) return; + onFolderDrop(e, item); + } + + // ── Upload split-button popup state ───────────────────────────────────── + let uploadMenuOpen = $state(false); // Close the upload popup when clicking outside of it. $effect(() => { @@ -1660,19 +1580,7 @@ -
{ - e.preventDefault(); - dragOver = true; - }} - ondragleave={() => (dragOver = false)} - ondrop={onDrop} -> +
- - - (sortDir = (sortDir * -1) as 1 | -1)} - showDotfileToggle - > - {#snippet start()} - {#if selectedCount > 0} -
-
- - {t('files.selected_count', { count: selectedCount }, '{{count}} selected')} -
-
-
- - - - - -
-
-
- {:else} -
-
- - {#if uploadMenuOpen} -
- - -
- {/if} -
- + {#if uploadMenuOpen} +
+ +
{/if} - {/snippet} - - - -
- - {#if error} - - {:else if showSkeleton && isEmpty} - - {:else if isEmpty} - {#if hiddenCount > 0} - - - {:else} - - {/if} - {:else} -
- {#if groupBy !== '' && filesStore.viewMode === 'list'} - -
- {@render fileListHeader()} - {#each groupedEntries as group (group.key)} -
{group.label}
- - {/each} -
- {:else if groupBy !== ''} - -
- {#each groupedEntries as group (group.key)} -
- {group.label} -
- - {/each} -
- {:else if filesStore.viewMode === 'list'} - -
- {@render fileListHeader()} - -
- {:else} - - - {/if} -
- {/if} -
- -{#snippet fileListHeader()} -
-
- 0 && selectedCount === totalCount} - indeterminate={selectedCount > 0 && selectedCount < totalCount} - onchange={toggleSelectAll} - /> -
- {#each [{ f: 'name', l: t('files.col_name', 'Name') }, { f: 'owner', l: t('files.col_owner', 'Owner') }, { f: 'type', l: t('files.col_type', 'Type') }, { f: 'size', l: t('files.col_size', 'Size') }, { f: 'modified_at', l: t('files.col_modified', 'Modified') }] as col (col.f)} - {#if col.f === 'owner'} -
{col.l}
- {:else} - - {/if} - {/each} -
-
-{/snippet} - -{#snippet entryRow(e: Entry)} - {#if e.kind === 'folder'} - {@render folderRow(e.folder)} - {:else} - {@render fileRow(e.file)} - {/if} -{/snippet} - -{#snippet folderRow(folder: FolderItem)} -
onItemDragStart(e, 'folder', folder.id, folder.name)} - ondragover={(e) => { - if (e.dataTransfer?.types.includes(DRAG_TYPE)) { - e.preventDefault(); - dropFolderId = folder.id; - } - }} - ondragleave={() => { - if (dropFolderId === folder.id) dropFolderId = null; - }} - ondrop={(e) => onFolderDrop(e, folder)} - ondblclick={() => openFolder(folder)} - onclick={(e) => { - if (!handleSelectionClick(e, folder.id)) openFolder(folder); - }} - oncontextmenu={(e) => openContext(e, 'folder', folder.id, folder.name)} - onkeydown={(e) => e.key === 'Enter' && openFolder(folder)} - > -
- { - e.stopPropagation(); - toggleSelected(folder.id); - }} - /> -
-
-
- {folder.name} - {#if favoriteIds.has(folder.id)}
- -
{/if} - {#if sharedIds.has(folder.id)}
- -
{/if} -
-
- {relativeTimeAgo(folder.modified_at)} -
-
- {ownerLabel(folder.created_by, session.user?.id ?? null)} -
-
{t('files.file_types.folder', 'Folder')}
-
—
-
{formatDate(folder.modified_at)}
-
- - - - - - -
-
-{/snippet} - -{#snippet fileRow(file: FileItem)} - {@const iconName = iconNameFromClass(file.icon_class)} -
onItemDragStart(e, 'file', file.id, file.name)} - ondblclick={() => openFile(file)} - onclick={(e) => { - if (!handleSelectionClick(e, file.id)) openFile(file); - }} - oncontextmenu={(e) => openContext(e, 'file', file.id, file.name)} - onkeydown={(e) => e.key === 'Enter' && openFile(file)} - > -
- { - e.stopPropagation(); - toggleSelected(file.id); - }} - /> -
-
-
- - - {#if canThumbnail(file)} - { - // Server-side thumbnail is missing (404) — try client-side - // generation for image / PDF / video and PUT the result - // back so the next viewer gets the server thumbnail. - // Ported from the legacy static/js/features/thumbnail.js. - const img = e.currentTarget as HTMLImageElement; - img.style.display = 'none'; - if (!canThumbnailClientSide(file)) return; - if (file.mime_type === 'application/pdf') preloadPdf(); - void queueThumbnailGenerate(file, (dataUrl) => { - img.src = dataUrl; - img.style.display = ''; - }); - }} - /> - {/if}
- {file.name} - {#if favoriteIds.has(file.id)}
- -
{/if} - {#if sharedIds.has(file.id)}
- -
{/if} -
-
- {relativeTimeAgo(file.modified_at)} - {#if file.size != null}{formatBytes(file.size)}{/if} -
-
- {ownerLabel(file.created_by, session.user?.id ?? null)} -
-
{typeLabel(file.category)}
-
{file.size != null ? formatBytes(file.size) : ''}
-
{formatDate(file.modified_at)}
-
+ + {t('actions.new_folder', 'New folder')} + + {/snippet} + + {#snippet batchActions(sel)} void batchFavorites()} > + + {t('files.add_favorites', 'Add to favorites')} + - + {t('files.move', 'Move')} + + + - - -
-
-{/snippet} + + {t('common.delete', 'Delete')} + + {/snippet} + + {#snippet rowBadge(item)} + {#if favoriteIds.has(item.id)} + + + + {/if} + {#if sharedIds.has(item.id)} + + + + {/if} + {/snippet} + +
{#if moveDialog.component} {@const MoveDialog = moveDialog.component} @@ -2492,41 +2013,6 @@ min-height: 100%; } - .files-page.dropzone-active { - outline: 2px dashed var(--color-accent); - outline-offset: -8px; - border-radius: var(--radius-xl); - } - - .page-sticky-header { - display: flex; - flex-direction: column; - gap: var(--space-3); - } - - .action-cell { - display: flex; - gap: var(--space-1); - justify-content: flex-end; - } - - .btn-action--delete:hover { - color: var(--color-danger-text); - } - - .btn-action { - text-decoration: none; - } - - /* Owner column header — non-sortable, so it's a plain div rather than a - sort button. Inherits the header row's weight/colour. */ - .list-header-owner { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - .item-badge { display: inline-flex; align-items: center;