diff --git a/client/package.json b/client/package.json index bfa291943..980fc2fb4 100644 --- a/client/package.json +++ b/client/package.json @@ -33,6 +33,7 @@ "core-js": "^2.5.4", "file-saver": "^2.0.0-rc.3", "material-design-icons": "^3.0.1", + "ngx-file-drop": "^5.0.0", "ngx-mat-select-search": "^1.4.0", "po2json": "^1.0.0-alpha", "roboto-fontface": "^0.10.0", diff --git a/client/src/app/core/core.module.ts b/client/src/app/core/core.module.ts index 7672353c9..0691b51a2 100644 --- a/client/src/app/core/core.module.ts +++ b/client/src/app/core/core.module.ts @@ -1,7 +1,6 @@ import { NgModule, Optional, SkipSelf } from '@angular/core'; import { CommonModule } from '@angular/common'; import { Title } from '@angular/platform-browser'; -import { HTTP_INTERCEPTORS } from '@angular/common/http'; // Core Services, Directives import { AuthGuard } from './services/auth-guard.service'; @@ -10,7 +9,6 @@ import { AutoupdateService } from './services/autoupdate.service'; import { DataStoreService } from './services/data-store.service'; import { OperatorService } from './services/operator.service'; import { WebsocketService } from './services/websocket.service'; -import { AddHeaderInterceptor } from './http-interceptor'; import { DataSendService } from './services/data-send.service'; import { ViewportService } from './services/viewport.service'; import { PromptDialogComponent } from '../shared/components/prompt-dialog/prompt-dialog.component'; @@ -31,12 +29,7 @@ import { HttpService } from './services/http.service'; HttpService, OperatorService, ViewportService, - WebsocketService, - { - provide: HTTP_INTERCEPTORS, - useClass: AddHeaderInterceptor, - multi: true - } + WebsocketService ], entryComponents: [PromptDialogComponent] }) diff --git a/client/src/app/core/http-interceptor.ts b/client/src/app/core/http-interceptor.ts deleted file mode 100644 index 7c4e80f26..000000000 --- a/client/src/app/core/http-interceptor.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { HttpEvent, HttpInterceptor, HttpHandler, HttpRequest } from '@angular/common/http'; -import { Observable } from 'rxjs'; - -/** - * Interceptor class for HTTP requests. Replaces all 'httpOptions' in all http.get or http.post requests. - * - * Should not need further adjustment. - */ -export class AddHeaderInterceptor implements HttpInterceptor { - /** - * Normal HttpInterceptor usage - * - * @param req Will clone the request and intercept it with our desired headers - * @param next HttpHandler will catch the response and forwards it to the original instance - */ - public intercept(req: HttpRequest, next: HttpHandler): Observable> { - const clonedRequest = req.clone({ - withCredentials: true, - headers: req.headers.set('Content-Type', 'application/json') - }); - - return next.handle(clonedRequest); - } -} diff --git a/client/src/app/core/services/http.service.ts b/client/src/app/core/services/http.service.ts index 2c9f22e29..ce3711585 100644 --- a/client/src/app/core/services/http.service.ts +++ b/client/src/app/core/services/http.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@angular/core'; -import { HttpClient, HttpErrorResponse } from '@angular/common/http'; +import { HttpClient, HttpErrorResponse, HttpHeaders } from '@angular/common/http'; import { TranslateService } from '@ngx-translate/core'; /** @@ -20,13 +20,22 @@ export enum HTTPMethod { providedIn: 'root' }) export class HttpService { + /** + * http headers used by most requests + */ + private defaultHeaders: HttpHeaders; + /** * Construct a HttpService * + * Sets the default headers to application/json + * * @param http The HTTP Client * @param translate */ - public constructor(private http: HttpClient, private translate: TranslateService) {} + public constructor(private http: HttpClient, private translate: TranslateService) { + this.defaultHeaders = new HttpHeaders().set('Content-Type', 'application/json') + } /** * Send the a http request the the given URL. @@ -35,14 +44,17 @@ export class HttpService { * @param url the target url, usually starting with /rest * @param method the required HTTP method (i.e get, post, put) * @param data optional, if sending a data body is required + * @param customHeader optional custom HTTP header of required + * @returns a promise containing a generic */ - private async send(url: string, method: HTTPMethod, data?: any): Promise { + private async send(url: string, method: HTTPMethod, data?: any, customHeader?: HttpHeaders): Promise { if (!url.endsWith('/')) { url += '/'; } const options = { - body: data + body: data, + headers: customHeader ? customHeader : this.defaultHeaders }; try { @@ -96,6 +108,7 @@ export class HttpService { * Errors from the servers may be string or array of strings. This function joins the strings together, * if an array is send. * @param str a string or a string array to join together. + * @returns Error text(s) as single string */ private processErrorTexts(str: string | string[]): string { if (str instanceof Array) { @@ -109,44 +122,54 @@ export class HttpService { * Exectures a get on a url with a certain object * @param url The url to send the request to. * @param data An optional payload for the request. + * @param header optional HTTP header if required + * @returns A promise holding a generic */ - public async get(url: string, data?: any): Promise { - return await this.send(url, HTTPMethod.GET, data); + public async get(url: string, data?: any, header?: HttpHeaders): Promise { + return await this.send(url, HTTPMethod.GET, data, header); } /** * Exectures a post on a url with a certain object * @param url string of the url to send semothing to * @param data The data to send + * @param header optional HTTP header if required + * @returns A promise holding a generic */ - public async post(url: string, data: any): Promise { - return await this.send(url, HTTPMethod.POST, data); + public async post(url: string, data: any, header?: HttpHeaders): Promise { + return await this.send(url, HTTPMethod.POST, data, header); } /** * Exectures a put on a url with a certain object * @param url string of the url to send semothing to * @param data the object that should be send + * @param header optional HTTP header if required + * @returns A promise holding a generic */ - public async patch(url: string, data: any): Promise { - return await this.send(url, HTTPMethod.PATCH, data); + public async patch(url: string, data: any, header?: HttpHeaders): Promise { + return await this.send(url, HTTPMethod.PATCH, data, header); } /** * Exectures a put on a url with a certain object * @param url the url that should be called * @param data: The data to send + * @param header optional HTTP header if required + * @returns A promise holding a generic */ - public async put(url: string, data: any): Promise { - return await this.send(url, HTTPMethod.PUT, data); + public async put(url: string, data: any, header?: HttpHeaders): Promise { + return await this.send(url, HTTPMethod.PUT, data, header); } /** * Makes a delete request. * @param url the url that should be called * @param data An optional data to send in the requestbody. + * @param header optional HTTP header if required + * @returns A promise holding a generic */ - public async delete(url: string, data?: any): Promise { - return await this.send(url, HTTPMethod.DELETE, data); + public async delete(url: string, data?: any, header?: HttpHeaders): Promise { + return await this.send(url, HTTPMethod.DELETE, data, header); } } diff --git a/client/src/app/shared/shared.module.ts b/client/src/app/shared/shared.module.ts index b02acab96..9496526ef 100644 --- a/client/src/app/shared/shared.module.ts +++ b/client/src/app/shared/shared.module.ts @@ -10,6 +10,7 @@ import { MatToolbarModule, MatCardModule, MatInputModule, + MatProgressBarModule, MatProgressSpinnerModule, MatSidenavModule, MatSnackBarModule, @@ -22,7 +23,7 @@ import { DateAdapter, MatIconModule, MatButtonToggleModule, - MatBadgeModule + MatBadgeModule, } from '@angular/material'; import { MatAutocompleteModule } from '@angular/material/autocomplete'; import { MatChipsModule } from '@angular/material'; @@ -39,6 +40,9 @@ import { DragDropModule } from '@angular/cdk/drag-drop'; // ngx-translate import { TranslateModule } from '@ngx-translate/core'; +// ngx-file-drop +import { FileDropModule } from 'ngx-file-drop'; + // directives import { PermsDirective } from './directives/perms.directive'; import { DomChangeDirective } from './directives/dom-change.directive'; @@ -82,6 +86,7 @@ import { SpeakerListComponent } from 'app/site/agenda/components/speaker-list/sp MatTableModule, MatSortModule, MatPaginatorModule, + MatProgressBarModule, MatProgressSpinnerModule, MatSidenavModule, MatListModule, @@ -101,7 +106,8 @@ import { SpeakerListComponent } from 'app/site/agenda/components/speaker-list/sp DragDropModule, TranslateModule.forChild(), RouterModule, - NgxMatSelectSearchModule + NgxMatSelectSearchModule, + FileDropModule ], exports: [ FormsModule, @@ -118,6 +124,7 @@ import { SpeakerListComponent } from 'app/site/agenda/components/speaker-list/sp MatTableModule, MatSortModule, MatPaginatorModule, + MatProgressBarModule, MatProgressSpinnerModule, MatSidenavModule, MatListModule, @@ -133,6 +140,7 @@ import { SpeakerListComponent } from 'app/site/agenda/components/speaker-list/sp MatButtonToggleModule, DragDropModule, NgxMatSelectSearchModule, + FileDropModule, TranslateModule, PermsDirective, DomChangeDirective, diff --git a/client/src/app/site/mediafiles/components/media-upload/media-upload.component.html b/client/src/app/site/mediafiles/components/media-upload/media-upload.component.html new file mode 100644 index 000000000..535f79368 --- /dev/null +++ b/client/src/app/site/mediafiles/components/media-upload/media-upload.component.html @@ -0,0 +1,105 @@ + + +

Upload files

+ + + +
+ + +
+ + +
+ + Drop files into this area OR select files + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Title + + + + File name {{ file.filename }} File information +
+ + insert_drive_file {{ file.mediafile.type }} + + + data_usage + {{ getReadableSize(file.mediafile.size) }} + +
+
Hidden + + Remove + +
+
+
+ + +
+ + +
+
+ + + + + + + + + + + diff --git a/client/src/app/site/mediafiles/components/media-upload/media-upload.component.scss b/client/src/app/site/mediafiles/components/media-upload/media-upload.component.scss new file mode 100644 index 000000000..2ebb80aad --- /dev/null +++ b/client/src/app/site/mediafiles/components/media-upload/media-upload.component.scss @@ -0,0 +1,62 @@ +.table-container { + overflow: auto; + + table { + width: 100%; + + /** Title */ + .mat-column-title { + min-width: 100px; + + .mat-form-field { + width: 95%; + } + } + + /** Filename */ + .mat-column-filename { + min-width: 100px; + } + + /** Information */ + .mat-column-information { + min-width: 100px; + } + + /** Hidden */ + .mat-column-hidden { + min-width: 100px; + } + + /** remove */ + .mat-column-remove { + min-width: 100px; + } + + .file-info-cell { + display: grid; + margin: 0; + + span { + .mat-icon { + font-size: 130%; + } + } + } + } +} + +.selection-area { + margin-bottom: 1em; + cursor: pointer; +} + +.upload-area { + margin-bottom: 1em; +} + +.action-buttons { + button + button { + margin-left: 1em; + } +} diff --git a/client/src/app/site/mediafiles/components/media-upload/media-upload.component.spec.ts b/client/src/app/site/mediafiles/components/media-upload/media-upload.component.spec.ts new file mode 100644 index 000000000..83ee843e7 --- /dev/null +++ b/client/src/app/site/mediafiles/components/media-upload/media-upload.component.spec.ts @@ -0,0 +1,26 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { MediaUploadComponent } from './media-upload.component'; +import { E2EImportsModule } from 'e2e-imports.module'; + +describe('MediaUploadComponent', () => { + let component: MediaUploadComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule], + declarations: [MediaUploadComponent], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(MediaUploadComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/mediafiles/components/media-upload/media-upload.component.ts b/client/src/app/site/mediafiles/components/media-upload/media-upload.component.ts new file mode 100644 index 000000000..a9880798f --- /dev/null +++ b/client/src/app/site/mediafiles/components/media-upload/media-upload.component.ts @@ -0,0 +1,283 @@ +import { Component, OnInit, ViewChild } from '@angular/core'; +import { Router, ActivatedRoute } from '@angular/router'; +import { Title } from '@angular/platform-browser'; +import { MatTableDataSource, MatTable, MatSnackBar } from '@angular/material'; + +import { UploadEvent, FileSystemFileEntry } from 'ngx-file-drop'; +import { TranslateService } from '@ngx-translate/core'; + +import { BaseViewComponent } from 'app/site/base/base-view'; +import { OperatorService } from 'app/core/services/operator.service'; +import { MediafileRepositoryService } from '../../services/mediafile-repository.service'; + +/** + * To hold the structure of files to upload + */ +interface FileData { + mediafile: File; + filename: string; + title: string; + uploader_id: number; + hidden: boolean; +} + +/** + * Handle file uploads from user + */ +@Component({ + selector: 'os-media-upload', + templateUrl: './media-upload.component.html', + styleUrls: ['./media-upload.component.scss'], +}) +export class MediaUploadComponent extends BaseViewComponent implements OnInit { + /** + * Columns to display in the upload-table + */ + public displayedColumns: string[] = ['title', 'filename', 'information', 'hidden', 'remove']; + + /** + * Determine wheter to show the progress bar + */ + public showProgress = false; + + /** + * Consumable data source for the table + */ + public uploadList: MatTableDataSource; + + /** + * To count the files that report successful uploading + */ + public filesUploaded: number; + + /** + * Determine if uploading should happen parallel or synchronously. + * Synchronous uploading might be necessary if we see that stuff breaks + */ + public parallel = true; + + /** + * Set to true if an error was detected to prevent automatic navigation + */ + public error = false; + + /** + * Hold the mat table to manually render new rows + */ + @ViewChild(MatTable) + public table: MatTable; + + /** + * Constructor for the media upload page + * + * @param titleService set the browser title + * @param translate the translation service + * @param matSnackBar showing errors and information + * @param router Angulars own router + * @param route Angulars activated route + * @param repo the mediafile repository + * @param op the operator, to check who was the uploader + */ + public constructor( + titleService: Title, + translate: TranslateService, + matSnackBar: MatSnackBar, + private router: Router, + private route: ActivatedRoute, + private repo: MediafileRepositoryService, + private op: OperatorService + ) { + super(titleService, translate, matSnackBar); + } + + /** + * Init + * Creates a new uploadList as consumable data source + */ + public ngOnInit(): void { + this.uploadList = new MatTableDataSource(); + } + + /** + * Converts given FileData into FormData format and hands it over to the repository + * to upload + * + * @param fileData the file to upload to the server, should fit to the FileData interface + */ + public async uploadFile(fileData: FileData): Promise { + const input = new FormData(); + input.set('mediafile', fileData.mediafile); + input.set('title', fileData.title); + input.set('uploader_id', '' + fileData.uploader_id); + input.set('hidden', '' + fileData.hidden); + + // raiseError will automatically ignore existing files + await this.repo.uploadFile(input).then( + () => { + this.filesUploaded++; + // remove the uploaded file from the array + this.onRemoveButton(fileData); + }, + error => { + this.error = true; + this.raiseError(`${error} :"${fileData.title}"`); + } + ); + } + + /** + * Converts a file size in bit into human readable format + * + * @param bits file size in bits + * @returns a readable file size representation + */ + public getReadableSize(bits: number): string { + const unitLevel = Math.floor(Math.log(bits) / Math.log(1024)); + const bytes = +(bits / Math.pow(1024, unitLevel)).toFixed(2); + return `${bytes} ${['B', 'kB', 'MB', 'GB', 'TB'][unitLevel]}`; + } + + /** + * Change event to set a file to hidden or not + * + * @param hidden whether the file should be hidden + * @param file the given file + */ + public onChangeHidden(hidden: boolean, file: FileData): void { + file.hidden = hidden; + } + + /** + * Change event to adjust the title + * + * @param newTitle the new title + * @param file the given file + */ + public onChangeTitle(newTitle: string, file: FileData): void { + file.title = newTitle; + } + + /** + * Add a file to list to upload later + * + * @param file the file to upload + */ + public addFile(file: File): void { + const newFile: FileData = { + mediafile: file, + filename: file.name, + title: file.name, + uploader_id: this.op.user.id, + hidden: false, + }; + this.uploadList.data.push(newFile); + + if (this.table) { + this.table.renderRows(); + } + } + + /** + * Handler for the select file event + * + * @param $event holds the file. Triggered by changing the file input element + */ + public onSelectFile(event: any): void { + if (event.target.files && event.target.files.length > 0) { + // file list is a special kind of collection, so array.foreach does not apply + for (const addedFile of event.target.files) { + this.addFile(addedFile); + } + } + } + + /** + * Handler for the drop-file event + * + * @param event holds the file. Triggered by dropping in the area + */ + public onDropFile(event: UploadEvent): void { + for (const droppedFile of event.files) { + // Check if the dropped element is a file. "Else" would be a dir. + if (droppedFile.fileEntry.isFile) { + const fileEntry = droppedFile.fileEntry as FileSystemFileEntry; + fileEntry.file((file: File) => { + this.addFile(file); + }); + } + } + } + + /** + * Click handler for the upload button. + * Iterate over the upload list and executes `uploadFile` on each element + */ + public async onUploadButton(): Promise { + if (this.uploadList && this.uploadList.data.length > 0) { + this.filesUploaded = 0; + this.error = false; + this.showProgress = true; + + if (this.parallel) { + const promises = this.uploadList.data.map(file => this.uploadFile(file)); + await Promise.all(promises); + } else { + for (const file of this.uploadList.data) { + await this.uploadFile(file); + } + } + this.showProgress = false; + + if (!this.error) { + this.router.navigate(['../'], { relativeTo: this.route }); + } else { + this.table.renderRows(); + } + } + } + + /** + * Calculate the progress to display in the progress bar + * Only used in synchronous upload since parallel upload + * + * @returns the upload progress in percent. + */ + public calcUploadProgress(): number { + if (this.filesUploaded && this.uploadList.data) { + return 100 / (this.uploadList.data.length / this.filesUploaded); + } else { + return 0; + } + } + + /** + * Removes the given file from the upload table + * + * @param file the file to remove + */ + public onRemoveButton(file: FileData): void { + if (this.uploadList.data) { + this.uploadList.data.splice(this.uploadList.data.indexOf(file), 1); + this.table.renderRows(); + } + } + + /** + * Click handler for the clear button. Deletes the upload list + */ + public onClearButton(): void { + this.uploadList.data = []; + if (this.table) { + this.table.renderRows(); + } + } + + /** + * Changes the upload strategy between synchronous and parallel + * + * @param isParallel true or false, whether parallel upload is required or not + */ + public setUploadStrategy(isParallel: boolean): void { + this.parallel = isParallel; + } +} diff --git a/client/src/app/site/mediafiles/components/mediafile-list/mediafile-list.component.html b/client/src/app/site/mediafiles/components/mediafile-list/mediafile-list.component.html new file mode 100644 index 000000000..ca66f2455 --- /dev/null +++ b/client/src/app/site/mediafiles/components/mediafile-list/mediafile-list.component.html @@ -0,0 +1,138 @@ + + +
+

Files

+ +
+ + + Required + + + + + Hidden + Visible + + +
+
+ + + +
+ + + + + Name + {{ file.title }} + + + + + Group + +
+ insert_drive_file {{ file.type }} + data_usage {{ file.size }} +
+
+
+ + + + Indicator + + + +
+ text_fields + insert_photo +
+
+
+ + + + Menu + + + + + + + +
+ + + + + + +
+
+ +
+
+ + +
+
+ +
+
+ + + + + +
+
+ + + + + + + + + + + diff --git a/client/src/app/site/mediafiles/components/mediafile-list/mediafile-list.component.scss b/client/src/app/site/mediafiles/components/mediafile-list/mediafile-list.component.scss new file mode 100644 index 000000000..3a178a865 --- /dev/null +++ b/client/src/app/site/mediafiles/components/mediafile-list/mediafile-list.component.scss @@ -0,0 +1,46 @@ +.os-listview-table { + /** Title */ + .mat-column-title { + flex: 2 0 50px; + } + + /** Info */ + .mat-column-info { + width: 100%; + flex: 1 0 40px; + } + + /** Indicator */ + .mat-column-indicator { + flex: 1 0 30px; + } + + /** Menu */ + .mat-column-menu { + flex: 0 0 30px; + } +} + +// multi line tooltip +::ng-deep .mat-tooltip { + white-space: pre-line !important; +} + + +.edit-file-form { + mat-form-field + mat-form-field { + margin: 1em; + } +} + +// duplicate. Put into own file +.file-info-cell { + display: grid; + margin: 0; + + span { + .mat-icon { + font-size: 130%; + } + } +} diff --git a/client/src/app/site/mediafiles/mediafile-list/mediafile-list.component.spec.ts b/client/src/app/site/mediafiles/components/mediafile-list/mediafile-list.component.spec.ts similarity index 91% rename from client/src/app/site/mediafiles/mediafile-list/mediafile-list.component.spec.ts rename to client/src/app/site/mediafiles/components/mediafile-list/mediafile-list.component.spec.ts index a73b7b4dc..85c41fa83 100644 --- a/client/src/app/site/mediafiles/mediafile-list/mediafile-list.component.spec.ts +++ b/client/src/app/site/mediafiles/components/mediafile-list/mediafile-list.component.spec.ts @@ -1,7 +1,7 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { MediafileListComponent } from './mediafile-list.component'; -import { E2EImportsModule } from '../../../../e2e-imports.module'; +import { E2EImportsModule } from 'e2e-imports.module'; describe('MediafileListComponent', () => { let component: MediafileListComponent; diff --git a/client/src/app/site/mediafiles/components/mediafile-list/mediafile-list.component.ts b/client/src/app/site/mediafiles/components/mediafile-list/mediafile-list.component.ts new file mode 100644 index 000000000..5b19bdfeb --- /dev/null +++ b/client/src/app/site/mediafiles/components/mediafile-list/mediafile-list.component.ts @@ -0,0 +1,282 @@ +import { Component, OnInit, ViewChild } from '@angular/core'; +import { Router, ActivatedRoute } from '@angular/router'; +import { FormGroup, FormControl, Validators } from '@angular/forms'; +import { Title } from '@angular/platform-browser'; +import { MatSnackBar } from '@angular/material'; + +import { TranslateService } from '@ngx-translate/core'; + +import { ListViewBaseComponent } from '../../../base/list-view-base'; +import { ViewMediafile } from '../../models/view-mediafile'; +import { MediafileRepositoryService } from '../../services/mediafile-repository.service'; +import { MediaManageService } from '../../services/media-manage.service'; +import { PromptService } from 'app/core/services/prompt.service'; +import { Mediafile } from 'app/shared/models/mediafiles/mediafile'; +import { ViewportService } from 'app/core/services/viewport.service'; + +/** + * Lists all the uploaded files. + */ +@Component({ + selector: 'os-mediafile-list', + templateUrl: './mediafile-list.component.html', + styleUrls: ['./mediafile-list.component.scss'], +}) +export class MediafileListComponent extends ListViewBaseComponent implements OnInit { + /** + * Holds the actions for logos. Updated via an observable + */ + public logoActions: string[]; + + /** + * Holds the actions for fonts. Update via an observable + */ + public fontActions: string[]; + + /** + * Columns to display in Mediafile table when fill width is available + */ + public displayedColumnsDesktop: string[] = ['title', 'info', 'indicator', 'menu']; + + /** + * Columns to display in Mediafile table when fill width is available + */ + public displayedColumnsMobile: string[] = ['title', 'menu']; + + /** + * Show or hide the edit mode + */ + public editFile = false; + + /** + * Holds the file to edit + */ + public fileToEdit: ViewMediafile; + + /** + * The form to edit Files + */ + @ViewChild('fileEditForm') + public fileEditForm: FormGroup; + + /** + * Constructs the component + * + * @param titleService sets the browser title + * @param translate translation for the parent + * @param matSnackBar showing errors and sucsess messages + * @param router angulars router + * @param route anduglars ActivatedRoute + * @param repo the repository for mediafiles + * @param mediaManage service to manage media files (setting images as logos) + * @param promptService prevent deletion by accident + * @param vp viewport Service to check screen size + */ + public constructor( + titleService: Title, + matSnackBar: MatSnackBar, + protected translate: TranslateService, + private router: Router, + private route: ActivatedRoute, + private repo: MediafileRepositoryService, + private mediaManage: MediaManageService, + private promptService: PromptService, + public vp: ViewportService + ) { + super(titleService, translate, matSnackBar); + } + + /** + * Init. + * Set the title, make the edit Form and observe Mediafiles + */ + public ngOnInit(): void { + super.setTitle('Files'); + this.initTable(); + + this.fileEditForm = new FormGroup({ + title: new FormControl('', Validators.required), + hidden: new FormControl(), + }); + + this.repo.getViewModelListObservable().subscribe(newFiles => { + this.dataSource.data = newFiles; + }); + + // Observe the logo actions + this.mediaManage.getLogoActions().subscribe(action => { + this.logoActions = action; + }); + + // Observe the font actions + this.mediaManage.getFontActions().subscribe(action => { + this.fontActions = action; + }); + } + + /** + * Handler for the main Event. + * In edit mode, this abandons the changes + * Without edit mode, this will navigate to the upload page + */ + public onMainEvent(): void { + if (!this.editFile) { + this.router.navigate(['./upload'], { relativeTo: this.route }); + } else { + this.editFile = false; + } + } + + /** + * Click on the edit button in the file menu + * + * @param file the selected file + */ + public onEditFile(file: ViewMediafile): void { + console.log('edit file ', file); + this.fileToEdit = file; + + this.editFile = true; + this.fileEditForm.setValue({ title: this.fileToEdit.title, hidden: this.fileToEdit.hidden }); + } + + /** + * Click on the save button in edit mode + */ + public onSaveEditedFile(): void { + if (!this.fileEditForm.value || !this.fileEditForm.valid) { + return; + } + const updateData = new Mediafile({ + title: this.fileEditForm.value.title, + hidden: this.fileEditForm.value.hidden, + }); + + this.repo.update(updateData, this.fileToEdit).then(() => { + this.editFile = false; + }, this.raiseError); + } + + /** + * Sends a delete request to the repository. + * + * @param file the file to delete + */ + public async onDelete(file: ViewMediafile): Promise { + const content = this.translate.instant('Delete a file'); + if (await this.promptService.open('Are you sure?', content)) { + this.repo.delete(file); + } + } + + /** + * triggers a routine to delete all MediaFiles + * TODO: Remove after Multiselect + * + * @deprecated to be removed once multi selection is implemented + */ + public async onDeleteAllFiles(): Promise { + const content = this.translate.instant('This will delete all files.'); + if (await this.promptService.open('Are you sure?', content)) { + const viewMediafiles = this.dataSource.data; + viewMediafiles.forEach(file => { + this.repo.delete(file); + }); + } + } + + /** + * Returns the display name of an action + * + * @param mediaFileAction Logo or font action + * @returns the display name of the selected action + */ + public getNameOfAction(mediaFileAction: string): string { + return this.translate.instant(this.mediaManage.getMediaConfig(mediaFileAction).display_name); + } + + /** + * Returns a formated string for the tooltip containing all the action names. + * + * @param file the target file where the tooltip should be shown + * @returns getNameOfAction with formated strings. + */ + public formatIndicatorTooltip(file: ViewMediafile): string { + const settings = this.getFileSettings(file); + const actionNames = settings.map(option => this.getNameOfAction(option)); + return actionNames.join('\n'); + } + + /** + * Checks if the given file is used in the following action + * i.e checks if a image is used as projector logo + * + * @param mediaFileAction the action to check for + * @param media the mediafile to check + * @returns whether the file is used + */ + public isUsedAs(file: ViewMediafile, mediaFileAction: string): boolean { + const config = this.mediaManage.getMediaConfig(mediaFileAction); + return config.path === file.downloadUrl; + } + + /** + * Look up the managed options for the given file + * + * @param file the file to look up + * @returns array of actions + */ + public getFileSettings(file: ViewMediafile): string[] { + let uses = []; + if (file) { + if (file.isFont()) { + uses = this.fontActions.filter(action => this.isUsedAs(file, action)); + } else if (file.isImage()) { + uses = this.logoActions.filter(action => this.isUsedAs(file, action)); + } + } + return uses; + } + + /** + * Set the given image as the given option + * + * @param event The fired event after clicking the button + * @param file the selected file + * @param action the action that should be executed + */ + public onManageButton(event: any, file: ViewMediafile, action: string): void { + // prohibits automatic closing + event.stopPropagation(); + this.mediaManage.setAs(file, action); + } + + /** + * Uses the ViewportService to determine which column definition to use + * + * @returns the column definition for the screen size + */ + public getColumnDefinition(): string[] { + return this.vp.isMobile ? this.displayedColumnsMobile : this.displayedColumnsDesktop; + } + + /** + * Directly downloads a mediafile + * + * @param file the select file to download + */ + public download(file: ViewMediafile): void { + window.open(file.downloadUrl); + } + + /** + * Clicking escape while in editFileForm should deactivate edit mode. + * + * @param event The key that was pressed + */ + public keyDownFunction(event: KeyboardEvent): void { + if (event.key === 'Escape') { + this.editFile = false; + } + } +} diff --git a/client/src/app/site/mediafiles/mediafile-list/mediafile-list.component.css b/client/src/app/site/mediafiles/mediafile-list/mediafile-list.component.css deleted file mode 100644 index e69de29bb..000000000 diff --git a/client/src/app/site/mediafiles/mediafile-list/mediafile-list.component.html b/client/src/app/site/mediafiles/mediafile-list/mediafile-list.component.html deleted file mode 100644 index c3879947b..000000000 --- a/client/src/app/site/mediafiles/mediafile-list/mediafile-list.component.html +++ /dev/null @@ -1,51 +0,0 @@ - - -
-

Files

-
- - - -
- - - - - Name - {{ file.title }} - - - - - Group - - {{ file.type }} -
- {{ file.size }} -
-
- - - - Download - - save_alt - - - - - -
- - - - - - diff --git a/client/src/app/site/mediafiles/mediafile-list/mediafile-list.component.ts b/client/src/app/site/mediafiles/mediafile-list/mediafile-list.component.ts deleted file mode 100644 index 1c3a21b9c..000000000 --- a/client/src/app/site/mediafiles/mediafile-list/mediafile-list.component.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { Component, OnInit } from '@angular/core'; -import { Title } from '@angular/platform-browser'; - -import { TranslateService } from '@ngx-translate/core'; - -import { ViewMediafile } from '../models/view-mediafile'; -import { MediafileRepositoryService } from '../services/mediafile-repository.service'; -import { ListViewBaseComponent } from '../../base/list-view-base'; -import { MatSnackBar } from '@angular/material'; - -/** - * Lists all the uploaded files. - * - */ -@Component({ - selector: 'os-mediafile-list', - templateUrl: './mediafile-list.component.html', - styleUrls: ['./mediafile-list.component.css'] -}) -export class MediafileListComponent extends ListViewBaseComponent implements OnInit { - /** - * Constructor - * - * @param repo the repository for files - * @param titleService - * @param translate - */ - public constructor( - titleService: Title, - translate: TranslateService, - matSnackBar: MatSnackBar, - private repo: MediafileRepositoryService - ) { - super(titleService, translate, matSnackBar); - } - - /** - * Init. - * Set the title - */ - public ngOnInit(): void { - super.setTitle('Files'); - this.initTable(); - this.repo.getViewModelListObservable().subscribe(newUsers => { - this.dataSource.data = newUsers; - }); - } - - /** - * Click on the plus button delegated from head-bar - */ - public onPlusButton(): void { - console.log('clicked plus (mediafile)'); - } - - /** - * function to Download all files - * (serves as example to use functions on head bar) - * - * TODO: Not yet implemented, might not even be required - */ - public deleteAllFiles(): void { - console.log('do download'); - } - - /** - * Clicking on a list row - * @param file the selected file - */ - public selectFile(file: ViewMediafile): void { - console.log('The file: ', file); - } - - /** - * Directly download a mediafile using the download button on the table - * @param file - */ - public download(file: ViewMediafile): void { - window.open(file.downloadUrl); - } -} diff --git a/client/src/app/site/mediafiles/mediafiles-routing.module.ts b/client/src/app/site/mediafiles/mediafiles-routing.module.ts index 47e99d6a1..7f8ca9cf3 100644 --- a/client/src/app/site/mediafiles/mediafiles-routing.module.ts +++ b/client/src/app/site/mediafiles/mediafiles-routing.module.ts @@ -1,11 +1,15 @@ import { NgModule } from '@angular/core'; import { Routes, RouterModule } from '@angular/router'; -import { MediafileListComponent } from './mediafile-list/mediafile-list.component'; +import { MediafileListComponent } from './components/mediafile-list/mediafile-list.component'; +import { MediaUploadComponent } from './components/media-upload/media-upload.component'; -const routes: Routes = [{ path: '', component: MediafileListComponent }]; +const routes: Routes = [ + { path: '', component: MediafileListComponent }, + { path: 'upload', component: MediaUploadComponent }, +]; @NgModule({ imports: [RouterModule.forChild(routes)], - exports: [RouterModule] + exports: [RouterModule], }) export class MediafilesRoutingModule {} diff --git a/client/src/app/site/mediafiles/mediafiles.module.ts b/client/src/app/site/mediafiles/mediafiles.module.ts index cb645cf69..3261da26a 100644 --- a/client/src/app/site/mediafiles/mediafiles.module.ts +++ b/client/src/app/site/mediafiles/mediafiles.module.ts @@ -3,10 +3,11 @@ import { CommonModule } from '@angular/common'; import { MediafilesRoutingModule } from './mediafiles-routing.module'; import { SharedModule } from '../../shared/shared.module'; -import { MediafileListComponent } from './mediafile-list/mediafile-list.component'; +import { MediafileListComponent } from './components/mediafile-list/mediafile-list.component'; +import { MediaUploadComponent } from './components/media-upload/media-upload.component'; @NgModule({ imports: [CommonModule, MediafilesRoutingModule, SharedModule], - declarations: [MediafileListComponent] + declarations: [MediafileListComponent, MediaUploadComponent] }) export class MediafilesModule {} diff --git a/client/src/app/site/mediafiles/models/view-mediafile.ts b/client/src/app/site/mediafiles/models/view-mediafile.ts index 725cf2c94..61464ab35 100644 --- a/client/src/app/site/mediafiles/models/view-mediafile.ts +++ b/client/src/app/site/mediafiles/models/view-mediafile.ts @@ -34,6 +34,10 @@ export class ViewMediafile extends BaseViewModel { return this.mediafile ? this.mediafile.media_url_prefix : null; } + public get hidden(): boolean { + return this.mediafile ? this.mediafile.hidden : null; + } + public get fileName(): string { return this.mediafile && this.mediafile.mediafile ? this.mediafile.mediafile.name : null; } @@ -52,6 +56,63 @@ export class ViewMediafile extends BaseViewModel { return this.title; } + /** + * Determine if the file is an image + * + * @returns true or false + */ + public isImage(): boolean { + return ['image/png', 'image/jpeg', 'image/gif'].includes(this.type); + } + + /** + * Determine if the file is a font + * + * @returns true or false + */ + public isFont(): boolean { + return ['font/ttf', 'font/woff'].includes(this.type); + } + + /** + * Determine if the file is a pdf + * + * @returns true or false + */ + public isPdf(): boolean { + return ['application/pdf'].includes(this.type); + } + + /** + * Determine if the file is a video + * + * @returns true or false + */ + public isVideo(): boolean { + return [ + 'video/quicktime', + 'video/mp4', + 'video/webm', + 'video/ogg', + 'video/x-flv', + 'application/x-mpegURL', + 'video/MP2T', + 'video/3gpp', + 'video/x-msvideo', + 'video/x-ms-wmv', + 'video/x-matroska', + ].includes(this.type); + } + + /** + * Determine if the file is presentable + * + * @returns true or false + */ + public isPresentable(): boolean { + return this.isPdf() || this.isImage() || this.isVideo(); + } + public updateValues(update: Mediafile): void { if (this.mediafile.id === update.id) { this._mediafile = update; diff --git a/client/src/app/site/mediafiles/services/media-manage.service.spec.ts b/client/src/app/site/mediafiles/services/media-manage.service.spec.ts new file mode 100644 index 000000000..6b867cfe3 --- /dev/null +++ b/client/src/app/site/mediafiles/services/media-manage.service.spec.ts @@ -0,0 +1,13 @@ +import { TestBed } from '@angular/core/testing'; + +import { MediaManageService } from './media-manage.service'; +import { E2EImportsModule } from 'e2e-imports.module'; + +describe('MediaManageService', () => { + beforeEach(() => TestBed.configureTestingModule({ imports: [E2EImportsModule] })); + + it('should be created', () => { + const service: MediaManageService = TestBed.get(MediaManageService); + expect(service).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/mediafiles/services/media-manage.service.ts b/client/src/app/site/mediafiles/services/media-manage.service.ts new file mode 100644 index 000000000..0ca6bd505 --- /dev/null +++ b/client/src/app/site/mediafiles/services/media-manage.service.ts @@ -0,0 +1,109 @@ +import { Injectable } from '@angular/core'; +import { HttpService } from 'app/core/services/http.service'; +import { ConfigService } from 'app/core/services/config.service'; +import { Observable } from 'rxjs'; +import { ViewMediafile } from '../models/view-mediafile'; + +/** + * The structure of an image config object + */ +interface ImageConfigObject { + display_name: string + key: string; + path: string; +} + +/** + * The structure of a font config + */ +interface FontConfigObject { + display_name: string + default: string; + path: string; +} + +/** + * Holds the required structure of the manage payload + */ +interface ManagementPayload { + id: number, + key?: string, + default?: string, + value: ImageConfigObject | FontConfigObject +} + +/** + * The service to manage Mediafiles. + * + * Declaring images as logos (web, projector, pdf, ...) is handles here. + */ +@Injectable({ + providedIn: 'root', +}) +export class MediaManageService { + /** + * Constructor for the MediaManage service + * + * @param httpService OpenSlides own HttpService + */ + public constructor(private config: ConfigService, private httpService: HttpService) {} + + /** + * Sets the given Mediafile to using the given management option + * i.e: setting another projector logo + * + * TODO: Feels overly complicated. However, the server seems to requires a strictly shaped payload + * + * @param file the selected Mediafile + * @param action determines the action + */ + public async setAs(file: ViewMediafile, action: string): Promise { + const restPath = `rest/core/config/${action}`; + + const config = this.getMediaConfig(action); + const path = (config.path !== file.downloadUrl) ? file.downloadUrl : ''; + + // Create the payload that the server requires to manage a mediafile + const payload: ManagementPayload = { + id: file.id, + key: (config as ImageConfigObject).key, + default: (config as FontConfigObject).default, + value: { + display_name: config.display_name, + key: (config as ImageConfigObject).key, + default: (config as FontConfigObject).default, + path: path + } + } + + return this.httpService.put(restPath, payload); + } + + /** + * Get all actions that can be executed on images + * + * @returns observable array of strings with the actions for images + */ + public getLogoActions(): Observable { + return this.config.get('logos_available'); + } + + /** + * Get all actions that can be executed on fonts + * + * @returns observable array of string with the actions for fonts + */ + public getFontActions(): Observable { + return this.config.get('fonts_available'); + } + + /** + * returns the config object to a given action + * + * @param action the logo action or font action + * @returns A media config object containing the requested values + */ + public getMediaConfig(action: string): ImageConfigObject | FontConfigObject { + return this.config.instant(action); + } +} diff --git a/client/src/app/site/mediafiles/services/mediafile-repository.service.spec.ts b/client/src/app/site/mediafiles/services/mediafile-repository.service.spec.ts index a65a72d1f..5dcf53097 100644 --- a/client/src/app/site/mediafiles/services/mediafile-repository.service.spec.ts +++ b/client/src/app/site/mediafiles/services/mediafile-repository.service.spec.ts @@ -1,9 +1,10 @@ import { TestBed } from '@angular/core/testing'; import { MediafileRepositoryService } from './mediafile-repository.service'; +import { E2EImportsModule } from 'e2e-imports.module'; describe('FileRepositoryService', () => { - beforeEach(() => TestBed.configureTestingModule({})); + beforeEach(() => TestBed.configureTestingModule({ imports: [E2EImportsModule] })); it('should be created', () => { const service: MediafileRepositoryService = TestBed.get(MediafileRepositoryService); diff --git a/client/src/app/site/mediafiles/services/mediafile-repository.service.ts b/client/src/app/site/mediafiles/services/mediafile-repository.service.ts index b5c71ac70..a8c0b56e7 100644 --- a/client/src/app/site/mediafiles/services/mediafile-repository.service.ts +++ b/client/src/app/site/mediafiles/services/mediafile-repository.service.ts @@ -7,49 +7,86 @@ import { User } from '../../../shared/models/users/user'; import { DataStoreService } from '../../../core/services/data-store.service'; import { Identifiable } from '../../../shared/models/base/identifiable'; import { CollectionStringModelMapperService } from '../../../core/services/collectionStringModelMapper.service'; +import { DataSendService } from 'app/core/services/data-send.service'; +import { HttpService } from 'app/core/services/http.service'; +import { HttpHeaders } from '@angular/common/http'; /** - * Repository for files + * Repository for MediaFiles */ @Injectable({ - providedIn: 'root' + providedIn: 'root', }) export class MediafileRepositoryService extends BaseRepository { /** - * Consturctor for the file repo - * @param DS the DataStore + * Constructor for the mediafile repository + * @param DS Data store + * @param mapperService OpenSlides class mapping service + * @param dataSend sending data to the server + * @param httpService OpenSlides own http service */ - public constructor(DS: DataStoreService, mapperService: CollectionStringModelMapperService) { + public constructor( + DS: DataStoreService, + mapperService: CollectionStringModelMapperService, + private dataSend: DataSendService, + private httpService: HttpService, + ) { super(DS, mapperService, Mediafile, [User]); } /** - * Saves a config value. + * Alter a given mediaFile + * Usually just i.e change the name and the hidden flag. * - * TODO: used over not-yet-existing detail view + * @param file contains the new values + * @param viewFile the file that should be updated */ public async update(file: Partial, viewFile: ViewMediafile): Promise { - return null; + const updateFile = new Mediafile(); + updateFile.patchValues(viewFile.mediafile); + updateFile.patchValues(file); + await this.dataSend.updateModel(updateFile); } /** - * Saves a config value. + * Deletes the given file from the server * - * TODO: used over not-yet-existing detail view + * @param file the file to delete */ public async delete(file: ViewMediafile): Promise { - return null; + return await this.dataSend.deleteModel(file.mediafile); } /** - * Saves a config value. + * Mediafiles are uploaded using FormData objects and (usually) not created locally. * - * TODO: used over not-yet-existing detail view + * @param file a new mediafile + * @returns the ID as a promise */ public async create(file: Mediafile): Promise { - return null; + return await this.dataSend.createModel(file); } + /** + * Uploads a file to the server. + * The HttpHeader should be Application/FormData, the empty header will + * set the the required boundary automatically + * + * @param file created UploadData, containing a file + * @returns the promise to a new mediafile. + */ + public async uploadFile(file: FormData): Promise { + const restPath = `rest/mediafiles/mediafile/`; + const emptyHeader = new HttpHeaders(); + return this.httpService.post(restPath, file, emptyHeader); + } + + /** + * Creates mediafile ViewModels out of given mediafile objects + * + * @param file mediafile to convert + * @returns a new mediafile ViewModel + */ public createViewModel(file: Mediafile): ViewMediafile { const uploader = this.DS.get(User, file.uploader_id); return new ViewMediafile(file, uploader); diff --git a/client/src/styles.scss b/client/src/styles.scss index 3de1ff584..692b1038e 100644 --- a/client/src/styles.scss +++ b/client/src/styles.scss @@ -150,3 +150,10 @@ mat-expansion-panel { mat-panel-title mat-icon { padding-right: 30px; } + +// ngx-file-drop requires the custom style in the global css file +.file-drop-style { + margin: auto; + height: 100px; + border: 2px dotted #0782d0; +}