mirror of
https://github.com/gotson/komga.git
synced 2026-05-09 05:10:19 +02:00
finalize readlist import
This commit is contained in:
parent
550fd63836
commit
3d8c47b305
9 changed files with 470 additions and 218 deletions
|
|
@ -1,11 +1,40 @@
|
|||
import { defineMutation, useMutation } from '@pinia/colada'
|
||||
import { defineMutation, defineQueryOptions, useMutation } from '@pinia/colada'
|
||||
import { komgaClient } from '@/api/komga-client'
|
||||
import type { components } from '@/generated/openapi/komga'
|
||||
import type { PageRequest } from '@/types/PageRequest'
|
||||
|
||||
export const QUERY_KEYS_READLIST = {
|
||||
root: ['readlists'] as const,
|
||||
bySearch: (request: object) => [...QUERY_KEYS_READLIST.root, JSON.stringify(request)] as const,
|
||||
}
|
||||
|
||||
export const useListReadLists = defineQueryOptions(
|
||||
({
|
||||
search,
|
||||
libraryId,
|
||||
pageable,
|
||||
}: {
|
||||
search?: string
|
||||
libraryId?: string
|
||||
pageable?: PageRequest
|
||||
}) => ({
|
||||
key: QUERY_KEYS_READLIST.bySearch({ search: search, libraryId: libraryId, pageable: pageable }),
|
||||
query: () =>
|
||||
komgaClient
|
||||
.GET('/api/v1/readlists', {
|
||||
params: {
|
||||
query: {
|
||||
search: search,
|
||||
libraryId: libraryId,
|
||||
...pageable,
|
||||
},
|
||||
},
|
||||
})
|
||||
// unwrap the openapi-fetch structure on success
|
||||
.then((res) => res.data),
|
||||
}),
|
||||
)
|
||||
|
||||
export const useCreateReadList = defineMutation(() => {
|
||||
return useMutation({
|
||||
mutation: (readList: components['schemas']['ReadListCreationDto']) =>
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ const singleMatch = {
|
|||
},
|
||||
],
|
||||
}
|
||||
|
||||
export const Created: Story = {
|
||||
args: {
|
||||
match: singleMatch,
|
||||
|
|
@ -68,6 +69,12 @@ export const Created: Story = {
|
|||
}),
|
||||
}
|
||||
|
||||
export const DuplicateName: Story = {
|
||||
args: {
|
||||
match: { ...singleMatch, readListMatch: { name: 'Elfes', errorCode: '' } },
|
||||
},
|
||||
}
|
||||
|
||||
export const Empty: Story = {
|
||||
args: {
|
||||
match: { ...matchCbl, requests: [] },
|
||||
|
|
|
|||
|
|
@ -1,220 +1,223 @@
|
|||
<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="
|
||||
<div>
|
||||
<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: tooltip for status - duplicate book',
|
||||
defaultMessage: 'Duplicate book',
|
||||
id: '1MAL38',
|
||||
description:
|
||||
'Import reading list table: shown when table has no data because of the selected filter - title',
|
||||
defaultMessage: 'No data',
|
||||
id: 'AJa6Tq',
|
||||
})
|
||||
"
|
||||
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',
|
||||
description:
|
||||
'Import reading list table: shown when table has no data because of the selected filter - subtitle',
|
||||
defaultMessage: 'Try adjusting the filters',
|
||||
id: 'NpjqFA',
|
||||
})
|
||||
"
|
||||
:disabled="!isFormValid || creating || loading || finishedState"
|
||||
:prepend-icon="finishedState ? 'i-mdi:check' : undefined"
|
||||
@click="doCreateReadList"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<DialogSeriesPicker
|
||||
:activator="dialogSeriesPickerActivator"
|
||||
:fullscreen="display.xs.value"
|
||||
@selected-series="(series) => seriesPicked(series)"
|
||||
/>
|
||||
<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>
|
||||
|
||||
<DialogBookPicker
|
||||
:activator="dialogBookPickerActivator"
|
||||
:fullscreen="display.xs.value"
|
||||
:books="dialogBookPickerBooks"
|
||||
@selected-book="(book) => bookPicked(book)"
|
||||
/>
|
||||
<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',
|
||||
})
|
||||
"
|
||||
:error-messages="readListNameAlreadyExists ? duplicateNameMessage : undefined"
|
||||
></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>
|
||||
|
||||
<DialogSeriesPicker
|
||||
: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)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
|
@ -231,10 +234,11 @@ import {
|
|||
import { useDisplay } from 'vuetify'
|
||||
import { useQuery } from '@pinia/colada'
|
||||
import { bookListQuery } from '@/colada/books'
|
||||
import { useCreateReadList } from '@/colada/readlists'
|
||||
import { useCreateReadList, useListReadLists } from '@/colada/readlists'
|
||||
import { useMessagesStore } from '@/stores/messages'
|
||||
import type { ErrorCause } from '@/api/komga-client'
|
||||
import { commonMessages } from '@/utils/i18n/common-messages'
|
||||
import { PageRequest } from '@/types/PageRequest'
|
||||
|
||||
class ReadListEntry {
|
||||
index: number
|
||||
|
|
@ -257,7 +261,7 @@ class ReadListEntry {
|
|||
}
|
||||
|
||||
public get importable(): boolean {
|
||||
return !!this.series && !!this.book
|
||||
return !!this.book
|
||||
}
|
||||
|
||||
public get statusMessage(): string {
|
||||
|
|
@ -311,7 +315,7 @@ const selectedBooks = useArrayFilter(readListEntries, (it) =>
|
|||
)
|
||||
|
||||
const duplicateBookIds = useArrayFilter(
|
||||
useArrayMap(readListEntries, (it) => it.book?.bookId),
|
||||
useArrayMap(selectedBooks, (it) => it.book?.bookId),
|
||||
(it, index, array) => !!it && array.indexOf(it) !== index,
|
||||
)
|
||||
|
||||
|
|
@ -328,6 +332,18 @@ watchImmediate(
|
|||
},
|
||||
)
|
||||
|
||||
const { data: allReadLists } = useQuery(useListReadLists({ pageable: PageRequest.Unpaged() }))
|
||||
const readListNameAlreadyExists = computed(() =>
|
||||
allReadLists.value?.content?.some(
|
||||
(it) => it.name.localeCompare(readListName.value, undefined, { sensitivity: 'accent' }) == 0,
|
||||
),
|
||||
)
|
||||
const duplicateNameMessage = intl.formatMessage({
|
||||
description: 'Import reading list: error message if read list name already exists',
|
||||
defaultMessage: 'A read list with that name already exists',
|
||||
id: 'LjqS9+',
|
||||
})
|
||||
|
||||
const finishedState = computed<boolean>(() => !!readListCreated.value)
|
||||
|
||||
// Table setup
|
||||
|
|
@ -415,13 +431,20 @@ function filterFn(
|
|||
// Series Picker Dialog
|
||||
const dialogSeriesPickerActivator = ref<Element | undefined>(undefined)
|
||||
|
||||
function seriesPicked(series: components['schemas']['SeriesDto']) {
|
||||
async function seriesPicked(series: components['schemas']['SeriesDto']) {
|
||||
if (currentActionedItem.value) {
|
||||
currentActionedItem.value.series = {
|
||||
seriesId: series.id,
|
||||
title: series.metadata.title,
|
||||
releaseDate: series.booksMetadata?.releaseDate,
|
||||
}
|
||||
|
||||
const requestedNumber = currentActionedItem.value.request.request.number
|
||||
const seriesBooks = await getSeriesBooks(series.id)
|
||||
if (seriesBooks) {
|
||||
const matchedBook = seriesBooks.content?.find((b) => b.metadata.number === requestedNumber)
|
||||
if (matchedBook) bookPicked(matchedBook)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -455,14 +478,16 @@ const getSeriesBooks = useMemoize(async (seriesId: string) =>
|
|||
.then(({ data }) => data),
|
||||
)
|
||||
|
||||
const isFormValid = computed<boolean>(() => !!readListName.value)
|
||||
const isFormValid = computed<boolean>(
|
||||
() => !!readListName.value && !readListNameAlreadyExists.value,
|
||||
)
|
||||
const createPayload = computed(
|
||||
() =>
|
||||
({
|
||||
name: readListName.value,
|
||||
summary: readListSummary.value,
|
||||
ordered: true,
|
||||
bookIds: [],
|
||||
bookIds: selectedBooks.value.map((it) => it.book?.bookId),
|
||||
}) as components['schemas']['ReadListCreationDto'],
|
||||
)
|
||||
|
||||
|
|
@ -485,7 +510,7 @@ function doCreateReadList() {
|
|||
postReadList(createPayload.value)
|
||||
.then(({ data }) => {
|
||||
readListCreated.value = data
|
||||
//TODO: add link
|
||||
//TODO: add link to created readlist
|
||||
messagesStore.messages.push({
|
||||
text: 'Readlist created',
|
||||
})
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@ import { errorCodeMessages } from '@/utils/i18n/enum/error-codes'
|
|||
export function useErrorCodeFormatter() {
|
||||
const intl = useIntl()
|
||||
|
||||
function convertErrorCodes(message: string): string {
|
||||
function convertErrorCodes(message?: string): string {
|
||||
if (!message) return ''
|
||||
const match = message.match(/ERR_\d{4}/g)
|
||||
let r = message
|
||||
match?.forEach((errorCode) => {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { actuatorHandlers } from '@/mocks/api/handlers/actuator'
|
||||
import { announcementHandlers } from '@/mocks/api/handlers/announcements'
|
||||
import { releasesHandlers } from '@/mocks/api/handlers/releases'
|
||||
import { HttpResponse } from 'msw'
|
||||
import { HttpResponse, type JsonBodyType } from 'msw'
|
||||
import { librariesHandlers } from '@/mocks/api/handlers/libraries'
|
||||
import { referentialHandlers } from '@/mocks/api/handlers/referential'
|
||||
import { usersHandlers } from '@/mocks/api/handlers/users'
|
||||
|
|
@ -34,6 +34,8 @@ export const handlers = [
|
|||
export const response400BadRequest = () =>
|
||||
HttpResponse.json({ error: 'Bad Request' }, { status: 400 })
|
||||
|
||||
export const response400 = (body: JsonBodyType) => HttpResponse.json(body, { status: 400 })
|
||||
|
||||
export const response404NotFound = () => HttpResponse.json({ error: 'NotFound' }, { status: 404 })
|
||||
|
||||
export const response401Unauthorized = () =>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import { httpTyped } from '@/mocks/api/httpTyped'
|
||||
import { mockPage } from '@/mocks/api/pageable'
|
||||
import { PageRequest } from '@/types/PageRequest'
|
||||
|
||||
export const matchCbl = {
|
||||
readListMatch: { name: "Jupiter's Legacy", errorCode: '' },
|
||||
|
|
@ -134,7 +136,58 @@ export const matchCbl = {
|
|||
errorCode: '',
|
||||
}
|
||||
|
||||
export const emptyCbl = {
|
||||
error: 'Bad Request',
|
||||
message: 'ERR_1029',
|
||||
}
|
||||
|
||||
export const garbledCbl = {
|
||||
error: 'Bad Request',
|
||||
message: 'ERR_1015',
|
||||
}
|
||||
|
||||
const rl1 = {
|
||||
id: '02AQZYKBS00J8',
|
||||
name: 'Readlist example',
|
||||
summary: 'An example read list to show off how it works in Komga.',
|
||||
ordered: true,
|
||||
bookIds: ['BOOK1', 'BOOK2'],
|
||||
createdDate: new Date('2020-08-20T05:45:38Z'),
|
||||
lastModifiedDate: new Date('2021-08-09T08:42:38Z'),
|
||||
filtered: false,
|
||||
}
|
||||
|
||||
const rl2 = {
|
||||
id: '02AQZYKBS00J8',
|
||||
name: 'Elfes',
|
||||
summary: 'Elfes readlist',
|
||||
ordered: false,
|
||||
bookIds: ['BOOK3', 'BOOK4'],
|
||||
createdDate: new Date('2020-08-20T05:45:38Z'),
|
||||
lastModifiedDate: new Date('2021-08-09T08:42:38Z'),
|
||||
filtered: false,
|
||||
}
|
||||
|
||||
const readlists = [rl1, rl2]
|
||||
|
||||
export const readListsHandlers = [
|
||||
httpTyped.get('/api/v1/readlists', ({ query, response }) => {
|
||||
const selectedReadLists = query.get('search')
|
||||
? readlists.filter((it) => !!it.name.match(new RegExp(query.get('search')!, 'i')))
|
||||
: readlists
|
||||
|
||||
return response(200).json(
|
||||
mockPage(
|
||||
selectedReadLists,
|
||||
new PageRequest(
|
||||
Number(query.get('page')),
|
||||
Number(query.get('size')),
|
||||
undefined,
|
||||
Boolean(query.get('unpaged')),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
httpTyped.post('/api/v1/readlists', async ({ request, response }) => {
|
||||
const body = await request.json()
|
||||
return response(200).json({
|
||||
|
|
|
|||
54
next-ui/src/pages/import/readlist.stories.ts
Normal file
54
next-ui/src/pages/import/readlist.stories.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import ImportReadList from './readlist.vue'
|
||||
import DialogConfirmEditInstance from '@/components/dialog/ConfirmEditInstance.vue'
|
||||
import { delay, http } from 'msw'
|
||||
import SnackQueue from '@/components/SnackQueue.vue'
|
||||
import { emptyCbl, garbledCbl } from '@/mocks/api/handlers/readlists'
|
||||
import { response400 } from '@/mocks/api/handlers'
|
||||
|
||||
const meta = {
|
||||
component: ImportReadList,
|
||||
render: (args: object) => ({
|
||||
components: { ImportReadList, DialogConfirmEditInstance, SnackQueue },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
template: '<ImportReadList /><DialogConfirmEditInstance/><SnackQueue/>',
|
||||
}),
|
||||
parameters: {
|
||||
// More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
|
||||
},
|
||||
args: {},
|
||||
} satisfies Meta<typeof ImportReadList>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {},
|
||||
}
|
||||
|
||||
export const Loading: Story = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [http.all('*', async () => await delay(2_000))],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const ErrorNoBooks: Story = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [http.post('*/api/v1/readlists/match/comicrack', () => response400(emptyCbl))],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const ErrorInvalidFile: Story = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [http.post('*/api/v1/readlists/match/comicrack', () => response400(garbledCbl))],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
@ -1,9 +1,88 @@
|
|||
<template>
|
||||
<h1>Import Read List</h1>
|
||||
<v-container
|
||||
fluid
|
||||
class="pa-0 pa-sm-4 h-100 h-sm-auto"
|
||||
>
|
||||
<v-file-upload
|
||||
v-model="fileToUpload"
|
||||
:disabled="isLoading"
|
||||
density="compact"
|
||||
filter-by-type=".cbl"
|
||||
@rejected="handleReject()"
|
||||
>
|
||||
<template #item />
|
||||
</v-file-upload>
|
||||
|
||||
<v-progress-linear
|
||||
v-if="isLoading"
|
||||
indeterminate
|
||||
class="mt-2"
|
||||
/>
|
||||
|
||||
<ImportReadlistTable
|
||||
v-if="match"
|
||||
:match="match"
|
||||
:loading="isLoading"
|
||||
class="mt-4"
|
||||
/>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
//
|
||||
import { useMessagesStore } from '@/stores/messages'
|
||||
import { useMutation } from '@pinia/colada'
|
||||
import { type ErrorCause, komgaClient } from '@/api/komga-client'
|
||||
import { useErrorCodeFormatter } from '@/composables/errorCodeFormatter'
|
||||
import { commonMessages } from '@/utils/i18n/common-messages'
|
||||
import { useIntl } from 'vue-intl'
|
||||
|
||||
const intl = useIntl()
|
||||
const messagesStore = useMessagesStore()
|
||||
const { convertErrorCodes } = useErrorCodeFormatter()
|
||||
|
||||
const fileToUpload = ref<File>()
|
||||
|
||||
function handleReject() {
|
||||
messagesStore.messages.push(unsupportedFileTypeMessage)
|
||||
}
|
||||
|
||||
const {
|
||||
data: match,
|
||||
mutate: matchCbl,
|
||||
isLoading,
|
||||
} = useMutation({
|
||||
mutation: (file: File) =>
|
||||
komgaClient
|
||||
.POST('/api/v1/readlists/match/comicrack', {
|
||||
body: {
|
||||
file: file,
|
||||
},
|
||||
bodySerializer() {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
return fd
|
||||
},
|
||||
})
|
||||
// unwrap the openapi-fetch structure on success
|
||||
.then((res) => res.data)
|
||||
.catch((error) => {
|
||||
messagesStore.messages.push({
|
||||
text:
|
||||
convertErrorCodes((error?.cause as ErrorCause)?.message) ||
|
||||
intl.formatMessage(commonMessages.networkError),
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
||||
watch(fileToUpload, (file) => {
|
||||
if (file) matchCbl(file)
|
||||
})
|
||||
|
||||
const unsupportedFileTypeMessage = intl.formatMessage({
|
||||
description: 'Import readlist view: error message when trying to upload an unsupported file type',
|
||||
defaultMessage: 'File type not supported',
|
||||
id: 'CxuwFR',
|
||||
})
|
||||
</script>
|
||||
|
||||
<route lang="yaml">
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { createVuetify } from 'vuetify'
|
|||
import { md3 } from 'vuetify/blueprints'
|
||||
|
||||
// Labs
|
||||
import { VFileUpload } from 'vuetify/labs/VFileUpload'
|
||||
import { VIconBtn } from 'vuetify/labs/components'
|
||||
import { createRulesPlugin } from 'vuetify/labs/rules'
|
||||
|
||||
|
|
@ -69,6 +70,7 @@ export const vuetify = createVuetify({
|
|||
},
|
||||
blueprint: md3,
|
||||
components: {
|
||||
VFileUpload,
|
||||
VIconBtn,
|
||||
},
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue