add history page

This commit is contained in:
Gauthier Roebroeck 2025-08-11 11:34:58 +08:00
parent 2f7b4b1137
commit 13972ab4a1
29 changed files with 1140 additions and 7 deletions

View file

@ -64,7 +64,7 @@ export default defineConfigWithVueTs(
'error',
{
idInterpolationPattern: '[sha512:contenthash:base64:6]',
idWhitelist: ['app.*'],
idWhitelist: ['app.*', 'enum.*'],
},
],
},

View file

@ -0,0 +1,4 @@
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
}

View 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

View file

@ -0,0 +1,22 @@
import { defineQueryOptions } from '@pinia/colada'
import { komgaClient } from '@/api/komga-client'
export const QUERY_KEYS_BOOKS = {
root: ['books'] as const,
byId: (bookId: string) => [...QUERY_KEYS_BOOKS.root, bookId] as const,
}
export const bookDetailQuery = defineQueryOptions(({ bookId }: { bookId: string }) => ({
key: QUERY_KEYS_BOOKS.byId(bookId),
query: () =>
komgaClient
.GET('/api/v1/books/{bookId}', {
params: {
path: {
bookId: bookId,
},
},
})
// unwrap the openapi-fetch structure on success
.then((res) => res.data),
}))

View file

@ -0,0 +1,18 @@
import { defineQueryOptions } from '@pinia/colada'
import { komgaClient } from '@/api/komga-client'
export const historyQuery = defineQueryOptions(
({ page, size, sort }: { page?: number; size?: number; sort?: string[] }) => ({
key: ['history', { page: page, size: size, sort: sort }],
query: () =>
komgaClient
.GET('/api/v1/history', {
params: {
query: { page: page, size: size, sort: sort },
},
})
// unwrap the openapi-fetch structure on success
.then((res) => res.data),
placeholderData: (previousData: any) => previousData, // eslint-disable-line @typescript-eslint/no-explicit-any
}),
)

View file

@ -0,0 +1,22 @@
import { defineQueryOptions } from '@pinia/colada'
import { komgaClient } from '@/api/komga-client'
export const QUERY_KEYS_SERIES = {
root: ['series'] as const,
byId: (seriesId: string) => [...QUERY_KEYS_SERIES.root, seriesId] as const,
}
export const seriesDetailQuery = defineQueryOptions(({ seriesId }: { seriesId: string }) => ({
key: QUERY_KEYS_SERIES.byId(seriesId),
query: () =>
komgaClient
.GET('/api/v1/series/{seriesId}', {
params: {
path: {
seriesId: seriesId,
},
},
})
// unwrap the openapi-fetch structure on success
.then((res) => res.data),
}))

View file

@ -22,6 +22,15 @@ 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']
FragmentHistoryBookFileDeleted: typeof import('./fragments/fragment/history/BookFileDeleted.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']
FragmentHistoryExpandDuplicatePageDeleted: typeof import('./fragments/fragment/history/expand/DuplicatePageDeleted.vue')['default']
FragmentHistoryExpandFileDeleted: typeof import('./fragments/fragment/history/ExpandFileDeleted.vue')['default']
FragmentHistoryExpandSeriesFolderDeleted: typeof import('./fragments/fragment/history/expand/SeriesFolderDeleted.vue')['default']
FragmentHistoryExpandTable: typeof import('./fragments/fragment/history/expand/Table.vue')['default']
FragmentHistoryTable: typeof import('./fragments/fragment/history/Table.vue')['default']
FragmentLocaleSelector: typeof import('./fragments/fragment/LocaleSelector.vue')['default']
FragmentSnackQueue: typeof import('./fragments/fragment/SnackQueue.vue')['default']
FragmentThemeSelector: typeof import('./fragments/fragment/ThemeSelector.vue')['default']

View file

