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:
Dionisio Pozo
2026-07-14 09:07:09 +02:00
committed by GitHub
43 changed files with 1873 additions and 94 deletions
@@ -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 !== ''}
+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';