diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index b66a68e..b86fedf 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -1,5 +1,9 @@ import {NgModule} from '@angular/core'; -import {PreloadAllModules, RouterModule, Routes} from '@angular/router'; +import {RouterModule, Routes} from '@angular/router'; +import {FirebaseApp} from '@angular/fire/app'; +import {provideStorage} from '@angular/fire/storage'; +import {inject} from '@angular/core'; +import {getStorage} from 'firebase/storage'; import {RoleGuard} from './widget-modules/guards/role.guard'; import {AuthGuard} from './widget-modules/guards/auth.guard'; @@ -12,6 +16,7 @@ const routes: Routes = [ { path: 'songs', loadChildren: () => import('./modules/songs/songs.module').then(m => m.SongsModule), + providers: [provideStorage(() => getStorage(inject(FirebaseApp)))], canActivate: [AuthGuard, RoleGuard], data: { requiredRoles: ['user'], @@ -50,7 +55,6 @@ const routes: Routes = [ @NgModule({ imports: [ RouterModule.forRoot(routes, { - preloadingStrategy: PreloadAllModules, scrollPositionRestoration: 'enabled', // relativeLinkResolution: 'legacy', }), diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 8b9274c..4a4b340 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -1,6 +1,5 @@ import {ChangeDetectionStrategy, Component, OnInit} from '@angular/core'; import {fader} from './animations'; -import {register} from 'swiper/element/bundle'; import {RouterOutlet} from '@angular/router'; import {NavigationComponent} from './widget-modules/components/application-frame/navigation/navigation.component'; @@ -13,10 +12,6 @@ import {NavigationComponent} from './widget-modules/components/application-frame imports: [RouterOutlet, NavigationComponent], }) export class AppComponent implements OnInit { - public constructor() { - register(); - } - public ngOnInit(): void { setTimeout(() => document.querySelector('#load-bg')?.classList.add('hidden'), 1000); setTimeout(() => document.querySelector('#load-bg')?.remove(), 5000); diff --git a/src/app/modules/guest/guest.component.ts b/src/app/modules/guest/guest.component.ts index a3a2d3d..30b648d 100644 --- a/src/app/modules/guest/guest.component.ts +++ b/src/app/modules/guest/guest.component.ts @@ -8,6 +8,7 @@ import {SongTextComponent} from '../../widget-modules/components/song-text/song- import {ShowTypePipe} from '../../widget-modules/pipes/show-type-translater/show-type.pipe'; import {concat, from, Observable, of} from 'rxjs'; import {GuestShow} from './guest-show'; +import {ensureSwiperElement} from '../../services/swiper-element'; @Component({ selector: 'app-guest', @@ -20,6 +21,10 @@ export class GuestComponent { private currentRoute = inject(ActivatedRoute); private service = inject(GuestShowDataService); + public constructor() { + void ensureSwiperElement(); + } + public showState$: Observable = this.currentRoute.params.pipe( map(param => param.id as string), switchMap(id => diff --git a/src/app/modules/shows/dialog/share-dialog/share-dialog.component.spec.ts b/src/app/modules/shows/dialog/share-dialog/share-dialog.component.spec.ts index fb91cad..26d52f0 100644 --- a/src/app/modules/shows/dialog/share-dialog/share-dialog.component.spec.ts +++ b/src/app/modules/shows/dialog/share-dialog/share-dialog.component.spec.ts @@ -1,21 +1,31 @@ import {ComponentFixture, TestBed} from '@angular/core/testing'; - import {ShareDialogComponent} from './share-dialog.component'; -import QRCode from 'qrcode'; +import {MAT_DIALOG_DATA} from '@angular/material/dialog'; describe('ShareDialogComponent', () => { let component: ShareDialogComponent; let fixture: ComponentFixture; beforeEach(async () => { - spyOn(QRCode, 'toDataURL').and.resolveTo('data:image/jpeg;base64,test'); - await TestBed.configureTestingModule({ imports: [ShareDialogComponent], + providers: [ + { + provide: MAT_DIALOG_DATA, + useValue: { + url: 'https://example.com/guest/1', + show: { + showType: 'service-worship', + date: {toDate: () => new Date('2026-03-20T00:00:00Z')}, + }, + }, + }, + ], }).compileComponents(); fixture = TestBed.createComponent(ShareDialogComponent); component = fixture.componentInstance; + spyOn(component, 'generateQrCode').and.resolveTo('data:image/jpeg;base64,test'); fixture.detectChanges(); }); diff --git a/src/app/modules/shows/dialog/share-dialog/share-dialog.component.ts b/src/app/modules/shows/dialog/share-dialog/share-dialog.component.ts index fa93dad..fce4f74 100644 --- a/src/app/modules/shows/dialog/share-dialog/share-dialog.component.ts +++ b/src/app/modules/shows/dialog/share-dialog/share-dialog.component.ts @@ -1,7 +1,6 @@ import {Component, inject} from '@angular/core'; import {MAT_DIALOG_DATA, MatDialogActions, MatDialogClose, MatDialogContent} from '@angular/material/dialog'; import {MatButton} from '@angular/material/button'; -import QRCode from 'qrcode'; import {ShowTypePipe} from '../../../../widget-modules/pipes/show-type-translater/show-type.pipe'; import {Show} from '../../services/show'; @@ -24,18 +23,7 @@ export class ShareDialogComponent { public constructor() { const data = this.data; - // eslint-disable-next-line @typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment - void QRCode.toDataURL(data.url, { - type: 'image/jpeg', - quality: 0.92, - width: 1280, - height: 1280, - color: { - dark: '#010414', - light: '#ffffff', - }, - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-return - }).then((qrCode: string) => (this.qrCode = qrCode)); + void this.generateQrCode(data.url).then(qrCode => (this.qrCode = qrCode)); } public async share(): Promise { @@ -48,4 +36,18 @@ export class ShareDialogComponent { url: this.data.url, }); } + + private async generateQrCode(url: string): Promise { + const {default: QRCode} = await import('qrcode'); + return await QRCode.toDataURL(url, { + type: 'image/jpeg', + quality: 0.92, + width: 1280, + height: 1280, + color: { + dark: '#010414', + light: '#ffffff', + }, + }); + } } diff --git a/src/app/modules/shows/services/docx.service.spec.ts b/src/app/modules/shows/services/docx.service.spec.ts index d701f40..10dc0f1 100644 --- a/src/app/modules/shows/services/docx.service.spec.ts +++ b/src/app/modules/shows/services/docx.service.spec.ts @@ -1,13 +1,15 @@ import {TestBed} from '@angular/core/testing'; -import {Packer} from 'docx'; import {DocxService} from './docx.service'; describe('DocxService', () => { let service: DocxService; type DocxServiceInternals = DocxService & { prepareData: (showId: string) => Promise; - prepareNewDocument: (data: unknown, options?: unknown) => unknown; + renderTitle: (docx: unknown, title: string) => unknown[]; + renderSongs: (docx: unknown, songs: unknown[], options: unknown, config: unknown) => unknown[]; + prepareNewDocument: (docx: unknown, data: unknown, options?: unknown, sections?: unknown) => unknown; saveAs: (blob: Blob, name: string) => void; + loadDocx: () => Promise<{Packer: {toBlob: (document: unknown) => Promise}}>; }; beforeEach(async () => { @@ -32,6 +34,11 @@ describe('DocxService', () => { it('should build and save a docx file when all data is available', async () => { const blob = new Blob(['docx']); + const docxModule = { + Packer: { + toBlob: jasmine.createSpy('toBlob').and.resolveTo(blob), + }, + }; const serviceInternals = service as DocxServiceInternals; const prepareDataSpy = spyOn(serviceInternals, 'prepareData').and.resolveTo({ show: { @@ -42,15 +49,17 @@ describe('DocxService', () => { user: {name: 'Benjamin'}, config: {ccliLicenseId: '12345'}, }); + spyOn(serviceInternals, 'loadDocx').and.resolveTo(docxModule); + spyOn(serviceInternals, 'renderTitle').and.returnValue([]); + spyOn(serviceInternals, 'renderSongs').and.returnValue([]); const prepareNewDocumentSpy = spyOn(serviceInternals, 'prepareNewDocument').and.returnValue({doc: true}); const saveAsSpy = spyOn(serviceInternals, 'saveAs'); - spyOn(Packer, 'toBlob').and.resolveTo(blob); await service.create('show-1', {copyright: true}); expect(prepareDataSpy).toHaveBeenCalledWith('show-1'); expect(prepareNewDocumentSpy).toHaveBeenCalled(); - expect(Packer.toBlob).toHaveBeenCalledWith({doc: true} as never); + expect(docxModule.Packer.toBlob).toHaveBeenCalledWith({doc: true} as never); expect(saveAsSpy).toHaveBeenCalledWith(blob, jasmine.stringMatching(/\.docx$/)); }); }); diff --git a/src/app/modules/shows/services/docx.service.ts b/src/app/modules/shows/services/docx.service.ts index 65e76c2..434c1f3 100644 --- a/src/app/modules/shows/services/docx.service.ts +++ b/src/app/modules/shows/services/docx.service.ts @@ -1,5 +1,4 @@ import {Injectable, inject} from '@angular/core'; -import {Document, HeadingLevel, ISectionOptions, Packer, Paragraph} from 'docx'; import {ShowService} from './show.service'; import {ShowTypePipe} from '../../../widget-modules/pipes/show-type-translater/show-type.pipe'; import {ShowSongService} from './show-song.service'; @@ -17,6 +16,11 @@ import {LineType} from '../../songs/services/line-type'; import {Line} from '../../songs/services/line'; import {firstValueFrom} from 'rxjs'; +type DocxModule = typeof import('docx'); +type DocxDocument = import('docx').Document; +type DocxParagraph = import('docx').Paragraph; +type DocxSectionOptions = import('docx').ISectionOptions; + export interface DownloadOptions { copyright?: boolean; chordMode?: ChordMode; @@ -35,13 +39,14 @@ export class DocxService { public async create(showId: string, options: DownloadOptions = {}): Promise { const data = await this.prepareData(showId); if (!data) return; + const docx = await this.loadDocx(); const {show, songs, user, config} = data; const type = new ShowTypePipe().transform(show.showType); const title = `${type} ${show.date.toDate().toLocaleDateString()}`; - const paragraphs = [...this.renderTitle(title), ...this.renderSongs(songs, options, config)]; + const paragraphs = [...this.renderTitle(docx, title), ...this.renderSongs(docx, songs, options, config)]; - const sections: ISectionOptions[] = [ + const sections: DocxSectionOptions[] = [ { properties: { page: { @@ -51,16 +56,16 @@ export class DocxService { children: paragraphs, }, ]; - const document = this.prepareNewDocument(type, user.name, options, sections); + const document = this.prepareNewDocument(docx, type, user.name, options, sections); - const blob = await Packer.toBlob(document); + const blob = await docx.Packer.toBlob(document); // saveAs from FileSaver will download the file this.saveAs(blob, `${title}.docx`); } - private prepareNewDocument(type: string, name: string, options: DownloadOptions, sections: ISectionOptions[]): Document { - return new Document({ + private prepareNewDocument(docx: DocxModule, type: string, name: string, options: DownloadOptions, sections: DocxSectionOptions[]): DocxDocument { + return new docx.Document({ creator: name, title: type, description: '... mit Beschreibung', @@ -91,32 +96,33 @@ export class DocxService { } private renderSongs( + docx: DocxModule, songs: { showSong: ShowSong; sections: Section[]; }[], options: DownloadOptions, config: Config - ): Paragraph[] { - return songs.reduce((p: Paragraph[], song) => [...p, ...this.renderSong(song.showSong, song.showSong, song.sections, options, config)], []); + ): DocxParagraph[] { + return songs.reduce((p: DocxParagraph[], song) => [...p, ...this.renderSong(docx, song.showSong, song.showSong, song.sections, options, config)], []); } - private renderSong(showSong: ShowSong, song: Song, sections: Section[], options: DownloadOptions, config: Config): Paragraph[] { - const songTitle = this.renderSongTitle(song); - const copyright = this.renderCopyright(song, options, config); - const songText = this.renderSongText(sections, options?.chordMode ?? showSong.chordMode); + private renderSong(docx: DocxModule, showSong: ShowSong, song: Song, sections: Section[], options: DownloadOptions, config: Config): DocxParagraph[] { + const songTitle = this.renderSongTitle(docx, song); + const copyright = this.renderCopyright(docx, song, options, config); + const songText = this.renderSongText(docx, sections, options?.chordMode ?? showSong.chordMode); return copyright ? [songTitle, copyright, ...songText] : [songTitle, ...songText]; } - private renderSongText(sections: Section[], chordMode: ChordMode): Paragraph[] { - return sections.reduce((p: Paragraph[], section) => [...p, ...this.renderSection(section, chordMode)], []); + private renderSongText(docx: DocxModule, sections: Section[], chordMode: ChordMode): DocxParagraph[] { + return sections.reduce((p: DocxParagraph[], section) => [...p, ...this.renderSection(docx, section, chordMode)], []); } - private renderSongTitle(song: Song): Paragraph { - return new Paragraph({ + private renderSongTitle(docx: DocxModule, song: Song): DocxParagraph { + return new docx.Paragraph({ text: song.title, - heading: HeadingLevel.HEADING_2, + heading: docx.HeadingLevel.HEADING_2, thematicBreak: true, spacing: { before: 200, @@ -124,7 +130,7 @@ export class DocxService { }); } - private renderCopyright(song: Song, options: DownloadOptions, config: Config): Paragraph | null { + private renderCopyright(docx: DocxModule, song: Song, options: DownloadOptions, config: Config): DocxParagraph | null { if (!options?.copyright) { return null; } @@ -135,13 +141,13 @@ export class DocxService { const origin = song.origin ? song.origin + ', ' : ''; const licence = song.legalOwner === 'CCLI' ? 'CCLI-Liednummer: ' + song.legalOwnerId + ', CCLI-Lizenz: ' + config.ccliLicenseId : 'CCLI-Liednummer: ' + song.legalOwnerId; - return new Paragraph({ + return new docx.Paragraph({ text: artist + label + termsOfUse + origin + licence, style: 'licence', }); } - private renderSection(section: Section, chordMode: ChordMode): Paragraph[] { + private renderSection(docx: DocxModule, section: Section, chordMode: ChordMode): DocxParagraph[] { return section.lines .filter(line => { if (line.type === LineType.text) { @@ -156,22 +162,22 @@ export class DocxService { return section.number === 0; } }) - .map((line, i) => this.renderLine(line, i === 0)); + .map((line, i) => this.renderLine(docx, line, i === 0)); } - private renderLine(line: Line, isFirstLine: boolean): Paragraph { + private renderLine(docx: DocxModule, line: Line, isFirstLine: boolean): DocxParagraph { const spacing = isFirstLine ? {before: 200} : {}; - return new Paragraph({ + return new docx.Paragraph({ text: line.text, style: 'songtext', spacing, }); } - private renderTitle(type: string): Paragraph[] { - const songTitle = new Paragraph({ + private renderTitle(docx: DocxModule, type: string): DocxParagraph[] { + const songTitle = new docx.Paragraph({ text: type, - heading: HeadingLevel.HEADING_1, + heading: docx.HeadingLevel.HEADING_1, thematicBreak: true, }); @@ -232,4 +238,8 @@ export class DocxService { document.body.removeChild(a); }, 1000); } + + private loadDocx(): Promise { + return import('docx'); + } } diff --git a/src/app/modules/shows/show/show.component.ts b/src/app/modules/shows/show/show.component.ts index 4e2785c..7c67a58 100644 --- a/src/app/modules/shows/show/show.component.ts +++ b/src/app/modules/shows/show/show.component.ts @@ -54,6 +54,7 @@ import {ReportedTypePipe} from '../../../widget-modules/pipes/reported-type-tran 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'; @Component({ selector: 'app-show', @@ -126,6 +127,7 @@ export class ShowComponent implements OnInit, OnDestroy { private clockIntervalId: ReturnType | null = null; public ngOnInit(): void { + void ensureSwiperElement(); this.currentTime = new Date(); this.clockIntervalId = setInterval(() => { this.currentTime = new Date(); diff --git a/src/app/modules/songs/services/file.service.ts b/src/app/modules/songs/services/file.service.ts index 0bc162d..1b4af0a 100644 --- a/src/app/modules/songs/services/file.service.ts +++ b/src/app/modules/songs/services/file.service.ts @@ -3,9 +3,7 @@ import {deleteObject, getDownloadURL, ref, Storage} from '@angular/fire/storage' import {from, Observable} from 'rxjs'; import {FileDataService} from './file-data.service'; -@Injectable({ - providedIn: 'root', -}) +@Injectable() export class FileService { private storage = inject(Storage); private fileDataService = inject(FileDataService); diff --git a/src/app/modules/songs/services/upload.service.ts b/src/app/modules/songs/services/upload.service.ts index 0b1950a..1934d80 100644 --- a/src/app/modules/songs/services/upload.service.ts +++ b/src/app/modules/songs/services/upload.service.ts @@ -5,9 +5,7 @@ import {ref, Storage, uploadBytesResumable} from '@angular/fire/storage'; import {FileBase} from './fileBase'; import {FileServer} from './fileServer'; -@Injectable({ - providedIn: 'root', -}) +@Injectable() export class UploadService extends FileBase { private fileDataService = inject(FileDataService); private storage = inject(Storage); diff --git a/src/app/modules/songs/song/edit/edit-file/edit-file.component.ts b/src/app/modules/songs/song/edit/edit-file/edit-file.component.ts index f1c0f4c..967a643 100644 --- a/src/app/modules/songs/song/edit/edit-file/edit-file.component.ts +++ b/src/app/modules/songs/song/edit/edit-file/edit-file.component.ts @@ -18,6 +18,7 @@ import {FileComponent} from './file/file.component'; templateUrl: './edit-file.component.html', styleUrls: ['./edit-file.component.less'], imports: [CardComponent, NgStyle, MatIconButton, MatIcon, FileComponent, AsyncPipe], + providers: [UploadService], }) export class EditFileComponent { private activatedRoute = inject(ActivatedRoute); diff --git a/src/app/modules/songs/song/edit/edit-file/file/file.component.ts b/src/app/modules/songs/song/edit/edit-file/file/file.component.ts index 6796578..431edd2 100644 --- a/src/app/modules/songs/song/edit/edit-file/file/file.component.ts +++ b/src/app/modules/songs/song/edit/edit-file/file/file.component.ts @@ -12,6 +12,7 @@ import {AsyncPipe} from '@angular/common'; templateUrl: './file.component.html', styleUrls: ['./file.component.less'], imports: [MatIconButton, FaIconComponent, AsyncPipe], + providers: [FileService], }) export class FileComponent { private fileService = inject(FileService); diff --git a/src/index.html b/src/index.html index e73a5bb..9cec517 100644 --- a/src/index.html +++ b/src/index.html @@ -9,12 +9,12 @@ - - - + + + - + diff --git a/src/main.ts b/src/main.ts index 66188d7..956862d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,15 +6,12 @@ import {bootstrapApplication, BrowserModule} from '@angular/platform-browser'; import {provideAnimations} from '@angular/platform-browser/animations'; import {AppRoutingModule} from './app/app-routing.module'; import {ServiceWorkerModule} from '@angular/service-worker'; -import {FontAwesomeModule} from '@fortawesome/angular-fontawesome'; import {AppComponent} from './app/app.component'; import {FirebaseApp, provideFirebaseApp} from '@angular/fire/app'; import {provideFirestore} from '@angular/fire/firestore'; import {provideAuth} from '@angular/fire/auth'; -import {provideStorage} from '@angular/fire/storage'; import {initializeApp} from 'firebase/app'; import {getAuth} from 'firebase/auth'; -import {getStorage} from 'firebase/storage'; import {initializeFirestore, persistentLocalCache, persistentMultipleTabManager} from 'firebase/firestore'; import {UserService} from './app/services/user/user.service'; @@ -39,8 +36,7 @@ bootstrapApplication(AppComponent, { AppRoutingModule, ServiceWorkerModule.register('ngsw-worker.js', { enabled: environment.production, - }), - FontAwesomeModule + }) ), provideFirebaseApp(() => initializeApp(environment.firebase)), provideAuth(() => getAuth(inject(FirebaseApp))), @@ -49,7 +45,6 @@ bootstrapApplication(AppComponent, { localCache: persistentLocalCache({tabManager: persistentMultipleTabManager()}), }) ), - provideStorage(() => getStorage(inject(FirebaseApp))), {provide: MAT_DATE_LOCALE, useValue: 'de-DE'}, provideAnimations(), ],