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:
2026-10-01 01:10:11 +08:00
parent deb6dc831b
commit bd2c7dc45e
23 changed files with 66 additions and 368 deletions
+2 -2
View File
@@ -400,7 +400,7 @@
let suggestBusy = $state(false); let suggestBusy = $state(false);
// Live count of active filter presets — drives the toggle button's badge // Live count of active filter presets — drives the toggle button's badge
// and its "filters applied" highlight. The shared store is also what the // 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)); const filterActiveCount = $derived(activeFilterCount(resourceFilters));
let suggestTimer: ReturnType<typeof setTimeout> | null = null; let suggestTimer: ReturnType<typeof setTimeout> | null = null;
// Stale-response guard (same family as the search page): the debounce // Stale-response guard (same family as the search page): the debounce
@@ -420,7 +420,7 @@
// variant; these params don't need reactivity anyway. // variant; these params don't need reactivity anyway.
const parts = [`q=${encodeURIComponent(q)}`]; const parts = [`q=${encodeURIComponent(q)}`];
// Active filter presets ride along (`type=image&kind=file&…`) — the // 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. // mutate, so submitting from here carries exactly what the user sees.
const filterQs = filterParamsToString(resourceFilters); const filterQs = filterParamsToString(resourceFilters);
if (filterQs) parts.push(filterQs); if (filterQs) parts.push(filterQs);
@@ -2,11 +2,11 @@
The filter dimensions themselves (type / size / modified / created / kind / The filter dimensions themselves (type / size / modified / created / kind /
recursive), bound to the shared `ResourceFilterState`. recursive), bound to the shared `ResourceFilterState`.
Extracted so the files page's filter bar and the top bar's filter panel Extracted so the top bar's filter panel renders the same fields off the
render the same fields off the same vocabulary — the option lists used to same vocabulary the /search page chips name — the option lists used to
live in two copies (SearchFilterBar and the /search page). The vocabularies live in several copies. The vocabularies (extensions, byte bounds, date
(extensions, byte bounds, date presets) stay in `$lib/utils/searchFilters`; presets) stay in `$lib/utils/searchFilters`; only the i18n labels live
only the i18n labels live here. here.
--> -->
<script lang="ts"> <script lang="ts">
import { t } from '$lib/i18n/index.svelte'; 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>
+3 -3
View File
@@ -1,9 +1,9 @@
/** /**
* Shared resource-filter state for the search-backed surfaces. * Shared resource-filter state for the search-backed surfaces.
* *
* One module-level `$state` object is THE filter state: the files page's * One module-level `$state` object is THE filter state: the top bar's
* `SearchFilterBar`, the top bar's filter panel, and the /search chips all * search box and filter panel, and the /search chips all read and mutate
* read and mutate this same proxy, and each surface projects it into its URL * this same proxy, and each surface projects it into its URL
* (see `useFilterUrlSync`). Exported as a `const` — callers mutate fields, * (see `useFilterUrlSync`). Exported as a `const` — callers mutate fields,
* never reassign, which is also what keeps every holder looking at the same * never reassign, which is also what keeps every holder looking at the same
* proxy. * proxy.
@@ -75,7 +75,6 @@
batchZipName, batchZipName,
type ActionTarget type ActionTarget
} from '$lib/composables/useResourceActions.svelte'; } from '$lib/composables/useResourceActions.svelte';
import SearchFilterBar from '$lib/components/SearchFilterBar.svelte';
// Message-bus logger. Users can tune with // Message-bus logger. Users can tune with
// oxi.setLogLevel('oxi:message-bus', 'debug') // oxi.setLogLevel('oxi:message-bus', 'debug')
@@ -451,7 +450,7 @@
} }
// ── Filter / search mode ───────────────────────────────────────────────── // ── 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` // the folder page (`fetchFolderPage`) to a scoped search (`searchResources`
// with folder_id = currentId). The two data paths keep independent cursors // with folder_id = currentId). The two data paths keep independent cursors
// and stale guards; entering/leaving the mode neutralizes the other path's // and stale guards; entering/leaving the mode neutralizes the other path's
@@ -2012,15 +2011,8 @@
<ReadOnlyBanner driveName={currentDrive.name} /> <ReadOnlyBanner driveName={currentDrive.name} />
{/if} {/if}
<!-- Fuzzy filter bar: scoped keyword + type/size/date presets over the <!-- Filtering is driven from the top bar (search box + filter panel),
current folder (recursive toggle inside). While any dimension is which mutates the shared store this page's search mode reads. -->
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>
<!-- Hidden upload inputs stay mounted even while the batch bar is shown. <!-- Hidden upload inputs stay mounted even while the batch bar is shown.
Kept OUTSIDE ResourceList so the split-button dropdown in the Kept OUTSIDE ResourceList so the split-button dropdown in the
@@ -2545,9 +2537,4 @@
rendered near-invisible here). Mirrors the user-menu logout red. */ rendered near-invisible here). Mirrors the user-menu logout red. */
color: var(--color-danger-alt); color: var(--color-danger-alt);
} }
.files-filter-row {
padding: 0 var(--space-2);
margin-bottom: var(--space-1);
}
</style> </style>
+32 -28
View File
@@ -85,6 +85,7 @@ import { resolveOwnedHashes, tryDeltaUpload } from '$lib/api/endpoints/deltaUplo
import { addFavoritesBatch } from '$lib/api/endpoints/favorites'; import { addFavoritesBatch } from '$lib/api/endpoints/favorites';
import { searchResources } from '$lib/api/endpoints/search'; import { searchResources } from '$lib/api/endpoints/search';
import { files as filesStore } from '$lib/stores/files.svelte'; import { files as filesStore } from '$lib/stores/files.svelte';
import { resourceFilters, resetFilters } from '$lib/stores/filter.svelte';
import FilesPage from './[...path]/+page.svelte'; import FilesPage from './[...path]/+page.svelte';
const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>; 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 // `/files/<drive-root>` via goto (see the external-user test), so the
// listing-oriented tests target a folder directly. // listing-oriented tests target a folder directly.
pageState.params.path = 'home'; 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. // List view renders the select-all header + per-row checkboxes; grid hides them.
filesStore.viewMode = 'list'; 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 }); m(searchResources).mockResolvedValue({ items: [], query_time_ms: 1 });
render(FilesPage); render(FilesPage);
await waitFor(() => expect(fetchFolderPage).toHaveBeenCalled()); await waitFor(() => expect(fetchFolderPage).toHaveBeenCalled());
vi.useFakeTimers(); // The keyword arrives through the shared filter store — the top-bar
await fireEvent.input(screen.getByTestId('filter-keyword-input'), { // search box and filter panel are its writers on this page.
target: { value: 'hello' } resourceFilters.query = 'hello';
}); await waitFor(() =>
await vi.advanceTimersByTimeAsync(400); expect(searchResources).toHaveBeenCalledWith(
vi.useRealTimers(); 'hello',
expect(searchResources).toHaveBeenCalledWith( expect.objectContaining({ folderId: 'home', recursive: true })
'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); render(FilesPage);
await waitFor(() => expect(fetchFolderPage).toHaveBeenCalled()); await waitFor(() => expect(fetchFolderPage).toHaveBeenCalled());
vi.useFakeTimers(); resourceFilters.query = 'found';
await fireEvent.input(screen.getByTestId('filter-keyword-input'), { // Sync on the search having fired (not just the store write): from this
target: { value: 'found' } // point the mocked response is already on the microtask queue, so the
}); // text wait below observes the post-search DOM deterministically.
await vi.advanceTimersByTimeAsync(400); await waitFor(() => expect(searchResources).toHaveBeenCalled());
vi.useRealTimers();
const stat = await screen.findByTestId('files-folder-stat'); 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 () => { 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'], fileTypes: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'heic', 'avif', 'tiff'],
resourceTypes: ['folder'] 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 () => { 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); render(FilesPage);
await waitFor(() => expect(fetchFolderPage).toHaveBeenCalled()); await waitFor(() => expect(fetchFolderPage).toHaveBeenCalled());
m(goto).mockClear(); m(goto).mockClear();
vi.useFakeTimers(); resourceFilters.query = 'hello';
await fireEvent.input(screen.getByTestId('filter-keyword-input'), { // One replaceState write for the keyword — and nothing else: the sync
target: { value: 'hello' } // effect must recognize its own write and settle, not ping-pong.
}); await waitFor(() => expect(goto).toHaveBeenCalledTimes(1));
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 // The sync composable passes a path/search/hash string (a URL instance
// would trip svelte/prefer-svelte-reactivity in the .svelte.ts module). // would trip svelte/prefer-svelte-reactivity in the .svelte.ts module).
const written = m(goto).mock.calls[0][0] as string; const written = m(goto).mock.calls[0][0] as string;
expect(written).toContain('q=hello'); 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);
}); });
-3
View File
@@ -417,12 +417,9 @@
"shared_notificationFailed": "فشل إرسال الإشعار" "shared_notificationFailed": "فشل إرسال الإشعار"
}, },
"filter": { "filter": {
"placeholder": "ابحث في هذا المجلد والمجلدات الفرعية…",
"keyword": "الكلمة المفتاحية",
"advanced": "المرشحات", "advanced": "المرشحات",
"recursive": "تضمين المجلدات الفرعية", "recursive": "تضمين المجلدات الفرعية",
"results_count": "{{n}} نتائج", "results_count": "{{n}} نتائج",
"clear_keyword": "مسح البحث",
"modified_label": "Modified", "modified_label": "Modified",
"created_label": "Created", "created_label": "Created",
"kind_label": "Items", "kind_label": "Items",
-3
View File
@@ -417,12 +417,9 @@
"shared_notificationFailed": "Benachrichtigung konnte nicht gesendet werden" "shared_notificationFailed": "Benachrichtigung konnte nicht gesendet werden"
}, },
"filter": { "filter": {
"placeholder": "Diesen Ordner und Unterordner durchsuchen…",
"keyword": "Suchbegriff",
"advanced": "Filter", "advanced": "Filter",
"recursive": "Unterordner einbeziehen", "recursive": "Unterordner einbeziehen",
"results_count": "{{n}} Ergebnisse", "results_count": "{{n}} Ergebnisse",
"clear_keyword": "Suche löschen",
"modified_label": "Modified", "modified_label": "Modified",
"created_label": "Created", "created_label": "Created",
"kind_label": "Items", "kind_label": "Items",
-3
View File
@@ -452,12 +452,9 @@
"shared_notificationFailed": "Failed to send notification" "shared_notificationFailed": "Failed to send notification"
}, },
"filter": { "filter": {
"placeholder": "Search this folder and subfolders…",
"keyword": "Keyword",
"advanced": "Filters", "advanced": "Filters",
"recursive": "Include subfolders", "recursive": "Include subfolders",
"results_count": "{{n}} results", "results_count": "{{n}} results",
"clear_keyword": "Clear search",
"modified_label": "Modified", "modified_label": "Modified",
"created_label": "Created", "created_label": "Created",
"kind_label": "Items", "kind_label": "Items",
-3
View File
@@ -417,12 +417,9 @@
"title": "Menú de usuario" "title": "Menú de usuario"
}, },
"filter": { "filter": {
"placeholder": "Buscar en esta carpeta y subcarpetas…",
"keyword": "Palabra clave",
"advanced": "Filtros", "advanced": "Filtros",
"recursive": "Incluir subcarpetas", "recursive": "Incluir subcarpetas",
"results_count": "{{n}} resultados", "results_count": "{{n}} resultados",
"clear_keyword": "Borrar búsqueda",
"modified_label": "Modified", "modified_label": "Modified",
"created_label": "Created", "created_label": "Created",
"kind_label": "Items", "kind_label": "Items",
-3
View File
@@ -417,12 +417,9 @@
"shared_notificationFailed": "ارسال آگاه‌سازی ناموفق بود" "shared_notificationFailed": "ارسال آگاه‌سازی ناموفق بود"
}, },
"filter": { "filter": {
"placeholder": "جستجو در این پوشه و زیرپوشه‌ها…",
"keyword": "کلیدواژه",
"advanced": "فیلترها", "advanced": "فیلترها",
"recursive": "شامل زیرپوشه‌ها", "recursive": "شامل زیرپوشه‌ها",
"results_count": "{{n}} نتیجه", "results_count": "{{n}} نتیجه",
"clear_keyword": "پاک کردن جستجو",
"modified_label": "Modified", "modified_label": "Modified",
"created_label": "Created", "created_label": "Created",
"kind_label": "Items", "kind_label": "Items",
-3
View File
@@ -417,12 +417,9 @@
"shared_notificationFailed": "Erreur lors de l'envoi de la notification" "shared_notificationFailed": "Erreur lors de l'envoi de la notification"
}, },
"filter": { "filter": {
"placeholder": "Rechercher dans ce dossier et ses sous-dossiers…",
"keyword": "Mot-clé",
"advanced": "Filtres", "advanced": "Filtres",
"recursive": "Inclure les sous-dossiers", "recursive": "Inclure les sous-dossiers",
"results_count": "{{n}} résultats", "results_count": "{{n}} résultats",
"clear_keyword": "Effacer la recherche",
"modified_label": "Modified", "modified_label": "Modified",
"created_label": "Created", "created_label": "Created",
"kind_label": "Items", "kind_label": "Items",
-3
View File
@@ -417,12 +417,9 @@
"shared_notificationFailed": "सूचना भेजने में विफल" "shared_notificationFailed": "सूचना भेजने में विफल"
}, },
"filter": { "filter": {
"placeholder": "इस फ़ोल्डर और सबफ़ोल्डर में खोजें…",
"keyword": "कीवर्ड",
"advanced": "फ़िल्टर", "advanced": "फ़िल्टर",
"recursive": "सबफ़ोल्डर शामिल करें", "recursive": "सबफ़ोल्डर शामिल करें",
"results_count": "{{n}} परिणाम", "results_count": "{{n}} परिणाम",
"clear_keyword": "खोज साफ़ करें",
"modified_label": "Modified", "modified_label": "Modified",
"created_label": "Created", "created_label": "Created",
"kind_label": "Items", "kind_label": "Items",
-3
View File
@@ -417,12 +417,9 @@
"shared_notificationFailed": "Impossibile inviare la notifica" "shared_notificationFailed": "Impossibile inviare la notifica"
}, },
"filter": { "filter": {
"placeholder": "Cerca in questa cartella e nelle sottocartelle…",
"keyword": "Parola chiave",
"advanced": "Filtri", "advanced": "Filtri",
"recursive": "Includi sottocartelle", "recursive": "Includi sottocartelle",
"results_count": "{{n}} risultati", "results_count": "{{n}} risultati",
"clear_keyword": "Cancella ricerca",
"modified_label": "Modified", "modified_label": "Modified",
"created_label": "Created", "created_label": "Created",
"kind_label": "Items", "kind_label": "Items",
-3
View File
@@ -417,12 +417,9 @@
"shared_notificationFailed": "通知の送信に失敗しました" "shared_notificationFailed": "通知の送信に失敗しました"
}, },
"filter": { "filter": {
"placeholder": "このフォルダとサブフォルダを検索…",
"keyword": "キーワード",
"advanced": "フィルター", "advanced": "フィルター",
"recursive": "サブフォルダを含める", "recursive": "サブフォルダを含める",
"results_count": "{{n}} 件の結果", "results_count": "{{n}} 件の結果",
"clear_keyword": "検索をクリア",
"modified_label": "Modified", "modified_label": "Modified",
"created_label": "Created", "created_label": "Created",
"kind_label": "Items", "kind_label": "Items",
-3
View File
@@ -417,12 +417,9 @@
"shared_notificationFailed": "알림 전송에 실패했습니다" "shared_notificationFailed": "알림 전송에 실패했습니다"
}, },
"filter": { "filter": {
"placeholder": "이 폴더와 하위 폴더 검색…",
"keyword": "키워드",
"advanced": "필터", "advanced": "필터",
"recursive": "하위 폴더 포함", "recursive": "하위 폴더 포함",
"results_count": "{{n}}개 결과", "results_count": "{{n}}개 결과",
"clear_keyword": "검색 지우기",
"modified_label": "Modified", "modified_label": "Modified",
"created_label": "Created", "created_label": "Created",
"kind_label": "Items", "kind_label": "Items",
-3
View File
@@ -417,12 +417,9 @@
"shared_notificationFailed": "Notificatie verzenden mislukt" "shared_notificationFailed": "Notificatie verzenden mislukt"
}, },
"filter": { "filter": {
"placeholder": "Deze map en submappen doorzoeken…",
"keyword": "Trefwoord",
"advanced": "Filters", "advanced": "Filters",
"recursive": "Submappen opnemen", "recursive": "Submappen opnemen",
"results_count": "{{n}} resultaten", "results_count": "{{n}} resultaten",
"clear_keyword": "Zoekopdracht wissen",
"modified_label": "Modified", "modified_label": "Modified",
"created_label": "Created", "created_label": "Created",
"kind_label": "Items", "kind_label": "Items",
-3
View File
@@ -417,12 +417,9 @@
"shared_notificationFailed": "Nie udało się wysłać powiadomienia" "shared_notificationFailed": "Nie udało się wysłać powiadomienia"
}, },
"filter": { "filter": {
"placeholder": "Szukaj w tym folderze i podfolderach…",
"keyword": "Słowo kluczowe",
"advanced": "Filtry", "advanced": "Filtry",
"recursive": "Uwzględnij podfoldery", "recursive": "Uwzględnij podfoldery",
"results_count": "Wyniki: {{n}}", "results_count": "Wyniki: {{n}}",
"clear_keyword": "Wyczyść wyszukiwanie",
"modified_label": "Modified", "modified_label": "Modified",
"created_label": "Created", "created_label": "Created",
"kind_label": "Items", "kind_label": "Items",
-3
View File
@@ -417,12 +417,9 @@
"shared_notificationFailed": "Falha ao enviar a notificação" "shared_notificationFailed": "Falha ao enviar a notificação"
}, },
"filter": { "filter": {
"placeholder": "Pesquisar nesta pasta e subpastas…",
"keyword": "Palavra-chave",
"advanced": "Filtros", "advanced": "Filtros",
"recursive": "Incluir subpastas", "recursive": "Incluir subpastas",
"results_count": "{{n}} resultados", "results_count": "{{n}} resultados",
"clear_keyword": "Limpar pesquisa",
"modified_label": "Modified", "modified_label": "Modified",
"created_label": "Created", "created_label": "Created",
"kind_label": "Items", "kind_label": "Items",
-3
View File
@@ -417,12 +417,9 @@
"shared_notificationFailed": "Не удалось отправить уведомление" "shared_notificationFailed": "Не удалось отправить уведомление"
}, },
"filter": { "filter": {
"placeholder": "Поиск в этой папке и подпапках…",
"keyword": "Ключевое слово",
"advanced": "Фильтры", "advanced": "Фильтры",
"recursive": "Включая подпапки", "recursive": "Включая подпапки",
"results_count": "Результатов: {{n}}", "results_count": "Результатов: {{n}}",
"clear_keyword": "Очистить поиск",
"modified_label": "Modified", "modified_label": "Modified",
"created_label": "Created", "created_label": "Created",
"kind_label": "Items", "kind_label": "Items",
-3
View File
@@ -417,12 +417,9 @@
"shared_typeFolder": "資料夾" "shared_typeFolder": "資料夾"
}, },
"filter": { "filter": {
"placeholder": "搜尋此資料夾及子資料夾…",
"keyword": "關鍵字",
"advanced": "篩選", "advanced": "篩選",
"recursive": "包含子資料夾", "recursive": "包含子資料夾",
"results_count": "{{n}} 個結果", "results_count": "{{n}} 個結果",
"clear_keyword": "清除搜尋",
"modified_label": "修改時間", "modified_label": "修改時間",
"created_label": "建立時間", "created_label": "建立時間",
"kind_label": "項目", "kind_label": "項目",
-3
View File
@@ -417,12 +417,9 @@
"shared_typeFolder": "文件夹" "shared_typeFolder": "文件夹"
}, },
"filter": { "filter": {
"placeholder": "搜索此文件夹及子文件夹…",
"keyword": "关键词",
"advanced": "筛选", "advanced": "筛选",
"recursive": "包含子文件夹", "recursive": "包含子文件夹",
"results_count": "{{n}} 个结果", "results_count": "{{n}} 个结果",
"clear_keyword": "清除搜索",
"modified_label": "修改时间", "modified_label": "修改时间",
"created_label": "创建时间", "created_label": "创建时间",
"kind_label": "条目", "kind_label": "条目",
+21
View File
@@ -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] 顶栏搜索框 × 筛选栏合并(并补齐筛选维度) ### [2026-09-21] 顶栏搜索框 × 筛选栏合并(并补齐筛选维度)
- **状态**: 已完成(commit `29d0c335`;`npm run check` 全绿:svelte-check 0 错 0 警 + eslint + stylelint + prettier;`vitest run` 492 通过 0 失败,连续两轮稳定) - **状态**: 已完成(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`(仅文件/仅文件夹)与创建时间维度。后端零改动。 - **计划**: 把上游顶栏搜索框与本地 `SearchFilterBar` 合并成一套筛选能力:顶栏搜索框内加筛选按钮 + 下拉面板(不动 70px 高度);文件页筛选栏保留,两处共享同一份筛选状态并同步到 URL(就地筛选行为不丢);`/search` 改为 URL 驱动、移除三个页内 select(改为可撤销 chips);同时补上后端已支持但前端未暴露的 `resource_types`(仅文件/仅文件夹)与创建时间维度。后端零改动。