Lazy description and previews to video form
authorChocobozzz <florian.bigard@gmail.com>
Mon, 30 Oct 2017 19:26:06 +0000 (20:26 +0100)
committerChocobozzz <florian.bigard@gmail.com>
Mon, 30 Oct 2017 19:26:06 +0000 (20:26 +0100)
23 files changed:
client/.bootstraprc
client/src/app/core/auth/auth.service.ts
client/src/app/shared/forms/form-validators/video.ts
client/src/app/videos/+video-edit/video-add.component.html
client/src/app/videos/+video-edit/video-add.component.ts
client/src/app/videos/+video-edit/video-add.module.ts
client/src/app/videos/+video-edit/video-edit.module.ts [new file with mode: 0644]
client/src/app/videos/+video-edit/video-update.component.html
client/src/app/videos/+video-edit/video-update.component.ts
client/src/app/videos/+video-edit/video-update.module.ts
client/src/app/videos/+video-watch/video-report.component.html
client/src/app/videos/+video-watch/video-watch.component.html
client/src/app/videos/+video-watch/video-watch.component.scss
client/src/app/videos/+video-watch/video-watch.component.ts
client/src/app/videos/shared/index.ts
client/src/app/videos/shared/video-description.component.html [new file with mode: 0644]
client/src/app/videos/shared/video-description.component.scss [new file with mode: 0644]
client/src/app/videos/shared/video-description.component.ts [new file with mode: 0644]
client/src/app/videos/shared/video-details.model.ts
client/src/app/videos/shared/video.service.ts
client/tslint.json
server/models/video/video-interface.ts
server/tests/api/check-params/videos.ts

index e560cb5fb0f0f98f2fe459862cb50272ce2da989..6ceef4fe98db37e33a9dcdcd7b79a9fa760c8fec 100644 (file)
@@ -81,7 +81,7 @@ styles:
   dropdowns: true
   button-groups: true
   input-groups: true
-  navs: false
+  navs: true
   navbar: false
   breadcrumbs: false
   pagination: true
index df6e5135b9aff824ecd35f626739b37d25934526..913c857e3c6bc44509019f56d0fd3c4e45fcd07f 100644 (file)
@@ -3,6 +3,8 @@ import { Router } from '@angular/router'
 import { Observable } from 'rxjs/Observable'
 import { Subject } from 'rxjs/Subject'
 import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http'
+import { ReplaySubject } from 'rxjs/ReplaySubject'
+import 'rxjs/add/operator/do'
 import 'rxjs/add/operator/map'
 import 'rxjs/add/operator/mergeMap'
 import 'rxjs/add/observable/throw'
