From 1987ee455a48d005b9f01681622a47dc0dfcb1b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Dec 2025 20:23:13 +0000 Subject: [PATCH 1/2] feat(web): add series detail page at /series/[series_id] - Create new route /series/[series_id]/ with full page structure - Add SeriesController with series.search integration - Add SeriesQueryParamsRune with series_index sort support (default) - Add SeriesMediaListRune for forager.series.search API - Add series_index display under each media tile (#n format) - Add series link in browse view for media_series tiles - Export SeriesSearchResponse type from @forager/core - Implement Tag.decode() in web parsers The series page mirrors the browse page UI with: - Header with filters and "Series Index" sort option - Sidebar showing media details with series index - Footer with thumbnail controls - Series-specific search parameters --- packages/core/src/mod.ts | 2 +- packages/web/src/lib/parsers.ts | 12 +- .../browse/components/SearchResults.svelte | 6 +- .../routes/series/[series_id]/+page.svelte | 35 +++ .../[series_id]/components/Footer.svelte | 91 ++++++++ .../[series_id]/components/Header.svelte | 16 ++ .../components/MediaDetails.svelte | 181 ++++++++++++++ .../[series_id]/components/MediaList.svelte | 59 +++++ .../[series_id]/components/MediaView.svelte | 151 ++++++++++++ .../components/SearchResults.svelte | 149 ++++++++++++ .../components/SeriesSearchParams.svelte | 170 ++++++++++++++ .../routes/series/[series_id]/controller.ts | 38 +++ .../runes/series_media_list.svelte.ts | 83 +++++++ .../runes/series_media_selections.svelte.ts | 131 +++++++++++ .../runes/series_media_view.svelte.ts | 136 +++++++++++ .../runes/series_queryparams.svelte.ts | 221 ++++++++++++++++++ 16 files changed, 1477 insertions(+), 4 deletions(-) create mode 100644 packages/web/src/routes/series/[series_id]/+page.svelte create mode 100644 packages/web/src/routes/series/[series_id]/components/Footer.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/MediaDetails.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/MediaView.svelte create mode 100644 packages/web/src/routes/series/[series_id]/components/SearchResults.svelte create mode 100644 packages/web/src/routes/series/[series_id]/components/SeriesSearchParams.svelte create mode 100644 packages/web/src/routes/series/[series_id]/controller.ts create mode 100644 packages/web/src/routes/series/[series_id]/runes/series_media_list.svelte.ts create mode 100644 packages/web/src/routes/series/[series_id]/runes/series_media_selections.svelte.ts create mode 100644 packages/web/src/routes/series/[series_id]/runes/series_media_view.svelte.ts create mode 100644 packages/web/src/routes/series/[series_id]/runes/series_queryparams.svelte.ts diff --git a/packages/core/src/mod.ts b/packages/core/src/mod.ts index 46bf678a..58b87a32 100644 --- a/packages/core/src/mod.ts +++ b/packages/core/src/mod.ts @@ -68,7 +68,7 @@ class Forager { export { Forager } export * as errors from './lib/errors.ts' export type ForagerConfig = inputs.ForagerConfig -export type {MediaResponse, MediaFileResponse, MediaSeriesResponse, MediaGroupResponse} from './actions/lib/base.ts' +export type {MediaResponse, MediaFileResponse, MediaSeriesResponse, MediaGroupResponse, SeriesSearchResponse} from './actions/lib/base.ts' export type * from './actions/mod.ts' export { type inputs } export type * as model_types from '~/models/lib/result_types.ts' diff --git a/packages/web/src/lib/parsers.ts b/packages/web/src/lib/parsers.ts index d8e17ef8..3cf3ac89 100644 --- a/packages/web/src/lib/parsers.ts +++ b/packages/web/src/lib/parsers.ts @@ -10,8 +10,16 @@ class Tag { } } - static decode(tag_str: string) { - throw new Error('unimplemented') + static decode(tag_str: string): {name: string, group?: string} { + const colon_index = tag_str.indexOf(':') + if (colon_index === -1) { + return { name: tag_str } + } else { + return { + group: tag_str.substring(0, colon_index), + name: tag_str.substring(colon_index + 1) + } + } } } diff --git a/packages/web/src/routes/browse/components/SearchResults.svelte b/packages/web/src/routes/browse/components/SearchResults.svelte index 5d481749..e240fdaa 100644 --- a/packages/web/src/routes/browse/components/SearchResults.svelte +++ b/packages/web/src/routes/browse/components/SearchResults.svelte @@ -125,7 +125,11 @@ {/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 './components/MediaDetails.svelte' + import SeriesSearchParams from './components/SeriesSearchParams.svelte' + import MediaList from './components/MediaList.svelte' + import MediaView from './components/MediaView.svelte' + import Footer from './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/Footer.svelte b/packages/web/src/routes/series/[series_id]/components/Footer.svelte new file mode 100644 index 00000000..eb4d73d2 --- /dev/null +++ b/packages/web/src/routes/series/[series_id]/components/Footer.svelte @@ -0,0 +1,91 @@ + + + + +
+ +
+
+ + { + runes.settings.set('ui.media_list.thumbnail_size', e.target.value) + }}> + {runes.settings.ui.media_list.thumbnail_size}px +
+ +
+ +
+ + + Series items: {human_readable_number(runes.media_list.total)} + +
+
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..2fff7d42 --- /dev/null +++ b/packages/web/src/routes/series/[series_id]/components/Header.svelte @@ -0,0 +1,16 @@ + + +
+ {queryparams.human_readable_summary || 'Forager - Series'} + +
diff --git a/packages/web/src/routes/series/[series_id]/components/MediaDetails.svelte b/packages/web/src/routes/series/[series_id]/components/MediaDetails.svelte new file mode 100644 index 00000000..96498da8 --- /dev/null +++ b/packages/web/src/routes/series/[series_id]/components/MediaDetails.svelte @@ -0,0 +1,181 @@ + + + +
{ + e.preventDefault() + let media_info = undefined + let tags: {add: string[]} | undefined + if (new_tag_str) { + tags = {add: [new_tag_str]} + } + media_selections.current_selection.media_response!.update(media_info, tags) + new_tag_str = '' + }} + > + {#if media_selections.current_selection.media_response} + + + + + + + + + {#each sorted_tags as tag_group_entry, tag_entry_index (tag_group_entry[0])} + {#each tag_group_entry[1] as tag, tag_index (tag.id)} +
+ + + + +
+ {/each} + {/each} + + + + + + + + + + + + + + + + {#if media_selections.current_selection.media_response.media_type === 'media_file'} + + + + {/if} + + {:else} +
+

Series ID: {controller.series_id}

+

Select an item to view details

+
+ {/if} + +
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/MediaView.svelte b/packages/web/src/routes/series/[series_id]/components/MediaView.svelte new file mode 100644 index 00000000..5074d25b --- /dev/null +++ b/packages/web/src/routes/series/[series_id]/components/MediaView.svelte @@ -0,0 +1,151 @@ + + + + +
+ {#if media_selections.current_selection.show && media_selections.current_selection.media_response} + {#if media_selections.current_selection.media_response.media_type === 'media_file'} + {#if media_selections.current_selection.media_response.media_file.media_type === 'IMAGE'} + + {:else if media_selections.current_selection.media_response.media_file.media_type === 'VIDEO'} + + + {#if controller.runes.settings.ui.media_view.filmstrip.enabled} +
+ {#each media_selections.current_selection.thumbnails.results as thumbnail} +
+
+ {/each} +
+ {/if} + {:else if media_selections.current_selection.media_response.media_file.media_type === 'AUDIO'} + + + {/if} + {:else} + unhandled media type {media_selections.current_selection.media_response.media_type} + {/if} + {/if} +
+
+ + 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..9e57d5ac --- /dev/null +++ b/packages/web/src/routes/series/[series_id]/components/SearchResults.svelte @@ -0,0 +1,149 @@ + + + + + + +
+ {#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'} + /files/thumbnail{result.preview_thumbnail.filepath} + {:else} + /files/thumbnail{result.preview_thumbnail.filepath} + {/if} +
+ + +
+ #{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} +
+
+
+
+ {/each} +
+{#if media_list.loading} + Loading... +{/if} diff --git a/packages/web/src/routes/series/[series_id]/components/SeriesSearchParams.svelte b/packages/web/src/routes/series/[series_id]/components/SeriesSearchParams.svelte new file mode 100644 index 00000000..bc756f3c --- /dev/null +++ b/packages/web/src/routes/series/[series_id]/components/SeriesSearchParams.svelte @@ -0,0 +1,170 @@ + + +
{ + e.preventDefault() + await update_search() + }}> +
+
+ ← Browse + + +
+ +
+
+
+ + +
+ + + + + +
+ + + + + + +
+ +
+ +
+
+ + +
+ + + Series ID: {controller.series_id} + +
+
+
+ +
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..d84f61a7 --- /dev/null +++ b/packages/web/src/routes/series/[series_id]/controller.ts @@ -0,0 +1,38 @@ +import type { Config } from '$lib/server/config.ts' +import {BaseController} from '$lib/base_controller.ts' +import {create_focuser, SettingsRune } from '$lib/runes/index.ts' +import { create_dimensional_rune } from '../../browse/runes/dimensions.svelte.ts' +import { SeriesMediaSelectionsRune } from './runes/series_media_selections.svelte.ts' +import { SeriesQueryParamsRune } from './runes/series_queryparams.svelte.ts' +import { SeriesMediaListRune } from './runes/series_media_list.svelte.ts' + + +class SeriesController extends BaseController { + series_id: number + + runes: { + media_list: SeriesMediaListRune + focus: ReturnType + dimensions: ReturnType + settings: SettingsRune + media_selections: SeriesMediaSelectionsRune + queryparams: SeriesQueryParamsRune + } + + public constructor(config: Config, series_id: number) { + super(config) + this.series_id = series_id + + const media_list_rune = new SeriesMediaListRune(this.client) + this.runes = { + media_list: media_list_rune, + focus: create_focuser(), + dimensions: create_dimensional_rune(), + settings: new SettingsRune(this.client, this.config), + media_selections: new SeriesMediaSelectionsRune(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/series_media_list.svelte.ts b/packages/web/src/routes/series/[series_id]/runes/series_media_list.svelte.ts new file mode 100644 index 00000000..13ae25f0 --- /dev/null +++ b/packages/web/src/routes/series/[series_id]/runes/series_media_list.svelte.ts @@ -0,0 +1,83 @@ +import {Rune} from '$lib/runes/rune.ts' +import type { Forager, SeriesSearchResponse } from '@forager/core' +import { SeriesMediaViewRune } from './series_media_view.svelte.ts' + + +type Result = ReturnType + +interface SearchInput { + type: 'series' + params: Parameters[0] +} + +export type Input = SearchInput + +interface SeriesMediaListState { + loading: boolean + content: Result | null + results: SeriesMediaViewRune[] +} + +export class SeriesMediaListRune extends Rune { + #saved_params: {} | undefined + #prev_query_hash: string = '' + #fetch_count = 0 + #has_more = true + #cursor: Result['cursor'] = undefined + #state = $state({ + loading: true, + content: null, + results: [] + }) + + get loading() { return this.#state.loading } + + get content(): Result | null { return this.#state.content } + + get results(): SeriesMediaViewRune[] { return this.#state.results } + + get total() { return this.#state.content?.total ?? 0 } + + clear() { + this.#has_more = true + this.#cursor = undefined + this.#fetch_count = 0 + this.#saved_params = undefined + this.#state = { + loading: false, + results: [], + content: null + } + } + + async paginate(params?: Input) { + this.#saved_params = {...this.#saved_params, ...params?.params} + if (this.#fetch_count > 0 && this.#state.loading) return + + if (!this.#has_more) return + + this.#fetch_count ++ + this.#state.loading = true + + let fetch_params: Input['params'] | undefined = this.#saved_params + if (this.#cursor !== undefined) { + fetch_params = {...fetch_params, cursor: this.#cursor} as any + } + + const content = await this.client.forager.series.search(fetch_params) + + const results = content.results.map(result => { + return SeriesMediaViewRune.create(this.client, result) + }) + + this.#cursor = content.cursor + if (!this.#cursor) { + this.#has_more = false + } + this.#state = { + content: content, + results: this.#state.results.concat(results), + loading: false, + } + } +} diff --git a/packages/web/src/routes/series/[series_id]/runes/series_media_selections.svelte.ts b/packages/web/src/routes/series/[series_id]/runes/series_media_selections.svelte.ts new file mode 100644 index 00000000..2006fe14 --- /dev/null +++ b/packages/web/src/routes/series/[series_id]/runes/series_media_selections.svelte.ts @@ -0,0 +1,131 @@ +import {Rune} from '$lib/runes/rune.ts' +import type { SeriesSearchResponse } from '@forager/core' +import type { SeriesMediaViewRune } from './series_media_view.svelte.ts' +import type { BaseController } from '$lib/base_controller.ts' +import type { SeriesMediaListRune } from './series_media_list.svelte.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: SeriesMediaViewRune | null + result_index: number +} + +const CURRENT_SELECTION_DEFAULTS: CurrentSelection = { + show: false, + media_response: null, + result_index: 0, +} + +export class SeriesMediaSelectionsRune extends Rune { + #selected_thumbnails = $state({type: 'none'}) + #current_selection = $state({...CURRENT_SELECTION_DEFAULTS}) + + public constructor(client: BaseController['client'], media_list_rune: SeriesMediaListRune) { + 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) { + if (this.#current_selection.media_response?.media_reference.id === media_reference_id) { + return true + } + return false + } + + public async set_current_selection(media_response: SeriesMediaViewRune, result_index: number) { + this.#current_selection.show + 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: SeriesMediaViewRune[]) { + 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] + this.#current_selection.result_index = next_index + + await this.view_media() + } + + async prev_media(results: SeriesMediaViewRune[]) { + 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] + this.#current_selection.result_index = prev_index + + await this.view_media() + } +} diff --git a/packages/web/src/routes/series/[series_id]/runes/series_media_view.svelte.ts b/packages/web/src/routes/series/[series_id]/runes/series_media_view.svelte.ts new file mode 100644 index 00000000..873e5832 --- /dev/null +++ b/packages/web/src/routes/series/[series_id]/runes/series_media_view.svelte.ts @@ -0,0 +1,136 @@ +import {Rune} from '$lib/runes/rune.ts' +import type { BaseController } from '$lib/base_controller.ts' +import type { SeriesSearchResponse, inputs, type model_types } from '@forager/core' + + +interface State { + media: SeriesSearchResponse + full_thumbnails: SeriesSearchResponse['thumbnails'] | undefined +} + + +export class SeriesMediaViewRune extends Rune { + media_type!: SeriesSearchResponse['media_type'] + state = $state() + current_view: model_types.View + + protected constructor(client: BaseController['client'], media_response: SeriesSearchResponse) { + super(client) + this.state = { + media: media_response, + full_thumbnails: undefined + } + } + + get media() { + return this.state!.media + } + + set media(media: SeriesSearchResponse) { + return this.state!.media = media + } + + get tags() { + return this.media.tags + } + + get media_reference() { + return this.media.media_reference + } + + get media_file() { + return this.media.media_file + } + + get series_index(): number { + return this.media.series_index + } + + get preview_thumbnail() { + return this.media.thumbnails.results[0] + } + + get thumbnails() { + return this.media.thumbnails + } + + public update(media_info: inputs.MediaInfo, tags: inputs.MediaReferenceUpdateTags) { + throw new Error('requires override') + } + + public async star(stars: number) { + const updated = await this.client.forager.media.update( + this.media_reference.id, + {stars} + ) + this.state!.media = {...this.state!.media, media_reference: updated.media_reference, tags: updated.tags} + } + + public async load_detailed_view() { + throw new Error('requires override') + } + + public async add_view() { + const view_response = await this.client.forager.views.start({media_reference_id: this.media_reference.id }) + this.current_view = view_response.view + this.state.media.media_reference.view_count = view_response.media_reference.view_count + } + + static create(client: BaseController['client'], media_response: SeriesSearchResponse): SeriesMediaViewRune { + if (media_response.media_type === 'media_file') { + return new SeriesMediaFileRune(client, media_response) + } else if (media_response.media_type === 'media_series') { + return new SeriesMediaSeriesRune(client, media_response) + } else { + throw new Error(`Unexpected media_response ${JSON.stringify(media_response)}`) + } + } + + public img_fit_classes() { + throw new Error('requires override') + } +} + + +export class SeriesMediaFileRune extends SeriesMediaViewRune { + media_type = 'media_file' as const satisfies SeriesSearchResponse['media_type'] + + public override async update(media_info: inputs.MediaInfo, tags: inputs.MediaReferenceUpdateTags) { + const updated = await this.client.forager.media.update( + this.media_reference.id, + media_info, + tags + ) + this.state!.media = {...this.state!.media, media_reference: updated.media_reference, tags: updated.tags} + } + + public override async load_detailed_view() { + if (this.state!.full_thumbnails) return + const result = await this.client.forager.media.get({media_reference_id: this.media_reference.id }) + this.state!.full_thumbnails = result.thumbnails + } + + public img_fit_classes() { + if (this.media.media_file.width > this.media.media_file.height) { + return "w-full" + } else { + return "h-full" + } + } +} + + +export class SeriesMediaSeriesRune extends SeriesMediaViewRune { + media_type = 'media_series' as const satisfies SeriesSearchResponse['media_type'] + + public override async load_detailed_view() { + if (this.state!.full_thumbnails) return + const series = await this.client.forager.series.get({series_id: this.media_reference.id }) + this.state!.full_thumbnails = series.thumbnails + } + + public img_fit_classes() { + console.warn(`media series img fit functions are not implemented`) + return "w-full" + } +} diff --git a/packages/web/src/routes/series/[series_id]/runes/series_queryparams.svelte.ts b/packages/web/src/routes/series/[series_id]/runes/series_queryparams.svelte.ts new file mode 100644 index 00000000..baae6e8a --- /dev/null +++ b/packages/web/src/routes/series/[series_id]/runes/series_queryparams.svelte.ts @@ -0,0 +1,221 @@ +import type { inputs } from '@forager/core' +import type { SeriesController } from '../controller.ts' +import type { SeriesMediaListRune } from './series_media_list.svelte.ts' +import * as parsers from '$lib/parsers.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: 'series_index' | 'created_at' | 'updated_at' | 'source_created_at' | 'view_count' | 'last_viewed_at' + 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 = '?' + public series_id: number + + private search_rune: SeriesMediaListRune + private popstate_listener_fn!: (params: State) => void + + public constructor(client: SeriesController['client'], search_rune: SeriesMediaListRune, 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 e => { + 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[key] ?? key + let deserialized_value = 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[params_key] = deserialized_value + } + + this.current_url = {...params} + return params + } + + 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 serialize(params: State) { + const queryparams = new Map() + for (const [key, value] of Object.entries(params)) { + if (value !== DEFAULTS[key]) { + const queryparam_key = NAME_MAP[key] ?? key + let serialized_value = value + if (key === 'search_string') { + serialized_value = encodeURIComponent(value.replaceAll(/\s/g, ',')) + .replaceAll('%3A', ':') + .replaceAll('%2C', ',') + } else if (key === 'filepath') { + serialized_value = encodeURIComponent(value) + } + queryparams.set(queryparam_key, serialized_value) + } + } + + const serialized_params = '?' + Array.from(queryparams.entries()) + .map(([key, val]) => `${key}=${val}`) + .join('&') + + return serialized_params + } + + public async goto(params: State) { + this.popstate_listener_fn(params) + await this.submit(params) + } + + public merge(partial_params: Partial) { + const params = {...this.current_url} + for (const [key, val] of Object.entries(partial_params)) { + const params_key: keyof State = NAME_MAP_REVERSED[key] ?? key + + if (params_key === 'search_string') { + const search_strings = new Set(params['search_string'].split(/\s+/)) + search_strings.add(val) + params.search_string = [...search_strings].join(' ').trim() + } else { + params[params_key] = val + } + } + return params + } + + public extend(key: 'tag', value: string): State { + const params = {...this.current_url} + + 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') + } + } + + public async submit(params: State) { + this.write_url(params) + await this.submit_internal(params) + } + + private async submit_internal(params: State) { + const stars = params.stars + const stars_equality = params.stars_equality + 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: tags.map(t => parsers.Tag.decode(t)), + filepath, + } + + if (params.unread_only) { + if (params.unread_only === 'true' || params.unread_only === true) { + query.unread = true + } + } + + if (stars !== undefined) { + query.stars = parseInt(stars) + query.stars_equality = stars_equality ?? '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: 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 + } +} From 2ea31b97549695ec3ad85ba2b5b90d60bd8f0c63 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Jan 2026 20:31:02 +0000 Subject: [PATCH 2/2] refactor(web): unify browse and series pages with shared components Create a unified architecture for media pages using: - MediaPageController: Single controller with mode config (browse/series) - MediaQueryParamsRune: Unified queryparams handling for both modes - Shared components in $lib/components/media_page/: - MediaPage: Full page composition component - Header, Footer, MediaList, MediaView, MediaDetails - SearchParams: Mode-aware search controls - SearchResults: Mode-aware result display with series_index Both /browse and /series/[series_id] pages now use identical structure: - 12 lines of code each - Differ only in mode configuration Key features preserved: - Browse: group_by mode, search modes, all media types - Series: series_index display, back-to-browse link, series-specific sorting --- .../components/MediaDetailEntry.svelte | 4 +- .../components/media_page}/Footer.svelte | 14 +- .../lib/components/media_page/Header.svelte | 20 ++ .../media_page}/MediaDetails.svelte | 28 +- .../components/media_page}/MediaList.svelte | 11 +- .../components/media_page/MediaPage.svelte | 36 ++ .../components/media_page}/MediaView.svelte | 5 +- .../components/media_page}/SearchLink.svelte | 11 +- .../media_page}/SearchParams.svelte | 131 ++++--- .../media_page}/SearchResults.svelte | 64 ++-- .../src/lib/components/media_page/index.ts | 9 + packages/web/src/lib/media_page_controller.ts | 64 ++++ .../browse => lib}/runes/dimensions.svelte.ts | 0 packages/web/src/lib/runes/index.ts | 3 + .../src/lib/runes/media_list_rune.svelte.ts | 28 +- .../src/lib/runes/media_queryparams.svelte.ts | 327 +++++++++++++++++ .../runes/media_selections.svelte.ts | 14 +- .../src/lib/runes/media_view_rune.svelte.ts | 39 ++- packages/web/src/routes/browse/+page.svelte | 35 +- .../routes/browse/components/Header.svelte | 17 - .../browse/components/MediaDetails.svelte | 174 ---------- .../routes/browse/components/MediaList.svelte | 59 ---- .../routes/browse/components/MediaView.svelte | 158 --------- packages/web/src/routes/browse/controller.ts | 34 -- .../routes/browse/runes/queryparams.svelte.ts | 328 ------------------ .../routes/series/[series_id]/+page.svelte | 31 +- .../[series_id]/components/Footer.svelte | 91 ----- .../[series_id]/components/Header.svelte | 16 - .../components/SearchResults.svelte | 149 -------- .../components/SeriesSearchParams.svelte | 170 --------- .../routes/series/[series_id]/controller.ts | 38 -- .../runes/series_media_list.svelte.ts | 83 ----- .../runes/series_media_selections.svelte.ts | 131 ------- .../runes/series_media_view.svelte.ts | 136 -------- .../runes/series_queryparams.svelte.ts | 221 ------------ 35 files changed, 693 insertions(+), 1986 deletions(-) rename packages/web/src/{routes/browse => lib}/components/MediaDetailEntry.svelte (92%) rename packages/web/src/{routes/browse/components => lib/components/media_page}/Footer.svelte (86%) create mode 100644 packages/web/src/lib/components/media_page/Header.svelte rename packages/web/src/{routes/series/[series_id]/components => lib/components/media_page}/MediaDetails.svelte (88%) rename packages/web/src/{routes/series/[series_id]/components => lib/components/media_page}/MediaList.svelte (89%) create mode 100644 packages/web/src/lib/components/media_page/MediaPage.svelte rename packages/web/src/{routes/series/[series_id]/components => lib/components/media_page}/MediaView.svelte (97%) rename packages/web/src/{routes/browse/components => lib/components/media_page}/SearchLink.svelte (63%) rename packages/web/src/{routes/browse/components => lib/components/media_page}/SearchParams.svelte (64%) rename packages/web/src/{routes/browse/components => lib/components/media_page}/SearchResults.svelte (77%) create mode 100644 packages/web/src/lib/components/media_page/index.ts create mode 100644 packages/web/src/lib/media_page_controller.ts rename packages/web/src/{routes/browse => lib}/runes/dimensions.svelte.ts (100%) create mode 100644 packages/web/src/lib/runes/media_queryparams.svelte.ts rename packages/web/src/{routes/browse => lib}/runes/media_selections.svelte.ts (89%) delete mode 100644 packages/web/src/routes/browse/components/Header.svelte delete mode 100644 packages/web/src/routes/browse/components/MediaDetails.svelte delete mode 100644 packages/web/src/routes/browse/components/MediaList.svelte delete mode 100644 packages/web/src/routes/browse/components/MediaView.svelte delete mode 100644 packages/web/src/routes/browse/controller.ts delete mode 100644 packages/web/src/routes/browse/runes/queryparams.svelte.ts delete mode 100644 packages/web/src/routes/series/[series_id]/components/Footer.svelte delete mode 100644 packages/web/src/routes/series/[series_id]/components/Header.svelte delete mode 100644 packages/web/src/routes/series/[series_id]/components/SearchResults.svelte delete mode 100644 packages/web/src/routes/series/[series_id]/components/SeriesSearchParams.svelte delete mode 100644 packages/web/src/routes/series/[series_id]/controller.ts delete mode 100644 packages/web/src/routes/series/[series_id]/runes/series_media_list.svelte.ts delete mode 100644 packages/web/src/routes/series/[series_id]/runes/series_media_selections.svelte.ts delete mode 100644 packages/web/src/routes/series/[series_id]/runes/series_media_view.svelte.ts delete mode 100644 packages/web/src/routes/series/[series_id]/runes/series_queryparams.svelte.ts diff --git a/packages/web/src/routes/browse/components/MediaDetailEntry.svelte b/packages/web/src/lib/components/MediaDetailEntry.svelte similarity index 92% rename from packages/web/src/routes/browse/components/MediaDetailEntry.svelte rename to packages/web/src/lib/components/MediaDetailEntry.svelte index 7c78dd08..df651c16 100644 --- a/packages/web/src/routes/browse/components/MediaDetailEntry.svelte +++ b/packages/web/src/lib/components/MediaDetailEntry.svelte @@ -1,10 +1,10 @@ + +
+ {queryparams.human_readable_summary || default_title} + +
diff --git a/packages/web/src/routes/series/[series_id]/components/MediaDetails.svelte b/packages/web/src/lib/components/media_page/MediaDetails.svelte similarity index 88% rename from packages/web/src/routes/series/[series_id]/components/MediaDetails.svelte rename to packages/web/src/lib/components/media_page/MediaDetails.svelte index 96498da8..4a4af788 100644 --- a/packages/web/src/routes/series/[series_id]/components/MediaDetails.svelte +++ b/packages/web/src/lib/components/media_page/MediaDetails.svelte @@ -2,16 +2,21 @@ import * as path from '@std/path' import type { Forager } from '@forager/core' import Sidebar from '$lib/components/Sidebar.svelte' - import MediaDetailEntry from '../../../browse/components/MediaDetailEntry.svelte' + import MediaDetailEntry from '$lib/components/MediaDetailEntry.svelte' import TagAutoCompleteInput from '$lib/components/TagAutoCompleteInput.svelte' import Tag from '$lib/components/Tag.svelte' import Icon from '$lib/components/Icon.svelte' import {XCircle} from '$lib/icons/mod.ts' import * as parsers from '$lib/parsers.ts' - import { SeriesController } from '../controller.ts' - let {controller}: {controller: SeriesController} = $props() - let {dimensions, media_selections, settings, queryparams} = controller.runes + import type { MediaPageController } from '$lib/media_page_controller.ts' + + interface Props { + controller: MediaPageController + } + + let {controller}: Props = $props() + let {dimensions, media_selections, settings} = controller.runes let new_tag_str = $state('') @@ -73,11 +78,13 @@ }} > {#if media_selections.current_selection.media_response} - - + + {#if controller.is_series && media_selections.current_selection.media_response.series_index !== undefined} + + {/if} -

Series ID: {controller.series_id}

-

Select an item to view details

+

Select an item to view details

{/if} diff --git a/packages/web/src/routes/series/[series_id]/components/MediaList.svelte b/packages/web/src/lib/components/media_page/MediaList.svelte similarity index 89% rename from packages/web/src/routes/series/[series_id]/components/MediaList.svelte rename to packages/web/src/lib/components/media_page/MediaList.svelte index 6e9d0851..182f512c 100644 --- a/packages/web/src/routes/series/[series_id]/components/MediaList.svelte +++ b/packages/web/src/lib/components/media_page/MediaList.svelte @@ -1,9 +1,13 @@ -
+
controller.runes.media_list.paginate()} diff --git a/packages/web/src/lib/components/media_page/MediaPage.svelte b/packages/web/src/lib/components/media_page/MediaPage.svelte new file mode 100644 index 00000000..42f8d8ee --- /dev/null +++ b/packages/web/src/lib/components/media_page/MediaPage.svelte @@ -0,0 +1,36 @@ + + +
+
+
+ +
+ + +
+
+
+
+ + diff --git a/packages/web/src/routes/series/[series_id]/components/MediaView.svelte b/packages/web/src/lib/components/media_page/MediaView.svelte similarity index 97% rename from packages/web/src/routes/series/[series_id]/components/MediaView.svelte rename to packages/web/src/lib/components/media_page/MediaView.svelte index 5074d25b..30617d7d 100644 --- a/packages/web/src/routes/series/[series_id]/components/MediaView.svelte +++ b/packages/web/src/lib/components/media_page/MediaView.svelte @@ -1,8 +1,8 @@
-
+ + +
+ +
-
+
@@ -166,30 +196,37 @@ bind:value={params.filepath}>
- { - if (params.search_mode !== 'group_by') { - params.group_by = undefined - } - }} - bind:value={params.search_mode} - /> + {#if controller.is_browse} + {@const browse_params = get_browse_params()} + { + if (browse_params && browse_params.search_mode !== 'group_by') { + browse_params.group_by = undefined + } + }} + bind:value={browse_params.search_mode} + /> - {#if params.search_mode == "group_by"} -
- - -
+ {#if browse_params?.search_mode === "group_by"} +
+ + +
+ {/if} + {:else if controller.is_series} + + Series ID: {controller.series_id} + {/if}
diff --git a/packages/web/src/routes/browse/components/SearchResults.svelte b/packages/web/src/lib/components/media_page/SearchResults.svelte similarity index 77% rename from packages/web/src/routes/browse/components/SearchResults.svelte rename to packages/web/src/lib/components/media_page/SearchResults.svelte index e240fdaa..ad4a7c77 100644 --- a/packages/web/src/routes/browse/components/SearchResults.svelte +++ b/packages/web/src/lib/components/media_page/SearchResults.svelte @@ -1,5 +1,5 @@ + .container-media-tile.no-series-index { + grid-template-rows: 1fr max-content; + } +
{#each media_list.results as result, result_index}
-
+ + {#if controller.is_series && result.series_index !== undefined} +
+ #{result.series_index} +
+ {/if} + -
+
{#if result.media_type === 'media_file'} {#if result.media_file.media_type === 'VIDEO'} @@ -123,24 +139,32 @@ {:else} unknown media type {result.media_file.media_type} {/if} - {:else if result.media_type === 'media_series'} + {:else if result.media_type === 'media_series'} ({result.media_reference.media_series_length}) - {:else if result.media_type === 'grouped'} + {:else if result.media_type === 'grouped'} + {@const browse_state = get_browse_state()} - {result.group_metadata.value} - + {#if browse_state} + + {result.group_metadata.value} + + {:else} + {result.group_metadata.value} + {/if} {result.group_metadata.count} - {:else} - UNEXPECTED MEDIA TYPE {result.media_type} - {/if} -
+ {:else} + UNEXPECTED MEDIA TYPE {result.media_type} + {/if} +
diff --git a/packages/web/src/lib/components/media_page/index.ts b/packages/web/src/lib/components/media_page/index.ts new file mode 100644 index 00000000..e32b517d --- /dev/null +++ b/packages/web/src/lib/components/media_page/index.ts @@ -0,0 +1,9 @@ +export { default as MediaPage } from './MediaPage.svelte' +export { default as Header } from './Header.svelte' +export { default as Footer } from './Footer.svelte' +export { default as MediaList } from './MediaList.svelte' +export { default as MediaView } from './MediaView.svelte' +export { default as MediaDetails } from './MediaDetails.svelte' +export { default as SearchParams } from './SearchParams.svelte' +export { default as SearchResults } from './SearchResults.svelte' +export { default as SearchLink } from './SearchLink.svelte' diff --git a/packages/web/src/lib/media_page_controller.ts b/packages/web/src/lib/media_page_controller.ts new file mode 100644 index 00000000..9a88d48b --- /dev/null +++ b/packages/web/src/lib/media_page_controller.ts @@ -0,0 +1,64 @@ +import type { Config } from '$lib/server/config.ts' +import { BaseController } from '$lib/base_controller.ts' +import { + create_focuser, + SettingsRune, + MediaListRune, + MediaSelectionsRune, + create_dimensional_rune +} from '$lib/runes/index.ts' +import { MediaQueryParamsRune, type PageMode, type QueryParamsConfig } from '$lib/runes/media_queryparams.svelte.ts' + +export type { PageMode } from '$lib/runes/media_queryparams.svelte.ts' + +export interface MediaPageConfig { + mode: PageMode + series_id?: number +} + +/** + * Unified controller for media page views (browse, series, etc.). + * Configured via mode to handle different page types. + */ +export class MediaPageController extends BaseController { + public mode: PageMode + public series_id?: number + + runes: { + media_list: MediaListRune + focus: ReturnType + dimensions: ReturnType + settings: SettingsRune + media_selections: MediaSelectionsRune + queryparams: MediaQueryParamsRune + } + + public constructor(config: Config, page_config: MediaPageConfig) { + super(config) + this.mode = page_config.mode + this.series_id = page_config.series_id + + const media_list_rune = new MediaListRune(this.client) + 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 MediaQueryParamsRune(this.client, media_list_rune, { + mode: page_config.mode, + series_id: page_config.series_id, + }), + } + } + + /** Whether this is a browse page */ + get is_browse(): boolean { + return this.mode === 'browse' + } + + /** Whether this is a series page */ + get is_series(): boolean { + return this.mode === 'series' + } +} diff --git a/packages/web/src/routes/browse/runes/dimensions.svelte.ts b/packages/web/src/lib/runes/dimensions.svelte.ts similarity index 100% rename from packages/web/src/routes/browse/runes/dimensions.svelte.ts rename to packages/web/src/lib/runes/dimensions.svelte.ts diff --git a/packages/web/src/lib/runes/index.ts b/packages/web/src/lib/runes/index.ts index 480185a4..d4d380c8 100644 --- a/packages/web/src/lib/runes/index.ts +++ b/packages/web/src/lib/runes/index.ts @@ -2,3 +2,6 @@ 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 './media_selections.svelte.ts' +export * from './media_queryparams.svelte.ts' +export * from './dimensions.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..b644a716 100644 --- a/packages/web/src/lib/runes/media_list_rune.svelte.ts +++ b/packages/web/src/lib/runes/media_list_rune.svelte.ts @@ -1,12 +1,16 @@ import {Rune} from '$lib/runes/rune.ts' -import type { Forager, MediaResponse, outputs } from '@forager/core' -import { MediaViewRune } from '.' +import type { Forager, MediaResponse, SeriesSearchResponse } from '@forager/core' +import { MediaViewRune, type MediaResponseWithOptionalSeriesIndex } from '.' -type Result = +type BrowseResult = | ReturnType | ReturnType +type SeriesResult = ReturnType + +type Result = BrowseResult | SeriesResult + interface SearchInput { type: 'media' params: Parameters[0] @@ -15,6 +19,10 @@ interface GroupByInput { type: 'group_by' params: Parameters[0] } +interface SeriesInput { + type: 'series' + params: Parameters[0] +} interface FilesystemInput { type: 'filesystem' params: {} @@ -22,6 +30,7 @@ interface FilesystemInput { export type Input = | SearchInput | GroupByInput + | SeriesInput | FilesystemInput interface MediaListState { @@ -31,7 +40,7 @@ interface MediaListState { } export class MediaListRune extends Rune { - #saved_params_type: 'media' | 'group_by' = 'media' + #saved_params_type: Input['type'] = 'media' #saved_params: {} | undefined #prev_query_hash: string = '' #fetch_count = 0 @@ -47,7 +56,7 @@ export class MediaListRune extends Rune { get content(): Result | null { return this.#state.content } - get results(): Result['results'] { return this.#state.results } + get results(): MediaViewRune[] { return this.#state.results } get total() { return this.#state.content?.total ?? 0 } @@ -64,7 +73,6 @@ export class MediaListRune extends Rune { } async paginate(params?: Input) { - // params = {type: 'group_by', params: {group_by: {tag_group: 'artist'}, limit: 10}} this.#saved_params = {...this.#saved_params, ...params?.params} if (this.#fetch_count > 0 && this.#state.loading) return @@ -87,11 +95,15 @@ 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 { + } + else if (params_type === 'series') { + content = await this.client.forager.series.search(fetch_params) + } + else { throw new Error('unimplemented') } - const results = content.results.map(result => { + const results = content.results.map((result: MediaResponseWithOptionalSeriesIndex) => { return MediaViewRune.create(this.client, result, fetch_params) }) diff --git a/packages/web/src/lib/runes/media_queryparams.svelte.ts b/packages/web/src/lib/runes/media_queryparams.svelte.ts new file mode 100644 index 00000000..e5ca1a6e --- /dev/null +++ b/packages/web/src/lib/runes/media_queryparams.svelte.ts @@ -0,0 +1,327 @@ +import type { inputs } from '@forager/core' +import type { MediaListRune } from './media_list_rune.svelte.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' + +export type PageMode = 'browse' | 'series' + +interface BrowseState { + search_string: string + filepath: string | undefined + sort: inputs.PaginatedSearch['sort_by'] + unread_only: boolean + search_mode: 'media' | 'group_by' | 'filesystem' + group_by: string | undefined + stars: number | undefined + stars_equality: 'lte' | 'gte' | 'eq' | undefined + order: 'desc' | 'asc' + media_type: string +} + +interface SeriesState { + search_string: string + filepath: string | undefined + sort: 'series_index' | 'created_at' | 'updated_at' | 'source_created_at' | 'view_count' | 'last_viewed_at' + unread_only: boolean + stars: number | undefined + stars_equality: 'lte' | 'gte' | 'eq' | undefined + order: 'desc' | 'asc' + media_type: string +} + +export type State = BrowseState | SeriesState + +const BROWSE_DEFAULTS: BrowseState = { + search_string: '', + filepath: undefined, + sort: 'source_created_at', + order: 'desc', + unread_only: false, + search_mode: 'media', + group_by: undefined, + stars: undefined, + stars_equality: undefined, + media_type: 'all', +} + +const SERIES_DEFAULTS: SeriesState = { + search_string: '', + filepath: undefined, + sort: 'series_index', + order: 'asc', + unread_only: false, + stars: undefined, + stars_equality: undefined, + media_type: 'all', +} + +const NAME_MAP: Record = { + search_string: 'tags', + unread_only: 'unread', + search_mode: 'mode', + media_type: 'type', +} +const NAME_MAP_REVERSED = Object.fromEntries(Object.entries(NAME_MAP).map(([key, val]) => [val, key])) + +export interface QueryParamsConfig { + mode: PageMode + series_id?: number +} + +export class MediaQueryParamsRune extends Rune { + public mode: PageMode + public series_id?: number + public DEFAULTS: State + public current_url: State = $state({} as State) + public current_serialized: string = '?' + + private search_rune: MediaListRune + private popstate_listener_fn!: (params: State) => void + + public constructor(client: BaseController['client'], search_rune: MediaListRune, config: QueryParamsConfig) { + super(client) + this.search_rune = search_rune + this.mode = config.mode + this.series_id = config.series_id + this.DEFAULTS = config.mode === 'browse' ? BROWSE_DEFAULTS : SERIES_DEFAULTS + this.current_url = {...this.DEFAULTS} + + onMount(async () => { + const params = this.read(window.location) + this.popstate_listener_fn(params) + await this.submit_internal(params) + + window.addEventListener('popstate', async e => { + const params = this.read(window.location) + this.popstate_listener_fn(params) + await this.submit_internal(params) + }) + }) + } + + public read(url: URL): State { + const params: State = {...this.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[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 + } + + // re-add inferred keys for browse mode + if (this.mode === 'browse' && queryparams.has('group_by')) { + ;(params as BrowseState).search_mode = 'group_by' + } + + this.current_url = {...params} + return params + } + + 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 serialize(params: State): string { + const queryparams = new Map() + for (const [key, value] of Object.entries(params)) { + if (value !== (this.DEFAULTS as any)[key]) { + const queryparam_key = NAME_MAP[key] ?? key + let serialized_value = value + if (key === 'search_string') { + serialized_value = encodeURIComponent(value.replaceAll(/\s/g, ',')) + .replaceAll('%3A', ':') + .replaceAll('%2C', ',') + } else if (key === 'filepath') { + serialized_value = encodeURIComponent(value) + } + queryparams.set(queryparam_key, serialized_value) + } + } + + // strip out inferred keys for browse mode + if (this.mode === 'browse' && ['group_by', 'media'].includes(queryparams.get('mode'))) { + queryparams.delete('mode') + } + + const serialized_params = '?' + Array.from(queryparams.entries()) + .map(([key, val]) => `${key}=${val}`) + .join('&') + + return serialized_params + } + + public async goto(params: State) { + this.popstate_listener_fn(params) + await this.submit(params) + } + + public merge(partial_params: Record): State { + const params: State = {...this.current_url} + for (const [key, val] of Object.entries(partial_params)) { + const params_key = NAME_MAP_REVERSED[key] ?? key + + if (params_key === 'search_string') { + const search_strings = new Set((params as any).search_string.split(/\s+/)) + search_strings.add(val) + ;(params as any).search_string = [...search_strings].join(' ').trim() + } else if (params_key === 'search_mode' && this.mode === 'browse') { + ;(params as BrowseState).search_mode = val + if (val !== 'group_by') { + ;(params as BrowseState).group_by = undefined + } + } else { + ;(params as any)[params_key] = val + } + } + return params + } + + public extend(key: 'tag' | 'group_by_tag', value: string): State { + const params: State = {...this.current_url} + + // group_by_tag means we want to do a normal search including the group by tag + if (key === 'group_by_tag' && this.mode === 'browse') { + const browse_params = params as BrowseState + if (browse_params.search_mode !== 'group_by') { + throw new Error('unexpected code path. "group_by_tag" should only be used with search_mode "group_by"') + } + value = parsers.Tag.encode({group: browse_params.group_by, name: value}) + key = 'tag' + } + + if (key === 'tag') { + const search_strings = new Set((params as any).search_string.split(/\s+/)) + search_strings.add(value) + ;(params as any).search_string = [...search_strings].join(' ').trim() + return params + } else { + throw new Error('unimplemented') + } + } + + public async submit(params: State) { + this.write_url(params) + await this.submit_internal(params) + } + + private async submit_internal(params: State) { + const stars = params.stars + const stars_equality = params.stars_equality + 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() + + if (this.mode === 'series') { + // Series mode - use series.search + const query: inputs.SeriesSearch['query'] = { + series_id: this.series_id!, + tags: tags.map(t => parsers.Tag.decode(t)), + filepath, + } + + if (params.unread_only === true || params.unread_only === 'true' as any) { + query.unread = true + } + + if (stars !== undefined) { + query.stars = typeof stars === 'string' ? parseInt(stars) : stars + query.stars_equality = stars_equality ?? 'gte' + } + + if (params.media_type === 'animated') { + query.animated = true + } + + await this.search_rune.paginate({ + type: 'series', + params: { query, sort_by: sort_by as any, order } + }) + } else { + // Browse mode - use media.search or media.search_grouped + const browse_params = params as BrowseState + const query: inputs.PaginatedSearch['query'] = { tags, filepath } + + if (browse_params.unread_only === true || browse_params.unread_only === 'true' as any) { + query.unread = true + } + + if (stars !== undefined) { + query.stars = typeof stars === 'string' ? parseInt(stars) : stars + query.stars_equality = stars_equality ?? 'gte' + } + + if (browse_params.media_type === 'animated') { + query.animated = true + } + + if (browse_params.search_mode === 'media') { + await this.search_rune.paginate({ + type: 'media', + params: { query, sort_by: sort_by as any, order } + }) + } else if (browse_params.search_mode === 'group_by') { + await this.search_rune.paginate({ + type: 'group_by', + params: { + group_by: { tag_group: browse_params.group_by ?? '' }, + query, + sort_by: sort_by as any, + order + } + }) + } else { + throw new Error('unimplemented search mode') + } + } + } + + public popstate_listener(fn: (params: State) => void) { + this.popstate_listener_fn = fn + } + + public get human_readable_summary(): string { + if (this.mode === 'series') { + let summary = `Series #${this.series_id}` + if (this.current_url.search_string) { + summary += ` - ${this.current_url.search_string}` + } + return summary + } else { + return this.current_url.search_string || '' + } + } + + // Browse-specific getters + public get is_browse(): boolean { + return this.mode === 'browse' + } + + public get is_series(): boolean { + return this.mode === 'series' + } + + public get browse_state(): BrowseState | null { + return this.mode === 'browse' ? this.current_url as BrowseState : null + } +} diff --git a/packages/web/src/routes/browse/runes/media_selections.svelte.ts b/packages/web/src/lib/runes/media_selections.svelte.ts similarity index 89% rename from packages/web/src/routes/browse/runes/media_selections.svelte.ts rename to packages/web/src/lib/runes/media_selections.svelte.ts index 71687f60..67d2a351 100644 --- a/packages/web/src/routes/browse/runes/media_selections.svelte.ts +++ b/packages/web/src/lib/runes/media_selections.svelte.ts @@ -1,6 +1,6 @@ import {Rune} from '$lib/runes/rune.ts' -import type { Forager, MediaResponse } from '@forager/core' -import type * as runes from '$lib/runes/index.ts' +import type { MediaViewRune, MediaViewRunes } from './media_view_rune.svelte.ts' +import type { MediaListRune } from './media_list_rune.svelte.ts' import type { BaseController } from '$lib/base_controller.ts' interface SelectIndividual { @@ -26,16 +26,16 @@ export type ThumbnailSelections = export interface CurrentSelection { show: boolean - media_response: runes.MediaViewRunes | null + media_response: MediaViewRunes | null result_index: number } const CURRENT_SELECTION_DEFAULTS: CurrentSelection = { show: false, - thumbnails: null, media_response: null, result_index: 0, } + export class MediaSelectionsRune extends Rune { #selected_thumbnails = $state({type: 'none'}) #current_selection = $state({...CURRENT_SELECTION_DEFAULTS}) @@ -60,7 +60,7 @@ export class MediaSelectionsRune extends Rune { return false } - public async set_current_selection(media_response: runes.MediaViewRune, result_index: number) { + public async set_current_selection(media_response: MediaViewRune, result_index: number) { this.#current_selection.show if (this.is_currently_selected(media_response.media_reference.id)) { await this.open_media() @@ -92,7 +92,7 @@ export class MediaSelectionsRune extends Rune { this.#current_selection.show = false } - public async next_media(results: MediaResponse[]) { + public async next_media(results: MediaViewRune[]) { let next_index = 0 if ( this.#selected_thumbnails.type === 'ids' && this.#selected_thumbnails.media_reference_ids.length <= 1 @@ -109,7 +109,7 @@ export class MediaSelectionsRune extends Rune { await this.view_media() } - async prev_media(results: MediaResponse[]) { + async prev_media(results: MediaViewRune[]) { let prev_index = 0 if ( this.#selected_thumbnails.type === 'ids' && this.#selected_thumbnails.media_reference_ids.length <= 1 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..613911d8 100644 --- a/packages/web/src/lib/runes/media_view_rune.svelte.ts +++ b/packages/web/src/lib/runes/media_view_rune.svelte.ts @@ -1,11 +1,14 @@ 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' +/** Union type for responses that can include series_index */ +export type MediaResponseWithOptionalSeriesIndex = MediaResponse | SeriesSearchResponse interface State { - media: MediaResponse + media: MediaResponseWithOptionalSeriesIndex full_thumbnails: MediaResponse['thumbnails'] | undefined + series_index?: number } @@ -16,11 +19,12 @@ export class MediaViewRune extends Rune { state = $state() current_view: model_types.View - protected constructor(client: BaseController['client'], media_response: MediaResponse) { + protected constructor(client: BaseController['client'], media_response: MediaResponseWithOptionalSeriesIndex, series_index?: number) { super(client) this.state = { media: media_response, - full_thumbnails: undefined + full_thumbnails: undefined, + series_index: series_index, } } @@ -28,7 +32,7 @@ export class MediaViewRune extends Rune { return this.state!.media } - set media(media: MediaResponse) { + set media(media: MediaResponseWithOptionalSeriesIndex) { return this.state!.media = media } @@ -44,6 +48,11 @@ export class MediaViewRune extends Rune { return this.media.media_file } + /** Returns series_index if this item is part of a series search, undefined otherwise */ + get series_index(): number | undefined { + return this.state?.series_index + } + get preview_thumbnail() { return this.media.thumbnails.results[0] } @@ -61,7 +70,7 @@ export class MediaViewRune extends Rune { this.media_reference.id, {stars} ) - this.media = updated + this.state!.media = {...this.state!.media, media_reference: updated.media_reference, tags: updated.tags} } public async load_detailed_view() { @@ -75,11 +84,14 @@ 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: MediaResponseWithOptionalSeriesIndex, search_params: SearchParams): MediaViewRune { + // Extract series_index if present (from SeriesSearchResponse) + const series_index = 'series_index' in media_response ? media_response.series_index : undefined + if (media_response.media_type === 'media_file') { - return new MediaFileRune(client, media_response) + return new MediaFileRune(client, media_response, series_index) } else if (media_response.media_type === 'media_series') { - return new MediaSeriesRune(client, media_response) + return new MediaSeriesRune(client, media_response, series_index) } else if (media_response.media_type === 'grouped') { return new MediaGroupRune(client, media_response, search_params) } else { @@ -103,7 +115,7 @@ export class MediaFileRune extends MediaViewRune { media_info, tags ) - this.media = updated + this.state!.media = {...this.state!.media, media_reference: updated.media_reference, tags: updated.tags} } public override async load_detailed_view() { @@ -129,10 +141,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() { @@ -152,7 +162,6 @@ export class MediaGroupRune extends MediaViewRune { }) protected constructor(client: BaseController['client'], media_response: MediaResponse, search_params: SearchParams) { - // debugger super(client, media_response) const {group_by, cursor, ...merged_search_params} = search_params @@ -202,4 +211,4 @@ export class MediaGroupRune extends MediaViewRune { } } -export type MediaViewRunes = MediaSeriesRune | MediaFileRune +export type MediaViewRunes = MediaSeriesRune | MediaFileRune | MediaGroupRune diff --git a/packages/web/src/routes/browse/+page.svelte b/packages/web/src/routes/browse/+page.svelte index 011da575..6b973835 100644 --- a/packages/web/src/routes/browse/+page.svelte +++ b/packages/web/src/routes/browse/+page.svelte @@ -1,34 +1,11 @@ -
-
-
- -
- - -
-
-
-
- - + diff --git a/packages/web/src/routes/browse/components/Header.svelte b/packages/web/src/routes/browse/components/Header.svelte deleted file mode 100644 index b6a7f44a..00000000 --- a/packages/web/src/routes/browse/components/Header.svelte +++ /dev/null @@ -1,17 +0,0 @@ - - -
- {queryparams.human_readable_summary || 'Forager'} - -
diff --git a/packages/web/src/routes/browse/components/MediaDetails.svelte b/packages/web/src/routes/browse/components/MediaDetails.svelte deleted file mode 100644 index 4645842b..00000000 --- a/packages/web/src/routes/browse/components/MediaDetails.svelte +++ /dev/null @@ -1,174 +0,0 @@ - - - - { - e.preventDefault() - let media_info = undefined - let tags: {add: string[]} | undefined - if (new_tag_str) { - tags = {add: [new_tag_str]} - } - media_selections.current_selection.media_response!.update(media_info, tags) - new_tag_str = '' - }} - > - {#if media_selections.current_selection.media_response} - - - - - - {#each sorted_tags as tag_group_entry, tag_entry_index (tag_group_entry[0])} - {#each tag_group_entry[1] as tag, tag_index (tag.id)} -
- - - - -
- {/each} - {/each} - - - - - - - - - - - - - - - - - {#if media_selections.current_selection.media_response.media_type === 'media_file'} - - - - {/if} - - {:else} - TODO: show tag summary - {/if} - -
diff --git a/packages/web/src/routes/browse/components/MediaList.svelte b/packages/web/src/routes/browse/components/MediaList.svelte deleted file mode 100644 index f2a08670..00000000 --- a/packages/web/src/routes/browse/components/MediaList.svelte +++ /dev/null @@ -1,59 +0,0 @@ - - -
- 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/browse/components/MediaView.svelte b/packages/web/src/routes/browse/components/MediaView.svelte deleted file mode 100644 index bed423a0..00000000 --- a/packages/web/src/routes/browse/components/MediaView.svelte +++ /dev/null @@ -1,158 +0,0 @@ - - - - -
- {#if media_selections.current_selection.show && media_selections.current_selection.media_response} - {#if media_selections.current_selection.media_response.media_type === 'media_file'} - {#if media_selections.current_selection.media_response.media_file.media_type === 'IMAGE'} - - {:else if media_selections.current_selection.media_response.media_file.media_type === 'VIDEO'} - - - {#if controller.runes.settings.ui.media_view.filmstrip.enabled} -
- {#each media_selections.current_selection.thumbnails.results as thumbnail} -
-
- {/each} -
- {/if} - {:else if media_selections.current_selection.media_response.media_file.media_type === 'AUDIO'} - - - {/if} - {:else} - unhandled media type {media_selections.current_selection.media_response.media_type} - {/if} - {/if} -
-
- - diff --git a/packages/web/src/routes/browse/controller.ts b/packages/web/src/routes/browse/controller.ts deleted file mode 100644 index d1d3e0f2..00000000 --- a/packages/web/src/routes/browse/controller.ts +++ /dev/null @@ -1,34 +0,0 @@ -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 './runes/dimensions.svelte.ts' -import { MediaSelectionsRune } from './runes/media_selections.svelte.ts' -import { QueryParamsRune } from './runes/queryparams.svelte.ts' - - -class BrowseController extends BaseController { - runes: { - media_list: MediaListRune - focus: ReturnType - dimensions: ReturnType - settings: SettingsRune - media_selections: MediaSelectionsRune - queryparams: QueryParams - } - - public constructor(config: Config) { - super(config) - - 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 QueryParamsRune(this.client, media_list_rune), - } - } -} - -export { BrowseController } diff --git a/packages/web/src/routes/browse/runes/queryparams.svelte.ts b/packages/web/src/routes/browse/runes/queryparams.svelte.ts deleted file mode 100644 index c2bdbc63..00000000 --- a/packages/web/src/routes/browse/runes/queryparams.svelte.ts +++ /dev/null @@ -1,328 +0,0 @@ -import type { inputs } from '@forager/core' -import type { BrowseController } from '../controller.ts' -import type { MediaListRune } from '$lib/runes/index.ts' -import * as parsers from '$lib/parsers.ts' -import {onMount} from 'svelte' -import { pushState } from '$app/navigation'; -import { page } from '$app/state'; -import {Rune} from '$lib/runes/rune.ts' - -interface State { - search_string: string - filepath: string | undefined - sort: inputs.PaginatedSearch['sort_by'] - unread_only: boolean - search_mode: 'media' | 'group_by' | 'filesystem' - group_by: string | undefined - 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: 'source_created_at' as const, - order: 'desc' as const, - unread_only: false, - search_mode: 'media', - group_by: undefined, - stars: undefined, - stars_equality: undefined, - media_type: 'all', -} - -const NAME_MAP: Partial> = { - search_string: 'tags', - unread_only: 'unread', - search_mode: 'mode', - media_type: 'type', -} -const NAME_MAP_REVERSED = Object.fromEntries(Object.entries(NAME_MAP).map(([key, val]) => [val, key])) -export class QueryParamsRune extends Rune { - public DEFAULTS = DEFAULTS - public current_url: State = $state({...DEFAULTS}) - public current_serialized: string = '?' - - private search_rune: MediaListRune - private popstate_listener_fn!: (params: State) => void - - public constructor(client: BrowseController['client'], search_rune: MediaListRune) { - super(client) - this.search_rune = search_rune - - onMount(async () => { - const params = this.read(window.location) - this.popstate_listener_fn(params) - await this.submit_internal(params) - - window.addEventListener('popstate', async e => { - 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[key] ?? key - let deserialized_value = 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[params_key] = deserialized_value - } - - // if (params.stars === undefined && params.stars_equality !== undefined) { - // delete params.stars_equality - // } - // re-add inferred keys - if (queryparams.has('group_by')) { - console.log('re-adding group by params', params) - params['search_mode'] = 'group_by' - } - this.current_url = {...params} - return params - } - - 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 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 !== DEFAULTS[key]) { - const queryparam_key = NAME_MAP[key] ?? key - let serialized_value = value - if (key === 'search_string') { - serialized_value = encodeURIComponent(value.replaceAll(/\s/g, ',')) - .replaceAll('%3A', ':') - .replaceAll('%2C', ',') - } else if (key === 'filepath') { - serialized_value = encodeURIComponent(value) - } - queryparams.set(queryparam_key, serialized_value) - } - } - // strip out inferred keys - if (['group_by', 'media'].includes(queryparams.get('mode'))) { - queryparams.delete('mode') - } - - const serialized_params = '?' + Array.from(queryparams.entries()) - .map(([key, val]) => `${key}=${val}`) - .join('&') - - return serialized_params - } - - public async goto(params: State) { - this.popstate_listener_fn(params) - await this.submit(params) - } - - public merge(partial_params: Partial) { - const params = {...this.current_url} - for (const [key, val] of Object.entries(partial_params)) { - const params_key: keyof State = NAME_MAP_REVERSED[key] ?? key - - if (params_key === 'search_string') { - 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') { - params[params_key] = val - if (val !== 'group_by') { - params.group_by = undefined - } - }else { - params[params_key] = val - } - } - return params - } - - public extend(key: 'tag' | 'group_by_tag', value: string): State { - const params = {...this.current_url} - - // group_by_tag means we want to do a normal search including the group by tag - if (key === 'group_by_tag') { - if (params.search_mode !== 'group_by') { - throw new Error('unexpected code path. "group_by_tag" should only be used with search_mode "group_by"') - } - value = parsers.Tag.encode({group: params.group_by, name: value}) - key = 'tag' - } - - 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') - } - } - - public async submit(params: State) { - this.write_url(params) - await this.submit_internal(params) - } - - private async submit_internal(params: State) { - - const stars = params.stars - const stars_equality = params.stars_equality - 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.PaginatedSearch['query'] = { - tags, - filepath, - } - - if (params.unread_only) { - if (params.unread_only === 'true' || params.unread_only === true) { - query.unread = true - } - } - - if (stars !== undefined) { - query.stars = parseInt(stars) - query.stars_equality = stars_equality ?? 'gte' - } - - switch(params.media_type) { - case 'all': { - // do nothing - break - } - case 'animated': { - query.animated = true - break - } - default: { - throw new Error(`Unimplemented media type ${params.media_type}`) - } - } - - if (params.search_mode === 'media') { - await this.search_rune.paginate({ - type: params.search_mode, - params: { - query: query, - sort_by, - order - } - }) - } else if (params.search_mode === 'group_by') { - let group_by = params.group_by ?? '' - await this.search_rune.paginate({ - type: params.search_mode, - params: { - group_by: { - tag_group: group_by, - }, - query: query, - sort_by, - order - } - }) - } else { - throw new Error('unimplemented') - } - } - - public search_input(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 - - const query: inputs.PaginatedSearch['query'] = { - tags, - filepath, - unread: params.unread_only, - } - switch(params.media_type) { - case 'all': { - // do nothing - break - } - case 'animated': { - query.animated = true - break - } - default: { - throw new Error(`Unimplemented media type ${params.media_type}`) - } - } - - if (params.search_mode === 'media') { - return { - type: params.search_mode, - params: { - query: query, - sort_by, - order - } - } - } else if (params.search_mode === 'group_by') { - if (params.group_by === undefined) { - throw new Error(`params must be defined!`) - } - return { - type: params.search_mode, - params: { - group_by: { - tag_group: params.group_by, - }, - query: query, - sort_by: 'count', // TODO we want to support created_at as well. Sorting is a bit janky with group by for now - order - } - } - } else { - throw new Error('unimplemented') - } - } - - public get contextual_query() { - const params = this.search_input(this.current_url) - return params.params.query - } - - public popstate_listener(fn: (params: State) => void) { - this.popstate_listener_fn = fn - } - - public get human_readable_summary() { - let summary = '' - if (this.current_url.search_string) { - summary += this.current_url.search_string - } - - return summary - } -} diff --git a/packages/web/src/routes/series/[series_id]/+page.svelte b/packages/web/src/routes/series/[series_id]/+page.svelte index 58a3f69a..3f09c207 100644 --- a/packages/web/src/routes/series/[series_id]/+page.svelte +++ b/packages/web/src/routes/series/[series_id]/+page.svelte @@ -1,35 +1,12 @@ -
-
-
- -
- - -
-
-
-
- - + diff --git a/packages/web/src/routes/series/[series_id]/components/Footer.svelte b/packages/web/src/routes/series/[series_id]/components/Footer.svelte deleted file mode 100644 index eb4d73d2..00000000 --- a/packages/web/src/routes/series/[series_id]/components/Footer.svelte +++ /dev/null @@ -1,91 +0,0 @@ - - - - -
- -
-
- - { - runes.settings.set('ui.media_list.thumbnail_size', e.target.value) - }}> - {runes.settings.ui.media_list.thumbnail_size}px -
- -
- -
- - - Series items: {human_readable_number(runes.media_list.total)} - -
-
diff --git a/packages/web/src/routes/series/[series_id]/components/Header.svelte b/packages/web/src/routes/series/[series_id]/components/Header.svelte deleted file mode 100644 index 2fff7d42..00000000 --- a/packages/web/src/routes/series/[series_id]/components/Header.svelte +++ /dev/null @@ -1,16 +0,0 @@ - - -
- {queryparams.human_readable_summary || 'Forager - Series'} - -
diff --git a/packages/web/src/routes/series/[series_id]/components/SearchResults.svelte b/packages/web/src/routes/series/[series_id]/components/SearchResults.svelte deleted file mode 100644 index 9e57d5ac..00000000 --- a/packages/web/src/routes/series/[series_id]/components/SearchResults.svelte +++ /dev/null @@ -1,149 +0,0 @@ - - - - - - -
- {#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'} - /files/thumbnail{result.preview_thumbnail.filepath} - {:else} - /files/thumbnail{result.preview_thumbnail.filepath} - {/if} -
- - -
- #{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} -
-
-
-
- {/each} -
-{#if media_list.loading} - Loading... -{/if} diff --git a/packages/web/src/routes/series/[series_id]/components/SeriesSearchParams.svelte b/packages/web/src/routes/series/[series_id]/components/SeriesSearchParams.svelte deleted file mode 100644 index bc756f3c..00000000 --- a/packages/web/src/routes/series/[series_id]/components/SeriesSearchParams.svelte +++ /dev/null @@ -1,170 +0,0 @@ - - -
{ - e.preventDefault() - await update_search() - }}> -
-
- ← Browse - - -
- -
-
-
- - -
- - - - - -
- - - - - - -
- -
- -
-
- - -
- - - Series ID: {controller.series_id} - -
-
-
- -
diff --git a/packages/web/src/routes/series/[series_id]/controller.ts b/packages/web/src/routes/series/[series_id]/controller.ts deleted file mode 100644 index d84f61a7..00000000 --- a/packages/web/src/routes/series/[series_id]/controller.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { Config } from '$lib/server/config.ts' -import {BaseController} from '$lib/base_controller.ts' -import {create_focuser, SettingsRune } from '$lib/runes/index.ts' -import { create_dimensional_rune } from '../../browse/runes/dimensions.svelte.ts' -import { SeriesMediaSelectionsRune } from './runes/series_media_selections.svelte.ts' -import { SeriesQueryParamsRune } from './runes/series_queryparams.svelte.ts' -import { SeriesMediaListRune } from './runes/series_media_list.svelte.ts' - - -class SeriesController extends BaseController { - series_id: number - - runes: { - media_list: SeriesMediaListRune - focus: ReturnType - dimensions: ReturnType - settings: SettingsRune - media_selections: SeriesMediaSelectionsRune - queryparams: SeriesQueryParamsRune - } - - public constructor(config: Config, series_id: number) { - super(config) - this.series_id = series_id - - const media_list_rune = new SeriesMediaListRune(this.client) - this.runes = { - media_list: media_list_rune, - focus: create_focuser(), - dimensions: create_dimensional_rune(), - settings: new SettingsRune(this.client, this.config), - media_selections: new SeriesMediaSelectionsRune(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/series_media_list.svelte.ts b/packages/web/src/routes/series/[series_id]/runes/series_media_list.svelte.ts deleted file mode 100644 index 13ae25f0..00000000 --- a/packages/web/src/routes/series/[series_id]/runes/series_media_list.svelte.ts +++ /dev/null @@ -1,83 +0,0 @@ -import {Rune} from '$lib/runes/rune.ts' -import type { Forager, SeriesSearchResponse } from '@forager/core' -import { SeriesMediaViewRune } from './series_media_view.svelte.ts' - - -type Result = ReturnType - -interface SearchInput { - type: 'series' - params: Parameters[0] -} - -export type Input = SearchInput - -interface SeriesMediaListState { - loading: boolean - content: Result | null - results: SeriesMediaViewRune[] -} - -export class SeriesMediaListRune extends Rune { - #saved_params: {} | undefined - #prev_query_hash: string = '' - #fetch_count = 0 - #has_more = true - #cursor: Result['cursor'] = undefined - #state = $state({ - loading: true, - content: null, - results: [] - }) - - get loading() { return this.#state.loading } - - get content(): Result | null { return this.#state.content } - - get results(): SeriesMediaViewRune[] { return this.#state.results } - - get total() { return this.#state.content?.total ?? 0 } - - clear() { - this.#has_more = true - this.#cursor = undefined - this.#fetch_count = 0 - this.#saved_params = undefined - this.#state = { - loading: false, - results: [], - content: null - } - } - - async paginate(params?: Input) { - this.#saved_params = {...this.#saved_params, ...params?.params} - if (this.#fetch_count > 0 && this.#state.loading) return - - if (!this.#has_more) return - - this.#fetch_count ++ - this.#state.loading = true - - let fetch_params: Input['params'] | undefined = this.#saved_params - if (this.#cursor !== undefined) { - fetch_params = {...fetch_params, cursor: this.#cursor} as any - } - - const content = await this.client.forager.series.search(fetch_params) - - const results = content.results.map(result => { - return SeriesMediaViewRune.create(this.client, result) - }) - - this.#cursor = content.cursor - if (!this.#cursor) { - this.#has_more = false - } - this.#state = { - content: content, - results: this.#state.results.concat(results), - loading: false, - } - } -} diff --git a/packages/web/src/routes/series/[series_id]/runes/series_media_selections.svelte.ts b/packages/web/src/routes/series/[series_id]/runes/series_media_selections.svelte.ts deleted file mode 100644 index 2006fe14..00000000 --- a/packages/web/src/routes/series/[series_id]/runes/series_media_selections.svelte.ts +++ /dev/null @@ -1,131 +0,0 @@ -import {Rune} from '$lib/runes/rune.ts' -import type { SeriesSearchResponse } from '@forager/core' -import type { SeriesMediaViewRune } from './series_media_view.svelte.ts' -import type { BaseController } from '$lib/base_controller.ts' -import type { SeriesMediaListRune } from './series_media_list.svelte.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: SeriesMediaViewRune | null - result_index: number -} - -const CURRENT_SELECTION_DEFAULTS: CurrentSelection = { - show: false, - media_response: null, - result_index: 0, -} - -export class SeriesMediaSelectionsRune extends Rune { - #selected_thumbnails = $state({type: 'none'}) - #current_selection = $state({...CURRENT_SELECTION_DEFAULTS}) - - public constructor(client: BaseController['client'], media_list_rune: SeriesMediaListRune) { - 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) { - if (this.#current_selection.media_response?.media_reference.id === media_reference_id) { - return true - } - return false - } - - public async set_current_selection(media_response: SeriesMediaViewRune, result_index: number) { - this.#current_selection.show - 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: SeriesMediaViewRune[]) { - 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] - this.#current_selection.result_index = next_index - - await this.view_media() - } - - async prev_media(results: SeriesMediaViewRune[]) { - 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] - this.#current_selection.result_index = prev_index - - await this.view_media() - } -} diff --git a/packages/web/src/routes/series/[series_id]/runes/series_media_view.svelte.ts b/packages/web/src/routes/series/[series_id]/runes/series_media_view.svelte.ts deleted file mode 100644 index 873e5832..00000000 --- a/packages/web/src/routes/series/[series_id]/runes/series_media_view.svelte.ts +++ /dev/null @@ -1,136 +0,0 @@ -import {Rune} from '$lib/runes/rune.ts' -import type { BaseController } from '$lib/base_controller.ts' -import type { SeriesSearchResponse, inputs, type model_types } from '@forager/core' - - -interface State { - media: SeriesSearchResponse - full_thumbnails: SeriesSearchResponse['thumbnails'] | undefined -} - - -export class SeriesMediaViewRune extends Rune { - media_type!: SeriesSearchResponse['media_type'] - state = $state() - current_view: model_types.View - - protected constructor(client: BaseController['client'], media_response: SeriesSearchResponse) { - super(client) - this.state = { - media: media_response, - full_thumbnails: undefined - } - } - - get media() { - return this.state!.media - } - - set media(media: SeriesSearchResponse) { - return this.state!.media = media - } - - get tags() { - return this.media.tags - } - - get media_reference() { - return this.media.media_reference - } - - get media_file() { - return this.media.media_file - } - - get series_index(): number { - return this.media.series_index - } - - get preview_thumbnail() { - return this.media.thumbnails.results[0] - } - - get thumbnails() { - return this.media.thumbnails - } - - public update(media_info: inputs.MediaInfo, tags: inputs.MediaReferenceUpdateTags) { - throw new Error('requires override') - } - - public async star(stars: number) { - const updated = await this.client.forager.media.update( - this.media_reference.id, - {stars} - ) - this.state!.media = {...this.state!.media, media_reference: updated.media_reference, tags: updated.tags} - } - - public async load_detailed_view() { - throw new Error('requires override') - } - - public async add_view() { - const view_response = await this.client.forager.views.start({media_reference_id: this.media_reference.id }) - this.current_view = view_response.view - this.state.media.media_reference.view_count = view_response.media_reference.view_count - } - - static create(client: BaseController['client'], media_response: SeriesSearchResponse): SeriesMediaViewRune { - if (media_response.media_type === 'media_file') { - return new SeriesMediaFileRune(client, media_response) - } else if (media_response.media_type === 'media_series') { - return new SeriesMediaSeriesRune(client, media_response) - } else { - throw new Error(`Unexpected media_response ${JSON.stringify(media_response)}`) - } - } - - public img_fit_classes() { - throw new Error('requires override') - } -} - - -export class SeriesMediaFileRune extends SeriesMediaViewRune { - media_type = 'media_file' as const satisfies SeriesSearchResponse['media_type'] - - public override async update(media_info: inputs.MediaInfo, tags: inputs.MediaReferenceUpdateTags) { - const updated = await this.client.forager.media.update( - this.media_reference.id, - media_info, - tags - ) - this.state!.media = {...this.state!.media, media_reference: updated.media_reference, tags: updated.tags} - } - - public override async load_detailed_view() { - if (this.state!.full_thumbnails) return - const result = await this.client.forager.media.get({media_reference_id: this.media_reference.id }) - this.state!.full_thumbnails = result.thumbnails - } - - public img_fit_classes() { - if (this.media.media_file.width > this.media.media_file.height) { - return "w-full" - } else { - return "h-full" - } - } -} - - -export class SeriesMediaSeriesRune extends SeriesMediaViewRune { - media_type = 'media_series' as const satisfies SeriesSearchResponse['media_type'] - - public override async load_detailed_view() { - if (this.state!.full_thumbnails) return - const series = await this.client.forager.series.get({series_id: this.media_reference.id }) - this.state!.full_thumbnails = series.thumbnails - } - - public img_fit_classes() { - console.warn(`media series img fit functions are not implemented`) - return "w-full" - } -} diff --git a/packages/web/src/routes/series/[series_id]/runes/series_queryparams.svelte.ts b/packages/web/src/routes/series/[series_id]/runes/series_queryparams.svelte.ts deleted file mode 100644 index baae6e8a..00000000 --- a/packages/web/src/routes/series/[series_id]/runes/series_queryparams.svelte.ts +++ /dev/null @@ -1,221 +0,0 @@ -import type { inputs } from '@forager/core' -import type { SeriesController } from '../controller.ts' -import type { SeriesMediaListRune } from './series_media_list.svelte.ts' -import * as parsers from '$lib/parsers.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: 'series_index' | 'created_at' | 'updated_at' | 'source_created_at' | 'view_count' | 'last_viewed_at' - 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 = '?' - public series_id: number - - private search_rune: SeriesMediaListRune - private popstate_listener_fn!: (params: State) => void - - public constructor(client: SeriesController['client'], search_rune: SeriesMediaListRune, 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 e => { - 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[key] ?? key - let deserialized_value = 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[params_key] = deserialized_value - } - - this.current_url = {...params} - return params - } - - 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 serialize(params: State) { - const queryparams = new Map() - for (const [key, value] of Object.entries(params)) { - if (value !== DEFAULTS[key]) { - const queryparam_key = NAME_MAP[key] ?? key - let serialized_value = value - if (key === 'search_string') { - serialized_value = encodeURIComponent(value.replaceAll(/\s/g, ',')) - .replaceAll('%3A', ':') - .replaceAll('%2C', ',') - } else if (key === 'filepath') { - serialized_value = encodeURIComponent(value) - } - queryparams.set(queryparam_key, serialized_value) - } - } - - const serialized_params = '?' + Array.from(queryparams.entries()) - .map(([key, val]) => `${key}=${val}`) - .join('&') - - return serialized_params - } - - public async goto(params: State) { - this.popstate_listener_fn(params) - await this.submit(params) - } - - public merge(partial_params: Partial) { - const params = {...this.current_url} - for (const [key, val] of Object.entries(partial_params)) { - const params_key: keyof State = NAME_MAP_REVERSED[key] ?? key - - if (params_key === 'search_string') { - const search_strings = new Set(params['search_string'].split(/\s+/)) - search_strings.add(val) - params.search_string = [...search_strings].join(' ').trim() - } else { - params[params_key] = val - } - } - return params - } - - public extend(key: 'tag', value: string): State { - const params = {...this.current_url} - - 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') - } - } - - public async submit(params: State) { - this.write_url(params) - await this.submit_internal(params) - } - - private async submit_internal(params: State) { - const stars = params.stars - const stars_equality = params.stars_equality - 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: tags.map(t => parsers.Tag.decode(t)), - filepath, - } - - if (params.unread_only) { - if (params.unread_only === 'true' || params.unread_only === true) { - query.unread = true - } - } - - if (stars !== undefined) { - query.stars = parseInt(stars) - query.stars_equality = stars_equality ?? '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: 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 - } -}