Fix speed and size inputs for queue rules (#440)

This commit is contained in:
Flaminel
2026-02-15 01:19:32 +02:00
committed by GitHub
parent 8aeeca111c
commit 701829001c
6 changed files with 297 additions and 8 deletions

View File

@@ -267,7 +267,8 @@
hint="Reset strike count when torrent shows progress"
helpKey="queue-cleaner:stallRule.resetStrikesOnProgress" />
@if (stallResetOnProgress()) {
<app-input label="Minimum Progress to Reset" placeholder="e.g. 1MB" [(value)]="stallMinProgress"
<app-size-input label="Minimum Progress to Reset" [units]="sizeUnits" [(value)]="stallMinProgress"
placeholder="e.g. 1"
hint="Only reset strikes after the torrent downloads at least this amount. Leave blank to reset on any progress."
helpKey="queue-cleaner:stallRule.minimumProgress" />
}
@@ -297,8 +298,9 @@
hint="Number of strikes before action is taken"
[error]="slowMaxStrikesError()"
helpKey="queue-cleaner:slowRule.maxStrikes" />
<app-input label="Min Speed" placeholder="e.g. 100KB/s" [(value)]="slowMinSpeed"
hint="Minimum speed threshold for slow downloads (e.g., 100KB/s)"
<app-size-input label="Min Speed" [units]="speedUnits" [(value)]="slowMinSpeed"
placeholder="e.g. 100"
hint="Minimum speed threshold for slow downloads"
helpKey="queue-cleaner:slowRule.minSpeed" />
<app-number-input label="Maximum Time (Hours)" [(value)]="slowMaxTimeHours" [min]="0"
hint="Maximum time allowed for slow downloads (0 means disabled)"
@@ -314,7 +316,8 @@
<app-number-input label="Max Completion %" [(value)]="slowMaxCompletion" [min]="0" [max]="100" suffix="%"
hint="Apply the rule up to and including this completion percentage"
helpKey="queue-cleaner:slowRule.completionRange" />
<app-input label="Ignore Above Size" placeholder="e.g. 25 GB" [(value)]="slowIgnoreAboveSize"
<app-size-input label="Ignore Above Size" [units]="sizeUnitsLarge" [(value)]="slowIgnoreAboveSize"
placeholder="e.g. 25"
hint="Downloads will be ignored if size exceeds this threshold"
helpKey="queue-cleaner:slowRule.ignoreAboveSize" />
<app-toggle label="Reset Strikes on Progress" [(checked)]="slowResetOnProgress"

View File

@@ -4,7 +4,8 @@ import {
CardComponent, ButtonComponent, InputComponent, ToggleComponent,
NumberInputComponent, SelectComponent, ChipInputComponent, AccordionComponent,
BadgeComponent, ModalComponent, EmptyStateComponent, LoadingStateComponent,
type SelectOption,
SizeInputComponent,
type SelectOption, type SizeUnit,
} from '@ui';
import { NgIcon } from '@ng-icons/core';
import { QueueCleanerApi } from '@core/api/queue-cleaner.api';
@@ -41,7 +42,8 @@ const SCHEDULE_UNIT_OPTIONS: SelectOption[] = [
imports: [
PageHeaderComponent, CardComponent, ButtonComponent, InputComponent,
ToggleComponent, NumberInputComponent, SelectComponent, ChipInputComponent,
AccordionComponent, BadgeComponent, ModalComponent, EmptyStateComponent, LoadingStateComponent, NgIcon,
AccordionComponent, BadgeComponent, ModalComponent, EmptyStateComponent, LoadingStateComponent,
SizeInputComponent, NgIcon,
],
templateUrl: './queue-cleaner.component.html',
styleUrl: './queue-cleaner.component.scss',
@@ -58,6 +60,18 @@ export class QueueCleanerComponent implements OnInit, HasPendingChanges {
readonly patternModeOptions = PATTERN_MODE_OPTIONS;
readonly privacyTypeOptions = PRIVACY_TYPE_OPTIONS;
readonly scheduleUnitOptions = SCHEDULE_UNIT_OPTIONS;
readonly speedUnits: SizeUnit[] = [
{ label: 'KB/s', value: 'KB' },
{ label: 'MB/s', value: 'MB' },
];
readonly sizeUnits: SizeUnit[] = [
{ label: 'KB', value: 'KB' },
{ label: 'MB', value: 'MB' },
];
readonly sizeUnitsLarge: SizeUnit[] = [
{ label: 'MB', value: 'MB' },
{ label: 'GB', value: 'GB' },
];
readonly loader = new DeferredLoader();
readonly loadError = signal(false);
readonly saving = signal(false);
@@ -381,7 +395,7 @@ export class QueueCleanerComponent implements OnInit, HasPendingChanges {
this.stallModalVisible.set(false);
this.loadStallRules();
},
error: () => this.toast.error('Failed to save stall rule'),
error: (e: Error) => this.toast.error(e.message),
});
}
@@ -461,7 +475,7 @@ export class QueueCleanerComponent implements OnInit, HasPendingChanges {
this.slowModalVisible.set(false);
this.loadSlowRules();
},
error: () => this.toast.error('Failed to save slow rule'),
error: (e: Error) => this.toast.error(e.message),
});
}

