diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6b8fe10..9cc6cd5 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -106,7 +106,6 @@ "core-js": "3.19.3", "critters": "0.0.15", "css-loader": "6.5.1", - "esbuild": "0.14.2", "esbuild-wasm": "0.14.2", "glob": "7.2.0", "https-proxy-agent": "5.0.0", @@ -350,7 +349,6 @@ "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-13.1.1.tgz", "integrity": "sha512-66PyWg+zKdxTe3b1pc1RduT8hsMs/hJ0aD0JX0pSEWVq7O0OJWJ5f0z+Mk03T9tAERA3NK1GifcKEDq5k7R2Zw==", "dependencies": { - "parse5": "^5.0.0", "tslib": "^2.3.0" }, "optionalDependencies": { @@ -3692,7 +3690,6 @@ "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", - "fsevents": "~2.3.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", @@ -5198,25 +5195,6 @@ "dev": true, "hasInstallScript": true, "optional": true, - "dependencies": { - "esbuild-android-arm64": "0.14.2", - "esbuild-darwin-64": "0.14.2", - "esbuild-darwin-arm64": "0.14.2", - "esbuild-freebsd-64": "0.14.2", - "esbuild-freebsd-arm64": "0.14.2", - "esbuild-linux-32": "0.14.2", - "esbuild-linux-64": "0.14.2", - "esbuild-linux-arm": "0.14.2", - "esbuild-linux-arm64": "0.14.2", - "esbuild-linux-mips64le": "0.14.2", - "esbuild-linux-ppc64le": "0.14.2", - "esbuild-netbsd-64": "0.14.2", - "esbuild-openbsd-64": "0.14.2", - "esbuild-sunos-64": "0.14.2", - "esbuild-windows-32": "0.14.2", - "esbuild-windows-64": "0.14.2", - "esbuild-windows-arm64": "0.14.2" - }, "bin": { "esbuild": "bin/esbuild" }, @@ -7418,9 +7396,6 @@ "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", "integrity": "sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss=", "dev": true, - "dependencies": { - "graceful-fs": "^4.1.6" - }, "optionalDependencies": { "graceful-fs": "^4.1.6" } @@ -7674,14 +7649,7 @@ "dev": true, "dependencies": { "copy-anything": "^2.0.1", - "errno": "^0.1.1", - "graceful-fs": "^4.1.2", - "image-size": "~0.5.0", - "make-dir": "^2.1.0", - "mime": "^1.4.1", - "needle": "^2.5.2", "parse-node-version": "^1.0.1", - "source-map": "~0.6.0", "tslib": "^2.3.0" }, "bin": { @@ -8229,7 +8197,6 @@ "integrity": "sha512-CGH1eblLq26Y15+Azk7ey4xh0J/XfJfrCox5LDJiKqI2Q2iwOLOKrlmIaODiSQS8d18jalF6y2K2ePUm0CmShw==", "dev": true, "dependencies": { - "encoding": "^0.1.12", "minipass": "^3.1.0", "minipass-sized": "^1.0.3", "minizlib": "^2.0.0" @@ -9246,8 +9213,7 @@ "dependencies": { "eventemitter-asyncresource": "^1.0.0", "hdr-histogram-js": "^2.0.1", - "hdr-histogram-percentiles-obj": "^3.0.0", - "nice-napi": "^1.0.2" + "hdr-histogram-percentiles-obj": "^3.0.0" }, "optionalDependencies": { "nice-napi": "^1.0.2" diff --git a/frontend/src/app/app.component.html b/frontend/src/app/app.component.html index 504d33a..47595a1 100644 --- a/frontend/src/app/app.component.html +++ b/frontend/src/app/app.component.html @@ -34,7 +34,7 @@ + [ngModel]="get$ | async" (onChange)="onFilterStateChange($event.value)" placeholder="State"> diff --git a/frontend/src/app/app.component.scss b/frontend/src/app/app.component.scss index ddd7d0a..a8b8c8c 100644 --- a/frontend/src/app/app.component.scss +++ b/frontend/src/app/app.component.scss @@ -56,7 +56,7 @@ } .layout-content { - padding: 1rem; + padding: 1rem 1rem 5rem 1rem; } } diff --git a/frontend/src/app/app.component.ts b/frontend/src/app/app.component.ts index 9e35825..3d5cd7c 100644 --- a/frontend/src/app/app.component.ts +++ b/frontend/src/app/app.component.ts @@ -6,6 +6,7 @@ import {SelectItem} from 'primeng/api'; import {FocusService} from './focus.service'; import {DialogService} from 'primeng/dynamicdialog'; import {PluginEnableComponent} from './components/plugin-enable/plugin-enable.component'; +import {PreferencesService, SortField} from './preferences.service'; type OptionalState = State | null; @@ -16,10 +17,28 @@ type OptionalState = State | null; styleUrls: ['./app.component.scss'], }) export class AppComponent { - sortByField: keyof Torrent = null; - sortReverse = false; + private _sortByField: SortField = null; + private _sortReverse = false; - sortOptions: SelectItem[] = [ + get sortByField(): SortField { + return this._sortByField; + } + + set sortByField(value: SortField) { + this._sortByField = value; + this.preferences.save({sortByField: value}); + } + + get sortReverse(): boolean { + return this._sortReverse; + } + + set sortReverse(value: boolean) { + this._sortReverse = value; + this.preferences.save({sortReverse: value}); + } + + sortOptions: SelectItem[] = [ { label: 'State', value: 'State' @@ -112,8 +131,13 @@ export class AppComponent { get$: BehaviorSubject; - constructor(private api: ApiService, private focus: FocusService, private dialogService: DialogService) { - this.get$ = new BehaviorSubject(null); + constructor(private api: ApiService, private focus: FocusService, private dialogService: DialogService, private preferences: PreferencesService) { + // Load saved preferences + const savedPrefs = this.preferences.load(); + this._sortByField = savedPrefs.sortByField; + this._sortReverse = savedPrefs.sortReverse; + + this.get$ = new BehaviorSubject(savedPrefs.filterState); this.refreshInterval(2000); } @@ -216,4 +240,13 @@ export class AppComponent { _ => console.log(`torrents in view reached target state ${targetState}`) ); } + + /** + * Called when the filter state dropdown changes + * @param state The new filter state + */ + onFilterStateChange(state: OptionalState): void { + this.get$.next(state); + this.preferences.save({filterState: state}); + } } diff --git a/frontend/src/app/preferences.service.spec.ts b/frontend/src/app/preferences.service.spec.ts new file mode 100644 index 0000000..5c22009 --- /dev/null +++ b/frontend/src/app/preferences.service.spec.ts @@ -0,0 +1,116 @@ +import {TestBed} from '@angular/core/testing'; + +import {PreferencesService, UserPreferences} from './preferences.service'; + +describe('PreferencesService', () => { + let service: PreferencesService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(PreferencesService); + localStorage.clear(); + }); + + afterEach(() => { + localStorage.clear(); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + describe('load', () => { + it('should return default preferences when no preferences are saved', () => { + const prefs = service.load(); + expect(prefs.sortByField).toBeNull(); + expect(prefs.sortReverse).toBe(false); + expect(prefs.filterState).toBeNull(); + }); + + it('should return saved preferences', () => { + const saved: UserPreferences = { + sortByField: 'Name', + sortReverse: true, + filterState: 'Downloading', + }; + localStorage.setItem('storm_preferences', JSON.stringify(saved)); + + const prefs = service.load(); + expect(prefs.sortByField).toBe('Name'); + expect(prefs.sortReverse).toBe(true); + expect(prefs.filterState).toBe('Downloading'); + }); + + it('should return default preferences when localStorage contains invalid JSON', () => { + localStorage.setItem('storm_preferences', 'invalid json'); + + const prefs = service.load(); + expect(prefs.sortByField).toBeNull(); + expect(prefs.sortReverse).toBe(false); + expect(prefs.filterState).toBeNull(); + }); + + it('should merge saved preferences with defaults for missing fields', () => { + localStorage.setItem('storm_preferences', JSON.stringify({sortByField: 'State'})); + + const prefs = service.load(); + expect(prefs.sortByField).toBe('State'); + expect(prefs.sortReverse).toBe(false); + expect(prefs.filterState).toBeNull(); + }); + + it('should return default values for invalid sort fields', () => { + localStorage.setItem('storm_preferences', JSON.stringify({sortByField: 'InvalidField'})); + + const prefs = service.load(); + expect(prefs.sortByField).toBeNull(); + }); + + it('should return default values for invalid filter states', () => { + localStorage.setItem('storm_preferences', JSON.stringify({filterState: 'InvalidState'})); + + const prefs = service.load(); + expect(prefs.filterState).toBeNull(); + }); + + it('should return default values for invalid sortReverse type', () => { + localStorage.setItem('storm_preferences', JSON.stringify({sortReverse: 'yes'})); + + const prefs = service.load(); + expect(prefs.sortReverse).toBe(false); + }); + }); + + describe('save', () => { + it('should save preferences to localStorage', () => { + service.save({sortByField: 'Progress', sortReverse: true}); + + const stored = localStorage.getItem('storm_preferences'); + expect(stored).toBeTruthy(); + + const parsed = JSON.parse(stored); + expect(parsed.sortByField).toBe('Progress'); + expect(parsed.sortReverse).toBe(true); + }); + + it('should merge new preferences with existing preferences', () => { + service.save({sortByField: 'Name'}); + service.save({filterState: 'Seeding'}); + + const stored = localStorage.getItem('storm_preferences'); + const parsed = JSON.parse(stored); + expect(parsed.sortByField).toBe('Name'); + expect(parsed.filterState).toBe('Seeding'); + }); + }); + + describe('clear', () => { + it('should remove preferences from localStorage', () => { + service.save({sortByField: 'Name'}); + expect(localStorage.getItem('storm_preferences')).toBeTruthy(); + + service.clear(); + expect(localStorage.getItem('storm_preferences')).toBeNull(); + }); + }); +}); diff --git a/frontend/src/app/preferences.service.ts b/frontend/src/app/preferences.service.ts new file mode 100644 index 0000000..03e2892 --- /dev/null +++ b/frontend/src/app/preferences.service.ts @@ -0,0 +1,89 @@ +import {Injectable} from '@angular/core'; +import {State, Torrent} from './api.service'; + +/** + * Valid sort field values for torrents + */ +export type SortField = 'State' | 'TimeAdded' | 'Progress' | 'ETA' | 'Name' | 'TotalSize' | 'Ratio' | 'SeedingTime'; + +/** + * User preferences for filtering and sorting torrents + */ +export interface UserPreferences { + sortByField: SortField | null; + sortReverse: boolean; + filterState: State | null; +} + +const STORAGE_KEY = 'storm_preferences'; + +const VALID_SORT_FIELDS: SortField[] = ['State', 'TimeAdded', 'Progress', 'ETA', 'Name', 'TotalSize', 'Ratio', 'SeedingTime']; +const VALID_FILTER_STATES: (State | null)[] = [null, 'Active', 'Queued', 'Downloading', 'Seeding', 'Paused', 'Error']; + +const DEFAULT_PREFERENCES: UserPreferences = { + sortByField: null, + sortReverse: false, + filterState: null, +}; + +@Injectable({ + providedIn: 'root' +}) +export class PreferencesService { + + constructor() { + } + + /** + * Load user preferences from localStorage + * @returns The saved preferences or default preferences if none exist + */ + public load(): UserPreferences { + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + const parsed = JSON.parse(stored); + // Validate and sanitize stored values + const sortByField = VALID_SORT_FIELDS.includes(parsed.sortByField) ? parsed.sortByField : null; + const sortReverse = typeof parsed.sortReverse === 'boolean' ? parsed.sortReverse : false; + const filterState = VALID_FILTER_STATES.includes(parsed.filterState) ? parsed.filterState : null; + return { + sortByField, + sortReverse, + filterState, + }; + } + } catch (e) { + console.error('Failed to load preferences from localStorage', e); + } + return {...DEFAULT_PREFERENCES}; + } + + /** + * Save user preferences to localStorage + * @param preferences The preferences to save + */ + public save(preferences: Partial): void { + try { + const current = this.load(); + const updated = { + ...current, + ...preferences, + }; + localStorage.setItem(STORAGE_KEY, JSON.stringify(updated)); + } catch (e) { + console.error('Failed to save preferences to localStorage', e); + } + } + + /** + * Clear all saved preferences + */ + public clear(): void { + try { + localStorage.removeItem(STORAGE_KEY); + } catch (e) { + console.error('Failed to clear preferences from localStorage', e); + } + } +}