@ -4,8 +4,8 @@ import * as Stories from './Table.stories';
<Meta of={Stories} />
# ApikeyTable
# FragmentApikeyTable
Table showing API keys.
[Server DataTable](https://vuetifyjs.com/en/components/data-tables/server-side-tables/) showing API keys.
<Canvas of={Stories.Default} />

View file

@ -0,0 +1,13 @@
import { Canvas, Meta } from '@storybook/addon-docs/blocks';
import * as Stories from './Table.stories';
<Meta of={Stories} />
# FragmentHistoryTable
[Server DataTable](https://vuetifyjs.com/en/components/data-tables/server-side-tables/) showing historical events:
- rows can be expanded to display event details
- Series and Book column will show the actual series/book name if it exists
<Canvas of={Stories.Default} />

View file

@ -0,0 +1,58 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import Table from './Table.vue'
import { delay, http } from 'msw'
import { response401Unauthorized } from '@/mocks/api/handlers'
import { httpTyped } from '@/mocks/api/httpTyped'
import { mockPage } from '@/mocks/api/pageable'
import { PageRequest } from '@/types/PageRequest'
const meta = {
component: Table,
render: (args: object) => ({
components: { Table },
setup() {
return { args }
},
template: '<Table v-bind="args" />',
}),
parameters: {
// More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
},
args: {},
} satisfies Meta<typeof Table>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {},
}
export const Loading: Story = {
parameters: {
msw: {
handlers: [http.all('*', async () => await delay(5_000))],
},
},
}
export const NoData: Story = {
parameters: {
msw: {
handlers: [
httpTyped.get('/api/v1/history', ({ response }) =>
response(200).json(mockPage([], new PageRequest())),
),
],
},
},
}
export const Error: Story = {
parameters: {
msw: {
handlers: [http.all('*', response401Unauthorized)],
},
},
}

View file

@ -0,0 +1,232 @@
<template>
<v-data-table-server
v-model:sort-by="sortBy"
:loading="isLoading"
:items="data?.content"
:items-length="data?.totalElements || 0"
:items-per-page-options="[
{ value: 10, title: '10' },
{ value: 25, title: '25' },
{ value: 50, title: '50' },
{ value: 100, title: '100' },
]"
:headers="headers"
item-value="timestamp"
fixed-header
fixed-footer
multi-sort
mobile-breakpoint="md"
show-expand
style="height: 100%"
@update:options="updateOptions"
>
<template #top>
<v-toolbar flat>
<v-toolbar-title>
<v-icon
color="medium-emphasis"
icon="i-mdi:history"
size="x-small"
start
/>
{{
$formatMessage({
description: 'History table global header',
defaultMessage: 'History',
id: '7z91nm',
})
}}
</v-toolbar-title>
</v-toolbar>
</template>
<template #no-data>
<EmptyStateNetworkError v-if="error" />
<template v-else>
{{
$formatMessage({
description: 'History table: shown when table has no data',
defaultMessage: 'No recent events',
id: 'Ym48fa',
})
}}
</template>
</template>
<template #[`item.type`]="{ value }">
<div class="d-flex flex-row-reverse flex-md-row ga-2">
<v-icon :icon="getIcon(value)" />
<span>{{ $formatMessage(getEventMessage(value)) }}</span>
</div>
</template>
<template #[`item.seriesId`]="{ value: seriesId }">
{{ seriesCache[seriesId] || seriesId }}
</template>
<template #[`item.bookId`]="{ value: bookId }">
{{ booksCache[bookId] || bookId }}
</template>
<template #[`item.timestamp`]="{ value }">
{{ $formatDate(value, { dateStyle: 'medium', timeStyle: 'short' }) }}
</template>
<template #expanded-row="{ columns, item }">
<tr>
<td
:colspan="columns.length"
class="py-2 px-1 px-md-4"
>
<component
:is="getExpandedComponent(item.type)"
:event="item"
/>
</td>
</tr>
</template>
</v-data-table-server>
</template>
<script setup lang="ts">
import { useIntl } from 'vue-intl'
import { PageRequest, type SortItem } from '@/types/PageRequest'
import { useQuery } from '@pinia/colada'
import { historyQuery } from '@/colada/history'
import FragmentHistoryExpandBookFileDeleted from '@/fragments/fragment/history/expand/BookFileDeleted.vue'
import FragmentHistoryExpandBookImported from '@/fragments/fragment/history/expand/BookImported.vue'
import FragmentHistoryExpandBookConverted from '@/fragments/fragment/history/expand/BookConverted.vue'
import FragmentHistoryExpandDuplicatePageDeleted from '@/fragments/fragment/history/expand/DuplicatePageDeleted.vue'
import FragmentHistoryExpandSeriesFolderDeleted from '@/fragments/fragment/history/expand/SeriesFolderDeleted.vue'
import { historicalEventMessages } from '@/utils/i18n/enum/historical-event'
import type { MessageDescriptor } from '@formatjs/intl/src/types'
import { seriesDetailQuery } from '@/colada/series'
import { bookDetailQuery } from '@/colada/books'
const intl = useIntl()
const sortBy = ref<SortItem[]>([{ key: 'timestamp', order: 'desc' }])
const headers = [
{
title: intl.formatMessage({
description: 'History Table table header: Event type',
defaultMessage: 'Type',
id: '6nCIPn',
}),
key: 'type',
},
{
title: intl.formatMessage({
description: 'History Table table header: Series',
defaultMessage: 'Series',
id: 'pR3VdQ',
}),
key: 'seriesId',
},
{
title: intl.formatMessage({
description: 'History Table table header: Book',
defaultMessage: 'Book',
id: 'zqAxRE',
}),
key: 'bookId',
},
{
title: intl.formatMessage({
description: 'History Table table header: Date Time',
defaultMessage: 'Date Time',
id: 'HMbMJF',
}),
key: 'timestamp',
},
]
const pageRequest = ref<PageRequest>(new PageRequest())
const { data, isLoading, error } = useQuery(historyQuery, () => ({ ...pageRequest.value }))
function updateOptions({
page,
itemsPerPage,
sortBy,
}: {
page: number
itemsPerPage: number
sortBy: SortItem[]
}) {
pageRequest.value = PageRequest.FromVuetify(page - 1, itemsPerPage, sortBy)
}
function getIcon(eventType: string): string {
switch (eventType) {
case 'BookFileDeleted':
return 'i-mdi:file-remove'
case 'SeriesFolderDeleted':
return 'i-mdi:folder-remove'
case 'DuplicatePageDeleted':
return 'i-mdi:book-minus'
case 'BookConverted':
return 'i-mdi:archive-refresh'
case 'BookImported':
return 'i-mdi:import'
default:
return ''
}
}
function getEventMessage(eventType: string): MessageDescriptor {
return (
historicalEventMessages[eventType] || {
id: eventType,
defaultMessage: eventType,
}
)
}
function getExpandedComponent(eventType: string): Component | null {
switch (eventType) {
case 'BookFileDeleted':
return markRaw(FragmentHistoryExpandBookFileDeleted)
case 'SeriesFolderDeleted':
return markRaw(FragmentHistoryExpandSeriesFolderDeleted)
case 'DuplicatePageDeleted':
return markRaw(FragmentHistoryExpandDuplicatePageDeleted)
case 'BookConverted':
return markRaw(FragmentHistoryExpandBookConverted)
case 'BookImported':
return markRaw(FragmentHistoryExpandBookImported)
default:
return null
}
}
const seriesCache = reactive<Record<string, string>>({})
const seriesCacheNotFound = reactive<string[]>([])
const booksCache = reactive<Record<string, string>>({})
const booksCacheNotFound = reactive<string[]>([])
watch(data, (data) => {
for (const seriesId of new Set(data?.content?.map((s) => s.seriesId))) {
if (seriesId && !seriesCacheNotFound.includes(seriesId) && !(seriesId in seriesCache)) {
const { refresh } = useQuery(seriesDetailQuery, () => ({ seriesId: seriesId }))
refresh(true)
.then(({ data }) => {
if (data) seriesCache[seriesId] = data.metadata.title
})
.catch(() => seriesCacheNotFound.push(seriesId))
}
}
for (const bookId of new Set(data?.content?.map((s) => s.bookId))) {
if (bookId && !booksCacheNotFound.includes(bookId) && !(bookId in booksCache)) {
const { refresh } = useQuery(bookDetailQuery, () => ({ bookId: bookId }))
refresh(true)
.then(({ data }) => {
if (data) booksCache[bookId] = data.metadata.title
})
.catch(() => booksCacheNotFound.push(bookId))
}
}
})
</script>

