komga/komga-webui/src/components/ItemCard.vue
2021-06-21 14:53:06 +08:00

324 lines
10 KiB
Vue

<template>
<v-hover :disabled="disableHover">
<template v-slot:default="{ hover }">
<v-card
:width="width"
@click="onClick"
:class="noLink ? 'no-link' : ''"
:ripple="false"
>
<!-- Thumbnail-->
<v-img
:src="thumbnailUrl"
lazy-src="../assets/cover.svg"
aspect-ratio="0.7071"
contain
@error="thumbnailError = true"
>
<!-- unread tick for book -->
<div class="unread" v-if="isUnread"/>
<!-- unread count for series -->
<span v-if="unreadCount"
class="white--text pa-1 px-2 text-subtitle-2"
:style="{background: 'orange', position: 'absolute', right: 0}"
>
{{ unreadCount }}
</span>
<!-- Thumbnail overlay -->
<v-fade-transition>
<v-overlay
v-if="hover || selected || preselect || actionMenuState"
absolute
:opacity="hover || actionMenuState ? 0.3 : 0"
:class="`${hover || actionMenuState ? 'item-border-darken' : selected ? 'item-border' : 'item-border-transparent'} overlay-full`"
>
<!-- Circle icon for selection (top left) -->
<v-icon v-if="onSelected"
:color="selected ? 'secondary' : ''"
:style="'position: absolute; top: 5px; ' + ($vuetify.rtl ? 'right' : 'left') + ': 10px'"
@click.stop="selectItem"
>
{{
selected || (preselect && hover) ? 'mdi-checkbox-marked-circle' : 'mdi-checkbox-blank-circle-outline'
}}
</v-icon>
<!-- FAB reading (center) -->
<v-btn
v-if="bookReady && !selected && !preselect && canReadPages"
fab
x-large
color="accent"
style="position: absolute; top: 50%; left: 50%; margin-left: -36px; margin-top: -36px"
:to="fabTo"
>
<v-icon>mdi-book-open-page-variant</v-icon>
</v-btn>
<!-- Pen icon for edition (bottom left) -->
<v-btn icon
v-if="!selected && !preselect && onEdit"
:style="'position: absolute; bottom: 5px; ' + ($vuetify.rtl ? 'right' : 'left' ) +': 5px'"
@click.stop="editItem"
>
<v-icon>mdi-pencil</v-icon>
</v-btn>
<!-- Action menu (bottom right) -->
<div v-if="!selected && !preselect && actionMenu"
:style="'position: absolute; bottom: 5px; ' + ($vuetify.rtl ? 'left' : 'right') +': 5px'"
>
<book-actions-menu v-if="computedItem.type() === ItemTypes.BOOK"
:book="item"
:menu.sync="actionMenuState"
/>
<series-actions-menu v-if="computedItem.type() === ItemTypes.SERIES"
:series="item"
:menu.sync="actionMenuState"
/>
<collection-actions-menu v-if="computedItem.type() === ItemTypes.COLLECTION"
:collection="item"
:menu.sync="actionMenuState"
/>
<read-list-actions-menu v-if="computedItem.type() === ItemTypes.READLIST"
:read-list="item"
:menu.sync="actionMenuState"
/>
</div>
</v-overlay>
</v-fade-transition>
<v-progress-linear
v-if="isInProgress"
:value="readProgressPercentage"
color="orange"
height="6"
style="position: absolute; bottom: 0"
/>
</v-img>
<!-- Description-->
<template v-if="!thumbnailOnly">
<router-link :to="to" class="link-underline">
<v-card-subtitle
v-line-clamp="2"
v-bind="subtitleProps"
v-html="title"
>
</v-card-subtitle>
</router-link>
<v-card-text class="px-2" v-html="body">
</v-card-text>
</template>
</v-card>
</template>
</v-hover>
</template>
<script lang="ts">
import BookActionsMenu from '@/components/menus/BookActionsMenu.vue'
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 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, THUMBNAILSERIES_ADDED} from "@/types/events";
import {ThumbnailBookSseDto, ThumbnailSeriesSseDto} from "@/types/komga-sse";
export default Vue.extend({
name: 'ItemCard',
components: {BookActionsMenu, SeriesActionsMenu, CollectionActionsMenu, ReadListActionsMenu},
props: {
item: {
type: Object as () => BookDto | SeriesDto | CollectionDto | ReadListDto,
required: true,
},
// hide the bottom part of the card
thumbnailOnly: {
type: Boolean,
default: false,
},
// disables the default link on clicking the card
noLink: {
type: Boolean,
default: false,
},
width: {
type: [String, Number],
required: false,
default: 150,
},
// when true, card will show the active border and circle icon full
selected: {
type: Boolean,
default: false,
},
// when true, will display the border like if the card was hovered, and click anywhere will trigger onSelected
preselect: {
type: Boolean,
required: false,
},
// callback function to call when selecting the card
onSelected: {
type: Function,
default: undefined,
required: false,
},
// callback function for the edit button
onEdit: {
type: Function,
default: undefined,
required: false,
},
// action menu enabled or not
actionMenu: {
type: Boolean,
default: true,
},
},
data: () => {
return {
ItemTypes,
actionMenuState: false,
thumbnailError: false,
thumbnailCacheBust: '',
}
},
created() {
this.$eventHub.$on(THUMBNAILBOOK_ADDED, this.thumbnailBookAdded)
this.$eventHub.$on(THUMBNAILSERIES_ADDED, this.thumbnailSeriesAdded)
},
beforeDestroy() {
this.$eventHub.$off(THUMBNAILBOOK_ADDED, this.thumbnailBookAdded)
this.$eventHub.$off(THUMBNAILSERIES_ADDED, this.thumbnailSeriesAdded)
},
computed: {
canReadPages(): boolean {
return this.$store.getters.mePageStreaming && this.computedItem.type() === ItemTypes.BOOK
},
overlay(): boolean {
return this.onEdit !== undefined || this.onSelected !== undefined || this.bookReady || this.canReadPages || this.actionMenu
},
computedItem(): Item<BookDto | SeriesDto | CollectionDto | ReadListDto> {
return createItem(this.item)
},
disableHover(): boolean {
return !this.overlay
},
thumbnailUrl(): string {
return this.computedItem.thumbnailUrl() + this.thumbnailCacheBust
},
title(): string {
return this.computedItem.title()
},
subtitleProps(): Object {
return this.computedItem.subtitleProps()
},
body(): string {
return this.computedItem.body()
},
isInProgress(): boolean {
if (this.computedItem.type() === ItemTypes.BOOK) return getReadProgress(this.item as BookDto) === ReadStatus.IN_PROGRESS
return false
},
isUnread(): boolean {
if (this.computedItem.type() === ItemTypes.BOOK) return getReadProgress(this.item as BookDto) === ReadStatus.UNREAD
return false
},
unreadCount(): number | undefined {
if (this.computedItem.type() === ItemTypes.SERIES) return (this.item as SeriesDto).booksUnreadCount + (this.item as SeriesDto).booksInProgressCount
return undefined
},
readProgressPercentage(): number {
if (this.computedItem.type() === ItemTypes.BOOK) return getReadProgressPercentage(this.item as BookDto)
return 0
},
bookReady(): boolean {
if (this.computedItem.type() === ItemTypes.BOOK) {
return (this.item as BookDto).media.status === 'READY'
}
return false
},
to(): RawLocation {
return this.computedItem.to()
},
fabTo(): RawLocation {
return this.computedItem.fabTo()
},
},
methods: {
thumbnailBookAdded(event: ThumbnailBookSseDto) {
if (this.thumbnailError &&
((this.computedItem.type() === ItemTypes.BOOK && event.bookId === this.item.id) || (this.computedItem.type() === ItemTypes.SERIES && event.seriesId === this.item.id))
) {
this.thumbnailCacheBust = '?' + this.$_.random(1000)
}
},
thumbnailSeriesAdded(event: ThumbnailSeriesSseDto) {
if (this.thumbnailError && (this.computedItem.type() === ItemTypes.SERIES && event.seriesId === this.item.id)) {
this.thumbnailCacheBust = '?' + this.$_.random(1000)
}
},
onClick() {
if (this.preselect && this.onSelected !== undefined) {
this.selectItem()
} else if (!this.noLink) {
this.goto()
}
},
goto() {
this.$router.push(this.computedItem.to())
},
selectItem() {
if (this.onSelected !== undefined) {
this.onSelected()
}
},
editItem() {
if (this.onEdit !== undefined) {
this.onEdit(this.item)
}
},
},
})
</script>
<style>
.no-link {
cursor: default;
}
.item-border {
border: 3px solid var(--v-secondary-base);
}
.item-border-transparent {
border: 3px solid transparent;
}
.item-border-darken {
border: 3px solid var(--v-secondary-darken2);
}
.overlay-full .v-overlay__content {
width: 100%;
height: 100%;
}
.unread {
border-left: 25px solid transparent;
border-right: 25px solid orange;
border-bottom: 25px solid transparent;
height: 0;
width: 0;
position: absolute;
right: 0;
z-index: 2;
}
</style>