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);
+ }
+ }
+}