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:
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user