This commit is contained in:
2020-03-02 18:47:04 +01:00
committed by smuddy
parent 5b746e0db5
commit ccd91aa81c
93 changed files with 444 additions and 89 deletions

View File

@@ -0,0 +1,22 @@
export interface Song {
id: string;
comment: string;
final: boolean;
key: string;
number: number;
tempo: number;
text: string;
title: string;
type: string;
legalType: string;
legalLink: string;
legalOwner: string;
legalOwnerId: string;
legalLicenseId: string;
artist: string;
label: string;
termsOfUse: string;
origin: string;
}

View File

@@ -0,0 +1,12 @@
import {TestBed} from '@angular/core/testing';
import {FileDataService} from './file-data.service';
describe('FileDataService', () => {
beforeEach(() => TestBed.configureTestingModule({}));
it('should be created', () => {
const service: FileDataService = TestBed.get(FileDataService);
expect(service).toBeTruthy();
});
});

View File

@@ -0,0 +1,34 @@
import {Injectable} from '@angular/core';
import {SongDataService} from './song-data.service';
import {File} from './file';
import {Observable} from 'rxjs';
import {map} from 'rxjs/operators';
import {FileServer} from './fileServer';
@Injectable({
providedIn: 'root'
})
export class FileDataService {
constructor(private songDataService: SongDataService) {
}
public async put(songId: string, file: FileServer): Promise<string> {
const songRef = this.songDataService.getSongRef(songId);
const fileCollection = songRef.collection('files');
const id = await fileCollection.add(file);
return id.id;
}
public get$(songId: string): Observable<File[]> {
const songRef = this.songDataService.getSongRef(songId);
return songRef.collection<File>('files').snapshotChanges().pipe(map(actions => {
return actions.map(a => ({
...a.payload.doc.data(),
id: a.payload.doc.id
}));
}));
}
}

View File

@@ -0,0 +1,5 @@
import {FileServer} from './fileServer';
export interface File extends FileServer {
id: string;
}

View File

@@ -0,0 +1,5 @@
export class FileBase {
protected basePath = '/attachments';
protected directory = (songId: string) => `${this.basePath}/${songId}`;
}

View File

@@ -0,0 +1,5 @@
export interface FileServer {
name: string;
path: string;
createdAt: Date;
}

View File

@@ -0,0 +1,41 @@
import {async, TestBed} from '@angular/core/testing';
import {SongDataService} from './song-data.service';
import {AngularFirestore} from '@angular/fire/firestore';
import {of} from 'rxjs';
describe('SongDataService', () => {
const songs = [
{title: 'title1'}
];
const angularFirestoreCollection = {
valueChanges: () => of(songs)
};
const mockAngularFirestore = {
collection: () => angularFirestoreCollection
};
beforeEach(() => TestBed.configureTestingModule({
providers: [
{provide: AngularFirestore, useValue: mockAngularFirestore}
]
}));
it('should be created', () => {
const service: SongDataService = TestBed.get(SongDataService);
expect(service).toBeTruthy();
});
it('should list songs', async(() => {
const service: SongDataService = TestBed.get(SongDataService);
service.list().subscribe(s => {
expect(s).toEqual([
{title: 'title1'}
] as any);
}
);
}));
});

View File

@@ -0,0 +1,40 @@
import {Injectable} from '@angular/core';
import {AngularFirestore, AngularFirestoreCollection} from '@angular/fire/firestore';
import {Song} from '../models/song';
import {Observable} from 'rxjs';
import {map} from 'rxjs/operators';
import {AngularFirestoreDocument} from '@angular/fire/firestore/document/document';
@Injectable({
providedIn: 'root'
})
export class SongDataService {
private songCollection: AngularFirestoreCollection<Song>;
private readonly songs: Observable<Song[]>;
constructor(private afs: AngularFirestore) {
this.songCollection = afs.collection<Song>('songs');
this.songs = this.songCollection.snapshotChanges().pipe(map(actions => {
return actions.map(a => ({
...a.payload.doc.data(),
id: a.payload.doc.id
}));
}));
}
public list = (): Observable<Song[]> => this.songs;
public getSongRef = (songId: string): AngularFirestoreDocument<Song> => this.afs.doc<Song>('songs/' + songId);
public read(songId: string): Observable<Song | undefined> {
return this.getSongRef(songId).valueChanges().pipe(map(song => ({
...song,
id: songId
} as Song)));
}
public async update(songId: string, data: any): Promise<void> {
await this.getSongRef(songId).update(data);
}
}

View File

