Compare commits

2 Commits

Author SHA1 Message Date
cjw deb6dc831b docs(status): record filter-merge task completion (29d0c335)
CI / Frontend end-to-end tests (via Playwright) (push) Blocked by required conditions
CI / changes (push) Waiting to run
CI / Frontend — svelte-check, ESLint, Stylelint, Prettier (push) Blocked by required conditions
CI / Message-bus spec — AsyncAPI + TypeScript DTO drift (push) Blocked by required conditions
CI / Migration ordering (new migrations postdate target branch) (push) Blocked by required conditions
CI / Rustfmt (push) Blocked by required conditions
CI / Clippy (push) Blocked by required conditions
CI / Wasm — fmt + clippy (push) Blocked by required conditions
CI / Wasm — release tests (push) Blocked by required conditions
CI / Plugins — fixtures + runtime tests (push) Blocked by required conditions
CI / Server Unit and Functionnal Tests (push) Blocked by required conditions
CI / Security Audit (push) Blocked by required conditions
CI / Build (push) Waiting to run
CI / API, WebDAV & OIDC tests (push) Blocked by required conditions
CI / Bundled-assets binary — embed + SPA-serve integration (push) Blocked by required conditions
CI / WebDAV RFC 4918 — litmus (59/59) (push) Blocked by required conditions
CI / CalDAV + CardDAV — python-caldav (push) Blocked by required conditions
Docker Build and Test / Build and Test Docker Image (push) Waiting to run
Docker Publish (release, main, dry-run) / Pre-publish Tests (push) Waiting to run
Docker Publish (release, main, dry-run) / Build & Push Multi-Arch (push) Blocked by required conditions
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-27 20:00:03 +08:00
cjw 29d0c33558 feat(frontend): merge top-bar search with the filter bar, expose kind/created filters
Unify the upstream top-bar search box and the local SearchFilterBar into
one filtering capability sharing a single state store, with the URL as
its projection:

- lib/stores/filter.svelte.ts: module-level $state store, the single
  source of truth; every surface mutates the same proxy in place
- lib/composables/useFilterUrlSync.svelte.ts: one $effect serves both
  directions (URL -> store adoption, store -> URL replaceState write)
  with echo suppression so the loop settles in one round-trip
- FilterFields.svelte: the five selects (type/size/modified/created/
  kind) + recursive switch; word lists and i18n labels live in one
  place; SearchFilterBar reuses it instead of its own copies
- TopBarFilterPanel.svelte: dropdown panel anchored inside the top-bar
  search input (in-field toggle button with active-count badge);
  Escape/outside-click close, no overlay stacking with suggestions
- FilterChips.svelte: dismissible chips on /search, which is now
  URL-driven and loses its three inline <select>s
- New dimensions the backend already supported: kind (file/folder,
  resource_types) and created (created_after); URL keys q/type/size/
  date/created/kind/recursive, non-defaults only, unknown values
  degrade to defaults
- /files keeps in-place filtering (no navigation); folderTarget
  carries filter params through folder entry; ?file= and in/scope
  survive filter writes
- topbar.css: input right padding reserves the three in-field controls

