From a874db0b41163d82e514afa90cd6bdd1399b764c Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 20 Dec 2025 23:58:04 +0100 Subject: [PATCH] feat(ui): add filter and sort menu to header and clean styles Add a new mat-menu component for filtering and sorting playlists in the header area. The filter section iterates playlistTypes and allows toggling type selection with visual check state. The sort section adds options for name (asc/desc) and date added (newest/oldest) with active-state visuals and click handlers that call setSortOptions. A close button is also included. Remove unused/duplicate CSS rules from header styles: drop .filter- container and obsolete .dropdown-menu-header rules to avoid styling conflicts and reduce dead code. Adjust surrounding selector formatting. --- .../filter-sort-menu.component.html | 123 ++++++++++++++++++ .../filter-sort-menu.component.scss | 90 +++++++++++++ .../filter-sort-menu.component.ts | 119 +++++++++++++++++ .../components/header/header.component.html | 100 ++------------ .../components/header/header.component.scss | 37 +----- .../components/header/header.component.ts | 78 ++--------- 6 files changed, 357 insertions(+), 190 deletions(-) create mode 100644 apps/web/src/app/shared/components/filter-sort-menu/filter-sort-menu.component.html create mode 100644 apps/web/src/app/shared/components/filter-sort-menu/filter-sort-menu.component.scss create mode 100644 apps/web/src/app/shared/components/filter-sort-menu/filter-sort-menu.component.ts diff --git a/apps/web/src/app/shared/components/filter-sort-menu/filter-sort-menu.component.html b/apps/web/src/app/shared/components/filter-sort-menu/filter-sort-menu.component.html new file mode 100644 index 000000000..0037a9e76 --- /dev/null +++ b/apps/web/src/app/shared/components/filter-sort-menu/filter-sort-menu.component.html @@ -0,0 +1,123 @@ + + +
+ + @for (type of playlistTypes; track type.id) { + + } +
+ + +
+ + + + + +
+ + + +
diff --git a/apps/web/src/app/shared/components/filter-sort-menu/filter-sort-menu.component.scss b/apps/web/src/app/shared/components/filter-sort-menu/filter-sort-menu.component.scss new file mode 100644 index 000000000..119f9028b --- /dev/null +++ b/apps/web/src/app/shared/components/filter-sort-menu/filter-sort-menu.component.scss @@ -0,0 +1,90 @@ +// Combined Filter & Sort menu styles +.filter-section, +.sort-section { + padding: 4px 0; +} + +.menu-section-header { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 16px 4px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.05em; + text-transform: uppercase; + pointer-events: none; + + .section-icon { + font-size: 16px; + width: 16px; + height: 16px; + color: rgba(139, 92, 246, 0.6); + } +} + +// Menu item layout with right-aligned checkmark +::ng-deep .filter-sort-menu { + .mat-mdc-menu-item { + display: flex !important; + align-items: center !important; + gap: 12px !important; + min-height: 40px !important; + padding: 0 16px !important; + + .mat-icon:first-child { + margin-right: 0 !important; + flex-shrink: 0; + } + + .menu-item-text { + flex: 1; + text-align: left; + } + + .check-icon { + margin-left: auto !important; + margin-right: 0 !important; + flex-shrink: 0; + opacity: 0; + transform: scale(0.8); + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + + &.check-visible { + opacity: 1; + transform: scale(1); + } + } + } +} + +.active-option { + background-color: rgba(139, 92, 246, 0.12) !important; + position: relative; + + &::before { + content: ''; + position: absolute; + left: 0; + top: 50%; + transform: translateY(-50%); + width: 3px; + height: 60%; + background: rgba(139, 92, 246, 0.8); + border-radius: 0 2px 2px 0; + } +} + +.check-icon { + font-size: 18px !important; + width: 18px !important; + height: 18px !important; + color: rgba(139, 92, 246, 1) !important; +} + +// Menu footer +.menu-footer { + padding: 4px 8px 4px; + display: flex; + justify-content: center; +} diff --git a/apps/web/src/app/shared/components/filter-sort-menu/filter-sort-menu.component.ts b/apps/web/src/app/shared/components/filter-sort-menu/filter-sort-menu.component.ts new file mode 100644 index 000000000..6bf906626 --- /dev/null +++ b/apps/web/src/app/shared/components/filter-sort-menu/filter-sort-menu.component.ts @@ -0,0 +1,119 @@ +import { + Component, + effect, + inject, + input, + output, + viewChild, +} from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatMenu, MatMenuModule, MatMenuTrigger } from '@angular/material/menu'; +import { Store } from '@ngrx/store'; +import { TranslateModule } from '@ngx-translate/core'; +import { FilterActions, selectActiveTypeFilters } from 'm3u-state'; +import { SortBy, SortOrder, SortService } from 'services'; + +@Component({ + selector: 'app-filter-sort-menu', + templateUrl: './filter-sort-menu.component.html', + styleUrls: ['./filter-sort-menu.component.scss'], + imports: [MatButtonModule, MatIconModule, MatMenuModule, TranslateModule], +}) +export class FilterSortMenuComponent { + private store = inject(Store); + private sortService = inject(SortService); + + readonly menu = viewChild.required('filterSortMenu'); + readonly menuTrigger = input(); + readonly filterChanged = output(); + + playlistTypes = [ + { + id: 'm3u', + icon: 'playlist_play', + translationKey: 'HOME.PLAYLIST_TYPES.M3U', + checked: true, + }, + { + id: 'xtream', + icon: 'cloud', + translationKey: 'HOME.PLAYLIST_TYPES.XTREAM', + checked: true, + }, + { + id: 'stalker', + icon: 'router', + translationKey: 'HOME.PLAYLIST_TYPES.STALKER', + checked: true, + }, + ]; + + private readonly selectedTypeFilters = this.store.selectSignal( + selectActiveTypeFilters + ); + + readonly SortBy = SortBy; + readonly SortOrder = SortOrder; + private readonly currentSortOptions = toSignal( + this.sortService.getSortOptions(), + { + requireSync: true, + } + ); + + constructor() { + effect(() => { + if (this.selectedTypeFilters) { + this.playlistTypes = this.playlistTypes.map((type) => { + type.checked = this.selectedTypeFilters().includes(type.id); + return type; + }); + } + }); + } + + togglePlaylistType(type: { + id: string; + icon: string; + translationKey: string; + checked: boolean; + }) { + const currentlySelectedCount = this.playlistTypes.filter( + (f) => f.checked + ).length; + + // Prevent deselecting if it's the last selected option + if (type.checked && currentlySelectedCount === 1) { + return; + } + + type.checked = !type.checked; + this.store.dispatch( + FilterActions.setSelectedFilters({ + selectedFilters: this.playlistTypes + .filter((f) => f.checked) + .map((f) => f.id), + }) + ); + this.filterChanged.emit(); + } + + setSortOptions(by: SortBy, order: SortOrder): void { + this.sortService.setSortOptions({ by, order }); + } + + isSortActive(by: SortBy, order: SortOrder): boolean { + const options = this.currentSortOptions(); + return options?.by === by && options?.order === order; + } + + get activeFiltersCount(): number { + return this.playlistTypes.filter((type) => !type.checked).length; + } + + closeMenu(): void { + this.menuTrigger()?.closeMenu(); + } +} diff --git a/apps/web/src/app/shared/components/header/header.component.html b/apps/web/src/app/shared/components/header/header.component.html index dc81325e0..f45a226e3 100644 --- a/apps/web/src/app/shared/components/header/header.component.html +++ b/apps/web/src/app/shared/components/header/header.component.html @@ -34,23 +34,23 @@ - + - + @if (isDesktop) { - - - - - - -
- - @for (type of playlistTypes; track $index) { -

- {{ type.title }} -

- } -
-
} diff --git a/apps/web/src/app/shared/components/header/header.component.scss b/apps/web/src/app/shared/components/header/header.component.scss index a74b0d8bb..1f43821b3 100644 --- a/apps/web/src/app/shared/components/header/header.component.scss +++ b/apps/web/src/app/shared/components/header/header.component.scss @@ -344,10 +344,6 @@ app-region: no-drag; // Allow button interactions } -.filter-container { - padding: 0 10px; -} - .add-playlist-btn { background-color: rgba(139, 92, 246, 0.12) !important; color: rgba(139, 92, 246, 1) !important; @@ -365,35 +361,10 @@ } } -.dropdown-menu-header { - padding: 0 16px; - margin: 8px 0; - font-size: 11px; - font-weight: 600; - color: rgba(255, 255, 255, 0.5); - letter-spacing: 0.05em; - text-transform: uppercase; -} - -.active-sort { - background-color: rgba(139, 92, 246, 0.12) !important; - position: relative; - - &::before { - content: ''; - position: absolute; - left: 0; - top: 50%; - transform: translateY(-50%); - width: 3px; - height: 60%; - background: rgba(139, 92, 246, 0.8); - border-radius: 0 2px 2px 0; - } -} - -.check-icon { - margin-left: 8px; +// Badge styling for filter count +::ng-deep .mat-badge-accent .mat-badge-content { + background: rgba(139, 92, 246, 1); + color: white; } // Refined Material button and icon overrides diff --git a/apps/web/src/app/shared/components/header/header.component.ts b/apps/web/src/app/shared/components/header/header.component.ts index b3446e34f..6657cb1cb 100644 --- a/apps/web/src/app/shared/components/header/header.component.ts +++ b/apps/web/src/app/shared/components/header/header.component.ts @@ -2,30 +2,28 @@ import { Component, HostBinding, OnInit, - effect, inject, input, output, viewChild, } from '@angular/core'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { MatBadgeModule } from '@angular/material/badge'; import { MatButtonModule } from '@angular/material/button'; -import { MatCheckboxModule } from '@angular/material/checkbox'; import { MatDialog } from '@angular/material/dialog'; import { MatDividerModule } from '@angular/material/divider'; import { MatIconModule } from '@angular/material/icon'; import { MatMenuModule } from '@angular/material/menu'; import { MatTooltipModule } from '@angular/material/tooltip'; import { ActivatedRoute, Router } from '@angular/router'; -import { Store } from '@ngrx/store'; import { TranslateModule } from '@ngx-translate/core'; -import { DataService, SortBy, SortOrder, SortService } from 'services'; +import { DataService } from 'services'; //import { shell } from 'electron'; import { AddPlaylistMenuComponent, PlaylistType } from 'components'; -import { selectActiveTypeFilters, setSelectedFilters } from 'm3u-state'; import { HomeComponent } from '../../../home/home.component'; import { AboutDialogComponent } from '../about-dialog/about-dialog.component'; import { AddPlaylistDialogComponent } from '../add-playlist/add-playlist-dialog.component'; +import { FilterSortMenuComponent } from '../filter-sort-menu/filter-sort-menu.component'; @Component({ selector: 'app-header', @@ -33,9 +31,10 @@ import { AddPlaylistDialogComponent } from '../add-playlist/add-playlist-dialog. styleUrls: ['./header.component.scss'], imports: [ AddPlaylistMenuComponent, + FilterSortMenuComponent, FormsModule, + MatBadgeModule, MatButtonModule, - MatCheckboxModule, MatDividerModule, MatIconModule, MatMenuModule, @@ -53,12 +52,13 @@ export class HeaderComponent implements OnInit { private dialog = inject(MatDialog); private dataService = inject(DataService); private router = inject(Router); - private store = inject(Store); - private sortService = inject(SortService); readonly addPlaylistMenuComponent = viewChild.required( AddPlaylistMenuComponent ); + readonly filterSortMenuComponent = viewChild.required( + FilterSortMenuComponent + ); readonly isDesktop = !!window.electron; readonly title = input.required(); readonly subtitle = input.required(); @@ -66,47 +66,6 @@ export class HeaderComponent implements OnInit { isHome = true; - playlistTypes = [ - { - title: 'M3U (local, url, text)', - id: 'm3u', - checked: true, - }, - { - title: 'Xtream', - id: 'xtream', - checked: true, - }, - { - title: 'Stalker', - id: 'stalker', - checked: true, - }, - ]; - - private readonly selectedTypeFilters = this.store.selectSignal( - selectActiveTypeFilters - ); - - readonly SortBy = SortBy; - readonly SortOrder = SortOrder; - private currentSortOptions: { by: SortBy; order: SortOrder }; - - constructor() { - effect(() => { - if (this.selectedTypeFilters) { - this.playlistTypes = this.playlistTypes.map((type) => { - type.checked = this.selectedTypeFilters().includes(type.id); - return type; - }); - } - }); - - this.sortService.getSortOptions().subscribe((options) => { - this.currentSortOptions = options; - }); - } - ngOnInit() { this.isHome = this.activatedRoute.snapshot.component.name === HomeComponent.name; @@ -154,27 +113,6 @@ export class HeaderComponent implements OnInit { ); } - onPlaylistFilterChange() { - this.store.dispatch( - setSelectedFilters({ - selectedFilters: this.playlistTypes - .filter((f) => f.checked) - .map((f) => f.id), - }) - ); - } - - 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 - ); - } - onSearchQueryUpdate(query: string): void { this.searchQuery.emit(query); }