]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/commitdiff
Add/update/delete/list my playlists
authorChocobozzz <me@florianbigard.com>
Wed, 6 Mar 2019 14:36:44 +0000 (15:36 +0100)
committerChocobozzz <chocobozzz@cpy.re>
Mon, 18 Mar 2019 10:17:59 +0000 (11:17 +0100)
48 files changed:
client/src/app/+my-account/my-account-routing.module.ts
client/src/app/+my-account/my-account-subscriptions/my-account-subscriptions.component.ts
client/src/app/+my-account/my-account-video-channels/my-account-video-channels.component.html
client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-create.component.ts [new file with mode: 0644]
client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.component.html [new file with mode: 0644]
client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.component.scss [new file with mode: 0644]
client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.ts [new file with mode: 0644]
client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-update.component.ts [new file with mode: 0644]
client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.html [new file with mode: 0644]
client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.scss [new file with mode: 0644]
client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.ts [new file with mode: 0644]
client/src/app/+my-account/my-account.component.ts
client/src/app/+my-account/my-account.module.ts
client/src/app/app.component.ts
client/src/app/core/server/server.service.ts
client/src/app/shared/buttons/button.component.ts
client/src/app/shared/forms/form-validators/index.ts
client/src/app/shared/forms/form-validators/video-playlist-validators.service.ts [new file with mode: 0644]
client/src/app/shared/images/global-icon.component.html [moved from client/src/app/shared/icons/global-icon.component.html with 100% similarity]
client/src/app/shared/images/global-icon.component.scss [moved from client/src/app/shared/icons/global-icon.component.scss with 100% similarity]
client/src/app/shared/images/global-icon.component.ts [moved from client/src/app/shared/icons/global-icon.component.ts with 100% similarity]
client/src/app/shared/images/image-upload.component.html [moved from client/src/app/videos/+video-edit/shared/video-image.component.html with 100% similarity]
client/src/app/shared/images/image-upload.component.scss [moved from client/src/app/videos/+video-edit/shared/video-image.component.scss with 100% similarity]
client/src/app/shared/images/image-upload.component.ts [moved from client/src/app/videos/+video-edit/shared/video-image.component.ts with 84% similarity]
client/src/app/shared/misc/utils.ts
client/src/app/shared/shared.module.ts
client/src/app/shared/video-playlist/video-playlist-miniature.component.html [new file with mode: 0644]
client/src/app/shared/video-playlist/video-playlist-miniature.component.scss [new file with mode: 0644]
client/src/app/shared/video-playlist/video-playlist-miniature.component.ts [new file with mode: 0644]
client/src/app/shared/video-playlist/video-playlist.model.ts [new file with mode: 0644]
client/src/app/shared/video-playlist/video-playlist.service.ts [new file with mode: 0644]
client/src/app/shared/video/abstract-video-list.scss
client/src/app/shared/video/video-miniature.component.scss
client/src/app/shared/video/video-thumbnail.component.html
client/src/app/shared/video/video-thumbnail.component.scss
client/src/app/shared/video/video.model.ts
client/src/app/videos/+video-edit/shared/video-edit.component.html
client/src/app/videos/+video-edit/shared/video-edit.module.ts
client/src/app/videos/video-list/video-overview.component.scss
client/src/sass/include/_miniature.scss [new file with mode: 0644]
client/src/sass/include/_mixins.scss
client/tsconfig.json
scripts/i18n/create-custom-files.ts
server/initializers/constants.ts
server/middlewares/validators/videos/video-playlists.ts
shared/models/videos/index.ts
shared/models/videos/playlist/video-playlist.model.ts
shared/utils/videos/video-playlists.ts

index 9996218ca1032cd586da3f58b625a79aaccf7ea4..0193afff7dee2307cd0fef0797e9edcc6aaef906 100644 (file)
@@ -15,6 +15,13 @@ import { MyAccountBlocklistComponent } from '@app/+my-account/my-account-blockli
 import { MyAccountServerBlocklistComponent } from '@app/+my-account/my-account-blocklist/my-account-server-blocklist.component'
 import { MyAccountHistoryComponent } from '@app/+my-account/my-account-history/my-account-history.component'
 import { MyAccountNotificationsComponent } from '@app/+my-account/my-account-notifications/my-account-notifications.component'
+import { MyAccountVideoPlaylistsComponent } from '@app/+my-account/my-account-video-playlists/my-account-video-playlists.component'
+import {
+  MyAccountVideoPlaylistCreateComponent
+} from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-create.component'
+import {
+  MyAccountVideoPlaylistUpdateComponent
+} from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-update.component'
 
 const myAccountRoutes: Routes = [
   {
@@ -36,6 +43,7 @@ const myAccountRoutes: Routes = [
           }
         }
       },
+
       {
         path: 'video-channels',
         component: MyAccountVideoChannelsComponent,
@@ -63,6 +71,35 @@ const myAccountRoutes: Routes = [
           }
         }
       },