View File

@@ -22,3 +22,5 @@ export { EmptyStateComponent } from './empty-state/empty-state.component';
export { LoadingStateComponent } from './loading-state/loading-state.component';
export { ToastContainerComponent } from './toast-container/toast-container.component';
export { ConfirmDialogComponent } from './confirm-dialog/confirm-dialog.component';
export { SizeInputComponent } from './size-input/size-input.component';
export type { SizeUnit } from './size-input/size-input.component';

View File

@@ -0,0 +1,40 @@
@if (label()) {
<label class="size-label">
{{ label() }}
@if (helpKey()) {
<button class="field-help-btn" (click)="onHelpClick($event)" aria-label="Open documentation" tabindex="-1">
<ng-icon name="tablerQuestionMark" size="14" />
</button>
}
</label>
}
<div class="size-wrapper">
<input
class="size-field"
[class.size-field--error]="error()"
type="number"
[placeholder]="placeholder()"
[disabled]="disabled()"
[min]="min()"
[value]="numericValue()"
(input)="onNumericInput($event)"
/>
<div class="size-units">
@for (unit of units(); track unit.value) {
<button
class="size-unit-btn"
[class.size-unit-btn--active]="selectedUnit() === unit.value"
[disabled]="disabled()"
type="button"
(click)="selectUnit(unit.value)"
>
{{ unit.label }}
</button>
}
</div>
</div>
@if (error()) {
<span class="size-error">{{ error() }}</span>
} @else if (hint()) {
<span class="size-hint">{{ hint() }}</span>
}

View File

