fix(webui): refactor Cards to a single dynamic component (#148)

this removes the badges on Series and Book cards. For Series it's duplicated information with the card showing the number of books. For Books it's information that is more technical and not needed in the overview, and still available in the book detailed view.
This commit is contained in:
primetoxinz 2020-05-03 22:57:18 -04:00 committed by GitHub
parent 1de0d8491b
commit 74a9f7e628
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 431 additions and 145 deletions

View file

@ -16415,9 +16415,9 @@
"dev": true
},
"typescript": {
"version": "3.8.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-3.8.2.tgz",
"integrity": "sha512-EgOVgL/4xfVrCMbhYKUQTdF37SQn4Iw73H5BgCrF1Abdun7Kwy/QZsE/ssAy0y4LxBbvua3PIbFsbRczWWnDdQ==",
"version": "3.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-3.8.3.tgz",
"integrity": "sha512-MYlEfn5VrLNsgudQTVJeNaQFUAI7DkhnOjdpAp4T+ku1TfQClewlbSuTVHiA+8skNBgaf02TL/kLOvig4y3G8w==",
"dev": true
},
"uglify-js": {

View file

@ -51,7 +51,7 @@
"sass-loader": "^8.0.2",
"ts-jest": "^25.2.1",
"typeface-roboto": "0.0.75",
"typescript": "^3.8.2",
"typescript": "^3.8.3",
"vue-cli-plugin-vuetify": "^2.0.5",
"vue-template-compiler": "^2.6.11",
"vuetify-loader": "^1.4.3"

View file

@ -13,14 +13,14 @@
<span class="white--text pa-1 px-2 subtitle-2"
:style="{background: format.color, position: 'absolute', right: 0}"
>
{{ format.type }}
{{ format.type }} 1
</span>
<span v-if="book.media.status !== 'READY'"
class="white--text pa-1 px-2 subtitle-2"
:style="{background: statusColor, position: 'absolute', bottom: 0, width: `${width}px`}"
>
{{ book.media.status }}
{{ book.media.status }} 2
</span>
<v-fade-transition>

View file

@ -0,0 +1,116 @@
<template>
<v-item-group multiple v-model="selectedItems">
<v-row justify="start" ref="content" v-resize="onResize" v-if="hasItems">
<v-skeleton-loader v-for="(item, index) in items"
:key="index"
:width="itemWidth"
:height="itemHeight"
justify-self="start"
:loading="item === null"
type="card, text"
class="ma-3 mx-2"
:data-index="index"
v-intersect="onElementIntersect"
>
<v-item v-slot:default="{ toggle, active }" :value="$_.get(item, 'id', 0)">
<slot name="item" v-bind:data="{ toggle, active, item, index, itemWidth, preselect: shouldPreselect(), editItem }">
<item-card
:item="item"
:width="itemWidth"
:selected="active"
:preselect="shouldPreselect()"
:onEdit="editItem"
:onSelected="toggle"
></item-card>
</slot>
</v-item>
</v-skeleton-loader>
</v-row>
<v-row v-else justify="center">
<slot name="empty"></slot>
</v-row>
</v-item-group>
</template>
<script lang="ts">
import Vue from 'vue'
import { computeCardWidth } from '@/functions/grid-utilities'
import ItemCard from '@/components/ItemCard.vue'
import mixins from 'vue-typed-mixins'
import VisibleElements from '@/mixins/VisibleElements'
export default mixins(VisibleElements).extend({
name: 'ItemBrowser',
components: { ItemCard },
props: {
items: {
type: Array,
required: true,
},
selected: {
type: Array,
required: true,
},
editFunction: {
type: Function,
},
resizeFunction: {
type: Function,
},
},
data: () => {
return {
selectedItems: [],
width: 150,
}
},
watch: {
series: {
handler () {
this.visibleElements = []
},
immediate: true,
},
selectedItems: {
handler () {
this.$emit('update:selected', this.selectedItems)
},
immediate: true,
},
selected: {
handler () {
this.selectedItems = this.selected as []
},
immediate: true,
},
},
computed: {
hasItems (): boolean {
return this.items.length > 0
},
itemWidth (): number {
return this.width
},
itemHeight (): number {
return this.width / 0.7071 + 116
},
},
methods: {
shouldPreselect (): boolean {
return this.selectedItems.length > 0
},
editItem (item: any) {
this.editFunction(item)
},
onResize () {
const content = this.$refs.content as HTMLElement
this.width = computeCardWidth(content.clientWidth, this.$vuetify.breakpoint.name)
},
},
})
</script>
<style scoped>
</style>

View file

@ -0,0 +1,158 @@
<template>
<v-hover :disabled="disableHover">
<template v-slot:default="{ hover }">
<v-card
:width="width"
@click="onClick"
:ripple="false"
>
<!-- Thumbnail-->
<v-img
:src="thumbnailUrl"
lazy-src="../assets/cover.svg"
aspect-ratio="0.7071"
>
<v-fade-transition>
<v-overlay
v-if="hover || selected || preselect"
absolute
:opacity="hover ? 0.3 : 0"
:class="`${hover ? 'item-border-darken' : selected ? 'item-border' : 'item-border-transparent'} overlay-full`"
>
<v-icon v-if="onSelected"
:color="selected ? 'secondary' : ''"
style="position: absolute; top: 5px; left: 10px"
@click.stop="selectItem"
>
{{ selected || (preselect && hover) ? 'mdi-checkbox-marked-circle' : 'mdi-checkbox-blank-circle-outline'
}}
</v-icon>
<v-icon v-if="!selected && !preselect && onEdit"
style="position: absolute; bottom: 10px; left: 10px"
@click.stop="editItem"
>
mdi-pencil
</v-icon>
</v-overlay>
</v-fade-transition>
</v-img>
<!-- Description-->
<v-card-subtitle
v-line-clamp="2"
v-bind="subtitleProps"
v-html="title"
>
</v-card-subtitle>
<v-card-text class="px-2" v-html="body">
</v-card-text>
</v-card>
</template>
</v-hover>
</template>
<script lang="ts">
import Vue from 'vue'
import { BookItem, createItem, Item } from '@/types/items'
export default Vue.extend({
name: 'ItemCard',
props: {
item: {
type: Object as () => BookDto | SeriesDto,
required: true,
},
width: {
type: [String, Number],
required: false,
default: 150,
},
selected: {
type: Boolean,
default: false,
},
preselect: {
type: Boolean,
required: false,
},
onSelected: {
type: Function,
default: undefined,
required: false,
},
onEdit: {
type: Function,
default: undefined,
required: false,
},
},
data: () => {
return {
}
},
computed: {
overlay (): boolean {
return this.onEdit !== undefined || this.onSelected !== undefined
},
computedItem (): Item<BookDto | SeriesDto> {
return createItem(this.item)
},
disableHover (): boolean {
return !this.overlay
},
thumbnailUrl (): string {
return this.computedItem.thumbnailUrl()
},
title (): string {
return this.computedItem.title()
},
subtitleProps (): Object {
return this.computedItem.subtitleProps()
},
body (): string {
return this.computedItem.body()
},
},
methods: {
onClick () {
if (this.preselect && this.onSelected !== undefined) {
this.selectItem()
} else {
this.goto()
}
},
goto () {
this.computedItem.goto(this.$router)
},
selectItem () {
if (this.onSelected !== undefined) {
this.onSelected()
}
},
editItem () {
if (this.onEdit !== undefined) {
this.onEdit(this.item)
}
},
},
})
</script>
<style>
.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%;
}
</style>