View file

@ -0,0 +1,28 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import BookConverted from './BookConverted.vue'
import { historyBookConverted } from '@/mocks/api/handlers/history'
const meta = {
component: BookConverted,
render: (args: object) => ({
components: { BookConverted },
setup() {
return { args }
},
template: '<BookConverted v-bind="args" />',
}),
parameters: {
// More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
},
args: {},
} satisfies Meta<typeof BookConverted>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
event: historyBookConverted,
},
}

View file

@ -0,0 +1,16 @@
<template>
<fragment-history-expand-table :rows="rows" />
</template>
<script setup lang="ts">
import type { components } from '@/generated/openapi/komga'
const { event } = defineProps<{
event: components['schemas']['HistoricalEventDto']
}>()
const rows = computed(() => [
{ header: 'Before', value: event.properties['former file'] },
{ header: 'After', value: event.properties.name },
])
</script>

View file

@ -0,0 +1,28 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import BookFileDeleted from './BookFileDeleted.vue'
import { historyBookFileDeleted } from '@/mocks/api/handlers/history'
const meta = {
component: BookFileDeleted,
render: (args: object) => ({
components: { BookFileDeleted },
setup() {
return { args }
},
template: '<BookFileDeleted v-bind="args" />',
}),
parameters: {
// More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
},
args: {},
} satisfies Meta<typeof BookFileDeleted>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
event: historyBookFileDeleted,
},
}

