Added a Browse tag page as well. Need to decide how to link to this.
This commit is contained in:
parent
9f91004f3e
commit
7ef95b3e12
23 changed files with 238 additions and 71 deletions
|
|
@ -112,6 +112,22 @@ public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService loc
|
|||
return Ok(await unitOfWork.TagRepository.GetAllTagDtosForLibrariesAsync(User.GetUserId()));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a list of Tags with counts for counts when Tag is on Series/Chapter
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
[HttpPost("tags-with-counts")]
|
||||
[ResponseCache(CacheProfileName = ResponseCacheProfiles.FiveMinute)]
|
||||
public async Task<ActionResult<PagedList<BrowseTagDto>>> GetBrowseTags(UserParams? userParams = null)
|
||||
{
|
||||
userParams ??= UserParams.Default;
|
||||
|
||||
var list = await unitOfWork.TagRepository.GetBrowseableTag(User.GetUserId(), userParams);
|
||||
Response.AddPaginationHeader(list.CurrentPage, list.PageSize, list.TotalCount, list.TotalPages);
|
||||
|
||||
return Ok(list);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches all age ratings from the instance
|
||||
/// </summary>
|
||||
|
|
|
|||
15
API/DTOs/Metadata/BrowseTagDto.cs
Normal file
15
API/DTOs/Metadata/BrowseTagDto.cs
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
using API.Entities;
|
||||
|
||||
namespace API.DTOs.Metadata;
|
||||
|
||||
public sealed record BrowseTagDto : TagDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Number of Series this Entity is on
|
||||
/// </summary>
|
||||
public int SeriesCount { get; set; }
|
||||
/// <summary>
|
||||
/// Number of Issues this Entity is on
|
||||
/// </summary>
|
||||
public int IssueCount { get; set; }
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
namespace API.DTOs.Metadata;
|
||||
|
||||
public sealed record TagDto
|
||||
public record TagDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public required string Title { get; set; }
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ using API.DTOs.Metadata;
|
|||
using API.Entities;
|
||||
using API.Extensions;
|
||||
using API.Extensions.QueryExtensions;
|
||||
using API.Helpers;
|
||||
using API.Services.Tasks.Scanner.Parser;
|
||||
using AutoMapper;
|
||||
using AutoMapper.QueryableExtensions;
|
||||
|
|
@ -23,6 +24,7 @@ public interface ITagRepository
|
|||
Task RemoveAllTagNoLongerAssociated();
|
||||
Task<IList<TagDto>> GetAllTagDtosForLibrariesAsync(int userId, IList<int>? libraryIds = null);
|
||||
Task<List<string>> GetAllTagsNotInListAsync(ICollection<string> tags);
|
||||
Task<PagedList<BrowseTagDto>> GetBrowseableTag(int userId, UserParams userParams);
|
||||
}
|
||||
|
||||
public class TagRepository : ITagRepository
|
||||
|
|
@ -104,6 +106,30 @@ public class TagRepository : ITagRepository
|
|||
return missingTags.Select(normalizedName => normalizedToOriginalMap[normalizedName]).ToList();
|
||||
}
|
||||
|
||||
public async Task<PagedList<BrowseTagDto>> GetBrowseableTag(int userId, UserParams userParams)
|
||||
{
|
||||
var ageRating = await _context.AppUser.GetUserAgeRestriction(userId);
|
||||
|
||||
var query = _context.Tag
|
||||
.RestrictAgainstAgeRestriction(ageRating)
|
||||
.Select(g => new BrowseTagDto
|
||||
{
|
||||
Id = g.Id,
|
||||
Title = g.Title,
|
||||
SeriesCount = g.SeriesMetadatas
|
||||
.Select(sm => sm.Id)
|
||||
.Distinct()
|
||||
.Count(),
|
||||
IssueCount = g.Chapters
|
||||
.Select(ch => ch.Id)
|
||||
.Distinct()
|
||||
.Count()
|
||||
})
|
||||
.OrderBy(g => g.Title);
|
||||
|
||||
return await PagedList<BrowseTagDto>.CreateAsync(query, userParams.PageNumber, userParams.PageSize);
|
||||
}
|
||||
|
||||
public async Task<IList<Tag>> GetAllTagsAsync()
|
||||
{
|
||||
return await _context.Tag.ToListAsync();
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
.genre-card {
|
||||
.tag-card {
|
||||
background-color: var(--bs-card-color, #2c2c2c);
|
||||
padding: 1rem;
|
||||
border-radius: 12px;
|
||||
|
|
@ -7,12 +7,12 @@
|
|||
cursor: pointer;
|
||||
}
|
||||
|
||||
.genre-card:hover {
|
||||
.tag-card:hover {
|
||||
background-color: #3a3a3a;
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
|
||||
.genre-name {
|
||||
.tag-name {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
|
|
@ -22,7 +22,7 @@
|
|||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.genre-meta {
|
||||
.tag-meta {
|
||||
font-size: 0.85rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import {Genre} from "./genre";
|
||||
import {Genre} from "../genre";
|
||||
|
||||
export interface BrowseGenre extends Genre {
|
||||
seriesCount: number;
|
||||
6
UI/Web/src/app/_models/metadata/browse/browse-tag.ts
Normal file
6
UI/Web/src/app/_models/metadata/browse/browse-tag.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import {Tag} from "../../tag";
|
||||
|
||||
export interface BrowseTag extends Tag {
|
||||
seriesCount: number;
|
||||
issueCount: number;
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
import {Routes} from "@angular/router";
|
||||
import {BrowseAuthorsComponent} from "../browse-people/browse-authors.component";
|
||||
|
||||
|
||||
export const routes: Routes = [
|
||||
{path: '', component: BrowseAuthorsComponent, pathMatch: 'full'},
|
||||
];
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
import {Routes} from "@angular/router";
|
||||
import {BrowseGenresComponent} from "../all-genres/browse-genres.component";
|
||||
|
||||
|
||||
export const routes: Routes = [
|
||||
{path: '', component: BrowseGenresComponent, pathMatch: 'full'},
|
||||
];
|
||||
11
UI/Web/src/app/_routes/browse-routing.module.ts
Normal file
11
UI/Web/src/app/_routes/browse-routing.module.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import {Routes} from "@angular/router";
|
||||
import {BrowseAuthorsComponent} from "../browse/browse-people/browse-authors.component";
|
||||
import {BrowseGenresComponent} from "../browse/browse-genres/browse-genres.component";
|
||||
import {BrowseTagsComponent} from "../browse/browse-tags/browse-tags.component";
|
||||
|
||||
|
||||
export const routes: Routes = [
|
||||
{path: 'authors', component: BrowseAuthorsComponent, pathMatch: 'full'},
|
||||
{path: 'genres', component: BrowseGenresComponent, pathMatch: 'full'},
|
||||
{path: 'tags', component: BrowseTagsComponent, pathMatch: 'full'},
|
||||
];
|
||||
|
|
@ -16,5 +16,5 @@ export const routes: Routes = [
|
|||
runGuardsAndResolvers: 'always',
|
||||
canActivate: [AuthGuard, LibraryAccessGuard],
|
||||
component: LibraryDetailComponent
|
||||
}
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -27,7 +27,8 @@ import {LibraryService} from './library.service';
|
|||
import {CollectionTagService} from "./collection-tag.service";
|
||||
import {PaginatedResult} from "../_models/pagination";
|
||||
import {UtilityService} from "../shared/_services/utility.service";
|
||||
import {BrowseGenre} from "../_models/metadata/browse-genre";
|
||||
import {BrowseGenre} from "../_models/metadata/browse/browse-genre";
|
||||
import {BrowseTag} from "../_models/metadata/browse/browse-tag";
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
|
|
@ -100,6 +101,17 @@ export class MetadataService {
|
|||
);
|
||||
}
|
||||
|
||||
getTagWithCounts(pageNum?: number, itemsPerPage?: number) {
|
||||
let params = new HttpParams();
|
||||
params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage);
|
||||
|
||||
return this.httpClient.post<PaginatedResult<BrowseTag[]>>(this.baseUrl + 'metadata/tags-with-counts', {}, {observe: 'response', params}).pipe(
|
||||
map((response: any) => {
|
||||
return this.utilityService.createPaginatedResult(response) as PaginatedResult<BrowseTag[]>;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
getAllLanguages(libraries?: Array<number>) {
|
||||
let method = 'metadata/languages'
|
||||
if (libraries != undefined && libraries.length > 0) {
|
||||
|
|
|
|||
|
|
@ -50,12 +50,8 @@ const routes: Routes = [
|
|||
loadChildren: () => import('./_routes/person-detail-routing.module').then(m => m.routes)
|
||||
},
|
||||
{
|
||||
path: 'browse/authors',
|
||||
loadChildren: () => import('./_routes/browse-authors-routing.module').then(m => m.routes)
|
||||
},
|
||||
{
|
||||
path: 'browse/genres',
|
||||
loadChildren: () => import('./_routes/browse-genres-routing.module').then(m => m.routes)
|
||||
path: 'browse',
|
||||
loadChildren: () => import('./_routes/browse-routing.module').then(m => m.routes)
|
||||
},
|
||||
{
|
||||
path: 'library',
|
||||
|
|
|
|||
|
|
@ -19,11 +19,11 @@
|
|||
>
|
||||
<ng-template #cardItem let-item let-position="idx">
|
||||
|
||||
<div class="genre-card" (click)="openFilter(FilterField.Genres, item.id)">
|
||||
<div class="genre-name">{{ item.title }}</div>
|
||||
<div class="genre-meta">
|
||||
<span>{{ item.seriesCount }} Series</span>
|
||||
<span>{{ item.issueCount }} Issues</span>
|
||||
<div class="tag-card" (click)="openFilter(FilterField.Genres, item.id)">
|
||||
<div class="tag-name">{{ item.title }}</div>
|
||||
<div class="tag-meta">
|
||||
<span>{{t('series-count', {num: item.seriesCount | compactNumber})}}</span>
|
||||
<span>{{t('issue-count', {num: item.issueCount | compactNumber})}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -0,0 +1 @@
|
|||
@use '../../../tag-card-common';
|
||||
|
|
@ -1,35 +1,29 @@
|
|||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
DestroyRef,
|
||||
EventEmitter,
|
||||
inject,
|
||||
OnInit
|
||||
} from '@angular/core';
|
||||
import {CardDetailLayoutComponent} from "../cards/card-detail-layout/card-detail-layout.component";
|
||||
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, inject, OnInit} from '@angular/core';
|
||||
import {CardDetailLayoutComponent} from "../../cards/card-detail-layout/card-detail-layout.component";
|
||||
import {DecimalPipe} from "@angular/common";
|
||||
import {
|
||||
SideNavCompanionBarComponent
|
||||
} from "../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component";
|
||||
} from "../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component";
|
||||
import {TranslocoDirective} from "@jsverse/transloco";
|
||||
import {JumpbarService} from "../_services/jumpbar.service";
|
||||
import {BrowsePerson} from "../_models/person/browse-person";
|
||||
import {Pagination} from "../_models/pagination";
|
||||
import {JumpKey} from "../_models/jumpbar/jump-key";
|
||||
import {MetadataService} from "../_services/metadata.service";
|
||||
import {BrowseGenre} from "../_models/metadata/browse-genre";
|
||||
import {FilterField} from "../_models/metadata/v2/filter-field";
|
||||
import {FilterComparison} from "../_models/metadata/v2/filter-comparison";
|
||||
import {FilterUtilitiesService} from "../shared/_services/filter-utilities.service";
|
||||
import {JumpbarService} from "../../_services/jumpbar.service";
|
||||
import {BrowsePerson} from "../../_models/person/browse-person";
|
||||
import {Pagination} from "../../_models/pagination";
|
||||
import {JumpKey} from "../../_models/jumpbar/jump-key";
|
||||
import {MetadataService} from "../../_services/metadata.service";
|
||||
import {BrowseGenre} from "../../_models/metadata/browse/browse-genre";
|
||||
import {FilterField} from "../../_models/metadata/v2/filter-field";
|
||||
import {FilterComparison} from "../../_models/metadata/v2/filter-comparison";
|
||||
import {FilterUtilitiesService} from "../../shared/_services/filter-utilities.service";
|
||||
import {CompactNumberPipe} from "../../_pipes/compact-number.pipe";
|
||||
|
||||
@Component({
|
||||
selector: 'app-all-genres',
|
||||
selector: 'app-browse-genres',
|
||||
imports: [
|
||||
CardDetailLayoutComponent,
|
||||
DecimalPipe,
|
||||
SideNavCompanionBarComponent,
|
||||
TranslocoDirective
|
||||
TranslocoDirective,
|
||||
CompactNumberPipe
|
||||
],
|
||||
templateUrl: './browse-genres.component.html',
|
||||
styleUrl: './browse-genres.component.scss',
|
||||
|
|
@ -39,7 +33,6 @@ export class BrowseGenresComponent implements OnInit {
|
|||
|
||||
protected readonly FilterField = FilterField;
|
||||
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
private readonly metadataService = inject(MetadataService);
|
||||
private readonly jumpbarService = inject(JumpbarService);
|
||||
|
|
@ -9,20 +9,20 @@ import {
|
|||
} from '@angular/core';
|
||||
import {
|
||||
SideNavCompanionBarComponent
|
||||
} from "../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component";
|
||||
import {CardDetailLayoutComponent} from "../cards/card-detail-layout/card-detail-layout.component";
|
||||
} from "../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component";
|
||||
import {CardDetailLayoutComponent} from "../../cards/card-detail-layout/card-detail-layout.component";
|
||||
import {DecimalPipe} from "@angular/common";
|
||||
import {Series} from "../_models/series";
|
||||
import {Pagination} from "../_models/pagination";
|
||||
import {JumpKey} from "../_models/jumpbar/jump-key";
|
||||
import {Series} from "../../_models/series";
|
||||
import {Pagination} from "../../_models/pagination";
|
||||
import {JumpKey} from "../../_models/jumpbar/jump-key";
|
||||
import {Router} from "@angular/router";
|
||||
import {PersonService} from "../_services/person.service";
|
||||
import {BrowsePerson} from "../_models/person/browse-person";
|
||||
import {JumpbarService} from "../_services/jumpbar.service";
|
||||
import {PersonCardComponent} from "../cards/person-card/person-card.component";
|
||||
import {ImageService} from "../_services/image.service";
|
||||
import {PersonService} from "../../_services/person.service";
|
||||
import {BrowsePerson} from "../../_models/person/browse-person";
|
||||
import {JumpbarService} from "../../_services/jumpbar.service";
|
||||
import {PersonCardComponent} from "../../cards/person-card/person-card.component";
|
||||
import {ImageService} from "../../_services/image.service";
|
||||
import {TranslocoDirective} from "@jsverse/transloco";
|
||||
import {CompactNumberPipe} from "../_pipes/compact-number.pipe";
|
||||
import {CompactNumberPipe} from "../../_pipes/compact-number.pipe";
|
||||
|
||||
|
||||
@Component({
|
||||
34
UI/Web/src/app/browse/browse-tags/browse-tags.component.html
Normal file
34
UI/Web/src/app/browse/browse-tags/browse-tags.component.html
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<div class="main-container container-fluid">
|
||||
<ng-container *transloco="let t; read:'browse-tags'" >
|
||||
<app-side-nav-companion-bar [hasFilter]="false">
|
||||
<h2 title>
|
||||
<span>{{t('title')}}</span>
|
||||
</h2>
|
||||
<h6 subtitle>{{t('genre-count', {num: pagination.totalItems | number})}} </h6>
|
||||
|
||||
</app-side-nav-companion-bar>
|
||||
|
||||
<app-card-detail-layout
|
||||
[isLoading]="isLoading"
|
||||
[items]="tags"
|
||||
[pagination]="pagination"
|
||||
[trackByIdentity]="trackByIdentity"
|
||||
[jumpBarKeys]="jumpKeys"
|
||||
[filteringDisabled]="true"
|
||||
[refresh]="refresh"
|
||||
>
|
||||
<ng-template #cardItem let-item let-position="idx">
|
||||
|
||||
<div class="tag-card" (click)="openFilter(FilterField.Tags, item.id)">
|
||||
<div class="tag-name">{{ item.title }}</div>
|
||||
<div class="tag-meta">
|
||||
<span>{{t('series-count', {num: item.seriesCount | compactNumber})}}</span>
|
||||
<span>{{t('issue-count', {num: item.issueCount | compactNumber})}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</ng-template>
|
||||
</app-card-detail-layout>
|
||||
|
||||
</ng-container>
|
||||
</div>
|
||||
|
|
@ -0,0 +1 @@
|
|||
@use '../../../tag-card-common';
|
||||
63
UI/Web/src/app/browse/browse-tags/browse-tags.component.ts
Normal file
63
UI/Web/src/app/browse/browse-tags/browse-tags.component.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, inject, OnInit} from '@angular/core';
|
||||
import {CardDetailLayoutComponent} from "../../cards/card-detail-layout/card-detail-layout.component";
|
||||
import {DecimalPipe} from "@angular/common";
|
||||
import {
|
||||
SideNavCompanionBarComponent
|
||||
} from "../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component";
|
||||
import {TranslocoDirective} from "@jsverse/transloco";
|
||||
import {MetadataService} from "../../_services/metadata.service";
|
||||
import {JumpbarService} from "../../_services/jumpbar.service";
|
||||
import {FilterUtilitiesService} from "../../shared/_services/filter-utilities.service";
|
||||
import {BrowseGenre} from "../../_models/metadata/browse/browse-genre";
|
||||
import {Pagination} from "../../_models/pagination";
|
||||
import {JumpKey} from "../../_models/jumpbar/jump-key";
|
||||
import {BrowsePerson} from "../../_models/person/browse-person";
|
||||
import {FilterField} from "../../_models/metadata/v2/filter-field";
|
||||
import {FilterComparison} from "../../_models/metadata/v2/filter-comparison";
|
||||
import {BrowseTag} from "../../_models/metadata/browse/browse-tag";
|
||||
import {CompactNumberPipe} from "../../_pipes/compact-number.pipe";
|
||||
|
||||
@Component({
|
||||
selector: 'app-browse-tags',
|
||||
imports: [
|
||||
CardDetailLayoutComponent,
|
||||
DecimalPipe,
|
||||
SideNavCompanionBarComponent,
|
||||
TranslocoDirective,
|
||||
CompactNumberPipe
|
||||
],
|
||||
templateUrl: './browse-tags.component.html',
|
||||
styleUrl: './browse-tags.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class BrowseTagsComponent implements OnInit {
|
||||
protected readonly FilterField = FilterField;
|
||||
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
private readonly metadataService = inject(MetadataService);
|
||||
private readonly jumpbarService = inject(JumpbarService);
|
||||
protected readonly filterUtilityService = inject(FilterUtilitiesService);
|
||||
|
||||
isLoading = false;
|
||||
tags: Array<BrowseTag> = [];
|
||||
pagination: Pagination = {currentPage: 0, totalPages: 0, totalItems: 0, itemsPerPage: 0};
|
||||
refresh: EventEmitter<void> = new EventEmitter();
|
||||
jumpKeys: Array<JumpKey> = [];
|
||||
trackByIdentity = (index: number, item: BrowsePerson) => `${item.id}`;
|
||||
|
||||
ngOnInit() {
|
||||
this.isLoading = true;
|
||||
this.cdRef.markForCheck();
|
||||
this.metadataService.getTagWithCounts(undefined, undefined).subscribe(d => {
|
||||
this.tags = d.result;
|
||||
this.pagination = d.pagination;
|
||||
this.jumpKeys = this.jumpbarService.getJumpKeys(this.tags, (d: BrowseGenre) => d.title);
|
||||
this.isLoading = false;
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
}
|
||||
|
||||
openFilter(field: FilterField, value: string | number) {
|
||||
this.filterUtilityService.applyFilter(['all-series'], field, FilterComparison.Equal, `${value}`).subscribe();
|
||||
}
|
||||
}
|
||||
|
|
@ -1118,6 +1118,13 @@
|
|||
"series-count": "{{common.series-count}}"
|
||||
},
|
||||
|
||||
"browse-tags": {
|
||||
"title": "Browse Tags",
|
||||
"genre-count": "{{num}} Tags",
|
||||
"issue-count": "{{common.issue-count}}",
|
||||
"series-count": "{{common.series-count}}"
|
||||
},
|
||||
|
||||
"person-detail": {
|
||||
"aka-title": "Also known as ",
|
||||
"known-for-title": "Known For",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue