diff --git a/next-ui/src/colada/referential.ts b/next-ui/src/colada/referential.ts index 8d92ee6c..a9561bb6 100644 --- a/next-ui/src/colada/referential.ts +++ b/next-ui/src/colada/referential.ts @@ -217,3 +217,33 @@ export const languagesQuery = defineQueryOptions( } }, ) + +export const releaseYearsQuery = defineQueryOptions( + ({ + library_id, + collection_id, + pageRequest, + }: { + library_id?: string[] + collection_id?: string[] + pageRequest?: PageRequest + }) => { + const queryParams = { + library_id: library_id, + collection_id: collection_id, + ...pageRequest, + } + return { + key: ['release-years', queryParams], + query: () => + komgaClient + .GET('/api/v2/series/release-years', { + params: { + query: queryParams, + }, + }) + // unwrap the openapi-fetch structure on success + .then((res) => res.data), + } + }, +) diff --git a/next-ui/src/components.d.ts b/next-ui/src/components.d.ts index e98cbf71..389b2573 100644 --- a/next-ui/src/components.d.ts +++ b/next-ui/src/components.d.ts @@ -39,6 +39,7 @@ declare module 'vue' { FilterByGenre: typeof import('./components/filter/by/Genre.vue')['default'] FilterByLanguage: typeof import('./components/filter/by/Language.vue')['default'] FilterByPublisher: typeof import('./components/filter/by/Publisher.vue')['default'] + FilterByReleaseYear: typeof import('./components/filter/by/ReleaseYear.vue')['default'] FilterBySeriesStatus: typeof import('./components/filter/by/SeriesStatus.vue')['default'] FilterBySharingLabel: typeof import('./components/filter/by/SharingLabel.vue')['default'] FilterByTag: typeof import('./components/filter/by/Tag.vue')['default'] diff --git a/next-ui/src/components/filter/by/ReleaseYear.stories.ts b/next-ui/src/components/filter/by/ReleaseYear.stories.ts new file mode 100644 index 00000000..f94257db --- /dev/null +++ b/next-ui/src/components/filter/by/ReleaseYear.stories.ts @@ -0,0 +1,52 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' + +import ReleaseYear from './ReleaseYear.vue' +import { fn } from 'storybook/test' + +const meta = { + component: ReleaseYear, + render: (args: object) => ({ + components: { ReleaseYear }, + setup() { + return { args } + }, + template: '', + }), + parameters: { + // More on how to position stories at: https://storybook.js.org/docs/configure/story-layout + docs: { + description: { + component: 'Release year filter.', + }, + }, + }, + args: { + 'onUpdate:modelValue': fn(), + modelValue: { is: undefined }, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = { + args: {}, +} + +export const InitialValue: Story = { + args: { + modelValue: { is: '2016' }, + }, +} + +export const InitialRange: Story = { + args: { + modelValue: { min: '2018', max: '2020' }, + }, +} + +export const InitialBoth: Story = { + args: { + modelValue: { is: '2016', min: '2018', max: '2020' }, + }, +} diff --git a/next-ui/src/components/filter/by/ReleaseYear.vue b/next-ui/src/components/filter/by/ReleaseYear.vue new file mode 100644 index 00000000..7b43b732 --- /dev/null +++ b/next-ui/src/components/filter/by/ReleaseYear.vue @@ -0,0 +1,80 @@ + + + + + + + diff --git a/next-ui/src/functions/filter.ts b/next-ui/src/functions/filter.ts index 8f1158bd..14aba4cf 100644 --- a/next-ui/src/functions/filter.ts +++ b/next-ui/src/functions/filter.ts @@ -98,3 +98,30 @@ export function schemaFilterStringToConditions( anyOf: list, } } + +export function schemaFilterReleaseYearToConditions( + filter: InferOutput, +) { + const conds = [] + if (!!filter.is || !!filter.min) { + const year = Number(filter.is || filter.min) + conds.push({ + releaseDate: { + operator: 'after', + dateTime: `${(year - 1).toString().padStart(4, '0')}-12-31T12:00:00Z`, + }, + }) + } + if (!!filter.is || !!filter.max) { + const year = Number(filter.is || filter.max) + conds.push({ + releaseDate: { + operator: 'before', + dateTime: `${(year + 1).toString().padStart(4, '0')}-01-01T12:00:00Z`, + }, + }) + } + return { + allOf: conds, + } +} diff --git a/next-ui/src/generated/openapi/komga.d.ts b/next-ui/src/generated/openapi/komga.d.ts index ce2cd482..07864a38 100644 --- a/next-ui/src/generated/openapi/komga.d.ts +++ b/next-ui/src/generated/openapi/komga.d.ts @@ -53,7 +53,7 @@ export interface paths { /** * List age ratings * @deprecated - * @description Use GET /v2/genres instead. Deprecated since 1.x.0 + * @description Use GET /v2/age-ratings instead. Deprecated since 1.x.0 */ get: operations["getAgeRatings_1"]; put?: never; @@ -1532,7 +1532,7 @@ export interface paths { /** * List publishers * @deprecated - * @description Use GET /v2/genres instead. Deprecated since 1.x.0 + * @description Use GET /v2/publishers instead. Deprecated since 1.x.0 */ get: operations["getPublishers_1"]; put?: never; @@ -1934,9 +1934,9 @@ export interface paths { /** * List series release dates * @deprecated - * @description Use GET /v2/genres instead. Deprecated since 1.x.0 + * @description Use GET /v2/series/release-years instead. Deprecated since 1.x.0 */ - get: operations["getSeriesReleaseDates_1"]; + get: operations["getSeriesReleaseDates"]; put?: never; post?: never; delete?: never; @@ -2288,7 +2288,7 @@ export interface paths { /** * List tags * @deprecated - * @description Use GET /v2/sharing-labels instead. Deprecated since 1.x.0 + * @description Use GET /v2/tags instead. Deprecated since 1.x.0 */ get: operations["getTags_1"]; put?: never; @@ -2309,7 +2309,7 @@ export interface paths { /** * List book tags * @deprecated - * @description Use GET /v2/sharing-labels instead. Deprecated since 1.x.0 + * @description Use GET /v2/tags instead. Deprecated since 1.x.0 */ get: operations["getBookTags"]; put?: never; @@ -2330,7 +2330,7 @@ export interface paths { /** * List series tags * @deprecated - * @description Use GET /v2/sharing-labels instead. Deprecated since 1.x.0 + * @description Use GET /v2/tags instead. Deprecated since 1.x.0 */ get: operations["getSeriesTags"]; put?: never; @@ -2565,7 +2565,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v2/series/release-dates": { + "/api/v2/series/release-years": { parameters: { query?: never; header?: never; @@ -2573,10 +2573,10 @@ export interface paths { cookie?: never; }; /** - * List series release dates + * List series release years * @description Can be filtered by various criteria */ - get: operations["getSeriesReleaseDates"]; + get: operations["getSeriesReleaseYears"]; put?: never; post?: never; delete?: never; @@ -8293,7 +8293,7 @@ export interface operations { }; }; }; - getSeriesReleaseDates_1: { + getSeriesReleaseDates: { parameters: { query?: { library_id?: string[]; @@ -9499,7 +9499,7 @@ export interface operations { }; }; }; - getSeriesReleaseDates: { + getSeriesReleaseYears: { parameters: { query?: { library_id?: string[]; diff --git a/next-ui/src/mocks/api/handlers/referential.ts b/next-ui/src/mocks/api/handlers/referential.ts index 196016e1..6babbad4 100644 --- a/next-ui/src/mocks/api/handlers/referential.ts +++ b/next-ui/src/mocks/api/handlers/referential.ts @@ -3,8 +3,6 @@ import type { components } from '@/generated/openapi/komga' import { mockPage } from '@/mocks/api/pageable' import { PageRequest } from '@/types/PageRequest' -const sharingLabels = ['kids', 'teens'] - const authorRoles = [ 'writer', 'penciller', @@ -37,6 +35,7 @@ const mockTags = doMockStrings(10000, 'Tag') const mockPublishers = doMockStrings(10000, 'Publisher') const mockSharingLabels = doMockStrings(150, 'SharingLabel') const mockLanguages = ['de', 'en', 'en-US', 'es', 'fr', 'fr-CA', 'ja', 'it'] +const mockReleaseYears = ['2022', '2021', '2020', '2019', '2018', '2016', '1988', '1970'] function filterAndPage( search: string | null, @@ -54,7 +53,6 @@ function filterAndPage( } export const referentialHandlers = [ - httpTyped.get('/api/v1/sharing-labels', ({ response }) => response(200).json(sharingLabels)), httpTyped.get('/api/v2/genres', ({ query, response }) => response(200).json( filterAndPage( @@ -110,6 +108,17 @@ export const referentialHandlers = [ ), ), ), + httpTyped.get('/api/v2/series/release-years', ({ query, response }) => + response(200).json( + filterAndPage( + null, + mockReleaseYears, + query.get('page'), + query.get('size'), + query.get('unpaged'), + ), + ), + ), httpTyped.get('/api/v2/authors', ({ query, response }) => { const search = query.get('search') const role = query.get('role') diff --git a/next-ui/src/pages/libraries/[id]/series.vue b/next-ui/src/pages/libraries/[id]/series.vue index 8b3a7380..39f82bd6 100644 --- a/next-ui/src/pages/libraries/[id]/series.vue +++ b/next-ui/src/pages/libraries/[id]/series.vue @@ -114,6 +114,14 @@ v-model:mode="filterLanguage.m" /> + + + + @@ -226,6 +234,7 @@ import { schemaFilterAuthorsToConditions, schemaFilterStringToConditions, schemaFilterSeriesStatusToConditions, + schemaFilterReleaseYearToConditions, } from '@/functions/filter' import * as v from 'valibot' import { @@ -233,6 +242,7 @@ import { SchemaFilterAuthors, SchemaFilterSeriesStatus, SchemaFilterStrings, + SchemaSeriesReleaseYears, } from '@/types/filter' import { useRouteQuerySchema } from '@/composables/useRouteQuerySchema' import { authorRoles } from '@/types/referential' @@ -286,12 +296,19 @@ function clearFilter(filter: FilterType) { if ('m' in filter) filter.m = 'anyOf' } +function clearFilterYear() { + filterReleaseYear.value.is = undefined + filterReleaseYear.value.min = undefined + filterReleaseYear.value.max = undefined +} + const { data: filterSeriesStatus } = useRouteQuerySchema('status', SchemaFilterSeriesStatus) const { data: filterGenre } = useRouteQuerySchema('genre', SchemaFilterStrings) const { data: filterTag } = useRouteQuerySchema('tag', SchemaFilterStrings) const { data: filterPublisher } = useRouteQuerySchema('publisher', SchemaFilterStrings) const { data: filterSharingLabel } = useRouteQuerySchema('sharingLabel', SchemaFilterStrings) const { data: filterLanguage } = useRouteQuerySchema('language', SchemaFilterStrings) +const { data: filterReleaseYear } = useRouteQuerySchema('year', SchemaSeriesReleaseYears) const conds = computed(() => ({ allOf: [ @@ -302,6 +319,7 @@ const conds = computed(() => ({ schemaFilterStringToConditions(filterPublisher.value, 'publisher', false), schemaFilterStringToConditions(filterSharingLabel.value, 'sharingLabel', true), schemaFilterStringToConditions(filterLanguage.value, 'language', false), + schemaFilterReleaseYearToConditions(filterReleaseYear.value), ...Object.entries(filterAuthors).map(([, filter]) => schemaFilterAuthorsToConditions(toValue(filter.filter), toValue(filter.role)), ), diff --git a/next-ui/src/types/filter.test.ts b/next-ui/src/types/filter.test.ts index eb9a35ca..176c71be 100644 --- a/next-ui/src/types/filter.test.ts +++ b/next-ui/src/types/filter.test.ts @@ -5,6 +5,7 @@ import { SchemaFilterAuthors, SchemaFilterSeriesStatus, SchemaFilterStrings, + SchemaSeriesReleaseYears, SchemaSeriesStatus, } from '@/types/filter' @@ -59,6 +60,35 @@ describe('schema series status', () => { }) }) +describe('schema series release years', () => { + test('correct value exact', () => { + const input = { is: '1985' } + const result = v.parse(SchemaSeriesReleaseYears, input) + + expect(result).toStrictEqual(input) + }) + + test('correct value min', () => { + const input = { min: '1985' } + const result = v.parse(SchemaSeriesReleaseYears, input) + + expect(result).toStrictEqual(input) + }) + + test('correct value max', () => { + const input = { max: '1985' } + const result = v.parse(SchemaSeriesReleaseYears, input) + + expect(result).toStrictEqual(input) + }) + + test('other value throws error', () => { + const input = { is: '20254' } + + expect(() => v.parse(SchemaSeriesReleaseYears, input)).toThrowError() + }) +}) + describe('filter schemas have a default value', () => { test('SchemaFilterSeriesStatus', () => { const expected = { v: [] } @@ -80,4 +110,11 @@ describe('filter schemas have a default value', () => { expect(defaults).toStrictEqual(expected) }) + + test('SchemaSeriesReleaseYears', () => { + const expected = { is: undefined, min: undefined, max: undefined } + const defaults = v.getDefaults(SchemaSeriesReleaseYears) + + expect(defaults).toStrictEqual(expected) + }) }) diff --git a/next-ui/src/types/filter.ts b/next-ui/src/types/filter.ts index a10750f4..8825c35d 100644 --- a/next-ui/src/types/filter.ts +++ b/next-ui/src/types/filter.ts @@ -65,6 +65,8 @@ export const SchemaAnyNone = v.strictObject({ export const SchemaAuthor = v.union([SchemaString, SchemaAnyNone]) +const SchemaYear = v.pipe(v.string(), v.regex(/^\d{4}$/, 'Must be exactly 4 digits')) + //////////////////////////////////////////////////// // All schema filters need to have a default value //////////////////////////////////////////////////// @@ -93,6 +95,15 @@ function createSchemaFilterArray(schema: T) { */ export const SchemaFilterSeriesStatus = createSchemaFilterArray(SchemaSeriesStatus) +/** + * Schema for Series Release Years + */ +export const SchemaSeriesReleaseYears = v.strictObject({ + is: v.optional(SchemaYear), + min: v.optional(SchemaYear), + max: v.optional(SchemaYear), +}) + /** * Schema for a list of string. * Can be used for tags, genre, sharing labels…