From 996cb30f4c648f29a2b906a387ef7ae92f983db0 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 1 Mar 2025 22:27:50 +0100 Subject: [PATCH] feat(sort): implement sorting functionality for playlists with user-defined options --- .../recent-playlists.component.ts | 35 +++--- src/app/services/playlists.service.ts | 3 - src/app/services/sort.service.ts | 106 ++++++++++++++++++ .../components/header/header.component.html | 92 +++++++++++++-- .../components/header/header.component.scss | 15 +++ .../components/header/header.component.ts | 23 +++- src/assets/i18n/en.json | 10 +- 7 files changed, 256 insertions(+), 28 deletions(-) create mode 100644 src/app/services/sort.service.ts diff --git a/src/app/home/recent-playlists/recent-playlists.component.ts b/src/app/home/recent-playlists/recent-playlists.component.ts index 57fc4f180..710220b90 100644 --- a/src/app/home/recent-playlists/recent-playlists.component.ts +++ b/src/app/home/recent-playlists/recent-playlists.component.ts @@ -28,6 +28,7 @@ import { IpcCommand } from '../../../../shared/ipc-command.class'; import { Playlist } from '../../../../shared/playlist.interface'; import { DataService } from '../../services/data.service'; import { DatabaseService } from '../../services/database.service'; +import { SortService } from '../../services/sort.service'; import * as PlaylistActions from '../../state/actions'; import { selectActiveTypeFilters, @@ -73,11 +74,11 @@ export class RecentPlaylistsComponent implements OnDestroy { playlists$ = combineLatest([ this.store.select(selectAllPlaylistsMeta), this.searchQuery, - // eslint-disable-next-line @ngrx/avoid-combining-selectors this.store.select(selectActiveTypeFilters), + this.sortService.getSortOptions(), ]).pipe( - map(([playlists, searchQuery, filters]) => - playlists + map(([playlists, searchQuery, filters, sortOptions]) => { + const filteredPlaylists = playlists .filter((item) => { const isStalkerFilter = item.macAddress && filters.includes('stalker'); @@ -101,9 +102,14 @@ export class RecentPlaylistsComponent implements OnDestroy { }) .filter((item) => item.title.toLowerCase().includes(searchQuery.toLowerCase()) - ) - .sort((a, b) => a.position - b.position) - ) + ); + + // Apply sorting using the SortService + return this.sortService.sortPlaylists( + filteredPlaylists, + sortOptions + ); + }) ); allPlaylistsLoaded = this.store.selectSignal(selectPlaylistsLoadingFlag); @@ -138,15 +144,16 @@ export class RecentPlaylistsComponent implements OnDestroy { ]; constructor( - private databaseService: DatabaseService, - private dialog: MatDialog, - private dialogService: DialogService, - private electronService: DataService, - private ngZone: NgZone, - private router: Router, - private snackBar: MatSnackBar, + private readonly databaseService: DatabaseService, + private readonly dialog: MatDialog, + private readonly dialogService: DialogService, + private readonly electronService: DataService, + private readonly ngZone: NgZone, + private readonly router: Router, + private readonly snackBar: MatSnackBar, + private readonly sortService: SortService, private readonly store: Store, - private translate: TranslateService + private readonly translate: TranslateService ) {} ngOnInit(): void { diff --git a/src/app/services/playlists.service.ts b/src/app/services/playlists.service.ts index 227c641e6..55cdb8e94 100644 --- a/src/app/services/playlists.service.ts +++ b/src/app/services/playlists.service.ts @@ -37,9 +37,6 @@ export class PlaylistsService { data.map(({ playlist, items, header, ...rest }) => ({ ...rest, })) - ), - map((playlists) => - playlists.sort((a, b) => a.position - b.position) ) ); } diff --git a/src/app/services/sort.service.ts b/src/app/services/sort.service.ts new file mode 100644 index 000000000..8d734b08d --- /dev/null +++ b/src/app/services/sort.service.ts @@ -0,0 +1,106 @@ +import { Injectable } from '@angular/core'; +import { BehaviorSubject, Observable } from 'rxjs'; +import { PlaylistMeta } from '../shared/playlist-meta.type'; + +export enum SortBy { + DATE_ADDED = 'date', + NAME = 'name', +} + +export enum SortOrder { + ASC = 'asc', + DESC = 'desc', +} + +export interface SortOptions { + by: SortBy; + order: SortOrder; +} + +const SORT_OPTIONS_STORAGE_KEY = 'iptvnator-sort-options'; + +@Injectable({ + providedIn: 'root', +}) +export class SortService { + private sortOptions = new BehaviorSubject( + this.getSavedSortOptions() + ); + + getSortOptions(): Observable { + return this.sortOptions.asObservable(); + } + + setSortOptions(options: SortOptions): void { + this.sortOptions.next(options); + this.saveSortOptions(options); + } + + private getSavedSortOptions(): SortOptions { + const defaultOptions: SortOptions = { + by: SortBy.DATE_ADDED, + order: SortOrder.DESC, + }; + + try { + const savedOptions = localStorage.getItem(SORT_OPTIONS_STORAGE_KEY); + if (!savedOptions) { + return defaultOptions; + } + + const parsedOptions = JSON.parse(savedOptions) as SortOptions; + + // Validate that the saved options match our expected types + if ( + (parsedOptions.by === SortBy.DATE_ADDED || + parsedOptions.by === SortBy.NAME) && + (parsedOptions.order === SortOrder.ASC || + parsedOptions.order === SortOrder.DESC) + ) { + return parsedOptions; + } + + return defaultOptions; + } catch (error) { + console.error( + 'Error retrieving sort options from localStorage:', + error + ); + return defaultOptions; + } + } + + private saveSortOptions(options: SortOptions): void { + try { + localStorage.setItem( + SORT_OPTIONS_STORAGE_KEY, + JSON.stringify(options) + ); + } catch (error) { + console.error('Error saving sort options to localStorage:', error); + } + } + + sortPlaylists( + playlists: PlaylistMeta[], + options: SortOptions + ): PlaylistMeta[] { + const { by, order } = options; + + return [...playlists].sort((a, b) => { + let comparison = 0; + + if (by === SortBy.NAME) { + comparison = a.title + .toLowerCase() + .localeCompare(b.title.toLowerCase()); + } else if (by === SortBy.DATE_ADDED) { + const dateA = new Date(a.importDate).getTime(); + const dateB = new Date(b.importDate).getTime(); + comparison = dateA - dateB; + } + + return order === SortOrder.ASC ? comparison : -comparison; + }); + } +} diff --git a/src/app/shared/components/header/header.component.html b/src/app/shared/components/header/header.component.html index 404bab977..b3919a6dd 100644 --- a/src/app/shared/components/header/header.component.html +++ b/src/app/shared/components/header/header.component.html @@ -16,20 +16,22 @@ > add - + + + + + + + + + + + +
-

{{ 'HOME.FILTER_BY_TYPE' | translate }}:

+ @for (type of playlistTypes; track $index) {

{ if (this.selectedTypeFilters) { @@ -107,6 +113,10 @@ export class HeaderComponent implements OnInit { }); } }); + + this.sortService.getSortOptions().subscribe((options) => { + this.currentSortOptions = options; + }); } ngOnInit() { @@ -174,4 +184,15 @@ export class HeaderComponent implements OnInit { }) ); } + + setSortOptions(by: SortBy, order: SortOrder): void { + this.sortService.setSortOptions({ by, order }); + } + + isSortActive(by: SortBy, order: SortOrder): boolean { + return ( + this.currentSortOptions?.by === by && + this.currentSortOptions?.order === order + ); + } } diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 35e88c81a..0b16dc17a 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -90,6 +90,14 @@ }, "FILTER_BY_NAME": "Filter by name", "FILTER_BY_TYPE": "Filter by type", + "SORT_PLAYLISTS": "Sort playlists", + "SORT_BY": "Sort by", + "SORT_OPTIONS": { + "NAME_ASC": "Name (A-Z)", + "NAME_DESC": "Name (Z-A)", + "NEWEST": "Date added (Newest first)", + "OLDEST": "Date added (Oldest first)" + }, "IMPORTED_AS_TEXT": "Imported as text", "PARSING_ERROR": "Error: Parsing failed, not a valid playlist:" }, @@ -124,7 +132,7 @@ "IMPORT_EXPORT_DATA_DESCRIPTION": "Export or import playlists in the JSON format used within the application.", "IMPORT_DATA": "Import", "EXPORT_DATA": "Export", - "REMOVE_ALL": "Remove playlist", + "REMOVE_ALL": "Remove playlists", "REMOVE_ALL_BUTTON": "Remove", "REMOVE_ALL_DESCRIPTION": "It will remove all existing playlists from the application.", "REMOVE_DIALOG": {