Backend unchanged.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-27 15:40:20 +08:00
32 changed files with 1445 additions and 284 deletions
+131 -14
View File
@@ -13,15 +13,22 @@
import { lazyComponent } from '$lib/composables/lazyComponent.svelte'; import { lazyComponent } from '$lib/composables/lazyComponent.svelte';
import DrivePicker from '$lib/components/DrivePicker.svelte'; import DrivePicker from '$lib/components/DrivePicker.svelte';
import ReadOnlyBanner from '$lib/components/ReadOnlyBanner.svelte'; import ReadOnlyBanner from '$lib/components/ReadOnlyBanner.svelte';
import TopBarFilterPanel from '$lib/components/TopBarFilterPanel.svelte';
import Icon from '$lib/icons/Icon.svelte'; import Icon from '$lib/icons/Icon.svelte';
import { dateTimeFormatFor, iconNameFromClass } from '$lib/utils/display'; import { dateTimeFormatFor, iconNameFromClass } from '$lib/utils/display';
import { userInitials, avatarColorIndex } from '$lib/utils/avatar'; import { userInitials, avatarColorIndex } from '$lib/utils/avatar';
import {
activeFilterCount,
filterParamsToString,
filterToSearchOptions
} from '$lib/utils/searchFilters';
import { i18n, LANGUAGES, setLocale, t, type Locale } from '$lib/i18n/index.svelte'; import { i18n, LANGUAGES, setLocale, t, type Locale } from '$lib/i18n/index.svelte';
import { serverConfig } from '$lib/stores/serverConfig.svelte'; import { serverConfig } from '$lib/stores/serverConfig.svelte';
import { serverStatus } from '$lib/stores/serverStatus.svelte'; import { serverStatus } from '$lib/stores/serverStatus.svelte';
import { apiFetch } from '$lib/api/client'; import { apiFetch } from '$lib/api/client';
import { dialogs } from '$lib/stores/dialogs.svelte'; import { dialogs } from '$lib/stores/dialogs.svelte';
import { files as filesStore } from '$lib/stores/files.svelte'; import { files as filesStore } from '$lib/stores/files.svelte';
import { resourceFilters } from '$lib/stores/filter.svelte';
import { preferences } from '$lib/stores/preferences.svelte'; import { preferences } from '$lib/stores/preferences.svelte';
import { session } from '$lib/stores/session.svelte'; import { session } from '$lib/stores/session.svelte';
import { theme, type Theme } from '$lib/stores/theme.svelte'; import { theme, type Theme } from '$lib/stores/theme.svelte';
@@ -309,7 +316,9 @@
let menuOpen = $state(false); let menuOpen = $state(false);
let searchQuery = $state(''); let searchQuery = $state('');
/** Mobile collapsible-search overlay state (toggles .top-bar--search-active). */ /** Mobile collapsible-search overlay state (toggles .top-bar--search-active). */
let searchActive = $state(false); let mobileSearchOpen = $state(false);
/** Top-bar filter dropdown (opens from the button inside the search input). */
let filterPanelOpen = $state(false);
let langOpen = $state(false); let langOpen = $state(false);
let aboutOpen = $state(false); let aboutOpen = $state(false);
let appVersion = $state(''); let appVersion = $state('');
@@ -348,15 +357,26 @@
const totalUnreadBadge = $derived(totalUnread > 99 ? '99+' : String(totalUnread)); const totalUnreadBadge = $derived(totalUnread > 99 ? '99+' : String(totalUnread));
function openMobileSearch() { function openMobileSearch() {
searchActive = true; mobileSearchOpen = true;
requestAnimationFrame(() => searchInputEl?.focus()); requestAnimationFrame(() => searchInputEl?.focus());
} }
function closeMobileSearch() { function closeMobileSearch() {
searchActive = false; mobileSearchOpen = false;
filterPanelOpen = false;
clearSearch(); clearSearch();
} }
function toggleFilterPanel() {
filterPanelOpen = !filterPanelOpen;
if (filterPanelOpen) {
// The two overlays would stack (both anchor to .search-container's
// bottom) — the panel wins while it's open; suggestions resume on
// the next input event after it closes.
suggestOpen = false;
}
}
async function openAbout() { async function openAbout() {
menuOpen = false; menuOpen = false;
aboutOpen = true; aboutOpen = true;
@@ -378,6 +398,10 @@
let suggestions = $state<Suggestion[]>([]); let suggestions = $state<Suggestion[]>([]);
let suggestOpen = $state(false); let suggestOpen = $state(false);
let suggestBusy = $state(false); let suggestBusy = $state(false);
// Live count of active filter presets — drives the toggle button's badge
// and its "filters applied" highlight. The shared store is also what the
// panel (below) and the files page's filter bar mutate.
const filterActiveCount = $derived(activeFilterCount(resourceFilters));
let suggestTimer: ReturnType<typeof setTimeout> | null = null; let suggestTimer: ReturnType<typeof setTimeout> | null = null;
// Stale-response guard (same family as the search page): the debounce // Stale-response guard (same family as the search page): the debounce
// spaces requests out but doesn't stop a SLOW earlier response from // spaces requests out but doesn't stop a SLOW earlier response from
@@ -389,16 +413,21 @@
const q = searchQuery.trim(); const q = searchQuery.trim();
if (!q) return; if (!q) return;
suggestOpen = false; suggestOpen = false;
searchActive = false; filterPanelOpen = false;
mobileSearchOpen = false;
// Built by hand instead of via `URLSearchParams` because the Svelte
// lint (svelte/prefer-svelte-reactivity) flags the mutable stdlib
// variant; these params don't need reactivity anyway.
const parts = [`q=${encodeURIComponent(q)}`];
// Active filter presets ride along (`type=image&kind=file&…`) — the
// shared store is what the top-bar panel and the files page's bar
// mutate, so submitting from here carries exactly what the user sees.
const filterQs = filterParamsToString(resourceFilters);
if (filterQs) parts.push(filterQs);
// Carry the currently-open folder into the search URL as `?in=<uuid>` // Carry the currently-open folder into the search URL as `?in=<uuid>`
// so a hard refresh, a shared link, or a bookmark all restore the // so a hard refresh, a shared link, or a bookmark all restore the
// "This folder" scope. Trash section is always global — skip. See // "This folder" scope. Trash section is always global — skip. See
// `/search/+page.svelte` for the receiver side. // `/search/+page.svelte` for the receiver side.
//
// Built by hand instead of via `URLSearchParams` because the Svelte
// lint (svelte/prefer-svelte-reactivity) flags the mutable stdlib
// variant; the two params here don't need reactivity anyway.
const parts = [`q=${encodeURIComponent(q)}`];
if (filesStore.currentFolder && filesStore.section !== 'trash') { if (filesStore.currentFolder && filesStore.section !== 'trash') {
parts.push(`in=${encodeURIComponent(filesStore.currentFolder)}`); parts.push(`in=${encodeURIComponent(filesStore.currentFolder)}`);
} }
@@ -411,6 +440,8 @@
} }
function onSearchInput() { function onSearchInput() {
// The filter panel owns the dropdown area while open — don't fight it.
if (filterPanelOpen) return;
if (suggestTimer) clearTimeout(suggestTimer); if (suggestTimer) clearTimeout(suggestTimer);
const q = searchQuery.trim(); const q = searchQuery.trim();
if (q.length < 2) { if (q.length < 2) {
@@ -428,7 +459,13 @@
suggestInflight = ctl; suggestInflight = ctl;
suggestBusy = true; suggestBusy = true;
try { try {
const r = await searchResources(q, { recursive: true, limit: 9, signal: ctl.signal }); // Suggestions honor the active filter presets, mirroring what
// "See all results" will show on /search.
const r = await searchResources(q, {
...filterToSearchOptions(resourceFilters),
limit: 9,
signal: ctl.signal
});
if (seq !== suggestSeq) return; // superseded while awaiting if (seq !== suggestSeq) return; // superseded while awaiting
// The wire is ordered — folders first, then files — but slice // The wire is ordered — folders first, then files — but slice
// per kind explicitly so the header preview stays a folder-heavy // per kind explicitly so the header preview stays a folder-heavy
@@ -491,6 +528,22 @@
langOpen = false; langOpen = false;
} }
/**
* Window-level click: the existing close-outside-click for the menus,
* plus the same treatment for the top-bar filter panel. Anything inside
* the search container — the input, the toggle, the panel itself — keeps
* the panel open (same `closest` guard as DisplayModeControls).
*/
function onGlobalClick(e: MouseEvent) {
closeMenus();
if (
filterPanelOpen &&
!(e.target instanceof Element && e.target.closest('.search-container'))
) {
filterPanelOpen = false;
}
}
/** /**
* True when the shortcut target is a text-input surface — <input>, * True when the shortcut target is a text-input surface — <input>,
* <textarea>, or any `contenteditable` element. Used by the * <textarea>, or any `contenteditable` element. Used by the
@@ -563,7 +616,7 @@
</script> </script>
<svelte:window <svelte:window
onclick={closeMenus} onclick={onGlobalClick}
onkeydown={(e) => { onkeydown={(e) => {
// First Cmd/Ctrl+K loads the palette and mounts it open; once mounted, // First Cmd/Ctrl+K loads the palette and mounts it open; once mounted,
// the palette's own handler takes over toggling/closing. // the palette's own handler takes over toggling/closing.
@@ -597,7 +650,8 @@
} }
if (e.key !== 'Escape') return; if (e.key !== 'Escape') return;
if (aboutOpen) aboutOpen = false; if (aboutOpen) aboutOpen = false;
else if (searchActive) closeMobileSearch(); else if (filterPanelOpen) filterPanelOpen = false;
else if (mobileSearchOpen) closeMobileSearch();
else closeMenus(); else closeMenus();
}} }}
/> />
@@ -702,7 +756,7 @@
</div> </div>
<div class="main-content"> <div class="main-content">
<div class="top-bar" class:top-bar--search-active={searchActive}> <div class="top-bar" class:top-bar--search-active={mobileSearchOpen}>
<button <button
class="sidebar-toggle" class="sidebar-toggle"
aria-label={t('nav.toggle', 'Toggle navigation menu')} aria-label={t('nav.toggle', 'Toggle navigation menu')}
@@ -760,6 +814,24 @@
<Icon name="times" /> <Icon name="times" />
</button> </button>
{/if} {/if}
<!-- Filter toggle sits inside the input's right edge (between the
clear × and the submit button) and opens the shared filter
panel — the same state the files page's filter bar edits. -->
<button
class="search-filter-btn"
class:search-filter-btn--active={filterPanelOpen || filterActiveCount > 0}
type="button"
aria-expanded={filterPanelOpen}
title={t('filter.advanced', 'Filters')}
aria-label={t('filter.advanced', 'Filters')}
data-testid="appshell-filter-toggle-btn"
onclick={toggleFilterPanel}
>
<Icon name="sliders-h" />
{#if filterActiveCount > 0}
<span class="search-filter-btn__badge">{filterActiveCount}</span>
{/if}
</button>
<button <button
class="search-button" class="search-button"
type="submit" type="submit"
@@ -770,6 +842,10 @@
<Icon name="search" /> <Icon name="search" />
</button> </button>
{#if filterPanelOpen}
<TopBarFilterPanel value={resourceFilters} onclose={() => (filterPanelOpen = false)} />
{/if}
{#if suggestOpen} {#if suggestOpen}
<ul class="suggest"> <ul class="suggest">
{#each suggestions as s (s.kind + s.item.id)} {#each suggestions as s (s.kind + s.item.id)}
@@ -1223,7 +1299,7 @@
/* Clear (×) button sits left of the submit button inside the search field. */ /* Clear (×) button sits left of the submit button inside the search field. */
.search-clear { .search-clear {
position: absolute; position: absolute;
right: 44px; right: 72px;
display: grid; display: grid;
place-items: center; place-items: center;
width: 28px; width: 28px;
@@ -1240,6 +1316,47 @@
color: var(--color-text); color: var(--color-text);
} }
/* Filter toggle inside the input's right edge: submit button at 6px,
clear × at 72px, this one between them at 44px. The badge counts the
active presets, mirroring the files page's filter-bar toggle. */
.search-filter-btn {
position: absolute;
right: 44px;
display: grid;
place-items: center;
width: 28px;
height: 28px;
border: none;
border-radius: 50%;
background: none;
color: var(--color-text-muted);
cursor: pointer;
}
.search-filter-btn:hover {
background: var(--color-bg-hover);
color: var(--color-text);
}
.search-filter-btn--active {
color: var(--color-accent);
}
.search-filter-btn__badge {
position: absolute;
top: -2px;
right: -3px;
min-width: 13px;
height: 13px;
padding: 0 2px;
border-radius: var(--radius-sm);
background: var(--color-accent);
color: var(--color-on-accent);
font-size: 9px;
line-height: 13px;
text-align: center;
}
.suggest { .suggest {
position: absolute; position: absolute;
top: calc(100% + 4px); top: calc(100% + 4px);
@@ -88,3 +88,49 @@ it('submits a search and routes to /search', async () => {
await fireEvent.click(screen.getByTestId('appshell-search-submit-btn')); await fireEvent.click(screen.getByTestId('appshell-search-submit-btn'));
await waitFor(() => expect(goto).toHaveBeenCalledWith('/search?q=report')); await waitFor(() => expect(goto).toHaveBeenCalledWith('/search?q=report'));
}); });
it('opens the filter panel, and its presets ride along on submit', async () => {
render(AppShell, { props: { children } });
// Panel opens from the toggle inside the search input…
await fireEvent.click(screen.getByTestId('appshell-filter-toggle-btn'));
expect(screen.getByTestId('appshell-filter-panel')).toBeTruthy();
// …and picking a preset lights the toggle's badge (shared store).
await fireEvent.change(screen.getByTestId('filter-type-select'), {
target: { value: 'image' }
});
expect(screen.getByTestId('appshell-filter-toggle-btn').textContent).toContain('1');
// Submitting carries the preset into the search URL.
const input = screen.getByTestId('appshell-search-input');
await fireEvent.input(input, { target: { value: 'report' } });
await fireEvent.click(screen.getByTestId('appshell-search-submit-btn'));
await waitFor(() => expect(goto).toHaveBeenCalledWith('/search?q=report&type=image'));
// Submit closes the panel; reopen it and "Clear filters" resets the
// presets — the badge drops and the next submit is param-free.
await fireEvent.click(screen.getByTestId('appshell-filter-toggle-btn'));
await fireEvent.click(screen.getByTestId('appshell-filter-clear-btn'));
expect(screen.getByTestId('appshell-filter-toggle-btn').textContent!.includes('1')).toBe(false);
await fireEvent.input(input, { target: { value: 'again' } });
await fireEvent.click(screen.getByTestId('appshell-search-submit-btn'));
await waitFor(() => expect(goto).toHaveBeenCalledWith('/search?q=again'));
// Leave the shared store clean for the other tests in this file.
await fireEvent.click(screen.getByTestId('appshell-filter-toggle-btn'));
await fireEvent.click(screen.getByTestId('appshell-filter-clear-btn'));
});
it('closes the filter panel on Escape and on outside click', async () => {
render(AppShell, { props: { children } });
await fireEvent.click(screen.getByTestId('appshell-filter-toggle-btn'));
expect(screen.getByTestId('appshell-filter-panel')).toBeTruthy();
await fireEvent.click(screen.getByTestId('appshell-filter-done-btn'));
expect(screen.queryByTestId('appshell-filter-panel')).toBeNull();
// Outside click (window-level handler) reopens-then-closes too.
await fireEvent.click(screen.getByTestId('appshell-filter-toggle-btn'));
expect(screen.getByTestId('appshell-filter-panel')).toBeTruthy();
await fireEvent.click(screen.getByTestId('shell-child'));
expect(screen.queryByTestId('appshell-filter-panel')).toBeNull();
// Escape closes it as well.
await fireEvent.click(screen.getByTestId('appshell-filter-toggle-btn'));
expect(screen.getByTestId('appshell-filter-panel')).toBeTruthy();
await fireEvent.keyDown(window, { key: 'Escape' });
expect(screen.queryByTestId('appshell-filter-panel')).toBeNull();
});
@@ -0,0 +1,130 @@
<!--
The active filter presets as dismissible chips, for surfaces whose filter
editing lives elsewhere (the top bar's panel) — /search after its in-page
selects were retired. Without these the page would show filtered results
with no visible way to see or undo why.
`value` is the shared state proxy, mutated in place — deliberately a plain
prop, not `$bindable`: there is exactly one state object (the store) and
nobody reassigns it, so a two-way binding contract would be noise.
-->
<script lang="ts">
import Icon from '$lib/icons/Icon.svelte';
import { t } from '$lib/i18n/index.svelte';
import {
activeFilters,
clearFilterDimension,
filterDimensionLabelKey,
filterValueLabelKey,
type ResourceFilterState
} from '$lib/utils/searchFilters';
interface Props {
value: ResourceFilterState;
}
let { value }: Props = $props();
const chips = $derived(activeFilters(value));
function clearAll() {
for (const { key } of activeFilters(value)) clearFilterDimension(value, key);
// A lingering recursive=0 would survive a "clear everything" otherwise.
value.recursive = true;
}
</script>
{#if chips.length > 0}
<div
class="fchips"
data-testid="filter-chips"
role="list"
aria-label={t('filter.advanced', 'Filters')}
>
{#each chips as chip (chip.key)}
<span class="fchips__chip" role="listitem" data-testid={`filter-chip-${chip.key}`}>
<span class="fchips__dim">{t(filterDimensionLabelKey(chip.key), chip.key)}</span>
<span class="fchips__val">{t(filterValueLabelKey(chip.key, chip.value), chip.value)}</span>
<button
class="fchips__dismiss"
type="button"
aria-label={t('common.clear', 'Clear')}
data-testid={`filter-chip-${chip.key}-dismiss-btn`}
onclick={() => clearFilterDimension(value, chip.key)}
>
<Icon name="times" />
</button>
</span>
{/each}
<button
class="fchips__clear-all"
type="button"
data-testid="filter-clear-all-btn"
onclick={clearAll}
>
{t('search.clear_filters', 'Clear filters')}
</button>
</div>
{/if}
<style>
.fchips {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-1);
}
.fchips__chip {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.15rem 0.3rem 0.15rem 0.55rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-2xl, 999px);
background: var(--color-bg-surface);
font-size: var(--text-sm);
}
.fchips__dim {
color: var(--color-text-secondary);
}
.fchips__val {
color: var(--color-text);
font-weight: 500;
}
.fchips__dismiss {
display: flex;
align-items: center;
justify-content: center;
width: 1.2rem;
height: 1.2rem;
padding: 0;
border: none;
border-radius: var(--radius-sm);
background: none;
color: var(--color-text-secondary);
cursor: pointer;
}
.fchips__dismiss:hover {
background: var(--color-bg-hover);
color: var(--color-text);
}
.fchips__clear-all {
padding: 0.15rem 0.5rem;
border: none;
border-radius: var(--radius-sm);
background: none;
color: var(--color-accent);
font-size: var(--text-sm);
cursor: pointer;
}
.fchips__clear-all:hover {
text-decoration: underline;
}
</style>
@@ -0,0 +1,178 @@
<!--
The filter dimensions themselves (type / size / modified / created / kind /
recursive), bound to the shared `ResourceFilterState`.
Extracted so the files page's filter bar and the top bar's filter panel
render the same fields off the same vocabulary — the option lists used to
live in two copies (SearchFilterBar and the /search page). The vocabularies
(extensions, byte bounds, date presets) stay in `$lib/utils/searchFilters`;
only the i18n labels live here.
-->
<script lang="ts">
import { t } from '$lib/i18n/index.svelte';
import {
type DateKey,
type KindKey,
type ResourceFilterState,
type SizeKey,
type TypeKey
} from '$lib/utils/searchFilters';
interface Props {
/** Bindable filter state — the caller owns it (usually the shared store). */
value: ResourceFilterState;
/** `inline` wraps in a row (files page); `panel` uses a grid (top bar). */
layout?: 'inline' | 'panel';
/** Hide the recursive toggle (a surface where scope is owned elsewhere). */
hideRecursive?: boolean;
}
let { value = $bindable(), layout = 'inline', hideRecursive = false }: Props = $props();
const TYPES: { v: TypeKey; l: string }[] = [
{ v: 'all', l: t('search.type.all', 'All types') },
{ v: 'image', l: t('search.type.image', 'Images') },
{ v: 'video', l: t('search.type.video', 'Videos') },
{ v: 'document', l: t('search.type.document', 'Documents') },
{ v: 'audio', l: t('search.type.audio', 'Audio') },
{ v: 'archive', l: t('search.type.archive', 'Archives') }
];
const SIZES: { v: SizeKey; l: string }[] = [
{ v: 'all', l: t('search.size.all', 'Any size') },
{ v: 'small', l: t('search.size.small', '< 1 MB') },
{ v: 'medium', l: t('search.size.medium', '1–100 MB') },
{ v: 'large', l: t('search.size.large', '> 100 MB') }
];
const DATES: { v: DateKey; l: string }[] = [
{ v: 'all', l: t('search.date.all', 'Any time') },
{ v: 'day', l: t('search.date.day', 'Past 24 hours') },
{ v: 'week', l: t('search.date.week', 'Past week') },
{ v: 'month', l: t('search.date.month', 'Past month') },
{ v: 'year', l: t('search.date.year', 'Past year') }
];
const KINDS: { v: KindKey; l: string }[] = [
{ v: 'all', l: t('filter.kind.all', 'Files and folders') },
{ v: 'file', l: t('filter.kind.file', 'Files only') },
{ v: 'folder', l: t('filter.kind.folder', 'Folders only') }
];
</script>
<div
class="filter-fields"
class:filter-fields--panel={layout === 'panel'}
data-testid="filter-fields"
>
<label class="filter-fields__field">
<span class="filter-fields__label">{t('search.type_label', 'Type')}</span>
<select data-testid="filter-type-select" bind:value={value.type}>
{#each TYPES as opt (opt.v)}
<option value={opt.v}>{opt.l}</option>
{/each}
</select>
</label>
<label class="filter-fields__field">
<span class="filter-fields__label">{t('filter.kind_label', 'Items')}</span>
<select data-testid="filter-kind-select" bind:value={value.kind}>
{#each KINDS as opt (opt.v)}
<option value={opt.v}>{opt.l}</option>
{/each}
</select>
</label>
<label class="filter-fields__field">
<span class="filter-fields__label">{t('search.size_label', 'Size')}</span>
<select data-testid="filter-size-select" bind:value={value.size}>
{#each SIZES as opt (opt.v)}
<option value={opt.v}>{opt.l}</option>
{/each}
</select>
</label>
<label class="filter-fields__field">
<span class="filter-fields__label">{t('filter.modified_label', 'Modified')}</span>
<select data-testid="filter-date-select" bind:value={value.date}>
{#each DATES as opt (opt.v)}
<option value={opt.v}>{opt.l}</option>
{/each}
</select>
</label>
<label class="filter-fields__field">
<span class="filter-fields__label">{t('filter.created_label', 'Created')}</span>
<select data-testid="filter-created-select" bind:value={value.created}>
{#each DATES as opt (opt.v)}
<option value={opt.v}>{opt.l}</option>
{/each}
</select>
</label>
{#if !hideRecursive}
<label class="filter-fields__check">
<input
type="checkbox"
data-testid="filter-recursive-checkbox"
bind:checked={value.recursive}
/>
<span>{t('filter.recursive', 'Include subfolders')}</span>
</label>
{/if}
</div>
<style>
.filter-fields {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
}
/* Panel shape: two even columns, so five selects plus the toggle do not
run off the width of the top bar's search slot. */
.filter-fields--panel {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-2);
}
.filter-fields__field {
display: flex;
align-items: center;
gap: 0.4rem;
min-width: 0;
}
.filter-fields__label {
color: var(--color-text-secondary);
font-size: var(--text-sm);
white-space: nowrap;
}
.filter-fields__field select {
flex: 1;
min-width: 0;
padding: 0.3rem 0.4rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg-surface);
color: var(--color-text);
font-size: var(--text-sm);
max-width: 9rem;
}
.filter-fields__check {
display: flex;
align-items: center;
gap: 0.4rem;
color: var(--color-text);
font-size: var(--text-sm);
cursor: pointer;
white-space: nowrap;
}
@media (width <= 768px) {
.filter-fields--panel {
grid-template-columns: minmax(0, 1fr);
}
}
</style>
@@ -1,18 +1,22 @@
<script lang="ts"> <script lang="ts">
import FilterFields from '$lib/components/FilterFields.svelte';
import Icon from '$lib/icons/Icon.svelte'; import Icon from '$lib/icons/Icon.svelte';
import { t } from '$lib/i18n/index.svelte'; import { t } from '$lib/i18n/index.svelte';
import { import {
activeFilterCount,
defaultFilterState, defaultFilterState,
type DateKey, type ResourceFilterState
type ResourceFilterState,
type SizeKey,
type TypeKey
} from '$lib/utils/searchFilters'; } from '$lib/utils/searchFilters';
interface Props { interface Props {
/** Bindable full filter state (keyword + toggles + presets). */ /**
* Full filter state (keyword + toggles + presets), mutated in place —
* in practice the shared store proxy from `$lib/stores/filter.svelte`.
* Deliberately NOT `$bindable`: there is exactly one state object and
* nobody reassigns it, so a two-way binding contract would be noise.
*/
value?: ResourceFilterState; value?: ResourceFilterState;
/** Advanced section (type/size/date/recursive) expanded. */ /** Advanced section (presets + recursive) expanded. */
expanded?: boolean; expanded?: boolean;
placeholder?: string; placeholder?: string;
/** Debounce for the keyword input (ms). */ /** Debounce for the keyword input (ms). */
@@ -22,44 +26,22 @@
} }
let { let {
value = $bindable(defaultFilterState()), value = defaultFilterState(),
expanded = $bindable(false), expanded = $bindable(false),
placeholder = t('filter.placeholder', 'Search this folder and subfolders…'), placeholder = t('filter.placeholder', 'Search this folder and subfolders…'),
debounceMs = 300, debounceMs = 300,
hideRecursive = false hideRecursive = false
}: Props = $props(); }: Props = $props();
// Option label lists reuse the /search page's i18n keys — the vocabularies
// themselves (extensions, byte bounds) live in the shared searchFilters util.
const TYPES: { v: TypeKey; l: string }[] = [
{ v: 'all', l: t('search.type.all', 'All types') },
{ v: 'image', l: t('search.type.image', 'Images') },
{ v: 'video', l: t('search.type.video', 'Videos') },
{ v: 'document', l: t('search.type.document', 'Documents') },
{ v: 'audio', l: t('search.type.audio', 'Audio') },
{ v: 'archive', l: t('search.type.archive', 'Archives') }
];
const SIZES: { v: SizeKey; l: string }[] = [
{ v: 'all', l: t('search.size.all', 'Any size') },
{ v: 'small', l: t('search.size.small', '< 1 MB') },
{ v: 'medium', l: t('search.size.medium', '1–100 MB') },
{ v: 'large', l: t('search.size.large', '> 100 MB') }
];
const DATES: { v: DateKey; l: string }[] = [
{ v: 'all', l: t('search.date.all', 'Any time') },
{ v: 'day', l: t('search.date.day', 'Past 24 hours') },
{ v: 'week', l: t('search.date.week', 'Past week') },
{ v: 'month', l: t('search.date.month', 'Past month') },
{ v: 'year', l: t('search.date.year', 'Past year') }
];
// Keyword buffer: typing updates the buffer immediately (responsive input) // Keyword buffer: typing updates the buffer immediately (responsive input)
// and pushes into `value.query` debounced, so a keystroke doesn't fire a // and pushes into `value.query` debounced, so a keystroke doesn't fire a
// backend search per character. `lastPushed` disambiguates our own pushes // backend search per character. `lastPushed` disambiguates our own pushes
// from external writes (e.g. the page's clear-filter Escape path), which // from external writes (e.g. the page's clear-filter Escape path, or a
// flow back into the buffer via the sync effect below. // URL hydration of a deep link like /files/…?q=abc), which flow back into
let keyword = $state(value.query); // the buffer via the sync effect below — including on first mount, which
let lastPushed = value.query; // is why both start empty instead of capturing `value.query` here.
let keyword = $state('');
let lastPushed = '';
let timer: ReturnType<typeof setTimeout> | null = null; let timer: ReturnType<typeof setTimeout> | null = null;
$effect(() => { $effect(() => {
@@ -119,9 +101,7 @@
pushKeyword(''); pushKeyword('');
} }
const activeCount = $derived( const activeCount = $derived(activeFilterCount(value));
(value.type !== 'all' ? 1 : 0) + (value.size !== 'all' ? 1 : 0) + (value.date !== 'all' ? 1 : 0)
);
</script> </script>
<div class="sfb" data-testid="search-filter-bar"> <div class="sfb" data-testid="search-filter-bar">
@@ -168,37 +148,8 @@
</div> </div>
{#if expanded} {#if expanded}
<div class="sfb__advanced" data-testid="filter-advanced-row"> <div data-testid="filter-advanced-row">
<label class="sfb__field"> <FilterFields bind:value {hideRecursive} />
<span class="sfb__label">{t('search.type_label', 'Type')}</span>
<select bind:value={value.type}>
{#each TYPES as opt (opt.v)}
<option value={opt.v}>{opt.l}</option>
{/each}
</select>
</label>
<label class="sfb__field">
<span class="sfb__label">{t('search.size_label', 'Size')}</span>
<select bind:value={value.size}>
{#each SIZES as opt (opt.v)}
<option value={opt.v}>{opt.l}</option>
{/each}
</select>
</label>
<label class="sfb__field">
<span class="sfb__label">{t('search.date_label', 'Date')}</span>
<select bind:value={value.date}>
{#each DATES as opt (opt.v)}
<option value={opt.v}>{opt.l}</option>
{/each}
</select>
</label>
{#if !hideRecursive}
<label class="sfb__check">
<input type="checkbox" bind:checked={value.recursive} />
<span>{t('filter.recursive', 'Include subfolders')}</span>
</label>
{/if}
</div> </div>
{/if} {/if}
</div> </div>
@@ -312,43 +263,4 @@
line-height: 1rem; line-height: 1rem;
text-align: center; text-align: center;
} }
.sfb__advanced {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
}
.sfb__field {
display: flex;
align-items: center;
gap: 0.4rem;
}
.sfb__label {
color: var(--color-text-secondary);
font-size: var(--text-sm);
white-space: nowrap;
}
.sfb__field select {
padding: 0.3rem 0.4rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg-surface);
color: var(--color-text);
font-size: var(--text-sm);
max-width: 9rem;
}
.sfb__check {
display: flex;
align-items: center;
gap: 0.4rem;
color: var(--color-text);
font-size: var(--text-sm);
cursor: pointer;
white-space: nowrap;
}
</style> </style>
@@ -0,0 +1,122 @@
<!--
The top bar's filter dropdown — the panel that opens from the button inside
the search input. A shell around the shared `FilterFields` plus a
clear-presets footer; positioning assumes it is rendered inside
`.search-container` (position: relative), same stacking trick as the
suggestions list.
Mutates the shared state proxy in place — plain prop, not `$bindable` (see
FilterChips for the rationale).
-->
<script lang="ts">
import FilterFields from '$lib/components/FilterFields.svelte';
import Icon from '$lib/icons/Icon.svelte';
import { t } from '$lib/i18n/index.svelte';
import { activeFilterCount, type ResourceFilterState } from '$lib/utils/searchFilters';
interface Props {
value: ResourceFilterState;
/** Dismisses the panel (the clear button keeps it open on the files page). */
onclose: () => void;
}
let { value, onclose }: Props = $props();
const activeCount = $derived(activeFilterCount(value));
function clearPresets() {
for (const dim of ['type', 'size', 'date', 'created', 'kind'] as const) {
value[dim] = 'all';
}
value.recursive = true;
}
</script>
<div
class="tfp"
data-testid="appshell-filter-panel"
role="group"
aria-label={t('filter.advanced', 'Filters')}
>
<FilterFields layout="panel" {value} />
<div class="tfp__footer">
<button
class="tfp__clear"
type="button"
data-testid="appshell-filter-clear-btn"
disabled={activeCount === 0 && value.recursive}
onclick={clearPresets}
>
<Icon name="times" />
{t('search.clear_filters', 'Clear filters')}
</button>
<button
class="tfp__done"
type="button"
data-testid="appshell-filter-done-btn"
onclick={onclose}
>
{t('common.done', 'Done')}
</button>
</div>
</div>
<style>
.tfp {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
z-index: var(--z-dropdown);
display: flex;
flex-direction: column;
gap: var(--space-2);
padding: var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg, var(--radius-md));
background: var(--color-bg-surface);
box-shadow: var(--shadow-lg, 0 10px 30px var(--color-overlay-shadow));
}
.tfp__footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
}
.tfp__clear,
.tfp__done {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.3rem 0.6rem;
border: none;
border-radius: var(--radius-sm);
background: none;
font-size: var(--text-sm);
cursor: pointer;
}
.tfp__clear {
color: var(--color-accent);
}
.tfp__clear:disabled {
color: var(--color-text-secondary);
cursor: default;
}
.tfp__clear:not(:disabled):hover {
background: var(--color-bg-hover);
}
.tfp__done {
background: var(--color-accent);
color: var(--color-on-accent);
}
.tfp__done:hover {
filter: brightness(1.05);
}
</style>
@@ -0,0 +1,60 @@
/**
* Two-way sync between the shared filter store and the current page's URL.
*
* The store is the single source of truth; the URL is its projection. One
* `$effect` serves both directions, disambiguated by tracking the last URL
* search string we processed:
*
* - URL changed (navigation, Back/Forward, a shared link) → the store adopts
* the URL's filter params via `hydrateFilters`.
* - Store changed (a select moved, keyword debounced in) → the new state is
* written back with `goto(..., { replaceState: true })` so refresh,
* bookmarks and Back all see it — without adding a history entry per edit.
*
* Convergence: after a write, `page.url` catches up to what we wrote, the
* `lastSeenSearch` guard recognizes it as our own echo and the store is left
* alone — the loop settles in one round-trip instead of ping-ponging.
*
* `includeQuery` decides who owns `?q=`: true on the files page (the filter
* bar's keyword belongs in the URL there), false on /search (the top-bar
* search box writes `?q=` itself; a second writer would fight it).
*/
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { untrack } from 'svelte';
import { hydrateFilters, resourceFilters } from '$lib/stores/filter.svelte';
import { filterSearchOver, filtersFromParams } from '$lib/utils/searchFilters';
export function useFilterUrlSync(includeQuery: boolean): void {
let lastSeenSearch: string | null = null;
$effect(() => {
const current = page.url;
if (current.search !== lastSeenSearch) {
// External URL change → URL wins. `untrack` because `hydrateFilters`
// mutates the store we read below; the mutation schedules this
// effect's next run, where `lastSeenSearch` already matches and the
// adoption is skipped.
lastSeenSearch = current.search;
untrack(() => hydrateFilters(filtersFromParams(current.searchParams)));
}
// Store → URL: overwrite only the filter params so the surface's own
// params (`?file=`, `in`, `scope`) survive untouched. The desired
// search string is composed in `searchFilters` — a throwaway `new URL`
// copy here would trip svelte/prefer-svelte-reactivity.
const desired = filterSearchOver(current.searchParams, resourceFilters, includeQuery);
if (desired !== current.search) {
lastSeenSearch = desired;
// Same-origin path/search/hash built from page.url; resolve() only
// accepts a route string, so it can't type a dynamic URL.
// eslint-disable-next-line svelte/no-navigation-without-resolve
void goto(`${current.pathname}${desired}${current.hash}`, {
replaceState: true,
noScroll: true,
keepFocus: true
});
}
});
}
+36
View File
@@ -0,0 +1,36 @@
/**
* Shared resource-filter state for the search-backed surfaces.
*
* One module-level `$state` object is THE filter state: the files page's
* `SearchFilterBar`, the top bar's filter panel, and the /search chips all
* read and mutate this same proxy, and each surface projects it into its URL
* (see `useFilterUrlSync`). Exported as a `const` — callers mutate fields,
* never reassign, which is also what keeps every holder looking at the same
* proxy.
*/
import {
clearFilterState,
defaultFilterState,
type ResourceFilterState
} from '$lib/utils/searchFilters';
export const resourceFilters: ResourceFilterState = $state(defaultFilterState());
/**
* Adopt `next` field-by-field (never reassign — the proxy identity is the
* contract every surface binds to).
*/
export function hydrateFilters(next: ResourceFilterState): void {
resourceFilters.query = next.query;
resourceFilters.recursive = next.recursive;
resourceFilters.type = next.type;
resourceFilters.size = next.size;
resourceFilters.date = next.date;
resourceFilters.created = next.created;
resourceFilters.kind = next.kind;
}
/** Reset every dimension (keyword included) in place. */
export function resetFilters(): void {
clearFilterState(resourceFilters);
}
+3 -1
View File
@@ -112,7 +112,9 @@
.search-container input { .search-container input {
width: 100%; width: 100%;
padding: var(--space-3) 50px var(--space-3) var(--space-11); /* Right padding reserves the three in-field controls: submit (6px),
filter toggle (44px), clear × (72px). */
padding: var(--space-3) 104px var(--space-3) var(--space-11);
border-radius: var(--radius-2xl); border-radius: var(--radius-2xl);
border: 2px solid var(--color-border); border: 2px solid var(--color-border);
background-color: var(--color-bg-input); background-color: var(--color-bg-input);
+197 -1
View File
@@ -1,9 +1,18 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { import {
activeFilterCount,
activeFilters,
applyFilterParams,
clearFilterDimension,
clearFilterState, clearFilterState,
dateBound, dateBound,
defaultFilterState, defaultFilterState,
filterParamEntries,
filterParamsToString,
filterSearchOver,
filtersFromParams,
filterToSearchOptions, filterToSearchOptions,
filterValueLabelKey,
isFilterActive, isFilterActive,
sizeBounds, sizeBounds,
TYPE_EXT TYPE_EXT
@@ -58,6 +67,8 @@ describe('isFilterActive', () => {
expect(isFilterActive({ ...defaultFilterState(), type: 'image' })).toBe(true); expect(isFilterActive({ ...defaultFilterState(), type: 'image' })).toBe(true);
expect(isFilterActive({ ...defaultFilterState(), size: 'small' })).toBe(true); expect(isFilterActive({ ...defaultFilterState(), size: 'small' })).toBe(true);
expect(isFilterActive({ ...defaultFilterState(), date: 'week' })).toBe(true); expect(isFilterActive({ ...defaultFilterState(), date: 'week' })).toBe(true);
expect(isFilterActive({ ...defaultFilterState(), created: 'week' })).toBe(true);
expect(isFilterActive({ ...defaultFilterState(), kind: 'folder' })).toBe(true);
}); });
it('ignores the recursive toggle', () => { it('ignores the recursive toggle', () => {
@@ -65,6 +76,51 @@ describe('isFilterActive', () => {
}); });
}); });
describe('activeFilters', () => {
it('is empty for the default state', () => {
expect(activeFilters(defaultFilterState())).toEqual([]);
expect(activeFilterCount(defaultFilterState())).toBe(0);
});
it('lists live dimensions in URL-key order', () => {
const f = {
...defaultFilterState(),
kind: 'file' as const,
type: 'image' as const,
date: 'week' as const
};
expect(activeFilters(f)).toEqual([
{ key: 'type', value: 'image' },
{ key: 'date', value: 'week' },
{ key: 'kind', value: 'file' }
]);
expect(activeFilterCount(f)).toBe(3);
});
it('does not count the keyword', () => {
expect(activeFilterCount({ ...defaultFilterState(), query: 'report' })).toBe(0);
});
});
describe('clearFilterDimension', () => {
it('resets only the named dimension', () => {
const f = { ...defaultFilterState(), type: 'image' as const, kind: 'folder' as const };
clearFilterDimension(f, 'kind');
expect(f.kind).toBe('all');
expect(f.type).toBe('image');
});
});
describe('filterValueLabelKey', () => {
it('names each dimension from its own i18n family', () => {
expect(filterValueLabelKey('type', 'image')).toBe('search.type.image');
expect(filterValueLabelKey('size', 'large')).toBe('search.size.large');
expect(filterValueLabelKey('date', 'week')).toBe('search.date.week');
expect(filterValueLabelKey('created', 'week')).toBe('search.date.week');
expect(filterValueLabelKey('kind', 'folder')).toBe('filter.kind.folder');
});
});
describe('clearFilterState', () => { describe('clearFilterState', () => {
it('resets every field in place', () => { it('resets every field in place', () => {
const f = { ...defaultFilterState(), query: 'a', recursive: false, type: 'video' as const }; const f = { ...defaultFilterState(), query: 'a', recursive: false, type: 'video' as const };
@@ -80,6 +136,8 @@ describe('filterToSearchOptions', () => {
minSize: undefined, minSize: undefined,
maxSize: undefined, maxSize: undefined,
modifiedAfter: undefined, modifiedAfter: undefined,
createdAfter: undefined,
resourceTypes: undefined,
recursive: true recursive: true
}); });
}); });
@@ -90,12 +148,150 @@ describe('filterToSearchOptions', () => {
recursive: false, recursive: false,
type: 'archive', type: 'archive',
size: 'medium', size: 'medium',
date: 'month' date: 'month',
created: 'day',
kind: 'folder'
}); });
expect(opts.fileTypes).toEqual(TYPE_EXT.archive); expect(opts.fileTypes).toEqual(TYPE_EXT.archive);
expect(opts.minSize).toBe(MB); expect(opts.minSize).toBe(MB);
expect(opts.maxSize).toBe(100 * MB); expect(opts.maxSize).toBe(100 * MB);
expect(opts.modifiedAfter).toBe(dateBound('month')); expect(opts.modifiedAfter).toBe(dateBound('month'));
expect(opts.createdAfter).toBe(dateBound('day'));
expect(opts.resourceTypes).toEqual(['folder']);
expect(opts.recursive).toBe(false); expect(opts.recursive).toBe(false);
}); });
}); });
describe('filtersFromParams', () => {
it('returns the defaults for an empty query string', () => {
expect(filtersFromParams(new URLSearchParams(''))).toEqual(defaultFilterState());
});
it('reads every dimension', () => {
const f = filtersFromParams(
new URLSearchParams('q=report&type=image&size=large&date=week&created=day&kind=folder')
);
expect(f).toEqual({
query: 'report',
recursive: true,
type: 'image',
size: 'large',
date: 'week',
created: 'day',
kind: 'folder'
});
});
it('falls back to the default for unknown values instead of erroring', () => {
const f = filtersFromParams(new URLSearchParams('type=pdf&size=huge&kind=folderz'));
expect(f.type).toBe('all');
expect(f.size).toBe('all');
expect(f.kind).toBe('all');
});
it('treats recursive=0 as off and anything else as on', () => {
expect(filtersFromParams(new URLSearchParams('recursive=0')).recursive).toBe(false);
expect(filtersFromParams(new URLSearchParams('recursive=1')).recursive).toBe(true);
});
it('ignores params that belong to the surface', () => {
const f = filtersFromParams(new URLSearchParams('in=abc&scope=all&file=xyz'));
expect(f).toEqual(defaultFilterState());
});
});
describe('applyFilterParams', () => {
it('writes only the non-default dimensions', () => {
const f = { ...defaultFilterState(), type: 'image' as const, kind: 'file' as const };
const p = new URLSearchParams('');
applyFilterParams(p, f);
expect(p.toString()).toBe('type=image&kind=file');
});
it('keeps the surface-owned params around it', () => {
const f = { ...defaultFilterState(), size: 'small' as const, recursive: false };
const p = new URLSearchParams('in=abc&scope=all');
applyFilterParams(p, f);
expect(p.get('in')).toBe('abc');
expect(p.get('scope')).toBe('all');
expect(p.get('size')).toBe('small');
expect(p.get('recursive')).toBe('0');
});
it('drops a dimension that went back to its default', () => {
const p = new URLSearchParams('type=image&kind=folder');
applyFilterParams(p, defaultFilterState());
expect(p.toString()).toBe('');
});
it('leaves the keyword alone unless asked', () => {
const p = new URLSearchParams('q=report');
applyFilterParams(p, defaultFilterState());
expect(p.get('q')).toBe('report');
const p2 = new URLSearchParams('q=report');
applyFilterParams(p2, { ...defaultFilterState(), query: 'plan' }, true);
expect(p2.get('q')).toBe('plan');
});
it('round-trips through filtersFromParams', () => {
const f = {
query: 'report',
recursive: false,
type: 'video' as const,
size: 'medium' as const,
date: 'month' as const,
created: 'year' as const,
kind: 'file' as const
};
const p = new URLSearchParams('');
applyFilterParams(p, f, true);
expect(filtersFromParams(p)).toEqual(f);
});
});
describe('filterParamsToString', () => {
it('omits the keyword by default and encodes values', () => {
expect(filterParamsToString({ ...defaultFilterState(), query: 'a b' })).toBe('');
expect(
filterParamsToString({ ...defaultFilterState(), query: 'a b', type: 'image' }, true)
).toBe('q=a%20b&type=image');
});
});
describe('filterParamEntries', () => {
it('lists every live dimension as a param pair', () => {
const f = {
...defaultFilterState(),
type: 'image' as const,
size: 'large' as const,
date: 'week' as const,
created: 'month' as const,
kind: 'folder' as const,
recursive: false
};
expect(filterParamEntries(f)).toEqual([
['type', 'image'],
['size', 'large'],
['date', 'week'],
['created', 'month'],
['kind', 'folder'],
['recursive', '0']
]);
});
});
describe('filterSearchOver', () => {
it('overwrites filter params onto a base and keeps foreign params', () => {
const base = new URLSearchParams('?file=abc&in=dir1&type=video');
expect(filterSearchOver(base, { ...defaultFilterState(), type: 'image' })).toBe(
'?file=abc&in=dir1&type=image'
);
});
it('collapses an all-defaults result to an empty search string', () => {
const base = new URLSearchParams('?type=video&q=x');
// includeQuery=true — with false, `?q=` belongs to the search box and
// survives the overwrite by design.
expect(filterSearchOver(base, defaultFilterState(), true)).toBe('');
});
});
+192 -11
View File
@@ -1,15 +1,22 @@
// Shared resource-filter model for search-backed list views. // Shared resource-filter model for search-backed list views.
// //
// Extracted from the /search page so the files page's filter bar and // One source of truth for the preset vocabularies (type / size / date /
// /search's filter selects share one source of truth for the preset // created / kind), their mapping onto `SearchOptions`, and their
// vocabularies (type / size / date) and their mapping onto // serialization in and out of the URL. The files page's filter bar, the
// `SearchOptions`. Pure functions only — no runes here, so the module is // top bar's filter panel and the /search page all bind the same state
// unit-testable without component scaffolding. // object, and the URL is its projection — see `filtersFromParams` /
// `filterParamsInto`. Pure functions only — no runes here, so the module
// is unit-testable without component scaffolding.
import type { SearchOptions } from '$lib/api/endpoints/search'; import type { SearchOptions } from '$lib/api/endpoints/search';
export type TypeKey = 'all' | 'image' | 'video' | 'document' | 'audio' | 'archive'; export type TypeKey = 'all' | 'image' | 'video' | 'document' | 'audio' | 'archive';
export type SizeKey = 'all' | 'small' | 'medium' | 'large'; export type SizeKey = 'all' | 'small' | 'medium' | 'large';
export type DateKey = 'all' | 'day' | 'week' | 'month' | 'year'; export type DateKey = 'all' | 'day' | 'week' | 'month' | 'year';
/** Files, folders, or both — maps to the backend's `resource_types`. */
export type KindKey = 'all' | 'file' | 'folder';
/** The filter dimensions carried in the URL, in URL-key order. */
export type FilterDimension = 'type' | 'size' | 'date' | 'created' | 'kind';
/** Full filter state for a search-backed resource list. */ /** Full filter state for a search-backed resource list. */
export interface ResourceFilterState { export interface ResourceFilterState {
@@ -19,10 +26,21 @@ export interface ResourceFilterState {
type: TypeKey; type: TypeKey;
size: SizeKey; size: SizeKey;
date: DateKey; date: DateKey;
/** Created-time preset, independent of the modified-time one. */
created: DateKey;
kind: KindKey;
} }
export function defaultFilterState(): ResourceFilterState { export function defaultFilterState(): ResourceFilterState {
return { query: '', recursive: true, type: 'all', size: 'all', date: 'all' }; return {
query: '',
recursive: true,
type: 'all',
size: 'all',
date: 'all',
created: 'all',
kind: 'all'
};
} }
export const TYPE_EXT: Record<Exclude<TypeKey, 'all'>, string[]> = { export const TYPE_EXT: Record<Exclude<TypeKey, 'all'>, string[]> = {
@@ -66,7 +84,52 @@ export function dateBound(k: DateKey): number | undefined {
/** True when any filter dimension would change the result set. */ /** True when any filter dimension would change the result set. */
export function isFilterActive(f: ResourceFilterState): boolean { export function isFilterActive(f: ResourceFilterState): boolean {
return f.query.trim() !== '' || f.type !== 'all' || f.size !== 'all' || f.date !== 'all'; return f.query.trim() !== '' || activeFilterCount(f) > 0;
}
/**
* Number of active preset dimensions (the keyword is counted separately —
* it has its own clear affordance). Drives the filter button's badge.
*/
export function activeFilterCount(f: ResourceFilterState): number {
return activeFilters(f).length;
}
/** The dimensions currently differing from their default, URL-key ordered. */
export function activeFilters(f: ResourceFilterState): { key: FilterDimension; value: string }[] {
const out: { key: FilterDimension; value: string }[] = [];
if (f.type !== 'all') out.push({ key: 'type', value: f.type });
if (f.size !== 'all') out.push({ key: 'size', value: f.size });
if (f.date !== 'all') out.push({ key: 'date', value: f.date });
if (f.created !== 'all') out.push({ key: 'created', value: f.created });
if (f.kind !== 'all') out.push({ key: 'kind', value: f.kind });
return out;
}
/**
* i18n key naming a dimension's current value, so chips and labels read
* from one vocabulary. `created` reuses the modified-time preset labels
* ("Past week" reads the same for either timestamp) and `kind` gets its
* own family.
*/
export function filterValueLabelKey(key: FilterDimension, value: string): string {
if (key === 'created') return `search.date.${value}`;
if (key === 'kind') return `filter.kind.${value}`;
return `search.${key}.${value}`;
}
/** i18n key naming a dimension itself (the chip's "Type:" prefix). */
export function filterDimensionLabelKey(key: FilterDimension): string {
if (key === 'created') return 'filter.created_label';
if (key === 'kind') return 'filter.kind_label';
if (key === 'date') return 'filter.modified_label';
return `search.${key}_label`;
}
/** Reset one dimension to its default (the chip's dismiss action). */
export function clearFilterDimension(f: ResourceFilterState, key: FilterDimension): void {
if (key === 'kind') f.kind = 'all';
else f[key] = 'all';
} }
/** Reset every dimension in place (runes-friendly — mutates the $state proxy). */ /** Reset every dimension in place (runes-friendly — mutates the $state proxy). */
@@ -76,21 +139,139 @@ export function clearFilterState(f: ResourceFilterState): void {
f.type = 'all'; f.type = 'all';
f.size = 'all'; f.size = 'all';
f.date = 'all'; f.date = 'all';
f.created = 'all';
f.kind = 'all';
} }
/** /**
* Map the filter state onto the search-wire options. Scope (folderId) and * Map the filter state onto the search-wire options. Scope (folderId) and
* sorting stay the caller's concern — they differ per surface. The date * sorting stay the caller's concern — they differ per surface.
* preset maps to `modifiedAfter` (its labels read "Past N", which matches
* modified-time semantics); created-time bounds are a possible follow-up.
*/ */
export function filterToSearchOptions( export function filterToSearchOptions(
f: ResourceFilterState f: ResourceFilterState
): Pick<SearchOptions, 'fileTypes' | 'minSize' | 'maxSize' | 'modifiedAfter' | 'recursive'> { ): Pick<
SearchOptions,
| 'fileTypes'
| 'minSize'
| 'maxSize'
| 'modifiedAfter'
| 'createdAfter'
| 'resourceTypes'
| 'recursive'
> {
return { return {
fileTypes: f.type === 'all' ? undefined : TYPE_EXT[f.type], fileTypes: f.type === 'all' ? undefined : TYPE_EXT[f.type],
...sizeBounds(f.size), ...sizeBounds(f.size),
modifiedAfter: dateBound(f.date), modifiedAfter: dateBound(f.date),
createdAfter: dateBound(f.created),
resourceTypes: f.kind === 'all' ? undefined : [f.kind],
recursive: f.recursive recursive: f.recursive
}; };
} }
// ---------------------------------------------------------------------------
// URL projection
//
// The URL carries the UI preset keys (`type=image`), never the expanded wire
// values (extension lists, byte ranges) — the URL stays readable and keeps
// working when a vocabulary grows. Only non-default dimensions are written,
// and an unrecognized value degrades to the default rather than erroring.
// ---------------------------------------------------------------------------
const TYPE_KEYS: readonly TypeKey[] = ['all', 'image', 'video', 'document', 'audio', 'archive'];
const SIZE_KEYS: readonly SizeKey[] = ['all', 'small', 'medium', 'large'];
const DATE_KEYS: readonly DateKey[] = ['all', 'day', 'week', 'month', 'year'];
const KIND_KEYS: readonly KindKey[] = ['all', 'file', 'folder'];
/** Every param this module owns, so a write can clear the stale ones first. */
const FILTER_PARAM_KEYS = ['q', 'type', 'size', 'date', 'created', 'kind', 'recursive'] as const;
function pickOne<T extends string>(raw: string | null, allowed: readonly T[], fallback: T): T {
return raw !== null && (allowed as readonly string[]).includes(raw) ? (raw as T) : fallback;
}
/**
* Read the filter dimensions out of URL params. Every other param (`in`,
* `scope`, `file`, …) belongs to the surface and is left untouched; missing
* or unknown values fall back to the default for that dimension.
*/
export function filtersFromParams(params: URLSearchParams): ResourceFilterState {
return {
query: params.get('q')?.trim() ?? '',
recursive: params.get('recursive') !== '0',
type: pickOne(params.get('type'), TYPE_KEYS, 'all'),
size: pickOne(params.get('size'), SIZE_KEYS, 'all'),
date: pickOne(params.get('date'), DATE_KEYS, 'all'),
created: pickOne(params.get('created'), DATE_KEYS, 'all'),
kind: pickOne(params.get('kind'), KIND_KEYS, 'all')
};
}
/**
* The query-string entries for a filter state, defaults omitted.
*
* `q` is opt-in: on the files page the keyword belongs to the filter bar, but
* on /search the search box owns `?q=` and already writes it — emitting it
* from the state there would put two writers on one param.
*/
export function filterParamEntries(
f: ResourceFilterState,
includeQuery = false
): [string, string][] {
const out: [string, string][] = [];
const q = f.query.trim();
if (includeQuery && q) out.push(['q', q]);
if (f.type !== 'all') out.push(['type', f.type]);
if (f.size !== 'all') out.push(['size', f.size]);
if (f.date !== 'all') out.push(['date', f.date]);
if (f.created !== 'all') out.push(['created', f.created]);
if (f.kind !== 'all') out.push(['kind', f.kind]);
// `recursive` defaults to true, so only the override is worth carrying.
if (!f.recursive) out.push(['recursive', '0']);
return out;
}
/** The entries as a query string (no leading `?`), for hand-built targets. */
export function filterParamsToString(f: ResourceFilterState, includeQuery = false): string {
return filterParamEntries(f, includeQuery)
.map(([k, v]) => `${k}=${encodeURIComponent(v)}`)
.join('&');
}
/**
* Overwrite the filter params of `params` **in place** — the shape the pages
* need, since they write into a copy of `page.url` so the surface's own
* params (`?file=`, `in`, `scope`) survive untouched.
*/
export function applyFilterParams(
params: URLSearchParams,
f: ResourceFilterState,
includeQuery = false
): void {
for (const key of FILTER_PARAM_KEYS) {
// `?q=` is the search box's when the state does not own it — deleting a
// param we do not write would wipe the query the page is showing.
if (key === 'q' && !includeQuery) continue;
params.delete(key);
}
for (const [k, v] of filterParamEntries(f, includeQuery)) params.set(k, v);
}
/**
* The `url.search` string `base` carries once the filter params of `f` are
* overwritten onto it — everything else (`?file=`, `in`, `scope`) survives.
* An all-defaults result collapses to `''` so callers can compare the value
* directly against `page.url.search`. Composed here rather than at the call
* site so reactive modules can build the string without holding a mutable
* `URL` copy (which `svelte/prefer-svelte-reactivity` treats as state).
*/
export function filterSearchOver(
base: URLSearchParams,
f: ResourceFilterState,
includeQuery = false
): string {
const params = new URLSearchParams(base);
applyFilterParams(params, f, includeQuery);
const serialized = params.toString();
return serialized ? `?${serialized}` : '';
}
@@ -62,12 +62,13 @@
import { replaceSet } from '$lib/utils/sets'; import { replaceSet } from '$lib/utils/sets';
import { mapLimit } from '$lib/utils/mapLimit'; import { mapLimit } from '$lib/utils/mapLimit';
import { import {
defaultFilterState,
clearFilterState, clearFilterState,
filterParamsToString,
isFilterActive, isFilterActive,
filterToSearchOptions, filterToSearchOptions
type ResourceFilterState
} from '$lib/utils/searchFilters'; } from '$lib/utils/searchFilters';
import { resourceFilters } from '$lib/stores/filter.svelte';
import { useFilterUrlSync } from '$lib/composables/useFilterUrlSync.svelte';
import { searchResources } from '$lib/api/endpoints/search'; import { searchResources } from '$lib/api/endpoints/search';
import { import {
useResourceActions, useResourceActions,
@@ -278,7 +279,11 @@
typeof localStorage !== 'undefined' ? localStorage.getItem('oxi-last-drive-root') : null; typeof localStorage !== 'undefined' ? localStorage.getItem('oxi-last-drive-root') : null;
const target = last ?? home; const target = last ?? home;
if (target) { if (target) {
await goto(resolve(`/files/${target}`), { replaceState: true }); // `folderTarget` keeps any deep-linked filter params
// (`/files?type=image`) alive through the canonicalization.
// folderTarget embeds resolve(); the rule can't see through it.
// eslint-disable-next-line svelte/no-navigation-without-resolve
await goto(folderTarget(target), { replaceState: true });
return; return;
} }
} }
@@ -458,8 +463,25 @@
// searches (type/size/date, no keyword) work; its Tantivy content index // searches (type/size/date, no keyword) work; its Tantivy content index
// additionally requires ≥2 chars before it engages, so empty queries stay // additionally requires ≥2 chars before it engages, so empty queries stay
// name/filter-driven. // name/filter-driven.
let filter = $state<ResourceFilterState>(defaultFilterState()); //
// `filter` is the module-level shared store (`$lib/stores/filter.svelte`)
// — the same object the top bar's filter panel mutates, so both surfaces
// always agree. `useFilterUrlSync(true)` mirrors it to/from this page's
// URL (`?q=…&type=…&recursive=0`), which is what makes a filtered view
// refresh-proof and Back-button-restoreable. Local alias kept so the
// search-mode code below reads unchanged.
const filter = resourceFilters;
useFilterUrlSync(true);
const searchActive = $derived(isFilterActive(filter)); const searchActive = $derived(isFilterActive(filter));
/** Folder-entry URL that carries the active filter params along (the
* filter used to live in component state and survived folder navigation;
* URL state needs the params forwarded explicitly). */
function folderTarget(id: string): string {
const base = resolve(`/files/${id}`);
const qs = filterParamsToString(filter, true);
return qs ? `${base}?${qs}` : base;
}
let searchItems = $state<Array<FileItem | FolderItem>>([]); let searchItems = $state<Array<FileItem | FolderItem>>([]);
let searchCursor = $state<string | undefined>(undefined); let searchCursor = $state<string | undefined>(undefined);
let searchSeq = 0; let searchSeq = 0;
@@ -606,8 +628,11 @@
function openFolder(folder: FolderItem) { function openFolder(folder: FolderItem) {
// Canonical single-id URL. Legacy `/files/A/B/C` still resolves // Canonical single-id URL. Legacy `/files/A/B/C` still resolves
// (canonicalize-on-load rewrites it inside `load()`), but new // (canonicalize-on-load rewrites it inside `load()`), but new
// navigation lands directly on `/files/{id}`. // navigation lands directly on `/files/{id}`. Active filter params
goto(resolve(`/files/${folder.id}`)); // ride along (`folderTarget`) so filtering survives folder entry.
// folderTarget embeds resolve(); the rule can't see through it.
// eslint-disable-next-line svelte/no-navigation-without-resolve
goto(folderTarget(folder.id));
} }
async function onNewFolder() { async function onNewFolder() {
@@ -1931,12 +1956,16 @@
// inputs change: any filter dimension, the folder it's scoped to // inputs change: any filter dimension, the folder it's scoped to
// (`currentId`, resolved by `load()` above), or the sort dimension. // (`currentId`, resolved by `load()` above), or the sort dimension.
// Inactive (plain folder listing) is the no-op fast path. `filter` is // Inactive (plain folder listing) is the no-op fast path. `filter` is
// a `$state` proxy — the field reads are what register the deps. // the shared store's `$state` proxy — the field reads are what register
// the deps (and `useFilterUrlSync` above runs first in the same flush,
// so URL hydration lands before this fires).
$effect(() => { $effect(() => {
void filter.query; void filter.query;
void filter.type; void filter.type;
void filter.size; void filter.size;
void filter.date; void filter.date;
void filter.created;
void filter.kind;
void filter.recursive; void filter.recursive;
void currentId; void currentId;
void sortField; void sortField;
@@ -1986,9 +2015,11 @@
<!-- Fuzzy filter bar: scoped keyword + type/size/date presets over the <!-- Fuzzy filter bar: scoped keyword + type/size/date presets over the
current folder (recursive toggle inside). While any dimension is current folder (recursive toggle inside). While any dimension is
active the listing below switches from the folder page to the active the listing below switches from the folder page to the
search results; clearing it returns to the plain folder view. --> search results; clearing it returns to the plain folder view. The
bar mutates the shared store proxy in place — no bind, the state
lives in `$lib/stores/filter.svelte`. -->
<div class="files-filter-row"> <div class="files-filter-row">
<SearchFilterBar bind:value={filter} /> <SearchFilterBar value={filter} />
</div> </div>
<!-- Hidden upload inputs stay mounted even while the batch bar is shown. <!-- Hidden upload inputs stay mounted even while the batch bar is shown.
@@ -2302,7 +2333,8 @@
const id = ctxTarget!.id; const id = ctxTarget!.id;
closeContext(); closeContext();
// Canonical single-id URL — see `openFolder` above. // Canonical single-id URL — see `openFolder` above.
goto(resolve(`/files/${id}`)); // eslint-disable-next-line svelte/no-navigation-without-resolve
goto(folderTarget(id));
}}><Icon name="folder-open" /> {t('files.open', 'Open')}</button }}><Icon name="folder-open" /> {t('files.open', 'Open')}</button
> >
<button <button
+38
View File
@@ -321,3 +321,41 @@ it('shows the search result count while the filter is active', async () => {
const stat = await screen.findByTestId('files-folder-stat'); const stat = await screen.findByTestId('files-folder-stat');
expect(stat.textContent).toContain('1 results'); expect(stat.textContent).toContain('1 results');
}); });
it('hydrates filter presets from a deep-linked URL and runs the scoped search', async () => {
withListing();
pageState.url = new URL('http://localhost/files/home?type=image&kind=folder');
m(searchResources).mockResolvedValue({ items: [], query_time_ms: 1 });
render(FilesPage);
await waitFor(() => expect(fetchFolderPage).toHaveBeenCalled());
await waitFor(() => expect(searchResources).toHaveBeenCalled());
expect(m(searchResources).mock.calls[0][1]).toMatchObject({
folderId: 'home',
fileTypes: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'heic', 'avif', 'tiff'],
resourceTypes: ['folder']
});
// The bar's own select reflects the hydrated state (expand the row first).
await fireEvent.click(screen.getByTestId('filter-advanced-toggle-btn'));
expect((screen.getByTestId('filter-type-select') as HTMLSelectElement).value).toBe('image');
});
it('writes an edited filter back to the URL exactly once (no sync loop)', async () => {
withListing();
m(searchResources).mockResolvedValue({ items: [], query_time_ms: 1 });
render(FilesPage);
await waitFor(() => expect(fetchFolderPage).toHaveBeenCalled());
m(goto).mockClear();
vi.useFakeTimers();
await fireEvent.input(screen.getByTestId('filter-keyword-input'), {
target: { value: 'hello' }
});
await vi.advanceTimersByTimeAsync(400);
vi.useRealTimers();
// One replaceState write for the debounced keyword — and nothing else:
// the effect must recognize its own write and settle, not ping-pong.
expect(goto).toHaveBeenCalledTimes(1);
// The sync composable passes a path/search/hash string (a URL instance
// would trip svelte/prefer-svelte-reactivity in the .svelte.ts module).
const written = m(goto).mock.calls[0][0] as string;
expect(written).toContain('q=hello');
});
+48 -122
View File
@@ -22,17 +22,20 @@
import type { FileItem, FolderItem, SearchResourceItem, SortBy } from '$lib/api/types'; import type { FileItem, FolderItem, SearchResourceItem, SortBy } from '$lib/api/types';
import { lazyComponent } from '$lib/composables/lazyComponent.svelte'; import { lazyComponent } from '$lib/composables/lazyComponent.svelte';
import { folderAccessCached, probeFolderAccess } from '$lib/utils/folderAccess'; import { folderAccessCached, probeFolderAccess } from '$lib/utils/folderAccess';
import { TYPE_EXT, dateBound, sizeBounds } from '$lib/utils/searchFilters'; import { filterToSearchOptions } from '$lib/utils/searchFilters';
import { useFilterUrlSync } from '$lib/composables/useFilterUrlSync.svelte';
import { SvelteSet } from 'svelte/reactivity'; import { SvelteSet } from 'svelte/reactivity';
import { replaceSet } from '$lib/utils/sets'; import { replaceSet } from '$lib/utils/sets';
import { import {
useResourceActions, useResourceActions,
type ActionTarget type ActionTarget
} from '$lib/composables/useResourceActions.svelte'; } from '$lib/composables/useResourceActions.svelte';
import FilterChips from '$lib/components/FilterChips.svelte';
import Icon from '$lib/icons/Icon.svelte'; import Icon from '$lib/icons/Icon.svelte';
import { t } from '$lib/i18n/index.svelte'; import { t } from '$lib/i18n/index.svelte';
import { confirmDialog, promptDialog } from '$lib/stores/dialogs.svelte'; import { confirmDialog, promptDialog } from '$lib/stores/dialogs.svelte';
import { files as filesStore } from '$lib/stores/files.svelte'; import { files as filesStore } from '$lib/stores/files.svelte';
import { resourceFilters } from '$lib/stores/filter.svelte';
import { session } from '$lib/stores/session.svelte'; import { session } from '$lib/stores/session.svelte';
import { ui } from '$lib/stores/ui.svelte'; import { ui } from '$lib/stores/ui.svelte';
@@ -125,72 +128,48 @@
scopeOverride === 'all' ? 'all' : effectiveFolder && scopeFolderId ? 'folder' : 'all' scopeOverride === 'all' ? 'all' : effectiveFolder && scopeFolderId ? 'folder' : 'all'
); );
function setScope(next: 'all' | 'folder') { function setScope(next: 'all' | 'folder') {
// Build the query string by hand — Svelte's lint flags mutating a // Copy the current URL and flip only the scope params — `q=` and the
// stdlib `URLSearchParams`, and we don't need reactivity here. // filter presets already in it survive untouched (they used to be
// dropped here when the query string was rebuilt by hand).
// //
// Key point (Ed's 2026-07-26 UX ask): the `in=` param is preserved // Key point (Ed's 2026-07-26 UX ask): the `in=` param is preserved
// even when switching to "Everywhere" so "This folder" stays // even when switching to "Everywhere" so "This folder" stays
// clickable and remembers WHICH folder. The active-scope flip // clickable and remembers WHICH folder. The active-scope flip
// rides on `scope=all` instead. // rides on `scope=all` instead.
const parts: string[] = []; const url = new URL(page.url);
if (query) parts.push(`q=${encodeURIComponent(query)}`);
// Sticky `in=`: keep whatever's already in the URL, or seed it // Sticky `in=`: keep whatever's already in the URL, or seed it
// from filesStore when the user first pins "This folder" from a // from filesStore when the user first pins "This folder" from a
// fresh /search visit. // fresh /search visit.
const stickyFolder = scopeFolderId ?? (next === 'folder' ? filesStore.currentFolder : null); const stickyFolder = scopeFolderId ?? (next === 'folder' ? filesStore.currentFolder : null);
if (stickyFolder) { if (stickyFolder) {
parts.push(`in=${encodeURIComponent(stickyFolder)}`); url.searchParams.set('in', stickyFolder);
} else {
url.searchParams.delete('in');
} }
if (next === 'all' && stickyFolder) { if (next === 'all' && stickyFolder) {
// Only meaningful when there's a folder to override — otherwise // Only meaningful when there's a folder to override — otherwise
// the URL is "everywhere by default" and the flag would be noise. // the URL is "everywhere by default" and the flag would be noise.
parts.push('scope=all'); url.searchParams.set('scope', 'all');
} else {
url.searchParams.delete('scope');
} }
const target = resolve(parts.length ? `/search?${parts.join('&')}` : '/search');
// `replaceState: true` keeps the browser back-button meaningful — // `replaceState: true` keeps the browser back-button meaningful —
// scope changes are UI state, not navigation. `keepFocus: true` // scope changes are UI state, not navigation. `keepFocus: true`
// keeps focus on whatever button the user just clicked. // keeps focus on whatever button the user just clicked.
void goto(target, { replaceState: true, keepFocus: true, noScroll: true }); // Same-origin URL object (see useFilterUrlSync); resolve() can't type it.
// eslint-disable-next-line svelte/no-navigation-without-resolve
void goto(url, { replaceState: true, keepFocus: true, noScroll: true });
} }
// Filters — the preset vocabularies and their SearchOptions mapping live // Filters live in the shared store (`$lib/stores/filter.svelte`) and are
// in the shared `searchFilters` util (also consumed by the files page's // mirrored to/from this page's URL — the top bar's filter panel and the
// filter bar); only the i18n label lists stay local since they need `t()`. // files page's filter bar mutate the same object, so a filter set in
type TypeKey = 'all' | 'image' | 'video' | 'document' | 'audio' | 'archive'; // either place is live here, and every dimension is URL-persisted
type SizeKey = 'all' | 'small' | 'medium' | 'large'; // (`?type=…&size=…&created=…&kind=…&recursive=0`) for refresh/bookmark/
type DateKey = 'all' | 'day' | 'week' | 'month' | 'year'; // Back. The active presets render as dismissible chips in the header
let typeFilter = $state<TypeKey>('all'); // (`<FilterChips>` in the actions snippet) — the old in-page selects
let sizeFilter = $state<SizeKey>('all'); // duplicated the vocabularies and lost state on every refresh.
let dateFilter = $state<DateKey>('all'); useFilterUrlSync(false);
const TYPES: { v: TypeKey; l: string }[] = [
{ v: 'all', l: t('search.type.all', 'All types') },
{ v: 'image', l: t('search.type.image', 'Images') },
{ v: 'video', l: t('search.type.video', 'Videos') },
{ v: 'document', l: t('search.type.document', 'Documents') },
{ v: 'audio', l: t('search.type.audio', 'Audio') },
{ v: 'archive', l: t('search.type.archive', 'Archives') }
];
const SIZES: { v: SizeKey; l: string }[] = [
{ v: 'all', l: t('search.size.all', 'Any size') },
{ v: 'small', l: t('search.size.small', '< 1 MB') },
{ v: 'medium', l: t('search.size.medium', '1–100 MB') },
{ v: 'large', l: t('search.size.large', '> 100 MB') }
];
const DATES: { v: DateKey; l: string }[] = [
{ v: 'all', l: t('search.date.all', 'Any time') },
{ v: 'day', l: t('search.date.day', 'Past 24 hours') },
{ v: 'week', l: t('search.date.week', 'Past week') },
{ v: 'month', l: t('search.date.month', 'Past month') },
{ v: 'year', l: t('search.date.year', 'Past year') }
];
const hasFilters = $derived(typeFilter !== 'all' || sizeFilter !== 'all' || dateFilter !== 'all');
function clearFilters() {
typeFilter = 'all';
sizeFilter = 'all';
dateFilter = 'all';
}
// ── Group / sort dimensions (shown in the DisplayModeControls dropdown) ── // ── Group / sort dimensions (shown in the DisplayModeControls dropdown) ──
// Ed's 2026-07-26 spec: 4 options total — // Ed's 2026-07-26 spec: 4 options total —
@@ -254,16 +233,14 @@
scope === 'folder' && filesStore.section !== 'trash' scope === 'folder' && filesStore.section !== 'trash'
? (effectiveFolder ?? undefined) ? (effectiveFolder ?? undefined)
: undefined; : undefined;
// TYPE_EXT / sizeBounds / dateBound come from `$lib/utils/searchFilters` // Filter presets come from the shared store (hydrated from this
// (shared with the files-page filter bar). // page's URL by `useFilterUrlSync`), mapped onto the wire by the
// util shared with the files page's filter bar.
return { return {
recursive: true, ...filterToSearchOptions(resourceFilters),
sortBy: orderByForGroup() as SortBy, sortBy: orderByForGroup() as SortBy,
reverse: reversed, reverse: reversed,
folderId, folderId
fileTypes: typeFilter === 'all' ? undefined : TYPE_EXT[typeFilter],
...sizeBounds(sizeFilter),
modifiedAfter: dateBound(dateFilter)
}; };
} }
@@ -546,13 +523,19 @@
]; ];
$effect(() => { $effect(() => {
// re-run when query, sort/direction, scope, or any filter changes // re-run when query, sort/direction, scope, or any filter changes.
// The filter store is hydrated from the URL by `useFilterUrlSync`
// (registered earlier, so it runs first in the same flush) — these
// field reads register the deps.
void groupBy; void groupBy;
void reversed; void reversed;
void scope; void scope;
void typeFilter; void resourceFilters.type;
void sizeFilter; void resourceFilters.size;
void dateFilter; void resourceFilters.date;
void resourceFilters.created;
void resourceFilters.kind;
void resourceFilters.recursive;
void run(query); void run(query);
}); });
@@ -686,47 +669,14 @@
{t('search.this_folder', 'This folder')} {t('search.this_folder', 'This folder')}
</button> </button>
</div> </div>
<select
class="sort-select"
bind:value={typeFilter}
aria-label={t('search.type_label', 'Type')}
data-testid="search-type-filter-select"
>
{#each TYPES as o (o.v)}<option value={o.v} data-testid={`search-type-${o.v}`}>{o.l}</option
>{/each}
</select>
<select
class="sort-select"
bind:value={sizeFilter}
aria-label={t('search.size_label', 'Size')}
data-testid="search-size-filter-select"
>
{#each SIZES as o (o.v)}<option value={o.v} data-testid={`search-size-${o.v}`}>{o.l}</option
>{/each}
</select>
<select
class="sort-select"
bind:value={dateFilter}
aria-label={t('search.date_label', 'Date')}
data-testid="search-date-filter-select"
>
{#each DATES as o (o.v)}<option value={o.v} data-testid={`search-date-${o.v}`}>{o.l}</option
>{/each}
</select>
<!-- <!--
NOTE: sort dimension + asc/desc live in ResourceList's Filter editing lives in the top bar's panel (the unified
built-in DisplayModeControls now (fed by `groupBys` + entry); this page only SHOWS the active presets, as
`bind:groupBy` + `bind:reversed` below), matching dismissible chips. NOTE: sort dimension + asc/desc live in
/favorites / /recent / /trash. The old ResourceList's built-in DisplayModeControls (fed by
`<select bind:value={sortBy}>` was removed with the `groupBys` + `bind:groupBy` + `bind:reversed` below).
`SORTS` array.
--> -->
{#if hasFilters} <FilterChips value={resourceFilters} />
<button class="clear-filters" data-testid="search-clear-filters-btn" onclick={clearFilters}>
<Icon name="times" />
{t('search.clear_filters', 'Clear filters')}
</button>
{/if}
{/snippet} {/snippet}
{#snippet breadcrumb()} {#snippet breadcrumb()}
<!-- <!--
@@ -845,20 +795,12 @@
{/if} {/if}
<style> <style>
/* Filter cluster lives inside ResourceList's action-bar snippet now, /* Scope segment lives inside ResourceList's action-bar snippet now,
but the actual DOM is scoped to THIS component's \3c style> block — but the actual DOM is scoped to THIS component's \3c style> block —
Svelte's scoped selectors still apply because these are declared Svelte's scoped selectors still apply because these are declared
with the elements they style below. with the elements they style below.
Every color/border here uses tokens; no raw values (Stylelint gate). */ Every color/border here uses tokens; no raw values (Stylelint gate). */
.sort-select {
padding: var(--space-2) var(--space-2-5);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-input);
color: var(--color-text);
}
.seg { .seg {
display: flex; display: flex;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
@@ -883,20 +825,4 @@
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
} }
.clear-filters {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-surface);
color: var(--color-text-muted);
cursor: pointer;
}
.clear-filters:hover {
background: var(--color-bg-hover);
}
</style> </style>
+33
View File
@@ -126,6 +126,39 @@ it('surfaces a search error', async () => {
await waitFor(() => expect(screen.getByText('search boom')).toBeTruthy()); await waitFor(() => expect(screen.getByText('search boom')).toBeTruthy());
}); });
it('hydrates filter presets from the URL onto the wire options', async () => {
pageState.url = new URL(
'http://localhost/search?q=report&type=image&size=small&created=week&kind=file&recursive=0'
);
render(SearchPage);
await waitFor(() => expect(searchResources).toHaveBeenCalled());
expect(m(searchResources).mock.calls[0][1]).toMatchObject({
fileTypes: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'heic', 'avif', 'tiff'],
maxSize: 1024 * 1024,
createdAfter: expect.any(Number),
resourceTypes: ['file'],
recursive: false
});
// The live presets are visible — and dismissible — as chips.
expect(screen.getByTestId('filter-chips')).toBeTruthy();
expect(screen.getByTestId('filter-chip-type')).toBeTruthy();
expect(screen.getByTestId('filter-chip-kind')).toBeTruthy();
expect(screen.getByTestId('filter-chip-size')).toBeTruthy();
expect(screen.getByTestId('filter-chip-created')).toBeTruthy();
});
it('an unknown filter value in the URL degrades to the default', async () => {
pageState.url = new URL('http://localhost/search?q=report&type=pdf&kind=aliens');
render(SearchPage);
await waitFor(() => expect(searchResources).toHaveBeenCalled());
expect(m(searchResources).mock.calls[0][1]).toMatchObject({
fileTypes: undefined,
resourceTypes: undefined
});
// No chips for dimensions that fell back to their defaults.
expect(screen.queryByTestId('filter-chip-type')).toBeNull();
});
it('batch-deletes the selected search results after confirmation', async () => { it('batch-deletes the selected search results after confirmation', async () => {
m(searchResources).mockResolvedValue(searchHit()); m(searchResources).mockResolvedValue(searchHit());
confirmDialog.mockResolvedValue(true); confirmDialog.mockResolvedValue(true);
+9 -1
View File
@@ -422,7 +422,15 @@
"advanced": "المرشحات", "advanced": "المرشحات",
"recursive": "تضمين المجلدات الفرعية", "recursive": "تضمين المجلدات الفرعية",
"results_count": "{{n}} نتائج", "results_count": "{{n}} نتائج",
"clear_keyword": "مسح البحث" "clear_keyword": "مسح البحث",
"modified_label": "Modified",
"created_label": "Created",
"kind_label": "Items",
"kind": {
"all": "Files and folders",
"file": "Files only",
"folder": "Folders only"
}
}, },
"files": { "files": {
"name": "الاسم", "name": "الاسم",
+9 -1
View File
@@ -422,7 +422,15 @@
"advanced": "Filter", "advanced": "Filter",
"recursive": "Unterordner einbeziehen", "recursive": "Unterordner einbeziehen",
"results_count": "{{n}} Ergebnisse", "results_count": "{{n}} Ergebnisse",
"clear_keyword": "Suche löschen" "clear_keyword": "Suche löschen",
"modified_label": "Modified",
"created_label": "Created",
"kind_label": "Items",
"kind": {
"all": "Files and folders",
"file": "Files only",
"folder": "Folders only"
}
}, },
"files": { "files": {
"name": "Name", "name": "Name",
+9 -1
View File
@@ -457,7 +457,15 @@
"advanced": "Filters", "advanced": "Filters",
"recursive": "Include subfolders", "recursive": "Include subfolders",
"results_count": "{{n}} results", "results_count": "{{n}} results",
"clear_keyword": "Clear search" "clear_keyword": "Clear search",
"modified_label": "Modified",
"created_label": "Created",
"kind_label": "Items",
"kind": {
"all": "Files and folders",
"file": "Files only",
"folder": "Folders only"
}
}, },
"files": { "files": {
"name": "Name", "name": "Name",
+9 -1
View File
@@ -422,7 +422,15 @@
"advanced": "Filtros", "advanced": "Filtros",
"recursive": "Incluir subcarpetas", "recursive": "Incluir subcarpetas",
"results_count": "{{n}} resultados", "results_count": "{{n}} resultados",
"clear_keyword": "Borrar búsqueda" "clear_keyword": "Borrar búsqueda",
"modified_label": "Modified",
"created_label": "Created",
"kind_label": "Items",
"kind": {
"all": "Files and folders",
"file": "Files only",
"folder": "Folders only"
}
}, },
"files": { "files": {
"name": "Nombre", "name": "Nombre",
+9 -1
View File
@@ -422,7 +422,15 @@
"advanced": "فیلترها", "advanced": "فیلترها",
"recursive": "شامل زیرپوشه‌ها", "recursive": "شامل زیرپوشه‌ها",
"results_count": "{{n}} نتیجه", "results_count": "{{n}} نتیجه",
"clear_keyword": "پاک کردن جستجو" "clear_keyword": "پاک کردن جستجو",
"modified_label": "Modified",
"created_label": "Created",
"kind_label": "Items",
"kind": {
"all": "Files and folders",
"file": "Files only",
"folder": "Folders only"
}
}, },
"files": { "files": {
"name": "نام", "name": "نام",
+9 -1
View File
@@ -422,7 +422,15 @@
"advanced": "Filtres", "advanced": "Filtres",
"recursive": "Inclure les sous-dossiers", "recursive": "Inclure les sous-dossiers",
"results_count": "{{n}} résultats", "results_count": "{{n}} résultats",
"clear_keyword": "Effacer la recherche" "clear_keyword": "Effacer la recherche",
"modified_label": "Modified",
"created_label": "Created",
"kind_label": "Items",
"kind": {
"all": "Files and folders",
"file": "Files only",
"folder": "Folders only"
}
}, },
"files": { "files": {
"name": "Nom", "name": "Nom",
+9 -1
View File
@@ -422,7 +422,15 @@
"advanced": "फ़िल्टर", "advanced": "फ़िल्टर",
"recursive": "सबफ़ोल्डर शामिल करें", "recursive": "सबफ़ोल्डर शामिल करें",
"results_count": "{{n}} परिणाम", "results_count": "{{n}} परिणाम",
"clear_keyword": "खोज साफ़ करें" "clear_keyword": "खोज साफ़ करें",
"modified_label": "Modified",
"created_label": "Created",
"kind_label": "Items",
"kind": {
"all": "Files and folders",
"file": "Files only",
"folder": "Folders only"
}
}, },
"files": { "files": {
"name": "नाम", "name": "नाम",
+9 -1
View File
@@ -422,7 +422,15 @@
"advanced": "Filtri", "advanced": "Filtri",
"recursive": "Includi sottocartelle", "recursive": "Includi sottocartelle",
"results_count": "{{n}} risultati", "results_count": "{{n}} risultati",
"clear_keyword": "Cancella ricerca" "clear_keyword": "Cancella ricerca",
"modified_label": "Modified",
"created_label": "Created",
"kind_label": "Items",
"kind": {
"all": "Files and folders",
"file": "Files only",
"folder": "Folders only"
}
}, },
"files": { "files": {
"name": "Nome", "name": "Nome",
+9 -1
View File
@@ -422,7 +422,15 @@
"advanced": "フィルター", "advanced": "フィルター",
"recursive": "サブフォルダを含める", "recursive": "サブフォルダを含める",
"results_count": "{{n}} 件の結果", "results_count": "{{n}} 件の結果",
"clear_keyword": "検索をクリア" "clear_keyword": "検索をクリア",
"modified_label": "Modified",
"created_label": "Created",
"kind_label": "Items",
"kind": {
"all": "Files and folders",
"file": "Files only",
"folder": "Folders only"
}
}, },
"files": { "files": {
"name": "名前", "name": "名前",
+9 -1
View File
@@ -422,7 +422,15 @@
"advanced": "필터", "advanced": "필터",
"recursive": "하위 폴더 포함", "recursive": "하위 폴더 포함",
"results_count": "{{n}}개 결과", "results_count": "{{n}}개 결과",
"clear_keyword": "검색 지우기" "clear_keyword": "검색 지우기",
"modified_label": "Modified",
"created_label": "Created",
"kind_label": "Items",
"kind": {
"all": "Files and folders",
"file": "Files only",
"folder": "Folders only"
}
}, },
"files": { "files": {
"name": "이름", "name": "이름",
+9 -1
View File
@@ -422,7 +422,15 @@
"advanced": "Filters", "advanced": "Filters",
"recursive": "Submappen opnemen", "recursive": "Submappen opnemen",
"results_count": "{{n}} resultaten", "results_count": "{{n}} resultaten",
"clear_keyword": "Zoekopdracht wissen" "clear_keyword": "Zoekopdracht wissen",
"modified_label": "Modified",
"created_label": "Created",
"kind_label": "Items",
"kind": {
"all": "Files and folders",
"file": "Files only",
"folder": "Folders only"
}
}, },
"files": { "files": {
"name": "Naam", "name": "Naam",
+9 -1
View File
@@ -422,7 +422,15 @@
"advanced": "Filtry", "advanced": "Filtry",
"recursive": "Uwzględnij podfoldery", "recursive": "Uwzględnij podfoldery",
"results_count": "Wyniki: {{n}}", "results_count": "Wyniki: {{n}}",
"clear_keyword": "Wyczyść wyszukiwanie" "clear_keyword": "Wyczyść wyszukiwanie",
"modified_label": "Modified",
"created_label": "Created",
"kind_label": "Items",
"kind": {
"all": "Files and folders",
"file": "Files only",
"folder": "Folders only"
}
}, },
"files": { "files": {
"name": "Nazwa", "name": "Nazwa",
+9 -1
View File
@@ -422,7 +422,15 @@
"advanced": "Filtros", "advanced": "Filtros",
"recursive": "Incluir subpastas", "recursive": "Incluir subpastas",
"results_count": "{{n}} resultados", "results_count": "{{n}} resultados",
"clear_keyword": "Limpar pesquisa" "clear_keyword": "Limpar pesquisa",
"modified_label": "Modified",
"created_label": "Created",
"kind_label": "Items",
"kind": {
"all": "Files and folders",
"file": "Files only",
"folder": "Folders only"
}
}, },
"files": { "files": {
"name": "Nome", "name": "Nome",
+9 -1
View File
@@ -422,7 +422,15 @@
"advanced": "Фильтры", "advanced": "Фильтры",
"recursive": "Включая подпапки", "recursive": "Включая подпапки",
"results_count": "Результатов: {{n}}", "results_count": "Результатов: {{n}}",
"clear_keyword": "Очистить поиск" "clear_keyword": "Очистить поиск",
"modified_label": "Modified",
"created_label": "Created",
"kind_label": "Items",
"kind": {
"all": "Files and folders",
"file": "Files only",
"folder": "Folders only"
}
}, },
"files": { "files": {
"name": "Имя", "name": "Имя",
+9 -1
View File
@@ -422,7 +422,15 @@
"advanced": "篩選", "advanced": "篩選",
"recursive": "包含子資料夾", "recursive": "包含子資料夾",
"results_count": "{{n}} 個結果", "results_count": "{{n}} 個結果",
"clear_keyword": "清除搜尋" "clear_keyword": "清除搜尋",
"modified_label": "修改時間",
"created_label": "建立時間",
"kind_label": "項目",
"kind": {
"all": "檔案與資料夾",
"file": "僅檔案",
"folder": "僅資料夾"
}
}, },
"files": { "files": {
"name": "名稱", "name": "名稱",
+9 -1
View File
@@ -422,7 +422,15 @@
"advanced": "筛选", "advanced": "筛选",
"recursive": "包含子文件夹", "recursive": "包含子文件夹",
"results_count": "{{n}} 个结果", "results_count": "{{n}} 个结果",
"clear_keyword": "清除搜索" "clear_keyword": "清除搜索",
"modified_label": "修改时间",
"created_label": "创建时间",
"kind_label": "条目",
"kind": {
"all": "文件和文件夹",
"file": "仅文件",
"folder": "仅文件夹"
}
}, },
"files": { "files": {
"name": "名称", "name": "名称",
+24
View File
@@ -9,6 +9,30 @@
## 已完成 ## 已完成
### [2026-09-21] 顶栏搜索框 × 筛选栏合并(并补齐筛选维度)
- **状态**: 已完成(commit `29d0c335`;`npm run check` 全绿:svelte-check 0 错 0 警 + eslint + stylelint + prettier;`vitest run` 492 通过 0 失败,连续两轮稳定)
- **计划**: 把上游顶栏搜索框与本地 `SearchFilterBar` 合并成一套筛选能力:顶栏搜索框内加筛选按钮 + 下拉面板(不动 70px 高度);文件页筛选栏保留,两处共享同一份筛选状态并同步到 URL(就地筛选行为不丢);`/search` 改为 URL 驱动、移除三个页内 select(改为可撤销 chips);同时补上后端已支持但前端未暴露的 `resource_types`(仅文件/仅文件夹)与创建时间维度。后端零改动。
- **改动文件**:
- 新增(仅本地):
- `frontend/src/lib/stores/filter.svelte.ts` — 共享筛选 store(模块级 `$state`,唯一真值)
- `frontend/src/lib/composables/useFilterUrlSync.svelte.ts` — store↔URL 双向同步(单 `$effect` + `lastSeenSearch` 回声抑制,一轮收敛不成环)
- `frontend/src/lib/components/FilterFields.svelte` — 5 个 select(类型/大小/修改/创建/条目种类)+ 递归开关,词表与 i18n 标签集中于此(inline/panel 两种布局)
- `frontend/src/lib/components/TopBarFilterPanel.svelte` — 顶栏下拉面板壳(清除/完成按钮,激活计数驱动 badge)
- `frontend/src/lib/components/FilterChips.svelte` — `/search` 可撤销 chips(按维度撤销 + 清除全部)
- 仅本地改造:
- `frontend/src/lib/utils/searchFilters.ts` — 新维度 `kind`/`created` + URL 序列化(`filtersFromParams`/`filterParamEntries`/`filterParamsToString`/`applyFilterParams`/`filterSearchOver`);测试 29 条
- `frontend/src/lib/components/SearchFilterBar.svelte` — 展开区改复用 `FilterFields`;`value` 为普通 prop(原地改共享代理,**故意不用 `$bindable`**,导入绑定不可再赋值)
- 上游文件:
- `frontend/src/lib/components/AppShell.svelte` — 搜索框内筛选按钮(右 44px,激活 badge)+ 面板接线;`searchActive`→`mobileSearchOpen`(CSS 类名不动);提交/建议吃筛选(`filterToSearchOptions`);Escape 链插入面板;全局点击关面板(`.search-container` 内不关)
- `frontend/src/lib/styles/ported/topbar.css` — 输入框右内边距 50→104px(预留 submit/筛选/清除三个控件)单行改动
- `frontend/src/routes/search/+page.svelte` — URL 驱动(`useFilterUrlSync(false)`)、删三个页内 select 与本地词表、接 `FilterChips`、`setScope` 只动 `in`/`scope`
- `frontend/src/routes/files/[...path]/+page.svelte` — 删本地 filter 状态改绑共享 store、`useFilterUrlSync(true)`、`folderTarget` 携带筛选参数(就地筛选/导航存活两不误)、驱动 effect 增加 `created`/`kind` 依赖
- `frontend/static/locales/*.json`(16 个)— `filter` 块新增 `modified_label`/`created_label`/`kind_label`/`kind.{all,file,folder}`(zh/zh-TW 真翻译,其余英文兜底;fr.json 按行拼接保持既有格式)
- 测试:`AppShell.test.ts`(+2:面板开关/预设随提交/清除)、`search/page.test.ts`(+2:URL 水合+chips、非法值回落)、`files/page.test.ts`(+2:深链水合+wire 参数、关键词写回恰好一次)、`searchFilters.test.ts`(扩到 31 条)
- **仅本地文件**: 上述 5 个新增源文件 + `status.md`
- **上游冲突风险**: 高 — `AppShell.svelte`、`topbar.css`、`routes/search/+page.svelte`、16 个 locale 均为上游活跃区。合并核对要点:① store 是唯一真值、URL 是投影(`lastSeenSearch` 回声抑制,勿改回双向各自 `goto`);② `?q=` 归属——files 页 `includeQuery=true`、`/search` 与顶栏 `false`(`applyFilterParams` 删 `q` 前先看该开关);③ `folderTarget` 三处 `goto` 带 `eslint-disable`(helper 内嵌 `resolve()`,规则看不穿);④ `filterSearchOver` 下沉在普通 `.ts` 是为避开 `.svelte.ts` 的 `prefer-svelte-reactivity`(勿移回 composable);⑤ 已知 dev-only 警告 `ownership_invalid_mutation`(共享代理按普通 prop 传入+原地改的既定设计,生产无影响)
### [2026-09-19] 缩略图路径 DB 点查缓存(find_attached_blob 进程内缓存) ### [2026-09-19] 缩略图路径 DB 点查缓存(find_attached_blob 进程内缓存)
- **状态**: 已完成(commit `d33d1932`;cargo fmt --check ✓;clippy --all-features --all-targets -D warnings 0 警告 ✓;`cargo test --lib` 927 通过 0 失败,含新增 5 个缓存契约测试 ✓) - **状态**: 已完成(commit `d33d1932`;cargo fmt --check ✓;clippy --all-features --all-targets -D warnings 0 警告 ✓;`cargo test --lib` 927 通过 0 失败,含新增 5 个缓存契约测试 ✓)
- **计划**: 给缩略图热路径(ETag 计算 `thumbnail_content_id` + tier 2b)每次请求都要打的 `find_attached_blob` DB 点查加进程内缓存(moka::future + try_get_with,正+负缓存,Err 不入缓存;写路径成功后失效;删除经 `ThumbnailRefreshHook::on_file_deleted` 搭车失效 + 60s TTL 兜底)。缓解"每次进照片墙 = 每张可见图 1-2 次 DB 点查"的负载。 - **计划**: 给缩略图热路径(ETag 计算 `thumbnail_content_id` + tier 2b)每次请求都要打的 `find_attached_blob` DB 点查加进程内缓存(moka::future + try_get_with,正+负缓存,Err 不入缓存;写路径成功后失效;删除经 `ThumbnailRefreshHook::on_file_deleted` 搭车失效 + 60s TTL 兜底)。缓解"每次进照片墙 = 每张可见图 1-2 次 DB 点查"的负载。