diff --git a/next-ui/src/colada/collections.ts b/next-ui/src/colada/collections.ts new file mode 100644 index 00000000..75ae64d7 --- /dev/null +++ b/next-ui/src/colada/collections.ts @@ -0,0 +1,57 @@ +import { defineQueryOptions } from '@pinia/colada' +import { komgaClient } from '@/api/komga-client' +import type { PageRequest } from '@/types/PageRequest' + +export const QUERY_KEYS_COLLECTIONS = { + root: ['collections'] as const, + bySearch: (request: object) => [...QUERY_KEYS_COLLECTIONS.root, JSON.stringify(request)] as const, + byId: (seriesId: string) => [...QUERY_KEYS_COLLECTIONS.root, seriesId] as const, +} + +export const collectionsListQuery = defineQueryOptions( + ({ + search, + libraryIds, + pause = false, + pageRequest, + }: { + search?: string + libraryIds?: string[] + pause?: boolean + pageRequest?: PageRequest + }) => ({ + key: QUERY_KEYS_COLLECTIONS.bySearch({ search: search, libraryIds, pageRequest: pageRequest }), + query: () => + komgaClient + .GET('/api/v1/collections', { + params: { + query: { + search: search, + library_id: libraryIds, + ...pageRequest, + }, + }, + }) + // unwrap the openapi-fetch structure on success + .then((res) => res.data), + enabled: !pause, + placeholderData: (previousData: any) => previousData, // eslint-disable-line @typescript-eslint/no-explicit-any + }), +) + +export const collectionDetailQuery = defineQueryOptions( + ({ collectionId }: { collectionId: string }) => ({ + key: QUERY_KEYS_COLLECTIONS.byId(collectionId), + query: () => + komgaClient + .GET('/api/v1/collections/{id}', { + params: { + path: { + id: collectionId, + }, + }, + }) + // unwrap the openapi-fetch structure on success + .then((res) => res.data), + }), +) diff --git a/next-ui/src/colada/readlists.ts b/next-ui/src/colada/readlists.ts index 44414814..26390b99 100644 --- a/next-ui/src/colada/readlists.ts +++ b/next-ui/src/colada/readlists.ts @@ -8,19 +8,21 @@ export const QUERY_KEYS_READLIST = { bySearch: (request: object) => [...QUERY_KEYS_READLIST.root, JSON.stringify(request)] as const, } -export const useListReadLists = defineQueryOptions( +export const readListsListQuery = defineQueryOptions( ({ search, - libraryId, + libraryIds, + pause = false, pageRequest, }: { search?: string - libraryId?: string + libraryIds?: string[] + pause?: boolean pageRequest?: PageRequest }) => ({ key: QUERY_KEYS_READLIST.bySearch({ search: search, - libraryId: libraryId, + libraryIds: libraryIds, pageRequest: pageRequest, }), query: () => @@ -29,13 +31,15 @@ export const useListReadLists = defineQueryOptions( params: { query: { search: search, - libraryId: libraryId, + library_id: libraryIds, ...pageRequest, }, }, }) // unwrap the openapi-fetch structure on success .then((res) => res.data), + enabled: !pause, + placeholderData: (previousData: any) => previousData, // eslint-disable-line @typescript-eslint/no-explicit-any }), ) diff --git a/next-ui/src/components.d.ts b/next-ui/src/components.d.ts index 6638d4fb..a15c622d 100644 --- a/next-ui/src/components.d.ts +++ b/next-ui/src/components.d.ts @@ -54,14 +54,17 @@ declare module 'vue' { LayoutAppDrawerMenuMedia: typeof import('./components/layout/app/drawer/menu/Media.vue')['default'] LayoutAppDrawerMenuServer: typeof import('./components/layout/app/drawer/menu/Server.vue')['default'] LayoutAppDrawerReorderLibraries: typeof import('./components/layout/app/drawer/ReorderLibraries.vue')['default'] + LibraryBottomNavigation: typeof import('./components/library/BottomNavigation.vue')['default'] LibraryDeletionWarning: typeof import('./components/library/DeletionWarning.vue')['default'] LibraryFormCreateEdit: typeof import('./components/library/form/CreateEdit.vue')['default'] LibraryFormStepGeneral: typeof import('./components/library/form/StepGeneral.vue')['default'] LibraryFormStepMetadata: typeof import('./components/library/form/StepMetadata.vue')['default'] LibraryFormStepOptions: typeof import('./components/library/form/StepOptions.vue')['default'] LibraryFormStepScanner: typeof import('./components/library/form/StepScanner.vue')['default'] + LibraryHolder: typeof import('./components/library/Holder.vue')['default'] LibraryMenuLibraries: typeof import('./components/library/MenuLibraries.vue')['default'] LibraryMenuLibrary: typeof import('./components/library/MenuLibrary.vue')['default'] + LibraryTabNavigation: typeof import('./components/library/TabNavigation.vue')['default'] LocaleSelector: typeof import('./components/LocaleSelector.vue')['default'] PageHashKnownTable: typeof import('./components/pageHash/KnownTable.vue')['default'] PageHashMatchTable: typeof import('./components/pageHash/MatchTable.vue')['default'] diff --git a/next-ui/src/components/import/readlist/Table.vue b/next-ui/src/components/import/readlist/Table.vue index f4c73edc..710618e3 100644 --- a/next-ui/src/components/import/readlist/Table.vue +++ b/next-ui/src/components/import/readlist/Table.vue @@ -236,7 +236,7 @@ import { import { useDisplay } from 'vuetify' import { useQuery } from '@pinia/colada' import { bookListQuery } from '@/colada/books' -import { useCreateReadList, useListReadLists } from '@/colada/readlists' +import { useCreateReadList, readListsListQuery } from '@/colada/readlists' import { useMessagesStore } from '@/stores/messages' import type { ErrorCause } from '@/api/komga-client' import { commonMessages } from '@/utils/i18n/common-messages' @@ -335,7 +335,7 @@ watchImmediate( ) //region Duplicate read list name check -const { data: allReadLists } = useQuery(useListReadLists({ pageRequest: PageRequest.Unpaged() })) +const { data: allReadLists } = useQuery(readListsListQuery({ pageRequest: PageRequest.Unpaged() })) const readListNameAlreadyExists = computed(() => allReadLists.value?.content?.some( (it) => it.name.localeCompare(readListName.value, undefined, { sensitivity: 'accent' }) == 0, diff --git a/next-ui/src/components/layout/app/drawer/menu/Libraries.vue b/next-ui/src/components/layout/app/drawer/menu/Libraries.vue index feb7183c..a71f5535 100644 --- a/next-ui/src/components/layout/app/drawer/menu/Libraries.vue +++ b/next-ui/src/components/layout/app/drawer/menu/Libraries.vue @@ -8,11 +8,13 @@ }) " prepend-icon="i-mdi:bookshelf" + to="/libraries/pinned" > @@ -44,6 +48,7 @@ v-for="library in pinned" :key="library.id" :title="library.name" + :to="`/libraries/${library.id}`" prepend-icon="blank" > @@ -38,6 +43,7 @@ const actions = [ }), onClick: () => (appStore.reorderLibraries = true), }, + { divider: true }, { title: intl.formatMessage({ description: 'Libraries menu: scan', diff --git a/next-ui/src/components/library/TabNavigation.stories.ts b/next-ui/src/components/library/TabNavigation.stories.ts new file mode 100644 index 00000000..4bac6425 --- /dev/null +++ b/next-ui/src/components/library/TabNavigation.stories.ts @@ -0,0 +1,82 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' + +import TabNavigation from './TabNavigation.vue' +import { expect, waitFor } from 'storybook/test' + +const meta = { + component: TabNavigation, + render: (args: object) => ({ + components: { TabNavigation }, + setup() { + return { args } + }, + template: '', + }), + parameters: { + // More on how to position stories at: https://storybook.js.org/docs/configure/story-layout + }, + args: {}, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = { + args: { + routes: [ + { title: 'Recommended', icon: 'i-mdi:star', to: '' }, + { title: 'Series', icon: 'i-mdi:bookshelf', to: '' }, + { title: 'Books', icon: 'i-mdi:book-multiple', to: '' }, + { title: 'Collections', icon: 'i-mdi:layers-triple', to: '' }, + { title: 'Read Lists', icon: 'i-mdi:bookmark-multiple', to: '' }, + ], + }, + play: async ({ canvas }) => { + await waitFor(() => expect(canvas.queryByText(/collections/i)).not.toBeNull()) + await waitFor(() => expect(canvas.queryByText(/read lists/i)).not.toBeNull()) + }, +} + +export const NoCollection: Story = { + args: { + routes: [ + { title: 'Recommended', icon: 'i-mdi:star', to: '' }, + { title: 'Series', icon: 'i-mdi:bookshelf', to: '' }, + { title: 'Books', icon: 'i-mdi:book-multiple', to: '' }, + { title: 'Read Lists', icon: 'i-mdi:bookmark-multiple', to: '' }, + ], + }, + play: async ({ canvas }) => { + await waitFor(() => expect(canvas.queryByText(/collections/i)).toBeNull()) + await waitFor(() => expect(canvas.queryByText(/read lists/i)).not.toBeNull()) + }, +} + +export const NoReadList: Story = { + args: { + routes: [ + { title: 'Recommended', icon: 'i-mdi:star', to: '' }, + { title: 'Series', icon: 'i-mdi:bookshelf', to: '' }, + { title: 'Books', icon: 'i-mdi:book-multiple', to: '' }, + { title: 'Collections', icon: 'i-mdi:layers-triple', to: '' }, + ], + }, + play: async ({ canvas }) => { + await waitFor(() => expect(canvas.queryByText(/collections/i)).not.toBeNull()) + await waitFor(() => expect(canvas.queryByText(/read lists/i)).toBeNull()) + }, +} + +export const NoCollectionNorReadList: Story = { + args: { + routes: [ + { title: 'Recommended', icon: 'i-mdi:star', to: '' }, + { title: 'Series', icon: 'i-mdi:bookshelf', to: '' }, + { title: 'Books', icon: 'i-mdi:book-multiple', to: '' }, + ], + }, + play: async ({ canvas }) => { + await waitFor(() => expect(canvas.queryByText(/collections/i)).toBeNull()) + await waitFor(() => expect(canvas.queryByText(/read lists/i)).toBeNull()) + }, +} diff --git a/next-ui/src/components/library/TabNavigation.vue b/next-ui/src/components/library/TabNavigation.vue new file mode 100644 index 00000000..753ed40f --- /dev/null +++ b/next-ui/src/components/library/TabNavigation.vue @@ -0,0 +1,20 @@ + + + + + diff --git a/next-ui/src/composables/libraries.test.ts b/next-ui/src/composables/libraries.test.ts new file mode 100644 index 00000000..871a56f7 --- /dev/null +++ b/next-ui/src/composables/libraries.test.ts @@ -0,0 +1,88 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'vitest' +import { server } from '@/mocks/api/node' +import { createMockColada } from '@/mocks/pinia-colada' +import { enableAutoUnmount } from '@vue/test-utils' +import { useGetLibrariesById } from '@/composables/libraries' +import { httpTyped } from '@/mocks/api/httpTyped' +import { libraries } from '@/mocks/api/handlers/libraries' +import type { components } from '@/generated/openapi/komga' +import { CLIENT_SETTING_USER, type ClientSettingUserLibrary } from '@/types/ClientSettingsUser' +import { waitFor } from 'storybook/test' +import type { LibraryId } from '@/types/libraries' + +beforeAll(() => server.listen()) +beforeEach(() => + server.use( + httpTyped.get('/api/v1/libraries', ({ response }) => { + const bds = { + ...libraries[0], + id: '3', + name: 'BDs', + } as components['schemas']['LibraryDto'] + const magazines = { + ...libraries[0], + id: '4', + name: 'Magazines', + } as components['schemas']['LibraryDto'] + const manga = { + ...libraries[0], + id: '5', + name: 'Mangas', + } as components['schemas']['LibraryDto'] + const libs = [bds, magazines, manga] + return response(200).json(libs) + }), + httpTyped.get('/api/v1/client-settings/user/list', ({ response }) => { + const userLibraries: Record = { + '3': { + unpinned: true, + }, + '4': { + unpinned: true, + }, + } + const settings: Record = { + [CLIENT_SETTING_USER.NEXTUI_LIBRARIES]: { + value: JSON.stringify(userLibraries), + }, + } + return response(200).json(settings) + }), + ), +) +afterEach(() => server.resetHandlers()) +afterAll(() => server.close()) + +enableAutoUnmount(afterEach) + +describe('libraries composable', () => { + test("when getting 'all' libraries then values are correct", async () => { + await doTest('all', ['3', '4', '5']) + }) + + test("when getting 'pinned' libraries then values are correct", async () => { + await doTest('pinned', ['5']) + }) + + test("when getting 'unpinned' libraries then values are correct", async () => { + await doTest('unpinned', ['3', '4']) + }) + + test('when getting specific ID then values are correct', async () => { + await doTest('4', ['4']) + }) + + test('when getting non-existent ID then values are correct', async () => { + await doTest('ABC', []) + }) +}) + +async function doTest(libraryId: LibraryId, expectedIds: string[]) { + createMockColada(() => useGetLibrariesById(libraryId)) + const { libraries } = useGetLibrariesById(libraryId) + + await waitFor(() => { + if (libraries.value === undefined) throw new Error('data not fetched') + }) + expect(libraries.value?.map((it) => it.id)).toStrictEqual(expectedIds) +} diff --git a/next-ui/src/composables/libraries.ts b/next-ui/src/composables/libraries.ts new file mode 100644 index 00000000..40eb699b --- /dev/null +++ b/next-ui/src/composables/libraries.ts @@ -0,0 +1,37 @@ +import { useLibraries } from '@/colada/libraries' +import type { LibraryId } from '@/types/libraries' +import type { components } from '@/generated/openapi/komga' + +/** + * A composable that returns libraries filtered by a LibraryId. + * @param libraryId the library ID or group to get + */ +export function useGetLibrariesById(libraryId: MaybeRefOrGetter) { + const { data: all, pinned, unpinned, status } = useLibraries() + + const libs = computed(() => { + if (status.value !== 'success') return undefined + + let libs: components['schemas']['LibraryDto'][] = [] + switch (toValue(libraryId)) { + case 'all': + libs = all.value || [] + break + case 'pinned': + libs = pinned.value + break + case 'unpinned': + libs = unpinned.value + break + default: + const lib = all.value?.find((it) => it.id === libraryId) + if (lib) libs = [lib] + break + } + return libs + }) + + return { + libraries: libs, + } +} diff --git a/next-ui/src/mocks/api/handlers.ts b/next-ui/src/mocks/api/handlers.ts index f42b8c0c..744e81f5 100644 --- a/next-ui/src/mocks/api/handlers.ts +++ b/next-ui/src/mocks/api/handlers.ts @@ -15,6 +15,7 @@ import { transientBooksHandlers } from '@/mocks/api/handlers/transient-books' import { readListsHandlers } from '@/mocks/api/handlers/readlists' import { pageHashesHandlers } from '@/mocks/api/handlers/page-hashes' import { clientSettingsHandlers } from '@/mocks/api/handlers/client-settings' +import { collectionsHandlers } from '@/mocks/api/handlers/collections' export const handlers = [ ...actuatorHandlers, @@ -22,6 +23,7 @@ export const handlers = [ ...booksHandlers, ...claimHandlers, ...clientSettingsHandlers, + ...collectionsHandlers, ...filesystemHandlers, ...historyHandlers, ...librariesHandlers, diff --git a/next-ui/src/mocks/api/handlers/collections.ts b/next-ui/src/mocks/api/handlers/collections.ts new file mode 100644 index 00000000..6a445b9c --- /dev/null +++ b/next-ui/src/mocks/api/handlers/collections.ts @@ -0,0 +1,47 @@ +import { httpTyped } from '@/mocks/api/httpTyped' +import { PageRequest } from '@/types/PageRequest' +import { mockPage } from '@/mocks/api/pageable' + +const collection1 = { + id: '026801S4HWRZA', + name: 'Golden Age', + ordered: true, + seriesIds: ['57'], + createdDate: new Date('2020-08-06T06:13:25Z'), + lastModifiedDate: new Date('2020-08-06T06:17:12Z'), + filtered: false, +} + +const collections = [collection1] + +export const collectionsHandlers = [ + httpTyped.get('/api/v1/collections', async ({ query, response }) => { + const search = query.get('search') + + const selected = collections.filter((it) => { + let include = true + if (search) include = include && !!it.name.match(new RegExp(search, 'i')) + return include + }) + + return response(200).json( + mockPage(selected, new PageRequest(Number(query.get('page')), Number(query.get('size')))), + ) + }), + // httpTyped.get('/api/v1/series/{seriesId}', ({ params, response }) => { + // if (params.seriesId === '404') return response(404).empty() + // return response(200).json( + // Object.assign({}, series1, { metadata: { title: `Series ${params.seriesId}` } }), + // ) + // }), + // http.get('*/api/v1/series/*/thumbnail', async () => { + // // Get an ArrayBuffer from reading the file from disk or fetching it. + // const buffer = await fetch(mockThumbnailUrl).then((response) => response.arrayBuffer()) + // + // return HttpResponse.arrayBuffer(buffer, { + // headers: { + // 'content-type': 'image/jpg', + // }, + // }) + // }), +] diff --git a/next-ui/src/pages/libraries/[id].vue b/next-ui/src/pages/libraries/[id].vue new file mode 100644 index 00000000..899cda69 --- /dev/null +++ b/next-ui/src/pages/libraries/[id].vue @@ -0,0 +1,22 @@ + + + + + +meta: + requiresRole: ADMIN + diff --git a/next-ui/src/pages/libraries/[id]/books.vue b/next-ui/src/pages/libraries/[id]/books.vue new file mode 100644 index 00000000..99155563 --- /dev/null +++ b/next-ui/src/pages/libraries/[id]/books.vue @@ -0,0 +1,12 @@ +series.vue + + + + + +meta: + requiresRole: ADMIN + diff --git a/next-ui/src/pages/libraries/[id]/collections.vue b/next-ui/src/pages/libraries/[id]/collections.vue new file mode 100644 index 00000000..8f400b68 --- /dev/null +++ b/next-ui/src/pages/libraries/[id]/collections.vue @@ -0,0 +1,13 @@ + + + + + +meta: + requiresRole: ADMIN + diff --git a/next-ui/src/pages/libraries/[id]/readlists.vue b/next-ui/src/pages/libraries/[id]/readlists.vue new file mode 100644 index 00000000..9a8e48f3 --- /dev/null +++ b/next-ui/src/pages/libraries/[id]/readlists.vue @@ -0,0 +1,13 @@ + + + + + +meta: + requiresRole: ADMIN + diff --git a/next-ui/src/pages/libraries/[id]/recommended.vue b/next-ui/src/pages/libraries/[id]/recommended.vue new file mode 100644 index 00000000..21c27160 --- /dev/null +++ b/next-ui/src/pages/libraries/[id]/recommended.vue @@ -0,0 +1,13 @@ + + + + + +meta: + requiresRole: ADMIN + diff --git a/next-ui/src/pages/libraries/[id]/series.vue b/next-ui/src/pages/libraries/[id]/series.vue new file mode 100644 index 00000000..0d547ff5 --- /dev/null +++ b/next-ui/src/pages/libraries/[id]/series.vue @@ -0,0 +1,13 @@ + + + + + +meta: + requiresRole: ADMIN + diff --git a/next-ui/src/typed-router.d.ts b/next-ui/src/typed-router.d.ts index ec28f9bf..39673ce6 100644 --- a/next-ui/src/typed-router.d.ts +++ b/next-ui/src/typed-router.d.ts @@ -100,6 +100,52 @@ declare module 'vue-router/auto-routes' { Record, | never >, + '/libraries/[id]': RouteRecordInfo< + '/libraries/[id]', + '/libraries/:id', + { id: ParamValue }, + { id: ParamValue }, + | '/libraries/[id]/books' + | '/libraries/[id]/collections' + | '/libraries/[id]/readlists' + | '/libraries/[id]/recommended' + | '/libraries/[id]/series' + >, + '/libraries/[id]/books': RouteRecordInfo< + '/libraries/[id]/books', + '/libraries/:id/books', + { id: ParamValue }, + { id: ParamValue }, + | never + >, + '/libraries/[id]/collections': RouteRecordInfo< + '/libraries/[id]/collections', + '/libraries/:id/collections', + { id: ParamValue }, + { id: ParamValue }, + | never + >, + '/libraries/[id]/readlists': RouteRecordInfo< + '/libraries/[id]/readlists', + '/libraries/:id/readlists', + { id: ParamValue }, + { id: ParamValue }, + | never + >, + '/libraries/[id]/recommended': RouteRecordInfo< + '/libraries/[id]/recommended', + '/libraries/:id/recommended', + { id: ParamValue }, + { id: ParamValue }, + | never + >, + '/libraries/[id]/series': RouteRecordInfo< + '/libraries/[id]/series', + '/libraries/:id/series', + { id: ParamValue }, + { id: ParamValue }, + | never + >, '/login': RouteRecordInfo< '/login', '/login', @@ -277,6 +323,47 @@ declare module 'vue-router/auto-routes' { views: | never } + 'src/pages/libraries/[id].vue': { + routes: + | '/libraries/[id]' + | '/libraries/[id]/books' + | '/libraries/[id]/collections' + | '/libraries/[id]/readlists' + | '/libraries/[id]/recommended' + | '/libraries/[id]/series' + views: + | 'default' + } + 'src/pages/libraries/[id]/books.vue': { + routes: + | '/libraries/[id]/books' + views: + | never + } + 'src/pages/libraries/[id]/collections.vue': { + routes: + | '/libraries/[id]/collections' + views: + | never + } + 'src/pages/libraries/[id]/readlists.vue': { + routes: + | '/libraries/[id]/readlists' + views: + | never + } + 'src/pages/libraries/[id]/recommended.vue': { + routes: + | '/libraries/[id]/recommended' + views: + | never + } + 'src/pages/libraries/[id]/series.vue': { + routes: + | '/libraries/[id]/series' + views: + | never + } 'src/pages/login.vue': { routes: | '/login' diff --git a/next-ui/src/types/PageRequest.ts b/next-ui/src/types/PageRequest.ts index 7e1f4c02..ce12d321 100644 --- a/next-ui/src/types/PageRequest.ts +++ b/next-ui/src/types/PageRequest.ts @@ -17,6 +17,10 @@ export class PageRequest { return new PageRequest(undefined, undefined, undefined, true) } + static Zero(): PageRequest { + return new PageRequest(undefined, 0, undefined, undefined) + } + /** * Can be used from v-data-table-server @update:options * @param page diff --git a/next-ui/src/types/libraries.ts b/next-ui/src/types/libraries.ts new file mode 100644 index 00000000..5456f806 --- /dev/null +++ b/next-ui/src/types/libraries.ts @@ -0,0 +1,5 @@ +/** + * Represents either a specific library ID, or a group of libraries + */ +// eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents +export type LibraryId = string | 'all' | 'pinned' | 'unpinned' diff --git a/next-ui/src/types/route.ts b/next-ui/src/types/route.ts new file mode 100644 index 00000000..bc3f2f35 --- /dev/null +++ b/next-ui/src/types/route.ts @@ -0,0 +1,5 @@ +export type Route = { + title: string + icon?: string + to: string +}