mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-05-18 11:34:59 -04:00
Fix speed and size inputs for queue rules (#440)
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
115
code/frontend/src/app/ui/size-input/size-input.component.scss
Normal file
115
code/frontend/src/app/ui/size-input/size-input.component.scss
Normal 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);
|
||||
}
|
||||
115
code/frontend/src/app/ui/size-input/size-input.component.ts
Normal file
115
code/frontend/src/app/ui/size-input/size-input.component.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user