Files
MangaStatus/frontend/src/components/manga/MangaListTable.vue

280 lines
8.4 KiB
Vue

<script lang="ts">
import {Options, Vue} from 'vue-class-component';
import {Prop, Watch} from 'vue-property-decorator';
import {BTable, type TableItem} from 'bootstrap-vue-next';
//@ts-ignore TS2307
import type {TableField, TableFieldObject} from 'bootstrap-vue-next/dist/src/types';
import type {ViewEntry, ViewList} from '@/components/manga/MangaList.vue';
import MangaEntryDetailsModal from '@/components/manga/MangaEntryDetailsModal.vue';
import {get, latestChaptersSorted, latestChapterString, newChapterCount} from '@/components/manga/util.manga';
type HeadData<I> = {
label: string,
column: string,
field: TableFieldObject<I>,
'is-foot': boolean,
}
type CellData<I, V> = {
value: V,
index: number,
item: TableItem<I>,
field: TableFieldObject<I>,
items: TableItem<I>[],
toggleDetails: () => void,
detailsShowing: boolean | undefined,
}
@Options({
name: 'MangaListTable',
components: {BTable, MangaEntryDetailsModal},
})
export default class MangaListTable extends Vue {
@Prop({required: true})
readonly viewList!: ViewList;
bTableRefreshHack = true;
sortKey: string | null = null;
sortAsc: boolean = false;
//methods
latestChaptersSorted = latestChaptersSorted;
latestChapterString = latestChapterString;
newChapterCount = newChapterCount;
get fields(): TableFieldObject<ViewEntry>[] {
return [{
key: 'media.coverImage.large',
label: '',
sortable: false,
tdClass: 'c-pointer',
}, {
key: 'media.title.userPreferred',
label: this.$t('manga.title'),
sortable: true,
tdClass: 'c-pointer',
thClass: 'c-pointer',
}, {
key: 'entry.score',
label: this.$t('manga.score'),
sortable: true,
tdClass: 'text-center manga-column-score c-pointer',
thClass: 'text-center manga-column-score c-pointer',
}, {
key: 'entry.progress',
label: this.$t('manga.progress'),
sortable: true,
tdClass: 'text-end text-nowrap c-pointer',
thClass: 'text-end c-pointer',
}, {
key: 'newChapters',
label: this.$t('manga.chapters.newCount'),
sortable: true,
tdClass: 'text-end c-pointer',
thClass: 'text-end c-pointer',
}, {
key: 'latestChapters',
label: this.$t('manga.chapters.latest'),
sortable: false,
tdClass: 'manga-column-latest-chapters c-pointer',
thClass: 'manga-column-latest-chapters',
}];
}
get tableEntries(): ViewEntry[] {
return this.viewList.entries;
}
get tableEntriesSorted(): ViewEntry[] {
if (!this.sortKey) {
return this.tableEntries;
}
const keyExtractor = (e: ViewEntry) => get<any>(e, this.sortKey!);
const comparer = (l: ViewEntry, r: ViewEntry) => {
const lkey = keyExtractor(l);
const rkey = keyExtractor(r);
if ([null, undefined].includes(lkey) && [null, undefined].includes(lkey)) {
return 0;
} else if ([null, undefined].includes(lkey) && ![null, undefined].includes(lkey)) {
return -1;
} else if (![null, undefined].includes(lkey) && [null, undefined].includes(lkey)) {
return 1;
} else if (typeof lkey === 'number' && typeof rkey === 'number') {
return lkey - rkey;
} else if (typeof lkey === 'string' && typeof rkey === 'string') {
return lkey.localeCompare(rkey);
} else if (lkey < rkey) {
return -1;
} else if (lkey > rkey) {
return 1;
}
return 0;
};
return [...this.tableEntries].sort((l, r) => comparer(l, r) * (this.sortAsc ? 1 : -1));
}
cd<V = any>(data: V): CellData<ViewEntry, V> {
return (data as CellData<ViewEntry, V>);
}
hd<V = any>(data: V): HeadData<V> {
return (data as HeadData<V>);
}
onHeaderClicked(fieldKey: string, field: TableField<ViewEntry>, event: MouseEvent, isFooter: boolean): void {
if (!field.sortable) {
return;
}
if (this.sortKey === fieldKey) {
this.sortAsc = !this.sortAsc;
} else {
this.sortAsc = false;
}
this.sortKey = fieldKey;
}
onRowClicked(entry: TableItem<ViewEntry>): void {
(this.$refs.detailsModal as MangaEntryDetailsModal).open(entry);
}
@Watch('viewList', {deep: true})
private onViewListChanged(): void {
this.bTableRefreshHack = false;
this.$nextTick(() => {
this.bTableRefreshHack = true;
// (this.$refs.table as any).refresh();
// (this.$refs.table as any).$forceUpdate();
});
}
}
</script>
<template>
<div>
<BTable ref="table" v-if="bTableRefreshHack" :fields="fields" :items="tableEntriesSorted" :primary-key="'id'"
class="manga-table" hover striped responsive no-sort-reset sort-by="newChapters" sort-desc
@row-clicked="onRowClicked as any /* TODO dumb typing issue */"
@head-clicked="onHeaderClicked">
<template #cell(media.coverImage.large)="data">
<img :src="data.value as string" alt="cover-img" class="list-cover"/>
</template>
<template #cell(media.title.userPreferred)="data">
<div class="flex-grow-1">
<div>{{ cd(data).item.media?.title.native }}</div>
<div>{{ cd(data).item.media?.title.english ?? cd(data).item.media?.title.romaji }}</div>
</div>
<div v-if="cd(data).item.relation" class="d-flex flex-row" style="font-size: 0.5em">
<span>
MangaUpdates:&nbsp;
</span>
<template v-if="cd(data).item.series">
<a :href="cd(data).item.series!.url" target="_blank">
{{ cd(data).item.series!.title }}
</a>
</template>
<span v-else>{{ $t('mangaupdates.relation.found') }}</span>
</div>
</template>
<template #head(entry.score)="data">
<div class="table-header-mobile"><i class="fa fa-star" style="color: var(--bs-orange)"/></div>
<div class="table-header-desktop"> {{ hd(data).label }}</div>
</template>
<template #cell(entry.score)="data">
<div class="table-data-mobile">{{ cd(data).value }}</div>
<div class="table-data-desktop">
<i class="fa fa-star" style="color: var(--bs-orange)"/> {{ cd(data).value }}
</div>
</template>
<template #head(entry.progress)="data">
<div class="table-header-mobile"><i class="fa fa-bars-progress"/></div>
<div class="table-header-desktop"> {{ hd(data).label }}</div>
</template>
<template #cell(entry.progress)="data">
{{ cd(data).value + '/' + latestChapterString(cd(data).item) }}
</template>
<template #head(newChapters)="data">
<div class="table-header-mobile"><i class="fa fa-plus" style="color: var(--bs-success)"/></div>
<div class="table-header-desktop"> {{ hd(data).label }}</div>
</template>
<template #cell(latestChapters)="data">
<div v-for="c in latestChaptersSorted(cd(data).item)">
{{ c.chapter + ' (' + c.group + ')' }}
</div>
</template>
</BTable>
<MangaEntryDetailsModal ref="detailsModal"/>
</div>
</template>
<style lang="scss">
@import 'bootstrap/scss/functions';
@import 'bootstrap/scss/variables';
@import 'bootstrap/scss/mixins/breakpoints';
//disable wrapping of table headers
.manga-table th {
white-space: nowrap;
}
//cover
.manga-table .list-cover {
max-width: var(--manga-cover-size);
max-height: calc(var(--manga-cover-size) * 2);
object-fit: cover;
border-radius: calc(var(--manga-cover-size) / 16);
}
//disable bottom border on last row
.manga-table > :not(caption) tr:last-child td {
border-bottom-width: 0;
}
@include media-breakpoint-down(sm) {
.manga-table {
--manga-cover-size: 3rem;
font-size: 0.8em;
max-width: 100vw;
overflow-x: hidden;
}
.manga-table th,
.manga-table td {
padding: 0.25rem !important;
}
//.manga-table .manga-column-score,
.manga-table .manga-column-latest-chapters {
display: none;
}
.manga-table .table-header-desktop,
.manga-table .table-data-desktop {
display: none;
}
}
@include media-breakpoint-up(sm) {
.manga-table {
--manga-cover-size: 4rem;
}
//more horizontal space usage at the start of the row
.manga-table tr td:first-child,
.manga-table tr th:first-child {
padding-inline-start: 1rem;
}
//more horizontal space usage at the end of the row
.manga-table tr td:last-child,
.manga-table tr th:last-child {
padding-inline-end: 1rem;
}
.manga-table .table-header-mobile,
.manga-table .table-data-mobile {
display: none;
}
}
</style>