Added the ability to browse different genres (still needs polish).
Fixed a rare bug with crypto.UUID by backfilling it.
This commit is contained in:
parent
d400938610
commit
2e80316057
19 changed files with 288 additions and 33 deletions
|
|
@ -6,6 +6,7 @@ using System.Threading.Tasks;
|
|||
using API.Constants;
|
||||
using API.Data;
|
||||
using API.Data.Repositories;
|
||||
using API.DTOs;
|
||||
using API.DTOs.Filtering;
|
||||
using API.DTOs.Metadata;
|
||||
using API.DTOs.Person;
|
||||
|
|
@ -46,6 +47,22 @@ public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService loc
|
|||
return Ok(await unitOfWork.GenreRepository.GetAllGenreDtosForLibrariesAsync(User.GetUserId(), ids, context));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a list of Genres with counts for counts when Genre is on Series/Chapter
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
[HttpPost("genres-with-counts")]
|
||||
[ResponseCache(CacheProfileName = ResponseCacheProfiles.FiveMinute)]
|
||||
public async Task<ActionResult<PagedList<BrowseGenreDto>>> GetBrowseGenres(UserParams? userParams = null)
|
||||
{
|
||||
userParams ??= UserParams.Default;
|
||||
|
||||
var list = await unitOfWork.GenreRepository.GetBrowseableGenre(User.GetUserId(), userParams);
|
||||
Response.AddPaginationHeader(list.CurrentPage, list.PageSize, list.TotalCount, list.TotalPages);
|
||||
|
||||
return Ok(list);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches people from the instance by role
|
||||
/// </summary>
|
||||
|
|
|
|||
13
API/DTOs/Metadata/BrowseGenreDto.cs
Normal file
13
API/DTOs/Metadata/BrowseGenreDto.cs
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
namespace API.DTOs.Metadata;
|
||||
|
||||
public sealed record BrowseGenreDto : GenreTagDto
|
||||
{
|
||||
/// <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 GenreTagDto
|
||||
public record GenreTagDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public required string Title { get; set; }
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ public class BrowsePersonDto : PersonDto
|
|||
/// </summary>
|
||||
public int SeriesCount { get; set; }
|
||||
/// <summary>
|
||||
/// Number or Issues this Person is the Writer for
|
||||
/// Number of Issues this Person is the Writer for
|
||||
/// </summary>
|
||||
public int IssueCount { get; set; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,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;
|
||||
|
|
@ -27,6 +28,7 @@ public interface IGenreRepository
|
|||
Task<GenreTagDto> GetRandomGenre();
|
||||
Task<GenreTagDto> GetGenreById(int id);
|
||||
Task<List<string>> GetAllGenresNotInListAsync(ICollection<string> genreNames);
|
||||
Task<PagedList<BrowseGenreDto>> GetBrowseableGenre(int userId, UserParams userParams);
|
||||
}
|
||||
|
||||
public class GenreRepository : IGenreRepository
|
||||
|
|
@ -165,4 +167,28 @@ public class GenreRepository : IGenreRepository
|
|||
// Return the original non-normalized genres for the missing ones
|
||||
return missingGenres.Select(normalizedName => normalizedToOriginalMap[normalizedName]).ToList();
|
||||
}
|
||||
|
||||
public async Task<PagedList<BrowseGenreDto>> GetBrowseableGenre(int userId, UserParams userParams)
|
||||
{
|
||||
var ageRating = await _context.AppUser.GetUserAgeRestriction(userId);
|
||||
|
||||
var query = _context.Genre
|
||||
.RestrictAgainstAgeRestriction(ageRating)
|
||||
.Select(g => new BrowseGenreDto
|
||||
{
|
||||
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<BrowseGenreDto>.CreateAsync(query, userParams.PageNumber, userParams.PageSize);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ using System.Linq;
|
|||
using System.Text.RegularExpressions;
|
||||
using API.Data.Misc;
|
||||
using API.Entities.Enums;
|
||||
using API.Entities.Metadata;
|
||||
|
||||
namespace API.Extensions;
|
||||
#nullable enable
|
||||
|
|
@ -42,4 +43,16 @@ public static class EnumerableExtensions
|
|||
|
||||
return q;
|
||||
}
|
||||
|
||||
public static IEnumerable<SeriesMetadata> RestrictAgainstAgeRestriction(this IEnumerable<SeriesMetadata> items, AgeRestriction restriction)
|
||||
{
|
||||
if (restriction.AgeRating == AgeRating.NotApplicable) return items;
|
||||
var q = items.Where(s => s.AgeRating <= restriction.AgeRating);
|
||||
if (!restriction.IncludeUnknowns)
|
||||
{
|
||||
return q.Where(s => s.AgeRating != AgeRating.Unknown);
|
||||
}
|
||||
|
||||
return q;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ using System.Linq;
|
|||
using API.Data.Misc;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Entities.Metadata;
|
||||
using API.Entities.Person;
|
||||
|
||||
namespace API.Extensions.QueryExtensions;
|
||||
|
|
@ -26,6 +27,19 @@ public static class RestrictByAgeExtensions
|
|||
return q;
|
||||
}
|
||||
|
||||
public static IQueryable<SeriesMetadata> RestrictAgainstAgeRestriction(this IQueryable<SeriesMetadata> queryable, AgeRestriction restriction)
|
||||
{
|
||||
if (restriction.AgeRating == AgeRating.NotApplicable) return queryable;
|
||||
var q = queryable.Where(s => s.AgeRating <= restriction.AgeRating);
|
||||
|
||||
if (!restriction.IncludeUnknowns)
|
||||
{
|
||||
return q.Where(s => s.AgeRating != AgeRating.Unknown);
|
||||
}
|
||||
|
||||
return q;
|
||||
}
|
||||
|
||||
public static IQueryable<Chapter> RestrictAgainstAgeRestriction(this IQueryable<Chapter> queryable, AgeRestriction restriction)
|
||||
{
|
||||
if (restriction.AgeRating == AgeRating.NotApplicable) return queryable;
|
||||
|
|
|
|||
6
UI/Web/src/app/_models/metadata/browse-genre.ts
Normal file
6
UI/Web/src/app/_models/metadata/browse-genre.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import {Genre} from "./genre";
|
||||
|
||||
export interface BrowseGenre extends Genre {
|
||||
seriesCount: number;
|
||||
issueCount: number;
|
||||
}
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
import { Routes } from "@angular/router";
|
||||
import { AllSeriesComponent } from "../all-series/_components/all-series/all-series.component";
|
||||
import {Routes} from "@angular/router";
|
||||
import {BrowseAuthorsComponent} from "../browse-people/browse-authors.component";
|
||||
|
||||
|
||||
|
|
|
|||
7
UI/Web/src/app/_routes/browse-genres-routing.module.ts
Normal file
7
UI/Web/src/app/_routes/browse-genres-routing.module.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import {Routes} from "@angular/router";
|
||||
import {BrowseGenresComponent} from "../all-genres/browse-genres.component";
|
||||
|
||||
|
||||
export const routes: Routes = [
|
||||
{path: '', component: BrowseGenresComponent, pathMatch: 'full'},
|
||||
];
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
import { JumpKey } from '../_models/jumpbar/jump-key';
|
||||
import {Injectable} from '@angular/core';
|
||||
import {JumpKey} from '../_models/jumpbar/jump-key';
|
||||
|
||||
const keySize = 25; // Height of the JumpBar button
|
||||
|
||||
|
|
@ -105,14 +105,18 @@ export class JumpbarService {
|
|||
getJumpKeys(data :Array<any>, keySelector: (data: any) => string) {
|
||||
const keys: {[key: string]: number} = {};
|
||||
data.forEach(obj => {
|
||||
let ch = keySelector(obj).charAt(0).toUpperCase();
|
||||
if (/\d|\#|!|%|@|\(|\)|\^|\.|_|\*/g.test(ch)) {
|
||||
ch = '#';
|
||||
try {
|
||||
let ch = keySelector(obj).charAt(0).toUpperCase();
|
||||
if (/\d|\#|!|%|@|\(|\)|\^|\.|_|\*/g.test(ch)) {
|
||||
ch = '#';
|
||||
}
|
||||
if (!keys.hasOwnProperty(ch)) {
|
||||
keys[ch] = 0;
|
||||
}
|
||||
keys[ch] += 1;
|
||||
} catch (e) {
|
||||
console.error('Failed to calculate jump key for ', obj, e);
|
||||
}
|
||||
if (!keys.hasOwnProperty(ch)) {
|
||||
keys[ch] = 0;
|
||||
}
|
||||
keys[ch] += 1;
|
||||
});
|
||||
return Object.keys(keys).map(k => {
|
||||
k = k.toUpperCase();
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import {HttpClient} from '@angular/common/http';
|
||||
import {HttpClient, HttpParams} from '@angular/common/http';
|
||||
import {inject, Injectable} from '@angular/core';
|
||||
import {tap} from 'rxjs/operators';
|
||||
import {map, of} from 'rxjs';
|
||||
|
|
@ -25,6 +25,9 @@ import {MangaFormatPipe} from "../_pipes/manga-format.pipe";
|
|||
import {TranslocoService} from "@jsverse/transloco";
|
||||
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";
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
|
|
@ -34,6 +37,7 @@ export class MetadataService {
|
|||
private readonly translocoService = inject(TranslocoService);
|
||||
private readonly libraryService = inject(LibraryService);
|
||||
private readonly collectionTagService = inject(CollectionTagService);
|
||||
private readonly utilityService = inject(UtilityService);
|
||||
|
||||
baseUrl = environment.apiUrl;
|
||||
private validLanguages: Array<Language> = [];
|
||||
|
|
@ -85,6 +89,17 @@ export class MetadataService {
|
|||
return this.httpClient.get<Array<Genre>>(this.baseUrl + method);
|
||||
}
|
||||
|
||||
getGenreWithCounts(pageNum?: number, itemsPerPage?: number) {
|
||||
let params = new HttpParams();
|
||||
params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage);
|
||||
|
||||
return this.httpClient.post<PaginatedResult<BrowseGenre[]>>(this.baseUrl + 'metadata/genres-with-counts', {}, {observe: 'response', params}).pipe(
|
||||
map((response: any) => {
|
||||
return this.utilityService.createPaginatedResult(response) as PaginatedResult<BrowseGenre[]>;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
getAllLanguages(libraries?: Array<number>) {
|
||||
let method = 'metadata/languages'
|
||||
if (libraries != undefined && libraries.length > 0) {
|
||||
|
|
|
|||
34
UI/Web/src/app/all-genres/browse-genres.component.html
Normal file
34
UI/Web/src/app/all-genres/browse-genres.component.html
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<div class="main-container container-fluid">
|
||||
<ng-container *transloco="let t; read:'browse-genres'" >
|
||||
<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]="genres"
|
||||
[pagination]="pagination"
|
||||
[trackByIdentity]="trackByIdentity"
|
||||
[jumpBarKeys]="jumpKeys"
|
||||
[filteringDisabled]="true"
|
||||
[refresh]="refresh"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
</ng-template>
|
||||
</app-card-detail-layout>
|
||||
|
||||
</ng-container>
|
||||
</div>
|
||||
26
UI/Web/src/app/all-genres/browse-genres.component.scss
Normal file
26
UI/Web/src/app/all-genres/browse-genres.component.scss
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
.genre-card {
|
||||
background-color: var(--bs-card-color, #2c2c2c);
|
||||
padding: 1rem;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
|
||||
transition: transform 0.2s ease, background 0.3s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.genre-card:hover {
|
||||
background-color: #3a3a3a;
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
|
||||
.genre-name {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.genre-meta {
|
||||
font-size: 0.85rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: var(--text-muted-color, #bbb);
|
||||
}
|
||||
72
UI/Web/src/app/all-genres/browse-genres.component.ts
Normal file
72
UI/Web/src/app/all-genres/browse-genres.component.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
DestroyRef,
|
||||
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 {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";
|
||||
|
||||
@Component({
|
||||
selector: 'app-all-genres',
|
||||
imports: [
|
||||
CardDetailLayoutComponent,
|
||||
DecimalPipe,
|
||||
SideNavCompanionBarComponent,
|
||||
TranslocoDirective
|
||||
],
|
||||
templateUrl: './browse-genres.component.html',
|
||||
styleUrl: './browse-genres.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
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);
|
||||
protected readonly filterUtilityService = inject(FilterUtilitiesService);
|
||||
|
||||
isLoading = false;
|
||||
genres: Array<BrowseGenre> = [];
|
||||
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.getGenreWithCounts(undefined, undefined).subscribe(d => {
|
||||
this.genres = d.result;
|
||||
this.pagination = d.pagination;
|
||||
this.jumpKeys = this.jumpbarService.getJumpKeys(this.genres, (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();
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
@ -1,8 +1,7 @@
|
|||
import { NgModule } from '@angular/core';
|
||||
import { Routes, RouterModule, PreloadAllModules } from '@angular/router';
|
||||
import { AuthGuard } from './_guards/auth.guard';
|
||||
import { LibraryAccessGuard } from './_guards/library-access.guard';
|
||||
import { AdminGuard } from './_guards/admin.guard';
|
||||
import {NgModule} from '@angular/core';
|
||||
import {PreloadAllModules, RouterModule, Routes} from '@angular/router';
|
||||
import {AuthGuard} from './_guards/auth.guard';
|
||||
import {LibraryAccessGuard} from './_guards/library-access.guard';
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
|
|
@ -54,6 +53,10 @@ const routes: 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: 'library',
|
||||
runGuardsAndResolvers: 'always',
|
||||
|
|
|
|||
|
|
@ -15,12 +15,7 @@ import {DecimalPipe} from "@angular/common";
|
|||
import {Series} from "../_models/series";
|
||||
import {Pagination} from "../_models/pagination";
|
||||
import {JumpKey} from "../_models/jumpbar/jump-key";
|
||||
import {ActivatedRoute, Router} from "@angular/router";
|
||||
import {Title} from "@angular/platform-browser";
|
||||
import {ActionFactoryService} from "../_services/action-factory.service";
|
||||
import {ActionService} from "../_services/action.service";
|
||||
import {MessageHubService} from "../_services/message-hub.service";
|
||||
import {UtilityService} from "../shared/_services/utility.service";
|
||||
import {Router} from "@angular/router";
|
||||
import {PersonService} from "../_services/person.service";
|
||||
import {BrowsePerson} from "../_models/person/browse-person";
|
||||
import {JumpbarService} from "../_services/jumpbar.service";
|
||||
|
|
@ -48,13 +43,7 @@ export class BrowseAuthorsComponent implements OnInit {
|
|||
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly titleService = inject(Title);
|
||||
private readonly actionFactoryService = inject(ActionFactoryService);
|
||||
private readonly actionService = inject(ActionService);
|
||||
private readonly hubService = inject(MessageHubService);
|
||||
private readonly utilityService = inject(UtilityService);
|
||||
private readonly personService = inject(PersonService);
|
||||
private readonly jumpbarService = inject(JumpbarService);
|
||||
protected readonly imageService = inject(ImageService);
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ export class SettingSwitchComponent implements AfterContentInit {
|
|||
const inputElement = element.querySelector('input');
|
||||
|
||||
// If no id, generate a random id and assign it to the input
|
||||
inputElement.id = crypto.randomUUID();
|
||||
inputElement.id = this.generateId();
|
||||
|
||||
if (inputElement && inputElement.id) {
|
||||
this.labelId = inputElement.id;
|
||||
|
|
@ -62,4 +62,14 @@ export class SettingSwitchComponent implements AfterContentInit {
|
|||
});
|
||||
}
|
||||
|
||||
|
||||
private generateId(): string {
|
||||
if (crypto && crypto.randomUUID) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
// Fallback for browsers without crypto.randomUUID (which has happened multiple times in my user base)
|
||||
return 'id-' + Math.random().toString(36).substr(2, 9) + '-' + Date.now().toString(36);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1111,6 +1111,13 @@
|
|||
"series-count": "{{common.series-count}}"
|
||||
},
|
||||
|
||||
"browse-genres": {
|
||||
"title": "Browse Genres",
|
||||
"genre-count": "{{num}} Genres",
|
||||
"issue-count": "{{common.issue-count}}",
|
||||
"series-count": "{{common.series-count}}"
|
||||
},
|
||||
|
||||
"person-detail": {
|
||||
"aka-title": "Also known as ",
|
||||
"known-for-title": "Known For",
|
||||
|
|
@ -2595,7 +2602,7 @@
|
|||
"browse-title-pipe": {
|
||||
"publication-status": "{{value}} works",
|
||||
"age-rating": "Rated {{value}}",
|
||||
"user-rating": "Rated {{value}} stars",
|
||||
"user-rating": "{{value}} star rating",
|
||||
"tag": "Has Tag {{value}}",
|
||||
"translator": "Translated by {{value}}",
|
||||
"character": "Has character {{value}}",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue