From 701829001c748cccb6ae7fb8ccfd8f11aee2f11b Mon Sep 17 00:00:00 2001 From: Flaminel Date: Sun, 15 Feb 2026 01:19:32 +0200 Subject: [PATCH] Fix speed and size inputs for queue rules (#440) --- .../queue-cleaner.component.html | 11 +- .../queue-cleaner/queue-cleaner.component.ts | 22 +++- code/frontend/src/app/ui/index.ts | 2 + .../ui/size-input/size-input.component.html | 40 ++++++ .../ui/size-input/size-input.component.scss | 115 ++++++++++++++++++ .../app/ui/size-input/size-input.component.ts | 115 ++++++++++++++++++ 6 files changed, 297 insertions(+), 8 deletions(-) create mode 100644 code/frontend/src/app/ui/size-input/size-input.component.html create mode 100644 code/frontend/src/app/ui/size-input/size-input.component.scss create mode 100644 code/frontend/src/app/ui/size-input/size-input.component.ts diff --git a/code/frontend/src/app/features/settings/queue-cleaner/queue-cleaner.component.html b/code/frontend/src/app/features/settings/queue-cleaner/queue-cleaner.component.html index dd475b14..34a9b7d6 100644 --- a/code/frontend/src/app/features/settings/queue-cleaner/queue-cleaner.component.html +++ b/code/frontend/src/app/features/settings/queue-cleaner/queue-cleaner.component.html @@ -267,7 +267,8 @@ hint="Reset strike count when torrent shows progress" helpKey="queue-cleaner:stallRule.resetStrikesOnProgress" /> @if (stallResetOnProgress()) { - } @@ -297,8 +298,9 @@ hint="Number of strikes before action is taken" [error]="slowMaxStrikesError()" helpKey="queue-cleaner:slowRule.maxStrikes" /> - - 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), }); } diff --git a/code/frontend/src/app/ui/index.ts b/code/frontend/src/app/ui/index.ts index e8de7449..600d0b0f 100644 --- a/code/frontend/src/app/ui/index.ts +++ b/code/frontend/src/app/ui/index.ts @@ -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'; diff --git a/code/frontend/src/app/ui/size-input/size-input.component.html b/code/frontend/src/app/ui/size-input/size-input.component.html new file mode 100644 index 00000000..606d08e5 --- /dev/null +++ b/code/frontend/src/app/ui/size-input/size-input.component.html @@ -0,0 +1,40 @@ +@if (label()) { + +} +
+ +
+ @for (unit of units(); track unit.value) { + + } +
+
+@if (error()) { + {{ error() }} +} @else if (hint()) { + {{ hint() }} +} diff --git a/code/frontend/src/app/ui/size-input/size-input.component.scss b/code/frontend/src/app/ui/size-input/size-input.component.scss new file mode 100644 index 00000000..520b1f55 --- /dev/null +++ b/code/frontend/src/app/ui/size-input/size-input.component.scss @@ -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); +} diff --git a/code/frontend/src/app/ui/size-input/size-input.component.ts b/code/frontend/src/app/ui/size-input/size-input.component.ts new file mode 100644 index 00000000..54066fdd --- /dev/null +++ b/code/frontend/src/app/ui/size-input/size-input.component.ts @@ -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(); + units = input.required(); + placeholder = input(''); + disabled = input(false); + min = input(0); + error = input(); + hint = input(); + helpKey = input(); + value = model(''); + + readonly numericValue = signal(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); + } + } +}