View file

@ -14,6 +14,7 @@ const visibleElements = Vue.extend({
} else {
this.$_.pull(this.visibleElements, elementIndex)
}
this.$emit('update', this.visibleElements)
},
},
})

View file

@ -0,0 +1,82 @@
import { bookThumbnailUrl, seriesThumbnailUrl } from '@/functions/urls'
import { VueRouter } from 'vue-router/types/router'
function plural (count: number, singular: string, plural: string) {
return `${count} ${count === 1 ? singular : plural}`
}
export function createItem (item: BookDto | SeriesDto): Item<BookDto | SeriesDto> {
if ('seriesId' in item) {
return new BookItem(item)
} else if ('libraryId' in item) {
return new SeriesItem(item)
} else {
throw new Error('The given item type is not known!')
}
}
export abstract class Item<T> {
item: T;
constructor (item: T) {
this.item = item
}
subtitleProps (): Object {
return {
style: 'word-break: normal !important; height: 4em',
'class': 'pa-2 pb-1 text--primary',
title: this.title(),
}
}
abstract thumbnailUrl(): string
abstract title(): string
abstract body(): string
abstract goto(router: VueRouter): void
}
export class BookItem extends Item<BookDto> {
thumbnailUrl (): string {
return bookThumbnailUrl(this.item.id)
}
title (): string {
const m = this.item.metadata
return `#${m.number} - ${m.title}`
}
body (): string {
let c = this.item.media.pagesCount
return `
<div>${this.item.size}</div>
<div>${plural(c, 'Page', 'Pages')}</div>
`
}
goto (router: VueRouter): void {
router.push({ name: 'browse-book', params: { bookId: this.item.id.toString() } })
}
}
export class SeriesItem extends Item<SeriesDto> {
thumbnailUrl (): string {
return seriesThumbnailUrl(this.item.id)
}
title (): string {
return this.item.metadata.title
}
body (): string {
let c = this.item.booksCount
return `<span>${plural(c, 'Book', 'Books')}</span>`
}
goto (router: VueRouter): void {
router.push({ name: 'browse-series', params: { seriesId: this.item.id.toString() } })
}
}

