Collection Redesign (#500)

* Setup UI for the collection redesign.

* Implemented collection details page
This commit is contained in:
Joseph Milazzo 2021-08-16 16:08:56 -07:00 committed by GitHub
parent 226d7408bf
commit 51ea41fc35
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 289 additions and 42 deletions

View File

@ -20,7 +20,7 @@ namespace Kavita.Common.EnvironmentInfo
var config = attributes.OfType<AssemblyConfigurationAttribute>().FirstOrDefault(); var config = attributes.OfType<AssemblyConfigurationAttribute>().FirstOrDefault();
if (config != null) if (config != null)
{ {
Branch = config.Configuration; // TODO: This is not helpful, better to have main/develop branch Branch = config.Configuration; // NOTE: This is not helpful, better to have main/develop branch
} }
Release = $"{Version}-{Branch}"; Release = $"{Version}-{Branch}";
@ -53,4 +53,4 @@ namespace Kavita.Common.EnvironmentInfo
} }
} }
} }
} }

View File

@ -16,7 +16,7 @@ const routes: Routes = [
@NgModule({ @NgModule({
imports: [RouterModule.forChild(routes), ], imports: [RouterModule.forChild(routes)],
exports: [RouterModule] exports: [RouterModule]
}) })
export class AdminRoutingModule { } export class AdminRoutingModule { }

View File

@ -1,6 +1,5 @@
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router'; import { Routes, RouterModule } from '@angular/router';
import { AllCollectionsComponent } from './all-collections/all-collections.component';
import { HomeComponent } from './home/home.component'; import { HomeComponent } from './home/home.component';
import { LibraryDetailComponent } from './library-detail/library-detail.component'; import { LibraryDetailComponent } from './library-detail/library-detail.component';
import { LibraryComponent } from './library/library.component'; import { LibraryComponent } from './library/library.component';
@ -21,6 +20,10 @@ const routes: Routes = [
path: 'admin', path: 'admin',
loadChildren: () => import('./admin/admin.module').then(m => m.AdminModule) loadChildren: () => import('./admin/admin.module').then(m => m.AdminModule)
}, },
{
path: 'collections',
loadChildren: () => import('./collections/collections.module').then(m => m.CollectionsModule)
},
{path: 'library', component: LibraryComponent}, {path: 'library', component: LibraryComponent},
{ {
path: '', path: '',
@ -29,14 +32,6 @@ const routes: Routes = [
children: [ children: [
{path: 'library/:id', component: LibraryDetailComponent}, {path: 'library/:id', component: LibraryDetailComponent},
{path: 'library/:libraryId/series/:seriesId', component: SeriesDetailComponent}, {path: 'library/:libraryId/series/:seriesId', component: SeriesDetailComponent},
{
path: 'library/:libraryId/series/:seriesId/manga',
loadChildren: () => import('../app/manga-reader/manga-reader.module').then(m => m.MangaReaderModule)
},
{
path: 'library/:libraryId/series/:seriesId/book',
loadChildren: () => import('../app/book-reader/book-reader.module').then(m => m.BookReaderModule)
}
] ]
}, },
{ {
@ -46,12 +41,10 @@ const routes: Routes = [
children: [ children: [
{path: 'recently-added', component: RecentlyAddedComponent}, {path: 'recently-added', component: RecentlyAddedComponent},
{path: 'in-progress', component: InProgressComponent}, {path: 'in-progress', component: InProgressComponent},
{path: 'collections', component: AllCollectionsComponent}, {path: 'preferences', component: UserPreferencesComponent},
{path: 'collections/:id', component: AllCollectionsComponent},
] ]
}, },
{path: 'login', component: UserLoginComponent}, {path: 'login', component: UserLoginComponent},
{path: 'preferences', component: UserPreferencesComponent},
{path: 'no-connection', component: NotConnectedComponent}, {path: 'no-connection', component: NotConnectedComponent},
{path: '**', component: HomeComponent, pathMatch: 'full'} {path: '**', component: HomeComponent, pathMatch: 'full'}
]; ];

View File

