feat(webui): series multi-selection and edition

Series cards can now display an edit button on hover, and can be multi-selectable
inline edition enabled when browsing libraries and on dashboard
multi-selection for edition enabled when browing libraries
This commit is contained in:
Gauthier Roebroeck 2020-02-27 15:58:40 +08:00
parent 4e0e409326
commit cfce0768ba
7 changed files with 301 additions and 90 deletions

View file

@ -1,34 +1,61 @@
<template> <template>
<v-card :width="width" <v-hover :disabled="!overlay">
:to="{name:'browse-series', params: {seriesId: series.id}}" <template v-slot:default="{ hover }">
> <v-card :width="width"
<v-img :to="{name:'browse-series', params: {seriesId: series.id}}"
:src="thumbnailUrl"
lazy-src="../assets/cover.svg"
aspect-ratio="0.7071"
>
<span class="white--text pa-1 px-2 subtitle-2"
style="background: darkorange; position: absolute; right: 0"
> >
{{ series.booksCount }} <v-img
</span> :src="thumbnailUrl"
</v-img> lazy-src="../assets/cover.svg"
aspect-ratio="0.7071"
>
<span class="white--text pa-1 px-2 subtitle-2"
style="background: darkorange; position: absolute; right: 0"
>
{{ series.booksCount }}
</span>
<v-fade-transition>
<v-overlay
v-if="hover || selected"
absolute
:opacity="hover ? 0.3 : 0"
:class="`item-border${hover ? '-darken' : ''} overlay-full`"
>
<v-icon v-if="select"
:color="selected ? 'secondary' : ''"
style="position: absolute; top: 5px; left: 10px"
@click.prevent="selectItem"
>
{{ selected ? 'mdi-checkbox-marked-circle' : 'mdi-checkbox-blank-circle-outline' }}
</v-icon>
<v-card-subtitle class="pa-2 pb-1 text--primary" <v-icon v-if="!selected && showEdit && edit"
v-line-clamp="2" style="position: absolute; bottom: 10px; left: 10px"
style="word-break: normal !important; height: 4em" @click.prevent="editItem"
:title="series.metadata.title" >
> mdi-pencil
{{ series.metadata.title }} </v-icon>
</v-card-subtitle> </v-overlay>
</v-fade-transition>
</v-img>
<v-card-text class="px-2" <v-card-subtitle class="pa-2 pb-1 text--primary"
> v-line-clamp="2"
<span v-if="series.booksCount === 1">{{ series.booksCount }} book</span> style="word-break: normal !important; height: 4em"
<span v-else>{{ series.booksCount }} books</span> :title="series.metadata.title"
</v-card-text> >
{{ series.metadata.title }}
</v-card-subtitle>
</v-card> <v-card-text class="px-2"
>
<span v-if="series.booksCount === 1">{{ series.booksCount }} book</span>
<span v-else>{{ series.booksCount }} books</span>
</v-card-text>
</v-card>
</template>
</v-hover>
</template> </template>
<script lang="ts"> <script lang="ts">
@ -46,15 +73,58 @@ export default Vue.extend({
type: [String, Number], type: [String, Number],
required: false, required: false,
default: 150 default: 150
},
selected: {
type: Boolean,
default: false
},
showEdit: {
type: Boolean,
default: true
},
select: {
type: Function,
required: false
},
edit: {
type: Function,
required: false
} }
}, },
computed: { computed: {
thumbnailUrl (): string { thumbnailUrl (): string {
return seriesThumbnailUrl(this.series.id) return seriesThumbnailUrl(this.series.id)
},
overlay (): boolean {
return this.edit !== undefined || this.select !== undefined
}
},
methods: {
selectItem () {
if (this.select !== undefined) {
this.select()
}
},
editItem () {
if (this.edit !== undefined) {
this.edit(this.series)
}
} }
} }
}) })
</script> </script>
<style scoped> <style>
.item-border {
border: 3px solid var(--v-secondary-base);
}
.item-border-darken {
border: 3px solid var(--v-secondary-darken2);
}
.overlay-full .v-overlay__content {
width: 100%;
height: 100%;
}
</style> </style>

View file

