diff --git a/packages/web/src/routes/browse/components/SearchResults.svelte b/packages/web/src/routes/browse/components/SearchResults.svelte index 5d481749..9d2d5840 100644 --- a/packages/web/src/routes/browse/components/SearchResults.svelte +++ b/packages/web/src/routes/browse/components/SearchResults.svelte @@ -125,7 +125,9 @@ {/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 SearchParams from './components/SearchParams.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 controller = new SeriesController(props.data.config, props.data.series_id) + let { dimensions, focus, queryparams } = controller.runes + focus.stack({component: 'SeriesPage', focus: 'page'}) + + + + + + + + + + + + + + + diff --git a/packages/web/src/routes/series/[series_id]/+page.ts b/packages/web/src/routes/series/[series_id]/+page.ts new file mode 100644 index 00000000..4d34411a --- /dev/null +++ b/packages/web/src/routes/series/[series_id]/+page.ts @@ -0,0 +1,5 @@ +export function load({ params }) { + return { + series_id: parseInt(params.series_id) + } +} 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..75c60412 --- /dev/null +++ b/packages/web/src/routes/series/[series_id]/components/Footer.svelte @@ -0,0 +1,91 @@ + + + + + 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..0dd4e093 --- /dev/null +++ b/packages/web/src/routes/series/[series_id]/components/Header.svelte @@ -0,0 +1,17 @@ + + + + {queryparams.human_readable_summary || 'Forager'} + + diff --git a/packages/web/src/routes/series/[series_id]/components/MediaDetailEntry.svelte b/packages/web/src/routes/series/[series_id]/components/MediaDetailEntry.svelte new file mode 100644 index 00000000..7c78dd08 --- /dev/null +++ b/packages/web/src/routes/series/[series_id]/components/MediaDetailEntry.svelte @@ -0,0 +1,50 @@ + + +{#if !(props.hide_if_null && props.content === null)} + + {props.label} + + {#if props.editable} + + {:else} + + {value} + + {/if} + + +{/if} 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..e066086c --- /dev/null +++ b/packages/web/src/routes/series/[series_id]/components/MediaDetails.svelte @@ -0,0 +1,174 @@ + + + + { + 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} + + + + + Tags + {#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)} + + + + + { + await media_selections.current_selection.media_response.update(undefined, {remove: [`${tag.group}:${tag.name}`]}) + }}> + + + + {/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/series/[series_id]/components/MediaList.svelte b/packages/web/src/routes/series/[series_id]/components/MediaList.svelte new file mode 100644 index 00000000..3951bd50 --- /dev/null +++ b/packages/web/src/routes/series/[series_id]/components/MediaList.svelte @@ -0,0 +1,59 @@ + + + + controller.runes.series_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..c5289601 --- /dev/null +++ b/packages/web/src/routes/series/[series_id]/components/MediaView.svelte @@ -0,0 +1,158 @@ + + + + + + {#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/SearchLink.svelte b/packages/web/src/routes/series/[series_id]/components/SearchLink.svelte new file mode 100644 index 00000000..dd584447 --- /dev/null +++ b/packages/web/src/routes/series/[series_id]/components/SearchLink.svelte @@ -0,0 +1,24 @@ + + + { + if (e.ctrlKey || e.shiftKey) return + e.preventDefault() + await queryparams.goto(params) + }} +> + {@render children?.()} + diff --git a/packages/web/src/routes/series/[series_id]/components/SearchParams.svelte b/packages/web/src/routes/series/[series_id]/components/SearchParams.svelte new file mode 100644 index 00000000..285b5748 --- /dev/null +++ b/packages/web/src/routes/series/[series_id]/components/SearchParams.svelte @@ -0,0 +1,170 @@ + + + { + e.preventDefault() + await update_search() + }}> + + + + { + settings.toggle('ui.search.advanced_filters.hide') + }}> + + + + + + + + + Series Index + Created At + Added On + Updated At + View Count + Last Viewed + + { + if (params.order === 'asc') params.order = 'desc' + else params.order = 'asc' + update_search() + }}> + {#if params.order === 'asc'} + + {:else} + + {/if} + + + + + + { + params.unread_only = !params.unread_only + update_search() + }} + title="Show unread results only" + > + Unread + + + + + + + + + { + switch(params.stars_equality) { + /* + case 'lte': { + params.stars_equality = 'eq' + break + } + */ + case '=': { + params.stars_equality = '>=' + break + } + case '>=': { + params.stars_equality = '=' + break + } + case undefined: { + params.stars_equality = '=' + break + } + default: { + throw new Error(`Unexpected params.stars_equality ${params.stars_equality}`) + } + + } + update_search() + }}> + {params.stars_equality ?? '>='} + + + + + + + + Filepath: + + + + + + + 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..773c14e5 --- /dev/null +++ b/packages/web/src/routes/series/[series_id]/components/SearchResults.svelte @@ -0,0 +1,143 @@ + + + + + + + + {#each series_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'} + + {:else} + + {/if} + + + + + {#if result.media_type === 'media_file'} + {#if result.media_file.media_type === 'VIDEO'} + + + {#if result.media_file.audio} + + {/if} + + {human_readable_duration(result.media_file.duration)} + {:else if result.media_file.media_type === 'IMAGE' && result.media_file.content_type === 'image/gif'} + + {:else if result.media_file.media_type === 'IMAGE'} + + {:else if result.media_file.media_type === 'AUDIO'} + + {:else} + unknown media type {result.media_file.media_type} + {/if} + {:else if result.media_type === 'media_series'} + + ({result.media_reference.media_series_length}) + {:else} + UNEXPECTED MEDIA TYPE {result.media_type} + {/if} + + + + + #{result.series_index} + + + + + {/each} + +{#if series_list.loading} + Loading... +{/if} diff --git a/packages/web/src/routes/series/[series_id]/controller.ts b/packages/web/src/routes/series/[series_id]/controller.ts new file mode 100644 index 00000000..1b7ca571 --- /dev/null +++ b/packages/web/src/routes/series/[series_id]/controller.ts @@ -0,0 +1,35 @@ +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 './runes/dimensions.svelte.ts' +import { MediaSelectionsRune } from '../../browse/runes/media_selections.svelte.ts' +import { SeriesQueryParamsRune } from './runes/queryparams.svelte.ts' +import { SeriesListRune } from './runes/series_list.svelte.ts' + + +class SeriesController extends BaseController { + runes: { + series_list: SeriesListRune + focus: ReturnType + dimensions: ReturnType + settings: SettingsRune + media_selections: MediaSelectionsRune + queryparams: SeriesQueryParamsRune + } + + public constructor(config: Config, series_id: number) { + super(config) + + const series_list_rune = new SeriesListRune(this.client, this.settings) + this.runes = { + series_list: series_list_rune, + focus: create_focuser(), + dimensions: create_dimensional_rune(), + settings: new SettingsRune(this.client, this.config), + media_selections: new MediaSelectionsRune(this.client, series_list_rune), + queryparams: new SeriesQueryParamsRune(this.client, series_list_rune, series_id), + } + } +} + +export { SeriesController } diff --git a/packages/web/src/routes/series/[series_id]/runes/dimensions.svelte.ts b/packages/web/src/routes/series/[series_id]/runes/dimensions.svelte.ts new file mode 100644 index 00000000..e6d627da --- /dev/null +++ b/packages/web/src/routes/series/[series_id]/runes/dimensions.svelte.ts @@ -0,0 +1,19 @@ +export function create_dimensional_rune() { + let heights = $state({ + screen: 0, + header: 0, + media_list: 0, + footer: 0, + }) + + $effect(() => { + // 5 in here is just to fix an issue with heights being tool long causing a scrollbar to appear. It may have to do with borders or shadows + heights.media_list = heights.screen - heights.header - heights.footer - 5 + }) + + return { + get heights() { + return heights + } + } +} diff --git a/packages/web/src/routes/series/[series_id]/runes/queryparams.svelte.ts b/packages/web/src/routes/series/[series_id]/runes/queryparams.svelte.ts new file mode 100644 index 00000000..aecf0dae --- /dev/null +++ b/packages/web/src/routes/series/[series_id]/runes/queryparams.svelte.ts @@ -0,0 +1,266 @@ +import type { inputs } from '@forager/core' +import type { SeriesController } from '../controller.ts' +import type { SeriesListRune } from './series_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 { + series_id: number + search_string: string + filepath: string | undefined + sort: inputs.SeriesSearch['sort_by'] + unread_only: boolean + stars: number | undefined + stars_equality: 'lte' | 'gte' | 'eq' | undefined + order: 'desc' | 'asc' + media_type: string +} + +const DEFAULTS: Omit = { + 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 + public current_serialized: string = '?' + + private search_rune: SeriesListRune + private popstate_listener_fn!: (params: State) => void + private series_id: number + + public constructor(client: SeriesController['client'], search_rune: SeriesListRune, series_id: number) { + super(client) + this.search_rune = search_rune + this.series_id = series_id + this.current_url = $state({...DEFAULTS, 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, series_id: this.series_id} + + 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 (key === 'series_id') continue // Don't put series_id in query params, it's in the URL path + 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, + 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}`) + } + } + + await this.search_rune.paginate({ + type: 'series', + params: { + query: query, + sort_by, + order + } + }) + } + + 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.SeriesSearch['query'] = { + series_id: this.series_id, + 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}`) + } + } + + return { + type: 'series' as const, + params: { + query: query, + sort_by, + order + } + } + } + + 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]/runes/series_list.svelte.ts b/packages/web/src/routes/series/[series_id]/runes/series_list.svelte.ts new file mode 100644 index 00000000..ab54ad45 --- /dev/null +++ b/packages/web/src/routes/series/[series_id]/runes/series_list.svelte.ts @@ -0,0 +1,88 @@ +import {Rune} from '$lib/runes/rune.ts' +import type { Forager } from '@forager/core' +import { MediaViewRune } from '$lib/runes/index.ts' + + +type Result = ReturnType + +interface SeriesSearchInput { + type: 'series' + params: Parameters[0] +} + +export type Input = SeriesSearchInput + +interface SeriesListState { + loading: boolean + content: Result | null + results: MediaViewRune[] +} + +export class SeriesListRune extends Rune { + #saved_params: {} | undefined + #prev_query_hash: string = '' + #fetch_count = 0 + #has_more = true + #cursor: Result['cursor'] = undefined + #state = $state({ + loading: true, // empty state acts like it is loading by default + content: null, + results: [] + }) + + get loading() { return this.#state.loading } + + get content(): Result | null { return this.#state.content } + + get results(): Result['results'] { 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: Result = await this.client.forager.series.search(fetch_params) + + const results = content.results.map(result => { + return MediaViewRune.create(this.client, result, fetch_params) + }) + + this.#cursor = content.cursor + if (!this.#cursor) { + this.#has_more = false + } + this.#state = { + content: content, + results: this.#state.results.concat(results), + loading: false, + } + + const query_hash = JSON.stringify(fetch_params.query) + if (this.#prev_query_hash !== query_hash) { + this.#prev_query_hash = query_hash + } + } +}