@ -33,10 +33,10 @@ import { RewriteFrames as RewriteFramesIntegration } from '@sentry/integrations'
import { Dedupe as DedupeIntegration } from '@sentry/integrations'; import { Dedupe as DedupeIntegration } from '@sentry/integrations';
import { PersonBadgeComponent } from './person-badge/person-badge.component'; import { PersonBadgeComponent } from './person-badge/person-badge.component';
import { TypeaheadModule } from './typeahead/typeahead.module'; import { TypeaheadModule } from './typeahead/typeahead.module';
import { AllCollectionsComponent } from './all-collections/all-collections.component';
import { RecentlyAddedComponent } from './recently-added/recently-added.component'; import { RecentlyAddedComponent } from './recently-added/recently-added.component';
import { InProgressComponent } from './in-progress/in-progress.component'; import { InProgressComponent } from './in-progress/in-progress.component';
import { CardsModule } from './cards/cards.module'; import { CardsModule } from './cards/cards.module';
import { CollectionsModule } from './collections/collections.module';
let sentryProviders: any[] = []; let sentryProviders: any[] = [];
@ -96,7 +96,6 @@ if (environment.production) {
UserPreferencesComponent, // Move into SettingsModule UserPreferencesComponent, // Move into SettingsModule
ReviewSeriesModalComponent, ReviewSeriesModalComponent,
PersonBadgeComponent, PersonBadgeComponent,
AllCollectionsComponent,
RecentlyAddedComponent, RecentlyAddedComponent,
InProgressComponent, InProgressComponent,
], ],
@ -122,6 +121,7 @@ if (environment.production) {
CarouselModule, CarouselModule,
TypeaheadModule, TypeaheadModule,
CardsModule, CardsModule,
CollectionsModule,
ToastrModule.forRoot({ ToastrModule.forRoot({
positionClass: 'toast-bottom-right', positionClass: 'toast-bottom-right',

View File

@ -105,7 +105,7 @@ export class EditCollectionTagsComponent implements OnInit {
} }
close() { close() {
this.modal.close(false); this.modal.dismiss();
} }
async save() { async save() {
@ -135,7 +135,7 @@ export class EditCollectionTagsComponent implements OnInit {
get someSelected() { get someSelected() {
const selected = this.selections.selected(); const selected = this.selections.selected();
return (selected.length != this.series.length && selected.length != 0); return (selected.length !== this.series.length && selected.length !== 0);
} }
updateSelectedIndex(index: number) { updateSelectedIndex(index: number) {

View File

@ -38,7 +38,7 @@ import { CardDetailsModalComponent } from './_modals/card-details-modal/card-det
], ],
imports: [ imports: [
CommonModule, CommonModule,
BrowserModule, //BrowserModule,
RouterModule, RouterModule,
ReactiveFormsModule, ReactiveFormsModule,
FormsModule, // EditCollectionsModal FormsModule, // EditCollectionsModal
@ -48,7 +48,6 @@ import { CardDetailsModalComponent } from './_modals/card-details-modal/card-det
NgbNavModule, NgbNavModule,
NgbTooltipModule, // Card item NgbTooltipModule, // Card item
//NgbAccordionModule,
NgbCollapseModule, NgbCollapseModule,
NgbNavModule, //Series Detail NgbNavModule, //Series Detail

View File

@ -3,13 +3,15 @@ import { Title } from '@angular/platform-browser';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { ToastrService } from 'ngx-toastr'; import { ToastrService } from 'ngx-toastr';
import { EditCollectionTagsComponent } from '../cards/_modals/edit-collection-tags/edit-collection-tags.component'; import { EditCollectionTagsComponent } from 'src/app/cards/_modals/edit-collection-tags/edit-collection-tags.component';
import { CollectionTag } from '../_models/collection-tag'; import { CollectionTag } from 'src/app/_models/collection-tag';
import { Pagination } from '../_models/pagination'; import { Pagination } from 'src/app/_models/pagination';
import { Series } from '../_models/series'; import { Series } from 'src/app/_models/series';
import { Action, ActionFactoryService, ActionItem } from '../_services/action-factory.service'; import { ActionItem, ActionFactoryService, Action } from 'src/app/_services/action-factory.service';
import { CollectionTagService } from '../_services/collection-tag.service'; import { CollectionTagService } from 'src/app/_services/collection-tag.service';
import { SeriesService } from '../_services/series.service'; import { ImageService } from 'src/app/_services/image.service';
import { SeriesService } from 'src/app/_services/series.service';
/** /**
* This component is used as a standard layout for any card detail. ie) series, in-progress, collections, etc. * This component is used as a standard layout for any card detail. ie) series, in-progress, collections, etc.
@ -31,7 +33,7 @@ export class AllCollectionsComponent implements OnInit {
constructor(private collectionService: CollectionTagService, private router: Router, private route: ActivatedRoute, constructor(private collectionService: CollectionTagService, private router: Router, private route: ActivatedRoute,
private seriesService: SeriesService, private toastr: ToastrService, private actionFactoryService: ActionFactoryService, private seriesService: SeriesService, private toastr: ToastrService, private actionFactoryService: ActionFactoryService,
private modalService: NgbModal, private titleService: Title) { private modalService: NgbModal, private titleService: Title, private imageService: ImageService) {
this.router.routeReuseStrategy.shouldReuseRoute = () => false; this.router.routeReuseStrategy.shouldReuseRoute = () => false;
const routeId = this.route.snapshot.paramMap.get('id'); const routeId = this.route.snapshot.paramMap.get('id');
@ -97,9 +99,10 @@ export class AllCollectionsComponent implements OnInit {
case(Action.Edit): case(Action.Edit):
const modalRef = this.modalService.open(EditCollectionTagsComponent, { size: 'lg', scrollable: true }); const modalRef = this.modalService.open(EditCollectionTagsComponent, { size: 'lg', scrollable: true });
modalRef.componentInstance.tag = collectionTag; modalRef.componentInstance.tag = collectionTag;
modalRef.closed.subscribe((reloadNeeded: boolean) => { modalRef.closed.subscribe((results: {success: boolean, coverImageUpdated: boolean}) => {
if (reloadNeeded) { this.loadPage();
this.loadPage(); if (results.coverImageUpdated) {
collectionTag.coverImage = this.imageService.randomize(this.imageService.getCollectionCoverImage(collectionTag.id));
} }
}); });
break; break;

View File

@ -0,0 +1,56 @@
<div class="container-fluid" *ngIf="collectionTag !== undefined" style="padding-top: 10px">
<div class="row mb-3">
<div class="col-md-2 col-xs-4 col-sm-6">
<img class="poster lazyload" [src]="imageService.placeholderImage" [attr.data-src]="tagImage"
(error)="imageService.updateErroredImage($event)" aria-hidden="true">
</div>
<div class="col-md-10 col-xs-8 col-sm-6">
<div class="row no-gutters">
<h2>
{{collectionTag.title}}
</h2>
</div>
<div class="row no-gutters mt-2 mb-2">
<!-- <div>
<button class="btn btn-primary" (click)="read()" [disabled]="isLoading">
<span>
<i class="fa fa-book-open"></i>
</span>
<span class="read-btn--text">&nbsp;Read</span>
</button>
</div> -->
<div class="ml-2" *ngIf="isAdmin">
<button class="btn btn-secondary" (click)="openEditCollectionTagModal(collectionTag)" title="Edit Series information">
<span>
<i class="fa fa-pen" aria-hidden="true"></i>
</span>
</button>
</div>
</div>
<div class="row no-gutters">
<app-read-more [text]="collectionTag.summary" [maxLength]="250"></app-read-more>
</div>
</div>
</div>
<hr>
<app-card-detail-layout
header="Series"
[isLoading]="isLoading"
[items]="series"
[pagination]="seriesPagination"
(pageChange)="onPageChange($event)"
[filters]="filters"
(applyFilter)="updateFilter($event)"
>
<ng-template #cardItem let-item let-position="idx">
<app-series-card [data]="item" [libraryId]="item.libraryId" (reload)="loadPage()"></app-series-card>
</ng-template>
</app-card-detail-layout>
<div class="mx-auto" *ngIf="isLoading" style="width: 200px;">
<div class="spinner-border text-secondary loading" role="status">
<span class="invisible">Loading...</span>
</div>
</div>
</div>

View File

@ -0,0 +1,20 @@
@import '~bootstrap/scss/mixins/_breakpoints.scss';
.poster {
width: 100%;
max-height: 481px;
}
// Including breakpoint has issue with resovling variable, so copying gridpoints here
@include media-breakpoint-down(xs, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px)) {
.poster {
display: none;
}
}
@include media-breakpoint-down(sm, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px)) {
.read-btn--text {
display: none;
}
}

View File

@ -0,0 +1,130 @@
import { Component, OnInit } from '@angular/core';
import { Title } from '@angular/platform-browser';
import { Router, ActivatedRoute } from '@angular/router';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { ToastrService } from 'ngx-toastr';
import { take } from 'rxjs/operators';
import { UpdateFilterEvent } from 'src/app/cards/card-detail-layout/card-detail-layout.component';
import { EditCollectionTagsComponent } from 'src/app/cards/_modals/edit-collection-tags/edit-collection-tags.component';
import { UtilityService } from 'src/app/shared/_services/utility.service';
import { CollectionTag } from 'src/app/_models/collection-tag';
import { MangaFormat } from 'src/app/_models/manga-format';
import { Pagination } from 'src/app/_models/pagination';
import { Series } from 'src/app/_models/series';
import { FilterItem, mangaFormatFilters, SeriesFilter } from 'src/app/_models/series-filter';
import { AccountService } from 'src/app/_services/account.service';
import { Action, ActionFactoryService, ActionItem } from 'src/app/_services/action-factory.service';
import { CollectionTagService } from 'src/app/_services/collection-tag.service';
import { ImageService } from 'src/app/_services/image.service';
import { SeriesService } from 'src/app/_services/series.service';
@Component({
selector: 'app-collection-detail',
templateUrl: './collection-detail.component.html',
styleUrls: ['./collection-detail.component.scss']
})
export class CollectionDetailComponent implements OnInit {
collectionTag!: CollectionTag;
tagImage: string = '';
isLoading: boolean = true;
collections: CollectionTag[] = [];
collectionTagName: string = '';
series: Array<Series> = [];
seriesPagination!: Pagination;
collectionTagActions: ActionItem<CollectionTag>[] = [];
isAdmin: boolean = false;
filters: Array<FilterItem> = mangaFormatFilters;
filter: SeriesFilter = {
mangaFormat: null
};
constructor(public imageService: ImageService, private collectionService: CollectionTagService, private router: Router, private route: ActivatedRoute,
private seriesService: SeriesService, private toastr: ToastrService, private actionFactoryService: ActionFactoryService,
private modalService: NgbModal, private titleService: Title, private accountService: AccountService, private utilityService: UtilityService) {
this.router.routeReuseStrategy.shouldReuseRoute = () => false;
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
if (user) {
this.isAdmin = this.accountService.hasAdminRole(user);
}
});
const routeId = this.route.snapshot.paramMap.get('id');
if (routeId === null) {
this.router.navigate(['collections']);
return;
}
const tagId = parseInt(routeId, 10);
this.collectionService.allTags().subscribe(tags => {
this.collections = tags;
const matchingTags = this.collections.filter(t => t.id === tagId);
if (matchingTags.length === 0) {
this.toastr.error('You don\'t have access to any libraries this tag belongs to or this tag is invalid');
return;
}
this.collectionTag = matchingTags[0];
this.tagImage = this.imageService.randomize(this.imageService.getCollectionCoverImage(this.collectionTag.id));
this.titleService.setTitle('Kavita - ' + this.collectionTag.title + ' Collection');
this.loadPage();
});
}
ngOnInit(): void {
this.collectionTagActions = this.actionFactoryService.getCollectionTagActions(this.handleCollectionActionCallback.bind(this));
}
onPageChange(pagination: Pagination) {
this.router.navigate(['collections', this.collectionTag.id], {replaceUrl: true, queryParamsHandling: 'merge', queryParams: {page: this.seriesPagination.currentPage} });
}
loadPage() {
const page = this.route.snapshot.queryParamMap.get('page');
if (page != null) {
if (this.seriesPagination === undefined || this.seriesPagination === null) {
this.seriesPagination = {currentPage: 0, itemsPerPage: 30, totalItems: 0, totalPages: 1};
}
this.seriesPagination.currentPage = parseInt(page, 10);
}
// Reload page after a series is updated or first load
this.seriesService.getSeriesForTag(this.collectionTag.id, this.seriesPagination?.currentPage, this.seriesPagination?.itemsPerPage).subscribe(tags => {
this.series = tags.result;
this.seriesPagination = tags.pagination;
this.isLoading = false;
window.scrollTo(0, 0);
});
}
updateFilter(data: UpdateFilterEvent) {
this.filter.mangaFormat = data.filterItem.value;
if (this.seriesPagination !== undefined && this.seriesPagination !== null) {
this.seriesPagination.currentPage = 1;
this.onPageChange(this.seriesPagination);
} else {
this.loadPage();
}
}
handleCollectionActionCallback(action: Action, collectionTag: CollectionTag) {
switch (action) {
case(Action.Edit):
this.openEditCollectionTagModal(this.collectionTag);
break;
default:
break;
}
}
openEditCollectionTagModal(collectionTag: CollectionTag) {
const modalRef = this.modalService.open(EditCollectionTagsComponent, { size: 'lg', scrollable: true });
modalRef.componentInstance.tag = this.collectionTag;
modalRef.closed.subscribe((results: {success: boolean, coverImageUpdated: boolean}) => {
this.loadPage();
if (results.coverImageUpdated) {
this.tagImage = this.imageService.randomize(this.imageService.getCollectionCoverImage(collectionTag.id));
}
});
}
}

View File

@ -0,0 +1,24 @@
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { AuthGuard } from '../_guards/auth.guard';
import { AllCollectionsComponent } from './all-collections/all-collections.component';
import { CollectionDetailComponent } from './collection-detail/collection-detail.component';
const routes: Routes = [
{
path: '',
runGuardsAndResolvers: 'always',
canActivate: [AuthGuard],
children: [
{path: '', component: AllCollectionsComponent, pathMatch: 'full'},
{path: ':id', component: CollectionDetailComponent},
]
}
];
@NgModule({
imports: [RouterModule.forChild(routes), ],
exports: [RouterModule]
})
export class CollectionsRoutingModule { }

View File

@ -0,0 +1,23 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { CollectionDetailComponent } from './collection-detail/collection-detail.component';
import { SharedModule } from '../shared/shared.module';
import { CollectionsRoutingModule } from './collections-routing.module';
import { CardsModule } from '../cards/cards.module';
import { AllCollectionsComponent } from './all-collections/all-collections.component';
@NgModule({
declarations: [
AllCollectionsComponent,
CollectionDetailComponent
],
imports: [
CommonModule,
SharedModule,
CardsModule,
CollectionsRoutingModule,
]
})
export class CollectionsModule { }

View File

@ -120,11 +120,10 @@ export class LibraryComponent implements OnInit, OnDestroy {
case(Action.Edit): case(Action.Edit):
const modalRef = this.modalService.open(EditCollectionTagsComponent, { size: 'lg', scrollable: true }); const modalRef = this.modalService.open(EditCollectionTagsComponent, { size: 'lg', scrollable: true });
modalRef.componentInstance.tag = collectionTag; modalRef.componentInstance.tag = collectionTag;
modalRef.closed.subscribe((reloadNeeded: boolean) => { modalRef.closed.subscribe((results: {success: boolean, coverImageUpdated: boolean}) => {
if (reloadNeeded) { this.reloadTags();
// Reload tags if (results.coverImageUpdated) {
this.reloadTags(); collectionTag.coverImage = this.imageService.randomize(collectionTag.coverImage);
collectionTag.coverImage = this.imageService.randomize(this.imageService.getCollectionCoverImage(collectionTag.id));
} }
}); });
break; break;

View File

@ -6,16 +6,16 @@
} }
.poster {
width: 100%;
max-height: 481px;
}
.rating-star { .rating-star {
margin-top: 2px; margin-top: 2px;
font-size: 1.5rem; font-size: 1.5rem;
} }
.poster {
width: 100%;
max-height: 481px;
}
// Including breakpoint has issue with resovling variable, so copying gridpoints here // Including breakpoint has issue with resovling variable, so copying gridpoints here
@include media-breakpoint-down(xs, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px)) { @include media-breakpoint-down(xs, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px)) {