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/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/browse/components/MediaDetails.svelte b/packages/web/src/lib/components/media_page/MediaDetails.svelte similarity index 86% rename from packages/web/src/routes/browse/components/MediaDetails.svelte rename to packages/web/src/lib/components/media_page/MediaDetails.svelte index 4645842b..4a4af788 100644 --- a/packages/web/src/routes/browse/components/MediaDetails.svelte +++ b/packages/web/src/lib/components/media_page/MediaDetails.svelte @@ -2,17 +2,21 @@ import * as path from '@std/path' import type { Forager } from '@forager/core' import Sidebar from '$lib/components/Sidebar.svelte' - import MediaDetailEntry from './MediaDetailEntry.svelte' + import MediaDetailEntry from '$lib/components/MediaDetailEntry.svelte' import TagAutoCompleteInput from '$lib/components/TagAutoCompleteInput.svelte' - import SearchLink from './SearchLink.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 { BrowseController } from '../controller.ts' - let {controller}: {controller: BrowseController} = $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('') @@ -74,6 +78,14 @@ }} > {#if media_selections.current_selection.media_response} + + {#if controller.is_series && media_selections.current_selection.media_response.series_index !== undefined} + + {/if} + - + - + - +
@@ -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 75% rename from packages/web/src/routes/browse/components/SearchResults.svelte rename to packages/web/src/lib/components/media_page/SearchResults.svelte index 5d481749..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,20 +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'} - - {result.group_metadata.value} - + title="View series" + >({result.media_reference.media_series_length}) + {:else if result.media_type === 'grouped'} + {@const browse_state = get_browse_state()} + + {#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/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/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/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 new file mode 100644 index 00000000..3f09c207 --- /dev/null +++ b/packages/web/src/routes/series/[series_id]/+page.svelte @@ -0,0 +1,12 @@ + + +