Compare commits

...

3 Commits

Author SHA1 Message Date
benjamin fce007b848 optimize show filter 2026-05-14 16:44:49 +02:00
benjamin 1a1d4ccdea optimize song filter 2026-05-14 16:30:50 +02:00
benjamin 38fe753570 fix firebase indizes 2026-05-14 16:19:54 +02:00
15 changed files with 201 additions and 16 deletions
+21 -1
View File
@@ -1,4 +1,24 @@
{ {
"indexes": [], "indexes": [
{
"collectionGroup": "shows",
"queryScope": "COLLECTION",
"fields": [
{
"fieldPath": "published",
"order": "ASCENDING"
},
{
"fieldPath": "date",
"order": "DESCENDING"
},
{
"fieldPath": "__name__",
"order": "DESCENDING"
}
],
"density": "SPARSE_ALL"
}
],
"fieldOverrides": [] "fieldOverrides": []
} }
@@ -36,7 +36,9 @@
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
<mat-checkbox formControlName="archived">Archiviert</mat-checkbox> <mat-checkbox formControlName="archived">Archiviert</mat-checkbox>
</div>
<!-- <i>Anzahl der Suchergebnisse: {{ shows?.length ?? 0 }}</i>--> @if (filterActive) {
<app-button (click)="resetFilter()" [fullWidth]="true" [icon]="faResetFilter">Filter zurücksetzen</app-button>
}
</div>
</div> </div>
@@ -2,13 +2,17 @@
div[formGroup] { div[formGroup] {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: var(--gap-m);
} }
.third { .third {
gap: 0; gap: 0;
} }
mat-checkbox {
margin-bottom: var(--gap-m);
}
:host ::ng-deep .mat-mdc-form-field { :host ::ng-deep .mat-mdc-form-field {
width: 100%; width: 100%;
} }
@@ -2,6 +2,7 @@ import {Component, DestroyRef, inject, Input} from '@angular/core';
import {KeyValue} from '@angular/common'; import {KeyValue} from '@angular/common';
import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; import {takeUntilDestroyed} from '@angular/core/rxjs-interop';
import {FormBuilder, FormControl, FormGroup, ReactiveFormsModule} from '@angular/forms'; import {FormBuilder, FormControl, FormGroup, ReactiveFormsModule} from '@angular/forms';
import {faFilterCircleXmark} from '@fortawesome/free-solid-svg-icons';
import {FilterValues} from './filter-values'; import {FilterValues} from './filter-values';
import {Show} from '../../services/show'; import {Show} from '../../services/show';
import {ShowService} from '../../services/show.service'; import {ShowService} from '../../services/show.service';
@@ -15,14 +16,23 @@ import {MatSelect} from '@angular/material/select';
import {MatOptgroup, MatOption} from '@angular/material/core'; import {MatOptgroup, MatOption} from '@angular/material/core';
import {ShowTypePipe} from '../../../../widget-modules/pipes/show-type-translater/show-type.pipe'; import {ShowTypePipe} from '../../../../widget-modules/pipes/show-type-translater/show-type.pipe';
import {MatCheckbox} from '@angular/material/checkbox'; import {MatCheckbox} from '@angular/material/checkbox';
import {ButtonComponent} from '../../../../widget-modules/components/button/button.component';
const DEFAULT_SHOW_FILTER: FilterValues = {
time: 1,
owner: '',
showType: '',
archived: false,
};
@Component({ @Component({
selector: 'app-filter', selector: 'app-filter',
templateUrl: './filter.component.html', templateUrl: './filter.component.html',
styleUrls: ['./filter.component.less'], styleUrls: ['./filter.component.less'],
imports: [ReactiveFormsModule, MatFormField, MatLabel, MatSelect, MatOption, MatOptgroup, ShowTypePipe, MatCheckbox], imports: [ReactiveFormsModule, MatFormField, MatLabel, MatSelect, MatOption, MatOptgroup, ShowTypePipe, MatCheckbox, ButtonComponent],
}) })
export class FilterComponent { export class FilterComponent {
public faResetFilter = faFilterCircleXmark;
@Input() public shows: Show[] = []; @Input() public shows: Show[] = [];
public showTypePublic = ShowService.SHOW_TYPE_PUBLIC; public showTypePublic = ShowService.SHOW_TYPE_PUBLIC;
public showTypePrivate = ShowService.SHOW_TYPE_PRIVATE; public showTypePrivate = ShowService.SHOW_TYPE_PRIVATE;
@@ -106,6 +116,20 @@ export class FilterComponent {
); );
}; };
public get filterActive(): boolean {
const filter = this.filterFormGroup.getRawValue();
return (
filter.time !== DEFAULT_SHOW_FILTER.time ||
!!filter.owner ||
!!filter.showType ||
filter.archived !== DEFAULT_SHOW_FILTER.archived
);
}
public resetFilter(): void {
this.filterStore.resetShowFilter();
}
private filterValueChanged<T>(key: keyof FilterValues, value: T): void { private filterValueChanged<T>(key: keyof FilterValues, value: T): void {
this.filterStore.updateShowFilter({[key]: value} as Partial<FilterValues>); this.filterStore.updateShowFilter({[key]: value} as Partial<FilterValues>);
} }
+14 -2
View File
@@ -1,11 +1,17 @@
@if (showSidebar$ | async) { @if (showSidebar$ | async) {
<app-page-frame title="Veranstaltungen"> <app-page-frame title="Veranstaltungen" [menuBadge]="(filterActive$ | async) ?? false">
<div class="sidebar-content" sidebar> <div class="sidebar-content" sidebar>
<app-filter [shows]="(publicShows$ | async) ?? []"></app-filter> <app-filter [shows]="(publicShows$ | async) ?? []"></app-filter>
</div> </div>
<div content> <div content>
@if (privateShows$ | async; as privateShows) { @if (privateShows$ | async; as privateShows) {
<app-card [padding]="false" heading="Meine Veranstaltungen"> <app-card [padding]="false" heading="Meine Veranstaltungen">
@if ((filterActive$ | async) ?? false) {
<div class="filter-active">
<span>Filter aktiv: {{ privateShows.length }} Veranstaltungen angezeigt.</span>
<button (click)="resetFilter()" class="filter-reset-link" type="button">Filter zurücksetzen</button>
</div>
}
@for (show of privateShows; track trackBy($index, show)) { @for (show of privateShows; track trackBy($index, show)) {
<app-list-item <app-list-item
[routerLink]="show.id" [routerLink]="show.id"
@@ -20,8 +26,14 @@
</app-card> </app-card>
} }
@if (publicShows$ | async; as shows) { @if (publicShows$ | async; as shows) {
@if (shows.length > 0) { @if (shows.length > 0 || ((filterActive$ | async) ?? false)) {
<app-card [padding]="false" heading="Veröffentlichte Veranstaltungen"> <app-card [padding]="false" heading="Veröffentlichte Veranstaltungen">
@if ((filterActive$ | async) ?? false) {
<div class="filter-active">
<span>Filter aktiv: {{ shows.length }} Veranstaltungen angezeigt.</span>
<button (click)="resetFilter()" class="filter-reset-link" type="button">Filter zurücksetzen</button>
</div>
}
@for (show of shows; track trackBy($index, show)) { @for (show of shows; track trackBy($index, show)) {
<app-list-item [routerLink]="show.id" [show]="show"></app-list-item> <app-list-item [routerLink]="show.id" [show]="show"></app-list-item>
} }
@@ -5,3 +5,28 @@
.list-action { .list-action {
margin: 10px 20px; margin: 10px 20px;
} }
.filter-active {
padding: 10px 20px;
display: flex;
align-items: baseline;
gap: 10px;
flex-wrap: wrap;
color: var(--danger);
font-weight: bold;
}
.filter-reset-link {
padding: 0;
border: 0;
background: transparent;
color: var(--link-color);
cursor: pointer;
font: inherit;
font-weight: normal;
text-decoration: underline;
}
.filter-reset-link:hover {
color: var(--primary-active);
}
@@ -17,6 +17,13 @@ import {ButtonComponent} from '../../../widget-modules/components/button/button.
import {faPlus} from '@fortawesome/free-solid-svg-icons'; import {faPlus} from '@fortawesome/free-solid-svg-icons';
import {RoleDirective} from '../../../services/user/role.directive'; import {RoleDirective} from '../../../services/user/role.directive';
const DEFAULT_SHOW_FILTER: FilterValues = {
time: 1,
owner: '',
showType: '',
archived: false,
};
@Component({ @Component({
selector: 'app-list', selector: 'app-list',
templateUrl: './list.component.html', templateUrl: './list.component.html',
@@ -28,6 +35,7 @@ export class ListComponent {
public faNewShow = faPlus; public faNewShow = faPlus;
private filterStore = inject(FilterStoreService); private filterStore = inject(FilterStoreService);
public filter$ = this.filterStore.showFilter$; public filter$ = this.filterStore.showFilter$;
public filterActive$ = this.filter$.pipe(map(filter => this.isFilterActive(filter)));
public lastMonths$ = this.filter$.pipe(map((filterValues: FilterValues) => filterValues.time || 1)); public lastMonths$ = this.filter$.pipe(map((filterValues: FilterValues) => filterValues.time || 1));
public owner$ = this.filter$.pipe(map((filterValues: FilterValues) => filterValues.owner)); public owner$ = this.filter$.pipe(map((filterValues: FilterValues) => filterValues.owner));
public showType$ = this.filter$.pipe(map((filterValues: FilterValues) => filterValues.showType)); public showType$ = this.filter$.pipe(map((filterValues: FilterValues) => filterValues.showType));
@@ -69,6 +77,10 @@ export class ListComponent {
public trackBy = (index: number, show: unknown) => (show as Show).id; public trackBy = (index: number, show: unknown) => (show as Show).id;
public resetFilter(): void {
this.filterStore.resetShowFilter();
}
private matchesTimeFilter(show: Show, lastMonths: number): boolean { private matchesTimeFilter(show: Show, lastMonths: number): boolean {
const startDate = new Date(); const startDate = new Date();
startDate.setHours(0, 0, 0, 0); startDate.setHours(0, 0, 0, 0);
@@ -88,4 +100,13 @@ export class ListComponent {
const roles = role.split(';').map(item => item.trim()); const roles = role.split(';').map(item => item.trim());
return roles.includes('admin') || roles.includes('leader'); return roles.includes('admin') || roles.includes('leader');
} }
private isFilterActive(filter: FilterValues): boolean {
return (
filter.time !== DEFAULT_SHOW_FILTER.time ||
!!filter.owner ||
!!filter.showType ||
filter.archived !== DEFAULT_SHOW_FILTER.archived
);
}
} }
@@ -44,7 +44,8 @@
} }
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
@if (filterActive) {
<app-button (click)="resetFilter()" [fullWidth]="true" [icon]="faResetFilter">Filter zurücksetzen</app-button>
}
</div> </div>
<i>Anzahl der Suchergebnisse: {{ songs.length }}</i>
</div> </div>
@@ -2,6 +2,7 @@ import {Component, DestroyRef, Input, inject} from '@angular/core';
import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; import {takeUntilDestroyed} from '@angular/core/rxjs-interop';
import {FormBuilder, FormControl, FormGroup, ReactiveFormsModule} from '@angular/forms'; import {FormBuilder, FormControl, FormGroup, ReactiveFormsModule} from '@angular/forms';
import {debounceTime, distinctUntilChanged} from 'rxjs/operators'; import {debounceTime, distinctUntilChanged} from 'rxjs/operators';
import {faFilterCircleXmark} from '@fortawesome/free-solid-svg-icons';
import {SongService} from '../../services/song.service'; import {SongService} from '../../services/song.service';
import {FilterValues} from './filter-values'; import {FilterValues} from './filter-values';
import {Song} from '../../services/song'; import {Song} from '../../services/song';
@@ -15,16 +16,18 @@ import {MatOption} from '@angular/material/core';
import {LegalTypePipe} from '../../../../widget-modules/pipes/legal-type-translator/legal-type.pipe'; import {LegalTypePipe} from '../../../../widget-modules/pipes/legal-type-translator/legal-type.pipe';
import {KeyPipe} from '../../../../widget-modules/pipes/key-translator/key.pipe'; import {KeyPipe} from '../../../../widget-modules/pipes/key-translator/key.pipe';
import {SongTypePipe} from '../../../../widget-modules/pipes/song-type-translater/song-type.pipe'; import {SongTypePipe} from '../../../../widget-modules/pipes/song-type-translater/song-type.pipe';
import {ButtonComponent} from '../../../../widget-modules/components/button/button.component';
@Component({ @Component({
selector: 'app-filter', selector: 'app-filter',
templateUrl: './filter.component.html', templateUrl: './filter.component.html',
styleUrls: ['./filter.component.less'], styleUrls: ['./filter.component.less'],
imports: [ReactiveFormsModule, MatFormField, MatLabel, MatInput, MatSelect, MatOption, LegalTypePipe, KeyPipe, SongTypePipe], imports: [ReactiveFormsModule, MatFormField, MatLabel, MatInput, MatSelect, MatOption, LegalTypePipe, KeyPipe, SongTypePipe, ButtonComponent],
}) })
export class FilterComponent { export class FilterComponent {
private filterStore = inject(FilterStoreService); private filterStore = inject(FilterStoreService);
private destroyRef = inject(DestroyRef); private destroyRef = inject(DestroyRef);
public faResetFilter = faFilterCircleXmark;
public filterFormGroup: FormGroup<{ public filterFormGroup: FormGroup<{
q: FormControl<string>; q: FormControl<string>;
@@ -73,6 +76,15 @@ export class FilterComponent {
return flags.filter((n, i) => flags.indexOf(n) === i); return flags.filter((n, i) => flags.indexOf(n) === i);
} }
public get filterActive(): boolean {
const filter = this.filterFormGroup.getRawValue();
return !!(filter.q || filter.type || filter.key || filter.legalType || filter.flag);
}
public resetFilter(): void {
this.filterStore.resetSongFilter();
}
private filterValueChanged(key: keyof FilterValues, value: string): void { private filterValueChanged(key: keyof FilterValues, value: string): void {
this.filterStore.updateSongFilter({[key]: value} as Partial<FilterValues>); this.filterStore.updateSongFilter({[key]: value} as Partial<FilterValues>);
} }
@@ -1,11 +1,17 @@
@if (songs$ | async; as songs) { @if (viewModel$ | async; as viewModel) {
<app-page-frame title="Lieder"> <app-page-frame title="Lieder" [menuBadge]="viewModel.filterActive">
<div class="sidebar-content" sidebar> <div class="sidebar-content" sidebar>
<app-filter [songs]="songs"></app-filter> <app-filter [songs]="viewModel.songs"></app-filter>
</div> </div>
<div content> <div content>
<app-card [padding]="false"> <app-card [padding]="false">
@for (song of songs; track trackBy($index, song)) { @if (viewModel.filterActive) {
<div class="filter-active">
<span>Filter aktiv: {{ viewModel.songs.length }} Lieder gefunden.</span>
<button (click)="resetFilter()" class="filter-reset-link" type="button">Filter zurücksetzen</button>
</div>
}
@for (song of viewModel.songs; track trackBy($index, song)) {
<div [routerLink]="song.id" class="list-item"> <div [routerLink]="song.id" class="list-item">
<div class="number">{{ song.number }}</div> <div class="number">{{ song.number }}</div>
<div class="title"> <div class="title">
@@ -31,6 +31,31 @@
text-align: right; text-align: right;
} }
.filter-active {
padding: 10px 20px;
display: flex;
align-items: baseline;
gap: 10px;
flex-wrap: wrap;
color: var(--danger);
font-weight: bold;
}
.filter-reset-link {
padding: 0;
border: 0;
background: transparent;
color: var(--link-color);
cursor: pointer;
font: inherit;
font-weight: normal;
text-decoration: underline;
}
.filter-reset-link:hover {
color: var(--primary-active);
}
.title { .title {
gap: 6px; gap: 6px;
} }
@@ -21,6 +21,11 @@ interface SongListItem extends Song {
hasChordValidationIssues: boolean; hasChordValidationIssues: boolean;
} }
interface SongListViewModel {
songs: SongListItem[];
filterActive: boolean;
}
@Component({ @Component({
selector: 'app-songs', selector: 'app-songs',
templateUrl: './song-list.component.html', templateUrl: './song-list.component.html',
@@ -37,22 +42,31 @@ export class SongListComponent {
private route = inject(ActivatedRoute); private route = inject(ActivatedRoute);
private textRenderingService = inject(TextRenderingService); private textRenderingService = inject(TextRenderingService);
private filterStore = inject(FilterStoreService); private filterStore = inject(FilterStoreService);
public songs$: Observable<SongListItem[]> = combineLatest([ public viewModel$: Observable<SongListViewModel> = combineLatest([
this.filterStore.songFilter$, this.filterStore.songFilter$,
this.route.data.pipe(map(data => (data['songs'] as Song[]).slice().sort((a, b) => a.number - b.number))), this.route.data.pipe(map(data => (data['songs'] as Song[]).slice().sort((a, b) => a.number - b.number))),
]).pipe( ]).pipe(
map(([filter, songs]) => { map(([filter, songs]) => {
return searchSongs(songs, filter.q) const filteredSongs = searchSongs(songs, filter.q)
.filter(song => this.filter(song, filter)) .filter(song => this.filter(song, filter))
.map(song => ({ .map(song => ({
...song, ...song,
hasChordValidationIssues: this.textRenderingService.validateChordNotation(song.text ?? '').length > 0, hasChordValidationIssues: this.textRenderingService.validateChordNotation(song.text ?? '').length > 0,
})); }));
return {
songs: filteredSongs,
filterActive: this.isFilterActive(filter),
};
}), }),
); );
public trackBy = (index: number, show: SongListItem) => show.id; public trackBy = (index: number, show: SongListItem) => show.id;
public resetFilter(): void {
this.filterStore.resetSongFilter();
}
private filter(song: Song, filter: FilterValues): boolean { private filter(song: Song, filter: FilterValues): boolean {
let baseFilter = !filter.type || filter.type === song.type; let baseFilter = !filter.type || filter.type === song.type;
baseFilter = baseFilter && (!filter.key || filter.key === song.key); baseFilter = baseFilter && (!filter.key || filter.key === song.key);
@@ -62,6 +76,10 @@ export class SongListComponent {
return baseFilter; return baseFilter;
} }
private isFilterActive(filter: FilterValues): boolean {
return !!(filter.q || filter.type || filter.key || filter.legalType || filter.flag);
}
private checkFlag(flag: string, flags: string) { private checkFlag(flag: string, flags: string) {
if (!flags) { if (!flags) {
return false; return false;
@@ -7,6 +7,9 @@
class="sidebar-toggle" class="sidebar-toggle"
mat-icon-button type="button"> mat-icon-button type="button">
<fa-icon [icon]="collapsed ? closedIcon : openIcon"></fa-icon> <fa-icon [icon]="collapsed ? closedIcon : openIcon"></fa-icon>
@if (menuBadge()) {
<span aria-hidden="true" class="sidebar-toggle-badge"></span>
}
</button> </button>
} }
<div class="title">{{ title() }}</div> <div class="title">{{ title() }}</div>
@@ -39,6 +39,17 @@
color: inherit; color: inherit;
} }
.sidebar-toggle-badge {
position: absolute;
right: 2px;
bottom: 3px;
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--danger);
box-shadow: 0 0 0 1px var(--surface-persist);
}
.sidebar-toggle:hover { .sidebar-toggle:hover {
color: var(--icon-button-hover-color); color: var(--icon-button-hover-color);
} }
@@ -18,6 +18,7 @@ export class PageFrameComponent {
public closedIcon = faBars; public closedIcon = faBars;
public title = input.required<string>(); public title = input.required<string>();
public withMenu = input<boolean>(true); public withMenu = input<boolean>(true);
public menuBadge = input<boolean>(false);
public toggle(): void { public toggle(): void {
this.collapsed = !this.collapsed; this.collapsed = !this.collapsed;