Merge pull request #577 from EdouardVanbelle/feat/users-perfs-and-filter-dotfiles
feat: users prefs server side + filter dotfiles + filter shares by resource type
This commit is contained in:
@@ -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> {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
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 +30,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 +52,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 +80,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
|
||||
@@ -125,6 +142,28 @@
|
||||
onclick={() => filesStore.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>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -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,
|
||||
@@ -398,6 +404,7 @@
|
||||
ongroup={selectGroup}
|
||||
ondirection={toggleDirection}
|
||||
{showViewToggle}
|
||||
{showDotfileToggle}
|
||||
>
|
||||
{#snippet start()}
|
||||
<div class="action-buttons">{@render toolbar?.()}</div>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -83,6 +83,13 @@ class FilesStore {
|
||||
currentFolder = $state<string | null>(null);
|
||||
currentFolderInfo = $state<FolderItem | null>(null);
|
||||
breadcrumbPath = $state<Array<{ id: string; name: string }>>([]);
|
||||
// View mode INTENTIONALLY lives here (localStorage) rather than in
|
||||
// the server-side `preferences` bag. See the note in
|
||||
// `preferences.svelte.ts::UiPreferences` for the full rationale —
|
||||
// short version: server persistence broke Playwright test
|
||||
// isolation (favorites.spec's list-view click leaked into every
|
||||
// downstream test's context), and view mode isn't a preference
|
||||
// users have asked to sync across devices.
|
||||
viewMode = $state<ViewMode>(readViewMode());
|
||||
section = $state<Section>('files');
|
||||
isSearchMode = $state(false);
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* 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;
|
||||
// NOTE: view_mode (grid/list) DELIBERATELY stays in localStorage
|
||||
// (`oxi-view-mode` on `filesStore`). Making it server-persistent
|
||||
// caused a real Playwright regression: `favorites.spec.ts` clicks
|
||||
// the list-view toggle, and on the server-backed store that
|
||||
// preference would then leak into every downstream test's fresh
|
||||
// browser context — Playwright's default context isolation
|
||||
// relies on localStorage being fresh per test, which the server
|
||||
// bag can't provide. Result: files-extra's `Zip-*` folder fell
|
||||
// outside list view's smaller virtualisation window (~25 vs ~75
|
||||
// grid items) and `getByTestId` timed out. Google Drive / Finder
|
||||
// / Dropbox also keep view mode per-device — the sync-across-
|
||||
// devices UX isn't a strongly-requested pattern.
|
||||
}
|
||||
|
||||
/** Reasonable default for an empty bag or a missing key. */
|
||||
const DEFAULTS: Required<UiPreferences> = {
|
||||
hide_dotfiles: false
|
||||
};
|
||||
|
||||
/**
|
||||
* 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
|
||||
);
|
||||
|
||||
// ── 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);
|
||||
}
|
||||
}
|
||||
|
||||
export const preferences = new PreferencesStore();
|
||||
@@ -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';
|
||||
|
||||
@@ -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,7 +1359,12 @@
|
||||
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'
|
||||
);
|
||||
@@ -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)]);
|
||||
@@ -1475,6 +1559,7 @@
|
||||
class:dropzone-active={dragOver}
|
||||
role="region"
|
||||
aria-label={t('nav.files', 'Files')}
|
||||
data-testid="files-dropzone"
|
||||
ondragover={(e) => {
|
||||
e.preventDefault();
|
||||
dragOver = true;
|
||||
@@ -1508,6 +1593,7 @@
|
||||
reversed={sortDir === -1}
|
||||
ongroup={onPickGroup}
|
||||
ondirection={() => (sortDir = (sortDir * -1) as 1 | -1)}
|
||||
showDotfileToggle
|
||||
>
|
||||
{#snippet start()}
|
||||
{#if selectedCount > 0}
|
||||
@@ -1673,10 +1759,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 !== ''}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user