@@ -54,6 +56,7 @@ export class AuthService {
   private static BASE_USER_INFORMATION_URL = API_URL + '/api/v1/users/me'
 
   loginChangedSource: Observable<AuthStatus>
+  userInformationLoaded = new ReplaySubject<boolean>(1)
 
   private clientId: string
   private clientSecret: string
@@ -199,16 +202,17 @@ export class AuthService {
     }
 
     this.mergeUserInformation(obj)
-        .subscribe(
-          res => {
-            this.user.displayNSFW = res.displayNSFW
-            this.user.role = res.role
-            this.user.videoChannels = res.videoChannels
-            this.user.author = res.author
-
-            this.user.save()
-          }
-        )
+      .do(() => this.userInformationLoaded.next(true))
+      .subscribe(
+        res => {
+          this.user.displayNSFW = res.displayNSFW
+          this.user.role = res.role
+          this.user.videoChannels = res.videoChannels
+          this.user.author = res.author
+
+          this.user.save()
+        }
+      )
   }
 
   private mergeUserInformation (obj: UserLoginWithUsername): Observable<UserLoginWithUserInformation> {
index 286a1117981d664d3d5309e90f46c31aeca6e64b..434773501231299ce9c8048db712e794a70b3d75 100644 (file)
@@ -36,11 +36,11 @@ export const VIDEO_CHANNEL = {
 }
 
 export const VIDEO_DESCRIPTION = {
-  VALIDATORS: [ Validators.required, Validators.minLength(3), Validators.maxLength(250) ],
+  VALIDATORS: [ Validators.required, Validators.minLength(3), Validators.maxLength(3000) ],
   MESSAGES: {
     'required': 'Video description is required.',
     'minlength': 'Video description must be at least 3 characters long.',
-    'maxlength': 'Video description cannot be more than 250 characters long.'
+    'maxlength': 'Video description cannot be more than 3000 characters long.'
   }
 }
 
index 3bf4101f4afd763bbfd172d36a9ccf56d7c447f5..a70788ed82264e0a9e54a29460742217dd5a5673 100644 (file)
@@ -28,7 +28,6 @@
       <div class="form-group">
         <label for="category">Channel</label>
         <select class="form-control" id="channelId" formControlName="channelId">
-          <option></option>
           <option *ngFor="let channel of userVideoChannels" [value]="channel.id">{{ channel.label }}</option>
         </select>
 
 
       <div class="form-group">
         <label for="description">Description</label>
-        <textarea
-          id="description" class="form-control" placeholder="Description..."
-          formControlName="description"
-        >
-        </textarea>
+        <my-video-description formControlName="description"></my-video-description>
+
         <div *ngIf="formErrors.description" class="alert alert-danger">
           {{ formErrors.description }}
         </div>
index 92b03e8c96cae9c6898aa871929a3e139ef38f0d..5b5557ed9fe45652534b9ca779ee2a002208eae1 100644 (file)
@@ -82,7 +82,7 @@ export class VideoAddComponent extends FormReactive implements OnInit {
       category: [ '', VIDEO_CATEGORY.VALIDATORS ],
       licence: [ '', VIDEO_LICENCE.VALIDATORS ],
       language: [ '', VIDEO_LANGUAGE.VALIDATORS ],
-      channelId: [ this.userVideoChannels[0].id, VIDEO_CHANNEL.VALIDATORS ],
+      channelId: [ '', VIDEO_CHANNEL.VALIDATORS ],
       description: [ '', VIDEO_DESCRIPTION.VALIDATORS ],
       videofile: [ '', VIDEO_FILE.VALIDATORS ],
       tags: [ '' ]
@@ -96,10 +96,22 @@ export class VideoAddComponent extends FormReactive implements OnInit {
     this.videoLicences = this.serverService.getVideoLicences()
     this.videoLanguages = this.serverService.getVideoLanguages()
 
-    const user = this.authService.getUser()
-    this.userVideoChannels = user.videoChannels.map(v => ({ id: v.id, label: v.name }))
-
     this.buildForm()
+
+    this.authService.userInformationLoaded
+      .subscribe(
+        () => {
+          const user = this.authService.getUser()
+          if (!user) return
+
+          const videoChannels = user.videoChannels
+          if (Array.isArray(videoChannels) === false) return
+
+          this.userVideoChannels = videoChannels.map(v => ({ id: v.id, label: v.name }))
+
+          this.form.patchValue({ channelId: this.userVideoChannels[0].id })
+        }
+      )
   }
 
   // The goal is to keep reactive form validation (required field)
index 141d33ad2cc922f75518541767a06c110ab1aac4..3d937b008f05bf4a04731ce6cecc8f7d209a4c7d 100644 (file)
@@ -1,17 +1,14 @@
 import { NgModule } from '@angular/core'
 
-import { TagInputModule } from 'ngx-chips'
-
 import { VideoAddRoutingModule } from './video-add-routing.module'
 import { VideoAddComponent } from './video-add.component'
-import { VideoService } from '../shared'
+import { VideoEditModule } from './video-edit.module'
 import { SharedModule } from '../../shared'
 
 @NgModule({
   imports: [
-    TagInputModule,
-
     VideoAddRoutingModule,
+    VideoEditModule,
     SharedModule
   ],
 
@@ -23,8 +20,6 @@ import { SharedModule } from '../../shared'
     VideoAddComponent
   ],
 
-  providers: [
-    VideoService
-  ]
+  providers: [ ]
 })
 export class VideoAddModule { }
diff --git a/client/src/app/videos/+video-edit/video-edit.module.ts b/client/src/app/videos/+video-edit/video-edit.module.ts
new file mode 100644 (file)
index 0000000..33f6549
--- /dev/null
@@ -0,0 +1,33 @@
+import { NgModule } from '@angular/core'
+
+import { TagInputModule } from 'ngx-chips'
+import { TabsModule } from 'ngx-bootstrap/tabs'
+
+import { VideoService, MarkdownService, VideoDescriptionComponent } from '../shared'
+import { SharedModule } from '../../shared'
+
+@NgModule({
+  imports: [
+    TagInputModule,
+    TabsModule.forRoot(),
+
+    SharedModule
+  ],
+
+  declarations: [
+    VideoDescriptionComponent
+  ],
+
+  exports: [
+    TagInputModule,
+    TabsModule,
+
+    VideoDescriptionComponent
+  ],
+
+  providers: [
+    VideoService,
+    MarkdownService
+  ]
+})
+export class VideoEditModule { }
index 4dcb3ea5676618bd16be6a5674cff5409e58e9b3..ec040630e1feaed912bd334f0bd82662033d7421 100644 (file)
@@ -62,7 +62,7 @@
     </div>
 
     <div class="form-group">
-      <label for="tags" class="label-tags">Tags</label> <span class="little-information">(press enter to add the tag)</span>
+      <label class="label-tags">Tags</label> <span class="little-information">(press enter to add the tag)</span>
       <tag-input
         [ngModel]="tags" [validators]="tagValidators" [errorMessages]="tagValidatorsMessages"
         formControlName="tags" maxItems="5" modelAsStrings="true"
 
     <div class="form-group">
       <label for="description">Description</label>
-      <textarea
-        id="description" class="form-control" placeholder="Description..."
-        formControlName="description"
-      >
-      </textarea>
+      <my-video-description formControlName="description"></my-video-description>
+
       <div *ngIf="formErrors.description" class="alert alert-danger">
         {{ formErrors.description }}
       </div>
index 30390ac058ef23836a9a2f420e6c0bdaa407d383..6ced77f1a4b2d26b6c9d2a9cd3029193d94504f9 100644 (file)
@@ -1,6 +1,8 @@
 import { Component, OnInit } from '@angular/core'
 import { FormBuilder, FormGroup } from '@angular/forms'
 import { ActivatedRoute, Router } from '@angular/router'
+import { Observable } from 'rxjs/Observable'
+import 'rxjs/add/observable/forkJoin'
 
 import { NotificationsService } from 'angular2-notifications'
 
@@ -84,19 +86,26 @@ export class VideoUpdateComponent extends FormReactive implements OnInit {
     this.videoLanguages = this.serverService.getVideoLanguages()
 
     const uuid: string = this.route.snapshot.params['uuid']
-    this.videoService.getVideo(uuid)
-                     .subscribe(
-                       video => {
-                         this.video = new VideoEdit(video)
-
-                         this.hydrateFormFromVideo()
-                       },
 
-                       err => {
-                         console.error(err)
-                         this.error = 'Cannot fetch video.'
-                       }
-                     )
+    this.videoService.getVideo(uuid)
+      .switchMap(video => {
+        return this.videoService
+          .loadCompleteDescription(video.descriptionPath)
+          .do(description => video.description = description)
+          .map(() => video)
+      })
+      .subscribe(
+        video => {
+          this.video = new VideoEdit(video)
+
+          this.hydrateFormFromVideo()
+        },
+
+        err => {
+          console.error(err)
+          this.error = 'Cannot fetch video.'
+        }
+      )
   }
 
   checkForm () {
index eeb2e35e2083a0beac491194c9527577f7f6aaf0..f7bd77c752a69dcb620d2d69f5961d66e7e965d3 100644 (file)
@@ -1,17 +1,14 @@
 import { NgModule } from '@angular/core'
 
-import { TagInputModule } from 'ngx-chips'
-
 import { VideoUpdateRoutingModule } from './video-update-routing.module'
 import { VideoUpdateComponent } from './video-update.component'
-import { VideoService } from '../shared'
+import { VideoEditModule } from './video-edit.module'
 import { SharedModule } from '../../shared'
 
 @NgModule({
   imports: [
-    TagInputModule,
-
     VideoUpdateRoutingModule,
+    VideoEditModule,
     SharedModule
   ],
 
@@ -23,8 +20,6 @@ import { SharedModule } from '../../shared'
     VideoUpdateComponent
   ],
 
-  providers: [
-    VideoService
-  ]
+  providers: [ ]
 })
 export class VideoUpdateModule { }
index 741080ead2a8d02aa8bef5c59e2b61b1d029be5d..ceb7cf50a75084a44ca312d6c3b5316552aaa91c 100644 (file)
@@ -13,7 +13,7 @@
 
         <form novalidate [formGroup]="form">
           <div class="form-group">
-            <label for="description">Reason</label>
+            <label for="reason">Reason</label>
             <textarea
               id="reason" class="form-control" placeholder="Reason..."
               formControlName="reason"
index 4b594e7edf77e8cf5d1443c08bf5f885c61be1ea..71f986ccdbcd59849a94f914571765e122dbb5ed 100644 (file)
       </div>
 
       <div class="video-details-description" [innerHTML]="videoHTMLDescription"></div>
+
+      <div *ngIf="completeDescriptionShown === false && video.description.length === 250" (click)="showMoreDescription()" class="video-details-description-more">
+        Show more
+        <span class="glyphicon glyphicon-menu-down"></span>
+      </div>
+
+      <div *ngIf="completeDescriptionShown === true" (click)="showLessDescription()" class="video-details-description-more">
+        Show less
+        <span class="glyphicon glyphicon-menu-up"></span>
+      </div>
     </div>
 
     <div class="video-details-attributes col-xs-4 col-md-3">
index 01ceab3c590c712f2b42c78e99e0f5f0876ce1fd..ab0539fa30302db7568198c4c40ff4ee5f3910b8 100644 (file)
         font-weight: bold;
         margin-bottom: 30px;
       }
