start adding libraries in drawer

This commit is contained in:
Gauthier Roebroeck 2025-12-02 15:51:05 +08:00
parent 8f3f8c1d9a
commit ba41c110f6
10 changed files with 315 additions and 1 deletions

View file

@ -0,0 +1,49 @@
import { defineMutation, defineQuery, useMutation, useQuery, useQueryCache } from '@pinia/colada'
import { komgaClient } from '@/api/komga-client'
import type { components } from '@/generated/openapi/komga'
import { CLIENT_SETTING_USER, type ClientSettingUserLibrary } from '@/types/ClientSettingsUser'
export const QUERY_KEYS_CLIENT_SETTINGS = {
root: ['client-settings'] as const,
global: () => [...QUERY_KEYS_CLIENT_SETTINGS.root, 'global'] as const,
user: () => [...QUERY_KEYS_CLIENT_SETTINGS.root, 'user'] as const,
}
export const useClientSettingsUser = defineQuery(() => {
const { data, ...rest } = useQuery({
key: () => QUERY_KEYS_CLIENT_SETTINGS.user(),
query: () =>
komgaClient
.GET('/api/v1/client-settings/user/list')
// unwrap the openapi-fetch structure on success
.then((res) => res.data),
// 1 hour
staleTime: 60 * 60 * 1000,
gcTime: false,
})
const userLibraries = computed(() => {
const json = data.value?.[CLIENT_SETTING_USER.NEXTUI_LIBRARIES]?.value
if (json) return JSON.parse(json) as Record<string, ClientSettingUserLibrary>
return {}
})
return {
data,
...rest,
userLibraries,
}
})
export const useUpdateClientSettingsUser = defineMutation(() => {
const queryCache = useQueryCache()
return useMutation({
mutation: (settings: Record<string, components['schemas']['ClientSettingUserUpdateDto']>) =>
komgaClient.PATCH('/api/v1/client-settings/user', {
body: settings,
}),
onSuccess: () => {
void queryCache.invalidateQueries({ key: QUERY_KEYS_CLIENT_SETTINGS.user() })
},
})
})

View file

