Compare commits
2 Commits
b7640e9be4
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| deb6dc831b | |||
| 29d0c33558 |
@@ -13,15 +13,22 @@
|
||||
import { lazyComponent } from '$lib/composables/lazyComponent.svelte';
|
||||
import DrivePicker from '$lib/components/DrivePicker.svelte';
|
||||
import ReadOnlyBanner from '$lib/components/ReadOnlyBanner.svelte';
|
||||
import TopBarFilterPanel from '$lib/components/TopBarFilterPanel.svelte';
|
||||
import Icon from '$lib/icons/Icon.svelte';
|
||||
import { dateTimeFormatFor, iconNameFromClass } from '$lib/utils/display';
|
||||
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 { serverConfig } from '$lib/stores/serverConfig.svelte';
|
||||
import { serverStatus } from '$lib/stores/serverStatus.svelte';
|
||||
import { apiFetch } from '$lib/api/client';
|
||||
import { dialogs } from '$lib/stores/dialogs.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 { session } from '$lib/stores/session.svelte';
|
||||
import { theme, type Theme } from '$lib/stores/theme.svelte';
|
||||
@@ -309,7 +316,9 @@
|
||||
let menuOpen = $state(false);
|
||||
let searchQuery = $state('');
|
||||
/** 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 aboutOpen = $state(false);
|
||||
let appVersion = $state('');
|
||||
@@ -348,15 +357,26 @@
|
||||
const totalUnreadBadge = $derived(totalUnread > 99 ? '99+' : String(totalUnread));
|
||||
|
||||
function openMobileSearch() {
|
||||
searchActive = true;
|
||||
mobileSearchOpen = true;
|
||||
requestAnimationFrame(() => searchInputEl?.focus());
|
||||
}
|
||||
|
||||
function closeMobileSearch() {
|
||||
searchActive = false;
|
||||
mobileSearchOpen = false;
|
||||
filterPanelOpen = false;
|
||||
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() {
|
||||
menuOpen = false;
|
||||
aboutOpen = true;
|
||||
@@ -378,6 +398,10 @@
|
||||
let suggestions = $state<Suggestion[]>([]);
|
||||
let suggestOpen = $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;
|
||||
// Stale-response guard (same family as the search page): the debounce
|
||||
// spaces requests out but doesn't stop a SLOW earlier response from
|
||||
@@ -389,16 +413,21 @@
|
||||
const q = searchQuery.trim();
|
||||
if (!q) return;
|
||||
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>`
|
||||
// so a hard refresh, a shared link, or a bookmark all restore the
|
||||
// "This folder" scope. Trash section is always global — skip. See
|
||||
// `/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') {
|
||||
parts.push(`in=${encodeURIComponent(filesStore.currentFolder)}`);
|
||||
}
|
||||
@@ -411,6 +440,8 @@
|
||||
}
|
||||
|
||||
function onSearchInput() {
|
||||
// The filter panel owns the dropdown area while open — don't fight it.
|
||||
if (filterPanelOpen) return;
|
||||
if (suggestTimer) clearTimeout(suggestTimer);
|
||||
const q = searchQuery.trim();
|
||||
if (q.length < 2) {
|
||||
@@ -428,7 +459,13 @@
|
||||
suggestInflight = ctl;
|
||||
suggestBusy = true;
|
||||
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
|
||||
// The wire is ordered — folders first, then files — but slice
|
||||
// per kind explicitly so the header preview stays a folder-heavy
|
||||
@@ -491,6 +528,22 @@
|
||||
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>,
|
||||
* <textarea>, or any `contenteditable` element. Used by the
|
||||
@@ -563,7 +616,7 @@
|
||||
</script>
|
||||
|
||||
<svelte:window
|
||||
onclick={closeMenus}
|
||||
onclick={onGlobalClick}
|
||||
onkeydown={(e) => {
|
||||
// First Cmd/Ctrl+K loads the palette and mounts it open; once mounted,
|
||||
// the palette's own handler takes over toggling/closing.
|
||||
@@ -597,7 +650,8 @@
|
||||
}
|
||||
if (e.key !== 'Escape') return;
|
||||
if (aboutOpen) aboutOpen = false;
|
||||
else if (searchActive) closeMobileSearch();
|
||||
else if (filterPanelOpen) filterPanelOpen = false;
|
||||
else if (mobileSearchOpen) closeMobileSearch();
|
||||
else closeMenus();
|
||||
}}
|
||||
/>
|
||||
@@ -702,7 +756,7 @@
|
||||
</div>
|
||||
|
||||
<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
|
||||
class="sidebar-toggle"
|
||||
aria-label={t('nav.toggle', 'Toggle navigation menu')}
|
||||
@@ -760,6 +814,24 @@
|
||||
<Icon name="times" />
|
||||
</button>
|
||||
{/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
|
||||
class="search-button"
|
||||
type="submit"
|
||||
@@ -770,6 +842,10 @@
|
||||
<Icon name="search" />
|
||||
</button>
|
||||
|
||||
{#if filterPanelOpen}
|
||||
<TopBarFilterPanel value={resourceFilters} onclose={() => (filterPanelOpen = false)} />
|
||||
{/if}
|
||||
|
||||
{#if suggestOpen}
|
||||
<ul class="suggest">
|
||||
{#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. */
|
||||
.search-clear {
|
||||
position: absolute;
|
||||
right: 44px;
|
||||
right: 72px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 28px;
|
||||
@@ -1240,6 +1316,47 @@
|
||||
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 {
|
||||
position: absolute;
|
||||
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 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">
|
||||
import FilterFields from '$lib/components/FilterFields.svelte';
|
||||
import Icon from '$lib/icons/Icon.svelte';
|
||||
import { t } from '$lib/i18n/index.svelte';
|
||||
import {
|
||||
activeFilterCount,
|
||||
defaultFilterState,
|
||||
type DateKey,
|
||||
type ResourceFilterState,
|
||||
type SizeKey,
|
||||
type TypeKey
|
||||
type ResourceFilterState
|
||||
} from '$lib/utils/searchFilters';
|
||||
|
||||
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;
|
||||
/** Advanced section (type/size/date/recursive) expanded. */
|
||||
/** Advanced section (presets + recursive) expanded. */
|
||||
expanded?: boolean;
|
||||
placeholder?: string;
|
||||
/** Debounce for the keyword input (ms). */
|
||||
@@ -22,44 +26,22 @@
|
||||
}
|
||||
|
||||
let {
|
||||
value = $bindable(defaultFilterState()),
|
||||
value = defaultFilterState(),
|
||||
expanded = $bindable(false),
|
||||
placeholder = t('filter.placeholder', 'Search this folder and subfolders…'),
|
||||
debounceMs = 300,
|
||||
hideRecursive = false
|
||||
}: 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)
|
||||
// and pushes into `value.query` debounced, so a keystroke doesn't fire a
|
||||
// backend search per character. `lastPushed` disambiguates our own pushes
|
||||
// from external writes (e.g. the page's clear-filter Escape path), which
|
||||
// flow back into the buffer via the sync effect below.
|
||||
let keyword = $state(value.query);
|
||||
let lastPushed = value.query;
|
||||
// from external writes (e.g. the page's clear-filter Escape path, or a
|
||||
// URL hydration of a deep link like /files/…?q=abc), which flow back into
|
||||
// the buffer via the sync effect below — including on first mount, which
|
||||
// is why both start empty instead of capturing `value.query` here.
|
||||
let keyword = $state('');
|
||||
let lastPushed = '';
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
$effect(() => {
|
||||
@@ -119,9 +101,7 @@
|
||||
pushKeyword('');
|
||||
}
|
||||
|
||||
const activeCount = $derived(
|
||||
(value.type !== 'all' ? 1 : 0) + (value.size !== 'all' ? 1 : 0) + (value.date !== 'all' ? 1 : 0)
|
||||
);
|
||||
const activeCount = $derived(activeFilterCount(value));
|
||||
</script>
|
||||
|
||||
<div class="sfb" data-testid="search-filter-bar">
|
||||
@@ -168,37 +148,8 @@
|
||||
</div>
|
||||
|
||||
{#if expanded}
|
||||
<div class="sfb__advanced" data-testid="filter-advanced-row">
|
||||
<label class="sfb__field">
|
||||
<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 data-testid="filter-advanced-row">
|
||||
<FilterFields bind:value {hideRecursive} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -312,43 +263,4 @@
|
||||
line-height: 1rem;
|
||||
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>
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -112,7 +112,9 @@
|
||||
|
||||
.search-container input {
|
||||
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: 2px solid var(--color-border);
|
||||
background-color: var(--color-bg-input);
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
activeFilterCount,
|
||||
activeFilters,
|
||||
applyFilterParams,
|
||||
clearFilterDimension,
|
||||
clearFilterState,
|
||||
dateBound,
|
||||
defaultFilterState,
|
||||
filterParamEntries,
|
||||
filterParamsToString,
|
||||
filterSearchOver,
|
||||
filtersFromParams,
|
||||
filterToSearchOptions,
|
||||
filterValueLabelKey,
|
||||
isFilterActive,
|
||||
sizeBounds,
|
||||
TYPE_EXT
|
||||
@@ -58,6 +67,8 @@ describe('isFilterActive', () => {
|
||||
expect(isFilterActive({ ...defaultFilterState(), type: 'image' })).toBe(true);
|
||||
expect(isFilterActive({ ...defaultFilterState(), size: 'small' })).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', () => {
|
||||
@@ -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', () => {
|
||||
it('resets every field in place', () => {
|
||||
const f = { ...defaultFilterState(), query: 'a', recursive: false, type: 'video' as const };
|
||||
@@ -80,6 +136,8 @@ describe('filterToSearchOptions', () => {
|
||||
minSize: undefined,
|
||||
maxSize: undefined,
|
||||
modifiedAfter: undefined,
|
||||
createdAfter: undefined,
|
||||
resourceTypes: undefined,
|
||||
recursive: true
|
||||
});
|
||||
});
|
||||
@@ -90,12 +148,150 @@ describe('filterToSearchOptions', () => {
|
||||
recursive: false,
|
||||
type: 'archive',
|
||||
size: 'medium',
|
||||
date: 'month'
|
||||
date: 'month',
|
||||
created: 'day',
|
||||
kind: 'folder'
|
||||
});
|
||||
expect(opts.fileTypes).toEqual(TYPE_EXT.archive);
|
||||
expect(opts.minSize).toBe(MB);
|
||||
expect(opts.maxSize).toBe(100 * MB);
|
||||
expect(opts.modifiedAfter).toBe(dateBound('month'));
|
||||
expect(opts.createdAfter).toBe(dateBound('day'));
|
||||
expect(opts.resourceTypes).toEqual(['folder']);
|
||||
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('');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
// Shared resource-filter model for search-backed list views.
|
||||
//
|
||||
// Extracted from the /search page so the files page's filter bar and
|
||||
// /search's filter selects share one source of truth for the preset
|
||||
// vocabularies (type / size / date) and their mapping onto
|
||||
// `SearchOptions`. Pure functions only — no runes here, so the module is
|
||||
// unit-testable without component scaffolding.
|
||||
// One source of truth for the preset vocabularies (type / size / date /
|
||||
// created / kind), their mapping onto `SearchOptions`, and their
|
||||
// serialization in and out of the URL. The files page's filter bar, the
|
||||
// top bar's filter panel and the /search page all bind the same state
|
||||
// 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';
|
||||
|
||||
export type TypeKey = 'all' | 'image' | 'video' | 'document' | 'audio' | 'archive';
|
||||
export type SizeKey = 'all' | 'small' | 'medium' | 'large';
|
||||
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. */
|
||||
export interface ResourceFilterState {
|
||||
@@ -19,10 +26,21 @@ export interface ResourceFilterState {
|
||||
type: TypeKey;
|
||||
size: SizeKey;
|
||||
date: DateKey;
|
||||
/** Created-time preset, independent of the modified-time one. */
|
||||
created: DateKey;
|
||||
kind: KindKey;
|
||||
}
|
||||
|
||||
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[]> = {
|
||||
@@ -66,7 +84,52 @@ export function dateBound(k: DateKey): number | undefined {
|
||||
|
||||
/** True when any filter dimension would change the result set. */
|
||||
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). */
|
||||
@@ -76,21 +139,139 @@ export function clearFilterState(f: ResourceFilterState): void {
|
||||
f.type = 'all';
|
||||
f.size = 'all';
|
||||
f.date = 'all';
|
||||
f.created = 'all';
|
||||
f.kind = 'all';
|
||||
}
|
||||
|
||||
/**
|
||||
* Map the filter state onto the search-wire options. Scope (folderId) and
|
||||
* sorting stay the caller's concern — they differ per surface. The date
|
||||
* preset maps to `modifiedAfter` (its labels read "Past N", which matches
|
||||
* modified-time semantics); created-time bounds are a possible follow-up.
|
||||
* sorting stay the caller's concern — they differ per surface.
|
||||
*/
|
||||
export function filterToSearchOptions(
|
||||
f: ResourceFilterState
|
||||
): Pick<SearchOptions, 'fileTypes' | 'minSize' | 'maxSize' | 'modifiedAfter' | 'recursive'> {
|
||||
): Pick<
|
||||
SearchOptions,
|
||||
| 'fileTypes'
|
||||
| 'minSize'
|
||||
| 'maxSize'
|
||||
| 'modifiedAfter'
|
||||
| 'createdAfter'
|
||||
| 'resourceTypes'
|
||||
| 'recursive'
|
||||
> {
|
||||
return {
|
||||
fileTypes: f.type === 'all' ? undefined : TYPE_EXT[f.type],
|
||||
...sizeBounds(f.size),
|
||||
modifiedAfter: dateBound(f.date),
|
||||
createdAfter: dateBound(f.created),
|
||||
resourceTypes: f.kind === 'all' ? undefined : [f.kind],
|
||||
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 { mapLimit } from '$lib/utils/mapLimit';
|
||||
import {
|
||||
defaultFilterState,
|
||||
clearFilterState,
|
||||
filterParamsToString,
|
||||
isFilterActive,
|
||||
filterToSearchOptions,
|
||||
type ResourceFilterState
|
||||
filterToSearchOptions
|
||||
} 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 {
|
||||
useResourceActions,
|
||||
@@ -278,7 +279,11 @@
|
||||
typeof localStorage !== 'undefined' ? localStorage.getItem('oxi-last-drive-root') : null;
|
||||
const target = last ?? home;
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -458,8 +463,25 @@
|
||||
// searches (type/size/date, no keyword) work; its Tantivy content index
|
||||
// additionally requires ≥2 chars before it engages, so empty queries stay
|
||||
// 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));
|
||||
|
||||
/** 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 searchCursor = $state<string | undefined>(undefined);
|
||||
let searchSeq = 0;
|
||||
@@ -606,8 +628,11 @@
|
||||
function openFolder(folder: FolderItem) {
|
||||
// Canonical single-id URL. Legacy `/files/A/B/C` still resolves
|
||||
// (canonicalize-on-load rewrites it inside `load()`), but new
|
||||
// navigation lands directly on `/files/{id}`.
|
||||
goto(resolve(`/files/${folder.id}`));
|
||||
// navigation lands directly on `/files/{id}`. Active filter params
|
||||
// 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() {
|
||||
@@ -1931,12 +1956,16 @@
|
||||
// inputs change: any filter dimension, the folder it's scoped to
|
||||
// (`currentId`, resolved by `load()` above), or the sort dimension.
|
||||
// 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(() => {
|
||||
void filter.query;
|
||||
void filter.type;
|
||||
void filter.size;
|
||||
void filter.date;
|
||||
void filter.created;
|
||||
void filter.kind;
|
||||
void filter.recursive;
|
||||
void currentId;
|
||||
void sortField;
|
||||
@@ -1986,9 +2015,11 @@
|
||||
<!-- Fuzzy filter bar: scoped keyword + type/size/date presets over the
|
||||
current folder (recursive toggle inside). While any dimension is
|
||||
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">
|
||||
<SearchFilterBar bind:value={filter} />
|
||||
<SearchFilterBar value={filter} />
|
||||
</div>
|
||||
|
||||
<!-- Hidden upload inputs stay mounted even while the batch bar is shown.
|
||||
@@ -2302,7 +2333,8 @@
|
||||
const id = ctxTarget!.id;
|
||||
closeContext();
|
||||
// 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
|
||||
>
|
||||
<button
|
||||
|
||||
@@ -321,3 +321,41 @@ it('shows the search result count while the filter is active', async () => {
|
||||
const stat = await screen.findByTestId('files-folder-stat');
|
||||
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');
|
||||
});
|
||||
|
||||
@@ -22,17 +22,20 @@
|
||||
import type { FileItem, FolderItem, SearchResourceItem, SortBy } from '$lib/api/types';
|
||||
import { lazyComponent } from '$lib/composables/lazyComponent.svelte';
|
||||
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 { replaceSet } from '$lib/utils/sets';
|
||||
import {
|
||||
useResourceActions,
|
||||
type ActionTarget
|
||||
} from '$lib/composables/useResourceActions.svelte';
|
||||
import FilterChips from '$lib/components/FilterChips.svelte';
|
||||
import Icon from '$lib/icons/Icon.svelte';
|
||||
import { t } from '$lib/i18n/index.svelte';
|
||||
import { confirmDialog, promptDialog } from '$lib/stores/dialogs.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 { ui } from '$lib/stores/ui.svelte';
|
||||
|
||||
@@ -125,72 +128,48 @@
|
||||
scopeOverride === 'all' ? 'all' : effectiveFolder && scopeFolderId ? 'folder' : 'all'
|
||||
);
|
||||
function setScope(next: 'all' | 'folder') {
|
||||
// Build the query string by hand — Svelte's lint flags mutating a
|
||||
// stdlib `URLSearchParams`, and we don't need reactivity here.
|
||||
// Copy the current URL and flip only the scope params — `q=` and the
|
||||
// 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
|
||||
// even when switching to "Everywhere" so "This folder" stays
|
||||
// clickable and remembers WHICH folder. The active-scope flip
|
||||
// rides on `scope=all` instead.
|
||||
const parts: string[] = [];
|
||||
if (query) parts.push(`q=${encodeURIComponent(query)}`);
|
||||
const url = new URL(page.url);
|
||||
// Sticky `in=`: keep whatever's already in the URL, or seed it
|
||||
// from filesStore when the user first pins "This folder" from a
|
||||
// fresh /search visit.
|
||||
const stickyFolder = scopeFolderId ?? (next === 'folder' ? filesStore.currentFolder : null);
|
||||
if (stickyFolder) {
|
||||
parts.push(`in=${encodeURIComponent(stickyFolder)}`);
|
||||
url.searchParams.set('in', stickyFolder);
|
||||
} else {
|
||||
url.searchParams.delete('in');
|
||||
}
|
||||
if (next === 'all' && stickyFolder) {
|
||||
// Only meaningful when there's a folder to override — otherwise
|
||||
// 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 —
|
||||
// scope changes are UI state, not navigation. `keepFocus: true`
|
||||
// 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
|
||||
// in the shared `searchFilters` util (also consumed by the files page's
|
||||
// filter bar); only the i18n label lists stay local since they need `t()`.
|
||||
type TypeKey = 'all' | 'image' | 'video' | 'document' | 'audio' | 'archive';
|
||||
type SizeKey = 'all' | 'small' | 'medium' | 'large';
|
||||
type DateKey = 'all' | 'day' | 'week' | 'month' | 'year';
|
||||
let typeFilter = $state<TypeKey>('all');
|
||||
let sizeFilter = $state<SizeKey>('all');
|
||||
let dateFilter = $state<DateKey>('all');
|
||||
|
||||
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';
|
||||
}
|
||||
// Filters live in the shared store (`$lib/stores/filter.svelte`) and are
|
||||
// mirrored to/from this page's URL — the top bar's filter panel and the
|
||||
// files page's filter bar mutate the same object, so a filter set in
|
||||
// either place is live here, and every dimension is URL-persisted
|
||||
// (`?type=…&size=…&created=…&kind=…&recursive=0`) for refresh/bookmark/
|
||||
// Back. The active presets render as dismissible chips in the header
|
||||
// (`<FilterChips>` in the actions snippet) — the old in-page selects
|
||||
// duplicated the vocabularies and lost state on every refresh.
|
||||
useFilterUrlSync(false);
|
||||
|
||||
// ── Group / sort dimensions (shown in the DisplayModeControls dropdown) ──
|
||||
// Ed's 2026-07-26 spec: 4 options total —
|
||||
@@ -254,16 +233,14 @@
|
||||
scope === 'folder' && filesStore.section !== 'trash'
|
||||
? (effectiveFolder ?? undefined)
|
||||
: undefined;
|
||||
// TYPE_EXT / sizeBounds / dateBound come from `$lib/utils/searchFilters`
|
||||
// (shared with the files-page filter bar).
|
||||
// Filter presets come from the shared store (hydrated from this
|
||||
// page's URL by `useFilterUrlSync`), mapped onto the wire by the
|
||||
// util shared with the files page's filter bar.
|
||||
return {
|
||||
recursive: true,
|
||||
...filterToSearchOptions(resourceFilters),
|
||||
sortBy: orderByForGroup() as SortBy,
|
||||
reverse: reversed,
|
||||
folderId,
|
||||
fileTypes: typeFilter === 'all' ? undefined : TYPE_EXT[typeFilter],
|
||||
...sizeBounds(sizeFilter),
|
||||
modifiedAfter: dateBound(dateFilter)
|
||||
folderId
|
||||
};
|
||||
}
|
||||
|
||||
@@ -546,13 +523,19 @@
|
||||
];
|
||||
|
||||
$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 reversed;
|
||||
void scope;
|
||||
void typeFilter;
|
||||
void sizeFilter;
|
||||
void dateFilter;
|
||||
void resourceFilters.type;
|
||||
void resourceFilters.size;
|
||||
void resourceFilters.date;
|
||||
void resourceFilters.created;
|
||||
void resourceFilters.kind;
|
||||
void resourceFilters.recursive;
|
||||
void run(query);
|
||||
});
|
||||
|
||||
@@ -686,47 +669,14 @@
|
||||
{t('search.this_folder', 'This folder')}
|
||||
</button>
|
||||
</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
|
||||
built-in DisplayModeControls now (fed by `groupBys` +
|
||||
`bind:groupBy` + `bind:reversed` below), matching
|
||||
/favorites / /recent / /trash. The old
|
||||
`<select bind:value={sortBy}>` was removed with the
|
||||
`SORTS` array.
|
||||
Filter editing lives in the top bar's panel (the unified
|
||||
entry); this page only SHOWS the active presets, as
|
||||
dismissible chips. NOTE: sort dimension + asc/desc live in
|
||||
ResourceList's built-in DisplayModeControls (fed by
|
||||
`groupBys` + `bind:groupBy` + `bind:reversed` below).
|
||||
-->
|
||||
{#if hasFilters}
|
||||
<button class="clear-filters" data-testid="search-clear-filters-btn" onclick={clearFilters}>
|
||||
<Icon name="times" />
|
||||
{t('search.clear_filters', 'Clear filters')}
|
||||
</button>
|
||||
{/if}
|
||||
<FilterChips value={resourceFilters} />
|
||||
{/snippet}
|
||||
{#snippet breadcrumb()}
|
||||
<!--
|
||||
@@ -845,20 +795,12 @@
|
||||
{/if}
|
||||
|
||||
<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 —
|
||||
Svelte's scoped selectors still apply because these are declared
|
||||
with the elements they style below.
|
||||
|
||||
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 {
|
||||
display: flex;
|
||||
border: 1px solid var(--color-border);
|
||||
@@ -883,20 +825,4 @@
|
||||
opacity: 0.5;
|
||||
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>
|
||||
|
||||
@@ -126,6 +126,39 @@ it('surfaces a search error', async () => {
|
||||
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 () => {
|
||||
m(searchResources).mockResolvedValue(searchHit());
|
||||
confirmDialog.mockResolvedValue(true);
|
||||
|
||||
@@ -422,7 +422,15 @@
|
||||
"advanced": "المرشحات",
|
||||
"recursive": "تضمين المجلدات الفرعية",
|
||||
"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": {
|
||||
"name": "الاسم",
|
||||
|
||||
@@ -422,7 +422,15 @@
|
||||
"advanced": "Filter",
|
||||
"recursive": "Unterordner einbeziehen",
|
||||
"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": {
|
||||
"name": "Name",
|
||||
|
||||
@@ -457,7 +457,15 @@
|
||||
"advanced": "Filters",
|
||||
"recursive": "Include subfolders",
|
||||
"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": {
|
||||
"name": "Name",
|
||||
|
||||
@@ -422,7 +422,15 @@
|
||||
"advanced": "Filtros",
|
||||
"recursive": "Incluir subcarpetas",
|
||||
"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": {
|
||||
"name": "Nombre",
|
||||
|
||||
@@ -422,7 +422,15 @@
|
||||
"advanced": "فیلترها",
|
||||
"recursive": "شامل زیرپوشهها",
|
||||
"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": {
|
||||
"name": "نام",
|
||||
|
||||
@@ -422,7 +422,15 @@
|
||||
"advanced": "Filtres",
|
||||
"recursive": "Inclure les sous-dossiers",
|
||||
"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": {
|
||||
"name": "Nom",
|
||||
|
||||
@@ -422,7 +422,15 @@
|
||||
"advanced": "फ़िल्टर",
|
||||
"recursive": "सबफ़ोल्डर शामिल करें",
|
||||
"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": {
|
||||
"name": "नाम",
|
||||
|
||||
@@ -422,7 +422,15 @@
|
||||
"advanced": "Filtri",
|
||||
"recursive": "Includi sottocartelle",
|
||||
"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": {
|
||||
"name": "Nome",
|
||||
|
||||
@@ -422,7 +422,15 @@
|
||||
"advanced": "フィルター",
|
||||
"recursive": "サブフォルダを含める",
|
||||
"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": {
|
||||
"name": "名前",
|
||||
|
||||
@@ -422,7 +422,15 @@
|
||||
"advanced": "필터",
|
||||
"recursive": "하위 폴더 포함",
|
||||
"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": {
|
||||
"name": "이름",
|
||||
|
||||
@@ -422,7 +422,15 @@
|
||||
"advanced": "Filters",
|
||||
"recursive": "Submappen opnemen",
|
||||
"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": {
|
||||
"name": "Naam",
|
||||
|
||||
@@ -422,7 +422,15 @@
|
||||
"advanced": "Filtry",
|
||||
"recursive": "Uwzględnij podfoldery",
|
||||
"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": {
|
||||
"name": "Nazwa",
|
||||
|
||||
@@ -422,7 +422,15 @@
|
||||
"advanced": "Filtros",
|
||||
"recursive": "Incluir subpastas",
|
||||
"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": {
|
||||
"name": "Nome",
|
||||
|
||||
@@ -422,7 +422,15 @@
|
||||
"advanced": "Фильтры",
|
||||
"recursive": "Включая подпапки",
|
||||
"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": {
|
||||
"name": "Имя",
|
||||
|
||||
@@ -422,7 +422,15 @@
|
||||
"advanced": "篩選",
|
||||
"recursive": "包含子資料夾",
|
||||
"results_count": "{{n}} 個結果",
|
||||
"clear_keyword": "清除搜尋"
|
||||
"clear_keyword": "清除搜尋",
|
||||
"modified_label": "修改時間",
|
||||
"created_label": "建立時間",
|
||||
"kind_label": "項目",
|
||||
"kind": {
|
||||
"all": "檔案與資料夾",
|
||||
"file": "僅檔案",
|
||||
"folder": "僅資料夾"
|
||||
}
|
||||
},
|
||||
"files": {
|
||||
"name": "名稱",
|
||||
|
||||
@@ -422,7 +422,15 @@
|
||||
"advanced": "筛选",
|
||||
"recursive": "包含子文件夹",
|
||||
"results_count": "{{n}} 个结果",
|
||||
"clear_keyword": "清除搜索"
|
||||
"clear_keyword": "清除搜索",
|
||||
"modified_label": "修改时间",
|
||||
"created_label": "创建时间",
|
||||
"kind_label": "条目",
|
||||
"kind": {
|
||||
"all": "文件和文件夹",
|
||||
"file": "仅文件",
|
||||
"folder": "仅文件夹"
|
||||
}
|
||||
},
|
||||
"files": {
|
||||
"name": "名称",
|
||||
|
||||
@@ -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 进程内缓存)
|
||||
- **状态**: 已完成(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 点查"的负载。
|
||||
|
||||
Reference in New Issue
Block a user