+
+      .video-details-description-more {
+        cursor: pointer;
+        margin-top: 15px;
+        font-weight: bold;
+        color: #acaeb7;
+
+        .glyphicon {
+          position: relative;
+          top: 2px;
+        }
+      }
     }
 
     .video-details-attributes {
index 199666bdc9a490311fd4b941a4e4a4a876cdb73d..5e2486b9ce91a51c37a06356f78ff128ec3d31ce 100644 (file)
@@ -38,6 +38,10 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
   video: VideoDetails = null
   videoPlayerLoaded = false
   videoNotFound = false
+
+  completeDescriptionShown = false
+  completeVideoDescription: string
+  shortVideoDescription: string
   videoHTMLDescription = ''
 
   private paramsSub: Subscription
@@ -154,6 +158,36 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
     )
   }
 
+  showMoreDescription () {
+    this.completeDescriptionShown = true
+
+    if (this.completeVideoDescription === undefined) {
+      return this.loadCompleteDescription()
+    }
+
+    this.updateVideoDescription(this.completeVideoDescription)
+  }
+
+  showLessDescription () {
+    this.completeDescriptionShown = false
+
+    this.updateVideoDescription(this.shortVideoDescription)
+  }
+
+  loadCompleteDescription () {
+    this.videoService.loadCompleteDescription(this.video.descriptionPath)
+      .subscribe(
+        description => {
+          this.shortVideoDescription = this.video.description
+          this.completeVideoDescription = description
+
+          this.updateVideoDescription(this.completeVideoDescription)
+        },
+
+        error => this.notificationsService.error('Error', error.text)
+      )
+  }
+
   showReportModal (event: Event) {
     event.preventDefault()
     this.videoReportModal.show()
@@ -184,6 +218,15 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
     return this.video.isBlackistableBy(this.authService.getUser())
   }
 