@@ -0,0 +1,36 @@
import {async, TestBed} from '@angular/core/testing';
import {SongService} from './song.service';
import {SongDataService} from './song-data.service';
import {of} from 'rxjs';
describe('SongService', () => {
const songs = [
{title: 'title1'}
];
const mockSongDataService = {
list: () => of(songs)
};
beforeEach(() => TestBed.configureTestingModule({
providers: [
{provide: SongDataService, useValue: mockSongDataService}
]
}));
it('should be created', () => {
const service: SongService = TestBed.get(SongService);
expect(service).toBeTruthy();
});
it('should list songs', async(() => {
const service: SongService = TestBed.get(SongService);
service.list$().subscribe(s => {
expect(s).toEqual([
{title: 'title1'}
] as any);
});
}));
});

View File

@@ -0,0 +1,61 @@
import {Injectable} from '@angular/core';
import {Observable} from 'rxjs';
import {Song} from '../models/song';
import {SongDataService} from './song-data.service';
import {tap} from 'rxjs/operators';
declare var importCCLI: any;
@Injectable({
providedIn: 'root'
})
export class SongService {
public TYPES = ['Praise', 'Worship'];
public LEGAL_OWNER = ['CCLI', 'other'];
public LEGAL_TYPE = ['open', 'allowed'];
public KEYS = [
'C', 'C#', 'Db', 'D', 'D#', 'Eb', 'E', 'F', 'F#', 'Gb', 'G', 'G#', 'Ab', 'A', 'A#', 'B', 'H',
'c', 'c#', 'db', 'd', 'd#', 'eb', 'e', 'f', 'f#', 'gb', 'g', 'g#', 'ab', 'a', 'a#', 'b', 'h'
];
private list: Song[];
constructor(private songDataService: SongDataService) {
importCCLI = (songs: Song[]) => this.updateFromCLI(songs);
}
public list$ = (): Observable<Song[]> => this.songDataService.list().pipe(tap(_ => this.list = _));
public read = (songId: string): Observable<Song | undefined> => this.songDataService.read(songId);
public async update(songId: string, data: any): Promise<void> {
await this.songDataService.update(songId, data);
}
// https://www.csvjson.com/csv2json
private async updateFromCLI(songs: Song[]) {
const mapped = songs.map(_ => ({
number: _.number,
legalType: _.legalType === 'ja' ? 'allowed' : 'open',
legalOwner: _.legalOwner === 'ja' ? 'CCLI' : 'other',
title: _.title,
legalOwnerId: _.legalOwnerId,
origin: _.origin,
artist: _.artist,
comment: _.comment
}));
const promises = this.list.map(async _ => {
// tslint:disable-next-line:triple-equals
const mappedSongs = mapped.filter(f => f.number == _.number);
if (mappedSongs.length === 1) {
const mappedSong = mappedSongs[0];
const id = _.id;
return await this.update(id, mappedSong);
}
});
await Promise.all(promises);
}
}

View File

@@ -0,0 +1,12 @@
import {TestBed} from '@angular/core/testing';
import {TextRenderingService} from './text-rendering.service';
describe('TextRenderingService', () => {
beforeEach(() => TestBed.configureTestingModule({}));
it('should be created', () => {
const service: TextRenderingService = TestBed.get(TextRenderingService);
expect(service).toBeTruthy();
});
});

View File

@@ -0,0 +1,45 @@
import {Injectable} from '@angular/core';
export enum SectionType {
Verse,
Chorus,
Bridge,
}
export enum LineType {
title,
chrod,
text,
}
export interface Line {
type: LineType;
text: string;
}
export interface Section {
type: SectionType;
number: number;
lines: Line[];
}
@Injectable({
providedIn: 'root'
})
export class TextRenderingService {
constructor() {
}
public render(text: string): Section[] {
const lines = text.match(/[^\r\n]+/g);
}
private findSection(line: string) {
}
}

View File

@@ -0,0 +1,12 @@
import {TestBed} from '@angular/core/testing';
import {UploadService} from './upload.service';
describe('UploadServiceService', () => {
beforeEach(() => TestBed.configureTestingModule({}));
it('should be created', () => {
const service: UploadService = TestBed.get(UploadService);
expect(service).toBeTruthy();
});
});

View File

@@ -0,0 +1,60 @@
import {Injectable} from '@angular/core';
import {Upload} from './upload';
import {FileDataService} from './file-data.service';
import {AngularFireStorage} from '@angular/fire/storage';
import {finalize} from 'rxjs/operators';
import {FileBase} from './fileBase';
import {FileServer} from './fileServer';
@Injectable({
providedIn: 'root'
})
export class UploadService extends FileBase {
constructor(private fileDataService: FileDataService, private angularFireStorage: AngularFireStorage) {
super();
}
public async pushUpload(songId: string, upload: Upload) {
const directory = this.directory(songId);
const filePath = `${directory}/${upload.file.name}`;
upload.path = directory;
const ref = this.angularFireStorage.ref(filePath);
const task = ref.put(upload.file);
task.percentageChanges().subscribe(percent => upload.progress = percent);
task.snapshotChanges().pipe(
finalize(() => {
this.saveFileData(songId, upload);
})
).subscribe();
// const storageRef = storage().ref();
// const uploadTask = storageRef.child(`${this.basePath}/${songId}/${file.file.name}`).put(file.file as any);
//
// uploadTask.on(storage.TaskEvent.STATE_CHANGED,
// (snapshot) => {
// file.progress = (snapshot.bytesTransferred / snapshot.totalBytes) * 100;
// },
// (error) => {
// console.log(error);
// },
// () => {
// file.url = uploadTask.snapshot.downloadURL;
// file.name = file.file.name;
// this.saveFileData(songId, file);
// }
// );
}
private async saveFileData(songId: string, upload: Upload) {
const file: FileServer = {
name: upload.file.name,
path: upload.path,
createdAt: new Date()
};
await this.fileDataService.put(songId, file);
}
}

