more filter stuff

This commit is contained in:
Gauthier Roebroeck 2026-02-12 10:08:17 +08:00
parent 86061ad25f
commit 032aacd682
25 changed files with 1170 additions and 150 deletions

24
next-ui/filters.md Normal file
View file

@ -0,0 +1,24 @@
# Filters
## Series
| Field | Show | Control | Negative | Any/None | Search |
|---------------|------|-----------|----------|----------|--------|
| Library ID | | | | | |
| Collection ID | | | | | |
| Read List ID | | | | | |
| Deleted | Y | | | | |
| Complete | Y | | | | |
| OneShot | Y | Tri-State | | | |
| Title | ? | | | | |
| Title Sort | | | | | |
| Release Date | Y | Range | | Y | |
| Tag | Y | Tri-State | Y | Y | Y |
| Sharing Label | Y | Tri-State | Y | Y | Y |
| Publisher | Y | Tri-State | Y | Y | Y |
| Genre | Y | Tri-State | Y | Y | Y |
| Language | Y | Tri-State | Y | Y | Y |
| Age Rating | Y | Range | | Y | |
| Read Status | Y | Checkbox | | | |
| Series Status | Y | Checkbox | | | |
| Author | Y | | | Y | Y |

View file

@ -1,5 +1,6 @@
import { defineQuery, useQuery } from '@pinia/colada'
import { defineQuery, defineQueryOptions, useQuery } from '@pinia/colada'
import { komgaClient } from '@/api/komga-client'
import type { PageRequest } from '@/types/PageRequest'
export const useSharingLabels = defineQuery(() => {
return useQuery({
@ -14,3 +15,64 @@ export const useSharingLabels = defineQuery(() => {
gcTime: false,
})
})
export const authorsQuery = defineQueryOptions(
({
search,
role,
library_id,
collection_id,
series_id,
readlist_id,
pageRequest,
pause = false,
placeholder = true,
}: {
search?: string
role?: string
library_id?: string[]
collection_id?: string
series_id?: string
readlist_id?: string
pageRequest?: PageRequest
pause?: boolean
placeholder?: boolean
}) => {
const queryParams = {
search: search,
role: role,
library_id: library_id,
collection_id: collection_id,
series_id: series_id,
readlist_id: readlist_id,
...pageRequest,
}
return {
key: ['authors', queryParams],
query: () =>
komgaClient
.GET('/api/v2/authors', {
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 authorRolesQuery = defineQueryOptions(() => {
return {
key: ['authors', 'roles'],
query: () =>
komgaClient
.GET('/api/v1/authors/roles')
// unwrap the openapi-fetch structure on success
.then((res) => res.data),
placeholderData: (previousData: any) => previousData, // eslint-disable-line @typescript-eslint/no-explicit-any
staleTime: 60 * 60 * 1000, // 1 hour
}
})

View file

@ -36,7 +36,12 @@ declare module 'vue' {
DialogSimpleInstance: typeof import('./components/dialog/DialogSimpleInstance.vue')['default']
EmptyStateConstruction: typeof import('./components/EmptyStateConstruction.vue')['default']
EmptyStateNetworkError: typeof import('./components/EmptyStateNetworkError.vue')['default']
FilterAnyAll: typeof import('./components/filter/AnyAll.vue')['default']
FilterByAuthor: typeof import('./components/filter/by/Author.vue')['default']
FilterBySeriesStatus: typeof import('./components/filter/by/SeriesStatus.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']
FilterTriState: typeof import('./components/filter/TriState.vue')['default']
FormattedMessage: typeof import('./components/FormattedMessage.ts')['default']
HelloWorld: typeof import('./components/HelloWorld.vue')['default']

View file

@ -0,0 +1,58 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import AnyAll from './AnyAll.vue'
import { fn } from 'storybook/test'
const meta = {
component: AnyAll,
render: (args: object) => ({
components: { AnyAll },
setup() {
return { args }
},
template: '<AnyAll />',
}),
parameters: {
// More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
docs: {
description: {
component: 'Selector for how multiple conditions are applied.',
},
},
},
args: {
'onUpdate:modelValue': fn(),
},
} satisfies Meta<typeof AnyAll>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {},
}
export const Props: Story = {
args: {
props: { rounded: false, color: 'red' },
},
}
export const TextAndIcon: Story = {
args: {
text: true,
icons: true,
},
}
export const TextOnly: Story = {
args: {
text: true,
icons: false,
},
}
export const InitialValue: Story = {
args: {
modelValue: 'allOf',
},
}

View file

@ -0,0 +1,69 @@
<template>
<v-btn-toggle
v-model="model"
variant="outlined"
v-bind="props"
>
<v-btn
v-for="b in buttons"
:key="b.value"
v-tooltip:bottom="!text ? b.text : ''"
:icon="!text ? b.icon : undefined"
:prepend-icon="text && icons ? b.icon : undefined"
size="small"
:value="b.value"
><template
v-if="text"
#default
>{{ b.text }}</template
></v-btn
>
</v-btn-toggle>
</template>
<script setup lang="ts">
import type { AnyAll } from '@/types/filter'
import { useIntl } from 'vue-intl'
/**
* Selection mode.
*/
const model = defineModel<AnyAll>({ default: 'anyOf' })
const {
text = false,
icons = true,
props,
} = defineProps<{
text?: boolean
icons?: boolean
props?: object
}>()
const intl = useIntl()
const buttons = [
{
text: intl.formatMessage({
description: 'Any/All button: anyOf value',
defaultMessage: 'Any of',
id: 'RMHfEo',
}),
icon: 'i-mdi:filter-outline',
value: 'anyOf',
},
{
text: intl.formatMessage({
description: 'Any/All button: allOf value',
defaultMessage: 'All of',
id: '0UFr4y',
}),
icon: 'i-mdi:filter-multiple-outline',
value: 'allOf',
},
]
</script>
<script lang="ts"></script>
<style scoped></style>

View file

@ -0,0 +1,61 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import ExpansionPanel from './ExpansionPanel.vue'
import { VExpansionPanels } from 'vuetify/components'
import { expect, fn, waitFor } from 'storybook/test'
const meta = {
component: ExpansionPanel,
render: (args: object) => ({
components: { ExpansionPanel, VExpansionPanels },
setup() {
return { args }
},
template:
'<v-expansion-panels><ExpansionPanel v-bind="args">Slot content</ExpansionPanel></v-expansion-panels>',
}),
parameters: {
// More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
docs: {
description: {
component:
'A predefined `v-expansion-panel` which should be used insed a `v-expansion-panels`.',
},
},
},
args: {
onClear: fn(),
title: 'Default title',
},
} satisfies Meta<typeof ExpansionPanel>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {},
}
export const Title: Story = {
args: {
title: 'Custom title',
},
}
export const Count: Story = {
args: {
count: 25,
},
}
export const Reset: Story = {
args: {
count: 25,
},
play: async ({ canvas, userEvent, args }) => {
const chip = canvas.getByRole('button', { name: /close/i })
await userEvent.click(chip)
await expect(args.onClear).toHaveBeenCalledOnce()
},
}

View file

@ -0,0 +1,53 @@
<template>
<v-expansion-panel :value="value ?? id">
<template #title>
<span>{{ title }}</span>
<v-chip
v-if="count > 0"
color="primary"
rounded
closable
class="ms-2"
variant="elevated"
size="small"
@click:close="emit('clear')"
>
{{ count }}
</v-chip>
</template>
<template #text>
<slot></slot>
</template>
</v-expansion-panel>
</template>
<script setup lang="ts">
const id = useId()
const { value, count = 0 } = defineProps<{
/**
* Controls the opened/closed state of content.
* Defaults to a unique ID if not set.
*/
value?: string
/**
* Count of active filters.
*/
count?: number
/**
* Component's title.
*/
title?: string
}>()
const emit = defineEmits<{
/**
* Event that is emitted when the chip's close button is clicked.
*/
clear: []
}>()
</script>
<script lang="ts"></script>
<style scoped></style>

View file

@ -1,88 +1,107 @@
<template>
<v-checkbox
:model-value="anyAll === 'allOf'"
label="All of"
@update:model-value="(v) => (anyAll = v ? 'allOf' : 'anyOf')"
/>
<v-list>
<FilterTriState
v-for="(item, i) in items"
:key="i"
v-model="internalModel[i]"
:label="item.title"
:tri-state="!!item.valueExclude"
:color="color"
<v-list v-if="items.length > 0">
<FilterAnyAll
v-if="showModeSelector"
v-model="modelMode"
class="position-absolute top-0 right-0 mt-2 mr-2 semi-transparent"
style="z-index: 999"
:props="{ color: color, density: 'comfortable', rounded: 'lg' }"
/>
<v-virtual-scroll :items="items">
<template #default="{ item }">
<FilterTriState
v-model="internalModel[JSON.stringify(item)]"
:label="item.title"
:tri-state="!!item.valueExclude"
:color="color"
@change="(newVal, oldVal) => internalUpdate(item, newVal, oldVal)"
/>
</template>
</v-virtual-scroll>
<div
v-intersect.quiet="
(isIntersecting: boolean) => (isIntersecting ? emit('loadMore') : undefined)
"
></div>
</v-list>
</template>
<script setup lang="ts">
import { syncRef } from '@vueuse/core'
import type { AnyAll } from '@/types/filter'
import type { IncludeExclude } from '@/components/filter/TriState.vue'
import type { AnyAll } from '@/types/filter'
const model = defineModel<unknown[]>({ default: [] })
/**
* Selection mode.
*/
const anyAll = defineModel<AnyAll>('mode', { default: 'anyOf' })
//TODO: handle mode better
//TODO: add clear all?
const { items = [], color } = defineProps<{
items?: {
title: string
value: unknown
valueExclude?: unknown
}[]
color?: string
}>()
function initializeInternalModel() {
const c: Record<number, IncludeExclude> = {}
items.forEach((_it, i) => (c[i] = undefined))
return c
export type ItemType<T> = {
title: string
value: T
valueExclude?: T
}
const internalModel = ref<Record<number, IncludeExclude>>(initializeInternalModel())
const model = defineModel<unknown[]>({ default: [] })
const modelMode = defineModel<AnyAll>('mode', { default: 'anyOf' })
syncRef(model, internalModel, {
direction: 'both',
deep: true,
transform: {
ltr: (left) =>
left.reduce((acc, item) => {
const indexInclude = items.findIndex(
(it) => JSON.stringify(it.value) === JSON.stringify(item),
)
if (indexInclude >= 0)
return {
...(acc as Record<number, string | undefined>),
[indexInclude]: 'include',
}
const {
items = [],
color,
showModeSelector = false,
} = defineProps<{
items?: ItemType<unknown>[]
color?: string
showModeSelector?: boolean
}>()
const indexExclude = items.findIndex(
(it) => JSON.stringify(it.valueExclude) === JSON.stringify(item),
)
if (indexExclude >= 0)
return {
...(acc as Record<number, string | undefined>),
[indexExclude]: 'exclude',
}
const emit = defineEmits<{
loadMore: []
}>()
return acc
}, {}) as Record<number, IncludeExclude>,
rtl: (right) =>
items
.map((it, i) =>
right[i] === 'include' ? it.value : right[i] === 'exclude' ? it.valueExclude : undefined,
)
.filter((it) => it != undefined),
},
const internalModel = ref<Record<string, IncludeExclude>>({})
function toModel(item: ItemType<unknown>, value: IncludeExclude) {
if (value === 'include') return item.value
if (value === 'exclude') return item.valueExclude
return undefined
}
function internalUpdate(item: ItemType<unknown>, newVal: IncludeExclude, oldVal: IncludeExclude) {
const oldEl = toModel(item, oldVal)
const newEl = toModel(item, newVal)
// remove old element if present
const oldIndex = model.value.findIndex((it) => JSON.stringify(it) === JSON.stringify(oldEl))
if (oldIndex >= 0) model.value.splice(oldIndex, 1)
// add new element if defined
if (newEl) model.value.push(newEl)
}
watchEffect(() => {
internalModel.value = model.value.reduce((acc, item) => {
const itemInclude = items.find((it) => JSON.stringify(it.value) === JSON.stringify(item))
if (itemInclude)
return {
...(acc as Record<number, string | undefined>),
[JSON.stringify(itemInclude)]: 'include',
}
const itemExclude = items.find((it) => JSON.stringify(it.valueExclude) === JSON.stringify(item))
if (itemExclude)
return {
...(acc as Record<number, string | undefined>),
[JSON.stringify(itemExclude)]: 'exclude',
}
return acc
}, {}) as Record<string, IncludeExclude>
})
</script>
<script lang="ts"></script>
<style scoped></style>
<style scoped>
.semi-transparent {
opacity: 0.5;
}
.semi-transparent:hover {
opacity: 1;
}
</style>

View file

@ -0,0 +1,59 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import SearchList from './SearchList.vue'
import { expect, waitFor } from 'storybook/test'
const meta = {
component: SearchList,
render: (args: object) => ({
components: { SearchList },
setup() {
return { args }
},
template: '<SearchList />',
}),
parameters: {
// More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
docs: {
description: {
component: 'Searchable filter list.',
},
},
},
args: {
items: [
{ title: 'Tag 1', value: '+tag1', valueExclude: '-tag1' },
{ title: 'Tag 2', value: '+tag2', valueExclude: '-tag2' },
{ title: 'Tag 3', value: '+tag3', valueExclude: '-tag3' },
{ title: 'Tag include only', value: '+tag4' },
],
searchItems: [{ title: 'Tag 1 (search result)', value: '+tag1', valueExclude: '-tag1' }],
},
} satisfies Meta<typeof SearchList>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {},
}
export const InitialValue: Story = {
args: {
modelValue: ['+tag1', '-tag2', 'crap'],
},
}
export const Search: Story = {
args: {
search: 't',
},
play: async ({ canvas, userEvent }) => {
const search = canvas.getByLabelText(/search/i, {
selector: 'input',
})
await userEvent.type(search, 'tag')
await waitFor(() => expect(canvas.getByText(/result/i)).toBeVisible())
},
}

View file

@ -0,0 +1,68 @@
<template>
<v-text-field
v-model="search"
clearable
hide-details
:loading="searchLoading"
:label="
$formatMessage({
description: 'Filter search field',
defaultMessage: 'Search',
id: 'bzY8FH',
})
"
>
</v-text-field>
<v-list v-if="search && searchItems.length === 0">
<v-list-item>
<slot name="no-data">{{
$formatMessage({
description: 'Search Filter: no results',
defaultMessage: 'No results',
id: '/NAG9i',
})
}}</slot>
</v-list-item>
</v-list>
<FilterList
v-model="model"
v-model:mode="modelMode"
:items="shownItems"
color="primary"
:show-mode-selector="showModeSelector"
@load-more="emit('loadMore')"
></FilterList>
</template>
<script setup lang="ts">
import type { ItemType } from '@/components/filter/List.vue'
import type { AnyAll } from '@/types/filter'
const model = defineModel<unknown[]>({ default: [] })
const modelMode = defineModel<AnyAll>('mode', { default: 'anyOf' })
const search = defineModel<string>('search', { default: '' })
const {
items = [],
searchItems = [],
searchLoading = false,
showModeSelector = false,
} = defineProps<{
items?: ItemType<unknown>[]
searchItems?: ItemType<unknown>[]
searchLoading?: boolean
showModeSelector?: boolean
}>()
const emit = defineEmits<{
loadMore: []
}>()
const shownItems = computed(() => (search.value ? searchItems : items))
</script>
<script lang="ts"></script>
<style scoped></style>

View file

@ -10,7 +10,7 @@ const meta = {
setup() {
return { args }
},
template: '<TriState />',
template: '<TriState v-model="args.modelValue"/>',
}),
parameters: {
// More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
@ -37,6 +37,7 @@ export const Default: Story = {
export const Color: Story = {
args: {
color: 'primary',
modelValue: 'include',
},
}

View file

@ -16,6 +16,10 @@ const model = defineModel<IncludeExclude>()
const icon = computed(() => states.value.find((it) => it.value === model.value)?.icon)
const emit = defineEmits<{
change: [newValue: IncludeExclude, oldValue: IncludeExclude]
}>()
const {
label,
triState = true,
@ -35,31 +39,37 @@ const {
color?: string
}>()
const states = computed(() => [
{
icon: 'i-mdi:checkbox-marked',
value: 'include',
},
...(triState
? [
{
icon: 'i-mdi:close-box',
value: 'exclude',
},
]
: []),
{
icon: 'i-mdi:checkbox-blank-outline',
value: undefined,
},
])
const states = computed(
() =>
[
{
icon: 'i-mdi:checkbox-marked',
value: 'include',
},
...(triState
? [
{
icon: 'i-mdi:close-box',
value: 'exclude',
},
]
: []),
{
icon: 'i-mdi:checkbox-blank-outline',
value: undefined,
},
] as { icon: string; value: IncludeExclude }[],
)
if (!states.value.some((it) => it.value === model.value)) model.value = undefined
function cycle() {
const index = states.value.findIndex((x) => x.value === model.value)
const newIndex = (index + 1) % states.value.length
model.value = states.value[newIndex]!.value
const oldVal = model.value
const newVal = states.value[newIndex]!.value
model.value = newVal
emit('change', newVal, oldVal)
}
</script>

View file

@ -0,0 +1,56 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import Author from './Author.vue'
import { fn } from 'storybook/test'
const meta = {
component: Author,
render: (args: object) => ({
components: { Author },
setup() {
return { args }
},
template: '<Author v-model="args.modelValue"/>',
}),
parameters: {
// More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
docs: {
description: {
component: 'Author filter.',
},
},
},
args: {
'onUpdate:modelValue': fn(),
modelValue: [],
},
} satisfies Meta<typeof Author>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {},
}
export const Writer: Story = {
args: {
role: 'writer',
},
}
export const NoData: Story = {
args: {
role: 'nodata',
},
}
export const InitialValue: Story = {
args: {
modelValue: [
{ i: 'e', v: 'Author 2 (inker)' },
{ i: 'i', v: 'Author 3 (colorist)' },
{ i: 'i', v: 'Author 0 (writer)' },
],
},
}

View file

@ -0,0 +1,101 @@
<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 { authorsQuery } from '@/colada/referential'
import { PageRequest } from '@/types/PageRequest'
import { komgaClient } from '@/api/komga-client'
import * as v from 'valibot'
import { type AnyAll, filterKeys, filterMessages, SchemaAuthor } from '@/types/filter'
import type { ItemType } from '@/components/filter/List.vue'
import { refDebounced } from '@vueuse/core'
import { useIntl } from 'vue-intl'
type Author = v.InferOutput<typeof SchemaAuthor>
const intl = useIntl()
const model = defineModel<Author[]>({ default: [] })
const modelMode = defineModel<AnyAll>('mode', { default: 'anyOf' })
const search = ref()
const searchDebounced = refDebounced(search, 500)
const { role } = defineProps<{
role?: string
}>()
const filterContext = inject(filterKeys.context, {})
const apiQuery = {
...filterContext,
role: role,
}
const { data: searchItems, isLoading: searchLoading } = useQuery(authorsQuery, () => ({
pageRequest: PageRequest.Unpaged(),
search: searchDebounced.value,
pause: !searchDebounced.value,
placeholder: false,
...apiQuery,
}))
const searchResults = computed(() => searchItems.value?.content?.map((it) => toItemType(it.name)))
const { data: infiniteData, loadNextPage } = useInfiniteQuery({
key: () => ['infinite_authors', apiQuery],
initialPageParam: new PageRequest(0, 50),
query: ({ pageParam }) =>
komgaClient
.GET('/api/v2/authors', {
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 = Array.from(
new Set(
infiniteData.value?.pages.flatMap((it) => it?.content?.map((it) => it.name) ?? []) ?? [],
),
).map((it) => toItemType(it))
return [
{
title: intl.formatMessage(filterMessages.any!),
value: { a: 'any' },
valueExclude: { a: 'none' },
},
...itemTypes,
]
})
function toItemType(authorName: string): ItemType<Author> {
return {
title: authorName,
value: { i: 'i', v: authorName },
valueExclude: { i: 'e', v: authorName },
}
}
</script>
<script lang="ts"></script>
<style scoped></style>

View file

@ -0,0 +1,40 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import SeriesStatus from './SeriesStatus.vue'
import { fn } from 'storybook/test'
const meta = {
component: SeriesStatus,
render: (args: object) => ({
components: { SeriesStatus },
setup() {
return { args }
},
template: '<SeriesStatus v-model="args.modelValue"/>',
}),
parameters: {
// More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
docs: {
description: {
component: 'Series status filter.',
},
},
},
args: {
'onUpdate:modelValue': fn(),
modelValue: [],
},
} satisfies Meta<typeof SeriesStatus>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {},
}
export const InitialValue: Story = {
args: {
modelValue: ['ENDED', 'ABANDONED'],
},
}

View file

@ -0,0 +1,32 @@
<template>
<FilterList
v-model="model"
:items="items"
color="primary"
/>
</template>
<script setup lang="ts">
import * as v from 'valibot'
import { SchemaSeriesStatus } from '@/types/filter'
import { SeriesStatus, seriesStatusMessages } from '@/types/SeriesStatus'
import { useIntl } from 'vue-intl'
type SeriesStatus = v.InferOutput<typeof SchemaSeriesStatus>
const intl = useIntl()
const model = defineModel<SeriesStatus[]>({ default: [] })
const items: {
title: string
value: SeriesStatus
}[] = Object.values(SeriesStatus).map((it) => ({
title: intl.formatMessage(seriesStatusMessages[it]),
value: it,
}))
</script>
<script lang="ts"></script>
<style scoped></style>

View file

@ -1,16 +1,49 @@
import type { SchemaFilterSeriesStatus } from '@/types/filter'
import { SchemaAnyNone, SchemaFilterAuthors, type SchemaFilterSeriesStatus } from '@/types/filter'
import type { InferOutput } from 'valibot'
import * as v from 'valibot'
export function schemaFilterSeriesStatusToConditions(
filter: InferOutput<typeof SchemaFilterSeriesStatus>,
) {
const list = filter.v.map((it) => ({
seriesStatus: {
operator: it.i === 'e' ? 'isNot' : 'is',
value: it.v,
operator: 'is',
value: it,
},
}))
return {
anyOf: list,
}
}
export function schemaFilterAuthorsToConditions(
filter: InferOutput<typeof SchemaFilterAuthors>,
role?: string,
) {
const list = filter.v.map((it) => {
if (v.is(SchemaAnyNone, it)) {
return {
author: {
operator: it.a === 'any' ? 'is' : 'isNot',
value: {
role: role,
},
},
}
} else {
return {
author: {
operator: it.i === 'e' ? 'isNot' : 'is',
value: {
name: it.v,
role: role,
},
},
}
}
})
if (filter.m === 'allOf')
return {
allOf: list,

View file

@ -1,7 +1,53 @@
import { httpTyped } from '@/mocks/api/httpTyped'
import type { components } from '@/generated/openapi/komga'
import { mockPage } from '@/mocks/api/pageable'
import { PageRequest } from '@/types/PageRequest'
export const sharingLabels = ['kids', 'teens']
const sharingLabels = ['kids', 'teens']
const authorRoles = [
'writer',
'penciller',
'inker',
'colorist',
'letterer',
'cover',
'editor',
'translator',
]
function doMockAuthors(count: number) {
return [...Array(count).keys()].map((index) => {
const role = authorRoles[index % authorRoles.length]
return {
role: role,
name: `Author ${index} (${role})`,
} as components['schemas']['AuthorDto']
})
}
const mockAuthors = doMockAuthors(10000)
export const referentialHandlers = [
httpTyped.get('/api/v1/sharing-labels', ({ response }) => response(200).json(sharingLabels)),
httpTyped.get('/api/v2/authors', ({ query, response }) => {
const search = query.get('search')
const role = query.get('role')
const selected = search
? mockAuthors.filter((it) => !!it.name.match(new RegExp(search, 'i')))
: mockAuthors
const byRole = role ? selected.filter((it) => it.role === role) : selected
return response(200).json(
mockPage(
byRole,
new PageRequest(
Number(query.get('page')),
Number(query.get('size')),
undefined,
Boolean(query.get('unpaged')),
),
),
)
}),
]

View file

@ -5,6 +5,7 @@ export function mockPage<T>(data: T[], pageRequest: PageRequest) {
const size = Number(pageRequest.size) || 20
const unpaged = pageRequest.unpaged || false
const sort = pageRequest.sort
const totalPages = Math.ceil(data.length / size)
const start = page * size
const slice = unpaged ? data : data.slice(start, start + size)
@ -28,12 +29,12 @@ export function mockPage<T>(data: T[], pageRequest: PageRequest) {
unpaged: unpaged,
paged: !unpaged,
},
last: false,
totalPages: Math.ceil(data.length / size),
last: page === totalPages - 1,
totalPages: totalPages,
totalElements: data.length,
first: false,
first: page === 0,
size: size,
number: 1,
number: page,
sort: {
empty: false,
unsorted: false,

View file

@ -7,10 +7,15 @@
<script lang="ts" setup>
import { watchImmediate } from '@vueuse/core'
import { filterKeys } from '@/types/filter'
import { useGetLibrariesById } from '@/composables/libraries'
const route = useRoute('/libraries/[id]')
const router = useRouter()
const libraryId = computed(() => route.params.id)
const { libraries } = useGetLibrariesById(libraryId)
provide(filterKeys.context, { library_id: libraries.value?.map((it) => it.id) })
//TODO: for now we always redirect to 'recommended', this should be persisted per libraryId
watchImmediate(libraryId, () => {

View file

@ -25,19 +25,80 @@
allow-unpaged
:sizes="[1, 10, 20]"
/>
<v-icon-btn
icon="i-mdi:filter-variant"
@click="filterDrawer = true"
/>
</v-app-bar>
<!-- TODO: Move into its own component -->
<!-- Teleport is needed so that the scrim covers the whole screen -->
<Teleport to="#app">
<!-- order=-1 is needed for the drawer to open full height -->
<!-- disable-route-watcher is needed, else the drawer closes when the route query params are updated when the filters change -->
<v-navigation-drawer
v-model="filterDrawer"
location="end"
temporary
order="-1"
disable-route-watcher
>
<v-list>
<v-expansion-panels
v-model="filterExpansionPanels"
variant="accordion"
class="no-padding"
flat
tile
>
<FilterExpansionPanel
title="Status"
:count="filterSeriesStatus.v.length"
@clear="clearFilter(filterSeriesStatus)"
>
<FilterBySeriesStatus v-model="filterSeriesStatus.v" />
</FilterExpansionPanel>
</v-expansion-panels>
<v-divider />
<v-list-subheader>CREATORS</v-list-subheader>
<v-expansion-panels
v-model="filterExpansionPanels"
variant="accordion"
class="no-padding"
flat
tile
>
<FilterExpansionPanel
v-for="(filterAuthor, role) in filterAuthors"
:key="role"
:title="filterAuthor.text"
:count="filterAuthor.filter.v.length"
@clear="clearFilter(filterAuthor.filter)"
>
<FilterByAuthor
v-model="filterAuthor.filter.v"
v-model:mode="filterAuthor.filter.m"
:role="filterAuthor.role"
/>
</FilterExpansionPanel>
</v-expansion-panels>
</v-list>
</v-navigation-drawer>
</Teleport>
<div>FILTER AUTHORS</div>
<p>{{ filterAuthors }}</p>
<div>AUTHOR ROLES</div>
<p>{{ authorRoles }}</p>
<div>FILTER</div>
<div>{{ filter }}</div>
<div>{{ filterSeriesStatus }}</div>
<div>CONDITION</div>
<div>{{ conds }}</div>
<FilterList
v-model="filter.v"
v-model:mode="filter.m"
:items="filterStatusItems"
/>
<template v-if="series">
<v-data-iterator
v-model="selectedItems"
@ -112,16 +173,22 @@ import { useSearchConditionLibraries } from '@/composables/search'
import { storeToRefs } from 'pinia'
import { useSelectionStore } from '@/stores/selection'
import { useDisplay } from 'vuetify'
import { schemaFilterSeriesStatusToConditions } from '@/functions/filter'
import {
schemaFilterAuthorsToConditions,
schemaFilterSeriesStatusToConditions,
} from '@/functions/filter'
import * as v from 'valibot'
import { SchemaFilterSeriesStatus, SchemaSeriesStatus } from '@/types/filter'
import { type FilterType, SchemaFilterAuthors, SchemaFilterSeriesStatus } from '@/types/filter'
import { useRouteQuerySchema } from '@/composables/useRouteQuerySchema'
import { authorRoles } from '@/types/referential'
import { useIntl } from 'vue-intl'
const route = useRoute('/libraries/[id]/series')
const libraryId = route.params.id
const { libraries } = useGetLibrariesById(libraryId)
const { librariesCondition } = useSearchConditionLibraries(libraries)
const intl = useIntl()
const display = useDisplay()
const appStore = useAppStore()
const { browsingPageSize } = storeToRefs(appStore)
@ -137,18 +204,47 @@ const selectionStore = useSelectionStore()
const { selection: selectedItems } = storeToRefs(selectionStore)
const preSelect = computed(() => selectedItems.value.length > 0)
const { data: filter } = useRouteQuerySchema('status', SchemaFilterSeriesStatus)
type AuthorQuery = v.InferOutput<typeof SchemaFilterAuthors>
const filterAuthors = reactive<
Record<string, { filter: AuthorQuery; text: string; role?: string }>
>({})
filterAuthors['anyrole'] = {
filter: useRouteQuerySchema('anyrole', SchemaFilterAuthors).data.value,
text: intl.formatMessage({
description: 'Author filter: any role',
defaultMessage: 'All creators',
id: 'RmNasP',
}),
}
// TODO: get roles dynamically
Object.entries(authorRoles).forEach(([role, value]) => {
filterAuthors[role] = {
filter: useRouteQuerySchema(role, SchemaFilterAuthors).data.value,
text: intl.formatMessage(value),
role: role,
}
})
function clearFilter(filter: FilterType) {
filter.v = []
if ('m' in filter) filter.m = 'anyOf'
}
const { data: filterSeriesStatus } = useRouteQuerySchema('status', SchemaFilterSeriesStatus)
const conds = computed(() => ({
allOf: [
librariesCondition.value as components['schemas']['AnyOfSeries'],
schemaFilterSeriesStatusToConditions(filter.value),
schemaFilterSeriesStatusToConditions(filterSeriesStatus.value),
...Object.entries(filterAuthors).map(([, filter]) =>
schemaFilterAuthorsToConditions(toValue(filter.filter), toValue(filter.role)),
),
],
}))
const { data: series } = useQuery(seriesListQuery, () => {
const search: components['schemas']['SeriesSearch'] = {
// condition: librariesCondition.value as components['schemas']['AnyOfSeries'],
condition: conds.value as components['schemas']['AllOfSeries'],
}
@ -162,22 +258,18 @@ watch(series, (newSeries) => {
if (newSeries) pageCount.value = newSeries.totalPages ?? 0
})
const filterStatusItems: {
title: string
value: v.InferOutput<typeof SchemaSeriesStatus>
valueExclude?: v.InferOutput<typeof SchemaSeriesStatus>
}[] = [
{ title: 'Ended', value: { i: 'i', v: 'ENDED' }, valueExclude: { i: 'e', v: 'ENDED' } },
{ title: 'Ongoing', value: { i: 'i', v: 'ONGOING' }, valueExclude: { i: 'e', v: 'ONGOING' } },
{ title: 'Hiatus', value: { i: 'i', v: 'HIATUS' }, valueExclude: { i: 'e', v: 'HIATUS' } },
{
title: 'Abandoned',
value: { i: 'i', v: 'ABANDONED' },
valueExclude: { i: 'e', v: 'ABANDONED' },
},
]
const filterDrawer = ref(false)
// shared model for all the expansion-panels, so only 1 is opened at the same time
const filterExpansionPanels = ref()
</script>
<style lang="scss">
.no-padding .v-expansion-panel-text__wrapper {
padding: 0 4px;
}
</style>
<route lang="yaml">
meta:
requiresRole: USER

View file

@ -0,0 +1,31 @@
import { defineMessages } from 'vue-intl'
export enum SeriesStatus {
ENDED = 'ENDED',
ONGOING = 'ONGOING',
HIATUS = 'HIATUS',
ABANDONED = 'ABANDONED',
}
export const seriesStatusMessages = defineMessages({
[SeriesStatus.ENDED]: {
description: 'Series status: ENDED',
defaultMessage: 'Ended',
id: 'waBpAI',
},
[SeriesStatus.ONGOING]: {
description: 'Series status: ONGOING',
defaultMessage: 'Ongoing',
id: 'k0iQcZ',
},
[SeriesStatus.HIATUS]: {
description: 'Series status: HIATUS',
defaultMessage: 'Hiatus',
id: '+hyKAd',
},
[SeriesStatus.ABANDONED]: {
description: 'Series status: ABANDONED',
defaultMessage: 'Abandoned',
id: 'NQctWq',
},
})

View file

@ -2,6 +2,7 @@ import { describe, expect, test } from 'vitest'
import * as v from 'valibot'
import {
SchemaAnyAll,
SchemaFilterAuthors,
SchemaFilterSeriesStatus,
SchemaFilterStrings,
SchemaSeriesStatus,
@ -60,7 +61,7 @@ describe('schema series status', () => {
describe('filter schemas have a default value', () => {
test('SchemaFilterSeriesStatus', () => {
const expected = { m: 'anyOf', v: [] }
const expected = { v: [] }
const defaults = v.getDefaults(SchemaFilterSeriesStatus)
expect(defaults).toStrictEqual(expected)
@ -72,4 +73,11 @@ describe('filter schemas have a default value', () => {
expect(defaults).toStrictEqual(expected)
})
test('SchemaFilterAuthors', () => {
const expected = { m: 'anyOf', v: [] }
const defaults = v.getDefaults(SchemaFilterAuthors)
expect(defaults).toStrictEqual(expected)
})
})

View file

@ -1,4 +1,22 @@
import * as v from 'valibot'
import { defineMessage, type MessageDescriptor } from 'vue-intl'
export const filterMessages: Record<string, MessageDescriptor> = {
any: defineMessage({
description: 'Filter values: any',
defaultMessage: 'Any',
id: 'bDNv5+',
}),
}
export const filterKeys = {
context: Symbol() as InjectionKey<{
library_id?: string[]
collection_id?: string
series_id?: string
readlist_id?: string
}>,
}
export type AnyAll = 'anyOf' | 'allOf'
@ -11,14 +29,14 @@ export type AnyAll = 'anyOf' | 'allOf'
/**
* Schema for criteria API with `anyOf` or `allOf` condition.
*/
export const SchemaAnyAll = v.object({
export const SchemaAnyAll = v.strictObject({
/**
* Shorthand for `mode`.
*/
m: v.optional(v.picklist(['anyOf', 'allOf']), 'anyOf'),
})
const SchemaIncludeExclude = v.object({
const SchemaIncludeExclude = v.strictObject({
/**
* Shorthand for `include`.
*
@ -27,15 +45,13 @@ const SchemaIncludeExclude = v.object({
i: v.optional(v.picklist(['i', 'e'])),
})
export const SchemaSeriesStatus = v.object({
...SchemaIncludeExclude.entries,
/**
* Shorthand for `value`.
*/
v: v.pipe(v.string(), v.toUpperCase(), v.picklist(['ENDED', 'ONGOING', 'ABANDONED', 'HIATUS'])),
})
export const SchemaSeriesStatus = v.pipe(
v.string(),
v.toUpperCase(),
v.picklist(['ENDED', 'ONGOING', 'ABANDONED', 'HIATUS']),
)
export const SchemaString = v.object({
export const SchemaString = v.strictObject({
...SchemaIncludeExclude.entries,
/**
* Shorthand for `value`.
@ -43,29 +59,55 @@ export const SchemaString = v.object({
v: v.string(),
})
export const SchemaAnyNone = v.strictObject({
a: v.optional(v.picklist(['any', 'none'])),
})
export const SchemaAuthor = v.union([SchemaString, SchemaAnyNone])
////////////////////////////////////////////////////
// All schema filters need to have a default value
////////////////////////////////////////////////////
function createSchemaFilterAnyAll<T extends v.GenericSchema>(schema: T) {
return v.strictObject({
...SchemaAnyAll.entries,
/**
* Shorthand for 'value'
*/
v: v.optional(v.array(schema), []),
})
}
function createSchemaFilterArray<T extends v.GenericSchema>(schema: T) {
return v.strictObject({
/**
* Shorthand for 'value'
*/
v: v.optional(v.array(schema), []),
})
}
/**
* Schema for Series Status.
*/
export const SchemaFilterSeriesStatus = v.object({
...SchemaAnyAll.entries,
/**
* Shorthand for 'value'
*/
v: v.optional(v.array(SchemaSeriesStatus), []),
})
export const SchemaFilterSeriesStatus = createSchemaFilterArray(SchemaSeriesStatus)
/**
* Schema for a list of string.
* Can be used for tags, genre, sharing labels
*/
export const SchemaFilterStrings = v.object({
...SchemaAnyAll.entries,
/**
* Shorthand for 'value'
*/
v: v.optional(v.array(SchemaString), []),
})
export const SchemaFilterStrings = createSchemaFilterAnyAll(SchemaString)
/**
* Schema for authors.
*/
export const SchemaFilterAuthors = createSchemaFilterAnyAll(SchemaAuthor)
export const SchemaFilterAnyAll = createSchemaFilterAnyAll(v.unknown())
export type FilterTypeAnyAll = v.InferOutput<typeof SchemaFilterAnyAll>
export const SchemaFilterArray = createSchemaFilterArray(v.unknown())
export type FilterTypeSimpleList = v.InferOutput<typeof SchemaFilterArray>
export type FilterType = FilterTypeAnyAll | FilterTypeSimpleList

View file

@ -0,0 +1,44 @@
import { defineMessage, type MessageDescriptor } from 'vue-intl'
export const authorRoles: Record<string, MessageDescriptor> = {
writer: defineMessage({
description: 'Author role: writer',
defaultMessage: 'Writer',
id: '7hwFJo',
}),
penciller: defineMessage({
description: 'Author role: penciller',
defaultMessage: 'Penciller',
id: 'II5EFN',
}),
inker: defineMessage({
description: 'Author role: inker',
defaultMessage: 'Inker',
id: 'xeiMMk',
}),
colorist: defineMessage({
description: 'Author role: colorist',
defaultMessage: 'Colorist',
id: 'k2JkZX',
}),
letterer: defineMessage({
description: 'Author role: letterer',
defaultMessage: 'Letterer',
id: '8NDqor',
}),
cover: defineMessage({
description: 'Author role: cover',
defaultMessage: 'Cover',
id: 'crClNV',
}),
editor: defineMessage({
description: 'Author role: editor',
defaultMessage: 'Editor',
id: 'VtC7Ce',
}),
translator: defineMessage({
description: 'Author role: translator',
defaultMessage: 'Translator',
id: 'FZXkIP',
}),
} as const