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.