+  private updateVideoDescription (description: string) {
+    this.video.description = description
+    this.setVideoDescriptionHTML()
+  }
+
+  private setVideoDescriptionHTML () {
+    this.videoHTMLDescription = this.markdownService.markdownToHTML(this.video.description)
+  }
+
   private handleError (err: any) {
     const errorMessage: string = typeof err === 'string' ? err : err.message
     let message = ''
@@ -264,7 +307,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
           })
         })
 
-        this.videoHTMLDescription = this.markdownService.markdownToHTML(this.video.description)
+        this.setVideoDescriptionHTML()
 
         this.setOpenGraphTags()
         this.checkUserRating()
index 09d961dd3ea74b40ac4c62572fad8a8ca6f19cc6..3f14580885b291b750a665962d88cf21a5be7033 100644 (file)
@@ -4,4 +4,5 @@ export * from './video.model'
 export * from './video-details.model'
 export * from './video-edit.model'
 export * from './video.service'
+export * from './video-description.component'
 export * from './video-pagination.model'
diff --git a/client/src/app/videos/shared/video-description.component.html b/client/src/app/videos/shared/video-description.component.html
new file mode 100644 (file)
index 0000000..7a22885
--- /dev/null
@@ -0,0 +1,9 @@
+<textarea
+    [(ngModel)]="description" (ngModelChange)="onModelChange()"
+    id="description" class="form-control" placeholder="My super video">
+</textarea>
+
+<tabset #staticTabs class="previews">
+  <tab heading="Truncated description preview" [innerHTML]="truncatedDescriptionHTML"></tab>
+  <tab heading="Complete description preview" [innerHTML]="descriptionHTML"></tab>
+</tabset>
diff --git a/client/src/app/videos/shared/video-description.component.scss b/client/src/app/videos/shared/video-description.component.scss
new file mode 100644 (file)
index 0000000..d8d73e8
--- /dev/null
@@ -0,0 +1,15 @@
+textarea {
+  height: 150px;
+}
+
+.previews /deep/ {
+  .nav {
+    margin-top: 10px;
+    font-size: 0.9em;
+  }
+
+  .tab-content {
+    min-height: 75px;
+    padding: 5px;
+  }
+}
diff --git a/client/src/app/videos/shared/video-description.component.ts b/client/src/app/videos/shared/video-description.component.ts
new file mode 100644 (file)
index 0000000..d9ffb78
--- /dev/null
@@ -0,0 +1,68 @@
+import { Component, forwardRef, Input, OnInit } from '@angular/core'
+import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
+import { Subject } from 'rxjs/Subject'
+import 'rxjs/add/operator/debounceTime'
+import 'rxjs/add/operator/distinctUntilChanged'
+
+import { truncate } from 'lodash'
+
+import { MarkdownService } from './markdown.service'
+
+@Component({
+  selector: 'my-video-description',
+  templateUrl: './video-description.component.html',
+  styleUrls: [ './video-description.component.scss' ],
+  providers: [
+    {
+      provide: NG_VALUE_ACCESSOR,
+      useExisting: forwardRef(() => VideoDescriptionComponent),
+      multi: true
+    }
+  ]
+})
+
+export class VideoDescriptionComponent implements ControlValueAccessor, OnInit {
+  @Input() description = ''
+  truncatedDescriptionHTML = ''
+  descriptionHTML = ''
+
+  private descriptionChanged = new Subject<string>()
+
+  constructor (private markdownService: MarkdownService) {}
+
+  ngOnInit () {
+    this.descriptionChanged
+      .debounceTime(150)
+      .distinctUntilChanged()
+      .subscribe(() => this.updateDescriptionPreviews())
+
+    this.descriptionChanged.next(this.description)
+  }
+
+  propagateChange = (_: any) => { /* empty */ }
+
+  writeValue (description: string) {
+    this.description = description
+
+    this.descriptionChanged.next(this.description)
+  }
+
+  registerOnChange (fn: (_: any) => void) {
+    this.propagateChange = fn
+  }
+
+  registerOnTouched () {
+    // Unused
+  }
+
+  onModelChange () {
+    this.propagateChange(this.description)
+
+    this.descriptionChanged.next(this.description)
+  }
+
+  private updateDescriptionPreviews () {
+    this.truncatedDescriptionHTML = this.markdownService.markdownToHTML(truncate(this.description, { length: 250 }))
+    this.descriptionHTML = this.markdownService.markdownToHTML(this.description)
+  }
+}
index 3a6ecc480449ea0c4e585ba8f6b7a587c16c96f5..68ded5210161153a6737ad384775a71a4091f229 100644 (file)
@@ -38,12 +38,14 @@ export class VideoDetails extends Video implements VideoDetailsServerModel {
   likes: number
   dislikes: number
   nsfw: boolean
+  descriptionPath: string
   files: VideoFile[]
   channel: VideoChannel
 
   constructor (hash: VideoDetailsServerModel) {
     super(hash)
 
+    this.descriptionPath = hash.descriptionPath
     this.files = hash.files
     this.channel = hash.channel
   }
index 8fdc1f213fae0e55aeb205ba02a9a2df7243d895..7d5372334630a254ddb5007ccb4055eb8d191539 100644 (file)
@@ -99,15 +99,11 @@ export class VideoService {
                .catch((res) => this.restExtractor.handleError(res))
   }
 
-  reportVideo (id: number, reason: string) {
-    const url = VideoService.BASE_VIDEO_URL + id + '/abuse'
-    const body: VideoAbuseCreate = {
-      reason
-    }
-
-    return this.authHttp.post(url, body)
-                        .map(this.restExtractor.extractDataBool)
-                        .catch(res => this.restExtractor.handleError(res))
+  loadCompleteDescription (descriptionPath: string) {
+    return this.authHttp
+      .get(API_URL + descriptionPath)
+      .map(res => res['description'])
+      .catch((res) => this.restExtractor.handleError(res))
   }
 
   setVideoLike (id: number) {
index 6438519a69bcee7db8d876169a2dec2185ee17f4..068fe596e2b7640d03b180d5cef48bd26959c7fa 100644 (file)
@@ -21,7 +21,7 @@
     "no-attribute-parameter-decorator": true,
     "no-input-rename": true,
     "no-output-rename": true,
-    "no-forward-ref": true,
+    "no-forward-ref": false,
     "use-life-cycle-interface": true,
     "use-pipe-transform-interface": true,
     "pipe-naming": [true, "camelCase", "my"],
index 3a7bc82a4885ed3edeb3d6553f5d21a4fb18e3c1..587652f45f493966fb3870f1c1c84344a3c88c4e 100644 (file)
@@ -138,7 +138,7 @@ export interface VideoInstance extends VideoClass, VideoAttributes, Sequelize.In
   getOriginalFileHeight: VideoMethods.GetOriginalFileHeight
   getEmbedPath: VideoMethods.GetEmbedPath
   getDescriptionPath: VideoMethods.GetDescriptionPath
-  getTruncatedDescription : VideoMethods.GetTruncatedDescription
+  getTruncatedDescription: VideoMethods.GetTruncatedDescription
 
   setTags: Sequelize.HasManySetAssociationsMixin<TagAttributes, string>
   addVideoFile: Sequelize.HasManyAddAssociationMixin<VideoFileAttributes, string>
index 765b9c16e023ac3b59c899bca26752901f3ed565..c59f5da93d1b56085742aa61967908ec9ed932bd 100644 (file)
@@ -280,9 +280,7 @@ describe('Test videos API validator', function () {
         licence: 1,
         language: 6,
         nsfw: false,
-        description: 'my super description which is very very very very very very very very very very very very very very' +
-                     'very very very very very very very very very very very very very very very very very very very very very' +
-                     'very very very very very very very very very very very very very very very long',
+        description: 'my super description which is very very very very very very very very very very very very very very long'.repeat(35),
         tags: [ 'tag1', 'tag2' ],
         channelId
       }
@@ -617,9 +615,7 @@ describe('Test videos API validator', function () {
         licence: 2,
         language: 6,
         nsfw: false,
-        description: 'my super description which is very very very very very very very very very very very very very very' +
-                     'very very very very very very very very very very very very very very very very very very very very very' +
-                     'very very very very very very very very very very very very very very very long',
+        description: 'my super description which is very very very very very very very very very very very very very long'.repeat(35),
         tags: [ 'tag1', 'tag2' ]
       }
       await makePutBodyRequest({ url: server.url, path: path + videoId, token: server.accessToken, fields })