@ -1,8 +1,9 @@
import { defineQuery, useQuery } from '@pinia/colada'
import { komgaClient } from '@/api/komga-client'
import { useClientSettingsUser } from '@/colada/client-settings'
export const useLibraries = defineQuery(() => {
return useQuery({
const { data, ...rest } = useQuery({
key: () => ['libraries'],
query: () =>
komgaClient
@ -13,4 +14,28 @@ export const useLibraries = defineQuery(() => {
staleTime: 60 * 60 * 1000,
gcTime: false,
})
const { userLibraries } = useClientSettingsUser()
const ordered = computed(() =>
data?.value?.sort(
(a, b) =>
(userLibraries.value?.[a.id]?.order || 0) - (userLibraries.value?.[b.id]?.order || 0),
),
)
const unpinned = computed(
() => ordered.value?.filter((it) => userLibraries.value?.[it.id]?.unpinned) || [],
)
const pinned = computed(
() => ordered.value?.filter((it) => !userLibraries.value?.[it.id]?.unpinned) || [],
)
return {
data,
ordered,
unpinned,
pinned,
...rest,
}
})

View file

@ -9,6 +9,7 @@ import {
import { komgaClient } from '@/api/komga-client'
import { UserRoles } from '@/types/UserRoles'
import type { components } from '@/generated/openapi/komga'
import { QUERY_KEYS_CLIENT_SETTINGS } from '@/colada/client-settings'
export const QUERY_KEYS_USERS = {
root: ['users'] as const,
@ -78,6 +79,8 @@ export const useLogin = defineMutation(() => {
onSuccess: ({ data }) => {
queryCache.setQueryData(QUERY_KEYS_USERS.currentUser, data)
queryCache.cancelQueries({ key: QUERY_KEYS_USERS.currentUser })
void queryCache.invalidateQueries({ key: QUERY_KEYS_CLIENT_SETTINGS.root })
},
})
})
@ -88,6 +91,8 @@ export const useLogout = defineMutation(() => {
mutation: () => komgaClient.POST('/api/logout'),
onSuccess: () => {
void queryCache.invalidateQueries({ key: QUERY_KEYS_USERS.currentUser })
void queryCache.invalidateQueries({ key: QUERY_KEYS_CLIENT_SETTINGS.root })
},
})
})

View file

@ -49,6 +49,7 @@ declare module 'vue' {
LayoutAppDrawerMenuAccount: typeof import('./components/layout/app/drawer/menu/Account.vue')['default']
LayoutAppDrawerMenuHistory: typeof import('./components/layout/app/drawer/menu/History.vue')['default']
LayoutAppDrawerMenuImport: typeof import('./components/layout/app/drawer/menu/Import.vue')['default']
LayoutAppDrawerMenuLibraries: typeof import('./components/layout/app/drawer/menu/Libraries.vue')['default']
LayoutAppDrawerMenuLogout: typeof import('./components/layout/app/drawer/menu/Logout.vue')['default']
LayoutAppDrawerMenuMedia: typeof import('./components/layout/app/drawer/menu/Media.vue')['default']
LayoutAppDrawerMenuServer: typeof import('./components/layout/app/drawer/menu/Server.vue')['default']

View file

@ -0,0 +1,102 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import Libraries from './Libraries.vue'
import { httpTyped } from '@/mocks/api/httpTyped'
import { userRegular } from '@/mocks/api/handlers/users'
import { expect, waitFor } from 'storybook/test'
import { CLIENT_SETTING_USER, type ClientSettingUserLibrary } from '@/types/ClientSettingsUser'
import type { components } from '@/generated/openapi/komga'
import { VList } from 'vuetify/components'
const meta = {
component: Libraries,
render: (args: object) => ({
components: { Libraries, VList },
setup() {
return { args }
},
template: '<v-list nav><Libraries /></v-list>',
}),
parameters: {
// More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
},
args: {},
} satisfies Meta<typeof Libraries>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {},
play: async ({ canvas }) => {
await waitFor(() => expect(canvas.queryByLabelText(/add library/i)).not.toBeNull())
await waitFor(() => expect(canvas.queryByLabelText(/libraries menu/i)).not.toBeNull())
await waitFor(() => expect(canvas.queryAllByLabelText(/library menu/i)).not.toBeNull())
},
}
export const NonAdmin: Story = {
parameters: {
msw: {
handlers: [
httpTyped.get('/api/v2/users/me', ({ response }) => response(200).json(userRegular)),
],
},
},
play: async ({ canvas }) => {
await waitFor(() => expect(canvas.queryByLabelText(/add library/i)).toBeNull())
await waitFor(() => expect(canvas.queryByLabelText(/libraries menu/i)).not.toBeNull())
await waitFor(() => expect(canvas.queryAllByLabelText(/library menu/i)).toHaveLength(0))
},
}
export const Unpinned: Story = {
parameters: {
msw: {
handlers: [
httpTyped.get('/api/v1/client-settings/user/list', ({ response }) => {
const userLibraries: Record<string, ClientSettingUserLibrary> = {
'2': {
unpinned: true,
},
}
const settings: Record<string, components['schemas']['ClientSettingUserUpdateDto']> = {
[CLIENT_SETTING_USER.NEXTUI_LIBRARIES]: {
value: JSON.stringify(userLibraries),
},
}
return response(200).json(settings)
}),
],
},
},
play: async ({ canvas, userEvent }) => {
await waitFor(() => userEvent.click(canvas.getByText(/more/i)))
await waitFor(() => expect(canvas.queryByText(/comics/i)).toBeVisible())
},
}
export const Ordered: Story = {
parameters: {
msw: {
handlers: [
httpTyped.get('/api/v1/client-settings/user/list', ({ response }) => {
const userLibraries: Record<string, ClientSettingUserLibrary> = {
'1': {
order: 2,
},
'2': {
order: 1,
},
}
const settings: Record<string, components['schemas']['ClientSettingUserUpdateDto']> = {
[CLIENT_SETTING_USER.NEXTUI_LIBRARIES]: {
value: JSON.stringify(userLibraries),
},
}
return response(200).json(settings)
}),
],
},
},
}

View file

@ -0,0 +1,105 @@
<template>
<v-list-item
:title="
$formatMessage({
description: 'Drawer menu for Libraries',
defaultMessage: 'Libraries',
id: 'eyYZUe',
})
"
prepend-icon="i-mdi:bookshelf"
>
<template #append>
<v-icon-btn
v-if="isAdmin"
icon="i-mdi:plus"
:aria-label="
$formatMessage({
description: 'Add library button: aria label',
defaultMessage: 'add library',
id: '90yqRq',
})
"
/>
<v-icon-btn
icon="i-mdi:dots-vertical"
:aria-label="
$formatMessage({
description: 'Libraries menu button: aria label',
defaultMessage: 'libraries menu',
id: 'hJEc5M',
})
"
/>
</template>
</v-list-item>
<v-list-item
v-for="library in pinned"
:key="library.id"
:title="library.name"
prepend-icon="blank"
>
<template #append>
<v-icon-btn
v-if="isAdmin"
icon="i-mdi:dots-vertical"
:aria-label="
$formatMessage({
description: 'Library menu button: aria label',
defaultMessage: 'library menu',
id: '3gimvl',
})
"
/>
</template>
</v-list-item>
<v-list-group
v-if="unpinned.length > 0"
value="Unpinned"
>
<template #activator="{ props }">
<v-list-item
v-bind="props"
prepend-icon="blank"
:title="
$formatMessage({
description: 'Drawer menu for Unpinned libraries',
defaultMessage: 'More',
id: 'XDV3Si',
})
"
/>
</template>
<v-list-item
v-for="library in unpinned"
:key="library.id"
:title="library.name"
prepend-icon="blank"
>
<template #append>
<v-icon-btn
v-if="isAdmin"
icon="i-mdi:dots-vertical"
:aria-label="
$formatMessage({
description: 'Library menu button: aria label',
defaultMessage: 'library menu',
id: '3gimvl',
})
"
/>
</template>
</v-list-item>
</v-list-group>
</template>
<script setup lang="ts">
import { useLibraries } from '@/colada/libraries'
import { useCurrentUser } from '@/colada/users'
const { unpinned, pinned } = useLibraries()
const { isAdmin } = useCurrentUser()
</script>

View file

@ -1,5 +1,6 @@
<template>
<v-list nav>
<LayoutAppDrawerMenuLibraries />
<LayoutAppDrawerMenuImport v-if="isAdmin" />
<LayoutAppDrawerMenuMedia v-if="isAdmin" />
<LayoutAppDrawerMenuHistory v-if="isAdmin" />

View file

@ -14,12 +14,14 @@ import { filesystemHandlers } from '@/mocks/api/handlers/filesystem'
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'
export const handlers = [
...actuatorHandlers,
...announcementHandlers,
...booksHandlers,
...claimHandlers,
...clientSettingsHandlers,
...filesystemHandlers,
...historyHandlers,
...librariesHandlers,

View file

@ -0,0 +1,16 @@
import { httpTyped } from '@/mocks/api/httpTyped'
import { CLIENT_SETTING_USER } from '@/types/ClientSettingsUser'
import type { components } from '@/generated/openapi/komga'
const settings: Record<string, components['schemas']['ClientSettingUserUpdateDto']> = {
[CLIENT_SETTING_USER.NEXTUI_LIBRARIES]: {
value: JSON.stringify({}),
},
}
export const clientSettingsHandlers = [
httpTyped.get('/api/v1/client-settings/user/list', ({ response }) =>
response(200).json(settings),
),
httpTyped.patch('/api/v1/client-settings/user', ({ response }) => response(204).empty()),
]

View file

@ -0,0 +1,8 @@
export enum CLIENT_SETTING_USER {
NEXTUI_LIBRARIES = 'komga.nextui.libraries',
}
export type ClientSettingUserLibrary = {
unpinned?: boolean
order?: number
}