diff --git a/docs/guide/search.md b/docs/guide/search.md index d86ad1d0..2d47c953 100644 --- a/docs/guide/search.md +++ b/docs/guide/search.md @@ -1,69 +1,106 @@ # Search -OxiCloud provides authenticated file and folder search with simple query parameters, advanced JSON criteria, pagination, recursive traversal, and in-memory result caching. +OxiCloud provides authenticated file and folder search with a +cursor-paginated response, filter/sort query parameters, recursive +traversal, and in-memory result caching. ## Endpoints | Method | Endpoint | Description | | --- | --- | --- | -| `GET` | `/api/search/` | Simple search using query parameters | -| `POST` | `/api/search/advanced` | Advanced search with a JSON body | +| `GET` | `/api/search` | Cursor-paginated search | | `GET` | `/api/search/suggest` | Lightweight autocomplete suggestions | | `DELETE` | `/api/admin/search/cache` | Flush the shared search results cache (admin only) | All search endpoints require authentication. The cache flush is additionally restricted to administrators — see [Result Caching](#result-caching). -## Simple Search Parameters +## Query Parameters | Parameter | Description | | --- | --- | -| `query` | Text to search in file and folder names | -| `type` | Comma-separated file extensions | -| `created_after` / `created_before` | Filter by creation time | -| `modified_after` / `modified_before` | Filter by modification time | -| `min_size` / `max_size` | Filter by file size in bytes | +| `query` | Text to search in file/folder names (and file content when the Tantivy index is enabled) | +| `type` | Comma-separated file extensions (files only) | | `folder_id` | Restrict search scope to one folder | | `recursive` | Search subfolders, defaults to `true` | -| `limit` | Maximum results, defaults to `100` | -| `offset` | Pagination offset | -| `sort_by` | `relevance`, `name`, `name_desc`, `date`, `date_desc`, `size`, or `size_desc` | +| `created_after` / `created_before` | Filter by creation time (unix seconds) | +| `modified_after` / `modified_before` | Filter by modification time | +| `min_size` / `max_size` | Filter by file size in bytes | +| `resource_types` | Comma-separated: `file`, `folder` (both by default) | +| `order_by` | Sort dimension: `relevance` (default), `name`, `size`, `updated_at`, `created_at` | +| `reverse` | Reverse sort direction (no-op for `relevance`) | +| `limit` | Page size (1–200, default 50) | +| `cursor` | Opaque cursor returned by the previous page | + +## Response Shape + +`/api/search` returns the same envelope as every other `/*/resources` +listing (folders, favorites, recent, trash, shared) so a single +client component can render all of them: + +```json +{ + "items": [ + { + "resource_type": "file", + "resource": { "id": "…", "name": "report.pdf", "size": 12345, "…": "…" }, + "meta": { + "score": 0.82, + "snippet": "…quarterly report…", + "via": "content" + } + }, + { + "resource_type": "folder", + "resource": { "id": "…", "name": "Reports", "…": "…" }, + "meta": { "score": 0.31, "via": "name" } + } + ], + "next_cursor": "eyJvZmZzZXQiOjUwLCJvcmRlcl9ieSI6InJlbGV2YW5jZSJ9", + "query_time_ms": 12, + "total": 137 +} +``` + +- `resource_type`: `"file"` or `"folder"` — tells the client which + variant of `resource` to render. +- `resource`: the same `FileDto` / `FolderDto` shape any other + endpoint would emit. +- `meta.score`: relevance in `[0, 1]`. +- `meta.snippet`: optional HTML-safe excerpt from the content index + (present only when the match came from file content). +- `meta.via`: `"name"`, `"content"`, or `"path"` — where the match + fired. +- `next_cursor`: opaque; present iff another page exists. Pass it + verbatim as `?cursor=…` for the next call. +- `total`: integer, approximate — reflects the caller-visible match + count (permission-filtered). Omitted when unknown; never leaks a + count for rows the caller cannot see. ### Example ```bash curl -H "Authorization: Bearer $TOKEN" \ - "https://oxicloud.example.com/api/search/?query=report&type=pdf,docx&recursive=true&limit=20" -``` - -## Advanced Search - -```json -{ - "name_contains": "report", - "file_types": ["pdf", "docx"], - "min_size": 1024, - "folder_id": "folder-uuid", - "recursive": true, - "limit": 50, - "offset": 0 -} + "https://oxicloud.example.com/api/search?query=report&type=pdf,docx&limit=20" ``` ## Suggestions -Use `/api/search/suggest?query=rep&limit=10` for quick autocomplete-style results. Suggestions can also be scoped to a folder with `folder_id`. +Use `/api/search/suggest?query=rep&limit=10` for quick +autocomplete-style results. Suggestions can also be scoped to a +folder with `folder_id`. ## Result Caching -Search results are cached in memory using the search criteria and user ID as the cache key. +Search results are cached in memory using the search criteria and +user ID as the cache key. - Cache TTL: 5 minutes - Max entries: 1000 - Manual invalidation: `DELETE /api/admin/search/cache` — admin-only. The endpoint calls `invalidate_all()` on the shared moka cache, so - one call cold-starts every subsequent search for every tenant; it's - an operator debug lever, not a per-user affordance. + one call cold-starts every subsequent search for every tenant; + it's an operator debug lever, not a per-user affordance. ## Feature Flag diff --git a/examples/bench_search_cache_mem.rs b/examples/bench_search_cache_mem.rs index d454e0ec..e914e239 100644 --- a/examples/bench_search_cache_mem.rs +++ b/examples/bench_search_cache_mem.rs @@ -145,6 +145,11 @@ fn synth_entry(idx: u64) -> Arc { blob_hash: pseudo_hex(&mut rng, 64), snippet: content_hit.then(|| SNIPPET.to_string()), match_source: Some(match_source.to_string()), + etag: pseudo_hex(&mut rng, 16), + created_by: None, + updated_by: None, + is_favorite: false, + is_shared: false, }); } @@ -203,12 +208,20 @@ struct PhaseReport { /// Insert the full corpus, settle the cache, then measure retention and /// hot-key read latency. Identical for both variants — only the cache /// configuration differs. -async fn run_phase(cache: &moka::future::Cache>) -> PhaseReport { +async fn run_phase( + cache: &moka::future::Cache<(uuid::Uuid, u64), Arc>, +) -> PhaseReport { let hwm_start_kb = status_kb("VmHWM"); let rss_start_kb = status_kb("VmRSS"); + // Cache key changed to `(Uuid, u64)` in the per-user invalidation + // refactor (2026-07-26). Bench uses one fixed user across all keys — + // varying the u64 part exercises the same cardinality the pre-refactor + // benchmark did (one entry per query variant). + let bench_user = uuid::Uuid::nil(); + for i in 0..ENTRIES { - cache.insert(i, synth_entry(i)).await; + cache.insert((bench_user, i), synth_entry(i)).await; // Let eviction run as it would under live traffic, so evicted pages // are actually freed instead of piling up in moka's pending queue. if i % 64 == 0 { @@ -226,7 +239,7 @@ async fn run_phase(cache: &moka::future::Cache>) -> P .sum(); // Hot-key read latency: p50 over GETS reads of one resident key. - let hot: u64 = *cache.iter().next().expect("cache is empty after fill").0; + let hot: (uuid::Uuid, u64) = *cache.iter().next().expect("cache is empty after fill").0; for _ in 0..1_000 { black_box(cache.get(&hot).await); // warmup } @@ -257,7 +270,10 @@ async fn run_phase(cache: &moka::future::Cache>) -> P #[tokio::main] async fn main() { - let entry_weight = u64::from(search_results_entry_weight(&0, &synth_entry(0))); + let entry_weight = u64::from(search_results_entry_weight( + &(uuid::Uuid::nil(), 0), + &synth_entry(0), + )); println!("\n###########################################################"); println!("# Search-results cache: entry-count bound vs byte bound"); println!( @@ -274,7 +290,10 @@ async fn main() { println!("###########################################################\n"); // --- Phase 1: BEFORE (entry-count bound, exactly the old wiring) --- - let before_cache: moka::future::Cache> = + // Key type mirrors production's post-2026-07-26 tuple key so both + // phases exercise the same `Cache<(Uuid, u64), _>` shape; only the + // capacity bound differs (entry-count here vs weigher below). + let before_cache: moka::future::Cache<(uuid::Uuid, u64), Arc> = moka::future::Cache::builder() .max_capacity(BEFORE_MAX_ENTRIES) .time_to_live(Duration::from_secs(TTL_SECS)) diff --git a/frontend/src/lib/api/endpoints/search.test.ts b/frontend/src/lib/api/endpoints/search.test.ts index d041b953..6fb4fd1e 100644 --- a/frontend/src/lib/api/endpoints/search.test.ts +++ b/frontend/src/lib/api/endpoints/search.test.ts @@ -2,24 +2,39 @@ 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, maxSize: 9, - sortBy: 'date' + sortBy: 'updated_at' }).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=updated_at'), 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..2b37d2a0 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,33 @@ 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; + /** Reverse sort direction; maps to backend `reverse`. */ + reverse?: boolean; + /** 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 +56,12 @@ 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); + if (opts.reverse) params.append('reverse', 'true'); + 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..38efe888 100644 --- a/frontend/src/lib/api/types.ts +++ b/frontend/src/lib/api/types.ts @@ -248,40 +248,52 @@ export interface AuthResponse { expires_in: number; } -export type SortBy = - | 'relevance' - | 'name' - | 'name_desc' - | 'date' - | 'date_desc' - | 'size' - | 'size_desc'; +/** + * Sort dimension for `GET /api/search`. Wire-matches the backend's + * `SearchResourcesQuery.order_by` — 5 canonical values, direction is + * a separate `reverse` boolean (the `_desc` suffix pattern was + * retired 2026-07-26; `date` was renamed to the more explicit + * `updated_at` alongside the new `created_at`). + */ +export type SortBy = 'relevance' | 'name' | 'size' | 'updated_at' | 'created_at'; -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/icons/registry.ts b/frontend/src/lib/icons/registry.ts index 3e797d03..6e09388d 100644 --- a/frontend/src/lib/icons/registry.ts +++ b/frontend/src/lib/icons/registry.ts @@ -370,6 +370,15 @@ export const OxiIcons: Record = { 512, "M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM169.8 165.3c7.9-22.3 29.1-37.3 52.8-37.3l58.3 0c34.9 0 63.1 28.3 63.1 63.1c0 22.6-12.1 43.5-31.7 54.8L280 264.4c-.2 13-10.9 23.6-24 23.6c-13.3 0-24-10.7-24-24l0-13.5c0-8.6 4.6-16.5 12.1-20.8l44.3-25.4c4.7-2.7 7.6-7.7 7.6-13.1c0-8.4-6.8-15.1-15.1-15.1l-58.3 0c-3.4 0-6.4 2.1-7.5 5.3l-.4 1.2c-4.4 12.5-18.2 19-30.6 14.6s-19-18.2-14.6-30.6l.4-1.2zM224 352a32 32 0 1 1 64 0 32 32 0 1 1 -64 0z" ], + // Font Awesome Free 6.7.2 `ranking-star` (podium with a star crown). + // Hand-added — /search's group-by dropdown uses this glyph for the + // "Relevance" option (Ed's 2026-07-26 UX call). Registry header says + // "regenerate from the source" but no generator lives in-repo, so + // hand-inserting matches the shape of every other entry. + "ranking-star": [ + 640, + "M353.8 54.1L330.2 6.3c-3.9-8.3-16.1-8.3-20 0L286.6 54.1l-52.9 7.7c-9.2 1.3-12.9 12.7-6.2 19.3l38.3 37.3-9.1 52.7c-1.6 9.2 8.1 16.2 16.3 11.9l47.3-24.9 47.3 24.9c8.2 4.3 17.9-2.7 16.3-11.9l-9-52.7 38.3-37.3c6.7-6.5 3-17.9-6.2-19.3L353.8 54.1zM256 256c-17.7 0-32 14.3-32 32l0 192c0 17.7 14.3 32 32 32l128 0c17.7 0 32-14.3 32-32l0-192c0-17.7-14.3-32-32-32l-128 0zM32 320c-17.7 0-32 14.3-32 32L0 480c0 17.7 14.3 32 32 32l128 0c17.7 0 32-14.3 32-32l0-128c0-17.7-14.3-32-32-32L32 320zm416 96l0 64c0 17.7 14.3 32 32 32l128 0c17.7 0 32-14.3 32-32l0-64c0-17.7-14.3-32-32-32l-128 0c-17.7 0-32 14.3-32 32z" + ], "repeat": [ 512, "M470.6 118.6c12.5-12.5 12.5-32.8 0-45.3l-64-64c-9.2-9.2-22.9-11.9-34.9-6.9S352 19.1 352 32l0 32-160 0C86 64 0 150 0 256 0 273.7 14.3 288 32 288s32-14.3 32-32c0-70.7 57.3-128 128-128l160 0 0 32c0 12.9 7.8 24.6 19.8 29.6s25.7 2.2 34.9-6.9l64-64zM41.4 393.4c-12.5 12.5-12.5 32.8 0 45.3l64 64c9.2 9.2 22.9 11.9 34.9 6.9S160 492.9 160 480l0-32 160 0c106 0 192-86 192-192 0-17.7-14.3-32-32-32s-32 14.3-32 32c0 70.7-57.3 128-128 128l-160 0 0-32c0-12.9-7.8-24.6-19.8-29.6s-25.7-2.2-34.9 6.9l-64 64z" diff --git a/frontend/src/lib/styles/ported/resourceList.css b/frontend/src/lib/styles/ported/resourceList.css index 996227a4..32129ce9 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,30 +516,58 @@ .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); } -/* Fav-star + shared-button share the same visibility rule: hidden on - quiet rows, visible on row hover, and — crucially — always visible - when their `.active` class is set. That's what lets a favorited or - shared row be discoverable at a glance in list view without the - user having to mouse over it. +/* 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); +} - We use `visibility: hidden` (not `display: none`) so a hidden - button still reserves its slot in the action cell. Otherwise a row - that's shared-but-not-favorited would slide its shared icon into - the fav-star's column, breaking vertical alignment across rows. */ +.files-list-view .file-item .action-cell button.shared-button.active { + color: var(--color-badge-blue-text); +} + +/* Fav-star + shared-button — hidden on quiet rows, visible on row hover, + and always visible when their `.active` class is set (so a favorited / + shared row is discoverable without mousing over). + + `opacity: 0` + `pointer-events: none` (instead of `visibility: hidden`) + so the hide transitions match the kebab's fade — Ed's 2026-07-26 UX + note: pre-refactor kebab faded over motion-fast while star/shared + snapped out instantly (visibility flips have no animatable value), + producing a staggered exit when the pointer left a row. The slot + still reserves layout because the button geometry is unchanged; only + its paint is toggled. */ .files-list-view .file-item .action-cell button.favorite-star, .files-list-view .file-item .action-cell button.shared-button { - visibility: hidden; + opacity: 0; + pointer-events: none; border: none; + transition: opacity var(--motion-fast) var(--ease-standard); } +/* Strict reveal: star + shared appear ONLY on `.active` (row is + favorited / shared) or pointer hover — Ed's 2026-07-26 spec. No + `:focus-within` reveal, so keyboard focus on the row-body (name / + path cells) does NOT flash the actions cluster. Quiet rows stay + quiet at rest. */ .files-list-view .file-item:hover .action-cell button.favorite-star, .files-list-view .file-item:hover .action-cell button.shared-button, .files-list-view .file-item .action-cell button.favorite-star.active, .files-list-view .file-item .action-cell button.shared-button.active { - visibility: visible; + opacity: 1; + pointer-events: auto; } /* Reveal the kebab on hover for cleaner rows — but only on hover-capable @@ -668,8 +716,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 +750,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 +766,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 +847,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; @@ -826,26 +892,36 @@ /* Per-button visibility (mirrors list view): each button is independently gated by its own `.active` flag OR row-hover. This prevents a favorited - row from also lighting up the shared button (and vice versa) — the cell - used to reveal all its children together via a single opacity toggle. */ + row from also lighting up the shared button (and vice versa). + `opacity + pointer-events` so the fade timing matches the kebab and + every `.btn-action--hover` — see the list-view block above for why. */ .files-grid-view .file-item .action-cell .favorite-star, .files-grid-view .file-item .action-cell .shared-button { - visibility: hidden; - transition: visibility var(--motion-fast) var(--ease-standard); + opacity: 0; + pointer-events: none; + transition: opacity var(--motion-fast) var(--ease-standard); } +/* Strict reveal: same rule as the list view — `.active` OR pointer + hover, no `:focus-within` (Ed's 2026-07-26 spec). */ .files-grid-view .file-item:hover .action-cell .favorite-star, .files-grid-view .file-item:hover .action-cell .shared-button, -.files-grid-view .file-item:focus-within .action-cell .favorite-star, -.files-grid-view .file-item:focus-within .action-cell .shared-button, .files-grid-view .file-item .action-cell .favorite-star.active, .files-grid-view .file-item .action-cell .shared-button.active { - visibility: visible; + opacity: 1; + pointer-events: auto; } /* Chip visuals for anything inside the corner cluster — the kebab, the star, the shared button, any `.btn-action`. Uniform 30x30 scrim pill so they - line up in the flex row. */ + line up in the flex row. + NOTE: no `opacity: 1` here. Per-button reveal rules above own the + opacity (star/shared: `.active` + `:hover`; kebab + btn-action: + `:hover`). Pre-refactor this block force-set `opacity: 1` on the + assumption that reveal lived on the parent `.action-cell` — that + assumption is gone (Ed 2026-07-26), and the leftover made star + + shared appear always-visible in grid view regardless of hover / + `.active` state. */ .files-grid-view .file-item .action-cell .file-actions, .files-grid-view .file-item .action-cell .favorite-star, .files-grid-view .file-item .action-cell .shared-button, @@ -871,11 +947,25 @@ color: var(--color-text); font-size: var(--text-md); cursor: pointer; - /* Opacity/hover-reveal moves up to `.action-cell` — children stay opaque. */ - 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 +974,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 +995,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,25 +1362,41 @@ .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. - Used by `/recent`'s per-row broom (a history-management action that - shouldn't distract from the row content at rest). Trash's Restore / - Delete stay on the plain `.btn-action` — those are the reason the - user opened trash, and hiding them would fail Fitts' law. */ + Used by `/recent`'s per-row broom and `/search`'s open-parent + (history-management / navigational actions that shouldn't distract + from the row content at rest). Trash's Restore / Delete stay on the + plain `.btn-action` — those are the reason the user opened trash, + and hiding them would fail Fitts' law. + `opacity + pointer-events` (not `visibility`) so the fade timing + matches the kebab + star + shared button — Ed's 2026-07-26 UX note: + pre-refactor each reveal mechanism was different, producing a + staggered exit when the pointer left a row. */ .files-list-view .file-item .action-cell .btn-action--hover, .files-grid-view .file-item .action-cell .btn-action--hover { - visibility: hidden; - transition: visibility var(--motion-fast) var(--ease-standard); + opacity: 0; + pointer-events: none; + transition: opacity var(--motion-fast) var(--ease-standard); } .files-list-view .file-item:hover .action-cell .btn-action--hover, .files-list-view .file-item:focus-within .action-cell .btn-action--hover, .files-grid-view .file-item:hover .action-cell .btn-action--hover, .files-grid-view .file-item:focus-within .action-cell .btn-action--hover { - visibility: visible; + opacity: 1; + pointer-events: auto; } /* Legacy: a margin-top on `.btn-action` in grid view for the era when 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..b02ddd76 100644 --- a/frontend/src/routes/search/+page.svelte +++ b/frontend/src/routes/search/+page.svelte @@ -1,32 +1,170 @@