mirror of
https://github.com/gotson/komga.git
synced 2026-05-07 20:15:47 +02:00
scaffolding for library browsing
This commit is contained in:
parent
6a73408c3c
commit
e46afaa98d
25 changed files with 728 additions and 11 deletions
57
next-ui/src/colada/collections.ts
Normal file
57
next-ui/src/colada/collections.ts
Normal file
|
|
@ -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),
|
||||
}),
|
||||
)
|
||||
|
|
@ -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
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
|
|||
3
next-ui/src/components.d.ts
vendored
3
next-ui/src/components.d.ts
vendored
|
|
@ -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']
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -8,11 +8,13 @@
|
|||
})
|
||||
"
|
||||
prepend-icon="i-mdi:bookshelf"
|
||||
to="/libraries/pinned"
|
||||
>
|
||||
<template #append>
|
||||
<v-icon-btn
|
||||
v-if="isAdmin"
|
||||
icon="i-mdi:plus"
|
||||
variant="text"
|
||||
:aria-label="
|
||||
$formatMessage({
|
||||
description: 'Add library button: aria label',
|
||||
|
|
@ -23,11 +25,12 @@
|
|||
@mouseenter="
|
||||
(event: Event) => (dialogConfirmEdit.activator = event.currentTarget as Element)
|
||||
"
|
||||
@click="createLibrary"
|
||||
@click.prevent="createLibrary"
|
||||
/>
|
||||
<v-icon-btn
|
||||
id="ID01KC5N8S3V35QV04SYETY01M9H"
|
||||
icon="i-mdi:dots-vertical"
|
||||
variant="text"
|
||||
:aria-label="
|
||||
$formatMessage({
|
||||
description: 'Libraries menu button: aria label',
|
||||
|
|
@ -35,6 +38,7 @@
|
|||
id: 'hJEc5M',
|
||||
})
|
||||
"
|
||||
@click.prevent
|
||||
/>
|
||||
<LibraryMenuLibraries activator-id="#ID01KC5N8S3V35QV04SYETY01M9H" />
|
||||
</template>
|
||||
|
|
@ -44,6 +48,7 @@
|
|||
v-for="library in pinned"
|
||||
:key="library.id"
|
||||
:title="library.name"
|
||||
:to="`/libraries/${library.id}`"
|
||||
prepend-icon="blank"
|
||||
>
|
||||
<template #append>
|
||||
|
|
@ -51,6 +56,7 @@
|
|||
v-if="isAdmin"
|
||||
:id="`ID01KC5NTP02S3CMF12ZS2R4HNWX${library.id}`"
|
||||
icon="i-mdi:dots-vertical"
|
||||
variant="text"
|
||||
:aria-label="
|
||||
$formatMessage({
|
||||
description: 'Library menu button: aria label',
|
||||
|
|
@ -58,6 +64,7 @@
|
|||
id: '3gimvl',
|
||||
})
|
||||
"
|
||||
@click.prevent
|
||||
/>
|
||||
<LibraryMenuLibrary
|
||||
:activator-id="`#ID01KC5NTP02S3CMF12ZS2R4HNWX${library.id}`"
|
||||
|
|
@ -88,6 +95,7 @@
|
|||
v-for="library in unpinned"
|
||||
:key="library.id"
|
||||
:title="library.name"
|
||||
:to="`/libraries/${library.id}`"
|
||||
prepend-icon="blank"
|
||||
>
|
||||
<template #append>
|
||||
|
|
@ -95,6 +103,7 @@
|
|||
v-if="isAdmin"
|
||||
:id="`ID01KC5QH18T79WTFFJWJ6ES4SFE${library.id}`"
|
||||
icon="i-mdi:dots-vertical"
|
||||
variant="text"
|
||||
:aria-label="
|
||||
$formatMessage({
|
||||
description: 'Library menu button: aria label',
|
||||
|
|
@ -102,6 +111,7 @@
|
|||
id: '3gimvl',
|
||||
})
|
||||
"
|
||||
@click.prevent
|
||||
/>
|
||||
<LibraryMenuLibrary
|
||||
:activator-id="`#ID01KC5QH18T79WTFFJWJ6ES4SFE${library.id}`"
|
||||
|
|
@ -127,6 +137,7 @@ import type { ErrorCause } from '@/api/komga-client'
|
|||
import { commonMessages } from '@/utils/i18n/common-messages'
|
||||
|
||||
const intl = useIntl()
|
||||
const router = useRouter()
|
||||
const display = useDisplay()
|
||||
const { unpinned, pinned, refresh } = useLibraries()
|
||||
const { isAdmin } = useCurrentUser()
|
||||
|
|
|
|||
82
next-ui/src/components/library/BottomNavigation.stories.ts
Normal file
82
next-ui/src/components/library/BottomNavigation.stories.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import BottomNavigation from './BottomNavigation.vue'
|
||||
import { expect, waitFor } from 'storybook/test'
|
||||
|
||||
const meta = {
|
||||
component: BottomNavigation,
|
||||
render: (args: object) => ({
|
||||
components: { BottomNavigation },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
template: '<BottomNavigation v-bind="args"/>',
|
||||
}),
|
||||
parameters: {
|
||||
// More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
|
||||
},
|
||||
args: {},
|
||||
} satisfies Meta<typeof BottomNavigation>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
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())
|
||||
},
|
||||
}
|
||||
25
next-ui/src/components/library/BottomNavigation.vue
Normal file
25
next-ui/src/components/library/BottomNavigation.vue
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<template>
|
||||
<v-bottom-navigation grow>
|
||||
<v-btn
|
||||
v-for="(route, i) in routes"
|
||||
:key="i"
|
||||
:to="route.to"
|
||||
>
|
||||
<v-icon
|
||||
v-if="route.icon"
|
||||
:icon="route.icon"
|
||||
/>
|
||||
<span>{{ route.title }}</span>
|
||||
</v-btn>
|
||||
</v-bottom-navigation>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Route } from '@/types/route'
|
||||
|
||||
const { routes } = defineProps<{
|
||||
routes: Route[]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
66
next-ui/src/components/library/Holder.vue
Normal file
66
next-ui/src/components/library/Holder.vue
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
<template>
|
||||
<LibraryBottomNavigation
|
||||
v-if="display.xs.value"
|
||||
:routes="routes"
|
||||
/>
|
||||
|
||||
<LibraryTabNavigation
|
||||
v-else
|
||||
:routes="routes"
|
||||
/>
|
||||
|
||||
<RouterView />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { LibraryId } from '@/types/libraries'
|
||||
import { useGetLibrariesById } from '@/composables/libraries'
|
||||
import { useQuery } from '@pinia/colada'
|
||||
import { collectionsListQuery } from '@/colada/collections'
|
||||
import { PageRequest } from '@/types/PageRequest'
|
||||
import { readListsListQuery } from '@/colada/readlists'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
const { libraryId } = defineProps<{
|
||||
libraryId: LibraryId
|
||||
}>()
|
||||
|
||||
const display = useDisplay()
|
||||
|
||||
const { libraries } = useGetLibrariesById(libraryId)
|
||||
const { data: collections } = useQuery(collectionsListQuery, () => ({
|
||||
libraryIds: libraries.value?.map((it) => it.id),
|
||||
pageRequest: PageRequest.Zero(),
|
||||
pause: libraries.value === undefined,
|
||||
}))
|
||||
const { data: readlists } = useQuery(readListsListQuery, () => ({
|
||||
libraryIds: libraries.value?.map((it) => it.id),
|
||||
pageRequest: PageRequest.Zero(),
|
||||
pause: libraries.value === undefined,
|
||||
}))
|
||||
|
||||
const routesBase = [
|
||||
{ title: 'Recommended', icon: 'i-mdi:star', to: `/libraries/${libraryId}/recommended` },
|
||||
{ title: 'Series', icon: 'i-mdi:bookshelf', to: `/libraries/${libraryId}/series` },
|
||||
{ title: 'Books', icon: 'i-mdi:book-multiple', to: `/libraries/${libraryId}/books` },
|
||||
]
|
||||
|
||||
const routes = computed(() => {
|
||||
const extra = []
|
||||
if ((collections.value?.totalElements ?? 0) > 0)
|
||||
extra.push({
|
||||
title: 'Collections',
|
||||
icon: 'i-mdi:layers-triple',
|
||||
to: `/libraries/${libraryId}/collections`,
|
||||
})
|
||||
if ((readlists.value?.totalElements ?? 0) > 0)
|
||||
extra.push({
|
||||
title: 'Read Lists',
|
||||
icon: 'i-mdi:bookmark-multiple',
|
||||
to: `/libraries/${libraryId}/readlists`,
|
||||
})
|
||||
return [...routesBase, ...extra]
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
@ -4,11 +4,16 @@
|
|||
location="end"
|
||||
>
|
||||
<v-list density="compact">
|
||||
<v-list-item
|
||||
<template
|
||||
v-for="(action, i) in actions"
|
||||
:key="i"
|
||||
v-bind="action"
|
||||
/>
|
||||
>
|
||||
<v-divider v-if="action.divider" />
|
||||
<v-list-item
|
||||
v-else
|
||||
v-bind="action"
|
||||
/>
|
||||
</template>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</template>
|
||||
|
|
@ -38,6 +43,7 @@ const actions = [
|
|||
}),
|
||||
onClick: () => (appStore.reorderLibraries = true),
|
||||
},
|
||||
{ divider: true },
|
||||
{
|
||||
title: intl.formatMessage({
|
||||
description: 'Libraries menu: scan',
|
||||
|
|
|
|||
82
next-ui/src/components/library/TabNavigation.stories.ts
Normal file
82
next-ui/src/components/library/TabNavigation.stories.ts
Normal file
|
|
@ -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: '<TabNavigation v-bind="args"/>',
|
||||
}),
|
||||
parameters: {
|
||||
// More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
|
||||
},
|
||||
args: {},
|
||||
} satisfies Meta<typeof TabNavigation>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
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())
|
||||
},
|
||||
}
|
||||
20
next-ui/src/components/library/TabNavigation.vue
Normal file
20
next-ui/src/components/library/TabNavigation.vue
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<template>
|
||||
<v-tabs :items="routes">
|
||||
<template #tab="{ item: route }">
|
||||
<v-tab
|
||||
:text="route.title"
|
||||
:to="route.to"
|
||||
/>
|
||||
</template>
|
||||
</v-tabs>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Route } from '@/types/route'
|
||||
|
||||
const { routes } = defineProps<{
|
||||
routes: Route[]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
88
next-ui/src/composables/libraries.test.ts
Normal file
88
next-ui/src/composables/libraries.test.ts
Normal file
|
|
@ -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<string, ClientSettingUserLibrary> = {
|
||||
'3': {
|
||||
unpinned: true,
|
||||
},
|
||||
'4': {
|
||||
unpinned: true,
|
||||
},
|
||||
}
|
||||
const settings: Record<string, components['schemas']['ClientSettingUserUpdateDto']> = {
|
||||
[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)
|
||||
}
|
||||
37
next-ui/src/composables/libraries.ts
Normal file
37
next-ui/src/composables/libraries.ts
Normal file
|
|
@ -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<LibraryId>) {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
47
next-ui/src/mocks/api/handlers/collections.ts
Normal file
47
next-ui/src/mocks/api/handlers/collections.ts
Normal file
|
|
@ -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',
|
||||
// },
|
||||
// })
|
||||
// }),
|
||||
]
|
||||
22
next-ui/src/pages/libraries/[id].vue
Normal file
22
next-ui/src/pages/libraries/[id].vue
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<template>
|
||||
<LibraryHolder
|
||||
:key="libraryId"
|
||||
:library-id="libraryId"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const libraryId = computed(() => route.params.id)
|
||||
|
||||
//TODO: for now we always redirect to 'recommended', this should be persisted per libraryId
|
||||
watch(libraryId, () => {
|
||||
void router.push({ name: '/libraries/[id]/recommended', params: { id: libraryId.value } })
|
||||
})
|
||||
</script>
|
||||
|
||||
<route lang="yaml">
|
||||
meta:
|
||||
requiresRole: ADMIN
|
||||
</route>
|
||||
12
next-ui/src/pages/libraries/[id]/books.vue
Normal file
12
next-ui/src/pages/libraries/[id]/books.vue
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
series.vue
|
||||
<template>
|
||||
BOOKS
|
||||
<EmptyStateConstruction />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup></script>
|
||||
|
||||
<route lang="yaml">
|
||||
meta:
|
||||
requiresRole: ADMIN
|
||||
</route>
|
||||
13
next-ui/src/pages/libraries/[id]/collections.vue
Normal file
13
next-ui/src/pages/libraries/[id]/collections.vue
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<template>
|
||||
COLLECTIONS
|
||||
<EmptyStateConstruction />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
//
|
||||
</script>
|
||||
|
||||
<route lang="yaml">
|
||||
meta:
|
||||
requiresRole: ADMIN
|
||||
</route>
|
||||
13
next-ui/src/pages/libraries/[id]/readlists.vue
Normal file
13
next-ui/src/pages/libraries/[id]/readlists.vue
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<template>
|
||||
READLISTS
|
||||
<EmptyStateConstruction />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
//
|
||||
</script>
|
||||
|
||||
<route lang="yaml">
|
||||
meta:
|
||||
requiresRole: ADMIN
|
||||
</route>
|
||||
13
next-ui/src/pages/libraries/[id]/recommended.vue
Normal file
13
next-ui/src/pages/libraries/[id]/recommended.vue
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<template>
|
||||
RECOMMENDED
|
||||
<EmptyStateConstruction />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
//
|
||||
</script>
|
||||
|
||||
<route lang="yaml">
|
||||
meta:
|
||||
requiresRole: ADMIN
|
||||
</route>
|
||||
13
next-ui/src/pages/libraries/[id]/series.vue
Normal file
13
next-ui/src/pages/libraries/[id]/series.vue
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<template>
|
||||
SERIES
|
||||
<EmptyStateConstruction />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
//
|
||||
</script>
|
||||
|
||||
<route lang="yaml">
|
||||
meta:
|
||||
requiresRole: ADMIN
|
||||
</route>
|
||||
87
next-ui/src/typed-router.d.ts
vendored
87
next-ui/src/typed-router.d.ts
vendored
|
|
@ -100,6 +100,52 @@ declare module 'vue-router/auto-routes' {
|
|||
Record<never, never>,
|
||||
| never
|
||||
>,
|
||||
'/libraries/[id]': RouteRecordInfo<
|
||||
'/libraries/[id]',
|
||||
'/libraries/:id',
|
||||
{ id: ParamValue<true> },
|
||||
{ id: ParamValue<false> },
|
||||
| '/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<true> },
|
||||
{ id: ParamValue<false> },
|
||||
| never
|
||||
>,
|
||||
'/libraries/[id]/collections': RouteRecordInfo<
|
||||
'/libraries/[id]/collections',
|
||||
'/libraries/:id/collections',
|
||||
{ id: ParamValue<true> },
|
||||
{ id: ParamValue<false> },
|
||||
| never
|
||||
>,
|
||||
'/libraries/[id]/readlists': RouteRecordInfo<
|
||||
'/libraries/[id]/readlists',
|
||||
'/libraries/:id/readlists',
|
||||
{ id: ParamValue<true> },
|
||||
{ id: ParamValue<false> },
|
||||
| never
|
||||
>,
|
||||
'/libraries/[id]/recommended': RouteRecordInfo<
|
||||
'/libraries/[id]/recommended',
|
||||
'/libraries/:id/recommended',
|
||||
{ id: ParamValue<true> },
|
||||
{ id: ParamValue<false> },
|
||||
| never
|
||||
>,
|
||||
'/libraries/[id]/series': RouteRecordInfo<
|
||||
'/libraries/[id]/series',
|
||||
'/libraries/:id/series',
|
||||
{ id: ParamValue<true> },
|
||||
{ id: ParamValue<false> },
|
||||
| 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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
5
next-ui/src/types/libraries.ts
Normal file
5
next-ui/src/types/libraries.ts
Normal file
|
|
@ -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'
|
||||
5
next-ui/src/types/route.ts
Normal file
5
next-ui/src/types/route.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export type Route = {
|
||||
title: string
|
||||
icon?: string
|
||||
to: string
|
||||
}
|
||||
Loading…
Reference in a new issue