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
+10
View File
@@ -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> {
+16
View File
@@ -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();
+47 -9
View File
@@ -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>
+4
View File
@@ -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"
+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();
+53
View File
@@ -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;
}
@@ -37,6 +37,15 @@
const byId = $derived(new Map(raw.map((it) => [it.resource.id, it])));
// Favorites view DELIBERATELY ignores `preferences.hideDotfiles`.
// Rationale: favoriting is an explicit "I want to keep an eye on
// this" action by the user — hiding a starred item on a different
// listing page because it starts with `.` contradicts that intent.
// The hide preference is for reducing incidental clutter in
// algorithmic listings (files/recent/photos), not for overriding
// user-intentional pins. Trash follows the same principle for a
// safety-net reason; the general rule shaping up: explicit-action
// surfaces don't filter, algorithmic surfaces do.
const entries = $derived(
raw.map((it): ResourceEntry => {
const isFile = it.resource_type === 'file';
+124 -11
View File
@@ -41,6 +41,8 @@
import { addTracks, createPlaylist, listPlaylists } from '$lib/api/endpoints/music';
import { apiFetch } from '$lib/api/client';
import { getCsrfHeaders } from '$lib/api/csrf';
import { countHidden, filterDotfiles } from '$lib/utils/dotfileFilter';
import { preferences } from '$lib/stores/preferences.svelte';
import type { FileItem, FolderItem, ItemType } from '$lib/api/types';
import ListToolbar from '$lib/components/ListToolbar.svelte';
import VirtualList from '$lib/components/VirtualList.svelte';
@@ -91,6 +93,24 @@
});
let listing = $state<FolderListing>({ folders: [], files: [], favoriteIds: [], sharedIds: [] });
// Dotfile hide filter — applied BEFORE sort so `sortedFolders` /
// `sortedFiles` reflect exactly what the user sees. Selection,
// select-all, batch operations, and the empty-state check all
// derive from these visible arrays so a hidden file can't be
// silently swept up by "select all" or a "delete visible" batch.
// Direct lookups by id (deep-links via `?file=<uuid>`) still go
// through `listing.files` so hidden files remain accessible by
// their own URL — same UX as macOS Finder.
const visibleFolders = $derived(filterDotfiles(listing.folders, preferences.hideDotfiles));
const visibleFiles = $derived(filterDotfiles(listing.files, preferences.hideDotfiles));
// Count of items suppressed by the filter — surfaced in the
// empty-state hint when the folder isn't visually empty but
// contains only dotfiles the user has hidden, so a "why is this
// empty?" question is answerable at a glance.
const hiddenCount = $derived(
preferences.hideDotfiles ? countHidden(listing.folders) + countHidden(listing.files) : 0
);
let crumbs = $state<Array<{ id: string; name: string }>>([]);
let currentId = $state<string | null>(null);
let loading = $state(false);
@@ -287,6 +307,21 @@
try {
await createFolder(name, currentId);
await reload();
// Vanish-warning: user just made a `.folder` and it's
// already hidden by their preference — otherwise the new
// folder would appear to have not been created. Third hook
// point in the "creating a dotfile while hide is on" family
// (upload + rename cover the other two).
if (preferences.hideDotfiles && name.startsWith('.')) {
ui.notify(
t(
'files.new_folder_dotfile_hidden',
{ name },
"Created folder '{{name}}' — hidden by your dotfile preference."
),
'info'
);
}
} catch (e) {
errorToast(e);
}
@@ -559,6 +594,26 @@
// Storage usage changed server-side — pull the fresh figure so the
// "Almacenamiento" bar moves off its login value instead of 0%.
void session.refresh();
// Vanish-warning: if hide-dotfiles is on and any uploaded
// files start with `.`, the successfully-uploaded rows are
// invisible in the grid the moment they land. Fire a
// single grouped nudge so users don't think the upload
// failed. Only fires when the preference is on AND at
// least one uploaded file matched. Bell notification
// stays quiet (already covers success/failure counts).
if (preferences.hideDotfiles) {
const hidden = files.filter((f) => f.name.startsWith('.')).length;
if (hidden > 0) {
ui.notify(
t(
'files.upload_dotfile_hidden',
{ n: hidden },
'{{n}} file(s) uploaded but hidden by your dotfile preference.'
),
'info'
);
}
}
} catch (err) {
ui.finishProgress(nid, errorMessage(err), 'error');
} finally {
@@ -645,6 +700,23 @@
rememberFolderName(id, name); // keep breadcrumbs current immediately
}
await reload();
// Vanish-warning: the file didn't start with `.` before but
// does now, AND the user has hide-dotfiles on → the row is
// about to disappear from the grid. Toast so the operation
// doesn't feel like a silent failure. Only fires on the
// transition (`.env` renamed to `.env2` doesn't need the
// nudge — it was already hidden). No toast when hide is off
// because nothing vanished.
if (preferences.hideDotfiles && name.startsWith('.') && !current.startsWith('.')) {
ui.notify(
t(
'files.rename_dotfile_hidden',
{ name },
"Renamed to '{{name}}' — now hidden by your preference."
),
'info'
);
}
} catch (e) {
errorToast(e);
}
@@ -787,11 +859,14 @@
}
const selectedCount = $derived(selected.size);
const totalCount = $derived(listing.folders.length + listing.files.length);
const totalCount = $derived(visibleFolders.length + visibleFiles.length);
function toggleSelectAll() {
if (selected.size === totalCount) clearSelection();
else selected = new Set([...listing.folders, ...listing.files].map((i) => i.id));
// Select-all only picks what the user can see — dotfiles hidden
// by the current filter are excluded so "select all → delete"
// can't accidentally sweep up hidden files the user never saw.
else selected = new Set([...visibleFolders, ...visibleFiles].map((i) => i.id));
}
/**
@@ -1284,9 +1359,14 @@
input.value = '';
}
const isEmpty = $derived(listing.folders.length === 0 && listing.files.length === 0);
// Visual emptiness — reflects the filtered set, not the raw listing.
// When the folder contains only dotfiles that the user has chosen to
// hide, `visibleFolders + visibleFiles` is empty and the empty state
// renders; `hiddenCount` above lets the template surface a "you're
// hiding N items" hint so users aren't confused.
const isEmpty = $derived(visibleFolders.length === 0 && visibleFiles.length === 0);
const viewClass = $derived(
filesStore.viewMode === 'grid' ? 'files-grid-view' : 'files-list-view'
preferences.viewMode === 'grid' ? 'files-grid-view' : 'files-list-view'
);
// Client-side sort (flat, Drive-style). The listing endpoint returns the
@@ -1321,8 +1401,12 @@
return v * sortDir;
}
const sortedFolders = $derived([...listing.folders].sort(cmpFolders));
const sortedFiles = $derived([...listing.files].sort(cmpFiles));
// `visibleFolders` / `visibleFiles` are declared up-top (near
// `listing`) because `totalCount` and `isEmpty` reference them
// before this block; only the sorted copies live here so they
// stay next to the sort comparators.
const sortedFolders = $derived([...visibleFolders].sort(cmpFolders));
const sortedFiles = $derived([...visibleFiles].sort(cmpFiles));
/** Flat id order matching how rows are displayed (folders then files). */
const orderedIds = $derived([...sortedFolders.map((f) => f.id), ...sortedFiles.map((f) => f.id)]);
@@ -1508,6 +1592,7 @@
reversed={sortDir === -1}
ongroup={onPickGroup}
ondirection={() => (sortDir = (sortDir * -1) as 1 | -1)}
showDotfileToggle
>
{#snippet start()}
{#if selectedCount > 0}
@@ -1673,10 +1758,38 @@
{:else if showSkeleton && isEmpty}
<SkeletonList count={SKELETON.length} />
{:else if isEmpty}
<EmptyState
title={t('files.empty_title', 'This folder is empty')}
hint={t('files.empty_hint', 'Drop files here or use the Upload button to add files.')}
/>
{#if hiddenCount > 0}
<!-- Folder isn't really empty — it's just filtered. Hint the
user rather than making the "why is my folder empty?"
question require a preferences hunt. Toggling the
preference flips the whole app's dotfile visibility. -->
<EmptyState
icon="eye-slash"
title={t(
'files.empty_hidden_title',
{ n: hiddenCount },
'{{n}} hidden item(s) in this folder'
)}
hint={t(
'files.empty_hidden_hint',
"Files whose name starts with '.' are hidden. Toggle the setting to see them."
)}
>
<button
class="btn btn-secondary"
onclick={() => preferences.setHideDotfiles(false)}
data-testid="files-show-hidden-btn"
>
<Icon name="eye" />
{t('files.show_hidden', 'Show hidden files')}
</button>
</EmptyState>
{:else}
<EmptyState
title={t('files.empty_title', 'This folder is empty')}
hint={t('files.empty_hint', 'Drop files here or use the Upload button to add files.')}
/>
{/if}
{:else}
<div class="files-container" bind:clientWidth={gridWidth}>
{#if groupBy !== ''}
@@ -1692,7 +1805,7 @@
{/each}
{/each}
</div>
{:else if filesStore.viewMode === 'list'}
{:else if preferences.viewMode === 'list'}
<!-- Flat list: only the rows near the viewport are mounted. -->
<div class="files-list-view">
{@render fileListHeader()}
+7 -2
View File
@@ -62,7 +62,7 @@ vi.mock('$lib/api/endpoints/folders', () => ({
import { fetchFolderListing, createFolder, deleteFolder } from '$lib/api/endpoints/folders';
import { deleteFile } from '$lib/api/endpoints/files';
import { apiFetch } from '$lib/api/client';
import { files as filesStore } from '$lib/stores/files.svelte';
import { preferences } from '$lib/stores/preferences.svelte';
import FilesPage from './[...path]/+page.svelte';
const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
@@ -126,7 +126,12 @@ beforeEach(() => {
// listing-oriented tests target a folder directly.
pageState.params.path = 'home';
// List view renders the select-all header + per-row checkboxes; grid hides them.
filesStore.viewMode = 'list';
// The store's `setViewMode` writes through to the server bag via a
// debounced PATCH; in the test harness there's no session so the
// PATCH silently no-ops on the network side but the optimistic local
// mutation (session.user.ui_preferences.view_mode) still lands and
// downstream `preferences.viewMode` re-derives to 'list'.
preferences.setViewMode('list');
});
it('loads the home folder listing on mount and renders its contents', async () => {
+47 -12
View File
@@ -11,8 +11,10 @@
import { fileDownloadUrl, fileThumbnailUrl } from '$lib/api/endpoints/files';
import Icon from '$lib/icons/Icon.svelte';
import { confirmDialog } from '$lib/stores/dialogs.svelte';
import { preferences } from '$lib/stores/preferences.svelte';
import { t } from '$lib/i18n/index.svelte';
import { ui } from '$lib/stores/ui.svelte';
import { filterDotfiles } from '$lib/utils/dotfileFilter';
import { isVideo, photoTimestamp } from '$lib/utils/media';
type Tab = 'moments' | 'places' | 'people';
@@ -27,6 +29,17 @@
let peopleAvailable = $state(false);
let items = $state<PhotoItem[]>([]);
// Client-side dotfile filter over `items`. Applied here (not
// server-side) because the filter is a UI-only preference and
// applies uniformly across every listing surface. Lightbox +
// grouping consume `visibleItems`; mutations still target `items`
// (the raw fetched set) so a deletion still removes the photo even
// if it's currently hidden by the filter.
const visibleItems = $derived(filterDotfiles(items, preferences.hideDotfiles));
// Count of items suppressed by the dotfile filter — surfaced in
// the empty-state hint below so a `.thumbnails/`-only photos view
// doesn't read as "no photos yet".
const hiddenCount = $derived(preferences.hideDotfiles ? items.length - visibleItems.length : 0);
let cursor = $state<string | null>(null);
let exhausted = $state(false);
let loading = $state(false);
@@ -76,7 +89,7 @@
// Transient scratch map built inside $derived.by and discarded — not reactive state.
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const index = new Map<string, number>();
for (const p of items) {
for (const p of visibleItems) {
const d = new Date(photoTimestamp(p));
const key = bucketKey(d);
let i = index.get(key);
@@ -230,7 +243,11 @@
/** A plain tile click toggles selection once anything is selected, else opens the lightbox. */
function onTileClick(p: PhotoItem) {
if (selected.size > 0) selected.toggle(p.id);
else lightbox = items.findIndex((x) => x.id === p.id);
// Lightbox index refers to what's actually rendered — grouping
// loops `visibleItems`, so the index space must too. If we
// used `items` here a hidden photo could ride the paging
// buttons even though it doesn't appear in the grid.
else lightbox = visibleItems.findIndex((x) => x.id === p.id);
}
function onDeletePhoto(id: string) {
@@ -406,15 +423,30 @@
{#if error}
<p class="status status--error" role="alert">{error}</p>
{:else if items.length === 0 && exhausted}
<EmptyState
icon="images"
title={t('photos.empty', 'No photos yet.')}
hint={t(
'photos.empty_hint',
'Photos and videos you upload will appear here, grouped by date.'
)}
/>
{:else if visibleItems.length === 0 && exhausted}
{#if hiddenCount > 0}
<EmptyState
icon="eye-slash"
title={t(
'photos.empty_hidden',
{ n: hiddenCount },
'{{n}} photo(s) hidden by your dotfile preference'
)}
hint={t(
'photos.empty_hidden_hint',
'Turn off "Hide dotfiles" in your profile to see them.'
)}
/>
{:else}
<EmptyState
icon="images"
title={t('photos.empty', 'No photos yet.')}
hint={t(
'photos.empty_hint',
'Photos and videos you upload will appear here, grouped by date.'
)}
/>
{/if}
{:else}
<div class="photos-area">
<div class="photos-measure" bind:clientWidth={gridWidth}>
@@ -444,7 +476,10 @@
{#if photoLightbox.component}
{@const PhotoLightbox = photoLightbox.component}
<PhotoLightbox {items} bind:index={lightbox} onDelete={onDeletePhoto} />
<!-- Lightbox operates on `visibleItems` — indices align with
what the grid rendered, so next/prev never surfaces a
hidden photo the user can't see in the grid behind. -->
<PhotoLightbox items={visibleItems} bind:index={lightbox} onDelete={onDeletePhoto} />
{/if}
{:else if tab === 'places'}
{#if placesMap.component}
+32
View File
@@ -17,6 +17,7 @@
import { SUPPORTED_LOCALES, setLocale, t, type Locale } from '$lib/i18n/index.svelte';
import Icon from '$lib/icons/Icon.svelte';
import { confirmDialog } from '$lib/stores/dialogs.svelte';
import { preferences } from '$lib/stores/preferences.svelte';
import { session } from '$lib/stores/session.svelte';
import { ui } from '$lib/stores/ui.svelte';
import { formatBytes } from '$lib/utils/format';
@@ -28,6 +29,12 @@
let username = $state('');
let preferredLocale = $state<string>('');
let notifyOnShare = $state(true);
// Batched into the profile save flow (same UX as
// `notifyOnShare` above). The `preferences` store is still the
// source of truth for the persisted value — this local mirrors it
// on hydrate, and the diff feeds `patch.ui_preferences` on save
// so the whole card follows one save discipline.
let hideDotfiles = $state(false);
let currentPw = $state('');
let newPw = $state('');
@@ -91,6 +98,12 @@
username = u.username ?? '';
preferredLocale = u.preferred_locale ?? '';
notifyOnShare = u.notify_on_share;
// Source of truth is the preferences store, which itself
// derives from `session.user.ui_preferences`. Reading through
// the store here (rather than the raw bag) means a new
// preference field just needs a getter in the store and its
// own line here — no wire-format knowledge on the page.
hideDotfiles = preferences.hideDotfiles;
}
async function saveProfile(e: SubmitEvent) {
@@ -110,6 +123,12 @@
patch.preferred_locale = preferredLocale || undefined;
}
if (notifyOnShare !== u.notify_on_share) patch.notify_on_share = notifyOnShare;
// Ship the diff as a partial `ui_preferences` patch — the
// server does a shallow merge, so only the changed key is
// touched; siblings set on other devices survive.
if (hideDotfiles !== preferences.hideDotfiles) {
patch.ui_preferences = { hide_dotfiles: hideDotfiles };
}
if (Object.keys(patch).length === 0) {
ui.notify(t('profile.profile_no_changes', 'No changes to save.'), 'info');
@@ -539,6 +558,19 @@
/>
<span>{t('profile.notify_on_share', 'Email me when someone shares with me')}</span>
</label>
<label class="checkbox">
<input
type="checkbox"
data-testid="profile-hide-dotfiles-checkbox"
bind:checked={hideDotfiles}
/>
<span
>{t(
'profile.hide_dotfiles',
'Hide files whose name starts with a dot (.env, .git, …)'
)}</span
>
</label>
<button type="submit" data-testid="profile-save-btn" disabled={savingProfile}
>{t('profile.save_profile', 'Save changes')}</button
>
+20 -4
View File
@@ -26,6 +26,8 @@
type ResourceEntry
} from '$lib/components/ResourceList.svelte';
import { confirmDialog, promptDialog } from '$lib/stores/dialogs.svelte';
import { preferences } from '$lib/stores/preferences.svelte';
import { filterDotfiles } from '$lib/utils/dotfileFilter';
import { t } from '$lib/i18n/index.svelte';
let raw = $state<RecentResourceItem[]>([]);
@@ -39,7 +41,7 @@
const byId = $derived(new Map(raw.map((it) => [it.resource.id, it])));
const entries = $derived(
const allEntries = $derived(
raw.map((it): ResourceEntry => {
const isFile = it.resource_type === 'file';
// §14 provenance: Recent's mental model is "who touched this
@@ -62,6 +64,11 @@
};
})
);
const entries = $derived(filterDotfiles(allEntries, preferences.hideDotfiles));
// Count of items suppressed by the dotfile filter — surfaced in
// the empty-state hint below so a `.eslintrc`-only Recent doesn't
// read as "nothing here yet".
const hiddenCount = $derived(preferences.hideDotfiles ? allEntries.length - entries.length : 0);
const groupBys: GroupByDef[] = [
{ key: '', label: t('files.name', 'Name'), orderBy: 'name', icon: 'arrow-up-a-z' },
@@ -323,14 +330,23 @@
items={entries}
{loading}
{error}
emptyIcon="clock"
emptyText={t('recent.empty_state', 'No recent files')}
emptyHint={t('recent.empty_hint', 'Files you open will appear here')}
emptyIcon={hiddenCount > 0 ? 'eye-slash' : 'clock'}
emptyText={hiddenCount > 0
? t(
'recent.empty_hidden_state',
{ n: hiddenCount },
'{{n}} recent item(s) hidden by your dotfile preference'
)
: t('recent.empty_state', 'No recent files')}
emptyHint={hiddenCount > 0
? t('recent.empty_hidden_hint', 'Turn off "Hide dotfiles" in your profile to see them.')
: t('recent.empty_hint', 'Files you open will appear here')}
hasMore={!!cursor}
onloadmore={() => load(false, orderByForGroup())}
onopen={open}
onfavorite={toggleFavorite}
showOwner
showDotfileToggle
selectable
{contextActions}
{groupBys}
+20 -8
View File
@@ -12,6 +12,9 @@
type ShareListing,
type ShareMeta
} from '$lib/api/endpoints/share';
import { preferences } from '$lib/stores/preferences.svelte';
import { session } from '$lib/stores/session.svelte';
import { filterDotfiles } from '$lib/utils/dotfileFilter';
import { t } from '$lib/i18n/index.svelte';
type State = 'loading' | 'password' | 'expired' | 'invalid' | 'file' | 'folder';
@@ -41,9 +44,18 @@
return null;
}
const mediaFiles = $derived(
(listing?.files ?? []).filter((f) => mediaKind(f.mime_type) !== null)
);
// Dotfile hide only applies to LOGGED-IN viewers of a public
// share. Anonymous viewers see exactly what the sharer put in
// the link — hiding items behind a UI toggle they don't
// control would be surprising ("the owner said this was in
// there but I don't see it"). Logged-in viewers get their own
// preference respected, matching every other list surface in
// the app.
const applyDotfileFilter = $derived(session.isAuthenticated && preferences.hideDotfiles);
const visibleFolders = $derived(filterDotfiles(listing?.folders ?? [], applyDotfileFilter));
const visibleFiles = $derived(filterDotfiles(listing?.files ?? [], applyDotfileFilter));
const mediaFiles = $derived(visibleFiles.filter((f) => mediaKind(f.mime_type) !== null));
function setViewMode(mode: ViewMode) {
viewMode = mode;
@@ -335,14 +347,14 @@
</div>
</header>
{#if listing.folders.length === 0 && listing.files.length === 0}
{#if visibleFolders.length === 0 && visibleFiles.length === 0}
<p class="share__status">{t('share.empty_folder', 'This folder is empty.')}</p>
{/if}
{#if listing.folders.length > 0}
{#if visibleFolders.length > 0}
<h2 class="share__section">{t('share.folders', 'Folders')}</h2>
<ul class="share__grid" class:share__grid--list={viewMode === 'list'}>
{#each listing.folders as f (f.id)}
{#each visibleFolders as f (f.id)}
<li>
<button
class="card"
@@ -357,10 +369,10 @@
</ul>
{/if}
{#if listing.files.length > 0}
{#if visibleFiles.length > 0}
<h2 class="share__section">{t('share.files', 'Files')}</h2>
<ul class="share__grid" class:share__grid--list={viewMode === 'list'}>
{#each listing.files as f (f.id)}
{#each visibleFiles as f (f.id)}
{@const kind = mediaKind(f.mime_type)}
{#if kind}
<li>
+191 -3
View File
@@ -52,6 +52,89 @@
let groupBy = $state<GroupBy>('items');
let reversed = $state(false);
// ── Kind filter ─────────────────────────────────────────────────────────
// Client-side filter over `raw`. The backend endpoint
// `GET /api/grants/outgoing/resources` currently emits `file`, `folder`,
// and `drive` only. Calendar / contact / playlist grants exist as
// backend resource kinds (`ResourceKind::Calendar` etc.) but aren't
// aggregated by `list_my_shares` — a separate backend PR will extend
// the endpoint, at which point another kind entry is added here.
//
// Filtering happens after pagination fetch, not inside the request,
// so unchecking a kind is instant and doesn't cost a reload. The
// pagination cursor is unaffected — Load more still fetches all kinds
// and the filter re-applies to the growing list.
const KIND_OPTIONS: { key: GrantResourceType; label: string; icon: string }[] = [
{ key: 'file', label: t('myshares.filter.files', 'Files'), icon: 'file' },
{ key: 'folder', label: t('myshares.filter.folders', 'Folders'), icon: 'folder' },
{ key: 'drive', label: t('myshares.filter.drives', 'Drives'), icon: 'hdd' }
];
// Default: files + folders visible, drives hidden. Drives share
// less frequently (whole-tree grants) and clutter the list when
// what the user wants is a file/folder audit.
const DEFAULT_KINDS: Record<GrantResourceType, boolean> = {
file: true,
folder: true,
drive: false
};
// Persist filter selection across sessions on THIS device. Not
// stored server-side because the kind filter is a device-local
// view choice — a user auditing shared drives on their admin
// machine likely has a different filter than what they use to
// track files on their laptop. Contrast `preferences.hideDotfiles`
// which is per-user + cross-device (JSONB on the user row).
//
// localStorage key uses the `oxi-*` prefix so it participates in
// the switch-account wipe in `localStoragePrefs.ts` — a fresh
// login starts with defaults, not the previous user's choice.
const STORAGE_KEY = 'oxi-shared-kinds';
function loadSelectedKinds(): Record<GrantResourceType, boolean> {
if (typeof localStorage === 'undefined') return { ...DEFAULT_KINDS };
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return { ...DEFAULT_KINDS };
const parsed = JSON.parse(raw) as Partial<Record<GrantResourceType, boolean>>;
// Merge over DEFAULT_KINDS so a stored record from a build
// before some kind existed still yields a full record.
// Rejects any junk (non-boolean values) by ignoring them.
const merged: Record<GrantResourceType, boolean> = { ...DEFAULT_KINDS };
for (const opt of KIND_OPTIONS) {
const v = parsed[opt.key];
if (typeof v === 'boolean') merged[opt.key] = v;
}
return merged;
} catch {
return { ...DEFAULT_KINDS };
}
}
function saveSelectedKinds(kinds: Record<GrantResourceType, boolean>): void {
if (typeof localStorage === 'undefined') return;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(kinds));
} catch {
/* quota / private mode — silently skip, filter still works this session */
}
}
let selectedKinds = $state<Record<GrantResourceType, boolean>>(loadSelectedKinds());
let filterOpen = $state(false);
function toggleKind(k: GrantResourceType) {
selectedKinds[k] = !selectedKinds[k];
saveSelectedKinds(selectedKinds);
}
function resetKinds() {
selectedKinds = { ...DEFAULT_KINDS };
saveSelectedKinds(selectedKinds);
}
const activeKindCount = $derived(KIND_OPTIONS.filter((k) => selectedKinds[k.key]).length);
const filteredRaw = $derived(raw.filter((item) => selectedKinds[item.resource_type]));
// Edit-sharing dialog
let dialogOpen = $state(false);
let dialogItem = $state<{ id: string; name: string; kind: GrantResourceType } | null>(null);
@@ -109,7 +192,7 @@
}
return lane;
};
for (const item of raw) {
for (const item of filteredRaw) {
if (groupBy === 'items') {
const lane = ensure(`resource:${item.resource.id}`, { kind: 'resource', item });
for (const grant of item.grants) lane.rows.push({ grant, item });
@@ -370,12 +453,23 @@
}
const isEmpty = $derived(!loading && raw.length === 0 && !error);
// `raw` has data but the kind filter hides all of it — distinct empty
// state so we can offer a "reset filter" affordance instead of the
// generic "you haven't shared anything" hint.
const noMatchesForFilter = $derived(
!loading && !error && raw.length > 0 && filteredRaw.length === 0
);
onMount(() => load(true));
</script>
<svelte:head><title>{t('nav.shared', 'Shared')} · OxiCloud</title></svelte:head>
<svelte:window onclick={() => menuFor && closeMenu()} />
<svelte:window
onclick={() => {
if (menuFor) closeMenu();
if (filterOpen) filterOpen = false;
}}
/>
<div class="page-sticky-header">
<h1 class="page-title">{t('nav.shared', 'Shared')}</h1>
@@ -386,7 +480,53 @@
ongroup={(key) => setGroupBy(key as GroupBy)}
ondirection={toggleDirection}
showViewToggle={false}
/>
>
{#snippet beforeGroupBy()}
<div class="group-by-selector ms-filter" data-testid="shared-filter-menu">
<button
class="toggle-btn group-by-btn active"
title={t('myshares.filter.title', 'Filter by kind')}
aria-haspopup="true"
aria-expanded={filterOpen}
data-testid="shared-filter-btn"
onclick={(e) => {
e.stopPropagation();
filterOpen = !filterOpen;
}}
>
<Icon name="filter" />
<span class="group-by-label">
{t('myshares.filter.button', 'Kinds')}
{#if activeKindCount < KIND_OPTIONS.length}
<span class="ms-filter__badge">{activeKindCount}</span>
{/if}
</span>
</button>
{#if filterOpen}
<div
class="group-by-menu"
role="menu"
tabindex="-1"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.key === 'Escape' && (filterOpen = false)}
>
{#each KIND_OPTIONS as k (k.key)}
<label class="group-by-option ms-filter__row" class:active={selectedKinds[k.key]}>
<input
type="checkbox"
data-testid={`shared-filter-${k.key}`}
checked={selectedKinds[k.key]}
onchange={() => toggleKind(k.key)}
/>
<Icon name={k.icon} />
{k.label}
</label>
{/each}
</div>
{/if}
</div>
{/snippet}
</ListToolbar>
</div>
{#if error}
@@ -397,6 +537,20 @@
title={t('myshares.emptyStateTitle', "You haven't shared anything yet")}
hint={t('myshares.emptyStateDesc', 'Items you share with others will appear here')}
/>
{:else if noMatchesForFilter}
<EmptyState
icon="filter"
title={t('myshares.filter.emptyTitle', 'No shares match the current filter')}
hint={t(
'myshares.filter.emptyHint',
'Adjust the kind filter or reset it to the default (Files + Folders).'
)}
>
<button class="btn btn-secondary" data-testid="shared-filter-reset" onclick={resetKinds}>
<Icon name="rotate-left" />
{t('myshares.filter.reset', 'Reset filter')}
</button>
</EmptyState>
{:else}
<div class="ms-lanes">
{#each lanes as lane (lane.key)}
@@ -907,4 +1061,38 @@
.ms-more {
margin: var(--space-3) auto 0;
}
/* Kind filter — nested inside ListToolbar's `.view-toggle`, styled
as a sibling of the group-by dropdown. The `.group-by-selector`,
`.group-by-btn`, `.group-by-menu`, `.group-by-option` classes
are inherited from the global `ported/buttons.css` — see the
`beforeGroupBy` snippet in the template. Only the local tweaks
below (checkbox layout + active-count badge) stay page-scoped. */
.ms-filter__row {
cursor: pointer;
}
.ms-filter__row input[type='checkbox'] {
margin: 0;
cursor: pointer;
}
/* Count of active kinds when the filter is narrower than "all
kinds" — small pill inside the button's label so the button
still reads as a single group-by-style control. */
.ms-filter__badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.25rem;
height: 1.1rem;
margin-left: var(--space-1);
padding: 0 var(--space-1);
border-radius: var(--radius-pill, 999px);
background: var(--color-accent);
color: var(--color-text-light);
font-size: var(--text-xs);
font-weight: var(--weight-semibold, 600);
}
</style>
+7
View File
@@ -30,6 +30,13 @@
let groupBy = $state('remainingDays');
let reversed = $state(false);
// Trash view DELIBERATELY ignores `preferences.hideDotfiles`.
// Rationale: trash is a safety net — hiding items here would let
// an accidentally-trashed dotfile ride the retention timer to
// permanent deletion without ever being visible for recovery.
// The hide preference is UI cosmetics elsewhere; here it would
// become a footgun. Same reasoning applies to any future
// "review before destructive action" surface.
const entries = $derived(
raw.map((it): ResourceEntry => {
const isFile = it.resource_type === 'file';
+28 -2
View File
@@ -58,6 +58,8 @@
"photos": {
"empty_state": "لا توجد صور بعد",
"empty_hint": "ارفع صوراً أو مقاطع فيديو لعرضها هنا",
"empty_hidden": "{{n}} من الصور مخفية وفقاً لتفضيلاتك",
"empty_hidden_hint": "قم بإيقاف تشغيل \"إخفاء الملفات المخفية\" في ملفك الشخصي لرؤيتها.",
"items_selected": "محدد",
"view_daily": "يوم",
"view_monthly": "شهر",
@@ -365,7 +367,15 @@
"folder": "مجلد",
"new_folder": "مجلد جديد",
"share": "مشاركة",
"view": "عرض"
"view": "عرض",
"empty_hidden_title": "{{n}} عنصر مخفي في هذا المجلد",
"empty_hidden_hint": "الملفات التي يبدأ اسمها بـ '.' مخفية. غيّر الإعداد لرؤيتها.",
"show_hidden": "إظهار الملفات المخفية",
"upload_dotfile_hidden": "تم رفع {{n}} ملف/ملفات ولكن تم إخفاؤها وفقاً لتفضيلاتك.",
"rename_dotfile_hidden": "تمت إعادة التسمية إلى \"{{name}}\" — أصبحت الآن مخفية وفقاً لتفضيلاتك.",
"new_folder_dotfile_hidden": "تم إنشاء المجلد \"{{name}}\" — مخفي وفقاً لتفضيلاتك.",
"dotfiles_hidden_toast": "تم إخفاء الملفات المخفية",
"dotfiles_shown_toast": "تم إظهار الملفات المخفية"
},
"dialogs": {
"rename_folder": "إعادة تسمية المجلد",
@@ -570,6 +580,8 @@
"accessed": "تم الوصول",
"empty_state": "لا توجد ملفات حديثة",
"empty_hint": "الملفات التي تفتحها ستظهر هنا",
"empty_hidden_state": "{{n}} من العناصر الأخيرة مخفية وفقاً لتفضيلاتك",
"empty_hidden_hint": "قم بإيقاف تشغيل \"إخفاء الملفات المخفية\" في ملفك الشخصي لرؤيتها.",
"loadMore": "تحميل المزيد"
},
"notifications": {
@@ -879,6 +891,7 @@
"family_name": "اسم العائلة",
"notify_on_share": "أرسل لي بريدًا إلكترونيًا عندما يشاركني شخص ما",
"notify_on_share_hint": "عند إلغاء التحديد، ستظل المشاركات تظهر في حسابك — لن تتلقى فقط بريدًا إلكترونيًا بشأنها.",
"hide_dotfiles": "إخفاء الملفات التي يبدأ اسمها بنقطة (.env، .git، …)",
"save_profile": "حفظ التغييرات",
"profile_saved": "تم تحديث الملف الشخصي",
"profile_no_changes": "لا توجد تغييرات لحفظها.",
@@ -999,7 +1012,17 @@
"notifyRateLimited": "عدد كبير من الإشعارات لهذا المستلم — حاول لاحقًا.",
"removeAccess": "إزالة الوصول",
"resendInvitation": "إعادة إرسال بريد الدعوة",
"publicLinks": "Public links"
"publicLinks": "Public links",
"filter": {
"button": "الأنواع",
"title": "تصفية حسب النوع",
"files": "ملفات",
"folders": "مجلدات",
"drives": "الأقراص",
"emptyTitle": "لا توجد مشاركات تطابق التصفية الحالية",
"emptyHint": "اضبط تصفية النوع أو أعِد ضبطها إلى الافتراضي (ملفات + مجلدات).",
"reset": "إعادة تعيين التصفية"
}
},
"sort": {
"asc": "ascending",
@@ -1129,5 +1152,8 @@
"view": {
"grid": "عرض شبكي",
"list": "عرض قائمة"
},
"preferences": {
"save_failed": "تعذّر حفظ تفضيلك. حاول مرة أخرى."
}
}
+28 -2
View File
@@ -58,6 +58,8 @@
"photos": {
"empty_state": "Noch keine Fotos",
"empty_hint": "Laden Sie Bilder oder Videos hoch, um sie hier zu sehen",
"empty_hidden": "{{n}} Foto(s) durch Ihre Einstellung ausgeblendet",
"empty_hidden_hint": "Deaktivieren Sie \"Verborgene Dateien ausblenden\" in Ihrem Profil, um sie anzuzeigen.",
"items_selected": "ausgewählt",
"view_daily": "Tag",
"view_monthly": "Monat",
@@ -365,7 +367,15 @@
"folder": "Ordner",
"new_folder": "Neuer Ordner",
"share": "Teilen",
"view": "Anzeigen"
"view": "Anzeigen",
"empty_hidden_title": "{{n}} verborgene(s) Element(e) in diesem Ordner",
"empty_hidden_hint": "Dateien, deren Name mit '.' beginnt, sind ausgeblendet. Ändern Sie die Einstellung, um sie anzuzeigen.",
"show_hidden": "Verborgene Dateien anzeigen",
"upload_dotfile_hidden": "{{n}} Datei(en) hochgeladen, aber durch Ihre Einstellung ausgeblendet.",
"rename_dotfile_hidden": "In \"{{name}}\" umbenannt — jetzt durch Ihre Einstellung ausgeblendet.",
"new_folder_dotfile_hidden": "Ordner \"{{name}}\" erstellt — durch Ihre Einstellung ausgeblendet.",
"dotfiles_hidden_toast": "Verborgene Dateien ausgeblendet",
"dotfiles_shown_toast": "Verborgene Dateien angezeigt"
},
"dialogs": {
"rename_folder": "Ordner umbenennen",
@@ -570,6 +580,8 @@
"accessed": "Zugegriffen",
"empty_state": "Keine zuletzt verwendeten Dateien",
"empty_hint": "Dateien, die Sie öffnen, werden hier angezeigt",
"empty_hidden_state": "{{n}} zuletzt verwendete(s) Element(e) durch Ihre Einstellung ausgeblendet",
"empty_hidden_hint": "Deaktivieren Sie \"Verborgene Dateien ausblenden\" in Ihrem Profil, um sie anzuzeigen.",
"loadMore": "Mehr laden"
},
"notifications": {
@@ -879,6 +891,7 @@
"family_name": "Nachname",
"notify_on_share": "Mich per E-Mail benachrichtigen, wenn jemand mit mir teilt",
"notify_on_share_hint": "Wenn deaktiviert, werden Freigaben weiterhin in deinem Konto angezeigt — du erhältst nur keine E-Mail dazu.",
"hide_dotfiles": "Dateien ausblenden, deren Name mit einem Punkt beginnt (.env, .git, …)",
"save_profile": "Änderungen speichern",
"profile_saved": "Profil aktualisiert",
"profile_no_changes": "Keine Änderungen zu speichern.",
@@ -999,7 +1012,17 @@
"notifyRateLimited": "Zu viele Benachrichtigungen für diesen Empfänger — versuchen Sie es später erneut.",
"removeAccess": "Zugriff entfernen",
"resendInvitation": "Einladungs-E-Mail erneut senden",
"publicLinks": "Public links"
"publicLinks": "Public links",
"filter": {
"button": "Arten",
"title": "Nach Art filtern",
"files": "Dateien",
"folders": "Ordner",
"drives": "Laufwerke",
"emptyTitle": "Keine Freigaben entsprechen dem aktuellen Filter",
"emptyHint": "Passen Sie den Art-Filter an oder setzen Sie ihn auf den Standard zurück (Dateien + Ordner).",
"reset": "Filter zurücksetzen"
}
},
"sort": {
"asc": "aufsteigend",
@@ -1129,5 +1152,8 @@
"view": {
"grid": "Rasteransicht",
"list": "Listenansicht"
},
"preferences": {
"save_failed": "Ihre Einstellung konnte nicht gespeichert werden. Bitte versuchen Sie es erneut."
}
}
+31 -3
View File
@@ -57,7 +57,17 @@
"manageAccess": "Manage access",
"notifySent": "Notification sent.",
"passwordLinks": "Password-protected links",
"publicLinks": "Public links"
"publicLinks": "Public links",
"filter": {
"button": "Kinds",
"title": "Filter by kind",
"files": "Files",
"folders": "Folders",
"drives": "Drives",
"emptyTitle": "No shares match the current filter",
"emptyHint": "Adjust the kind filter or reset it to the default (Files + Folders).",
"reset": "Reset filter"
}
},
"nav": {
"files": "Files",
@@ -77,6 +87,8 @@
"photos": {
"empty_state": "No photos yet",
"empty_hint": "Upload images or videos to see them here",
"empty_hidden": "{{n}} photo(s) hidden by your dotfile preference",
"empty_hidden_hint": "Turn off \"Hide dotfiles\" in your profile to see them.",
"items_selected": "selected",
"view_daily": "Day",
"view_monthly": "Month",
@@ -508,7 +520,15 @@
"uploading": "Uploading…",
"uploading_file": "Uploading {{name}}…",
"uploading_n": "Uploading {{done}}/{{total}} files…",
"view": "View"
"view": "View",
"empty_hidden_title": "{{n}} hidden item(s) in this folder",
"empty_hidden_hint": "Files whose name starts with '.' are hidden. Toggle the setting to see them.",
"show_hidden": "Show hidden files",
"upload_dotfile_hidden": "{{n}} file(s) uploaded but hidden by your dotfile preference.",
"rename_dotfile_hidden": "Renamed to '{{name}}' — now hidden by your preference.",
"new_folder_dotfile_hidden": "Created folder '{{name}}' — hidden by your dotfile preference.",
"dotfiles_hidden_toast": "Dotfiles hidden",
"dotfiles_shown_toast": "Dotfiles shown"
},
"dialogs": {
"rename_folder": "Rename folder",
@@ -728,6 +748,8 @@
"accessed": "Accessed",
"empty_state": "No recent files",
"empty_hint": "Files you open will appear here",
"empty_hidden_state": "{{n}} recent item(s) hidden by your dotfile preference",
"empty_hidden_hint": "Turn off \"Hide dotfiles\" in your profile to see them.",
"loadMore": "Load more",
"confirm_clear": "Clear your recent items?"
},
@@ -1164,6 +1186,7 @@
"family_name": "Last name",
"notify_on_share": "Email me when someone shares with me",
"notify_on_share_hint": "When unchecked, shares still appear in your account — you just won't get an email about them.",
"hide_dotfiles": "Hide files whose name starts with a dot (.env, .git, …)",
"save_profile": "Save changes",
"profile_saved": "Profile updated",
"profile_no_changes": "No changes to save.",
@@ -1525,6 +1548,11 @@
"view": {
"grid": "Grid view",
"label": "View options",
"list": "List view"
"list": "List view",
"hide_dotfiles": "Hide hidden files",
"show_dotfiles": "Show hidden files"
},
"preferences": {
"save_failed": "Couldn't save your preference. Please try again."
}
}
+28 -2
View File
@@ -58,6 +58,8 @@
"photos": {
"empty_state": "Aún no hay fotos",
"empty_hint": "Sube imágenes o videos para verlos aquí",
"empty_hidden": "{{n}} foto(s) oculta(s) por tu preferencia",
"empty_hidden_hint": "Desactiva \"Ocultar archivos ocultos\" en tu perfil para verlas.",
"items_selected": "seleccionados",
"view_daily": "Día",
"view_monthly": "Mes",
@@ -370,7 +372,15 @@
"uploaded_saved": "Subida completa — {{mb}} MB deduplicados",
"uploaded_partial": "{{ok}} subidos, {{failed}} fallaron",
"uploaded_skipped": "{{ok}} subidos · {{skipped}} omitidos (no son ficheros normales)",
"upload_failed": "La subida falló"
"upload_failed": "La subida falló",
"empty_hidden_title": "{{n}} elemento(s) oculto(s) en esta carpeta",
"empty_hidden_hint": "Los archivos cuyo nombre empieza por '.' están ocultos. Cambia la opción para verlos.",
"show_hidden": "Mostrar archivos ocultos",
"upload_dotfile_hidden": "{{n}} archivo(s) subido(s) pero ocultado(s) por tu preferencia.",
"rename_dotfile_hidden": "Renombrado a \"{{name}}\" — ahora oculto por tu preferencia.",
"new_folder_dotfile_hidden": "Carpeta \"{{name}}\" creada — oculta por tu preferencia.",
"dotfiles_hidden_toast": "Archivos ocultos ocultados",
"dotfiles_shown_toast": "Archivos ocultos mostrados"
},
"dialogs": {
"rename_folder": "Renombrar carpeta",
@@ -575,6 +585,8 @@
"accessed": "Accedido",
"empty_state": "No hay archivos recientes",
"empty_hint": "Los archivos que abras aparecerán aquí",
"empty_hidden_state": "{{n}} elemento(s) reciente(s) oculto(s) por tu preferencia",
"empty_hidden_hint": "Desactiva \"Ocultar archivos ocultos\" en tu perfil para verlos.",
"loadMore": "Cargar más"
},
"notifications": {
@@ -894,6 +906,7 @@
"family_name": "Apellidos",
"notify_on_share": "Enviarme un correo cuando alguien comparta conmigo",
"notify_on_share_hint": "Cuando esté desmarcado, los recursos compartidos seguirán apareciendo en tu cuenta — simplemente no recibirás un correo sobre ellos.",
"hide_dotfiles": "Ocultar archivos cuyo nombre empieza por un punto (.env, .git, …)",
"save_profile": "Guardar cambios",
"profile_saved": "Perfil actualizado",
"profile_no_changes": "Sin cambios que guardar.",
@@ -1014,7 +1027,17 @@
"notifyRateLimited": "Demasiadas notificaciones para este destinatario — inténtalo más tarde.",
"removeAccess": "Quitar acceso",
"resendInvitation": "Reenviar correo de invitación",
"publicLinks": "Enlaces públicos"
"publicLinks": "Enlaces públicos",
"filter": {
"button": "Tipos",
"title": "Filtrar por tipo",
"files": "Archivos",
"folders": "Carpetas",
"drives": "Unidades",
"emptyTitle": "Ningún elemento compartido coincide con el filtro actual",
"emptyHint": "Ajusta el filtro por tipo o restablécelo al valor predeterminado (Archivos + Carpetas).",
"reset": "Restablecer filtro"
}
},
"sort": {
"asc": "ascendente",
@@ -1144,5 +1167,8 @@
"view": {
"grid": "Vista de cuadrícula",
"list": "Vista de lista"
},
"preferences": {
"save_failed": "No se pudo guardar tu preferencia. Inténtalo de nuevo."
}
}
+28 -2
View File
@@ -58,6 +58,8 @@
"photos": {
"empty_state": "هنوز عکسی نیست",
"empty_hint": "تصاویر یا ویدیوها را آپلود کنید تا اینجا نمایش داده شوند",
"empty_hidden": "{{n}} عکس طبق تنظیمات شما پنهان است",
"empty_hidden_hint": "برای مشاهده آن‌ها \"پنهان کردن پرونده‌های پنهان\" را در پروفایل خود غیرفعال کنید.",
"items_selected": "انتخاب شده",
"view_daily": "روز",
"view_monthly": "ماه",
@@ -365,7 +367,15 @@
"folder": "پوشه",
"new_folder": "پوشهٔ جدید",
"share": "هم‌رسانی",
"view": "مشاهده"
"view": "مشاهده",
"empty_hidden_title": "{{n}} مورد پنهان در این پوشه",
"empty_hidden_hint": "پرونده‌هایی که نامشان با '.' شروع می‌شود پنهان هستند. تنظیم را تغییر دهید تا آن‌ها را ببینید.",
"show_hidden": "نمایش پرونده‌های پنهان",
"upload_dotfile_hidden": "{{n}} فایل بارگذاری شد اما طبق تنظیمات شما پنهان است.",
"rename_dotfile_hidden": "نام به \"{{name}}\" تغییر کرد — اکنون طبق تنظیمات شما پنهان است.",
"new_folder_dotfile_hidden": "پوشه \"{{name}}\" ایجاد شد — طبق تنظیمات شما پنهان است.",
"dotfiles_hidden_toast": "پرونده‌های پنهان مخفی شد",
"dotfiles_shown_toast": "پرونده‌های پنهان نمایش داده شد"
},
"dialogs": {
"rename_folder": "تغییر نام پوشه",
@@ -570,6 +580,8 @@
"accessed": "دسترسی یافته",
"empty_state": "هنوز هیچ پروندهٔ اخیر وجود ندارد",
"empty_hint": "پرونده‌هایی که باز می‌کنید اینجا ظاهر می‌شوند",
"empty_hidden_state": "{{n}} مورد اخیر طبق تنظیمات شما پنهان است",
"empty_hidden_hint": "برای مشاهده آن‌ها \"پنهان کردن پرونده‌های پنهان\" را در پروفایل خود غیرفعال کنید.",
"loadMore": "بارگذاری بیشتر"
},
"batch": {
@@ -862,6 +874,7 @@
"family_name": "نام خانوادگی",
"notify_on_share": "وقتی کسی با من چیزی به اشتراک می‌گذارد، به من ایمیل بزن",
"notify_on_share_hint": "وقتی تیک‌خورده نباشد، اشتراک‌گذاری‌ها همچنان در حساب شما نمایش داده می‌شوند — فقط ایمیلی درباره آنها دریافت نخواهید کرد.",
"hide_dotfiles": "پنهان کردن فایل‌هایی که نامشان با نقطه شروع می‌شود (.env، .git، …)",
"save_profile": "ذخیره تغییرات",
"profile_saved": "نمایه به‌روز شد",
"profile_no_changes": "تغییری برای ذخیره وجود ندارد.",
@@ -999,7 +1012,17 @@
"notifyRateLimited": "اعلان‌های زیادی برای این گیرنده — بعداً دوباره تلاش کنید.",
"removeAccess": "حذف دسترسی",
"resendInvitation": "ارسال مجدد ایمیل دعوت",
"publicLinks": "Public links"
"publicLinks": "Public links",
"filter": {
"button": "انواع",
"title": "فیلتر بر اساس نوع",
"files": "پرونده‌ها",
"folders": "پوشه‌ها",
"drives": "درایوها",
"emptyTitle": "هیچ اشتراکی با فیلتر فعلی مطابقت ندارد",
"emptyHint": "فیلتر نوع را تنظیم کنید یا آن را به حالت پیش‌فرض (پرونده‌ها + پوشه‌ها) بازنشانی کنید.",
"reset": "بازنشانی فیلتر"
}
},
"sort": {
"asc": "ascending",
@@ -1129,5 +1152,8 @@
"view": {
"grid": "نمای شبکه‌ای",
"list": "نمای فهرستی"
},
"preferences": {
"save_failed": "ذخیره ترجیح شما ممکن نشد. لطفاً دوباره تلاش کنید."
}
}
+28 -2
View File
@@ -58,6 +58,8 @@
"photos": {
"empty_state": "Pas encore de photos",
"empty_hint": "Téléchargez des images ou des vidéos pour les voir ici",
"empty_hidden": "{{n}} photo(s) masquée(s) par votre préférence",
"empty_hidden_hint": "Désactivez \"Masquer les fichiers\" dans votre profil pour les voir.",
"items_selected": "sélectionnés",
"view_daily": "Jour",
"view_monthly": "Mois",
@@ -365,7 +367,15 @@
"folder": "Dossier",
"new_folder": "Nouveau dossier",
"share": "Partager",
"view": "Afficher"
"view": "Afficher",
"empty_hidden_title": "{{n}} élément(s) masqué(s) dans ce dossier",
"empty_hidden_hint": "Les fichiers dont le nom commence par '.' sont masqués. Modifiez le réglage pour les afficher.",
"show_hidden": "Afficher les fichiers masqués",
"upload_dotfile_hidden": "{{n}} fichier(s) téléversé(s) mais masqué(s) par votre préférence.",
"rename_dotfile_hidden": "Renommé en \"{{name}}\" — désormais masqué par votre préférence.",
"new_folder_dotfile_hidden": "Dossier \"{{name}}\" créé — masqué par votre préférence.",
"dotfiles_hidden_toast": "Fichiers masqués",
"dotfiles_shown_toast": "Fichiers affichés"
},
"dialogs": {
"rename_folder": "Renommer le dossier",
@@ -570,6 +580,8 @@
"accessed": "Consulté",
"empty_state": "Aucun fichier récent",
"empty_hint": "Les fichiers que vous ouvrez apparaîtront ici",
"empty_hidden_state": "{{n}} élément(s) récent(s) masqué(s) par votre préférence",
"empty_hidden_hint": "Désactivez \"Masquer les fichiers\" dans votre profil pour les voir.",
"loadMore": "Charger plus"
},
"notifications": {
@@ -879,6 +891,7 @@
"family_name": "Nom",
"notify_on_share": "M'avertir par e-mail quand quelqu'un partage avec moi",
"notify_on_share_hint": "Lorsque décoché, les partages apparaissent toujours dans votre compte — vous ne recevrez simplement pas d'e-mail à leur sujet.",
"hide_dotfiles": "Masquer les fichiers dont le nom commence par un point (.env, .git, …)",
"save_profile": "Enregistrer",
"profile_saved": "Profil mis à jour",
"profile_no_changes": "Aucun changement à enregistrer.",
@@ -999,7 +1012,17 @@
"notifyRateLimited": "Trop de notifications pour ce destinataire — réessayez plus tard.",
"removeAccess": "Retirer l'accès",
"resendInvitation": "Renvoyer l'e-mail d'invitation",
"publicLinks": "Public links"
"publicLinks": "Public links",
"filter": {
"button": "Types",
"title": "Filtrer par type",
"files": "Fichiers",
"folders": "Dossiers",
"drives": "Lecteurs",
"emptyTitle": "Aucun partage ne correspond au filtre actuel",
"emptyHint": "Ajustez le filtre de type ou réinitialisez-le à sa valeur par défaut (Fichiers + Dossiers).",
"reset": "Réinitialiser le filtre"
}
},
"sort": {
"asc": "croissant",
@@ -1129,5 +1152,8 @@
"view": {
"grid": "Vue en grille",
"list": "Vue en liste"
},
"preferences": {
"save_failed": "Impossible d'enregistrer votre préférence. Veuillez réessayer."
}
}
+28 -2
View File
@@ -58,6 +58,8 @@
"photos": {
"empty_state": "अभी कोई फ़ोटो नहीं",
"empty_hint": "यहाँ देखने के लिए चित्र या वीडियो अपलोड करें",
"empty_hidden": "आपकी वरीयता के अनुसार {{n}} फ़ोटो छिपी हुई हैं",
"empty_hidden_hint": "उन्हें देखने के लिए अपनी प्रोफ़ाइल में \"छिपी फ़ाइलें छिपाएँ\" को बंद करें।",
"items_selected": "चयनित",
"view_daily": "दिन",
"view_monthly": "महीना",
@@ -365,7 +367,15 @@
"folder": "फ़ोल्डर",
"new_folder": "नया फ़ोल्डर",
"share": "साझा करें",
"view": "देखें"
"view": "देखें",
"empty_hidden_title": "इस फ़ोल्डर में {{n}} छिपे हुए आइटम",
"empty_hidden_hint": "'.' से शुरू होने वाले फ़ाइल नाम छिपे हैं। उन्हें देखने के लिए सेटिंग बदलें।",
"show_hidden": "छिपी फ़ाइलें दिखाएँ",
"upload_dotfile_hidden": "{{n}} फ़ाइल(ें) अपलोड की गईं लेकिन आपकी वरीयता के अनुसार छिपी हुई हैं।",
"rename_dotfile_hidden": "\"{{name}}\" में नाम बदला — अब आपकी वरीयता के अनुसार छिपा हुआ है।",
"new_folder_dotfile_hidden": "फ़ोल्डर \"{{name}}\" बनाया गया — आपकी वरीयता के अनुसार छिपा हुआ है।",
"dotfiles_hidden_toast": "छिपी फ़ाइलें छिपाई गईं",
"dotfiles_shown_toast": "छिपी फ़ाइलें दिखाई गईं"
},
"dialogs": {
"rename_folder": "फ़ोल्डर का नाम बदलें",
@@ -570,6 +580,8 @@
"accessed": "एक्सेस किया",
"empty_state": "कोई हाल की फ़ाइलें नहीं",
"empty_hint": "जो फ़ाइलें आप खोलेंगे वे यहाँ दिखेंगी",
"empty_hidden_state": "आपकी वरीयता के अनुसार {{n}} हाल की वस्तुएँ छिपी हुई हैं",
"empty_hidden_hint": "उन्हें देखने के लिए अपनी प्रोफ़ाइल में \"छिपी फ़ाइलें छिपाएँ\" को बंद करें।",
"loadMore": "और लोड करें"
},
"notifications": {
@@ -879,6 +891,7 @@
"family_name": "अंतिम नाम",
"notify_on_share": "जब कोई मेरे साथ साझा करे तो मुझे ईमेल भेजें",
"notify_on_share_hint": "जब अनचेक किया जाए, तो साझाकरण आपके खाते में दिखाई देते रहेंगे — आपको बस उनके बारे में ईमेल नहीं मिलेगा।",
"hide_dotfiles": "उन फ़ाइलों को छिपाएँ जिनका नाम बिंदु से शुरू होता है (.env, .git, …)",
"save_profile": "परिवर्तन सहेजें",
"profile_saved": "प्रोफ़ाइल अद्यतन की गई",
"profile_no_changes": "सहेजने के लिए कोई परिवर्तन नहीं।",
@@ -999,7 +1012,17 @@
"notifyRateLimited": "इस प्राप्तकर्ता के लिए बहुत अधिक सूचनाएँ — बाद में पुनः प्रयास करें।",
"removeAccess": "पहुँच हटाएँ",
"resendInvitation": "आमंत्रण ईमेल पुनः भेजें",
"publicLinks": "Public links"
"publicLinks": "Public links",
"filter": {
"button": "प्रकार",
"title": "प्रकार के अनुसार फ़िल्टर करें",
"files": "फ़ाइलें",
"folders": "फ़ोल्डर",
"drives": "ड्राइव",
"emptyTitle": "कोई साझा वर्तमान फ़िल्टर से मेल नहीं खाता",
"emptyHint": "प्रकार फ़िल्टर समायोजित करें या इसे डिफ़ॉल्ट (फ़ाइलें + फ़ोल्डर) पर पुनः सेट करें।",
"reset": "फ़िल्टर रीसेट करें"
}
},
"sort": {
"asc": "ascending",
@@ -1129,5 +1152,8 @@
"view": {
"grid": "ग्रिड दृश्य",
"list": "सूची दृश्य"
},
"preferences": {
"save_failed": "आपकी वरीयता सहेजी नहीं जा सकी। कृपया पुनः प्रयास करें।"
}
}
+28 -2
View File
@@ -58,6 +58,8 @@
"photos": {
"empty_state": "Nessuna foto ancora",
"empty_hint": "Carica immagini o video per vederli qui",
"empty_hidden": "{{n}} foto nascosta/e dalla tua preferenza",
"empty_hidden_hint": "Disattiva \"Nascondi i file nascosti\" nel tuo profilo per vederle.",
"items_selected": "selezionati",
"view_daily": "Giorno",
"view_monthly": "Mese",
@@ -365,7 +367,15 @@
"folder": "Cartella",
"new_folder": "Nuova cartella",
"share": "Condividi",
"view": "Visualizza"
"view": "Visualizza",
"empty_hidden_title": "{{n}} elemento/i nascosto/i in questa cartella",
"empty_hidden_hint": "I file il cui nome inizia con '.' sono nascosti. Cambia l'impostazione per vederli.",
"show_hidden": "Mostra i file nascosti",
"upload_dotfile_hidden": "{{n}} file caricato/i ma nascosto/i dalla tua preferenza.",
"rename_dotfile_hidden": "Rinominato in \"{{name}}\" — ora nascosto dalla tua preferenza.",
"new_folder_dotfile_hidden": "Cartella \"{{name}}\" creata — nascosta dalla tua preferenza.",
"dotfiles_hidden_toast": "File nascosti occultati",
"dotfiles_shown_toast": "File nascosti mostrati"
},
"dialogs": {
"rename_folder": "Rinomina cartella",
@@ -570,6 +580,8 @@
"accessed": "Accesso",
"empty_state": "Nessun file recente",
"empty_hint": "I file che apri appariranno qui",
"empty_hidden_state": "{{n}} elemento/i recente/i nascosto/i dalla tua preferenza",
"empty_hidden_hint": "Disattiva \"Nascondi i file nascosti\" nel tuo profilo per vederli.",
"loadMore": "Carica altri"
},
"notifications": {
@@ -879,6 +891,7 @@
"family_name": "Cognome",
"notify_on_share": "Avvisami via email quando qualcuno condivide con me",
"notify_on_share_hint": "Se deselezionato, le condivisioni continueranno ad apparire nel tuo account — semplicemente non riceverai un'email a riguardo.",
"hide_dotfiles": "Nascondi i file il cui nome inizia con un punto (.env, .git, …)",
"save_profile": "Salva modifiche",
"profile_saved": "Profilo aggiornato",
"profile_no_changes": "Nessuna modifica da salvare.",
@@ -999,7 +1012,17 @@
"notifyRateLimited": "Troppe notifiche per questo destinatario — riprova più tardi.",
"removeAccess": "Rimuovi accesso",
"resendInvitation": "Reinvia email di invito",
"publicLinks": "Link pubblici"
"publicLinks": "Link pubblici",
"filter": {
"button": "Tipi",
"title": "Filtra per tipo",
"files": "File",
"folders": "Cartelle",
"drives": "Unità",
"emptyTitle": "Nessun elemento condiviso corrisponde al filtro attuale",
"emptyHint": "Modifica il filtro per tipo o reimpostalo al valore predefinito (File + Cartelle).",
"reset": "Reimposta filtro"
}
},
"sort": {
"asc": "crescente",
@@ -1129,5 +1152,8 @@
"view": {
"grid": "Visualizzazione griglia",
"list": "Visualizzazione elenco"
},
"preferences": {
"save_failed": "Impossibile salvare la preferenza. Riprova."
}
}
+28 -2
View File
@@ -58,6 +58,8 @@
"photos": {
"empty_state": "写真はまだありません",
"empty_hint": "画像や動画をアップロードするとここに表示されます",
"empty_hidden": "設定により非表示になっている写真が {{n}} 件あります",
"empty_hidden_hint": "プロフィールで「非表示ファイルを隠す」をオフにすると表示されます。",
"items_selected": "件選択中",
"view_daily": "日",
"view_monthly": "月",
@@ -365,7 +367,15 @@
"folder": "フォルダ",
"new_folder": "新しいフォルダ",
"share": "共有",
"view": "表示"
"view": "表示",
"empty_hidden_title": "このフォルダに非表示の項目が {{n}} 件あります",
"empty_hidden_hint": "名前が '.' で始まるファイルは非表示です。設定を切り替えると表示できます。",
"show_hidden": "非表示のファイルを表示",
"upload_dotfile_hidden": "{{n}} 個のファイルをアップロードしましたが、設定により非表示になっています。",
"rename_dotfile_hidden": "「{{name}}」に名前を変更しました — 設定により非表示になりました。",
"new_folder_dotfile_hidden": "フォルダ「{{name}}」を作成しました — 設定により非表示になっています。",
"dotfiles_hidden_toast": "非表示ファイルを隠しました",
"dotfiles_shown_toast": "非表示ファイルを表示しました"
},
"dialogs": {
"rename_folder": "フォルダ名を変更",
@@ -570,6 +580,8 @@
"accessed": "アクセス日",
"empty_state": "最近のファイルはありません",
"empty_hint": "開いたファイルがここに表示されます",
"empty_hidden_state": "設定により非表示になっている最近の項目が {{n}} 件あります",
"empty_hidden_hint": "プロフィールで「非表示ファイルを隠す」をオフにすると表示されます。",
"loadMore": "さらに読み込む"
},
"notifications": {
@@ -879,6 +891,7 @@
"family_name": "姓",
"notify_on_share": "誰かが共有したときにメールで通知する",
"notify_on_share_hint": "チェックを外しても、共有はアカウントに表示されますが、メールでの通知は届きません。",
"hide_dotfiles": "名前がドットで始まるファイルを非表示にする(.env、.git、…)",
"save_profile": "変更を保存",
"profile_saved": "プロフィールを更新しました",
"profile_no_changes": "保存する変更はありません。",
@@ -999,7 +1012,17 @@
"notifyRateLimited": "この受信者への通知が多すぎます — しばらくしてから再試行してください。",
"removeAccess": "アクセスを削除",
"resendInvitation": "招待メールを再送信",
"publicLinks": "Public links"
"publicLinks": "Public links",
"filter": {
"button": "種類",
"title": "種類でフィルタ",
"files": "ファイル",
"folders": "フォルダ",
"drives": "ドライブ",
"emptyTitle": "現在のフィルタに一致する共有はありません",
"emptyHint": "種類フィルタを調整するか、既定 (ファイル + フォルダ) にリセットしてください。",
"reset": "フィルタをリセット"
}
},
"sort": {
"asc": "ascending",
@@ -1129,5 +1152,8 @@
"view": {
"grid": "グリッド表示",
"list": "リスト表示"
},
"preferences": {
"save_failed": "設定を保存できませんでした。もう一度お試しください。"
}
}
+28 -2
View File
@@ -60,6 +60,8 @@
"photos": {
"empty_state": "아직 사진이 없습니다",
"empty_hint": "이미지나 동영상을 업로드하면 여기에 표시됩니다",
"empty_hidden": "설정에 따라 숨겨진 사진 {{n}}개",
"empty_hidden_hint": "프로필에서 \"숨겨진 파일 숨기기\"를 끄면 볼 수 있습니다.",
"items_selected": "개 선택됨",
"view_daily": "일",
"view_monthly": "월",
@@ -483,7 +485,15 @@
"upload_failed": "업로드 실패",
"uploading": "업로드 중…",
"uploading_file": "{{name}} 업로드 중…",
"uploading_n": "파일 업로드 중 {{done}}/{{total}}…"
"uploading_n": "파일 업로드 중 {{done}}/{{total}}…",
"empty_hidden_title": "이 폴더에 숨겨진 항목 {{n}}개",
"empty_hidden_hint": "이름이 '.' 로 시작하는 파일은 숨겨져 있습니다. 설정을 변경하면 표시됩니다.",
"show_hidden": "숨겨진 파일 표시",
"upload_dotfile_hidden": "파일 {{n}}개를 업로드했지만 설정에 따라 숨겨졌습니다.",
"rename_dotfile_hidden": "\"{{name}}\"(으)로 이름이 변경되었습니다 — 이제 설정에 따라 숨겨졌습니다.",
"new_folder_dotfile_hidden": "폴더 \"{{name}}\"을(를) 생성했습니다 — 설정에 따라 숨겨졌습니다.",
"dotfiles_hidden_toast": "숨겨진 파일 숨김",
"dotfiles_shown_toast": "숨겨진 파일 표시"
},
"dialogs": {
"rename_folder": "폴더 이름 변경",
@@ -703,6 +713,8 @@
"accessed": "접근일",
"empty_state": "최근 파일이 없습니다",
"empty_hint": "열어본 파일이 여기에 표시됩니다",
"empty_hidden_state": "설정에 따라 숨겨진 최근 항목 {{n}}개",
"empty_hidden_hint": "프로필에서 \"숨겨진 파일 숨기기\"를 끄면 볼 수 있습니다.",
"loadMore": "더 불러오기",
"confirm_clear": "최근 항목을 지우시겠습니까?"
},
@@ -1139,6 +1151,7 @@
"family_name": "성",
"notify_on_share": "다른 사람이 나에게 공유할 때 이메일로 알림 받기",
"notify_on_share_hint": "선택을 해제해도 공유 항목은 계정에 계속 표시되지만, 이메일 알림은 받지 않습니다.",
"hide_dotfiles": "이름이 점으로 시작하는 파일 숨기기 (.env, .git, …)",
"save_profile": "변경 사항 저장",
"profile_saved": "프로필이 업데이트되었습니다",
"profile_no_changes": "저장할 변경 사항이 없습니다.",
@@ -1275,7 +1288,17 @@
"emptyStateTitle": "아직 공유한 항목이 없습니다",
"manageAccess": "접근 권한 관리",
"notifySent": "알림이 전송되었습니다.",
"passwordLinks": "비밀번호로 보호된 링크"
"passwordLinks": "비밀번호로 보호된 링크",
"filter": {
"button": "종류",
"title": "종류로 필터링",
"files": "파일",
"folders": "폴더",
"drives": "드라이브",
"emptyTitle": "현재 필터와 일치하는 공유가 없습니다",
"emptyHint": "종류 필터를 조정하거나 기본값 (파일 + 폴더) 으로 재설정하세요.",
"reset": "필터 재설정"
}
},
"sort": {
"asc": "ascending",
@@ -1526,5 +1549,8 @@
},
"sortdir": {
"title": "정렬 방향"
},
"preferences": {
"save_failed": "환경설정을 저장할 수 없습니다. 다시 시도하세요."
}
}
+28 -2
View File
@@ -58,6 +58,8 @@
"photos": {
"empty_state": "Nog geen foto's",
"empty_hint": "Upload afbeeldingen of video's om ze hier te zien",
"empty_hidden": "{{n}} foto('s) verborgen door je voorkeur",
"empty_hidden_hint": "Schakel \"Verborgen bestanden verbergen\" uit in je profiel om ze te zien.",
"items_selected": "geselecteerd",
"view_daily": "Dag",
"view_monthly": "Maand",
@@ -365,7 +367,15 @@
"folder": "Map",
"new_folder": "Nieuwe map",
"share": "Delen",
"view": "Bekijken"
"view": "Bekijken",
"empty_hidden_title": "{{n}} verborgen item(s) in deze map",
"empty_hidden_hint": "Bestanden waarvan de naam met '.' begint, zijn verborgen. Wijzig de instelling om ze te zien.",
"show_hidden": "Verborgen bestanden weergeven",
"upload_dotfile_hidden": "{{n}} bestand(en) geüpload maar verborgen door je voorkeur.",
"rename_dotfile_hidden": "Hernoemd naar \"{{name}}\" — nu verborgen door je voorkeur.",
"new_folder_dotfile_hidden": "Map \"{{name}}\" aangemaakt — verborgen door je voorkeur.",
"dotfiles_hidden_toast": "Verborgen bestanden verborgen",
"dotfiles_shown_toast": "Verborgen bestanden weergegeven"
},
"dialogs": {
"rename_folder": "Map hernoemen",
@@ -570,6 +580,8 @@
"accessed": "Geopend",
"empty_state": "Geen recente bestanden",
"empty_hint": "Bestanden die je opent verschijnen hier",
"empty_hidden_state": "{{n}} recent(e) item(s) verborgen door je voorkeur",
"empty_hidden_hint": "Schakel \"Verborgen bestanden verbergen\" uit in je profiel om ze te zien.",
"loadMore": "Meer laden"
},
"notifications": {
@@ -879,6 +891,7 @@
"family_name": "Achternaam",
"notify_on_share": "Stuur me een e-mail wanneer iemand iets met mij deelt",
"notify_on_share_hint": "Wanneer uitgevinkt, verschijnen gedeelde items nog steeds in je account — je krijgt er alleen geen e-mail over.",
"hide_dotfiles": "Verberg bestanden waarvan de naam begint met een punt (.env, .git, …)",
"save_profile": "Wijzigingen opslaan",
"profile_saved": "Profiel bijgewerkt",
"profile_no_changes": "Geen wijzigingen om op te slaan.",
@@ -999,7 +1012,17 @@
"notifyRateLimited": "Te veel notificaties voor deze ontvanger — probeer het later opnieuw.",
"removeAccess": "Toegang verwijderen",
"resendInvitation": "Uitnodigingsmail opnieuw verzenden",
"publicLinks": "Public links"
"publicLinks": "Public links",
"filter": {
"button": "Soorten",
"title": "Filteren op soort",
"files": "Bestanden",
"folders": "Mappen",
"drives": "Schijven",
"emptyTitle": "Geen gedeelde items komen overeen met het huidige filter",
"emptyHint": "Pas het soortfilter aan of stel het opnieuw in op de standaard (Bestanden + Mappen).",
"reset": "Filter opnieuw instellen"
}
},
"sort": {
"asc": "ascending",
@@ -1129,5 +1152,8 @@
"view": {
"grid": "Rasterweergave",
"list": "Lijstweergave"
},
"preferences": {
"save_failed": "Kon je voorkeur niet opslaan. Probeer het opnieuw."
}
}
+28 -2
View File
@@ -58,6 +58,8 @@
"photos": {
"empty_state": "Brak zdjęć",
"empty_hint": "Prześlij obrazy lub filmy, aby zobaczyć je tutaj",
"empty_hidden": "{{n}} zdjęć ukrytych zgodnie z Twoją preferencją",
"empty_hidden_hint": "Wyłącz \"Ukryj ukryte pliki\" w swoim profilu, aby je zobaczyć.",
"items_selected": "wybrane",
"view_daily": "Dzień",
"view_monthly": "Miesiąc",
@@ -365,7 +367,15 @@
"folder": "Folder",
"new_folder": "Nowy folder",
"share": "Udostępnij",
"view": "Pokaż"
"view": "Pokaż",
"empty_hidden_title": "{{n}} ukrytych elementów w tym folderze",
"empty_hidden_hint": "Pliki, których nazwa zaczyna się od '.', są ukryte. Zmień ustawienie, aby je zobaczyć.",
"show_hidden": "Pokaż ukryte pliki",
"upload_dotfile_hidden": "Przesłano {{n}} plik(ów), ale są ukryte zgodnie z Twoją preferencją.",
"rename_dotfile_hidden": "Zmieniono nazwę na \"{{name}}\" — teraz ukryty zgodnie z Twoją preferencją.",
"new_folder_dotfile_hidden": "Utworzono folder \"{{name}}\" — ukryty zgodnie z Twoją preferencją.",
"dotfiles_hidden_toast": "Ukryte pliki ukryte",
"dotfiles_shown_toast": "Ukryte pliki wyświetlone"
},
"dialogs": {
"rename_folder": "Zmień nazwę folderu",
@@ -570,6 +580,8 @@
"accessed": "Otwarte",
"empty_state": "Brak ostatnich plików",
"empty_hint": "Otwarte pliki pojawią się tutaj",
"empty_hidden_state": "{{n}} ostatnich elementów ukrytych zgodnie z Twoją preferencją",
"empty_hidden_hint": "Wyłącz \"Ukryj ukryte pliki\" w swoim profilu, aby je zobaczyć.",
"loadMore": "Załaduj więcej"
},
"notifications": {
@@ -879,6 +891,7 @@
"family_name": "Nazwisko",
"notify_on_share": "Wyślij mi e-mail, gdy ktoś coś mi udostępni",
"notify_on_share_hint": "Gdy odznaczone, udostępnienia nadal pojawiają się na Twoim koncie — po prostu nie otrzymasz o nich e-maila.",
"hide_dotfiles": "Ukryj pliki, których nazwa zaczyna się od kropki (.env, .git, …)",
"save_profile": "Zapisz zmiany",
"profile_saved": "Profil zaktualizowany",
"profile_no_changes": "Brak zmian do zapisania.",
@@ -999,7 +1012,17 @@
"notifyRateLimited": "Zbyt wiele powiadomień dla tego odbiorcy — spróbuj ponownie później.",
"removeAccess": "Usuń dostęp",
"resendInvitation": "Wyślij ponownie e-mail z zaproszeniem",
"publicLinks": "Public links"
"publicLinks": "Public links",
"filter": {
"button": "Rodzaje",
"title": "Filtruj według rodzaju",
"files": "Pliki",
"folders": "Foldery",
"drives": "Dyski",
"emptyTitle": "Żadne udostępnienie nie pasuje do bieżącego filtru",
"emptyHint": "Dostosuj filtr rodzaju lub przywróć wartość domyślną (Pliki + Foldery).",
"reset": "Resetuj filtr"
}
},
"sort": {
"asc": "ascending",
@@ -1129,5 +1152,8 @@
"view": {
"grid": "Widok siatki",
"list": "Widok listy"
},
"preferences": {
"save_failed": "Nie udało się zapisać ustawienia. Spróbuj ponownie."
}
}
+28 -2
View File
@@ -58,6 +58,8 @@
"photos": {
"empty_state": "Nenhuma foto ainda",
"empty_hint": "Envie imagens ou vídeos para vê-los aqui",
"empty_hidden": "{{n}} foto(s) oculta(s) pela sua preferência",
"empty_hidden_hint": "Desative \"Ocultar arquivos ocultos\" no seu perfil para vê-las.",
"items_selected": "selecionados",
"view_daily": "Dia",
"view_monthly": "Mês",
@@ -365,7 +367,15 @@
"folder": "Pasta",
"new_folder": "Nova pasta",
"share": "Compartilhar",
"view": "Visualizar"
"view": "Visualizar",
"empty_hidden_title": "{{n}} item(ns) oculto(s) nesta pasta",
"empty_hidden_hint": "Arquivos cujo nome começa com '.' estão ocultos. Altere a configuração para vê-los.",
"show_hidden": "Mostrar arquivos ocultos",
"upload_dotfile_hidden": "{{n}} arquivo(s) enviado(s) mas oculto(s) pela sua preferência.",
"rename_dotfile_hidden": "Renomeado para \"{{name}}\" — agora oculto pela sua preferência.",
"new_folder_dotfile_hidden": "Pasta \"{{name}}\" criada — oculta pela sua preferência.",
"dotfiles_hidden_toast": "Arquivos ocultos ocultados",
"dotfiles_shown_toast": "Arquivos ocultos exibidos"
},
"dialogs": {
"rename_folder": "Renomear pasta",
@@ -570,6 +580,8 @@
"accessed": "Acessado",
"empty_state": "Nenhum arquivo recente",
"empty_hint": "Os arquivos que você abrir aparecerão aqui",
"empty_hidden_state": "{{n}} item(ns) recente(s) oculto(s) pela sua preferência",
"empty_hidden_hint": "Desative \"Ocultar arquivos ocultos\" no seu perfil para vê-los.",
"loadMore": "Carregar mais"
},
"notifications": {
@@ -879,6 +891,7 @@
"family_name": "Sobrenome",
"notify_on_share": "Avisar-me por e-mail quando alguém compartilhar comigo",
"notify_on_share_hint": "Quando desmarcado, os compartilhamentos continuarão aparecendo na sua conta — você apenas não receberá um e-mail sobre eles.",
"hide_dotfiles": "Ocultar arquivos cujo nome começa com um ponto (.env, .git, …)",
"save_profile": "Salvar alterações",
"profile_saved": "Perfil atualizado",
"profile_no_changes": "Sem alterações para salvar.",
@@ -999,7 +1012,17 @@
"notifyRateLimited": "Demasiadas notificações para este destinatário — tente novamente mais tarde.",
"removeAccess": "Remover acesso",
"resendInvitation": "Reenviar e-mail de convite",
"publicLinks": "Public links"
"publicLinks": "Public links",
"filter": {
"button": "Tipos",
"title": "Filtrar por tipo",
"files": "Arquivos",
"folders": "Pastas",
"drives": "Unidades",
"emptyTitle": "Nenhum compartilhamento corresponde ao filtro atual",
"emptyHint": "Ajuste o filtro de tipo ou redefina-o para o padrão (Arquivos + Pastas).",
"reset": "Redefinir filtro"
}
},
"sort": {
"asc": "ascendente",
@@ -1129,5 +1152,8 @@
"view": {
"grid": "Visualização em grade",
"list": "Visualização em lista"
},
"preferences": {
"save_failed": "Não foi possível salvar sua preferência. Tente novamente."
}
}
+28 -2
View File
@@ -58,6 +58,8 @@
"photos": {
"empty_state": "Фотографий пока нет",
"empty_hint": "Загрузите изображения или видео, чтобы увидеть их здесь",
"empty_hidden": "Фотографий скрыто: {{n}}",
"empty_hidden_hint": "Отключите \"Скрывать скрытые файлы\" в профиле, чтобы увидеть их.",
"items_selected": "выбрано",
"view_daily": "День",
"view_monthly": "Месяц",
@@ -365,7 +367,15 @@
"folder": "Папка",
"new_folder": "Новая папка",
"share": "Поделиться",
"view": "Просмотр"
"view": "Просмотр",
"empty_hidden_title": "Скрытых элементов в этой папке: {{n}}",
"empty_hidden_hint": "Файлы, имя которых начинается с '.', скрыты. Измените настройку, чтобы их увидеть.",
"show_hidden": "Показать скрытые файлы",
"upload_dotfile_hidden": "Загружено файлов: {{n}}, но они скрыты в соответствии с вашими настройками.",
"rename_dotfile_hidden": "Переименовано в \"{{name}}\" — теперь скрыто в соответствии с вашими настройками.",
"new_folder_dotfile_hidden": "Папка \"{{name}}\" создана — скрыта в соответствии с вашими настройками.",
"dotfiles_hidden_toast": "Скрытые файлы скрыты",
"dotfiles_shown_toast": "Скрытые файлы показаны"
},
"dialogs": {
"rename_folder": "Переименовать папку",
@@ -570,6 +580,8 @@
"accessed": "Открыт",
"empty_state": "Нет недавних файлов",
"empty_hint": "Открытые вами файлы будут отображаться здесь",
"empty_hidden_state": "Недавних элементов скрыто: {{n}}",
"empty_hidden_hint": "Отключите \"Скрывать скрытые файлы\" в профиле, чтобы увидеть их.",
"loadMore": "Загрузить ещё"
},
"notifications": {
@@ -879,6 +891,7 @@
"family_name": "Фамилия",
"notify_on_share": "Уведомлять меня по электронной почте, когда кто-то делится со мной",
"notify_on_share_hint": "Если флажок снят, общие ресурсы по-прежнему будут отображаться в вашей учётной записи — вы просто не будете получать о них письма.",
"hide_dotfiles": "Скрывать файлы, имя которых начинается с точки (.env, .git, …)",
"save_profile": "Сохранить изменения",
"profile_saved": "Профиль обновлён",
"profile_no_changes": "Нет изменений для сохранения.",
@@ -999,7 +1012,17 @@
"notifyRateLimited": "Слишком много уведомлений для этого получателя — попробуйте позже.",
"removeAccess": "Отозвать доступ",
"resendInvitation": "Отправить приглашение повторно",
"publicLinks": "Public links"
"publicLinks": "Public links",
"filter": {
"button": "Типы",
"title": "Фильтр по типу",
"files": "Файлы",
"folders": "Папки",
"drives": "Диски",
"emptyTitle": "Ни один общий доступ не соответствует текущему фильтру",
"emptyHint": "Настройте фильтр по типу или сбросьте его на значение по умолчанию (Файлы + Папки).",
"reset": "Сбросить фильтр"
}
},
"sort": {
"asc": "ascending",
@@ -1129,5 +1152,8 @@
"view": {
"grid": "Сетка",
"list": "Список"
},
"preferences": {
"save_failed": "Не удалось сохранить настройку. Повторите попытку."
}
}
+28 -2
View File
@@ -58,6 +58,8 @@
"photos": {
"empty_state": "還沒有照片",
"empty_hint": "上傳圖片或影片即可在此檢視",
"empty_hidden": "根據您的偏好隱藏了 {{n}} 張相片",
"empty_hidden_hint": "在個人資料中關閉「隱藏隱藏檔案」即可查看。",
"items_selected": "已選擇",
"view_daily": "日",
"view_monthly": "月",
@@ -365,7 +367,15 @@
"folder": "資料夾",
"new_folder": "新建資料夾",
"share": "分享",
"view": "檢視"
"view": "檢視",
"empty_hidden_title": "此資料夾中有 {{n}} 個隱藏項目",
"empty_hidden_hint": "以 '.' 開頭的檔案被隱藏。切換設定即可顯示。",
"show_hidden": "顯示隱藏檔案",
"upload_dotfile_hidden": "已上傳 {{n}} 個檔案,但已根據您的偏好隱藏。",
"rename_dotfile_hidden": "已重新命名為「{{name}}」——現已根據您的偏好隱藏。",
"new_folder_dotfile_hidden": "已建立資料夾「{{name}}」——根據您的偏好隱藏。",
"dotfiles_hidden_toast": "已隱藏隱藏檔案",
"dotfiles_shown_toast": "已顯示隱藏檔案"
},
"dialogs": {
"rename_folder": "重新命名資料夾",
@@ -570,6 +580,8 @@
"accessed": "訪問於",
"empty_state": "沒有最近檔案",
"empty_hint": "您開啟的檔案將顯示在這裡",
"empty_hidden_state": "根據您的偏好隱藏了 {{n}} 個最近項目",
"empty_hidden_hint": "在個人資料中關閉「隱藏隱藏檔案」即可查看。",
"loadMore": "載入更多"
},
"batch": {
@@ -862,6 +874,7 @@
"family_name": "姓",
"notify_on_share": "當有人與我分享時透過電子郵件通知我",
"notify_on_share_hint": "取消勾選後,分享項目仍會顯示在您的帳戶中 — 只是不會收到相關郵件通知。",
"hide_dotfiles": "隱藏名稱以點開頭的檔案(.env、.git 等)",
"save_profile": "儲存變更",
"profile_saved": "個人資料已更新",
"profile_no_changes": "沒有變更可儲存。",
@@ -999,7 +1012,17 @@
"notifyRateLimited": "對此收件者的通知過多 — 請稍後重試。",
"removeAccess": "移除存取權限",
"resendInvitation": "重新傳送邀請郵件",
"publicLinks": "Public links"
"publicLinks": "Public links",
"filter": {
"button": "類型",
"title": "依類型篩選",
"files": "檔案",
"folders": "資料夾",
"drives": "磁碟機",
"emptyTitle": "沒有分享項目符合目前的篩選",
"emptyHint": "調整類型篩選或將其重設為預設 (檔案 + 資料夾)。",
"reset": "重設篩選"
}
},
"sort": {
"asc": "ascending",
@@ -1129,5 +1152,8 @@
"view": {
"grid": "網格檢視",
"list": "列表檢視"
},
"preferences": {
"save_failed": "無法儲存偏好設定。請再試一次。"
}
}
+28 -2
View File
@@ -58,6 +58,8 @@
"photos": {
"empty_state": "还没有照片",
"empty_hint": "上传图片或视频即可在此查看",
"empty_hidden": "根据您的偏好隐藏了 {{n}} 张照片",
"empty_hidden_hint": "在个人资料中关闭「隐藏隐藏文件」即可查看。",
"items_selected": "已选择",
"view_daily": "日",
"view_monthly": "月",
@@ -365,7 +367,15 @@
"folder": "文件夹",
"new_folder": "新建文件夹",
"share": "分享",
"view": "查看"
"view": "查看",
"empty_hidden_title": "此文件夹中有 {{n}} 个隐藏项",
"empty_hidden_hint": "以 '.' 开头的文件被隐藏。切换设置即可显示。",
"show_hidden": "显示隐藏文件",
"upload_dotfile_hidden": "已上传 {{n}} 个文件,但已根据您的偏好隐藏。",
"rename_dotfile_hidden": "已重命名为「{{name}}」——现已根据您的偏好隐藏。",
"new_folder_dotfile_hidden": "已创建文件夹「{{name}}」——根据您的偏好隐藏。",
"dotfiles_hidden_toast": "已隐藏隐藏文件",
"dotfiles_shown_toast": "已显示隐藏文件"
},
"dialogs": {
"rename_folder": "重命名文件夹",
@@ -570,6 +580,8 @@
"accessed": "访问于",
"empty_state": "没有最近文件",
"empty_hint": "您打开的文件将显示在这里",
"empty_hidden_state": "根据您的偏好隐藏了 {{n}} 个最近项目",
"empty_hidden_hint": "在个人资料中关闭「隐藏隐藏文件」即可查看。",
"loadMore": "加载更多"
},
"batch": {
@@ -862,6 +874,7 @@
"family_name": "姓",
"notify_on_share": "当有人与我共享时通过电子邮件通知我",
"notify_on_share_hint": "取消勾选后,共享项目仍会显示在您的账户中 — 只是不会收到相关邮件通知。",
"hide_dotfiles": "隐藏名称以点开头的文件(.env、.git 等)",
"save_profile": "保存更改",
"profile_saved": "个人资料已更新",
"profile_no_changes": "无更改可保存。",
@@ -999,7 +1012,17 @@
"notifyRateLimited": "对此收件人的通知过多 — 请稍后重试。",
"removeAccess": "移除访问权限",
"resendInvitation": "重新发送邀请邮件",
"publicLinks": "Public links"
"publicLinks": "Public links",
"filter": {
"button": "类型",
"title": "按类型筛选",
"files": "文件",
"folders": "文件夹",
"drives": "驱动器",
"emptyTitle": "没有共享项符合当前筛选",
"emptyHint": "调整类型筛选或将其重置为默认 (文件 + 文件夹)。",
"reset": "重置筛选"
}
},
"sort": {
"asc": "ascending",
@@ -1129,5 +1152,8 @@
"view": {
"grid": "网格视图",
"list": "列表视图"
},
"preferences": {
"save_failed": "无法保存偏好设置。请重试。"
}
}
@@ -0,0 +1,41 @@
-- Add opaque UI preferences bag to auth.users.
--
-- Purpose. Cross-device persistence of pure UI toggles (hide dotfiles,
-- view mode, group-by choice, sidebar collapse, …). The server NEVER
-- inspects the contents — this column exists solely so that the SPA can
-- fetch its own settings from `GET /api/auth/me` on a fresh browser and
-- write them back via `PATCH /api/auth/me/profile`.
--
-- Design rule. Preferences that ONLY affect the UI live here.
-- Preferences the SERVER reads (locale for magic-link templates,
-- notify_on_share for the notification pipeline, role for authz) stay as
-- typed columns. When a UI-only preference graduates to server-relevant,
-- promote it to a column and drop the JSON key in a follow-up migration.
--
-- Merge semantics. `PATCH /api/auth/me/profile` performs a SHALLOW
-- merge via `ui_preferences || $1::jsonb` in `pg_user_repository.rs`,
-- optionally stripping nulls (frontend convention: sending `{key: null}`
-- clears the key). Full replacement isn't offered — every operation is
-- additive so a partial write from Device A doesn't wipe prefs set on
-- Device B.
--
-- Size cap. Enforced via CHECK constraint: 16 KiB compressed JSONB is
-- generous for realistic UI prefs and prevents the endpoint from being
-- used as a scratch key-value store. `pg_column_size(ui_preferences)`
-- returns the on-disk byte size which is what actually consumes rows.
ALTER TABLE auth.users
ADD COLUMN ui_preferences JSONB NOT NULL DEFAULT '{}'::jsonb;
-- Object shape only — arrays / scalars / null are rejected. The merge
-- semantics assume an object; a scalar in this column would break the
-- shallow-merge SQL. Cheap check (single jsonb_typeof call).
ALTER TABLE auth.users
ADD CONSTRAINT users_ui_preferences_is_object
CHECK (jsonb_typeof(ui_preferences) = 'object');
-- Size guard — 16 KiB is 16384 bytes. Realistic UI-toggle payloads are
-- well under 1 KiB; the cap exists to fence off misuse, not to be
-- tight.
ALTER TABLE auth.users
ADD CONSTRAINT users_ui_preferences_size_cap
CHECK (pg_column_size(ui_preferences) <= 16384);
+22
View File
@@ -61,6 +61,14 @@ pub struct UserDto {
/// could never claim the share. Round-trips through `/api/auth/me`
/// and `PATCH /api/auth/me/profile`.
pub notify_on_share: bool,
/// Opaque UI preferences bag. Cross-device store for pure UI
/// toggles (hide dotfiles, view mode, sidebar collapse, …). The
/// server never inspects the contents — this DTO field just echoes
/// what was PATCHed via `PATCH /api/auth/me/profile`. Shape is a
/// JSON object; the frontend defines the keys it cares about (see
/// `frontend/src/lib/stores/preferences.svelte.ts`). Always present
/// on the wire; empty bag is `{}`, never `null`.
pub ui_preferences: serde_json::Value,
}
impl From<User> for UserDto {
@@ -85,6 +93,7 @@ impl From<User> for UserDto {
email_verified_at: user.email_verified_at(),
preferred_locale: user.preferred_locale().map(str::to_string),
notify_on_share: user.notify_on_share(),
ui_preferences: user.ui_preferences().clone(),
}
}
}
@@ -185,6 +194,19 @@ pub struct UpdateProfileDto {
/// always send.
#[serde(default)]
pub notify_on_share: Option<bool>,
/// Partial patch into the opaque UI preferences bag. **Must be a
/// JSON object.** Applied via a SHALLOW merge on the server:
/// keys present here overwrite existing top-level keys; keys not
/// present survive. A key value of `null` REMOVES that key from
/// the bag (implemented via `jsonb_strip_nulls` after the merge).
///
/// Example: current bag `{"a":1,"b":2}`, patch `{"b":3,"c":4}`
/// → merged `{"a":1,"b":3,"c":4}`. Patch `{"a":null}` → `{"b":2}`.
///
/// Absent → no change to the bag. This is a UI-only surface;
/// server never inspects the keys.
#[serde(default)]
pub ui_preferences: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
@@ -1348,12 +1348,51 @@ impl AuthApplicationService {
changed.push("notify_on_share");
}
if changed.is_empty() {
// ── UI preferences shallow-merge ──────────────────────────
// The other fields above modify the in-memory `user` and land
// via `update_user(user)` at the end. UI preferences take a
// different path because the merge has to happen at write
// time in SQL — two devices PATCH'ing partial patches
// concurrently would otherwise race and clobber each other if
// we did merge-then-write in application code. See
// `UserPgRepository::update_ui_preferences` for the SQL.
//
// Boundary validation only: shape must be a JSON object.
// Contents are opaque to the server — no key inspection here.
// Size cap is enforced by the schema CHECK constraint; a
// violating merge surfaces as a repo error.
let ui_prefs_patch = if let Some(patch) = dto.ui_preferences.as_ref() {
if !patch.is_object() {
return Err(DomainError::validation_error(
"ui_preferences must be a JSON object".to_string(),
));
}
Some(patch.clone())
} else {
None
};
if changed.is_empty() && ui_prefs_patch.is_none() {
// No-op — return the current user without a DB write.
return Ok(UserDto::from(user));
}
let updated = self.user_storage.update_user(user).await?;
// Persist the typed-field changes first (if any). Skip the
// `update_user` call entirely when only `ui_preferences`
// changed — the shallow-merge SQL below is authoritative for
// that field, and running `update_user` unnecessarily would
// rewrite every column with its current in-memory value.
if !changed.is_empty() {
self.user_storage.update_user(user).await?;
}
if let Some(patch) = ui_prefs_patch {
self.user_storage
.update_ui_preferences(caller_id, &patch)
.await?;
changed.push("ui_preferences");
}
tracing::info!(
target: "audit",
event = "auth.profile_updated",
@@ -1362,7 +1401,11 @@ impl AuthApplicationService {
"👤 profile updated for {}",
caller_id,
);
Ok(UserDto::from(updated))
// Refetch so the returned DTO reflects the merged JSONB bag
// (the in-memory `user` above holds the pre-merge value).
let refreshed = self.user_storage.get_user_by_id(caller_id).await?;
Ok(UserDto::from(refreshed))
}
// Alias for consistency with handler method
+40
View File
@@ -107,6 +107,24 @@ pub struct User {
/// and opts out, subsequent shares from other granters honor the
/// flag.
notify_on_share: bool,
/// Opaque UI preferences bag (PR — this session). Stored as JSONB
/// on `auth.users.ui_preferences`; the server NEVER inspects the
/// contents. This is the SPA's cross-device backing store for pure
/// UI toggles (hide-dotfiles, view mode, sidebar collapse, …).
///
/// Merge semantics live in the repo layer: `PATCH /me/profile` does
/// a SHALLOW merge via `ui_preferences || $1::jsonb`, so partial
/// writes from one device don't clobber keys set on another.
///
/// Load-bearing rule: if a preference EVER becomes something the
/// server reads (like `preferred_locale` did), promote it out of
/// this bag into a typed column. Keep this field for UI-only
/// toggles.
///
/// Invariant: always a JSON object (enforced by the schema CHECK
/// `users_ui_preferences_is_object`). Empty bag is `{}`, never
/// `null` or missing.
ui_preferences: serde_json::Value,
}
impl User {
@@ -205,6 +223,11 @@ impl User {
// `users_notify_on_share` mirrors this for rows reconstructed
// from disk without going through `new`.
notify_on_share: true,
// Empty bag on creation. The SPA writes into it via
// `PATCH /me/profile { ui_preferences: {...} }` after
// login. Never NULL — the DB CHECK enforces JSON object
// shape.
ui_preferences: serde_json::json!({}),
})
}
@@ -249,6 +272,7 @@ impl User {
email_verified_at: None,
preferred_locale: None,
notify_on_share: true,
ui_preferences: serde_json::json!({}),
}
}
@@ -274,6 +298,10 @@ impl User {
email_verified_at: Option<DateTime<Utc>>,
preferred_locale: Option<String>,
notify_on_share: bool,
// Opaque UI-preferences bag. Callers reading from the DB pass
// `row.get("ui_preferences")`; tests that don't care can pass
// `serde_json::json!({})`.
ui_preferences: serde_json::Value,
) -> Self {
Self {
id,
@@ -296,6 +324,7 @@ impl User {
email_verified_at,
preferred_locale,
notify_on_share,
ui_preferences,
}
}
@@ -536,6 +565,16 @@ impl User {
self.updated_at = Utc::now();
}
/// Opaque UI preferences bag. Read-only accessor for the DTO
/// conversion; mutation goes through the repo's shallow-merge SQL
/// (`UserPgRepository::update_ui_preferences`) rather than a
/// setter here — the DB is authoritative on the merged state
/// because two devices can PATCH concurrently and the merge has
/// to happen at write time, not at read time.
pub fn ui_preferences(&self) -> &serde_json::Value {
&self.ui_preferences
}
/// Claim or change the username. Runs the same validation as the
/// constructor — callers must still ensure uniqueness at the repo
/// level. Bumps `updated_at`. Used by the post-create profile-edit
@@ -722,6 +761,7 @@ mod tests {
None,
None,
true,
serde_json::json!({}),
)
}
@@ -106,6 +106,48 @@ impl UserPgRepository {
.map_err(Self::map_sqlx_error)?;
Ok(())
}
/// Shallow-merge a partial UI-preferences patch into
/// `ui_preferences`. The Postgres `||` operator merges top-level
/// keys — `{"a":1,"b":2} || {"b":3,"c":4}` → `{"a":1,"b":3,"c":4}`,
/// which is exactly the semantic PATCH callers want: a partial
/// write only touches the keys it mentions, so a preference set on
/// one device isn't wiped by a partial write from another.
///
/// `jsonb_strip_nulls` removes any key whose incoming value is
/// null, giving callers a documented delete-a-key path (`PATCH
/// {"foo": null}` clears `foo`). Nested nulls inside a value
/// object survive — we only strip at the top level via the merge
/// result.
///
/// Not part of the `UserRepository` trait — called directly from
/// `AuthApplicationService::update_profile`. Bumps `updated_at`
/// so the standard "when did this row change" audits stay useful.
///
/// The CHECK constraints
/// (`users_ui_preferences_is_object` + `_size_cap`) enforce shape
/// and cap at the schema layer; a violating patch surfaces as an
/// sqlx error and returns to the handler as 400.
pub async fn update_ui_preferences(
&self,
user_id: Uuid,
patch: &serde_json::Value,
) -> UserRepositoryResult<()> {
sqlx::query(
r#"
UPDATE auth.users
SET ui_preferences = jsonb_strip_nulls(ui_preferences || $2::jsonb),
updated_at = NOW()
WHERE id = $1
"#,
)
.bind(user_id)
.bind(patch)
.execute(&*self.pool)
.await
.map_err(Self::map_sqlx_error)?;
Ok(())
}
}
impl UserRepository for UserPgRepository {
@@ -138,10 +180,10 @@ impl UserRepository for UserPgRepository {
created_at, updated_at, last_login_at, active,
oidc_provider, oidc_subject, image, is_external,
given_name, family_name, email_verified_at,
preferred_locale, notify_on_share
preferred_locale, notify_on_share, ui_preferences
) VALUES (
$1, $2, $3, $4, $5::auth.userrole, $6, $7, $8, $9, $10, $11,
$12, $13, $14, $15, $16, $17, $18, $19, $20
$12, $13, $14, $15, $16, $17, $18, $19, $20, $21
)
RETURNING *
"#,
@@ -166,6 +208,10 @@ impl UserRepository for UserPgRepository {
.bind(user_clone.email_verified_at())
.bind(user_clone.preferred_locale())
.bind(user_clone.notify_on_share())
// ui_preferences bind: always a JSON object. `User::new`
// initialises the bag to `{}`; ownership stays with the
// repo for shallow-merge writes via `update_ui_preferences`.
.bind(user_clone.ui_preferences())
.execute(&mut **tx)
.await
.map_err(Self::map_sqlx_error)?;
@@ -190,7 +236,8 @@ impl UserRepository for UserPgRepository {
storage_quota_bytes, storage_used_bytes,
created_at, updated_at, last_login_at, active,
oidc_provider, oidc_subject, image, is_external,
given_name, family_name, email_verified_at, preferred_locale, notify_on_share
given_name, family_name, email_verified_at, preferred_locale, notify_on_share,
ui_preferences
FROM auth.users
WHERE id = $1
"#,
@@ -228,6 +275,7 @@ impl UserRepository for UserPgRepository {
row.get("email_verified_at"),
row.get("preferred_locale"),
row.get("notify_on_share"),
row.get::<serde_json::Value, _>("ui_preferences"),
))
}
@@ -240,7 +288,8 @@ impl UserRepository for UserPgRepository {
storage_quota_bytes, storage_used_bytes,
created_at, updated_at, last_login_at, active,
oidc_provider, oidc_subject, image, is_external,
given_name, family_name, email_verified_at, preferred_locale, notify_on_share
given_name, family_name, email_verified_at, preferred_locale, notify_on_share,
ui_preferences
FROM auth.users
WHERE username = $1
"#,
@@ -278,6 +327,7 @@ impl UserRepository for UserPgRepository {
row.get("email_verified_at"),
row.get("preferred_locale"),
row.get("notify_on_share"),
row.get::<serde_json::Value, _>("ui_preferences"),
))
}
@@ -290,7 +340,8 @@ impl UserRepository for UserPgRepository {
storage_quota_bytes, storage_used_bytes,
created_at, updated_at, last_login_at, active,
oidc_provider, oidc_subject, image, is_external,
given_name, family_name, email_verified_at, preferred_locale, notify_on_share
given_name, family_name, email_verified_at, preferred_locale, notify_on_share,
ui_preferences
FROM auth.users
WHERE email = $1
"#,
@@ -328,6 +379,7 @@ impl UserRepository for UserPgRepository {
row.get("email_verified_at"),
row.get("preferred_locale"),
row.get("notify_on_share"),
row.get::<serde_json::Value, _>("ui_preferences"),
))
}
@@ -347,7 +399,8 @@ impl UserRepository for UserPgRepository {
storage_quota_bytes, storage_used_bytes,
created_at, updated_at, last_login_at, active,
oidc_provider, oidc_subject, image, is_external,
given_name, family_name, email_verified_at, preferred_locale, notify_on_share
given_name, family_name, email_verified_at, preferred_locale, notify_on_share,
ui_preferences
FROM auth.users
WHERE id = ANY($1)
"#,
@@ -387,6 +440,7 @@ impl UserRepository for UserPgRepository {
row.get("email_verified_at"),
row.get("preferred_locale"),
row.get("notify_on_share"),
row.get::<serde_json::Value, _>("ui_preferences"),
)
})
.collect())
@@ -514,7 +568,8 @@ impl UserRepository for UserPgRepository {
storage_quota_bytes, storage_used_bytes,
created_at, updated_at, last_login_at, active,
oidc_provider, oidc_subject, image, is_external,
given_name, family_name, email_verified_at, preferred_locale, notify_on_share
given_name, family_name, email_verified_at, preferred_locale, notify_on_share,
ui_preferences
FROM auth.users
WHERE ($3 OR is_external = FALSE)
ORDER BY created_at DESC
@@ -559,6 +614,7 @@ impl UserRepository for UserPgRepository {
row.get("email_verified_at"),
row.get("preferred_locale"),
row.get("notify_on_share"),
row.get::<serde_json::Value, _>("ui_preferences"),
)
})
.collect();
@@ -580,7 +636,8 @@ impl UserRepository for UserPgRepository {
storage_quota_bytes, storage_used_bytes,
created_at, updated_at, last_login_at, active,
oidc_provider, oidc_subject, image, is_external,
given_name, family_name, email_verified_at, preferred_locale, notify_on_share
given_name, family_name, email_verified_at, preferred_locale, notify_on_share,
ui_preferences
FROM auth.users
WHERE (username ILIKE $1 OR email ILIKE $1)
AND ($3 OR is_external = FALSE)
@@ -625,6 +682,7 @@ impl UserRepository for UserPgRepository {
row.get("email_verified_at"),
row.get("preferred_locale"),
row.get("notify_on_share"),
row.get::<serde_json::Value, _>("ui_preferences"),
)
})
.collect();
@@ -712,7 +770,8 @@ impl UserRepository for UserPgRepository {
storage_quota_bytes, storage_used_bytes,
created_at, updated_at, last_login_at, active,
oidc_provider, oidc_subject, image, is_external,
given_name, family_name, email_verified_at, preferred_locale, notify_on_share
given_name, family_name, email_verified_at, preferred_locale, notify_on_share,
ui_preferences
FROM auth.users
WHERE role::text = $1
ORDER BY created_at DESC
@@ -754,6 +813,7 @@ impl UserRepository for UserPgRepository {
row.get("email_verified_at"),
row.get("preferred_locale"),
row.get("notify_on_share"),
row.get::<serde_json::Value, _>("ui_preferences"),
)
})
.collect();
@@ -790,7 +850,8 @@ impl UserRepository for UserPgRepository {
storage_quota_bytes, storage_used_bytes,
created_at, updated_at, last_login_at, active,
oidc_provider, oidc_subject, image, is_external,
given_name, family_name, email_verified_at, preferred_locale, notify_on_share
given_name, family_name, email_verified_at, preferred_locale, notify_on_share,
ui_preferences
FROM auth.users
WHERE oidc_provider = $1 AND oidc_subject = $2
"#,
@@ -828,6 +889,7 @@ impl UserRepository for UserPgRepository {
row.get("email_verified_at"),
row.get("preferred_locale"),
row.get("notify_on_share"),
row.get::<serde_json::Value, _>("ui_preferences"),
))
}
+1
View File
@@ -146,6 +146,7 @@ log "Running Hurl tests..."
hurl --variables-file "$API_DIR/test.env" --file-root "$REPO_ROOT/tests" --test --jobs 1 \
"$API_DIR/setup.hurl" \
"$API_DIR/auth_login.hurl" \
"$API_DIR/user_ui_preferences.hurl" \
"$API_DIR/auth_session_lifecycle.hurl" \
"$API_DIR/registration.hurl" \
"$API_DIR/nc_status_capabilities.hurl" \
+184
View File
@@ -0,0 +1,184 @@
# =============================================================
# OxiCloud — auth.users.ui_preferences round-trip
# =============================================================
# The `ui_preferences` JSONB column is the SPA's cross-device
# backing store for pure UI toggles (hide dotfiles, view mode,
# sidebar collapse, …). The server treats the contents as
# opaque; this suite pins the semantics of the PATCH surface
# so a future refactor can't silently break cross-device sync:
#
# 1. Fresh user starts with an empty object bag (`{}`), not
# `null` and not missing from the response body.
# 2. PATCH does a SHALLOW merge — a partial write only
# touches the keys it mentions; siblings survive. Load-
# bearing invariant: without it, Device A's write would
# silently wipe preferences Device B just set.
# 3. Sending `{key: null}` in the patch REMOVES that key
# server-side (jsonb_strip_nulls after the merge). This
# is the documented delete-a-key path.
# 4. Non-object patch shape is rejected with 400. Prevents
# the endpoint from being a scratch scalar store and
# catches malformed clients early.
#
# Not covered here (intentional):
# • 16 KiB size cap — the CHECK is at the schema layer and
# is exercised by unit tests without needing an integration
# round-trip; constructing a 16 KiB JSON body in Hurl adds
# line noise without meaningful signal.
# • Concurrency safety of the shallow merge under two
# simultaneous PATCHes — postgres' `||` operator is atomic
# per row, so this is a DB-guarantee test rather than an
# API test.
# =============================================================
# ─────────────────────────────────────────────────────────────
# Step 1 — Admin login. All PATCH/GET below use this token so
# the same user's bag is under test.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/auth/login
Content-Type: application/json
{ "username": "{{username}}", "password": "{{password}}" }
HTTP 200
[Captures]
admin_token: jsonpath "$.access_token"
# ─────────────────────────────────────────────────────────────
# Step 2 — Fresh state: bag is present in the response and is
# an empty object.
#
# Note: if a PRIOR test in the API suite has already
# PATCHed this user's ui_preferences, this step's
# `count == 0` check would fail. Currently no other
# test writes to `ui_preferences` — if a future test
# does, it MUST clean up its keys at teardown
# (`PATCH { key: null }`) to keep this baseline valid.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/auth/me
Authorization: Bearer {{admin_token}}
HTTP 200
[Asserts]
jsonpath "$.ui_preferences" exists
jsonpath "$.ui_preferences" isCollection
# Empty-object baseline. Neither `count == 0` on `.*` nor the
# `== {}` object-literal predicate are supported by this Hurl
# version. Fall back to a body-shape check on the serialised
# response — serde_json emits `"ui_preferences":{}` without
# whitespace inside the braces on Rust's default JSON writer,
# so this pins the empty-object serialisation reliably.
body contains "\"ui_preferences\":{}"
# ─────────────────────────────────────────────────────────────
# Step 3 — Write one key. Response echoes the merged bag with
# the new key. Bumps updated_at (not asserted — it's
# set by the repo unconditionally so no branch to pin).
# ─────────────────────────────────────────────────────────────
PATCH {{base_url}}/api/auth/me/profile
Authorization: Bearer {{admin_token}}
Content-Type: application/json
{ "ui_preferences": { "hide_dotfiles": true } }
HTTP 200
[Asserts]
jsonpath "$.ui_preferences.hide_dotfiles" == true
# ─────────────────────────────────────────────────────────────
# Step 4 — Write a SECOND key. Shallow merge must preserve the
# first key. This is the load-bearing regression
# assertion: a full-replacement bug here would show
# `hide_dotfiles` missing from the response.
# ─────────────────────────────────────────────────────────────
PATCH {{base_url}}/api/auth/me/profile
Authorization: Bearer {{admin_token}}
Content-Type: application/json
{ "ui_preferences": { "view_mode": "grid" } }
HTTP 200
[Asserts]
jsonpath "$.ui_preferences.hide_dotfiles" == true
jsonpath "$.ui_preferences.view_mode" == "grid"
# ─────────────────────────────────────────────────────────────
# Step 5 — GET reflects the merged state after the round-trip
# (belt-and-braces — Step 4's PATCH response could
# have been returning a computed value while the DB
# state diverged; the fresh GET catches that).
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/auth/me
Authorization: Bearer {{admin_token}}
HTTP 200
[Asserts]
jsonpath "$.ui_preferences.hide_dotfiles" == true
jsonpath "$.ui_preferences.view_mode" == "grid"
# ─────────────────────────────────────────────────────────────
# Step 6 — Null-value deletes the key. `hide_dotfiles` is
# removed; `view_mode` stays. This exercises the
# `jsonb_strip_nulls(bag || patch)` path in the repo.
# ─────────────────────────────────────────────────────────────
PATCH {{base_url}}/api/auth/me/profile
Authorization: Bearer {{admin_token}}
Content-Type: application/json
{ "ui_preferences": { "hide_dotfiles": null } }
HTTP 200
[Asserts]
jsonpath "$.ui_preferences.view_mode" == "grid"
# Deleted key must not survive as `null` — it must be absent
# (`jsonb_strip_nulls` in the repo strips it post-merge).
jsonpath "$.ui_preferences.hide_dotfiles" not exists
# ─────────────────────────────────────────────────────────────
# Step 7 — Non-object patch is rejected. Sending an array
# would be a client bug or an abuse attempt (the bag
# is documented as a JSON OBJECT). The schema CHECK
# `users_ui_preferences_is_object` enforces at the DB
# level; the service layer catches it earlier with a
# 400.
# ─────────────────────────────────────────────────────────────
PATCH {{base_url}}/api/auth/me/profile
Authorization: Bearer {{admin_token}}
Content-Type: application/json
{ "ui_preferences": [1, 2, 3] }
HTTP 400
# ─────────────────────────────────────────────────────────────
# Step 8 — Scalar patch is rejected (same class as array).
# Both cases route through the same `patch.is_object()`
# gate in `AuthApplicationService::update_profile`.
# ─────────────────────────────────────────────────────────────
PATCH {{base_url}}/api/auth/me/profile
Authorization: Bearer {{admin_token}}
Content-Type: application/json
{ "ui_preferences": "not-a-bag" }
HTTP 400
# ─────────────────────────────────────────────────────────────
# Teardown — restore the bag to empty so downstream tests
# don't inherit `view_mode`. Sending each surviving key with
# `null` deletes them via jsonb_strip_nulls, leaving `{}`.
# ─────────────────────────────────────────────────────────────
PATCH {{base_url}}/api/auth/me/profile
Authorization: Bearer {{admin_token}}
Content-Type: application/json
{ "ui_preferences": { "view_mode": null } }
HTTP 200
[Asserts]
# Same empty-object serialised shape as Step 2's baseline —
# `body contains "\"ui_preferences\":{}"` is the tightest empty
# check available on this Hurl version.
body contains "\"ui_preferences\":{}"