diff options
Diffstat (limited to 'ui/src')
-rw-r--r-- | ui/src/app/app.component.html | 74 | ||||
-rw-r--r-- | ui/src/app/app.component.sass | 34 | ||||
-rw-r--r-- | ui/src/app/app.component.ts | 72 | ||||
-rw-r--r-- | ui/src/app/app.module.ts | 5 | ||||
-rw-r--r-- | ui/src/app/downloads.service.ts | 62 | ||||
-rw-r--r-- | ui/src/app/master-checkbox.component.ts | 53 |
6 files changed, 219 insertions, 81 deletions
diff --git a/ui/src/app/app.component.html b/ui/src/app/app.component.html index 3796b72..b05aee7 100644 --- a/ui/src/app/app.component.html +++ b/ui/src/app/app.component.html @@ -1,10 +1,9 @@ <nav class="navbar navbar-expand-md navbar-dark bg-dark"> <a class="navbar-brand" href="#">MeTube</a> + <!-- <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarsDefault" aria-controls="navbarsDefault" aria-expanded="false" aria-label="Toggle navigation"> <span class="navbar-toggler-icon"></span> </button> - - <!-- <div class="collapse navbar-collapse" id="navbarsDefault"> <ul class="navbar-nav mr-auto"> <li class="nav-item active"> @@ -29,47 +28,66 @@ </form> <p *ngIf="downloads.loading">Loading...</p> - <div *ngIf="!downloads.loading"> - <div *ngIf="downloads.empty()" class="jumbotron jumbotron-fluid px-4"> - <div class="container text-center"> - <h1 class="display-4">Welcome to MeTube!</h1> - <p class="lead">Please add some downloads via the URL box above.</p> - </div> - </div> - <table *ngIf="!downloads.empty()" class="table"> - <thead> + <div class="metube-section-header">Downloading</div> + <table class="table"> + <thead> <tr> - <th scope="col" style="width: 1rem; vertical-align: middle;"> - <div class="custom-control custom-checkbox"> - <input type="checkbox" class="custom-control-input" id="select-all" #masterCheckbox [(ngModel)]="masterSelected" (change)="checkUncheckAll()"> - <label class="custom-control-label" for="select-all"></label> - </div> + <th scope="col" style="width: 1rem;"> + <app-master-checkbox #queueMasterCheckbox [id]="'queue'" [list]="downloads.queue" (changed)="queueSelectionChanged($event)"></app-master-checkbox> </th> <th scope="col"> - <button type="button" class="btn btn-link px-0" disabled #delSelected (click)="delSelectedDownloads()"><fa-icon [icon]="faTrashAlt"></fa-icon> Clear selected</button> + <button type="button" class="btn btn-link px-0 mr-4" disabled #queueDelSelected (click)="delSelectedDownloads('queue')"><fa-icon [icon]="faTrashAlt"></fa-icon> Cancel selected</button> </th> <th scope="col" style="width: 14rem;"></th> <th scope="col" style="width: 8rem;">Speed</th> <th scope="col" style="width: 7rem;">ETA</th> <th scope="col" style="width: 2rem;"></th> </tr> - </thead> - <tbody> - <tr *ngFor="let download of downloads.downloads | keyvalue: asIsOrder" [class.disabled]='download.value.deleting'> + </thead> + <tbody> + <tr *ngFor="let download of downloads.queue | keyvalue: asIsOrder" [class.disabled]='download.value.deleting'> <td> - <div class="custom-control custom-checkbox"> - <input type="checkbox" class="custom-control-input" id="select-{{download.key}}" [(ngModel)]="download.value.checked" (change)="selectionChanged()"> - <label class="custom-control-label" for="select-{{download.key}}"></label> - </div> + <app-slave-checkbox [id]="download.key" [master]="queueMasterCheckbox" [checkable]="download.value"></app-slave-checkbox> </td> <td>{{ download.value.title }}</td> <td><ngb-progressbar height="1.5rem" [showValue]="download.value.status != 'preparing'" [striped]="download.value.status == 'preparing'" [animated]="download.value.status == 'preparing'" type="success" [value]="download.value.status == 'preparing' ? 100 : download.value.percent | number:'1.0-0'"></ngb-progressbar></td> <td>{{ download.value.speed | speed }}</td> <td>{{ download.value.eta | eta }}</td> - <td><button type="button" class="btn btn-link" (click)="delDownload(download.key)"><fa-icon [icon]="faTrashAlt"></fa-icon></button></td> + <td><button type="button" class="btn btn-link" (click)="delDownload('queue', download.key)"><fa-icon [icon]="faTrashAlt"></fa-icon></button></td> </tr> - </tbody> - </table> - </div> + </tbody> + </table> + + <div class="metube-section-header">Completed</div> + <table class="table"> + <thead> + <tr> + <th scope="col" style="width: 1rem;"> + <app-master-checkbox #doneMasterCheckbox [id]="'done'" [list]="downloads.done" (changed)="doneSelectionChanged($event)"></app-master-checkbox> + </th> + <th scope="col"> + <button type="button" class="btn btn-link px-0 mr-4" disabled #doneDelSelected (click)="delSelectedDownloads('done')"><fa-icon [icon]="faTrashAlt"></fa-icon> Clear selected</button> + <button type="button" class="btn btn-link px-0 mr-4" disabled #doneClearCompleted (click)="clearCompletedDownloads()"><fa-icon [icon]="faCheckCircle"></fa-icon> Clear completed</button> + <button type="button" class="btn btn-link px-0 mr-4" disabled #doneClearFailed (click)="clearFailedDownloads()"><fa-icon [icon]="faTimesCircle"></fa-icon> Clear failed</button> + </th> + <th scope="col" style="width: 2rem;"></th> + </tr> + </thead> + <tbody> + <tr *ngFor="let download of downloads.done | keyvalue: asIsOrder" [class.disabled]='download.value.deleting'> + <td> + <app-slave-checkbox [id]="download.key" [master]="doneMasterCheckbox" [checkable]="download.value"></app-slave-checkbox> + </td> + <td> + <div style="display: inline-block; width: 1.3rem;"> + <fa-icon *ngIf="download.value.status == 'finished'" [icon]="faCheckCircle" style="color: green;"></fa-icon> + <fa-icon *ngIf="download.value.status == 'error'" [icon]="faTimesCircle" style="color: red;"></fa-icon> + </div> + {{ download.value.title }} + </td> + <td><button type="button" class="btn btn-link" (click)="delDownload('done', download.key)"><fa-icon [icon]="faTrashAlt"></fa-icon></button></td> + </tr> + </tbody> + </table> </main><!-- /.container --> diff --git a/ui/src/app/app.component.sass b/ui/src/app/app.component.sass index 847fc30..71b07f8 100644 --- a/ui/src/app/app.component.sass +++ b/ui/src/app/app.component.sass @@ -1,15 +1,37 @@ .add-url-box
- padding: 5rem 0
max-width: 720px
- margin: auto
+ margin: 4rem auto
-.rounded-box
- border: 1px solid rgba(0,0,0,.125)
- border-radius: .25rem
+$metube-section-color-bg: rgba(0,0,0,.07)
+
+.metube-section-header
+ font-size: 1.8rem
+ font-weight: 300
+ position: relative
+ background: $metube-section-color-bg
+ padding: 0.5rem 0
+ margin-top: 3.5rem
+
+.metube-section-header:before
+ content: ""
+ position: absolute
+ top: 0
+ bottom: 0
+ left: -9999px
+ right: 0
+ border-left: 9999px solid $metube-section-color-bg
+ box-shadow: 9999px 0 0 $metube-section-color-bg
+
+button:hover
+ text-decoration: none
th
border-top: 0
- border-bottom: 3px solid #dee2e6 !important
+ border-bottom-width: 3px !important
+ vertical-align: middle !important
+
+td
+ vertical-align: middle
.disabled
opacity: 0.5
diff --git a/ui/src/app/app.component.ts b/ui/src/app/app.component.ts index 53d3a57..e0961ce 100644 --- a/ui/src/app/app.component.ts +++ b/ui/src/app/app.component.ts @@ -1,23 +1,48 @@ -import { Component, ViewChild, ElementRef } from '@angular/core'; -import { faTrashAlt } from '@fortawesome/free-regular-svg-icons'; +import { Component, ViewChild, ElementRef, AfterViewInit } from '@angular/core'; +import { faTrashAlt, faCheckCircle, faTimesCircle } from '@fortawesome/free-regular-svg-icons'; import { DownloadsService, Status } from './downloads.service'; +import { MasterCheckboxComponent } from './master-checkbox.component'; @Component({ selector: 'app-root', templateUrl: './app.component.html', styleUrls: ['./app.component.sass'] }) -export class AppComponent { +export class AppComponent implements AfterViewInit { addUrl: string; addInProgress = false; + + @ViewChild('queueMasterCheckbox', {static: false}) queueMasterCheckbox: MasterCheckboxComponent; + @ViewChild('queueDelSelected', {static: false}) queueDelSelected: ElementRef; + @ViewChild('doneMasterCheckbox', {static: false}) doneMasterCheckbox: MasterCheckboxComponent; + @ViewChild('doneDelSelected', {static: false}) doneDelSelected: ElementRef; + @ViewChild('doneClearCompleted', {static: false}) doneClearCompleted: ElementRef; + @ViewChild('doneClearFailed', {static: false}) doneClearFailed: ElementRef; + faTrashAlt = faTrashAlt; - masterSelected: boolean; - @ViewChild('masterCheckbox', {static: false}) masterCheckbox: ElementRef; - @ViewChild('delSelected', {static: false}) delSelected: ElementRef; + faCheckCircle = faCheckCircle; + faTimesCircle = faTimesCircle; constructor(public downloads: DownloadsService) { - this.downloads.dlChanges.subscribe(() => this.selectionChanged()); + } + + ngAfterViewInit() { + this.downloads.queueChanged.subscribe(() => { + this.queueMasterCheckbox.selectionChanged(); + }); + this.downloads.doneChanged.subscribe(() => { + this.doneMasterCheckbox.selectionChanged(); + let completed: number = 0, failed: number = 0; + this.downloads.done.forEach(dl => { + if (dl.status === 'finished') + completed++; + else if (dl.status === 'error') + failed++; + }); + this.doneClearCompleted.nativeElement.disabled = completed === 0; + this.doneClearFailed.nativeElement.disabled = failed === 0; + }); } // workaround to allow fetching of Map values in the order they were inserted @@ -26,19 +51,12 @@ export class AppComponent { return 1; } - checkUncheckAll() { - this.downloads.downloads.forEach(dl => dl.checked = this.masterSelected); - this.selectionChanged(); + queueSelectionChanged(checked: number) { + this.queueDelSelected.nativeElement.disabled = checked == 0; } - selectionChanged() { - if (!this.masterCheckbox) - return; - let checked: number = 0; - this.downloads.downloads.forEach(dl => { if(dl.checked) checked++ }); - this.masterSelected = checked > 0 && checked == this.downloads.downloads.size; - this.masterCheckbox.nativeElement.indeterminate = checked > 0 && checked < this.downloads.downloads.size; - this.delSelected.nativeElement.disabled = checked == 0; + doneSelectionChanged(checked: number) { + this.doneDelSelected.nativeElement.disabled = checked == 0; } addDownload() { @@ -53,13 +71,19 @@ export class AppComponent { }); } - delDownload(id: string) { - this.downloads.del([id]).subscribe(); + delDownload(where: string, id: string) { + this.downloads.delById(where, [id]).subscribe(); + } + + delSelectedDownloads(where: string) { + this.downloads.delByFilter(where, dl => dl.checked).subscribe(); + } + + clearCompletedDownloads() { + this.downloads.delByFilter('done', dl => dl.status === 'finished').subscribe(); } - delSelectedDownloads() { - let ids: string[] = []; - this.downloads.downloads.forEach(dl => { if(dl.checked) ids.push(dl.id) }); - this.downloads.del(ids).subscribe(); + clearFailedDownloads() { + this.downloads.delByFilter('done', dl => dl.status === 'error').subscribe(); } } diff --git a/ui/src/app/app.module.ts b/ui/src/app/app.module.ts index a169f14..126de30 100644 --- a/ui/src/app/app.module.ts +++ b/ui/src/app/app.module.ts @@ -8,6 +8,7 @@ import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; import { AppComponent } from './app.component'; import { EtaPipe, SpeedPipe } from './downloads.pipe'; +import { MasterCheckboxComponent, SlaveCheckboxComponent } from './master-checkbox.component'; const config: SocketIoConfig = { url: '', options: {} }; @@ -15,7 +16,9 @@ const config: SocketIoConfig = { url: '', options: {} }; declarations: [ AppComponent, EtaPipe, - SpeedPipe + SpeedPipe, + MasterCheckboxComponent, + SlaveCheckboxComponent ], imports: [ BrowserModule, diff --git a/ui/src/app/downloads.service.ts b/ui/src/app/downloads.service.ts index cae16f3..6e19a7a 100644 --- a/ui/src/app/downloads.service.ts +++ b/ui/src/app/downloads.service.ts @@ -26,39 +26,51 @@ interface Download { }) export class DownloadsService { loading = true; - downloads = new Map<string, Download>(); - dlChanges = new Subject(); + queue = new Map<string, Download>(); + done = new Map<string, Download>(); + queueChanged = new Subject(); + doneChanged = new Subject(); constructor(private http: HttpClient, private socket: Socket) { - socket.fromEvent('queue').subscribe((strdata: string) => { + socket.fromEvent('all').subscribe((strdata: string) => { this.loading = false; - this.downloads.clear(); - let data: [[string, Download]] = JSON.parse(strdata); - data.forEach(entry => this.downloads.set(...entry)); - this.dlChanges.next(); + let data: [[[string, Download]], [[string, Download]]] = JSON.parse(strdata); + this.queue.clear(); + data[0].forEach(entry => this.queue.set(...entry)); + this.done.clear(); + data[1].forEach(entry => this.done.set(...entry)); + this.queueChanged.next(); + this.doneChanged.next(); }); socket.fromEvent('added').subscribe((strdata: string) => { let data: Download = JSON.parse(strdata); - this.downloads.set(data.id, data); - this.dlChanges.next(); + this.queue.set(data.id, data); + this.queueChanged.next(); }); socket.fromEvent('updated').subscribe((strdata: string) => { let data: Download = JSON.parse(strdata); - let dl: Download = this.downloads.get(data.id); + let dl: Download = this.queue.get(data.id); data.checked = dl.checked; data.deleting = dl.deleting; - this.downloads.set(data.id, data); - this.dlChanges.next(); + this.queue.set(data.id, data); }); - socket.fromEvent('deleted').subscribe((strdata: string) => { + socket.fromEvent('completed').subscribe((strdata: string) => { + let data: Download = JSON.parse(strdata); + this.queue.delete(data.id); + this.done.set(data.id, data); + this.queueChanged.next(); + this.doneChanged.next(); + }); + socket.fromEvent('canceled').subscribe((strdata: string) => { let data: string = JSON.parse(strdata); - this.downloads.delete(data); - this.dlChanges.next(); + this.queue.delete(data); + this.queueChanged.next(); + }); + socket.fromEvent('cleared').subscribe((strdata: string) => { + let data: string = JSON.parse(strdata); + this.done.delete(data); + this.doneChanged.next(); }); - } - - empty() { - return this.downloads.size == 0; } handleHTTPError(error: HttpErrorResponse) { @@ -72,8 +84,14 @@ export class DownloadsService { ); } - public del(ids: string[]) { - ids.forEach(id => this.downloads.get(id).deleting = true); - return this.http.post('delete', {ids: ids}); + public delById(where: string, ids: string[]) { + ids.forEach(id => this[where].get(id).deleting = true); + return this.http.post('delete', {where: where, ids: ids}); + } + + public delByFilter(where: string, filter: (dl: Download) => boolean) { + let ids: string[] = []; + this[where].forEach((dl: Download) => { if (filter(dl)) ids.push(dl.id) }); + return this.delById(where, ids); } } diff --git a/ui/src/app/master-checkbox.component.ts b/ui/src/app/master-checkbox.component.ts new file mode 100644 index 0000000..683ea71 --- /dev/null +++ b/ui/src/app/master-checkbox.component.ts @@ -0,0 +1,53 @@ +import { Component, Input, ViewChild, ElementRef, Output, EventEmitter } from '@angular/core'; + +interface Checkable { + checked: boolean; +} + +@Component({ + selector: 'app-master-checkbox', + template: ` + <div class="custom-control custom-checkbox"> + <input type="checkbox" class="custom-control-input" id="{{id}}-select-all" #masterCheckbox [(ngModel)]="selected" (change)="clicked()"> + <label class="custom-control-label" for="{{id}}-select-all"></label> + </div> +` +}) +export class MasterCheckboxComponent { + @Input() id: string; + @Input() list: Map<String, Checkable>; + @Output() changed = new EventEmitter<number>(); + + @ViewChild('masterCheckbox', {static: false}) masterCheckbox: ElementRef; + selected: boolean; + + clicked() { + this.list.forEach(item => item.checked = this.selected); + this.selectionChanged(); + } + + selectionChanged() { + if (!this.masterCheckbox) + return; + let checked: number = 0; + this.list.forEach(item => { if(item.checked) checked++ }); + this.selected = checked > 0 && checked == this.list.size; + this.masterCheckbox.nativeElement.indeterminate = checked > 0 && checked < this.list.size; + this.changed.emit(checked); + } +} + +@Component({ + selector: 'app-slave-checkbox', + template: ` + <div class="custom-control custom-checkbox"> + <input type="checkbox" class="custom-control-input" id="{{master.id}}-{{id}}-select" [(ngModel)]="checkable.checked" (change)="master.selectionChanged()"> + <label class="custom-control-label" for="{{master.id}}-{{id}}-select"></label> + </div> +` +}) +export class SlaveCheckboxComponent { + @Input() id: string; + @Input() master: MasterCheckboxComponent; + @Input() checkable: Checkable; +} |