168 lines
6.2 KiB
TypeScript
168 lines
6.2 KiB
TypeScript
|
|
/**
|
||
|
|
* 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();
|