add age rating filter

This commit is contained in:
Gauthier Roebroeck 2026-03-20 17:48:31 +08:00
parent a1d734e5a5
commit 44d2ca6d2d
11 changed files with 395 additions and 63 deletions

View file

@ -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),
}
},
)

View file

@ -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']

View file

@ -0,0 +1,86 @@
<template>
<v-select
v-model="model.is"
:label="labelSelect"
clearable
hide-details
:items="selectItems"
@click:clear="model.is = undefined"
/>
<div class="px-2 pt-2">
<div class="d-flex justify-space-between align-center mb-2">
<span
class="text-body-large"
style="min-height: 26px"
>{{ labelRange }}</span
>
<v-chip
v-if="!!model.min && !!model.max"
:disabled="isSingle"
closable
color="primary"
rounded
size="small"
variant="elevated"
@click:close="clearRange()"
>{{ model.min }} - {{ model.max }}
</v-chip>
</div>
<v-range-slider
v-model="modelRange"
strict
hide-details
:disabled="disabled || isSingle"
:step="1"
:min="min"
:max="max"
thumb-label="hover"
/>
</div>
</template>
<script setup lang="ts">
import { type FilterTypeSelectRange } from '@/types/filter'
const model = defineModel<FilterTypeSelectRange>({ required: true })
const {
labelSelect,
labelRange,
selectItems,
min,
max,
disabled = false,
rangeMapper = (it) => it,
} = defineProps<{
labelSelect: string
labelRange: string
selectItems: unknown[]
min?: number
max?: number
disabled?: boolean
rangeMapper?: (number: number) => string | number
}>()
const isSingle = computed(() => model.value.is !== undefined)
const modelRange = computed({
get: () => [model.value.min || 0, model.value.max || 10000],
set: (newValue) => {
if (newValue) {
model.value.min = rangeMapper(newValue[0] as number)
model.value.max = rangeMapper(newValue[1] as number)
}
},
})
function clearRange() {
model.value.min = undefined
model.value.max = undefined
}
</script>
<script lang="ts"></script>
<style scoped></style>

View file

@ -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: '<AgeRating v-model="args.modelValue" v-bind="args"/>',
}),
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<typeof AgeRating>
export default meta
type Story = StoryObj<typeof meta>
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 },
},
}

View file

@ -0,0 +1,59 @@
<template>
<FilterSelectRange
v-model="model"
label-select="Age rating"
label-range="Age range"
:select-items="selectItems"
:min="min"
:max="max"
:disabled="disabled"
/>
</template>
<script setup lang="ts">
import * as v from 'valibot'
import { filterKeys, filterMessages, SchemaSeriesAgeRatings } from '@/types/filter'
import { useQuery } from '@pinia/colada'
import { ageRatingsQuery } from '@/colada/referential'
import { PageRequest } from '@/types/PageRequest'
import { useIntl } from 'vue-intl'
const intl = useIntl()
type AgeRatings = v.InferOutput<typeof SchemaSeriesAgeRatings>
const model = defineModel<AgeRatings>({ required: true })
const filterContext = inject(filterKeys.context, {})
const apiQuery = {
...filterContext,
}
const { data: items } = useQuery(() => ({
...ageRatingsQuery({
pageRequest: PageRequest.Unpaged(),
...apiQuery,
}),
}))
const disabled = computed(() => (items.value?.totalElements || 0) === 0)
const selectItems = computed(() => [
{
title: intl.formatMessage(filterMessages.any!),
value: 'any',
},
{
title: intl.formatMessage(filterMessages.none!),
value: 'none',
},
...(items.value?.content?.map((it) => ({ title: it, value: it })) || []),
])
const min = computed(() => items.value?.content?.map((it) => Number(it))?.at(0))
const max = computed(() => items.value?.content?.map((it) => Number(it))?.at(-1))
</script>
<script lang="ts"></script>
<style scoped></style>

View file

