diff --git a/next-ui/src/colada/referential.ts b/next-ui/src/colada/referential.ts
index a9c39ad1..05691360 100644
--- a/next-ui/src/colada/referential.ts
+++ b/next-ui/src/colada/referential.ts
@@ -63,3 +63,168 @@ export const authorsQuery = defineQueryOptions(
}
},
)
+
+export const genresQuery = defineQueryOptions(
+ ({
+ search,
+ library_id,
+ collection_id,
+ pageRequest,
+ pause = false,
+ placeholder = true,
+ }: {
+ search?: string
+ library_id?: string[]
+ collection_id?: string[]
+ pageRequest?: PageRequest
+ pause?: boolean
+ placeholder?: boolean
+ }) => {
+ const queryParams = {
+ search: search,
+ library_id: library_id,
+ collection_id: collection_id,
+ ...pageRequest,
+ }
+ return {
+ key: ['genres', queryParams],
+ query: () =>
+ komgaClient
+ .GET('/api/v2/genres', {
+ params: {
+ query: queryParams,
+ },
+ })
+ // unwrap the openapi-fetch structure on success
+ .then((res) => res.data),
+ enabled: !pause,
+ placeholderData: placeholder ? (previousData: any) => previousData : undefined, // eslint-disable-line @typescript-eslint/no-explicit-any
+ }
+ },
+)
+
+export const tagsQuery = defineQueryOptions(
+ ({
+ search,
+ library_id,
+ collection_id,
+ series_id,
+ readlist_id,
+ include,
+ pageRequest,
+ pause = false,
+ placeholder = true,
+ }: {
+ search?: string
+ library_id?: string[]
+ collection_id?: string[]
+ series_id?: string[]
+ readlist_id?: string[]
+ include?: 'SERIES' | 'BOOK' | 'BOTH'
+ pageRequest?: PageRequest
+ pause?: boolean
+ placeholder?: boolean
+ }) => {
+ const queryParams = {
+ search: search,
+ library_id: library_id,
+ collection_id: collection_id,
+ series_id: series_id,
+ readlist_id: readlist_id,
+ include: include,
+ ...pageRequest,
+ }
+ return {
+ key: ['tags', queryParams],
+ query: () =>
+ komgaClient
+ .GET('/api/v2/tags', {
+ params: {
+ query: queryParams,
+ },
+ })
+ // unwrap the openapi-fetch structure on success
+ .then((res) => res.data),
+ enabled: !pause,
+ placeholderData: placeholder ? (previousData: any) => previousData : undefined, // eslint-disable-line @typescript-eslint/no-explicit-any
+ }
+ },
+)
+
+export const publishersQuery = defineQueryOptions(
+ ({
+ search,
+ library_id,
+ collection_id,
+ pageRequest,
+ pause = false,
+ placeholder = true,
+ }: {
+ search?: string
+ library_id?: string[]
+ collection_id?: string[]
+ pageRequest?: PageRequest
+ pause?: boolean
+ placeholder?: boolean
+ }) => {
+ const queryParams = {
+ search: search,
+ library_id: library_id,
+ collection_id: collection_id,
+ ...pageRequest,
+ }
+ return {
+ key: ['publishers', queryParams],
+ query: () =>
+ komgaClient
+ .GET('/api/v2/publishers', {
+ params: {
+ query: queryParams,
+ },
+ })
+ // unwrap the openapi-fetch structure on success
+ .then((res) => res.data),
+ enabled: !pause,
+ placeholderData: placeholder ? (previousData: any) => previousData : undefined, // eslint-disable-line @typescript-eslint/no-explicit-any
+ }
+ },
+)
+
+export const sharingLabelsQuery = defineQueryOptions(
+ ({
+ search,
+ library_id,
+ collection_id,
+ pageRequest,
+ pause = false,
+ placeholder = true,
+ }: {
+ search?: string
+ library_id?: string[]
+ collection_id?: string[]
+ pageRequest?: PageRequest
+ pause?: boolean
+ placeholder?: boolean
+ }) => {
+ const queryParams = {
+ search: search,
+ library_id: library_id,
+ collection_id: collection_id,
+ ...pageRequest,
+ }
+ return {
+ key: ['sharing-labels', queryParams],
+ query: () =>
+ komgaClient
+ .GET('/api/v2/sharing-labels', {
+ params: {
+ query: queryParams,
+ },
+ })
+ // unwrap the openapi-fetch structure on success
+ .then((res) => res.data),
+ enabled: !pause,
+ placeholderData: placeholder ? (previousData: any) => previousData : undefined, // eslint-disable-line @typescript-eslint/no-explicit-any
+ }
+ },
+)
diff --git a/next-ui/src/components.d.ts b/next-ui/src/components.d.ts
index d4eb68e4..caac53fb 100644
--- a/next-ui/src/components.d.ts
+++ b/next-ui/src/components.d.ts
@@ -38,7 +38,11 @@ declare module 'vue' {
EmptyStateNetworkError: typeof import('./components/EmptyStateNetworkError.vue')['default']
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']
+ 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']
+ FilterByTag: typeof import('./components/filter/by/Tag.vue')['default']
FilterExpansionPanel: typeof import('./components/filter/ExpansionPanel.vue')['default']
FilterList: typeof import('./components/filter/List.vue')['default']
FilterSearchList: typeof import('./components/filter/SearchList.vue')['default']
diff --git a/next-ui/src/components/filter/by/Genre.stories.ts b/next-ui/src/components/filter/by/Genre.stories.ts
new file mode 100644
index 00000000..21465134
--- /dev/null
+++ b/next-ui/src/components/filter/by/Genre.stories.ts
@@ -0,0 +1,65 @@
+import type { Meta, StoryObj } from '@storybook/vue3-vite'
+
+import Genre from './Genre.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: Genre,
+ render: (args: object) => ({
+ components: { Genre },
+ setup() {
+ return { args }
+ },
+ template: '',
+ }),
+ parameters: {
+ // More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
+ docs: {
+ description: {
+ component: 'Genre 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/genres', ({ response }) =>
+ response(200).json(mockPage([], new PageRequest())),
+ ),
+ ],
+ },
+ },
+}
+
+export const InitialValue: Story = {
+ args: {
+ modelValue: [
+ { i: 'e', v: 'Genre 3' },
+ { i: 'i', v: 'Genre 5' },
+ { i: 'i', v: 'Genre 8' },
+ ],
+ },
+}
+
+export const InitialValueOutsideShown: Story = {
+ args: {
+ modelValue: [{ i: 'i', v: 'Genre 100' }],
+ },
+}
diff --git a/next-ui/src/components/filter/by/Genre.vue b/next-ui/src/components/filter/by/Genre.vue
new file mode 100644
index 00000000..23d5b74e
--- /dev/null
+++ b/next-ui/src/components/filter/by/Genre.vue
@@ -0,0 +1,94 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/next-ui/src/components/filter/by/Publisher.stories.ts b/next-ui/src/components/filter/by/Publisher.stories.ts
new file mode 100644
index 00000000..b602742c
--- /dev/null
+++ b/next-ui/src/components/filter/by/Publisher.stories.ts
@@ -0,0 +1,65 @@
+import type { Meta, StoryObj } from '@storybook/vue3-vite'
+
+import Publisher from './Publisher.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: Publisher,
+ render: (args: object) => ({
+ components: { Publisher },
+ setup() {
+ return { args }
+ },
+ template: '',
+ }),
+ parameters: {
+ // More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
+ docs: {
+ description: {
+ component: 'Publisher 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/publishers', ({ response }) =>
+ response(200).json(mockPage([], new PageRequest())),
+ ),
+ ],
+ },
+ },
+}
+
+export const InitialValue: Story = {
+ args: {
+ modelValue: [
+ { i: 'e', v: 'Publisher 3' },
+ { i: 'i', v: 'Publisher 5' },
+ { i: 'i', v: 'Publisher 8' },
+ ],
+ },
+}
+
+export const InitialValueOutsideShown: Story = {
+ args: {
+ modelValue: [{ i: 'i', v: 'Publisher 100' }],
+ },
+}
diff --git a/next-ui/src/components/filter/by/Publisher.vue b/next-ui/src/components/filter/by/Publisher.vue
new file mode 100644
index 00000000..aa836dbd
--- /dev/null
+++ b/next-ui/src/components/filter/by/Publisher.vue
@@ -0,0 +1,94 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/next-ui/src/components/filter/by/SharingLabel.stories.ts b/next-ui/src/components/filter/by/SharingLabel.stories.ts
new file mode 100644
index 00000000..eb85055e
--- /dev/null
+++ b/next-ui/src/components/filter/by/SharingLabel.stories.ts
@@ -0,0 +1,65 @@
+import type { Meta, StoryObj } from '@storybook/vue3-vite'
+
+import SharingLabel from './SharingLabel.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: SharingLabel,
+ render: (args: object) => ({
+ components: { SharingLabel },
+ setup() {
+ return { args }
+ },
+ template: '',
+ }),
+ parameters: {
+ // More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
+ docs: {
+ description: {
+ component: 'SharingLabel 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/sharing-labels', ({ response }) =>
+ response(200).json(mockPage([], new PageRequest())),
+ ),
+ ],
+ },
+ },
+}
+
+export const InitialValue: Story = {
+ args: {
+ modelValue: [
+ { i: 'e', v: 'SharingLabel 3' },
+ { i: 'i', v: 'SharingLabel 5' },
+ { i: 'i', v: 'SharingLabel 8' },
+ ],
+ },
+}
+
+export const InitialValueOutsideShown: Story = {
+ args: {
+ modelValue: [{ i: 'i', v: 'SharingLabel 100' }],
+ },
+}
diff --git a/next-ui/src/components/filter/by/SharingLabel.vue b/next-ui/src/components/filter/by/SharingLabel.vue
new file mode 100644
index 00000000..a9c0a298
--- /dev/null
+++ b/next-ui/src/components/filter/by/SharingLabel.vue
@@ -0,0 +1,94 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/next-ui/src/components/filter/by/Tag.stories.ts b/next-ui/src/components/filter/by/Tag.stories.ts
new file mode 100644
index 00000000..98a39f1f
--- /dev/null
+++ b/next-ui/src/components/filter/by/Tag.stories.ts
@@ -0,0 +1,65 @@
+import type { Meta, StoryObj } from '@storybook/vue3-vite'
+
+import Tag from './Tag.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: Tag,
+ render: (args: object) => ({
+ components: { Tag },
+ setup() {
+ return { args }
+ },
+ template: '',
+ }),
+ parameters: {
+ // More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
+ docs: {
+ description: {
+ component: 'Tag 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/tags', ({ response }) =>
+ response(200).json(mockPage([], new PageRequest())),
+ ),
+ ],
+ },
+ },
+}
+
+export const InitialValue: Story = {
+ args: {
+ modelValue: [
+ { i: 'e', v: 'Tag 3' },
+ { i: 'i', v: 'Tag 5' },
+ { i: 'i', v: 'Tag 8' },
+ ],
+ },
+}
+
+export const InitialValueOutsideShown: Story = {
+ args: {
+ modelValue: [{ i: 'i', v: 'Tag 100' }],
+ },
+}
diff --git a/next-ui/src/components/filter/by/Tag.vue b/next-ui/src/components/filter/by/Tag.vue
new file mode 100644
index 00000000..75926a01
--- /dev/null
+++ b/next-ui/src/components/filter/by/Tag.vue
@@ -0,0 +1,99 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/next-ui/src/functions/filter.ts b/next-ui/src/functions/filter.ts
index e2b453dd..b60f989e 100644
--- a/next-ui/src/functions/filter.ts
+++ b/next-ui/src/functions/filter.ts
@@ -1,4 +1,9 @@
-import { SchemaAnyNone, SchemaFilterAuthors, type SchemaFilterSeriesStatus } from '@/types/filter'
+import {
+ SchemaAnyNone,
+ SchemaFilterAuthors,
+ type SchemaFilterSeriesStatus,
+ SchemaFilterStrings,
+} from '@/types/filter'
import type { InferOutput } from 'valibot'
import * as v from 'valibot'
@@ -53,3 +58,34 @@ export function schemaFilterAuthorsToConditions(
anyOf: list,
}
}
+
+export function schemaFilterNullableStringToConditions(
+ filter: InferOutput,
+ key: string,
+) {
+ const list = filter.v.map((it) => {
+ if (v.is(SchemaAnyNone, it)) {
+ return {
+ [key]: {
+ operator: it.a === 'any' ? 'isNotNull' : 'isNull',
+ },
+ }
+ } else {
+ return {
+ [key]: {
+ operator: it.i === 'e' ? 'isNot' : 'is',
+ value: it.v,
+ },
+ }
+ }
+ })
+
+ if (filter.m === 'allOf')
+ return {
+ allOf: list,
+ }
+ else
+ return {
+ anyOf: list,
+ }
+}
diff --git a/next-ui/src/generated/openapi/komga.d.ts b/next-ui/src/generated/openapi/komga.d.ts
index 8c9b6d55..ce2cd482 100644
--- a/next-ui/src/generated/openapi/komga.d.ts
+++ b/next-ui/src/generated/openapi/komga.d.ts
@@ -9644,8 +9644,8 @@ export interface operations {
search?: string;
library_id?: string[];
collection_id?: string[];
- series_id?: string;
- readlist_id?: string;
+ series_id?: string[];
+ readlist_id?: string[];
include?: "SERIES" | "BOOK" | "BOTH";
unpaged?: boolean;
/** @description Zero-based page index (0..N) */
diff --git a/next-ui/src/mocks/api/handlers/referential.ts b/next-ui/src/mocks/api/handlers/referential.ts
index a472ba48..3d803adb 100644
--- a/next-ui/src/mocks/api/handlers/referential.ts
+++ b/next-ui/src/mocks/api/handlers/referential.ts
@@ -25,11 +25,79 @@ function doMockAuthors(count: number) {
} as components['schemas']['AuthorDto']
})
}
-
const mockAuthors = doMockAuthors(10000)
+function doMockStrings(count: number, prefix: string) {
+ return [...Array(count).keys()].map((index) => {
+ return `${prefix} ${index}`
+ })
+}
+const mockGenres = doMockStrings(10000, 'Genre')
+const mockTags = doMockStrings(10000, 'Tag')
+const mockPublishers = doMockStrings(10000, 'Publisher')
+const mockSharingLabels = doMockStrings(150, 'SharingLabel')
+
+function filterAndPage(
+ search: string | null,
+ data: string[],
+ page: string | null,
+ size: string | null,
+ unpaged: string | null,
+) {
+ const selected = search ? data.filter((it) => !!it.match(new RegExp(search, 'i'))) : data
+
+ return mockPage(
+ selected,
+ new PageRequest(Number(page), Number(size), undefined, Boolean(unpaged)),
+ )
+}
+
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(
+ query.get('search'),
+ mockGenres,
+ query.get('page'),
+ query.get('size'),
+ query.get('unpaged'),
+ ),
+ ),
+ ),
+ httpTyped.get('/api/v2/tags', ({ query, response }) =>
+ response(200).json(
+ filterAndPage(
+ query.get('search'),
+ mockTags,
+ query.get('page'),
+ query.get('size'),
+ query.get('unpaged'),
+ ),
+ ),
+ ),
+ httpTyped.get('/api/v2/publishers', ({ query, response }) =>
+ response(200).json(
+ filterAndPage(
+ query.get('search'),
+ mockPublishers,
+ query.get('page'),
+ query.get('size'),
+ query.get('unpaged'),
+ ),
+ ),
+ ),
+ httpTyped.get('/api/v2/sharing-labels', ({ query, response }) =>
+ response(200).json(
+ filterAndPage(
+ query.get('search'),
+ mockSharingLabels,
+ 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 5f2648f2..37e92213 100644
--- a/next-ui/src/pages/libraries/[id]/series.vue
+++ b/next-ui/src/pages/libraries/[id]/series.vue
@@ -59,6 +59,50 @@
>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -90,12 +134,6 @@
- FILTER AUTHORS
- {{ filterAuthors }}
- AUTHOR ROLES
- {{ authorRoles }}
- FILTER
- {{ filterSeriesStatus }}
CONDITION
{{ conds }}
@@ -175,10 +213,16 @@ import { useSelectionStore } from '@/stores/selection'
import { useDisplay } from 'vuetify'
import {
schemaFilterAuthorsToConditions,
+ schemaFilterNullableStringToConditions,
schemaFilterSeriesStatusToConditions,
} from '@/functions/filter'
import * as v from 'valibot'
-import { type FilterType, SchemaFilterAuthors, SchemaFilterSeriesStatus } from '@/types/filter'
+import {
+ type FilterType,
+ SchemaFilterAuthors,
+ SchemaFilterSeriesStatus,
+ SchemaFilterStrings,
+} from '@/types/filter'
import { useRouteQuerySchema } from '@/composables/useRouteQuerySchema'
import { authorRoles } from '@/types/referential'
import { useIntl } from 'vue-intl'
@@ -232,11 +276,19 @@ function clearFilter(filter: FilterType) {
}
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 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'),
...Object.entries(filterAuthors).map(([, filter]) =>
schemaFilterAuthorsToConditions(toValue(filter.filter), toValue(filter.role)),
),