mirror of
https://github.com/gotson/komga.git
synced 2026-05-07 20:15:47 +02:00
more filters
This commit is contained in:
parent
4d1c87d765
commit
3a4894fde0
14 changed files with 977 additions and 11 deletions
|
|
@ -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
|
||||
}
|
||||
},
|
||||
)
|
||||
|
|
|
|||
4
next-ui/src/components.d.ts
vendored
4
next-ui/src/components.d.ts
vendored
|
|
@ -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']
|
||||
|
|
|
|||
65
next-ui/src/components/filter/by/Genre.stories.ts
Normal file
65
next-ui/src/components/filter/by/Genre.stories.ts
Normal file
|
|
@ -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: '<Genre v-model="args.modelValue"/>',
|
||||
}),
|
||||
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<typeof Genre>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
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' }],
|
||||
},
|
||||
}
|
||||
94
next-ui/src/components/filter/by/Genre.vue
Normal file
94
next-ui/src/components/filter/by/Genre.vue
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
<template>
|
||||
<FilterSearchList
|
||||
v-model="model"
|
||||
v-model:mode="modelMode"
|
||||
v-model:search="search"
|
||||
:items="infiniteItems"
|
||||
:search-items="searchResults"
|
||||
:search-loading="searchLoading"
|
||||
show-mode-selector
|
||||
@load-more="loadNextPage()"
|
||||
>
|
||||
</FilterSearchList>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useInfiniteQuery, useQuery } from '@pinia/colada'
|
||||
import { genresQuery } from '@/colada/referential'
|
||||
import { PageRequest } from '@/types/PageRequest'
|
||||
import { komgaClient } from '@/api/komga-client'
|
||||
import * as v from 'valibot'
|
||||
import { type AnyAll, filterKeys, filterMessages, SchemaString } from '@/types/filter'
|
||||
import type { ItemType } from '@/components/filter/List.vue'
|
||||
import { refDebounced } from '@vueuse/core'
|
||||
import { useIntl } from 'vue-intl'
|
||||
|
||||
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 searchDebounced = refDebounced(search, 500)
|
||||
|
||||
const filterContext = inject(filterKeys.context, {})
|
||||
|
||||
const apiQuery = {
|
||||
...filterContext,
|
||||
}
|
||||
|
||||
const { data: searchItems, isLoading: searchLoading } = useQuery(genresQuery, () => ({
|
||||
pageRequest: PageRequest.Unpaged(),
|
||||
search: searchDebounced.value,
|
||||
pause: !searchDebounced.value,
|
||||
placeholder: false,
|
||||
...apiQuery,
|
||||
}))
|
||||
const searchResults = computed(() => searchItems.value?.content?.map((it) => toItemType(it)))
|
||||
|
||||
const { data: infiniteData, loadNextPage } = useInfiniteQuery({
|
||||
key: () => ['infinite_genres', apiQuery],
|
||||
initialPageParam: new PageRequest(0, 50),
|
||||
query: ({ pageParam }) =>
|
||||
komgaClient
|
||||
.GET('/api/v2/genres', {
|
||||
params: {
|
||||
query: {
|
||||
...apiQuery,
|
||||
...pageParam,
|
||||
},
|
||||
},
|
||||
})
|
||||
// unwrap the openapi-fetch structure on success
|
||||
.then((res) => res.data),
|
||||
getNextPageParam: (lastPage) =>
|
||||
!lastPage?.last ? new PageRequest((lastPage?.number ?? 0) + 1, lastPage?.size) : null,
|
||||
})
|
||||
const infiniteItems = computed(() => {
|
||||
const itemTypes = (infiniteData.value?.pages.flatMap((it) => it?.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: value,
|
||||
value: { i: 'i', v: value },
|
||||
valueExclude: { i: 'e', v: value },
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts"></script>
|
||||
|
||||
<style scoped></style>
|
||||
65
next-ui/src/components/filter/by/Publisher.stories.ts
Normal file
65
next-ui/src/components/filter/by/Publisher.stories.ts
Normal file
|
|
@ -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: '<Publisher v-model="args.modelValue"/>',
|
||||
}),
|
||||
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<typeof Publisher>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
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' }],
|
||||
},
|
||||
}
|
||||
94
next-ui/src/components/filter/by/Publisher.vue
Normal file
94
next-ui/src/components/filter/by/Publisher.vue
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
<template>
|
||||
<FilterSearchList
|
||||
v-model="model"
|
||||
v-model:mode="modelMode"
|
||||
v-model:search="search"
|
||||
:items="infiniteItems"
|
||||
:search-items="searchResults"
|
||||
:search-loading="searchLoading"
|
||||
show-mode-selector
|
||||
@load-more="loadNextPage()"
|
||||
>
|
||||
</FilterSearchList>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useInfiniteQuery, useQuery } from '@pinia/colada'
|
||||
import { publishersQuery } from '@/colada/referential'
|
||||
import { PageRequest } from '@/types/PageRequest'
|
||||
import { komgaClient } from '@/api/komga-client'
|
||||
import * as v from 'valibot'
|
||||
import { type AnyAll, filterKeys, filterMessages, SchemaString } from '@/types/filter'
|
||||
import type { ItemType } from '@/components/filter/List.vue'
|
||||
import { refDebounced } from '@vueuse/core'
|
||||
import { useIntl } from 'vue-intl'
|
||||
|
||||
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 searchDebounced = refDebounced(search, 500)
|
||||
|
||||
const filterContext = inject(filterKeys.context, {})
|
||||
|
||||
const apiQuery = {
|
||||
...filterContext,
|
||||
}
|
||||
|
||||
const { data: searchItems, isLoading: searchLoading } = useQuery(publishersQuery, () => ({
|
||||
pageRequest: PageRequest.Unpaged(),
|
||||
search: searchDebounced.value,
|
||||
pause: !searchDebounced.value,
|
||||
placeholder: false,
|
||||
...apiQuery,
|
||||
}))
|
||||
const searchResults = computed(() => searchItems.value?.content?.map((it) => toItemType(it)))
|
||||
|
||||
const { data: infiniteData, loadNextPage } = useInfiniteQuery({
|
||||
key: () => ['infinite_publishers', apiQuery],
|
||||
initialPageParam: new PageRequest(0, 50),
|
||||
query: ({ pageParam }) =>
|
||||
komgaClient
|
||||
.GET('/api/v2/publishers', {
|
||||
params: {
|
||||
query: {
|
||||
...apiQuery,
|
||||
...pageParam,
|
||||
},
|
||||
},
|
||||
})
|
||||
// unwrap the openapi-fetch structure on success
|
||||
.then((res) => res.data),
|
||||
getNextPageParam: (lastPage) =>
|
||||
!lastPage?.last ? new PageRequest((lastPage?.number ?? 0) + 1, lastPage?.size) : null,
|
||||
})
|
||||
const infiniteItems = computed(() => {
|
||||
const itemTypes = (infiniteData.value?.pages.flatMap((it) => it?.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: value,
|
||||
value: { i: 'i', v: value },
|
||||
valueExclude: { i: 'e', v: value },
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts"></script>
|
||||
|
||||
<style scoped></style>
|
||||
65
next-ui/src/components/filter/by/SharingLabel.stories.ts
Normal file
65
next-ui/src/components/filter/by/SharingLabel.stories.ts
Normal file
|
|
@ -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: '<SharingLabel v-model="args.modelValue"/>',
|
||||
}),
|
||||
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<typeof SharingLabel>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
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' }],
|
||||
},
|
||||
}
|
||||
94
next-ui/src/components/filter/by/SharingLabel.vue
Normal file
94
next-ui/src/components/filter/by/SharingLabel.vue
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
<template>
|
||||
<FilterSearchList
|
||||
v-model="model"
|
||||
v-model:mode="modelMode"
|
||||
v-model:search="search"
|
||||
:items="infiniteItems"
|
||||
:search-items="searchResults"
|
||||
:search-loading="searchLoading"
|
||||
show-mode-selector
|
||||
@load-more="loadNextPage()"
|
||||
>
|
||||
</FilterSearchList>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useInfiniteQuery, useQuery } from '@pinia/colada'
|
||||
import { sharingLabelsQuery } from '@/colada/referential'
|
||||
import { PageRequest } from '@/types/PageRequest'
|
||||
import { komgaClient } from '@/api/komga-client'
|
||||
import * as v from 'valibot'
|
||||
import { type AnyAll, filterKeys, filterMessages, SchemaString } from '@/types/filter'
|
||||
import type { ItemType } from '@/components/filter/List.vue'
|
||||
import { refDebounced } from '@vueuse/core'
|
||||
import { useIntl } from 'vue-intl'
|
||||
|
||||
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 searchDebounced = refDebounced(search, 500)
|
||||
|
||||
const filterContext = inject(filterKeys.context, {})
|
||||
|
||||
const apiQuery = {
|
||||
...filterContext,
|
||||
}
|
||||
|
||||
const { data: searchItems, isLoading: searchLoading } = useQuery(sharingLabelsQuery, () => ({
|
||||
pageRequest: PageRequest.Unpaged(),
|
||||
search: searchDebounced.value,
|
||||
pause: !searchDebounced.value,
|
||||
placeholder: false,
|
||||
...apiQuery,
|
||||
}))
|
||||
const searchResults = computed(() => searchItems.value?.content?.map((it) => toItemType(it)))
|
||||
|
||||
const { data: infiniteData, loadNextPage } = useInfiniteQuery({
|
||||
key: () => ['infinite_sharing-labels', apiQuery],
|
||||
initialPageParam: new PageRequest(0, 50),
|
||||
query: ({ pageParam }) =>
|
||||
komgaClient
|
||||
.GET('/api/v2/sharing-labels', {
|
||||
params: {
|
||||
query: {
|
||||
...apiQuery,
|
||||
...pageParam,
|
||||
},
|
||||
},
|
||||
})
|
||||
// unwrap the openapi-fetch structure on success
|
||||
.then((res) => res.data),
|
||||
getNextPageParam: (lastPage) =>
|
||||
!lastPage?.last ? new PageRequest((lastPage?.number ?? 0) + 1, lastPage?.size) : null,
|
||||
})
|
||||
const infiniteItems = computed(() => {
|
||||
const itemTypes = (infiniteData.value?.pages.flatMap((it) => it?.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: value,
|
||||
value: { i: 'i', v: value },
|
||||
valueExclude: { i: 'e', v: value },
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts"></script>
|
||||
|
||||
<style scoped></style>
|
||||
65
next-ui/src/components/filter/by/Tag.stories.ts
Normal file
65
next-ui/src/components/filter/by/Tag.stories.ts
Normal file
|
|
@ -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: '<Tag v-model="args.modelValue"/>',
|
||||
}),
|
||||
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<typeof Tag>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
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' }],
|
||||
},
|
||||
}
|
||||
99
next-ui/src/components/filter/by/Tag.vue
Normal file
99
next-ui/src/components/filter/by/Tag.vue
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
<template>
|
||||
<FilterSearchList
|
||||
v-model="model"
|
||||
v-model:mode="modelMode"
|
||||
v-model:search="search"
|
||||
:items="infiniteItems"
|
||||
:search-items="searchResults"
|
||||
:search-loading="searchLoading"
|
||||
show-mode-selector
|
||||
@load-more="loadNextPage()"
|
||||
>
|
||||
</FilterSearchList>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useInfiniteQuery, useQuery } from '@pinia/colada'
|
||||
import { tagsQuery } from '@/colada/referential'
|
||||
import { PageRequest } from '@/types/PageRequest'
|
||||
import { komgaClient } from '@/api/komga-client'
|
||||
import * as v from 'valibot'
|
||||
import { type AnyAll, filterKeys, filterMessages, SchemaString } from '@/types/filter'
|
||||
import type { ItemType } from '@/components/filter/List.vue'
|
||||
import { refDebounced } from '@vueuse/core'
|
||||
import { useIntl } from 'vue-intl'
|
||||
|
||||
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 searchDebounced = refDebounced(search, 500)
|
||||
|
||||
const { include = 'BOTH' } = defineProps<{
|
||||
include?: 'SERIES' | 'BOOK' | 'BOTH'
|
||||
}>()
|
||||
|
||||
const filterContext = inject(filterKeys.context, {})
|
||||
|
||||
const apiQuery = {
|
||||
...filterContext,
|
||||
include: include,
|
||||
}
|
||||
|
||||
const { data: searchItems, isLoading: searchLoading } = useQuery(tagsQuery, () => ({
|
||||
pageRequest: PageRequest.Unpaged(),
|
||||
search: searchDebounced.value,
|
||||
pause: !searchDebounced.value,
|
||||
placeholder: false,
|
||||
...apiQuery,
|
||||
}))
|
||||
const searchResults = computed(() => searchItems.value?.content?.map((it) => toItemType(it)))
|
||||
|
||||
const { data: infiniteData, loadNextPage } = useInfiniteQuery({
|
||||
key: () => ['infinite_tags', apiQuery],
|
||||
initialPageParam: new PageRequest(0, 50),
|
||||
query: ({ pageParam }) =>
|
||||
komgaClient
|
||||
.GET('/api/v2/tags', {
|
||||
params: {
|
||||
query: {
|
||||
...apiQuery,
|
||||
...pageParam,
|
||||
},
|
||||
},
|
||||
})
|
||||
// unwrap the openapi-fetch structure on success
|
||||
.then((res) => res.data),
|
||||
getNextPageParam: (lastPage) =>
|
||||
!lastPage?.last ? new PageRequest((lastPage?.number ?? 0) + 1, lastPage?.size) : null,
|
||||
})
|
||||
const infiniteItems = computed(() => {
|
||||
const itemTypes = (infiniteData.value?.pages.flatMap((it) => it?.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: value,
|
||||
value: { i: 'i', v: value },
|
||||
valueExclude: { i: 'e', v: value },
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts"></script>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
@ -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<typeof SchemaFilterStrings>,
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
4
next-ui/src/generated/openapi/komga.d.ts
vendored
4
next-ui/src/generated/openapi/komga.d.ts
vendored
|
|
@ -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) */
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -59,6 +59,50 @@
|
|||
>
|
||||
<FilterBySeriesStatus v-model="filterSeriesStatus.v" />
|
||||
</FilterExpansionPanel>
|
||||
|
||||
<FilterExpansionPanel
|
||||
title="Genre"
|
||||
:count="filterGenre.v.length"
|
||||
@clear="clearFilter(filterGenre)"
|
||||
>
|
||||
<FilterByGenre
|
||||
v-model="filterGenre.v"
|
||||
v-model:mode="filterGenre.m"
|
||||
/>
|
||||
</FilterExpansionPanel>
|
||||
|
||||
<FilterExpansionPanel
|
||||
title="Tag"
|
||||
:count="filterTag.v.length"
|
||||
@clear="clearFilter(filterTag)"
|
||||
>
|
||||
<FilterByTag
|
||||
v-model="filterTag.v"
|
||||
v-model:mode="filterTag.m"
|
||||
/>
|
||||
</FilterExpansionPanel>
|
||||
|
||||
<FilterExpansionPanel
|
||||
title="Publisher"
|
||||
:count="filterPublisher.v.length"
|
||||
@clear="clearFilter(filterPublisher)"
|
||||
>
|
||||
<FilterByPublisher
|
||||
v-model="filterPublisher.v"
|
||||
v-model:mode="filterPublisher.m"
|
||||
/>
|
||||
</FilterExpansionPanel>
|
||||
|
||||
<FilterExpansionPanel
|
||||
title="Sharing label"
|
||||
:count="filterSharingLabel.v.length"
|
||||
@clear="clearFilter(filterSharingLabel)"
|
||||
>
|
||||
<FilterBySharingLabel
|
||||
v-model="filterSharingLabel.v"
|
||||
v-model:mode="filterSharingLabel.m"
|
||||
/>
|
||||
</FilterExpansionPanel>
|
||||
</v-expansion-panels>
|
||||
|
||||
<v-divider />
|
||||
|
|
@ -90,12 +134,6 @@
|
|||
</v-navigation-drawer>
|
||||
</Teleport>
|
||||
|
||||
<div>FILTER AUTHORS</div>
|
||||
<p>{{ filterAuthors }}</p>
|
||||
<div>AUTHOR ROLES</div>
|
||||
<p>{{ authorRoles }}</p>
|
||||
<div>FILTER</div>
|
||||
<div>{{ filterSeriesStatus }}</div>
|
||||
<div>CONDITION</div>
|
||||
<div>{{ conds }}</div>
|
||||
|
||||
|
|
@ -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)),
|
||||
),
|
||||
|
|
|
|||
Loading…
Reference in a new issue