From 44d2ca6d2d092a1bd2ac6619802537e52e9a00fa Mon Sep 17 00:00:00 2001 From: Gauthier Roebroeck Date: Fri, 20 Mar 2026 17:48:31 +0800 Subject: [PATCH] add age rating filter --- next-ui/src/colada/referential.ts | 30 +++++++ next-ui/src/components.d.ts | 2 + next-ui/src/components/filter/SelectRange.vue | 86 +++++++++++++++++++ .../components/filter/by/AgeRating.stories.ts | 64 ++++++++++++++ .../src/components/filter/by/AgeRating.vue | 59 +++++++++++++ .../src/components/filter/by/ReleaseYear.vue | 63 +++----------- next-ui/src/functions/filter.ts | 42 +++++++++ next-ui/src/mocks/api/handlers/referential.ts | 14 +++ next-ui/src/pages/libraries/[id]/series.vue | 23 +++-- next-ui/src/types/filter.test.ts | 51 +++++++++++ next-ui/src/types/filter.ts | 24 ++++-- 11 files changed, 395 insertions(+), 63 deletions(-) create mode 100644 next-ui/src/components/filter/SelectRange.vue create mode 100644 next-ui/src/components/filter/by/AgeRating.stories.ts create mode 100644 next-ui/src/components/filter/by/AgeRating.vue diff --git a/next-ui/src/colada/referential.ts b/next-ui/src/colada/referential.ts index a9561bb6..7422bb71 100644 --- a/next-ui/src/colada/referential.ts +++ b/next-ui/src/colada/referential.ts @@ -247,3 +247,33 @@ export const releaseYearsQuery = defineQueryOptions( } }, ) + +export const ageRatingsQuery = 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: ['age-ratings', queryParams], + query: () => + komgaClient + .GET('/api/v2/age-ratings', { + 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 389b2573..2f6849de 100644 --- a/next-ui/src/components.d.ts +++ b/next-ui/src/components.d.ts @@ -35,6 +35,7 @@ declare module 'vue' { EmptyStateConstruction: typeof import('./components/EmptyStateConstruction.vue')['default'] EmptyStateNetworkError: typeof import('./components/EmptyStateNetworkError.vue')['default'] FilterAnyAll: typeof import('./components/filter/AnyAll.vue')['default'] + FilterByAgeRating: typeof import('./components/filter/by/AgeRating.vue')['default'] FilterByAuthor: typeof import('./components/filter/by/Author.vue')['default'] FilterByGenre: typeof import('./components/filter/by/Genre.vue')['default'] FilterByLanguage: typeof import('./components/filter/by/Language.vue')['default'] @@ -46,6 +47,7 @@ declare module 'vue' { FilterExpansionPanel: typeof import('./components/filter/ExpansionPanel.vue')['default'] FilterList: typeof import('./components/filter/List.vue')['default'] FilterSearchList: typeof import('./components/filter/SearchList.vue')['default'] + FilterSelectRange: typeof import('./components/filter/SelectRange.vue')['default'] FilterTriState: typeof import('./components/filter/TriState.vue')['default'] FormattedMessage: typeof import('./components/FormattedMessage.ts')['default'] HelloWorld: typeof import('./components/HelloWorld.vue')['default'] diff --git a/next-ui/src/components/filter/SelectRange.vue b/next-ui/src/components/filter/SelectRange.vue new file mode 100644 index 00000000..d6402b8a --- /dev/null +++ b/next-ui/src/components/filter/SelectRange.vue @@ -0,0 +1,86 @@ + + + + + + + diff --git a/next-ui/src/components/filter/by/AgeRating.stories.ts b/next-ui/src/components/filter/by/AgeRating.stories.ts new file mode 100644 index 00000000..50f28fd9 --- /dev/null +++ b/next-ui/src/components/filter/by/AgeRating.stories.ts @@ -0,0 +1,64 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' + +import AgeRating from './AgeRating.vue' +import { fn } from 'storybook/test' + +const meta = { + component: AgeRating, + render: (args: object) => ({ + components: { AgeRating }, + setup() { + return { args } + }, + template: '', + }), + parameters: { + // More on how to position stories at: https://storybook.js.org/docs/configure/story-layout + docs: { + description: { + component: 'Age rating 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: 16 }, + }, +} + +export const InitialValueAny: Story = { + args: { + modelValue: { is: 'any' }, + }, +} + +export const InitialValueNone: Story = { + args: { + modelValue: { is: 'none' }, + }, +} + +export const InitialRange: Story = { + args: { + modelValue: { min: 16, max: 18 }, + }, +} + +export const InitialBoth: Story = { + args: { + modelValue: { is: 14, min: 16, max: 18 }, + }, +} diff --git a/next-ui/src/components/filter/by/AgeRating.vue b/next-ui/src/components/filter/by/AgeRating.vue new file mode 100644 index 00000000..8d7385e5 --- /dev/null +++ b/next-ui/src/components/filter/by/AgeRating.vue @@ -0,0 +1,59 @@ + + + + + + + diff --git a/next-ui/src/components/filter/by/ReleaseYear.vue b/next-ui/src/components/filter/by/ReleaseYear.vue index 882987a0..899863cb 100644 --- a/next-ui/src/components/filter/by/ReleaseYear.vue +++ b/next-ui/src/components/filter/by/ReleaseYear.vue @@ -1,43 +1,14 @@ diff --git a/next-ui/src/functions/filter.ts b/next-ui/src/functions/filter.ts index 82788bca..89449a41 100644 --- a/next-ui/src/functions/filter.ts +++ b/next-ui/src/functions/filter.ts @@ -3,6 +3,7 @@ import { SchemaFilterAuthors, type SchemaFilterSeriesStatus, SchemaFilterStrings, + SchemaSeriesAgeRatings, SchemaSeriesReleaseYears, } from '@/types/filter' import type { InferOutput } from 'valibot' @@ -140,3 +141,44 @@ export function schemaFilterReleaseYearToConditions( allOf: conds, } } + +export function schemaFilterAgeRatingToConditions( + filter: InferOutput, +) { + const conds = [] + if (filter.is === 'any') { + conds.push({ + ageRating: { + operator: 'isNotNull', + }, + }) + } else if (filter.is === 'none') { + conds.push({ + ageRating: { + operator: 'isNull', + }, + }) + } else { + if (!!filter.is || !!filter.min) { + const v = Number(filter.is || filter.min) + conds.push({ + ageRating: { + operator: 'greaterThan', + value: v, + }, + }) + } + if (!!filter.is || !!filter.max) { + const v = Number(filter.is || filter.max) + conds.push({ + ageRating: { + operator: 'lessThan', + value: v, + }, + }) + } + } + return { + allOf: conds, + } +} diff --git a/next-ui/src/mocks/api/handlers/referential.ts b/next-ui/src/mocks/api/handlers/referential.ts index 6babbad4..5fbc7591 100644 --- a/next-ui/src/mocks/api/handlers/referential.ts +++ b/next-ui/src/mocks/api/handlers/referential.ts @@ -36,6 +36,7 @@ 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'] +const mockAgeRatings = [5, 6, 7, 8, 12, 14, 16, 18] function filterAndPage( search: string | null, @@ -119,6 +120,19 @@ export const referentialHandlers = [ ), ), ), + httpTyped.get('/api/v2/age-ratings', ({ query, response }) => + response(200).json( + mockPage( + mockAgeRatings, + new PageRequest( + Number(query.get('page')), + Number(query.get('size')), + undefined, + Boolean(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 bbd10f35..b5a71785 100644 --- a/next-ui/src/pages/libraries/[id]/series.vue +++ b/next-ui/src/pages/libraries/[id]/series.vue @@ -96,11 +96,19 @@ + + + + ({ allOf: [ @@ -320,6 +332,7 @@ const conds = computed(() => ({ schemaFilterStringToConditions(filterSharingLabel.value, 'sharingLabel', true), schemaFilterStringToConditions(filterLanguage.value, 'language', false), schemaFilterReleaseYearToConditions(filterReleaseYear.value), + schemaFilterAgeRatingToConditions(filterAgeRating.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 c4e3223f..e1433190 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, + SchemaSeriesAgeRatings, SchemaSeriesReleaseYears, SchemaSeriesStatus, } from '@/types/filter' @@ -103,6 +104,49 @@ describe('schema series release years', () => { }) }) +describe('schema series age ratings', () => { + test('correct value exact', () => { + const input = { is: 10 } + const result = v.parse(SchemaSeriesAgeRatings, input) + + expect(result).toStrictEqual(input) + }) + + test('correct value min', () => { + const input = { min: 12 } + const result = v.parse(SchemaSeriesAgeRatings, input) + + expect(result).toStrictEqual(input) + }) + + test('correct value max', () => { + const input = { max: 15 } + const result = v.parse(SchemaSeriesAgeRatings, input) + + expect(result).toStrictEqual(input) + }) + + test('correct value is: any', () => { + const input = { is: 'any' } + const result = v.parse(SchemaSeriesAgeRatings, input) + + expect(result).toStrictEqual(input) + }) + + test('correct value is: none', () => { + const input = { is: 'none' } + const result = v.parse(SchemaSeriesAgeRatings, input) + + expect(result).toStrictEqual(input) + }) + + test('other value throws error', () => { + const input = { is: '22' } + + expect(() => v.parse(SchemaSeriesAgeRatings, input)).toThrowError() + }) +}) + describe('filter schemas have a default value', () => { test('SchemaFilterSeriesStatus', () => { const expected = { v: [] } @@ -131,4 +175,11 @@ describe('filter schemas have a default value', () => { expect(defaults).toStrictEqual(expected) }) + + test('SchemaSeriesAgeRatings', () => { + const expected = { is: undefined, min: undefined, max: undefined } + const defaults = v.getDefaults(SchemaSeriesAgeRatings) + + expect(defaults).toStrictEqual(expected) + }) }) diff --git a/next-ui/src/types/filter.ts b/next-ui/src/types/filter.ts index 3f9e5170..906f8bbf 100644 --- a/next-ui/src/types/filter.ts +++ b/next-ui/src/types/filter.ts @@ -95,6 +95,14 @@ function createSchemaFilterArray(schema: T) { }) } +function createSchemaFilterSelectRange(schema: T) { + return v.strictObject({ + is: v.optional(v.union([v.picklist(['any', 'none']), schema])), + min: v.optional(schema), + max: v.optional(schema), + }) +} + /** * Schema for Series Status. */ @@ -103,11 +111,12 @@ export const SchemaFilterSeriesStatus = createSchemaFilterArray(SchemaSeriesStat /** * Schema for Series Release Years */ -export const SchemaSeriesReleaseYears = v.strictObject({ - is: v.optional(v.union([v.picklist(['any', 'none']), SchemaYear])), - min: v.optional(SchemaYear), - max: v.optional(SchemaYear), -}) +export const SchemaSeriesReleaseYears = createSchemaFilterSelectRange(SchemaYear) + +/** + * Schema for Series Age Ratings + */ +export const SchemaSeriesAgeRatings = createSchemaFilterSelectRange(v.number()) /** * Schema for a list of string. @@ -126,4 +135,9 @@ export type FilterTypeAnyAll = v.InferOutput export const SchemaFilterArray = createSchemaFilterArray(v.unknown()) export type FilterTypeSimpleList = v.InferOutput +export const SchemaFilterSelectRange = createSchemaFilterSelectRange( + v.union([v.string(), v.number()]), +) +export type FilterTypeSelectRange = v.InferOutput + export type FilterType = FilterTypeAnyAll | FilterTypeSimpleList