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:
parent
894b49bb76
commit
857e419e4e
77 changed files with 72523 additions and 30914 deletions
|
@ -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",
|
||||
|
|
|
@ -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 = [
|
||||
{
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>>();
|
||||
|
||||
|
|
|
@ -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>
|
|
@ -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));
|
||||
}
|
||||
}
|
|
@ -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 => {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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> = [];
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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>
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
@ -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
|
@ -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
80
UI/Web/sync-locales.js
Normal 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();
|
Loading…
Add table
Add a link
Reference in a new issue