refactor(frontend): remove the files-page filter bar; top bar owns filtering
The top bar's unified search box + filter panel (29d0c335) already drives
the same shared filter store, so the files-page SearchFilterBar had become
a second, redundant input. Remove the component and its mount; the shared
store, URL sync and the files page's in-place search mode all stay — the
top bar is now the only filter writer. Drop the orphaned locale keys
(filter.placeholder/keyword/clear_keyword) from all 16 locales and rework
the affected tests to drive the store directly, with a resetFilters() +
URL reset in beforeEach to keep the module-level store from leaking.
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -400,7 +400,7 @@
|
||||
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.
|
||||
// panel (below) and the /search page mutate.
|
||||
const filterActiveCount = $derived(activeFilterCount(resourceFilters));
|
||||
let suggestTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
// Stale-response guard (same family as the search page): the debounce
|
||||
@@ -420,7 +420,7 @@
|
||||
// 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
|
||||
// shared store is what the top-bar panel and the /search page
|
||||
// mutate, so submitting from here carries exactly what the user sees.
|
||||
const filterQs = filterParamsToString(resourceFilters);
|
||||
if (filterQs) parts.push(filterQs);
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
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.
|
||||
Extracted so the top bar's filter panel renders the same fields off the
|
||||
same vocabulary the /search page chips name — the option lists used to
|
||||
live in several copies. 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';
|
||||
|
||||
@@ -1,266 +0,0 @@
|
||||
<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 ResourceFilterState
|
||||
} from '$lib/utils/searchFilters';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* 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 (presets + recursive) expanded. */
|
||||
expanded?: boolean;
|
||||
placeholder?: string;
|
||||
/** Debounce for the keyword input (ms). */
|
||||
debounceMs?: number;
|
||||
/** Hide the recursive toggle (a surface where scope is owned elsewhere). */
|
||||
hideRecursive?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
value = defaultFilterState(),
|
||||
expanded = $bindable(false),
|
||||
placeholder = t('filter.placeholder', 'Search this folder and subfolders…'),
|
||||
debounceMs = 300,
|
||||
hideRecursive = false
|
||||
}: Props = $props();
|
||||
|
||||
// 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, 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(() => {
|
||||
// External `value.query` change → adopt it into the input buffer.
|
||||
const external = value.query;
|
||||
if (external !== lastPushed) {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
keyword = external;
|
||||
lastPushed = external;
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
// No reactive deps — teardown-only, clearing a pending debounce on destroy.
|
||||
return () => {
|
||||
if (timer) clearTimeout(timer);
|
||||
};
|
||||
});
|
||||
|
||||
function pushKeyword(v: string) {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
value.query = v;
|
||||
lastPushed = v;
|
||||
}
|
||||
|
||||
function handleInput(e: Event) {
|
||||
const v = (e.target as HTMLInputElement).value;
|
||||
keyword = v;
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
timer = null;
|
||||
pushKeyword(v);
|
||||
}, debounceMs);
|
||||
}
|
||||
|
||||
function onInputKeydown(e: KeyboardEvent) {
|
||||
// Escape clears the keyword locally and never reaches the page-level
|
||||
// handler (which would otherwise also clear the selection / filters).
|
||||
if (e.key === 'Escape') {
|
||||
e.stopPropagation();
|
||||
clearKeyword();
|
||||
} else if (e.key === 'Enter') {
|
||||
// Enter flushes the debounce for an immediate search.
|
||||
e.preventDefault();
|
||||
pushKeyword(keyword);
|
||||
}
|
||||
}
|
||||
|
||||
function clearKeyword() {
|
||||
keyword = '';
|
||||
pushKeyword('');
|
||||
}
|
||||
|
||||
const activeCount = $derived(activeFilterCount(value));
|
||||
</script>
|
||||
|
||||
<div class="sfb" data-testid="search-filter-bar">
|
||||
<div class="sfb__row">
|
||||
<div class="sfb__input-wrap">
|
||||
<span class="sfb__magnifier"><Icon name="search" /></span>
|
||||
<input
|
||||
class="sfb__input"
|
||||
type="search"
|
||||
{placeholder}
|
||||
aria-label={t('filter.keyword', 'Keyword')}
|
||||
data-testid="filter-keyword-input"
|
||||
value={keyword}
|
||||
oninput={handleInput}
|
||||
onkeydown={onInputKeydown}
|
||||
/>
|
||||
{#if keyword.length > 0}
|
||||
<button
|
||||
class="sfb__clear"
|
||||
type="button"
|
||||
aria-label={t('filter.clear_keyword', 'Clear search')}
|
||||
data-testid="filter-clear-keyword-btn"
|
||||
onclick={clearKeyword}
|
||||
>
|
||||
<Icon name="times" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
class="sfb__toggle"
|
||||
class:sfb__toggle--active={activeCount > 0}
|
||||
type="button"
|
||||
aria-expanded={expanded}
|
||||
aria-label={t('filter.advanced', 'Filters')}
|
||||
title={t('filter.advanced', 'Filters')}
|
||||
data-testid="filter-advanced-toggle-btn"
|
||||
onclick={() => (expanded = !expanded)}
|
||||
>
|
||||
<Icon name="sliders-h" />
|
||||
{#if activeCount > 0}
|
||||
<span class="sfb__badge">{activeCount}</span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if expanded}
|
||||
<div data-testid="filter-advanced-row">
|
||||
<FilterFields bind:value {hideRecursive} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.sfb {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sfb__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.sfb__input-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sfb__magnifier {
|
||||
position: absolute;
|
||||
left: 0.6rem;
|
||||
color: var(--color-text-secondary);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.sfb__input {
|
||||
width: 100%;
|
||||
padding: 0.45rem 2rem 0.45rem 2.1rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg-surface);
|
||||
color: var(--color-text);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.sfb__input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.sfb__input::-webkit-search-cancel-button {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.sfb__clear {
|
||||
position: absolute;
|
||||
right: 0.4rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.4rem;
|
||||
height: 1.4rem;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sfb__clear:hover {
|
||||
background: var(--color-bg-hover);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.sfb__toggle {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.2rem;
|
||||
height: 2.2rem;
|
||||
padding: 0;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg-surface);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sfb__toggle:hover {
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.sfb__toggle--active {
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.sfb__badge {
|
||||
position: absolute;
|
||||
top: -0.4rem;
|
||||
right: -0.4rem;
|
||||
min-width: 1rem;
|
||||
height: 1rem;
|
||||
padding: 0 0.2rem;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-accent);
|
||||
color: var(--color-on-accent);
|
||||
font-size: var(--text-xs, 0.7rem);
|
||||
line-height: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* 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
|
||||
* One module-level `$state` object is THE filter state: the top bar's
|
||||
* search box and 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.
|
||||
|
||||
@@ -75,7 +75,6 @@
|
||||
batchZipName,
|
||||
type ActionTarget
|
||||
} from '$lib/composables/useResourceActions.svelte';
|
||||
import SearchFilterBar from '$lib/components/SearchFilterBar.svelte';
|
||||
|
||||
// Message-bus logger. Users can tune with
|
||||
// oxi.setLogLevel('oxi:message-bus', 'debug')
|
||||
@@ -451,7 +450,7 @@
|
||||
}
|
||||
|
||||
// ── Filter / search mode ─────────────────────────────────────────────────
|
||||
// While any SearchFilterBar dimension is active the listing switches from
|
||||
// While any filter dimension is active the listing switches from
|
||||
// the folder page (`fetchFolderPage`) to a scoped search (`searchResources`
|
||||
// with folder_id = currentId). The two data paths keep independent cursors
|
||||
// and stale guards; entering/leaving the mode neutralizes the other path's
|
||||
@@ -2012,15 +2011,8 @@
|
||||
<ReadOnlyBanner driveName={currentDrive.name} />
|
||||
{/if}
|
||||
|
||||
<!-- 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. 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 value={filter} />
|
||||
</div>
|
||||
<!-- Filtering is driven from the top bar (search box + filter panel),
|
||||
which mutates the shared store this page's search mode reads. -->
|
||||
|
||||
<!-- Hidden upload inputs stay mounted even while the batch bar is shown.
|
||||
Kept OUTSIDE ResourceList so the split-button dropdown in the
|
||||
@@ -2545,9 +2537,4 @@
|
||||
rendered near-invisible here). Mirrors the user-menu logout red. */
|
||||
color: var(--color-danger-alt);
|
||||
}
|
||||
|
||||
.files-filter-row {
|
||||
padding: 0 var(--space-2);
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -85,6 +85,7 @@ import { resolveOwnedHashes, tryDeltaUpload } from '$lib/api/endpoints/deltaUplo
|
||||
import { addFavoritesBatch } from '$lib/api/endpoints/favorites';
|
||||
import { searchResources } from '$lib/api/endpoints/search';
|
||||
import { files as filesStore } from '$lib/stores/files.svelte';
|
||||
import { resourceFilters, resetFilters } from '$lib/stores/filter.svelte';
|
||||
import FilesPage from './[...path]/+page.svelte';
|
||||
|
||||
const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
|
||||
@@ -151,6 +152,12 @@ beforeEach(() => {
|
||||
// `/files/<drive-root>` via goto (see the external-user test), so the
|
||||
// listing-oriented tests target a folder directly.
|
||||
pageState.params.path = 'home';
|
||||
// Same for the URL: the filter store hydrates from it, so a deep-link
|
||||
// URL left over from an earlier test would leak presets forward.
|
||||
pageState.url = new URL('http://localhost/files');
|
||||
// The filter store is module-level shared state — clear whatever a
|
||||
// previous test's keyword/preset mutation or URL hydration left there.
|
||||
resetFilters();
|
||||
// List view renders the select-all header + per-row checkboxes; grid hides them.
|
||||
filesStore.viewMode = 'list';
|
||||
});
|
||||
@@ -291,15 +298,14 @@ it('runs a scoped recursive search when the filter keyword is set', async () =>
|
||||
m(searchResources).mockResolvedValue({ items: [], query_time_ms: 1 });
|
||||
render(FilesPage);
|
||||
await waitFor(() => expect(fetchFolderPage).toHaveBeenCalled());
|
||||
vi.useFakeTimers();
|
||||
await fireEvent.input(screen.getByTestId('filter-keyword-input'), {
|
||||
target: { value: 'hello' }
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(400);
|
||||
vi.useRealTimers();
|
||||
// The keyword arrives through the shared filter store — the top-bar
|
||||
// search box and filter panel are its writers on this page.
|
||||
resourceFilters.query = 'hello';
|
||||
await waitFor(() =>
|
||||
expect(searchResources).toHaveBeenCalledWith(
|
||||
'hello',
|
||||
expect.objectContaining({ folderId: 'home', recursive: true })
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
@@ -312,14 +318,15 @@ it('shows the search result count while the filter is active', async () => {
|
||||
});
|
||||
render(FilesPage);
|
||||
await waitFor(() => expect(fetchFolderPage).toHaveBeenCalled());
|
||||
vi.useFakeTimers();
|
||||
await fireEvent.input(screen.getByTestId('filter-keyword-input'), {
|
||||
target: { value: 'found' }
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(400);
|
||||
vi.useRealTimers();
|
||||
resourceFilters.query = 'found';
|
||||
// Sync on the search having fired (not just the store write): from this
|
||||
// point the mocked response is already on the microtask queue, so the
|
||||
// text wait below observes the post-search DOM deterministically.
|
||||
await waitFor(() => expect(searchResources).toHaveBeenCalled());
|
||||
const stat = await screen.findByTestId('files-folder-stat');
|
||||
expect(stat.textContent).toContain('1 results');
|
||||
// The stat element exists in plain folder mode too (it shows the folder
|
||||
// counts there), so wait for the search landing to swap its text.
|
||||
await waitFor(() => expect(stat.textContent).toContain('1 results'));
|
||||
});
|
||||
|
||||
it('hydrates filter presets from a deep-linked URL and runs the scoped search', async () => {
|
||||
@@ -334,9 +341,6 @@ it('hydrates filter presets from a deep-linked URL and runs the scoped search',
|
||||
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 () => {
|
||||
@@ -345,17 +349,17 @@ it('writes an edited filter back to the URL exactly once (no sync loop)', async
|
||||
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);
|
||||
resourceFilters.query = 'hello';
|
||||
// One replaceState write for the keyword — and nothing else: the sync
|
||||
// effect must recognize its own write and settle, not ping-pong.
|
||||
await waitFor(() => 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');
|
||||
// Give the effect a tick to misbehave before asserting it settled —
|
||||
// a second write would mean the URL→store adoption re-triggered the
|
||||
// store→URL direction.
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(goto).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -417,12 +417,9 @@
|
||||
"shared_notificationFailed": "فشل إرسال الإشعار"
|
||||
},
|
||||
"filter": {
|
||||
"placeholder": "ابحث في هذا المجلد والمجلدات الفرعية…",
|
||||
"keyword": "الكلمة المفتاحية",
|
||||
"advanced": "المرشحات",
|
||||
"recursive": "تضمين المجلدات الفرعية",
|
||||
"results_count": "{{n}} نتائج",
|
||||
"clear_keyword": "مسح البحث",
|
||||
"modified_label": "Modified",
|
||||
"created_label": "Created",
|
||||
"kind_label": "Items",
|
||||
|
||||
@@ -417,12 +417,9 @@
|
||||
"shared_notificationFailed": "Benachrichtigung konnte nicht gesendet werden"
|
||||
},
|
||||
"filter": {
|
||||
"placeholder": "Diesen Ordner und Unterordner durchsuchen…",
|
||||
"keyword": "Suchbegriff",
|
||||
"advanced": "Filter",
|
||||
"recursive": "Unterordner einbeziehen",
|
||||
"results_count": "{{n}} Ergebnisse",
|
||||
"clear_keyword": "Suche löschen",
|
||||
"modified_label": "Modified",
|
||||
"created_label": "Created",
|
||||
"kind_label": "Items",
|
||||
|
||||
@@ -452,12 +452,9 @@
|
||||
"shared_notificationFailed": "Failed to send notification"
|
||||
},
|
||||
"filter": {
|
||||
"placeholder": "Search this folder and subfolders…",
|
||||
"keyword": "Keyword",
|
||||
"advanced": "Filters",
|
||||
"recursive": "Include subfolders",
|
||||
"results_count": "{{n}} results",
|
||||
"clear_keyword": "Clear search",
|
||||
"modified_label": "Modified",
|
||||
"created_label": "Created",
|
||||
"kind_label": "Items",
|
||||
|
||||
@@ -417,12 +417,9 @@
|
||||
"title": "Menú de usuario"
|
||||
},
|
||||
"filter": {
|
||||
"placeholder": "Buscar en esta carpeta y subcarpetas…",
|
||||
"keyword": "Palabra clave",
|
||||
"advanced": "Filtros",
|
||||
"recursive": "Incluir subcarpetas",
|
||||
"results_count": "{{n}} resultados",
|
||||
"clear_keyword": "Borrar búsqueda",
|
||||
"modified_label": "Modified",
|
||||
"created_label": "Created",
|
||||
"kind_label": "Items",
|
||||
|
||||
@@ -417,12 +417,9 @@
|
||||
"shared_notificationFailed": "ارسال آگاهسازی ناموفق بود"
|
||||
},
|
||||
"filter": {
|
||||
"placeholder": "جستجو در این پوشه و زیرپوشهها…",
|
||||
"keyword": "کلیدواژه",
|
||||
"advanced": "فیلترها",
|
||||
"recursive": "شامل زیرپوشهها",
|
||||
"results_count": "{{n}} نتیجه",
|
||||
"clear_keyword": "پاک کردن جستجو",
|
||||
"modified_label": "Modified",
|
||||
"created_label": "Created",
|
||||
"kind_label": "Items",
|
||||
|
||||
@@ -417,12 +417,9 @@
|
||||
"shared_notificationFailed": "Erreur lors de l'envoi de la notification"
|
||||
},
|
||||
"filter": {
|
||||
"placeholder": "Rechercher dans ce dossier et ses sous-dossiers…",
|
||||
"keyword": "Mot-clé",
|
||||
"advanced": "Filtres",
|
||||
"recursive": "Inclure les sous-dossiers",
|
||||
"results_count": "{{n}} résultats",
|
||||
"clear_keyword": "Effacer la recherche",
|
||||
"modified_label": "Modified",
|
||||
"created_label": "Created",
|
||||
"kind_label": "Items",
|
||||
|
||||
@@ -417,12 +417,9 @@
|
||||
"shared_notificationFailed": "सूचना भेजने में विफल"
|
||||
},
|
||||
"filter": {
|
||||
"placeholder": "इस फ़ोल्डर और सबफ़ोल्डर में खोजें…",
|
||||
"keyword": "कीवर्ड",
|
||||
"advanced": "फ़िल्टर",
|
||||
"recursive": "सबफ़ोल्डर शामिल करें",
|
||||
"results_count": "{{n}} परिणाम",
|
||||
"clear_keyword": "खोज साफ़ करें",
|
||||
"modified_label": "Modified",
|
||||
"created_label": "Created",
|
||||
"kind_label": "Items",
|
||||
|
||||
@@ -417,12 +417,9 @@
|
||||
"shared_notificationFailed": "Impossibile inviare la notifica"
|
||||
},
|
||||
"filter": {
|
||||
"placeholder": "Cerca in questa cartella e nelle sottocartelle…",
|
||||
"keyword": "Parola chiave",
|
||||
"advanced": "Filtri",
|
||||
"recursive": "Includi sottocartelle",
|
||||
"results_count": "{{n}} risultati",
|
||||
"clear_keyword": "Cancella ricerca",
|
||||
"modified_label": "Modified",
|
||||
"created_label": "Created",
|
||||
"kind_label": "Items",
|
||||
|
||||
@@ -417,12 +417,9 @@
|
||||
"shared_notificationFailed": "通知の送信に失敗しました"
|
||||
},
|
||||
"filter": {
|
||||
"placeholder": "このフォルダとサブフォルダを検索…",
|
||||
"keyword": "キーワード",
|
||||
"advanced": "フィルター",
|
||||
"recursive": "サブフォルダを含める",
|
||||
"results_count": "{{n}} 件の結果",
|
||||
"clear_keyword": "検索をクリア",
|
||||
"modified_label": "Modified",
|
||||
"created_label": "Created",
|
||||
"kind_label": "Items",
|
||||
|
||||
@@ -417,12 +417,9 @@
|
||||
"shared_notificationFailed": "알림 전송에 실패했습니다"
|
||||
},
|
||||
"filter": {
|
||||
"placeholder": "이 폴더와 하위 폴더 검색…",
|
||||
"keyword": "키워드",
|
||||
"advanced": "필터",
|
||||
"recursive": "하위 폴더 포함",
|
||||
"results_count": "{{n}}개 결과",
|
||||
"clear_keyword": "검색 지우기",
|
||||
"modified_label": "Modified",
|
||||
"created_label": "Created",
|
||||
"kind_label": "Items",
|
||||
|
||||
@@ -417,12 +417,9 @@
|
||||
"shared_notificationFailed": "Notificatie verzenden mislukt"
|
||||
},
|
||||
"filter": {
|
||||
"placeholder": "Deze map en submappen doorzoeken…",
|
||||
"keyword": "Trefwoord",
|
||||
"advanced": "Filters",
|
||||
"recursive": "Submappen opnemen",
|
||||
"results_count": "{{n}} resultaten",
|
||||
"clear_keyword": "Zoekopdracht wissen",
|
||||
"modified_label": "Modified",
|
||||
"created_label": "Created",
|
||||
"kind_label": "Items",
|
||||
|
||||
@@ -417,12 +417,9 @@
|
||||
"shared_notificationFailed": "Nie udało się wysłać powiadomienia"
|
||||
},
|
||||
"filter": {
|
||||
"placeholder": "Szukaj w tym folderze i podfolderach…",
|
||||
"keyword": "Słowo kluczowe",
|
||||
"advanced": "Filtry",
|
||||
"recursive": "Uwzględnij podfoldery",
|
||||
"results_count": "Wyniki: {{n}}",
|
||||
"clear_keyword": "Wyczyść wyszukiwanie",
|
||||
"modified_label": "Modified",
|
||||
"created_label": "Created",
|
||||
"kind_label": "Items",
|
||||
|
||||
@@ -417,12 +417,9 @@
|
||||
"shared_notificationFailed": "Falha ao enviar a notificação"
|
||||
},
|
||||
"filter": {
|
||||
"placeholder": "Pesquisar nesta pasta e subpastas…",
|
||||
"keyword": "Palavra-chave",
|
||||
"advanced": "Filtros",
|
||||
"recursive": "Incluir subpastas",
|
||||
"results_count": "{{n}} resultados",
|
||||
"clear_keyword": "Limpar pesquisa",
|
||||
"modified_label": "Modified",
|
||||
"created_label": "Created",
|
||||
"kind_label": "Items",
|
||||
|
||||
@@ -417,12 +417,9 @@
|
||||
"shared_notificationFailed": "Не удалось отправить уведомление"
|
||||
},
|
||||
"filter": {
|
||||
"placeholder": "Поиск в этой папке и подпапках…",
|
||||
"keyword": "Ключевое слово",
|
||||
"advanced": "Фильтры",
|
||||
"recursive": "Включая подпапки",
|
||||
"results_count": "Результатов: {{n}}",
|
||||
"clear_keyword": "Очистить поиск",
|
||||
"modified_label": "Modified",
|
||||
"created_label": "Created",
|
||||
"kind_label": "Items",
|
||||
|
||||
@@ -417,12 +417,9 @@
|
||||
"shared_typeFolder": "資料夾"
|
||||
},
|
||||
"filter": {
|
||||
"placeholder": "搜尋此資料夾及子資料夾…",
|
||||
"keyword": "關鍵字",
|
||||
"advanced": "篩選",
|
||||
"recursive": "包含子資料夾",
|
||||
"results_count": "{{n}} 個結果",
|
||||
"clear_keyword": "清除搜尋",
|
||||
"modified_label": "修改時間",
|
||||
"created_label": "建立時間",
|
||||
"kind_label": "項目",
|
||||
|
||||
@@ -417,12 +417,9 @@
|
||||
"shared_typeFolder": "文件夹"
|
||||
},
|
||||
"filter": {
|
||||
"placeholder": "搜索此文件夹及子文件夹…",
|
||||
"keyword": "关键词",
|
||||
"advanced": "筛选",
|
||||
"recursive": "包含子文件夹",
|
||||
"results_count": "{{n}} 个结果",
|
||||
"clear_keyword": "清除搜索",
|
||||
"modified_label": "修改时间",
|
||||
"created_label": "创建时间",
|
||||
"kind_label": "条目",
|
||||
|
||||
@@ -9,6 +9,27 @@
|
||||
|
||||
## 已完成
|
||||
|
||||
### [2026-10-01] 移除文件页搜索过滤栏(顶栏统一入口)
|
||||
- **状态**: 已完成(`npm run check` 全绿:svelte-check 0 错 0 警 + eslint + stylelint + prettier;`vitest run` 492 通过 0 失败)
|
||||
- **计划**: 顶栏搜索框与筛选面板(`29d0c335`)已提供同一套筛选能力后,文件页自己的
|
||||
`SearchFilterBar` 成为重复的第二个输入框,按用户决定移除。共享 store、URL 同步、
|
||||
文件页的就地搜索模式**全部保留**——顶栏(搜索框 + 面板)现在是唯一的筛选写入方。
|
||||
- **移除后的能力分布**: 预设筛选(类型/大小/修改/创建/种类/递归)在顶栏面板改共享 store →
|
||||
文件页就地过滤;关键词在顶栏搜索框提交 → 跳 `/search?q=…&in=<当前文件夹>`(范围跟随);
|
||||
`?q=` 深链仍直接在文件页水合生效。
|
||||
- **改动文件**:
|
||||
- 删除(仅本地): `frontend/src/lib/components/SearchFilterBar.svelte`
|
||||
- `frontend/src/routes/files/[...path]/+page.svelte` — 删组件导入与挂载(`.files-filter-row`),
|
||||
搜索模式/store/URL 同步不动;仅注释措辞更新("SearchFilterBar dimension"→"filter dimension")
|
||||
- `frontend/src/routes/files/page.test.ts` — 4 个测试改为直接写共享 store 驱动(`resourceFilters.query = …`,
|
||||
与顶栏同一写入路径),不再依赖已删除组件的 testid;beforeEach 补 `resetFilters()` + URL 复位防串扰
|
||||
- `frontend/static/locales/*.json`(16 个)— 删孤儿 key `filter.placeholder`/`filter.keyword`/`filter.clear_keyword`
|
||||
(其余 `filter.*` 被顶栏面板/FilterFields/FilterChips/文件页结果计数继续使用,保留)
|
||||
- 注释清理: `filter.svelte.ts`、`FilterFields.svelte`、`AppShell.svelte`(2 处)去掉对已删组件的引用
|
||||
- **仅本地文件**: 无新增(`status.md` 本身)
|
||||
- **上游冲突风险**: 低 — 净效果是 -323 行;SearchFilterBar 本就是仅本地组件,删除后与上游的冲突面
|
||||
比之前更小。locales 各文件仅 3 行删除。
|
||||
|
||||
### [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`(仅文件/仅文件夹)与创建时间维度。后端零改动。
|
||||
|
||||
Reference in New Issue
Block a user