@ -9,7 +9,7 @@
<v-btn icon @click="dialogCancel"> <v-btn icon @click="dialogCancel">
<v-icon>mdi-close</v-icon> <v-icon>mdi-close</v-icon>
</v-btn> </v-btn>
<v-toolbar-title>Edit {{ $_.get(series, 'metadata.title') }}</v-toolbar-title> <v-toolbar-title>{{ dialogTitle }}</v-toolbar-title>
<v-spacer/> <v-spacer/>
<v-toolbar-items> <v-toolbar-items>
<v-btn text color="primary" @click="dialogConfirm">Save changes</v-btn> <v-btn text color="primary" @click="dialogConfirm">Save changes</v-btn>
@ -18,7 +18,7 @@
<v-card-title class="hidden-xs-only"> <v-card-title class="hidden-xs-only">
<v-icon class="mr-4">mdi-pencil</v-icon> <v-icon class="mr-4">mdi-pencil</v-icon>
Edit {{ $_.get(series, 'metadata.title') }} {{ dialogTitle }}
</v-card-title> </v-card-title>
<v-tabs :vertical="$vuetify.breakpoint.smAndUp"> <v-tabs :vertical="$vuetify.breakpoint.smAndUp">
@ -34,7 +34,7 @@
<v-container fluid> <v-container fluid>
<!-- Title --> <!-- Title -->
<v-row> <v-row v-if="!multiple">
<v-col cols="12"> <v-col cols="12">
<v-text-field v-model="form.title" <v-text-field v-model="form.title"
label="Title" label="Title"
@ -52,7 +52,7 @@
</v-row> </v-row>
<!-- Sort Title --> <!-- Sort Title -->
<v-row> <v-row v-if="!multiple">
<v-col cols="12"> <v-col cols="12">
<v-text-field v-model="form.titleSort" <v-text-field v-model="form.titleSort"
label="Sort Title" label="Sort Title"
@ -130,7 +130,6 @@ export default Vue.extend({
modal: false, modal: false,
snackbar: false, snackbar: false,
snackText: '', snackText: '',
seriesStatus: Object.keys(SeriesStatus).map(x => capitalize(x)),
form: { form: {
status: '', status: '',
statusLock: false, statusLock: false,
@ -144,7 +143,7 @@ export default Vue.extend({
props: { props: {
value: Boolean, value: Boolean,
series: { series: {
type: Object, type: Array as () => SeriesDto[],
required: true required: true
} }
}, },
@ -160,18 +159,34 @@ export default Vue.extend({
} }
}, },
computed: { computed: {
libraries (): LibraryDto[] { multiple (): boolean {
return this.$store.state.komgaLibraries.libraries return this.series.length > 1
},
seriesStatus (): string[] {
return Object.keys(SeriesStatus).map(x => capitalize(x))
},
dialogTitle (): string {
return this.multiple
? `Edit ${this.series.length} series`
: `Edit ${this.$_.get(this.series[0], 'metadata.title')}`
} }
}, },
methods: { methods: {
dialogReset (series: SeriesDto) { dialogReset (series: SeriesDto[]) {
this.form.status = capitalize(series.metadata.status) if (series.length === 0) return
this.form.statusLock = series.metadata.statusLock if (series.length > 1) {
this.form.title = series.metadata.title const status = this.$_.uniq(series.map(x => x.metadata.status))
this.form.titleLock = series.metadata.titleLock this.form.status = status.length > 1 ? '' : capitalize(status[0])
this.form.titleSort = series.metadata.titleSort const statusLock = this.$_.uniq(series.map(x => x.metadata.statusLock))
this.form.titleSortLock = series.metadata.titleSortLock this.form.statusLock = statusLock.length > 1 ? false : statusLock[0]
} else {
this.form.status = capitalize(series[0].metadata.status)
this.form.statusLock = series[0].metadata.statusLock
this.form.title = series[0].metadata.title
this.form.titleLock = series[0].metadata.titleLock
this.form.titleSort = series[0].metadata.titleSort
this.form.titleSortLock = series[0].metadata.titleSortLock
}
}, },
dialogCancel () { dialogCancel () {
this.$emit('input', false) this.$emit('input', false)
@ -186,21 +201,34 @@ export default Vue.extend({
this.snackbar = true this.snackbar = true
}, },
async editSeries () { async editSeries () {
try { const updated = [] as SeriesDto[]
const metadata = { for (const s of this.series) {
status: this.form.status.toUpperCase(), try {
statusLock: this.form.statusLock, if (this.form.status === '') {
title: this.form.title, return
titleLock: this.form.titleLock, }
titleSort: this.form.titleSort, const metadata = {
titleSortLock: this.form.titleSortLock status: this.form.status.toUpperCase(),
} as SeriesMetadataUpdateDto statusLock: this.form.statusLock
} as SeriesMetadataUpdateDto
const updatedSeries = await this.$komgaSeries.updateMetadata(this.series.id, metadata) if (!this.multiple) {
this.$emit('update:series', updatedSeries) this.$_.merge(metadata, {
} catch (e) { title: this.form.title,
this.showSnack(e.message) titleLock: this.form.titleLock,
titleSort: this.form.titleSort,
titleSortLock: this.form.titleSortLock
})
}
const updatedSeries = await this.$komgaSeries.updateMetadata(s.id, metadata)
updated.push(updatedSeries)
} catch (e) {
this.showSnack(e.message)
updated.push(s)
}
} }
this.$emit('update:series', updated)
} }
} }
}) })

View file

@ -1,8 +1,9 @@
<template> <template>
<v-toolbar flat <v-toolbar flat
color="grey lighten-4" :color="color"
class="sticky-bar" class="sticky-bar"
:style="barStyle" :style="barStyle"
:elevation="elevation"
> >
<slot/> <slot/>
</v-toolbar> </v-toolbar>
@ -15,12 +16,22 @@ export default Vue.extend({
name: 'ToolbarSticky', name: 'ToolbarSticky',
computed: { computed: {
barStyle (): any { barStyle (): any {
if (this.$vuetify.breakpoint.name === 'xs') { if (['xs', 'sm'].includes(this.$vuetify.breakpoint.name)) {
return { 'top': '56px' } return { 'top': '56px' }
} else { } else {
return { 'top': '64px' } return { 'top': '64px' }
} }
} }
},
props: {
elevation: {
type: Number,
default: undefined
},
color: {
type: String,
default: 'grey lighten-4'
}
} }
}) })
</script> </script>

