mirror of
https://github.com/gotson/komga.git
synced 2026-05-07 12:01:40 +02:00
filter by release year
This commit is contained in:
parent
ac2d3346b4
commit
5a205942df
10 changed files with 280 additions and 15 deletions
|
|
@ -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),
|
||||
}
|
||||
},
|
||||
)
|
||||
|
|
|
|||
1
next-ui/src/components.d.ts
vendored
1
next-ui/src/components.d.ts
vendored
|
|
@ -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']
|
||||
|
|
|
|||
52
next-ui/src/components/filter/by/ReleaseYear.stories.ts
Normal file
52
next-ui/src/components/filter/by/ReleaseYear.stories.ts
Normal 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' },
|
||||
},
|
||||
}
|
||||
80
next-ui/src/components/filter/by/ReleaseYear.vue
Normal file
80
next-ui/src/components/filter/by/ReleaseYear.vue
Normal 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>
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
24
next-ui/src/generated/openapi/komga.d.ts
vendored
24
next-ui/src/generated/openapi/komga.d.ts
vendored
|
|
@ -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[];
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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…
|
||||
|
|
|
|||
Loading…
Reference in a new issue