Stats Fix & Library Bulk Actions (#3209)

Co-authored-by: Fesaa <77553571+Fesaa@users.noreply.github.com>
Co-authored-by: Weblate (bot) <hosted@weblate.org>
Co-authored-by: Gregory.Open <gregory.open@proton.me>
Co-authored-by: Mateusz <mateuszvx8.96@gmail.com>
Co-authored-by: majora2007 <kavitareader@gmail.com>
Co-authored-by: 無情天 <kofzhanganguo@126.com>
This commit is contained in:
Joe Milazzo 2024-09-23 08:07:37 -05:00 committed by GitHub
parent 894b49bb76
commit 857e419e4e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
77 changed files with 72523 additions and 30914 deletions

View file

@ -8,6 +8,7 @@
"minify-langs": "node minify-json.js",
"cache-locale": "node hash-localization.js",
"cache-locale-prime": "node hash-localization-prime.js",
"sync-locale": "node sync-locales.js",
"prod": "npm run cache-locale-prime && ng build --configuration production && npm run minify-langs && npm run cache-locale",
"explore": "ng build --stats-json && webpack-bundle-analyzer dist/stats.json",
"lint": "ng lint",

View file

@ -108,6 +108,10 @@ export enum Action {
* Invoke a refresh covers as false to generate colorscapes
*/
GenerateColorScape = 26,
/**
* Copy settings from one entity to another
*/
CopySettings = 27
}
/**
@ -254,6 +258,43 @@ export class ActionFactoryService {
return tasks.filter(t => !blacklist.includes(t.action));
}
getBulkLibraryActions(callback: ActionCallback<Library>) {
// Scan is currently not supported due to the backend not being able to handle it yet
const actions = this.flattenActions<Library>(this.libraryActions).filter(a => {
return [Action.Delete, Action.GenerateColorScape, Action.AnalyzeFiles, Action.RefreshMetadata, Action.CopySettings].includes(a.action);
});
actions.push({
_extra: undefined,
class: undefined,
description: '',
dynamicList: undefined,
action: Action.CopySettings,
callback: this.dummyCallback,
children: [],
requiresAdmin: true,
title: 'copy-settings'
})
return this.applyCallbackToList(actions, callback);
}
flattenActions<T>(actions: Array<ActionItem<T>>): Array<ActionItem<T>> {
return actions.reduce<Array<ActionItem<T>>>((flatArray, action) => {
if (action.action !== Action.Submenu) {
flatArray.push(action);
}
// Recursively flatten the children, if any
if (action.children && action.children.length > 0) {
flatArray.push(...this.flattenActions<T>(action.children));
}
return flatArray;
}, [] as Array<ActionItem<T>>); // Explicitly defining the type of flatArray
}
private _resetActions() {
this.libraryActions = [
{

View file

@ -93,6 +93,10 @@ export class LibraryService {
return this.httpClient.post(this.baseUrl + 'library/scan?libraryId=' + libraryId + '&force=' + force, {});
}
scanMultipleLibraries(libraryIds: Array<number>, force = false) {
return this.httpClient.post(this.baseUrl + 'library/scan-multiple', {ids: libraryIds, force: force});
}
analyze(libraryId: number) {
return this.httpClient.post(this.baseUrl + 'library/analyze?libraryId=' + libraryId, {});
}
@ -101,6 +105,18 @@ export class LibraryService {
return this.httpClient.post(this.baseUrl + `library/refresh-metadata?libraryId=${libraryId}&force=${forceUpdate}&forceColorscape=${forceColorscape}`, {});
}
refreshMetadataMultipleLibraries(libraryIds: Array<number>, force = false, forceColorscape = false) {
return this.httpClient.post(this.baseUrl + 'library/refresh-metadata-multiple?forceColorscape=' + forceColorscape, {ids: libraryIds, force: force});
}
analyzeFilesMultipleLibraries(libraryIds: Array<number>) {
return this.httpClient.post(this.baseUrl + 'library/analyze-multiple', {ids: libraryIds, force: false});
}
copySettingsFromLibrary(sourceLibraryId: number, targetLibraryIds: Array<number>, includeType: boolean) {
return this.httpClient.post(this.baseUrl + 'library/copy-settings-from', {sourceLibraryId, targetLibraryIds, includeType});
}
create(model: {name: string, type: number, folders: string[]}) {
return this.httpClient.post(this.baseUrl + 'library/create', model);
}

View file

@ -2,11 +2,17 @@
@if (actions.length > 0) {
@if ((utilityService.activeBreakpoint$ | async)! <= Breakpoint.Tablet) {
<button [disabled]="disabled" class="btn {{btnClass}}" id="actions-{{labelBy}}"
(click)="openMobileActionableMenu($event)"><i class="fa {{iconClass}}" aria-hidden="true"></i></button>
(click)="openMobileActionableMenu($event)">
{{label}}
<i class="fa {{iconClass}}" aria-hidden="true"></i>
</button>
} @else {
<div ngbDropdown container="body" class="d-inline-block">
<button [disabled]="disabled" class="btn {{btnClass}}" id="actions-{{labelBy}}" ngbDropdownToggle
(click)="preventEvent($event)"><i class="fa {{iconClass}}" aria-hidden="true"></i></button>
(click)="preventEvent($event)">
{{label}}
<i class="fa {{iconClass}}" aria-hidden="true"></i>
</button>
<div ngbDropdownMenu attr.aria-labelledby="actions-{{labelBy}}">
<ng-container *ngTemplateOutlet="submenu; context: { list: actions }"></ng-container>
</div>

View file

@ -16,7 +16,6 @@ import {TranslocoDirective} from "@jsverse/transloco";
import {DynamicListPipe} from "./_pipes/dynamic-list.pipe";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {Breakpoint, UtilityService} from "../../shared/_services/utility.service";
import {NavLinkModalComponent} from "../../nav/_components/nav-link-modal/nav-link-modal.component";
import {ActionableModalComponent} from "../actionable-modal/actionable-modal.component";
@Component({
@ -41,6 +40,10 @@ export class CardActionablesComponent implements OnInit {
@Input() btnClass = '';
@Input() actions: ActionItem<any>[] = [];
@Input() labelBy = 'card';
/**
* Text to display as if actionable was a button
*/
@Input() label = '';
@Input() disabled: boolean = false;
@Output() actionHandler = new EventEmitter<ActionItem<any>>();

View file

@ -0,0 +1,23 @@
<ng-container *transloco="let t; read:'copy-settings-from-library-modal'">
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{t('title')}}</h4>
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="modal.close(null)"></button>
</div>
<div class="modal-body">
<p>{{t('description')}}</p>
<form [formGroup]="libForm">
<select class="form-select" formControlName="library">
<option [value]="null">{{t('select-option')}}</option>
@for (lib of libraries; track lib.id) {
<option [value]="lib.id">{{lib.name}}</option>
}
</select>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" (click)="modal.close(null)">{{t('close')}}</button>
<button type="button" class="btn btn-primary" (click)="save()">{{t('select')}}</button>
</div>
</ng-container>

