Refactor videos selection components
authorChocobozzz <me@florianbigard.com>
Thu, 4 Apr 2019 08:44:18 +0000 (10:44 +0200)
committerChocobozzz <me@florianbigard.com>
Fri, 5 Apr 2019 08:53:08 +0000 (10:53 +0200)
18 files changed:
client/src/app/+admin/moderation/video-auto-blacklist-list/video-auto-blacklist-list.component.html
client/src/app/+admin/moderation/video-auto-blacklist-list/video-auto-blacklist-list.component.scss
client/src/app/+admin/moderation/video-auto-blacklist-list/video-auto-blacklist-list.component.ts
client/src/app/+my-account/my-account-videos/my-account-videos.component.html
client/src/app/+my-account/my-account-videos/my-account-videos.component.scss
client/src/app/+my-account/my-account-videos/my-account-videos.component.ts
client/src/app/shared/angular/from-now.pipe.ts [new file with mode: 0644]
client/src/app/shared/angular/number-formatter.pipe.ts [new file with mode: 0644]
client/src/app/shared/angular/object-length.pipe.ts [new file with mode: 0644]
client/src/app/shared/angular/peertube-template.directive.ts [new file with mode: 0644]
client/src/app/shared/misc/from-now.pipe.ts [deleted file]
client/src/app/shared/misc/number-formatter.pipe.ts [deleted file]
client/src/app/shared/misc/object-length.pipe.ts [deleted file]
client/src/app/shared/shared.module.ts
client/src/app/shared/video/abstract-video-list.ts
client/src/app/shared/video/videos-selection.component.html [new file with mode: 0644]
client/src/app/shared/video/videos-selection.component.scss [new file with mode: 0644]
client/src/app/shared/video/videos-selection.component.ts [new file with mode: 0644]

index 5ef497fa7d6d7baf3ef9ffeed30844bf65b0309d..62dde60bb7ed841f78e95965133303bcb5b557ab 100644 (file)
@@ -1,33 +1,19 @@
-<div i18n *ngIf="pagination.totalItems === 0">No results.</div>
+<my-videos-selection
+  [(selection)]="selection"
+  [(videosModel)]="videos"
+  [miniatureDisplayOptions]="miniatureDisplayOptions"
+  [titlePage]="titlePage"
+  [getVideosObservableFunction]="getVideosObservableFunction"
+>
+  <ng-template ptTemplate="globalButtons">
+    <span class="action-button action-button-unblacklist-selection" (click)="removeSelectedVideosFromBlacklist()">
+      <my-global-icon iconName="tick"></my-global-icon>
+      <ng-container i18n>Unblacklist</ng-container>
+    </span>
+  </ng-template>
 
-<div myInfiniteScroller [autoInit]="true" (nearOfBottom)="onNearOfBottom()" class="videos">
-  <div class="video" *ngFor="let video of videos; let i = index">
-    <div class="checkbox-container">
-      <my-peertube-checkbox [inputName]="'video-check-' + video.id" [(ngModel)]="checkedVideos[video.id]"></my-peertube-checkbox>
-    </div>
+  <ng-template ptTemplate="rowButtons" let-video>
+    <my-button i18n-label label="Unblacklist" icon="tick" (click)="removeVideoFromBlacklist(video)"></my-button>
+  </ng-template>
 
-    <my-video-miniature [video]="video" [displayAsRow]="true" [displayOptions]="miniatureDisplayOptions"></my-video-miniature>
-
-    <!-- Display only once -->
-    <div class="action-selection-mode" *ngIf="isInSelectionMode() === true && i === 0">
-      <div class="action-selection-mode-child">
-        <span i18n class="action-button action-button-cancel-selection" (click)="abortSelectionMode()">
-          Cancel
-        </span>
-
-        <span class="action-button action-button-unblacklist-selection" (click)="removeSelectedVideosFromBlacklist()">
-          <my-global-icon iconName="tick"></my-global-icon>
-          <ng-container i18n>Unblacklist</ng-container>
-        </span>
-      </div>
-    </div>
-
-    <my-button
-      *ngIf="isInSelectionMode() === false"
-      i18n-label
-      label="Unblacklist"
-      icon="tick"
-      (click)="removeVideoFromBlacklist(video)"
-    ></my-button>
-  </div>
-</div>
+</my-videos-selection>
index e43a2aa7be2ee88bd422a622a2a5879714165aa6..85ebc604102fd1778aac63d88cc0b7a05bd0baab 100644 (file)
@@ -1,67 +1,14 @@
 @import '_variables';
 @import '_mixins';
 