View File

@@ -0,0 +1,13 @@
export class Upload {
$key: string;
file: Upload;
name: string;
path: string;
progress: number;
createdAt: Date = new Date();
constructor(file: Upload) {
this.file = file;
}
}

View File

@@ -0,0 +1,6 @@
<div class="list-item">
<div class="number">{{song.number}}</div>
<div>{{song.title}}</div>
<div>{{song.key}}</div>
<div>{{song.legalType | legalType}}</div>
</div>

View File

@@ -0,0 +1,25 @@
@import "../../../../../styles/styles";
.list-item {
padding: 5px 20px;
display: grid;
grid-template-columns: 50px auto 30px 100px;
& > div {
display: flex;
align-items: center;
}
cursor: pointer;
&:hover {
background: @primary-color;
color: #fff;
}
}
.number {
font-size: 18px;
font-weight: bold;
text-align: right;
}

View File

@@ -0,0 +1,25 @@
import {async, ComponentFixture, TestBed} from '@angular/core/testing';
import {ListItemComponent} from './list-item.component';
describe('ListItemComponent', () => {
let component: ListItemComponent;
let fixture: ComponentFixture<ListItemComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ListItemComponent]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(ListItemComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,18 @@
import {Component, Input, OnInit} from '@angular/core';
import {Song} from '../../models/song';
@Component({
selector: 'app-list-item',
templateUrl: './list-item.component.html',
styleUrls: ['./list-item.component.less']
})
export class ListItemComponent implements OnInit {
@Input() public song: Song;
constructor() {
}
ngOnInit() {
}
}

View File

@@ -0,0 +1,3 @@
<app-card *ngIf="songs$ | async as songs" [@fade] [padding]="false">
<app-list-item *ngFor="let song of songs" [routerLink]="song.id" [song]="song"></app-list-item>
</app-card>

View File

@@ -0,0 +1,2 @@
.song-list {
}

View File

@@ -0,0 +1,47 @@
import {async, ComponentFixture, fakeAsync, TestBed, tick} from '@angular/core/testing';
import {SongListComponent} from './song-list.component';
import {of} from 'rxjs';
import {SongService} from '../services/song.service';
import {NO_ERRORS_SCHEMA} from '@angular/core';
describe('SongListComponent', () => {
let component: SongListComponent;
let fixture: ComponentFixture<SongListComponent>;
const songs = [
{title: 'title1'}
];
const mockSongService = {
list: () => of(songs)
};
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [SongListComponent],
providers: [
{provide: SongService, useValue: mockSongService}
],
schemas: [NO_ERRORS_SCHEMA]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(SongListComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should read songs from SongService', fakeAsync(() => {
tick();
expect(component.songs$).toEqual([
{title: 'title1'}
] as any);
}));
});

View File

@@ -0,0 +1,51 @@
import {Component, OnInit} from '@angular/core';
import {SongService} from '../services/song.service';
import {Song} from '../models/song';
import {debounceTime, map} from 'rxjs/operators';
import {combineLatest, Observable} from 'rxjs';
import {fade} from '../../../animations';
import {ActivatedRoute} from '@angular/router';
@Component({
selector: 'app-songs',
templateUrl: './song-list.component.html',
styleUrls: ['./song-list.component.less'],
animations: [fade]
})
export class SongListComponent implements OnInit {
public songs$: Observable<Song[]>;
constructor(private songService: SongService, private activatedRoute: ActivatedRoute) {
}
private static filter(song: Song, filterValue: string): boolean {
if (!filterValue) {
return true;
}
const textMatch = song.text && SongListComponent.normalize(song.text).indexOf(SongListComponent.normalize(filterValue)) !== -1;
const titleMatch = song.title && SongListComponent.normalize(song.title).indexOf(SongListComponent.normalize(filterValue)) !== -1;
return textMatch || titleMatch;
}
private static normalize(input: string): string {
return input.toLowerCase().replace(/\s/g, '');
}
ngOnInit() {
const filter$ = this.activatedRoute.queryParams.pipe(
debounceTime(300),
map(_ => _.q)
);
const songs$ = this.songService.list$().pipe(
map(songs => songs.sort((a, b) => a.number - b.number)),
);
this.songs$ = combineLatest([filter$, songs$]).pipe(
map(_ => _[1].filter(song => SongListComponent.filter(song, _[0])))
);
}
}

