diff --git a/API/Controllers/MetadataController.cs b/API/Controllers/MetadataController.cs index 4ab44185f..a37b3ae30 100644 --- a/API/Controllers/MetadataController.cs +++ b/API/Controllers/MetadataController.cs @@ -112,6 +112,22 @@ public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService loc return Ok(await unitOfWork.TagRepository.GetAllTagDtosForLibrariesAsync(User.GetUserId())); } + /// + /// Returns a list of Tags with counts for counts when Tag is on Series/Chapter + /// + /// + [HttpPost("tags-with-counts")] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.FiveMinute)] + public async Task>> 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); + } + /// /// Fetches all age ratings from the instance /// diff --git a/API/DTOs/Metadata/BrowseTagDto.cs b/API/DTOs/Metadata/BrowseTagDto.cs new file mode 100644 index 000000000..3bf45418e --- /dev/null +++ b/API/DTOs/Metadata/BrowseTagDto.cs @@ -0,0 +1,15 @@ +using API.Entities; + +namespace API.DTOs.Metadata; + +public sealed record BrowseTagDto : TagDto +{ + /// + /// Number of Series this Entity is on + /// + public int SeriesCount { get; set; } + /// + /// Number of Issues this Entity is on + /// + public int IssueCount { get; set; } +} diff --git a/API/DTOs/Metadata/TagDto.cs b/API/DTOs/Metadata/TagDto.cs index f8deb6913..f5c925e1f 100644 --- a/API/DTOs/Metadata/TagDto.cs +++ b/API/DTOs/Metadata/TagDto.cs @@ -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; } diff --git a/API/Data/Repositories/TagRepository.cs b/API/Data/Repositories/TagRepository.cs index c4f189957..a79f9780f 100644 --- a/API/Data/Repositories/TagRepository.cs +++ b/API/Data/Repositories/TagRepository.cs @@ -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> GetAllTagDtosForLibrariesAsync(int userId, IList? libraryIds = null); Task> GetAllTagsNotInListAsync(ICollection tags); + Task> 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> 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.CreateAsync(query, userParams.PageNumber, userParams.PageSize); + } + public async Task> GetAllTagsAsync() { return await _context.Tag.ToListAsync(); diff --git a/UI/Web/src/app/all-genres/browse-genres.component.scss b/UI/Web/src/_tag-card-common.scss similarity index 89% rename from UI/Web/src/app/all-genres/browse-genres.component.scss rename to UI/Web/src/_tag-card-common.scss index 65c728ba9..99a005b04 100644 --- a/UI/Web/src/app/all-genres/browse-genres.component.scss +++ b/UI/Web/src/_tag-card-common.scss @@ -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; diff --git a/UI/Web/src/app/_models/metadata/browse-genre.ts b/UI/Web/src/app/_models/metadata/browse/browse-genre.ts similarity index 74% rename from UI/Web/src/app/_models/metadata/browse-genre.ts rename to UI/Web/src/app/_models/metadata/browse/browse-genre.ts index 5ab5f628d..ce314ffc3 100644 --- a/UI/Web/src/app/_models/metadata/browse-genre.ts +++ b/UI/Web/src/app/_models/metadata/browse/browse-genre.ts @@ -1,4 +1,4 @@ -import {Genre} from "./genre"; +import {Genre} from "../genre"; export interface BrowseGenre extends Genre { seriesCount: number; diff --git a/UI/Web/src/app/_models/metadata/browse/browse-tag.ts b/UI/Web/src/app/_models/metadata/browse/browse-tag.ts new file mode 100644 index 000000000..caca48eb2 --- /dev/null +++ b/UI/Web/src/app/_models/metadata/browse/browse-tag.ts @@ -0,0 +1,6 @@ +import {Tag} from "../../tag"; + +export interface BrowseTag extends Tag { + seriesCount: number; + issueCount: number; +} diff --git a/UI/Web/src/app/_routes/browse-authors-routing.module.ts b/UI/Web/src/app/_routes/browse-authors-routing.module.ts deleted file mode 100644 index 1babde5c5..000000000 --- a/UI/Web/src/app/_routes/browse-authors-routing.module.ts +++ /dev/null @@ -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'}, -]; diff --git a/UI/Web/src/app/_routes/browse-genres-routing.module.ts b/UI/Web/src/app/_routes/browse-genres-routing.module.ts deleted file mode 100644 index ddf42d6cc..000000000 --- a/UI/Web/src/app/_routes/browse-genres-routing.module.ts +++ /dev/null @@ -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'}, -]; diff --git a/UI/Web/src/app/_routes/browse-routing.module.ts b/UI/Web/src/app/_routes/browse-routing.module.ts new file mode 100644 index 000000000..48d468fc1 --- /dev/null +++ b/UI/Web/src/app/_routes/browse-routing.module.ts @@ -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'}, +]; diff --git a/UI/Web/src/app/_routes/library-detail-routing.module.ts b/UI/Web/src/app/_routes/library-detail-routing.module.ts index 04cb3c9dd..7f2f4150c 100644 --- a/UI/Web/src/app/_routes/library-detail-routing.module.ts +++ b/UI/Web/src/app/_routes/library-detail-routing.module.ts @@ -1,7 +1,7 @@ -import { Routes } from '@angular/router'; -import { AuthGuard } from '../_guards/auth.guard'; -import { LibraryAccessGuard } from '../_guards/library-access.guard'; -import { LibraryDetailComponent } from '../library-detail/library-detail.component'; +import {Routes} from '@angular/router'; +import {AuthGuard} from '../_guards/auth.guard'; +import {LibraryAccessGuard} from '../_guards/library-access.guard'; +import {LibraryDetailComponent} from '../library-detail/library-detail.component'; export const routes: Routes = [ @@ -16,5 +16,5 @@ export const routes: Routes = [ runGuardsAndResolvers: 'always', canActivate: [AuthGuard, LibraryAccessGuard], component: LibraryDetailComponent - } + }, ]; diff --git a/UI/Web/src/app/_services/metadata.service.ts b/UI/Web/src/app/_services/metadata.service.ts index 109d06fd9..9034ab303 100644 --- a/UI/Web/src/app/_services/metadata.service.ts +++ b/UI/Web/src/app/_services/metadata.service.ts @@ -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>(this.baseUrl + 'metadata/tags-with-counts', {}, {observe: 'response', params}).pipe( + map((response: any) => { + return this.utilityService.createPaginatedResult(response) as PaginatedResult; + }) + ); + } + getAllLanguages(libraries?: Array) { let method = 'metadata/languages' if (libraries != undefined && libraries.length > 0) { diff --git a/UI/Web/src/app/app-routing.module.ts b/UI/Web/src/app/app-routing.module.ts index b7fae4288..35e11c0f0 100644 --- a/UI/Web/src/app/app-routing.module.ts +++ b/UI/Web/src/app/app-routing.module.ts @@ -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', diff --git a/UI/Web/src/app/all-genres/browse-genres.component.html b/UI/Web/src/app/browse/browse-genres/browse-genres.component.html similarity index 68% rename from UI/Web/src/app/all-genres/browse-genres.component.html rename to UI/Web/src/app/browse/browse-genres/browse-genres.component.html index 336ef4c22..4ca0c51bd 100644 --- a/UI/Web/src/app/all-genres/browse-genres.component.html +++ b/UI/Web/src/app/browse/browse-genres/browse-genres.component.html @@ -19,11 +19,11 @@ > -
-
{{ item.title }}
-
- {{ item.seriesCount }} Series - {{ item.issueCount }} Issues +
+
{{ item.title }}
+
+ {{t('series-count', {num: item.seriesCount | compactNumber})}} + {{t('issue-count', {num: item.issueCount | compactNumber})}}
diff --git a/UI/Web/src/app/browse/browse-genres/browse-genres.component.scss b/UI/Web/src/app/browse/browse-genres/browse-genres.component.scss new file mode 100644 index 000000000..90c313b37 --- /dev/null +++ b/UI/Web/src/app/browse/browse-genres/browse-genres.component.scss @@ -0,0 +1 @@ +@use '../../../tag-card-common'; diff --git a/UI/Web/src/app/all-genres/browse-genres.component.ts b/UI/Web/src/app/browse/browse-genres/browse-genres.component.ts similarity index 60% rename from UI/Web/src/app/all-genres/browse-genres.component.ts rename to UI/Web/src/app/browse/browse-genres/browse-genres.component.ts index b5d92f396..d854c5385 100644 --- a/UI/Web/src/app/all-genres/browse-genres.component.ts +++ b/UI/Web/src/app/browse/browse-genres/browse-genres.component.ts @@ -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); diff --git a/UI/Web/src/app/browse-people/browse-authors.component.html b/UI/Web/src/app/browse/browse-people/browse-authors.component.html similarity index 100% rename from UI/Web/src/app/browse-people/browse-authors.component.html rename to UI/Web/src/app/browse/browse-people/browse-authors.component.html diff --git a/UI/Web/src/app/browse-people/browse-authors.component.scss b/UI/Web/src/app/browse/browse-people/browse-authors.component.scss similarity index 100% rename from UI/Web/src/app/browse-people/browse-authors.component.scss rename to UI/Web/src/app/browse/browse-people/browse-authors.component.scss diff --git a/UI/Web/src/app/browse-people/browse-authors.component.ts b/UI/Web/src/app/browse/browse-people/browse-authors.component.ts similarity index 71% rename from UI/Web/src/app/browse-people/browse-authors.component.ts rename to UI/Web/src/app/browse/browse-people/browse-authors.component.ts index dd80e4a67..91bd61b57 100644 --- a/UI/Web/src/app/browse-people/browse-authors.component.ts +++ b/UI/Web/src/app/browse/browse-people/browse-authors.component.ts @@ -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({ diff --git a/UI/Web/src/app/browse/browse-tags/browse-tags.component.html b/UI/Web/src/app/browse/browse-tags/browse-tags.component.html new file mode 100644 index 000000000..a6b740c3d --- /dev/null +++ b/UI/Web/src/app/browse/browse-tags/browse-tags.component.html @@ -0,0 +1,34 @@ +
+ + +

+ {{t('title')}} +

+
{{t('genre-count', {num: pagination.totalItems | number})}}
+ +
+ + + + +
+
{{ item.title }}
+
+ {{t('series-count', {num: item.seriesCount | compactNumber})}} + {{t('issue-count', {num: item.issueCount | compactNumber})}} +
+
+ +
+
+ +
+
diff --git a/UI/Web/src/app/browse/browse-tags/browse-tags.component.scss b/UI/Web/src/app/browse/browse-tags/browse-tags.component.scss new file mode 100644 index 000000000..90c313b37 --- /dev/null +++ b/UI/Web/src/app/browse/browse-tags/browse-tags.component.scss @@ -0,0 +1 @@ +@use '../../../tag-card-common'; diff --git a/UI/Web/src/app/browse/browse-tags/browse-tags.component.ts b/UI/Web/src/app/browse/browse-tags/browse-tags.component.ts new file mode 100644 index 000000000..58a246bfa --- /dev/null +++ b/UI/Web/src/app/browse/browse-tags/browse-tags.component.ts @@ -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 = []; + pagination: Pagination = {currentPage: 0, totalPages: 0, totalItems: 0, itemsPerPage: 0}; + refresh: EventEmitter = new EventEmitter(); + jumpKeys: Array = []; + 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(); + } +} diff --git a/UI/Web/src/assets/langs/en.json b/UI/Web/src/assets/langs/en.json index ed4f38dbb..483c864cf 100644 --- a/UI/Web/src/assets/langs/en.json +++ b/UI/Web/src/assets/langs/en.json @@ -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",