+
+      {
+        path: 'video-playlists',
+        component: MyAccountVideoPlaylistsComponent,
+        data: {
+          meta: {
+            title: 'Account playlists'
+          }
+        }
+      },
+      {
+        path: 'video-playlists/create',
+        component: MyAccountVideoPlaylistCreateComponent,
+        data: {
+          meta: {
+            title: 'Create new playlist'
+          }
+        }
+      },
+      {
+        path: 'video-playlists/update/:videoPlaylistId',
+        component: MyAccountVideoPlaylistUpdateComponent,
+        data: {
+          meta: {
+            title: 'Update playlist'
+          }
+        }
+      },
+
       {
         path: 'videos',
         component: MyAccountVideosComponent,
index 9d2dccdf0accc3e879547d898b31aadfefab329e..6ce22989b44184d3b87460ddddcec916ee230e9e 100644 (file)
@@ -1,7 +1,6 @@
 import { Component, OnInit } from '@angular/core'
 import { Notifier } from '@app/core'
 import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
-import { I18n } from '@ngx-translate/i18n-polyfill'
 import { UserSubscriptionService } from '@app/shared/user-subscription'
 import { ComponentPagination } from '@app/shared/rest/component-pagination.model'
 
@@ -21,8 +20,7 @@ export class MyAccountSubscriptionsComponent implements OnInit {
 
   constructor (
     private userSubscriptionService: UserSubscriptionService,
-    private notifier: Notifier,
-    private i18n: I18n
+    private notifier: Notifier
   ) {}
 
   ngOnInit () {
index 51db2e75d7d4c0e3e2df1ebd46156068d224d05b..11e87ba79046d26d5fd1519e9108750a3dda0fe8 100644 (file)
@@ -1,7 +1,7 @@
 <div class="video-channels-header">
   <a class="create-button" routerLink="create">
     <my-global-icon iconName="add"></my-global-icon>
-    <ng-container i18n>Create another video channel</ng-container>
+    <ng-container i18n>Create a new video channel</ng-container>
   </a>
 </div>
 
diff --git a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-create.component.ts b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-create.component.ts
new file mode 100644 (file)
index 0000000..61b61e2
--- /dev/null
@@ -0,0 +1,89 @@
+import { Component, OnInit } from '@angular/core'
+import { Router } from '@angular/router'
+import { AuthService, Notifier, ServerService } from '@app/core'
+import { MyAccountVideoPlaylistEdit } from './my-account-video-playlist-edit'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
+import { VideoPlaylistValidatorsService } from '@app/shared'
+import { VideoPlaylistCreate } from '@shared/models/videos/playlist/video-playlist-create.model'
+import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service'
+import { VideoConstant } from '@shared/models'
+import { VideoPlaylistPrivacy } from '@shared/models/videos/playlist/video-playlist-privacy.model'
+import { populateAsyncUserVideoChannels } from '@app/shared/misc/utils'
+
+@Component({
+  selector: 'my-account-video-playlist-create',
+  templateUrl: './my-account-video-playlist-edit.component.html',
+  styleUrls: [ './my-account-video-playlist-edit.component.scss' ]
+})
+export class MyAccountVideoPlaylistCreateComponent extends MyAccountVideoPlaylistEdit implements OnInit {
+  error: string
+  videoPlaylistPrivacies: VideoConstant<VideoPlaylistPrivacy>[] = []
+
+  constructor (
+    protected formValidatorService: FormValidatorService,
+    private authService: AuthService,
+    private videoPlaylistValidatorsService: VideoPlaylistValidatorsService,
+    private notifier: Notifier,
+    private router: Router,
+    private videoPlaylistService: VideoPlaylistService,
+    private serverService: ServerService,
+    private i18n: I18n
+  ) {
+    super()
+  }
+
+  ngOnInit () {
+    this.buildForm({
+      'display-name': this.videoPlaylistValidatorsService.VIDEO_PLAYLIST_DISPLAY_NAME,
+      privacy: this.videoPlaylistValidatorsService.VIDEO_PLAYLIST_PRIVACY,
+      description: this.videoPlaylistValidatorsService.VIDEO_PLAYLIST_DESCRIPTION,
+      videoChannelId: this.videoPlaylistValidatorsService.VIDEO_PLAYLIST_CHANNEL_ID,
+      thumbnailfile: null
+    })
+
+    populateAsyncUserVideoChannels(this.authService, this.userVideoChannels)
+
+    this.serverService.videoPlaylistPrivaciesLoaded.subscribe(
+      () => {
+        this.videoPlaylistPrivacies = this.serverService.getVideoPlaylistPrivacies()
+
+        this.form.patchValue({
+          privacy: VideoPlaylistPrivacy.PRIVATE
+        })
+      }
+    )
+  }
+
+  formValidated () {
+    this.error = undefined
+
+    const body = this.form.value
+    const videoPlaylistCreate: VideoPlaylistCreate = {
+      displayName: body['display-name'],
+      privacy: body.privacy,
+      description: body.description || null,
+      videoChannelId: body.videoChannelId || null,
+      thumbnailfile: body.thumbnailfile || null
+    }
+
+    this.videoPlaylistService.createVideoPlaylist(videoPlaylistCreate).subscribe(
+      () => {
+        this.notifier.success(
+          this.i18n('Playlist {{playlistName}} created.', { playlistName: videoPlaylistCreate.displayName })
+        )
+        this.router.navigate([ '/my-account', 'video-playlists' ])
+      },
+
+      err => this.error = err.message
+    )
+  }
+
+  isCreation () {
+    return true
+  }
+
+  getFormButtonTitle () {
+    return this.i18n('Create')
+  }
+}
diff --git a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.component.html b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.component.html
new file mode 100644 (file)
index 0000000..b76488c
--- /dev/null
@@ -0,0 +1,64 @@
+<div i18n class="form-sub-title" *ngIf="isCreation() === true">Create a new playlist</div>
+
+<div *ngIf="error" class="alert alert-danger">{{ error }}</div>
+
+<form role="form" (ngSubmit)="formValidated()" [formGroup]="form">
+  <div class="row">
+    <div class="col-md-12 col-xl-6">
+      <div class="form-group">
+        <label i18n for="display-name">Display name</label>
+        <input
+          type="text" id="display-name"
+          formControlName="display-name" [ngClass]="{ 'input-error': formErrors['display-name'] }"
+        >
+        <div *ngIf="formErrors['display-name']" class="form-error">
+          {{ formErrors['display-name'] }}
+        </div>
+      </div>
+
+      <div class="form-group">
+        <label i18n for="description">Description</label>
+        <textarea
+          id="description" formControlName="description"
+          [ngClass]="{ 'input-error': formErrors['description'] }"
+        ></textarea>
+        <div *ngIf="formErrors.description" class="form-error">
+          {{ formErrors.description }}
+        </div>
+      </div>
+    </div>
+
+    <div class="col-md-12 col-xl-6">
+      <div class="form-group">
+        <label i18n for="privacy">Privacy</label>
+        <div class="peertube-select-container">
+          <select id="privacy" formControlName="privacy">
+            <option *ngFor="let privacy of videoPlaylistPrivacies" [value]="privacy.id">{{ privacy.label }}</option>
+          </select>
+        </div>
+
+        <div *ngIf="formErrors.privacy" class="form-error">
+          {{ formErrors.privacy }}
+        </div>
+      </div>
+
+      <div class="form-group">
+        <label i18n>Channel</label>
+        <div class="peertube-select-container">
+          <select formControlName="videoChannelId">
+            <option></option>
+            <option *ngFor="let channel of userVideoChannels" [value]="channel.id">{{ channel.label }}</option>
+          </select>
+        </div>
+      </div>
+
+      <div class="form-group">
+        <my-image-upload
+          i18n-inputLabel inputLabel="Upload thumbnail" inputName="thumbnailfile" formControlName="thumbnailfile"
+          previewWidth="200px" previewHeight="110px"
+        ></my-image-upload>
+      </div>
+    </div>
+  </div>
+  <input type="submit" value="{{ getFormButtonTitle() }}" [disabled]="!form.valid">
+</form>
diff --git a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.component.scss b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.component.scss
new file mode 100644 (file)
index 0000000..5af846d
--- /dev/null
@@ -0,0 +1,27 @@
+@import '_variables';
+@import '_mixins';
+
+.form-sub-title {
+  margin-bottom: 20px;
+}
+
+input[type=text] {
+  @include peertube-input-text(340px);
+
+  display: block;
+}
+
+textarea {
+  @include peertube-textarea(500px, 150px);
+
+  display: block;
+}
+
+.peertube-select-container {
+  @include peertube-select-container(340px);
+}
+
+input[type=submit] {
+  @include peertube-button;
+  @include orange-button;
+}
diff --git a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.ts b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.ts
new file mode 100644 (file)
index 0000000..fbfb4c8
--- /dev/null
@@ -0,0 +1,13 @@
+import { FormReactive } from '@app/shared'
+import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
+import { ServerService } from '@app/core'
+import { VideoPlaylist } from '@shared/models/videos/playlist/video-playlist.model'
+
+export abstract class MyAccountVideoPlaylistEdit extends FormReactive {
+  // Declare it here to avoid errors in create template
+  videoPlaylistToUpdate: VideoPlaylist
+  userVideoChannels: { id: number, label: string }[] = []
+
+  abstract isCreation (): boolean
+  abstract getFormButtonTitle (): string
+}
diff --git a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-update.component.ts b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-update.component.ts
new file mode 100644 (file)
index 0000000..167d7dd
--- /dev/null
@@ -0,0 +1,132 @@
+import { Component, OnDestroy, OnInit } from '@angular/core'
+import { ActivatedRoute, Router } from '@angular/router'
+import { AuthService, Notifier, ServerService } from '@app/core'
+import { Subscription } from 'rxjs'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
+import { MyAccountVideoPlaylistEdit } from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-edit'
+import { populateAsyncUserVideoChannels } from '@app/shared/misc/utils'
+import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service'
+import { VideoPlaylistValidatorsService } from '@app/shared'
+import { VideoPlaylistUpdate } from '@shared/models/videos/playlist/video-playlist-update.model'
+import { VideoConstant } from '@shared/models'
+import { VideoPlaylistPrivacy } from '@shared/models/videos/playlist/video-playlist-privacy.model'
+import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model'
+
+@Component({
+  selector: 'my-account-video-playlist-update',
+  templateUrl: './my-account-video-playlist-edit.component.html',
+  styleUrls: [ './my-account-video-playlist-edit.component.scss' ]
+})
+export class MyAccountVideoPlaylistUpdateComponent extends MyAccountVideoPlaylistEdit implements OnInit, OnDestroy {
+  error: string
+  videoPlaylistToUpdate: VideoPlaylist
+  videoPlaylistPrivacies: VideoConstant<VideoPlaylistPrivacy>[] = []
+
+  private paramsSub: Subscription
+
+  constructor (
+    protected formValidatorService: FormValidatorService,
+    private authService: AuthService,
+    private videoPlaylistValidatorsService: VideoPlaylistValidatorsService,
+    private notifier: Notifier,
+    private router: Router,
+    private route: ActivatedRoute,
+    private videoPlaylistService: VideoPlaylistService,
+    private i18n: I18n,
+    private serverService: ServerService
+  ) {
+    super()
+  }
+
+  ngOnInit () {
+    this.buildForm({
+      'display-name': this.videoPlaylistValidatorsService.VIDEO_PLAYLIST_DISPLAY_NAME,
+      privacy: this.videoPlaylistValidatorsService.VIDEO_PLAYLIST_PRIVACY,
+      description: this.videoPlaylistValidatorsService.VIDEO_PLAYLIST_DESCRIPTION,
+      videoChannelId: this.videoPlaylistValidatorsService.VIDEO_PLAYLIST_CHANNEL_ID,
+      thumbnailfile: null
+    })
+
+    populateAsyncUserVideoChannels(this.authService, this.userVideoChannels)
+
+    this.paramsSub = this.route.params.subscribe(routeParams => {
+      const videoPlaylistId = routeParams['videoPlaylistId']
+
+      this.videoPlaylistService.getVideoPlaylist(videoPlaylistId).subscribe(
+        videoPlaylistToUpdate => {
+          this.videoPlaylistToUpdate = videoPlaylistToUpdate
+
+          this.hydrateFormFromPlaylist()
+
+          this.serverService.videoPlaylistPrivaciesLoaded.subscribe(
+            () => {
+              this.videoPlaylistPrivacies = this.serverService.getVideoPlaylistPrivacies()
+                .filter(p => {
+                  // If the playlist is not private, we cannot put it in private anymore
+                  return this.videoPlaylistToUpdate.privacy.id === VideoPlaylistPrivacy.PRIVATE ||
+                    p.id !== VideoPlaylistPrivacy.PRIVATE
+                })
+            }
+          )
+        },
+
+        err => this.error = err.message
+      )
+    })
+  }
+
+  ngOnDestroy () {
+    if (this.paramsSub) this.paramsSub.unsubscribe()
+  }
+
+  formValidated () {
+    this.error = undefined
+
+    const body = this.form.value
+    const videoPlaylistUpdate: VideoPlaylistUpdate = {
+      displayName: body['display-name'],
+      privacy: body['privacy'],
+      description: body.description || null,
+      videoChannelId: body.videoChannelId || null,
+      thumbnailfile: body.thumbnailfile || undefined
+    }
+
+    this.videoPlaylistService.updateVideoPlaylist(this.videoPlaylistToUpdate, videoPlaylistUpdate).subscribe(
+      () => {
+        this.notifier.success(
+          this.i18n('Playlist {{videoPlaylistName}} updated.', { videoPlaylistName: videoPlaylistUpdate.displayName })
+        )
+
+        this.router.navigate([ '/my-account', 'video-playlists' ])
+      },
+
+      err => this.error = err.message
+    )
+  }
+
+  isCreation () {
+    return false
+  }
+
+  getFormButtonTitle () {
+    return this.i18n('Update')
+  }
+
+  private hydrateFormFromPlaylist () {
+    this.form.patchValue({
+      'display-name': this.videoPlaylistToUpdate.displayName,
+      privacy: this.videoPlaylistToUpdate.privacy.id,
+      description: this.videoPlaylistToUpdate.description,
+      videoChannelId: this.videoPlaylistToUpdate.videoChannel ? this.videoPlaylistToUpdate.videoChannel.id : null
+    })
+
+    fetch(this.videoPlaylistToUpdate.thumbnailUrl)
+      .then(response => response.blob())
+      .then(data => {
+        this.form.patchValue({
+          thumbnailfile: data
+        })
+      })
+  }
+}
diff --git a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.html b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.html
new file mode 100644 (file)
index 0000000..ab5d9cc
--- /dev/null
@@ -0,0 +1,20 @@
+<div class="video-playlists-header">
+  <a class="create-button" routerLink="create">
+    <my-global-icon iconName="add"></my-global-icon>
+    <ng-container i18n>Create a new playlist</ng-container>
+  </a>
+</div>
+
+<div class="video-playlists">
+  <div *ngFor="let playlist of videoPlaylists" class="video-playlist">
+    <div class="miniature-wrapper">
+      <my-video-playlist-miniature [playlist]="playlist"></my-video-playlist-miniature>
+    </div>
+
+    <div *ngIf="isRegularPlaylist(playlist)" class="video-playlist-buttons">
+      <my-delete-button (click)="deleteVideoPlaylist(playlist)"></my-delete-button>
+
+      <my-edit-button [routerLink]="[ 'update', playlist.uuid ]"></my-edit-button>
+    </div>
+  </div>
+</div>
diff --git a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.scss b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.scss
new file mode 100644 (file)
index 0000000..88fba5b
--- /dev/null
@@ -0,0 +1,50 @@
+@import '_variables';
+@import '_mixins';
+
+.create-button {
+  @include create-button;
+}
+
+/deep/ .action-button {
+  &.action-button-delete {
+    margin-right: 10px;
+  }
+}
+
+.video-playlist {
+  @include row-blocks;
+
+  .miniature-wrapper {
+    flex-grow: 1;
+
+    /deep/ .miniature {
+      display: flex;
+
+      .miniature-bottom {
+        margin-left: 10px;
+      }
+    }
+  }
+
+  .video-playlist-buttons {
+    min-width: 190px;
+  }
+}
+
+.video-playlists-header {
+  text-align: right;
+  margin: 20px 0 50px;
+}
+
+@media screen and (max-width: 800px) {
+  .video-playlists-header {
+    text-align: center;
+  }
+
+  .video-playlist {
+
+    .video-playlist-buttons {
+      margin-top: 10px;
+    }
+  }
+}
diff --git a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.ts b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.ts
new file mode 100644 (file)
index 0000000..761ce90
--- /dev/null
@@ -0,0 +1,85 @@
+import { Component, OnInit } from '@angular/core'
+import { Notifier } from '@app/core'
+import { AuthService } from '../../core/auth'
+import { ConfirmService } from '../../core/confirm'
+import { User } from '@app/shared'
+import { flatMap } from 'rxjs/operators'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model'
+import { ComponentPagination } from '@app/shared/rest/component-pagination.model'
+import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service'
+import { VideoPlaylistType } from '@shared/models'
+
+@Component({
+  selector: 'my-account-video-playlists',
+  templateUrl: './my-account-video-playlists.component.html',
+  styleUrls: [ './my-account-video-playlists.component.scss' ]
+})
+export class MyAccountVideoPlaylistsComponent implements OnInit {
+  videoPlaylists: VideoPlaylist[] = []
+
+  pagination: ComponentPagination = {
+    currentPage: 1,
+    itemsPerPage: 10,
+    totalItems: null
+  }
+
+  private user: User
+
+  constructor (
+    private authService: AuthService,
+    private notifier: Notifier,
+    private confirmService: ConfirmService,
+    private videoPlaylistService: VideoPlaylistService,
+    private i18n: I18n
+  ) {}
+
+  ngOnInit () {
+    this.user = this.authService.getUser()
+
+    this.loadVideoPlaylists()
+  }
+
+  async deleteVideoPlaylist (videoPlaylist: VideoPlaylist) {
+    const res = await this.confirmService.confirm(
+      this.i18n(
+        'Do you really want to delete {{playlistDisplayName}}?',
+        { playlistDisplayName: videoPlaylist.displayName }
+      ),
+      this.i18n('Delete')
+    )
+    if (res === false) return
+
+    this.videoPlaylistService.removeVideoPlaylist(videoPlaylist)
+      .subscribe(
+        () => {
+          this.videoPlaylists = this.videoPlaylists
+                                    .filter(p => p.id !== videoPlaylist.id)
+
+          this.notifier.success(
+            this.i18n('Playlist {{playlistDisplayName}} deleted.', { playlistDisplayName: videoPlaylist.displayName })
+          )
+        },
+
+        error => this.notifier.error(error.message)
+      )
+  }
+
+  isRegularPlaylist (playlist: VideoPlaylist) {
+    return playlist.type.id === VideoPlaylistType.REGULAR
+  }
+
+  private loadVideoPlaylists () {
+    this.authService.userInformationLoaded
+        .pipe(flatMap(() => this.videoPlaylistService.listAccountPlaylists(this.user.account)))
+        .subscribe(res => this.videoPlaylists = res.data)
+  }
+
+  private ofNearOfBottom () {
+    // Last page
+    if (this.pagination.totalItems <= (this.pagination.currentPage * this.pagination.itemsPerPage)) return
+
+    this.pagination.currentPage += 1
+    this.loadVideoPlaylists()
+  }
+}
index 8a4102d806725ca2349775b11c39e52b6322bd02..f624ff5058214ce76f2cabf075d57532ef361f04 100644 (file)
@@ -27,6 +27,10 @@ export class MyAccountComponent {
           label: this.i18n('My videos'),
           routerLink: '/my-account/videos'
         },
+        {
+          label: this.i18n('My playlists'),
+          routerLink: '/my-account/video-playlists'
+        },
         {
           label: this.i18n('My subscriptions'),
           routerLink: '/my-account/subscriptions'
index 18f51f171391c4d7ba6e5219ff89b2ac363f6df7..3dbce2b92b2ed9fb87ef0131e4b46a9eb35aedd4 100644 (file)
@@ -25,6 +25,13 @@ import { MyAccountServerBlocklistComponent } from '@app/+my-account/my-account-b
 import { MyAccountHistoryComponent } from '@app/+my-account/my-account-history/my-account-history.component'
 import { MyAccountNotificationsComponent } from '@app/+my-account/my-account-notifications/my-account-notifications.component'
 import { MyAccountNotificationPreferencesComponent } from '@app/+my-account/my-account-settings/my-account-notification-preferences'
+import {
+  MyAccountVideoPlaylistCreateComponent
+} from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-create.component'
+import {
+  MyAccountVideoPlaylistUpdateComponent
+} from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-update.component'
+import { MyAccountVideoPlaylistsComponent } from '@app/+my-account/my-account-video-playlists/my-account-video-playlists.component'
 
 @NgModule({
   imports: [
@@ -57,7 +64,11 @@ import { MyAccountNotificationPreferencesComponent } from '@app/+my-account/my-a
     MyAccountServerBlocklistComponent,
     MyAccountHistoryComponent,
     MyAccountNotificationsComponent,
-    MyAccountNotificationPreferencesComponent
+    MyAccountNotificationPreferencesComponent,
+
+    MyAccountVideoPlaylistCreateComponent,
+    MyAccountVideoPlaylistUpdateComponent,
+    MyAccountVideoPlaylistsComponent
   ],
 
   exports: [
index 7583fdee88712a0b1e0a5655646b8eaa61100a9d..c5c5a8f668703dda6121343790cd3e8565b3387e 100644 (file)
@@ -74,6 +74,7 @@ export class AppComponent implements OnInit {
     this.serverService.loadVideoLanguages()
     this.serverService.loadVideoLicences()
     this.serverService.loadVideoPrivacies()
+    this.serverService.loadVideoPlaylistPrivacies()
 
     // Do not display menu on small screens
     if (this.screenService.isInSmallView()) {
index 10acf6e72d4a4723488a1586779745768c047588..acaca8a019efec712801f68020b285853ac86036 100644 (file)
@@ -9,17 +9,20 @@ import { VideoConstant, VideoPrivacy } from '../../../../../shared/models/videos
 import { isDefaultLocale, peertubeTranslate } from '../../../../../shared/models/i18n'
 import { getDevLocale, isOnDevLocale } from '@app/shared/i18n/i18n-utils'
 import { sortBy } from '@app/shared/misc/utils'
+import { VideoPlaylistPrivacy } from '@shared/models/videos/playlist/video-playlist-privacy.model'
 
 @Injectable()
 export class ServerService {
   private static BASE_SERVER_URL = environment.apiUrl + '/api/v1/server/'
   private static BASE_CONFIG_URL = environment.apiUrl + '/api/v1/config/'
   private static BASE_VIDEO_URL = environment.apiUrl + '/api/v1/videos/'
+  private static BASE_VIDEO_PLAYLIST_URL = environment.apiUrl + '/api/v1/video-playlists/'
   private static BASE_LOCALE_URL = environment.apiUrl + '/client/locales/'
   private static CONFIG_LOCAL_STORAGE_KEY = 'server-config'
 
   configLoaded = new ReplaySubject<boolean>(1)
   videoPrivaciesLoaded = new ReplaySubject<boolean>(1)
+  videoPlaylistPrivaciesLoaded = new ReplaySubject<boolean>(1)
   videoCategoriesLoaded = new ReplaySubject<boolean>(1)
   videoLicencesLoaded = new ReplaySubject<boolean>(1)
   videoLanguagesLoaded = new ReplaySubject<boolean>(1)
@@ -101,6 +104,7 @@ export class ServerService {
   private videoLicences: Array<VideoConstant<number>> = []
   private videoLanguages: Array<VideoConstant<string>> = []
   private videoPrivacies: Array<VideoConstant<VideoPrivacy>> = []
+  private videoPlaylistPrivacies: Array<VideoConstant<VideoPlaylistPrivacy>> = []
 
   constructor (
     private http: HttpClient,
@@ -121,19 +125,28 @@ export class ServerService {
   }
 
   loadVideoCategories () {
-    return this.loadVideoAttributeEnum('categories', this.videoCategories, this.videoCategoriesLoaded, true)
+    return this.loadAttributeEnum(ServerService.BASE_VIDEO_URL, 'categories', this.videoCategories, this.videoCategoriesLoaded, true)
   }
 
   loadVideoLicences () {
-    return this.loadVideoAttributeEnum('licences', this.videoLicences, this.videoLicencesLoaded)
+    return this.loadAttributeEnum(ServerService.BASE_VIDEO_URL, 'licences', this.videoLicences, this.videoLicencesLoaded)
   }
 
   loadVideoLanguages () {
-    return this.loadVideoAttributeEnum('languages', this.videoLanguages, this.videoLanguagesLoaded, true)
+    return this.loadAttributeEnum(ServerService.BASE_VIDEO_URL, 'languages', this.videoLanguages, this.videoLanguagesLoaded, true)
   }
 
   loadVideoPrivacies () {
-    return this.loadVideoAttributeEnum('privacies', this.videoPrivacies, this.videoPrivaciesLoaded)
+    return this.loadAttributeEnum(ServerService.BASE_VIDEO_URL, 'privacies', this.videoPrivacies, this.videoPrivaciesLoaded)
+  }
+
+  loadVideoPlaylistPrivacies () {
+    return this.loadAttributeEnum(
+      ServerService.BASE_VIDEO_PLAYLIST_URL,
+      'privacies',
+      this.videoPlaylistPrivacies,
+      this.videoPlaylistPrivaciesLoaded
+    )
   }
 
   getConfig () {
@@ -156,7 +169,12 @@ export class ServerService {
     return this.videoPrivacies
   }
 
-  private loadVideoAttributeEnum (
+  getVideoPlaylistPrivacies () {
+    return this.videoPlaylistPrivacies
+  }
+
+  private loadAttributeEnum (
+    baseUrl: string,
     attributeName: 'categories' | 'licences' | 'languages' | 'privacies',
     hashToPopulate: VideoConstant<string | number>[],
     notifier: ReplaySubject<boolean>,
@@ -165,7 +183,7 @@ export class ServerService {
     this.localeObservable
         .pipe(
           switchMap(translations => {
-            return this.http.get<{ [id: string]: string }>(ServerService.BASE_VIDEO_URL + attributeName)
+            return this.http.get<{ [id: string]: string }>(baseUrl + attributeName)
                        .pipe(map(data => ({ data, translations })))
           })
         )
index a91e9c7eb1b140606494d1ffb3a84aee5e40b7e4..c2b69d31a618730de8c7a6f27409614a39ec90d7 100644 (file)
@@ -1,5 +1,5 @@
 import { Component, Input } from '@angular/core'
-import { GlobalIconName } from '@app/shared/icons/global-icon.component'
+import { GlobalIconName } from '@app/shared/images/global-icon.component'
 
 @Component({
   selector: 'my-button',
index fdcbedb71c026e17992aa35259488efdcf831caf..e3de3ae13de31b1e552f4e3aae7ec93b8642243d 100644 (file)
@@ -10,6 +10,7 @@ export * from './video-blacklist-validators.service'
 export * from './video-channel-validators.service'
 export * from './video-comment-validators.service'
 export * from './video-validators.service'
+export * from './video-playlist-validators.service'
 export * from './video-captions-validators.service'
 export * from './video-change-ownership-validators.service'
 export * from './video-accept-ownership-validators.service'
diff --git a/client/src/app/shared/forms/form-validators/video-playlist-validators.service.ts b/client/src/app/shared/forms/form-validators/video-playlist-validators.service.ts
new file mode 100644 (file)
index 0000000..726084b
--- /dev/null
@@ -0,0 +1,52 @@
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { Validators } from '@angular/forms'
+import { Injectable } from '@angular/core'
+import { BuildFormValidator } from '@app/shared'
+
+@Injectable()
+export class VideoPlaylistValidatorsService {
+  readonly VIDEO_PLAYLIST_DISPLAY_NAME: BuildFormValidator
+  readonly VIDEO_PLAYLIST_PRIVACY: BuildFormValidator
+  readonly VIDEO_PLAYLIST_DESCRIPTION: BuildFormValidator
+  readonly VIDEO_PLAYLIST_CHANNEL_ID: BuildFormValidator
+
+  constructor (private i18n: I18n) {
+    this.VIDEO_PLAYLIST_DISPLAY_NAME = {
+      VALIDATORS: [
+        Validators.required,
+        Validators.minLength(1),
+        Validators.maxLength(120)
+      ],
+      MESSAGES: {
+        'required': this.i18n('Display name is required.'),
+        'minlength': this.i18n('Display name must be at least 1 character long.'),
+        'maxlength': this.i18n('Display name cannot be more than 120 characters long.')
+      }
+    }
+
+    this.VIDEO_PLAYLIST_PRIVACY = {
+      VALIDATORS: [
+        Validators.required
+      ],
+      MESSAGES: {
+        'required': this.i18n('Privacy is required.')
+      }
+    }
+
+    this.VIDEO_PLAYLIST_DESCRIPTION = {
+      VALIDATORS: [
+        Validators.minLength(3),
+        Validators.maxLength(1000)
+      ],
+      MESSAGES: {
+        'minlength': i18n('Description must be at least 3 characters long.'),
+        'maxlength': i18n('Description cannot be more than 1000 characters long.')
+      }
+    }
+
+    this.VIDEO_PLAYLIST_CHANNEL_ID = {
+      VALIDATORS: [ ],
+      MESSAGES: { }
+    }
+  }
+}
similarity index 84%
rename from client/src/app/videos/+video-edit/shared/video-image.component.ts
rename to client/src/app/shared/images/image-upload.component.ts
index a604cde90c13f80801022174c3dc83d8933f301a..2da1592ff269bc4a7bdaf38ab4f2aeb3c6af1b36 100644 (file)
@@ -4,18 +4,18 @@ import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser'
 import { ServerService } from '@app/core'
 
 @Component({
-  selector: 'my-video-image',
-  styleUrls: [ './video-image.component.scss' ],
-  templateUrl: './video-image.component.html',
+  selector: 'my-image-upload',
+  styleUrls: [ './image-upload.component.scss' ],
+  templateUrl: './image-upload.component.html',
   providers: [
     {
       provide: NG_VALUE_ACCESSOR,
-      useExisting: forwardRef(() => VideoImageComponent),
+      useExisting: forwardRef(() => ImageUploadComponent),
       multi: true
     }
   ]
 })
-export class VideoImageComponent implements ControlValueAccessor {
+export class ImageUploadComponent implements ControlValueAccessor {
   @Input() inputLabel: string
   @Input() inputName: string
   @Input() previewWidth: string
index 7cc6055c2ae6c3b355ef7ba0940b77988332232a..8a1d342c9e1e344c8104bb23d18c27131d56b334 100644 (file)
@@ -17,7 +17,7 @@ function getParameterByName (name: string, url: string) {
   return decodeURIComponent(results[2].replace(/\+/g, ' '))
 }
 
-function populateAsyncUserVideoChannels (authService: AuthService, channel: { id: number, label: string, support: string }[]) {
+function populateAsyncUserVideoChannels (authService: AuthService, channel: { id: number, label: string, support?: string }[]) {
   return new Promise(res => {
     authService.userInformationLoaded
       .subscribe(
index 1c4e3df1aa94f3c58200364bb8562ae8ef6dc356..60a7bd6e2b3fe96482c548b90d7bd53d0e00553c 100644 (file)
@@ -45,6 +45,7 @@ import {
   VideoChangeOwnershipValidatorsService,
   VideoChannelValidatorsService,
   VideoCommentValidatorsService,
+  VideoPlaylistValidatorsService,
   VideoValidatorsService
 } from '@app/shared/forms'
 import { I18nPrimengCalendarService } from '@app/shared/i18n/i18n-primeng-calendar'
@@ -68,8 +69,11 @@ import { UserNotificationsComponent } from '@app/shared/users/user-notifications
 import { InstanceService } from '@app/shared/instance/instance.service'
 import { HtmlRendererService, LinkifierService, MarkdownService } from '@app/shared/renderer'
 import { ConfirmComponent } from '@app/shared/confirm/confirm.component'
-import { GlobalIconComponent } from '@app/shared/icons/global-icon.component'
 import { SmallLoaderComponent } from '@app/shared/misc/small-loader.component'
+import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service'
+import { ImageUploadComponent } from '@app/shared/images/image-upload.component'
+import { GlobalIconComponent } from '@app/shared/images/global-icon.component'
+import { VideoPlaylistMiniatureComponent } from '@app/shared/video-playlist/video-playlist-miniature.component'
 
 @NgModule({
   imports: [
@@ -92,8 +96,11 @@ import { SmallLoaderComponent } from '@app/shared/misc/small-loader.component'
   declarations: [
     LoaderComponent,
     SmallLoaderComponent,
+
     VideoThumbnailComponent,
     VideoMiniatureComponent,
+    VideoPlaylistMiniatureComponent,
+
     FeedComponent,
     ButtonComponent,
     DeleteButtonComponent,
@@ -116,7 +123,9 @@ import { SmallLoaderComponent } from '@app/shared/misc/small-loader.component'
     TopMenuDropdownComponent,
     UserNotificationsComponent,
     ConfirmComponent,
-    GlobalIconComponent
+
+    GlobalIconComponent,
+    ImageUploadComponent
   ],
 
   exports: [
@@ -138,8 +147,11 @@ import { SmallLoaderComponent } from '@app/shared/misc/small-loader.component'
 
     LoaderComponent,
     SmallLoaderComponent,
+
     VideoThumbnailComponent,
     VideoMiniatureComponent,
+    VideoPlaylistMiniatureComponent,
+
     FeedComponent,
     ButtonComponent,
     DeleteButtonComponent,
@@ -159,7 +171,9 @@ import { SmallLoaderComponent } from '@app/shared/misc/small-loader.component'
     TopMenuDropdownComponent,
     UserNotificationsComponent,
     ConfirmComponent,
+
     GlobalIconComponent,
+    ImageUploadComponent,
 
     NumberFormatterPipe,
     ObjectLengthPipe,
@@ -177,6 +191,7 @@ import { SmallLoaderComponent } from '@app/shared/misc/small-loader.component'
     VideoService,
     AccountService,
     VideoChannelService,
+    VideoPlaylistService,
     VideoCaptionService,
     VideoImportService,
     UserSubscriptionService,
@@ -186,6 +201,7 @@ import { SmallLoaderComponent } from '@app/shared/misc/small-loader.component'
     LoginValidatorsService,
     ResetPasswordValidatorsService,
     UserValidatorsService,
+    VideoPlaylistValidatorsService,
     VideoAbuseValidatorsService,
     VideoChannelValidatorsService,
     VideoCommentValidatorsService,
diff --git a/client/src/app/shared/video-playlist/video-playlist-miniature.component.html b/client/src/app/shared/video-playlist/video-playlist-miniature.component.html
new file mode 100644 (file)
index 0000000..1a39f5f
--- /dev/null
@@ -0,0 +1,22 @@
+<div class="miniature">
+  <a
+    [routerLink]="[ '/videos/watch' ]" [attr.title]="playlist.displayName"
+    class="miniature-thumbnail"
+  >
+    <img alt="" [attr.aria-labelledby]="playlist.displayName" [attr.src]="playlist.thumbnailUrl" />
+
+    <div class="miniature-playlist-info-overlay">
+      <ng-container i18n>{playlist.videosLength, plural, =0 {No videos} =1 {1 video} other {{{playlist.videosLength}} videos}}</ng-container>
+    </div>
+
+    <div class="play-overlay">
+      <div class="icon"></div>
+    </div>
+  </a>
+
+  <div class="miniature-bottom">
+    <a tabindex="-1" class="miniature-name" [routerLink]="[ '/videos/watch' ]" [attr.title]="playlist.displayName">
+      {{ playlist.displayName }}
+    </a>
+  </div>
+</div>
diff --git a/client/src/app/shared/video-playlist/video-playlist-miniature.component.scss b/client/src/app/shared/video-playlist/video-playlist-miniature.component.scss
new file mode 100644 (file)
index 0000000..a472065
--- /dev/null
@@ -0,0 +1,34 @@
+@import '_variables';
+@import '_mixins';
+@import '_miniature';
+
+.miniature {
+  display: inline-block;
+
+  .miniature-thumbnail {
+    @include miniature-thumbnail;
+
+    .miniature-playlist-info-overlay {
+      @include static-thumbnail-overlay;
+
+      position: absolute;
+      right: 0;
+      bottom: 0;
+      height: $video-thumbnail-height;
+      padding: 0 10px;
+      display: flex;
+      align-items: center;
+      font-size: 15px;
+    }
+  }
+
+  .miniature-bottom {
+    width: 200px;
+    margin-top: 2px;
+    line-height: normal;
+
+    .miniature-name {
+      @include miniature-name;
+    }
+  }
+}
diff --git a/client/src/app/shared/video-playlist/video-playlist-miniature.component.ts b/client/src/app/shared/video-playlist/video-playlist-miniature.component.ts
new file mode 100644 (file)
index 0000000..b3bba7c
--- /dev/null
@@ -0,0 +1,11 @@
+import { Component, Input } from '@angular/core'
+import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model'
+
+@Component({
+  selector: 'my-video-playlist-miniature',
+  styleUrls: [ './video-playlist-miniature.component.scss' ],
+  templateUrl: './video-playlist-miniature.component.html'
+})
+export class VideoPlaylistMiniatureComponent {
+  @Input() playlist: VideoPlaylist
+}
diff --git a/client/src/app/shared/video-playlist/video-playlist.model.ts b/client/src/app/shared/video-playlist/video-playlist.model.ts
new file mode 100644 (file)
index 0000000..9d0b027
--- /dev/null
@@ -0,0 +1,74 @@
+import {
+  VideoChannelSummary,
+  VideoConstant,
+  VideoPlaylist as ServerVideoPlaylist,
+  VideoPlaylistPrivacy,
+  VideoPlaylistType
+} from '../../../../../shared/models/videos'
+import { AccountSummary, peertubeTranslate } from '@shared/models'
+import { Actor } from '@app/shared/actor/actor.model'
+import { getAbsoluteAPIUrl } from '@app/shared/misc/utils'
+
+export class VideoPlaylist implements ServerVideoPlaylist {
+  id: number
+  uuid: string
+  isLocal: boolean
+
+  displayName: string
+  description: string
+  privacy: VideoConstant<VideoPlaylistPrivacy>
+
+  thumbnailPath: string
+
+  videosLength: number
+
+  type: VideoConstant<VideoPlaylistType>
+
+  createdAt: Date | string
+  updatedAt: Date | string
+
+  ownerAccount: AccountSummary
+  videoChannel?: VideoChannelSummary
+
+  thumbnailUrl: string
+
+  ownerBy: string
+  ownerAvatarUrl: string
+
+  videoChannelBy?: string
+  videoChannelAvatarUrl?: string
+
+  constructor (hash: ServerVideoPlaylist, translations: {}) {
+    const absoluteAPIUrl = getAbsoluteAPIUrl()
+
+    this.id = hash.id
+    this.uuid = hash.uuid
+    this.isLocal = hash.isLocal
+
+    this.displayName = hash.displayName
+    this.description = hash.description
+    this.privacy = hash.privacy
+
+    this.thumbnailPath = hash.thumbnailPath
+    this.thumbnailUrl = absoluteAPIUrl + hash.thumbnailPath
+
+    this.videosLength = hash.videosLength
+
+    this.type = hash.type
+
+    this.createdAt = new Date(hash.createdAt)
+    this.updatedAt = new Date(hash.updatedAt)
+
+    this.ownerAccount = hash.ownerAccount
+    this.ownerBy = Actor.CREATE_BY_STRING(hash.ownerAccount.name, hash.ownerAccount.host)
+    this.ownerAvatarUrl = Actor.GET_ACTOR_AVATAR_URL(this.ownerAccount)
+
+    if (hash.videoChannel) {
+      this.videoChannel = hash.videoChannel
+      this.videoChannelBy = Actor.CREATE_BY_STRING(hash.videoChannel.name, hash.videoChannel.host)
+      this.videoChannelAvatarUrl = Actor.GET_ACTOR_AVATAR_URL(this.videoChannel)
+    }
+
+    this.privacy.label = peertubeTranslate(this.privacy.label, translations)
+  }
+}
diff --git a/client/src/app/shared/video-playlist/video-playlist.service.ts b/client/src/app/shared/video-playlist/video-playlist.service.ts
new file mode 100644 (file)
index 0000000..8b66e12
--- /dev/null
@@ -0,0 +1,108 @@
+import { catchError, map, switchMap } from 'rxjs/operators'
+import { Injectable } from '@angular/core'
+import { Observable } from 'rxjs'
+import { RestExtractor } from '../rest/rest-extractor.service'
+import { HttpClient } from '@angular/common/http'
+import { ResultList } from '../../../../../shared'
+import { environment } from '../../../environments/environment'
+import { VideoPlaylist as VideoPlaylistServerModel } from '@shared/models/videos/playlist/video-playlist.model'
+import { VideoChannelService } from '@app/shared/video-channel/video-channel.service'
+import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
+import { VideoPlaylistCreate } from '@shared/models/videos/playlist/video-playlist-create.model'
+import { VideoPlaylistUpdate } from '@shared/models/videos/playlist/video-playlist-update.model'
+import { objectToFormData } from '@app/shared/misc/utils'
+import { ServerService } from '@app/core'
+import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model'
+import { AccountService } from '@app/shared/account/account.service'
+import { Account } from '@app/shared/account/account.model'
+
+@Injectable()
+export class VideoPlaylistService {
+  static BASE_VIDEO_PLAYLIST_URL = environment.apiUrl + '/api/v1/video-playlists/'
+
+  constructor (
+    private authHttp: HttpClient,
+    private serverService: ServerService,
+    private restExtractor: RestExtractor
+  ) { }
+
+  listChannelPlaylists (videoChannel: VideoChannel): Observable<ResultList<VideoPlaylist>> {
+    const url = VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannel.nameWithHost + '/video-playlists'
+
+    return this.authHttp.get<ResultList<VideoPlaylist>>(url)
+               .pipe(
+                 switchMap(res => this.extractPlaylists(res)),
+                 catchError(err => this.restExtractor.handleError(err))
+               )
+  }
+
+  listAccountPlaylists (account: Account): Observable<ResultList<VideoPlaylist>> {
+    const url = AccountService.BASE_ACCOUNT_URL + account.nameWithHost + '/video-playlists'
+
+    return this.authHttp.get<ResultList<VideoPlaylist>>(url)
+               .pipe(
+                 switchMap(res => this.extractPlaylists(res)),
+                 catchError(err => this.restExtractor.handleError(err))
+               )
+  }
+
+  getVideoPlaylist (id: string | number) {
+    const url = VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + id
+
+    return this.authHttp.get<VideoPlaylist>(url)
+               .pipe(
+                 switchMap(res => this.extractPlaylist(res)),
+                 catchError(err => this.restExtractor.handleError(err))
+               )
+  }
+
+  createVideoPlaylist (body: VideoPlaylistCreate) {
+    const data = objectToFormData(body)
+
+    return this.authHttp.post(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL, data)
+               .pipe(
+                 map(this.restExtractor.extractDataBool),
+                 catchError(err => this.restExtractor.handleError(err))
+               )
+  }
+
+  updateVideoPlaylist (videoPlaylist: VideoPlaylist, body: VideoPlaylistUpdate) {
+    const data = objectToFormData(body)
+
+    return this.authHttp.put(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + videoPlaylist.id, data)
+               .pipe(
+                 map(this.restExtractor.extractDataBool),
+                 catchError(err => this.restExtractor.handleError(err))
+               )
+  }
+
+  removeVideoPlaylist (videoPlaylist: VideoPlaylist) {
+    return this.authHttp.delete(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + videoPlaylist.id)
+               .pipe(
+                 map(this.restExtractor.extractDataBool),
+                 catchError(err => this.restExtractor.handleError(err))
+               )
+  }
+
+  extractPlaylists (result: ResultList<VideoPlaylistServerModel>) {
+    return this.serverService.localeObservable
+               .pipe(
+                 map(translations => {
+                   const playlistsJSON = result.data
+                   const total = result.total
+                   const playlists: VideoPlaylist[] = []
+
+                   for (const playlistJSON of playlistsJSON) {
+                     playlists.push(new VideoPlaylist(playlistJSON, translations))
+                   }
+
+                   return { data: playlists, total }
+                 })
+               )
+  }
+
+  extractPlaylist (playlist: VideoPlaylistServerModel) {
+    return this.serverService.localeObservable
+               .pipe(map(translations => new VideoPlaylist(playlist, translations)))
+  }
+}
index 292ede698eb2c056932ef556eebfed856dd2a9fb..65842af35f71a037692845aa0830c5ac855f161c 100644 (file)
@@ -1,4 +1,5 @@
 @import '_mixins';
+@import '_miniature';
 
 .videos {
   text-align: center;
index c118fc3a13dcb0f268b5d278885d49f567a65aca..7d857a74e4c8c6e5a68b49563a3de46b7d65276a 100644 (file)
@@ -1,5 +1,6 @@
 @import '_variables';
 @import '_mixins';
+@import '_miniature';
 
 .video-miniature {
   display: inline-block;
     line-height: normal;
 
     .video-miniature-name {
-      @include ellipsis-multiline(
-        $font-size: 1rem,
-        $line-height: 1,
-        $lines-to-show: 2
-      );
-      transition: color 0.2s;
-      font-size: 16px;
-      font-weight: $font-semibold;
-      color: var(--mainForegroundColor);
-      margin-top: 5px;
-      margin-bottom: 5px;
-
-      &:hover {
-        text-decoration: none;
-      }
-
-      &.blur-filter {
-        filter: blur(3px);
-        padding-left: 4px;
-      }
+      @include miniature-name;
     }
 
     .video-miniature-created-at-views {
index a15df725ef7970f79a01c029b0a62c75a963ac76..a6757fc4a211c4cd6ef0c27fbb9092963ec2ed5f 100644 (file)
@@ -4,9 +4,11 @@
 >
   <img alt="" [attr.aria-labelledby]="video.name" [attr.src]="getImageUrl()" [ngClass]="{ 'blur-filter': nsfw }" />
 
-  <div class="video-thumbnail-overlay">{{ video.durationLabel }}</div>
+  <div class="video-thumbnail-duration-overlay">{{ video.durationLabel }}</div>
 
-  <div class="play-overlay"></div>
+  <div class="play-overlay">
+    <div class="icon"></div>
+  </div>
 
   <div class="progress-bar" *ngIf="video.userHistory?.currentTime">
     <div [ngStyle]="{ 'width.%': getProgressPercent() }"></div>
index b9fd9182f08f256ec79bbe7c235798ccbd34938d..0113427a34ecd4383509d29f71e9cde270dc1076 100644 (file)
@@ -1,66 +1,9 @@
 @import '_variables';
 @import '_mixins';
-
-$play-overlay-transition: 0.2s ease;
-$play-overlay-height: 26px;
-$play-overlay-width: 18px;
+@import '_miniature';
 
 .video-thumbnail {
-  @include disable-outline;
-
-  display: inline-block;
-  position: relative;
-  border-radius: 3px;
-  overflow: hidden;
-  width: $video-thumbnail-width;
-  height: $video-thumbnail-height;
-  background-color: #ececec;
-  transition: filter $play-overlay-transition;
-
-  &:hover {
-    text-decoration: none !important;
-
-    filter: brightness(85%);
-
-    .play-overlay {
-      opacity: 1;
-
-      transform: translate(-50%, -50%) scale(1);
-    }
-  }
-
-  &.focus-visible {
-    box-shadow: 0 0 0 2px var(--mainColor);
-  }
-
-  img {
-    width: $video-thumbnail-width;
-    height: $video-thumbnail-height;
-
-    &.blur-filter {
-      filter: blur(5px);
-      transform : scale(1.03);
-    }
-  }
-
-  .play-overlay {
-    width: 0;
-    height: 0;
-
-    position: absolute;
-    left: 50%;
-    top: 50%;
-    transform: translate(-50%, -50%) scale(0.5);
-
-    transition: all $play-overlay-transition;
-
-    border-top: ($play-overlay-height / 2) solid transparent;
-    border-bottom: ($play-overlay-height / 2) solid transparent;
-
-    border-left: $play-overlay-width solid rgba(255, 255, 255, 0.95);
-
-    opacity: 0;
-  }
+  @include miniature-thumbnail;
 
   .progress-bar {
     height: 3px;
@@ -75,16 +18,15 @@ $play-overlay-width: 18px;
     }
   }
 
-  .video-thumbnail-overlay {
+  .video-thumbnail-duration-overlay {
+    @include static-thumbnail-overlay;
+
     position: absolute;
     right: 5px;
     bottom: 5px;
-    display: inline-block;
-    background-color: rgba(0, 0, 0, 0.7);
-    color: #fff;
+    padding: 0 5px;
+    border-radius: 3px;
     font-size: 12px;
     font-weight: $font-bold;
-    border-radius: 3px;
-    padding: 0 5px;
   }
 }
index 460c09258b732e7f268f6f8ec2c2a26e732db1d4..c936a8207468f5a9e16d344189af49907c915056 100644 (file)
@@ -117,9 +117,8 @@ export class Video implements VideoServerModel {
     this.privacy.label = peertubeTranslate(this.privacy.label, translations)
 
     this.scheduledUpdate = hash.scheduledUpdate
-    this.originallyPublishedAt = hash.originallyPublishedAt ?
-    new Date(hash.originallyPublishedAt.toString())
-    : null
+    this.originallyPublishedAt = hash.originallyPublishedAt ? new Date(hash.originallyPublishedAt.toString()) : null
+
     if (this.state) this.state.label = peertubeTranslate(this.state.label, translations)
 
     this.blacklisted = hash.blacklisted
index 1be1084adf5cd35b27829dd2b13d2c1bd4cb0017..99695204dd00b9c4cb5f993976fa2d80743bfe6a 100644 (file)
         <div class="row advanced-settings">
           <div class="col-md-12 col-xl-8">
             <div class="form-group">
-              <my-video-image
+              <my-image-upload
                 i18n-inputLabel inputLabel="Upload thumbnail" inputName="thumbnailfile" formControlName="thumbnailfile"
                 previewWidth="200px" previewHeight="110px"
-              ></my-video-image>
+              ></my-image-upload>
             </div>
 
             <div class="form-group">
-              <my-video-image
+              <my-image-upload
                 i18n-inputLabel inputLabel="Upload preview" inputName="previewfile" formControlName="previewfile"
                 previewWidth="360px" previewHeight="200px"
-              ></my-video-image>
+              ></my-image-upload>
             </div>
 
             <div class="form-group">
index f441d3fde4611eef4235e14787b8b3f6016c14c7..39b6daa931a88b4b5ca64ff865569e8989e79df4 100644 (file)
@@ -2,7 +2,6 @@ import { NgModule } from '@angular/core'
 import { TagInputModule } from 'ngx-chips'
 import { SharedModule } from '../../../shared/'
 import { VideoEditComponent } from './video-edit.component'
-import { VideoImageComponent } from './video-image.component'
 import { CalendarModule } from 'primeng/components/calendar/calendar'
 import { VideoCaptionAddModalComponent } from './video-caption-add-modal.component'
 
@@ -16,7 +15,6 @@ import { VideoCaptionAddModalComponent } from './video-caption-add-modal.compone
 
   declarations: [
     VideoEditComponent,
-    VideoImageComponent,
     VideoCaptionAddModalComponent
   ],
 
index aff45c0729cd0df64483747449b91022cbc3ad4f..42b542233031e5b3cad7c9aa6ccb1c6cf742d3a8 100644 (file)
@@ -1,5 +1,6 @@
 @import '_variables';
 @import '_mixins';
+@import '_miniature';
 
 .section {
   padding-top: 10px;
@@ -50,4 +51,4 @@
   .section {
     @include video-miniature-small-screen;
   }
-}
\ No newline at end of file
+}
diff --git a/client/src/sass/include/_miniature.scss b/client/src/sass/include/_miniature.scss
new file mode 100644 (file)
index 0000000..36d4e84
--- /dev/null
@@ -0,0 +1,133 @@
+@import '_variables';
+@import '_mixins';
+
+@mixin miniature-name {
+  @include ellipsis-multiline(
+    $font-size: 1rem,
+    $line-height: 1,
+    $lines-to-show: 2
+  );
+  transition: color 0.2s;
+  font-size: 16px;
+  font-weight: $font-semibold;
+  color: var(--mainForegroundColor);
+  margin-top: 5px;
+  margin-bottom: 5px;
+
+  &:hover {
+    text-decoration: none;
+  }
+
+  &.blur-filter {
+    filter: blur(3px);
+    padding-left: 4px;
+  }
+}
+
+$play-overlay-transition: 0.2s ease;
+$play-overlay-height: 26px;
+$play-overlay-width: 18px;
+
+@mixin miniature-thumbnail {
+  @include disable-outline;
+
+  display: inline-block;
+  position: relative;
+  border-radius: 3px;
+  overflow: hidden;
+  width: $video-thumbnail-width;
+  height: $video-thumbnail-height;
+  background-color: #ececec;
+  transition: filter $play-overlay-transition;
+
+  .play-overlay {
+    position: absolute;
+    right: 0;
+    bottom: 0;
+
+    width: $video-thumbnail-width;
+    height: $video-thumbnail-height;
+    opacity: 0;
+    background-color: rgba(0, 0, 0, 0.7);
+
+    &, .icon {
+      transition: all $play-overlay-transition;
+    }
+
+    .icon {
+      width: 0;
+      height: 0;
+
+      position: absolute;
+      left: 50%;
+      top: 50%;
+      transform: translate(-50%, -50%) scale(0.5);
+
+      border-top: ($play-overlay-height / 2) solid transparent;
+      border-bottom: ($play-overlay-height / 2) solid transparent;
+
+      border-left: $play-overlay-width solid rgba(255, 255, 255, 0.95);
+    }
+  }
+
+  &:hover {
+    text-decoration: none !important;
+
+    .play-overlay {
+      opacity: 1;
+
+      .icon {
+        transform: translate(-50%, -50%) scale(1);
+      }
+    }
+  }
+
+  &.focus-visible {
+    box-shadow: 0 0 0 2px var(--mainColor);
+  }
+
+  img {
+    width: $video-thumbnail-width;
+    height: $video-thumbnail-height;
+
+    &.blur-filter {
+      filter: blur(5px);
+      transform : scale(1.03);
+    }
+  }
+}
+
+@mixin static-thumbnail-overlay {
+  display: inline-block;
+  background-color: rgba(0, 0, 0, 0.7);
+  color: #fff;
+}
+
+@mixin video-miniature-small-screen {
+  text-align: center;
+
+  /deep/ .video-miniature {
+    padding-right: 0;
+    height: auto;
+    width: 100%;
+    margin-bottom: 20px;
+
+    .video-miniature-information {
+      width: 100% !important;
+
+      span {
+        width: 100%;
+      }
+    }
+
+    .video-thumbnail {
+      width: 100%;
+      height: auto;
+
+      img {
+        width: 100%;
+        height: auto;
+      }
+    }
+  }
+}
index c2e200a14df7ce6bfc0edfe4eac36f07b5e5d61b..59b2f42a5caa85b2d192a2c8a121580dfd023183 100644 (file)
   }
 }
 
-@mixin video-miniature-small-screen {
-  text-align: center;
-
-  /deep/ .video-miniature {
-    padding-right: 0;
-    height: auto;
-    width: 100%;
-    margin-bottom: 20px;
-
-    .video-miniature-information {
-      width: 100% !important;
-
-      span {
-        width: 100%;
-      }
-    }
-
-    .video-thumbnail {
-      width: 100%;
-      height: auto;
-
-      img {
-        width: 100%;
-        height: auto;
-      }
-    }
-  }
-}
index 3f9986f8a6674c306862d48bd44c119b7908c00c..a0fbc27c682ef665b9befeaf898129e84cbc5839 100644 (file)
@@ -28,6 +28,7 @@
     "baseUrl": "src",
     "paths": {
       "@app/*": [ "app/*" ],
+      "@shared/*": [ "../../shared/*" ],
       "video.js": [ "../node_modules/video.js/dist/alt/video.core.js" ],
       "fs": [ "./shims/noop" ],
       "http": [ "./shims/http" ],
     "strictInjectionParameters": true,
     "fullTemplateTypeCheck": true
   },
+  "include": [
+    "../../shared"
+  ],
   "exclude": [
+    "../../node_modules",
     "../node_modules",
-    "node_modules",
-    "dist",
-    "../server",
-    "src/**/*.spec.ts"
+    "../dist",
+    "../../server",
+    "../src/**/*.spec.ts"
   ]
 }
index 40c4208253db678c1029a6d57d803625db78fbf8..95897afa35b5768e6a0f2499a4ccdd3914a92780 100755 (executable)
@@ -5,7 +5,7 @@ import {
   buildLanguages,
   VIDEO_CATEGORIES,
   VIDEO_IMPORT_STATES,
-  VIDEO_LICENCES,
+  VIDEO_LICENCES, VIDEO_PLAYLIST_PRIVACIES, VIDEO_PLAYLIST_TYPES,
   VIDEO_PRIVACIES,
   VIDEO_STATES
 } from '../../server/initializers/constants'
@@ -46,6 +46,8 @@ values(VIDEO_CATEGORIES)
   .concat(values(VIDEO_PRIVACIES))
   .concat(values(VIDEO_STATES))
   .concat(values(VIDEO_IMPORT_STATES))
+  .concat(values(VIDEO_PLAYLIST_PRIVACIES))
+  .concat(values(VIDEO_PLAYLIST_TYPES))
   .concat([
     'This video does not exist.',
     'We cannot fetch the video. Please try again later.',
index 4cbb87ab549c58f59b46898e11cb818ca5ed2788..54c390540c2147736e77f0c64f8e83f233ccb04f 100644 (file)
@@ -642,7 +642,7 @@ let STATIC_MAX_AGE = '2h'
 // Videos thumbnail size
 const THUMBNAILS_SIZE = {
   width: 223,
-  height: 112
+  height: 122
 }
 const PREVIEWS_SIZE = {
   width: 560,
index fa26e2336b95a5c71cfab44e617e34b6e64563b2..22b8b8ff19d6a3861ba2c5ee53282ea061dd6807 100644 (file)
@@ -344,6 +344,7 @@ function getCommonPlaylistEditAttributes () {
       .custom(isVideoPlaylistPrivacyValid).withMessage('Should have correct playlist privacy'),
     body('videoChannelId')
       .optional()
+      .customSanitizer(toValueOrNull)
       .toInt()
   ] as (ValidationChain | express.Handler)[]
 }
index 056ae06da1e2bd0edfe638cc46f44fd5b33ad06a..9cf861048525693c8fe2bb6b3eef95ad596f62ea 100644 (file)
@@ -11,6 +11,13 @@ export * from './blacklist/video-blacklist-update.model'
 export * from './channel/video-channel-create.model'
 export * from './channel/video-channel-update.model'
 export * from './channel/video-channel.model'
+export * from './playlist/video-playlist-create.model'
+export * from './playlist/video-playlist-element-create.model'
+export * from './playlist/video-playlist-element-update.model'
+export * from './playlist/video-playlist-privacy.model'
+export * from './playlist/video-playlist-type.model'
+export * from './playlist/video-playlist-update.model'
+export * from './playlist/video-playlist.model'
 export * from './video-change-ownership.model'
 export * from './video-change-ownership-create.model'
 export * from './video-create.model'
@@ -27,4 +34,4 @@ export * from './caption/video-caption-update.model'
 export * from './import/video-import-create.model'
 export * from './import/video-import-state.enum'
 export * from './import/video-import.model'
-export { VideoConstant } from './video-constant.model'
+export * from './video-constant.model'
index 7fec0e42b57ad49e09590c03735f9c8e0d46918e..c0941727a92512a69146e520bd16172ded357d5f 100644 (file)
@@ -21,6 +21,6 @@ export interface VideoPlaylist {
   createdAt: Date | string
   updatedAt: Date | string
 
-  ownerAccount?: AccountSummary
+  ownerAccount: AccountSummary
   videoChannel?: VideoChannelSummary
 }
index 4af52ec0fd76f6d6feb1201d67f2fc378e3063f9..b84b21623521cdea912a98722a03b18e79f95aef 100644 (file)
@@ -265,9 +265,21 @@ async function checkPlaylistFilesWereRemoved (
   }
 }
 
+function getVideoPlaylistPrivacies (url: string) {
+  const path = '/api/v1/video-playlists/privacies'
+
+  return makeGetRequest({
+    url,
+    path,
+    statusCodeExpected: 200
+  })
+}
+
 // ---------------------------------------------------------------------------
 
 export {
+  getVideoPlaylistPrivacies,
+
   getVideoPlaylistsList,
   getVideoChannelPlaylistsList,
   getAccountPlaylistsList,