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