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
+7 -12
View File
@@ -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();
}
+5 -7
View File
@@ -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();