View file

@ -0,0 +1,16 @@
<template>
<fragment-history-expand-table :rows="rows" />
</template>
<script setup lang="ts">
import type { components } from '@/generated/openapi/komga'
const { event } = defineProps<{
event: components['schemas']['HistoricalEventDto']
}>()
const rows = computed(() => [
{ header: 'File', value: event.properties.name },
{ header: 'Reason', value: event.properties.reason },
])
</script>

View file

@ -0,0 +1,28 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import BookImported from './BookImported.vue'
import { historyBookImported } from '@/mocks/api/handlers/history'
const meta = {
component: BookImported,
render: (args: object) => ({
components: { BookImported },
setup() {
return { args }
},
template: '<BookImported v-bind="args" />',
}),
parameters: {
// More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
},
args: {},
} satisfies Meta<typeof BookImported>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
event: historyBookImported,
},
}

View file

@ -0,0 +1,17 @@
<template>
<fragment-history-expand-table :rows="rows" />
</template>
<script setup lang="ts">
import type { components } from '@/generated/openapi/komga'
const { event } = defineProps<{
event: components['schemas']['HistoricalEventDto']
}>()
const rows = computed(() => [
{ header: 'File', value: event.properties.name },
{ header: 'Source file', value: event.properties.source },
{ header: 'Upgrade', value: event.properties.upgrade },
])
</script>

View file

@ -0,0 +1,28 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import DuplicatePageDeleted from './DuplicatePageDeleted.vue'
import { historyDuplicatePageDeleted } from '@/mocks/api/handlers/history'
const meta = {
component: DuplicatePageDeleted,
render: (args: object) => ({
components: { DuplicatePageDeleted },
setup() {
return { args }
},
template: '<DuplicatePageDeleted v-bind="args" />',
}),
parameters: {
// More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
},
args: {},
} satisfies Meta<typeof DuplicatePageDeleted>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
event: historyDuplicatePageDeleted,
},
}

View file

