From 509c64d2c7aceb2669c9b992a79ae937bf58b29e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 21 Dec 2025 17:05:15 +0000 Subject: [PATCH] feat: Add series page and related components This commit introduces a new series page, including a dedicated controller, query parameters handling, and integration with existing media list components. It also refactors the browse controller and adds new runes for dimensions and media selections. Co-authored-by: kaisea.rpi --- .../src/lib/pages/media_list/controller.ts | 40 ++++ .../src/lib/runes/dimensions_rune.svelte.ts | 20 ++ packages/web/src/lib/runes/index.ts | 2 + .../src/lib/runes/media_list_rune.svelte.ts | 16 +- .../lib/runes/media_selections_rune.svelte.ts | 126 +++++++++++ .../src/lib/runes/media_view_rune.svelte.ts | 21 +- .../routes/browse/components/Footer.svelte | 4 +- .../routes/browse/components/Header.svelte | 4 +- .../browse/components/MediaDetailEntry.svelte | 4 +- .../browse/components/MediaDetails.svelte | 4 +- .../routes/browse/components/MediaList.svelte | 4 +- .../routes/browse/components/MediaView.svelte | 4 +- .../browse/components/SearchLink.svelte | 8 +- .../browse/components/SearchParams.svelte | 43 ++-- .../browse/components/SearchResults.svelte | 24 ++- packages/web/src/routes/browse/controller.ts | 10 +- .../routes/series/[series_id]/+page.svelte | 36 ++++ .../routes/series/[series_id]/controller.ts | 37 ++++ .../[series_id]/runes/queryparams.svelte.ts | 203 ++++++++++++++++++ 19 files changed, 551 insertions(+), 59 deletions(-) create mode 100644 packages/web/src/lib/pages/media_list/controller.ts create mode 100644 packages/web/src/lib/runes/dimensions_rune.svelte.ts create mode 100644 packages/web/src/lib/runes/media_selections_rune.svelte.ts create mode 100644 packages/web/src/routes/series/[series_id]/+page.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/pages/media_list/controller.ts b/packages/web/src/lib/pages/media_list/controller.ts new file mode 100644 index 00000000..4bab4715 --- /dev/null +++ b/packages/web/src/lib/pages/media_list/controller.ts @@ -0,0 +1,40 @@ +import type { SettingsRune, MediaListRune } from '$lib/runes/index.ts' +import type { MediaSelectionsRune } from '$lib/runes/media_selections_rune.svelte.ts' +import type { create_dimensional_rune } from '$lib/runes/dimensions_rune.svelte.ts' + +export type MediaListPageKind = 'browse' | 'series' + +/** + * Minimal controller shape required by the shared "media list page" UI + * (header/search, sidebar/details, list, view overlay, footer). + * + * This intentionally stays structural (not class-based) so multiple controllers + * can implement it without tight coupling. + */ +export interface MediaListPageController { + page_kind: MediaListPageKind + + // Used by components for keyboard handling + keybinds: { + handler: (e: KeyboardEvent) => void + component_listen: (handlers: Record void>) => void + } + + runes: { + media_list: MediaListRune + media_selections: MediaSelectionsRune + settings: SettingsRune + dimensions: ReturnType + queryparams: { + DEFAULTS: Record + current_url: Record + serialize: (params: Record) => string + goto: (params: Record) => Promise + submit: (params: Record) => Promise + merge: (partial_params: Record) => Record + popstate_listener: (fn: (params: Record) => void) => void + human_readable_summary?: string + } + } +} + diff --git a/packages/web/src/lib/runes/dimensions_rune.svelte.ts b/packages/web/src/lib/runes/dimensions_rune.svelte.ts new file mode 100644 index 00000000..355a80a6 --- /dev/null +++ b/packages/web/src/lib/runes/dimensions_rune.svelte.ts @@ -0,0 +1,20 @@ +export function create_dimensional_rune() { + const heights = $state({ + screen: 0, + header: 0, + media_list: 0, + footer: 0, + }) + + $effect(() => { + // 5 here fixes an issue with heights being too long causing a scrollbar to appear. + heights.media_list = heights.screen - heights.header - heights.footer - 5 + }) + + return { + get heights() { + return heights + }, + } +} + diff --git a/packages/web/src/lib/runes/index.ts b/packages/web/src/lib/runes/index.ts index 480185a4..1190e621 100644 --- a/packages/web/src/lib/runes/index.ts +++ b/packages/web/src/lib/runes/index.ts @@ -2,3 +2,5 @@ export * from './focus_rune.svelte.ts' export * from './settings_rune.svelte.ts' export * from './media_list_rune.svelte.ts' export * from './media_view_rune.svelte.ts' +export * from './dimensions_rune.svelte.ts' +export * from './media_selections_rune.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..06c72710 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 SeriesSearchInput { + type: 'series' + params: Parameters[0] +} interface FilesystemInput { type: 'filesystem' params: {} @@ -22,6 +27,7 @@ interface FilesystemInput { export type Input = | SearchInput | GroupByInput + | SeriesSearchInput | 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,8 @@ 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') } @@ -112,16 +120,16 @@ export class MediaListRune extends Rune { } } - async fetch_tag_summary(params: Input) { + async fetch_tag_summary(_params?: Input) { // NOTE this currently just does a "union". We want an "intersection" for this view. Otherwise our default view returns all tags! /* - if (params.type !== 'media') { + if (_params.type !== 'media') { return } const content = await this.client.forager.tag.search({ - contextual_query: params.params?.query + contextual_query: _params.params?.query }) console.log(content) */ diff --git a/packages/web/src/lib/runes/media_selections_rune.svelte.ts b/packages/web/src/lib/runes/media_selections_rune.svelte.ts new file mode 100644 index 00000000..ffee2486 --- /dev/null +++ b/packages/web/src/lib/runes/media_selections_rune.svelte.ts @@ -0,0 +1,126 @@ +import { Rune } from '$lib/runes/rune.ts' +import type * as runes from '$lib/runes/index.ts' +import type { BaseController } from '$lib/base_controller.ts' +import type { MediaListRune } from '$lib/runes/index.ts' + +interface SelectIndividual { + type: 'ids' + media_reference_ids: number[] +} +interface SelectRange { + type: 'range' + start_index: number + stop_index: number +} +interface SelectAll { + type: 'all' +} +interface SelectNone { + type: 'none' +} +export type ThumbnailSelections = + | SelectIndividual + | SelectRange + | SelectAll + | SelectNone + +export interface CurrentSelection { + show: boolean + media_response: runes.MediaViewRunes | null + result_index: number +} + +const CURRENT_SELECTION_DEFAULTS: CurrentSelection = { + show: false, + media_response: null, + result_index: 0, +} + +export class MediaSelectionsRune extends Rune { + #selected_thumbnails = $state({ type: 'none' }) + #current_selection = $state({ ...CURRENT_SELECTION_DEFAULTS }) + + public constructor(client: BaseController['client'], _media_list_rune: MediaListRune) { + super(client) + } + + public get thumbnail_selections() { + return this.#selected_thumbnails + } + + public get current_selection() { + return this.#current_selection + } + + private is_currently_selected(media_reference_id: number) { + return this.#current_selection.media_response?.media_reference.id === media_reference_id + } + + public async set_current_selection(media_response: runes.MediaViewRune, result_index: number) { + if (this.is_currently_selected(media_response.media_reference.id)) { + await this.open_media() + } else { + this.#current_selection.media_response = media_response + this.#current_selection.result_index = result_index + } + } + + public async view_media() { + await this.#current_selection.media_response?.load_detailed_view() + if (this.#current_selection.show) { + await this.#current_selection.media_response?.add_view() + } + } + + public clear_current_selection() { + this.#current_selection = { ...CURRENT_SELECTION_DEFAULTS } + } + + public async open_media() { + if (this.#current_selection.media_response) { + this.#current_selection.show = true + } + await this.view_media() + } + + public close_media = () => { + this.#current_selection.show = false + } + + public async next_media(results: runes.MediaViewRune[]) { + let next_index = 0 + if ( + this.#selected_thumbnails.type === 'ids' && this.#selected_thumbnails.media_reference_ids.length <= 1 || + this.#selected_thumbnails.type === 'none' + ) { + next_index = (this.#current_selection.result_index + 1) % results.length + } else { + throw new Error('unimplemented') + } + + this.#current_selection.media_response = results[next_index] as unknown as runes.MediaViewRunes + this.#current_selection.result_index = next_index + + await this.view_media() + } + + async prev_media(results: runes.MediaViewRune[]) { + let prev_index = 0 + if ( + this.#selected_thumbnails.type === 'ids' && this.#selected_thumbnails.media_reference_ids.length <= 1 || + this.#selected_thumbnails.type === 'none' + ) { + prev_index = this.#current_selection.result_index - 1 + if (prev_index < 0) { + prev_index = results.length - 1 + } + } else { + throw new Error('unimplemented') + } + this.#current_selection.media_response = results[prev_index] as unknown as runes.MediaViewRunes + this.#current_selection.result_index = prev_index + + await this.view_media() + } +} + 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 4caeaa17..b6d4068d 100644 --- a/packages/web/src/lib/runes/media_view_rune.svelte.ts +++ b/packages/web/src/lib/runes/media_view_rune.svelte.ts @@ -1,10 +1,10 @@ import {Rune} from '$lib/runes/rune.ts' import type { BaseController } from '$lib/base_controller.ts' -import type { MediaResponse, inputs, type model_types } from '@forager/core' +import type { MediaResponse, SeriesSearchResponse, inputs, type model_types } from '@forager/core' interface State { - media: MediaResponse + media: MediaResponse | SeriesSearchResponse full_thumbnails: MediaResponse['thumbnails'] | undefined } @@ -12,11 +12,11 @@ interface State { interface SearchParams {} export class MediaViewRune extends Rune { - media_type!: MediaResponse['media_type'] | 'grouped' + media_type!: (MediaResponse | SeriesSearchResponse)['media_type'] | 'grouped' state = $state() current_view: model_types.View - protected constructor(client: BaseController['client'], media_response: MediaResponse) { + protected constructor(client: BaseController['client'], media_response: MediaResponse | SeriesSearchResponse) { super(client) this.state = { media: media_response, @@ -28,10 +28,15 @@ export class MediaViewRune extends Rune { return this.state!.media } - set media(media: MediaResponse) { + set media(media: MediaResponse | SeriesSearchResponse) { return this.state!.media = media } + get series_index(): number | undefined { + // Present for results returned from forager.series.search + return (this.media as SeriesSearchResponse).series_index + } + get tags() { return this.media.tags } @@ -75,7 +80,7 @@ export class MediaViewRune extends Rune { this.state.media.media_reference.view_count = view_response.media_reference.view_count } - static create(client: BaseController['client'], media_response: MediaResponse, search_params: SearchParams) { + static create(client: BaseController['client'], media_response: MediaResponse | SeriesSearchResponse, search_params: SearchParams) { if (media_response.media_type === 'media_file') { return new MediaFileRune(client, media_response) } else if (media_response.media_type === 'media_series') { @@ -129,10 +134,8 @@ export class MediaSeriesRune extends MediaViewRune { public override async load_detailed_view() { if (this.state!.full_thumbnails) return - const series = await forager.series.get({series_id: media_response.media_reference.id }) + const series = await this.client.forager.series.get({series_id: this.media_reference.id }) this.state!.full_thumbnails = series.thumbnails - const series_items = await forager.media.search({query: {series_id: media_response.media_reference.id }}) - // TODO attach series items } public img_fit_classes() { diff --git a/packages/web/src/routes/browse/components/Footer.svelte b/packages/web/src/routes/browse/components/Footer.svelte index 454ee52e..db468210 100644 --- a/packages/web/src/routes/browse/components/Footer.svelte +++ b/packages/web/src/routes/browse/components/Footer.svelte @@ -1,8 +1,8 @@ diff --git a/packages/web/src/routes/browse/components/MediaDetailEntry.svelte b/packages/web/src/routes/browse/components/MediaDetailEntry.svelte index 7c78dd08..04ccc896 100644 --- a/packages/web/src/routes/browse/components/MediaDetailEntry.svelte +++ b/packages/web/src/routes/browse/components/MediaDetailEntry.svelte @@ -2,9 +2,9 @@ import * as datetime from '@std/datetime' import type { Json } from '@andykais/ts-rpc/adapters/sveltekit.ts' - import { BrowseController } from '../controller.ts' + import type { MediaListPageController } from '$lib/pages/media_list/controller.ts' let {controller, ...props}: { - controller: BrowseController + controller: MediaListPageController label: string content: string | number | Date | null | Json type?: "text" | "datetime-local" diff --git a/packages/web/src/routes/browse/components/MediaDetails.svelte b/packages/web/src/routes/browse/components/MediaDetails.svelte index 4645842b..76b8fbae 100644 --- a/packages/web/src/routes/browse/components/MediaDetails.svelte +++ b/packages/web/src/routes/browse/components/MediaDetails.svelte @@ -10,8 +10,8 @@ import {XCircle} from '$lib/icons/mod.ts' import * as parsers from '$lib/parsers.ts' - import { BrowseController } from '../controller.ts' - let {controller}: {controller: BrowseController} = $props() + import type { MediaListPageController } from '$lib/pages/media_list/controller.ts' + let {controller}: {controller: MediaListPageController} = $props() let {dimensions, media_selections, settings, queryparams} = controller.runes let new_tag_str = $state('') diff --git a/packages/web/src/routes/browse/components/MediaList.svelte b/packages/web/src/routes/browse/components/MediaList.svelte index f2a08670..24a4e8d5 100644 --- a/packages/web/src/routes/browse/components/MediaList.svelte +++ b/packages/web/src/routes/browse/components/MediaList.svelte @@ -1,9 +1,9 @@ + +
+
+
+ +
+ + +
+
+
+
+ + + 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..d23015f8 --- /dev/null +++ b/packages/web/src/routes/series/[series_id]/controller.ts @@ -0,0 +1,37 @@ +import type { Config } from '$lib/server/config.ts' +import { BaseController } from '$lib/base_controller.ts' +import { create_focuser, SettingsRune, MediaListRune, create_dimensional_rune, MediaSelectionsRune } from '$lib/runes/index.ts' +import { SeriesQueryParamsRune } from './runes/queryparams.svelte.ts' + +class SeriesController extends BaseController { + public page_kind = 'series' as const + public series_id: number + + runes: { + media_list: MediaListRune + focus: ReturnType + dimensions: ReturnType + settings: SettingsRune + media_selections: MediaSelectionsRune + queryparams: SeriesQueryParamsRune + } + + public constructor(config: Config, series_id: number) { + super(config) + this.series_id = series_id + + const settings = new SettingsRune(this.client, this.config) + const media_list_rune = new MediaListRune(this.client) + this.runes = { + media_list: media_list_rune, + focus: create_focuser(), + dimensions: create_dimensional_rune(), + settings, + media_selections: new MediaSelectionsRune(this.client, media_list_rune), + queryparams: new SeriesQueryParamsRune(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..3bd83403 --- /dev/null +++ b/packages/web/src/routes/series/[series_id]/runes/queryparams.svelte.ts @@ -0,0 +1,203 @@ +import type { inputs } from '@forager/core' +import type { MediaListRune } from '$lib/runes/index.ts' +import { onMount } from 'svelte' +import { pushState } from '$app/navigation' +import { Rune } from '$lib/runes/rune.ts' + +interface State { + search_string: string + filepath: string | undefined + sort: inputs.SeriesSearch['sort_by'] + unread_only: boolean + stars: number | undefined + stars_equality: 'lte' | 'gte' | 'eq' | undefined + order: 'desc' | 'asc' + media_type: string +} + +const DEFAULTS: State = { + search_string: '', + filepath: undefined as string | undefined, + sort: 'series_index' as const, + order: 'asc' as const, + unread_only: false, + stars: undefined, + stars_equality: undefined, + media_type: 'all', +} + +const NAME_MAP: Partial> = { + search_string: 'tags', + unread_only: 'unread', + media_type: 'type', +} +const NAME_MAP_REVERSED = Object.fromEntries(Object.entries(NAME_MAP).map(([key, val]) => [val, key])) + +export class SeriesQueryParamsRune extends Rune { + public DEFAULTS = DEFAULTS + public current_url: State = $state({ ...DEFAULTS }) + public current_serialized: string = '?' + + private search_rune: MediaListRune + private series_id: number + private popstate_listener_fn!: (params: State) => void + + public constructor(client: { forager: any }, search_rune: MediaListRune, series_id: number) { + super(client) + this.search_rune = search_rune + this.series_id = series_id + + onMount(async () => { + const params = this.read(window.location) + this.popstate_listener_fn(params) + await this.submit_internal(params) + + window.addEventListener('popstate', async () => { + const params = this.read(window.location) + this.popstate_listener_fn(params) + await this.submit_internal(params) + }) + }) + } + + public read(url: URL) { + const params: State = { ...DEFAULTS } + + this.current_serialized = url.search + const queryparams = new URLSearchParams(url.search) + for (const [key, val] of queryparams.entries()) { + const params_key = (NAME_MAP_REVERSED as any)[key] ?? key + let deserialized_value: any = val + if (params_key === 'search_string') { + deserialized_value = val.replaceAll(',', ' ') + } else if (params_key === 'stars') { + deserialized_value = parseInt(val) + } else if (params_key === 'filepath') { + deserialized_value = decodeURIComponent(val) + } + ;(params as any)[params_key] = deserialized_value + } + + this.current_url = { ...params } + return params + } + + public serialize(params: State) { + // NOTE we do not use URLSearchParams because it will (correctly) serialize ":" into "%3A", which is used all the time in our tags + const queryparams = new Map() + for (const [key, value] of Object.entries(params)) { + if ((value as any) !== (DEFAULTS as any)[key]) { + const queryparam_key = (NAME_MAP as any)[key] ?? key + let serialized_value: any = value + if (key === 'search_string') { + serialized_value = encodeURIComponent((value as string).replaceAll(/\s/g, ',')) + .replaceAll('%3A', ':') + .replaceAll('%2C', ',') + } else if (key === 'filepath') { + serialized_value = encodeURIComponent(value as string) + } + queryparams.set(queryparam_key, serialized_value) + } + } + + return '?' + Array.from(queryparams.entries()) + .map(([key, val]) => `${key}=${val}`) + .join('&') + } + + public write_url(params: State) { + const serialized_params = this.serialize(params) + + if (this.current_serialized !== serialized_params) { + this.current_serialized = serialized_params + this.current_url = { ...params } + pushState(serialized_params, {}) + } + } + + public merge(partial_params: Record) { + const params: any = { ...this.current_url } + for (const [key, val] of Object.entries(partial_params)) { + const params_key: keyof State = (NAME_MAP_REVERSED as any)[key] ?? (key as any) + + if (params_key === 'search_string') { + const search_strings = new Set((params['search_string'] as string).split(/\s+/)) + search_strings.add(val as string) + params.search_string = [...search_strings].join(' ').trim() + } else { + params[params_key] = val + } + } + return params as State + } + + public async goto(params: State) { + this.popstate_listener_fn(params) + await this.submit(params) + } + + public async submit(params: State) { + this.write_url(params) + await this.submit_internal(params) + } + + private async submit_internal(params: State) { + const tags = params.search_string.split(' ').filter(t => t.length > 0) + const sort_by = params.sort + const order = params.order + const filepath = params.filepath + + this.search_rune.clear() + const query: inputs.SeriesSearch['query'] = { + series_id: this.series_id, + tags, + filepath, + } + + if (params.unread_only) { + if (params.unread_only === 'true' || params.unread_only === true) { + query.unread = true + } + } + + if (params.stars !== undefined) { + query.stars = parseInt(params.stars as any) + query.stars_equality = (params.stars_equality as any) ?? 'gte' + } + + switch (params.media_type) { + case 'all': { + break + } + case 'animated': { + query.animated = true + break + } + default: { + throw new Error(`Unimplemented media type ${params.media_type}`) + } + } + + await this.search_rune.paginate({ + type: 'series', + params: { + query, + sort_by, + order, + }, + }) + } + + public popstate_listener(fn: (params: State) => void) { + this.popstate_listener_fn = fn + } + + public get human_readable_summary() { + let summary = `Series ${this.series_id}` + if (this.current_url.search_string) { + summary += ` - ${this.current_url.search_string}` + } + return summary + } +} +