@ -1,43 +1,14 @@
<template>
<v-select
v-model="model.is"
label="Year"
clearable
hide-details
:items="selectItems"
@click:clear="model.is = undefined"
<FilterSelectRange
v-model="model"
label-select="Year"
label-range="Year range"
:select-items="selectItems"
:min="min"
:max="max"
:disabled="disabled"
:range-mapper="(number) => number.toString()"
/>
<div class="px-2 pt-2">
<div class="d-flex justify-space-between align-center mb-2">
<span
class="text-body-large"
style="min-height: 26px"
>Year range</span
>
<v-chip
v-if="!!model.min && !!model.max"
:disabled="isSingle"
closable
color="primary"
rounded
size="small"
variant="elevated"
@click:close="clearRange()"
>{{ model.min }} - {{ model.max }}
</v-chip>
</div>
<v-range-slider
v-model="modelRange"
strict
hide-details
:disabled="disabled || isSingle"
:step="1"
:min="min"
:max="max"
thumb-label="hover"
/>
</div>
</template>
<script setup lang="ts">
@ -52,7 +23,7 @@ const intl = useIntl()
type ReleaseYears = v.InferOutput<typeof SchemaSeriesReleaseYears>
const model = defineModel<ReleaseYears>({ default: [] })
const model = defineModel<ReleaseYears>({ required: true })
const filterContext = inject(filterKeys.context, {})
@ -67,7 +38,6 @@ const { data: items } = useQuery(() => ({
}),
}))
const disabled = computed(() => (items.value?.totalElements || 0) === 0)
const isSingle = computed(() => model.value.is !== undefined)
const selectItems = computed(() => [
{
@ -83,19 +53,6 @@ const selectItems = computed(() => [
const min = computed(() => items.value?.content?.map((it) => Number(it))?.at(-1))
const max = computed(() => items.value?.content?.map((it) => Number(it))?.at(0))
const modelRange = computed({
get: () => [model.value.min || 0, model.value.max || 10000],
set: (newValue) => {
model.value.min = newValue?.[0]?.toString()
model.value.max = newValue?.[1]?.toString()
},
})
function clearRange() {
model.value.min = undefined
model.value.max = undefined
}
</script>
<script lang="ts"></script>

View file

@ -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<typeof SchemaSeriesAgeRatings>,
) {
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,
}
}

View file

@ -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')

View file

@ -96,11 +96,19 @@
<FilterExpansionPanel
title="Release year"
:count="!!filterReleaseYear.is ? 1 : !!filterReleaseYear.min ? 1 : 0"
@clear="clearFilterYear()"
@clear="clearFilterSelectRange(filterReleaseYear)"
>
<FilterByReleaseYear v-model="filterReleaseYear" />
</FilterExpansionPanel>
<FilterExpansionPanel
title="Age rating"
:count="!!filterAgeRating.is ? 1 : !!filterAgeRating.min ? 1 : 0"
@clear="clearFilterSelectRange(filterAgeRating)"
>
<FilterByAgeRating v-model="filterAgeRating" />
</FilterExpansionPanel>
<FilterExpansionPanel
title="Language"
:count="filterLanguage.v.length"
@ -235,13 +243,16 @@ import {
schemaFilterStringToConditions,
schemaFilterSeriesStatusToConditions,
schemaFilterReleaseYearToConditions,
schemaFilterAgeRatingToConditions,
} from '@/functions/filter'
import * as v from 'valibot'
import {
type FilterType,
type FilterTypeSelectRange,
SchemaFilterAuthors,
SchemaFilterSeriesStatus,
SchemaFilterStrings,
SchemaSeriesAgeRatings,
SchemaSeriesReleaseYears,
} from '@/types/filter'
import { useRouteQuerySchema } from '@/composables/useRouteQuerySchema'
@ -296,10 +307,10 @@ 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
function clearFilterSelectRange(filter: FilterTypeSelectRange) {
filter.is = undefined
filter.min = undefined
filter.max = undefined
}
const { data: filterSeriesStatus } = useRouteQuerySchema('status', SchemaFilterSeriesStatus)
@ -309,6 +320,7 @@ const { data: filterPublisher } = useRouteQuerySchema('publisher', SchemaFilterS
const { data: filterSharingLabel } = useRouteQuerySchema('sharingLabel', SchemaFilterStrings)
const { data: filterLanguage } = useRouteQuerySchema('language', SchemaFilterStrings)
const { data: filterReleaseYear } = useRouteQuerySchema('year', SchemaSeriesReleaseYears)
const { data: filterAgeRating } = useRouteQuerySchema('age', SchemaSeriesAgeRatings)
const conds = computed(() => ({
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)),
),

View file

@ -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)
})
})

View file

@ -95,6 +95,14 @@ function createSchemaFilterArray<T extends v.GenericSchema>(schema: T) {
})
}
function createSchemaFilterSelectRange<T extends v.GenericSchema>(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<typeof SchemaFilterAnyAll>
export const SchemaFilterArray = createSchemaFilterArray(v.unknown())
export type FilterTypeSimpleList = v.InferOutput<typeof SchemaFilterArray>
export const SchemaFilterSelectRange = createSchemaFilterSelectRange(
v.union([v.string(), v.number()]),
)
export type FilterTypeSelectRange = v.InferOutput<typeof SchemaFilterSelectRange>
export type FilterType = FilterTypeAnyAll | FilterTypeSimpleList