From c946cf43336b861b13676a1040de0637b5eebf26 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Sun, 26 Jul 2026 16:38:49 +0200 Subject: [PATCH] refactor(search): adapt UI to use ResourceList --- frontend/src/lib/api/endpoints/search.test.ts | 21 +- frontend/src/lib/api/endpoints/search.ts | 34 +- frontend/src/lib/api/types.ts | 56 +- frontend/src/lib/components/AppShell.svelte | 40 +- frontend/src/lib/components/AppShell.test.ts | 4 +- .../src/lib/components/CommandPalette.svelte | 47 +- .../src/lib/components/CommandPalette.test.ts | 10 +- .../src/lib/styles/ported/resourceList.css | 136 +++- frontend/src/routes/music/+page.svelte | 24 +- frontend/src/routes/search/+page.svelte | 660 ++++++++++++------ frontend/src/routes/search/page.test.ts | 16 +- 11 files changed, 739 insertions(+), 309 deletions(-) diff --git a/frontend/src/lib/api/endpoints/search.test.ts b/frontend/src/lib/api/endpoints/search.test.ts index d041b953..cc53844c 100644 --- a/frontend/src/lib/api/endpoints/search.test.ts +++ b/frontend/src/lib/api/endpoints/search.test.ts @@ -2,16 +2,16 @@ import { it, expect, vi, beforeEach } from 'vitest'; vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn(), apiJson: vi.fn() })); vi.mock('$lib/api/csrf', () => ({ getCsrfHeaders: () => ({}) })); import { apiFetch, apiJson } from '$lib/api/client'; -import { searchFiles, searchSuggest, clearSearchCache } from './search'; +import { searchResources, searchSuggest, clearSearchCache } from './search'; const f = apiFetch as unknown as ReturnType; const j = apiJson as unknown as ReturnType; beforeEach(() => { vi.clearAllMocks(); f.mockResolvedValue({ ok: true, status: 200, json: async () => ({}) }); - j.mockResolvedValue({ files: [], folders: [] }); + j.mockResolvedValue({ items: [], query_time_ms: 0 }); }); it('builds search requests including filters', async () => { - await searchFiles('q', { + await searchResources('q', { recursive: true, fileTypes: ['mp3', 'wav'], minSize: 1, @@ -19,7 +19,22 @@ it('builds search requests including filters', async () => { sortBy: 'date' }).catch(() => {}); expect(j).toHaveBeenCalledWith(expect.stringContaining('type=mp3%2Cwav'), expect.anything()); + // Sort dimension is sent on the wire as `order_by`, matching the + // backend's `SearchResourcesQuery` (post-normalization). + expect(j).toHaveBeenCalledWith(expect.stringContaining('order_by=date'), expect.anything()); await searchSuggest('q').catch(() => {}); await clearSearchCache().catch(() => {}); expect(f.mock.calls.length + j.mock.calls.length).toBeGreaterThan(1); }); + +it('forwards cursor pagination and resource-type filter', async () => { + await searchResources('q', { + cursor: 'abc', + resourceTypes: ['file'], + limit: 25 + }).catch(() => {}); + const call = j.mock.calls.at(-1)?.[0] as string; + expect(call).toContain('cursor=abc'); + expect(call).toContain('resource_types=file'); + expect(call).toContain('limit=25'); +}); diff --git a/frontend/src/lib/api/endpoints/search.ts b/frontend/src/lib/api/endpoints/search.ts index 6dae1209..3e00cbd9 100644 --- a/frontend/src/lib/api/endpoints/search.ts +++ b/frontend/src/lib/api/endpoints/search.ts @@ -1,6 +1,6 @@ -/** Search endpoint — ported from features/files/search.js. */ +// Search endpoint — hits the normalized `/*/resources` envelope shape. import { apiFetch, apiJson } from '$lib/api/client'; -import type { SearchResults, SortBy } from '$lib/api/types'; +import type { ItemType, SearchResourcesResponse, SortBy } from '$lib/api/types'; export interface SearchOptions { folderId?: string; @@ -16,14 +16,31 @@ export interface SearchOptions { modifiedAfter?: number; /** Unix-seconds upper bound on modified time. */ modifiedBefore?: number; + /** Page size (1–200 server-side; default 50). */ limit?: number; - offset?: number; + /** + * Cursor from a previous response's `next_cursor`. Absent → first page. + * The wire uses cursor pagination now; the old `offset` param is gone. + */ + cursor?: string; + /** Sort dimension; maps to backend `order_by`. */ sortBy?: SortBy; + /** Restrict to files, folders, or both (default). */ + resourceTypes?: ItemType[]; /** Abort the request when a newer search supersedes it. */ signal?: AbortSignal; } -export function searchFiles(query: string, opts: SearchOptions = {}): Promise { +/** + * Cursor-paginated search. Returns the shared envelope + * `{ items[], next_cursor?, query_time_ms, total? }` — same shape as + * favorites / recent / trash / folder listings so `ResourceList` + * consumes the items without a demux step. + */ +export function searchResources( + query: string, + opts: SearchOptions = {} +): Promise { const params = new URLSearchParams(); params.append('query', query); if (opts.folderId) params.append('folder_id', opts.folderId); @@ -37,10 +54,11 @@ export function searchFiles(query: string, opts: SearchOptions = {}): Promise(`/api/search?${params.toString()}`, { + if (opts.resourceTypes?.length) params.append('resource_types', opts.resourceTypes.join(',')); + if (opts.limit != null) params.append('limit', String(opts.limit)); + if (opts.cursor) params.append('cursor', opts.cursor); + if (opts.sortBy) params.append('order_by', opts.sortBy); + return apiJson(`/api/search?${params.toString()}`, { credentials: 'same-origin', signal: opts.signal }); diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts index 3d662092..9e735d09 100644 --- a/frontend/src/lib/api/types.ts +++ b/frontend/src/lib/api/types.ts @@ -257,31 +257,43 @@ export type SortBy = | 'size' | 'size_desc'; -export interface SearchCriteria { - sort_by: SortBy; - recursive: boolean; - limit: number; - offset: number; - name_contains?: string; - file_types?: string[]; - folder_id?: string; - min_size?: number; - max_size?: number; - created_before?: number; - created_after?: number; - modified_before?: number; - modified_after?: number; +/** + * Per-item search metadata inline on every hit in the normalized + * `/api/search` envelope. Mirrors backend `SearchMeta` — see + * `application/dtos/search_dto.rs`. + */ +export interface SearchMeta { + /** Relevance in [0, 1]; the higher the better. */ + score: number; + /** Optional HTML-safe excerpt when the match fired via content index. */ + snippet?: string; + /** Where the match fired. */ + via?: 'name' | 'content' | 'path'; } -export interface SearchResults { - files: FileItem[]; - folders: FolderItem[]; - total_count: number | null; - limit: number; - offset: number; - has_more: boolean; +/** + * Single hit in the `/api/search` envelope. `resource_type` disambiguates + * `resource`'s union so the shared `ResourceList` component can render it + * exactly like a folders/favorites/recent/trash row. + */ +export interface SearchResourceItem { + resource_type: ItemType; + resource: FileItem | FolderItem; + meta: SearchMeta; +} + +/** + * Wire response of `GET /api/search`. Same envelope shape as the other + * "resources" listing endpoints (`items[]` + optional `next_cursor`), + * plus two search-specific top-level fields: `query_time_ms` (health + * signal for admins, "Found N in Xms" for users) and `total` (approximate, + * caller-visible; never leaks a count for rows the caller can't see). + */ +export interface SearchResourcesResponse { + items: SearchResourceItem[]; + next_cursor?: string; query_time_ms: number; - sort_by: string; + total?: number; } export type DriveKind = 'personal' | 'shared'; diff --git a/frontend/src/lib/components/AppShell.svelte b/frontend/src/lib/components/AppShell.svelte index 90d1e538..861f8ab2 100644 --- a/frontend/src/lib/components/AppShell.svelte +++ b/frontend/src/lib/components/AppShell.svelte @@ -4,7 +4,7 @@ import { resolve } from '$app/paths'; import { page } from '$app/state'; import { logout } from '$lib/api/endpoints/auth'; - import { searchFiles } from '$lib/api/endpoints/search'; + import { searchResources } from '$lib/api/endpoints/search'; import { fileInlineUrl, deleteFile } from '$lib/api/endpoints/files'; import { deleteFolder } from '$lib/api/endpoints/folders'; import { addFavorite } from '$lib/api/endpoints/favorites'; @@ -17,6 +17,7 @@ import { i18n, LANGUAGES, setLocale, t, type Locale } from '$lib/i18n/index.svelte'; import { apiFetch } from '$lib/api/client'; import { dialogs } from '$lib/stores/dialogs.svelte'; + import { files as filesStore } from '$lib/stores/files.svelte'; import { preferences } from '$lib/stores/preferences.svelte'; import { session } from '$lib/stores/session.svelte'; import { theme, type Theme } from '$lib/stores/theme.svelte'; @@ -255,11 +256,22 @@ function goToResults() { const q = searchQuery.trim(); - if (q) { - suggestOpen = false; - searchActive = false; - goto(resolve(`/search?q=${encodeURIComponent(q)}`)); + if (!q) return; + suggestOpen = false; + searchActive = false; + // 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)}`); } + goto(resolve(`/search?${parts.join('&')}`)); } function onSearch(e: SubmitEvent) { @@ -285,12 +297,20 @@ suggestInflight = ctl; suggestBusy = true; try { - const r = await searchFiles(q, { recursive: true, limit: 6, signal: ctl.signal }); + const r = await searchResources(q, { recursive: true, limit: 9, signal: ctl.signal }); if (seq !== suggestSeq) return; // superseded while awaiting - suggestions = [ - ...r.folders.slice(0, 3).map((item) => ({ kind: 'folder' as const, item })), - ...r.files.slice(0, 6).map((item) => ({ kind: 'file' as const, item })) - ]; + // The wire is ordered — folders first, then files — but slice + // per kind explicitly so the header preview stays a folder-heavy + // list even when files dominate the result set. + const folders = r.items + .filter((it) => it.resource_type === 'folder') + .slice(0, 3) + .map((it) => ({ kind: 'folder' as const, item: it.resource as FolderItem })); + const files = r.items + .filter((it) => it.resource_type === 'file') + .slice(0, 6) + .map((it) => ({ kind: 'file' as const, item: it.resource as FileItem })); + suggestions = [...folders, ...files]; suggestOpen = suggestions.length > 0; } catch { if (seq !== suggestSeq || ctl.signal.aborted) return; diff --git a/frontend/src/lib/components/AppShell.test.ts b/frontend/src/lib/components/AppShell.test.ts index baa31a3c..3068940f 100644 --- a/frontend/src/lib/components/AppShell.test.ts +++ b/frontend/src/lib/components/AppShell.test.ts @@ -9,7 +9,9 @@ const { goto, pageState } = vi.hoisted(() => ({ vi.mock('$app/navigation', () => ({ goto })); vi.mock('$app/state', () => ({ page: pageState })); vi.mock('$lib/api/endpoints/auth', () => ({ logout: vi.fn() })); -vi.mock('$lib/api/endpoints/search', () => ({ searchFiles: vi.fn(async () => ({ items: [] })) })); +vi.mock('$lib/api/endpoints/search', () => ({ + searchResources: vi.fn(async () => ({ items: [], query_time_ms: 0 })) +})); vi.mock('$lib/api/endpoints/files', () => ({ fileInlineUrl: () => '/in' })); import { logout } from '$lib/api/endpoints/auth'; diff --git a/frontend/src/lib/components/CommandPalette.svelte b/frontend/src/lib/components/CommandPalette.svelte index 1f197cb2..2a7bb757 100644 --- a/frontend/src/lib/components/CommandPalette.svelte +++ b/frontend/src/lib/components/CommandPalette.svelte @@ -2,7 +2,7 @@ import { goto } from '$app/navigation'; import { resolve } from '$app/paths'; import { logout } from '$lib/api/endpoints/auth'; - import { searchFiles } from '$lib/api/endpoints/search'; + import { searchResources } from '$lib/api/endpoints/search'; import { fileInlineUrl } from '$lib/api/endpoints/files'; import Icon from '$lib/icons/Icon.svelte'; import { t } from '$lib/i18n/index.svelte'; @@ -193,24 +193,33 @@ } searchTimer = setTimeout(async () => { try { - const r = await searchFiles(q, { recursive: true, limit: 5 }); - const folders: Command[] = r.folders.slice(0, 3).map((f) => ({ - id: `fld-${f.id}`, - label: f.name, - icon: 'folder', - hint: t('files.folder', 'Folder'), - run: nav(`/files/${f.id}`) - })); - const files: Command[] = r.files.slice(0, 5).map((f) => ({ - id: `fil-${f.id}`, - label: f.name, - icon: 'file', - hint: t('files.file', 'File'), - run: () => { - close(); - window.open(fileInlineUrl(f.id), '_blank', 'noopener'); - } - })); + const r = await searchResources(q, { recursive: true, limit: 8 }); + // Wire items are ordered folders-first-then-files, but demux + // explicitly so the palette keeps the two-section layout even + // when file hits dominate the result set. + const folders: Command[] = r.items + .filter((it) => it.resource_type === 'folder') + .slice(0, 3) + .map((it) => ({ + id: `fld-${it.resource.id}`, + label: it.resource.name, + icon: 'folder', + hint: t('files.folder', 'Folder'), + run: nav(`/files/${it.resource.id}`) + })); + const files: Command[] = r.items + .filter((it) => it.resource_type === 'file') + .slice(0, 5) + .map((it) => ({ + id: `fil-${it.resource.id}`, + label: it.resource.name, + icon: 'file', + hint: t('files.file', 'File'), + run: () => { + close(); + window.open(fileInlineUrl(it.resource.id), '_blank', 'noopener'); + } + })); fileMatches = [...folders, ...files]; } catch { fileMatches = []; diff --git a/frontend/src/lib/components/CommandPalette.test.ts b/frontend/src/lib/components/CommandPalette.test.ts index d13c40f5..5eee034e 100644 --- a/frontend/src/lib/components/CommandPalette.test.ts +++ b/frontend/src/lib/components/CommandPalette.test.ts @@ -4,11 +4,13 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; const { goto } = vi.hoisted(() => ({ goto: vi.fn() })); vi.mock('$app/navigation', () => ({ goto })); vi.mock('$lib/api/endpoints/auth', () => ({ logout: vi.fn() })); -vi.mock('$lib/api/endpoints/search', () => ({ searchFiles: vi.fn(async () => ({ items: [] })) })); +vi.mock('$lib/api/endpoints/search', () => ({ + searchResources: vi.fn(async () => ({ items: [], query_time_ms: 0 })) +})); vi.mock('$lib/api/endpoints/files', () => ({ fileInlineUrl: () => '/in' })); vi.mock('$lib/stores/dialogs.svelte', () => ({ confirmDialog: vi.fn() })); -import { searchFiles } from '$lib/api/endpoints/search'; +import { searchResources } from '$lib/api/endpoints/search'; import { session } from '$lib/stores/session.svelte'; import CommandPalette from './CommandPalette.svelte'; @@ -42,6 +44,6 @@ it('searches files as the query is typed', async () => { await openPalette(); const input = await screen.findByTestId('command-palette-input'); await fireEvent.input(input, { target: { value: 'report' } }); - await waitFor(() => expect(searchFiles).toHaveBeenCalled()); - expect(m(searchFiles).mock.calls[0][0]).toBe('report'); + await waitFor(() => expect(searchResources).toHaveBeenCalled()); + expect(m(searchResources).mock.calls[0][0]).toBe('report'); }); diff --git a/frontend/src/lib/styles/ported/resourceList.css b/frontend/src/lib/styles/ported/resourceList.css index 996227a4..6439b9ac 100644 --- a/frontend/src/lib/styles/ported/resourceList.css +++ b/frontend/src/lib/styles/ported/resourceList.css @@ -197,13 +197,33 @@ .list-header-checkbox input[type="checkbox"], .file-item .checkbox-cell input[type="checkbox"] { - width: 17px; - height: 17px; + /* Bumped from 17px to 20px so the row-selection checkbox reads at + a similar visual weight to the 28x28 action buttons that sit at + the other end of the row (Ed's 2026-07-26 UX note). 20px is the + upper end of the browser-native checkbox range — beyond that + platforms start rendering an oversized-and-blurry glyph. */ + width: 20px; + height: 20px; cursor: pointer; accent-color: var(--color-accent); border-radius: var(--radius-sm); } +/* Orange outline on hover for the row-selection checkbox (Ed's UX ask + 2026-07-26). `outline` (not `border`) because native checkboxes in + list view honor `accent-color` but their border rendering is + platform-inconsistent — `outline` is drawn OUTSIDE the box and + doesn't reflow the layout. Grid view uses a custom-drawn checkbox + (see `.files-grid-view … input[type="checkbox"]` below) which + accepts real border styling; its hover override lives with that + block and beats this via specificity. Matches the "everything + hovers to accent" convention shared with row-action buttons. */ +.list-header-checkbox input[type="checkbox"]:hover, +.file-item .checkbox-cell input[type="checkbox"]:hover { + outline: 2px solid var(--color-accent); + outline-offset: 1px; +} + .list-header.selection-mode { grid-template-columns: 36px 1fr; background-color: var(--color-multiselect-bg); @@ -496,7 +516,26 @@ .files-list-view .file-item .action-cell button:not(.btn-action):hover { background: var(--color-border-subtle); - color: var(--color-text-dark); + /* Accent-orange hover — matches the grid view's `.file-actions:hover` + and the shared `.btn-action:hover` rule. Semantic overrides on + `.favorite-star:hover` / `.shared-button:hover` (gold / blue) take + precedence via more-specific selectors below. */ + color: var(--color-accent); +} + +/* List-view resting/active state colors for favorite (gold) and shared + (blue). Hover is intentionally NOT overridden here — the generic + `.action-cell button:not(.btn-action):hover → --color-accent` rule + above owns the orange hover for every button (kebab / favorite / + shared) per Ed's 2026-07-26 spec. This block only paints the + at-rest state on `.active` rows so the star / shared chip stays + discoverable on a quiet row. */ +.files-list-view .file-item .action-cell button.favorite-star.active { + color: var(--color-star-text-hover); +} + +.files-list-view .file-item .action-cell button.shared-button.active { + color: var(--color-badge-blue-text); } /* Fav-star + shared-button share the same visibility rule: hidden on @@ -668,8 +707,11 @@ position: absolute; top: calc(var(--space-3) + 8px); left: calc(var(--space-3) + 8px); - width: 26px; - height: 26px; + /* 30x30 matches the action-cell chip pills (`.file-actions`, star, + shared, `.btn-action`) so the checkbox and the corner-cluster + buttons read as siblings of one visual size (Ed 2026-07-26). */ + width: 30px; + height: 30px; border-radius: var(--radius-md); background: var(--color-scrim-control); backdrop-filter: blur(6px); @@ -699,8 +741,10 @@ .files-grid-view .file-item .checkbox-cell input[type="checkbox"] { appearance: none; -webkit-appearance: none; - width: 18px; - height: 18px; + /* Custom-drawn glyph sized proportionally to the 30x30 chip pill — + bumped from 18x18 to match the list-view checkbox's 20x20. */ + width: 20px; + height: 20px; margin: 0; border: 2px solid var(--color-border-medium); border-radius: var(--radius-sm); @@ -713,6 +757,17 @@ border-color var(--motion-fast) var(--ease-standard); } +/* Grid view uses a custom-drawn checkbox (`appearance: none`) so we can + swap the real border color on hover — cleaner than the list-view + `outline` trick, and no double-ring on this variant. Specificity + (0,4,1 + 0,0,1) beats the shared `input[type="checkbox"]:hover` + outline rule above so the grid-view chip doesn't get both a border + AND an outline. */ +.files-grid-view .file-item .checkbox-cell input[type="checkbox"]:hover { + border-color: var(--color-accent); + outline: none; +} + .files-grid-view .file-item .checkbox-cell input[type="checkbox"]::after { content: ""; width: 5px; @@ -783,9 +838,11 @@ markup still has shared, favorite, itemActions, kebab in that sequence so list-view's inline right-aligned flow is unchanged. */ .files-grid-view .file-item .action-cell:has(.shared-button, .favorite-star) { - /* Start right after the checkbox column (26px chip + inline gap) - so the left group aligns visually with the checkbox row. */ - left: calc(var(--space-3) + 8px + 26px + var(--space-2)); + /* Start right after the checkbox column (30px chip + inline gap) + so the left group aligns visually with the checkbox row. Kept in + sync with `.checkbox-cell` width above — the two share this + constant. */ + left: calc(var(--space-3) + 8px + 30px + var(--space-2)); right: calc(var(--space-3) + 8px); display: flex; align-items: center; @@ -875,7 +932,23 @@ opacity: 1; } -.files-grid-view .file-item .action-cell .file-actions:hover { +/* Unified row-action hover: every button in the grid-view corner cluster + (kebab, star, shared, btn-action*) turns accent-orange on hover. + Semantic states are conveyed by the `.active` class, not by hover + color, so favorite = gold when starred, shared = blue when shared, + both regardless of pointer position (see `.active` rules below). + Ed's 2026-07-26 UX call: "orange for mouse over on all buttons; + blue only for active shared." + + Selector specificity (0,4,1 + 0,0,1 = high) beats the chip-visual + base rule at ~line 852 (`.files-grid-view .file-item .action-cell + .btn-action { color: var(--color-text) }`, 0,4,0), which is why the + simpler `.btn-action:hover` didn't take effect inside the corner + cluster. */ +.files-grid-view .file-item .action-cell .file-actions:hover, +.files-grid-view .file-item .action-cell .btn-action:hover, +.files-grid-view .file-item .action-cell .favorite-star:hover, +.files-grid-view .file-item .action-cell .shared-button:hover { color: var(--color-accent); } @@ -884,13 +957,20 @@ border: 2px dashed var(--color-warning-border); } -/* Favorite star + shared button — visual overrides only. Position, - hover-reveal, chip geometry all come from the shared corner-cluster - rule on `.files-grid-view .file-item .action-cell`. What's left - here is just the per-state colour: subtle at rest, saturated when - the item's flag is set. `.active` on either button also bumps the - parent cluster's opacity (via `:has()` above) so an unhovered card - still shows its favorited/shared state. */ +/* Favorite star + shared button — resting/active state colors only. + HOVER color for both lives in the unified `.action-cell button:hover + → --color-accent` rule above; the per-state palette here only fires + when the button is NOT hovered. Convention (Ed 2026-07-26): + • hover → orange (accent) — every row action + • star.active (not hover) → gold + • shared.active (not hover) → blue + + `.active` retains its color even on hover for `favorite-star` (star + users expect gold-on-gold on the currently-starred item; losing the + glyph mid-click reads as broken) but yields to orange for `shared` + per Ed's explicit "blue only for active shared" — pointer-over on + an already-shared row should still communicate "you're about to + toggle something." */ .files-grid-view .file-item button.favorite-star, .files-grid-view .file-item button.shared-button { color: var(--color-text-subtle); @@ -898,19 +978,10 @@ line-height: var(--leading-none); } -.files-grid-view .file-item button.favorite-star:hover { - color: var(--color-star-text); -} - .files-grid-view .file-item button.favorite-star.active { color: var(--color-star-text-hover); } -.files-grid-view .file-item button.favorite-star.active:hover { - color: var(--color-star-active); -} - -.files-grid-view .file-item button.shared-button:hover, .files-grid-view .file-item button.shared-button.active { color: var(--color-badge-blue-text); } @@ -1274,7 +1345,16 @@ .btn-action:hover { background: var(--color-border-subtle); - color: var(--color-text-dark); + /* Accent (orange) hover matches `.file-actions` (kebab) and the + favorite / shared button semantics — the pre-refactor grey-only + tint left `/recent`'s broom, `/trash`'s restore and `/search`'s + "open parent" reading as inert on hover next to the accented + kebab. Ed's 2026-07-26 UX ask: "all row buttons should change + color on hover, not just kebab and favorite." Section-specific + variants (`.btn-action--delete` in trash, `--on` in ShareDialog) + still win via more-specific selectors so red / etc. semantics + are preserved. */ + color: var(--color-accent); } /* Opt-in modifier: hide the button until the row is hovered / focused. diff --git a/frontend/src/routes/music/+page.svelte b/frontend/src/routes/music/+page.svelte index ea6d6908..8b107e4d 100644 --- a/frontend/src/routes/music/+page.svelte +++ b/frontend/src/routes/music/+page.svelte @@ -24,7 +24,7 @@ type Playlist, type PlaylistItem } from '$lib/api/endpoints/music'; - import { searchFiles } from '$lib/api/endpoints/search'; + import { searchResources } from '$lib/api/endpoints/search'; import type { FileItem } from '$lib/api/types'; import Icon from '$lib/icons/Icon.svelte'; import { confirmDialog, promptDialog } from '$lib/stores/dialogs.svelte'; @@ -450,17 +450,25 @@ async function runAddSearch(query = '') { addSearching = true; try { - const res = await searchFiles(query.trim(), { + const res = await searchResources(query.trim(), { recursive: true, fileTypes: AUDIO_TYPES, + resourceTypes: ['file'], limit: 200 }); - // Belt-and-braces: keep only audio mime types. - addResults = res.files.filter( - (f) => - (f.mime_type ?? '').startsWith('audio/') || - AUDIO_TYPES.some((e) => f.name.toLowerCase().endsWith(`.${e}`)) - ); + // Belt-and-braces: keep only audio mime types. The envelope + // items are `{resource_type, resource}` — resourceTypes:['file'] + // already restricts to files, but re-narrow here so the + // downstream `FileItem[]` cast is honest even if the wire + // ordering ever surfaces a folder. + addResults = res.items + .filter((it) => it.resource_type === 'file') + .map((it) => it.resource as FileItem) + .filter( + (f) => + (f.mime_type ?? '').startsWith('audio/') || + AUDIO_TYPES.some((e) => f.name.toLowerCase().endsWith(`.${e}`)) + ); } catch (e) { errorToast(e); addResults = []; diff --git a/frontend/src/routes/search/+page.svelte b/frontend/src/routes/search/+page.svelte index 5cee5e4a..fbb10456 100644 --- a/frontend/src/routes/search/+page.svelte +++ b/frontend/src/routes/search/+page.svelte @@ -1,32 +1,110 @@