From 9f4766d2a3b813e9a1a294ee70b36acd8a159a6c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Jan 2026 16:26:15 +0000 Subject: [PATCH 01/11] feat(web): add series detail page at /series/ Implement a new page for viewing media series items with similar UI to the browse page but specialized for series navigation. Features: - New route at /series/[series_id] with full SvelteKit page structure - SeriesController and SeriesQueryParamsManager for series-specific state - Series-specific search filters and sort options (including series_index) - Display series_index number under each media tile in series view - Link from browse page media tiles to series detail page - Reuse browse components where possible (MediaDetails, MediaView, Footer) - Default sort by series_index in ascending order Changes: - Extend MediaListRune to support series.search API - Add series_index property to MediaViewRune - Create series-specific components in routes/series/[series_id]/ - Update browse SearchResults to link series items to detail page The implementation follows existing patterns and shares logic through SvelteKit layouts, shared components, and rune-based state management. --- .../src/lib/runes/media_list_rune.svelte.ts | 11 +- .../src/lib/runes/media_view_rune.svelte.ts | 3 + .../browse/components/SearchResults.svelte | 8 +- .../routes/series/[series_id]/+page.svelte | 35 +++ .../[series_id]/components/Header.svelte | 14 + .../[series_id]/components/MediaList.svelte | 59 ++++ .../components/SearchParams.svelte | 158 ++++++++++ .../components/SearchResults.svelte | 145 +++++++++ .../routes/series/[series_id]/controller.ts | 36 +++ .../[series_id]/runes/queryparams.svelte.ts | 280 ++++++++++++++++++ 10 files changed, 747 insertions(+), 2 deletions(-) create mode 100644 packages/web/src/routes/series/[series_id]/+page.svelte create mode 100644 packages/web/src/routes/series/[series_id]/components/Header.svelte create mode 100644 packages/web/src/routes/series/[series_id]/components/MediaList.svelte create mode 100644 packages/web/src/routes/series/[series_id]/components/SearchParams.svelte create mode 100644 packages/web/src/routes/series/[series_id]/components/SearchResults.svelte create mode 100644 packages/web/src/routes/series/[series_id]/controller.ts create mode 100644 packages/web/src/routes/series/[series_id]/runes/queryparams.svelte.ts diff --git a/packages/web/src/lib/runes/media_list_rune.svelte.ts b/packages/web/src/lib/runes/media_list_rune.svelte.ts index 9388acf7..3b359d4f 100644 --- a/packages/web/src/lib/runes/media_list_rune.svelte.ts +++ b/packages/web/src/lib/runes/media_list_rune.svelte.ts @@ -6,6 +6,7 @@ import { MediaViewRune } from '.' type Result = | ReturnType | ReturnType + | ReturnType interface SearchInput { type: 'media' @@ -15,6 +16,10 @@ interface GroupByInput { type: 'group_by' params: Parameters[0] } +interface SeriesInput { + type: 'series' + params: Parameters[0] +} interface FilesystemInput { type: 'filesystem' params: {} @@ -22,6 +27,7 @@ interface FilesystemInput { export type Input = | SearchInput | GroupByInput + | SeriesInput | FilesystemInput interface MediaListState { @@ -31,7 +37,7 @@ interface MediaListState { } export class MediaListRune extends Rune { - #saved_params_type: 'media' | 'group_by' = 'media' + #saved_params_type: 'media' | 'group_by' | 'series' = 'media' #saved_params: {} | undefined #prev_query_hash: string = '' #fetch_count = 0 @@ -87,6 +93,9 @@ export class MediaListRune extends Rune { else if (params_type === 'group_by') { fetch_params.limit = fetch_params.limit ?? 30 content = await this.client.forager.media.group(fetch_params) + } + else if (params_type === 'series') { + content = await this.client.forager.series.search(fetch_params) } else { throw new Error('unimplemented') } diff --git a/packages/web/src/lib/runes/media_view_rune.svelte.ts b/packages/web/src/lib/runes/media_view_rune.svelte.ts index 3950ea90..c9b7d5ed 100644 --- a/packages/web/src/lib/runes/media_view_rune.svelte.ts +++ b/packages/web/src/lib/runes/media_view_rune.svelte.ts @@ -15,6 +15,7 @@ export class MediaViewRune extends Rune { media_type!: MediaResponse['media_type'] | 'grouped' state = $state() current_view: model_types.View + series_index?: number protected constructor(client: BaseController['client'], media_response: MediaResponse) { super(client) @@ -22,6 +23,8 @@ export class MediaViewRune extends Rune { media: media_response, full_thumbnails: undefined } + // @ts-ignore - series_index is only present in SeriesSearchResponse + this.series_index = media_response.series_index } get media() { diff --git a/packages/web/src/routes/browse/components/SearchResults.svelte b/packages/web/src/routes/browse/components/SearchResults.svelte index cc9590e0..0a9a2a60 100644 --- a/packages/web/src/routes/browse/components/SearchResults.svelte +++ b/packages/web/src/routes/browse/components/SearchResults.svelte @@ -125,7 +125,13 @@ {/if} {:else if result.media_type === 'media_series'} - ({result.media_reference.media_series_length}) + + ({result.media_reference.media_series_length}) + {:else if result.media_type === 'grouped'} + import MediaDetails from '../../browse/components/MediaDetails.svelte' + import SearchParams from './components/SearchParams.svelte' + import MediaList from './components/MediaList.svelte' + import MediaView from '../../browse/components/MediaView.svelte' + import Footer from '../../browse/components/Footer.svelte' + import Header from './components/Header.svelte' + + import { SeriesController } from './controller.ts' + + /** @type {import('./$types').PageProps} */ + let props = $props() + + const series_id = parseInt(props.params.series_id) + const controller = new SeriesController(props.data.config, series_id) + let { dimensions, focus, queryparams } = controller.runes + focus.stack({component: 'SeriesPage', focus: 'page'}) + + +
+
+
+ +
+ + +
+
+
+
+ + diff --git a/packages/web/src/routes/series/[series_id]/components/Header.svelte b/packages/web/src/routes/series/[series_id]/components/Header.svelte new file mode 100644 index 00000000..f7ed127d --- /dev/null +++ b/packages/web/src/routes/series/[series_id]/components/Header.svelte @@ -0,0 +1,14 @@ + + +
+ {queryparams.human_readable_summary || 'Forager Series'} + +
diff --git a/packages/web/src/routes/series/[series_id]/components/MediaList.svelte b/packages/web/src/routes/series/[series_id]/components/MediaList.svelte new file mode 100644 index 00000000..6e9d0851 --- /dev/null +++ b/packages/web/src/routes/series/[series_id]/components/MediaList.svelte @@ -0,0 +1,59 @@ + + +
+ controller.runes.media_list.paginate()} + class={[ + "w-full focus:outline-none", + controller.runes.media_selections.current_selection.show ? "overflow-hidden" : "overflow-y-scroll", + ]} + style="height: {controller.runes.dimensions.heights.media_list}px" + > + + +
diff --git a/packages/web/src/routes/series/[series_id]/components/SearchParams.svelte b/packages/web/src/routes/series/[series_id]/components/SearchParams.svelte new file mode 100644 index 00000000..ac09733a --- /dev/null +++ b/packages/web/src/routes/series/[series_id]/components/SearchParams.svelte @@ -0,0 +1,158 @@ + + +
{ + e.preventDefault() + await update_search() + }}> +
+
+ + +
+ +
+
+
+ + +
+ + + + + +
+ + + + + + +
+ +
+ +
+
+ + +
+
+
+
+ +
diff --git a/packages/web/src/routes/series/[series_id]/components/SearchResults.svelte b/packages/web/src/routes/series/[series_id]/components/SearchResults.svelte new file mode 100644 index 00000000..e254c78a --- /dev/null +++ b/packages/web/src/routes/series/[series_id]/components/SearchResults.svelte @@ -0,0 +1,145 @@ + + + + + + +
+ {#each media_list.results as result, result_index} +
+
+
+
{ + if (e.key === 'Enter') { + media_selections.set_current_selection(result, result_index) + } + }} + role="button" + tabindex="0" + use:focusable={!media_selections.current_selection.show && media_selections.current_selection.result_index === result_index} + onclick={e => media_selections.set_current_selection(result, result_index)} + > + {#if settings.ui.media_list.thumbnail_shape === 'original'} + Thumbnail for {result.media_reference.title ?? 'media'} + {:else} + Thumbnail for {result.media_reference.title ?? 'media'} + {/if} +
+ + +
+ {#if result.media_type === 'media_file'} + {#if result.media_file.media_type === 'VIDEO'} + + + {#if result.media_file.audio} + + {/if} + + {human_readable_duration(result.media_file.duration)} + {:else if result.media_file.media_type === 'IMAGE' && result.media_file.content_type === 'image/gif'} + + {:else if result.media_file.media_type === 'IMAGE'} + + {:else if result.media_file.media_type === 'AUDIO'} + + {:else} + unknown media type {result.media_file.media_type} + {/if} + {:else if result.media_type === 'media_series'} + + ({result.media_reference.media_series_length}) + {:else} + UNEXPECTED MEDIA TYPE {result.media_type} + {/if} +
+ + + {#if result.series_index !== undefined} +
+ #{result.series_index} +
+ {/if} +
+
+
+ {/each} +
+{#if media_list.loading} + Loading... +{/if} diff --git a/packages/web/src/routes/series/[series_id]/controller.ts b/packages/web/src/routes/series/[series_id]/controller.ts new file mode 100644 index 00000000..54c47a02 --- /dev/null +++ b/packages/web/src/routes/series/[series_id]/controller.ts @@ -0,0 +1,36 @@ +import type { Config } from '$lib/server/config.ts' +import {BaseController} from '$lib/base_controller.ts' +import {create_focuser, SettingsRune, MediaListRune } from '$lib/runes/index.ts' +import { create_dimensional_rune } from '../../browse/runes/dimensions.svelte.ts' +import { MediaSelectionsRune } from '../../browse/runes/media_selections.svelte.ts' +import { SeriesQueryParamsManager } from './runes/queryparams.svelte.ts' + + +class SeriesController extends BaseController { + series_id: number + runes: { + media_list: MediaListRune + focus: ReturnType + dimensions: ReturnType + settings: SettingsRune + media_selections: MediaSelectionsRune + queryparams: SeriesQueryParamsManager + } + + public constructor(config: Config, series_id: number) { + super(config) + this.series_id = series_id + + const media_list_rune = new MediaListRune(this.client, this.settings) + this.runes = { + media_list: media_list_rune, + focus: create_focuser(), + dimensions: create_dimensional_rune(), + settings: new SettingsRune(this.client, this.config), + media_selections: new MediaSelectionsRune(this.client, media_list_rune), + queryparams: new SeriesQueryParamsManager(this.client, media_list_rune, series_id), + } + } +} + +export { SeriesController } diff --git a/packages/web/src/routes/series/[series_id]/runes/queryparams.svelte.ts b/packages/web/src/routes/series/[series_id]/runes/queryparams.svelte.ts new file mode 100644 index 00000000..7bcfe272 --- /dev/null +++ b/packages/web/src/routes/series/[series_id]/runes/queryparams.svelte.ts @@ -0,0 +1,280 @@ +import type { inputs } from '@forager/core' +import type { MediaListRune } from '$lib/runes/index.ts' +import type { BaseController } from '$lib/base_controller.ts' +import { onMount } from 'svelte' +import { pushState } from '$app/navigation' +import { Rune } from '$lib/runes/rune.ts' + +interface SeriesSearchParams { + search_string: string + filepath: string | undefined + sort: inputs.SeriesSearch['sort_by'] + unread_only: boolean + stars: number | undefined + stars_equality: 'gte' | 'eq' | undefined + order: 'desc' | 'asc' + media_type: string +} + +const DEFAULTS: SeriesSearchParams = { + search_string: '', + filepath: undefined, + sort: 'series_index', + order: 'asc', + unread_only: false, + stars: undefined, + stars_equality: undefined, + media_type: 'all', +} + +// Map internal names to URL param names +const URL_PARAM_MAP = { + search_string: 'tags', + unread_only: 'unread', + media_type: 'type', +} as const satisfies Partial> +type UrlParamMap = typeof URL_PARAM_MAP + +type SeriesSearchParamsReversed = { [K in keyof UrlParamMap as UrlParamMap[K]]: K} +const URL_PARAM_MAP_REVERSED = Object.fromEntries( + Object.entries(URL_PARAM_MAP).map(([key, val]) => [val, key]) +) as SeriesSearchParamsReversed + +/** + * Manages browser URL query parameters for series detail page. + * + * Similar to browse QueryParamsManager but for series.search API. + */ +export class SeriesQueryParamsManager extends Rune { + public DEFAULTS = DEFAULTS + + /** Committed params (matches URL, used for pagination/search) */ + public current: SeriesSearchParams = $state({ ...DEFAULTS }) + + /** Draft params (form staging, can differ from current) */ + public draft: SeriesSearchParams = $state({ ...DEFAULTS }) + + public current_serialized: string = '?' + + #media_list: MediaListRune + #series_id: number + + constructor(client: BaseController['client'], media_list: MediaListRune, series_id: number) { + super(client) + this.#media_list = media_list + this.#series_id = series_id + + // Initialize from URL on mount + onMount(async () => { + const params = this.#parse_url(new URL(window.location.toString())) + this.current = params + this.draft = { ...params } + await this.#execute_search(params) + + // Listen for browser back/forward + window.addEventListener('popstate', async () => { + const params = this.#parse_url(new URL(window.location.toString())) + this.current = params + this.draft = { ...params } + await this.#execute_search(params) + }) + }) + } + + /** + * Parse URL into SeriesSearchParams + */ + #parse_url(url: URL): SeriesSearchParams { + const params: SeriesSearchParams = { ...DEFAULTS } + const search = url.searchParams + + this.current_serialized = url.search + + if (search) { + for (const [key, val] of search.entries()) { + const params_key: keyof SeriesSearchParams = URL_PARAM_MAP_REVERSED[key] ?? key + + if (params_key === 'search_string') { + params.search_string = val.replaceAll(',', ' ') + } else if (params_key === 'stars') { + params.stars = parseInt(val) + } else if (params_key === 'filepath') { + params.filepath = decodeURIComponent(val) + } else { + // @ts-ignore - dynamic assignment + params[params_key] = val + } + } + } + + return params + } + + /** + * Serialize SeriesSearchParams to URL string + */ + public serialize(params: SeriesSearchParams): string { + const url_params = new Map() + + // Only include non-default values + for (const [key, value] of Object.entries(params)) { + if (value !== DEFAULTS[key as keyof SeriesSearchParams] && value !== undefined) { + const param_name = URL_PARAM_MAP[key as keyof SeriesSearchParams] ?? key + + // Special encoding for tags (preserve : and ,) + if (key === 'search_string') { + const encoded = encodeURIComponent(value.replaceAll(/\s/g, ',')) + .replaceAll('%3A', ':') + .replaceAll('%2C', ',') + url_params.set(param_name, encoded) + } else if (key === 'filepath') { + if (value) { + url_params.set(param_name, encodeURIComponent(value)) + } + } else { + url_params.set(param_name, String(value)) + } + } + } + + const query_string = Array.from(url_params.entries()) + .map(([key, val]) => `${key}=${val}`) + .join('&') + + return query_string ? '?' + query_string : null + } + + /** + * Update URL without executing search + */ + #write_url(params: SeriesSearchParams): void { + const serialized = this.serialize(params) + + if (this.current_serialized !== serialized) { + this.current_serialized = serialized + this.current = { ...params } + if (serialized) { + pushState(serialized, {}) + } else { + pushState(window.location.pathname, {}) + } + } + } + + /** + * Execute search based on params + */ + async #execute_search(params: SeriesSearchParams): Promise { + this.#media_list.clear() + + const tags = params.search_string.split(' ').filter((t) => t.length > 0) + const query: inputs.SeriesSearch['query'] = { + series_id: this.#series_id, + tags: tags.length > 0 ? tags.map(t => ({ name: t })) : undefined, + filepath: params.filepath, + } + + // Handle boolean and numeric filters + if (params.unread_only) { + if (params.unread_only === 'true' || params.unread_only === true) { + query.unread = true + } + } + + if (params.stars !== undefined) { + query.stars = parseInt(String(params.stars)) + query.stars_equality = params.stars_equality ?? 'gte' + } + + // Handle media type filtering + if (params.media_type === 'animated') { + query.animated = true + } + + // Execute series search + await this.#media_list.paginate({ + type: 'series', + params: { + query, + sort_by: params.sort, + order: params.order, + }, + }) + } + + /** + * Submit draft params: update URL and execute search + */ + public async submit(): Promise { + this.#write_url(this.draft) + await this.#execute_search(this.draft) + } + + /** + * Navigate to new params (updates draft, then submits) + */ + public async goto(params: SeriesSearchParams): Promise { + this.draft = { ...params } + await this.submit() + } + + /** + * Merge partial params with current params + */ + public merge(partial_params: Partial>): SeriesSearchParams { + const params = { ...this.current } + + for (const [key, val] of Object.entries(partial_params)) { + const params_key: keyof SeriesSearchParams = URL_PARAM_MAP_REVERSED[key] ?? key + + if (params_key === 'search_string') { + // Merge tags instead of replacing + const search_strings = new Set(params.search_string.split(/\s+/)) + search_strings.add(val) + params.search_string = [...search_strings].join(' ').trim() + } else { + // @ts-ignore - dynamic assignment + params[params_key] = val + } + } + + return params + } + + /** + * Extend current params with a tag + */ + public extend(key: 'tag', value: string): SeriesSearchParams { + const params = { ...this.current } + + if (key === 'tag') { + const search_strings = new Set(params.search_string.split(/\s+/)) + search_strings.add(value) + params.search_string = [...search_strings].join(' ').trim() + return params + } else { + throw new Error('unimplemented') + } + } + + /** + * Get contextual query for other components (e.g., tag autocomplete) + */ + public get contextual_query(): inputs.SeriesSearch['query'] { + const tags = this.current.search_string.split(' ').filter((t) => t.length > 0) + return { + series_id: this.#series_id, + tags: tags.length > 0 ? tags.map(t => ({ name: t })) : undefined, + filepath: this.current.filepath, + unread: this.current.unread_only || undefined, + animated: this.current.media_type === 'animated' ? true : undefined, + } + } + + /** + * Human-readable summary of current search + */ + public get human_readable_summary(): string { + return this.current.search_string || 'Series items' + } +} From 4435155e6bbad3f0cc5b369d645b6ae97c88790d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Jan 2026 17:04:28 +0000 Subject: [PATCH 02/11] refactor(web): use shared layout and base queryparams class Refactor browse and series pages to share common logic through inheritance and component composition, following SvelteKit patterns. Changes: - Create BaseQueryParamsManager abstract class with common URL handling - Refactor BrowseQueryParamsManager to extend base class - Refactor SeriesQueryParamsManager to extend base class - Create MediaBrowserLayout component to share UI structure - Update browse page to use MediaBrowserLayout with snippets - Update series page to use MediaBrowserLayout with snippets Benefits: - Eliminates code duplication between browse and series queryparams - Shared layout ensures consistent UI structure - Easier to maintain and extend in the future - Follows DRY principles and Svelte 5 snippet patterns The base class handles: - URL parsing and serialization - State management (current/draft params) - Browser history integration - Common merge/extend operations Each subclass implements: - execute_search (specific API calls) - contextual_query (specific query structure) - Custom URL parsing/serialization hooks --- .../lib/components/MediaBrowserLayout.svelte | 39 +++ .../src/lib/runes/base_queryparams.svelte.ts | 258 ++++++++++++++++++ packages/web/src/routes/browse/+page.svelte | 33 +-- .../routes/browse/runes/queryparams.svelte.ts | 219 ++------------- .../routes/series/[series_id]/+page.svelte | 33 +-- .../[series_id]/runes/queryparams.svelte.ts | 210 ++------------ 6 files changed, 364 insertions(+), 428 deletions(-) create mode 100644 packages/web/src/lib/components/MediaBrowserLayout.svelte create mode 100644 packages/web/src/lib/runes/base_queryparams.svelte.ts diff --git a/packages/web/src/lib/components/MediaBrowserLayout.svelte b/packages/web/src/lib/components/MediaBrowserLayout.svelte new file mode 100644 index 00000000..c28f8126 --- /dev/null +++ b/packages/web/src/lib/components/MediaBrowserLayout.svelte @@ -0,0 +1,39 @@ + + + + +
+ {@render header({ controller })} +
+ +
+ + {@render media_list({ controller })} +
+
+
+
+ + diff --git a/packages/web/src/lib/runes/base_queryparams.svelte.ts b/packages/web/src/lib/runes/base_queryparams.svelte.ts new file mode 100644 index 00000000..f13c5541 --- /dev/null +++ b/packages/web/src/lib/runes/base_queryparams.svelte.ts @@ -0,0 +1,258 @@ +import type { MediaListRune } from '$lib/runes/index.ts' +import type { BaseController } from '$lib/base_controller.ts' +import { onMount } from 'svelte' +import { pushState } from '$app/navigation' +import { Rune } from '$lib/runes/rune.ts' + +/** + * Base params interface - common fields shared by all search types + */ +export interface BaseSearchParams { + search_string: string + filepath: string | undefined + sort: string + unread_only: boolean + stars: number | undefined + stars_equality: 'gte' | 'eq' | undefined + order: 'desc' | 'asc' + media_type: string +} + +/** + * Base QueryParamsManager with common URL handling and state management. + * + * Two-state model: + * - `current`: Committed params (matches URL, used for pagination) + * - `draft`: Staging area for form edits (before submission) + * + * Subclasses must implement: + * - DEFAULTS getter + * - URL_PARAM_MAP getter + * - execute_search method + */ +export abstract class BaseQueryParamsManager extends Rune { + /** Committed params (matches URL, used for pagination/search) */ + public current: TParams = $state() as TParams + + /** Draft params (form staging, can differ from current) */ + public draft: TParams = $state() as TParams + + public current_serialized: string = '?' + + protected media_list: MediaListRune + + constructor(client: BaseController['client'], media_list: MediaListRune) { + super(client) + this.media_list = media_list + + // Initialize with defaults + this.current = { ...this.DEFAULTS } + this.draft = { ...this.DEFAULTS } + + // Initialize from URL on mount + onMount(async () => { + const params = this.parse_url(new URL(window.location.toString())) + this.current = params + this.draft = { ...params } + await this.execute_search(params) + + // Listen for browser back/forward + window.addEventListener('popstate', async () => { + const params = this.parse_url(new URL(window.location.toString())) + this.current = params + this.draft = { ...params } + await this.execute_search(params) + }) + }) + } + + /** Default parameter values - must be implemented by subclass */ + abstract get DEFAULTS(): TParams + + /** URL parameter mapping - must be implemented by subclass */ + abstract get URL_PARAM_MAP(): Partial> + + /** Reversed URL parameter mapping */ + protected get URL_PARAM_MAP_REVERSED(): Record { + return Object.fromEntries( + Object.entries(this.URL_PARAM_MAP).map(([key, val]) => [val, key]) + ) as Record + } + + /** + * Parse URL into params - can be overridden for custom parsing + */ + protected parse_url(url: URL): TParams { + const params: TParams = { ...this.DEFAULTS } + const search = url.searchParams + + this.current_serialized = url.search + + if (search) { + for (const [key, val] of search.entries()) { + const params_key: keyof TParams = this.URL_PARAM_MAP_REVERSED[key] ?? key + + if (params_key === 'search_string') { + // @ts-ignore + params.search_string = val.replaceAll(',', ' ') + } else if (params_key === 'stars') { + // @ts-ignore + params.stars = parseInt(val) + } else if (params_key === 'filepath') { + // @ts-ignore + params.filepath = decodeURIComponent(val) + } else { + // @ts-ignore - dynamic assignment + params[params_key] = val + } + } + + // Allow subclasses to add custom URL parsing + this.parse_url_custom(params, search) + } + + return params + } + + /** + * Hook for subclasses to add custom URL parsing logic + */ + protected parse_url_custom(params: TParams, search: URLSearchParams): void { + // Default: no-op + } + + /** + * Serialize params to URL string + */ + public serialize(params: TParams): string { + const url_params = new Map() + + // Only include non-default values + for (const [key, value] of Object.entries(params)) { + if (value !== this.DEFAULTS[key as keyof TParams] && value !== undefined) { + const param_name = this.URL_PARAM_MAP[key as keyof TParams] ?? key + + // Special encoding for tags (preserve : and ,) + if (key === 'search_string') { + const encoded = encodeURIComponent((value as string).replaceAll(/\s/g, ',')) + .replaceAll('%3A', ':') + .replaceAll('%2C', ',') + url_params.set(param_name as string, encoded) + } else if (key === 'filepath') { + if (value) { + url_params.set(param_name as string, encodeURIComponent(value as string)) + } + } else { + url_params.set(param_name as string, String(value)) + } + } + } + + // Allow subclasses to customize serialization + this.serialize_custom(url_params, params) + + const query_string = Array.from(url_params.entries()) + .map(([key, val]) => `${key}=${val}`) + .join('&') + + return query_string ? '?' + query_string : null + } + + /** + * Hook for subclasses to customize serialization + */ + protected serialize_custom(url_params: Map, params: TParams): void { + // Default: no-op + } + + /** + * Update URL without executing search + */ + protected write_url(params: TParams): void { + const serialized = this.serialize(params) + + if (this.current_serialized !== serialized) { + this.current_serialized = serialized + this.current = { ...params } + if (serialized) { + pushState(serialized, {}) + } else { + pushState(window.location.pathname, {}) + } + } + } + + /** + * Execute search based on params - must be implemented by subclass + */ + protected abstract execute_search(params: TParams): Promise + + /** + * Submit draft params: update URL and execute search + */ + public async submit(): Promise { + this.write_url(this.draft) + await this.execute_search(this.draft) + } + + /** + * Navigate to new params (updates draft, then submits) + */ + public async goto(params: TParams): Promise { + this.draft = { ...params } + await this.submit() + } + + /** + * Merge partial params with current params + */ + public merge(partial_params: Partial>): TParams { + const params = { ...this.current } + + for (const [key, val] of Object.entries(partial_params)) { + const params_key: keyof TParams = this.URL_PARAM_MAP_REVERSED[key] ?? key + + if (params_key === 'search_string') { + // Merge tags instead of replacing + const search_strings = new Set((params.search_string as string).split(/\s+/)) + search_strings.add(val) + // @ts-ignore + params.search_string = [...search_strings].join(' ').trim() + } else { + // @ts-ignore - dynamic assignment + params[params_key] = val + } + } + + return params + } + + /** + * Extend current params with a tag + */ + public extend(key: 'tag', value: string): TParams { + const params = { ...this.current } + + if (key === 'tag') { + const search_strings = new Set((params.search_string as string).split(/\s+/)) + search_strings.add(value) + // @ts-ignore + params.search_string = [...search_strings].join(' ').trim() + return params + } else { + throw new Error('unimplemented') + } + } + + /** + * Get contextual query for other components - must be implemented by subclass + */ + public abstract get contextual_query(): any + + /** + * Human-readable summary of current search + */ + public get human_readable_summary(): string { + return (this.current.search_string as string) || 'All media' + } +} diff --git a/packages/web/src/routes/browse/+page.svelte b/packages/web/src/routes/browse/+page.svelte index 011da575..e4507872 100644 --- a/packages/web/src/routes/browse/+page.svelte +++ b/packages/web/src/routes/browse/+page.svelte @@ -1,34 +1,23 @@ -
-
-
- -
- - -
-
-
-
+ + {#snippet header({ controller })} +
+ {/snippet} - + {#snippet media_list({ controller })} + + {/snippet} + diff --git a/packages/web/src/routes/browse/runes/queryparams.svelte.ts b/packages/web/src/routes/browse/runes/queryparams.svelte.ts index d37953bc..b74526f9 100644 --- a/packages/web/src/routes/browse/runes/queryparams.svelte.ts +++ b/packages/web/src/routes/browse/runes/queryparams.svelte.ts @@ -2,21 +2,12 @@ import type { inputs } from '@forager/core' import type { MediaListRune } from '$lib/runes/index.ts' import type { BaseController } from '$lib/base_controller.ts' import * as parsers from '$lib/parsers.ts' -import { onMount } from 'svelte' -import { pushState } from '$app/navigation' -import { Rune } from '$lib/runes/rune.ts' +import { BaseQueryParamsManager, type BaseSearchParams } from '$lib/runes/base_queryparams.svelte.ts' -interface SearchParams { - search_string: string - filepath: string | undefined +interface SearchParams extends BaseSearchParams { sort: inputs.PaginatedSearch['sort_by'] - unread_only: boolean search_mode: 'media' | 'group_by' | 'filesystem' group_by: string | undefined - stars: number | undefined - stars_equality: 'gte' | 'eq' | undefined - order: 'desc' | 'asc' - media_type: string } const DEFAULTS: SearchParams = { @@ -39,118 +30,32 @@ const URL_PARAM_MAP = { search_mode: 'mode', media_type: 'type', } as const satisfies Partial> -type UrlParamMap = typeof URL_PARAM_MAP - -type SearchParamsReversed = { [K in keyof UrlParamMap as UrlParamMap[K]]: K} -const URL_PARAM_MAP_REVERSED = Object.fromEntries( - Object.entries(URL_PARAM_MAP).map(([key, val]) => [val, key]) -) as SearchParamsReversed /** - * Manages browser URL query parameters and syncs them with search state. - * - * Two-state model: - * - `current`: Committed params (matches URL, used for pagination) - * - `draft`: Staging area for form edits (before submission) + * Browse-specific QueryParamsManager that handles media search and group_by modes. * - * When URL changes externally (back/forward), draft resets to match current. + * Extends BaseQueryParamsManager with browse-specific functionality: + * - search_mode support (media, group_by, filesystem) + * - group_by tag grouping + * - group_by_tag extension for grouped searches */ -export class QueryParamsManager extends Rune { - public DEFAULTS = DEFAULTS - - /** Committed params (matches URL, used for pagination/search) */ - public current: SearchParams = $state({ ...DEFAULTS }) - - /** Draft params (form staging, can differ from current) */ - public draft: SearchParams = $state({ ...DEFAULTS }) - - public current_serialized: string = '?' - - #media_list: MediaListRune - - constructor(client: BaseController['client'], media_list: MediaListRune) { - super(client) - this.#media_list = media_list - - // Initialize from URL on mount - onMount(async () => { - const params = this.#parse_url(new URL(window.location.toString())) - this.current = params - this.draft = { ...params } // Initialize draft - await this.#execute_search(params) - - // Listen for browser back/forward - window.addEventListener('popstate', async () => { - const params = this.#parse_url(new URL(window.location.toString())) - this.current = params - this.draft = { ...params } // Reset draft to match URL - await this.#execute_search(params) - }) - }) +export class QueryParamsManager extends BaseQueryParamsManager { + get DEFAULTS(): SearchParams { + return DEFAULTS } - /** - * Parse URL into SearchParams - */ - #parse_url(url: URL): SearchParams { - const params: SearchParams = { ...DEFAULTS } - const search = url.searchParams - - this.current_serialized = url.search - - // Parse each param with type coercion - if (search) { - for (const [key, val] of search.entries()) { - const params_key: keyof SearchParams = URL_PARAM_MAP_REVERSED[key] ?? key - - if (params_key === 'search_string') { - params.search_string = val.replaceAll(',', ' ') - } else if (params_key === 'stars') { - params.stars = parseInt(val) - } else if (params_key === 'filepath') { - params.filepath = decodeURIComponent(val) - } else { - // @ts-ignore - dynamic assignment - params[params_key] = val - } - } - - // Infer search_mode from group_by presence - if (search.has('group_by')) { - params.search_mode = 'group_by' - } - } - - return params + get URL_PARAM_MAP(): Partial> { + return URL_PARAM_MAP } - /** - * Serialize SearchParams to URL string (for SearchLink components) - */ - public serialize(params: SearchParams): string { - const url_params = new Map() - - // Only include non-default values - for (const [key, value] of Object.entries(params)) { - if (value !== DEFAULTS[key as keyof SearchParams] && value !== undefined) { - const param_name = URL_PARAM_MAP[key as keyof SearchParams] ?? key - - // Special encoding for tags (preserve : and ,) - if (key === 'search_string') { - const encoded = encodeURIComponent(value.replaceAll(/\s/g, ',')) - .replaceAll('%3A', ':') - .replaceAll('%2C', ',') - url_params.set(param_name, encoded) - } else if (key === 'filepath') { - if (value) { - url_params.set(param_name, encodeURIComponent(value)) - } - } else { - url_params.set(param_name, String(value)) - } - } + protected parse_url_custom(params: SearchParams, search: URLSearchParams): void { + // Infer search_mode from group_by presence + if (search.has('group_by')) { + params.search_mode = 'group_by' } + } + protected serialize_custom(url_params: Map, params: SearchParams): void { if (url_params.get('mode') === 'group_by' && !url_params.has('group_by')) { url_params.set('group_by', '') } @@ -159,37 +64,10 @@ export class QueryParamsManager extends Rune { if (['group_by', 'media'].includes(url_params.get('mode') ?? '')) { url_params.delete('mode') } - - const query_string = Array.from(url_params.entries()) - .map(([key, val]) => `${key}=${val}`) - .join('&') - - return query_string ? '?' + query_string : null } - /** - * Update URL without executing search - */ - #write_url(params: SearchParams): void { - const serialized = this.serialize(params) - - if (this.current_serialized !== serialized) { - this.current_serialized = serialized - this.current = { ...params } - if (serialized) { - pushState(serialized, {}) - } else { - // when we have empty query params, we do this to drop the "?" at the end of the url - pushState(window.location.pathname, {}) - } - } - } - - /** - * Execute search based on params - */ - async #execute_search(params: SearchParams): Promise { - this.#media_list.clear() + protected async execute_search(params: SearchParams): Promise { + this.media_list.clear() const tags = params.search_string.split(' ').filter((t) => t.length > 0) const query: inputs.PaginatedSearch['query'] = { @@ -216,7 +94,7 @@ export class QueryParamsManager extends Rune { // Execute appropriate search if (params.search_mode === 'media') { - await this.#media_list.paginate({ + await this.media_list.paginate({ type: 'media', params: { query, @@ -225,7 +103,7 @@ export class QueryParamsManager extends Rune { }, }) } else if (params.search_mode === 'group_by') { - await this.#media_list.paginate({ + await this.media_list.paginate({ type: 'group_by', params: { group_by: { @@ -239,57 +117,26 @@ export class QueryParamsManager extends Rune { } } - /** - * Submit draft params: update URL and execute search - */ - public async submit(): Promise { - this.#write_url(this.draft) - await this.#execute_search(this.draft) - } - - /** - * Navigate to new params (updates draft, then submits) - */ - public async goto(params: SearchParams): Promise { - this.draft = { ...params } - await this.submit() - } - - /** - * Merge partial params with current params - * Supports URL param names (e.g., 'tags') or internal names (e.g., 'search_string') - */ - public merge(partial_params: Partial>): SearchParams { - const params = { ...this.current } + public override merge(partial_params: Partial>): SearchParams { + const params = super.merge(partial_params) + // Handle browse-specific merging for (const [key, val] of Object.entries(partial_params)) { - const params_key: keyof SearchParams = URL_PARAM_MAP_REVERSED[key] ?? key + const params_key = this.URL_PARAM_MAP_REVERSED[key] ?? key - if (params_key === 'search_string') { - // Merge tags instead of replacing - const search_strings = new Set(params.search_string.split(/\s+/)) - search_strings.add(val) - params.search_string = [...search_strings].join(' ').trim() - } else if (params_key === 'search_mode') { + if (params_key === 'search_mode') { params.search_mode = val // Clear group_by if switching away from group_by mode if (val !== 'group_by') { params.group_by = undefined } - } else { - // @ts-ignore - dynamic assignment - params[params_key] = val } } return params } - /** - * Extend current params with a tag - * Supports special 'group_by_tag' key for group-by searches - */ - public extend(key: 'tag' | 'group_by_tag', value: string): SearchParams { + public override extend(key: 'tag' | 'group_by_tag', value: string): SearchParams { const params = { ...this.current } // group_by_tag means we want to do a normal search including the group by tag @@ -313,9 +160,6 @@ export class QueryParamsManager extends Rune { } } - /** - * Get contextual query for other components (e.g., tag autocomplete) - */ public get contextual_query(): inputs.PaginatedSearch['query'] { const tags = this.current.search_string.split(' ').filter((t) => t.length > 0) return { @@ -325,11 +169,4 @@ export class QueryParamsManager extends Rune { animated: this.current.media_type === 'animated' ? true : undefined, } } - - /** - * Human-readable summary of current search - */ - public get human_readable_summary(): string { - return this.current.search_string || 'All media' - } } diff --git a/packages/web/src/routes/series/[series_id]/+page.svelte b/packages/web/src/routes/series/[series_id]/+page.svelte index ed45b76c..b542cf14 100644 --- a/packages/web/src/routes/series/[series_id]/+page.svelte +++ b/packages/web/src/routes/series/[series_id]/+page.svelte @@ -1,11 +1,7 @@ -
-
-
- -
- - -
-
-
-
+ + {#snippet header({ controller })} +
+ {/snippet} - + {#snippet media_list({ controller })} + + {/snippet} + diff --git a/packages/web/src/routes/series/[series_id]/runes/queryparams.svelte.ts b/packages/web/src/routes/series/[series_id]/runes/queryparams.svelte.ts index 7bcfe272..64271386 100644 --- a/packages/web/src/routes/series/[series_id]/runes/queryparams.svelte.ts +++ b/packages/web/src/routes/series/[series_id]/runes/queryparams.svelte.ts @@ -1,19 +1,10 @@ import type { inputs } from '@forager/core' import type { MediaListRune } from '$lib/runes/index.ts' import type { BaseController } from '$lib/base_controller.ts' -import { onMount } from 'svelte' -import { pushState } from '$app/navigation' -import { Rune } from '$lib/runes/rune.ts' +import { BaseQueryParamsManager, type BaseSearchParams } from '$lib/runes/base_queryparams.svelte.ts' -interface SeriesSearchParams { - search_string: string - filepath: string | undefined +interface SeriesSearchParams extends BaseSearchParams { sort: inputs.SeriesSearch['sort_by'] - unread_only: boolean - stars: number | undefined - stars_equality: 'gte' | 'eq' | undefined - order: 'desc' | 'asc' - media_type: string } const DEFAULTS: SeriesSearchParams = { @@ -33,139 +24,33 @@ const URL_PARAM_MAP = { unread_only: 'unread', media_type: 'type', } as const satisfies Partial> -type UrlParamMap = typeof URL_PARAM_MAP - -type SeriesSearchParamsReversed = { [K in keyof UrlParamMap as UrlParamMap[K]]: K} -const URL_PARAM_MAP_REVERSED = Object.fromEntries( - Object.entries(URL_PARAM_MAP).map(([key, val]) => [val, key]) -) as SeriesSearchParamsReversed /** - * Manages browser URL query parameters for series detail page. + * Series-specific QueryParamsManager that handles series.search API. * - * Similar to browse QueryParamsManager but for series.search API. + * Extends BaseQueryParamsManager with series-specific functionality: + * - series_id filtering (fixed for the page) + * - series_index sort option (default) + * - Simplified search (no group_by mode) */ -export class SeriesQueryParamsManager extends Rune { - public DEFAULTS = DEFAULTS - - /** Committed params (matches URL, used for pagination/search) */ - public current: SeriesSearchParams = $state({ ...DEFAULTS }) - - /** Draft params (form staging, can differ from current) */ - public draft: SeriesSearchParams = $state({ ...DEFAULTS }) - - public current_serialized: string = '?' - - #media_list: MediaListRune +export class SeriesQueryParamsManager extends BaseQueryParamsManager { #series_id: number constructor(client: BaseController['client'], media_list: MediaListRune, series_id: number) { - super(client) - this.#media_list = media_list + super(client, media_list) this.#series_id = series_id - - // Initialize from URL on mount - onMount(async () => { - const params = this.#parse_url(new URL(window.location.toString())) - this.current = params - this.draft = { ...params } - await this.#execute_search(params) - - // Listen for browser back/forward - window.addEventListener('popstate', async () => { - const params = this.#parse_url(new URL(window.location.toString())) - this.current = params - this.draft = { ...params } - await this.#execute_search(params) - }) - }) - } - - /** - * Parse URL into SeriesSearchParams - */ - #parse_url(url: URL): SeriesSearchParams { - const params: SeriesSearchParams = { ...DEFAULTS } - const search = url.searchParams - - this.current_serialized = url.search - - if (search) { - for (const [key, val] of search.entries()) { - const params_key: keyof SeriesSearchParams = URL_PARAM_MAP_REVERSED[key] ?? key - - if (params_key === 'search_string') { - params.search_string = val.replaceAll(',', ' ') - } else if (params_key === 'stars') { - params.stars = parseInt(val) - } else if (params_key === 'filepath') { - params.filepath = decodeURIComponent(val) - } else { - // @ts-ignore - dynamic assignment - params[params_key] = val - } - } - } - - return params } - /** - * Serialize SeriesSearchParams to URL string - */ - public serialize(params: SeriesSearchParams): string { - const url_params = new Map() - - // Only include non-default values - for (const [key, value] of Object.entries(params)) { - if (value !== DEFAULTS[key as keyof SeriesSearchParams] && value !== undefined) { - const param_name = URL_PARAM_MAP[key as keyof SeriesSearchParams] ?? key - - // Special encoding for tags (preserve : and ,) - if (key === 'search_string') { - const encoded = encodeURIComponent(value.replaceAll(/\s/g, ',')) - .replaceAll('%3A', ':') - .replaceAll('%2C', ',') - url_params.set(param_name, encoded) - } else if (key === 'filepath') { - if (value) { - url_params.set(param_name, encodeURIComponent(value)) - } - } else { - url_params.set(param_name, String(value)) - } - } - } - - const query_string = Array.from(url_params.entries()) - .map(([key, val]) => `${key}=${val}`) - .join('&') - - return query_string ? '?' + query_string : null + get DEFAULTS(): SeriesSearchParams { + return DEFAULTS } - /** - * Update URL without executing search - */ - #write_url(params: SeriesSearchParams): void { - const serialized = this.serialize(params) - - if (this.current_serialized !== serialized) { - this.current_serialized = serialized - this.current = { ...params } - if (serialized) { - pushState(serialized, {}) - } else { - pushState(window.location.pathname, {}) - } - } + get URL_PARAM_MAP(): Partial> { + return URL_PARAM_MAP } - /** - * Execute search based on params - */ - async #execute_search(params: SeriesSearchParams): Promise { - this.#media_list.clear() + protected async execute_search(params: SeriesSearchParams): Promise { + this.media_list.clear() const tags = params.search_string.split(' ').filter((t) => t.length > 0) const query: inputs.SeriesSearch['query'] = { @@ -192,7 +77,7 @@ export class SeriesQueryParamsManager extends Rune { } // Execute series search - await this.#media_list.paginate({ + await this.media_list.paginate({ type: 'series', params: { query, @@ -202,64 +87,6 @@ export class SeriesQueryParamsManager extends Rune { }) } - /** - * Submit draft params: update URL and execute search - */ - public async submit(): Promise { - this.#write_url(this.draft) - await this.#execute_search(this.draft) - } - - /** - * Navigate to new params (updates draft, then submits) - */ - public async goto(params: SeriesSearchParams): Promise { - this.draft = { ...params } - await this.submit() - } - - /** - * Merge partial params with current params - */ - public merge(partial_params: Partial>): SeriesSearchParams { - const params = { ...this.current } - - for (const [key, val] of Object.entries(partial_params)) { - const params_key: keyof SeriesSearchParams = URL_PARAM_MAP_REVERSED[key] ?? key - - if (params_key === 'search_string') { - // Merge tags instead of replacing - const search_strings = new Set(params.search_string.split(/\s+/)) - search_strings.add(val) - params.search_string = [...search_strings].join(' ').trim() - } else { - // @ts-ignore - dynamic assignment - params[params_key] = val - } - } - - return params - } - - /** - * Extend current params with a tag - */ - public extend(key: 'tag', value: string): SeriesSearchParams { - const params = { ...this.current } - - if (key === 'tag') { - const search_strings = new Set(params.search_string.split(/\s+/)) - search_strings.add(value) - params.search_string = [...search_strings].join(' ').trim() - return params - } else { - throw new Error('unimplemented') - } - } - - /** - * Get contextual query for other components (e.g., tag autocomplete) - */ public get contextual_query(): inputs.SeriesSearch['query'] { const tags = this.current.search_string.split(' ').filter((t) => t.length > 0) return { @@ -271,10 +98,7 @@ export class SeriesQueryParamsManager extends Rune { } } - /** - * Human-readable summary of current search - */ - public get human_readable_summary(): string { + public override get human_readable_summary(): string { return this.current.search_string || 'Series items' } } From 2753eec29dcba3900516ac06c0237cb72e4ddb3b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Jan 2026 21:38:42 +0000 Subject: [PATCH 03/11] refactor(web): address PR feedback - improve separation of concerns Address feedback from PR #44 review comments: 1. Move series_index to specific rune classes - Remove series_index from base MediaViewRune - Add series_index to MediaFileRune (where it's actually used) - Add series_index to MediaSeriesRune (for nested series support) - Prevents pollution of base class with subclass-specific metadata 2. Make BaseQueryParamsManager neutral and opinion-free - Remove BaseSearchParams interface with opinionated fields - Change generic constraint from BaseSearchParams to Record - Move field-specific parsing logic (search_string, stars, filepath) to subclasses - Move field-specific serialization logic to subclasses - Make merge() generic instead of assuming search_string exists - Remove extend() method from base (now in subclasses only) - Base class now only handles URL state synchronization 3. Abstract at media tile level instead of MediaList level - Create reusable MediaTile component with metadata snippet slot - Browse SearchResults uses metadata slot for grouped/series links - Series SearchResults uses metadata slot for series_index display - Eliminates code duplication in tile rendering logic - Makes differences between pages explicit via snippets Benefits: - Better separation of concerns (base vs specialized logic) - More maintainable and extensible architecture - Clearer component boundaries and responsibilities - Easier to add new page types in the future --- .../web/src/lib/components/MediaTile.svelte | 124 +++++++++++++++ .../src/lib/runes/base_queryparams.svelte.ts | 130 +++------------ .../src/lib/runes/media_view_rune.svelte.ts | 17 +- .../browse/components/SearchResults.svelte | 148 +++--------------- .../components/SearchResults.svelte | 129 ++------------- .../[series_id]/runes/queryparams.svelte.ts | 69 +++++++- 6 files changed, 263 insertions(+), 354 deletions(-) create mode 100644 packages/web/src/lib/components/MediaTile.svelte diff --git a/packages/web/src/lib/components/MediaTile.svelte b/packages/web/src/lib/components/MediaTile.svelte new file mode 100644 index 00000000..84e9b8eb --- /dev/null +++ b/packages/web/src/lib/components/MediaTile.svelte @@ -0,0 +1,124 @@ + + +
+
+
+
{ + if (e.key === 'Enter') { + media_selections.set_current_selection(result, result_index) + } + }} + role="button" + tabindex="0" + use:focusable={!media_selections.current_selection.show && media_selections.current_selection.result_index === result_index} + onclick={e => media_selections.set_current_selection(result, result_index)} + > + {#if settings.ui.media_list.thumbnail_shape === 'original'} + Thumbnail for {result.media_reference.title ?? 'media'} + {:else} + Thumbnail for {result.media_reference.title ?? 'media'} + {/if} +
+ + +
+ {#if result.media_type === 'media_file'} + {#if result.media_file.media_type === 'VIDEO'} + + + {#if result.media_file.audio} + + {/if} + + {human_readable_duration(result.media_file.duration)} + {:else if result.media_file.media_type === 'IMAGE' && result.media_file.content_type === 'image/gif'} + + {:else if result.media_file.media_type === 'IMAGE'} + + {:else if result.media_file.media_type === 'AUDIO'} + + {:else} + unknown media type {result.media_file.media_type} + {/if} + {:else if result.media_type === 'media_series'} + + ({result.media_reference.media_series_length}) + {:else if result.media_type === 'grouped'} + + {result.group_metadata.count} + {:else} + UNEXPECTED MEDIA TYPE {result.media_type} + {/if} +
+ + + {#if metadata} + {@render metadata({ result })} + {/if} +
+
+
diff --git a/packages/web/src/lib/runes/base_queryparams.svelte.ts b/packages/web/src/lib/runes/base_queryparams.svelte.ts index f13c5541..0d2437a7 100644 --- a/packages/web/src/lib/runes/base_queryparams.svelte.ts +++ b/packages/web/src/lib/runes/base_queryparams.svelte.ts @@ -5,32 +5,22 @@ import { pushState } from '$app/navigation' import { Rune } from '$lib/runes/rune.ts' /** - * Base params interface - common fields shared by all search types - */ -export interface BaseSearchParams { - search_string: string - filepath: string | undefined - sort: string - unread_only: boolean - stars: number | undefined - stars_equality: 'gte' | 'eq' | undefined - order: 'desc' | 'asc' - media_type: string -} - -/** - * Base QueryParamsManager with common URL handling and state management. + * Base QueryParamsManager - handles URL state synchronization without opinions on param structure. * - * Two-state model: - * - `current`: Committed params (matches URL, used for pagination) - * - `draft`: Staging area for form edits (before submission) + * Responsibilities: + * - Two-state model (current/draft params) + * - URL parsing and serialization + * - Browser history integration (back/forward) + * - Generic merge/goto operations * - * Subclasses must implement: - * - DEFAULTS getter - * - URL_PARAM_MAP getter - * - execute_search method + * Subclasses define: + * - Param structure (TParams type) + * - Default values (DEFAULTS) + * - URL param mapping (URL_PARAM_MAP) + * - Search execution logic (execute_search) + * - Parse/serialize customization (hooks) */ -export abstract class BaseQueryParamsManager extends Rune { +export abstract class BaseQueryParamsManager> extends Rune { /** Committed params (matches URL, used for pagination/search) */ public current: TParams = $state() as TParams @@ -80,7 +70,7 @@ export abstract class BaseQueryParamsManager e } /** - * Parse URL into params - can be overridden for custom parsing + * Parse URL into params - subclasses should override to implement specific parsing logic */ protected parse_url(url: URL): TParams { const params: TParams = { ...this.DEFAULTS } @@ -91,38 +81,17 @@ export abstract class BaseQueryParamsManager e if (search) { for (const [key, val] of search.entries()) { const params_key: keyof TParams = this.URL_PARAM_MAP_REVERSED[key] ?? key - - if (params_key === 'search_string') { - // @ts-ignore - params.search_string = val.replaceAll(',', ' ') - } else if (params_key === 'stars') { - // @ts-ignore - params.stars = parseInt(val) - } else if (params_key === 'filepath') { - // @ts-ignore - params.filepath = decodeURIComponent(val) - } else { - // @ts-ignore - dynamic assignment - params[params_key] = val - } + // Generic assignment - subclass should override for type-specific parsing + // @ts-ignore - dynamic assignment + params[params_key] = val } - - // Allow subclasses to add custom URL parsing - this.parse_url_custom(params, search) } return params } /** - * Hook for subclasses to add custom URL parsing logic - */ - protected parse_url_custom(params: TParams, search: URLSearchParams): void { - // Default: no-op - } - - /** - * Serialize params to URL string + * Serialize params to URL string - subclasses should override for custom serialization logic */ public serialize(params: TParams): string { const url_params = new Map() @@ -131,26 +100,11 @@ export abstract class BaseQueryParamsManager e for (const [key, value] of Object.entries(params)) { if (value !== this.DEFAULTS[key as keyof TParams] && value !== undefined) { const param_name = this.URL_PARAM_MAP[key as keyof TParams] ?? key - - // Special encoding for tags (preserve : and ,) - if (key === 'search_string') { - const encoded = encodeURIComponent((value as string).replaceAll(/\s/g, ',')) - .replaceAll('%3A', ':') - .replaceAll('%2C', ',') - url_params.set(param_name as string, encoded) - } else if (key === 'filepath') { - if (value) { - url_params.set(param_name as string, encodeURIComponent(value as string)) - } - } else { - url_params.set(param_name as string, String(value)) - } + // Generic string conversion - subclass should override for custom encoding + url_params.set(param_name as string, String(value)) } } - // Allow subclasses to customize serialization - this.serialize_custom(url_params, params) - const query_string = Array.from(url_params.entries()) .map(([key, val]) => `${key}=${val}`) .join('&') @@ -158,13 +112,6 @@ export abstract class BaseQueryParamsManager e return query_string ? '?' + query_string : null } - /** - * Hook for subclasses to customize serialization - */ - protected serialize_custom(url_params: Map, params: TParams): void { - // Default: no-op - } - /** * Update URL without executing search */ @@ -204,55 +151,30 @@ export abstract class BaseQueryParamsManager e } /** - * Merge partial params with current params + * Merge partial params with current params - simple replacement by default + * Subclasses can override for more sophisticated merging (e.g., tag accumulation) */ public merge(partial_params: Partial>): TParams { const params = { ...this.current } for (const [key, val] of Object.entries(partial_params)) { const params_key: keyof TParams = this.URL_PARAM_MAP_REVERSED[key] ?? key - - if (params_key === 'search_string') { - // Merge tags instead of replacing - const search_strings = new Set((params.search_string as string).split(/\s+/)) - search_strings.add(val) - // @ts-ignore - params.search_string = [...search_strings].join(' ').trim() - } else { - // @ts-ignore - dynamic assignment - params[params_key] = val - } + // @ts-ignore - dynamic assignment + params[params_key] = val } return params } - /** - * Extend current params with a tag - */ - public extend(key: 'tag', value: string): TParams { - const params = { ...this.current } - - if (key === 'tag') { - const search_strings = new Set((params.search_string as string).split(/\s+/)) - search_strings.add(value) - // @ts-ignore - params.search_string = [...search_strings].join(' ').trim() - return params - } else { - throw new Error('unimplemented') - } - } - /** * Get contextual query for other components - must be implemented by subclass */ public abstract get contextual_query(): any /** - * Human-readable summary of current search + * Human-readable summary of current search - subclasses should override */ public get human_readable_summary(): string { - return (this.current.search_string as string) || 'All media' + return 'Search results' } } diff --git a/packages/web/src/lib/runes/media_view_rune.svelte.ts b/packages/web/src/lib/runes/media_view_rune.svelte.ts index c9b7d5ed..301c3b9f 100644 --- a/packages/web/src/lib/runes/media_view_rune.svelte.ts +++ b/packages/web/src/lib/runes/media_view_rune.svelte.ts @@ -15,7 +15,6 @@ export class MediaViewRune extends Rune { media_type!: MediaResponse['media_type'] | 'grouped' state = $state() current_view: model_types.View - series_index?: number protected constructor(client: BaseController['client'], media_response: MediaResponse) { super(client) @@ -23,8 +22,6 @@ export class MediaViewRune extends Rune { media: media_response, full_thumbnails: undefined } - // @ts-ignore - series_index is only present in SeriesSearchResponse - this.series_index = media_response.series_index } get media() { @@ -99,6 +96,13 @@ export class MediaViewRune extends Rune { export class MediaFileRune extends MediaViewRune { media_type = 'media_file' as const satisfies MediaResponse['media_type'] + series_index?: number + + constructor(client: BaseController['client'], media_response: MediaResponse) { + super(client, media_response) + // @ts-ignore - series_index is only present in SeriesSearchResponse + this.series_index = media_response.series_index + } public override async update(media_info: inputs.MediaInfo, tags: inputs.MediaReferenceUpdateTags) { const updated = await this.client.forager.media.update( @@ -129,6 +133,13 @@ export class MediaFileRune extends MediaViewRune { export class MediaSeriesRune extends MediaViewRune { media_type = 'media_series' as const satisfies MediaResponse['media_type'] + series_index?: number + + constructor(client: BaseController['client'], media_response: MediaResponse) { + super(client, media_response) + // @ts-ignore - series_index is only present in SeriesSearchResponse + this.series_index = media_response.series_index + } public override async load_detailed_view() { if (this.state!.full_thumbnails) return diff --git a/packages/web/src/routes/browse/components/SearchResults.svelte b/packages/web/src/routes/browse/components/SearchResults.svelte index 0a9a2a60..b1d6851f 100644 --- a/packages/web/src/routes/browse/components/SearchResults.svelte +++ b/packages/web/src/routes/browse/components/SearchResults.svelte @@ -1,34 +1,14 @@ - -
{#each media_list.results as result, result_index} -
-
-
-
{ - if (e.key === 'Enter') { - media_selections.set_current_selection(result, result_index) - } - }} - role="button" - tabindex="0" - use:focusable={!media_selections.current_selection.show && media_selections.current_selection.result_index === result_index} - onclick={e => media_selections.set_current_selection(result, result_index)} + + {#snippet metadata({ result })} + {#if result.media_type === 'media_series'} + - {#if settings.ui.media_list.thumbnail_shape === 'original'} - Thumbnail for {result.media_reference?.title ?? 'media'} - {:else} - Thumbnail for {result.media_reference?.title ?? 'media'} - {/if} -
- - - -
-
-
+ View series → + + {:else if result.media_type === 'grouped'} + + {result.group_metadata.value} + + {/if} + {/snippet} + {/each}
{#if media_list.loading} diff --git a/packages/web/src/routes/series/[series_id]/components/SearchResults.svelte b/packages/web/src/routes/series/[series_id]/components/SearchResults.svelte index e254c78a..30255b8d 100644 --- a/packages/web/src/routes/series/[series_id]/components/SearchResults.svelte +++ b/packages/web/src/routes/series/[series_id]/components/SearchResults.svelte @@ -1,32 +1,13 @@ - -
{#each media_list.results as result, result_index} -
-
-
-
{ - if (e.key === 'Enter') { - media_selections.set_current_selection(result, result_index) - } - }} - role="button" - tabindex="0" - use:focusable={!media_selections.current_selection.show && media_selections.current_selection.result_index === result_index} - onclick={e => media_selections.set_current_selection(result, result_index)} - > - {#if settings.ui.media_list.thumbnail_shape === 'original'} - Thumbnail for {result.media_reference.title ?? 'media'} - {:else} - Thumbnail for {result.media_reference.title ?? 'media'} - {/if} + + {#snippet metadata({ result })} + {#if result.series_index !== undefined} +
+ #{result.series_index}
- - -
- {#if result.media_type === 'media_file'} - {#if result.media_file.media_type === 'VIDEO'} - - - {#if result.media_file.audio} - - {/if} - - {human_readable_duration(result.media_file.duration)} - {:else if result.media_file.media_type === 'IMAGE' && result.media_file.content_type === 'image/gif'} - - {:else if result.media_file.media_type === 'IMAGE'} - - {:else if result.media_file.media_type === 'AUDIO'} - - {:else} - unknown media type {result.media_file.media_type} - {/if} - {:else if result.media_type === 'media_series'} - - ({result.media_reference.media_series_length}) - {:else} - UNEXPECTED MEDIA TYPE {result.media_type} - {/if} -
- - - {#if result.series_index !== undefined} -
- #{result.series_index} -
- {/if} -
-
-
+ {/if} + {/snippet} + {/each}
{#if media_list.loading} diff --git a/packages/web/src/routes/series/[series_id]/runes/queryparams.svelte.ts b/packages/web/src/routes/series/[series_id]/runes/queryparams.svelte.ts index 64271386..17809a7c 100644 --- a/packages/web/src/routes/series/[series_id]/runes/queryparams.svelte.ts +++ b/packages/web/src/routes/series/[series_id]/runes/queryparams.svelte.ts @@ -1,10 +1,17 @@ import type { inputs } from '@forager/core' import type { MediaListRune } from '$lib/runes/index.ts' import type { BaseController } from '$lib/base_controller.ts' -import { BaseQueryParamsManager, type BaseSearchParams } from '$lib/runes/base_queryparams.svelte.ts' +import { BaseQueryParamsManager } from '$lib/runes/base_queryparams.svelte.ts' -interface SeriesSearchParams extends BaseSearchParams { +interface SeriesSearchParams { + search_string: string + filepath: string | undefined sort: inputs.SeriesSearch['sort_by'] + unread_only: boolean + stars: number | undefined + stars_equality: 'gte' | 'eq' | undefined + order: 'desc' | 'asc' + media_type: string } const DEFAULTS: SeriesSearchParams = { @@ -49,6 +56,64 @@ export class SeriesQueryParamsManager extends BaseQueryParamsManager() + + // Only include non-default values + for (const [key, value] of Object.entries(params)) { + if (value !== DEFAULTS[key as keyof SeriesSearchParams] && value !== undefined) { + const param_name = URL_PARAM_MAP[key as keyof SeriesSearchParams] ?? key + + // Series-specific encoding + if (key === 'search_string') { + const encoded = encodeURIComponent(value.replaceAll(/\s/g, ',')) + .replaceAll('%3A', ':') + .replaceAll('%2C', ',') + url_params.set(param_name, encoded) + } else if (key === 'filepath') { + if (value) { + url_params.set(param_name, encodeURIComponent(value)) + } + } else { + url_params.set(param_name, String(value)) + } + } + } + + const query_string = Array.from(url_params.entries()) + .map(([key, val]) => `${key}=${val}`) + .join('&') + + return query_string ? '?' + query_string : null + } + protected async execute_search(params: SeriesSearchParams): Promise { this.media_list.clear() From fc75d14ee16de38aa4b566444da3ab8ce7ccc45d Mon Sep 17 00:00:00 2001 From: Andrew Kaiser Date: Mon, 12 Jan 2026 19:14:46 -0500 Subject: [PATCH 04/11] implement getting series thumbnails --- packages/core/src/actions/lib/base.ts | 15 ++++++++++++++- packages/core/src/actions/series_actions.ts | 17 +++-------------- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/packages/core/src/actions/lib/base.ts b/packages/core/src/actions/lib/base.ts index 5d13c333..4c140bd9 100644 --- a/packages/core/src/actions/lib/base.ts +++ b/packages/core/src/actions/lib/base.ts @@ -249,7 +249,8 @@ abstract class Actions extends Emitter extends Emitter) { const group = tag.group ?? '' const color = get_hash_color(group, 'hsl') diff --git a/packages/core/src/actions/series_actions.ts b/packages/core/src/actions/series_actions.ts index 6743d859..03521966 100644 --- a/packages/core/src/actions/series_actions.ts +++ b/packages/core/src/actions/series_actions.ts @@ -55,7 +55,7 @@ class SeriesActions extends Actions { const transaction_result = transaction() // no thumbnails should exist yet, so we give it limit: 0 - return this.#get_media_series_response({series_id: transaction_result.media_reference.id }) + return this.get_media_series_response({series_id: transaction_result.media_reference.id }) } public update = (series_id: number, media_info?: inputs.MediaInfo, tags?: inputs.Tag[], editing?: UpdateEditor): MediaSeriesResponse => { @@ -88,7 +88,7 @@ class SeriesActions extends Actions { }) transaction() - return this.#get_media_series_response({series_id: parsed.series_id}) + return this.get_media_series_response({series_id: parsed.series_id}) } public upsert = (media_info?: inputs.MediaSeriesInfo, tags?: inputs.Tag[]): MediaSeriesResponse => { @@ -149,7 +149,7 @@ class SeriesActions extends Actions { public get = (params: inputs.SeriesGet): MediaSeriesResponse => { const parsed = parsers.SeriesGet.parse(params) - return this.#get_media_series_response(parsed) + return this.get_media_series_response(parsed) } public search = (params: inputs.SeriesSearch): result_types.PaginatedResult => { @@ -194,17 +194,6 @@ class SeriesActions extends Actions { } } - #get_media_series_response(params: outputs.SeriesGet): MediaSeriesResponse { - const media_reference = this.models.MediaReference.select_one_media_series({id: params.series_id, media_series_name: params.series_name}) - const tags = this.models.Tag.select_all({media_reference_id: media_reference.id}) - return { - media_type: 'media_series', - media_reference, - tags, - thumbnails: this.models.MediaThumbnail.select_many({series_id: media_reference.id, limit: 0}), - } - } - #map_series_records_to_responses(records: ReturnType, thumbnail_limit: number, keypoint_tag_id: number | undefined): SeriesSearchResponse[] { const results: SeriesSearchResponse[] = records.results.map(row => { const tags = this.models.Tag.select_all({media_reference_id: row.id}) From ad70978f072bf060dc12e8f1ef9e020f8cc62acf Mon Sep 17 00:00:00 2001 From: Andrew Kaiser Date: Mon, 12 Jan 2026 19:19:31 -0500 Subject: [PATCH 05/11] fix loading series details bug --- packages/web/src/lib/components/MediaBrowserLayout.svelte | 2 +- packages/web/src/lib/runes/media_view_rune.svelte.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/web/src/lib/components/MediaBrowserLayout.svelte b/packages/web/src/lib/components/MediaBrowserLayout.svelte index c28f8126..c2e45611 100644 --- a/packages/web/src/lib/components/MediaBrowserLayout.svelte +++ b/packages/web/src/lib/components/MediaBrowserLayout.svelte @@ -1,4 +1,4 @@ -