View file

@ -0,0 +1,30 @@
import {ChangeDetectionStrategy, Component, inject, Input} from '@angular/core';
import {Library} from "../../../_models/library/library";
import {NgbActiveModal} from "@ng-bootstrap/ng-bootstrap";
import {TranslocoDirective} from "@jsverse/transloco";
import {FormControl, FormGroup, ReactiveFormsModule} from "@angular/forms";
@Component({
selector: 'app-copy-settings-from-library-modal',
standalone: true,
imports: [
TranslocoDirective,
ReactiveFormsModule,
],
templateUrl: './copy-settings-from-library-modal.component.html',
styleUrl: './copy-settings-from-library-modal.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class CopySettingsFromLibraryModalComponent {
protected readonly modal = inject(NgbActiveModal);
@Input() libraries: Array<Library> = [];
libForm = new FormGroup({
'library': new FormControl(null),
});
save() {
this.modal.close(parseInt(this.libForm.get('library')?.value + '', 10));
}
}

View file

@ -18,19 +18,20 @@ import {SelectionModel} from "../../../typeahead/_models/selection-model";
})
export class LibraryAccessModalComponent implements OnInit {
protected readonly modal = inject(NgbActiveModal);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly libraryService = inject(LibraryService);
@Input() member: Member | undefined;
allLibraries: Library[] = [];
selectedLibraries: Array<{selected: boolean, data: Library}> = [];
selections!: SelectionModel<Library>;
selectAll: boolean = false;
cdRef = inject(ChangeDetectorRef);
get hasSomeSelected() {
return this.selections != null && this.selections.hasSomeSelected();
}
constructor(public modal: NgbActiveModal, private libraryService: LibraryService) { }
ngOnInit(): void {
this.libraryService.getLibraries().subscribe(libs => {

View file

@ -1,15 +1,45 @@
<ng-container *transloco="let t; read: 'manage-library'">
<div class="position-relative">
<div class="position-absolute custom-position-2">
<app-card-actionables [actions]="bulkActions" btnClass="btn-primary-outline ms-1" [label]="t('bulk-action-label')" [disabled]="bulkMode" (actionHandler)="handleBulkAction($event, null)">
</app-card-actionables>
</div>
<button class="btn btn-primary-outline position-absolute custom-position" (click)="addLibrary()" [title]="t('add-library')">
<i class="fa fa-plus" aria-hidden="true"></i><span class="phone-hidden ms-1">{{t('add-library')}}</span>
</button>
</div>
<!-- TODO: We need a filter bar when there is more than 10 libraries -->
@if (bulkMode && bulkAction === Action.CopySettings && sourceCopyToLibrary) {
<div class="alert alert-warning">
{{t('bulk-copy-to', {libraryName: sourceCopyToLibrary.name})}}
<form [formGroup]="bulkForm">
<div class="form-check form-switch">
<input id="bulk-action-type" type="checkbox" class="form-check-input" formControlName="includeType" aria-describedby="include-type-help">
<label class="form-check-label" for="bulk-action-type">{{t('include-type-label')}}</label>
<i class="fa fa-info-circle ms-1" aria-hidden="true" placement="left" [ngbTooltip]="includeTypeTooltip" role="button" tabindex="0"></i>
<ng-template #includeTypeTooltip>{{t('include-type-tooltip')}}</ng-template>
<span class="visually-hidden" id="include-type-help"><ng-container [ngTemplateOutlet]="includeTypeTooltip"></ng-container></span>
</div>
</form>
<div class="mt-2">
<button class="btn btn-secondary" (click)="resetBulkMode()">{{t('cancel')}}</button>
<button class="btn btn-primary ms-1" (click)="applyBulkAction()" [disabled]="!hasSomeSelected">{{t('apply')}}</button>
</div>
</div>
}
<table class="table table-striped">
<thead>
<tr>
<th scope="col">
<div class="form-check">
<input id="select-all" type="checkbox" class="form-check-input"
[ngModel]="selectAll" (change)="toggleAll()" [indeterminate]="hasSomeSelected">
<label for="select-all" class="form-check-label d-md-block d-none">{{t('select-all')}}</label>
</div>
</th>
<th scope="col">{{t('name-header')}}</th>
<th scope="col">{{t('type-title')}}</th>
<th scope="col">{{t('shared-folders-title')}}</th>
@ -20,7 +50,14 @@
<tbody>
@for(library of libraries; track library.name + library.type + library.folders.length + library.lastScanned; let idx = $index) {
<tr>
<td id="username--{{idx}}">
<td>
<div class="form-check">
<input id="select-library-{{idx}}" type="checkbox" class="form-check-input"
[ngModel]="selections.isSelected(library)" (change)="handleSelection(library, idx)">
<label for="select-library-{{idx}}" class="form-check-label visually-hidden">{{library.name}}</label>
</div>
</td>
<td id="library--{{idx}}">
<a [routerLink]="'/library/' + library.id">{{library.name}}</a>
</td>
<td>
@ -33,6 +70,7 @@
{{library.lastScanned | timeAgo | defaultDate}}
</td>
<td>
<!-- On Mobile we want to use ... for each row -->
@if (useActionables$ | async) {
<app-card-actionables [actions]="actions" (actionHandler)="performAction($event, library)"></app-card-actionables>
} @else {

View file

@ -5,6 +5,19 @@
top: -42px;
}
.custom-position-2 {
right: 160px;
top: -42px;
}
@media(max-width: 576px) {
.custom-position-2 {
right: 65px;
}
}
.member-name {
word-break: keep-all;
margin: 0;

View file

@ -2,28 +2,31 @@ import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
DestroyRef, HostListener,
DestroyRef,
HostListener,
inject,
OnInit
} from '@angular/core';
import { NgbModal, NgbTooltip } from '@ng-bootstrap/ng-bootstrap';
import { ToastrService } from 'ngx-toastr';
import { distinctUntilChanged, filter, take } from 'rxjs/operators';
import { ConfirmService } from 'src/app/shared/confirm.service';
import { LibrarySettingsModalComponent } from 'src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component';
import { NotificationProgressEvent } from 'src/app/_models/events/notification-progress-event';
import { ScanSeriesEvent } from 'src/app/_models/events/scan-series-event';
import { Library } from 'src/app/_models/library/library';
import { LibraryService } from 'src/app/_services/library.service';
import { EVENTS, Message, MessageHubService } from 'src/app/_services/message-hub.service';
import {NgbModal, NgbTooltip} from '@ng-bootstrap/ng-bootstrap';
import {ToastrService} from 'ngx-toastr';
import {distinctUntilChanged, filter, take} from 'rxjs/operators';
import {ConfirmService} from 'src/app/shared/confirm.service';
import {
LibrarySettingsModalComponent
} from 'src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component';
import {NotificationProgressEvent} from 'src/app/_models/events/notification-progress-event';
import {ScanSeriesEvent} from 'src/app/_models/events/scan-series-event';
import {Library} from 'src/app/_models/library/library';
import {LibraryService} from 'src/app/_services/library.service';
import {EVENTS, Message, MessageHubService} from 'src/app/_services/message-hub.service';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import { SentenceCasePipe } from '../../_pipes/sentence-case.pipe';
import { TimeAgoPipe } from '../../_pipes/time-ago.pipe';
import { LibraryTypePipe } from '../../_pipes/library-type.pipe';
import { RouterLink } from '@angular/router';
import {SentenceCasePipe} from '../../_pipes/sentence-case.pipe';
import {TimeAgoPipe} from '../../_pipes/time-ago.pipe';
import {LibraryTypePipe} from '../../_pipes/library-type.pipe';
import {RouterLink} from '@angular/router';
import {translate, TranslocoModule} from "@jsverse/transloco";
import {DefaultDatePipe} from "../../_pipes/default-date.pipe";
import {AsyncPipe, TitleCasePipe} from "@angular/common";
import {AsyncPipe, NgTemplateOutlet, TitleCasePipe} from "@angular/common";
import {DefaultValuePipe} from "../../_pipes/default-value.pipe";
import {LoadingComponent} from "../../shared/loading/loading.component";
import {TagBadgeComponent} from "../../shared/tag-badge/tag-badge.component";
@ -33,6 +36,12 @@ import {Action, ActionFactoryService, ActionItem} from "../../_services/action-f
import {ActionService} from "../../_services/action.service";
import {CardActionablesComponent} from "../../_single-module/card-actionables/card-actionables.component";
import {BehaviorSubject, Observable} from "rxjs";
import {Select2Module} from "ng-select2-component";
import {SelectionModel} from "../../typeahead/_models/selection-model";
import {
CopySettingsFromLibraryModalComponent
} from "../_modals/copy-settings-from-library-modal/copy-settings-from-library-modal.component";
import {FormControl, FormGroup} from "@angular/forms";
@Component({
selector: 'app-manage-library',
@ -40,7 +49,9 @@ import {BehaviorSubject, Observable} from "rxjs";
styleUrls: ['./manage-library.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [RouterLink, NgbTooltip, LibraryTypePipe, TimeAgoPipe, SentenceCasePipe, TranslocoModule, DefaultDatePipe, AsyncPipe, DefaultValuePipe, LoadingComponent, TagBadgeComponent, TitleCasePipe, UtcToLocalTimePipe, CardActionablesComponent]
imports: [RouterLink, NgbTooltip, LibraryTypePipe, TimeAgoPipe, SentenceCasePipe, TranslocoModule, DefaultDatePipe,
AsyncPipe, DefaultValuePipe, LoadingComponent, TagBadgeComponent, TitleCasePipe, UtcToLocalTimePipe,
CardActionablesComponent, Select2Module, NgTemplateOutlet]
})
export class ManageLibraryComponent implements OnInit {
@ -56,8 +67,10 @@ export class ManageLibraryComponent implements OnInit {
private readonly actionService = inject(ActionService);
protected readonly Breakpoint = Breakpoint;
protected readonly Action = Action;
actions = this.actionFactoryService.getLibraryActions(this.handleAction.bind(this));
bulkActions = this.actionFactoryService.getBulkLibraryActions(this.handleBulkAction.bind(this));
libraries: Library[] = [];
loading = false;
/**
@ -66,6 +79,25 @@ export class ManageLibraryComponent implements OnInit {
deletionInProgress: boolean = false;
useActionableSource = new BehaviorSubject<boolean>(this.utilityService.getActiveBreakpoint() <= Breakpoint.Tablet);
useActionables$: Observable<boolean> = this.useActionableSource.asObservable();
selections!: SelectionModel<Library>;
selectAll: boolean = false;
bulkMode = false;
bulkAction: Action | null = null;
sourceCopyToLibrary: Library | null = null;
bulkForm = new FormGroup({'includeType': new FormControl(false)});
isShiftDown: boolean = false;
lastSelectedIndex: number | null = null;
@HostListener('document:keydown.shift', ['$event'])
handleKeypress(event: KeyboardEvent) {
this.isShiftDown = true;
}
@HostListener('document:keyup.shift', ['$event'])
handleKeyUp(event: KeyboardEvent) {
this.isShiftDown = false;
}
@HostListener('window:resize', ['$event'])
@HostListener('window:orientationchange', ['$event'])
@ -73,6 +105,10 @@ export class ManageLibraryComponent implements OnInit {
this.useActionableSource.next(this.utilityService.getActiveBreakpoint() <= Breakpoint.Tablet);
}
get hasSomeSelected() {
return this.selections != null && this.selections.hasSomeSelected();
}
ngOnInit(): void {
this.getLibraries();
@ -118,6 +154,8 @@ export class ManageLibraryComponent implements OnInit {
this.cdRef.markForCheck();
this.libraryService.getLibraries().pipe(take(1), takeUntilDestroyed(this.destroyRef)).subscribe(libraries => {
this.libraries = [...libraries];
this.setupSelections();
this.resetBulkMode();
this.loading = false;
this.cdRef.markForCheck();
});
@ -158,6 +196,80 @@ export class ManageLibraryComponent implements OnInit {
await this.actionService.scanLibrary(library);
}
async applyBulkAction() {
if (!this.bulkMode) {
this.resetBulkMode();
}
// Get Selected libraries
let selected = this.selections.selected();
// Remove the source library id from selected (if applicable)
if (this.bulkAction === Action.CopySettings) {
selected = selected.filter(l => l.id !== this.sourceCopyToLibrary!.id);
}
if (selected.length === 0) {
await this.confirmService.alert(translate('toasts.must-select-library'));
return;
}
switch(this.bulkAction) {
case (Action.Scan):
await this.confirmService.alert(translate('toasts.bulk-scan'));
this.libraryService.scanMultipleLibraries(selected.map(l => l.id)).subscribe();
break;
case Action.RefreshMetadata:
if (!await this.confirmService.confirm(translate('toasts.bulk-covers'))) return;
this.libraryService.refreshMetadataMultipleLibraries(selected.map(l => l.id), true, false).subscribe(() => this.getLibraries());
break
case Action.AnalyzeFiles:
this.libraryService.analyzeFilesMultipleLibraries(selected.map(l => l.id)).subscribe(() => this.getLibraries());
break;
case Action.GenerateColorScape:
this.libraryService.refreshMetadataMultipleLibraries(selected.map(l => l.id), false, true).subscribe(() => this.getLibraries());
break;
case Action.CopySettings:
// Remove the source library from the list
if (selected.length === 1 && selected[0].id === this.sourceCopyToLibrary!.id) {
return;
}
const includeType = this.bulkForm.get('includeType')!.value + '' == 'true';
this.libraryService.copySettingsFromLibrary(this.sourceCopyToLibrary!.id, selected.map(l => l.id), includeType).subscribe(() => this.getLibraries());
break;
}
}
async handleBulkAction(action: ActionItem<Library>, library : Library | null) {
this.bulkMode = true;
this.bulkAction = action.action;
this.cdRef.markForCheck();
switch (action.action) {
case(Action.Scan):
case(Action.RefreshMetadata):
case(Action.GenerateColorScape):
case (Action.Delete):
await this.applyBulkAction();
break;
case (Action.CopySettings):
// Prompt the user for the library then wait for them to manually trigger applyBulkAction
const ref = this.modalService.open(CopySettingsFromLibraryModalComponent, {size: 'lg', fullscreen: 'md'});
ref.componentInstance.libraries = this.libraries;
ref.closed.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((res: number | null) => {
if (res === null) return;
// res will be the library the user chose
this.bulkMode = true;
this.sourceCopyToLibrary = this.libraries.filter(l => l.id === res)[0];
this.cdRef.markForCheck();
});
break;
}
}
async handleAction(action: ActionItem<Library>, library: Library) {
switch (action.action) {
case(Action.Scan):
@ -185,4 +297,57 @@ export class ManageLibraryComponent implements OnInit {
action.callback(action, library);
}
}
setupSelections() {
this.selections = new SelectionModel<Library>(false, this.libraries);
this.cdRef.markForCheck();
}
toggleAll() {
this.selectAll = !this.selectAll;
this.libraries.forEach(s => this.selections.toggle(s, this.selectAll));
this.cdRef.markForCheck();
}
handleSelection(item: Library, index: number) {
if (this.isShiftDown && this.lastSelectedIndex !== null) {
// Bulk select items between the last selected item and the current one
const start = Math.min(this.lastSelectedIndex, index);
const end = Math.max(this.lastSelectedIndex, index);
for (let i = start; i <= end; i++) {
const library = this.libraries[i];
if (!this.selections.isSelected(library)) {
this.selections.toggle(library, true); // Select the item
}
}
} else {
// Toggle the clicked item
this.selections.toggle(item);
}
// Update the last selected index
this.lastSelectedIndex = index;
// Manage the state of "Select All" and "Has Some Selected"
const numberOfSelected = this.selections.selected().length;
this.selectAll = numberOfSelected === this.libraries.length;
this.cdRef.markForCheck();
}
resetBulkMode() {
this.bulkAction = null;
this.bulkMode = false;
this.sourceCopyToLibrary = null;
this.libraries.forEach(s => {
if (this.selections.isSelected(s)) {
this.selections.toggle(s, false)
}
});
this.selectAll = false;
this.cdRef.markForCheck();
}
}

View file

@ -1,7 +1,7 @@
import { ConfirmButton } from './confirm-button';
export class ConfirmConfig {
_type: string = 'confirm'; // internal only: confirm or alert (todo: use enum)
_type: 'confirm' | 'alert' = 'confirm';
header: string = 'Confirm';
content: string = '';
buttons: Array<ConfirmButton> = [];

View file

@ -3,6 +3,7 @@ import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { take } from 'rxjs/operators';
import { ConfirmDialogComponent } from './confirm-dialog/confirm-dialog.component';
import { ConfirmConfig } from './confirm-dialog/_models/confirm-config';
import {translate} from "@jsverse/transloco";
@Injectable({
providedIn: 'root'
@ -32,6 +33,7 @@ export class ConfirmService {
if (content !== undefined && config === undefined) {
config = this.defaultConfirm;
config.header = translate('confirm.confirm');
config.content = content;
}
if (content !== undefined && content !== '' && config!.content === '') {
@ -58,7 +60,8 @@ export class ConfirmService {
}
if (content !== undefined && config === undefined) {
config = this.defaultConfirm;
config = this.defaultAlert;
config.header = translate('confirm.alert');
config.content = content;
}

View file

@ -23,7 +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 {AsyncPipe, DecimalPipe, NgIf} from '@angular/common';
import {translate, TranslocoDirective, TranslocoService} from "@jsverse/transloco";
import {translate, TranslocoDirective} from "@jsverse/transloco";
import {FilterComparison} from "../../../_models/metadata/v2/filter-comparison";
import {FilterField} from "../../../_models/metadata/v2/filter-field";
import {

View file

@ -9,28 +9,34 @@
<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>
@for (item of timePeriods; track item.value) {
<option [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}}
@if (users$ | async; as users) {
<app-carousel-reel [alwaysShow]="false" [clickableTitle]="false" [items]="users">
<ng-template #carouselItem let-item>
<div class="card me-2">
<div class="card-header text-center">
{{item.username}}
</div>
<ul class="list-group list-group-flush">
<li class="list-group-item">{{t('comics-label', {value: item.comicsTime})}}</li>
<li class="list-group-item">{{t('manga-label', {value: item.mangaTime})}}</li>
<li class="list-group-item">{{t('books-label', {value: item.booksTime})}}</li>
</ul>
</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-template>
</app-carousel-reel>
}

View file

@ -11,8 +11,9 @@ 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 { AsyncPipe } from '@angular/common';
import {TranslocoDirective} from "@jsverse/transloco";
import {CarouselReelComponent} from "../../../carousel/_components/carousel-reel/carousel-reel.component";
export const TimePeriods: Array<{title: string, value: number}> =
[{title: 'this-week', value: new Date().getDay() || 1},
@ -28,15 +29,16 @@ export const TimePeriods: Array<{title: string, value: number}> =
styleUrls: ['./top-readers.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [ReactiveFormsModule, NgFor, AsyncPipe, TranslocoDirective]
imports: [ReactiveFormsModule, AsyncPipe, TranslocoDirective, CarouselReelComponent]
})
export class TopReadersComponent implements OnInit {
private readonly destroyRef = inject(DestroyRef);
formGroup: FormGroup;
timePeriods = TimePeriods;
users$: Observable<TopUserRead[]>;
private readonly destroyRef = inject(DestroyRef);
constructor(private statsService: StatisticsService, private readonly cdRef: ChangeDetectorRef) {
this.formGroup = new FormGroup({

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1172,7 +1172,7 @@
"no-data": "There are no libraries. Try creating one.",
"loading": "{{common.loading}}",
"last-scanned-title": "Last Scanned",
"shared-folders-title": "Shared Folders",
"shared-folders-title": "Folders",
"type-title": "Type",
"scan-library": "Scan Library",
"delete-library": "Delete Library",
@ -1181,7 +1181,23 @@
"edit-library-by-name": "Delete {{name}}",
"folder-count": "{{num}} folders",
"actions-header": "{{manage-users.actions-header}}",
"name-header": "{{manage-users.name-header}}"
"name-header": "{{manage-users.name-header}}",
"deselect-all": "{{common.deselect-all}}",
"select-all": "{{common.select-all}}",
"cancel": "{{common.cancel}}",
"apply": "{{common.apply}}",
"bulk-copy-to": "Select which libraries to copy the settings from {{libraryName}} to",
"include-type-label": "Include copying Library type",
"include-type-tooltip": "This will not scan automatically. On the next scan, you may have series shift due to parsing differences",
"bulk-action-label": "Bulk Action"
},
"copy-settings-from-library-modal": {
"close": "{{common.close}}",
"select": "Select",
"title": "Copy settings from one library to multiple others",
"description": "Select a library to copy settings from and on the next screen select libraries to apply to.",
"select-option": "Select a Library"
},
"manage-media-settings": {
@ -2239,7 +2255,10 @@
"is-empty": "Is Empty"
},
"confirm": {
"alert": "Alert",
"confirm": "Confirm"
},
"toasts": {
"regen-cover": "A job has been enqueued to regenerate the cover image",
@ -2351,7 +2370,10 @@
"stack-imported": "Stack Imported",
"confirm-delete-theme": "Removing this theme will delete it from the disk. You can grab it from temp directory before removal",
"mal-token-required": "MAL Token is required, set in User Settings",
"confirm-reset-server-settings": "This will reset your settings to first install values. Are you sure you want to continue?"
"confirm-reset-server-settings": "This will reset your settings to first install values. Are you sure you want to continue?",
"must-select-library": "At least one library must be selected",
"bulk-scan": "Scanning multiple libraries will be done linearly. This may take a long time and not complete depending on library size.",
"bulk-covers": "Refreshing covers on multiple libraries is intensive and can take a long time. Are you sure you want to continue?"
},
"read-time-pipe": {
@ -2423,8 +2445,8 @@
"new-collection": "New Collection",
"multiple-selections": "Multiple Selections",
"back-to": "Back to {{action}}",
"title": "Actions"
"title": "Actions",
"copy-settings": "Copy Settings From"
},
"preferences": {

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

80
UI/Web/sync-locales.js Normal file
View file

@ -0,0 +1,80 @@
const fs = require('fs');
const path = require('path');
function syncLocales() {
const webDir = path.resolve(__dirname);
const langDir = path.join(webDir, 'src', 'assets', 'langs');
const sourceFile = path.join(langDir, 'en.json');
console.log('Web directory:', webDir);
console.log('Language directory:', langDir);
console.log('Source file:', sourceFile);
if (!fs.existsSync(sourceFile)) {
console.error(`Source file not found: ${sourceFile}`);
process.exit(1);
}
const sourceData = JSON.parse(fs.readFileSync(sourceFile, 'utf8'));
const localeFiles = fs.readdirSync(langDir).filter(file => file.endsWith('.json') && file !== 'en.json');
localeFiles.forEach(localeFile => {
const filePath = path.join(langDir, localeFile);
console.log(`Processing: ${filePath}`);
if (!fs.existsSync(filePath)) {
console.error(`File not found: ${filePath}`);
return;
}
let localeData = JSON.parse(fs.readFileSync(filePath, 'utf8'));
let updated = false;
function updateNestedObject(source, target, parentKeys = []) {
const updatedTarget = {};
Object.keys(source).forEach(key => {
const fullKeyPath = [...parentKeys, key].join('.'); // Track parent keys
if (typeof source[key] === 'object' && source[key] !== null) {
if (!target[key] || Object.keys(target[key]).length === 0) {
updatedTarget[key] = {};
updated = true;
console.log(`Added new object for key: ${fullKeyPath}`);
} else {
updatedTarget[key] = target[key];
}
updatedTarget[key] = updateNestedObject(source[key], updatedTarget[key], [...parentKeys, key]);
} else {
if (typeof source[key] === 'string') {
if (source[key].match(/{{.+\..+}}/)) {
if (target[key] !== source[key]) {
updatedTarget[key] = source[key];
updated = true;
console.log(`Updated key: ${fullKeyPath}`);
} else {
updatedTarget[key] = target[key];
}
} else if (!target.hasOwnProperty(key)) {
updatedTarget[key] = '';
updated = true;
console.log(`Added empty string for key: ${fullKeyPath}`);
} else {
updatedTarget[key] = target[key];
}
}
}
});
return updatedTarget;
}
localeData = updateNestedObject(sourceData, localeData);
if (updated) {
fs.writeFileSync(filePath, JSON.stringify(localeData, null, 2));
console.log(`Updated ${localeFile}`);
} else {
console.log(`No updates needed for ${localeFile}`);
}
});
}
syncLocales();