mirror of
https://github.com/gotson/komga.git
synced 2025-12-06 08:32:25 +01:00
book import
This commit is contained in:
parent
0fc5b9ba54
commit
b934e6145d
40 changed files with 3507 additions and 45 deletions
|
|
@ -1,3 +1,13 @@
|
|||
export function seriesThumbnailUrl(seriesId?: string): string | undefined {
|
||||
if (seriesId) return `${import.meta.env.VITE_KOMGA_API_URL}/api/v1/series/${seriesId}/thumbnail`
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function bookThumbnailUrl(bookId?: string): string | undefined {
|
||||
if (bookId) return `${import.meta.env.VITE_KOMGA_API_URL}/api/v1/books/${bookId}/thumbnail`
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function pageHashKnownThumbnailUrl(hash?: string): string | undefined {
|
||||
if (hash) return `${import.meta.env.VITE_KOMGA_API_URL}/api/v1/page-hashes/${hash}/thumbnail`
|
||||
return undefined
|
||||
|
|
|
|||
144
next-ui/src/assets/cover-logo.svg
Normal file
144
next-ui/src/assets/cover-logo.svg
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
xmlns:osb="http://www.openswatchbook.org/uri/2009/osb"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
height="297mm"
|
||||
viewBox="0 0 793.70081 1122.5197"
|
||||
width="210mm"
|
||||
version="1.1"
|
||||
id="svg4586"
|
||||
sodipodi:docname="cover.svg"
|
||||
inkscape:version="0.92.4 (5da689c313, 2019-01-14)">
|
||||
<metadata
|
||||
id="metadata4592">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage"/>
|
||||
<dc:title></dc:title>
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<defs
|
||||
id="defs4590">
|
||||
<linearGradient
|
||||
id="linearGradient6082"
|
||||
osb:paint="solid">
|
||||
<stop
|
||||
style="stop-color:#000000;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop6080"/>
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="linearGradient6076"
|
||||
osb:paint="solid">
|
||||
<stop
|
||||
style="stop-color:#000000;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop6074"/>
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#linearGradient6082"
|
||||
id="linearGradient6084"
|
||||
x1="77.866814"
|
||||
y1="386.00677"
|
||||
x2="217.20259"
|
||||
y2="386.00677"
|
||||
gradientUnits="userSpaceOnUse"/>
|
||||
<filter
|
||||
style="color-interpolation-filters:sRGB"
|
||||
inkscape:label="Greyscale"
|
||||
id="filter4541">
|
||||
<feColorMatrix
|
||||
values="0.21 0.72 0.072 0 0 0.21 0.72 0.072 0 0 0.21 0.72 0.072 0 0 0 0 0 1 0 "
|
||||
id="feColorMatrix4539"/>
|
||||
</filter>
|
||||
<filter
|
||||
style="color-interpolation-filters:sRGB"
|
||||
inkscape:label="Greyscale"
|
||||
id="filter4545">
|
||||
<feColorMatrix
|
||||
values="0.21 0.72 0.072 0 0 0.21 0.72 0.072 0 0 0.21 0.72 0.072 0 0 0 0 0 1 0 "
|
||||
id="feColorMatrix4543"/>
|
||||
</filter>
|
||||
</defs>
|
||||
<sodipodi:namedview
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1"
|
||||
objecttolerance="10"
|
||||
gridtolerance="10"
|
||||
guidetolerance="10"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:window-width="1656"
|
||||
inkscape:window-height="1368"
|
||||
id="namedview4588"
|
||||
showgrid="false"
|
||||
inkscape:zoom="0.31281188"
|
||||
inkscape:cx="546.28827"
|
||||
inkscape:cy="1456.7169"
|
||||
inkscape:window-x="-7"
|
||||
inkscape:window-y="0"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:current-layer="svg4586"
|
||||
inkscape:snap-page="true"
|
||||
units="mm"
|
||||
showborder="true"
|
||||
viewbox-width="1"
|
||||
viewbox-height="1"
|
||||
scale-x="0.60001"/>
|
||||
<rect
|
||||
style="fill:#005ed3;fill-opacity:1;stroke:none;stroke-width:2.21274972;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;filter:url(#filter4541)"
|
||||
id="rect826"
|
||||
width="595.27557"
|
||||
height="841.88977"
|
||||
x="1.7763568e-14"
|
||||
y="-2.3789293e-05"
|
||||
transform="matrix(1.3333334,0,0,1.3333333,0,7.9297629e-6)"/>
|
||||
<g
|
||||
id="g4537"
|
||||
transform="matrix(1.3333334,0,0,1.3333333,23.517062,16.073517)"
|
||||
style="filter:url(#filter4545)">
|
||||
<path
|
||||
style="fill:#ff0335"
|
||||
inkscape:connector-curvature="0"
|
||||
id="path4560"
|
||||
d="m 280,239.63194 37.10937,63.73828 70.57422,-31.41406 -10.52734,71.71875 77.07812,12.91015 -54.14453,52.30469 54.14453,52.30469 -77.07812,12.91015 10.52734,71.71875 L 317.10937,514.40928 280,578.14756 l -37.10938,-63.73828 -70.57421,31.41406 10.52734,-71.71875 -77.07813,-12.91015 54.14454,-52.30469 -54.14454,-52.30469 77.07813,-12.91015 -10.52734,-71.71875 70.57421,31.41406 z m 0,0"/>
|
||||
<path
|
||||
style="fill:#c2001b"
|
||||
inkscape:connector-curvature="0"
|
||||
id="path4562"
|
||||
d="m 454.23047,461.19053 -77.07031,12.91016 10.51953,71.71875 L 317.10938,514.40928 280,578.15147 V 239.62803 l 37.10938,63.74219 70.57031,-31.41016 -6.75781,46.10156 -3.76172,25.61719 58.80078,9.85156 18.26953,3.0586 -13.39063,12.92969 -40.75,39.37109 11.37891,10.98828 z m 0,0"/>
|
||||
<path
|
||||
style="fill:#ffdf47"
|
||||
inkscape:connector-curvature="0"
|
||||
id="path4564"
|
||||
d="M 280,607.95616 236.69531,533.58506 153.51953,570.6085 165.86719,486.46787 73.949219,471.07334 138.32031,408.88975 73.949219,346.70616 165.86719,331.31162 153.51953,247.171 236.69922,284.19444 280,209.82335 l 43.30469,74.37109 83.17578,-37.02344 -12.34766,84.14062 91.91797,15.39454 -64.37109,62.18359 64.37109,62.18359 -91.91797,15.39844 12.34766,84.13672 -83.17578,-37.02344 z M 249.08203,495.2335 280,548.33506 310.91797,495.2335 368.88281,521.03428 360.17969,461.74131 422.41797,451.31553 378.5,408.88975 422.41797,366.46397 360.17969,356.03819 368.88281,296.74522 310.91797,322.546 280,269.44444 249.08203,322.546 l -57.96484,-25.80078 8.70312,59.29297 -62.23828,10.42578 43.91797,42.42578 -43.91797,42.42578 62.23828,10.42578 -8.70312,59.29297 z m 0,0"/>
|
||||
<path
|
||||
style="fill:#fec000"
|
||||
inkscape:connector-curvature="0"
|
||||
id="path4566"
|
||||
d="m 427.30859,414.33116 -5.6289,-5.44141 25.16015,-24.30078 39.21094,-37.87891 -55.75,-9.33984 -36.17187,-6.0586 2.80078,-19.09375 9.55078,-65.04687 -83.17969,37.01953 L 280,209.81944 v 59.62109 l 30.92188,53.10938 57.95703,-25.8086 -3.91016,26.66797 -2.54687,17.37891 -2.24219,15.25 2.48047,0.42187 59.76172,10.00781 -43.92188,42.42188 16.96875,16.39062 26.95313,26.03125 -62.24219,10.42969 8.69922,59.29688 -57.95703,-25.8086 L 280,548.33897 v 59.62109 l 43.30078,-74.37109 83.17969,37.01953 -12.35156,-84.14063 91.92187,-15.39843 z m 0,0"/>
|
||||
<g
|
||||
id="text4596"
|
||||
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:296.55969238px;line-height:125%;font-family:Impact;-inkscape-font-specification:Impact;letter-spacing:0px;word-spacing:0px;fill:#ffffff;fill-opacity:1;stroke:#000000;stroke-width:1.54528999;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
transform="matrix(1.1590846,-0.34467221,0.22789693,0.794981,24,152.88975)"
|
||||
aria-label="K">
|
||||
<path
|
||||
inkscape:connector-curvature="0"
|
||||
id="path824"
|
||||
style="font-size:296.55969238px;fill:#ffffff;fill-opacity:1;stroke:#000000;stroke-width:1.54528999;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 220.91497,266.9035 -34.89789,105.85211 38.2284,128.58643 H 161.2555 L 136.63873,400.84769 V 501.34204 H 75.676021 V 266.9035 h 60.962709 v 91.08205 l 27.07845,-91.08205 z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.7 KiB |
|
|
@ -105,40 +105,4 @@
|
|||
x="1.7763568e-14"
|
||||
y="-2.3789293e-05"
|
||||
transform="matrix(1.3333334,0,0,1.3333333,0,7.9297629e-6)"/>
|
||||
<g
|
||||
id="g4537"
|
||||
transform="matrix(1.3333334,0,0,1.3333333,23.517062,16.073517)"
|
||||
style="filter:url(#filter4545)">
|
||||
<path
|
||||
style="fill:#ff0335"
|
||||
inkscape:connector-curvature="0"
|
||||
id="path4560"
|
||||
d="m 280,239.63194 37.10937,63.73828 70.57422,-31.41406 -10.52734,71.71875 77.07812,12.91015 -54.14453,52.30469 54.14453,52.30469 -77.07812,12.91015 10.52734,71.71875 L 317.10937,514.40928 280,578.14756 l -37.10938,-63.73828 -70.57421,31.41406 10.52734,-71.71875 -77.07813,-12.91015 54.14454,-52.30469 -54.14454,-52.30469 77.07813,-12.91015 -10.52734,-71.71875 70.57421,31.41406 z m 0,0"/>
|
||||
<path
|
||||
style="fill:#c2001b"
|
||||
inkscape:connector-curvature="0"
|
||||
id="path4562"
|
||||
d="m 454.23047,461.19053 -77.07031,12.91016 10.51953,71.71875 L 317.10938,514.40928 280,578.15147 V 239.62803 l 37.10938,63.74219 70.57031,-31.41016 -6.75781,46.10156 -3.76172,25.61719 58.80078,9.85156 18.26953,3.0586 -13.39063,12.92969 -40.75,39.37109 11.37891,10.98828 z m 0,0"/>
|
||||
<path
|
||||
style="fill:#ffdf47"
|
||||
inkscape:connector-curvature="0"
|
||||
id="path4564"
|
||||
d="M 280,607.95616 236.69531,533.58506 153.51953,570.6085 165.86719,486.46787 73.949219,471.07334 138.32031,408.88975 73.949219,346.70616 165.86719,331.31162 153.51953,247.171 236.69922,284.19444 280,209.82335 l 43.30469,74.37109 83.17578,-37.02344 -12.34766,84.14062 91.91797,15.39454 -64.37109,62.18359 64.37109,62.18359 -91.91797,15.39844 12.34766,84.13672 -83.17578,-37.02344 z M 249.08203,495.2335 280,548.33506 310.91797,495.2335 368.88281,521.03428 360.17969,461.74131 422.41797,451.31553 378.5,408.88975 422.41797,366.46397 360.17969,356.03819 368.88281,296.74522 310.91797,322.546 280,269.44444 249.08203,322.546 l -57.96484,-25.80078 8.70312,59.29297 -62.23828,10.42578 43.91797,42.42578 -43.91797,42.42578 62.23828,10.42578 -8.70312,59.29297 z m 0,0"/>
|
||||
<path
|
||||
style="fill:#fec000"
|
||||
inkscape:connector-curvature="0"
|
||||
id="path4566"
|
||||
d="m 427.30859,414.33116 -5.6289,-5.44141 25.16015,-24.30078 39.21094,-37.87891 -55.75,-9.33984 -36.17187,-6.0586 2.80078,-19.09375 9.55078,-65.04687 -83.17969,37.01953 L 280,209.81944 v 59.62109 l 30.92188,53.10938 57.95703,-25.8086 -3.91016,26.66797 -2.54687,17.37891 -2.24219,15.25 2.48047,0.42187 59.76172,10.00781 -43.92188,42.42188 16.96875,16.39062 26.95313,26.03125 -62.24219,10.42969 8.69922,59.29688 -57.95703,-25.8086 L 280,548.33897 v 59.62109 l 43.30078,-74.37109 83.17969,37.01953 -12.35156,-84.14063 91.92187,-15.39843 z m 0,0"/>
|
||||
<g
|
||||
id="text4596"
|
||||
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:296.55969238px;line-height:125%;font-family:Impact;-inkscape-font-specification:Impact;letter-spacing:0px;word-spacing:0px;fill:#ffffff;fill-opacity:1;stroke:#000000;stroke-width:1.54528999;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
transform="matrix(1.1590846,-0.34467221,0.22789693,0.794981,24,152.88975)"
|
||||
aria-label="K">
|
||||
<path
|
||||
inkscape:connector-curvature="0"
|
||||
id="path824"
|
||||
style="font-size:296.55969238px;fill:#ffffff;fill-opacity:1;stroke:#000000;stroke-width:1.54528999;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 220.91497,266.9035 -34.89789,105.85211 38.2284,128.58643 H 161.2555 L 136.63873,400.84769 V 501.34204 H 75.676021 V 266.9035 h 60.962709 v 91.08205 l 27.07845,-91.08205 z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 4 KiB |
BIN
next-ui/src/assets/mock-thumbnail.jpg
Normal file
BIN
next-ui/src/assets/mock-thumbnail.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 242 KiB |
|
|
@ -1,11 +1,35 @@
|
|||
import { defineQueryOptions } from '@pinia/colada'
|
||||
import { komgaClient } from '@/api/komga-client'
|
||||
import type { components } from '@/generated/openapi/komga'
|
||||
|
||||
export const QUERY_KEYS_BOOKS = {
|
||||
root: ['books'] as const,
|
||||
bySearch: (search: components['schemas']['BookSearch']) =>
|
||||
[...QUERY_KEYS_BOOKS.root, JSON.stringify(search)] as const,
|
||||
byId: (bookId: string) => [...QUERY_KEYS_BOOKS.root, bookId] as const,
|
||||
}
|
||||
|
||||
export const bookListQuery = defineQueryOptions(
|
||||
({
|
||||
search,
|
||||
pause = false,
|
||||
}: {
|
||||
search: components['schemas']['BookSearch']
|
||||
pause?: boolean
|
||||
}) => ({
|
||||
key: QUERY_KEYS_BOOKS.bySearch(search),
|
||||
query: () =>
|
||||
komgaClient
|
||||
.POST('/api/v1/books/list', {
|
||||
body: search,
|
||||
})
|
||||
// unwrap the openapi-fetch structure on success
|
||||
.then((res) => res.data),
|
||||
enabled: !pause,
|
||||
placeholderData: (previousData: any) => previousData, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
}),
|
||||
)
|
||||
|
||||
export const bookDetailQuery = defineQueryOptions(({ bookId }: { bookId: string }) => ({
|
||||
key: QUERY_KEYS_BOOKS.byId(bookId),
|
||||
query: () =>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,35 @@
|
|||
import { defineQueryOptions } from '@pinia/colada'
|
||||
import { komgaClient } from '@/api/komga-client'
|
||||
import type { components } from '@/generated/openapi/komga'
|
||||
|
||||
export const QUERY_KEYS_SERIES = {
|
||||
root: ['series'] as const,
|
||||
bySearch: (search: components['schemas']['SeriesSearch']) =>
|
||||
[...QUERY_KEYS_SERIES.root, JSON.stringify(search)] as const,
|
||||
byId: (seriesId: string) => [...QUERY_KEYS_SERIES.root, seriesId] as const,
|
||||
}
|
||||
|
||||
export const seriesListQuery = defineQueryOptions(
|
||||
({
|
||||
search,
|
||||
pause = false,
|
||||
}: {
|
||||
search: components['schemas']['SeriesSearch']
|
||||
pause: boolean
|
||||
}) => ({
|
||||
key: QUERY_KEYS_SERIES.bySearch(search),
|
||||
query: () =>
|
||||
komgaClient
|
||||
.POST('/api/v1/series/list', {
|
||||
body: search,
|
||||
})
|
||||
// unwrap the openapi-fetch structure on success
|
||||
.then((res) => res.data),
|
||||
enabled: !pause,
|
||||
placeholderData: (previousData: any) => previousData, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
}),
|
||||
)
|
||||
|
||||
export const seriesDetailQuery = defineQueryOptions(({ seriesId }: { seriesId: string }) => ({
|
||||
key: QUERY_KEYS_SERIES.byId(seriesId),
|
||||
query: () =>
|
||||
|
|
|
|||
39
next-ui/src/colada/transient-books.ts
Normal file
39
next-ui/src/colada/transient-books.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { defineQueryOptions } from '@pinia/colada'
|
||||
import { komgaClient } from '@/api/komga-client'
|
||||
|
||||
export const QUERY_KEYS_TRANSIENT_BOOKS = {
|
||||
root: ['transient-books'] as const,
|
||||
byPath: (path: string) => [...QUERY_KEYS_TRANSIENT_BOOKS.root, path] as const,
|
||||
byId: (transientBookId: string) => [...QUERY_KEYS_TRANSIENT_BOOKS.root, transientBookId] as const,
|
||||
}
|
||||
|
||||
export const transientBooksScan = defineQueryOptions(({ path }: { path: string }) => ({
|
||||
key: QUERY_KEYS_TRANSIENT_BOOKS.byPath(path),
|
||||
enabled: path.length > 0,
|
||||
query: () =>
|
||||
komgaClient
|
||||
.POST('/api/v1/transient-books', {
|
||||
body: {
|
||||
path: path,
|
||||
},
|
||||
})
|
||||
// unwrap the openapi-fetch structure on success
|
||||
.then((res) => res.data),
|
||||
}))
|
||||
|
||||
export const transientBookAnalyze = defineQueryOptions(
|
||||
({ transientBookId }: { transientBookId: string }) => ({
|
||||
key: QUERY_KEYS_TRANSIENT_BOOKS.byId(transientBookId),
|
||||
query: () =>
|
||||
komgaClient
|
||||
.POST('/api/v1/transient-books/{id}/analyze', {
|
||||
params: {
|
||||
path: {
|
||||
id: transientBookId,
|
||||
},
|
||||
},
|
||||
})
|
||||
// unwrap the openapi-fetch structure on success
|
||||
.then((res) => res.data),
|
||||
}),
|
||||
)
|
||||
6
next-ui/src/components.d.ts
vendored
6
next-ui/src/components.d.ts
vendored
|
|
@ -12,8 +12,10 @@ declare module 'vue' {
|
|||
ApikeyDeletionWarning: typeof import('./components/apikey/DeletionWarning.vue')['default']
|
||||
ApikeyForceSyncWarning: typeof import('./components/apikey/ForceSyncWarning.vue')['default']
|
||||
AppFooter: typeof import('./components/AppFooter.vue')['default']
|
||||
DialogBookPicker: typeof import('./components/dialog/BookPicker.vue')['default']
|
||||
DialogConfirm: typeof import('./components/dialog/Confirm.vue')['default']
|
||||
DialogConfirmEdit: typeof import('./components/dialog/ConfirmEdit.vue')['default']
|
||||
DialogFileNamePicker: typeof import('./components/dialog/FileNamePicker.vue')['default']
|
||||
EmptyStateNetworkError: typeof import('./components/EmptyStateNetworkError.vue')['default']
|
||||
FormattedMessage: typeof import('./components/FormattedMessage.ts')['default']
|
||||
FragmentApikeyGenerateDialog: typeof import('./fragments/fragment/apikey/GenerateDialog.vue')['default']
|
||||
|
|
@ -22,6 +24,7 @@ declare module 'vue' {
|
|||
FragmentBuildVersion: typeof import('./fragments/fragment/BuildVersion.vue')['default']
|
||||
FragmentDialogConfirm: typeof import('./fragments/fragment/dialog/Confirm.vue')['default']
|
||||
FragmentDialogConfirmEdit: typeof import('./fragments/fragment/dialog/ConfirmEdit.vue')['default']
|
||||
FragmentDialogSeriesPicker: typeof import('./fragments/fragment/dialog/SeriesPicker.vue')['default']
|
||||
FragmentHistoryExpandBookConverted: typeof import('./fragments/fragment/history/expand/BookConverted.vue')['default']
|
||||
FragmentHistoryExpandBookFileDeleted: typeof import('./fragments/fragment/history/expand/BookFileDeleted.vue')['default']
|
||||
FragmentHistoryExpandBookImported: typeof import('./fragments/fragment/history/expand/BookImported.vue')['default']
|
||||
|
|
@ -29,13 +32,16 @@ declare module 'vue' {
|
|||
FragmentHistoryExpandSeriesDirectoryDeleted: typeof import('./fragments/fragment/history/expand/SeriesDirectoryDeleted.vue')['default']
|
||||
FragmentHistoryExpandTable: typeof import('./fragments/fragment/history/expand/Table.vue')['default']
|
||||
FragmentHistoryTable: typeof import('./fragments/fragment/history/Table.vue')['default']
|
||||
FragmentImportBooksTransientBooksTable: typeof import('./fragments/fragment/import/books/TransientBooksTable.vue')['default']
|
||||
FragmentLocaleSelector: typeof import('./fragments/fragment/LocaleSelector.vue')['default']
|
||||
FragmentRemoteFileList: typeof import('./fragments/fragment/RemoteFileList.vue')['default']
|
||||
FragmentSnackQueue: typeof import('./fragments/fragment/SnackQueue.vue')['default']
|
||||
FragmentThemeSelector: typeof import('./fragments/fragment/ThemeSelector.vue')['default']
|
||||
FragmentUserAuthenticationActivityTable: typeof import('./fragments/fragment/user/AuthenticationActivityTable.vue')['default']
|
||||
FragmentUserFormCreateEdit: typeof import('./fragments/fragment/user/form/CreateEdit.vue')['default']
|
||||
FragmentUserTable: typeof import('./fragments/fragment/user/Table.vue')['default']
|
||||
HelloWorld: typeof import('./components/HelloWorld.vue')['default']
|
||||
ImportBooksDirectorySelection: typeof import('./components/import/books/DirectorySelection.vue')['default']
|
||||
LayoutAppBar: typeof import('./fragments/layout/app/Bar.vue')['default']
|
||||
LayoutAppDrawer: typeof import('./fragments/layout/app/drawer/Drawer.vue')['default']
|
||||
LayoutAppDrawerFooter: typeof import('./fragments/layout/app/drawer/Footer.vue')['default']
|
||||
|
|
|
|||
9
next-ui/src/components/dialog/BookPicker.mdx
Normal file
9
next-ui/src/components/dialog/BookPicker.mdx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { Meta } from '@storybook/addon-docs/blocks';
|
||||
|
||||
import * as Stories from './BookPicker.stories';
|
||||
|
||||
<Meta of={Stories} />
|
||||
|
||||
# DialogBookPicker
|
||||
|
||||
Pick book from the provided selection. Can also be filtered.
|
||||
46
next-ui/src/components/dialog/BookPicker.stories.ts
Normal file
46
next-ui/src/components/dialog/BookPicker.stories.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import BookPicker from './BookPicker.vue'
|
||||
import { fn } from 'storybook/test'
|
||||
import { mockBooks } from '@/mocks/api/handlers/books'
|
||||
|
||||
const meta = {
|
||||
component: BookPicker,
|
||||
render: (args: object) => ({
|
||||
components: { BookPicker },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
template: '<BookPicker v-bind="args"/>',
|
||||
}),
|
||||
parameters: {
|
||||
// More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
|
||||
},
|
||||
args: {
|
||||
dialog: true,
|
||||
onSelectedBook: fn(),
|
||||
books: mockBooks(5),
|
||||
},
|
||||
} satisfies Meta<typeof BookPicker>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
...meta.args,
|
||||
},
|
||||
}
|
||||
export const PresetFilter: Story = {
|
||||
args: {
|
||||
filter: '3',
|
||||
...meta.args,
|
||||
},
|
||||
}
|
||||
|
||||
export const LargeList: Story = {
|
||||
args: {
|
||||
...meta.args,
|
||||
books: mockBooks(500),
|
||||
},
|
||||
}
|
||||
174
next-ui/src/components/dialog/BookPicker.vue
Normal file
174
next-ui/src/components/dialog/BookPicker.vue
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
<template>
|
||||
<v-dialog
|
||||
v-model="showDialog"
|
||||
:activator="activator"
|
||||
:fullscreen="fullscreen"
|
||||
:transition="fullscreen ? 'dialog-bottom-transition' : undefined"
|
||||
max-width="600px"
|
||||
scrollable
|
||||
:aria-label="$formatMessage(titleMessage)"
|
||||
@after-leave="reset()"
|
||||
>
|
||||
<template #default="{ isActive }">
|
||||
<v-card :title="$formatMessage(titleMessage)">
|
||||
<template #append>
|
||||
<v-icon
|
||||
icon="i-mdi:close"
|
||||
@click="isActive.value = false"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<v-card-text>
|
||||
<v-text-field
|
||||
v-model="filterRef"
|
||||
:placeholder="
|
||||
$formatMessage({
|
||||
description: 'Book picker dialog: filter field label',
|
||||
defaultMessage: 'Search',
|
||||
id: 'G25PY/',
|
||||
})
|
||||
"
|
||||
variant="outlined"
|
||||
flat
|
||||
hide-details
|
||||
autofocus
|
||||
prepend-inner-icon="i-mdi:magnify"
|
||||
/>
|
||||
|
||||
<v-divider class="my-2" />
|
||||
|
||||
<v-data-table
|
||||
:items="books"
|
||||
:headers="bookTableHeaders"
|
||||
:search="filterRef"
|
||||
:custom-filter="filterFn"
|
||||
fixed-header
|
||||
fixed-footer
|
||||
style="height: 90%"
|
||||
>
|
||||
<template #top>
|
||||
<v-toolbar
|
||||
flat
|
||||
:title="
|
||||
$formatMessage({
|
||||
description: 'Book picker dialog: series books table header',
|
||||
defaultMessage: 'Series books',
|
||||
id: 'DK4PsD',
|
||||
})
|
||||
"
|
||||
>
|
||||
</v-toolbar>
|
||||
</template>
|
||||
|
||||
<template #[`item.metadata.title`]="{ item }">
|
||||
<v-list-item
|
||||
class="px-0 cursor-pointer"
|
||||
@click="pick(item)"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-img
|
||||
width="52"
|
||||
height="75"
|
||||
contain
|
||||
:src="bookThumbnailUrl(item.id)"
|
||||
lazy-src="@/assets/cover.svg"
|
||||
class="me-2"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<v-list-item-title>{{ item.metadata.title }}</v-list-item-title>
|
||||
<v-list-item-subtitle v-if="item.metadata.releaseDate"
|
||||
>{{
|
||||
$formatDate(item.metadata.releaseDate, { dateStyle: 'long', timeZone: 'UTC' })
|
||||
}}
|
||||
</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { components } from '@/generated/openapi/komga'
|
||||
import { bookThumbnailUrl } from '@/api/images'
|
||||
import { useIntl } from 'vue-intl'
|
||||
|
||||
const intl = useIntl()
|
||||
|
||||
const showDialog = defineModel<boolean>('dialog', { required: false })
|
||||
|
||||
const {
|
||||
filter = '',
|
||||
books = [],
|
||||
fullscreen = undefined,
|
||||
activator = undefined,
|
||||
} = defineProps<{
|
||||
filter?: string
|
||||
books?: components['schemas']['BookDto'][]
|
||||
fullscreen?: boolean
|
||||
activator?: Element | string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
selectedBook: [book: components['schemas']['BookDto']]
|
||||
}>()
|
||||
|
||||
const filterRef = ref<string>(filter)
|
||||
|
||||
// sync the ref if the prop changes
|
||||
watch(
|
||||
() => filter,
|
||||
(it) => (filterRef.value = it),
|
||||
)
|
||||
|
||||
function pick(selectedBook: components['schemas']['BookDto']) {
|
||||
emit('selectedBook', selectedBook)
|
||||
showDialog.value = false
|
||||
}
|
||||
|
||||
function reset() {
|
||||
filterRef.value = filter
|
||||
}
|
||||
|
||||
const titleMessage = {
|
||||
description: 'Book picker dialog: title',
|
||||
defaultMessage: 'Select book',
|
||||
id: 'ycrpqO',
|
||||
}
|
||||
|
||||
function filterFn(
|
||||
value: string,
|
||||
query: string,
|
||||
item?: { raw: components['schemas']['BookDto'] },
|
||||
): boolean | number | [number, number] | [number, number][] {
|
||||
return (
|
||||
item?.raw.metadata.title.includes(query) ||
|
||||
item?.raw.metadata.number.includes(query) ||
|
||||
item?.raw.metadata.releaseDate?.includes(query) ||
|
||||
false
|
||||
)
|
||||
}
|
||||
|
||||
const bookTableHeaders = [
|
||||
{
|
||||
title: intl.formatMessage({
|
||||
description: 'Book picker dialog: series books table header: number',
|
||||
defaultMessage: 'Number',
|
||||
id: 'EIYfj+',
|
||||
}),
|
||||
key: 'metadata.number',
|
||||
},
|
||||
{
|
||||
title: intl.formatMessage({
|
||||
description: 'Book picker dialog: series books table header: book details',
|
||||
defaultMessage: 'Details',
|
||||
id: 'yrE0Rx',
|
||||
}),
|
||||
key: 'metadata.title',
|
||||
},
|
||||
]
|
||||
</script>
|
||||
<script setup lang="ts"></script>
|
||||
9
next-ui/src/components/dialog/FileNamePicker.mdx
Normal file
9
next-ui/src/components/dialog/FileNamePicker.mdx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { Meta } from '@storybook/addon-docs/blocks';
|
||||
|
||||
import * as Stories from './FileNamePicker.stories';
|
||||
|
||||
<Meta of={Stories} />
|
||||
|
||||
# DialogFileNamePicker
|
||||
|
||||
Displays file names for series books for easy picking.
|
||||
43
next-ui/src/components/dialog/FileNamePicker.stories.ts
Normal file
43
next-ui/src/components/dialog/FileNamePicker.stories.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import FileNamePicker from './FileNamePicker.vue'
|
||||
import { fn } from 'storybook/test'
|
||||
import { mockBooks } from '@/mocks/api/handlers/books'
|
||||
|
||||
const meta = {
|
||||
component: FileNamePicker,
|
||||
render: (args: object) => ({
|
||||
components: { FileNamePicker },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
template: '<FileNamePicker v-bind="args"/>',
|
||||
}),
|
||||
parameters: {
|
||||
// More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
|
||||
},
|
||||
args: {
|
||||
dialog: true,
|
||||
existingName: 'existing filename.cbz',
|
||||
onSelectedName: fn(),
|
||||
},
|
||||
} satisfies Meta<typeof FileNamePicker>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
seriesBooks: mockBooks(5),
|
||||
},
|
||||
}
|
||||
|
||||
export const LargeList: Story = {
|
||||
args: {
|
||||
seriesBooks: mockBooks(1000),
|
||||
},
|
||||
}
|
||||
|
||||
export const NoBooks: Story = {
|
||||
args: {},
|
||||
}
|
||||
193
next-ui/src/components/dialog/FileNamePicker.vue
Normal file
193
next-ui/src/components/dialog/FileNamePicker.vue
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
<template>
|
||||
<v-dialog
|
||||
v-model="showDialog"
|
||||
:activator="activator"
|
||||
:fullscreen="fullscreen"
|
||||
scrollable
|
||||
:transition="fullscreen ? 'dialog-bottom-transition' : undefined"
|
||||
max-width="600px"
|
||||
:aria-label="$formatMessage(titleMessage)"
|
||||
@after-leave="reset()"
|
||||
>
|
||||
<template #default="{ isActive }">
|
||||
<v-card :title="$formatMessage(titleMessage)">
|
||||
<template #append>
|
||||
<v-icon
|
||||
icon="i-mdi:close"
|
||||
@click="isActive.value = false"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<v-card-text>
|
||||
<v-container fluid>
|
||||
<v-row>
|
||||
<v-col>
|
||||
<div class="text-subtitle-2">
|
||||
{{
|
||||
$formatMessage({
|
||||
description: 'File name picker dialog: source file name field label',
|
||||
defaultMessage: 'Source file name',
|
||||
id: 'lSlhp0',
|
||||
})
|
||||
}}
|
||||
</div>
|
||||
<div>
|
||||
{{ existingName }}
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row align="center">
|
||||
<v-col>
|
||||
<v-text-field
|
||||
v-model="newName"
|
||||
autofocus
|
||||
:label="
|
||||
$formatMessage({
|
||||
description: 'File name picker dialog: destination file name field label',
|
||||
defaultMessage: 'Destination file name',
|
||||
id: 'bFoCHJ',
|
||||
})
|
||||
"
|
||||
variant="underlined"
|
||||
append-inner-icon="i-mdi:restore"
|
||||
hide-details
|
||||
@click:append-inner="newName = existingName"
|
||||
@keydown.enter="choose"
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="auto">
|
||||
<v-btn
|
||||
:disabled="!newName"
|
||||
:text="
|
||||
$formatMessage({
|
||||
description: 'File name picker dialog: confirmation button',
|
||||
defaultMessage: 'Choose',
|
||||
id: 'd2J/J/',
|
||||
})
|
||||
"
|
||||
@click="choose"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
|
||||
<v-divider class="my-2" />
|
||||
|
||||
<v-data-table
|
||||
v-if="seriesBooks"
|
||||
:items="seriesBooks"
|
||||
:headers="bookTableHeaders"
|
||||
fixed-header
|
||||
fixed-footer
|
||||
style="height: 80%"
|
||||
>
|
||||
<template #top>
|
||||
<v-toolbar
|
||||
flat
|
||||
:title="
|
||||
$formatMessage({
|
||||
description: 'File name picker dialog: series books table header',
|
||||
defaultMessage: 'Series books',
|
||||
id: 'Vpmsx0',
|
||||
})
|
||||
"
|
||||
></v-toolbar>
|
||||
</template>
|
||||
|
||||
<template #[`item.name`]="{ value }">
|
||||
<span
|
||||
class="cursor-pointer"
|
||||
@click="newName = value"
|
||||
>{{ value }}</span
|
||||
>
|
||||
</template>
|
||||
</v-data-table>
|
||||
|
||||
<v-alert
|
||||
v-else
|
||||
type="info"
|
||||
variant="tonal"
|
||||
:text="
|
||||
$formatMessage({
|
||||
description:
|
||||
'File name picker dialog: info text shown when there are no existing series books to show',
|
||||
defaultMessage: 'Select a series to see its books',
|
||||
id: 'FSJoDl',
|
||||
})
|
||||
"
|
||||
/>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { components } from '@/generated/openapi/komga'
|
||||
import { useIntl } from 'vue-intl'
|
||||
|
||||
const intl = useIntl()
|
||||
|
||||
const showDialog = defineModel<boolean>('dialog', { required: false })
|
||||
|
||||
const {
|
||||
fullscreen = undefined,
|
||||
activator = undefined,
|
||||
existingName = '',
|
||||
seriesBooks,
|
||||
} = defineProps<{
|
||||
fullscreen?: boolean
|
||||
activator?: Element | string
|
||||
existingName?: string
|
||||
seriesBooks?: components['schemas']['BookDto'][]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
selectedName: [name: string]
|
||||
}>()
|
||||
|
||||
const newName = ref<string>(existingName)
|
||||
|
||||
// sync the ref if the prop changes
|
||||
watch(
|
||||
() => existingName,
|
||||
(it) => (newName.value = it),
|
||||
)
|
||||
|
||||
function choose() {
|
||||
emit('selectedName', newName.value)
|
||||
showDialog.value = false
|
||||
}
|
||||
|
||||
function reset() {
|
||||
newName.value = existingName
|
||||
}
|
||||
|
||||
const titleMessage = {
|
||||
description: 'Filename picker dialog: title',
|
||||
defaultMessage: 'Destination filename',
|
||||
id: 'ycrpqO',
|
||||
}
|
||||
|
||||
const bookTableHeaders = [
|
||||
{
|
||||
title: intl.formatMessage({
|
||||
description: 'File name picker dialog: series books table header: order',
|
||||
defaultMessage: 'Order',
|
||||
id: 'rhtmLf',
|
||||
}),
|
||||
key: 'number',
|
||||
},
|
||||
{
|
||||
title: intl.formatMessage({
|
||||
description: 'File name picker dialog: series books table header: existing file name',
|
||||
defaultMessage: 'Existing file',
|
||||
id: 'IEUgyy',
|
||||
}),
|
||||
key: 'name',
|
||||
},
|
||||
]
|
||||
</script>
|
||||
<script setup lang="ts"></script>
|
||||
11
next-ui/src/components/import/books/DirectorySelection.mdx
Normal file
11
next-ui/src/components/import/books/DirectorySelection.mdx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { Canvas, Meta } from '@storybook/addon-docs/blocks';
|
||||
|
||||
import * as Stories from './DirectorySelection.stories';
|
||||
|
||||
<Meta of={Stories} />
|
||||
|
||||
# ImportBookDirectorySelection
|
||||
|
||||
Directory can be selected using the *Browse* button, or input directly.
|
||||
|
||||
<Canvas of={Stories.Default} />
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import DirectorySelection from './DirectorySelection.vue'
|
||||
import DialogConfirmEdit from '@/fragments/fragment/dialog/ConfirmEdit.vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { fn } from 'storybook/test'
|
||||
|
||||
const meta = {
|
||||
component: DirectorySelection,
|
||||
render: (args: object) => ({
|
||||
components: { DirectorySelection, DialogConfirmEdit },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
template: '<DirectorySelection v-bind="args"/><DialogConfirmEdit/>',
|
||||
}),
|
||||
parameters: {
|
||||
// More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
|
||||
},
|
||||
args: {
|
||||
onScan: fn(),
|
||||
},
|
||||
} satisfies Meta<typeof DirectorySelection>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {},
|
||||
}
|
||||
|
||||
export const PresetPath: Story = {
|
||||
play: () => {
|
||||
const appStore = useAppStore()
|
||||
appStore.importBooksPath = '/comics'
|
||||
},
|
||||
}
|
||||
97
next-ui/src/components/import/books/DirectorySelection.vue
Normal file
97
next-ui/src/components/import/books/DirectorySelection.vue
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
<template>
|
||||
<v-container fluid>
|
||||
<v-row align="center">
|
||||
<v-col
|
||||
cols="12"
|
||||
sm=""
|
||||
>
|
||||
<v-text-field
|
||||
v-model="appStore.importBooksPath"
|
||||
hide-details
|
||||
clearable
|
||||
:label="
|
||||
$formatMessage({
|
||||
description: 'Import books directory selection: directory text field label',
|
||||
defaultMessage: 'Import from directory',
|
||||
id: 'z8b1Xe',
|
||||
})
|
||||
"
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="auto">
|
||||
<v-btn
|
||||
color=""
|
||||
@click="browse"
|
||||
@mouseenter="dialogConfirmEdit.activator = $event.currentTarget"
|
||||
>{{
|
||||
$formatMessage({
|
||||
description: 'Import books directory selection: file browser button label',
|
||||
defaultMessage: 'Browse',
|
||||
id: 'Usohru',
|
||||
})
|
||||
}}
|
||||
</v-btn>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="auto">
|
||||
<v-btn
|
||||
:disabled="!appStore.importBooksPath || loading"
|
||||
:text="
|
||||
$formatMessage({
|
||||
description: 'Import books: scan button label',
|
||||
defaultMessage: 'Scan',
|
||||
id: 'uwFE74',
|
||||
})
|
||||
"
|
||||
@click="emit('scan', appStore.importBooksPath)"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useDialogsStore } from '@/stores/dialogs'
|
||||
import FragmentRemoteFileList from '@/fragments/fragment/RemoteFileList.vue'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useIntl } from 'vue-intl'
|
||||
|
||||
const intl = useIntl()
|
||||
const display = useDisplay()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const { confirmEdit: dialogConfirmEdit } = storeToRefs(useDialogsStore())
|
||||
|
||||
const { loading = false } = defineProps<{
|
||||
loading?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
scan: [directory: string]
|
||||
}>()
|
||||
|
||||
function browse() {
|
||||
dialogConfirmEdit.value.dialogProps = {
|
||||
title: intl.formatMessage({
|
||||
description: 'Import books: directory selection dialog title',
|
||||
defaultMessage: 'Import from directory',
|
||||
id: '8Om/o/',
|
||||
}),
|
||||
maxWidth: 600,
|
||||
closeOnSave: true,
|
||||
scrollable: true,
|
||||
fullscreen: display.xs.value,
|
||||
}
|
||||
dialogConfirmEdit.value.slot = {
|
||||
component: markRaw(FragmentRemoteFileList),
|
||||
props: {},
|
||||
}
|
||||
dialogConfirmEdit.value.record = appStore.importBooksPath || '' // workaround for https://github.com/vuetifyjs/vuetify/issues/4144
|
||||
dialogConfirmEdit.value.callback = () => {
|
||||
appStore.importBooksPath = dialogConfirmEdit.value.record as string
|
||||
}
|
||||
}
|
||||
</script>
|
||||
18
next-ui/src/composables/errorCodeFormatter.ts
Normal file
18
next-ui/src/composables/errorCodeFormatter.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { useIntl } from 'vue-intl'
|
||||
import { errorCodeMessages } from '@/utils/i18n/enum/error-codes'
|
||||
|
||||
export function useErrorCodeFormatter() {
|
||||
const intl = useIntl()
|
||||
|
||||
function convertErrorCodes(message: string): string {
|
||||
const match = message.match(/ERR_\d{4}/g)
|
||||
let r = message
|
||||
match?.forEach((errorCode) => {
|
||||
if (errorCodeMessages[errorCode])
|
||||
r = r.replace(errorCode, intl.formatMessage(errorCodeMessages[errorCode]))
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
return { convertErrorCodes }
|
||||
}
|
||||
11
next-ui/src/fragments/fragment/RemoteFileList.mdx
Normal file
11
next-ui/src/fragments/fragment/RemoteFileList.mdx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { Canvas, Meta } from '@storybook/addon-docs/blocks';
|
||||
|
||||
import * as Stories from './RemoteFileList.stories';
|
||||
|
||||
<Meta of={Stories} />
|
||||
|
||||
# FragmentRemoteFileList
|
||||
|
||||
A remote directory browser, to browse directories on a remote server.
|
||||
|
||||
<Canvas of={Stories.Default} />
|
||||
49
next-ui/src/fragments/fragment/RemoteFileList.stories.ts
Normal file
49
next-ui/src/fragments/fragment/RemoteFileList.stories.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import { http, delay } from 'msw'
|
||||
import RemoteFileList from './RemoteFileList.vue'
|
||||
import { response401Unauthorized } from '@/mocks/api/handlers'
|
||||
|
||||
const meta = {
|
||||
component: RemoteFileList,
|
||||
render: (args: object) => ({
|
||||
components: { RemoteFileList },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
template: '<RemoteFileList v-bind="args"/>',
|
||||
}),
|
||||
parameters: {
|
||||
// More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
|
||||
},
|
||||
args: {},
|
||||
} satisfies Meta<typeof RemoteFileList>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {},
|
||||
}
|
||||
|
||||
export const PresetPath: Story = {
|
||||
args: {
|
||||
modelValue: '/comics',
|
||||
},
|
||||
}
|
||||
|
||||
export const Loading: Story = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [http.all('*', async () => await delay(2_000))],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const Error: Story = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [http.all('*', response401Unauthorized)],
|
||||
},
|
||||
},
|
||||
}
|
||||
79
next-ui/src/fragments/fragment/RemoteFileList.vue
Normal file
79
next-ui/src/fragments/fragment/RemoteFileList.vue
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
<template>
|
||||
<EmptyStateNetworkError v-if="error" />
|
||||
|
||||
<template v-else>
|
||||
<v-list
|
||||
:disabled="isLoading"
|
||||
elevation="2"
|
||||
>
|
||||
<v-progress-linear
|
||||
v-if="isLoading"
|
||||
indeterminate
|
||||
height="3"
|
||||
class="position-absolute top-0"
|
||||
/>
|
||||
|
||||
<v-text-field
|
||||
v-model="selectedPath"
|
||||
readonly
|
||||
label="Selected path"
|
||||
variant="solo"
|
||||
flat
|
||||
hide-details
|
||||
/>
|
||||
|
||||
<v-divider class="my-2" />
|
||||
|
||||
<template v-if="directoryListing">
|
||||
<!-- Parent directory -->
|
||||
<v-list-item
|
||||
v-if="directoryListing.parent || directoryListing.parent === ''"
|
||||
:title="
|
||||
$formatMessage({
|
||||
description: 'File browser: parent directory',
|
||||
defaultMessage: 'Parent',
|
||||
id: 'B48EcS',
|
||||
})
|
||||
"
|
||||
prepend-icon="i-mdi:arrow-left"
|
||||
@click="selectedPath = directoryListing.parent"
|
||||
/>
|
||||
|
||||
<!-- Directory listing -->
|
||||
<v-list-item
|
||||
v-for="(dir, index) in directoryListing.directories"
|
||||
:key="index"
|
||||
:title="dir.name"
|
||||
prepend-icon="i-mdi:folder"
|
||||
@click="selectedPath = dir.path"
|
||||
/>
|
||||
</template>
|
||||
</v-list>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useQuery } from '@pinia/colada'
|
||||
import { komgaClient } from '@/api/komga-client'
|
||||
|
||||
const selectedPath = defineModel<string>({ required: false, default: '' })
|
||||
|
||||
const {
|
||||
data: directoryListing,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery({
|
||||
key: () => ['filesystem', selectedPath.value],
|
||||
query: () =>
|
||||
komgaClient
|
||||
.POST('/api/v1/filesystem', {
|
||||
body: {
|
||||
path: selectedPath.value,
|
||||
showFiles: false,
|
||||
},
|
||||
})
|
||||
// unwrap the openapi-fetch structure on success
|
||||
.then((res) => res.data),
|
||||
placeholderData: (previousData: any) => previousData, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
})
|
||||
</script>
|
||||
9
next-ui/src/fragments/fragment/dialog/SeriesPicker.mdx
Normal file
9
next-ui/src/fragments/fragment/dialog/SeriesPicker.mdx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { Meta } from '@storybook/addon-docs/blocks';
|
||||
|
||||
import * as Stories from './SeriesPicker.stories';
|
||||
|
||||
<Meta of={Stories} />
|
||||
|
||||
# FragmentDialogSeriesPicker
|
||||
|
||||
Search and pick series.
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import { http, delay } from 'msw'
|
||||
import SeriesPicker from './SeriesPicker.vue'
|
||||
import { response401Unauthorized } from '@/mocks/api/handlers'
|
||||
import { fn } from 'storybook/test'
|
||||
|
||||
const meta = {
|
||||
component: SeriesPicker,
|
||||
render: (args: object) => ({
|
||||
components: { SeriesPicker },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
template: '<SeriesPicker v-bind="args"/>',
|
||||
}),
|
||||
parameters: {
|
||||
// More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
|
||||
},
|
||||
args: {
|
||||
dialog: true,
|
||||
onSelectedSeries: fn(),
|
||||
},
|
||||
} satisfies Meta<typeof SeriesPicker>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {},
|
||||
}
|
||||
|
||||
export const PresetSearch: Story = {
|
||||
args: {
|
||||
searchString: 'd',
|
||||
},
|
||||
}
|
||||
|
||||
export const NoResults: Story = {
|
||||
args: {
|
||||
searchString: 'not found',
|
||||
},
|
||||
}
|
||||
|
||||
export const Loading: Story = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [http.all('*', async () => await delay(2_000))],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const Error: Story = {
|
||||
args: {
|
||||
searchString: 'd',
|
||||
},
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [http.all('*', response401Unauthorized)],
|
||||
},
|
||||
},
|
||||
}
|
||||
180
next-ui/src/fragments/fragment/dialog/SeriesPicker.vue
Normal file
180
next-ui/src/fragments/fragment/dialog/SeriesPicker.vue
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
<template>
|
||||
<v-dialog
|
||||
v-model="showDialog"
|
||||
:activator="activator"
|
||||
:fullscreen="fullscreen"
|
||||
:transition="fullscreen ? 'dialog-bottom-transition' : undefined"
|
||||
max-width="600px"
|
||||
:aria-label="$formatMessage(titleMessage)"
|
||||
@after-leave="reset()"
|
||||
>
|
||||
<template #default="{ isActive }">
|
||||
<v-card :title="$formatMessage(titleMessage)">
|
||||
<template #append>
|
||||
<v-icon
|
||||
icon="i-mdi:close"
|
||||
@click="isActive.value = false"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<v-card-text>
|
||||
<v-list
|
||||
:disabled="isLoading"
|
||||
elevation="2"
|
||||
>
|
||||
<v-progress-linear
|
||||
v-if="isLoading"
|
||||
indeterminate
|
||||
height="3"
|
||||
class="position-absolute top-0"
|
||||
/>
|
||||
|
||||
<v-text-field
|
||||
v-model="searchStringRef"
|
||||
:label="
|
||||
$formatMessage({
|
||||
description: 'Series picker dialog: search field label',
|
||||
defaultMessage: 'Search series',
|
||||
id: 'tPD6YO',
|
||||
})
|
||||
"
|
||||
variant="solo"
|
||||
flat
|
||||
hide-details
|
||||
autofocus
|
||||
clearable
|
||||
/>
|
||||
|
||||
<template v-if="searchStringRef">
|
||||
<v-divider class="my-2" />
|
||||
|
||||
<template v-if="series?.content">
|
||||
<v-list-item
|
||||
v-for="(s, index) in series.content"
|
||||
:key="index"
|
||||
@click="pick(s)"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-img
|
||||
width="52"
|
||||
height="75"
|
||||
contain
|
||||
:src="seriesThumbnailUrl(s.id)"
|
||||
lazy-src="@/assets/cover.svg"
|
||||
class="me-2"
|
||||
/>
|
||||
</template>
|
||||
<v-list-item-title>{{ s.metadata.title }}</v-list-item-title>
|
||||
<v-list-item-subtitle
|
||||
>{{ libraries?.find((it) => it.id === s.libraryId)?.name }}
|
||||
</v-list-item-subtitle>
|
||||
<v-list-item-subtitle v-if="s.booksMetadata.releaseDate"
|
||||
>{{
|
||||
$formatDate(s.booksMetadata.releaseDate, { year: 'numeric', timeZone: 'UTC' })
|
||||
}}
|
||||
</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
</template>
|
||||
|
||||
<v-empty-state
|
||||
v-if="series?.content?.length == 0"
|
||||
icon="i-mdi:magnify"
|
||||
:title="
|
||||
$formatMessage({
|
||||
description: 'Series picker dialog: no results empty state - title',
|
||||
defaultMessage: 'No series found',
|
||||
id: '4g2M9O',
|
||||
})
|
||||
"
|
||||
:text="
|
||||
$formatMessage({
|
||||
description: 'Series picker dialog: no results empty state - text',
|
||||
defaultMessage: 'Try searching for something else',
|
||||
id: 'lzHPYD',
|
||||
})
|
||||
"
|
||||
color="secondary"
|
||||
/>
|
||||
|
||||
<EmptyStateNetworkError v-if="error" />
|
||||
</template>
|
||||
</v-list>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useQuery } from '@pinia/colada'
|
||||
import { seriesListQuery } from '@/colada/series'
|
||||
import type { components } from '@/generated/openapi/komga'
|
||||
import { seriesThumbnailUrl } from '@/api/images'
|
||||
import { refDebounced } from '@vueuse/core'
|
||||
import { useLibraries } from '@/colada/libraries'
|
||||
|
||||
const showDialog = defineModel<boolean>('dialog', { required: false })
|
||||
|
||||
const {
|
||||
includeOneShots = true,
|
||||
searchString = '',
|
||||
fullscreen = undefined,
|
||||
activator = undefined,
|
||||
} = defineProps<{
|
||||
includeOneShots?: boolean
|
||||
searchString?: string
|
||||
fullscreen?: boolean
|
||||
activator?: Element | string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
selectedSeries: [series: components['schemas']['SeriesDto']]
|
||||
}>()
|
||||
|
||||
const searchStringRef = ref<string>(searchString)
|
||||
|
||||
// sync the ref if the prop changes
|
||||
watch(
|
||||
() => searchString,
|
||||
(it) => (searchStringRef.value = it),
|
||||
)
|
||||
|
||||
const searchStringDebounced = refDebounced(searchStringRef, 500)
|
||||
const {
|
||||
data: series,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery(seriesListQuery, () => {
|
||||
const search: components['schemas']['SeriesSearch'] = {
|
||||
fullTextSearch: searchStringDebounced.value,
|
||||
...(!includeOneShots && {
|
||||
condition: {
|
||||
oneShot: { operator: 'IsFalse' },
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
return {
|
||||
search: search,
|
||||
pause: !searchStringDebounced.value,
|
||||
}
|
||||
})
|
||||
|
||||
const { data: libraries } = useLibraries()
|
||||
|
||||
function pick(selectedSeries: components['schemas']['SeriesDto']) {
|
||||
emit('selectedSeries', selectedSeries)
|
||||
showDialog.value = false
|
||||
}
|
||||
|
||||
function reset() {
|
||||
searchStringRef.value = searchString
|
||||
}
|
||||
|
||||
const titleMessage = {
|
||||
description: 'Series picker dialog: title',
|
||||
defaultMessage: 'Select series',
|
||||
id: 'ycrpqO',
|
||||
}
|
||||
</script>
|
||||
<script setup lang="ts"></script>
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import { Canvas, Meta } from '@storybook/addon-docs/blocks';
|
||||
|
||||
import * as Stories from './TransientBooksTable.stories';
|
||||
|
||||
<Meta of={Stories} />
|
||||
|
||||
# FragmentImportBooksTransientBooksTable
|
||||
|
||||
Data table displaying books to import.
|
||||
- When books come into view, they will be analyzed.
|
||||
- Only `READY` books can be selected.
|
||||
- If a book has a `seriesId` returned by the server, it will be automatically selected.
|
||||
- If a book has a book number returned by the server, it will be automatically selected for upgrade.
|
||||
|
||||
<Canvas of={Stories.Default} />
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import { http, delay } from 'msw'
|
||||
import TransientBooksTable from './TransientBooksTable.vue'
|
||||
import { response401Unauthorized } from '@/mocks/api/handlers'
|
||||
import { scanned } from '@/mocks/api/handlers/transient-books'
|
||||
import SnackQueue from '@/fragments/fragment/SnackQueue.vue'
|
||||
|
||||
const meta = {
|
||||
component: TransientBooksTable,
|
||||
subcomponents: { SnackQueue },
|
||||
render: (args: object) => ({
|
||||
components: { TransientBooksTable, SnackQueue },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
template: '<TransientBooksTable v-bind="args"/><SnackQueue/>',
|
||||
}),
|
||||
parameters: {
|
||||
// More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
|
||||
},
|
||||
args: {},
|
||||
} satisfies Meta<typeof TransientBooksTable>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
books: scanned,
|
||||
},
|
||||
}
|
||||
|
||||
export const Empty: Story = {
|
||||
args: {},
|
||||
}
|
||||
|
||||
export const Loading: Story = {
|
||||
args: {
|
||||
books: scanned,
|
||||
},
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [http.all('*', async () => await delay(2_000))],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const ErrorOnImport: Story = {
|
||||
args: {
|
||||
books: scanned,
|
||||
},
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [http.all('*/v1/books/import', response401Unauthorized)],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
@ -0,0 +1,559 @@
|
|||
<template>
|
||||
<v-data-table
|
||||
v-model="selectedBookIds"
|
||||
v-model:items-per-page="itemsPerPage"
|
||||
:loading="importing || loading"
|
||||
:items="importBooks"
|
||||
item-value="transientBook.id"
|
||||
:headers="headers"
|
||||
:items-per-page-options="itemsPerPageOptions"
|
||||
:hide-default-footer="hideFooter"
|
||||
fixed-header
|
||||
fixed-footer
|
||||
show-select
|
||||
item-selectable="selectable"
|
||||
select-strategy="page"
|
||||
mobile-breakpoint="md"
|
||||
@update:current-items="onDisplayedItems"
|
||||
>
|
||||
<template #no-data>
|
||||
<v-empty-state
|
||||
icon="i-mdi:book-search"
|
||||
:title="
|
||||
$formatMessage({
|
||||
description: 'Import books table: shown when table has no data - title',
|
||||
defaultMessage: 'No books found',
|
||||
id: 'VvjZEl',
|
||||
})
|
||||
"
|
||||
:text="
|
||||
$formatMessage({
|
||||
description: 'Import books table: shown when table has no data - subtitle',
|
||||
defaultMessage: 'Try scanning another directory',
|
||||
id: '9VuyZU',
|
||||
})
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #[`header.analysisStatus`]>
|
||||
<v-icon icon="i-mdi:file-check-outline" />
|
||||
</template>
|
||||
|
||||
<template #[`item.analysisStatus`]="{ item }: { item: BookImport }">
|
||||
<v-progress-circular
|
||||
v-if="item.transientBook.status === MediaStatus.UNKNOWN.valueOf()"
|
||||
indeterminate
|
||||
color="primary"
|
||||
:size="20"
|
||||
:width="2"
|
||||
/>
|
||||
<v-icon
|
||||
v-if="
|
||||
item.transientBook.status === MediaStatus.ERROR.valueOf() ||
|
||||
item.transientBook.status === MediaStatus.UNSUPPORTED.valueOf()
|
||||
"
|
||||
v-tooltip="convertErrorCodes(item.transientBook.comment)"
|
||||
icon="i-mdi:alert-circle"
|
||||
color="error"
|
||||
/>
|
||||
<v-icon
|
||||
v-if="item.transientBook.status === MediaStatus.READY.valueOf()"
|
||||
icon="i-mdi:check-circle"
|
||||
color="success"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #[`item.transientBook.name`]="{ value, item }">
|
||||
<span :class="item.imported ? 'text-disabled' : undefined">{{ value }}</span>
|
||||
</template>
|
||||
|
||||
<template #[`item.series`]="{ item, internalItem, isSelected }">
|
||||
<div
|
||||
:class="item.selectable ? 'cursor-pointer' : 'cursor-not-allowed'"
|
||||
@mouseenter="
|
||||
item.selectable
|
||||
? (dialogSeriesPickerActivator = $event.currentTarget as Element)
|
||||
: (dialogSeriesPickerActivator = undefined)
|
||||
"
|
||||
@click="item.selectable ? (currentActionedItem = [item]) : undefined"
|
||||
>
|
||||
<span
|
||||
v-if="item.series"
|
||||
:class="item.imported ? 'text-disabled' : undefined"
|
||||
>{{ item.series?.metadata.title }}</span
|
||||
>
|
||||
<template v-else>
|
||||
<div
|
||||
style="height: 2em"
|
||||
:class="isSelected(internalItem) ? 'missing' : ''"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #[`item.upgradeBook`]="{ item }">
|
||||
<div
|
||||
@mouseenter="
|
||||
item.upgradable
|
||||
? (dialogBookPickerActivator = $event.currentTarget as Element)
|
||||
: undefined
|
||||
"
|
||||
@click="item.upgradable ? (currentActionedItem = [item]) : undefined"
|
||||
>
|
||||
<v-chip
|
||||
v-if="item.upgradeBook"
|
||||
variant="text"
|
||||
closable
|
||||
class="cursor-pointer"
|
||||
:disabled="!item.upgradable"
|
||||
@click:close="unassignBook(item)"
|
||||
>
|
||||
{{ item.upgradeBook.metadata.number }} - {{ item.upgradeBook.metadata.title }}
|
||||
</v-chip>
|
||||
<v-btn
|
||||
v-else
|
||||
color=""
|
||||
size="small"
|
||||
:disabled="!item.upgradable"
|
||||
prepend-icon="i-mdi:file-replace-outline"
|
||||
:text="
|
||||
$formatMessage({
|
||||
description: 'Import books table: book upgrade button',
|
||||
defaultMessage: 'Upgrade',
|
||||
id: 'hrh5Rn',
|
||||
})
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #[`item.destinationName`]="{ item }">
|
||||
<div
|
||||
:class="
|
||||
(item.selectable ? 'cursor-pointer' : 'cursor-not-allowed') +
|
||||
' ' +
|
||||
(item.imported ? 'text-disabled' : undefined)
|
||||
"
|
||||
@mouseenter="
|
||||
item.selectable
|
||||
? (dialogFileNamePickerActivator = $event.currentTarget as Element)
|
||||
: (dialogFileNamePickerActivator = undefined)
|
||||
"
|
||||
@click="item.selectable ? (currentActionedItem = [item]) : undefined"
|
||||
>
|
||||
{{ item.destinationName }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #[`header.statusMessage`]>
|
||||
<v-icon icon="i-mdi:alert-circle-outline" />
|
||||
</template>
|
||||
|
||||
<template #[`item.statusMessage`]="{ item, value, internalItem, isSelected }">
|
||||
<v-icon
|
||||
v-if="item.imported"
|
||||
v-tooltip="value"
|
||||
icon="i-mdi:import"
|
||||
color="info"
|
||||
/>
|
||||
<template v-else-if="isSelected(internalItem)">
|
||||
<v-icon
|
||||
v-if="item.upgradeBook"
|
||||
v-tooltip="value"
|
||||
icon="i-mdi:file-replace"
|
||||
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="center"
|
||||
>
|
||||
<v-col cols="auto">
|
||||
<v-select
|
||||
v-model="copyMode"
|
||||
:label="
|
||||
$formatMessage({
|
||||
description: 'Import books table: bottom bar: import mode selection dropdown label',
|
||||
defaultMessage: 'Import mode',
|
||||
id: '14/Uh8',
|
||||
})
|
||||
"
|
||||
hide-details
|
||||
:items="copyOptions"
|
||||
variant="outlined"
|
||||
min-width="250"
|
||||
></v-select>
|
||||
</v-col>
|
||||
|
||||
<v-col>
|
||||
<v-btn
|
||||
:text="
|
||||
$formatMessage({
|
||||
description: 'Import books table: bottom bar: select series button',
|
||||
defaultMessage: 'Select series',
|
||||
id: 'SqZoei',
|
||||
})
|
||||
"
|
||||
color=""
|
||||
:disabled="loading || selectedBookIds.length == 0"
|
||||
@mouseenter="dialogSeriesPickerActivator = $event.currentTarget as Element"
|
||||
@click="currentActionedItem = selectedBooks"
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="auto">
|
||||
<v-btn
|
||||
color="primary"
|
||||
:text="
|
||||
$formatMessage({
|
||||
description: 'Import books table: bottom bar: import button',
|
||||
defaultMessage: 'Import',
|
||||
id: 'RHJo8j',
|
||||
})
|
||||
"
|
||||
:disabled="importing || loading || importBatch.books.length == 0"
|
||||
@click="doImportBooks"
|
||||
/>
|
||||
</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="currentActionedItem?.at(0)?.seriesBooks"
|
||||
@selected-book="(book) => bookPicked(book)"
|
||||
/>
|
||||
|
||||
<DialogFileNamePicker
|
||||
:activator="dialogFileNamePickerActivator"
|
||||
:fullscreen="display.xs.value"
|
||||
:existing-name="currentActionedItem?.at(0)?.destinationName"
|
||||
:series-books="currentActionedItem?.at(0)?.seriesBooks"
|
||||
@selected-name="(name) => fileNamePicked(name)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { components } from '@/generated/openapi/komga'
|
||||
import { useIntl } from 'vue-intl'
|
||||
import { MediaStatus } from '@/types/MediaStatus'
|
||||
import { useErrorCodeFormatter } from '@/composables/errorCodeFormatter'
|
||||
import { syncRefs, useArrayFilter, useArrayMap } from '@vueuse/core'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useMutation, useQuery } from '@pinia/colada'
|
||||
import { seriesDetailQuery } from '@/colada/series'
|
||||
import FileNamePickerDialog from '@/components/dialog/FileNamePicker.vue'
|
||||
import { bookListQuery } from '@/colada/books'
|
||||
import { transientBookAnalyze } from '@/colada/transient-books'
|
||||
import BookPickerDialog from '@/components/dialog/BookPicker.vue'
|
||||
import { type ErrorCause, komgaClient } from '@/api/komga-client'
|
||||
import { commonMessages } from '@/utils/i18n/common-messages'
|
||||
import { useMessagesStore } from '@/stores/messages'
|
||||
|
||||
class BookImport {
|
||||
transientBook: components['schemas']['TransientBookDto']
|
||||
destinationName: string
|
||||
series?: components['schemas']['SeriesDto']
|
||||
seriesBooks?: components['schemas']['BookDto'][]
|
||||
upgradeBook?: components['schemas']['BookDto']
|
||||
imported: boolean
|
||||
|
||||
constructor(transientBook: components['schemas']['TransientBookDto']) {
|
||||
this.transientBook = transientBook
|
||||
this.destinationName = transientBook.name
|
||||
this.imported = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the book is selectable.
|
||||
* Only books in READY status and not yet imported can be selected
|
||||
*/
|
||||
public get selectable(): boolean {
|
||||
return this.transientBook.status === MediaStatus.READY.valueOf() && !this.imported
|
||||
}
|
||||
|
||||
public get upgradable(): boolean {
|
||||
return this.selectable && !!this.series && !!this.seriesBooks
|
||||
}
|
||||
|
||||
public get importable(): boolean {
|
||||
return this.selectable && !!this.series
|
||||
}
|
||||
|
||||
public get statusMessage(): string {
|
||||
switch (this.transientBook.status) {
|
||||
case MediaStatus.UNKNOWN.valueOf():
|
||||
return 'Book needs to be analyzed first'
|
||||
case MediaStatus.UNSUPPORTED.valueOf():
|
||||
return 'Book format is not supported'
|
||||
case MediaStatus.ERROR.valueOf():
|
||||
return 'Book could not be analyzed'
|
||||
}
|
||||
if (!this.series) return 'Choose a series'
|
||||
if (this.imported) return 'Import requested'
|
||||
if (this.upgradeBook) return 'Book will be upgraded'
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
const messagesStore = useMessagesStore()
|
||||
const display = useDisplay()
|
||||
const intl = useIntl()
|
||||
const { convertErrorCodes } = useErrorCodeFormatter()
|
||||
|
||||
const { books = [], loading = false } = defineProps<{
|
||||
books?: components['schemas']['TransientBookDto'][]
|
||||
loading?: boolean
|
||||
}>()
|
||||
|
||||
// a read-only array of BookImport, kept in sync with the books prop
|
||||
const importBooksRO = useArrayMap(
|
||||
toRef(() => books),
|
||||
(it) => new BookImport(it),
|
||||
)
|
||||
// read-write array of BookImport, will be modified by the different actions
|
||||
const importBooks = ref<BookImport[]>([])
|
||||
// 1-way sync from importBooksRO to importBooks
|
||||
syncRefs(importBooksRO, importBooks)
|
||||
|
||||
// the current items being acted upon, used for dialog callback
|
||||
const currentActionedItem = ref<BookImport[]>()
|
||||
// the selected book IDs, used to programmatically select items
|
||||
const selectedBookIds = ref<string[]>([])
|
||||
// the selected books
|
||||
const selectedBooks = useArrayFilter(importBooks, (it) =>
|
||||
selectedBookIds.value.includes(it.transientBook.id),
|
||||
)
|
||||
const selectedImportableBooks = useArrayFilter(
|
||||
importBooks,
|
||||
(it) => selectedBookIds.value.includes(it.transientBook.id) && it.importable,
|
||||
)
|
||||
|
||||
const importBatch = computed(
|
||||
() =>
|
||||
({
|
||||
copyMode: copyMode.value,
|
||||
books: selectedImportableBooks.value.map((it) => ({
|
||||
destinationName: it.destinationName,
|
||||
seriesId: it.series?.id,
|
||||
sourceFile: it.transientBook.url,
|
||||
upgradeBookId: it.upgradeBook?.id,
|
||||
})),
|
||||
}) as components['schemas']['BookImportBatchDto'],
|
||||
)
|
||||
|
||||
// only analyze books that are shown
|
||||
function onDisplayedItems(items: { key: string }[]) {
|
||||
importBooks.value
|
||||
.filter((b) => items.map((it) => it.key).includes(b.transientBook.id))
|
||||
.forEach((b) => {
|
||||
if (b.transientBook.status === MediaStatus.UNKNOWN.valueOf()) {
|
||||
analyzeBook(b)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Table setup
|
||||
const itemsPerPage = ref<number>(display.smAndDown.value ? 1 : 10)
|
||||
const itemsPerPageOptions = computed(() => (display.smAndDown.value ? [1, 5] : [10, 25, 50]))
|
||||
const hideFooter = computed(() => importBooks.value.length < itemsPerPage.value)
|
||||
|
||||
const headers = [
|
||||
{
|
||||
title: intl.formatMessage({
|
||||
description: 'Import books table header: analysis status',
|
||||
defaultMessage: 'Analysis status',
|
||||
id: 'f1fW81',
|
||||
}),
|
||||
key: 'analysisStatus',
|
||||
align: 'end',
|
||||
},
|
||||
{
|
||||
title: intl.formatMessage({
|
||||
description: 'Import books table header: file name',
|
||||
defaultMessage: 'File name',
|
||||
id: 'kYDPt1',
|
||||
}),
|
||||
key: 'transientBook.name',
|
||||
},
|
||||
{
|
||||
title: intl.formatMessage({
|
||||
description: 'Import books table header: series',
|
||||
defaultMessage: 'Series',
|
||||
id: '3OgH93',
|
||||
}),
|
||||
key: 'series',
|
||||
},
|
||||
{
|
||||
title: intl.formatMessage({
|
||||
description: 'Import books table header: book',
|
||||
defaultMessage: 'Book',
|
||||
id: 'Kie8HQ',
|
||||
}),
|
||||
key: 'upgradeBook',
|
||||
},
|
||||
{
|
||||
title: intl.formatMessage({
|
||||
description: 'Import books table header: destination name',
|
||||
defaultMessage: 'Destination name',
|
||||
id: 'bSoeY6',
|
||||
}),
|
||||
key: 'destinationName',
|
||||
},
|
||||
{
|
||||
title: intl.formatMessage({
|
||||
description: 'Import books table header: status message',
|
||||
defaultMessage: 'Status',
|
||||
id: 'knm6Z+',
|
||||
}),
|
||||
key: 'statusMessage',
|
||||
align: 'end',
|
||||
},
|
||||
] as const // workaround for https://github.com/vuetifyjs/vuetify/issues/18901
|
||||
|
||||
const copyOptions = [
|
||||
{
|
||||
title: 'Hardlink/Copy files',
|
||||
value: 'HARDLINK',
|
||||
},
|
||||
{
|
||||
title: 'Move files',
|
||||
value: 'MOVE',
|
||||
},
|
||||
]
|
||||
const copyMode = ref<string>(copyOptions[0]!.value)
|
||||
|
||||
// Series Picker Dialog
|
||||
const dialogSeriesPickerActivator = ref<Element | undefined>(undefined)
|
||||
|
||||
function seriesPicked(series: components['schemas']['SeriesDto']) {
|
||||
if (currentActionedItem.value) {
|
||||
currentActionedItem.value.forEach((it) => assignSeries(it, series))
|
||||
}
|
||||
}
|
||||
|
||||
// Book Picker Dialog
|
||||
const dialogBookPickerActivator = ref<Element | undefined>(undefined)
|
||||
|
||||
function bookPicked(book: components['schemas']['BookDto']) {
|
||||
if (currentActionedItem.value) {
|
||||
currentActionedItem.value.forEach((it) => assignBookNumber(it, book.metadata.numberSort))
|
||||
}
|
||||
}
|
||||
|
||||
// File Name Picker dialog
|
||||
const dialogFileNamePickerActivator = ref<Element | undefined>(undefined)
|
||||
|
||||
function fileNamePicked(name: string) {
|
||||
if (currentActionedItem.value) {
|
||||
currentActionedItem.value.forEach((it) => (it.destinationName = name))
|
||||
}
|
||||
}
|
||||
|
||||
function analyzeBook(book: BookImport) {
|
||||
const { refresh } = useQuery(transientBookAnalyze, () => ({
|
||||
transientBookId: book.transientBook.id,
|
||||
}))
|
||||
void refresh().then(({ data }) => {
|
||||
if (data) {
|
||||
book.transientBook = data
|
||||
if (book.transientBook.seriesId && book.transientBook.seriesId !== book.series?.id)
|
||||
fetchSeries(book)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function fetchSeries(book: BookImport) {
|
||||
const { refresh } = useQuery(seriesDetailQuery, () => ({
|
||||
seriesId: book.transientBook.seriesId!,
|
||||
}))
|
||||
void refresh().then(({ data }) => {
|
||||
if (data) {
|
||||
assignSeries(book, data)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function fetchBooks(book: BookImport) {
|
||||
const { refresh } = useQuery(bookListQuery, () => ({
|
||||
search: {
|
||||
condition: {
|
||||
seriesId: { operator: 'Is', value: book.series!.id },
|
||||
},
|
||||
} as components['schemas']['BookSearch'],
|
||||
}))
|
||||
void refresh().then(({ data }) => {
|
||||
if (data) {
|
||||
book.seriesBooks = data.content
|
||||
if (book.transientBook.number) assignBookNumber(book, book.transientBook.number)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function assignSeries(book: BookImport, series: components['schemas']['SeriesDto']) {
|
||||
book.series = series
|
||||
fetchBooks(book)
|
||||
if (book.importable && !selectedBookIds.value.includes(book.transientBook.id))
|
||||
selectedBookIds.value.push(book.transientBook.id)
|
||||
}
|
||||
|
||||
function assignBookNumber(book: BookImport, number: number) {
|
||||
book.upgradeBook = book.seriesBooks?.find((b) => b.metadata.numberSort === number)
|
||||
}
|
||||
|
||||
function unassignBook(book: BookImport) {
|
||||
book.upgradeBook = undefined
|
||||
}
|
||||
|
||||
const importing = ref<boolean>(false)
|
||||
|
||||
function doImportBooks() {
|
||||
importing.value = true
|
||||
const { mutateAsync } = useMutation({
|
||||
mutation: () => komgaClient.POST('/api/v1/books/import', { body: importBatch.value }),
|
||||
})
|
||||
|
||||
mutateAsync()
|
||||
.then(() => {
|
||||
selectedImportableBooks.value.forEach((it) => {
|
||||
it.imported = true
|
||||
// remove imported books from selection
|
||||
if (selectedBookIds.value.includes(it.transientBook.id))
|
||||
selectedBookIds.value.splice(selectedBookIds.value.indexOf(it.transientBook.id), 1)
|
||||
})
|
||||
})
|
||||
.catch((error) => {
|
||||
messagesStore.messages.push({
|
||||
text:
|
||||
(error?.cause as ErrorCause)?.message || intl.formatMessage(commonMessages.networkError),
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
importing.value = false
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.missing {
|
||||
border: 2px dashed red;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -10,21 +10,28 @@ import { claimHandlers } from '@/mocks/api/handlers/claim'
|
|||
import { historyHandlers } from '@/mocks/api/handlers/history'
|
||||
import { seriesHandlers } from '@/mocks/api/handlers/series'
|
||||
import { booksHandlers } from '@/mocks/api/handlers/books'
|
||||
import { filesystemHandlers } from '@/mocks/api/handlers/filesystem'
|
||||
import { transientBooksHandlers } from '@/mocks/api/handlers/transient-books'
|
||||
|
||||
export const handlers = [
|
||||
...actuatorHandlers,
|
||||
...announcementHandlers,
|
||||
...booksHandlers,
|
||||
...claimHandlers,
|
||||
...filesystemHandlers,
|
||||
...historyHandlers,
|
||||
...librariesHandlers,
|
||||
...referentialHandlers,
|
||||
...releasesHandlers,
|
||||
...seriesHandlers,
|
||||
...settingsHandlers,
|
||||
...transientBooksHandlers,
|
||||
...usersHandlers,
|
||||
]
|
||||
|
||||
export const response400BadRequest = () =>
|
||||
HttpResponse.json({ error: 'Bad Request' }, { status: 400 })
|
||||
|
||||
export const response404NotFound = () => HttpResponse.json({ error: 'NotFound' }, { status: 404 })
|
||||
|
||||
export const response401Unauthorized = () =>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
import { httpTyped } from '@/mocks/api/httpTyped'
|
||||
import { mockPage } from '@/mocks/api/pageable'
|
||||
import { PageRequest } from '@/types/PageRequest'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import mockThumbnailUrl from '@/assets/mock-thumbnail.jpg'
|
||||
|
||||
const book = {
|
||||
id: '05RKH8CC8B4RW',
|
||||
|
|
@ -57,11 +61,50 @@ const book = {
|
|||
oneshot: false,
|
||||
}
|
||||
|
||||
export function mockBooks(count: number) {
|
||||
return [...Array(count).keys()].map((index) =>
|
||||
Object.assign({}, book, {
|
||||
id: `BOOK${index + 1}`,
|
||||
name: `Book ${index + 1}`,
|
||||
number: index + 1,
|
||||
metadata: {
|
||||
title: `Book ${index + 1}`,
|
||||
number: `${index + 1}`,
|
||||
numberSort: index + 1,
|
||||
...(index % 2 === 0 && {
|
||||
releaseDate: `19${String(index).slice(-2).padStart(2, '0')}-05-10`,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export const booksHandlers = [
|
||||
httpTyped.post('/api/v1/books/list', ({ query, response }) => {
|
||||
return response(200).json(
|
||||
mockPage(
|
||||
mockBooks(50),
|
||||
new PageRequest(Number(query.get('page')), Number(query.get('size')), query.getAll('sort')),
|
||||
),
|
||||
)
|
||||
}),
|
||||
httpTyped.get('/api/v1/books/{bookId}', ({ params, response }) => {
|
||||
if (params.bookId === '404') return response(404).empty()
|
||||
return response(200).json(
|
||||
Object.assign({}, book, { metadata: { title: `Book ${params.bookId}` } }),
|
||||
)
|
||||
}),
|
||||
httpTyped.post('/api/v1/books/import', ({ response }) => {
|
||||
return response(202).empty()
|
||||
}),
|
||||
http.get('*/api/v1/books/*/thumbnail', async () => {
|
||||
// Get an ArrayBuffer from reading the file from disk or fetching it.
|
||||
const buffer = await fetch(mockThumbnailUrl).then((response) => response.arrayBuffer())
|
||||
|
||||
return HttpResponse.arrayBuffer(buffer, {
|
||||
headers: {
|
||||
'content-type': 'image/jpg',
|
||||
},
|
||||
})
|
||||
}),
|
||||
]
|
||||
|
|
|
|||
61
next-ui/src/mocks/api/handlers/filesystem.ts
Normal file
61
next-ui/src/mocks/api/handlers/filesystem.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { httpTyped } from '@/mocks/api/httpTyped'
|
||||
import { response400BadRequest } from '@/mocks/api/handlers'
|
||||
|
||||
const emptyPath = { directories: [{ type: 'directory', name: '/', path: '/' }], files: [] }
|
||||
|
||||
const rootSlash = {
|
||||
parent: '',
|
||||
directories: [
|
||||
{ type: 'directory', name: 'Applications', path: '/Applications' },
|
||||
{ type: 'directory', name: 'bin', path: '/bin' },
|
||||
{ type: 'directory', name: 'cores', path: '/cores' },
|
||||
{ type: 'directory', name: 'comics', path: '/comics' },
|
||||
{ type: 'directory', name: 'dev', path: '/dev' },
|
||||
{ type: 'directory', name: 'etc', path: '/etc' },
|
||||
{ type: 'directory', name: 'home', path: '/home' },
|
||||
{ type: 'directory', name: 'Library', path: '/Library' },
|
||||
{ type: 'directory', name: 'opt', path: '/opt' },
|
||||
{ type: 'directory', name: 'private', path: '/private' },
|
||||
{ type: 'directory', name: 'sbin', path: '/sbin' },
|
||||
{ type: 'directory', name: 'System', path: '/System' },
|
||||
{ type: 'directory', name: 'tmp', path: '/tmp' },
|
||||
{ type: 'directory', name: 'Users', path: '/Users' },
|
||||
{ type: 'directory', name: 'usr', path: '/usr' },
|
||||
{ type: 'directory', name: 'var', path: '/var' },
|
||||
{ type: 'directory', name: 'Volumes', path: '/Volumes' },
|
||||
],
|
||||
files: [],
|
||||
}
|
||||
|
||||
const comics = {
|
||||
parent: '/',
|
||||
directories: [
|
||||
{ type: 'directory', name: '_oneshots', path: '/comics/_oneshots' },
|
||||
{ type: 'directory', name: 'Golden Age', path: '/comics/Golden Age' },
|
||||
{ type: 'directory', name: 'Wika', path: '/comics/Wika' },
|
||||
{ type: 'directory', name: 'Zorro', path: '/comics/Zorro' },
|
||||
],
|
||||
files: [],
|
||||
}
|
||||
|
||||
const empty = { parent: '/', directories: [], files: [] }
|
||||
|
||||
export const filesystemHandlers = [
|
||||
httpTyped.post('/api/v1/filesystem', async ({ request, response }) => {
|
||||
const data = await request.json()
|
||||
|
||||
if (data?.path === '') {
|
||||
return response(200).json(emptyPath)
|
||||
} else if (data?.path === '/') {
|
||||
return response(200).json(rootSlash)
|
||||
} else if (data?.path === '/comics') {
|
||||
return response(200).json(comics)
|
||||
} else if (
|
||||
[...rootSlash.directories, ...comics.directories].some((it) => it.path === data?.path)
|
||||
) {
|
||||
return response(200).json(empty)
|
||||
}
|
||||
|
||||
return response.untyped(response400BadRequest())
|
||||
}),
|
||||
]
|
||||
|
|
@ -2,7 +2,7 @@ import { httpTyped } from '@/mocks/api/httpTyped'
|
|||
import { mockPage } from '@/mocks/api/pageable'
|
||||
import { PageRequest } from '@/types/PageRequest'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import logoUrl from '@/assets/logo.svg'
|
||||
import mockThumbnailUrl from '@/assets/mock-thumbnail.jpg'
|
||||
|
||||
export const historyBookImported = {
|
||||
id: 'H1',
|
||||
|
|
@ -114,11 +114,11 @@ export const historyHandlers = [
|
|||
),
|
||||
http.get('*/api/v1/page-hashes/*/thumbnail', async () => {
|
||||
// Get an ArrayBuffer from reading the file from disk or fetching it.
|
||||
const buffer = await fetch(logoUrl).then((response) => response.arrayBuffer())
|
||||
const buffer = await fetch(mockThumbnailUrl).then((response) => response.arrayBuffer())
|
||||
|
||||
return HttpResponse.arrayBuffer(buffer, {
|
||||
headers: {
|
||||
'content-type': 'image/svg+xml',
|
||||
'content-type': 'image/jpg',
|
||||
},
|
||||
})
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
import { httpTyped } from '@/mocks/api/httpTyped'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import mockThumbnailUrl from '@/assets/mock-thumbnail.jpg'
|
||||
import { PageRequest } from '@/types/PageRequest'
|
||||
import { mockPage } from '@/mocks/api/pageable'
|
||||
|
||||
const series = {
|
||||
const series1 = {
|
||||
id: '57',
|
||||
libraryId: '56',
|
||||
libraryId: '1',
|
||||
name: 'Super Duck',
|
||||
url: '/books/Super Duck',
|
||||
created: new Date('2020-07-05T12:11:50Z'),
|
||||
|
|
@ -56,11 +60,94 @@ const series = {
|
|||
oneshot: false,
|
||||
}
|
||||
|
||||
const series2 = {
|
||||
id: '63',
|
||||
libraryId: '2',
|
||||
name: 'Space Adventures',
|
||||
url: '/books/Space Adventures',
|
||||
created: new Date('2020-07-05T12:11:50Z'),
|
||||
lastModified: new Date('2020-07-05T12:11:50Z'),
|
||||
fileLastModified: new Date('2020-03-05T11:57:31Z'),
|
||||
booksCount: 4,
|
||||
booksReadCount: 0,
|
||||
booksUnreadCount: 3,
|
||||
booksInProgressCount: 1,
|
||||
metadata: {
|
||||
status: 'ENDED',
|
||||
statusLock: true,
|
||||
title: 'Space Adventures',
|
||||
titleLock: false,
|
||||
titleSort: 'Space Adventures',
|
||||
titleSortLock: false,
|
||||
summary:
|
||||
'Supersophisticated androids that can pass for human? Robots that turn on their creators to take control of their world? Strange alien armies secretly infiltrating the earth? Men rocketing through the galaxy as easily as taking an average Sunday drive in the country? Come on, that stuff is just a bunch of science fiction, right?\n\nYou bet it is! Published every two months, Charlton Comics presented a new collection of short stories about mankind’s long-dreamed-of exploration of the rest of the solar system…and beyond!\n\nThis series is notable for its many stories by Steve Ditko (creator of The Amazing Spider-Man), and for the first appearance of Captain Atom.',
|
||||
summaryLock: true,
|
||||
readingDirection: 'LEFT_TO_RIGHT',
|
||||
readingDirectionLock: true,
|
||||
publisher: 'Charlton',
|
||||
publisherLock: true,
|
||||
ageRatingLock: false,
|
||||
language: 'en',
|
||||
languageLock: true,
|
||||
genres: ['science fiction'],
|
||||
genresLock: true,
|
||||
tags: [],
|
||||
tagsLock: false,
|
||||
totalBookCount: 70,
|
||||
totalBookCountLock: true,
|
||||
sharingLabels: [],
|
||||
sharingLabelsLock: false,
|
||||
links: [],
|
||||
linksLock: false,
|
||||
alternateTitles: [],
|
||||
alternateTitlesLock: false,
|
||||
created: new Date('2020-07-05T12:11:50Z'),
|
||||
lastModified: new Date('2023-07-22T11:14:45Z'),
|
||||
},
|
||||
booksMetadata: {
|
||||
authors: [],
|
||||
tags: [],
|
||||
releaseDate: '2018-07-10',
|
||||
summary: '',
|
||||
summaryNumber: '',
|
||||
created: new Date('2021-01-11T09:59:23Z'),
|
||||
lastModified: new Date('2025-04-08T02:55:19Z'),
|
||||
},
|
||||
deleted: false,
|
||||
oneshot: false,
|
||||
}
|
||||
|
||||
const series = [series1, series2]
|
||||
|
||||
export const seriesHandlers = [
|
||||
httpTyped.post('/api/v1/series/list', async ({ query, request, response }) => {
|
||||
const body = await request.json()
|
||||
|
||||
const selectedSeries = body.fullTextSearch
|
||||
? series.filter((it) => !!it.metadata.title.match(new RegExp(body.fullTextSearch!, 'i')))
|
||||
: series
|
||||
|
||||
return response(200).json(
|
||||
mockPage(
|
||||
selectedSeries,
|
||||
new PageRequest(Number(query.get('page')), Number(query.get('size')), query.getAll('sort')),
|
||||
),
|
||||
)
|
||||
}),
|
||||
httpTyped.get('/api/v1/series/{seriesId}', ({ params, response }) => {
|
||||
if (params.seriesId === '404') return response(404).empty()
|
||||
return response(200).json(
|
||||
Object.assign({}, series, { metadata: { title: `Series ${params.seriesId}` } }),
|
||||
Object.assign({}, series1, { metadata: { title: `Series ${params.seriesId}` } }),
|
||||
)
|
||||
}),
|
||||
http.get('*/api/v1/series/*/thumbnail', async () => {
|
||||
// Get an ArrayBuffer from reading the file from disk or fetching it.
|
||||
const buffer = await fetch(mockThumbnailUrl).then((response) => response.arrayBuffer())
|
||||
|
||||
return HttpResponse.arrayBuffer(buffer, {
|
||||
headers: {
|
||||
'content-type': 'image/jpg',
|
||||
},
|
||||
})
|
||||
}),
|
||||
]
|
||||
|
|
|
|||
1105
next-ui/src/mocks/api/handlers/transient-books.ts
Normal file
1105
next-ui/src/mocks/api/handlers/transient-books.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -23,7 +23,7 @@ export function mockPage<T>(data: T[], pageRequest: PageRequest) {
|
|||
paged: !unpaged,
|
||||
},
|
||||
last: false,
|
||||
totalPages: data.length / size,
|
||||
totalPages: Math.ceil(data.length / size),
|
||||
totalElements: data.length,
|
||||
first: false,
|
||||
size: size,
|
||||
|
|
|
|||
13
next-ui/src/pages/import/books.mdx
Normal file
13
next-ui/src/pages/import/books.mdx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { Canvas, Meta } from '@storybook/addon-docs/blocks';
|
||||
|
||||
import * as Stories from './books.stories';
|
||||
|
||||
<Meta of={Stories} />
|
||||
|
||||
# Import Books
|
||||
|
||||
Book import works in 2 steps:
|
||||
1. Browse for or input the import directory.
|
||||
2. Scanned books are shown in the table, allowing to fine-tune the import.
|
||||
|
||||
<Canvas of={Stories.Default} />
|
||||
35
next-ui/src/pages/import/books.stories.ts
Normal file
35
next-ui/src/pages/import/books.stories.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import ImportBooks from './books.vue'
|
||||
import DialogConfirmEdit from '@/fragments/fragment/dialog/ConfirmEdit.vue'
|
||||
import { delay, http } from 'msw'
|
||||
|
||||
const meta = {
|
||||
component: ImportBooks,
|
||||
render: (args: object) => ({
|
||||
components: { ImportBooks, DialogConfirmEdit },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
template: '<ImportBooks /><DialogConfirmEdit/>',
|
||||
}),
|
||||
parameters: {
|
||||
// More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
|
||||
},
|
||||
args: {},
|
||||
} satisfies Meta<typeof ImportBooks>
|
||||
|
||||
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))],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
@ -1,9 +1,33 @@
|
|||
<template>
|
||||
<h1>Import Books</h1>
|
||||
<ImportBooksDirectorySelection
|
||||
:loading="isLoading"
|
||||
@scan="(directory) => doScan(directory)"
|
||||
/>
|
||||
<FragmentImportBooksTransientBooksTable
|
||||
v-if="transientBooks"
|
||||
:loading="isLoading"
|
||||
:books="transientBooks"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
//
|
||||
import { transientBooksScan } from '@/colada/transient-books'
|
||||
import { useQuery } from '@pinia/colada'
|
||||
|
||||
const scanDirectory = ref<string>('')
|
||||
|
||||
const {
|
||||
data: transientBooks,
|
||||
isLoading,
|
||||
refetch,
|
||||
} = useQuery(transientBooksScan, () => ({
|
||||
path: scanDirectory.value,
|
||||
}))
|
||||
|
||||
function doScan(directory: string) {
|
||||
scanDirectory.value = directory
|
||||
void refetch()
|
||||
}
|
||||
</script>
|
||||
|
||||
<route lang="yaml">
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ export const useAppStore = defineStore('app', {
|
|||
drawer: !useDisplay().mobile.value.valueOf(),
|
||||
theme: 'system',
|
||||
rememberMe: false,
|
||||
importBooksPath: '',
|
||||
}),
|
||||
persist: true,
|
||||
})
|
||||
|
|
|
|||
37
next-ui/src/types/MediaStatus.ts
Normal file
37
next-ui/src/types/MediaStatus.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { defineMessages } from 'vue-intl'
|
||||
|
||||
export enum MediaStatus {
|
||||
UNKNOWN = 'UNKNOWN',
|
||||
ERROR = 'ERROR',
|
||||
READY = 'READY',
|
||||
UNSUPPORTED = 'UNSUPPORTED',
|
||||
OUTDATED = 'OUTDATED',
|
||||
}
|
||||
|
||||
export const mediaStatusMessages = defineMessages({
|
||||
[MediaStatus.UNKNOWN]: {
|
||||
description: 'Media status: unknown',
|
||||
defaultMessage: 'Unknown',
|
||||
id: 'vBi53Y',
|
||||
},
|
||||
[MediaStatus.ERROR]: {
|
||||
description: 'Media status: error',
|
||||
defaultMessage: 'Error',
|
||||
id: 'G49aNP',
|
||||
},
|
||||
[MediaStatus.READY]: {
|
||||
description: 'Media status: ready',
|
||||
defaultMessage: 'Ready',
|
||||
id: 'k0XIsB',
|
||||
},
|
||||
[MediaStatus.UNSUPPORTED]: {
|
||||
description: 'Media status: unsupported',
|
||||
defaultMessage: 'Unsupported',
|
||||
id: '7iAvhC',
|
||||
},
|
||||
[MediaStatus.OUTDATED]: {
|
||||
description: 'Media status: outdated',
|
||||
defaultMessage: 'Outdated',
|
||||
id: 'xba3Ob',
|
||||
},
|
||||
})
|
||||
179
next-ui/src/utils/i18n/enum/error-codes.ts
Normal file
179
next-ui/src/utils/i18n/enum/error-codes.ts
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
import { defineMessage, type MessageDescriptor } from 'vue-intl'
|
||||
|
||||
export const errorCodeMessages: Record<string, MessageDescriptor> = {
|
||||
ERR_1000: defineMessage({
|
||||
description: 'Error code: ERR_1000',
|
||||
defaultMessage: 'File could not be accessed during analysis',
|
||||
id: 'app.error.ERR_1000',
|
||||
}),
|
||||
ERR_1001: defineMessage({
|
||||
description: 'Error code: ERR_1001',
|
||||
defaultMessage: 'Media type is not supported',
|
||||
id: 'app.error.ERR_1001',
|
||||
}),
|
||||
ERR_1002: defineMessage({
|
||||
description: 'Error code: ERR_1002',
|
||||
defaultMessage: 'Encrypted RAR archives are not supported',
|
||||
id: 'app.error.ERR_1002',
|
||||
}),
|
||||
ERR_1003: defineMessage({
|
||||
description: 'Error code: ERR_1003',
|
||||
defaultMessage: 'Solid RAR archives are not supported',
|
||||
id: 'app.error.ERR_1003',
|
||||
}),
|
||||
ERR_1004: defineMessage({
|
||||
description: 'Error code: ERR_1004',
|
||||
defaultMessage: 'Multi-Volume RAR archives are not supported',
|
||||
id: 'app.error.ERR_1004',
|
||||
}),
|
||||
ERR_1005: defineMessage({
|
||||
description: 'Error code: ERR_1005',
|
||||
defaultMessage: 'Unknown error while analyzing book',
|
||||
id: 'app.error.ERR_1005',
|
||||
}),
|
||||
ERR_1006: defineMessage({
|
||||
description: 'Error code: ERR_1006',
|
||||
defaultMessage: 'Book does not contain any page',
|
||||
id: 'app.error.ERR_1006',
|
||||
}),
|
||||
ERR_1007: defineMessage({
|
||||
description: 'Error code: ERR_1007',
|
||||
defaultMessage: 'Some entries could not be analyzed',
|
||||
id: 'app.error.ERR_1007',
|
||||
}),
|
||||
ERR_1008: defineMessage({
|
||||
description: 'Error code: ERR_1008',
|
||||
defaultMessage: "Unknown error while getting book's entries",
|
||||
id: 'app.error.ERR_1008',
|
||||
}),
|
||||
ERR_1009: defineMessage({
|
||||
description: 'Error code: ERR_1009',
|
||||
defaultMessage: 'A read list with that name already exists',
|
||||
id: 'app.error.ERR_1009',
|
||||
}),
|
||||
ERR_1015: defineMessage({
|
||||
description: 'Error code: ERR_1015',
|
||||
defaultMessage: 'Error while deserializing ComicRack CBL',
|
||||
id: 'app.error.ERR_1015',
|
||||
}),
|
||||
ERR_1016: defineMessage({
|
||||
description: 'Error code: ERR_1016',
|
||||
defaultMessage: 'Directory not accessible or not a directory',
|
||||
id: 'app.error.ERR_1016',
|
||||
}),
|
||||
ERR_1017: defineMessage({
|
||||
description: 'Error code: ERR_1017',
|
||||
defaultMessage: 'Cannot scan folder that is part of an existing library',
|
||||
id: 'app.error.ERR_1017',
|
||||
}),
|
||||
ERR_1018: defineMessage({
|
||||
description: 'Error code: ERR_1018',
|
||||
defaultMessage: 'File not found',
|
||||
id: 'app.error.ERR_1018',
|
||||
}),
|
||||
ERR_1019: defineMessage({
|
||||
description: 'Error code: ERR_1019',
|
||||
defaultMessage: 'Cannot import file that is part of an existing library',
|
||||
id: 'app.error.ERR_1019',
|
||||
}),
|
||||
ERR_1020: defineMessage({
|
||||
description: 'Error code: ERR_1020',
|
||||
defaultMessage: 'Book to upgrade does not belong to provided series',
|
||||
id: 'app.error.ERR_1020',
|
||||
}),
|
||||
ERR_1021: defineMessage({
|
||||
description: 'Error code: ERR_1021',
|
||||
defaultMessage: 'Destination file already exists',
|
||||
id: 'app.error.ERR_1021',
|
||||
}),
|
||||
ERR_1022: defineMessage({
|
||||
description: 'Error code: ERR_1022',
|
||||
defaultMessage: 'Newly imported book could not be scanned',
|
||||
id: 'app.error.ERR_1022',
|
||||
}),
|
||||
ERR_1023: defineMessage({
|
||||
description: 'Error code: ERR_1023',
|
||||
defaultMessage: 'Book already present in ReadingList',
|
||||
id: 'app.error.ERR_1023',
|
||||
}),
|
||||
ERR_1024: defineMessage({
|
||||
description: 'Error code: ERR_1024',
|
||||
defaultMessage: 'OAuth2 login error: no email attribute',
|
||||
id: 'app.error.ERR_1024',
|
||||
}),
|
||||
ERR_1025: defineMessage({
|
||||
description: 'Error code: ERR_1025',
|
||||
defaultMessage: 'OAuth2 login error: no local user exist with that email',
|
||||
id: 'app.error.ERR_1025',
|
||||
}),
|
||||
ERR_1026: defineMessage({
|
||||
description: 'Error code: ERR_1026',
|
||||
defaultMessage: 'OpenID Connect login error: email not verified',
|
||||
id: 'app.error.ERR_1026',
|
||||
}),
|
||||
ERR_1027: defineMessage({
|
||||
description: 'Error code: ERR_1027',
|
||||
defaultMessage: 'OpenID Connect login error: no email_verified attribute',
|
||||
id: 'app.error.ERR_1027',
|
||||
}),
|
||||
ERR_1028: defineMessage({
|
||||
description: 'Error code: ERR_1028',
|
||||
defaultMessage: 'OpenID Connect login error: no email attribute',
|
||||
id: 'app.error.ERR_1028',
|
||||
}),
|
||||
ERR_1029: defineMessage({
|
||||
description: 'Error code: ERR_1029',
|
||||
defaultMessage: 'ComicRack CBL does not contain any Book element',
|
||||
id: 'app.error.ERR_1029',
|
||||
}),
|
||||
ERR_1030: defineMessage({
|
||||
description: 'Error code: ERR_1030',
|
||||
defaultMessage: 'ComicRack CBL has no Name element',
|
||||
id: 'app.error.ERR_1030',
|
||||
}),
|
||||
ERR_1031: defineMessage({
|
||||
description: 'Error code: ERR_1031',
|
||||
defaultMessage: 'ComicRack CBL Book is missing series or number',
|
||||
id: 'app.error.ERR_1031',
|
||||
}),
|
||||
ERR_1032: defineMessage({
|
||||
description: 'Error code: ERR_1032',
|
||||
defaultMessage: 'EPUB file has wrong media type',
|
||||
id: 'app.error.ERR_1032',
|
||||
}),
|
||||
ERR_1033: defineMessage({
|
||||
description: 'Error code: ERR_1033',
|
||||
defaultMessage: 'Some entries are missing',
|
||||
id: 'app.error.ERR_1033',
|
||||
}),
|
||||
ERR_1034: defineMessage({
|
||||
description: 'Error code: ERR_1034',
|
||||
defaultMessage: 'An API key with that comment already exists',
|
||||
id: 'app.error.ERR_1034',
|
||||
}),
|
||||
ERR_1035: defineMessage({
|
||||
description: 'Error code: ERR_1035',
|
||||
defaultMessage: 'Error while getting EPUB TOC',
|
||||
id: 'app.error.ERR_1035',
|
||||
}),
|
||||
ERR_1036: defineMessage({
|
||||
description: 'Error code: ERR_1036',
|
||||
defaultMessage: 'Error while getting EPUB Landmarks',
|
||||
id: 'app.error.ERR_1036',
|
||||
}),
|
||||
ERR_1037: defineMessage({
|
||||
description: 'Error code: ERR_1037',
|
||||
defaultMessage: 'Error while getting EPUB page list',
|
||||
id: 'app.error.ERR_1037',
|
||||
}),
|
||||
ERR_1038: defineMessage({
|
||||
description: 'Error code: ERR_1038',
|
||||
defaultMessage: 'Error while getting EPUB divina pages',
|
||||
id: 'app.error.ERR_1038',
|
||||
}),
|
||||
ERR_1039: defineMessage({
|
||||
description: 'Error code: ERR_1039',
|
||||
defaultMessage: 'Error while getting EPUB positions',
|
||||
id: 'app.error.ERR_1039',
|
||||
}),
|
||||
}
|
||||
Loading…
Reference in a new issue