sidemenu song list

This commit is contained in:
2026-03-16 17:24:10 +01:00
parent f9516bbc4d
commit 3bd359ee9e
19 changed files with 276 additions and 146 deletions

View File

@@ -51,7 +51,7 @@
"docx",
"qrcode"
]
},
},
"configurations": {
"production": {
"fileReplacements": [
@@ -64,7 +64,7 @@
{
"type": "initial",
"maximumWarning": "500kB",
"maximumError": "1MB"
"maximumError": "10MB"
},
{
"type": "anyComponentStyle",
@@ -82,7 +82,7 @@
},
"defaultConfiguration": "production"
},
"serve": {
"serve": {
"builder": "@angular/build:dev-server",
"configurations": {
"production": {
@@ -99,5 +99,8 @@
}
}
}
},
"cli": {
"analytics": false
}
}

View File

@@ -1,5 +1,5 @@
<app-navigation></app-navigation>
<div [@fader]="o.isActivated ? o.activatedRoute : ''" class="content">
<router-outlet #o="outlet"></router-outlet>
<div class="content">
<router-outlet></router-outlet>
</div>

View File

@@ -1,83 +1,81 @@
@if (show) {
<div @fade>
<app-card [closeIcon]="faIcon" [heading]="show.showType | showType" [subheading]="show.date.toDate() | date:'dd.MM.yyyy'" closeLink="/presentation/select">
@if (!progress) {
<div class="song">
@if (show) {
<div class="song-parts">
<div (click)="onSectionClick('title', -1, show.id)" [class.active]="show.presentationSongId === 'title'" class="song-part">
<div class="head">Veranstaltung</div>
</div>
<div (click)="onSectionClick('empty', -1, show.id)" [class.active]="show.presentationSongId === 'empty'" class="song-part">
<div class="head">Leer</div>
</div>
<app-card [closeIcon]="faIcon" [heading]="show.showType | showType" [subheading]="show.date.toDate() | date:'dd.MM.yyyy'" closeLink="/presentation/select">
@if (!progress) {
<div class="song">
@if (show) {
<div class="song-parts">
<div (click)="onSectionClick('title', -1, show.id)" [class.active]="show.presentationSongId === 'title'" class="song-part">
<div class="head">Veranstaltung</div>
</div>
<div (click)="onSectionClick('empty', -1, show.id)" [class.active]="show.presentationSongId === 'empty'" class="song-part">
<div class="head">Leer</div>
</div>
}
</div>
@for (song of presentationSongs; track trackBy($index, song)) {
<div class="song">
@if (show) {
<div [class.active]="show.presentationSongId === song.id" class="title song-part">
<div (click)="onSectionClick(song.id, -1, show.id)" class="head">{{ song.title }}</div>
</div>
} @if (show) {
<div class="song-parts">
@for (section of song.sections; track section.type + '-' + section.number + '-' + $index; let i = $index) {
<div
(click)="onSectionClick(song.id, i, show.id)"
[class.active]="
}
</div>
@for (song of presentationSongs; track trackBy($index, song)) {
<div class="song">
@if (show) {
<div [class.active]="show.presentationSongId === song.id" class="title song-part">
<div (click)="onSectionClick(song.id, -1, show.id)" class="head">{{ song.title }}</div>
</div>
} @if (show) {
<div class="song-parts">
@for (section of song.sections; track section.type + '-' + section.number + '-' + $index; let i = $index) {
<div
(click)="onSectionClick(song.id, i, show.id)"
[class.active]="
show.presentationSongId === song.id &&
show.presentationSection === i
"
class="song-part"
>
<div class="head">{{ section.type | sectionType }} {{ section.number + 1 }}</div>
<div class="fragment">{{ getFirstLine(section) }}</div>
</div>
}
class="song-part"
>
<div class="head">{{ section.type | sectionType }} {{ section.number + 1 }}</div>
<div class="fragment">{{ getFirstLine(section) }}</div>
</div>
}
</div>
}
<div class="song">
@if (show) {
<div [class.active]="show.presentationSongId === 'dynamicText'" class="title song-part">
<div (click)="onSectionClick('dynamicText', -1, show.id)" class="head">Freier Text</div>
</div>
}
<mat-form-field appearance="outline">
<mat-label>Überschrift</mat-label>
<input (ngModelChange)="onDynamicCaption($event, show.id)" [ngModel]="show.presentationDynamicCaption" autocomplete="off" id="dynamic-caption" matInput type="text" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Text</mat-label>
<textarea (ngModelChange)="onDynamicText($event, show.id)" [ngModel]="show.presentationDynamicText" autocomplete="off" id="dynamic-text" matInput></textarea>
</mat-form-field>
</div>
</div>
}
<div class="song">
@if (show) {
<div class="div-bottom">
<button class="btn-start-presentation" mat-button routerLink="/presentation/monitor">
<fa-icon [icon]="faDesktop"></fa-icon>
Präsentation starten
</button>
<mat-form-field appearance="outline">
<mat-label>Hintergrund</mat-label>
<mat-select (ngModelChange)="onBackground($event, show.id)" [ngModel]="show.presentationBackground">
<mat-option value="none">kein Hintergrund</mat-option>
<mat-option value="blue">Sternenhimmel</mat-option>
<mat-option value="green">Blätter</mat-option>
<mat-option value="leder">Leder</mat-option>
<mat-option value="praise">Lobpreis</mat-option>
<mat-option value="bible">Bibel</mat-option>
</mat-select>
</mat-form-field>
<mat-slider #slider [max]="100" [min]="10" [step]="2" class="zoom-slider" color="primary" ngDefaultControl
><input (ngModelChange)="onZoom($event, show.id)" [ngModel]="show.presentationZoom" matSliderThumb />
</mat-slider>
<div [class.active]="show.presentationSongId === 'dynamicText'" class="title song-part">
<div (click)="onSectionClick('dynamicText', -1, show.id)" class="head">Freier Text</div>
</div>
} @if (show) {
<app-add-song [addedLive]="true" [showSongs]="showSongs" [show]="show" [songs]="songs$|async"></app-add-song>
} }
</app-card>
</div>
}
<mat-form-field appearance="outline">
<mat-label>Überschrift</mat-label>
<input (ngModelChange)="onDynamicCaption($event, show.id)" [ngModel]="show.presentationDynamicCaption" autocomplete="off" id="dynamic-caption" matInput type="text" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Text</mat-label>
<textarea (ngModelChange)="onDynamicText($event, show.id)" [ngModel]="show.presentationDynamicText" autocomplete="off" id="dynamic-text" matInput></textarea>
</mat-form-field>
</div>
@if (show) {
<div class="div-bottom">
<button class="btn-start-presentation" mat-button routerLink="/presentation/monitor">
<fa-icon [icon]="faDesktop"></fa-icon>
Präsentation starten
</button>
<mat-form-field appearance="outline">
<mat-label>Hintergrund</mat-label>
<mat-select (ngModelChange)="onBackground($event, show.id)" [ngModel]="show.presentationBackground">
<mat-option value="none">kein Hintergrund</mat-option>
<mat-option value="blue">Sternenhimmel</mat-option>
<mat-option value="green">Blätter</mat-option>
<mat-option value="leder">Leder</mat-option>
<mat-option value="praise">Lobpreis</mat-option>
<mat-option value="bible">Bibel</mat-option>
</mat-select>
</mat-form-field>
<mat-slider #slider [max]="100" [min]="10" [step]="2" class="zoom-slider" color="primary" ngDefaultControl
><input (ngModelChange)="onZoom($event, show.id)" [ngModel]="show.presentationZoom" matSliderThumb />
</mat-slider>
</div>
} @if (show) {
<app-add-song [addedLive]="true" [showSongs]="showSongs" [show]="show" [songs]="songs$|async"></app-add-song>
} }
</app-card>
}

View File

@@ -1,5 +1,15 @@
.third {
display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr;
column-gap: 20px;
.third,
:host ::ng-deep form,
div[formGroup] {
display: flex;
flex-direction: column;
gap: 12px;
}
.third {
gap: 0;
}
:host ::ng-deep .mat-mdc-form-field {
width: 100%;
}

View File

@@ -1,43 +1,48 @@
@if (songs$ | async; as songs) {
<div>
<app-list-header [anyFilterActive]="anyFilterActive">
<app-sidebar>
<div sidebar class="sidebar-content">
<app-filter [songs]="songs"></app-filter>
</app-list-header>
<app-card [padding]="false">
@for (song of songs; track trackBy($index, song)) {
<div [routerLink]="song.id" class="list-item">
<div class="number">{{ song.number }}</div>
<div class="title">
<span>{{ song.title }}</span>
@if (song.hasChordValidationIssues) {
<span class="validation-star" title="Akkord-Validierungsfehler vorhanden">*</span>
}
</div>
<div>
<ng-container *appRole="['contributor']">
@if (song.status === 'draft') {
<div class="sidebar-actions">
<app-button [icon]="faNewSong" routerLink="new">Neuen Song anlegen</app-button>
</div>
</div>
<div content>
<app-card [padding]="false">
@for (song of songs; track trackBy($index, song)) {
<div [routerLink]="song.id" class="list-item">
<div class="number">{{ song.number }}</div>
<div class="title">
<span>{{ song.title }}</span>
@if (song.hasChordValidationIssues) {
<span class="validation-star" title="Akkord-Validierungsfehler vorhanden">*</span>
}
</div>
<div>
<ng-container *appRole="['contributor']">
@if (song.status === 'draft') {
<div class="warning">
<fa-icon [icon]="faDraft"></fa-icon>
</div>
} @if (song.status === 'set') {
<div class="neutral">
<fa-icon [icon]="faDraft"></fa-icon>
</div>
} @if (song.status === 'final') {
<div class="success">
<fa-icon [icon]="faFinal"></fa-icon>
</div>
}
</ng-container>
@if (song.legalType === 'open') {
<div class="warning">
<fa-icon [icon]="faDraft"></fa-icon>
</div>
} @if (song.status === 'set') {
<div class="neutral">
<fa-icon [icon]="faDraft"></fa-icon>
</div>
} @if (song.status === 'final') {
<div class="success">
<fa-icon [icon]="faFinal"></fa-icon>
<fa-icon [icon]="faLegal"></fa-icon>
</div>
}
</ng-container>
@if (song.legalType === 'open') {
<div class="warning">
<fa-icon [icon]="faLegal"></fa-icon>
</div>
}
<div>{{ song.key }}</div>
</div>
<div>{{ song.key }}</div>
</div>
}
</app-card>
</div>
}
</app-card>
</div>
</app-sidebar>
}

View File

@@ -1,3 +1,15 @@
.sidebar-content {
padding: 20px;
height: 100%;
box-sizing: border-box;
display: flex;
flex-direction: column;
}
.sidebar-actions {
margin-top: auto;
}
.list-item {
padding: 5px 20px;
display: grid;

View File

@@ -6,15 +6,16 @@ import {fade} from '../../../animations';
import {ActivatedRoute, RouterLink} from '@angular/router';
import {filterSong} from '../../../services/filter.helper';
import {FilterValues} from './filter/filter-values';
import {faBalanceScaleRight, faCheck, faPencilRuler} from '@fortawesome/free-solid-svg-icons';
import {faBalanceScaleRight, faCheck, faPencilRuler, faPlus} from '@fortawesome/free-solid-svg-icons';
import {TextRenderingService} from '../services/text-rendering.service';
import {FilterStoreService} from '../../../services/filter-store.service';
import {AsyncPipe} from '@angular/common';
import {ListHeaderComponent} from '../../../widget-modules/components/list-header/list-header.component';
import {FilterComponent} from './filter/filter.component';
import {CardComponent} from '../../../widget-modules/components/card/card.component';
import {RoleDirective} from '../../../services/user/role.directive';
import {FaIconComponent} from '@fortawesome/angular-fontawesome';
import {SidebarComponent} from '../../../widget-modules/components/sidebar/sidebar.component';
import {ButtonComponent} from '../../../widget-modules/components/button/button.component';
interface SongListItem extends Song {
hasChordValidationIssues: boolean;
@@ -26,20 +27,21 @@ interface SongListItem extends Song {
styleUrls: ['./song-list.component.less'],
changeDetection: ChangeDetectionStrategy.OnPush,
animations: [fade],
imports: [ListHeaderComponent, FilterComponent, CardComponent, RouterLink, RoleDirective, FaIconComponent, AsyncPipe],
imports: [FilterComponent, CardComponent, RouterLink, RoleDirective, FaIconComponent, AsyncPipe, SidebarComponent, ButtonComponent],
})
export class SongListComponent {
public faLegal = faBalanceScaleRight;
public faDraft = faPencilRuler;
public faFinal = faCheck;
public faNewSong = faPlus;
private route = inject(ActivatedRoute);
private textRenderingService = inject(TextRenderingService);
private filterStore = inject(FilterStoreService);
public anyFilterActive = false;
public songs$: Observable<SongListItem[]> = combineLatest([
this.filterStore.songFilter$,
this.route.data.pipe(map(data => (data['songs'] as Song[]).slice().sort((a, b) => a.number - b.number))),
]).pipe(
map(([filter, songs]) => {
this.anyFilterActive = this.checkIfFilterActive(filter);
return songs
.filter(song => this.filter(song, filter))
.map(song => ({
@@ -49,9 +51,6 @@ export class SongListComponent {
.sort((a, b) => a.title?.localeCompare(b.title));
})
);
public faLegal = faBalanceScaleRight;
public faDraft = faPencilRuler;
public faFinal = faCheck;
public trackBy = (index: number, show: SongListItem) => show.id;
@@ -65,10 +64,6 @@ export class SongListComponent {
return baseFilter;
}
private checkIfFilterActive(filter: FilterValues): boolean {
return !!filter.q || !!filter.type || !!filter.key || !!filter.legalType || !!filter.flag;
}
private checkFlag(flag: string, flags: string) {
if (!flags) {
return false;

View File

@@ -1,4 +1,4 @@
<div [class.fullscreen]="fullscreen" [class.padding]="padding" class="card">
<div @fade [class.fullscreen]="fullscreen" [class.padding]="padding" class="card">
@if (closeLink && !fullscreen) {
<button [routerLink]="closeLink" class="btn-close" mat-icon-button>
<fa-icon [icon]="closeIcon"></fa-icon>

View File

@@ -70,9 +70,10 @@
}
.btn-close {
--icon-button-color: var(--text-soft);
--icon-button-hover-color: var(--text);
position: absolute;
right: 10px;
top: 15px;
opacity: 0.7;
color: var(--text-soft);
}

View File

@@ -4,12 +4,14 @@ import {faTimes} from '@fortawesome/free-solid-svg-icons';
import {MatIconButton} from '@angular/material/button';
import {RouterLink} from '@angular/router';
import {FaIconComponent} from '@fortawesome/angular-fontawesome';
import {fade} from '../../../animations';
@Component({
selector: 'app-card',
templateUrl: './card.component.html',
styleUrls: ['./card.component.less'],
imports: [MatIconButton, RouterLink, FaIconComponent],
animations: [fade],
})
export class CardComponent {
@Input() public padding = true;

View File

@@ -1,13 +1,15 @@
<div class="header">
@if (showFilterButton) {
<button (click)="onFilterClick()" [class.filter-active]="anyFilterActive" mat-icon-button>
<fa-icon [icon]="faFilter"></fa-icon>
</button>
}
<button mat-icon-button routerLink="new">
<fa-icon [icon]="faNew"></fa-icon>
</button>
</div>
@if (filterVisible || anyFilterActive) {
@if (showFilterButton && (filterVisible || anyFilterActive)) {
<div @fade>
<app-card>
<ng-content></ng-content>

View File

@@ -10,11 +10,15 @@
display: flex;
align-items: center;
justify-content: flex-end;
color: var(--primary-hover);
}
.filter-active {
color: var(--danger);
.header .mat-mdc-icon-button {
--icon-button-color: var(--primary-hover);
--icon-button-hover-color: var(--primary-active);
}
.header .mat-mdc-icon-button.filter-active {
--icon-button-color: var(--danger);
--icon-button-hover-color: var(--danger);
cursor: not-allowed;
}

View File

@@ -20,6 +20,7 @@ export class ListHeaderComponent {
public filterVisible = false;
@Output() public filterVisibleChanged = new EventEmitter<boolean>();
@Input() public anyFilterActive = false;
@Input() public showFilterButton = true;
public onFilterClick(): void {
this.filterVisible = !this.filterVisible || this.anyFilterActive;

View File

@@ -1,4 +1,6 @@
button {
.mat-mdc-button {
--icon-button-color: var(--primary-color);
--icon-button-hover-color: var(--primary-active);
min-width: 0;
padding: 0 var(--button-padding, 5px);
font-size: var(--button-font-size, 1em);

View File

@@ -1 +1,12 @@
<aside></aside>
<button class="sidebar-toggle" (click)="toggle()" [attr.aria-expanded]="!collapsed" aria-label="Sidebar umschalten" mat-icon-button type="button">
<fa-icon [icon]="collapsed ? closedIcon : openIcon"></fa-icon>
</button>
<aside [class.collapsed]="collapsed">
<div class="sidebar-toggle-placeholder" aria-hidden="true"></div>
<div class="sidebar-body">
<ng-content select="[sidebar]"></ng-content>
</div>
</aside>
<div class="content">
<ng-content select="[content]"></ng-content>
</div>

View File

@@ -1,5 +1,48 @@
:host {
--sidebar-width: 300px;
--sidebar-toggle-size: 48px;
--sidebar-toggle-offset: 12px;
display: grid;
width: 100%;
max-width: 100%;
min-width: 0;
grid-template-columns: var(--sidebar-width) minmax(0, 1fr);
align-items: start;
box-sizing: border-box;
transition: grid-template-columns 200ms ease;
}
:host.collapsed {
grid-template-columns: 0 minmax(0, 1fr);
}
.sidebar-toggle {
--icon-button-color: var(--primary-hover);
--icon-button-hover-color: var(--primary-active);
position: fixed;
top: calc(50px + var(--sidebar-toggle-offset));
left: var(--sidebar-toggle-offset);
z-index: 11;
color: var(--icon-button-color);
background: transparent;
box-shadow: none;
}
:host.collapsed .sidebar-toggle {
--icon-button-color: var(--text-inverse);
--icon-button-hover-color: var(--text-inverse);
}
.sidebar-toggle fa-icon {
color: inherit;
}
.sidebar-toggle:hover {
color: var(--icon-button-hover-color);
}
aside {
width: 200px;
width: var(--sidebar-width);
height: calc(100vh - 50px);
position: fixed;
@@ -8,4 +51,28 @@ aside {
bottom: 0;
background: var(--surface);
box-shadow: var(--shadow-card-2);
overflow: hidden;
transform: translateX(0);
transition: transform 200ms ease;
}
aside.collapsed {
transform: translateX(calc(-1 * var(--sidebar-width)));
}
.sidebar-toggle-placeholder {
height: calc(var(--sidebar-toggle-size) + var(--sidebar-toggle-offset));
flex: 0 0 auto;
}
.sidebar-body {
height: calc(100% - var(--sidebar-toggle-size) - var(--sidebar-toggle-offset));
}
.content {
grid-column: 2;
min-width: 0;
width: 100%;
padding: 0;
}

View File

@@ -1,9 +1,23 @@
import {Component} from '@angular/core';
import {MatIconButton} from '@angular/material/button';
import {FaIconComponent} from '@fortawesome/angular-fontawesome';
import {faBars, faChevronLeft} from '@fortawesome/free-solid-svg-icons';
@Component({
selector: 'app-sidebar',
imports: [],
imports: [MatIconButton, FaIconComponent],
templateUrl: './sidebar.component.html',
styleUrl: './sidebar.component.less',
host: {
'[class.collapsed]': 'collapsed',
},
})
export class SidebarComponent {}
export class SidebarComponent {
public collapsed = true;
public openIcon = faChevronLeft;
public closedIcon = faBars;
public toggle(): void {
this.collapsed = !this.collapsed;
}
}

View File

@@ -16,7 +16,7 @@ $wgenerator-theme: mat.m2-define-light-theme((
warn: $wgenerator-warn,
),
typography: mat.m2-define-typography-config(),
density: 0,
density: -2,
));
@include mat.all-component-themes($wgenerator-theme);

View File

@@ -34,6 +34,10 @@
--focus-ring: 0 0 0 2px rgba(111, 143, 149, 0.28);
--transition: all 300ms ease-in-out;
--transition-fast: all 150ms ease-in-out;
--icon-button-color: var(--primary-color);
--icon-button-hover-color: var(--primary-active);
--icon-button-opacity: 1;
--icon-button-hover-opacity: 1;
--mat-dialog-supporting-text-color: var(--text);
@@ -78,11 +82,11 @@ a {
}
.mat-mdc-icon-button {
color: var(--primary-color) !important;
color: var(--icon-button-color, var(--primary-color));
transition: var(--transition);
&:hover {
color: var(--primary-active) !important;
color: var(--icon-button-hover-color, var(--primary-active));
}
}
@@ -103,11 +107,11 @@ body .cdk-overlay-transparent-backdrop.cdk-overlay-backdrop-showing {
}
.btn-icon {
opacity: 0.2;
opacity: var(--icon-button-opacity);
transition: var(--transition);
&:hover {
opacity: 1;
opacity: var(--icon-button-hover-opacity);
}
}
@@ -126,6 +130,5 @@ body {
opacity: 0;
}
--mat-form-field-container-text-line-height: 16px;
--mat-form-field-container-text-size: 16px;
}