View file

@ -10,6 +10,9 @@ export default new Vuetify({
iconfont: 'mdi' iconfont: 'mdi'
}, },
theme: { theme: {
options: {
customProperties: true
},
themes: { themes: {
light: { light: {
primary: '#005ed3', primary: '#005ed3',

View file

@ -1,6 +1,6 @@
<template> <template>
<div> <div>
<toolbar-sticky> <toolbar-sticky v-if="selected.length === 0">
<!-- Action menu --> <!-- Action menu -->
<library-actions-menu v-if="library" <library-actions-menu v-if="library"
:library="library"/> :library="library"/>
@ -44,36 +44,69 @@
/> />
</toolbar-sticky> </toolbar-sticky>
<v-container fluid class="px-6"> <v-scroll-y-transition hide-on-leave>
<v-row justify="start" ref="content" v-resize="updateCardWidth" v-if="totalElements !== 0"> <toolbar-sticky v-if="selected.length > 0" :elevation="5" color="white">
<v-btn icon @click="selected=[]">
<v-icon>mdi-close</v-icon>
</v-btn>
<v-toolbar-title>
<span>{{ selected.length }} selected</span>
</v-toolbar-title>
<v-skeleton-loader v-for="(s, i) in series" <v-spacer/>
:key="i"
<v-btn icon @click="dialogEdit = true" v-if="isAdmin">
<v-icon>mdi-pencil</v-icon>
</v-btn>
</toolbar-sticky>
</v-scroll-y-transition>
<edit-series-dialog v-model="dialogEdit"
:series.sync="selectedSeries"
/>
<edit-series-dialog v-model="dialogEditSingle"
: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" :width="cardWidth"
:height="cardWidth / .7071 + 94" :selected="active"
justify-self="start" :select="toggle"
:loading="s === null" :showEdit="selected.length === 0"
type="card, text" :edit="singleEdit"
class="ma-3 mx-2" />
v-intersect="onElementIntersect" </v-item>
:data-index="i" </v-skeleton-loader>
> </v-row>
<card-series :series="s" :width="cardWidth"/>
</v-skeleton-loader>
</v-row> <!-- Empty state if filter returns no books -->
<v-row justify="center" v-else>
<!-- Empty state if filter returns no books --> <empty-state title="The active filter has no matches"
<v-row justify="center" v-else> sub-title="Use the menu above to change the active filter"
<empty-state title="The active filter has no matches" icon="mdi-book-multiple"
sub-title="Use the menu above to change the active filter" icon-color="secondary"
icon="mdi-book-multiple" >
icon-color="secondary" <v-btn @click="filterStatus = []">Clear filter</v-btn>
> </empty-state>
<v-btn @click="filterStatus = []">Clear filter</v-btn> </v-row>
</empty-state> </v-container>
</v-row> </v-item-group>
</v-container>
</div> </div>
</template> </template>
@ -81,6 +114,7 @@
import Badge from '@/components/Badge.vue' import Badge from '@/components/Badge.vue'
import CardSeries from '@/components/CardSeries.vue' import CardSeries from '@/components/CardSeries.vue'
import EmptyState from '@/components/EmptyState.vue' import EmptyState from '@/components/EmptyState.vue'
import EditSeriesDialog from '@/components/EditSeriesDialog.vue'
import LibraryActionsMenu from '@/components/LibraryActionsMenu.vue' import LibraryActionsMenu from '@/components/LibraryActionsMenu.vue'
import SortMenuButton from '@/components/SortMenuButton.vue' import SortMenuButton from '@/components/SortMenuButton.vue'
import ToolbarSticky from '@/components/ToolbarSticky.vue' import ToolbarSticky from '@/components/ToolbarSticky.vue'
@ -92,11 +126,13 @@ import mixins from 'vue-typed-mixins'
export default mixins(VisibleElements).extend({ export default mixins(VisibleElements).extend({
name: 'BrowseLibraries', name: 'BrowseLibraries',
components: { LibraryActionsMenu, CardSeries, EmptyState, ToolbarSticky, SortMenuButton, Badge }, components: { LibraryActionsMenu, CardSeries, EmptyState, ToolbarSticky, SortMenuButton, Badge, EditSeriesDialog },
data: () => { data: () => {
return { return {
library: undefined as LibraryDto | undefined, library: undefined as LibraryDto | undefined,
series: [] as SeriesDto[], series: [] as SeriesDto[],
selectedSeries: [] as SeriesDto[],
editSeriesSingle: [] as SeriesDto[],
pagesState: [] as LoadState[], pagesState: [] as LoadState[],
pageSize: 20, pageSize: 20,
totalElements: null as number | null, totalElements: null as number | null,
@ -111,7 +147,10 @@ export default mixins(VisibleElements).extend({
SeriesStatus, SeriesStatus,
cardWidth: 150, cardWidth: 150,
sortUnwatch: null as any, sortUnwatch: null as any,
filterUnwatch: null as any filterUnwatch: null as any,
selected: [],
dialogEdit: false,
dialogEditSingle: false
} }
}, },
props: { props: {
@ -135,6 +174,26 @@ export default mixins(VisibleElements).extend({
if (this.$route.params.index !== index) { if (this.$route.params.index !== index) {
this.updateRoute(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[]
},
selectedSeries (val: SeriesDto[]) {
val.forEach(s => {
const index = this.series.findIndex(x => x.id === s.id)
if (index !== -1) {
this.series[index] = s
}
})
},
editSeriesSingle (val: SeriesDto[]) {
val.forEach(s => {
const index = this.series.findIndex(x => x.id === s.id)
if (index !== -1) {
this.series[index] = s
}
})
} }
}, },
async created () { async created () {
@ -178,6 +237,11 @@ export default mixins(VisibleElements).extend({
next() next()
}, },
computed: {
isAdmin (): boolean {
return this.$store.getters.meAdmin
}
},
methods: { methods: {
setWatches () { setWatches () {
this.sortUnwatch = this.$watch('sortActive', this.updateRouteAndReload) this.sortUnwatch = this.$watch('sortActive', this.updateRouteAndReload)
@ -255,6 +319,10 @@ export default mixins(VisibleElements).extend({
} else { } else {
return undefined return undefined
} }
},
singleEdit (series: SeriesDto) {
this.editSeriesSingle = [series]
this.dialogEditSingle = true
} }
} }
}) })

View file

@ -89,7 +89,7 @@
</v-container> </v-container>
<edit-series-dialog v-model="dialogEdit" <edit-series-dialog v-model="dialogEdit"
:series.sync="series"/> :series.sync="seriesArray"/>
</div> </div>
</template> </template>
@ -136,6 +136,14 @@ export default mixins(VisibleElements).extend({
}, },
thumbnailUrl (): string { thumbnailUrl (): string {
return seriesThumbnailUrl(this.seriesId) return seriesThumbnailUrl(this.seriesId)
},
seriesArray: {
get (): SeriesDto[] {
return [this.series]
},
set (val: SeriesDto[]) {
this.series = val[0]
}
} }
}, },
props: { props: {

View file

@ -1,6 +1,10 @@
<template> <template>
<div class="ma-3"> <div class="ma-3">
<edit-series-dialog v-model="dialogEditSingle"
:series.sync="editSeriesSingle"
/>
<horizontal-scroller> <horizontal-scroller>
<template v-slot:prepend> <template v-slot:prepend>
<div class="title">Recently Added Series</div> <div class="title">Recently Added Series</div>
@ -18,6 +22,7 @@
<card-series v-else <card-series v-else
:series="s" :series="s"
class="ma-2 card" class="ma-2 card"
:edit="singleEdit"
/> />
</div> </div>
</template> </template>
@ -42,6 +47,7 @@
<card-series v-else <card-series v-else
:series="s" :series="s"
class="ma-2 card" class="ma-2 card"
:edit="singleEdit"
/> />
</div> </div>
</template> </template>
@ -80,17 +86,20 @@ import CardBook from '@/components/CardBook.vue'
import CardSeries from '@/components/CardSeries.vue' import CardSeries from '@/components/CardSeries.vue'
import HorizontalScroller from '@/components/HorizontalScroller.vue' import HorizontalScroller from '@/components/HorizontalScroller.vue'
import Vue from 'vue' import Vue from 'vue'
import EditSeriesDialog from '@/components/EditSeriesDialog.vue'
export default Vue.extend({ export default Vue.extend({
name: 'Dashboard', name: 'Dashboard',
components: { CardSeries, CardBook, HorizontalScroller }, components: { CardSeries, CardBook, HorizontalScroller, EditSeriesDialog },
data: () => { data: () => {
const pageSize = 20 const pageSize = 20
return { return {
newSeries: Array(pageSize).fill(null) as SeriesDto[], newSeries: Array(pageSize).fill(null) as SeriesDto[],
updatedSeries: Array(pageSize).fill(null) as SeriesDto[], updatedSeries: Array(pageSize).fill(null) as SeriesDto[],
books: Array(pageSize).fill(null) as BookDto[], books: Array(pageSize).fill(null) as BookDto[],
pageSize: pageSize pageSize: pageSize,
editSeriesSingle: [] as SeriesDto[],
dialogEditSingle: false
} }
}, },
mounted () { mounted () {
@ -102,6 +111,16 @@ export default Vue.extend({
this.loadLatestBooks() this.loadLatestBooks()
} }
}, },
watch: {
editSeriesSingle (val: SeriesDto[]) {
val.forEach(s => {
const index = this.newSeries.findIndex(x => x.id === s.id)
if (index !== -1) {
this.newSeries[index] = s
}
})
}
},
methods: { methods: {
async loadNewSeries () { async loadNewSeries () {
this.newSeries = (await this.$komgaSeries.getNewSeries()).content this.newSeries = (await this.$komgaSeries.getNewSeries()).content
@ -116,6 +135,10 @@ export default Vue.extend({
} as PageRequest } as PageRequest
this.books = (await this.$komgaBooks.getBooks(undefined, pageRequest)).content this.books = (await this.$komgaBooks.getBooks(undefined, pageRequest)).content
},
singleEdit (series: SeriesDto) {
this.editSeriesSingle = [series]
this.dialogEditSingle = true
} }
} }
}) })