View file

@ -69,65 +69,49 @@
:series.sync="editSeriesSingle"
/>
<v-item-group multiple v-model="selected">
<v-container fluid class="px-6">
<v-row justify="start" ref="content" v-resize="updateCardWidth" v-if="totalElements !== 0">
<v-skeleton-loader v-for="(s, i) in series"
:key="i"
:width="cardWidth"
:height="cardWidth / .7071 + 94"
justify-self="start"
:loading="s === null"
type="card, text"
class="ma-3 mx-2"
v-intersect="onElementIntersect"
:data-index="i"
>
<v-item v-slot:default="{ active, toggle }" :value="$_.get(s, 'id', 0)">
<card-series :series="s"
:width="cardWidth"
:selected="active"
:select="toggle"
:preSelect="selected.length > 0"
:edit="singleEdit"
/>
</v-item>
</v-skeleton-loader>
</v-row>
<!-- Empty state if filter returns no books -->
<v-row justify="center" v-else>
<empty-state title="The active filter has no matches"
sub-title="Use the menu above to change the active filter"
icon="mdi-book-multiple"
icon-color="secondary"
>
<v-btn @click="filterStatus = []">Clear filter</v-btn>
</empty-state>
</v-row>
</v-container>
</v-item-group>
<item-browser :items="series"
:selected.sync="selected"
:edit-function="this.singleEdit"
class="px-6"
@update="updateVisible"
>
<template #empty v-if="filterStatus.length > 0">
<empty-state title="The active filter has no matches"
sub-title="Use the menu above to change the active filter"
icon="mdi-book-multiple"
icon-color="secondary"
>
<v-btn @click="filterStatus = []">Clear filter</v-btn>
</empty-state>
</template>
<template #empty v-else>
<empty-state title="There are no current libraries"
sub-title="Use the + button on the side bar to add a library"
icon="mdi-book-multiple"
icon-color="secondary"
>
</empty-state>
</template>
</item-browser>
</div>
</template>
<script lang="ts">
import Badge from '@/components/Badge.vue'
import CardSeries from '@/components/CardSeries.vue'
import EmptyState from '@/components/EmptyState.vue'
import EditSeriesDialog from '@/components/EditSeriesDialog.vue'
import LibraryActionsMenu from '@/components/LibraryActionsMenu.vue'
import SortMenuButton from '@/components/SortMenuButton.vue'
import ToolbarSticky from '@/components/ToolbarSticky.vue'
import { computeCardWidth } from '@/functions/grid-utilities'
import { parseQuerySort } from '@/functions/query-params'
import VisibleElements from '@/mixins/VisibleElements'
import { LoadState } from '@/types/common'
import mixins from 'vue-typed-mixins'
import { SeriesStatus } from '@/types/enum-series'
import ItemBrowser from '@/components/ItemBrowser.vue'
import Vue from 'vue'
export default mixins(VisibleElements).extend({
export default Vue.extend({
name: 'BrowseLibraries',
components: { LibraryActionsMenu, CardSeries, EmptyState, ToolbarSticky, SortMenuButton, Badge, EditSeriesDialog },
components: { LibraryActionsMenu, EmptyState, ToolbarSticky, SortMenuButton, Badge, EditSeriesDialog, ItemBrowser },
data: () => {
return {
library: undefined as LibraryDto | undefined,
@ -146,7 +130,6 @@ export default mixins(VisibleElements).extend({
sortDefault: { key: 'metadata.titleSort', order: 'asc' } as SortActive,
filterStatus: [] as string[],
SeriesStatus,
cardWidth: 150,
sortUnwatch: null as any,
filterUnwatch: null as any,
selected: [],
@ -161,21 +144,6 @@ export default mixins(VisibleElements).extend({
},
},
watch: {
async visibleElements (val) {
for (const i of val) {
const pageNumber = Math.floor(i / this.pageSize)
if (this.pagesState[pageNumber] === undefined || this.pagesState[pageNumber] === LoadState.NotLoaded) {
this.processPage(await this.loadPage(pageNumber, this.libraryId))
}
}
const max = this.$_.max(val) as number | undefined
const index = (max === undefined ? 0 : max).toString()
if (this.$route.params.index !== index) {
this.updateRoute(index)
}
},
selected (val: number[]) {
this.selectedSeries = val.map(id => this.series.find(s => s.id === id))
.filter(x => x !== undefined) as SeriesDto[]
@ -242,6 +210,21 @@ export default mixins(VisibleElements).extend({
},
},
methods: {
async updateVisible (val: []) {
for (const i of val) {
const pageNumber = Math.floor(i / this.pageSize)
if (this.pagesState[pageNumber] === undefined || this.pagesState[pageNumber] === LoadState.NotLoaded) {
this.processPage(await this.loadPage(pageNumber, this.libraryId))
}
}
const max = this.$_.max(val) as number | undefined
const index = (max === undefined ? 0 : max).toString()
if (this.$route.params.index !== index) {
this.updateRoute(index)
}
},
setWatches () {
this.sortUnwatch = this.$watch('sortActive', this.updateRouteAndReload)
this.filterUnwatch = this.$watch('filterStatus', this.updateRouteAndReload)
@ -254,10 +237,6 @@ export default mixins(VisibleElements).extend({
this.updateRoute()
this.reloadData(this.libraryId)
},
updateCardWidth () {
const content = this.$refs.content as HTMLElement
this.cardWidth = computeCardWidth(content.clientWidth, this.$vuetify.breakpoint.name)
},
parseQuerySortOrDefault (querySort: any): SortActive {
return parseQuerySort(querySort, this.sortOptions) || this.$_.clone(this.sortDefault)
},
@ -267,7 +246,6 @@ export default mixins(VisibleElements).extend({
reloadData (libraryId: number, countItem?: number) {
this.totalElements = null
this.pagesState = []
this.visibleElements = []
this.series = Array(countItem || this.pageSize).fill(null)
this.loadInitialData(libraryId)
},

View file

@ -96,36 +96,8 @@
</v-row>
<v-divider class="my-4"/>
<v-item-group multiple v-model="selected">
<v-row justify="start" ref="content" v-resize="updateCardWidth">
<v-skeleton-loader v-for="(b, i) in books"
:key="i"
:width="cardWidth"
:height="cardWidth / .7071 + 116"
justify-self="start"
:loading="b === null"
type="card, text"
class="ma-3 mx-2"
v-intersect="onElementIntersect"
:data-index="i"
>
<v-item v-slot:default="{ active, toggle }" :value="$_.get(b, 'id', 0)">
<card-book :book="b"
:width="cardWidth"
:selected="active"
:select="toggle"
:preSelect="selected.length > 0"
:edit="singleEdit"
/>
</v-item>
</v-skeleton-loader>
</v-row>
</v-item-group>
<item-browser :items="books" :selected.sync="selected" :edit-function="this.singleEdit" class="px-6" @update="updateVisible"></item-browser>
</v-container>
<edit-series-dialog v-model="dialogEdit"
:series.sync="series"/>
</div>
@ -133,21 +105,19 @@
<script lang="ts">
import Badge from '@/components/Badge.vue'
import CardBook from '@/components/CardBook.vue'
import SortMenuButton from '@/components/SortMenuButton.vue'
import ToolbarSticky from '@/components/ToolbarSticky.vue'
import { computeCardWidth } from '@/functions/grid-utilities'
import Vue from 'vue'
import { parseQuerySort } from '@/functions/query-params'
import { seriesThumbnailUrl } from '@/functions/urls'
import VisibleElements from '@/mixins/VisibleElements'
import { LoadState } from '@/types/common'
import mixins from 'vue-typed-mixins'
import EditBooksDialog from '@/components/EditBooksDialog.vue'
import EditSeriesDialog from '@/components/EditSeriesDialog.vue'
import ItemBrowser from '@/components/ItemBrowser.vue'
export default mixins(VisibleElements).extend({
export default Vue.extend({
name: 'BrowseSeries',
components: { CardBook, ToolbarSticky, SortMenuButton, Badge, EditSeriesDialog, EditBooksDialog },
components: { ToolbarSticky, SortMenuButton, Badge, EditSeriesDialog, EditBooksDialog, ItemBrowser },
data: () => {
return {
series: {} as SeriesDto,
@ -163,7 +133,6 @@ export default mixins(VisibleElements).extend({
}] as SortOption[],
sortActive: {} as SortActive,
sortDefault: { key: 'metadata.numberSort', order: 'asc' } as SortActive,
cardWidth: 150,
dialogEdit: false,
sortUnwatch: null as any,
selected: [],
@ -189,21 +158,6 @@ export default mixins(VisibleElements).extend({
},
},
watch: {
async visibleElements (val) {
for (const i of val) {
const pageNumber = Math.floor(i / this.pageSize)
if (this.pagesState[pageNumber] === undefined || this.pagesState[pageNumber] === LoadState.NotLoaded) {
this.processPage(await this.loadPage(pageNumber, this.seriesId))
}
}
const max = this.$_.max(val) as number | undefined
const index = (max === undefined ? 0 : max).toString()
if (this.$route.params.index !== index) {
this.updateRoute(index)
}
},
series (val) {
if (this.$_.has(val, 'metadata.title')) {
document.title = `Komga - ${val.metadata.title}`
@ -268,6 +222,21 @@ export default mixins(VisibleElements).extend({
next()
},
methods: {
async updateVisible (val: []) {
for (const i of val) {
const pageNumber = Math.floor(i / this.pageSize)
if (this.pagesState[pageNumber] === undefined || this.pagesState[pageNumber] === LoadState.NotLoaded) {
this.processPage(await this.loadPage(pageNumber, this.seriesId))
}
}
const max = this.$_.max(val) as number | undefined
const index = (max === undefined ? 0 : max).toString()
if (this.$route.params.index !== index) {
this.updateRoute(index)
}
},
setWatches () {
this.sortUnwatch = this.$watch('sortActive', this.updateRouteAndReload)
},
@ -281,10 +250,6 @@ export default mixins(VisibleElements).extend({
async loadSeries () {
this.series = await this.$komgaSeries.getOneSeries(this.seriesId)
},
updateCardWidth () {
const content = this.$refs.content as HTMLElement
this.cardWidth = computeCardWidth(content.clientWidth, this.$vuetify.breakpoint.name)
},
parseQuerySortOrDefault (querySort: any): SortActive {
return parseQuerySort(querySort, this.sortOptions) || this.$_.clone(this.sortDefault)
},
@ -301,7 +266,6 @@ export default mixins(VisibleElements).extend({
reloadData (seriesId: number, countItem?: number) {
this.totalElements = null
this.pagesState = []
this.visibleElements = []
this.books = Array(countItem || this.pageSize).fill(null)
this.loadInitialData(seriesId)
},

View file

@ -23,11 +23,7 @@
height="306.14"
class="ma-2 card"
/>
<card-series v-else
:series="s"
class="ma-2 card"
:edit="singleEditSeries"
/>
<item-card v-else class="ma-2 card" :item="s" :on-edit="singleEditSeries"/>
</div>
</template>
</horizontal-scroller>
@ -48,11 +44,7 @@
height="306.14"
class="ma-2 card"
/>
<card-series v-else
:series="s"
class="ma-2 card"
:edit="singleEditSeries"
/>
<item-card v-else class="ma-2 card" :item="s" :on-edit="singleEditSeries"/>
</div>
</template>
</horizontal-scroller>
@ -74,11 +66,7 @@
height="328.13"
class="ma-2 card"
/>
<card-book v-else
:book="b"
class="ma-2 card"
:edit="singleEditBook"
/>
<item-card v-else class="ma-2 card" :item="b" :on-edit="singleEditBook"/>
</div>
</template>
</horizontal-scroller>
@ -87,16 +75,15 @@
</template>
<script lang="ts">
import CardBook from '@/components/CardBook.vue'
import CardSeries from '@/components/CardSeries.vue'
import HorizontalScroller from '@/components/HorizontalScroller.vue'
import Vue from 'vue'
import EditSeriesDialog from '@/components/EditSeriesDialog.vue'
import EditBooksDialog from '@/components/EditBooksDialog.vue'
import ItemCard from '@/components/ItemCard.vue'
export default Vue.extend({
name: 'Dashboard',
components: { CardSeries, CardBook, HorizontalScroller, EditSeriesDialog, EditBooksDialog },
components: { ItemCard, HorizontalScroller, EditSeriesDialog, EditBooksDialog },
data: () => {
const pageSize = 20
return {