@@ -0,0 +1,115 @@
@use 'glass' as *;
:host {
display: flex;
flex-direction: column;
gap: var(--space-1-5);
}
.size-label {
font-size: var(--font-size-sm);
font-weight: 500;
color: var(--text-secondary);
}
.size-wrapper {
display: flex;
align-items: center;
@include glass-input;
height: 38px;
padding: 0;
overflow: hidden;
&:focus-within {
border-color: var(--input-border-focus);
box-shadow: 0 0 0 3px var(--color-primary-subtle),
0 0 12px rgba(126, 87, 194, 0.15);
}
}
.size-field {
flex: 1;
height: 100%;
background: none;
border: none;
color: var(--input-text);
font-family: var(--font-family);
font-size: var(--font-size-sm);
padding: 0 var(--space-3);
outline: none;
-moz-appearance: textfield;
&::-webkit-inner-spin-button,
&::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
&::placeholder {
color: var(--input-placeholder);
}
&--error {
border-color: var(--color-error) !important;
}
}
.size-units {
display: flex;
align-items: center;
height: 100%;
flex-shrink: 0;
border-left: 1px solid var(--input-border);
}
.size-unit-btn {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
padding: 0 var(--space-2-5);
background: none;
border: none;
color: var(--text-tertiary);
font-family: var(--font-family);
font-size: var(--font-size-xs);
font-weight: 500;
cursor: pointer;
white-space: nowrap;
transition: color var(--duration-fast) var(--ease-default),
background var(--duration-fast) var(--ease-default);
&:not(:last-child) {
border-right: 1px solid var(--input-border);
}
&:hover:not(:disabled):not(.size-unit-btn--active) {
background: var(--glass-bg-hover);
color: var(--text-secondary);
}
&--active {
background: var(--color-primary);
color: #fff;
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
}
.size-error {
font-size: var(--font-size-xs);
color: var(--color-error);
}
.size-hint {
font-size: var(--font-size-xs);
color: var(--text-tertiary);
}
:host:focus-within .size-label {
color: var(--color-primary);
transition: color var(--duration-fast) var(--ease-default);
}

View File

@@ -0,0 +1,115 @@
import { Component, ChangeDetectionStrategy, input, model, signal, effect, untracked, inject } from '@angular/core';
import { NgIcon } from '@ng-icons/core';
import { DocumentationService } from '@core/services/documentation.service';
export interface SizeUnit {
label: string;
value: string;
}
@Component({
selector: 'app-size-input',
standalone: true,
imports: [NgIcon],
templateUrl: './size-input.component.html',
styleUrl: './size-input.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SizeInputComponent {
private readonly docs = inject(DocumentationService);
label = input<string>();
units = input.required<SizeUnit[]>();
placeholder = input('');
disabled = input(false);
min = input<number>(0);
error = input<string>();
hint = input<string>();
helpKey = input<string>();
value = model('');
readonly numericValue = signal<number | null>(null);
readonly selectedUnit = signal('');
private syncing = false;
constructor() {
// Parse incoming value string into numeric + unit
effect(() => {
const val = this.value();
const units = this.units();
if (this.syncing || units.length === 0) return;
untracked(() => {
const parsed = this.parseValue(val, units);
this.numericValue.set(parsed.numeric);
this.selectedUnit.set(parsed.unit);
});
});
}
private parseValue(val: string, units: SizeUnit[]): { numeric: number | null; unit: string } {
const defaultUnit = units[0]?.value ?? '';
if (!val || !val.trim()) {
return { numeric: null, unit: this.selectedUnit() || defaultUnit };
}
const trimmed = val.trim().toUpperCase();
// Try matching each unit suffix (longest first to avoid partial matches)
const sortedUnits = [...units].sort((a, b) => b.value.length - a.value.length);
for (const u of sortedUnits) {
if (trimmed.endsWith(u.value.toUpperCase())) {
const numStr = trimmed.slice(0, -u.value.length).trim();
const num = numStr ? Number(numStr) : null;
if (num !== null && !isNaN(num)) {
return { numeric: num, unit: u.value };
}
}
}
// Try parsing as plain number, keep current unit
const num = Number(trimmed);
if (!isNaN(num)) {
return { numeric: num, unit: this.selectedUnit() || defaultUnit };
}
return { numeric: null, unit: this.selectedUnit() || defaultUnit };
}
onNumericInput(event: Event): void {
const target = event.target as HTMLInputElement;
const num = target.value === '' ? null : Number(target.value);
this.numericValue.set(num);
this.compose();
}
selectUnit(unitValue: string): void {
if (this.disabled()) return;
this.selectedUnit.set(unitValue);
this.compose();
}
private compose(): void {
const num = this.numericValue();
const unit = this.selectedUnit();
this.syncing = true;
if (num == null || unit === '') {
this.value.set('');
} else {
this.value.set(`${num}${unit}`);
}
this.syncing = false;
}
onHelpClick(event: Event): void {
event.preventDefault();
event.stopPropagation();
const key = this.helpKey();
if (key) {
const [section, field] = key.split(':');
this.docs.openFieldDocumentation(section, field);
}
}
}