Added a Browse tag page as well. Need to decide how to link to this.

This commit is contained in:
Joseph Milazzo 2025-06-07 05:34:50 -05:00
parent 9f91004f3e
commit 7ef95b3e12
23 changed files with 238 additions and 71 deletions

View file

@ -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>

View 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; }
}

View file

@ -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; }

View file

@ -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();

View file

@ -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;

View file

@ -1,4 +1,4 @@
import {Genre} from "./genre";
import {Genre} from "../genre";
export interface BrowseGenre extends Genre {
seriesCount: number;

View file

@ -0,0 +1,6 @@
import {Tag} from "../../tag";
export interface BrowseTag extends Tag {
seriesCount: number;
issueCount: number;
}

View file

@ -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'},
];

View file

@ -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'},
];

View 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'},
];

View file

@ -16,5 +16,5 @@ export const routes: Routes = [
runGuardsAndResolvers: 'always',
canActivate: [AuthGuard, LibraryAccessGuard],
component: LibraryDetailComponent
}
},
];

View file

@ -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) {

View file

@ -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',

View file

@ -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>

View file

@ -0,0 +1 @@
@use '../../../tag-card-common';

View file

@ -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);

View file

@ -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({

View 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>

View file

@ -0,0 +1 @@
@use '../../../tag-card-common';

View 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();
}
}

View file

@ -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",