feat(user-perf): add ui user-perf + dotfile filter
- add resource kind filter (file, folder, drive) in shared section (localStorage stored) - add user preferences serverside store - add client side dotfile filter (show/hide dotfiles) (user perf stored, default: dotfiles are shown) for security trashed dotfile are always displayed protection added: if a folder has only hidden items, a notification invite user to display it if a user rename or create a hidden item, a notification tells it to user
This commit is contained in:
@@ -41,6 +41,8 @@
|
||||
import { addTracks, createPlaylist, listPlaylists } from '$lib/api/endpoints/music';
|
||||
import { apiFetch } from '$lib/api/client';
|
||||
import { getCsrfHeaders } from '$lib/api/csrf';
|
||||
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 VirtualList from '$lib/components/VirtualList.svelte';
|
||||
@@ -91,6 +93,24 @@
|
||||
});
|
||||
|
||||
let listing = $state<FolderListing>({ folders: [], files: [], favoriteIds: [], sharedIds: [] });
|
||||
|
||||
// Dotfile hide filter — applied BEFORE sort so `sortedFolders` /
|
||||
// `sortedFiles` reflect exactly what the user sees. Selection,
|
||||
// select-all, batch operations, and the empty-state check all
|
||||
// derive from these visible arrays so a hidden file can't be
|
||||
// silently swept up by "select all" or a "delete visible" batch.
|
||||
// Direct lookups by id (deep-links via `?file=<uuid>`) still go
|
||||
// through `listing.files` so hidden files remain accessible by
|
||||
// their own URL — same UX as macOS Finder.
|
||||
const visibleFolders = $derived(filterDotfiles(listing.folders, preferences.hideDotfiles));
|
||||
const visibleFiles = $derived(filterDotfiles(listing.files, preferences.hideDotfiles));
|
||||
// Count of items suppressed by the filter — surfaced in the
|
||||
// empty-state hint when the folder isn't visually empty but
|
||||
// contains only dotfiles the user has hidden, so a "why is this
|
||||
// empty?" question is answerable at a glance.
|
||||
const hiddenCount = $derived(
|
||||
preferences.hideDotfiles ? countHidden(listing.folders) + countHidden(listing.files) : 0
|
||||
);
|
||||
let crumbs = $state<Array<{ id: string; name: string }>>([]);
|
||||
let currentId = $state<string | null>(null);
|
||||
let loading = $state(false);
|
||||
@@ -287,6 +307,21 @@
|
||||
try {
|
||||
await createFolder(name, currentId);
|
||||
await reload();
|
||||
// Vanish-warning: user just made a `.folder` and it's
|
||||
// already hidden by their preference — otherwise the new
|
||||
// folder would appear to have not been created. Third hook
|
||||
// point in the "creating a dotfile while hide is on" family
|
||||
// (upload + rename cover the other two).
|
||||
if (preferences.hideDotfiles && name.startsWith('.')) {
|
||||
ui.notify(
|
||||
t(
|
||||
'files.new_folder_dotfile_hidden',
|
||||
{ name },
|
||||
"Created folder '{{name}}' — hidden by your dotfile preference."
|
||||
),
|
||||
'info'
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
errorToast(e);
|
||||
}
|
||||
@@ -559,6 +594,26 @@
|
||||
// Storage usage changed server-side — pull the fresh figure so the
|
||||
// "Almacenamiento" bar moves off its login value instead of 0%.
|
||||
void session.refresh();
|
||||
// Vanish-warning: if hide-dotfiles is on and any uploaded
|
||||
// files start with `.`, the successfully-uploaded rows are
|
||||
// invisible in the grid the moment they land. Fire a
|
||||
// single grouped nudge so users don't think the upload
|
||||
// failed. Only fires when the preference is on AND at
|
||||
// least one uploaded file matched. Bell notification
|
||||
// stays quiet (already covers success/failure counts).
|
||||
if (preferences.hideDotfiles) {
|
||||
const hidden = files.filter((f) => f.name.startsWith('.')).length;
|
||||
if (hidden > 0) {
|
||||
ui.notify(
|
||||
t(
|
||||
'files.upload_dotfile_hidden',
|
||||
{ n: hidden },
|
||||
'{{n}} file(s) uploaded but hidden by your dotfile preference.'
|
||||
),
|
||||
'info'
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
ui.finishProgress(nid, errorMessage(err), 'error');
|
||||
} finally {
|
||||
@@ -645,6 +700,23 @@
|
||||
rememberFolderName(id, name); // keep breadcrumbs current immediately
|
||||
}
|
||||
await reload();
|
||||
// Vanish-warning: the file didn't start with `.` before but
|
||||
// does now, AND the user has hide-dotfiles on → the row is
|
||||
// about to disappear from the grid. Toast so the operation
|
||||
// doesn't feel like a silent failure. Only fires on the
|
||||
// transition (`.env` renamed to `.env2` doesn't need the
|
||||
// nudge — it was already hidden). No toast when hide is off
|
||||
// because nothing vanished.
|
||||
if (preferences.hideDotfiles && name.startsWith('.') && !current.startsWith('.')) {
|
||||
ui.notify(
|
||||
t(
|
||||
'files.rename_dotfile_hidden',
|
||||
{ name },
|
||||
"Renamed to '{{name}}' — now hidden by your preference."
|
||||
),
|
||||
'info'
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
errorToast(e);
|
||||
}
|
||||
@@ -787,11 +859,14 @@
|
||||
}
|
||||
|
||||
const selectedCount = $derived(selected.size);
|
||||
const totalCount = $derived(listing.folders.length + listing.files.length);
|
||||
const totalCount = $derived(visibleFolders.length + visibleFiles.length);
|
||||
|
||||
function toggleSelectAll() {
|
||||
if (selected.size === totalCount) clearSelection();
|
||||
else selected = new Set([...listing.folders, ...listing.files].map((i) => i.id));
|
||||
// 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.
|
||||
else selected = new Set([...visibleFolders, ...visibleFiles].map((i) => i.id));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1284,9 +1359,14 @@
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
const isEmpty = $derived(listing.folders.length === 0 && listing.files.length === 0);
|
||||
// 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);
|
||||
const viewClass = $derived(
|
||||
filesStore.viewMode === 'grid' ? 'files-grid-view' : 'files-list-view'
|
||||
preferences.viewMode === 'grid' ? 'files-grid-view' : 'files-list-view'
|
||||
);
|
||||
|
||||
// Client-side sort (flat, Drive-style). The listing endpoint returns the
|
||||
@@ -1321,8 +1401,12 @@
|
||||
return v * sortDir;
|
||||
}
|
||||
|
||||
const sortedFolders = $derived([...listing.folders].sort(cmpFolders));
|
||||
const sortedFiles = $derived([...listing.files].sort(cmpFiles));
|
||||
// `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.
|
||||
const sortedFolders = $derived([...visibleFolders].sort(cmpFolders));
|
||||
const sortedFiles = $derived([...visibleFiles].sort(cmpFiles));
|
||||
|
||||
/** Flat id order matching how rows are displayed (folders then files). */
|
||||
const orderedIds = $derived([...sortedFolders.map((f) => f.id), ...sortedFiles.map((f) => f.id)]);
|
||||
@@ -1508,6 +1592,7 @@
|
||||
reversed={sortDir === -1}
|
||||
ongroup={onPickGroup}
|
||||
ondirection={() => (sortDir = (sortDir * -1) as 1 | -1)}
|
||||
showDotfileToggle
|
||||
>
|
||||
{#snippet start()}
|
||||
{#if selectedCount > 0}
|
||||
@@ -1673,10 +1758,38 @@
|
||||
{:else if showSkeleton && isEmpty}
|
||||
<SkeletonList count={SKELETON.length} />
|
||||
{:else if isEmpty}
|
||||
<EmptyState
|
||||
title={t('files.empty_title', 'This folder is empty')}
|
||||
hint={t('files.empty_hint', 'Drop files here or use the Upload button to add files.')}
|
||||
/>
|
||||
{#if hiddenCount > 0}
|
||||
<!-- Folder isn't really empty — it's just filtered. Hint the
|
||||
user rather than making the "why is my folder empty?"
|
||||
question require a preferences hunt. Toggling the
|
||||
preference flips the whole app's dotfile visibility. -->
|
||||
<EmptyState
|
||||
icon="eye-slash"
|
||||
title={t(
|
||||
'files.empty_hidden_title',
|
||||
{ n: hiddenCount },
|
||||
'{{n}} hidden item(s) in this folder'
|
||||
)}
|
||||
hint={t(
|
||||
'files.empty_hidden_hint',
|
||||
"Files whose name starts with '.' are hidden. Toggle the setting to see them."
|
||||
)}
|
||||
>
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
onclick={() => preferences.setHideDotfiles(false)}
|
||||
data-testid="files-show-hidden-btn"
|
||||
>
|
||||
<Icon name="eye" />
|
||||
{t('files.show_hidden', 'Show hidden files')}
|
||||
</button>
|
||||
</EmptyState>
|
||||
{:else}
|
||||
<EmptyState
|
||||
title={t('files.empty_title', 'This folder is empty')}
|
||||
hint={t('files.empty_hint', 'Drop files here or use the Upload button to add files.')}
|
||||
/>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="files-container" bind:clientWidth={gridWidth}>
|
||||
{#if groupBy !== ''}
|
||||
@@ -1692,7 +1805,7 @@
|
||||
{/each}
|
||||
{/each}
|
||||
</div>
|
||||
{:else if filesStore.viewMode === 'list'}
|
||||
{:else if preferences.viewMode === 'list'}
|
||||
<!-- Flat list: only the rows near the viewport are mounted. -->
|
||||
<div class="files-list-view">
|
||||
{@render fileListHeader()}
|
||||
|
||||
Reference in New Issue
Block a user