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:
Edouard Vanbelle
2026-07-13 19:26:19 +02:00
parent 06da428493
commit 5aaf49859e
43 changed files with 1676 additions and 121 deletions
@@ -14,6 +14,7 @@
import { userInitials, avatarColorIndex } from '$lib/utils/avatar';
import { i18n, LANGUAGES, setLocale, t, type Locale } from '$lib/i18n/index.svelte';
import { apiFetch } from '$lib/api/client';
import { preferences } from '$lib/stores/preferences.svelte';
import { session } from '$lib/stores/session.svelte';
import { theme, type Theme } from '$lib/stores/theme.svelte';
import { ui } from '$lib/stores/ui.svelte';
@@ -208,6 +209,19 @@
langOpen = false;
}
/**
* True when the shortcut target is a text-input surface — <input>,
* <textarea>, or any `contenteditable` element. Used by the
* Cmd/Ctrl+Shift+. shortcut to defer to normal typing when the
* user is composing text (otherwise typing `.` while holding Shift
* in a filename dialog would fight the shortcut).
*/
function isTextFieldFocused(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
const tag = target.tagName;
return tag === 'INPUT' || tag === 'TEXTAREA' || target.isContentEditable;
}
async function chooseLocale(loc: Locale) {
langOpen = false;
await setLocale(loc);
@@ -253,6 +267,29 @@
void palette.load();
return;
}
// Cmd/Ctrl+Shift+. toggles dotfile visibility — matches macOS
// Finder's convention. `e.code === 'Period'` targets the
// physical key regardless of keyboard layout (Cmd+Shift+.
// yields `.key === '>'` on some layouts). Skip when focus is
// inside a text field so users can still type `.` in inputs.
if (
(e.metaKey || e.ctrlKey) &&
e.shiftKey &&
e.code === 'Period' &&
!isTextFieldFocused(e.target)
) {
e.preventDefault();
preferences.toggleHideDotfiles();
ui.notify(
preferences.hideDotfiles
? t('files.dotfiles_hidden_toast', 'Dotfiles hidden')
: t('files.dotfiles_shown_toast', 'Dotfiles shown'),
'info',
2000,
false
);
return;
}
if (e.key !== 'Escape') return;
if (aboutOpen) aboutOpen = false;
else if (searchActive) closeMobileSearch();
+47 -9
View File
@@ -12,7 +12,7 @@
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 dimensions; omit/empty to hide the group-by control. */
@@ -29,6 +29,19 @@
showViewToggle?: boolean;
/** Left-hand actions (upload/new-folder/empty-trash/batch bar, …). */
start?: Snippet;
/** Right-hand extras rendered inside `.view-toggle`, immediately
* before the group-by button. Use for page-local dropdown
* controls (e.g. Shares' kind filter) that should sit as siblings
* of the group-by dropdown and reuse `.toggle-btn`/`.group-by-*`
* classes for a consistent look. */
beforeGroupBy?: Snippet;
/** Show the dotfile-visibility eye toggle. Opt-in per page so
* surfaces that don't filter dotfiles (favorites, trash) don't
* get a control that appears to do nothing. When enabled the
* button lands at the RIGHT end of `.view-toggle` — same row as
* grid/list — and its aria-pressed state mirrors
* `preferences.hideDotfiles`. */
showDotfileToggle?: boolean;
}
let {
@@ -38,7 +51,9 @@
ongroup,
ondirection,
showViewToggle = true,
start
start,
beforeGroupBy,
showDotfileToggle = false
}: Props = $props();
// The group-by button always reflects the active dimension (default = first).
@@ -64,8 +79,9 @@
<div class="actions-bar">
{#if start}{@render start()}{:else}<div class="action-buttons"></div>{/if}
{#if groups?.length || showViewToggle}
{#if groups?.length || showViewToggle || beforeGroupBy || showDotfileToggle}
<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="list-toolbar-groupby-menu">
<button
@@ -110,19 +126,41 @@
{#if showViewToggle}
<button
class="toggle-btn"
class:active={filesStore.viewMode === 'grid'}
class:active={preferences.viewMode === 'grid'}
title={t('view.grid', 'Grid view')}
aria-pressed={filesStore.viewMode === 'grid'}
aria-pressed={preferences.viewMode === 'grid'}
data-testid="list-toolbar-view-grid-btn"
onclick={() => filesStore.setViewMode('grid')}><Icon name="th" /></button
onclick={() => preferences.setViewMode('grid')}><Icon name="th" /></button
>
<button
class="toggle-btn"
class:active={filesStore.viewMode === 'list'}
class:active={preferences.viewMode === 'list'}
title={t('view.list', 'List view')}
aria-pressed={filesStore.viewMode === 'list'}
aria-pressed={preferences.viewMode === 'list'}
data-testid="list-toolbar-view-list-btn"
onclick={() => filesStore.setViewMode('list')}><Icon name="list" /></button
onclick={() => preferences.setViewMode('list')}><Icon name="list" /></button
>
{/if}
{#if showDotfileToggle}
<!--
Right-most utility toggle: flip dotfile visibility for
the current view without opening the profile page.
`aria-pressed` reflects the persisted state (across
sessions), matching how `preferences.hideDotfiles`
participates in ARIA-toggle-button semantics. The
title flips between "hide" / "show" so screen-reader
users get an action label, not a state label.
-->
<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="list-toolbar-dotfile-toggle-btn"
onclick={() => preferences.toggleHideDotfiles()}
><Icon name={preferences.hideDotfiles ? 'eye-slash' : 'eye'} /></button
>
{/if}
</div>
@@ -58,7 +58,7 @@
import UserVignette from '$lib/components/UserVignette.svelte';
import VirtualList from '$lib/components/VirtualList.svelte';
import { t } from '$lib/i18n/index.svelte';
import { files as filesStore } from '$lib/stores/files.svelte';
import { preferences } from '$lib/stores/preferences.svelte';
import { formatBytes } from '$lib/utils/format';
import { formatDate, iconNameFromClass, fileIconKindClass } from '$lib/utils/display';
import { gridColumns } from '$lib/utils/grid';
@@ -99,6 +99,11 @@
showOwner?: boolean;
/** Allow grid/list toggle (shares the app-wide view mode). */
showViewToggle?: boolean;
/** Show the dotfile-visibility eye toggle in the toolbar.
* Opt-in per host page — surfaces that never filter dotfiles
* (favorites, trash) leave this false so the button doesn't
* appear to do nothing. Forwarded to ListToolbar. */
showDotfileToggle?: boolean;
/** Multi-select checkboxes + selection model. */
selectable?: boolean;
/** Right-click / overflow context-menu actions. */
@@ -142,6 +147,7 @@
bucketAction,
showOwner = false,
showViewToggle = true,
showDotfileToggle = false,
selectable = false,
contextActions,
groupBys,
@@ -158,7 +164,7 @@
const isEmpty = $derived(items.length === 0);
const viewClass = $derived(
filesStore.viewMode === 'grid' ? 'files-grid-view' : 'files-list-view'
preferences.viewMode === 'grid' ? 'files-grid-view' : 'files-list-view'
);
/** Content width, for computing the grid's column count to match auto-fill. */
let gridWidth = $state(0);
@@ -398,6 +404,7 @@
ongroup={selectGroup}
ondirection={toggleDirection}
{showViewToggle}
{showDotfileToggle}
>
{#snippet start()}
<div class="action-buttons">{@render toolbar?.()}</div>
@@ -451,7 +458,7 @@
</span>
{/if}
</div>
{#if filesStore.viewMode === 'list'}
{#if preferences.viewMode === 'list'}
<!-- Window each section's rows so a large grouped list (e.g. a big
trash, grouped by remaining days) doesn't mount every row. The
grid-grouped branch stays un-windowed: `files-grid-view` is itself
@@ -464,7 +471,7 @@
{/if}
{/each}
</div>
{:else if filesStore.viewMode === 'list'}
{:else if preferences.viewMode === 'list'}
<!-- Flat list view: only the visible rows are mounted. The spacer keeps the
full scroll height so the end-of-list sentinel still fires. -->
<div class="files-list-view" style="--files-list-columns: {columns}">
@@ -1,5 +1,5 @@
<script lang="ts">
import { files as filesStore } from '$lib/stores/files.svelte';
import { preferences } from '$lib/stores/preferences.svelte';
interface Props {
/** Number of placeholder cards/rows to render (default 6). */
@@ -12,9 +12,11 @@
</script>
<div class="files-container">
<div class={filesStore.viewMode === 'grid' ? 'files-grid-view files-skeleton' : 'files-skeleton'}>
<div
class={preferences.viewMode === 'grid' ? 'files-grid-view files-skeleton' : 'files-skeleton'}
>
{#each placeholders as i (i)}
{#if filesStore.viewMode === 'grid'}
{#if preferences.viewMode === 'grid'}
<div class="skeleton-card">
<div class="skeleton skeleton-thumb"></div>
<div class="skeleton skeleton-line skeleton-line--medium"></div>