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.
This commit is contained in:
4gray committed 2025-12-21 00:42:48 +01:00
1 parent c84b42284a
commit a874db0b41
6 files changed
+357 -190

No files matched your search

@@ -0,0 +1,123 @@
<mat-menu #filterSortMenu="matMenu" class="filter-sort-menu" xPosition="before">
<!-- Filter section -->
<section class="filter-section">
<div class="menu-section-header">
<mat-icon class="section-icon">filter_list</mat-icon>
<span>{{ 'HOME.FILTER_BY_TYPE' | translate }}</span>
</div>
@for (type of playlistTypes; track type.id) {
<button
mat-menu-item
(click)="togglePlaylistType(type); $event.stopPropagation()"
[class.active-option]="type.checked"
>
<mat-icon>{{ type.icon }}</mat-icon>
<span class="menu-item-text">{{
type.translationKey | translate
}}</span>
<mat-icon
class="check-icon"
[class.check-visible]="type.checked"
>check</mat-icon
>
</button>
}
</section>
<!-- Sort section -->
<section class="sort-section">
<div class="menu-section-header">
<mat-icon class="section-icon">sort</mat-icon>
<span>{{ 'HOME.SORT_BY' | translate }}</span>
</div>
<button
mat-menu-item
(click)="
setSortOptions(SortBy.NAME, SortOrder.ASC);
$event.stopPropagation()
"
[class.active-option]="isSortActive(SortBy.NAME, SortOrder.ASC)"
>
<mat-icon>sort_by_alpha</mat-icon>
<span class="menu-item-text">{{
'HOME.SORT_OPTIONS.NAME_ASC' | translate
}}</span>
<mat-icon
class="check-icon"
[class.check-visible]="isSortActive(SortBy.NAME, SortOrder.ASC)"
>check</mat-icon
>
</button>
<button
mat-menu-item
(click)="
setSortOptions(SortBy.NAME, SortOrder.DESC);
$event.stopPropagation()
"
[class.active-option]="isSortActive(SortBy.NAME, SortOrder.DESC)"
>
<mat-icon>sort_by_alpha</mat-icon>
<span class="menu-item-text">{{
'HOME.SORT_OPTIONS.NAME_DESC' | translate
}}</span>
<mat-icon
class="check-icon"
[class.check-visible]="
isSortActive(SortBy.NAME, SortOrder.DESC)
"
>check</mat-icon
>
</button>
<button
mat-menu-item
(click)="
setSortOptions(SortBy.DATE_ADDED, SortOrder.DESC);
$event.stopPropagation()
"
[class.active-option]="
isSortActive(SortBy.DATE_ADDED, SortOrder.DESC)
"
>
<mat-icon>date_range</mat-icon>
<span class="menu-item-text">{{
'HOME.SORT_OPTIONS.NEWEST' | translate
}}</span>
<mat-icon
class="check-icon"
[class.check-visible]="
isSortActive(SortBy.DATE_ADDED, SortOrder.DESC)
"
>check</mat-icon
>
</button>
<button
mat-menu-item
(click)="
setSortOptions(SortBy.DATE_ADDED, SortOrder.ASC);
$event.stopPropagation()
"
[class.active-option]="
isSortActive(SortBy.DATE_ADDED, SortOrder.ASC)
"
>
<mat-icon>date_range</mat-icon>
<span class="menu-item-text">{{
'HOME.SORT_OPTIONS.OLDEST' | translate
}}</span>
<mat-icon
class="check-icon"
[class.check-visible]="
isSortActive(SortBy.DATE_ADDED, SortOrder.ASC)
"
>check</mat-icon
>
</button>
</section>
<!-- Close button -->
<div class="menu-footer">
<button mat-button (click)="closeMenu()">
{{ 'CLOSE' | translate }}
</button>
</div>
</mat-menu>
@@ -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;
}
@@ -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<MatMenu>('filterSortMenu');
readonly menuTrigger = input<MatMenuTrigger>();
readonly filterChanged = output<void>();
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();
}
}
@@ -34,23 +34,23 @@
<app-add-playlist-menu
(playlistTypeSelected)="openAddPlaylistDialog($event)"
/>
<!-- Sort button -->
<!-- Combined Filter & Sort button -->
<button
mat-icon-button
[matMenuTriggerFor]="sortMenu"
[matTooltip]="'HOME.SORT_PLAYLISTS' | translate"
data-test-id="sort-playlists"
#filterMenuTrigger="matMenuTrigger"
[matMenuTriggerFor]="filterSortMenuComponent().menu()"
[matTooltip]="'HOME.FILTER_AND_SORT' | translate"
[matBadge]="filterSortMenuComponent().activeFiltersCount"
[matBadgeHidden]="
filterSortMenuComponent().activeFiltersCount === 0
"
matBadgeSize="small"
matBadgeColor="accent"
data-test-id="filter-sort-playlists"
>
<mat-icon>sort</mat-icon>
</button>
<button
mat-icon-button
[matMenuTriggerFor]="filterPlaylistMenu"
[matTooltip]="'HOME.FILTER_BY_TYPE' | translate"
data-test-id="filter-playlist-by-type"
>
<mat-icon>filter_alt</mat-icon>
<mat-icon>tune</mat-icon>
</button>
<app-filter-sort-menu [menuTrigger]="filterMenuTrigger" />
@if (isDesktop) {
<button
mat-icon-button
@@ -120,80 +120,6 @@
</button>
</mat-menu>
}
<!-- Sort menu -->
<mat-menu #sortMenu="matMenu">
<div
class="dropdown-menu-header"
(click)="$event.stopPropagation()"
>
{{ 'HOME.SORT_BY' | translate }}
</div>
<button
mat-menu-item
(click)="setSortOptions(SortBy.NAME, SortOrder.ASC)"
[class.active-sort]="
isSortActive(SortBy.NAME, SortOrder.ASC)
"
>
<mat-icon>sort_by_alpha</mat-icon>
<span>{{ 'HOME.SORT_OPTIONS.NAME_ASC' | translate }}</span>
</button>
<button
mat-menu-item
(click)="setSortOptions(SortBy.NAME, SortOrder.DESC)"
[class.active-sort]="
isSortActive(SortBy.NAME, SortOrder.DESC)
"
>
<mat-icon>sort_by_alpha</mat-icon>
<span>{{ 'HOME.SORT_OPTIONS.NAME_DESC' | translate }}</span>
</button>
<mat-divider></mat-divider>
<button
mat-menu-item
(click)="setSortOptions(SortBy.DATE_ADDED, SortOrder.DESC)"
[class.active-sort]="
isSortActive(SortBy.DATE_ADDED, SortOrder.DESC)
"
>
<mat-icon>date_range</mat-icon>
<span>{{ 'HOME.SORT_OPTIONS.NEWEST' | translate }}</span>
</button>
<button
mat-menu-item
(click)="setSortOptions(SortBy.DATE_ADDED, SortOrder.ASC)"
[class.active-sort]="
isSortActive(SortBy.DATE_ADDED, SortOrder.ASC)
"
>
<mat-icon>date_range</mat-icon>
<span>{{ 'HOME.SORT_OPTIONS.OLDEST' | translate }}</span>
</button>
</mat-menu>
<mat-menu #filterPlaylistMenu="matMenu">
<section
(click)="$event.stopPropagation()"
class="filter-container"
>
<div
class="dropdown-menu-header"
(click)="$event.stopPropagation()"
>
{{ 'HOME.FILTER_BY_TYPE' | translate }}
</div>
@for (type of playlistTypes; track $index) {
<p>
<mat-checkbox
(click)="$event.stopPropagation()"
[checked]="true"
(change)="onPlaylistFilterChange()"
[(ngModel)]="type.checked"
>{{ type.title }}</mat-checkbox
>
</p>
}
</section>
</mat-menu>
</div>
}
</div>
@@ -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
@@ -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<string>();
readonly subtitle = input.required<string>();
@@ -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);
}