mirror of
https://github.com/gotson/komga.git
synced 2025-12-06 16:42:24 +01:00
start adding libraries in drawer
This commit is contained in:
parent
8f3f8c1d9a
commit
ba41c110f6
10 changed files with 315 additions and 1 deletions
49
next-ui/src/colada/client-settings.ts
Normal file
49
next-ui/src/colada/client-settings.ts
Normal 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() })
|
||||
},
|
||||
})
|
||||
})
|
||||
|
|
@ -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,
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
},
|
||||
})
|
||||
})
|
||||
|
|
|
|||
1
next-ui/src/components.d.ts
vendored
1
next-ui/src/components.d.ts
vendored
|
|
@ -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']
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
105
next-ui/src/components/layout/app/drawer/menu/Libraries.vue
Normal file
105
next-ui/src/components/layout/app/drawer/menu/Libraries.vue
Normal 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>
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
<template>
|
||||
<v-list nav>
|
||||
<LayoutAppDrawerMenuLibraries />
|
||||
<LayoutAppDrawerMenuImport v-if="isAdmin" />
|
||||
<LayoutAppDrawerMenuMedia v-if="isAdmin" />
|
||||
<LayoutAppDrawerMenuHistory v-if="isAdmin" />
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
16
next-ui/src/mocks/api/handlers/client-settings.ts
Normal file
16
next-ui/src/mocks/api/handlers/client-settings.ts
Normal 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()),
|
||||
]
|
||||
8
next-ui/src/types/ClientSettingsUser.ts
Normal file
8
next-ui/src/types/ClientSettingsUser.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
export enum CLIENT_SETTING_USER {
|
||||
NEXTUI_LIBRARIES = 'komga.nextui.libraries',
|
||||
}
|
||||
|
||||
export type ClientSettingUserLibrary = {
|
||||
unpinned?: boolean
|
||||
order?: number
|
||||
}
|
||||
Loading…
Reference in a new issue