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