feat(webui): adapt card content depending on context

closes #679
This commit is contained in:
Gauthier Roebroeck 2022-02-11 17:06:11 +08:00
parent 55df968651
commit 35bf05eb39
14 changed files with 210 additions and 71 deletions

View file

@ -26,6 +26,7 @@
<item-card
class="item-card"
:item="item"
:item-context="itemContext"
:width="itemWidth"
:selected="active"
:no-link="draggable || deletable"
@ -76,6 +77,7 @@ import ItemCard from '@/components/ItemCard.vue'
import {computeCardWidth} from '@/functions/grid-utilities'
import Vue from 'vue'
import draggable from 'vuedraggable'
import {ItemContext} from '@/types/items'
export default Vue.extend({
name: 'ItemBrowser',
@ -85,6 +87,10 @@ export default Vue.extend({
type: Array,
required: true,
},
itemContext: {
type: Array as () => ItemContext[],
default: () => [],
},
fixedItemWidth: {
type: Number,
required: false,

View file

@ -101,14 +101,28 @@
<!-- Description-->
<template v-if="!thumbnailOnly">
<router-link :to="to" class="link-underline">
<router-link v-if="!Array.isArray(title)" :to="title.to" class="link-underline" @click.native="$event.stopImmediatePropagation()">
<v-card-subtitle
v-line-clamp="2"
v-bind="subtitleProps"
v-html="title"
>
</v-card-subtitle>
v-html="title.title"
/>
</router-link>
<template v-if="Array.isArray(title)">
<v-card-subtitle
v-bind="subtitleProps"
>
<router-link
v-for="(t, i) in title"
:key="i"
:to="t.to"
@click.native="$event.stopImmediatePropagation()"
class="link-underline text-truncate"
v-html="t.title"
style="display: block"
/>
</v-card-subtitle>
</template>
<v-card-text class="px-2" v-html="body">
</v-card-text>
</template>
@ -123,17 +137,21 @@ import CollectionActionsMenu from '@/components/menus/CollectionActionsMenu.vue'
import SeriesActionsMenu from '@/components/menus/SeriesActionsMenu.vue'
import {getReadProgress, getReadProgressPercentage} from '@/functions/book-progress'
import {ReadStatus} from '@/types/enum-books'
import {createItem, Item, ItemTypes} from '@/types/items'
import {createItem, Item, ItemContext, ItemTitle, ItemTypes} from '@/types/items'
import Vue from 'vue'
import {RawLocation} from 'vue-router'
import ReadListActionsMenu from '@/components/menus/ReadListActionsMenu.vue'
import {BookDto} from '@/types/komga-books'
import {SeriesDto} from '@/types/komga-series'
import {
THUMBNAILBOOK_ADDED, THUMBNAILBOOK_DELETED,
THUMBNAILCOLLECTION_ADDED, THUMBNAILCOLLECTION_DELETED,
THUMBNAILREADLIST_ADDED, THUMBNAILREADLIST_DELETED,
THUMBNAILSERIES_ADDED, THUMBNAILSERIES_DELETED,
THUMBNAILBOOK_ADDED,
THUMBNAILBOOK_DELETED,
THUMBNAILCOLLECTION_ADDED,
THUMBNAILCOLLECTION_DELETED,
THUMBNAILREADLIST_ADDED,
THUMBNAILREADLIST_DELETED,
THUMBNAILSERIES_ADDED,
THUMBNAILSERIES_DELETED,
} from '@/types/events'
import {
ThumbnailBookSseDto,
@ -151,6 +169,10 @@ export default Vue.extend({
type: Object as () => BookDto | SeriesDto | CollectionDto | ReadListDto,
required: true,
},
itemContext: {
type: Array as () => ItemContext[],
default: () => [],
},
// hide the bottom part of the card
thumbnailOnly: {
type: Boolean,
@ -248,14 +270,14 @@ export default Vue.extend({
thumbnailUrl(): string {
return this.computedItem.thumbnailUrl() + this.thumbnailCacheBust
},
title(): string {
return this.computedItem.title()
title(): ItemTitle | ItemTitle[] {
return this.computedItem.title(this.itemContext)
},
subtitleProps(): Object {
return this.computedItem.subtitleProps()
},
body(): string {
return this.computedItem.body()
return this.computedItem.body(this.itemContext)
},
isInProgress(): boolean {
if (this.computedItem.type() === ItemTypes.BOOK) return getReadProgress(this.item as BookDto) === ReadStatus.IN_PROGRESS

View file

@ -13,6 +13,7 @@
</template>
<template v-slot:content>
<item-browser :items="readListsContent[index]"
:item-context="[ItemContext.SHOW_SERIES]"
nowrap
:selectable="false"
:action-menu="false"
@ -31,6 +32,7 @@ import ItemBrowser from '@/components/ItemBrowser.vue'
import Vue from 'vue'
import {BookDto} from '@/types/komga-books'
import {ContextOrigin} from '@/types/context'
import {ItemContext} from '@/types/items'
export default Vue.extend({
name: 'ReadListsExpansionPanels',
@ -46,6 +48,7 @@ export default Vue.extend({
},
data: () => {
return {
ItemContext,
readListPanel: undefined as number | undefined,
readListsContent: [[]] as any[],
}

View file

@ -43,7 +43,9 @@
"book_card": {
"error": "Error",
"unknown": "To be analyzed",
"unsupported": "Unsupported"
"unread": "Unread",
"unsupported": "Unsupported",
"no_release_date": "No release date"
},
"book_import": {
"button_browse": "Browse",

View file

@ -4,12 +4,27 @@ import {BookDto} from '@/types/komga-books'
import {SeriesDto} from '@/types/komga-series'
import i18n from '@/i18n'
import {MediaStatus} from '@/types/enum-books'
import {getFileSize} from '@/functions/file'
export enum ItemTypes {
BOOK, SERIES, COLLECTION, READLIST
}
export function createItem (item: BookDto | SeriesDto | CollectionDto | ReadListDto): Item<BookDto | SeriesDto | CollectionDto | ReadListDto> {
export enum ItemContext {
RELEASE_DATE = 'RELEASE_DATE',
DATE_ADDED = 'DATE_ADDED',
DATE_UPDATED = 'DATE_UPDATED',
FILE_SIZE = 'FILE_SIZE',
SHOW_SERIES = 'SHOW_SERIES',
READ_DATE = 'READ_DATE',
}
export interface ItemTitle {
title: string,
to: RawLocation,
}
export function createItem(item: BookDto | SeriesDto | CollectionDto | ReadListDto): Item<BookDto | SeriesDto | CollectionDto | ReadListDto> {
if ('bookIds' in item) {
return new ReadListItem(item)
} else if ('seriesIds' in item) {
@ -26,149 +41,202 @@ export function createItem (item: BookDto | SeriesDto | CollectionDto | ReadList
export abstract class Item<T> {
item: T
constructor (item: T) {
constructor(item: T) {
this.item = item
}
subtitleProps (): Object {
subtitleProps(): Object {
return {
style: 'word-break: normal !important; height: 4em',
'class': 'pa-2 pb-1 text--primary',
title: this.title(),
}
}
abstract type (): ItemTypes
abstract type(): ItemTypes
abstract thumbnailUrl (): string
abstract thumbnailUrl(): string
abstract title (): string
abstract title(context: ItemContext[]): ItemTitle | ItemTitle[]
abstract body (): string
abstract body(context: ItemContext[]): string
abstract to (): RawLocation
abstract to(): RawLocation
abstract fabTo (): RawLocation
abstract fabTo(): RawLocation
}
export class BookItem extends Item<BookDto> {
thumbnailUrl (): string {
thumbnailUrl(): string {
return bookThumbnailUrl(this.item.id)
}
type (): ItemTypes {
type(): ItemTypes {
return ItemTypes.BOOK
}
title (): string {
const m = this.item.metadata
return `${m.number} - ${m.title}`
}
body (): string {
if(this.item.deleted) return `<div class="text-truncate error--text">${i18n.t('common.unavailable')}</div>`
switch (this.item.media.status) {
case MediaStatus.ERROR: return `<div class="text-truncate error--text">${i18n.t('book_card.error')}</div>`
case MediaStatus.UNSUPPORTED: return `<div class="text-truncate warning--text">${i18n.t('book_card.unsupported')}</div>`
case MediaStatus.UNKNOWN: return `<div class="text-truncate">${i18n.t('book_card.unknown')}</div>`
default: return `<div class="text-truncate">${i18n.tc('common.pages_n', this.item.media.pagesCount)}</div>`
title(context: ItemContext[]): ItemTitle | ItemTitle[] {
if (context.includes(ItemContext.SHOW_SERIES))
return [
{
title: `${this.item.seriesTitle}`,
to: this.seriesTo(),
},
{
title: `${this.item.metadata.number} - ${this.item.metadata.title}`,
to: this.to(),
},
]
return {
title: `${this.item.metadata.number} - ${this.item.metadata.title}`,
to: this.to(),
}
}
to (): RawLocation {
body(context: ItemContext[] = []): string {
if (this.item.deleted) return `<div class="text-truncate error--text">${i18n.t('common.unavailable')}</div>`
switch (this.item.media.status) {
case MediaStatus.ERROR:
return `<div class="text-truncate error--text">${i18n.t('book_card.error')}</div>`
case MediaStatus.UNSUPPORTED:
return `<div class="text-truncate warning--text">${i18n.t('book_card.unsupported')}</div>`
case MediaStatus.UNKNOWN:
return `<div class="text-truncate">${i18n.t('book_card.unknown')}</div>`
default:
let text
if (context.includes(ItemContext.RELEASE_DATE))
text = this.item.metadata.releaseDate ? new Intl.DateTimeFormat(i18n.locale, {dateStyle: 'medium'} as Intl.DateTimeFormatOptions).format(new Date(this.item.metadata.releaseDate)) : i18n.t('book_card.no_release_date')
else if (context.includes(ItemContext.DATE_ADDED))
text = new Intl.DateTimeFormat(i18n.locale, {dateStyle: 'medium'} as Intl.DateTimeFormatOptions).format(new Date(this.item.created))
else if (context.includes(ItemContext.READ_DATE))
text = this.item.readProgress?.lastModified ? new Intl.DateTimeFormat(i18n.locale, {dateStyle: 'medium'} as Intl.DateTimeFormatOptions).format(new Date(this.item.readProgress?.lastModified)) : i18n.t('book_card.unread')
else if (context.includes(ItemContext.FILE_SIZE))
text = getFileSize(this.item.sizeBytes)
else
text = i18n.tc('common.pages_n', this.item.media.pagesCount)
return `<div class="text-truncate">${text}</div>`
}
}
to(): RawLocation {
return {
name: 'browse-book',
params: { bookId: this.item.id },
query: { context: this.item?.context?.origin, contextId: this.item?.context?.id },
params: {bookId: this.item.id},
query: {context: this.item?.context?.origin, contextId: this.item?.context?.id},
}
}
fabTo (): RawLocation {
seriesTo(): RawLocation {
return {
name: 'browse-series',
params: {seriesId: this.item.seriesId},
}
}
fabTo(): RawLocation {
return {
name: 'read-book',
params: { bookId: this.item.id },
query: { context: this.item?.context?.origin, contextId: this.item?.context?.id },
params: {bookId: this.item.id},
query: {context: this.item?.context?.origin, contextId: this.item?.context?.id},
}
}
}
export class SeriesItem extends Item<SeriesDto> {
thumbnailUrl (): string {
thumbnailUrl(): string {
return seriesThumbnailUrl(this.item.id)
}
type (): ItemTypes {
type(): ItemTypes {
return ItemTypes.SERIES
}
title (): string {
return this.item.metadata.title
title(context: ItemContext[]): ItemTitle | ItemTitle[] {
return {
title: this.item.metadata.title,
to: this.to(),
}
}
body (): string {
if(this.item.deleted) return `<div class="text-truncate error--text">${i18n.t('common.unavailable')}</div>`
return `<span>${i18n.tc('common.books_n', this.item.booksCount)}</span>`
body(context: ItemContext[] = []): string {
if (this.item.deleted) return `<div class="text-truncate error--text">${i18n.t('common.unavailable')}</div>`
let text
if (context.includes(ItemContext.RELEASE_DATE))
text = this.item.booksMetadata.releaseDate ? new Intl.DateTimeFormat(i18n.locale, {dateStyle: 'medium'} as Intl.DateTimeFormatOptions).format(new Date(this.item.booksMetadata.releaseDate)) : i18n.t('book_card.no_release_date')
else if (context.includes(ItemContext.DATE_ADDED))
text = new Intl.DateTimeFormat(i18n.locale, {dateStyle: 'medium'} as Intl.DateTimeFormatOptions).format(new Date(this.item.created))
else if (context.includes(ItemContext.DATE_UPDATED))
text = new Intl.DateTimeFormat(i18n.locale, {dateStyle: 'medium'} as Intl.DateTimeFormatOptions).format(new Date(this.item.lastModified))
else
text = i18n.tc('common.books_n', this.item.booksCount)
return `<div class="text-truncate">${text}</div>`
}
to (): RawLocation {
return { name: 'browse-series', params: { seriesId: this.item.id.toString() } }
to(): RawLocation {
return {name: 'browse-series', params: {seriesId: this.item.id.toString()}}
}
fabTo (): RawLocation {
fabTo(): RawLocation {
return undefined as unknown as RawLocation
}
}
export class CollectionItem extends Item<CollectionDto> {
thumbnailUrl (): string {
thumbnailUrl(): string {
return collectionThumbnailUrl(this.item.id)
}
type (): ItemTypes {
type(): ItemTypes {
return ItemTypes.COLLECTION
}
title (): string {
return this.item.name
title(context: ItemContext[]): ItemTitle | ItemTitle[] {
return {
title: this.item.name,
to: this.to(),
}
}
body (): string {
body(context: ItemContext[] = []): string {
const c = this.item.seriesIds.length
return `<span>${c} Series</span>`
}
to (): RawLocation {
return { name: 'browse-collection', params: { collectionId: this.item.id.toString() } }
to(): RawLocation {
return {name: 'browse-collection', params: {collectionId: this.item.id.toString()}}
}
fabTo (): RawLocation {
fabTo(): RawLocation {
return undefined as unknown as RawLocation
}
}
export class ReadListItem extends Item<ReadListDto> {
thumbnailUrl (): string {
thumbnailUrl(): string {
return readListThumbnailUrl(this.item.id)
}
type (): ItemTypes {
type(): ItemTypes {
return ItemTypes.READLIST
}
title (): string {
return this.item.name
title(context: ItemContext[]): ItemTitle | ItemTitle[] {
return {
title: this.item.name,
to: this.to(),
}
}
body (): string {
body(context: ItemContext[] = []): string {
const c = this.item.bookIds.length
return `<span>${c} Books</span>`
}
to (): RawLocation {
return { name: 'browse-readlist', params: { readListId: this.item.id.toString() } }
to(): RawLocation {
return {name: 'browse-readlist', params: {readListId: this.item.id.toString()}}
}
fabTo (): RawLocation {
fabTo(): RawLocation {
return undefined as unknown as RawLocation
}
}

View file

@ -4,10 +4,12 @@ import {CopyMode} from '@/types/enum-books'
export interface BookDto {
id: string,
seriesId: string,
seriesTitle: string,
libraryId: string,
name: string,
url: string,
number: number,
created: string,
lastModified: string,
sizeBytes: number,
size: string,

View file

@ -5,6 +5,7 @@ export interface SeriesDto {
libraryId: string,
name: string,
url: string,
created: string,
lastModified: string,
booksCount: number,
booksReadCount: number,

View file

@ -104,6 +104,7 @@
<item-browser
:items="series"
:item-context="itemContext"
:selected.sync="selectedSeries"
:edit-function="isAdmin ? editSingleSeries : undefined"
/>
@ -155,6 +156,7 @@ import {LibrarySseDto, ReadProgressSeriesSseDto, SeriesSseDto} from '@/types/kom
import {throttle} from 'lodash'
import AlphabeticalNavigation from '@/components/AlphabeticalNavigation.vue'
import {LibraryDto} from '@/types/komga-libraries'
import { ItemContext } from '@/types/items'
export default Vue.extend({
name: 'BrowseLibraries',
@ -262,6 +264,12 @@ export default Vue.extend({
next()
},
computed: {
itemContext(): ItemContext[] {
if(this.sortActive.key === 'booksMetadata.releaseDate') return [ItemContext.RELEASE_DATE]
if(this.sortActive.key === 'createdDate') return [ItemContext.DATE_ADDED]
if(this.sortActive.key === 'lastModifiedDate') return [ItemContext.DATE_UPDATED]
return []
},
sortOptions(): SortOption[] {
return [
{name: this.$t('sort.name').toString(), key: 'metadata.titleSort'},

View file

@ -110,6 +110,7 @@
<item-browser
:items.sync="books"
:item-context="[ItemContext.SHOW_SERIES]"
:selected.sync="selectedBooks"
:edit-function="editSingleBook"
:draggable="editElements"
@ -149,6 +150,7 @@ import {LibraryDto} from '@/types/komga-libraries'
import {mergeFilterParams, toNameValue} from '@/functions/filter'
import {Location} from 'vue-router'
import {readListFileUrl} from '@/functions/urls'
import {ItemContext} from '@/types/items'
export default Vue.extend({
name: 'BrowseReadList',
@ -164,6 +166,7 @@ export default Vue.extend({
},
data: () => {
return {
ItemContext,
readList: undefined as ReadListDto | undefined,
books: [] as BookDto[],
booksCopy: [] as BookDto[],

View file

@ -391,6 +391,7 @@
/>
<item-browser :items="books"
:item-context="itemContext"
:selected.sync="selectedBooks"
:edit-function="isAdmin ? editSingleBook : undefined"
/>
@ -450,6 +451,7 @@ import VueHorizontal from 'vue-horizontal'
import RtlIcon from '@/components/RtlIcon.vue'
import {throttle} from 'lodash'
import {BookSseDto, CollectionSseDto, LibrarySseDto, ReadProgressSseDto, SeriesSseDto} from '@/types/komga-sse'
import {ItemContext} from '@/types/items'
const tags = require('language-tags')
@ -496,6 +498,12 @@ export default Vue.extend({
}
},
computed: {
itemContext(): ItemContext[] {
if(this.sortActive.key === 'metadata.releaseDate') return [ItemContext.RELEASE_DATE]
if(this.sortActive.key === 'createdDate') return [ItemContext.DATE_ADDED]
if(this.sortActive.key === 'fileSize') return [ItemContext.FILE_SIZE]
return []
},
sortOptions(): SortOption[] {
return [
{name: this.$t('sort.number').toString(), key: 'metadata.numberSort'},

View file

@ -60,6 +60,7 @@
</template>
<template v-slot:content>
<item-browser :items="loaderInProgressBooks.items"
:item-context="[ItemContext.SHOW_SERIES]"
nowrap
:edit-function="isAdmin ? singleEditBook : undefined"
:selected.sync="selectedBooks"
@ -80,6 +81,7 @@
</template>
<template v-slot:content>
<item-browser :items="loaderOnDeckBooks.items"
:item-context="[ItemContext.SHOW_SERIES]"
nowrap
:edit-function="isAdmin ? singleEditBook : undefined"
:selected.sync="selectedBooks"
@ -100,6 +102,7 @@
</template>
<template v-slot:content>
<item-browser :items="loaderRecentlyReleasedBooks.items"
:item-context="[ItemContext.RELEASE_DATE, ItemContext.SHOW_SERIES]"
nowrap
:edit-function="isAdmin ? singleEditBook : undefined"
:selected.sync="selectedBooks"
@ -120,6 +123,7 @@
</template>
<template v-slot:content>
<item-browser :items="loaderLatestBooks.items"
:item-context="[ItemContext.SHOW_SERIES]"
nowrap
:edit-function="isAdmin ? singleEditBook : undefined"
:selected.sync="selectedBooks"
@ -180,6 +184,7 @@
</template>
<template v-slot:content>
<item-browser :items="loaderRecentlyReadBooks.items"
:item-context="[ItemContext.SHOW_SERIES, ItemContext.READ_DATE]"
nowrap
:edit-function="isAdmin ? singleEditBook : undefined"
:selected.sync="selectedBooks"
@ -222,6 +227,7 @@ import {subMonths} from 'date-fns'
import {BookSseDto, ReadProgressSeriesSseDto, ReadProgressSseDto, SeriesSseDto} from '@/types/komga-sse'
import {LibraryDto} from '@/types/komga-libraries'
import {PageLoader} from '@/types/pageLoader'
import {ItemContext} from '@/types/items'
export default Vue.extend({
name: 'DashboardView',
@ -236,6 +242,7 @@ export default Vue.extend({
},
data: () => {
return {
ItemContext,
loading: false,
library: undefined as LibraryDto | undefined,
loaderNewSeries: undefined as unknown as PageLoader<SeriesDto>,

View file

@ -84,6 +84,7 @@
</template>
<template v-slot:content>
<item-browser :items="loaderBooks.items"
:item-context="[ItemContext.SHOW_SERIES]"
nowrap
:edit-function="isAdmin ? singleEditBook : undefined"
:selected.sync="selectedBooks"
@ -173,6 +174,7 @@ import {
} from '@/types/komga-sse'
import {throttle} from 'lodash'
import {PageLoader} from '@/types/pageLoader'
import {ItemContext} from '@/types/items'
export default Vue.extend({
name: 'SearchView',
@ -185,6 +187,7 @@ export default Vue.extend({
},
data: () => {
return {
ItemContext,
loaderSeries: undefined as unknown as PageLoader<SeriesDto>,
loaderBooks: undefined as unknown as PageLoader<BookDto>,
loaderCollections: undefined as unknown as PageLoader<CollectionDto>,

View file

@ -47,6 +47,7 @@ class BookDtoDao(
private val r = Tables.READ_PROGRESS
private val a = Tables.BOOK_METADATA_AUTHOR
private val s = Tables.SERIES
private val sd = Tables.SERIES_METADATA
private val rlb = Tables.READLIST_BOOK
private val bt = Tables.BOOK_METADATA_TAG
private val bl = Tables.BOOK_METADATA_LINK
@ -282,11 +283,13 @@ class BookDtoDao(
*m.fields(),
*d.fields(),
*r.fields(),
sd.TITLE,
).apply { if (joinConditions.selectReadListNumber) select(rlb.NUMBER) }
.from(b)
.leftJoin(m).on(b.ID.eq(m.BOOK_ID))
.leftJoin(d).on(b.ID.eq(d.BOOK_ID))
.leftJoin(r).on(b.ID.eq(r.BOOK_ID)).and(readProgressCondition(userId))
.leftJoin(sd).on(b.SERIES_ID.eq(sd.SERIES_ID))
.apply { if (joinConditions.tag) leftJoin(bt).on(b.ID.eq(bt.BOOK_ID)) }
.apply { if (joinConditions.selectReadListNumber) leftJoin(rlb).on(b.ID.eq(rlb.BOOK_ID)) }
.apply { if (joinConditions.author) leftJoin(a).on(b.ID.eq(a.BOOK_ID)) }
@ -298,6 +301,7 @@ class BookDtoDao(
val mr = rec.into(m)
val dr = rec.into(d)
val rr = rec.into(r)
val seriesTitle = rec.into(sd.TITLE).component1()
val authors = dsl.selectFrom(a)
.where(a.BOOK_ID.eq(br.id))
@ -316,7 +320,7 @@ class BookDtoDao(
.fetchInto(bl)
.map { WebLinkDto(it.label, it.url) }
br.toDto(mr.toDto(), dr.toDto(authors, tags, links), if (rr.userId != null) rr.toDto() else null)
br.toDto(mr.toDto(), dr.toDto(authors, tags, links), if (rr.userId != null) rr.toDto() else null, seriesTitle)
}
private fun BookSearchWithReadProgress.toCondition(): Condition {
@ -365,10 +369,11 @@ class BookDtoDao(
val author: Boolean = false,
)
private fun BookRecord.toDto(media: MediaDto, metadata: BookMetadataDto, readProgress: ReadProgressDto?) =
private fun BookRecord.toDto(media: MediaDto, metadata: BookMetadataDto, readProgress: ReadProgressDto?, seriesTitle: String) =
BookDto(
id = id,
seriesId = seriesId,
seriesTitle = seriesTitle,
libraryId = libraryId,
name = name,
url = URL(url).toFilePath(),

View file

@ -9,6 +9,7 @@ import java.time.LocalDateTime
data class BookDto(
val id: String,
val seriesId: String,
val seriesTitle: String,
val libraryId: String,
val name: String,
val url: String,