mirror of
https://github.com/gotson/komga.git
synced 2026-05-08 12:35:30 +02:00
more filter stuff
This commit is contained in:
parent
86061ad25f
commit
032aacd682
25 changed files with 1170 additions and 150 deletions
24
next-ui/filters.md
Normal file
24
next-ui/filters.md
Normal 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 |
|
||||
|
|
@ -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
|
||||
}
|
||||
})
|
||||
|
|
|
|||
5
next-ui/src/components.d.ts
vendored
5
next-ui/src/components.d.ts
vendored
|
|
@ -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']
|
||||
|
|
|
|||
58
next-ui/src/components/filter/AnyAll.stories.ts
Normal file
58
next-ui/src/components/filter/AnyAll.stories.ts
Normal 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',
|
||||
},
|
||||
}
|
||||
69
next-ui/src/components/filter/AnyAll.vue
Normal file
69
next-ui/src/components/filter/AnyAll.vue
Normal 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>
|
||||
61
next-ui/src/components/filter/ExpansionPanel.stories.ts
Normal file
61
next-ui/src/components/filter/ExpansionPanel.stories.ts
Normal 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()
|
||||
},
|
||||
}
|
||||
53
next-ui/src/components/filter/ExpansionPanel.vue
Normal file
53
next-ui/src/components/filter/ExpansionPanel.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
59
next-ui/src/components/filter/SearchList.stories.ts
Normal file
59
next-ui/src/components/filter/SearchList.stories.ts
Normal 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())
|
||||
},
|
||||
}
|
||||
68
next-ui/src/components/filter/SearchList.vue
Normal file
68
next-ui/src/components/filter/SearchList.vue
Normal 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>
|
||||
|
|
@ -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',
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
56
next-ui/src/components/filter/by/Author.stories.ts
Normal file
56
next-ui/src/components/filter/by/Author.stories.ts
Normal 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)' },
|
||||
],
|
||||
},
|
||||
}
|
||||
101
next-ui/src/components/filter/by/Author.vue
Normal file
101
next-ui/src/components/filter/by/Author.vue
Normal 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>
|
||||
40
next-ui/src/components/filter/by/SeriesStatus.stories.ts
Normal file
40
next-ui/src/components/filter/by/SeriesStatus.stories.ts
Normal 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'],
|
||||
},
|
||||
}
|
||||
32
next-ui/src/components/filter/by/SeriesStatus.vue
Normal file
32
next-ui/src/components/filter/by/SeriesStatus.vue
Normal 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>
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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')),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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, () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
31
next-ui/src/types/SeriesStatus.ts
Normal file
31
next-ui/src/types/SeriesStatus.ts
Normal 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',
|
||||
},
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
44
next-ui/src/types/referential.ts
Normal file
44
next-ui/src/types/referential.ts
Normal 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
|
||||
Loading…
Reference in a new issue