-.action-selection-mode {
-  width: 194px;
-  display: flex;
-  justify-content: flex-end;
+.action-button-unblacklist-selection {
+  display: inline-block;
 
-  .action-selection-mode-child {
-    position: fixed;
+  @include peertube-button;
+  @include orange-button;
+  @include button-with-icon(21px);
 
-    .action-button {
-      display: inline-block;
-    }
-
-    .action-button-cancel-selection {
-      @include peertube-button;
-      @include grey-button;
-
-      margin-right: 10px;
-    }
-
-    .action-button-unblacklist-selection {
-      @include peertube-button;
-      @include orange-button;
-      @include button-with-icon(21px);
-
-      my-global-icon {
-        @include apply-svg-color(#fff);
-      }
-    }
-  }
-}
-
-.video {
-  @include row-blocks;
-
-  &:first-child {
-    margin-top: 47px;
-  }
-
-  .checkbox-container {
-    display: flex;
-    align-items: center;
-    margin-right: 20px;
-    margin-left: 12px;
-  }
-
-  my-video-miniature {
-    flex-grow: 1;
-  }
-}
-
-@media screen and (max-width: $small-view) {
-  .video {
-    flex-direction: column;
-    height: auto;
-
-    .checkbox-container {
-      display: none;
-    }
-
-    my-button {
-      margin-top: 10px;
-    }
+  my-global-icon {
+    @include apply-svg-color(#fff);
   }
 }
index d66a6dcaee0084bf40970b41e3b94d05126adf22..fb2962b47a010e60d72684386771ff496f1eae59 100644 (file)
@@ -1,29 +1,23 @@
-import { Component, OnDestroy, OnInit } from '@angular/core'
+import { Component } from '@angular/core'
 import { I18n } from '@ngx-translate/i18n-polyfill'
 import { ActivatedRoute, Router } from '@angular/router'
-import { AbstractVideoList } from '@app/shared/video/abstract-video-list'
 import { ComponentPagination } from '@app/shared/rest/component-pagination.model'
 import { AuthService, Notifier, ServerService } from '@app/core'
-import { Video } from '@shared/models'
 import { VideoBlacklistService } from '@app/shared'
 import { immutableAssign } from '@app/shared/misc/utils'
 import { ScreenService } from '@app/shared/misc/screen.service'
 import { MiniatureDisplayOptions } from '@app/shared/video/video-miniature.component'
+import { SelectionType } from '@app/shared/video/videos-selection.component'
+import { Video } from '@app/shared/video/video.model'
 
 @Component({
   selector: 'my-video-auto-blacklist-list',
   templateUrl: './video-auto-blacklist-list.component.html',
   styleUrls: [ './video-auto-blacklist-list.component.scss' ]
 })
-export class VideoAutoBlacklistListComponent extends AbstractVideoList implements OnInit, OnDestroy {
+export class VideoAutoBlacklistListComponent {
   titlePage: string
-  checkedVideos: { [ id: number ]: boolean } = {}
-  pagination: ComponentPagination = {
-    currentPage: 1,
-    itemsPerPage: 5,
-    totalItems: null
-  }
-
+  selection: SelectionType = {}
   miniatureDisplayOptions: MiniatureDisplayOptions = {
     date: true,
     views: false,
@@ -34,6 +28,13 @@ export class VideoAutoBlacklistListComponent extends AbstractVideoList implement
     blacklistInfo: false,
     nsfw: true
   }
+  pagination: ComponentPagination = {
+    currentPage: 1,
+    itemsPerPage: 5,
+    totalItems: null
+  }
+  videos: Video[] = []
+  getVideosObservableFunction = this.getVideosObservable.bind(this)
 
   constructor (
     protected router: Router,
@@ -45,42 +46,21 @@ export class VideoAutoBlacklistListComponent extends AbstractVideoList implement
     private i18n: I18n,
     private videoBlacklistService: VideoBlacklistService
   ) {
-    super()
-
     this.titlePage = this.i18n('Auto-blacklisted videos')
   }
 
-  ngOnInit () {
-    super.ngOnInit()
-  }
-
-  ngOnDestroy () {
-    super.ngOnDestroy()
-  }
-
-  abortSelectionMode () {
-    this.checkedVideos = {}
-  }
-
-  isInSelectionMode () {
-    return Object.keys(this.checkedVideos).some(k => this.checkedVideos[k] === true)
-  }
-
   getVideosObservable (page: number) {
     const newPagination = immutableAssign(this.pagination, { currentPage: page })
 
     return this.videoBlacklistService.getAutoBlacklistedAsVideoList(newPagination)
   }
 
-  generateSyndicationList () {
-    throw new Error('Method not implemented.')
-  }
-
   removeVideoFromBlacklist (entry: Video) {
     this.videoBlacklistService.removeVideoFromBlacklist(entry.id).subscribe(
       () => {
         this.notifier.success(this.i18n('Video {{name}} removed from blacklist.', { name: entry.name }))
-        this.reloadVideos()
+
+        this.videos = this.videos.filter(v => v.id !== entry.id)
       },
 
       error => this.notifier.error(error.message)
@@ -88,16 +68,16 @@ export class VideoAutoBlacklistListComponent extends AbstractVideoList implement
   }
 
   removeSelectedVideosFromBlacklist () {
-    const toReleaseVideosIds = Object.keys(this.checkedVideos)
-                                      .filter(k => this.checkedVideos[ k ] === true)
+    const toReleaseVideosIds = Object.keys(this.selection)
+                                      .filter(k => this.selection[ k ] === true)
                                       .map(k => parseInt(k, 10))
 
     this.videoBlacklistService.removeVideoFromBlacklist(toReleaseVideosIds).subscribe(
       () => {
         this.notifier.success(this.i18n('{{num}} videos removed from blacklist.', { num: toReleaseVideosIds.length }))
 
-        this.abortSelectionMode()
-        this.reloadVideos()
+        this.selection = {}
+        this.videos = this.videos.filter(v => toReleaseVideosIds.includes(v.id) === false)
       },
 
       error => this.notifier.error(error.message)
index 3a4054de87eadc87e1ef7ba9b45ef025611ad5f6..d7993fdc266b4e8f230004bd1bfb8ccf300ab027 100644 (file)
@@ -1,39 +1,30 @@
-<div i18n *ngIf="pagination.totalItems === 0">No results.</div>
+<my-videos-selection
+  [(selection)]="selection"
+  [(videosModel)]="videos"
+  [miniatureDisplayOptions]="miniatureDisplayOptions"
+  [titlePage]="titlePage"
+  [getVideosObservableFunction]="getVideosObservableFunction"
+  #videosSelection
+>
+  <ng-template ptTemplate="globalButtons">
+    <span class="action-button action-button-delete-selection" (click)="deleteSelectedVideos()">
+      <my-global-icon iconName="delete"></my-global-icon>
+      <ng-container i18n>Delete</ng-container>
+    </span>
+  </ng-template>
+
+  <ng-template ptTemplate="rowButtons" let-video>
+    <my-delete-button (click)="deleteVideo(video)"></my-delete-button>
+
+    <my-edit-button [routerLink]="[ '/videos', 'update', video.uuid ]"></my-edit-button>
+
+    <my-button i18n-label label="Change ownership"
+               className="action-button-change-ownership"
+               icon="im-with-her"
+               (click)="changeOwnership($event, video)"
+    ></my-button>
+  </ng-template>
+</my-videos-selection>
 
-<div myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [autoInit]="true" class="videos">
-  <div class="video" *ngFor="let video of videos; let i = index">
-    <div class="checkbox-container">
-      <my-peertube-checkbox [inputName]="'video-check-' + video.id" [(ngModel)]="checkedVideos[video.id]"></my-peertube-checkbox>
-    </div>
-
-    <my-video-miniature [video]="video" [displayOptions]="miniatureDisplayOptions" [displayAsRow]="true"></my-video-miniature>
-
-    <!-- Display only once -->
-    <div class="action-selection-mode" *ngIf="isInSelectionMode() === true && i === 0">
-      <div class="action-selection-mode-child">
-        <span i18n class="action-button action-button-cancel-selection" (click)="abortSelectionMode()">
-          Cancel
-        </span>
-
-        <span class="action-button action-button-delete-selection" (click)="deleteSelectedVideos()">
-          <my-global-icon iconName="delete"></my-global-icon>
-          <ng-container i18n>Delete</ng-container>
-        </span>
-      </div>
-    </div>
-
-    <div class="video-buttons" *ngIf="isInSelectionMode() === false">
-      <my-delete-button (click)="deleteVideo(video)"></my-delete-button>
-
-      <my-edit-button [routerLink]="[ '/videos', 'update', video.uuid ]"></my-edit-button>
-
-      <my-button i18n-label label="Change ownership"
-                 className="action-button-change-ownership"
-                 icon="im-with-her"
-                 (click)="changeOwnership($event, video)"
-      ></my-button>
-    </div>
-  </div>
-</div>
 
 <my-video-change-ownership #videoChangeOwnershipModal></my-video-change-ownership>
index 405ded3f809810b4230b9cd4da0eae4fcbf80b41..87398e7c8705f5fa9938d8774db85833ae0c5825 100644 (file)
@@ -1,75 +1,19 @@
 @import '_variables';
 @import '_mixins';
 
-.action-selection-mode {
-  width: 174px;
-  display: flex;
-  justify-content: flex-end;
+.action-button-delete-selection {
+  display: inline-block;
 
-  .action-selection-mode-child {
-    position: fixed;
+  @include peertube-button;
+  @include orange-button;
+  @include button-with-icon(21px);
 
-    .action-button {
-      display: inline-block;
-    }
-
-    .action-button-cancel-selection {
-      @include peertube-button;
-      @include grey-button;
-
-      margin-right: 10px;
-    }
-
-    .action-button-delete-selection {
-      @include peertube-button;
-      @include orange-button;
-      @include button-with-icon(21px);
-
-      my-global-icon {
-        @include apply-svg-color(#fff);
-      }
-    }
-  }
-}
-
-.video {
-  @include row-blocks;
-
-  &:first-child {
-    margin-top: 47px;
-  }
-
-  .checkbox-container {
-    display: flex;
-    align-items: center;
-    margin-right: 20px;
-    margin-left: 12px;
-  }
-
-  my-video-miniature {
-    flex-grow: 1;
-  }
-
-  .video-buttons {
-    min-width: 190px;
-
-    *:not(:last-child) {
-      margin-right: 10px;
-    }
+  my-global-icon {
+    @include apply-svg-color(#fff);
   }
 }
 
-@media screen and (max-width: $small-view) {
-  .video {
-    flex-direction: column;
-    height: auto;
-
-    .checkbox-container {
-      display: none;
-    }
-
-    .video-buttons {
-      margin-top: 10px;
-    }
-  }
+my-delete-button,
+my-edit-button {
+  margin-right: 10px;
 }
index bbe86af730e3e97abf7aeed516dfca8666d31fe1..5f29364a857ed78c3e04d85cc3652e4b1b6bc4ab 100644 (file)
@@ -1,31 +1,33 @@
 import { concat, Observable } from 'rxjs'
 import { tap, toArray } from 'rxjs/operators'
-import { Component, Inject, LOCALE_ID, OnDestroy, OnInit, ViewChild } from '@angular/core'
+import { Component, ViewChild } from '@angular/core'
 import { ActivatedRoute, Router } from '@angular/router'
 import { immutableAssign } from '@app/shared/misc/utils'
 import { ComponentPagination } from '@app/shared/rest/component-pagination.model'
 import { Notifier, ServerService } from '@app/core'
 import { AuthService } from '../../core/auth'
 import { ConfirmService } from '../../core/confirm'
-import { AbstractVideoList } from '../../shared/video/abstract-video-list'
 import { Video } from '../../shared/video/video.model'
 import { VideoService } from '../../shared/video/video.service'
 import { I18n } from '@ngx-translate/i18n-polyfill'
-import { VideoPrivacy, VideoState } from '../../../../../shared/models/videos'
 import { ScreenService } from '@app/shared/misc/screen.service'
 import { VideoChangeOwnershipComponent } from './video-change-ownership/video-change-ownership.component'
 import { MiniatureDisplayOptions } from '@app/shared/video/video-miniature.component'
+import { SelectionType, VideosSelectionComponent } from '@app/shared/video/videos-selection.component'
+import { VideoSortField } from '@app/shared/video/sort-field.type'
+import { DisableForReuseHook } from '@app/core/routing/disable-for-reuse-hook'
 
 @Component({
   selector: 'my-account-videos',
   templateUrl: './my-account-videos.component.html',
   styleUrls: [ './my-account-videos.component.scss' ]
 })
-export class MyAccountVideosComponent extends AbstractVideoList implements OnInit, OnDestroy {
+export class MyAccountVideosComponent implements DisableForReuseHook {
+  @ViewChild('videosSelection') videosSelection: VideosSelectionComponent
   @ViewChild('videoChangeOwnershipModal') videoChangeOwnershipModal: VideoChangeOwnershipComponent
 
   titlePage: string
-  checkedVideos: { [ id: number ]: boolean } = {}
+  selection: SelectionType = {}
   pagination: ComponentPagination = {
     currentPage: 1,
     itemsPerPage: 5,
@@ -40,6 +42,8 @@ export class MyAccountVideosComponent extends AbstractVideoList implements OnIni
     state: true,
     blacklistInfo: true
   }
+  videos: Video[] = []
+  getVideosObservableFunction = this.getVideosObservable.bind(this)
 
   constructor (
     protected router: Router,
@@ -50,43 +54,28 @@ export class MyAccountVideosComponent extends AbstractVideoList implements OnIni
     protected screenService: ScreenService,
     private i18n: I18n,
     private confirmService: ConfirmService,
-    private videoService: VideoService,
-    @Inject(LOCALE_ID) private localeId: string
+    private videoService: VideoService
   ) {
-    super()
-
     this.titlePage = this.i18n('My videos')
   }
 
-  ngOnInit () {
-    super.ngOnInit()
-  }
-
-  ngOnDestroy () {
-    super.ngOnDestroy()
+  disableForReuse () {
+    this.videosSelection.disableForReuse()
   }
 
-  abortSelectionMode () {
-    this.checkedVideos = {}
+  enabledForReuse () {
+    this.videosSelection.enabledForReuse()
   }
 
-  isInSelectionMode () {
-    return Object.keys(this.checkedVideos).some(k => this.checkedVideos[ k ] === true)
-  }
-
-  getVideosObservable (page: number) {
+  getVideosObservable (page: number, sort: VideoSortField) {
     const newPagination = immutableAssign(this.pagination, { currentPage: page })
 
-    return this.videoService.getMyVideos(newPagination, this.sort)
-  }
-
-  generateSyndicationList () {
-    throw new Error('Method not implemented.')
+    return this.videoService.getMyVideos(newPagination, sort)
   }
 
   async deleteSelectedVideos () {
-    const toDeleteVideosIds = Object.keys(this.checkedVideos)
-                                    .filter(k => this.checkedVideos[ k ] === true)
+    const toDeleteVideosIds = Object.keys(this.selection)
+                                    .filter(k => this.selection[ k ] === true)
                                     .map(k => parseInt(k, 10))
 
     const res = await this.confirmService.confirm(
@@ -109,7 +98,7 @@ export class MyAccountVideosComponent extends AbstractVideoList implements OnIni
         () => {
           this.notifier.success(this.i18n('{{deleteLength}} videos deleted.', { deleteLength: toDeleteVideosIds.length }))
 
-          this.abortSelectionMode()
+          this.selection = {}
         },
 
         err => this.notifier.error(err.message)
@@ -127,7 +116,7 @@ export class MyAccountVideosComponent extends AbstractVideoList implements OnIni
         .subscribe(
           () => {
             this.notifier.success(this.i18n('Video {{videoName}} deleted.', { videoName: video.name }))
-            this.reloadVideos()
+            this.removeVideoFromArray(video.id)
           },
 
           error => this.notifier.error(error.message)
@@ -139,27 +128,6 @@ export class MyAccountVideosComponent extends AbstractVideoList implements OnIni
     this.videoChangeOwnershipModal.show(video)
   }
 
-  getStateLabel (video: Video) {
-    let suffix: string
-
-    if (video.privacy.id !== VideoPrivacy.PRIVATE && video.state.id === VideoState.PUBLISHED) {
-      suffix = this.i18n('Published')
-    } else if (video.scheduledUpdate) {
-      const updateAt = new Date(video.scheduledUpdate.updateAt.toString()).toLocaleString(this.localeId)
-      suffix = this.i18n('Publication scheduled on ') + updateAt
-    } else if (video.state.id === VideoState.TO_TRANSCODE && video.waitTranscoding === true) {
-      suffix = this.i18n('Waiting transcoding')
-    } else if (video.state.id === VideoState.TO_TRANSCODE) {
-      suffix = this.i18n('To transcode')
-    } else if (video.state.id === VideoState.TO_IMPORT) {
-      suffix = this.i18n('To import')
-    } else {
-      return ''
-    }
-
-    return ' - ' + suffix
-  }
-
   private removeVideoFromArray (id: number) {
     this.videos = this.videos.filter(v => v.id !== id)
   }
diff --git a/client/src/app/shared/angular/from-now.pipe.ts b/client/src/app/shared/angular/from-now.pipe.ts
new file mode 100644 (file)
index 0000000..3a9a764
--- /dev/null
@@ -0,0 +1,40 @@
+import { Pipe, PipeTransform } from '@angular/core'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+
+// Thanks: https://stackoverflow.com/questions/3177836/how-to-format-time-since-xxx-e-g-4-minutes-ago-similar-to-stack-exchange-site
+@Pipe({ name: 'myFromNow' })
+export class FromNowPipe implements PipeTransform {
+
+  constructor (private i18n: I18n) { }
+
+  transform (arg: number | Date | string) {
+    const argDate = new Date(arg)
+    const seconds = Math.floor((Date.now() - argDate.getTime()) / 1000)
+
+    let interval = Math.floor(seconds / 31536000)
+    if (interval > 1) {
+      return this.i18n('{{interval}} years ago', { interval })
+    }
+
+    interval = Math.floor(seconds / 2592000)
+    if (interval > 1) return this.i18n('{{interval}} months ago', { interval })
+    if (interval === 1) return this.i18n('{{interval}} month ago', { interval })
+
+    interval = Math.floor(seconds / 604800)
+    if (interval > 1) return this.i18n('{{interval}} weeks ago', { interval })
+    if (interval === 1) return this.i18n('{{interval}} week ago', { interval })
+
+    interval = Math.floor(seconds / 86400)
+    if (interval > 1) return this.i18n('{{interval}} days ago', { interval })
+    if (interval === 1) return this.i18n('{{interval}} day ago', { interval })
+
+    interval = Math.floor(seconds / 3600)
+    if (interval > 1) return this.i18n('{{interval}} hours ago', { interval })
+    if (interval === 1) return this.i18n('{{interval}} hour ago', { interval })
+
+    interval = Math.floor(seconds / 60)
+    if (interval >= 1) return this.i18n('{{interval}} min ago', { interval })
+
+    return this.i18n('{{interval}} sec ago', { interval: Math.max(0, seconds) })
+  }
+}
diff --git a/client/src/app/shared/angular/number-formatter.pipe.ts b/client/src/app/shared/angular/number-formatter.pipe.ts
new file mode 100644 (file)
index 0000000..8a0756a
--- /dev/null
@@ -0,0 +1,19 @@
+import { Pipe, PipeTransform } from '@angular/core'
+
+// Thanks: https://github.com/danrevah/ngx-pipes/blob/master/src/pipes/math/bytes.ts
+
+@Pipe({ name: 'myNumberFormatter' })
+export class NumberFormatterPipe implements PipeTransform {
+  private dictionary: Array<{max: number, type: string}> = [
+    { max: 1000, type: '' },
+    { max: 1000000, type: 'K' },
+    { max: 1000000000, type: 'M' }
+  ]
+
+  transform (value: number) {
+    const format = this.dictionary.find(d => value < d.max) || this.dictionary[this.dictionary.length - 1]
+    const calc = Math.floor(value / (format.max / 1000))
+
+    return `${calc}${format.type}`
+  }
+}
diff --git a/client/src/app/shared/angular/object-length.pipe.ts b/client/src/app/shared/angular/object-length.pipe.ts
new file mode 100644 (file)
index 0000000..84d1820
--- /dev/null
@@ -0,0 +1,8 @@
+import { Pipe, PipeTransform } from '@angular/core'
+
+@Pipe({ name: 'myObjectLength' })
+export class ObjectLengthPipe implements PipeTransform {
+  transform (value: Object) {
+    return Object.keys(value).length
+  }
+}
diff --git a/client/src/app/shared/angular/peertube-template.directive.ts b/client/src/app/shared/angular/peertube-template.directive.ts
new file mode 100644 (file)
index 0000000..a514b60
--- /dev/null
@@ -0,0 +1,12 @@
+import { Directive, Input, TemplateRef } from '@angular/core'
+
+@Directive({
+  selector: '[ptTemplate]'
+})
+export class PeerTubeTemplateDirective {
+  @Input('ptTemplate') name: string
+
+  constructor (public template: TemplateRef<any>) {
+    // empty
+  }
+}
diff --git a/client/src/app/shared/misc/from-now.pipe.ts b/client/src/app/shared/misc/from-now.pipe.ts
deleted file mode 100644 (file)
index 3a9a764..0000000
+++ /dev/null
@@ -1,40 +0,0 @@
-import { Pipe, PipeTransform } from '@angular/core'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-
-// Thanks: https://stackoverflow.com/questions/3177836/how-to-format-time-since-xxx-e-g-4-minutes-ago-similar-to-stack-exchange-site
-@Pipe({ name: 'myFromNow' })
-export class FromNowPipe implements PipeTransform {
-
-  constructor (private i18n: I18n) { }
-
-  transform (arg: number | Date | string) {
-    const argDate = new Date(arg)
-    const seconds = Math.floor((Date.now() - argDate.getTime()) / 1000)
-
-    let interval = Math.floor(seconds / 31536000)
-    if (interval > 1) {
-      return this.i18n('{{interval}} years ago', { interval })
-    }
-
-    interval = Math.floor(seconds / 2592000)
-    if (interval > 1) return this.i18n('{{interval}} months ago', { interval })
-    if (interval === 1) return this.i18n('{{interval}} month ago', { interval })
-
-    interval = Math.floor(seconds / 604800)
-    if (interval > 1) return this.i18n('{{interval}} weeks ago', { interval })
-    if (interval === 1) return this.i18n('{{interval}} week ago', { interval })
-
-    interval = Math.floor(seconds / 86400)
-    if (interval > 1) return this.i18n('{{interval}} days ago', { interval })
-    if (interval === 1) return this.i18n('{{interval}} day ago', { interval })
-
-    interval = Math.floor(seconds / 3600)
-    if (interval > 1) return this.i18n('{{interval}} hours ago', { interval })
-    if (interval === 1) return this.i18n('{{interval}} hour ago', { interval })
-
-    interval = Math.floor(seconds / 60)
-    if (interval >= 1) return this.i18n('{{interval}} min ago', { interval })
-
-    return this.i18n('{{interval}} sec ago', { interval: Math.max(0, seconds) })
-  }
-}
diff --git a/client/src/app/shared/misc/number-formatter.pipe.ts b/client/src/app/shared/misc/number-formatter.pipe.ts
deleted file mode 100644 (file)
index 8a0756a..0000000
+++ /dev/null
@@ -1,19 +0,0 @@
-import { Pipe, PipeTransform } from '@angular/core'
-
-// Thanks: https://github.com/danrevah/ngx-pipes/blob/master/src/pipes/math/bytes.ts
-
-@Pipe({ name: 'myNumberFormatter' })
-export class NumberFormatterPipe implements PipeTransform {
-  private dictionary: Array<{max: number, type: string}> = [
-    { max: 1000, type: '' },
-    { max: 1000000, type: 'K' },
-    { max: 1000000000, type: 'M' }
-  ]
-
-  transform (value: number) {
-    const format = this.dictionary.find(d => value < d.max) || this.dictionary[this.dictionary.length - 1]
-    const calc = Math.floor(value / (format.max / 1000))
-
-    return `${calc}${format.type}`
-  }
-}
diff --git a/client/src/app/shared/misc/object-length.pipe.ts b/client/src/app/shared/misc/object-length.pipe.ts
deleted file mode 100644 (file)
index 84d1820..0000000
+++ /dev/null
@@ -1,8 +0,0 @@
-import { Pipe, PipeTransform } from '@angular/core'
-
-@Pipe({ name: 'myObjectLength' })
-export class ObjectLengthPipe implements PipeTransform {
-  transform (value: Object) {
-    return Object.keys(value).length
-  }
-}
index 3647fc786f02408dfa4b87b91fdbcdf7fe9d9bae..68225b457fbf6351d58fbd7ef1deb3b2db531b82 100644 (file)
@@ -14,10 +14,7 @@ import { AUTH_INTERCEPTOR_PROVIDER } from './auth'
 import { ButtonComponent } from './buttons/button.component'
 import { DeleteButtonComponent } from './buttons/delete-button.component'
 import { EditButtonComponent } from './buttons/edit-button.component'
-import { FromNowPipe } from './misc/from-now.pipe'
 import { LoaderComponent } from './misc/loader.component'
-import { NumberFormatterPipe } from './misc/number-formatter.pipe'
-import { ObjectLengthPipe } from './misc/object-length.pipe'
 import { RestExtractor, RestService } from './rest'
 import { UserService } from './users'
 import { VideoAbuseService } from './video-abuse'
@@ -78,6 +75,11 @@ import { VideoPlaylistMiniatureComponent } from '@app/shared/video-playlist/vide
 import { VideoAddToPlaylistComponent } from '@app/shared/video-playlist/video-add-to-playlist.component'
 import { TimestampInputComponent } from '@app/shared/forms/timestamp-input.component'
 import { VideoPlaylistElementMiniatureComponent } from '@app/shared/video-playlist/video-playlist-element-miniature.component'
+import { VideosSelectionComponent } from '@app/shared/video/videos-selection.component'
+import { NumberFormatterPipe } from '@app/shared/angular/number-formatter.pipe'
+import { ObjectLengthPipe } from '@app/shared/angular/object-length.pipe'
+import { FromNowPipe } from '@app/shared/angular/from-now.pipe'
+import { PeerTubeTemplateDirective } from '@app/shared/angular/peertube-template.directive'
 
 @NgModule({
   imports: [
@@ -107,6 +109,7 @@ import { VideoPlaylistElementMiniatureComponent } from '@app/shared/video-playli
     VideoPlaylistMiniatureComponent,
     VideoAddToPlaylistComponent,
     VideoPlaylistElementMiniatureComponent,
+    VideosSelectionComponent,
 
     FeedComponent,
 
@@ -114,10 +117,12 @@ import { VideoPlaylistElementMiniatureComponent } from '@app/shared/video-playli
     DeleteButtonComponent,
     EditButtonComponent,
 
-    ActionDropdownComponent,
     NumberFormatterPipe,
     ObjectLengthPipe,
     FromNowPipe,
+    PeerTubeTemplateDirective,
+
+    ActionDropdownComponent,
     MarkdownTextareaComponent,
     InfiniteScrollerDirective,
     TextareaAutoResizeDirective,
@@ -166,6 +171,7 @@ import { VideoPlaylistElementMiniatureComponent } from '@app/shared/video-playli
     VideoPlaylistMiniatureComponent,
     VideoAddToPlaylistComponent,
     VideoPlaylistElementMiniatureComponent,
+    VideosSelectionComponent,
 
     FeedComponent,
 
@@ -197,7 +203,8 @@ import { VideoPlaylistElementMiniatureComponent } from '@app/shared/video-playli
 
     NumberFormatterPipe,
     ObjectLengthPipe,
-    FromNowPipe
+    FromNowPipe,
+    PeerTubeTemplateDirective
   ],
 
   providers: [
index 467f629eae20e18983d2ccdd5e013e4a3a9bbc29..09965012999bdf5e0a4d485c6e83eea41d922a0d 100644 (file)
@@ -102,6 +102,8 @@ export abstract class AbstractVideoList implements OnInit, OnDestroy, DisableFor
       ({ videos, totalVideos }) => {
         this.pagination.totalItems = totalVideos
         this.videos = this.videos.concat(videos)
+
+        this.onMoreVideos()
       },
 
       error => this.notifier.error(error.message)
@@ -118,6 +120,9 @@ export abstract class AbstractVideoList implements OnInit, OnDestroy, DisableFor
     throw new Error('toggleModerationDisplay is not implemented')
   }
 
+  // On videos hook for children that want to do something
+  protected onMoreVideos () { /* empty */ }
+
   protected loadRouteParams (routeParams: { [ key: string ]: any }) {
     this.sort = routeParams[ 'sort' ] as VideoSortField || this.defaultSort
     this.categoryOneOf = routeParams[ 'categoryOneOf' ]
diff --git a/client/src/app/shared/video/videos-selection.component.html b/client/src/app/shared/video/videos-selection.component.html
new file mode 100644 (file)
index 0000000..6f3401b
--- /dev/null
@@ -0,0 +1,26 @@
+<div class="no-results" i18n *ngIf="pagination.totalItems === 0">No results.</div>
+
+<div myInfiniteScroller [autoInit]="true" (nearOfBottom)="onNearOfBottom()" class="videos">
+  <div class="video" *ngFor="let video of videos; let i = index">
+    <div class="checkbox-container">
+      <my-peertube-checkbox [inputName]="'video-check-' + video.id" [(ngModel)]="_selection[video.id]"></my-peertube-checkbox>
+    </div>
+
+    <my-video-miniature [video]="video" [displayAsRow]="true" [displayOptions]="miniatureDisplayOptions"></my-video-miniature>
+
+    <!-- Display only once -->
+    <div class="action-selection-mode" *ngIf="isInSelectionMode() === true && i === 0">
+      <div class="action-selection-mode-child">
+        <span i18n class="action-button action-button-cancel-selection" (click)="abortSelectionMode()">
+          Cancel
+        </span>
+
+        <ng-container *ngTemplateOutlet="globalButtonsTemplate"></ng-container>
+      </div>
+    </div>
+
+    <ng-container  *ngIf="isInSelectionMode() === false">
+      <ng-container *ngTemplateOutlet="rowButtonsTemplate; context: {$implicit: video}"></ng-container>
+    </ng-container>
+  </div>
+</div>
diff --git a/client/src/app/shared/video/videos-selection.component.scss b/client/src/app/shared/video/videos-selection.component.scss
new file mode 100644 (file)
index 0000000..d3cbabf
--- /dev/null
@@ -0,0 +1,57 @@
+@import '_variables';
+@import '_mixins';
+
+.action-selection-mode {
+  display: flex;
+  justify-content: flex-end;
+  flex-grow: 1;
+
+  .action-selection-mode-child {
+    position: fixed;
+
+    .action-button {
+      display: inline-block;
+    }
+
+    .action-button-cancel-selection {
+      @include peertube-button;
+      @include grey-button;
+
+      margin-right: 10px;
+    }
+  }
+}
+
+.video {
+  @include row-blocks;
+
+  &:first-child {
+    margin-top: 47px;
+  }
+
+  .checkbox-container {
+    display: flex;
+    align-items: center;
+    margin-right: 20px;
+    margin-left: 12px;
+  }
+
+  my-video-miniature {
+    flex-grow: 1;
+  }
+}
+
+@media screen and (max-width: $small-view) {
+  .video {
+    flex-direction: column;
+    height: auto;
+
+    .checkbox-container {
+      display: none;
+    }
+
+    my-button {
+      margin-top: 10px;
+    }
+  }
+}
diff --git a/client/src/app/shared/video/videos-selection.component.ts b/client/src/app/shared/video/videos-selection.component.ts
new file mode 100644 (file)
index 0000000..b6bedaf
--- /dev/null
@@ -0,0 +1,112 @@
+import {
+  AfterContentInit,
+  Component,
+  ContentChildren,
+  EventEmitter,
+  Input,
+  OnDestroy,
+  OnInit,
+  Output,
+  QueryList,
+  TemplateRef
+} from '@angular/core'
+import { ActivatedRoute, Router } from '@angular/router'
+import { AbstractVideoList } from '@app/shared/video/abstract-video-list'
+import { AuthService, Notifier, ServerService } from '@app/core'
+import { ScreenService } from '@app/shared/misc/screen.service'
+import { MiniatureDisplayOptions } from '@app/shared/video/video-miniature.component'
+import { Observable } from 'rxjs'
+import { Video } from '@app/shared/video/video.model'
+import { PeerTubeTemplateDirective } from '@app/shared/angular/peertube-template.directive'
+import { VideoSortField } from '@app/shared/video/sort-field.type'
+
+export type SelectionType = { [ id: number ]: boolean }
+
+@Component({
+  selector: 'my-videos-selection',
+  templateUrl: './videos-selection.component.html',
+  styleUrls: [ './videos-selection.component.scss' ]
+})
+export class VideosSelectionComponent extends AbstractVideoList implements OnInit, OnDestroy, AfterContentInit {
+  @Input() titlePage: string
+  @Input() miniatureDisplayOptions: MiniatureDisplayOptions
+  @Input() getVideosObservableFunction: (page: number, sort?: VideoSortField) => Observable<{ videos: Video[], totalVideos: number }>
+  @ContentChildren(PeerTubeTemplateDirective) templates: QueryList<PeerTubeTemplateDirective>
+
+  @Output() selectionChange = new EventEmitter<SelectionType>()
+  @Output() videosModelChange = new EventEmitter<Video[]>()
+
+  _selection: SelectionType = {}
+
+  rowButtonsTemplate: TemplateRef<any>
+  globalButtonsTemplate: TemplateRef<any>
+
+  constructor (
+    protected router: Router,
+    protected route: ActivatedRoute,
+    protected notifier: Notifier,
+    protected authService: AuthService,
+    protected screenService: ScreenService,
+    protected serverService: ServerService
+  ) {
+    super()
+  }
+
+  ngAfterContentInit () {
+    {
+      const t = this.templates.find(t => t.name === 'rowButtons')
+      if (t) this.rowButtonsTemplate = t.template
+    }
+
+    {
+      const t = this.templates.find(t => t.name === 'globalButtons')
+      if (t) this.globalButtonsTemplate = t.template
+    }
+  }
+
+  @Input() get selection () {
+    return this._selection
+  }
+
+  set selection (selection: SelectionType) {
+    this._selection = selection
+    this.selectionChange.emit(this._selection)
+  }
+
+  @Input() get videosModel () {
+    return this.videos
+  }
+
+  set videosModel (videos: Video[]) {
+    this.videos = videos
+    this.videosModelChange.emit(this.videos)
+  }
+
+  ngOnInit () {
+    super.ngOnInit()
+  }
+
+  ngOnDestroy () {
+    super.ngOnDestroy()
+  }
+
+  getVideosObservable (page: number) {
+    return this.getVideosObservableFunction(page, this.sort)
+  }
+
+  abortSelectionMode () {
+    this._selection = {}
+  }
+
+  isInSelectionMode () {
+    return Object.keys(this._selection).some(k => this._selection[ k ] === true)
+  }
+
+  generateSyndicationList () {
+    throw new Error('Method not implemented.')
+  }
+
+  protected onMoreVideos () {
+    this.videosModel = this.videos
+  }
+}