Merge pull request #4019 from tsiegleauq/mediafile-upload

Upload media files
This commit is contained in:
Jochen Saalfeld 2018-11-20 16:15:34 +01:00 committed by GitHub
commit 7ae95b9208
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 1245 additions and 201 deletions

View File

@ -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",

View File

@ -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]
})

View File

@ -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<any>, next: HttpHandler): Observable<HttpEvent<any>> {
const clonedRequest = req.clone({
withCredentials: true,
headers: req.headers.set('Content-Type', 'application/json')
});
return next.handle(clonedRequest);
}
}

View File

@ -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<T>(url: string, method: HTTPMethod, data?: any): Promise<T> {
private async send<T>(url: string, method: HTTPMethod, data?: any, customHeader?: HttpHeaders): Promise<T> {
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<T>(url: string, data?: any): Promise<T> {
return await this.send<T>(url, HTTPMethod.GET, data);
public async get<T>(url: string, data?: any, header?: HttpHeaders): Promise<T> {
return await this.send<T>(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<T>(url: string, data: any): Promise<T> {
return await this.send<T>(url, HTTPMethod.POST, data);
public async post<T>(url: string, data: any, header?: HttpHeaders): Promise<T> {
return await this.send<T>(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<T>(url: string, data: any): Promise<T> {
return await this.send<T>(url, HTTPMethod.PATCH, data);
public async patch<T>(url: string, data: any, header?: HttpHeaders): Promise<T> {
return await this.send<T>(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<T>(url: string, data: any): Promise<T> {
return await this.send<T>(url, HTTPMethod.PUT, data);
public async put<T>(url: string, data: any, header?: HttpHeaders): Promise<T> {
return await this.send<T>(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<T>(url: string, data?: any): Promise<T> {
return await this.send<T>(url, HTTPMethod.DELETE, data);
public async delete<T>(url: string, data?: any, header?: HttpHeaders): Promise<T> {
return await this.send<T>(url, HTTPMethod.DELETE, data, header);
}
}

View File

@ -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,

View File

@ -0,0 +1,105 @@
<os-head-bar [nav]="false">
<!-- Title -->
<div class="title-slot"> <h2 translate>Upload files</h2> </div>
<!-- Menu -->
<div class="menu-slot">
<button type="button" mat-icon-button [matMenuTriggerFor]="uploadMenu">
<mat-icon>more_vert</mat-icon>
</button>
</div>
</os-head-bar>
<mat-card class="os-card">
<div class="upload-area">
<input hidden type="file" #fileInput (change)="onSelectFile($event)" multiple />
<div class="selection-area">
<file-drop (onFileDrop)="onDropFile($event)" (click)="fileInput.click()" customstyle="file-drop-style">
<span translate>Drop files into this area OR select files</span>
</file-drop>
</div>
<div class="table-container" *ngIf="uploadList.data.length > 0">
<table mat-table [dataSource]="uploadList" class="mat-elevation-z8">
<!-- Title -->
<ng-container matColumnDef="title" sticky>
<th mat-header-cell *matHeaderCellDef> <span translate>Title</span> </th>
<td mat-cell *matCellDef="let file">
<mat-form-field>
<input matInput [value]="file.title" (input)="onChangeTitle($event.target.value, file)" />
</mat-form-field>
</td>
</ng-container>
<!-- Original file name -->
<ng-container matColumnDef="filename">
<th mat-header-cell *matHeaderCellDef> <span translate>File name</span> </th>
<td mat-cell *matCellDef="let file"> {{ file.filename }} </td>
</ng-container>
<!-- File information -->
<ng-container matColumnDef="information">
<th mat-header-cell *matHeaderCellDef> <span translate>File information</span> </th>
<td mat-cell *matCellDef="let file">
<div class="file-info-cell">
<span>
<mat-icon [inline]="true">insert_drive_file</mat-icon> {{ file.mediafile.type }}
</span>
<span>
<mat-icon [inline]="true">data_usage</mat-icon>
{{ getReadableSize(file.mediafile.size) }}
</span>
</div>
</td>
</ng-container>
<!-- Hidden -->
<ng-container matColumnDef="hidden">
<th mat-header-cell *matHeaderCellDef> <span translate>Hidden</span> </th>
<td mat-cell *matCellDef="let file">
<mat-checkbox
[checked]="file.hidden"
(change)="onChangeHidden($event.checked, file)"
></mat-checkbox>
</td>
</ng-container>
<!-- Remove Button -->
<ng-container matColumnDef="remove">
<th mat-header-cell *matHeaderCellDef> <span translate>Remove</span> </th>
<td mat-cell *matCellDef="let file">
<button mat-icon-button color="warn" (click)="onRemoveButton(file)">
<mat-icon>close</mat-icon>
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
</table>
</div>
</div>
<!-- Upload and clear buttons -->
<div class="action-buttons">
<button type="button" mat-raised-button (click)="onUploadButton()" color="primary">
<span translate> Upload </span>
</button>
<button type="button" mat-raised-button (click)="onClearButton()"> <span translate> Clear list </span> </button>
</div>
</mat-card>
<mat-card class="os-card" *ngIf="showProgress">
<mat-progress-bar *ngIf="!parallel" mode="determinate" [value]="calcUploadProgress()"></mat-progress-bar>
<mat-progress-bar *ngIf="parallel" mode="buffer"></mat-progress-bar>
</mat-card>
<mat-menu #uploadMenu="matMenu">
<!-- Select upload strategy -->
<button mat-menu-item (click)="setUploadStrategy(!parallel)">
<mat-icon color="accent">{{ parallel ? "check_box" : "check_box_outline_blank" }}</mat-icon>
<span translate>Parallel upload</span>
</button>
</mat-menu>

View File

@ -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;
}
}

View File

@ -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<MediaUploadComponent>;
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();
});
});

View File

@ -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<FileData>;
/**
* 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<any>;
/**
* 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<FileData>();
}
/**
* 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<void> {
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<void> {
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;
}
}

View File

@ -0,0 +1,138 @@
<os-head-bar [mainButton]="true" (mainEvent)="onMainEvent()" [editMode]="editFile" (saveEvent)="onSaveEditedFile()">
<!-- Title -->
<div class="title-slot">
<h2 *ngIf="!editFile" translate>Files</h2>
<form
class="edit-file-form"
*ngIf="editFile"
[formGroup]="fileEditForm"
(ngSubmit)="onSaveEditedFile()"
(keydown)="keyDownFunction($event)"
>
<mat-form-field>
<input
type="text"
matInput
osAutofocus
required
formControlName="title"
placeholder="{{ 'New file name' | translate}}"
/>
<mat-error *ngIf="fileEditForm.invalid" translate>Required</mat-error>
</mat-form-field>
<mat-form-field>
<mat-select formControlName="hidden" placeholder="{{ 'Visibility' | translate}}">
<mat-option [value]="true"> <span translate>Hidden</span> </mat-option>
<mat-option [value]="false"><span translate>Visible</span></mat-option>
</mat-select>
</mat-form-field>
</form>
</div>
<!-- Menu -->
<div class="menu-slot">
<button type="button" mat-icon-button [matMenuTriggerFor]="mediafilesMenu">
<mat-icon>more_vert</mat-icon>
</button>
</div>
</os-head-bar>
<mat-table class="os-listview-table on-transition-fade" [dataSource]="dataSource" matSort>
<!-- Filename -->
<ng-container matColumnDef="title">
<mat-header-cell *matHeaderCellDef mat-sort-header>Name</mat-header-cell>
<mat-cell *matCellDef="let file" (click)="download(file)">{{ file.title }}</mat-cell>
</ng-container>
<!-- Info -->
<ng-container matColumnDef="info">
<mat-header-cell *matHeaderCellDef mat-sort-header>Group</mat-header-cell>
<mat-cell *matCellDef="let file" (click)="download(file)">
<div class="file-info-cell">
<span> <mat-icon [inline]="true">insert_drive_file</mat-icon> {{ file.type }} </span>
<span> <mat-icon [inline]="true">data_usage</mat-icon> {{ file.size }} </span>
</div>
</mat-cell>
</ng-container>
<!-- indicator -->
<ng-container matColumnDef="indicator">
<mat-header-cell *matHeaderCellDef mat-sort-header>Indicator</mat-header-cell>
<mat-cell *matCellDef="let file">
<!-- check if the file is managed -->
<div
*ngIf="getFileSettings(file).length > 0"
[matMenuTriggerFor]="singleFileMenu"
[matMenuTriggerData]="{ file: file }"
[matTooltip]="formatIndicatorTooltip(file)"
>
<mat-icon *ngIf="file.isFont()">text_fields</mat-icon>
<mat-icon *ngIf="file.isImage()">insert_photo</mat-icon>
</div>
</mat-cell>
</ng-container>
<!-- menu -->
<ng-container matColumnDef="menu">
<mat-header-cell *matHeaderCellDef mat-sort-header>Menu</mat-header-cell>
<mat-cell *matCellDef="let file">
<button mat-icon-button [matMenuTriggerFor]="singleFileMenu" [matMenuTriggerData]="{ file: file }">
<mat-icon>more_vert</mat-icon>
</button>
</mat-cell>
</ng-container>
<mat-header-row *matHeaderRowDef="getColumnDefinition()"></mat-header-row>
<mat-row *matRowDef="let row; columns: getColumnDefinition()"></mat-row>
</mat-table>
<mat-paginator class="on-transition-fade" [pageSizeOptions]="[25, 50, 75, 100, 125]"></mat-paginator>
<mat-menu #singleFileMenu="matMenu">
<ng-template matMenuContent let-file="file">
<!-- Exclusive for images -->
<div *ngIf="file.isImage()">
<div *ngFor="let action of logoActions">
<ng-container *ngTemplateOutlet="manageButton; context: { file: file, action: action }"></ng-container>
</div>
</div>
<!-- Exclusive for fonts -->
<div *ngIf="file.isFont()">
<div *ngFor="let action of fontActions">
<ng-container *ngTemplateOutlet="manageButton; context: { file: file, action: action }"></ng-container>
</div>
</div>
<!-- Edit and delete for all images -->
<mat-divider></mat-divider>
<button mat-menu-item (click)="onEditFile(file)">
<mat-icon>edit</mat-icon>
<span translate>Edit</span>
</button>
<button mat-menu-item class="red-warning-text" (click)="onDelete(file)">
<mat-icon>delete</mat-icon>
<span translate>Delete</span>
</button>
</ng-template>
</mat-menu>
<!-- Template for the managing buttons -->
<ng-template #manageButton let-file="file" let-action="action">
<button mat-menu-item (click)="onManageButton($event, file, action)">
<mat-icon color="accent"> {{ isUsedAs(file, action) ? 'check_box' : 'check_box_outline_blank' }} </mat-icon>
<span>{{ getNameOfAction(action) }}</span>
</button>
</ng-template>
<!-- Menu for Mediafiles -->
<mat-menu #mediafilesMenu="matMenu">
<!-- Delete all files - later replaced with multi-select function -->
<button mat-menu-item class="red-warning-text" (click)="onDeleteAllFiles()">
<mat-icon>delete</mat-icon>
<span translate>Delete all files</span>
</button>
</mat-menu>

View File

@ -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%;
}
}
}

View File

@ -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;

View File

@ -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<ViewMediafile> 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<void> {
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<void> {
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;
}
}
}

View File

@ -1,51 +0,0 @@
<os-head-bar plusButton=true (plusButtonClicked)=onPlusButton()>
<!-- Title -->
<div class="title-slot">
<h2 translate>Files</h2>
</div>
<!-- Menu -->
<div class="menu-slot">
<button type="button" mat-icon-button [matMenuTriggerFor]="mediafilesMenu">
<mat-icon>more_vert</mat-icon>
</button>
</div>
</os-head-bar>
<mat-table class="os-listview-table on-transition-fade" [dataSource]="dataSource" matSort>
<!-- name column -->
<ng-container matColumnDef="title">
<mat-header-cell *matHeaderCellDef mat-sort-header>Name</mat-header-cell>
<mat-cell (click)="selectFile(file)" *matCellDef="let file">{{ file.title }}</mat-cell>
</ng-container>
<!-- prefix column -->
<ng-container matColumnDef="info">
<mat-header-cell *matHeaderCellDef mat-sort-header>Group</mat-header-cell>
<mat-cell (click)="selectFile(file)" *matCellDef="let file">
{{ file.type }}
<br>
{{ file.size }}
</mat-cell>
</ng-container>
<!-- prefix column -->
<ng-container matColumnDef="download">
<mat-header-cell *matHeaderCellDef mat-sort-header>Download</mat-header-cell>
<mat-cell (click)="download(file)" *matCellDef="let file">
<mat-icon>save_alt</mat-icon>
</mat-cell>
</ng-container>
<mat-header-row *matHeaderRowDef="['title', 'info', 'download']"></mat-header-row>
<mat-row *matRowDef="let row; columns: ['title', 'info', 'download']"></mat-row>
</mat-table>
<mat-paginator class="on-transition-fade" [pageSizeOptions]="[25, 50, 75, 100, 125]"></mat-paginator>
<mat-menu #mediafilesMenu="matMenu">
<button mat-menu-item class="red-warning-text" (click)="deleteAllFiles()">
<mat-icon>delete</mat-icon>
<span translate>Delete all files</span>
</button>
</mat-menu>

View File

@ -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<ViewMediafile> 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);
}
}

View File

@ -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 {}

View File

@ -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 {}

View File

@ -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;

View File

@ -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();
});
});

View File

@ -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<void> {
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<void>(restPath, payload);
}
/**
* Get all actions that can be executed on images
*
* @returns observable array of strings with the actions for images
*/
public getLogoActions(): Observable<string[]> {
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<string[]> {
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);
}
}

View File

@ -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);

View File

@ -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<ViewMediafile, Mediafile> {
/**
* 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<Mediafile>, viewFile: ViewMediafile): Promise<void> {
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<void> {
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<Identifiable> {
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<Identifiable> {
const restPath = `rest/mediafiles/mediafile/`;
const emptyHeader = new HttpHeaders();
return this.httpService.post<Identifiable>(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);

View File

@ -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;
}