filter by release year

This commit is contained in:
Gauthier Roebroeck 2026-03-20 13:24:25 +08:00
parent ac2d3346b4
commit 5a205942df
10 changed files with 280 additions and 15 deletions

View file

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

View file

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

View file

@ -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: '<ReleaseYear 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: 'Release year filter.',
},
},
},
args: {
'onUpdate:modelValue': fn(),
modelValue: { is: undefined },
},
} satisfies Meta<typeof ReleaseYear>
export default meta
type Story = StoryObj<typeof meta>
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' },
},
}

View file

@ -0,0 +1,80 @@
<template>
<v-select
v-model="model.is"
label="Year"
clearable
:items="items?.content"
/>
<div class="d-flex justify-space-between align-center pb-2">
<span
class="text-body-large"
style="min-height: 26px"
>Year range</span
>
<v-chip
v-if="!!model.min && !!model.max"
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
:disabled="disabled"
:step="1"
:min="min"
:max="max"
thumb-label="hover"
/>
</template>
<script setup lang="ts">
import * as v from 'valibot'
import { filterKeys, SchemaSeriesReleaseYears } from '@/types/filter'
import { useQuery } from '@pinia/colada'
import { releaseYearsQuery } from '@/colada/referential'
import { PageRequest } from '@/types/PageRequest'
type ReleaseYears = v.InferOutput<typeof SchemaSeriesReleaseYears>
const model = defineModel<ReleaseYears>({ default: [] })
const filterContext = inject(filterKeys.context, {})
const apiQuery = {
...filterContext,
}
const { data: items } = useQuery(() => ({
...releaseYearsQuery({
pageRequest: PageRequest.Unpaged(),
...apiQuery,
}),
}))
const disabled = computed(() => (items.value?.totalElements || 0) === 0)
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>
<style scoped></style>

View file

@ -98,3 +98,30 @@ export function schemaFilterStringToConditions(
anyOf: list,
}
}
export function schemaFilterReleaseYearToConditions(
filter: InferOutput<typeof SchemaSeriesReleaseYears>,
) {
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,
}
}

View file

@ -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[];

View file

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

View file

@ -114,6 +114,14 @@
v-model:mode="filterLanguage.m"
/>
</FilterExpansionPanel>
<FilterExpansionPanel
title="Release year"
:count="(!!filterReleaseYear.is ? 1 : 0) + (!!filterReleaseYear.min ? 1 : 0)"
@clear="clearFilterYear()"
>
<FilterByReleaseYear v-model="filterReleaseYear" />
</FilterExpansionPanel>
</v-expansion-panels>
<v-divider />
@ -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)),
),

View file

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

View file

@ -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<T extends v.GenericSchema>(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