mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-01-25 22:28:35 -05:00
fixed download client UI
This commit is contained in:
@@ -3,147 +3,237 @@
|
||||
<h1>Download Clients</h1>
|
||||
</div>
|
||||
|
||||
<p-card styleClass="settings-card">
|
||||
<ng-template pTemplate="header">
|
||||
<div class="flex align-items-center justify-content-between p-3 border-bottom-1 surface-border">
|
||||
<div class="header-title-container">
|
||||
<h2 class="card-title m-0">Download Client Configuration</h2>
|
||||
<span class="card-subtitle">Configure download client integration settings</span>
|
||||
</div>
|
||||
<div class="flex align-items-center gap-2">
|
||||
<i class="pi pi-download text-xl"></i>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<div class="card-content">
|
||||
<!-- Loading/Error State Component -->
|
||||
<app-loading-error-state
|
||||
*ngIf="downloadClientLoading() || downloadClientError()"
|
||||
[loading]="downloadClientLoading()"
|
||||
[error]="downloadClientError()"
|
||||
loadingMessage="Loading settings..."
|
||||
errorMessage="Could not connect to server"
|
||||
></app-loading-error-state>
|
||||
|
||||
<!-- Form Content - only shown when not loading and no error -->
|
||||
<form *ngIf="!downloadClientLoading() && !downloadClientError()" [formGroup]="downloadClientForm" class="p-fluid">
|
||||
<!-- Clients Section -->
|
||||
<div class="section-header mt-4">
|
||||
<h3>Download Clients</h3>
|
||||
<small>Configure multiple download client instances</small>
|
||||
</div>
|
||||
|
||||
<!-- Clients Container -->
|
||||
<div class="instances-container">
|
||||
<!-- Empty state message when no clients -->
|
||||
<div *ngIf="clients.controls.length === 0" class="empty-instances-message p-3 text-center">
|
||||
<p>No download clients defined. Add a client to start.</p>
|
||||
<!-- Loading/Error State Component -->
|
||||
<div class="mb-4">
|
||||
<app-loading-error-state
|
||||
*ngIf="downloadClientLoading() || downloadClientError()"
|
||||
[loading]="downloadClientLoading()"
|
||||
[error]="downloadClientError()"
|
||||
loadingMessage="Loading settings..."
|
||||
errorMessage="Could not connect to server"
|
||||
></app-loading-error-state>
|
||||
</div>
|
||||
|
||||
<!-- Content - only shown when not loading and no error -->
|
||||
<div *ngIf="!downloadClientLoading() && !downloadClientError()">
|
||||
|
||||
<!-- Client Management Card -->
|
||||
<p-card styleClass="settings-card mb-4">
|
||||
<ng-template pTemplate="header">
|
||||
<div class="flex align-items-center justify-content-between p-3 border-bottom-1 surface-border">
|
||||
<div class="header-title-container">
|
||||
<h2 class="card-title m-0">Download Clients</h2>
|
||||
<span class="card-subtitle">Manage download client instances</span>
|
||||
</div>
|
||||
|
||||
<!-- Client cards -->
|
||||
<div class="instance-list">
|
||||
<div *ngFor="let client of clients.controls; let i = index" class="instance-item" [formGroup]="getClientAsFormGroup(i)">
|
||||
<div class="instance-header">
|
||||
<div class="instance-title">
|
||||
<i class="pi pi-download instance-icon"></i>
|
||||
<input type="text" pInputText formControlName="name" placeholder="Client name" class="instance-name-input" />
|
||||
</div>
|
||||
<button pButton type="button" icon="pi pi-trash" class="p-button-danger p-button-sm"
|
||||
(click)="removeClient(i)" [disabled]="downloadClientForm.disabled"></button>
|
||||
</div>
|
||||
<small *ngIf="hasClientFieldError(i, 'name', 'required')" class="p-error block">Name is required</small>
|
||||
<div class="d-flex align-items-center ml-4 mb-2">
|
||||
<p-checkbox formControlName="enabled" [binary]="true"></p-checkbox>
|
||||
<small class="ml-2">Enabled</small>
|
||||
</div>
|
||||
|
||||
<div class="instance-content">
|
||||
<div class="instance-field">
|
||||
<label>Client Type</label>
|
||||
<div class="field-input">
|
||||
<p-select
|
||||
formControlName="type"
|
||||
[options]="clientTypeOptions"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
placeholder="Select client type"
|
||||
appendTo="body"
|
||||
></p-select>
|
||||
<small *ngIf="hasClientFieldError(i, 'type', 'required')" class="p-error block">Client type is required</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Connection fields - only shown when client type is not Usenet -->
|
||||
<ng-container *ngIf="!isUsenetClient(client.get('type')?.value)">
|
||||
<div class="instance-field">
|
||||
<label>Host</label>
|
||||
<div class="field-input">
|
||||
<input type="text" pInputText formControlName="host" placeholder="http://localhost:8080" required />
|
||||
<small *ngIf="hasClientFieldError(i, 'host', 'required')" class="p-error block">Host is required</small>
|
||||
<small *ngIf="hasClientFieldError(i, 'host', 'invalidUri')" class="p-error block">Host must be a valid URL</small>
|
||||
<small *ngIf="hasClientFieldError(i, 'host', 'invalidProtocol')" class="p-error block">Host must use http or https protocol</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="instance-field">
|
||||
<label>URL Base</label>
|
||||
<div class="field-input">
|
||||
<input type="text" pInputText formControlName="urlBase" placeholder="(Optional) Path prefix" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="instance-field">
|
||||
<label>Username</label>
|
||||
<div class="field-input">
|
||||
<input type="text" pInputText formControlName="username" placeholder="Username" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="instance-field">
|
||||
<label>Password</label>
|
||||
<div class="field-input">
|
||||
<input type="password" pInputText formControlName="password" placeholder="Password" />
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<!-- Message shown when Usenet is selected -->
|
||||
<div *ngIf="isUsenetClient(client.get('type')?.value)" class="p-3 mt-2 surface-overlay border-1 border-round-sm">
|
||||
<small class="text-secondary">Usenet client type is for categorization only. No connection details needed.</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex align-items-center gap-2">
|
||||
<i class="pi pi-download text-xl"></i>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<!-- Empty state when no clients -->
|
||||
<div *ngIf="clients.length === 0" class="empty-instances-message p-3 text-center">
|
||||
<i class="pi pi-inbox empty-icon"></i>
|
||||
<p>No download clients defined. Add a client to start.</p>
|
||||
</div>
|
||||
|
||||
<!-- Clients List -->
|
||||
<div *ngIf="clients.length > 0" class="instances-list">
|
||||
<div *ngFor="let client of clients" class="instance-item">
|
||||
<div class="instance-header">
|
||||
<div class="instance-title">
|
||||
<i class="pi pi-download instance-icon"></i>
|
||||
<span class="instance-name">{{ client.name }}</span>
|
||||
</div>
|
||||
<div class="instance-actions">
|
||||
<button
|
||||
pButton
|
||||
type="button"
|
||||
icon="pi pi-pencil"
|
||||
class="p-button-text p-button-sm"
|
||||
[disabled]="downloadClientSaving()"
|
||||
(click)="openEditClientModal(client)"
|
||||
pTooltip="Edit client"
|
||||
></button>
|
||||
<button
|
||||
pButton
|
||||
type="button"
|
||||
icon="pi pi-trash"
|
||||
class="p-button-text p-button-sm p-button-danger"
|
||||
[disabled]="downloadClientSaving()"
|
||||
(click)="deleteClient(client)"
|
||||
pTooltip="Delete client"
|
||||
></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-content-end mt-3">
|
||||
<button pButton type="button" icon="pi pi-plus" label="Add Client"
|
||||
(click)="addClient()" class="p-button-outlined"></button>
|
||||
<div class="instance-content">
|
||||
<div class="instance-field">
|
||||
<label>Type: {{ getClientTypeLabel(client) }}</label>
|
||||
</div>
|
||||
<div class="instance-field" *ngIf="client.host">
|
||||
<label>Host: {{ client.host }}</label>
|
||||
</div>
|
||||
<div class="instance-field" *ngIf="client.urlBase">
|
||||
<label>URL Base: {{ client.urlBase }}</label>
|
||||
</div>
|
||||
<div class="instance-field">
|
||||
<label>Status:
|
||||
<span [class]="client.enabled ? 'text-green-500' : 'text-red-500'">
|
||||
{{ client.enabled ? 'Enabled' : 'Disabled' }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div class="card-footer mt-3">
|
||||
<button
|
||||
pButton
|
||||
type="button"
|
||||
label="Save"
|
||||
icon="pi pi-save"
|
||||
class="p-button-primary"
|
||||
[disabled]="(!downloadClientForm.dirty || !hasActualChanges) || downloadClientForm.invalid || downloadClientSaving()"
|
||||
[loading]="downloadClientSaving()"
|
||||
(click)="saveDownloadClientConfig()"
|
||||
></button>
|
||||
<button
|
||||
pButton
|
||||
type="button"
|
||||
label="Reset"
|
||||
icon="pi pi-refresh"
|
||||
class="p-button-secondary p-button-outlined ml-2"
|
||||
(click)="resetDownloadClientConfig()"
|
||||
></button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</p-card>
|
||||
</div>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div class="card-footer mt-3">
|
||||
<button
|
||||
pButton
|
||||
type="button"
|
||||
icon="pi pi-plus"
|
||||
label="Add Client"
|
||||
class="p-button-outlined"
|
||||
[disabled]="downloadClientSaving()"
|
||||
(click)="openAddClientModal()"
|
||||
></button>
|
||||
</div>
|
||||
</p-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Client Modal -->
|
||||
<p-dialog
|
||||
[(visible)]="showClientModal"
|
||||
[modal]="true"
|
||||
[closable]="true"
|
||||
[draggable]="false"
|
||||
[resizable]="false"
|
||||
styleClass="instance-modal"
|
||||
[header]="modalTitle"
|
||||
(onHide)="closeClientModal()"
|
||||
>
|
||||
<form [formGroup]="clientForm" class="p-fluid instance-form">
|
||||
<div class="field flex flex-row">
|
||||
<label class="field-label">Enabled</label>
|
||||
<div class="field-input">
|
||||
<p-checkbox formControlName="enabled" [binary]="true"></p-checkbox>
|
||||
<small class="form-helper-text">Enable this download client</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="client-name">Name *</label>
|
||||
<input
|
||||
id="client-name"
|
||||
type="text"
|
||||
pInputText
|
||||
formControlName="name"
|
||||
placeholder="My Download Client"
|
||||
class="w-full"
|
||||
/>
|
||||
<small *ngIf="hasError(clientForm, 'name', 'required')" class="p-error">Name is required</small>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="client-type">Client Type *</label>
|
||||
<p-select
|
||||
id="client-type"
|
||||
formControlName="type"
|
||||
[options]="clientTypeOptions"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
placeholder="Select client type"
|
||||
appendTo="body"
|
||||
class="w-full"
|
||||
></p-select>
|
||||
<small *ngIf="hasError(clientForm, 'type', 'required')" class="p-error">Client type is required</small>
|
||||
</div>
|
||||
|
||||
<!-- Connection fields - only shown when client type is not Usenet -->
|
||||
<ng-container *ngIf="!isUsenetClient(clientForm.get('type')?.value)">
|
||||
<div class="field">
|
||||
<label for="client-host">Host *</label>
|
||||
<input
|
||||
id="client-host"
|
||||
type="text"
|
||||
pInputText
|
||||
formControlName="host"
|
||||
placeholder="http://localhost:8080"
|
||||
class="w-full"
|
||||
/>
|
||||
<small *ngIf="hasError(clientForm, 'host', 'required')" class="p-error">Host is required</small>
|
||||
<small *ngIf="hasError(clientForm, 'host', 'invalidUri')" class="p-error">Host must be a valid URL</small>
|
||||
<small *ngIf="hasError(clientForm, 'host', 'invalidProtocol')" class="p-error">Host must use http or https protocol</small>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="client-urlbase">URL Base</label>
|
||||
<input
|
||||
id="client-urlbase"
|
||||
type="text"
|
||||
pInputText
|
||||
formControlName="urlBase"
|
||||
placeholder="(Optional) Path prefix"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="client-username">Username</label>
|
||||
<input
|
||||
id="client-username"
|
||||
type="text"
|
||||
pInputText
|
||||
formControlName="username"
|
||||
placeholder="Username"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="client-password">Password</label>
|
||||
<input
|
||||
id="client-password"
|
||||
type="password"
|
||||
pInputText
|
||||
formControlName="password"
|
||||
placeholder="Password"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<!-- Message shown when Usenet is selected -->
|
||||
<div *ngIf="isUsenetClient(clientForm.get('type')?.value)" class="p-3 mt-2 surface-overlay border-1 border-round-sm">
|
||||
<small class="text-secondary">Usenet client type is for categorization only. No connection details needed.</small>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<ng-template pTemplate="footer">
|
||||
<div class="modal-footer">
|
||||
<button
|
||||
pButton
|
||||
type="button"
|
||||
label="Cancel"
|
||||
class="p-button-text"
|
||||
(click)="closeClientModal()"
|
||||
></button>
|
||||
<button
|
||||
pButton
|
||||
type="button"
|
||||
label="Save"
|
||||
icon="pi pi-save"
|
||||
class="p-button-primary ml-2"
|
||||
[disabled]="clientForm.invalid || downloadClientSaving()"
|
||||
[loading]="downloadClientSaving()"
|
||||
(click)="saveClient()"
|
||||
></button>
|
||||
</div>
|
||||
</ng-template>
|
||||
</p-dialog>
|
||||
|
||||
<!-- Confirmation Dialog -->
|
||||
<p-confirmDialog></p-confirmDialog>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Component, EventEmitter, OnDestroy, Output, effect, inject } from "@angular/core";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { FormArray, FormBuilder, FormGroup, ReactiveFormsModule, Validators, AbstractControl, ValidationErrors, FormControl } from "@angular/forms";
|
||||
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators, AbstractControl, ValidationErrors } from "@angular/forms";
|
||||
import { Subject, takeUntil } from "rxjs";
|
||||
import { DownloadClientConfigStore } from "./download-client-config.store";
|
||||
import { CanComponentDeactivate } from "../../core/guards";
|
||||
@@ -15,6 +15,9 @@ import { ButtonModule } from "primeng/button";
|
||||
import { InputNumberModule } from "primeng/inputnumber";
|
||||
import { SelectModule } from 'primeng/select';
|
||||
import { ToastModule } from "primeng/toast";
|
||||
import { DialogModule } from "primeng/dialog";
|
||||
import { ConfirmDialogModule } from "primeng/confirmdialog";
|
||||
import { ConfirmationService } from "primeng/api";
|
||||
import { NotificationService } from "../../core/services/notification.service";
|
||||
import { LoadingErrorStateComponent } from "../../shared/components/loading-error-state/loading-error-state.component";
|
||||
|
||||
@@ -31,9 +34,11 @@ import { LoadingErrorStateComponent } from "../../shared/components/loading-erro
|
||||
InputNumberModule,
|
||||
SelectModule,
|
||||
ToastModule,
|
||||
DialogModule,
|
||||
ConfirmDialogModule,
|
||||
LoadingErrorStateComponent
|
||||
],
|
||||
providers: [DownloadClientConfigStore],
|
||||
providers: [DownloadClientConfigStore, ConfirmationService],
|
||||
templateUrl: "./download-client-settings.component.html",
|
||||
styleUrls: ["./download-client-settings.component.scss"],
|
||||
})
|
||||
@@ -41,14 +46,13 @@ export class DownloadClientSettingsComponent implements OnDestroy, CanComponentD
|
||||
@Output() saved = new EventEmitter<void>();
|
||||
@Output() error = new EventEmitter<string>();
|
||||
|
||||
// Download Client Configuration Form
|
||||
downloadClientForm: FormGroup;
|
||||
// Forms
|
||||
clientForm: FormGroup;
|
||||
|
||||
// Original form values for tracking changes
|
||||
private originalFormValues: any;
|
||||
|
||||
// Track whether the form has actual changes compared to original values
|
||||
hasActualChanges = false;
|
||||
// Modal state
|
||||
showClientModal = false;
|
||||
modalMode: 'add' | 'edit' = 'add';
|
||||
editingClient: ClientConfig | null = null;
|
||||
|
||||
// Download client type options
|
||||
clientTypeOptions = [
|
||||
@@ -61,9 +65,10 @@ export class DownloadClientSettingsComponent implements OnDestroy, CanComponentD
|
||||
// Clean up subscriptions
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
// Inject the necessary services
|
||||
// Services
|
||||
private formBuilder = inject(FormBuilder);
|
||||
private notificationService = inject(NotificationService);
|
||||
private confirmationService = inject(ConfirmationService);
|
||||
private downloadClientStore = inject(DownloadClientConfigStore);
|
||||
|
||||
// Signals from store
|
||||
@@ -71,45 +76,34 @@ export class DownloadClientSettingsComponent implements OnDestroy, CanComponentD
|
||||
downloadClientLoading = this.downloadClientStore.loading;
|
||||
downloadClientError = this.downloadClientStore.error;
|
||||
downloadClientSaving = this.downloadClientStore.saving;
|
||||
pendingOperations = this.downloadClientStore.pendingOperations;
|
||||
|
||||
/**
|
||||
* Get the clients form array
|
||||
*/
|
||||
public get clients(): FormArray {
|
||||
return this.downloadClientForm.get('clients') as FormArray;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if component can be deactivated (navigation guard)
|
||||
*/
|
||||
canDeactivate(): boolean {
|
||||
return !this.downloadClientForm?.dirty || !this.hasActualChanges;
|
||||
return true; // No unsaved changes in modal-based approach
|
||||
}
|
||||
|
||||
constructor() {
|
||||
// Initialize the main form
|
||||
this.downloadClientForm = this.formBuilder.group({});
|
||||
|
||||
// Add clients FormArray to main form
|
||||
this.downloadClientForm.addControl('clients', this.formBuilder.array([]));
|
||||
// Initialize client form for modal
|
||||
this.clientForm = this.formBuilder.group({
|
||||
name: ['', Validators.required],
|
||||
type: [null, Validators.required],
|
||||
host: ['', [Validators.required, this.uriValidator.bind(this)]],
|
||||
username: [''],
|
||||
password: [''],
|
||||
urlBase: [''],
|
||||
enabled: [true]
|
||||
});
|
||||
|
||||
// Load Download Client config data
|
||||
this.downloadClientStore.loadConfig();
|
||||
|
||||
// Setup effect to update form when config changes
|
||||
effect(() => {
|
||||
const config = this.downloadClientConfig();
|
||||
if (config) {
|
||||
this.updateFormFromConfig(config);
|
||||
}
|
||||
});
|
||||
|
||||
// Track form changes for dirty state
|
||||
this.downloadClientForm.valueChanges
|
||||
// Setup client type change handler
|
||||
this.clientForm.get('type')?.valueChanges
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe(() => {
|
||||
this.hasActualChanges = this.formValuesChanged();
|
||||
this.onClientTypeChange();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -122,222 +116,213 @@ export class DownloadClientSettingsComponent implements OnDestroy, CanComponentD
|
||||
}
|
||||
|
||||
/**
|
||||
* Update form with values from the configuration
|
||||
* Custom validator to check if the input is a valid URI
|
||||
*/
|
||||
private updateFormFromConfig(config: DownloadClientConfig): void {
|
||||
// Clear existing clients
|
||||
const clientsArray = this.downloadClientForm.get('clients') as FormArray;
|
||||
clientsArray.clear();
|
||||
|
||||
// Add each client to the form array
|
||||
if (config.clients && config.clients.length > 0) {
|
||||
config.clients.forEach(client => {
|
||||
this.addClient(client);
|
||||
});
|
||||
}
|
||||
// Don't automatically add an empty client - let the template handle the empty state
|
||||
|
||||
// Store the original values for change detection
|
||||
this.storeOriginalValues();
|
||||
|
||||
// Mark the form as pristine after loading data
|
||||
this.downloadClientForm.markAsPristine();
|
||||
this.hasActualChanges = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store original form values for dirty checking
|
||||
*/
|
||||
private storeOriginalValues(): void {
|
||||
this.originalFormValues = JSON.parse(JSON.stringify(this.downloadClientForm.value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current form values are different from the original values
|
||||
*/
|
||||
private formValuesChanged(): boolean {
|
||||
return !this.isEqual(this.downloadClientForm.value, this.originalFormValues);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep compare two objects for equality
|
||||
*/
|
||||
private isEqual(obj1: any, obj2: any): boolean {
|
||||
if (obj1 === obj2) return true;
|
||||
if (obj1 === null || obj2 === null) return false;
|
||||
if (obj1 === undefined || obj2 === undefined) return false;
|
||||
|
||||
if (typeof obj1 !== 'object' && typeof obj2 !== 'object') {
|
||||
return obj1 === obj2;
|
||||
private uriValidator(control: AbstractControl): ValidationErrors | null {
|
||||
if (!control.value) {
|
||||
return null; // Let required validator handle empty values
|
||||
}
|
||||
|
||||
if (Array.isArray(obj1) && Array.isArray(obj2)) {
|
||||
if (obj1.length !== obj2.length) return false;
|
||||
for (let i = 0; i < obj1.length; i++) {
|
||||
if (!this.isEqual(obj1[i], obj2[i])) return false;
|
||||
try {
|
||||
const url = new URL(control.value);
|
||||
|
||||
// Check that we have a valid protocol (http or https)
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||
return { invalidProtocol: true };
|
||||
}
|
||||
return true;
|
||||
|
||||
return null; // Valid URI
|
||||
} catch (e) {
|
||||
return { invalidUri: true }; // Invalid URI
|
||||
}
|
||||
|
||||
const keys1 = Object.keys(obj1);
|
||||
const keys2 = Object.keys(obj2);
|
||||
|
||||
if (keys1.length !== keys2.length) return false;
|
||||
|
||||
for (const key of keys1) {
|
||||
if (!this.isEqual(obj1[key], obj2[key])) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the Download Client configuration
|
||||
* Mark all controls in a form group as touched
|
||||
*/
|
||||
saveDownloadClientConfig(): void {
|
||||
// Mark all form controls as touched to trigger validation
|
||||
this.markFormGroupTouched(this.downloadClientForm);
|
||||
private markFormGroupTouched(formGroup: FormGroup): void {
|
||||
Object.values(formGroup.controls).forEach((control) => {
|
||||
control.markAsTouched();
|
||||
|
||||
if (this.downloadClientForm.invalid) {
|
||||
if ((control as any).controls) {
|
||||
this.markFormGroupTouched(control as FormGroup);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a form control has an error
|
||||
*/
|
||||
hasError(form: FormGroup, controlName: string, errorName: string): boolean {
|
||||
const control = form.get(controlName);
|
||||
return control !== null && control.hasError(errorName) && control.touched;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get clients from current config
|
||||
*/
|
||||
get clients(): ClientConfig[] {
|
||||
return this.downloadClientConfig()?.clients || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Open modal to add new client
|
||||
*/
|
||||
openAddClientModal(): void {
|
||||
this.modalMode = 'add';
|
||||
this.editingClient = null;
|
||||
this.clientForm.reset();
|
||||
this.clientForm.patchValue({ enabled: true }); // Default enabled to true
|
||||
this.showClientModal = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open modal to edit existing client
|
||||
*/
|
||||
openEditClientModal(client: ClientConfig): void {
|
||||
this.modalMode = 'edit';
|
||||
this.editingClient = client;
|
||||
|
||||
// Map backend type to frontend type
|
||||
const frontendType = client.typeName
|
||||
? this.mapClientTypeFromBackend(client.typeName)
|
||||
: client.type;
|
||||
|
||||
this.clientForm.patchValue({
|
||||
name: client.name,
|
||||
type: frontendType,
|
||||
host: client.host,
|
||||
username: client.username,
|
||||
password: client.password,
|
||||
urlBase: client.urlBase,
|
||||
enabled: client.enabled
|
||||
});
|
||||
this.showClientModal = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close client modal
|
||||
*/
|
||||
closeClientModal(): void {
|
||||
this.showClientModal = false;
|
||||
this.editingClient = null;
|
||||
this.clientForm.reset();
|
||||
}
|
||||
|
||||
/**
|
||||
* Save client (add or edit)
|
||||
*/
|
||||
saveClient(): void {
|
||||
this.markFormGroupTouched(this.clientForm);
|
||||
|
||||
if (this.clientForm.invalid) {
|
||||
this.notificationService.showError('Please fix the validation errors before saving');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.hasActualChanges) {
|
||||
this.notificationService.showSuccess('No changes detected');
|
||||
return;
|
||||
}
|
||||
const formValue = this.clientForm.value;
|
||||
const mappedType = this.mapClientTypeForBackend(formValue.type);
|
||||
|
||||
const clientData: CreateDownloadClientDto = {
|
||||
name: formValue.name,
|
||||
typeName: mappedType.typeName,
|
||||
type: mappedType.type,
|
||||
host: formValue.host,
|
||||
username: formValue.username,
|
||||
password: formValue.password,
|
||||
urlBase: formValue.urlBase,
|
||||
enabled: formValue.enabled
|
||||
};
|
||||
|
||||
// Get the clients from the form
|
||||
const formClients = this.clients.getRawValue();
|
||||
|
||||
if (formClients.length === 0) {
|
||||
this.notificationService.showSuccess('No clients to save');
|
||||
return;
|
||||
}
|
||||
|
||||
// Separate creates and updates
|
||||
const creates: CreateDownloadClientDto[] = [];
|
||||
const updates: Array<{ id: string, client: ClientConfig }> = [];
|
||||
|
||||
formClients.forEach((client: any) => {
|
||||
// Map the client type for backend compatibility
|
||||
const mappedType = this.mapClientTypeForBackend(client.type);
|
||||
const backendClient = {
|
||||
...client,
|
||||
if (this.modalMode === 'add') {
|
||||
this.downloadClientStore.createClient(clientData);
|
||||
} else if (this.editingClient) {
|
||||
// For updates, create a proper ClientConfig object
|
||||
const clientConfig: ClientConfig = {
|
||||
id: this.editingClient.id!,
|
||||
name: formValue.name,
|
||||
type: formValue.type, // Keep the frontend enum type
|
||||
typeName: mappedType.typeName,
|
||||
type: mappedType.type
|
||||
host: formValue.host,
|
||||
username: formValue.username,
|
||||
password: formValue.password,
|
||||
urlBase: formValue.urlBase,
|
||||
enabled: formValue.enabled
|
||||
};
|
||||
|
||||
if (client.id) {
|
||||
// This is an existing client, prepare for update
|
||||
updates.push({ id: client.id, client: backendClient });
|
||||
} else {
|
||||
// This is a new client, prepare for creation (don't send ID)
|
||||
const { id, ...clientWithoutId } = backendClient;
|
||||
creates.push(clientWithoutId as CreateDownloadClientDto);
|
||||
}
|
||||
});
|
||||
|
||||
// Use batch operations to handle everything at once
|
||||
this.downloadClientStore.processBatchOperations({ creates, updates });
|
||||
|
||||
// Monitor the saving state to show completion feedback
|
||||
this.monitorSavingCompletion();
|
||||
this.downloadClientStore.updateClient({
|
||||
id: this.editingClient.id!,
|
||||
client: clientConfig
|
||||
});
|
||||
}
|
||||
|
||||
this.monitorClientSaving();
|
||||
}
|
||||
|
||||
/**
|
||||
* Monitor saving completion and show appropriate feedback
|
||||
* Monitor client saving completion
|
||||
*/
|
||||
private monitorSavingCompletion(): void {
|
||||
// Use a timeout to check the saving state periodically
|
||||
private monitorClientSaving(): void {
|
||||
const checkSavingStatus = () => {
|
||||
const saving = this.downloadClientSaving();
|
||||
const error = this.downloadClientError();
|
||||
const pendingOps = this.pendingOperations();
|
||||
|
||||
if (!saving && pendingOps === 0) {
|
||||
// Operations are complete
|
||||
if (!saving) {
|
||||
if (error) {
|
||||
this.notificationService.showError(`Save completed with issues: ${error}`);
|
||||
this.error.emit(error);
|
||||
// Don't mark as pristine if there were errors
|
||||
this.notificationService.showError(`Operation failed: ${error}`);
|
||||
} else {
|
||||
// Complete success
|
||||
this.notificationService.showSuccess('Download Client configuration saved successfully');
|
||||
this.saved.emit();
|
||||
|
||||
// Reload config from backend to ensure UI is in sync
|
||||
this.downloadClientStore.loadConfig();
|
||||
|
||||
// Reset form state after successful save
|
||||
setTimeout(() => {
|
||||
this.downloadClientForm.markAsPristine();
|
||||
this.hasActualChanges = false;
|
||||
this.storeOriginalValues();
|
||||
}, 100);
|
||||
const action = this.modalMode === 'add' ? 'created' : 'updated';
|
||||
this.notificationService.showSuccess(`Client ${action} successfully`);
|
||||
this.closeClientModal();
|
||||
}
|
||||
} else {
|
||||
// Still saving, check again in a short while
|
||||
setTimeout(checkSavingStatus, 100);
|
||||
}
|
||||
};
|
||||
|
||||
// Start monitoring
|
||||
setTimeout(checkSavingStatus, 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the Download Client configuration form to default values
|
||||
* Delete client with confirmation
|
||||
*/
|
||||
resetDownloadClientConfig(): void {
|
||||
// Clear all clients
|
||||
const clientsArray = this.downloadClientForm.get('clients') as FormArray;
|
||||
clientsArray.clear();
|
||||
|
||||
// Check if this reset actually changes anything compared to the original state
|
||||
const hasChangesAfterReset = this.formValuesChanged();
|
||||
|
||||
if (hasChangesAfterReset) {
|
||||
// Only mark as dirty if the reset actually changes something
|
||||
this.downloadClientForm.markAsDirty();
|
||||
this.hasActualChanges = true;
|
||||
} else {
|
||||
// If reset brings us back to original state, mark as pristine
|
||||
this.downloadClientForm.markAsPristine();
|
||||
this.hasActualChanges = false;
|
||||
}
|
||||
deleteClient(client: ClientConfig): void {
|
||||
this.confirmationService.confirm({
|
||||
message: `Are you sure you want to delete the client "${client.name}"?`,
|
||||
header: 'Confirm Deletion',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptButtonStyleClass: 'p-button-danger',
|
||||
accept: () => {
|
||||
this.downloadClientStore.deleteClient(client.id!);
|
||||
|
||||
// Monitor deletion
|
||||
const checkDeletionStatus = () => {
|
||||
const saving = this.downloadClientSaving();
|
||||
const error = this.downloadClientError();
|
||||
|
||||
if (!saving) {
|
||||
if (error) {
|
||||
this.notificationService.showError(`Deletion failed: ${error}`);
|
||||
} else {
|
||||
this.notificationService.showSuccess('Client deleted successfully');
|
||||
}
|
||||
} else {
|
||||
setTimeout(checkDeletionStatus, 100);
|
||||
}
|
||||
};
|
||||
|
||||
setTimeout(checkDeletionStatus, 100);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new client to the clients form array
|
||||
* @param client Optional client configuration to initialize the form with
|
||||
* Get modal title based on mode
|
||||
*/
|
||||
addClient(client: ClientConfig | null = null): void {
|
||||
// If client has typeName from backend, map it to frontend type
|
||||
const frontendType = client?.typeName
|
||||
? this.mapClientTypeFromBackend(client.typeName)
|
||||
: client?.type || null;
|
||||
|
||||
const clientForm = this.formBuilder.group({
|
||||
id: [client?.id || ''],
|
||||
name: [client?.name || '', Validators.required],
|
||||
type: [frontendType, Validators.required],
|
||||
host: [client?.host || '', Validators.required],
|
||||
username: [client?.username || ''],
|
||||
password: [client?.password || ''],
|
||||
urlBase: [client?.urlBase || ''],
|
||||
enabled: [client?.enabled ?? true]
|
||||
});
|
||||
|
||||
// Set up client type change handler
|
||||
clientForm.get('type')?.valueChanges.subscribe(() => {
|
||||
this.onClientTypeChange(clientForm);
|
||||
});
|
||||
|
||||
this.clients.push(clientForm);
|
||||
get modalTitle(): string {
|
||||
return this.modalMode === 'add' ? 'Add Download Client' : 'Edit Download Client';
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Map frontend client type to backend TypeName and Type
|
||||
*/
|
||||
@@ -373,95 +358,6 @@ export class DownloadClientSettingsComponent implements OnDestroy, CanComponentD
|
||||
return DownloadClientType.QBittorrent;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a client at the specified index
|
||||
*/
|
||||
removeClient(index: number): void {
|
||||
const clientForm = this.getClientAsFormGroup(index);
|
||||
const clientId = clientForm.get('id')?.value;
|
||||
|
||||
// If this is an existing client (has ID), delete it from the backend immediately
|
||||
if (clientId) {
|
||||
this.downloadClientStore.deleteClient(clientId);
|
||||
}
|
||||
|
||||
// Remove from the form array
|
||||
this.clients.removeAt(index);
|
||||
|
||||
// Mark form as dirty to enable save button
|
||||
this.downloadClientForm.markAsDirty();
|
||||
this.hasActualChanges = this.formValuesChanged();
|
||||
|
||||
// Don't automatically add an empty client - let users have a truly empty state
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark all controls in a form group as touched to trigger validation
|
||||
*/
|
||||
private markFormGroupTouched(formGroup: FormGroup): void {
|
||||
Object.values(formGroup.controls).forEach((control) => {
|
||||
control.markAsTouched();
|
||||
|
||||
if ((control as any).controls) {
|
||||
this.markFormGroupTouched(control as FormGroup);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a client at the specified index as a FormGroup
|
||||
*/
|
||||
public getClientAsFormGroup(index: number): FormGroup {
|
||||
return this.clients.at(index) as FormGroup;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the form control has an error
|
||||
* @param controlName The name of the control to check
|
||||
* @param errorName The name of the error to check for
|
||||
* @returns True if the control has the specified error
|
||||
*/
|
||||
hasError(controlName: string, errorName: string): boolean {
|
||||
const control = this.downloadClientForm.get(controlName);
|
||||
return control !== null && control.hasError(errorName) && control.touched;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a client field has an error
|
||||
* @param clientIndex The index of the client in the array
|
||||
* @param fieldName The name of the field to check
|
||||
* @param errorName The name of the error to check for
|
||||
* @returns True if the field has the specified error
|
||||
*/
|
||||
hasClientFieldError(clientIndex: number, fieldName: string, errorName: string): boolean {
|
||||
if (!this.clients || !this.clients.controls[clientIndex]) return false;
|
||||
|
||||
const control = this.getClientAsFormGroup(clientIndex).get(fieldName);
|
||||
return control !== null && control.hasError(errorName) && control.touched;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom validator to check if the input is a valid URI
|
||||
*/
|
||||
private uriValidator(control: AbstractControl): ValidationErrors | null {
|
||||
if (!control.value) {
|
||||
return null; // Let required validator handle empty values
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(control.value);
|
||||
|
||||
// Check that we have a valid protocol (http or https)
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||
return { invalidProtocol: true };
|
||||
}
|
||||
|
||||
return null; // Valid URI
|
||||
} catch (e) {
|
||||
return { invalidUri: true }; // Invalid URI
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a client type is Usenet
|
||||
@@ -472,11 +368,10 @@ export class DownloadClientSettingsComponent implements OnDestroy, CanComponentD
|
||||
|
||||
/**
|
||||
* Handle client type changes to update validation
|
||||
* @param clientFormGroup The form group containing the client type and host controls
|
||||
*/
|
||||
onClientTypeChange(clientFormGroup: FormGroup): void {
|
||||
const clientType = clientFormGroup.get('type')?.value;
|
||||
const hostControl = clientFormGroup.get('host');
|
||||
onClientTypeChange(): void {
|
||||
const clientType = this.clientForm.get('type')?.value;
|
||||
const hostControl = this.clientForm.get('host');
|
||||
|
||||
if (!hostControl) return;
|
||||
|
||||
@@ -495,4 +390,15 @@ export class DownloadClientSettingsComponent implements OnDestroy, CanComponentD
|
||||
hostControl.updateValueAndValidity();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client type label for display
|
||||
*/
|
||||
getClientTypeLabel(client: ClientConfig): string {
|
||||
const frontendType = client.typeName
|
||||
? this.mapClientTypeFromBackend(client.typeName)
|
||||
: client.type;
|
||||
|
||||
const option = this.clientTypeOptions.find(opt => opt.value === frontendType);
|
||||
return option?.label || 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user