From f7a975f79f2ee1c5ea3c54058183b7551fd2c67a Mon Sep 17 00:00:00 2001 From: Gauthier Roebroeck Date: Fri, 20 Mar 2026 10:23:33 +0800 Subject: [PATCH] language filter --- next-ui/src/colada/referential.ts | 33 +++++++++ next-ui/src/components.d.ts | 1 + .../components/filter/by/Language.stories.ts | 59 +++++++++++++++ next-ui/src/components/filter/by/Language.vue | 73 +++++++++++++++++++ next-ui/src/functions/filter.ts | 21 ++++-- next-ui/src/mocks/api/handlers/referential.ts | 12 +++ next-ui/src/pages/libraries/[id]/series.vue | 23 ++++-- next-ui/src/utils/i18n/locale-helper.ts | 1 + 8 files changed, 212 insertions(+), 11 deletions(-) create mode 100644 next-ui/src/components/filter/by/Language.stories.ts create mode 100644 next-ui/src/components/filter/by/Language.vue diff --git a/next-ui/src/colada/referential.ts b/next-ui/src/colada/referential.ts index a6a463ac..8d92ee6c 100644 --- a/next-ui/src/colada/referential.ts +++ b/next-ui/src/colada/referential.ts @@ -184,3 +184,36 @@ export const sharingLabelsQuery = defineQueryOptions( } }, ) + +export const languagesQuery = defineQueryOptions( + ({ + search, + library_id, + collection_id, + pageRequest, + }: { + search?: string + library_id?: string[] + collection_id?: string[] + pageRequest?: PageRequest + }) => { + const queryParams = { + search: search, + library_id: library_id, + collection_id: collection_id, + ...pageRequest, + } + return { + key: ['languages', queryParams], + query: () => + komgaClient + .GET('/api/v2/languages', { + 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 eb00a7da..e98cbf71 100644 --- a/next-ui/src/components.d.ts +++ b/next-ui/src/components.d.ts @@ -37,6 +37,7 @@ declare module 'vue' { FilterAnyAll: typeof import('./components/filter/AnyAll.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'] FilterByPublisher: typeof import('./components/filter/by/Publisher.vue')['default'] FilterBySeriesStatus: typeof import('./components/filter/by/SeriesStatus.vue')['default'] FilterBySharingLabel: typeof import('./components/filter/by/SharingLabel.vue')['default'] diff --git a/next-ui/src/components/filter/by/Language.stories.ts b/next-ui/src/components/filter/by/Language.stories.ts new file mode 100644 index 00000000..f54a4b90 --- /dev/null +++ b/next-ui/src/components/filter/by/Language.stories.ts @@ -0,0 +1,59 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' + +import Language from './Language.vue' +import { fn } from 'storybook/test' +import { httpTyped } from '@/mocks/api/httpTyped' +import { mockPage } from '@/mocks/api/pageable' +import { PageRequest } from '@/types/PageRequest' + +const meta = { + component: Language, + render: (args: object) => ({ + components: { Language }, + setup() { + return { args } + }, + template: '', + }), + parameters: { + // More on how to position stories at: https://storybook.js.org/docs/configure/story-layout + docs: { + description: { + component: 'Language filter.', + }, + }, + }, + args: { + 'onUpdate:modelValue': fn(), + modelValue: [], + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = { + args: {}, +} + +export const NoData: Story = { + parameters: { + msw: { + handlers: [ + httpTyped.get('/api/v2/languages', ({ response }) => + response(200).json(mockPage([], new PageRequest())), + ), + ], + }, + }, +} + +export const InitialValue: Story = { + args: { + modelValue: [ + { i: 'e', v: 'en' }, + { i: 'i', v: 'fr' }, + { i: 'i', v: 'it' }, + ], + }, +} diff --git a/next-ui/src/components/filter/by/Language.vue b/next-ui/src/components/filter/by/Language.vue new file mode 100644 index 00000000..bbf3ed4f --- /dev/null +++ b/next-ui/src/components/filter/by/Language.vue @@ -0,0 +1,73 @@ + + + + + + + diff --git a/next-ui/src/functions/filter.ts b/next-ui/src/functions/filter.ts index b60f989e..8f1158bd 100644 --- a/next-ui/src/functions/filter.ts +++ b/next-ui/src/functions/filter.ts @@ -59,17 +59,26 @@ export function schemaFilterAuthorsToConditions( } } -export function schemaFilterNullableStringToConditions( +export function schemaFilterStringToConditions( filter: InferOutput, key: string, + nullable: boolean, ) { const list = filter.v.map((it) => { if (v.is(SchemaAnyNone, it)) { - return { - [key]: { - operator: it.a === 'any' ? 'isNotNull' : 'isNull', - }, - } + if (nullable) + return { + [key]: { + operator: it.a === 'any' ? 'isNotNull' : 'isNull', + }, + } + else + return { + [key]: { + operator: it.a === 'any' ? 'isNot' : 'is', + value: '', + }, + } } else { return { [key]: { diff --git a/next-ui/src/mocks/api/handlers/referential.ts b/next-ui/src/mocks/api/handlers/referential.ts index 3d803adb..196016e1 100644 --- a/next-ui/src/mocks/api/handlers/referential.ts +++ b/next-ui/src/mocks/api/handlers/referential.ts @@ -36,6 +36,7 @@ const mockGenres = doMockStrings(10000, 'Genre') 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'] function filterAndPage( search: string | null, @@ -98,6 +99,17 @@ export const referentialHandlers = [ ), ), ), + httpTyped.get('/api/v2/languages', ({ query, response }) => + response(200).json( + filterAndPage( + query.get('search'), + mockLanguages, + 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 06621420..8b3a7380 100644 --- a/next-ui/src/pages/libraries/[id]/series.vue +++ b/next-ui/src/pages/libraries/[id]/series.vue @@ -103,6 +103,17 @@ v-model:mode="filterSharingLabel.m" /> + + + + @@ -213,7 +224,7 @@ import { useSelectionStore } from '@/stores/selection' import { useDisplay } from 'vuetify' import { schemaFilterAuthorsToConditions, - schemaFilterNullableStringToConditions, + schemaFilterStringToConditions, schemaFilterSeriesStatusToConditions, } from '@/functions/filter' import * as v from 'valibot' @@ -280,15 +291,17 @@ 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 conds = computed(() => ({ allOf: [ librariesCondition.value as components['schemas']['AnyOfSeries'], schemaFilterSeriesStatusToConditions(filterSeriesStatus.value), - schemaFilterNullableStringToConditions(filterGenre.value, 'genre'), - schemaFilterNullableStringToConditions(filterTag.value, 'tag'), - schemaFilterNullableStringToConditions(filterPublisher.value, 'publisher'), - schemaFilterNullableStringToConditions(filterSharingLabel.value, 'sharingLabel'), + schemaFilterStringToConditions(filterGenre.value, 'genre', true), + schemaFilterStringToConditions(filterTag.value, 'tag', true), + schemaFilterStringToConditions(filterPublisher.value, 'publisher', false), + schemaFilterStringToConditions(filterSharingLabel.value, 'sharingLabel', true), + schemaFilterStringToConditions(filterLanguage.value, 'language', false), ...Object.entries(filterAuthors).map(([, filter]) => schemaFilterAuthorsToConditions(toValue(filter.filter), toValue(filter.role)), ), diff --git a/next-ui/src/utils/i18n/locale-helper.ts b/next-ui/src/utils/i18n/locale-helper.ts index 13392e28..04c74efb 100644 --- a/next-ui/src/utils/i18n/locale-helper.ts +++ b/next-ui/src/utils/i18n/locale-helper.ts @@ -55,6 +55,7 @@ export function getLocale(): string { } export const currentLocale = getLocale() +export const languageDisplayNames = new Intl.DisplayNames(currentLocale, { type: 'language' }) /** * Save the locale to localStorage and reloads the window if it has changed.