View File

@@ -0,0 +1,22 @@
import {NgModule} from '@angular/core';
import {CommonModule} from '@angular/common';
import {SongListComponent} from './song-list.component';
import {ListItemComponent} from './list-item/list-item.component';
import {CardModule} from '../../../widget-modules/components/card/card.module';
import {RouterModule} from '@angular/router';
import {LegalTypeTranslatorModule} from '../../../widget-modules/pipes/legal-type-translator/legal-type-translator.module';
@NgModule({
declarations: [SongListComponent, ListItemComponent],
exports: [SongListComponent],
imports: [
CommonModule,
RouterModule,
CardModule,
LegalTypeTranslatorModule
]
})
export class SongListModule {
}

View File

@@ -0,0 +1,22 @@
<app-card heading="Angehängte Dateien">
<div *ngIf="currentUpload">
<div class="progress">
<div [ngStyle]="{ 'width': currentUpload?.progress + '%' }" class="progress-bar progress-bar-animated"></div>
</div>
Progress: {{currentUpload?.name}} | {{currentUpload?.progress}}% Complete
</div>
<div class="upload">
<label>
<input (change)="detectFiles($event)" type="file">
</label>
<button (click)="uploadSingle()"
[disabled]="!selectedFiles"
mat-icon-button>
<mat-icon>cloud_upload</mat-icon>
</button>
</div>
</app-card>

View File

@@ -0,0 +1,5 @@
.upload {
display: flex;
justify-content: space-between;
align-items: center;
}

View File

