reorder libraries component

This commit is contained in:
Gauthier Roebroeck 2025-12-04 14:54:25 +08:00
parent 2597df1ede
commit ebd9ee3af6
8 changed files with 306 additions and 2 deletions

View file

@ -55,7 +55,7 @@ export default defineConfigWithVueTs(
'PascalCase',
{
registeredComponentsOnly: false,
ignores: ['/^v-/'],
ignores: ['/^v-/', 'draggable'],
},
],
},

View file

@ -21,6 +21,7 @@
"pinia-plugin-persistedstate": "^4.7.1",
"vue": "^3.5.25",
"vue-intl": "^6.5.27",
"vuedraggable": "^4.1.0",
"vuetify": "^3.11.2"
},
"devDependencies": {
@ -10113,6 +10114,12 @@
"node": ">=18"
}
},
"node_modules/sortablejs": {
"version": "1.14.0",
"resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.14.0.tgz",
"integrity": "sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w==",
"license": "MIT"
},
"node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
@ -11708,6 +11715,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/vuedraggable": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/vuedraggable/-/vuedraggable-4.1.0.tgz",
"integrity": "sha512-FU5HCWBmsf20GpP3eudURW3WdWTKIbEIQxh9/8GE806hydR9qZqRRxRE3RjqX7PkuLuMQG/A7n3cfj9rCEchww==",
"license": "MIT",
"dependencies": {
"sortablejs": "1.14.0"
},
"peerDependencies": {
"vue": "^3.0.1"
}
},
"node_modules/vuetify": {
"version": "3.11.2",
"resolved": "https://registry.npmjs.org/vuetify/-/vuetify-3.11.2.tgz",

View file

@ -41,6 +41,7 @@
"pinia-plugin-persistedstate": "^4.7.1",
"vue": "^3.5.25",
"vue-intl": "^6.5.27",
"vuedraggable": "^4.1.0",
"vuetify": "^3.11.2"
},
"devDependencies": {

View file

@ -38,7 +38,9 @@ export const useClientSettingsUser = defineQuery(() => {
export const useUpdateClientSettingsUser = defineMutation(() => {
const queryCache = useQueryCache()
return useMutation({
mutation: (settings: Record<string, components['schemas']['ClientSettingUserUpdateDto']>) =>
mutation: (
settings: Record<CLIENT_SETTING_USER, components['schemas']['ClientSettingUserUpdateDto']>,
) =>
komgaClient.PATCH('/api/v1/client-settings/user', {
body: settings,
}),

View file

@ -53,6 +53,7 @@ declare module 'vue' {
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']
LayoutAppDrawerReorderLibraries: typeof import('./components/layout/app/drawer/ReorderLibraries.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

@ -0,0 +1,30 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import Drawer from './Drawer.vue'
import { useAppStore } from '@/stores/app'
const meta = {
component: Drawer,
render: (args: object) => ({
components: { Drawer },
setup() {
return { args }
},
template: '<Drawer />',
}),
parameters: {
// More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
},
args: {},
} satisfies Meta<typeof Drawer>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {},
play: () => {
const appStore = useAppStore()
appStore.drawer = true
},
}

View file

@ -0,0 +1,107 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import ReorderLibraries from './ReorderLibraries.vue'
import { httpTyped } from '@/mocks/api/httpTyped'
import { CLIENT_SETTING_USER, type ClientSettingUserLibrary } from '@/types/ClientSettingsUser'
import type { components } from '@/generated/openapi/komga'
import { libraries } from '@/mocks/api/handlers/libraries'
const meta = {
component: ReorderLibraries,
render: (args: object) => ({
components: { ReorderLibraries },
setup() {
return { args }
},
template: '<ReorderLibraries />',
}),
parameters: {
// More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
},
args: {},
} satisfies Meta<typeof ReorderLibraries>
export default meta
type Story = StoryObj<typeof meta>
const libHandler = 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 = [...libraries, bds, magazines, manga]
return response(200).json(libs)
})
export const AllPinned: Story = {
args: {},
parameters: {
msw: {
handlers: [libHandler],
},
},
}
export const SomeUnpinned: Story = {
args: {},
parameters: {
msw: {
handlers: [
libHandler,
httpTyped.get('/api/v1/client-settings/user/list', ({ response }) => {
const userLibraries: Record<string, ClientSettingUserLibrary> = {
'2': {
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)
}),
],
},
},
}
export const AllUnpinned: Story = {
args: {},
parameters: {
msw: {
handlers: [
httpTyped.get('/api/v1/client-settings/user/list', ({ response }) => {
const userLibraries: Record<string, ClientSettingUserLibrary> = {
'1': {
unpinned: true,
},
'2': {
unpinned: true,
},
}
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,144 @@
<template>
<v-list>
<v-list-item variant="tonal">
<v-list-item-title class="text-uppercase">{{
$formatMessage({
description: 'Reorder library drawer: title',
defaultMessage: 'reorder',
id: 'g+OQSw',
})
}}</v-list-item-title>
<template #append>
<v-icon-btn
icon="i-mdi:close"
variant="text"
:aria-label="
$formatMessage({
description: 'Reorder library drawer: aria label',
defaultMessage: 'close',
id: 'Ept33T',
})
"
/>
</template>
</v-list-item>
<v-list-subheader
:title="
$formatMessage({
description: 'Reorder library drawer: pinned section header',
defaultMessage: 'Pinned',
id: 'OZSDCE',
})
"
/>
<draggable
v-model="localPinned"
v-bind="draggableConfig"
>
<template #[`header`]>
<v-list-item
v-if="localPinned.length == 0"
:title="
$formatMessage({
description: 'Reorder library drawer: placeholder if no libraries are pinned',
defaultMessage: 'Drag here to pin a library',
id: '0MePSx',
})
"
class="text-grey"
/>
</template>
<template #[`item`]="{ element: library }">
<v-list-item
:title="library.name"
prepend-icon="i-mdi:drag-horizontal"
class="cursor-grab"
/>
</template>
</draggable>
<v-divider />
<v-list-subheader
:title="
$formatMessage({
description: 'Reorder library drawer: unpinned section header',
defaultMessage: 'Unpinned',
id: 'sj2JGj',
})
"
/>
<draggable
v-model="localUnpinned"
v-bind="draggableConfig"
>
<template #[`header`]>
<v-list-item
v-if="localUnpinned.length == 0"
:title="
$formatMessage({
description: 'Reorder library drawer: placeholder if no libraries are unpinned',
defaultMessage: 'Drag here to unpin a library',
id: 'H+LXXE',
})
"
class="text-grey"
/>
</template>
<template #[`item`]="{ element: library }">
<v-list-item
:title="library.name"
prepend-icon="i-mdi:drag-horizontal"
class="cursor-grab"
/>
</template>
</draggable>
</v-list>
</template>
<script setup lang="ts">
import draggable from 'vuedraggable'
import { useLibraries } from '@/colada/libraries'
import type { components } from '@/generated/openapi/komga'
import { CLIENT_SETTING_USER, type ClientSettingUserLibrary } from '@/types/ClientSettingsUser'
import { useUpdateClientSettingsUser } from '@/colada/client-settings'
const { unpinned, pinned, refresh } = useLibraries()
const localPinned = ref<components['schemas']['LibraryDto'][]>([])
const localUnpinned = ref<components['schemas']['LibraryDto'][]>([])
// one time copy to local refs
void refresh().then(() => {
localPinned.value = pinned.value
localUnpinned.value = unpinned.value
})
const draggableConfig = {
group: 'libs',
itemKey: 'id',
ghostClass: 'ghost',
chosenClass: 'chosen',
animation: 150,
}
const { mutate } = useUpdateClientSettingsUser()
watch([localPinned, localUnpinned], ([newPinned, newUnpinned]) => {
const newSettings: Record<string, ClientSettingUserLibrary> = {}
newPinned.forEach((it, index) => (newSettings[it.id] = { order: index, unpinned: false }))
newUnpinned.forEach(
(it, index) => (newSettings[it.id] = { order: newPinned.length + index, unpinned: true }),
)
mutate({ [CLIENT_SETTING_USER.NEXTUI_LIBRARIES]: { value: JSON.stringify(newSettings) } })
})
</script>
<style lang="scss">
.ghost {
opacity: 0.5;
background: #c8ebfb;
}
</style>