language filter

This commit is contained in:
Gauthier Roebroeck 2026-03-20 10:23:33 +08:00
parent 1ca37aa589
commit f7a975f79f
8 changed files with 212 additions and 11 deletions

View file

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

View file

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

View file

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

View file

@ -0,0 +1,73 @@
<template>
<FilterSearchList
v-model="model"
v-model:mode="modelMode"
v-model:search="search"
:items="allItems"
:search-items="searchResults"
show-mode-selector
>
</FilterSearchList>
</template>
<script setup lang="ts">
import { useQuery } from '@pinia/colada'
import { languagesQuery } from '@/colada/referential'
import { PageRequest } from '@/types/PageRequest'
import * as v from 'valibot'
import { type AnyAll, filterKeys, filterMessages, SchemaString } from '@/types/filter'
import type { ItemType } from '@/components/filter/List.vue'
import { useIntl } from 'vue-intl'
import { languageDisplayNames } from '@/utils/i18n/locale-helper'
type SchString = v.InferOutput<typeof SchemaString>
const intl = useIntl()
const model = defineModel<SchString[]>({ default: [] })
const modelMode = defineModel<AnyAll>('mode', { default: 'anyOf' })
const search = ref()
const filterContext = inject(filterKeys.context, {})
const apiQuery = {
...filterContext,
}
const { data: items } = useQuery(() => ({
...languagesQuery({
pageRequest: PageRequest.Unpaged(),
...apiQuery,
}),
}))
const searchResults = computed(() =>
items.value?.content
?.map((it) => toItemType(it))
.filter((it) => it.title.toLocaleLowerCase().includes(search.value?.toLocaleLowerCase() || '')),
)
const allItems = computed(() => {
const itemTypes = (items.value?.content ?? []).map((it) => toItemType(it))
return [
{
title: intl.formatMessage(filterMessages.any!),
value: { a: 'any' },
valueExclude: { a: 'none' },
},
...itemTypes,
]
})
function toItemType(value: string): ItemType<SchString> {
return {
title: `${languageDisplayNames.of(value)} (${value})`,
value: { i: 'i', v: value },
valueExclude: { i: 'e', v: value },
}
}
</script>
<script lang="ts"></script>
<style scoped></style>

View file

@ -59,17 +59,26 @@ export function schemaFilterAuthorsToConditions(
}
}
export function schemaFilterNullableStringToConditions(
export function schemaFilterStringToConditions(
filter: InferOutput<typeof SchemaFilterStrings>,
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]: {

View file

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

View file

@ -103,6 +103,17 @@
v-model:mode="filterSharingLabel.m"
/>
</FilterExpansionPanel>
<FilterExpansionPanel
title="Language"
:count="filterLanguage.v.length"
@clear="clearFilter(filterLanguage)"
>
<FilterByLanguage
v-model="filterLanguage.v"
v-model:mode="filterLanguage.m"
/>
</FilterExpansionPanel>
</v-expansion-panels>
<v-divider />
@ -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)),
),

View file

@ -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.