Files
wgenerator/src/app/modules/shows/show/show.component.ts
2026-04-27 22:02:15 +02:00

394 lines
12 KiB
TypeScript

import {
ChangeDetectorRef,
Component,
CUSTOM_ELEMENTS_SCHEMA,
HostListener,
inject,
OnDestroy,
OnInit,
} from '@angular/core';
import {filter, map, shareReplay, switchMap, take, tap} from 'rxjs/operators';
import {ActivatedRoute, Router} from '@angular/router';
import {ShowService} from '../services/show.service';
import {Observable, of, Subscription} from 'rxjs';
import {Show} from '../services/show';
import {SongService} from '../../songs/services/song.service';
import {Song} from '../../songs/services/song';
import {ShowSongService} from '../services/show-song.service';
import {ShowSong} from '../services/show-song';
import {DocxService} from '../services/docx.service';
import {
faArrowUpRightFromSquare,
faBox,
faBoxOpen,
faChevronRight,
faCompactDisc,
faFileDownload,
faLock,
faMagnifyingGlassMinus,
faMagnifyingGlassPlus,
faMaximize,
faMinimize,
faSliders,
faUnlock,
faUser,
faUsers,
} from '@fortawesome/free-solid-svg-icons';
import {CdkDrag, CdkDragDrop, CdkDropList, moveItemInArray} from '@angular/cdk/drag-drop';
import {fade} from '../../../animations';
import {MatDialog} from '@angular/material/dialog';
import {ArchiveDialogComponent} from '../dialog/archive-dialog/archive-dialog.component';
import {closeFullscreen, openFullscreen} from '../../../services/fullscreen';
import {GuestShowService} from '../../guest/guest-show.service';
import {ShareDialogComponent} from '../dialog/share-dialog/share-dialog.component';
import {AsyncPipe, DatePipe} from '@angular/common';
import {CardComponent} from '../../../widget-modules/components/card/card.component';
import {UserNameComponent} from '../../../services/user/user-name/user-name.component';
import {MatCheckbox} from '@angular/material/checkbox';
import {FormsModule, ReactiveFormsModule} from '@angular/forms';
import {MenuButtonComponent} from '../../../widget-modules/components/menu-button/menu-button.component';
import {MatTooltip} from '@angular/material/tooltip';
import {SongComponent} from './song/song.component';
import {FaIconComponent} from '@fortawesome/angular-fontawesome';
import {AddSongComponent} from '../../../widget-modules/components/add-song/add-song.component';
import {ButtonRowComponent} from '../../../widget-modules/components/button-row/button-row.component';
import {RoleDirective} from '../../../services/user/role.directive';
import {OwnerDirective} from '../../../services/user/owner.directive';
import {ButtonComponent} from '../../../widget-modules/components/button/button.component';
import {MatMenu, MatMenuTrigger} from '@angular/material/menu';
import {ShowTypePipe} from '../../../widget-modules/pipes/show-type-translater/show-type.pipe';
import {UserService} from '../../../services/user/user.service';
import {ReportedTypePipe} from '../../../widget-modules/pipes/reported-type-translator/reported-type.pipe';
import {BadgeComponent, BadgeType} from '../../../widget-modules/components/badge/badge.component';
import {ReportDialogComponent, ReportDialogSong} from '../dialog/report-dialog/report-dialog.component';
import {PublishedTypePipe} from '../../../widget-modules/pipes/published-type-translator/published-type.pipe';
import {ensureSwiperElement} from '../../../services/swiper-element';
import {PageFrameComponent} from '../../../widget-modules/components/sidebar/page-frame.component';
@Component({
selector: 'app-show',
templateUrl: './show.component.html',
styleUrls: ['./show.component.less'],
animations: [fade],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
imports: [
CardComponent,
UserNameComponent,
MatCheckbox,
ReactiveFormsModule,
FormsModule,
MenuButtonComponent,
MatTooltip,
CdkDropList,
CdkDrag,
SongComponent,
FaIconComponent,
AddSongComponent,
ButtonRowComponent,
RoleDirective,
OwnerDirective,
ButtonComponent,
MatMenuTrigger,
MatMenu,
AsyncPipe,
DatePipe,
ShowTypePipe,
ReportedTypePipe,
PublishedTypePipe,
BadgeComponent,
PageFrameComponent,
],
})
export class ShowComponent implements OnInit, OnDestroy {
public dialog = inject(MatDialog);
public show$: Observable<Show | null> | null = null;
public songs$: Observable<Song[] | null> | null = null;
public showSongs: ShowSong[] | null = null;
public showId: string | null = null;
public showText = false;
public faBox = faBox;
public faBoxOpen = faBoxOpen;
public faReport = faCompactDisc;
public faPublish = faUnlock;
public faUnpublish = faLock;
public faShare = faArrowUpRightFromSquare;
public faDownload = faFileDownload;
public faSliders = faSliders;
public faUser = faUser;
public faUsers = faUsers;
public faZoomIn = faMagnifyingGlassPlus;
public faZoomOut = faMagnifyingGlassMinus;
public useSwiper = false;
public textSize = 1;
public faRestore = faMinimize;
public faMaximize = faMaximize;
public faNextSong = faChevronRight;
public currentTime!: Date;
private activatedRoute = inject(ActivatedRoute);
private showService = inject(ShowService);
private songService = inject(SongService);
private showSongService = inject(ShowSongService);
private docxService = inject(DocxService);
private router = inject(Router);
private cRef = inject(ChangeDetectorRef);
private userService = inject(UserService);
private guestShowService = inject(GuestShowService);
private subs: Subscription[] = [];
private clockIntervalId: ReturnType<typeof setInterval> | null = null;
public ngOnInit(): void {
void ensureSwiperElement();
this.currentTime = new Date();
this.clockIntervalId = setInterval(() => {
this.currentTime = new Date();
}, 10000);
this.show$ = this.activatedRoute.params.pipe(
map(param => param as {showId: string}),
map(param => param.showId),
tap((_: string) => (this.showId = _)),
switchMap((showId: string) => this.showService.read$(showId)),
shareReplay({
bufferSize: 1,
refCount: true,
}),
);
this.subs.push(
this.activatedRoute.params
.pipe(
map(param => param as {showId: string}),
map(param => param.showId),
switchMap(showId => this.showSongService.list$(showId)),
filter(_ => !!_ && _.length > 0),
)
.subscribe(_ => {
this.showSongs = _;
this.cRef.markForCheck();
}),
);
this.songs$ = this.show$.pipe(
switchMap(show => (show && !show.published ? this.songService.list$() : of(null))),
shareReplay({
bufferSize: 1,
refCount: true,
}),
);
}
public ngOnDestroy(): void {
this.subs.forEach(_ => _.unsubscribe());
if (this.clockIntervalId) {
clearInterval(this.clockIntervalId);
}
}
public onZoomIn() {
this.textSize += 0.1;
}
public onZoomOut() {
this.textSize -= 0.1;
}
public onArchive(archived: boolean): void {
if (!archived && this.showId != null) void this.setArchiveState(false);
else {
const dialogRef = this.dialog.open(ArchiveDialogComponent, {
width: '350px',
});
dialogRef
.afterClosed()
.pipe(take(1))
.subscribe((archive: boolean) => {
if (archive && this.showId != null) void this.setArchiveState(true);
});
}
}
public async onPublish(show: Show, published: boolean): Promise<void> {
if (!show.id) {
return;
}
if (!published) {
await this.showService.update$(show.id, {published: false, reportedType: null});
return;
}
if (!show.public) {
await this.showService.update$(show.id, {published: true, reportedType: 'not-required'});
return;
}
const showSongs = this.showSongs ?? (await this.showSongService.list(show.id));
const reportedType = showSongs.some(song => song.legalOwner === 'CCLI' && !!song.legalOwnerId) ? 'pending' : 'not-required';
await this.showService.update$(show.id, {published: true, reportedType});
}
public onShare = async (show: Show): Promise<void> => {
const url = await this.guestShowService.share(show, this.orderedShowSongs(show));
this.dialog.open(ShareDialogComponent, {data: {url, show}});
};
public onReport(show: Show): void {
const songs = this.getReportableSongs(show);
if (songs.length === 0) {
return;
}
const dialogRef = this.dialog.open(ReportDialogComponent, {
width: '640px',
data: {songs},
});
dialogRef
.afterClosed()
.pipe(take(1))
.subscribe((reported: boolean) => {
if (reported) {
void this.showService.update$(show.id, {reportedType: 'reported'});
}
});
}
public getStatus(show: Show): string {
if (show.published) {
return 'veröffentlicht';
}
if (show.reportedType === 'reported') {
return 'gemeldet';
}
return 'entwurf';
}
public getReportedTypeBadgeType(show: Show): BadgeType {
switch (show.reportedType) {
case 'pending':
return 'error';
case 'reported':
return 'ok';
case 'not-required':
return 'none';
default:
return 'none';
}
}
public getPublishedBadgeType(show: Show): BadgeType {
return show.published ? 'ok' : 'none';
}
public async onDownload(): Promise<void> {
if (this.showId != null) await this.docxService.create(this.showId);
}
public async onDownloadHandout(): Promise<void> {
if (this.showId != null)
await this.docxService.create(this.showId, {
chordMode: 'hide',
copyright: true,
});
}
public async drop(event: CdkDragDrop<never>, show: Show): Promise<void> {
const order = [...show.order];
moveItemInArray(order, event.previousIndex, event.currentIndex);
await this.showService.update$(show.id, {order});
}
public orderedShowSongs(show: Show): ShowSong[] {
const list = this.showSongs;
if (!list) return [];
const byId = new Map(list.map(item => [item.id, item] as const));
return show.order.map(id => byId.get(id)).filter((song): song is ShowSong => !!song);
}
public trackBy = (_: number, show: ShowSong) => show?.id;
public async onChange(showId: string) {
await this.router.navigateByUrl('/shows/' + showId + '/edit');
}
@HostListener('document:keydown', ['$event'])
public handleKeyboardEvent(event: KeyboardEvent) {
if (!this.useSwiper) return;
const swiperEl = document.querySelector('swiper-container') as unknown as Swiper;
switch (event.code) {
case 'ArrowRight':
case 'ArrowDown':
swiperEl.swiper.slideNext();
break;
case 'ArrowLeft':
case 'ArrowUp':
swiperEl.swiper.slidePrev();
break;
}
}
public fullscreen(useSwiper: boolean) {
this.textSize = useSwiper ? 2 : 1;
if (useSwiper) openFullscreen();
else closeFullscreen();
}
public getNextSong(showSongs: ShowSong[], i: number): string {
if (!showSongs || showSongs.length === 0) return '';
const song = showSongs[i + 1];
return song?.title ?? '';
}
public getSongKeyColumnWidth(show: Show): string {
const labels = this.orderedShowSongs(show).map(song => {
if (song.keyOriginal && song.keyOriginal !== song.key) {
return `${song.keyOriginal} -> ${song.key}`;
}
return song.key ?? '';
});
const longestLabelLength = labels.reduce((max, label) => Math.max(max, label.length), 0);
const widthInCh = Math.max(3, longestLabelLength);
return `${widthInCh}ch`;
}
private getReportableSongs(show: Show): ReportDialogSong[] {
const uniqueSongs = new Map<string, ReportDialogSong>();
this.orderedShowSongs(show)
.filter(song => song.legalOwner === 'CCLI' && !!song.legalOwnerId)
.forEach(song => {
const key = song.songId || `${song.title}:${song.legalOwnerId}`;
if (!uniqueSongs.has(key)) {
uniqueSongs.set(key, {
title: song.title,
ccliNumber: song.legalOwnerId,
});
}
});
return Array.from(uniqueSongs.values());
}
private async setArchiveState(archived: boolean): Promise<void> {
if (!this.showId) {
return;
}
const updates: Array<Promise<void | null>> = [this.showService.update$(this.showId, {archived})];
(this.showSongs ?? []).forEach(showSong => {
updates.push(archived ? this.userService.decSongCount(showSong.songId) : this.userService.incSongCount(showSong.songId));
});
await Promise.all(updates);
}
}
export interface Swiper {
swiper: {
slideNext(): void;
slidePrev(): void;
};
}