mirror of
https://github.com/gotson/komga.git
synced 2025-12-06 08:32:25 +01:00
add import read list table
This commit is contained in:
parent
7a2deacb92
commit
3254d329d5
7 changed files with 783 additions and 1 deletions
16
next-ui/src/colada/readlists.ts
Normal file
16
next-ui/src/colada/readlists.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { defineMutation, useMutation } from '@pinia/colada'
|
||||||
|
import { komgaClient } from '@/api/komga-client'
|
||||||
|
import type { components } from '@/generated/openapi/komga'
|
||||||
|
|
||||||
|
export const QUERY_KEYS_READLIST = {
|
||||||
|
root: ['readlists'] as const,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useCreateReadList = defineMutation(() => {
|
||||||
|
return useMutation({
|
||||||
|
mutation: (readList: components['schemas']['ReadListCreationDto']) =>
|
||||||
|
komgaClient.POST('/api/v1/readlists', {
|
||||||
|
body: readList,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
})
|
||||||
1
next-ui/src/components.d.ts
vendored
1
next-ui/src/components.d.ts
vendored
|
|
@ -33,6 +33,7 @@ declare module 'vue' {
|
||||||
FragmentHistoryExpandTable: typeof import('./fragments/fragment/history/expand/Table.vue')['default']
|
FragmentHistoryExpandTable: typeof import('./fragments/fragment/history/expand/Table.vue')['default']
|
||||||
FragmentHistoryTable: typeof import('./fragments/fragment/history/Table.vue')['default']
|
FragmentHistoryTable: typeof import('./fragments/fragment/history/Table.vue')['default']
|
||||||
FragmentImportBooksTransientBooksTable: typeof import('./fragments/fragment/import/books/TransientBooksTable.vue')['default']
|
FragmentImportBooksTransientBooksTable: typeof import('./fragments/fragment/import/books/TransientBooksTable.vue')['default']
|
||||||
|
FragmentImportReadlistTable: typeof import('./fragments/fragment/import/readlist/Table.vue')['default']
|
||||||
FragmentLocaleSelector: typeof import('./fragments/fragment/LocaleSelector.vue')['default']
|
FragmentLocaleSelector: typeof import('./fragments/fragment/LocaleSelector.vue')['default']
|
||||||
FragmentRemoteFileList: typeof import('./fragments/fragment/RemoteFileList.vue')['default']
|
FragmentRemoteFileList: typeof import('./fragments/fragment/RemoteFileList.vue')['default']
|
||||||
FragmentSnackQueue: typeof import('./fragments/fragment/SnackQueue.vue')['default']
|
FragmentSnackQueue: typeof import('./fragments/fragment/SnackQueue.vue')['default']
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,97 @@
|
||||||
|
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||||
|
|
||||||
|
import { http, delay } from 'msw'
|
||||||
|
import Table from './Table.vue'
|
||||||
|
import { response400BadRequest } from '@/mocks/api/handlers'
|
||||||
|
import SnackQueue from '@/fragments/fragment/SnackQueue.vue'
|
||||||
|
import { matchCbl } from '@/mocks/api/handlers/readlists'
|
||||||
|
import { expect, waitFor } from 'storybook/test'
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
component: Table,
|
||||||
|
subcomponents: { SnackQueue },
|
||||||
|
render: (args: object) => ({
|
||||||
|
components: { Table, SnackQueue },
|
||||||
|
setup() {
|
||||||
|
return { args }
|
||||||
|
},
|
||||||
|
template: '<Table v-bind="args"/><SnackQueue/>',
|
||||||
|
}),
|
||||||
|
parameters: {
|
||||||
|
// More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
|
||||||
|
},
|
||||||
|
args: {},
|
||||||
|
} satisfies Meta<typeof Table>
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
args: {
|
||||||
|
match: matchCbl,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const singleMatch = {
|
||||||
|
...matchCbl,
|
||||||
|
requests: [
|
||||||
|
{
|
||||||
|
request: { series: ['Space Adventures (2018)', 'Space Adventures'], number: '1' },
|
||||||
|
matches: [
|
||||||
|
{
|
||||||
|
series: {
|
||||||
|
seriesId: '63',
|
||||||
|
title: 'Space Adventures',
|
||||||
|
releaseDate: '2018-07-10',
|
||||||
|
},
|
||||||
|
books: [{ bookId: '0F99E5W723ENS', number: '1', title: 'Volume 1' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
export const Created: Story = {
|
||||||
|
args: {
|
||||||
|
match: singleMatch,
|
||||||
|
},
|
||||||
|
play: async ({ canvas, userEvent }) => {
|
||||||
|
await userEvent.click(canvas.getByRole('button', { name: /create/i }))
|
||||||
|
|
||||||
|
await waitFor(() => expect(canvas.getByRole('button', { name: /create/i })).toBeDisabled())
|
||||||
|
},
|
||||||
|
render: (args: object) => ({
|
||||||
|
components: { Table, SnackQueue },
|
||||||
|
setup() {
|
||||||
|
return { args }
|
||||||
|
},
|
||||||
|
template: '<Table v-bind="args"/>',
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Empty: Story = {
|
||||||
|
args: {
|
||||||
|
match: { ...matchCbl, requests: [] },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Loading: Story = {
|
||||||
|
args: {
|
||||||
|
match: singleMatch,
|
||||||
|
},
|
||||||
|
parameters: {
|
||||||
|
msw: {
|
||||||
|
handlers: [http.all('*', async () => await delay(2_000))],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ErrorOnCreation: Story = {
|
||||||
|
args: {
|
||||||
|
match: singleMatch,
|
||||||
|
},
|
||||||
|
parameters: {
|
||||||
|
msw: {
|
||||||
|
handlers: [http.post('*/v1/readlists', response400BadRequest)],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
506
next-ui/src/fragments/fragment/import/readlist/Table.vue
Normal file
506
next-ui/src/fragments/fragment/import/readlist/Table.vue
Normal file
|
|
@ -0,0 +1,506 @@
|
||||||
|
<template>
|
||||||
|
<v-data-table
|
||||||
|
v-model="selectedIndices"
|
||||||
|
:loading="creating || loading"
|
||||||
|
:items="readListEntries"
|
||||||
|
item-value="index"
|
||||||
|
:headers="headers"
|
||||||
|
:search="filterRef"
|
||||||
|
:custom-filter="filterFn"
|
||||||
|
:hide-default-footer="hideFooter"
|
||||||
|
fixed-header
|
||||||
|
fixed-footer
|
||||||
|
show-select
|
||||||
|
item-selectable="selectable"
|
||||||
|
select-strategy="page"
|
||||||
|
mobile-breakpoint="md"
|
||||||
|
>
|
||||||
|
<template #no-data>
|
||||||
|
<v-empty-state
|
||||||
|
icon="i-mdi:filter"
|
||||||
|
:title="
|
||||||
|
$formatMessage({
|
||||||
|
description:
|
||||||
|
'Import reading list table: shown when table has no data because of the selected filter - title',
|
||||||
|
defaultMessage: 'No data',
|
||||||
|
id: 'AJa6Tq',
|
||||||
|
})
|
||||||
|
"
|
||||||
|
:text="
|
||||||
|
$formatMessage({
|
||||||
|
description:
|
||||||
|
'Import reading list table: shown when table has no data because of the selected filter - subtitle',
|
||||||
|
defaultMessage: 'Try adjusting the filters',
|
||||||
|
id: 'NpjqFA',
|
||||||
|
})
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #top>
|
||||||
|
<v-toolbar flat>
|
||||||
|
<v-spacer />
|
||||||
|
<v-chip-group
|
||||||
|
v-model="filterSelect"
|
||||||
|
multiple
|
||||||
|
class="ms-2"
|
||||||
|
:disabled="finishedState"
|
||||||
|
>
|
||||||
|
<v-chip
|
||||||
|
v-for="f in filterOptions"
|
||||||
|
:key="f.value"
|
||||||
|
:value="f.value"
|
||||||
|
:text="f.title"
|
||||||
|
filter
|
||||||
|
rounded
|
||||||
|
color="primary"
|
||||||
|
/>
|
||||||
|
</v-chip-group>
|
||||||
|
</v-toolbar>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #[`item.request.request.series`]="{ value }">
|
||||||
|
<div
|
||||||
|
v-for="s in value"
|
||||||
|
:key="s"
|
||||||
|
>
|
||||||
|
{{ s }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #[`item.series`]="{ item, internalItem, isSelected, value }">
|
||||||
|
<div
|
||||||
|
:class="finishedState ? undefined : 'cursor-pointer'"
|
||||||
|
@mouseenter="
|
||||||
|
finishedState
|
||||||
|
? undefined
|
||||||
|
: (dialogSeriesPickerActivator = $event.currentTarget as Element)
|
||||||
|
"
|
||||||
|
@click="finishedState ? undefined : (currentActionedItem = item)"
|
||||||
|
>
|
||||||
|
<template v-if="value">
|
||||||
|
<div>{{ value?.title }}</div>
|
||||||
|
<div v-if="value?.releaseDate">
|
||||||
|
{{ $formatDate(value?.releaseDate, { year: 'numeric', timeZone: 'UTC' }) }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
style="height: 2em"
|
||||||
|
:class="isSelected(internalItem) ? 'missing' : ''"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #[`item.book`]="{ item, internalItem, isSelected, value }">
|
||||||
|
<div
|
||||||
|
:class="finishedState || !item?.series ? undefined : 'cursor-pointer'"
|
||||||
|
@mouseenter="
|
||||||
|
finishedState || !item?.series
|
||||||
|
? undefined
|
||||||
|
: (dialogBookPickerActivator = $event.currentTarget as Element)
|
||||||
|
"
|
||||||
|
@click="finishedState || !item?.series ? undefined : (currentActionedItem = item)"
|
||||||
|
>
|
||||||
|
<span v-if="value">{{ value.number }} - {{ value.title }}</span>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
style="height: 2em"
|
||||||
|
:class="isSelected(internalItem) && item?.series ? 'missing' : ''"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #[`header.statusMessage`]>
|
||||||
|
<v-icon icon="i-mdi:alert-circle-outline" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #[`item.statusMessage`]="{ item, value, internalItem, isSelected }">
|
||||||
|
<template v-if="isSelected(internalItem)">
|
||||||
|
<v-icon
|
||||||
|
v-if="duplicateBookIds?.includes(item.book?.bookId)"
|
||||||
|
v-tooltip="
|
||||||
|
$formatMessage({
|
||||||
|
description: 'Import reading list table: tooltip for status - duplicate book',
|
||||||
|
defaultMessage: 'Duplicate book',
|
||||||
|
id: '1MAL38',
|
||||||
|
})
|
||||||
|
"
|
||||||
|
icon="i-mdi:alert-circle"
|
||||||
|
color="warning"
|
||||||
|
/>
|
||||||
|
<v-icon
|
||||||
|
v-else-if="value"
|
||||||
|
v-tooltip="value"
|
||||||
|
icon="i-mdi:alert-circle"
|
||||||
|
color="error"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</v-data-table>
|
||||||
|
|
||||||
|
<v-container fluid>
|
||||||
|
<v-row
|
||||||
|
justify="space-between"
|
||||||
|
align="end"
|
||||||
|
>
|
||||||
|
<v-col
|
||||||
|
cols="12"
|
||||||
|
sm=""
|
||||||
|
>
|
||||||
|
<v-row>
|
||||||
|
<v-col>
|
||||||
|
<v-text-field
|
||||||
|
v-model="readListName"
|
||||||
|
:rules="['required']"
|
||||||
|
:disabled="finishedState"
|
||||||
|
clearable
|
||||||
|
:label="
|
||||||
|
$formatMessage({
|
||||||
|
description: 'Import reading list: bottom bar: reading list name',
|
||||||
|
defaultMessage: 'Name',
|
||||||
|
id: 'rrF/Z2',
|
||||||
|
})
|
||||||
|
"
|
||||||
|
></v-text-field>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
<v-row>
|
||||||
|
<v-col>
|
||||||
|
<v-textarea
|
||||||
|
v-model="readListSummary"
|
||||||
|
rows="2"
|
||||||
|
hide-details
|
||||||
|
:disabled="finishedState"
|
||||||
|
clearable
|
||||||
|
:label="
|
||||||
|
$formatMessage({
|
||||||
|
description: 'Import reading list: bottom bar: reading list summary',
|
||||||
|
defaultMessage: 'Summary',
|
||||||
|
id: 'uW+6XG',
|
||||||
|
})
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-col>
|
||||||
|
|
||||||
|
<v-col cols="auto">
|
||||||
|
<v-btn
|
||||||
|
:color="finishedState ? 'success' : 'primary'"
|
||||||
|
:text="
|
||||||
|
$formatMessage({
|
||||||
|
description: 'Import reading list: bottom bar: create button',
|
||||||
|
defaultMessage: 'Create',
|
||||||
|
id: 'dipMGb',
|
||||||
|
})
|
||||||
|
"
|
||||||
|
:disabled="!isFormValid || creating || loading || finishedState"
|
||||||
|
:prepend-icon="finishedState ? 'i-mdi:check' : undefined"
|
||||||
|
@click="doCreateReadList"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-container>
|
||||||
|
|
||||||
|
<FragmentDialogSeriesPicker
|
||||||
|
:activator="dialogSeriesPickerActivator"
|
||||||
|
:fullscreen="display.xs.value"
|
||||||
|
@selected-series="(series) => seriesPicked(series)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DialogBookPicker
|
||||||
|
:activator="dialogBookPickerActivator"
|
||||||
|
:fullscreen="display.xs.value"
|
||||||
|
:books="dialogBookPickerBooks"
|
||||||
|
@selected-book="(book) => bookPicked(book)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { components } from '@/generated/openapi/komga'
|
||||||
|
import { useIntl } from 'vue-intl'
|
||||||
|
import {
|
||||||
|
asyncComputed,
|
||||||
|
syncRefs,
|
||||||
|
useArrayFilter,
|
||||||
|
useArrayMap,
|
||||||
|
useMemoize,
|
||||||
|
watchImmediate,
|
||||||
|
} from '@vueuse/core'
|
||||||
|
import { useDisplay } from 'vuetify'
|
||||||
|
import { useQuery } from '@pinia/colada'
|
||||||
|
import { bookListQuery } from '@/colada/books'
|
||||||
|
import { useCreateReadList } from '@/colada/readlists'
|
||||||
|
import { useMessagesStore } from '@/stores/messages'
|
||||||
|
import type { ErrorCause } from '@/api/komga-client'
|
||||||
|
import { commonMessages } from '@/utils/i18n/common-messages'
|
||||||
|
|
||||||
|
class ReadListEntry {
|
||||||
|
index: number
|
||||||
|
request: components['schemas']['ReadListRequestBookMatchesDto']
|
||||||
|
series?: components['schemas']['ReadListRequestBookMatchSeriesDto']
|
||||||
|
book?: components['schemas']['ReadListRequestBookMatchBookDto']
|
||||||
|
|
||||||
|
constructor(request: components['schemas']['ReadListRequestBookMatchesDto'], index: number) {
|
||||||
|
this.index = index
|
||||||
|
this.request = request
|
||||||
|
const match = request.matches.find(Boolean)
|
||||||
|
if (match) {
|
||||||
|
this.series = match.series
|
||||||
|
this.book = match.books.find(Boolean)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public get selectable(): boolean {
|
||||||
|
return !finishedState.value
|
||||||
|
}
|
||||||
|
|
||||||
|
public get importable(): boolean {
|
||||||
|
return !!this.series && !!this.book
|
||||||
|
}
|
||||||
|
|
||||||
|
public get statusMessage(): string {
|
||||||
|
if (!this.series)
|
||||||
|
return intl.formatMessage({
|
||||||
|
description: 'Import reading list: status message: choose a series',
|
||||||
|
defaultMessage: 'Choose a series',
|
||||||
|
id: 'H2B6uF',
|
||||||
|
})
|
||||||
|
if (!this.book)
|
||||||
|
return intl.formatMessage({
|
||||||
|
description: 'Import reading list: status message: choose a book',
|
||||||
|
defaultMessage: 'Choose a book',
|
||||||
|
id: 'xYp/8u',
|
||||||
|
})
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const display = useDisplay()
|
||||||
|
const intl = useIntl()
|
||||||
|
const messagesStore = useMessagesStore()
|
||||||
|
|
||||||
|
const { match, loading = false } = defineProps<{
|
||||||
|
match: components['schemas']['ReadListRequestMatchDto']
|
||||||
|
loading?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// a read-only array of ReadListEntry, kept in sync with the requests prop
|
||||||
|
const readListEntriesRO = useArrayMap(
|
||||||
|
toRef(() => match.requests),
|
||||||
|
(it, index) => new ReadListEntry(it, index + 1),
|
||||||
|
)
|
||||||
|
// read-write array of ReadListEntry, will be modified by the different actions
|
||||||
|
const readListEntries = ref<ReadListEntry[]>([])
|
||||||
|
// 1-way sync from readListEntriesRO to readListEntries
|
||||||
|
syncRefs(readListEntriesRO, readListEntries)
|
||||||
|
|
||||||
|
// the current item being acted upon, used for dialog callback
|
||||||
|
const currentActionedItem = ref<ReadListEntry>()
|
||||||
|
// the selected indices, used to programmatically select items
|
||||||
|
const selectedIndices = ref<number[]>([])
|
||||||
|
// a read-only list of the request indices, used to select all on initialization
|
||||||
|
const entriesIndex = useArrayMap(readListEntriesRO, (it) => it.index)
|
||||||
|
// 1-way sync from entriesIndex to selectedIndices, will effectively select all on initialization
|
||||||
|
syncRefs(entriesIndex, selectedIndices)
|
||||||
|
|
||||||
|
// the selected books
|
||||||
|
const selectedBooks = useArrayFilter(readListEntries, (it) =>
|
||||||
|
selectedIndices.value.includes(it.index),
|
||||||
|
)
|
||||||
|
|
||||||
|
const duplicateBookIds = useArrayFilter(
|
||||||
|
useArrayMap(readListEntries, (it) => it.book?.bookId),
|
||||||
|
(it, index, array) => !!it && array.indexOf(it) !== index,
|
||||||
|
)
|
||||||
|
|
||||||
|
const readListName = ref<string>(match.readListMatch.name)
|
||||||
|
const readListSummary = ref<string>()
|
||||||
|
const readListCreated = ref<components['schemas']['ReadListDto']>()
|
||||||
|
// if the prop changes, reset some data
|
||||||
|
watchImmediate(
|
||||||
|
() => match,
|
||||||
|
(m) => {
|
||||||
|
readListName.value = m.readListMatch.name
|
||||||
|
readListSummary.value = ''
|
||||||
|
readListCreated.value = undefined
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const finishedState = computed<boolean>(() => !!readListCreated.value)
|
||||||
|
|
||||||
|
// Table setup
|
||||||
|
const hideFooter = computed(() => readListEntries.value.length < 10)
|
||||||
|
|
||||||
|
const headers = [
|
||||||
|
{
|
||||||
|
title: '#',
|
||||||
|
key: 'index',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: intl.formatMessage({
|
||||||
|
description: 'Import reading list table header: requested series',
|
||||||
|
defaultMessage: 'Requested series',
|
||||||
|
id: 'LD5j8J',
|
||||||
|
}),
|
||||||
|
key: 'request.request.series',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: intl.formatMessage({
|
||||||
|
description: 'Import reading list table header: requested number',
|
||||||
|
defaultMessage: 'Requested number',
|
||||||
|
id: 'Wlzzv8',
|
||||||
|
}),
|
||||||
|
key: 'request.request.number',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: intl.formatMessage({
|
||||||
|
description: 'Import reading list table header: Series',
|
||||||
|
defaultMessage: 'Series',
|
||||||
|
id: 'ThHjN4',
|
||||||
|
}),
|
||||||
|
key: 'series',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: intl.formatMessage({
|
||||||
|
description: 'Import reading list table header: Book',
|
||||||
|
defaultMessage: 'Book',
|
||||||
|
id: '700A3r',
|
||||||
|
}),
|
||||||
|
key: 'book',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: intl.formatMessage({
|
||||||
|
description: 'Import reading list table header: status message',
|
||||||
|
defaultMessage: 'Status',
|
||||||
|
id: 'J44THG',
|
||||||
|
}),
|
||||||
|
key: 'statusMessage',
|
||||||
|
align: 'end',
|
||||||
|
},
|
||||||
|
] as const // workaround for https://github.com/vuetifyjs/vuetify/issues/18901
|
||||||
|
|
||||||
|
// Filtering
|
||||||
|
const filterSelect = ref<string[]>([])
|
||||||
|
const filterRef = computed(() => filterSelect.value.join(''))
|
||||||
|
const filterOptions = [
|
||||||
|
{
|
||||||
|
title: 'OK',
|
||||||
|
value: 'o',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Error',
|
||||||
|
value: 'e',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Duplicate',
|
||||||
|
value: 'd',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
function filterFn(
|
||||||
|
value: string,
|
||||||
|
query: string,
|
||||||
|
item?: { raw: ReadListEntry },
|
||||||
|
): boolean | number | [number, number] | [number, number][] {
|
||||||
|
const error = item?.raw.statusMessage
|
||||||
|
const duplicate = duplicateBookIds.value.includes(item?.raw.book?.bookId)
|
||||||
|
if (error && query.includes('e')) return true
|
||||||
|
if (!error && duplicate && query.includes('d')) return true
|
||||||
|
if (!error && !duplicate && query.includes('o')) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Series Picker Dialog
|
||||||
|
const dialogSeriesPickerActivator = ref<Element | undefined>(undefined)
|
||||||
|
|
||||||
|
function seriesPicked(series: components['schemas']['SeriesDto']) {
|
||||||
|
if (currentActionedItem.value) {
|
||||||
|
currentActionedItem.value.series = {
|
||||||
|
seriesId: series.id,
|
||||||
|
title: series.metadata.title,
|
||||||
|
releaseDate: series.booksMetadata?.releaseDate,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Book Picker Dialog
|
||||||
|
const dialogBookPickerActivator = ref<Element | undefined>(undefined)
|
||||||
|
const dialogBookPickerBooks = asyncComputed(async () =>
|
||||||
|
currentActionedItem.value?.series
|
||||||
|
? (await getSeriesBooks(currentActionedItem.value.series.seriesId))?.content
|
||||||
|
: undefined,
|
||||||
|
)
|
||||||
|
|
||||||
|
function bookPicked(book: components['schemas']['BookDto']) {
|
||||||
|
if (currentActionedItem.value) {
|
||||||
|
currentActionedItem.value.book = {
|
||||||
|
bookId: book.id,
|
||||||
|
title: book.metadata.title,
|
||||||
|
number: book.metadata.number,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSeriesBooks = useMemoize(async (seriesId: string) =>
|
||||||
|
useQuery(bookListQuery, () => ({
|
||||||
|
search: {
|
||||||
|
condition: {
|
||||||
|
seriesId: { operator: 'Is', value: seriesId },
|
||||||
|
},
|
||||||
|
} as components['schemas']['BookSearch'],
|
||||||
|
}))
|
||||||
|
.refresh()
|
||||||
|
.then(({ data }) => data),
|
||||||
|
)
|
||||||
|
|
||||||
|
const isFormValid = computed<boolean>(() => !!readListName.value)
|
||||||
|
const createPayload = computed(
|
||||||
|
() =>
|
||||||
|
({
|
||||||
|
name: readListName.value,
|
||||||
|
summary: readListSummary.value,
|
||||||
|
ordered: true,
|
||||||
|
bookIds: [],
|
||||||
|
}) as components['schemas']['ReadListCreationDto'],
|
||||||
|
)
|
||||||
|
|
||||||
|
const { mutateAsync: postReadList, isLoading: creating } = useCreateReadList()
|
||||||
|
|
||||||
|
function doCreateReadList() {
|
||||||
|
if (selectedBooks.value.length == 0) {
|
||||||
|
messagesStore.messages.push('Select some books')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (selectedBooks.value.some((it) => !it.importable)) {
|
||||||
|
messagesStore.messages.push('Some of the selected books are in error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (selectedBooks.value.some((it) => duplicateBookIds.value?.includes(it.book?.bookId))) {
|
||||||
|
messagesStore.messages.push('Some of the selected books are duplicates')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
postReadList(createPayload.value)
|
||||||
|
.then(({ data }) => {
|
||||||
|
readListCreated.value = data
|
||||||
|
//TODO: add link
|
||||||
|
messagesStore.messages.push({
|
||||||
|
text: 'Readlist created',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
messagesStore.messages.push({
|
||||||
|
text:
|
||||||
|
(error?.cause as ErrorCause)?.message || intl.formatMessage(commonMessages.networkError),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.missing {
|
||||||
|
border: 2px dashed red;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -12,6 +12,7 @@ import { seriesHandlers } from '@/mocks/api/handlers/series'
|
||||||
import { booksHandlers } from '@/mocks/api/handlers/books'
|
import { booksHandlers } from '@/mocks/api/handlers/books'
|
||||||
import { filesystemHandlers } from '@/mocks/api/handlers/filesystem'
|
import { filesystemHandlers } from '@/mocks/api/handlers/filesystem'
|
||||||
import { transientBooksHandlers } from '@/mocks/api/handlers/transient-books'
|
import { transientBooksHandlers } from '@/mocks/api/handlers/transient-books'
|
||||||
|
import { readListsHandlers } from '@/mocks/api/handlers/readlists'
|
||||||
|
|
||||||
export const handlers = [
|
export const handlers = [
|
||||||
...actuatorHandlers,
|
...actuatorHandlers,
|
||||||
|
|
@ -21,6 +22,7 @@ export const handlers = [
|
||||||
...filesystemHandlers,
|
...filesystemHandlers,
|
||||||
...historyHandlers,
|
...historyHandlers,
|
||||||
...librariesHandlers,
|
...librariesHandlers,
|
||||||
|
...readListsHandlers,
|
||||||
...referentialHandlers,
|
...referentialHandlers,
|
||||||
...releasesHandlers,
|
...releasesHandlers,
|
||||||
...seriesHandlers,
|
...seriesHandlers,
|
||||||
|
|
|
||||||
151
next-ui/src/mocks/api/handlers/readlists.ts
Normal file
151
next-ui/src/mocks/api/handlers/readlists.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
import { httpTyped } from '@/mocks/api/httpTyped'
|
||||||
|
|
||||||
|
export const matchCbl = {
|
||||||
|
readListMatch: { name: "Jupiter's Legacy", errorCode: '' },
|
||||||
|
requests: [
|
||||||
|
{
|
||||||
|
request: { series: ['Space Adventures (2018)', 'Space Adventures'], number: '1' },
|
||||||
|
matches: [
|
||||||
|
{
|
||||||
|
series: {
|
||||||
|
seriesId: '63',
|
||||||
|
title: 'Space Adventures',
|
||||||
|
releaseDate: '2018-07-10',
|
||||||
|
},
|
||||||
|
books: [{ bookId: '0F99E5W723ENS', number: '1', title: 'Volume 1' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
request: { series: ["Jupiter's Legacy (2013)", "Jupiter's Legacy"], number: '2' },
|
||||||
|
matches: [
|
||||||
|
{
|
||||||
|
series: {
|
||||||
|
seriesId: '63',
|
||||||
|
title: 'Space Adventures',
|
||||||
|
releaseDate: '2018-07-10',
|
||||||
|
},
|
||||||
|
books: [{ bookId: '0F99E5W723ENS', number: '1', title: 'Volume 1' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
request: { series: ["Jupiter's Legacy (2013)", "Jupiter's Legacy"], number: '3' },
|
||||||
|
matches: [
|
||||||
|
{
|
||||||
|
series: {
|
||||||
|
seriesId: '63',
|
||||||
|
title: "Jupiter's Legacy",
|
||||||
|
releaseDate: '2018-07-10',
|
||||||
|
},
|
||||||
|
books: [{ bookId: '0F99E5W763ECC', number: '3', title: 'Volume 3' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
request: { series: ["Jupiter's Legacy (2013)", "Jupiter's Legacy"], number: '4' },
|
||||||
|
matches: [
|
||||||
|
{
|
||||||
|
series: {
|
||||||
|
seriesId: '63',
|
||||||
|
title: "Jupiter's Legacy",
|
||||||
|
releaseDate: '2018-07-10',
|
||||||
|
},
|
||||||
|
books: [{ bookId: '0F99E5W723ENT', number: '4', title: 'Volume 4' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
request: { series: ["Jupiter's Legacy (2013)", "Jupiter's Legacy"], number: '5' },
|
||||||
|
matches: [
|
||||||
|
{
|
||||||
|
series: {
|
||||||
|
seriesId: '63',
|
||||||
|
title: "Jupiter's Legacy",
|
||||||
|
releaseDate: '2018-07-10',
|
||||||
|
},
|
||||||
|
books: [{ bookId: '0F99E5W723ENR', number: '5', title: 'Volume 5' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
request: { series: ["Jupiter's Legacy 2 (2016)", "Jupiter's Legacy 2"], number: '1' },
|
||||||
|
matches: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
request: { series: ["Jupiter's Legacy 2 (2016)", "Jupiter's Legacy 2"], number: '2' },
|
||||||
|
matches: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
request: { series: ["Jupiter's Legacy 2 (2016)", "Jupiter's Legacy 2"], number: '3' },
|
||||||
|
matches: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
request: { series: ["Jupiter's Legacy 2 (2016)", "Jupiter's Legacy 2"], number: '4' },
|
||||||
|
matches: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
request: { series: ["Jupiter's Legacy 2 (2016)", "Jupiter's Legacy 2"], number: '5' },
|
||||||
|
matches: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
request: {
|
||||||
|
series: ["Jupiter's Legacy Requiem (2021)", "Jupiter's Legacy Requiem"],
|
||||||
|
number: '1',
|
||||||
|
},
|
||||||
|
matches: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
request: {
|
||||||
|
series: ["Jupiter's Legacy Requiem (2021)", "Jupiter's Legacy Requiem"],
|
||||||
|
number: '2',
|
||||||
|
},
|
||||||
|
matches: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
request: {
|
||||||
|
series: ["Jupiter's Legacy Requiem (2021)", "Jupiter's Legacy Requiem"],
|
||||||
|
number: '3',
|
||||||
|
},
|
||||||
|
matches: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
request: {
|
||||||
|
series: ["Jupiter's Legacy Requiem (2021)", "Jupiter's Legacy Requiem"],
|
||||||
|
number: '4',
|
||||||
|
},
|
||||||
|
matches: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
request: {
|
||||||
|
series: ["Jupiter's Legacy Requiem (2021)", "Jupiter's Legacy Requiem"],
|
||||||
|
number: '5',
|
||||||
|
},
|
||||||
|
matches: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
request: {
|
||||||
|
series: ["Jupiter's Legacy Requiem (2021)", "Jupiter's Legacy Requiem"],
|
||||||
|
number: '6',
|
||||||
|
},
|
||||||
|
matches: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
errorCode: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const readListsHandlers = [
|
||||||
|
httpTyped.post('/api/v1/readlists', async ({ request, response }) => {
|
||||||
|
const body = await request.json()
|
||||||
|
return response(200).json({
|
||||||
|
...body,
|
||||||
|
createdDate: new Date(),
|
||||||
|
lastModifiedDate: new Date(),
|
||||||
|
id: (Math.random() + 1).toString(36).substring(7),
|
||||||
|
filtered: false,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
httpTyped.post('/api/v1/readlists/match/comicrack', ({ response }) => {
|
||||||
|
return response(200).json(matchCbl)
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
|
@ -1,8 +1,17 @@
|
||||||
// Utilities
|
// Utilities
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
|
|
||||||
|
type Message =
|
||||||
|
| {
|
||||||
|
text: string
|
||||||
|
color?: string
|
||||||
|
timer?: string | boolean
|
||||||
|
timeout?: string | number
|
||||||
|
}
|
||||||
|
| string
|
||||||
|
|
||||||
export const useMessagesStore = defineStore('messages', {
|
export const useMessagesStore = defineStore('messages', {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
messages: [] as object[],
|
messages: [] as Message[],
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue