Lazy description and previews to video form

pull/125/head
Chocobozzz 2017-10-30 20:26:06 +01:00
parent 8bf89b095a
commit 2de96f4d6b
No known key found for this signature in database
GPG Key ID: 583A612D890159BE
23 changed files with 266 additions and 73 deletions

View File

@ -81,7 +81,7 @@ styles:
dropdowns: true dropdowns: true
button-groups: true button-groups: true
input-groups: true input-groups: true
navs: false navs: true
navbar: false navbar: false
breadcrumbs: false breadcrumbs: false
pagination: true pagination: true

View File

@ -3,6 +3,8 @@ import { Router } from '@angular/router'
import { Observable } from 'rxjs/Observable' import { Observable } from 'rxjs/Observable'
import { Subject } from 'rxjs/Subject' import { Subject } from 'rxjs/Subject'
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http' 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/map'
import 'rxjs/add/operator/mergeMap' import 'rxjs/add/operator/mergeMap'
import 'rxjs/add/observable/throw' import 'rxjs/add/observable/throw'
@ -54,6 +56,7 @@ export class AuthService {
private static BASE_USER_INFORMATION_URL = API_URL + '/api/v1/users/me' private static BASE_USER_INFORMATION_URL = API_URL + '/api/v1/users/me'
loginChangedSource: Observable<AuthStatus> loginChangedSource: Observable<AuthStatus>
userInformationLoaded = new ReplaySubject<boolean>(1)
private clientId: string private clientId: string
private clientSecret: string private clientSecret: string
@ -199,16 +202,17 @@ export class AuthService {
} }
this.mergeUserInformation(obj) this.mergeUserInformation(obj)
.subscribe( .do(() => this.userInformationLoaded.next(true))
res => { .subscribe(
this.user.displayNSFW = res.displayNSFW res => {
this.user.role = res.role this.user.displayNSFW = res.displayNSFW
this.user.videoChannels = res.videoChannels this.user.role = res.role
this.user.author = res.author this.user.videoChannels = res.videoChannels
this.user.author = res.author
this.user.save() this.user.save()
} }
) )
} }
private mergeUserInformation (obj: UserLoginWithUsername): Observable<UserLoginWithUserInformation> { private mergeUserInformation (obj: UserLoginWithUsername): Observable<UserLoginWithUserInformation> {

View File

@ -36,11 +36,11 @@ export const VIDEO_CHANNEL = {
} }
export const VIDEO_DESCRIPTION = { export const VIDEO_DESCRIPTION = {
VALIDATORS: [ Validators.required, Validators.minLength(3), Validators.maxLength(250) ], VALIDATORS: [ Validators.required, Validators.minLength(3), Validators.maxLength(3000) ],
MESSAGES: { MESSAGES: {
'required': 'Video description is required.', 'required': 'Video description is required.',
'minlength': 'Video description must be at least 3 characters long.', '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.'
} }
} }

View File

@ -28,7 +28,6 @@
<div class="form-group"> <div class="form-group">
<label for="category">Channel</label> <label for="category">Channel</label>
<select class="form-control" id="channelId" formControlName="channelId"> <select class="form-control" id="channelId" formControlName="channelId">
<option></option>
<option *ngFor="let channel of userVideoChannels" [value]="channel.id">{{ channel.label }}</option> <option *ngFor="let channel of userVideoChannels" [value]="channel.id">{{ channel.label }}</option>
</select> </select>
@ -103,11 +102,8 @@
<div class="form-group"> <div class="form-group">
<label for="description">Description</label> <label for="description">Description</label>
<textarea <my-video-description formControlName="description"></my-video-description>
id="description" class="form-control" placeholder="Description..."
formControlName="description"
>
</textarea>
<div *ngIf="formErrors.description" class="alert alert-danger"> <div *ngIf="formErrors.description" class="alert alert-danger">
{{ formErrors.description }} {{ formErrors.description }}
</div> </div>

View File

@ -82,7 +82,7 @@ export class VideoAddComponent extends FormReactive implements OnInit {
category: [ '', VIDEO_CATEGORY.VALIDATORS ], category: [ '', VIDEO_CATEGORY.VALIDATORS ],
licence: [ '', VIDEO_LICENCE.VALIDATORS ], licence: [ '', VIDEO_LICENCE.VALIDATORS ],
language: [ '', VIDEO_LANGUAGE.VALIDATORS ], language: [ '', VIDEO_LANGUAGE.VALIDATORS ],
channelId: [ this.userVideoChannels[0].id, VIDEO_CHANNEL.VALIDATORS ], channelId: [ '', VIDEO_CHANNEL.VALIDATORS ],
description: [ '', VIDEO_DESCRIPTION.VALIDATORS ], description: [ '', VIDEO_DESCRIPTION.VALIDATORS ],
videofile: [ '', VIDEO_FILE.VALIDATORS ], videofile: [ '', VIDEO_FILE.VALIDATORS ],
tags: [ '' ] tags: [ '' ]
@ -96,10 +96,22 @@ export class VideoAddComponent extends FormReactive implements OnInit {
this.videoLicences = this.serverService.getVideoLicences() this.videoLicences = this.serverService.getVideoLicences()
this.videoLanguages = this.serverService.getVideoLanguages() 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.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) // The goal is to keep reactive form validation (required field)

View File

@ -1,17 +1,14 @@
import { NgModule } from '@angular/core' import { NgModule } from '@angular/core'
import { TagInputModule } from 'ngx-chips'
import { VideoAddRoutingModule } from './video-add-routing.module' import { VideoAddRoutingModule } from './video-add-routing.module'
import { VideoAddComponent } from './video-add.component' import { VideoAddComponent } from './video-add.component'
import { VideoService } from '../shared' import { VideoEditModule } from './video-edit.module'
import { SharedModule } from '../../shared' import { SharedModule } from '../../shared'
@NgModule({ @NgModule({
imports: [ imports: [
TagInputModule,
VideoAddRoutingModule, VideoAddRoutingModule,
VideoEditModule,
SharedModule SharedModule
], ],
@ -23,8 +20,6 @@ import { SharedModule } from '../../shared'
VideoAddComponent VideoAddComponent
], ],
providers: [ providers: [ ]
VideoService
]
}) })
export class VideoAddModule { } export class VideoAddModule { }

View File

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

View File

@ -62,7 +62,7 @@
</div> </div>
<div class="form-group"> <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 <tag-input
[ngModel]="tags" [validators]="tagValidators" [errorMessages]="tagValidatorsMessages" [ngModel]="tags" [validators]="tagValidators" [errorMessages]="tagValidatorsMessages"
formControlName="tags" maxItems="5" modelAsStrings="true" formControlName="tags" maxItems="5" modelAsStrings="true"
@ -71,11 +71,8 @@
<div class="form-group"> <div class="form-group">
<label for="description">Description</label> <label for="description">Description</label>
<textarea <my-video-description formControlName="description"></my-video-description>
id="description" class="form-control" placeholder="Description..."
formControlName="description"
>
</textarea>
<div *ngIf="formErrors.description" class="alert alert-danger"> <div *ngIf="formErrors.description" class="alert alert-danger">
{{ formErrors.description }} {{ formErrors.description }}
</div> </div>

View File

@ -1,6 +1,8 @@
import { Component, OnInit } from '@angular/core' import { Component, OnInit } from '@angular/core'
import { FormBuilder, FormGroup } from '@angular/forms' import { FormBuilder, FormGroup } from '@angular/forms'
import { ActivatedRoute, Router } from '@angular/router' import { ActivatedRoute, Router } from '@angular/router'
import { Observable } from 'rxjs/Observable'
import 'rxjs/add/observable/forkJoin'
import { NotificationsService } from 'angular2-notifications' import { NotificationsService } from 'angular2-notifications'
@ -84,19 +86,26 @@ export class VideoUpdateComponent extends FormReactive implements OnInit {
this.videoLanguages = this.serverService.getVideoLanguages() this.videoLanguages = this.serverService.getVideoLanguages()
const uuid: string = this.route.snapshot.params['uuid'] const uuid: string = this.route.snapshot.params['uuid']
this.videoService.getVideo(uuid) this.videoService.getVideo(uuid)
.subscribe( .switchMap(video => {
video => { return this.videoService
this.video = new VideoEdit(video) .loadCompleteDescription(video.descriptionPath)
.do(description => video.description = description)
.map(() => video)
})
.subscribe(
video => {
this.video = new VideoEdit(video)
this.hydrateFormFromVideo() this.hydrateFormFromVideo()
}, },
err => { err => {
console.error(err) console.error(err)
this.error = 'Cannot fetch video.' this.error = 'Cannot fetch video.'
} }
) )
} }
checkForm () { checkForm () {

View File

@ -1,17 +1,14 @@
import { NgModule } from '@angular/core' import { NgModule } from '@angular/core'
import { TagInputModule } from 'ngx-chips'
import { VideoUpdateRoutingModule } from './video-update-routing.module' import { VideoUpdateRoutingModule } from './video-update-routing.module'
import { VideoUpdateComponent } from './video-update.component' import { VideoUpdateComponent } from './video-update.component'
import { VideoService } from '../shared' import { VideoEditModule } from './video-edit.module'
import { SharedModule } from '../../shared' import { SharedModule } from '../../shared'
@NgModule({ @NgModule({
imports: [ imports: [
TagInputModule,
VideoUpdateRoutingModule, VideoUpdateRoutingModule,
VideoEditModule,
SharedModule SharedModule
], ],
@ -23,8 +20,6 @@ import { SharedModule } from '../../shared'
VideoUpdateComponent VideoUpdateComponent
], ],
providers: [ providers: [ ]
VideoService
]
}) })
export class VideoUpdateModule { } export class VideoUpdateModule { }

View File

@ -13,7 +13,7 @@
<form novalidate [formGroup]="form"> <form novalidate [formGroup]="form">
<div class="form-group"> <div class="form-group">
<label for="description">Reason</label> <label for="reason">Reason</label>
<textarea <textarea
id="reason" class="form-control" placeholder="Reason..." id="reason" class="form-control" placeholder="Reason..."
formControlName="reason" formControlName="reason"

View File

@ -129,6 +129,16 @@
</div> </div>
<div class="video-details-description" [innerHTML]="videoHTMLDescription"></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>
<div class="video-details-attributes col-xs-4 col-md-3"> <div class="video-details-attributes col-xs-4 col-md-3">

View File

@ -170,6 +170,18 @@
font-weight: bold; font-weight: bold;
margin-bottom: 30px; 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 { .video-details-attributes {

View File

@ -38,6 +38,10 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
video: VideoDetails = null video: VideoDetails = null
videoPlayerLoaded = false videoPlayerLoaded = false
videoNotFound = false videoNotFound = false
completeDescriptionShown = false
completeVideoDescription: string
shortVideoDescription: string
videoHTMLDescription = '' videoHTMLDescription = ''
private paramsSub: Subscription 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) { showReportModal (event: Event) {
event.preventDefault() event.preventDefault()
this.videoReportModal.show() this.videoReportModal.show()
@ -184,6 +218,15 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
return this.video.isBlackistableBy(this.authService.getUser()) 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) { private handleError (err: any) {
const errorMessage: string = typeof err === 'string' ? err : err.message const errorMessage: string = typeof err === 'string' ? err : err.message
let 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.setOpenGraphTags()
this.checkUserRating() this.checkUserRating()

View File

@ -4,4 +4,5 @@ export * from './video.model'
export * from './video-details.model' export * from './video-details.model'
export * from './video-edit.model' export * from './video-edit.model'
export * from './video.service' export * from './video.service'
export * from './video-description.component'
export * from './video-pagination.model' export * from './video-pagination.model'

View File

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

View File

@ -0,0 +1,15 @@
textarea {
height: 150px;
}
.previews /deep/ {
.nav {
margin-top: 10px;
font-size: 0.9em;
}
.tab-content {
min-height: 75px;
padding: 5px;
}
}

View File

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

View File

@ -38,12 +38,14 @@ export class VideoDetails extends Video implements VideoDetailsServerModel {
likes: number likes: number
dislikes: number dislikes: number
nsfw: boolean nsfw: boolean
descriptionPath: string
files: VideoFile[] files: VideoFile[]
channel: VideoChannel channel: VideoChannel
constructor (hash: VideoDetailsServerModel) { constructor (hash: VideoDetailsServerModel) {
super(hash) super(hash)
this.descriptionPath = hash.descriptionPath
this.files = hash.files this.files = hash.files
this.channel = hash.channel this.channel = hash.channel
} }

View File

@ -99,15 +99,11 @@ export class VideoService {
.catch((res) => this.restExtractor.handleError(res)) .catch((res) => this.restExtractor.handleError(res))
} }
reportVideo (id: number, reason: string) { loadCompleteDescription (descriptionPath: string) {
const url = VideoService.BASE_VIDEO_URL + id + '/abuse' return this.authHttp
const body: VideoAbuseCreate = { .get(API_URL + descriptionPath)
reason .map(res => res['description'])
} .catch((res) => this.restExtractor.handleError(res))
return this.authHttp.post(url, body)
.map(this.restExtractor.extractDataBool)
.catch(res => this.restExtractor.handleError(res))
} }
setVideoLike (id: number) { setVideoLike (id: number) {

View File

@ -21,7 +21,7 @@
"no-attribute-parameter-decorator": true, "no-attribute-parameter-decorator": true,
"no-input-rename": true, "no-input-rename": true,
"no-output-rename": true, "no-output-rename": true,
"no-forward-ref": true, "no-forward-ref": false,
"use-life-cycle-interface": true, "use-life-cycle-interface": true,
"use-pipe-transform-interface": true, "use-pipe-transform-interface": true,
"pipe-naming": [true, "camelCase", "my"], "pipe-naming": [true, "camelCase", "my"],

View File

@ -138,7 +138,7 @@ export interface VideoInstance extends VideoClass, VideoAttributes, Sequelize.In
getOriginalFileHeight: VideoMethods.GetOriginalFileHeight getOriginalFileHeight: VideoMethods.GetOriginalFileHeight
getEmbedPath: VideoMethods.GetEmbedPath getEmbedPath: VideoMethods.GetEmbedPath
getDescriptionPath: VideoMethods.GetDescriptionPath getDescriptionPath: VideoMethods.GetDescriptionPath
getTruncatedDescription : VideoMethods.GetTruncatedDescription getTruncatedDescription: VideoMethods.GetTruncatedDescription
setTags: Sequelize.HasManySetAssociationsMixin<TagAttributes, string> setTags: Sequelize.HasManySetAssociationsMixin<TagAttributes, string>
addVideoFile: Sequelize.HasManyAddAssociationMixin<VideoFileAttributes, string> addVideoFile: Sequelize.HasManyAddAssociationMixin<VideoFileAttributes, string>

View File

@ -280,9 +280,7 @@ describe('Test videos API validator', function () {
licence: 1, licence: 1,
language: 6, language: 6,
nsfw: false, nsfw: false,
description: 'my super description which is very very very very very very very very very very very very very very' + description: 'my super description which is very very very very very very very very very very very very very very long'.repeat(35),
'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',
tags: [ 'tag1', 'tag2' ], tags: [ 'tag1', 'tag2' ],
channelId channelId
} }
@ -617,9 +615,7 @@ describe('Test videos API validator', function () {
licence: 2, licence: 2,
language: 6, language: 6,
nsfw: false, nsfw: false,
description: 'my super description which is very very very very very very very very very very very very very very' + description: 'my super description which is very very very very very very very very very very very very very long'.repeat(35),
'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',
tags: [ 'tag1', 'tag2' ] tags: [ 'tag1', 'tag2' ]
} }
await makePutBodyRequest({ url: server.url, path: path + videoId, token: server.accessToken, fields }) await makePutBodyRequest({ url: server.url, path: path + videoId, token: server.accessToken, fields })