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';