@@ -0,0 +1,25 @@
import {async, ComponentFixture, TestBed} from '@angular/core/testing';
import {EditFileComponent} from './edit-file.component';
describe('EditFileComponent', () => {
let component: EditFileComponent;
let fixture: ComponentFixture<EditFileComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [EditFileComponent]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(EditFileComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,36 @@
import {Component} from '@angular/core';
import {Upload} from '../../../services/upload';
import {UploadService} from '../../../services/upload.service';
import {ActivatedRoute} from '@angular/router';
import {map} from 'rxjs/operators';
@Component({
selector: 'app-edit-file',
templateUrl: './edit-file.component.html',
styleUrls: ['./edit-file.component.less']
})
export class EditFileComponent {
public selectedFiles: FileList;
public currentUpload: Upload;
public songId: string;
constructor(private activatedRoute: ActivatedRoute, private uploadService: UploadService) {
this.activatedRoute.params.pipe(
map(param => param.songId),
).subscribe(songId => {
this.songId = songId;
});
}
detectFiles(event) {
this.selectedFiles = event.target.files;
}
public async uploadSingle() {
const file = this.selectedFiles.item(0);
this.currentUpload = new Upload(file as any);
await this.uploadService.pushUpload(this.songId, this.currentUpload);
}
}

View File

@@ -0,0 +1 @@
<p>file works!</p>

View File

@@ -0,0 +1,25 @@
import {async, ComponentFixture, TestBed} from '@angular/core/testing';
import {FileComponent} from './file.component';
describe('FileComponent', () => {
let component: FileComponent;
let fixture: ComponentFixture<FileComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [FileComponent]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(FileComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,16 @@
import {Component, OnInit} from '@angular/core';
@Component({
selector: 'app-file',
templateUrl: './file.component.html',
styleUrls: ['./file.component.less']
})
export class FileComponent implements OnInit {
constructor() {
}
ngOnInit() {
}
}

View File

@@ -0,0 +1,95 @@
<app-card *ngIf="song" [heading]="song.number + ' bearbeiten'">
<form [formGroup]="form" class="form">
<mat-form-field appearance="outline">
<mat-label>Titel</mat-label>
<input formControlName="title" matInput>
</mat-form-field>
<div class="third">
<mat-form-field appearance="outline">
<mat-label>Typ</mat-label>
<mat-select formControlName="type">
<mat-option *ngFor="let type of types" [value]="type">{{type | songType}}</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Tonart</mat-label>
<mat-select formControlName="key">
<mat-option *ngFor="let key of keys" [value]="key">{{key}}</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Tempo</mat-label>
<input formControlName="tempo" matInput>
</mat-form-field>
</div>
<mat-form-field appearance="outline">
<mat-label>Songtext</mat-label>
<textarea [mat-autosize]="true" formControlName="text" matInput></textarea>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Kommentar</mat-label>
<textarea [mat-autosize]="true" formControlName="comment" matInput></textarea>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Rechtlicher Status</mat-label>
<mat-select formControlName="legalType">
<mat-option *ngFor="let key of legalType" [value]="key">{{key|legalType}}</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Rechteinhaber</mat-label>
<mat-select formControlName="legalOwner">
<mat-option *ngFor="let key of legalOwner" [value]="key">{{key|legalOwner}}</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Rechteinhaber Link</mat-label>
<input formControlName="legalLink" matInput>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Rechteinhaber ID (z.B. CCLI Liednummer)</mat-label>
<input formControlName="legalOwnerId" matInput>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Lizenznummer</mat-label>
<input formControlName="legalLicenseId" matInput>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Künstler</mat-label>
<input formControlName="artist" matInput>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Verlag</mat-label>
<input formControlName="label" matInput>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Nutzungsbedingungen</mat-label>
<input formControlName="termsOfUse" matInput>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>abweichende Quelle</mat-label>
<input formControlName="origin" matInput>
</mat-form-field>
</form>
<app-button-row>
<button (click)="onSave()" color="primary" mat-flat-button>Speichern</button>
<button mat-stroked-button routerLink="../">Abbrechen</button>
</app-button-row>
</app-card>

View File

@@ -0,0 +1,18 @@
.form {
margin-top: 20px;
width: 100%;
> * {
width: 100%;
}
.third {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
column-gap: 20px;
}
textarea {
font-family: 'Ubuntu Mono', monospace;
}
}

View File

@@ -0,0 +1,25 @@
import {async, ComponentFixture, TestBed} from '@angular/core/testing';
import {EditSongComponent} from './edit-song.component';
describe('EditSongComponent', () => {
let component: EditSongComponent;
let fixture: ComponentFixture<EditSongComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [EditSongComponent]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(EditSongComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,47 @@
import {Component, OnInit} from '@angular/core';
import {Song} from '../../../models/song';
import {FormGroup} from '@angular/forms';
import {ActivatedRoute, Router} from '@angular/router';
import {SongService} from '../../../services/song.service';
import {EditService} from '../edit.service';
import {first, map, switchMap} from 'rxjs/operators';
@Component({
selector: 'app-edit-song',
templateUrl: './edit-song.component.html',
styleUrls: ['./edit-song.component.less']
})
export class EditSongComponent implements OnInit {
public song: Song;
public form: FormGroup;
public keys = this.songService.KEYS;
public types = this.songService.TYPES;
public legalOwner = this.songService.LEGAL_OWNER;
public legalType = this.songService.LEGAL_TYPE;
constructor(
private activatedRoute: ActivatedRoute,
private songService: SongService,
private editService: EditService,
private router: Router
) {
}
public ngOnInit(): void {
this.activatedRoute.params.pipe(
map(param => param.songId),
switchMap(songId => this.songService.read(songId)),
first()
).subscribe(song => {
this.song = song;
this.form = this.editService.createSongForm(song);
});
}
public async onSave(): Promise<void> {
const data = this.form.value;
await this.songService.update(this.song.id, data);
await this.router.navigateByUrl('songs/' + this.song.id);
}
}

View File

@@ -0,0 +1,2 @@
<app-edit-song></app-edit-song>
<app-edit-file></app-edit-file>

View File

@@ -0,0 +1,25 @@
import {async, ComponentFixture, TestBed} from '@angular/core/testing';
import {EditComponent} from './edit.component';
describe('EditComponent', () => {
let component: EditComponent;
let fixture: ComponentFixture<EditComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [EditComponent]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(EditComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,10 @@
import {Component} from '@angular/core';
@Component({
selector: 'app-edit',
templateUrl: './edit.component.html',
styleUrls: ['./edit.component.less']
})
export class EditComponent {
}

View File

@@ -0,0 +1,44 @@
import {NgModule} from '@angular/core';
import {CommonModule} from '@angular/common';
import {EditComponent} from './edit.component';
import {CardModule} from '../../../../widget-modules/components/card/card.module';
import {SongTypeTranslaterModule} from '../../../../widget-modules/pipes/song-type-translater/song-type-translater.module';
import {ReactiveFormsModule} from '@angular/forms';
import {MatInputModule} from '@angular/material/input';
import {MatCheckboxModule} from '@angular/material/checkbox';
import {MatSelectModule} from '@angular/material/select';
import {MatButtonModule} from '@angular/material/button';
import {ButtonRowModule} from '../../../../widget-modules/components/button-row/button-row.module';
import {RouterModule} from '@angular/router';
import {EditSongComponent} from './edit-song/edit-song.component';
import {EditFileComponent} from './edit-file/edit-file.component';
import {MatIconModule} from '@angular/material/icon';
import {FileComponent} from './edit-file/file/file.component';
import {LegalOwnerTranslatorModule} from '../../../../widget-modules/pipes/legal-owner-translator/legal-owner-translator.module';
import {LegalTypeTranslatorModule} from '../../../../widget-modules/pipes/legal-type-translator/legal-type-translator.module';
@NgModule({
declarations: [EditComponent, EditSongComponent, EditFileComponent, FileComponent],
exports: [EditComponent],
imports: [
CommonModule,
CardModule,
SongTypeTranslaterModule,
ReactiveFormsModule,
RouterModule,
MatInputModule,
MatButtonModule,
MatCheckboxModule,
MatSelectModule,
ButtonRowModule,
MatIconModule,
LegalOwnerTranslatorModule,
LegalTypeTranslatorModule,
]
})
export class EditModule {
}

View File

@@ -0,0 +1,12 @@
import {TestBed} from '@angular/core/testing';
import {EditService} from './edit.service';
describe('EditService', () => {
beforeEach(() => TestBed.configureTestingModule({}));
it('should be created', () => {
const service: EditService = TestBed.get(EditService);
expect(service).toBeTruthy();
});
});

View File

@@ -0,0 +1,34 @@
import {Injectable} from '@angular/core';
import {Song} from '../../models/song';
import {FormControl, FormGroup} from '@angular/forms';
@Injectable({
providedIn: 'root'
})
export class EditService {
constructor() {
}
public createSongForm(song: Song): FormGroup {
return new FormGroup({
text: new FormControl(song.text),
title: new FormControl(song.title),
comment: new FormControl(song.comment),
key: new FormControl(song.key),
tempo: new FormControl(song.tempo),
type: new FormControl(song.type),
legalType: new FormControl(song.legalType),
legalLink: new FormControl(song.legalLink),
legalOwner: new FormControl(song.legalOwner),
legalOwnerId: new FormControl(song.legalOwnerId),
legalLicenseId: new FormControl(song.legalLicenseId),
artist: new FormControl(song.artist),
label: new FormControl(song.label),
termsOfUse: new FormControl(song.termsOfUse),
origin: new FormControl(song.origin),
});
}
}

View File

@@ -0,0 +1,29 @@
<app-card *ngIf="song$ | async as song" [heading]="song.number + ' - ' + song.title">
<div class="song">
<div>
<div class="detail">
<div>Typ: {{song.type | songType}}</div>
<div>Tonart: {{song.key}}</div>
<div>Tempo: {{song.tempo}}</div>
<div *ngIf="song.legalOwner">Rechteinhaber: {{song.legalOwner|legalOwner}}</div>
<div *ngIf="song.legalOwnerId">Rechteinhaber ID: {{song.legalOwnerId}}</div>
<div *ngIf="song.legalLicenseId">Lizenznummer: {{song.legalLicenseId}}</div>
<div *ngIf="song.artist">Künstler: {{song.artist}}</div>
<div *ngIf="song.label">Verlag: {{song.label}}</div>
<div *ngIf="song.origin">Quelle: {{song.origin}}</div>
</div>
</div>
<div class="text">{{song.text}}</div>
<div class="text">{{song.comment}}</div>
<div>
<h3>Anhänge</h3>
<div *ngFor="let file of (files$|async)">{{file.name}}</div>
</div>
</div>
<app-button-row>
<button color="primary" mat-flat-button routerLink="edit">Bearbeiten</button>
</app-button-row>
</app-card>

View File

@@ -0,0 +1,17 @@
.song {
display: grid;
grid-template-columns: 1fr;
margin-top: 20px;
grid-gap: 20px;
}
.text {
white-space: pre-wrap;
font-family: 'Ubuntu Mono', monospace;
}
.detail {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
}

View File

@@ -0,0 +1,39 @@
import {async, ComponentFixture, fakeAsync, TestBed, tick} from '@angular/core/testing';
import {SongComponent} from './song.component';
import {of} from 'rxjs';
import {ActivatedRoute} from '@angular/router';
describe('SongComponent', () => {
let component: SongComponent;
let fixture: ComponentFixture<SongComponent>;
const mockActivatedRoute = {
params: of({songId: '4711'})
};
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [SongComponent],
providers: [
{provide: ActivatedRoute, useValue: mockActivatedRoute}
]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(SongComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should provide songId', fakeAsync(() => {
tick();
expect(component.songId).toBe('4711');
}));
});

View File

@@ -0,0 +1,39 @@
import {Component, OnInit} from '@angular/core';
import {ActivatedRoute} from '@angular/router';
import {SongService} from '../services/song.service';
import {map, switchMap} from 'rxjs/operators';
import {Song} from '../models/song';
import {Observable} from 'rxjs';
import {FileDataService} from '../services/file-data.service';
import {File} from '../services/file';
@Component({
selector: 'app-song',
templateUrl: './song.component.html',
styleUrls: ['./song.component.less']
})
export class SongComponent implements OnInit {
public song$: Observable<Song>;
public files$: Observable<File[]>;
constructor(
private activatedRoute: ActivatedRoute,
private songService: SongService,
private fileService: FileDataService,
) {
}
public ngOnInit(): void {
this.song$ = this.activatedRoute.params.pipe(
map(param => param.songId),
switchMap(songId => this.songService.read(songId))
);
this.files$ = this.activatedRoute.params.pipe(
map(param => param.songId),
switchMap(songId => this.fileService.get$(songId))
);
}
}

View File

@@ -0,0 +1,27 @@
import {NgModule} from '@angular/core';
import {CommonModule} from '@angular/common';
import {SongComponent} from './song.component';
import {CardModule} from '../../../widget-modules/components/card/card.module';
import {SongTypeTranslaterModule} from '../../../widget-modules/pipes/song-type-translater/song-type-translater.module';
import {MatButtonModule} from '@angular/material/button';
import {ButtonRowModule} from '../../../widget-modules/components/button-row/button-row.module';
import {RouterModule} from '@angular/router';
import {LegalOwnerTranslatorModule} from '../../../widget-modules/pipes/legal-owner-translator/legal-owner-translator.module';
@NgModule({
declarations: [SongComponent],
exports: [SongComponent],
imports: [
CommonModule,
CardModule,
RouterModule,
SongTypeTranslaterModule,
MatButtonModule,
ButtonRowModule,
LegalOwnerTranslatorModule,
]
})
export class SongModule {
}

View File

@@ -0,0 +1,29 @@
import {NgModule} from '@angular/core';
import {RouterModule, Routes} from '@angular/router';
import {SongComponent} from './song/song.component';
import {SongListComponent} from './song-list/song-list.component';
import {EditComponent} from './song/edit/edit.component';
const routes: Routes = [
{
path: '',
component: SongListComponent,
pathMatch: 'full'
},
{
path: ':songId/edit',
component: EditComponent
},
{
path: ':songId',
component: SongComponent
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class SongsRoutingModule {
}

View File

@@ -0,0 +1,20 @@
import {NgModule} from '@angular/core';
import {CommonModule} from '@angular/common';
import {SongsRoutingModule} from './songs-routing.module';
import {SongListModule} from './song-list/song-list.module';
import {SongModule} from './song/song.module';
import {EditModule} from './song/edit/edit.module';
@NgModule({
declarations: [],
imports: [
CommonModule,
SongsRoutingModule,
SongListModule,
SongModule,
EditModule
]
})
export class SongsModule {
}

View File

@@ -0,0 +1,7 @@
<app-card *ngIf="user$|async as user">
<h2>Hallo {{user.name}}</h2>
<p>{{user.role|role}}</p>
<app-button-row>
<button mat-button routerLink="../logout">Abmelden</button>
</app-button-row>
</app-card>

View File

@@ -0,0 +1,25 @@
import {async, ComponentFixture, TestBed} from '@angular/core/testing';
import {InfoComponent} from './info.component';
describe('InfoComponent', () => {
let component: InfoComponent;
let fixture: ComponentFixture<InfoComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [InfoComponent]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(InfoComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,21 @@
import {Component, OnInit} from '@angular/core';
import {UserService} from '../../../services/user.service';
import {Observable} from 'rxjs';
import {User} from '../../../services/user';
@Component({
selector: 'app-info',
templateUrl: './info.component.html',
styleUrls: ['./info.component.less']
})
export class InfoComponent implements OnInit {
public user$: Observable<User>;
constructor(private userService: UserService) {
}
ngOnInit() {
this.user$ = this.userService.user$;
}
}

View File

@@ -0,0 +1,8 @@
import {RolePipe} from './role.pipe';
describe('RolePipe', () => {
it('create an instance', () => {
const pipe = new RolePipe();
expect(pipe).toBeTruthy();
});
});

View File

@@ -0,0 +1,15 @@
import {Pipe, PipeTransform} from '@angular/core';
@Pipe({
name: 'role'
})
export class RolePipe implements PipeTransform {
transform(role: 'admin'): string {
switch (role) {
case 'admin':
return 'Administrator';
}
}
}

View File

@@ -0,0 +1,8 @@
import {AuthMessagePipe} from './auth-message.pipe';
describe('AuthMessagePipe', () => {
it('create an instance', () => {
const pipe = new AuthMessagePipe();
expect(pipe).toBeTruthy();
});
});

View File

@@ -0,0 +1,19 @@
import {Pipe, PipeTransform} from '@angular/core';
@Pipe({
name: 'authMessage'
})
export class AuthMessagePipe implements PipeTransform {
transform(code: string): string {
switch (code) {
case 'auth/user-not-found':
return 'Benutzer wurde nicht gefunden';
case 'auth/wrong-password':
return 'Passwort ist falsch';
default :
return code;
}
}
}

View File

@@ -0,0 +1,19 @@
<app-card>
<div [formGroup]="form" class="form">
<mat-form-field appearance="outline">
<mat-label>E-Mail Addresse</mat-label>
<input (keyup.enter)="onLogin()" formControlName="user" matInput>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Passwort</mat-label>
<input (keyup.enter)="onLogin()" formControlName="pass" matInput type="password">
</mat-form-field>
<app-button-row>
<button (click)="onLogin()" mat-button>Anmelden</button>
<p *ngIf="errorMessage" class="error">{{errorMessage|authMessage}}</p>
</app-button-row>
</div>
</app-card>

View File

@@ -0,0 +1,8 @@
.form {
display: grid;
}
p.error {
margin: 8px 10px;
color: darkred;
}

View File

@@ -0,0 +1,25 @@
import {async, ComponentFixture, TestBed} from '@angular/core/testing';
import {LoginComponent} from './login.component';
describe('LoginComponent', () => {
let component: LoginComponent;
let fixture: ComponentFixture<LoginComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [LoginComponent]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(LoginComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,36 @@
import {Component, OnInit} from '@angular/core';
import {FormControl, FormGroup, Validators} from '@angular/forms';
import {AngularFireAuth} from '@angular/fire/auth';
import {Router} from '@angular/router';
@Component({
selector: 'app-login',
templateUrl: './login.component.html',
styleUrls: ['./login.component.less']
})
export class LoginComponent implements OnInit {
public form: FormGroup;
public errorMessage: string;
constructor(public afAuth: AngularFireAuth, private router: Router) {
}
ngOnInit() {
this.form = new FormGroup({
user: new FormControl(null, [Validators.required, Validators.email]),
pass: new FormControl(null, [Validators.required]),
});
}
public async onLogin() {
this.form.updateValueAndValidity();
if (this.form.valid) {
try {
await this.afAuth.auth.signInWithEmailAndPassword(this.form.value.user, this.form.value.pass);
await this.router.navigateByUrl('/');
} catch (ex) {
this.errorMessage = ex.code;
}
}
}
}

View File

@@ -0,0 +1 @@
<p>logout works!</p>

View File

@@ -0,0 +1,25 @@
import {async, ComponentFixture, TestBed} from '@angular/core/testing';
import {LogoutComponent} from './logout.component';
describe('LogoutComponent', () => {
let component: LogoutComponent;
let fixture: ComponentFixture<LogoutComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [LogoutComponent]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(LogoutComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,18 @@
import {AfterViewInit, Component} from '@angular/core';
import {AngularFireAuth} from '@angular/fire/auth';
import {Router} from '@angular/router';
@Component({
selector: 'app-logout',
templateUrl: './logout.component.html',
styleUrls: ['./logout.component.less']
})
export class LogoutComponent implements AfterViewInit {
constructor(public afAuth: AngularFireAuth, private router: Router) {
}
public async ngAfterViewInit() {
await this.afAuth.auth.signOut();
await this.router.navigateByUrl('/');
}
}

View File

@@ -0,0 +1,36 @@
import {NgModule} from '@angular/core';
import {RouterModule, Routes} from '@angular/router';
import {LoginComponent} from './login/login.component';
import {InfoComponent} from './info/info.component';
import {LogoutComponent} from './logout/logout.component';
import {AngularFireAuthGuard, redirectUnauthorizedTo} from '@angular/fire/auth-guard';
const routes: Routes = [
{
path: '',
redirectTo: 'info',
pathMatch: 'full'
},
{
path: 'login',
component: LoginComponent
},
{
path: 'logout',
component: LogoutComponent
},
{
path: 'info',
component: InfoComponent,
canActivate: [AngularFireAuthGuard],
data: {authGuardPipe: () => redirectUnauthorizedTo(['user', 'login'])}
},
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class UserRoutingModule {
}

View File

@@ -0,0 +1,33 @@
import {NgModule} from '@angular/core';
import {CommonModule} from '@angular/common';
import {LoginComponent} from './login/login.component';
import {UserRoutingModule} from './user-routing.module';
import {CardModule} from '../../widget-modules/components/card/card.module';
import {MatFormFieldModule} from '@angular/material/form-field';
import {ReactiveFormsModule} from '@angular/forms';
import {MatInputModule} from '@angular/material/input';
import {ButtonRowModule} from '../../widget-modules/components/button-row/button-row.module';
import {MatButtonModule} from '@angular/material/button';
import {AuthMessagePipe} from './login/auth-message.pipe';
import {InfoComponent} from './info/info.component';
import {LogoutComponent} from './logout/logout.component';
import {RolePipe} from './info/role.pipe';
@NgModule({
declarations: [LoginComponent, AuthMessagePipe, InfoComponent, LogoutComponent, RolePipe],
imports: [
CommonModule,
UserRoutingModule,
CardModule,
MatFormFieldModule,
ReactiveFormsModule,
MatInputModule,
ButtonRowModule,
MatButtonModule,
]
})
export class UserModule {
}