diff --git a/frontend/src/lib/components/AppShell.svelte b/frontend/src/lib/components/AppShell.svelte index 3bd9a15d..60f7d4a6 100644 --- a/frontend/src/lib/components/AppShell.svelte +++ b/frontend/src/lib/components/AppShell.svelte @@ -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([]); 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 | 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=` // 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 — , *