scaffolding for library browsing

This commit is contained in:
Gauthier Roebroeck 2025-12-17 18:09:05 +08:00
parent 6a73408c3c
commit e46afaa98d
25 changed files with 728 additions and 11 deletions

View 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),
}),
)

View file

@ -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
}),
)

View file

@ -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']

View file

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

View file

@ -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()

View 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())
},
}

View 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>

View 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>

View file

@ -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',

View 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())
},
}

View 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>

View 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)
}

View 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,
}
}

View file

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

View 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',
// },
// })
// }),
]

View 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>

View file

@ -0,0 +1,12 @@
series.vue
<template>
BOOKS
<EmptyStateConstruction />
</template>
<script lang="ts" setup></script>
<route lang="yaml">
meta:
requiresRole: ADMIN
</route>

View file

@ -0,0 +1,13 @@
<template>
COLLECTIONS
<EmptyStateConstruction />
</template>
<script lang="ts" setup>
//
</script>
<route lang="yaml">
meta:
requiresRole: ADMIN
</route>

View file

@ -0,0 +1,13 @@
<template>
READLISTS
<EmptyStateConstruction />
</template>
<script lang="ts" setup>
//
</script>
<route lang="yaml">
meta:
requiresRole: ADMIN
</route>

View file

@ -0,0 +1,13 @@
<template>
RECOMMENDED
<EmptyStateConstruction />
</template>
<script lang="ts" setup>
//
</script>
<route lang="yaml">
meta:
requiresRole: ADMIN
</route>

View file

@ -0,0 +1,13 @@
<template>
SERIES
<EmptyStateConstruction />
</template>
<script lang="ts" setup>
//
</script>
<route lang="yaml">
meta:
requiresRole: ADMIN
</route>

View file

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

View file

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

View 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'

View file

@ -0,0 +1,5 @@
export type Route = {
title: string
icon?: string
to: string
}