diff --git a/next-ui/filters.md b/next-ui/filters.md
new file mode 100644
index 00000000..c75f4f47
--- /dev/null
+++ b/next-ui/filters.md
@@ -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 |
diff --git a/next-ui/src/colada/referential.ts b/next-ui/src/colada/referential.ts
index f60ea4a8..a132e43b 100644
--- a/next-ui/src/colada/referential.ts
+++ b/next-ui/src/colada/referential.ts
@@ -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
+ }
+})
diff --git a/next-ui/src/components.d.ts b/next-ui/src/components.d.ts
index 9033835e..d4eb68e4 100644
--- a/next-ui/src/components.d.ts
+++ b/next-ui/src/components.d.ts
@@ -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']
diff --git a/next-ui/src/components/filter/AnyAll.stories.ts b/next-ui/src/components/filter/AnyAll.stories.ts
new file mode 100644
index 00000000..799b53ca
--- /dev/null
+++ b/next-ui/src/components/filter/AnyAll.stories.ts
@@ -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: '',
+ }),
+ 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
+
+export default meta
+type Story = StoryObj
+
+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',
+ },
+}
diff --git a/next-ui/src/components/filter/AnyAll.vue b/next-ui/src/components/filter/AnyAll.vue
new file mode 100644
index 00000000..c1bdfa1f
--- /dev/null
+++ b/next-ui/src/components/filter/AnyAll.vue
@@ -0,0 +1,69 @@
+
+
+ {{ b.text }}
+
+
+
+
+
+
+
+
diff --git a/next-ui/src/components/filter/ExpansionPanel.stories.ts b/next-ui/src/components/filter/ExpansionPanel.stories.ts
new file mode 100644
index 00000000..915d5ea3
--- /dev/null
+++ b/next-ui/src/components/filter/ExpansionPanel.stories.ts
@@ -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:
+ 'Slot content',
+ }),
+ 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
+
+export default meta
+type Story = StoryObj
+
+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()
+ },
+}
diff --git a/next-ui/src/components/filter/ExpansionPanel.vue b/next-ui/src/components/filter/ExpansionPanel.vue
new file mode 100644
index 00000000..7466f49e
--- /dev/null
+++ b/next-ui/src/components/filter/ExpansionPanel.vue
@@ -0,0 +1,53 @@
+
+
+
+ {{ title }}
+
+ {{ count }}
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/next-ui/src/components/filter/List.vue b/next-ui/src/components/filter/List.vue
index 454e4c01..a8ef5a9c 100644
--- a/next-ui/src/components/filter/List.vue
+++ b/next-ui/src/components/filter/List.vue
@@ -1,88 +1,107 @@
- (anyAll = v ? 'allOf' : 'anyOf')"
- />
-
-
+
+
+
+ internalUpdate(item, newVal, oldVal)"
+ />
+
+
+
-
+
diff --git a/next-ui/src/components/filter/SearchList.stories.ts b/next-ui/src/components/filter/SearchList.stories.ts
new file mode 100644
index 00000000..4a96cc03
--- /dev/null
+++ b/next-ui/src/components/filter/SearchList.stories.ts
@@ -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: '',
+ }),
+ 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
+
+export default meta
+type Story = StoryObj
+
+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())
+ },
+}
diff --git a/next-ui/src/components/filter/SearchList.vue b/next-ui/src/components/filter/SearchList.vue
new file mode 100644
index 00000000..5e4b8327
--- /dev/null
+++ b/next-ui/src/components/filter/SearchList.vue
@@ -0,0 +1,68 @@
+
+
+
+
+
+
+ {{
+ $formatMessage({
+ description: 'Search Filter: no results',
+ defaultMessage: 'No results',
+ id: '/NAG9i',
+ })
+ }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/next-ui/src/components/filter/TriState.stories.ts b/next-ui/src/components/filter/TriState.stories.ts
index ce03792b..c64d6bb3 100644
--- a/next-ui/src/components/filter/TriState.stories.ts
+++ b/next-ui/src/components/filter/TriState.stories.ts
@@ -10,7 +10,7 @@ const meta = {
setup() {
return { args }
},
- template: '',
+ template: '',
}),
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',
},
}
diff --git a/next-ui/src/components/filter/TriState.vue b/next-ui/src/components/filter/TriState.vue
index 19e903f9..56d54f02 100644
--- a/next-ui/src/components/filter/TriState.vue
+++ b/next-ui/src/components/filter/TriState.vue
@@ -16,6 +16,10 @@ const model = defineModel()
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)
}
diff --git a/next-ui/src/components/filter/by/Author.stories.ts b/next-ui/src/components/filter/by/Author.stories.ts
new file mode 100644
index 00000000..1892e7dd
--- /dev/null
+++ b/next-ui/src/components/filter/by/Author.stories.ts
@@ -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: '',
+ }),
+ 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
+
+export default meta
+type Story = StoryObj
+
+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)' },
+ ],
+ },
+}
diff --git a/next-ui/src/components/filter/by/Author.vue b/next-ui/src/components/filter/by/Author.vue
new file mode 100644
index 00000000..bf7347d1
--- /dev/null
+++ b/next-ui/src/components/filter/by/Author.vue
@@ -0,0 +1,101 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/next-ui/src/components/filter/by/SeriesStatus.stories.ts b/next-ui/src/components/filter/by/SeriesStatus.stories.ts
new file mode 100644
index 00000000..37b1bd7b
--- /dev/null
+++ b/next-ui/src/components/filter/by/SeriesStatus.stories.ts
@@ -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: '',
+ }),
+ 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
+
+export default meta
+type Story = StoryObj
+
+export const Default: Story = {
+ args: {},
+}
+
+export const InitialValue: Story = {
+ args: {
+ modelValue: ['ENDED', 'ABANDONED'],
+ },
+}
diff --git a/next-ui/src/components/filter/by/SeriesStatus.vue b/next-ui/src/components/filter/by/SeriesStatus.vue
new file mode 100644
index 00000000..e4c08a0f
--- /dev/null
+++ b/next-ui/src/components/filter/by/SeriesStatus.vue
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
diff --git a/next-ui/src/functions/filter.ts b/next-ui/src/functions/filter.ts
index 2b83902d..e2b453dd 100644
--- a/next-ui/src/functions/filter.ts
+++ b/next-ui/src/functions/filter.ts
@@ -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,
) {
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,
+ 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,
diff --git a/next-ui/src/mocks/api/handlers/referential.ts b/next-ui/src/mocks/api/handlers/referential.ts
index 5726cb16..6974182b 100644
--- a/next-ui/src/mocks/api/handlers/referential.ts
+++ b/next-ui/src/mocks/api/handlers/referential.ts
@@ -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')),
+ ),
+ ),
+ )
+ }),
]
diff --git a/next-ui/src/mocks/api/pageable.ts b/next-ui/src/mocks/api/pageable.ts
index 76073593..f25be5db 100644
--- a/next-ui/src/mocks/api/pageable.ts
+++ b/next-ui/src/mocks/api/pageable.ts
@@ -5,6 +5,7 @@ export function mockPage(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(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,
diff --git a/next-ui/src/pages/libraries/[id].vue b/next-ui/src/pages/libraries/[id].vue
index 3d346a6d..22dc51b4 100644
--- a/next-ui/src/pages/libraries/[id].vue
+++ b/next-ui/src/pages/libraries/[id].vue
@@ -7,10 +7,15 @@
+
+
meta:
requiresRole: USER
diff --git a/next-ui/src/types/SeriesStatus.ts b/next-ui/src/types/SeriesStatus.ts
new file mode 100644
index 00000000..18ede3ce
--- /dev/null
+++ b/next-ui/src/types/SeriesStatus.ts
@@ -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',
+ },
+})
diff --git a/next-ui/src/types/filter.test.ts b/next-ui/src/types/filter.test.ts
index 3469352b..1af9c298 100644
--- a/next-ui/src/types/filter.test.ts
+++ b/next-ui/src/types/filter.test.ts
@@ -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)
+ })
})
diff --git a/next-ui/src/types/filter.ts b/next-ui/src/types/filter.ts
index 85c8ab7d..675363d0 100644
--- a/next-ui/src/types/filter.ts
+++ b/next-ui/src/types/filter.ts
@@ -1,4 +1,22 @@
import * as v from 'valibot'
+import { defineMessage, type MessageDescriptor } from 'vue-intl'
+
+export const filterMessages: Record = {
+ 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(schema: T) {
+ return v.strictObject({
+ ...SchemaAnyAll.entries,
+ /**
+ * Shorthand for 'value'
+ */
+ v: v.optional(v.array(schema), []),
+ })
+}
+
+function createSchemaFilterArray(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
+
+export const SchemaFilterArray = createSchemaFilterArray(v.unknown())
+export type FilterTypeSimpleList = v.InferOutput
+
+export type FilterType = FilterTypeAnyAll | FilterTypeSimpleList
diff --git a/next-ui/src/types/referential.ts b/next-ui/src/types/referential.ts
new file mode 100644
index 00000000..59c3669b
--- /dev/null
+++ b/next-ui/src/types/referential.ts
@@ -0,0 +1,44 @@
+import { defineMessage, type MessageDescriptor } from 'vue-intl'
+
+export const authorRoles: Record = {
+ 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