@ -0,0 +1,72 @@
<template>
<v-sheet
rounded="lg"
border
>
<div class="d-flex flex-column flex-md-row align-center">
<v-table
density="compact"
class="my-2"
>
<tbody>
<tr>
<th>Book file</th>
<td>{{ event.properties.name }}</td>
</tr>
</tbody>
<tbody>
<tr>
<th>Page file name</th>
<td>{{ event.properties['page file name'] }}</td>
</tr>
</tbody>
<tbody>
<tr>
<th>Page number</th>
<td>{{ event.properties['page number'] }}</td>
</tr>
</tbody>
<tbody>
<tr>
<th>Page file size</th>
<td>{{ event.properties['page file size'] }}</td>
</tr>
</tbody>
<tbody>
<tr>
<th>Page media type</th>
<td>{{ event.properties['page media type'] }}</td>
</tr>
</tbody>
</v-table>
<!-- TODO: add link to known page hash -->
<v-img
width="200"
height="200"
contain
:src="pageHashKnownThumbnailUrl(event.properties['page file hash'])"
lazy-src="@/assets/cover.svg"
class="my-2"
>
<template #placeholder>
<div class="d-flex align-center justify-center fill-height">
<v-progress-circular
color="grey"
indeterminate
/>
</div>
</template>
</v-img>
</div>
</v-sheet>
</template>
<script setup lang="ts">
import type { components } from '@/generated/openapi/komga'
import { pageHashKnownThumbnailUrl } from '@/api/images'
const { event } = defineProps<{
event: components['schemas']['HistoricalEventDto']
}>()
</script>

View file

@ -0,0 +1,28 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import SeriesFolderDeleted from './SeriesFolderDeleted.vue'
import { historySeriesFolderDeleted } from '@/mocks/api/handlers/history'
const meta = {
component: SeriesFolderDeleted,
render: (args: object) => ({
components: { SeriesFolderDeleted },
setup() {
return { args }
},
template: '<SeriesFolderDeleted v-bind="args" />',
}),
parameters: {
// More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
},
args: {},
} satisfies Meta<typeof SeriesFolderDeleted>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
event: historySeriesFolderDeleted,
},
}

View file

@ -0,0 +1,16 @@
<template>
<fragment-history-expand-table :rows="rows" />
</template>
<script setup lang="ts">
import type { components } from '@/generated/openapi/komga'
const { event } = defineProps<{
event: components['schemas']['HistoricalEventDto']
}>()
const rows = computed(() => [
{ header: 'Folder', value: event.properties.name },
{ header: 'Reason', value: event.properties.reason },
])
</script>

View file

@ -0,0 +1,24 @@
<template>
<v-sheet
rounded="lg"
border
>
<v-table density="compact">
<tbody
v-for="row in rows"
:key="row.header"
>
<tr>
<th>{{ row.header }}</th>
<td>{{ row.value }}</td>
</tr>
</tbody>
</v-table>
</v-sheet>
</template>
<script setup lang="ts">
const { rows } = defineProps<{
rows: { header: string; value?: string }[]
}>()
</script>

View file

