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:
@@ -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,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()}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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