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