Localization - First Pass (#2174)

* Started designing the backend localization service

* Worked in Transloco for initial PoC

* Worked in Transloco for initial PoC

* Translated the login screen

* translated dashboard screen

* Started work on the backend

* Fixed a logic bug

* translated edit-user screen

* Hooked up the backend for having a locale property.

* Hooked up the ability to view the available locales and switch to them.

* Made the localization service languages be derived from what's in langs/ directory.

* Fixed up localization switching

* Switched when we check for a license on UI bootstrap

* Tweaked some code

* Fixed the bug where dashboard wasn't loading and made it so language switching is working.

* Fixed a bug on dashboard with languagePath

* Converted user-scrobble-history.component.html

* Converted spoiler.component.html

* Converted review-series-modal.component.html

* Converted review-card-modal.component.html

* Updated the readme

* Translated using Weblate (English)

Currently translated at 100.0% (54 of 54 strings)

Translation: Kavita/ui
Translate-URL: https://hosted.weblate.org/projects/kavita/ui/en/

* Converted review-card.component.html

* Deleted dead component

* Converted want-to-read.component.html

* Added translation using Weblate (Korean)

* Translated using Weblate (Spanish)

Currently translated at 40.7% (22 of 54 strings)

Translation: Kavita/ui
Translate-URL: https://hosted.weblate.org/projects/kavita/ui/es/

* Translated using Weblate (Korean)

Currently translated at 62.9% (34 of 54 strings)

Translation: Kavita/ui
Translate-URL: https://hosted.weblate.org/projects/kavita/ui/ko/

* Converted user-preferences.component.html

* Translated using Weblate (Korean)

Currently translated at 92.5% (50 of 54 strings)

Translation: Kavita/ui
Translate-URL: https://hosted.weblate.org/projects/kavita/ui/ko/

* Converted user-holds.component.html

* Converted theme-manager.component.html

* Converted restriction-selector.component.html

* Converted manage-devices.component.html

* Converted edit-device.component.html

* Converted change-password.component.html

* Converted change-email.component.html

* Converted change-age-restriction.component.html

* Converted api-key.component.html

* Converted anilist-key.component.html

* Converted typeahead.component.html

* Converted user-stats-info-cards.component.html

* Converted user-stats.component.html

* Converted top-readers.component.html

* Converted some pipes and ensure translation is loaded before the app.

* Finished all but one pipe for localization

* Converted directory-picker.component.html

* Converted library-access-modal.component.html

* Converted a few components

* Converted a few components

* Converted a few components

* Converted a few components

* Converted a few components

* Merged weblate in

* ... -> … update

* Updated the readme

* Updateded all fonts to be woff2

* Cleaned up some strings to increase re-use

* Removed an old flow (that doesn't exist in backend any longer) from when we introduced emails on Kavita.

* Converted Series detail

* Lots more converted

* Lots more converted & hooked up the ability to flatten during prod build the language files.

* Lots more converted

* Lots more converted & fixed a bunch of broken pipes due to inject()

* Lots more converted

* Lots more converted

* Lots more converted & fixed some bad keys

* Lots more converted

* Fixed some bugs with admin dasbhoard nested tabs not rendering on first load due to not using onpush change detection

* Fixed up some localization errors and fixed forgot password error when the user doesn't have change password permission

* Fixed a stupid build issue again

* Started adding errors for interceptor and backend.

* Finished off manga-reader

* More translations

* Few fixes

* Fixed a bug where character tag badges weren't showing the name on chapter info

* All components are translated

* All toasts are translated

* All confirm/alerts are translated

* Trying something new for the backend

* Migrated the localization strings for the backend into a new file.

* Updated the localization service to be able to do backend localization with fallback to english.

* Cleaned up some external reviews code to reduce looping

* Localized AccountController.cs

* 60% done with controllers

* All controllers are done

* All KavitaExceptions are covered

* Some shakeout fixes

* Prep for initial merge

* Everything is done except options and basic shakeout proves response times are good. Unit tests are broken.

* Fixed up the unit tests

* All unit tests are now working

* Removed some quantifier

* I'm not sure I can support localization for some Volume/Chapter/Book strings within the codebase.

---------

Co-authored-by: Robbie Davis <robbie@therobbiedavis.com>
Co-authored-by: majora2007 <kavitareader@gmail.com>
Co-authored-by: expertjun <jtrobin@naver.com>
Co-authored-by: ThePromidius <thepromidiusyt@gmail.com>
This commit is contained in:
Joe Milazzo 2023-08-03 10:33:51 -05:00 committed by GitHub
parent 670bf82c38
commit 3b23d63234
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
389 changed files with 13652 additions and 7925 deletions

View file

@ -1,28 +1,30 @@
<div class="modal-header">
<ng-container *transloco="let t; read:'generic-list-modal'">
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
</div>
<div class="modal-body">
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="close()"></button>
</div>
<div class="modal-body">
<form style="width: 100%" [formGroup]="listForm">
<div class="mb-3" *ngIf="items.length >= 5">
<label for="filter" class="form-label">Filter</label>
<div class="input-group">
<input id="filter" autocomplete="off" class="form-control" formControlName="filterQuery" type="text" aria-describedby="reset-input">
<button class="btn btn-outline-secondary" type="button" id="reset-input" (click)="listForm.get('filterQuery')?.setValue('');">Clear</button>
</div>
</div>
<div class="list-group">
<li class="list-group-item d-flex justify-content-between align-items-center" *ngFor="let item of items | filter: filterList; let i = index">
{{item}}
<button class="btn btn-primary" *ngIf="clicked !== undefined" (click)="handleClick(item)">
<i class="fa-solid fa-arrow-up-right-from-square" aria-hidden="true"></i>
<span class="visually-hidden">Open a filtered search for {{item}}</span>
</button>
</li>
<div class="mb-3" *ngIf="items.length >= 5">
<label for="filter" class="form-label">{{t('filter')}}</label>
<div class="input-group">
<input id="filter" autocomplete="off" class="form-control" formControlName="filterQuery" type="text" aria-describedby="reset-input">
<button class="btn btn-outline-secondary" type="button" id="reset-input" (click)="listForm.get('filterQuery')?.setValue('');">{{t('clear')}}</button>
</div>
</div>
<div class="list-group">
<li class="list-group-item d-flex justify-content-between align-items-center" *ngFor="let item of items | filter: filterList; let i = index">
{{item}}
<button class="btn btn-primary" *ngIf="clicked !== undefined" (click)="handleClick(item)">
<i class="fa-solid fa-arrow-up-right-from-square" aria-hidden="true"></i>
<span class="visually-hidden">{{t('open-filtered-search',{item: item})}}</span>
</button>
</li>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" (click)="close()">Close</button>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" (click)="close()">{{t('close')}}</button>
</div>
</ng-container>

View file

@ -3,13 +3,14 @@ import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { FilterPipe } from '../../../../pipe/filter.pipe';
import { NgIf, NgFor } from '@angular/common';
import {TranslocoModule} from "@ngneat/transloco";
@Component({
selector: 'app-generic-list-modal',
templateUrl: './generic-list-modal.component.html',
styleUrls: ['./generic-list-modal.component.scss'],
standalone: true,
imports: [ReactiveFormsModule, NgIf, NgFor, FilterPipe]
imports: [ReactiveFormsModule, NgIf, NgFor, FilterPipe, TranslocoModule]
})
export class GenericListModalComponent {
@Input() items: Array<string> = [];

View file

@ -1,17 +1,19 @@
<div class="dashboard-card-content">
<div class="row g-0 mb-2">
<h4>Day Breakdown</h4>
</div>
<ng-container *transloco="let t; read: 'day-breakdown'">
<div class="dashboard-card-content">
<div class="row g-0 mb-2">
<h4>{{t('title')}}</h4>
</div>
<ngx-charts-bar-vertical
class="dark"
[results]="dayBreakdown$ | async"
[xAxis]="true"
[yAxis]="true"
[legend]="showLegend"
[showXAxisLabel]="true"
[showYAxisLabel]="true"
xAxisLabel="Day of Week"
yAxisLabel="Reading Events">
</ngx-charts-bar-vertical>
</div>
<ngx-charts-bar-vertical
class="dark"
[results]="dayBreakdown$ | async"
[xAxis]="true"
[yAxis]="true"
[legend]="showLegend"
[showXAxisLabel]="true"
[showYAxisLabel]="true"
[xAxisLabel]="t('x-axis-label')"
[yAxisLabel]="t('y-axis-label')">
</ngx-charts-bar-vertical>
</div>
</ng-container>

View file

@ -1,6 +1,6 @@
import {ChangeDetectionStrategy, Component, DestroyRef, inject} from '@angular/core';
import {FormControl} from '@angular/forms';
import { LegendPosition, BarChartModule } from '@swimlane/ngx-charts';
import { BarChartModule } from '@swimlane/ngx-charts';
import {map, Observable} from 'rxjs';
import {DayOfWeek, StatisticsService} from 'src/app/_services/statistics.service';
import {PieDataItem} from '../../_models/pie-data-item';
@ -8,6 +8,7 @@ import {StatCount} from '../../_models/stat-count';
import {DayOfWeekPipe} from '../../_pipes/day-of-week.pipe';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import { AsyncPipe } from '@angular/common';
import {TranslocoModule} from "@ngneat/transloco";
@Component({
selector: 'app-day-breakdown',
@ -15,19 +16,12 @@ import { AsyncPipe } from '@angular/common';
styleUrls: ['./day-breakdown.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [BarChartModule, AsyncPipe]
imports: [BarChartModule, AsyncPipe, TranslocoModule]
})
export class DayBreakdownComponent {
view: [number, number] = [0,0];
gradient: boolean = true;
showLegend: boolean = true;
showLabels: boolean = true;
isDoughnut: boolean = false;
legendPosition: LegendPosition = LegendPosition.Right;
colorScheme = {
domain: ['#5AA454', '#A10A28', '#C7B42C', '#AAAAAA']
};
formControl: FormControl = new FormControl(true, []);
dayBreakdown$!: Observable<Array<PieDataItem>>;

View file

@ -1,74 +1,77 @@
<div class="dashboard-card-content">
<ng-container *transloco="let t; read: 'file-breakdown-stats'">
<div class="dashboard-card-content">
<div class="row g-0 mb-2">
<div class="col-8">
<h4><span>Format</span>
<i class="fa fa-info-circle ms-1" aria-hidden="true" placement="right" [ngbTooltip]="tooltip" role="button" tabindex="0"></i>
</h4>
</div>
<div class="col-4">
<form>
<div class="form-check form-switch mt-2">
<input id="pub-file-breakdown-viz" type="checkbox" class="form-check-input" [formControl]="formControl" role="switch">
<label for="pub-file-breakdown-viz" class="form-check-label">{{formControl.value ? 'Vizualization' : 'Data Table' }}</label>
</div>
</form>
</div>
<div class="col-8">
<h4><span>{{t('format-title')}}</span>
<i class="fa fa-info-circle ms-1" aria-hidden="true" placement="right" [ngbTooltip]="tooltip" role="button" tabindex="0"></i>
</h4>
</div>
<div class="col-4">
<form>
<div class="form-check form-switch mt-2">
<input id="pub-file-breakdown-viz" type="checkbox" class="form-check-input" [formControl]="formControl" role="switch">
<label for="pub-file-breakdown-viz" class="form-check-label">{{formControl.value ? t('visualisation-label') : t('data-table-label') }}</label>
</div>
</form>
</div>
</div>
<ng-template #tooltip>Not Classified means Kavita has not scanned some files. This occurs on old files existing prior to v0.7. You may need to run a forced scan via Library settings modal.</ng-template>
<ng-template #tooltip>{{t('format-tooltip')}}</ng-template>
<ng-container *ngIf="files$ | async as files">
<ng-container *ngIf="formControl.value; else tableLayout">
<ngx-charts-advanced-pie-chart [results]="vizData2$ | async"></ngx-charts-advanced-pie-chart>
</ng-container>
<ng-template #tableLayout>
<table class="table table-striped table-striped table-hover table-sm scrollable">
<thead>
<tr>
<th scope="col" sortable="extension" (sort)="onSort($event)">
Extension
</th>
<th scope="col" sortable="format" (sort)="onSort($event)">
Format
</th>
<th scope="col" sortable="totalSize" (sort)="onSort($event)">
Total Size
</th>
<th scope="col" sortable="totalFiles" (sort)="onSort($event)">
Total Files
</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let item of files; let idx = index;">
<td id="adhoctask--{{idx}}">
{{item.extension || 'Not Classified'}}
</td>
<td>
{{item.format | mangaFormat}}
</td>
<td>
{{item.totalSize | bytes}}
</td>
<td>
{{item.totalFiles | number:'1.0-0'}}
</td>
</tr>
</tbody>
<tfoot>
<tr>
<td>Total File Size:</td>
<td></td>
<td></td>
<td>{{((rawData$ | async)?.totalFileSize || 0) | bytes}}</td>
</tr>
</tfoot>
</table>
</ng-template>
<ng-container *ngIf="formControl.value; else tableLayout">
<ngx-charts-advanced-pie-chart [results]="vizData2$ | async"></ngx-charts-advanced-pie-chart>
</ng-container>
<ng-template #tableLayout>
<table class="table table-striped table-striped table-hover table-sm scrollable">
<thead>
<tr>
<th scope="col" sortable="extension" (sort)="onSort($event)">
{{t('extension-header')}}
</th>
<th scope="col" sortable="format" (sort)="onSort($event)">
{{t('format-header')}}
</th>
<th scope="col" sortable="totalSize" (sort)="onSort($event)">
{{t('total-size-header')}}
</th>
<th scope="col" sortable="totalFiles" (sort)="onSort($event)">
{{t('total-files-header')}}
</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let item of files; let idx = index;">
<td id="adhoctask--{{idx}}">
{{item.extension || t('not-classified')}}
</td>
<td>
{{item.format | mangaFormat}}
</td>
<td>
{{item.totalSize | bytes}}
</td>
<td>
{{item.totalFiles | number:'1.0-0'}}
</td>
</tr>
</tbody>
<tfoot>
<tr>
<td>{{t('total-file-size-title')}}</td>
<td></td>
<td></td>
<td>{{((rawData$ | async)?.totalFileSize || 0) | bytes}}</td>
</tr>
</tfoot>
</table>
</ng-template>
</ng-container>
</div>
</div>
</ng-container>

View file

@ -3,13 +3,12 @@ import {
Component,
DestroyRef,
inject,
OnDestroy,
QueryList,
ViewChildren
} from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { LegendPosition, PieChartModule } from '@swimlane/ngx-charts';
import { Observable, Subject, BehaviorSubject, combineLatest, map, takeUntil, shareReplay } from 'rxjs';
import { Observable, BehaviorSubject, combineLatest, map, shareReplay } from 'rxjs';
import { StatisticsService } from 'src/app/_services/statistics.service';
import { SortableHeader, SortEvent, compare } from 'src/app/_single-module/table/_directives/sortable-header.directive';
import { FileExtension, FileExtensionBreakdown } from '../../_models/file-breakdown';
@ -20,6 +19,7 @@ import { BytesPipe } from '../../../pipe/bytes.pipe';
import { SortableHeader as SortableHeader_1 } from '../../../_single-module/table/_directives/sortable-header.directive';
import { NgIf, NgFor, AsyncPipe, DecimalPipe } from '@angular/common';
import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap';
import {TranslocoModule, TranslocoService} from "@ngneat/transloco";
export interface StackedBarChartDataItem {
name: string,
@ -32,7 +32,7 @@ export interface StackedBarChartDataItem {
styleUrls: ['./file-breakdown-stats.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [NgbTooltip, ReactiveFormsModule, NgIf, PieChartModule, SortableHeader_1, NgFor, AsyncPipe, DecimalPipe, BytesPipe, MangaFormatPipe]
imports: [NgbTooltip, ReactiveFormsModule, NgIf, PieChartModule, SortableHeader_1, NgFor, AsyncPipe, DecimalPipe, BytesPipe, MangaFormatPipe, TranslocoModule]
})
export class FileBreakdownStatsComponent {
@ -40,7 +40,6 @@ export class FileBreakdownStatsComponent {
rawData$!: Observable<FileExtensionBreakdown>;
files$!: Observable<Array<FileExtension>>;
vizData$!: Observable<Array<StackedBarChartDataItem>>;
vizData2$!: Observable<Array<PieDataItem>>;
currentSort = new BehaviorSubject<SortEvent<FileExtension>>({column: 'extension', direction: 'asc'});
@ -49,19 +48,11 @@ export class FileBreakdownStatsComponent {
private readonly destroyRef = inject(DestroyRef);
view: [number, number] = [700, 400];
gradient: boolean = true;
showLegend: boolean = true;
showLabels: boolean = true;
isDoughnut: boolean = false;
legendPosition: LegendPosition = LegendPosition.Right;
colorScheme = {
domain: ['#5AA454', '#A10A28', '#C7B42C', '#AAAAAA']
};
formControl: FormControl = new FormControl(true, []);
constructor(private statService: StatisticsService) {
constructor(private statService: StatisticsService, private translocoService: TranslocoService) {
this.rawData$ = this.statService.getFileBreakdown().pipe(takeUntilDestroyed(this.destroyRef), shareReplay());
this.files$ = combineLatest([this.currentSort$, this.rawData$]).pipe(
@ -80,7 +71,7 @@ export class FileBreakdownStatsComponent {
this.vizData2$ = this.files$.pipe(takeUntilDestroyed(this.destroyRef), map(data => data.map(d => {
return {name: d.extension || 'Not Categorized', value: d.totalFiles, extra: d.totalSize};
return {name: d.extension || this.translocoService.translate('file-breakdown-stats.not-classified'), value: d.totalFiles, extra: d.totalSize};
})));
}

View file

@ -1,58 +1,56 @@
<div class="row g-0 mb-2">
<ng-container *transloco="let t; read: 'manga-format-stats'">
<div class="row g-0 mb-2">
<div class="col-8">
<h4><span>Format</span>
<i class="fa fa-info-circle ms-1" aria-hidden="true" placement="right" [ngbTooltip]="tooltip" role="button" tabindex="0"></i>
</h4>
<h4><span>{{t('title')}}</span></h4>
</div>
<div class="col-4">
<form>
<div class="form-check form-switch mt-2">
<input id="manga-format-viz" type="checkbox" class="form-check-input" [formControl]="formControl" role="switch">
<label for="manga-format-viz" class="form-check-label">{{formControl.value ? 'Vizualization' : 'Data Table' }}</label>
</div>
</form>
<form>
<div class="form-check form-switch mt-2">
<input id="manga-format-viz" type="checkbox" class="form-check-input" [formControl]="formControl" role="switch">
<label for="manga-format-viz" class="form-check-label">{{formControl.value ? t('visualisation-label') : t('data-table-label') }}</label>
</div>
</form>
</div>
</div>
</div>
<ng-template #tooltip></ng-template>
<ng-container *ngIf="formats$ | async as formats">
<ng-container *ngIf="formats$ | async as formats">
<ng-container *ngIf="formControl.value; else tableLayout">
<ngx-charts-pie-chart
<ngx-charts-pie-chart
[view]="view"
[results]="formats"
[legend]="showLegend"
[legendPosition]="legendPosition"
[labels]="showLabels"
>
</ngx-charts-pie-chart>
>
</ngx-charts-pie-chart>
</ng-container>
<ng-template #tableLayout>
<table class="table table-striped table-striped table-hover table-sm scrollable">
<thead>
<tr>
<th scope="col" sortable="name" (sort)="onSort($event)">
Format
</th>
<th scope="col" sortable="value" (sort)="onSort($event)">
Count
</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let item of formats; let idx = index;">
<td id="adhoctask--{{idx}}">
{{item.name}}
</td>
<td>
{{item.value | number:'1.0-0'}}
</td>
</tr>
</tbody>
</table>
<table class="table table-striped table-striped table-hover table-sm scrollable">
<thead>
<tr>
<th scope="col" sortable="name" (sort)="onSort($event)">
{{t('format-header')}}
</th>
<th scope="col" sortable="value" (sort)="onSort($event)">
{{t('count-header')}}
</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let item of formats; let idx = index;">
<td id="adhoctask--{{idx}}">
{{item.name}}
</td>
<td>
{{item.value | number:'1.0-0'}}
</td>
</tr>
</tbody>
</table>
</ng-template>
</ng-container>
</ng-container>

View file

@ -3,14 +3,12 @@ import {
Component,
DestroyRef,
inject,
OnDestroy,
OnInit,
QueryList,
ViewChildren
} from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { LegendPosition, PieChartModule } from '@swimlane/ngx-charts';
import { Observable, Subject, BehaviorSubject, combineLatest, map, takeUntil } from 'rxjs';
import { Observable, BehaviorSubject, combineLatest, map } from 'rxjs';
import { StatisticsService } from 'src/app/_services/statistics.service';
import { compare, SortableHeader, SortEvent } from 'src/app/_single-module/table/_directives/sortable-header.directive';
import { PieDataItem } from '../../_models/pie-data-item';
@ -18,6 +16,7 @@ import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import { SortableHeader as SortableHeader_1 } from '../../../_single-module/table/_directives/sortable-header.directive';
import { NgIf, NgFor, AsyncPipe, DecimalPipe } from '@angular/common';
import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap';
import {TranslocoModule} from "@ngneat/transloco";
@Component({
selector: 'app-manga-format-stats',
@ -25,7 +24,7 @@ import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap';
styleUrls: ['./manga-format-stats.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [NgbTooltip, ReactiveFormsModule, NgIf, PieChartModule, SortableHeader_1, NgFor, AsyncPipe, DecimalPipe]
imports: [NgbTooltip, ReactiveFormsModule, NgIf, PieChartModule, SortableHeader_1, NgFor, AsyncPipe, DecimalPipe, TranslocoModule]
})
export class MangaFormatStatsComponent {
@ -38,14 +37,9 @@ export class MangaFormatStatsComponent {
currentSort$: Observable<SortEvent<PieDataItem>> = this.currentSort.asObservable();
view: [number, number] = [700, 400];
gradient: boolean = true;
showLegend: boolean = true;
showLabels: boolean = true;
isDoughnut: boolean = false;
legendPosition: LegendPosition = LegendPosition.Right;
colorScheme = {
domain: ['#5AA454', '#A10A28', '#C7B42C', '#AAAAAA']
};
formControl: FormControl = new FormControl(true, []);

View file

@ -1,54 +1,57 @@
<div class="dashboard-card-content">
<ng-container *transloco="let t; read: 'publication-status-stats'">
<div class="dashboard-card-content">
<div class="row g-0 mb-2">
<div class="col-8">
<h4><span>Publication Status</span>
</h4>
</div>
<div class="col-4">
<form>
<div class="form-check form-switch mt-2">
<input id="pub-status-viz" type="checkbox" class="form-check-input" [formControl]="formControl" role="switch">
<label for="pub-status-viz" class="form-check-label">{{formControl.value ? 'Vizualization' : 'Data Table' }}</label>
</div>
</form>
</div>
<div class="col-8">
<h4><span>{{t('title')}}</span>
</h4>
</div>
<div class="col-4">
<form>
<div class="form-check form-switch mt-2">
<input id="pub-status-viz" type="checkbox" class="form-check-input" [formControl]="formControl" role="switch">
<label for="pub-status-viz" class="form-check-label">{{formControl.value ? t('visualisation-label') : t('data-table-label') }}</label>
</div>
</form>
</div>
</div>
<ng-container *ngIf="publicationStatues$ | async as statuses">
<ng-container *ngIf="formControl.value; else tableLayout">
<ngx-charts-advanced-pie-chart
[results]="statuses"
>
</ngx-charts-advanced-pie-chart>
</ng-container>
<ng-template #tableLayout>
<table class="table table-striped table-hover table-striped table-sm scrollable">
<thead>
<tr>
<th scope="col" sortable="name" (sort)="onSort($event)">
Year
</th>
<th scope="col" sortable="value" (sort)="onSort($event)">
Count
</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let item of statuses; let idx = index;">
<td id="adhoctask--{{idx}}">
{{item.name}}
</td>
<td>
{{item.value | number:'1.0-0'}}
</td>
</tr>
</tbody>
</table>
</ng-template>
<ng-container *ngIf="formControl.value; else tableLayout">
<ngx-charts-advanced-pie-chart
[results]="statuses"
>
</ngx-charts-advanced-pie-chart>
</ng-container>
<ng-template #tableLayout>
<table class="table table-striped table-hover table-striped table-sm scrollable">
<thead>
<tr>
<th scope="col" sortable="name" (sort)="onSort($event)">
{{t('year-header')}}
</th>
<th scope="col" sortable="value" (sort)="onSort($event)">
{{t('count-header')}}
</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let item of statuses; let idx = index;">
<td id="adhoctask--{{idx}}">
{{item.name}}
</td>
<td>
{{item.value | number:'1.0-0'}}
</td>
</tr>
</tbody>
</table>
</ng-template>
</ng-container>
</div>
</div>
</ng-container>

View file

@ -3,19 +3,19 @@ import {
Component,
DestroyRef,
inject,
OnDestroy,
QueryList,
ViewChildren
} from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { LegendPosition, PieChartModule } from '@swimlane/ngx-charts';
import { Observable, Subject, map, takeUntil, combineLatest, BehaviorSubject } from 'rxjs';
import { Observable, map, combineLatest, BehaviorSubject } from 'rxjs';
import { StatisticsService } from 'src/app/_services/statistics.service';
import { compare, SortableHeader, SortEvent } from 'src/app/_single-module/table/_directives/sortable-header.directive';
import { PieDataItem } from '../../_models/pie-data-item';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import { SortableHeader as SortableHeader_1 } from '../../../_single-module/table/_directives/sortable-header.directive';
import { NgIf, NgFor, AsyncPipe, DecimalPipe } from '@angular/common';
import {TranslocoModule} from "@ngneat/transloco";
@Component({
selector: 'app-publication-status-stats',
@ -23,7 +23,7 @@ import { NgIf, NgFor, AsyncPipe, DecimalPipe } from '@angular/common';
styleUrls: ['./publication-status-stats.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [ReactiveFormsModule, NgIf, PieChartModule, SortableHeader_1, NgFor, AsyncPipe, DecimalPipe]
imports: [ReactiveFormsModule, NgIf, PieChartModule, SortableHeader_1, NgFor, AsyncPipe, DecimalPipe, TranslocoModule]
})
export class PublicationStatusStatsComponent {
@ -35,14 +35,6 @@ export class PublicationStatusStatsComponent {
currentSort$: Observable<SortEvent<PieDataItem>> = this.currentSort.asObservable();
view: [number, number] = [700, 400];
gradient: boolean = true;
showLegend: boolean = true;
showLabels: boolean = true;
isDoughnut: boolean = false;
legendPosition: LegendPosition = LegendPosition.Right;
colorScheme = {
domain: ['#5AA454', '#A10A28', '#C7B42C', '#AAAAAA']
};
private readonly destroyRef = inject(DestroyRef);

View file

@ -1,54 +1,58 @@
<ng-container>
<ng-container *transloco="let t; read: 'reading-activity'">
<ng-container>
<div class="dashboard-card-content">
<div class="row g-0 mb-2 align-items-center">
<div class="col-4">
<h4>Reading Activity</h4>
</div>
<div class="col-8">
<form [formGroup]="formGroup" class="d-inline-flex float-end">
<div class="d-flex me-1" *ngIf="isAdmin && !individualUserMode">
<label for="time-select-read-by-day" class="form-check-label"></label>
<select id="time-select-read-by-day" class="form-select" formControlName="users"
[class.is-invalid]="formGroup.get('users')?.invalid && formGroup.get('users')?.touched">
<option [value]="0">All Users</option>
<option *ngFor="let item of users$ | async" [value]="item.id">{{item.username}}</option>
</select>
</div>
<div class="d-flex">
<label for="time-select-top-reads" class="form-check-label"></label>
<select id="time-select-top-reads" class="form-select" formControlName="days"
[class.is-invalid]="formGroup.get('days')?.invalid && formGroup.get('days')?.touched">
<option *ngFor="let item of timePeriods" [value]="item.value">{{item.title}}</option>
</select>
</div>
</form>
</div>
<div class="row g-0 mb-2 align-items-center">
<div class="col-4">
<h4>{{t('reading-activity')}}</h4>
</div>
<div class="row g-0">
<ng-container *ngIf="data$ | async as data">
<ngx-charts-line-chart
*ngIf="data.length > 0; else noData"
class="dark"
[legend]="true"
legendTitle="Formats"
[showXAxisLabel]="true"
[showYAxisLabel]="true"
[xAxis]="true"
[yAxis]="true"
[showGridLines]="false"
[showRefLines]="true"
[roundDomains]="true"
[autoScale]="true"
xAxisLabel="Time"
yAxisLabel="Hours Read"
[timeline]="false"
[results]="data"
>
</ngx-charts-line-chart>
</ng-container>
<div class="col-8">
<form [formGroup]="formGroup" class="d-inline-flex float-end">
<div class="d-flex me-1" *ngIf="isAdmin && !individualUserMode">
<label for="time-select-read-by-day" class="form-check-label"></label>
<select id="time-select-read-by-day" class="form-select" formControlName="users"
[class.is-invalid]="formGroup.get('users')?.invalid && formGroup.get('users')?.touched">
<option [value]="0">All Users</option>
<option *ngFor="let item of users$ | async" [value]="item.id">{{item.username}}</option>
</select>
</div>
<div class="d-flex">
<label for="time-select-top-reads" class="form-check-label">
<span class="visually-hidden">{{t('time-frame-label')}}</span>
</label>
<select id="time-select-top-reads" class="form-select" formControlName="days"
[class.is-invalid]="formGroup.get('days')?.invalid && formGroup.get('days')?.touched">
<option *ngFor="let item of timePeriods" [value]="item.value">{{t(item.title)}}</option>
</select>
</div>
</form>
</div>
<ng-template #noData>
No Reading progress
</ng-template>
</div>
<div class="row g-0">
<ng-container *ngIf="data$ | async as data">
<ngx-charts-line-chart
*ngIf="data.length > 0; else noData"
class="dark"
[legend]="true"
[legendTitle]="t('legend-label')"
[showXAxisLabel]="true"
[showYAxisLabel]="true"
[xAxis]="true"
[yAxis]="true"
[showGridLines]="false"
[showRefLines]="true"
[roundDomains]="true"
[autoScale]="true"
[xAxisLabel]="t('x-axis-label')"
[yAxisLabel]="t('y-axis-label')"
[timeline]="false"
[results]="data"
>
</ngx-charts-line-chart>
</ng-container>
</div>
<ng-template #noData>
{{t('no-data')}}
</ng-template>
</div>
</ng-container>
</ng-container>
</ng-container>

View file

@ -10,9 +10,9 @@ import { TimePeriods } from '../top-readers/top-readers.component';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import { LineChartModule } from '@swimlane/ngx-charts';
import { NgIf, NgFor, AsyncPipe } from '@angular/common';
import {TranslocoModule, TranslocoService} from "@ngneat/transloco";
const options: Intl.DateTimeFormatOptions = { month: "short", day: "numeric" };
const mangaFormatPipe = new MangaFormatPipe();
@Component({
selector: 'app-reading-activity',
@ -20,7 +20,7 @@ const mangaFormatPipe = new MangaFormatPipe();
styleUrls: ['./reading-activity.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [ReactiveFormsModule, NgIf, NgFor, LineChartModule, AsyncPipe]
imports: [ReactiveFormsModule, NgIf, NgFor, LineChartModule, AsyncPipe, TranslocoModule]
})
export class ReadingActivityComponent implements OnInit {
/**
@ -39,13 +39,15 @@ export class ReadingActivityComponent implements OnInit {
data$: Observable<Array<PieDataItem>>;
timePeriods = TimePeriods;
private readonly destroyRef = inject(DestroyRef);
private readonly translocoService = inject(TranslocoService);
mangaFormatPipe = new MangaFormatPipe(this.translocoService);
constructor(private statService: StatisticsService, private memberService: MemberService) {
this.data$ = this.formGroup.valueChanges.pipe(
switchMap(_ => this.statService.getReadCountByDay(this.formGroup.get('users')!.value, this.formGroup.get('days')!.value)),
map(data => {
const gList = data.reduce((formats, entry) => {
const formatTranslated = mangaFormatPipe.transform(entry.format);
const formatTranslated = this.mangaFormatPipe.transform(entry.format);
if (!formats[formatTranslated]) {
formats[formatTranslated] = {
name: formatTranslated,

View file

@ -1,123 +1,124 @@
<div class="container-fluid">
<ng-container *transloco="let t; read:'server-stats'">
<div class="container-fluid">
<div class="row g-0 mt-4 mb-3 d-flex justify-content-around" *ngIf="stats$ | async as stats">
<ng-container>
<div class="col-auto mb-2">
<app-icon-and-title label="Total Series" [clickable]="false" fontClasses="fa-solid fa-book-open" title="Total Series: {{stats.seriesCount | number}}">
{{stats.seriesCount | compactNumber}} Series
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container >
<div class="col-auto mb-2">
<app-icon-and-title label="Total Volumes" [clickable]="false" fontClasses="fas fa-book" title="Total Volumes: {{stats.volumeCount | number}}">
{{stats.volumeCount | compactNumber}} Volumes
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container>
<div class="col-auto mb-2">
<app-icon-and-title [label]="t('total-series-label')" [clickable]="false" fontClasses="fa-solid fa-book-open" [title]="t('total-series-tooltip', {count: stats.seriesCount | number})">
{{stats.seriesCount | compactNumber}} Series
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container>
<div class="col-auto mb-2">
<app-icon-and-title label="Total Files" [clickable]="false" fontClasses="fa-regular fa-file" title="Total Files: {{stats.totalFiles | number}}">
{{stats.totalFiles | compactNumber}} Files
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container>
<div class="col-auto mb-2">
<app-icon-and-title label="Total Size" [clickable]="false" fontClasses="fa-solid fa-scale-unbalanced" title="Total Size">
{{stats.totalSize | bytes}}
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container >
<div class="col-auto mb-2">
<app-icon-and-title [label]="t('total-volumes-label')" [clickable]="false" fontClasses="fas fa-book" [title]="t('total-volumes-tooltip', {count: stats.volumeCount | number})">
{{stats.volumeCount | compactNumber}} Volumes
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container>
<div class="col-auto mb-2">
<app-icon-and-title label="Total Genres" [clickable]="true" fontClasses="fa-solid fa-tags" title="Total Genres: {{stats.totalGenres | number}}" (click)="openGenreList();$event.stopPropagation();">
{{stats.totalGenres | compactNumber}} Genres
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container>
<div class="col-auto mb-2">
<app-icon-and-title [label]="t('total-files-label')" [clickable]="false" fontClasses="fa-regular fa-file" [title]="t('total-files-tooltip', {count: stats.totalFiles | number})">
{{stats.totalFiles | compactNumber}} Files
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container>
<div class="col-auto mb-2">
<app-icon-and-title label="Total Tags" [clickable]="true" fontClasses="fa-solid fa-tags" title="Total Tags: {{stats.totalTags | number}}" (click)="openTagList();$event.stopPropagation();">
{{stats.totalTags | compactNumber}} Tags
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container>
<div class="col-auto mb-2">
<app-icon-and-title [label]="t('total-size-label')" [clickable]="false" fontClasses="fa-solid fa-scale-unbalanced" [title]="t('total-size-label')">
{{stats.totalSize | bytes}}
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container>
<div class="col-auto mb-2">
<app-icon-and-title label="Total People" [clickable]="true" fontClasses="fa-solid fa-user-tag" title="Total People: {{stats.totalPeople | number}}" (click)="openPeopleList();$event.stopPropagation();">
{{stats.totalPeople | compactNumber}} People
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container>
<div class="col-auto mb-2">
<app-icon-and-title label="Total Genres" [clickable]="true" fontClasses="fa-solid fa-tags" [title]="t('total-genres-tooltip', {count: stats.totalGenres | number})" (click)="openGenreList();$event.stopPropagation();">
{{t('genre-count', {num: stats.totalGenres | compactNumber})}}
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container>
<div class="col-auto mb-2">
<app-icon-and-title label="Total Read Time" [clickable]="false" fontClasses="fas fa-eye" title="Total Read Time: {{stats.totalReadingTime | number}}">
{{stats.totalReadingTime | timeDuration}}
</app-icon-and-title>
</div>
</ng-container>
<ng-container>
<div class="col-auto mb-2">
<app-icon-and-title [label]="t('total-tags-label')" [clickable]="true" fontClasses="fa-solid fa-tags" [title]="t('total-tags-tooltip', {count: stats.totalTags | number})" (click)="openTagList();$event.stopPropagation();">
{{t('tag-count', {num: stats.totalTags | compactNumber})}}
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container>
<div class="col-auto mb-2">
<app-icon-and-title [label]="t('total-people-label')" [clickable]="true" fontClasses="fa-solid fa-user-tag" [title]="t('total-people-tooltip', {count: stats.totalPeople | number})" (click)="openPeopleList();$event.stopPropagation();">
{{t('people-count', {num: stats.totalPeople | compactNumber})}}
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container>
<div class="col-auto mb-2">
<app-icon-and-title [label]="t('total-read-time-label')" [clickable]="false" fontClasses="fas fa-eye" [title]="t('total-read-time-tooltip', {count: stats.totalReadingTime | number})">
{{stats.totalReadingTime | timeDuration}}
</app-icon-and-title>
</div>
</ng-container>
</div>
<div class="grid row g-0 pt-2 pb-2 d-flex justify-content-around">
<div class="col-auto">
<app-stat-list [data$]="releaseYears$" title="Release Years" label="series"></app-stat-list>
</div>
<div class="col-auto">
<app-stat-list [data$]="mostActiveUsers$" title="Most Active Users" label="reads"></app-stat-list>
</div>
<div class="col-auto">
<app-stat-list [data$]="mostActiveLibrary$" title="Popular Libraries" label="reads"></app-stat-list>
</div>
<div class="col-auto">
<app-stat-list [data$]="mostActiveSeries$" title="Popular Series" [image]="seriesImage" [handleClick]="openSeries">
</app-stat-list>
</div>
<div class="col-auto">
<app-stat-list [data$]="recentlyRead$" title="Recently Read" [image]="seriesImage" [handleClick]="openSeries"></app-stat-list>
</div>
<div class="col-auto">
<app-stat-list [data$]="releaseYears$" [title]="t('release-years-title')" [label]="t('series')"></app-stat-list>
</div>
<div class="col-auto">
<app-stat-list [data$]="mostActiveUsers$" [title]="t('most-active-users-title')" [label]="t('reads')"></app-stat-list>
</div>
<div class="col-auto">
<app-stat-list [data$]="mostActiveLibrary$" [title]="t('popular-libraries-title')" [label]="t('reads')"></app-stat-list>
</div>
<div class="col-auto">
<app-stat-list [data$]="mostActiveSeries$" [title]="t('popular-series-title')" [image]="seriesImage" [handleClick]="openSeries">
</app-stat-list>
</div>
<div class="col-auto">
<app-stat-list [data$]="recentlyRead$" title="Recently Read" [image]="seriesImage" [handleClick]="openSeries"></app-stat-list>
</div>
</div>
<ng-container *ngIf="breakpoint$ | async as bp">
<div class="row g-0 pt-2 pb-2">
<app-top-readers></app-top-readers>
</div>
<div class="row g-0 pt-4 pb-2">
<div class="col-lg-6 col-md-12 mb-md-5">
<app-file-breakdown-stats></app-file-breakdown-stats>
</div>
<div class="col-lg-6 col-md-12">
<app-publication-status-stats></app-publication-status-stats>
</div>
</div>
<div class="row g-0 pt-2 pb-2">
<app-top-readers></app-top-readers>
</div>
<div class="row g-0 pt-4 pb-2">
<div class="col-md-12 col-sm-12 mt-4 pt-2">
<app-reading-activity [isAdmin]="true"></app-reading-activity>
</div>
<div class="row g-0 pt-4 pb-2">
<div class="col-lg-6 col-md-12 mb-md-5">
<app-file-breakdown-stats></app-file-breakdown-stats>
</div>
<div class="row g-0 pt-4 pb-2">
<div class="col-md-12 col-sm-12 mt-4 pt-2">
<app-day-breakdown></app-day-breakdown>
</div>
<div class="col-lg-6 col-md-12">
<app-publication-status-stats></app-publication-status-stats>
</div>
</div>
<div class="row g-0 pt-4 pb-2">
<div class="col-md-12 col-sm-12 mt-4 pt-2">
<app-reading-activity [isAdmin]="true"></app-reading-activity>
</div>
</div>
<div class="row g-0 pt-4 pb-2">
<div class="col-md-12 col-sm-12 mt-4 pt-2">
<app-day-breakdown></app-day-breakdown>
</div>
</div>
</ng-container>
</div>
</div>
</ng-container>

View file

@ -1,7 +1,7 @@
import {ChangeDetectionStrategy, Component, DestroyRef, HostListener, inject, OnDestroy} from '@angular/core';
import {ChangeDetectionStrategy, Component, DestroyRef, HostListener, inject} from '@angular/core';
import { Router } from '@angular/router';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import {BehaviorSubject, map, Observable, ReplaySubject, shareReplay, Subject, takeUntil} from 'rxjs';
import { map, Observable, ReplaySubject, shareReplay } from 'rxjs';
import { FilterQueryParam } from 'src/app/shared/_services/filter-utilities.service';
import { Breakpoint, UtilityService } from 'src/app/shared/_services/utility.service';
import { Series } from 'src/app/_models/series';
@ -23,6 +23,7 @@ import { TopReadersComponent } from '../top-readers/top-readers.component';
import { StatListComponent } from '../stat-list/stat-list.component';
import { IconAndTitleComponent } from '../../../shared/icon-and-title/icon-and-title.component';
import { NgIf, AsyncPipe, DecimalPipe } from '@angular/common';
import {TranslocoModule, TranslocoService} from "@ngneat/transloco";
@Component({
selector: 'app-server-stats',
@ -30,7 +31,7 @@ import { NgIf, AsyncPipe, DecimalPipe } from '@angular/common';
styleUrls: ['./server-stats.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [NgIf, IconAndTitleComponent, StatListComponent, TopReadersComponent, FileBreakdownStatsComponent, PublicationStatusStatsComponent, ReadingActivityComponent, DayBreakdownComponent, AsyncPipe, DecimalPipe, CompactNumberPipe, TimeDurationPipe, BytesPipe]
imports: [NgIf, IconAndTitleComponent, StatListComponent, TopReadersComponent, FileBreakdownStatsComponent, PublicationStatusStatsComponent, ReadingActivityComponent, DayBreakdownComponent, AsyncPipe, DecimalPipe, CompactNumberPipe, TimeDurationPipe, BytesPipe, TranslocoModule]
})
export class ServerStatsComponent {
@ -58,6 +59,7 @@ export class ServerStatsComponent {
}
translocoService = inject(TranslocoService);
get Breakpoint() { return Breakpoint; }
constructor(private statService: StatisticsService, private router: Router, private imageService: ImageService,
@ -108,7 +110,7 @@ export class ServerStatsComponent {
this.metadataService.getAllGenres().subscribe(genres => {
const ref = this.modalService.open(GenericListModalComponent, { scrollable: true });
ref.componentInstance.items = genres.map(t => t.title);
ref.componentInstance.title = 'Genres';
ref.componentInstance.title = this.translocoService.translate('server-stats.genres');
ref.componentInstance.clicked = (item: string) => {
const params: any = {};
params[FilterQueryParam.Genres] = genres.filter(g => g.title === item)[0].id;
@ -122,7 +124,7 @@ export class ServerStatsComponent {
this.metadataService.getAllTags().subscribe(tags => {
const ref = this.modalService.open(GenericListModalComponent, { scrollable: true });
ref.componentInstance.items = tags.map(t => t.title);
ref.componentInstance.title = 'Tags';
ref.componentInstance.title = this.translocoService.translate('server-stats.tags');
ref.componentInstance.clicked = (item: string) => {
const params: any = {};
params[FilterQueryParam.Tags] = tags.filter(g => g.title === item)[0].id;
@ -136,7 +138,7 @@ export class ServerStatsComponent {
this.metadataService.getAllPeople().subscribe(people => {
const ref = this.modalService.open(GenericListModalComponent, { scrollable: true });
ref.componentInstance.items = [...new Set(people.map(person => person.name))];
ref.componentInstance.title = 'People';
ref.componentInstance.title = this.translocoService.translate('server-stats.people');
});
}

View file

@ -1,17 +1,17 @@
<ng-container *ngIf="data$ | async as data">
<div class="card" style="width: 18rem;">
<div class="card-header text-center">
{{title}}
<i class="fa fa-info-circle ms-1" aria-hidden="true" placement="right" [ngbTooltip]="tooltip" role="button" tabindex="0" *ngIf="description && description.length > 0"></i>
</div>
<ul class="list-group list-group-flush">
<li class="list-group-item" [ngClass]="{'underline': handleClick !== undefined}" *ngFor="let item of data" (click)="doClick(item)">
<ng-container *ngIf="image && image(item) as url">
<app-image *ngIf="url && url.length > 0" width="32px" maxHeight="32px" class="img-top me-1" [imageUrl]="url"></app-image>
</ng-container>
{{item.name}} <span class="float-end" *ngIf="item.value >= 0">{{item.value | compactNumber}} {{label}}</span>
</li>
</ul>
<div class="card" style="width: 18rem;">
<div class="card-header text-center">
{{title}}
<i class="fa fa-info-circle ms-1" aria-hidden="true" placement="right" [ngbTooltip]="tooltip" role="button" tabindex="0" *ngIf="description && description.length > 0"></i>
</div>
<ul class="list-group list-group-flush">
<li class="list-group-item" [ngClass]="{'underline': handleClick !== undefined}" *ngFor="let item of data" (click)="doClick(item)">
<ng-container *ngIf="image && image(item) as url">
<app-image *ngIf="url && url.length > 0" width="32px" maxHeight="32px" class="img-top me-1" [imageUrl]="url"></app-image>
</ng-container>
{{item.name}} <span class="float-end" *ngIf="item.value >= 0">{{item.value | compactNumber}} {{label}}</span>
</li>
</ul>
</div>
</ng-container>
<ng-template #tooltip></ng-template>

View file

@ -6,6 +6,7 @@ import { CompactNumberPipe } from '../../../pipe/compact-number.pipe';
import { ImageComponent } from '../../../shared/image/image.component';
import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap';
import { NgIf, NgFor, NgClass, AsyncPipe } from '@angular/common';
import {TranslocoModule} from "@ngneat/transloco";
@Component({
selector: 'app-stat-list',
@ -13,7 +14,7 @@ import { NgIf, NgFor, NgClass, AsyncPipe } from '@angular/common';
styleUrls: ['./stat-list.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [NgIf, NgbTooltip, NgFor, NgClass, ImageComponent, AsyncPipe, CompactNumberPipe]
imports: [NgIf, NgbTooltip, NgFor, NgClass, ImageComponent, AsyncPipe, CompactNumberPipe, TranslocoModule]
})
export class StatListComponent {

View file

@ -1,35 +1,38 @@
<div class="row g-0 mb-2 align-items-center">
<ng-container *transloco="let t; read:'top-readers'">
<div class="row g-0 mb-2 align-items-center">
<div class="col-4">
<h4>Top Readers</h4>
<h4>{{t('title')}}</h4>
</div>
<div class="col-8">
<form [formGroup]="formGroup" class="d-inline-flex float-end">
<div class="d-flex">
<label for="time-select-top-reads" class="form-check-label"></label>
<select id="time-select-top-reads" class="form-select" formControlName="days"
[class.is-invalid]="formGroup.get('days')?.invalid && formGroup.get('days')?.touched">
<option *ngFor="let item of timePeriods" [value]="item.value">{{item.title}}</option>
</select>
</div>
</form>
</div>
</div>
<ng-container>
<div class="grid row g-0">
<div class="card" *ngFor="let user of (users$ | async)">
<div class="card-header text-center">
{{user.username}}
</div>
<ul class="list-group list-group-flush">
<li class="list-group-item">Comics: {{user.comicsTime}} hrs</li>
<li class="list-group-item">Manga: {{user.mangaTime}} hrs</li>
<li class="list-group-item">Books: {{user.booksTime}} hrs</li>
</ul>
<form [formGroup]="formGroup" class="d-inline-flex float-end">
<div class="d-flex">
<label for="time-select-top-reads" class="form-check-label visually-hidden">{{t('time-selection-label')}}</label>
<select id="time-select-top-reads" class="form-select" formControlName="days"
[class.is-invalid]="formGroup.get('days')?.invalid && formGroup.get('days')?.touched">
<option *ngFor="let item of timePeriods" [value]="item.value">{{t(item.title)}}</option>
</select>
</div>
</form>
</div>
</div>
<ng-container>
<div class="grid row g-0">
<div class="card" *ngFor="let user of (users$ | async)">
<div class="card-header text-center">
{{user.username}}
</div>
<ul class="list-group list-group-flush">
<li class="list-group-item">{{t('comics-label', {value: user.comicsTime})}}</li>
<li class="list-group-item">{{t('manga-label', {value: user.mangaTime})}}</li>
<li class="list-group-item">{{t('books-label', {value: user.booksTime})}}</li>
</ul>
</div>
</div>
</ng-container>
</ng-container>

View file

@ -4,17 +4,23 @@ import {
Component,
DestroyRef,
inject,
OnDestroy,
OnInit
} from '@angular/core';
import { FormGroup, FormControl, ReactiveFormsModule } from '@angular/forms';
import { Observable, Subject, takeUntil, switchMap, shareReplay } from 'rxjs';
import { Observable, switchMap, shareReplay } from 'rxjs';
import { StatisticsService } from 'src/app/_services/statistics.service';
import { TopUserRead } from '../../_models/top-reads';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import { NgFor, AsyncPipe } from '@angular/common';
import {TranslocoModule} from "@ngneat/transloco";
export const TimePeriods: Array<{title: string, value: number}> = [{title: 'This Week', value: new Date().getDay() || 1}, {title: 'Last 7 Days', value: 7}, {title: 'Last 30 Days', value: 30}, {title: 'Last 90 Days', value: 90}, {title: 'Last Year', value: 365}, {title: 'All Time', value: 0}];
export const TimePeriods: Array<{title: string, value: number}> =
[{title: 'this-week', value: new Date().getDay() || 1},
{title: 'last-7-days', value: 7},
{title: 'last-30-days', value: 30},
{title: 'last-90-days', value: 90},
{title: 'last-year', value: 365},
{title: 'all-time', value: 0}];
@Component({
selector: 'app-top-readers',
@ -22,7 +28,7 @@ export const TimePeriods: Array<{title: string, value: number}> = [{title: 'This
styleUrls: ['./top-readers.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [ReactiveFormsModule, NgFor, AsyncPipe]
imports: [ReactiveFormsModule, NgFor, AsyncPipe, TranslocoModule]
})
export class TopReadersComponent implements OnInit {

View file

@ -1,54 +1,60 @@
<div class="row g-0 mt-4 mb-3 d-flex justify-content-around">
<ng-container *transloco="let t; read:'user-stats-info-cards'">
<div class="row g-0 mt-4 mb-3 d-flex justify-content-around">
<ng-container>
<div class="col-auto mb-2">
<app-icon-and-title label="Total Pages Read" [clickable]="true" fontClasses="fa-regular fa-file-lines" title="Total Pages Read: {{totalPagesRead | number}}" (click)="openPageByYearList();$event.stopPropagation();">
{{totalPagesRead | compactNumber}}
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
<div class="col-auto mb-2">
<app-icon-and-title [label]="t('total-pages-read-label')" [clickable]="true" fontClasses="fa-regular fa-file-lines"
[title]="t('total-pages-read-tooltip', {value: totalPagesRead | number})" (click)="openPageByYearList();$event.stopPropagation();">
{{totalPagesRead | compactNumber}}
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container>
<div class="col-auto mb-2">
<app-icon-and-title label="Total Words Read" [clickable]="true" fontClasses="fa-regular fa-file-lines" title="Total Words Read: {{totalWordsRead | number}}" (click)="openWordByYearList();$event.stopPropagation();">
{{totalWordsRead | compactNumber}}
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
<div class="col-auto mb-2">
<app-icon-and-title [label]="t('total-words-read-label')" [clickable]="true" fontClasses="fa-regular fa-file-lines"
[title]="t('total-words-read-tooltip', {value: totalWordsRead | number})" (click)="openWordByYearList();$event.stopPropagation();">
{{totalWordsRead | compactNumber}}
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container >
<div class="col-auto mb-2">
<app-icon-and-title label="Time Spent Reading" [clickable]="false" fontClasses="fas fa-eye" title="Time Spent Reading: {{timeSpentReading | number}}">
{{timeSpentReading | timeDuration}}
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
<div class="col-auto mb-2">
<app-icon-and-title [label]="t('time-spent-reading-label')" [clickable]="false" fontClasses="fas fa-eye"
[title]="t('time-spent-reading-tooltip', {value: timeSpentReading | number})">
{{timeSpentReading | timeDuration}}
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container>
<div class="col-auto mb-2">
<app-icon-and-title label="Average Reading / Week" [clickable]="false" fontClasses="fas fa-eye">
{{avgHoursPerWeekSpentReading | timeDuration}}
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
<div class="col-auto mb-2">
<app-icon-and-title [label]="t('avg-reading-per-week-label')" [clickable]="false" fontClasses="fas fa-eye">
{{avgHoursPerWeekSpentReading | timeDuration}}
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container>
<div class="col-auto mb-2">
<app-icon-and-title label="Chapters Read" [clickable]="false" fontClasses="fa-regular fa-file-lines" title="Chapters Read: {{chaptersRead | number}}">
{{chaptersRead | compactNumber}} Chapters
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
<div class="col-auto mb-2">
<app-icon-and-title [label]="t('chapters-read-label')" [clickable]="false" fontClasses="fa-regular fa-file-lines"
[title]="t('chapters-read-tooltip', {value: (chaptersRead | number)})">
{{t('chapters', {value: (chaptersRead | compactNumber)})}}
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container>
<div class="col-auto mb-2">
<app-icon-and-title label="Last Active" [clickable]="false" fontClasses="fa-regular fa-calendar" title="Last Active">
{{lastActive | timeAgo}}
</app-icon-and-title>
</div>
<div class="col-auto mb-2">
<app-icon-and-title [label]="t('last-active-label')" [clickable]="false" fontClasses="fa-regular fa-calendar">
{{lastActive | timeAgo}}
</app-icon-and-title>
</div>
</ng-container>
</div>
</div>
</ng-container>

View file

@ -8,6 +8,7 @@ import { TimeDurationPipe } from '../../../pipe/time-duration.pipe';
import { CompactNumberPipe as CompactNumberPipe_1 } from '../../../pipe/compact-number.pipe';
import { DecimalPipe } from '@angular/common';
import { IconAndTitleComponent } from '../../../shared/icon-and-title/icon-and-title.component';
import {TranslocoModule} from "@ngneat/transloco";
@Component({
selector: 'app-user-stats-info-cards',
@ -15,7 +16,7 @@ import { IconAndTitleComponent } from '../../../shared/icon-and-title/icon-and-t
styleUrls: ['./user-stats-info-cards.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [IconAndTitleComponent, DecimalPipe, CompactNumberPipe_1, TimeDurationPipe, TimeAgoPipe]
imports: [IconAndTitleComponent, DecimalPipe, CompactNumberPipe_1, TimeDurationPipe, TimeAgoPipe, TranslocoModule]
})
export class UserStatsInfoCardsComponent {

View file

@ -1,19 +1,22 @@
<div class="container-fluid" *ngIf="userId">
<ng-container *transloco="let t; read:'user-stats'">
<div class="container-fluid" *ngIf="userId">
<div class="row g-0 d-flex justify-content-around">
<ng-container *ngIf="userStats$ | async as userStats">
<app-user-stats-info-cards [totalPagesRead]="userStats.totalPagesRead" [totalWordsRead]="userStats.totalWordsRead" [timeSpentReading]="userStats.timeSpentReading"
[chaptersRead]="userStats.chaptersRead" [lastActive]="userStats.lastActive" [avgHoursPerWeekSpentReading]="userStats.avgHoursPerWeekSpentReading"></app-user-stats-info-cards>
</ng-container>
<ng-container *ngIf="userStats$ | async as userStats">
<app-user-stats-info-cards [totalPagesRead]="userStats.totalPagesRead" [totalWordsRead]="userStats.totalWordsRead" [timeSpentReading]="userStats.timeSpentReading"
[chaptersRead]="userStats.chaptersRead" [lastActive]="userStats.lastActive" [avgHoursPerWeekSpentReading]="userStats.avgHoursPerWeekSpentReading"></app-user-stats-info-cards>
</ng-container>
</div>
<div class="row g-0 pt-4 pb-2 " style="height: 242px">
<div class="col-md-12 col-sm-12 mt-4 pt-2">
<app-reading-activity [userId]="userId" [isAdmin]="(isAdmin$ | async) || false" [individualUserMode]="true"></app-reading-activity>
</div>
<div class="col-md-12 col-sm-12 mt-4 pt-2">
<app-reading-activity [userId]="userId" [isAdmin]="(isAdmin$ | async) || false" [individualUserMode]="true"></app-reading-activity>
</div>
</div>
<div class="row g-0 pt-4 pb-2 " style="height: 242px">
<app-stat-list [data$]="percentageRead$" label="% Read" title="Library Read Progress"></app-stat-list>
<app-stat-list [data$]="percentageRead$" [label]="t('read-percentage')" [title]="t('library-read-progress-title')"></app-stat-list>
</div>
</div>
</div>
</ng-container>

View file

@ -21,6 +21,7 @@ import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import { StatListComponent } from '../stat-list/stat-list.component';
import { ReadingActivityComponent } from '../reading-activity/reading-activity.component';
import { UserStatsInfoCardsComponent } from '../user-stats-info-cards/user-stats-info-cards.component';
import {TranslocoModule} from "@ngneat/transloco";
@Component({
selector: 'app-user-stats',
@ -34,6 +35,7 @@ import { UserStatsInfoCardsComponent } from '../user-stats-info-cards/user-stats
ReadingActivityComponent,
StatListComponent,
AsyncPipe,
TranslocoModule,
],
})
export class UserStatsComponent implements OnInit {

View file

@ -1,5 +1,6 @@
import { Pipe, PipeTransform } from '@angular/core';
import {inject, Pipe, PipeTransform} from '@angular/core';
import { DayOfWeek } from 'src/app/_services/statistics.service';
import {TranslocoService} from "@ngneat/transloco";
@Pipe({
name: 'dayOfWeek',
@ -7,15 +8,24 @@ import { DayOfWeek } from 'src/app/_services/statistics.service';
})
export class DayOfWeekPipe implements PipeTransform {
translocoService = inject(TranslocoService);
transform(value: DayOfWeek): string {
switch(value) {
case DayOfWeek.Monday: return 'Monday';
case DayOfWeek.Tuesday: return 'Tuesday';
case DayOfWeek.Wednesday: return 'Wednesday';
case DayOfWeek.Thursday: return 'Thursday';
case DayOfWeek.Friday: return 'Friday';
case DayOfWeek.Saturday: return 'Saturday';
case DayOfWeek.Sunday: return 'Sunday';
case DayOfWeek.Monday:
return this.translocoService.translate('day-of-week-pipe.monday');
case DayOfWeek.Tuesday:
return this.translocoService.translate('day-of-week-pipe.tuesday');
case DayOfWeek.Wednesday:
return this.translocoService.translate('day-of-week-pipe.wednesday');
case DayOfWeek.Thursday:
return this.translocoService.translate('day-of-week-pipe.thursday');
case DayOfWeek.Friday:
return this.translocoService.translate('day-of-week-pipe.friday');
case DayOfWeek.Saturday:
return this.translocoService.translate('day-of-week-pipe.saturday');
case DayOfWeek.Sunday:
return this.translocoService.translate('day-of-week-pipe.sunday');
}
}