Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 1 addition & 35 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion frontend/src/app/app.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
</div>

<p-dropdown class="t-filter-input" [options]="filterStatesOptions" appendTo="body"
(onChange)="get$.next($event.value)" placeholder="State"></p-dropdown>
[ngModel]="get$ | async" (onChange)="onFilterStateChange($event.value)" placeholder="State"></p-dropdown>
</t-breakpoint-overlay>
</div>

Expand Down
2 changes: 1 addition & 1 deletion frontend/src/app/app.component.scss
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@
}

.layout-content {
padding: 1rem;
padding: 1rem 1rem 5rem 1rem;
}
}

Expand Down
43 changes: 38 additions & 5 deletions frontend/src/app/app.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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<keyof Torrent>[] = [
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<SortField>[] = [
{
label: 'State',
value: 'State'
Expand Down Expand Up @@ -112,8 +131,13 @@ export class AppComponent {

get$: BehaviorSubject<OptionalState>;

constructor(private api: ApiService, private focus: FocusService, private dialogService: DialogService) {
this.get$ = new BehaviorSubject<OptionalState>(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<OptionalState>(savedPrefs.filterState);
this.refreshInterval(2000);
}

Expand Down Expand Up @@ -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});
}
}
116 changes: 116 additions & 0 deletions frontend/src/app/preferences.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
89 changes: 89 additions & 0 deletions frontend/src/app/preferences.service.ts
Original file line number Diff line number Diff line change
@@ -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<UserPreferences>): 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);
}
}
}