@ -3814,6 +3814,10 @@ export interface components {
SettingsUpdateDto: {
deleteEmptyCollections?: boolean;
deleteEmptyReadLists?: boolean;
/**
* @deprecated
* @description Will be removed in a future version
*/
kepubifyPath?: string;
/** Format: int32 */
koboPort?: number;
@ -4641,6 +4645,13 @@ export interface operations {
"*/*": components["schemas"]["ValidationErrorResponse"];
};
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
bookAnalyze: {
@ -8133,6 +8144,13 @@ export interface operations {
"*/*": components["schemas"]["ValidationErrorResponse"];
};
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
seriesAnalyze: {

View file

@ -7,18 +7,26 @@ import { referentialHandlers } from '@/mocks/api/handlers/referential'
import { usersHandlers } from '@/mocks/api/handlers/users'
import { settingsHandlers } from '@/mocks/api/handlers/settings'
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'
export const handlers = [
...actuatorHandlers,
...announcementHandlers,
...booksHandlers,
...claimHandlers,
...historyHandlers,
...librariesHandlers,
...referentialHandlers,
...releasesHandlers,
...seriesHandlers,
...settingsHandlers,
...usersHandlers,
]
export const response404NotFound = () => HttpResponse.json({ error: 'NotFound' }, { status: 404 })
export const response401Unauthorized = () =>
HttpResponse.json({ error: 'Unauthorized' }, { status: 401 })

View file

@ -0,0 +1,67 @@
import { httpTyped } from '@/mocks/api/httpTyped'
const book = {
id: '05RKH8CC8B4RW',
seriesId: '57',
seriesTitle: 'Super Duck',
libraryId: '56',
name: 'Super_Duck_001__MLJ___Fall_1944___c2c___titansfan_editor_',
url: '/books/Super Duck/Super_Duck_001__MLJ___Fall_1944___c2c___titansfan_editor_.cbz',
number: 1,
created: new Date('2021-07-27T13:33:52Z'),
lastModified: new Date('2023-07-22T11:14:47Z'),
fileLastModified: new Date('2020-03-05T03:20:12Z'),
sizeBytes: 82920938,
size: '79.1 MiB',
media: {
status: 'READY',
mediaType: 'application/zip',
pagesCount: 53,
comment: '',
epubDivinaCompatible: false,
epubIsKepub: false,
mediaProfile: 'DIVINA',
},
metadata: {
title: 'Super Duck 001',
titleLock: true,
summary: '',
summaryLock: false,
number: '1',
numberLock: false,
numberSort: 1.0,
numberSortLock: false,
releaseDateLock: false,
authors: [],
authorsLock: true,
tags: [],
tagsLock: false,
isbn: '',
isbnLock: false,
links: [],
linksLock: false,
created: new Date('2021-07-27T13:33:52Z'),
lastModified: new Date('2023-03-15T09:50:54Z'),
},
readProgress: {
page: 53,
completed: true,
readDate: new Date('2025-02-19T11:29:25Z'),
created: new Date('2025-02-19T11:29:25Z'),
lastModified: new Date('2025-02-19T11:29:25Z'),
deviceId: '',
deviceName: '',
},
deleted: false,
fileHash: '7dc12ae431a8847b7f49918745254b0b',
oneshot: false,
}
export const booksHandlers = [
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}` } }),
)
}),
]

View file

@ -0,0 +1,118 @@
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'
export const historyBookImported = {
type: 'BookImported',
timestamp: new Date('2025-08-07T09:36:22.596'),
bookId: 'B5',
seriesId: 'S1',
properties: {
name: '/data/Books/Donjon Parade/Donjon Parade - 09 - Nécomancien pour de faux.cbz',
source: '/data/Import/Donjon Parade - 9 - Nécomancien pour de faux (Sfar-Trandheim-Delaf).cbz',
upgrade: 'No',
},
}
export const historySeriesFolderDeleted = {
type: 'SeriesFolderDeleted',
timestamp: new Date('2025-08-05T21:33:56.452'),
seriesId: '404',
properties: {
name: '/data/Books/One Piece',
reason: 'Folder was deleted because it was empty',
},
}
export const historyBookFileDeleted = {
type: 'BookFileDeleted',
timestamp: new Date('2025-08-05T21:33:56.445'),
bookId: '404',
seriesId: 'S2',
properties: {
name: '/data/Books/One Piece/Volume 1.cbz',
reason: 'File was deleted by user request',
},
}
export const historyBookImportedForUpgrade = {
type: 'BookImported',
timestamp: new Date('2025-07-28T17:52:14.126'),
bookId: 'B15',
seriesId: 'S3',
properties: {
name: '/data/Lastman/Lastman - T12.cbz',
source: '/data/Import/Lastman/Lastman_-_Nouvelle_Édition_T12.cbz',
upgrade: 'Yes',
},
}
export const historyBookFileDeletedForUpgrade = {
type: 'BookFileDeleted',
timestamp: new Date('2025-07-28T17:52:14.016'),
bookId: 'b14',
seriesId: 'S3',
properties: {
name: '/data/Lastman/Lastman - 12.cbz',
reason: 'File was deleted to import an upgrade',
},
}
export const historyDuplicatePageDeleted = {
type: 'DuplicatePageDeleted',
timestamp: new Date('2025-07-26T21:33:18.809'),
bookId: 'B5',
seriesId: 'S2',
properties: {
name: '/data/Lastman/Volume 6.cbz',
'page file hash': 'e3f8f3814609645d6ffc6f6f2c4c65aa',
'page file name': 'credits.jpg',
'page file size': '1204289',
'page media type': 'image/jpeg',
'page number': '34',
},
}
export const historyBookConverted = {
type: 'BookConverted',
timestamp: new Date('2025-07-07T17:33:39.545'),
bookId: 'B25',
seriesId: 'S21',
properties: {
'former file': '/data/Usagi Yojimbo/Usagi Yojimbo (Book 40) - The Crow (2025).cbr',
name: '/data/Usagi Yojimbo/Usagi Yojimbo (Book 40) - The Crow (2025).cbz',
},
}
const history = [
historyBookImported,
historySeriesFolderDeleted,
historyBookFileDeleted,
historyBookFileDeletedForUpgrade,
historyBookImportedForUpgrade,
historyDuplicatePageDeleted,
historyBookConverted,
]
export const historyHandlers = [
httpTyped.get('/api/v1/history', ({ query, response }) =>
response(200).json(
mockPage(
history,
new PageRequest(Number(query.get('page')), Number(query.get('size')), query.getAll('sort')),
),
),
),
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())
return HttpResponse.arrayBuffer(buffer, {
headers: {
'content-type': 'image/svg+xml',
},
})
}),
]

View file

@ -0,0 +1,66 @@
import { httpTyped } from '@/mocks/api/httpTyped'
const series = {
id: '57',
libraryId: '56',
name: 'Super Duck',
url: '/books/Super Duck',
created: new Date('2020-07-05T12:11:50Z'),
lastModified: new Date('2021-07-27T13:33:54Z'),
fileLastModified: new Date('2020-03-05T15:24:59Z'),
booksCount: 5,
booksReadCount: 1,
booksUnreadCount: 3,
booksInProgressCount: 1,
metadata: {
status: 'ENDED',
statusLock: true,
title: 'Super Duck',
titleLock: false,
titleSort: 'Super Duck',
titleSortLock: false,
summary:
'Super Duck is the greatest hero of New Duck City. Brash, arrogant and virtually unbeatable, hes defeated all threats to the city and routinely foils the schemes of his greatest rival, criminal genius and corporate billionaire Dapper Duck.\n\nHowever, when Dapper takes to the streets with a giant mechanical monster, will Super Duck prove once more to be the heroic champion everyone knows and loves or is his goose finally cooked?',
summaryLock: true,
readingDirection: 'LEFT_TO_RIGHT',
readingDirectionLock: true,
publisher: 'Archie',
publisherLock: true,
ageRatingLock: false,
language: 'en',
languageLock: true,
genres: ['humor'],
genresLock: true,
tags: [],
tagsLock: false,
totalBookCount: 94,
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: [],
summary: '',
summaryNumber: '',
created: new Date('2021-01-11T09:59:23Z'),
lastModified: new Date('2023-07-22T11:14:45Z'),
},
deleted: false,
oneshot: false,
}
export const seriesHandlers = [
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}` } }),
)
}),
]

