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 @@ + + + + + + + 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 @@ + + + + + + + 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 @@ - + 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 @@ + + + + + + + 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