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:
@@ -12,6 +12,16 @@ export interface ProfilePatch {
|
||||
family_name?: string;
|
||||
preferred_locale?: string;
|
||||
notify_on_share?: boolean;
|
||||
/**
|
||||
* Partial patch into the opaque UI preferences bag. Server does a
|
||||
* SHALLOW merge — keys present here overwrite existing top-level
|
||||
* keys; absent keys survive. Set a key to `null` to remove it
|
||||
* (server runs `jsonb_strip_nulls` after the merge).
|
||||
*
|
||||
* Wire-side type is `Record<string, unknown>`; the typed view over
|
||||
* this bag lives in `lib/stores/preferences.svelte.ts`.
|
||||
*/
|
||||
ui_preferences?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function updateProfile(patch: ProfilePatch): Promise<User> {
|
||||
|
||||
@@ -172,6 +172,22 @@ export interface User {
|
||||
email_verified_at?: string;
|
||||
preferred_locale?: string;
|
||||
notify_on_share: boolean;
|
||||
/**
|
||||
* Opaque UI preferences bag. Server-side JSONB column that persists
|
||||
* pure UI toggles (hide-dotfiles, view mode, sidebar collapse, …)
|
||||
* across devices. The server never inspects the contents — the SPA
|
||||
* defines the keys (see `lib/stores/preferences.svelte.ts` for the
|
||||
* typed view). Always an object on the wire (empty bag is `{}`,
|
||||
* never `null` or missing).
|
||||
*
|
||||
* When PATCHing back to the server via
|
||||
* `PATCH /api/auth/me/profile { ui_preferences: {...} }`, the
|
||||
* server SHALLOW-merges — only the keys present in the patch are
|
||||
* touched, so partial writes from one device don't clobber
|
||||
* preferences set on another. Set a key to `null` in the patch to
|
||||
* delete it from the bag.
|
||||
*/
|
||||
ui_preferences: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -186,6 +186,10 @@ export const OxiIcons: Record<string, IconEntry> = {
|
||||
576,
|
||||
"M288 32c-80.8 0-145.5 36.8-192.6 80.6C48.6 156 17.3 208 2.5 243.7c-3.3 7.9-3.3 16.7 0 24.6C17.3 304 48.6 356 95.4 399.4C142.5 443.2 207.2 480 288 480s145.5-36.8 192.6-80.6c46.8-43.5 78.1-95.4 93-131.1c3.3-7.9 3.3-16.7 0-24.6c-14.9-35.7-46.2-87.7-93-131.1C433.5 68.8 368.8 32 288 32zM144 256a144 144 0 1 1 288 0 144 144 0 1 1 -288 0zm144-64c0 35.3-28.7 64-64 64c-7.1 0-13.9-1.2-20.3-3.3c-5.5-1.8-11.9 1.6-11.7 7.4c.3 6.9 1.3 13.8 3.2 20.7c13.7 51.2 66.4 81.6 117.6 67.9s81.6-66.4 67.9-117.6c-11.1-41.5-47.8-69.4-88.6-71.1c-5.8-.2-9.2 6.1-7.4 11.7c2.1 6.4 3.3 13.2 3.3 20.3z"
|
||||
],
|
||||
"eye-slash": [
|
||||
640,
|
||||
"M38.8 5.1C28.4-3.1 13.3-1.2 5.1 9.2S-1.2 34.7 9.2 42.9l592 464c10.4 8.2 25.5 6.3 33.7-4.1s6.3-25.5-4.1-33.7L525.6 386.7c39.6-40.6 66.4-86.1 79.9-118.4c3.3-7.9 3.3-16.7 0-24.6c-14.9-35.7-46.2-87.7-93-131.1C465.5 68.8 400.8 32 320 32c-68.2 0-125 26.3-169.3 60.8L38.8 5.1zM223.1 149.5C248.6 126.2 282.7 112 320 112c79.5 0 144 64.5 144 144c0 24.9-6.3 48.3-17.4 68.7L408 294.5c8.4-19.3 10.6-41.4 4.8-63.3c-11.1-41.5-47.8-69.4-88.6-71.1c-5.8-.2-9.2 6.1-7.4 11.7c2.1 6.4 3.3 13.2 3.3 20.3c0 10.2-2.4 19.8-6.6 28.3l-90.3-70.4zM373 389.9c-16.4 6.5-34.3 10.1-53 10.1c-79.5 0-144-64.5-144-144c0-6.9 .5-13.6 1.4-20.2L83.1 161.5C60.3 191.2 44 220.8 34.5 243.7c-3.3 7.9-3.3 16.7 0 24.6c14.9 35.7 46.2 87.7 93 131.1C174.5 443.2 239.2 480 320 480c47.8 0 89.9-12.9 126.2-32.5L373 389.9z"
|
||||
],
|
||||
"file": [
|
||||
384,
|
||||
"M0 64C0 28.7 28.7 0 64 0L224 0l0 128c0 17.7 14.3 32 32 32l128 0 0 288c0 35.3-28.7 64-64 64L64 512c-35.3 0-64-28.7-64-64L0 64zm384 64l-128 0L256 0 384 128z"
|
||||
|
||||
@@ -72,28 +72,23 @@ export type Section =
|
||||
| 'photos'
|
||||
| 'music';
|
||||
|
||||
const VIEW_KEY = 'oxi-view-mode';
|
||||
|
||||
function readViewMode(): ViewMode {
|
||||
if (typeof localStorage === 'undefined') return 'grid';
|
||||
return localStorage.getItem(VIEW_KEY) === 'list' ? 'list' : 'grid';
|
||||
}
|
||||
// `viewMode` used to live here (localStorage `oxi-view-mode`), but
|
||||
// moved to the server-side `ui_preferences` bag so the choice
|
||||
// follows the user across devices. Read via
|
||||
// `preferences.viewMode` and mutate via `preferences.setViewMode`
|
||||
// (`lib/stores/preferences.svelte.ts`). Kept `ViewMode` as an
|
||||
// exported type because template code still needs it for prop
|
||||
// annotations without pulling in the whole preferences module.
|
||||
|
||||
class FilesStore {
|
||||
currentFolder = $state<string | null>(null);
|
||||
currentFolderInfo = $state<FolderItem | null>(null);
|
||||
breadcrumbPath = $state<Array<{ id: string; name: string }>>([]);
|
||||
viewMode = $state<ViewMode>(readViewMode());
|
||||
section = $state<Section>('files');
|
||||
isSearchMode = $state(false);
|
||||
// Reactive set: in-place mutations below drive template/$derived reads.
|
||||
selection = new SvelteSet<string>();
|
||||
|
||||
setViewMode(mode: ViewMode): void {
|
||||
this.viewMode = mode;
|
||||
if (typeof localStorage !== 'undefined') localStorage.setItem(VIEW_KEY, mode);
|
||||
}
|
||||
|
||||
clearSelection(): void {
|
||||
this.selection.clear();
|
||||
}
|
||||
|
||||
@@ -27,13 +27,11 @@ it('shows the owner as "Me" for the current user and a short id otherwise', () =
|
||||
expect(ownerLabel('abcdef123456', 'someone-else')).toBe('abcdef12');
|
||||
});
|
||||
|
||||
it('persists the view mode and toggles selection', () => {
|
||||
files.setViewMode('list');
|
||||
expect(files.viewMode).toBe('list');
|
||||
expect(localStorage.getItem('oxi-view-mode')).toBe('list');
|
||||
files.setViewMode('grid');
|
||||
expect(files.viewMode).toBe('grid');
|
||||
|
||||
// View-mode assertions moved to `preferences.svelte.test.ts` — the
|
||||
// setting now lives on the server-side `ui_preferences` bag via the
|
||||
// `preferences` store, not on `FilesStore`. What remains of `FilesStore`
|
||||
// is navigation + selection state, exercised below.
|
||||
it('toggles selection', () => {
|
||||
files.clearSelection();
|
||||
expect(files.selection.size).toBe(0);
|
||||
files.toggleSelected('a');
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* UI preferences store — typed view over `session.user.ui_preferences`.
|
||||
*
|
||||
* The bag itself lives on the server (`auth.users.ui_preferences` JSONB
|
||||
* column), so it persists across devices without any localStorage
|
||||
* ceremony. This store just:
|
||||
* • hydrates typed reactive fields from `session.user.ui_preferences`
|
||||
* whenever the session changes,
|
||||
* • debounces user-driven writes and PATCHes them back with a shallow
|
||||
* merge,
|
||||
* • rolls back on network failure and surfaces a toast.
|
||||
*
|
||||
* # Adding a new preference
|
||||
*
|
||||
* 1. Add a field to `UiPreferences` below with its type + default.
|
||||
* 2. Add a getter/setter pair (see `hideDotfiles` for the pattern).
|
||||
* 3. That's it. No backend changes — the server treats the bag as
|
||||
* opaque JSON.
|
||||
*
|
||||
* If a preference ever needs to influence server behaviour (locale did),
|
||||
* promote it to a typed column on `auth.users` in a follow-up
|
||||
* migration and drop it from this bag.
|
||||
*/
|
||||
import { updateProfile } from '$lib/api/endpoints/profile';
|
||||
import { session } from '$lib/stores/session.svelte';
|
||||
import { ui } from '$lib/stores/ui.svelte';
|
||||
import { t } from '$lib/i18n/index.svelte';
|
||||
|
||||
/**
|
||||
* Typed shape of the SPA-known keys inside `ui_preferences`. The bag
|
||||
* itself is `Record<string, unknown>` on the wire — this interface is
|
||||
* the SPA's contract with its own future self. Unknown keys are
|
||||
* preserved by the shallow merge; obsolete keys are silently ignored
|
||||
* on read.
|
||||
*/
|
||||
export interface UiPreferences {
|
||||
/**
|
||||
* Hide files/folders whose name starts with a dot (Unix-style hide
|
||||
* convention). Default `false` — show everything. Cross-platform
|
||||
* hide is name-based only; Windows HIDDEN attribute is not
|
||||
* preserved on upload, matching Nextcloud / ownCloud / Seafile.
|
||||
*/
|
||||
hide_dotfiles?: boolean;
|
||||
/**
|
||||
* App-wide file list view: grid tiles or list rows. Default
|
||||
* `'grid'`. Migrated from the localStorage `oxi-view-mode` key
|
||||
* so the choice follows the user across devices — muscle memory
|
||||
* for "I use list on my laptop, grid on my tablet" is rare;
|
||||
* consistency across devices is the common case. Public-share
|
||||
* viewers still use `oxi-share-view` (localStorage) because
|
||||
* anonymous consumers have no server preferences.
|
||||
*/
|
||||
view_mode?: 'grid' | 'list';
|
||||
}
|
||||
|
||||
/** Reasonable default for an empty bag or a missing key. */
|
||||
const DEFAULTS: Required<UiPreferences> = {
|
||||
hide_dotfiles: false,
|
||||
view_mode: 'grid'
|
||||
};
|
||||
|
||||
/**
|
||||
* Milliseconds to wait after the last local mutation before PATCHing.
|
||||
* Fires under fast successive toggles (keyboard shortcut, mis-click,
|
||||
* settings-page checkbox drag) and coalesces into one wire write.
|
||||
*/
|
||||
const PATCH_DEBOUNCE_MS = 500;
|
||||
|
||||
class PreferencesStore {
|
||||
/**
|
||||
* The typed view of the bag. Derived from `session.user?.ui_preferences`
|
||||
* so signing in / out / refresh flips it in lockstep with the session.
|
||||
* Reads pass through DEFAULTS for any missing key.
|
||||
*/
|
||||
private bag = $derived<Record<string, unknown>>(
|
||||
(session.user?.ui_preferences as Record<string, unknown> | undefined) ?? {}
|
||||
);
|
||||
|
||||
// ── Typed accessors ──────────────────────────────────────────
|
||||
|
||||
hideDotfiles = $derived<boolean>(
|
||||
typeof this.bag.hide_dotfiles === 'boolean'
|
||||
? (this.bag.hide_dotfiles as boolean)
|
||||
: DEFAULTS.hide_dotfiles
|
||||
);
|
||||
|
||||
viewMode = $derived<'grid' | 'list'>(this.bag.view_mode === 'list' ? 'list' : DEFAULTS.view_mode);
|
||||
|
||||
// ── Mutations ─────────────────────────────────────────────────
|
||||
|
||||
private patchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private pendingPatch: Record<string, unknown> = {};
|
||||
|
||||
/**
|
||||
* Apply one or more key updates. Optimistic: the in-memory
|
||||
* `session.user.ui_preferences` is updated synchronously so the UI
|
||||
* flips right away; the wire PATCH is debounced. On PATCH failure,
|
||||
* we roll back to the last server-observed bag and toast.
|
||||
*
|
||||
* A value of `null` deletes the key server-side (mirrors the SQL
|
||||
* `jsonb_strip_nulls` after the merge).
|
||||
*/
|
||||
set(patch: Partial<Record<keyof UiPreferences, unknown>>): void {
|
||||
if (!session.user) return;
|
||||
|
||||
// Optimistic local write — mutate the reactive user shallowly.
|
||||
const nextBag = {
|
||||
...((session.user.ui_preferences as Record<string, unknown> | undefined) ?? {}),
|
||||
...patch
|
||||
};
|
||||
// Strip any explicit-null locally so the derived getters see the
|
||||
// same shape the server will end up with. Server's
|
||||
// `jsonb_strip_nulls` handles the persisted side; this keeps
|
||||
// UI in sync between optimistic write and confirmation.
|
||||
for (const [k, v] of Object.entries(patch)) {
|
||||
if (v === null) delete (nextBag as Record<string, unknown>)[k];
|
||||
}
|
||||
session.user = { ...session.user, ui_preferences: nextBag };
|
||||
|
||||
// Accumulate keys so successive `set` calls before the debounce
|
||||
// fires collapse into a single PATCH body — matters for
|
||||
// mass-toggle sequences (e.g. bulk settings-page save).
|
||||
this.pendingPatch = { ...this.pendingPatch, ...patch };
|
||||
|
||||
if (this.patchTimer !== null) clearTimeout(this.patchTimer);
|
||||
this.patchTimer = setTimeout(() => this.flush(), PATCH_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
private async flush(): Promise<void> {
|
||||
this.patchTimer = null;
|
||||
const patch = this.pendingPatch;
|
||||
this.pendingPatch = {};
|
||||
if (Object.keys(patch).length === 0) return;
|
||||
|
||||
const previousUser = session.user;
|
||||
try {
|
||||
const updated = await updateProfile({ ui_preferences: patch });
|
||||
session.user = updated;
|
||||
} catch {
|
||||
// Roll back to whatever the server last confirmed. The
|
||||
// optimistic local mutation is discarded and the derived
|
||||
// `hideDotfiles` / other getters snap back on the next
|
||||
// reactivity tick.
|
||||
session.user = previousUser;
|
||||
ui.notify(
|
||||
t('preferences.save_failed', "Couldn't save your preference. Please try again."),
|
||||
'error'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Convenience wrappers ─────────────────────────────────────
|
||||
|
||||
setHideDotfiles(value: boolean): void {
|
||||
this.set({ hide_dotfiles: value });
|
||||
}
|
||||
|
||||
toggleHideDotfiles(): void {
|
||||
this.setHideDotfiles(!this.hideDotfiles);
|
||||
}
|
||||
|
||||
setViewMode(mode: 'grid' | 'list'): void {
|
||||
this.set({ view_mode: mode });
|
||||
}
|
||||
}
|
||||
|
||||
export const preferences = new PreferencesStore();
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Unix-style dotfile hide convention.
|
||||
*
|
||||
* A file / folder is considered "hidden" when its display name starts
|
||||
* with a `.`. This matches the convention used by every Unix shell,
|
||||
* macOS Finder (with Cmd+Shift+.), and every cloud-share product that
|
||||
* offers a hide toggle (Nextcloud, ownCloud, Seafile).
|
||||
*
|
||||
* Windows-style HIDDEN attribute is not honoured — the attribute isn't
|
||||
* preserved across upload / dedup, and OxiCloud stores content-
|
||||
* addressable blobs without any filesystem metadata carrier. Matches
|
||||
* Nextcloud desktop client behaviour, which also strips HIDDEN on
|
||||
* upload.
|
||||
*
|
||||
* Scope: this helper is UI cosmetics ONLY. A direct URL to a hidden
|
||||
* file (`/files/<uuid>`) still resolves; batch operations only touch
|
||||
* what the UI actually rendered; WebDAV / NC / CalDAV surfaces are
|
||||
* unaffected because they consume the raw API responses. The whole
|
||||
* filter lives at the render layer, keyed on
|
||||
* `preferences.hideDotfiles`.
|
||||
*/
|
||||
|
||||
/** True when the name is a Unix-style hidden file (leading `.`). */
|
||||
export function isDotfile(name: string): boolean {
|
||||
return name.startsWith('.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter an array of `{ name }`-shaped items down to the visible set.
|
||||
* When `hide` is `false`, returns the input array reference unchanged
|
||||
* (no allocation, no derived recomputation churn); when `hide` is
|
||||
* `true`, returns a new array with dotfiles removed.
|
||||
*
|
||||
* `T extends { name: string }` matches `FileItem`, `FolderItem`,
|
||||
* `SearchHit`, and the mixed `ResourceList` union without further
|
||||
* type gymnastics at the call sites.
|
||||
*/
|
||||
export function filterDotfiles<T extends { name: string }>(items: T[], hide: boolean): T[] {
|
||||
if (!hide) return items;
|
||||
return items.filter((item) => !isDotfile(item.name));
|
||||
}
|
||||
|
||||
/**
|
||||
* Count the hidden items in an array. Callers use this to render
|
||||
* an empty-state hint like "N hidden — show them?" so users don't
|
||||
* get surprised by a mysteriously empty folder that actually contains
|
||||
* dotfiles.
|
||||
*/
|
||||
export function countHidden<T extends { name: string }>(items: T[]): number {
|
||||
let n = 0;
|
||||
for (const item of items) if (isDotfile(item.name)) n++;
|
||||
return n;
|
||||
}
|
||||
Reference in New Issue
Block a user