View file

@ -1,10 +1,13 @@
<template>
<h1>History</h1>
<v-container
fluid
class="pa-0 pa-sm-4 h-100 h-sm-auto"
>
<fragment-history-table />
</v-container>
</template>
<script lang="ts" setup>
//
</script>
<script lang="ts" setup></script>
<route lang="yaml">
meta:

View file

@ -0,0 +1,30 @@
import { defineMessage, type MessageDescriptor } from 'vue-intl'
// the keys are the event names for easier lookup
export const historicalEventMessages: Record<string, MessageDescriptor> = {
BookFileDeleted: defineMessage({
description: 'Historical event: BookFileDeleted',
defaultMessage: 'Book File Deleted',
id: 'enum.historicalEvent.BookFileDeleted',
}),
SeriesFolderDeleted: defineMessage({
description: 'Historical event: SeriesFolderDeleted',
defaultMessage: 'Series Folder Deleted',
id: 'enum.historicalEvent.SeriesFolderDeleted',
}),
DuplicatePageDeleted: defineMessage({
description: 'Historical event: DuplicatePageDeleted',
defaultMessage: 'Duplicate Page Deleted',
id: 'enum.historicalEvent.DuplicatePageDeleted',
}),
BookConverted: defineMessage({
description: 'Historical event: ',
defaultMessage: 'Book Converted',
id: 'enum.historicalEvent.BookConverted',
}),
BookImported: defineMessage({
description: 'Historical event: BookImported',
defaultMessage: 'Book Imported',
id: 'enum.historicalEvent.BookImported',
}),
}