diff options
author | Chocobozzz <me@florianbigard.com> | 2017-12-27 16:11:53 +0100 |
---|---|---|
committer | Chocobozzz <me@florianbigard.com> | 2017-12-27 16:11:53 +0100 |
commit | 4635f59d7c3fea4b97029f10886c62fdf38b2084 (patch) | |
tree | d97357a00042bbfb33c4177ee24c01171d28dfce | |
parent | ea44f375f5d3da06ca0aebfe871b9f924a26ec29 (diff) | |
download | PeerTube-4635f59d7c3fea4b97029f10886c62fdf38b2084.tar.gz PeerTube-4635f59d7c3fea4b97029f10886c62fdf38b2084.tar.zst PeerTube-4635f59d7c3fea4b97029f10886c62fdf38b2084.zip |
Add video comment components
41 files changed, 666 insertions, 70 deletions
diff --git a/client/src/app/shared/forms/form-validators/video-comment.ts b/client/src/app/shared/forms/form-validators/video-comment.ts new file mode 100644 index 000000000..42a97e300 --- /dev/null +++ b/client/src/app/shared/forms/form-validators/video-comment.ts | |||
@@ -0,0 +1,10 @@ | |||
1 | import { Validators } from '@angular/forms' | ||
2 | |||
3 | export const VIDEO_COMMENT_TEXT = { | ||
4 | VALIDATORS: [ Validators.required, Validators.minLength(2), Validators.maxLength(3000) ], | ||
5 | MESSAGES: { | ||
6 | 'required': 'Comment is required.', | ||
7 | 'minlength': 'Comment must be at least 2 characters long.', | ||
8 | 'maxlength': 'Comment cannot be more than 3000 characters long.' | ||
9 | } | ||
10 | } | ||
diff --git a/client/src/app/shared/misc/button.component.scss b/client/src/app/shared/misc/button.component.scss index c380c7ae1..145a3474a 100644 --- a/client/src/app/shared/misc/button.component.scss +++ b/client/src/app/shared/misc/button.component.scss | |||
@@ -20,7 +20,7 @@ | |||
20 | top: -2px; | 20 | top: -2px; |
21 | 21 | ||
22 | &.icon-edit { | 22 | &.icon-edit { |
23 | background-image: url('../../../assets/images/global/edit.svg'); | 23 | background-image: url('../../../assets/images/global/edit-grey.svg'); |
24 | } | 24 | } |
25 | 25 | ||
26 | &.icon-delete-grey { | 26 | &.icon-delete-grey { |
diff --git a/client/src/app/shared/video/video-pagination.model.ts b/client/src/app/shared/rest/component-pagination.model.ts index e9db61596..0b8ecc318 100644 --- a/client/src/app/shared/video/video-pagination.model.ts +++ b/client/src/app/shared/rest/component-pagination.model.ts | |||
@@ -1,4 +1,4 @@ | |||
1 | export interface VideoPagination { | 1 | export interface ComponentPagination { |
2 | currentPage: number | 2 | currentPage: number |
3 | itemsPerPage: number | 3 | itemsPerPage: number |
4 | totalItems?: number | 4 | totalItems?: number |
diff --git a/client/src/app/shared/rest/rest.service.ts b/client/src/app/shared/rest/rest.service.ts index a1c301050..5d5410de9 100644 --- a/client/src/app/shared/rest/rest.service.ts +++ b/client/src/app/shared/rest/rest.service.ts | |||
@@ -1,6 +1,7 @@ | |||
1 | import { Injectable } from '@angular/core' | 1 | import { Injectable } from '@angular/core' |
2 | import { HttpParams } from '@angular/common/http' | 2 | import { HttpParams } from '@angular/common/http' |
3 | import { SortMeta } from 'primeng/components/common/sortmeta' | 3 | import { SortMeta } from 'primeng/components/common/sortmeta' |
4 | import { ComponentPagination } from './component-pagination.model' | ||
4 | 5 | ||
5 | import { RestPagination } from './rest-pagination' | 6 | import { RestPagination } from './rest-pagination' |
6 | 7 | ||
@@ -31,4 +32,10 @@ export class RestService { | |||
31 | return newParams | 32 | return newParams |
32 | } | 33 | } |
33 | 34 | ||
35 | componentPaginationToRestPagination (componentPagination: ComponentPagination): RestPagination { | ||
36 | const start: number = (componentPagination.currentPage - 1) * componentPagination.itemsPerPage | ||
37 | const count: number = componentPagination.itemsPerPage | ||
38 | |||
39 | return { start, count } | ||
40 | } | ||
34 | } | 41 | } |
diff --git a/client/src/app/shared/video/abstract-video-list.ts b/client/src/app/shared/video/abstract-video-list.ts index 2b6870a78..bfe46bcdd 100644 --- a/client/src/app/shared/video/abstract-video-list.ts +++ b/client/src/app/shared/video/abstract-video-list.ts | |||
@@ -3,12 +3,12 @@ import { ActivatedRoute, Router } from '@angular/router' | |||
3 | import { NotificationsService } from 'angular2-notifications' | 3 | import { NotificationsService } from 'angular2-notifications' |
4 | import { Observable } from 'rxjs/Observable' | 4 | import { Observable } from 'rxjs/Observable' |
5 | import { AuthService } from '../../core/auth' | 5 | import { AuthService } from '../../core/auth' |
6 | import { ComponentPagination } from '../rest/component-pagination.model' | ||
6 | import { SortField } from './sort-field.type' | 7 | import { SortField } from './sort-field.type' |
7 | import { VideoPagination } from './video-pagination.model' | ||
8 | import { Video } from './video.model' | 8 | import { Video } from './video.model' |
9 | 9 | ||
10 | export abstract class AbstractVideoList implements OnInit { | 10 | export abstract class AbstractVideoList implements OnInit { |
11 | pagination: VideoPagination = { | 11 | pagination: ComponentPagination = { |
12 | currentPage: 1, | 12 | currentPage: 1, |
13 | itemsPerPage: 25, | 13 | itemsPerPage: 25, |
14 | totalItems: null | 14 | totalItems: null |
diff --git a/client/src/app/shared/video/video-miniature.component.scss b/client/src/app/shared/video/video-miniature.component.scss index 49ba1e51c..f0888ad9f 100644 --- a/client/src/app/shared/video/video-miniature.component.scss +++ b/client/src/app/shared/video/video-miniature.component.scss | |||
@@ -18,7 +18,6 @@ | |||
18 | overflow: hidden; | 18 | overflow: hidden; |
19 | text-overflow: ellipsis; | 19 | text-overflow: ellipsis; |
20 | white-space: nowrap; | 20 | white-space: nowrap; |
21 | font-weight: bold; | ||
22 | transition: color 0.2s; | 21 | transition: color 0.2s; |
23 | font-size: 16px; | 22 | font-size: 16px; |
24 | font-weight: $font-semibold; | 23 | font-weight: $font-semibold; |
diff --git a/client/src/app/shared/video/video.service.ts b/client/src/app/shared/video/video.service.ts index 91dd3977a..fc7505a51 100644 --- a/client/src/app/shared/video/video.service.ts +++ b/client/src/app/shared/video/video.service.ts | |||
@@ -10,13 +10,13 @@ import { UserVideoRate } from '../../../../../shared/models/videos/user-video-ra | |||
10 | import { VideoRateType } from '../../../../../shared/models/videos/video-rate.type' | 10 | import { VideoRateType } from '../../../../../shared/models/videos/video-rate.type' |
11 | import { VideoUpdate } from '../../../../../shared/models/videos/video-update.model' | 11 | import { VideoUpdate } from '../../../../../shared/models/videos/video-update.model' |
12 | import { environment } from '../../../environments/environment' | 12 | import { environment } from '../../../environments/environment' |
13 | import { ComponentPagination } from '../rest/component-pagination.model' | ||
13 | import { RestExtractor } from '../rest/rest-extractor.service' | 14 | import { RestExtractor } from '../rest/rest-extractor.service' |
14 | import { RestService } from '../rest/rest.service' | 15 | import { RestService } from '../rest/rest.service' |
15 | import { UserService } from '../users/user.service' | 16 | import { UserService } from '../users/user.service' |
16 | import { SortField } from './sort-field.type' | 17 | import { SortField } from './sort-field.type' |
17 | import { VideoDetails } from './video-details.model' | 18 | import { VideoDetails } from './video-details.model' |
18 | import { VideoEdit } from './video-edit.model' | 19 | import { VideoEdit } from './video-edit.model' |
19 | import { VideoPagination } from './video-pagination.model' | ||
20 | import { Video } from './video.model' | 20 | import { Video } from './video.model' |
21 | 21 | ||
22 | @Injectable() | 22 | @Injectable() |
@@ -71,8 +71,8 @@ export class VideoService { | |||
71 | .catch(this.restExtractor.handleError) | 71 | .catch(this.restExtractor.handleError) |
72 | } | 72 | } |
73 | 73 | ||
74 | getMyVideos (videoPagination: VideoPagination, sort: SortField): Observable<{ videos: Video[], totalVideos: number}> { | 74 | getMyVideos (videoPagination: ComponentPagination, sort: SortField): Observable<{ videos: Video[], totalVideos: number}> { |
75 | const pagination = this.videoPaginationToRestPagination(videoPagination) | 75 | const pagination = this.restService.componentPaginationToRestPagination(videoPagination) |
76 | 76 | ||
77 | let params = new HttpParams() | 77 | let params = new HttpParams() |
78 | params = this.restService.addRestGetParams(params, pagination, sort) | 78 | params = this.restService.addRestGetParams(params, pagination, sort) |
@@ -82,8 +82,8 @@ export class VideoService { | |||
82 | .catch((res) => this.restExtractor.handleError(res)) | 82 | .catch((res) => this.restExtractor.handleError(res)) |
83 | } | 83 | } |
84 | 84 | ||
85 | getVideos (videoPagination: VideoPagination, sort: SortField): Observable<{ videos: Video[], totalVideos: number}> { | 85 | getVideos (videoPagination: ComponentPagination, sort: SortField): Observable<{ videos: Video[], totalVideos: number}> { |
86 | const pagination = this.videoPaginationToRestPagination(videoPagination) | 86 | const pagination = this.restService.componentPaginationToRestPagination(videoPagination) |
87 | 87 | ||
88 | let params = new HttpParams() | 88 | let params = new HttpParams() |
89 | params = this.restService.addRestGetParams(params, pagination, sort) | 89 | params = this.restService.addRestGetParams(params, pagination, sort) |
@@ -94,10 +94,14 @@ export class VideoService { | |||
94 | .catch((res) => this.restExtractor.handleError(res)) | 94 | .catch((res) => this.restExtractor.handleError(res)) |
95 | } | 95 | } |
96 | 96 | ||
97 | searchVideos (search: string, videoPagination: VideoPagination, sort: SortField): Observable<{ videos: Video[], totalVideos: number}> { | 97 | searchVideos ( |
98 | search: string, | ||
99 | videoPagination: ComponentPagination, | ||
100 | sort: SortField | ||
101 | ): Observable<{ videos: Video[], totalVideos: number}> { | ||
98 | const url = VideoService.BASE_VIDEO_URL + 'search' | 102 | const url = VideoService.BASE_VIDEO_URL + 'search' |
99 | 103 | ||
100 | const pagination = this.videoPaginationToRestPagination(videoPagination) | 104 | const pagination = this.restService.componentPaginationToRestPagination(videoPagination) |
101 | 105 | ||
102 | let params = new HttpParams() | 106 | let params = new HttpParams() |
103 | params = this.restService.addRestGetParams(params, pagination, sort) | 107 | params = this.restService.addRestGetParams(params, pagination, sort) |
@@ -139,13 +143,6 @@ export class VideoService { | |||
139 | .catch(res => this.restExtractor.handleError(res)) | 143 | .catch(res => this.restExtractor.handleError(res)) |
140 | } | 144 | } |
141 | 145 | ||
142 | private videoPaginationToRestPagination (videoPagination: VideoPagination) { | ||
143 | const start: number = (videoPagination.currentPage - 1) * videoPagination.itemsPerPage | ||
144 | const count: number = videoPagination.itemsPerPage | ||
145 | |||
146 | return { start, count } | ||
147 | } | ||
148 | |||
149 | private setVideoRate (id: number, rateType: VideoRateType) { | 146 | private setVideoRate (id: number, rateType: VideoRateType) { |
150 | const url = VideoService.BASE_VIDEO_URL + id + '/rate' | 147 | const url = VideoService.BASE_VIDEO_URL + id + '/rate' |
151 | const body: UserVideoRateUpdate = { | 148 | const body: UserVideoRateUpdate = { |
diff --git a/client/src/app/videos/+video-watch/comment/video-comment-add.component.html b/client/src/app/videos/+video-watch/comment/video-comment-add.component.html new file mode 100644 index 000000000..792053614 --- /dev/null +++ b/client/src/app/videos/+video-watch/comment/video-comment-add.component.html | |||
@@ -0,0 +1,15 @@ | |||
1 | <form novalidate [formGroup]="form" (ngSubmit)="formValidated()"> | ||
2 | <div class="form-group"> | ||
3 | <textarea placeholder="Add comment..." formControlName="text" [ngClass]="{ 'input-error': formErrors['text'] }"> | ||
4 | </textarea> | ||
5 | <div *ngIf="formErrors.text" class="form-error"> | ||
6 | {{ formErrors.text }} | ||
7 | </div> | ||
8 | </div> | ||
9 | |||
10 | <div class="submit-comment"> | ||
11 | <button *ngIf="isAddButtonDisplayed()" [ngClass]="{ disabled: !form.valid }"> | ||
12 | Post comment | ||
13 | </button> | ||
14 | </div> | ||
15 | </form> | ||
diff --git a/client/src/app/videos/+video-watch/comment/video-comment-add.component.scss b/client/src/app/videos/+video-watch/comment/video-comment-add.component.scss new file mode 100644 index 000000000..9661062e8 --- /dev/null +++ b/client/src/app/videos/+video-watch/comment/video-comment-add.component.scss | |||
@@ -0,0 +1,20 @@ | |||
1 | @import '_variables'; | ||
2 | @import '_mixins'; | ||
3 | |||
4 | .form-group { | ||
5 | margin-bottom: 10px; | ||
6 | } | ||
7 | |||
8 | textarea { | ||
9 | @include peertube-textarea(100%, 150px); | ||
10 | } | ||
11 | |||
12 | .submit-comment { | ||
13 | display: flex; | ||
14 | justify-content: end; | ||
15 | |||
16 | button { | ||
17 | @include peertube-button; | ||
18 | @include orange-button | ||
19 | } | ||
20 | } | ||
diff --git a/client/src/app/videos/+video-watch/comment/video-comment-add.component.ts b/client/src/app/videos/+video-watch/comment/video-comment-add.component.ts new file mode 100644 index 000000000..5ad83fc47 --- /dev/null +++ b/client/src/app/videos/+video-watch/comment/video-comment-add.component.ts | |||
@@ -0,0 +1,84 @@ | |||
1 | import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core' | ||
2 | import { FormBuilder, FormGroup } from '@angular/forms' | ||
3 | import { NotificationsService } from 'angular2-notifications' | ||
4 | import { Observable } from 'rxjs/Observable' | ||
5 | import { VideoCommentCreate } from '../../../../../../shared/models/videos/video-comment.model' | ||
6 | import { FormReactive } from '../../../shared' | ||
7 | import { VIDEO_COMMENT_TEXT } from '../../../shared/forms/form-validators/video-comment' | ||
8 | import { Video } from '../../../shared/video/video.model' | ||
9 | import { VideoComment } from './video-comment.model' | ||
10 | import { VideoCommentService } from './video-comment.service' | ||
11 | |||
12 | @Component({ | ||
13 | selector: 'my-video-comment-add', | ||
14 | templateUrl: './video-comment-add.component.html', | ||
15 | styleUrls: ['./video-comment-add.component.scss'] | ||
16 | }) | ||
17 | export class VideoCommentAddComponent extends FormReactive implements OnInit { | ||
18 | @Input() video: Video | ||
19 | @Input() parentComment: VideoComment | ||
20 | |||
21 | @Output() commentCreated = new EventEmitter<VideoCommentCreate>() | ||
22 | |||
23 | form: FormGroup | ||
24 | formErrors = { | ||
25 | 'text': '' | ||
26 | } | ||
27 | validationMessages = { | ||
28 | 'text': VIDEO_COMMENT_TEXT.MESSAGES | ||
29 | } | ||
30 | |||
31 | constructor ( | ||
32 | private formBuilder: FormBuilder, | ||
33 | private notificationsService: NotificationsService, | ||
34 | private videoCommentService: VideoCommentService | ||
35 | ) { | ||
36 | super() | ||
37 | } | ||
38 | |||
39 | buildForm () { | ||
40 | this.form = this.formBuilder.group({ | ||
41 | text: [ '', VIDEO_COMMENT_TEXT.VALIDATORS ] | ||
42 | }) | ||
43 | |||
44 | this.form.valueChanges.subscribe(data => this.onValueChanged(data)) | ||
45 | } | ||
46 | |||
47 | ngOnInit () { | ||
48 | this.buildForm() | ||
49 | } | ||
50 | |||
51 | formValidated () { | ||
52 | const commentCreate: VideoCommentCreate = this.form.value | ||
53 | let obs: Observable<any> | ||
54 | |||
55 | if (this.parentComment) { | ||
56 | obs = this.addCommentReply(commentCreate) | ||
57 | } else { | ||
58 | obs = this.addCommentThread(commentCreate) | ||
59 | } | ||
60 | |||
61 | obs.subscribe( | ||
62 | comment => { | ||
63 | this.commentCreated.emit(comment) | ||
64 | this.form.reset() | ||
65 | }, | ||
66 | |||
67 | err => this.notificationsService.error('Error', err.text) | ||
68 | ) | ||
69 | } | ||
70 | |||
71 | isAddButtonDisplayed () { | ||
72 | return this.form.value['text'] | ||
73 | } | ||
74 | |||
75 | private addCommentReply (commentCreate: VideoCommentCreate) { | ||
76 | return this.videoCommentService | ||
77 | .addCommentReply(this.video.id, this.parentComment.id, commentCreate) | ||
78 | } | ||
79 | |||
80 | private addCommentThread (commentCreate: VideoCommentCreate) { | ||
81 | return this.videoCommentService | ||
82 | .addCommentThread(this.video.id, commentCreate) | ||
83 | } | ||
84 | } | ||
diff --git a/client/src/app/videos/+video-watch/comment/video-comment.component.html b/client/src/app/videos/+video-watch/comment/video-comment.component.html new file mode 100644 index 000000000..9608a1033 --- /dev/null +++ b/client/src/app/videos/+video-watch/comment/video-comment.component.html | |||
@@ -0,0 +1,29 @@ | |||
1 | <div class="comment"> | ||
2 | <div class="comment-account-date"> | ||
3 | <div class="comment-account">{{ comment.by }}</div> | ||
4 | <div class="comment-date">{{ comment.createdAt | myFromNow }}</div> | ||
5 | </div> | ||
6 | <div>{{ comment.text }}</div> | ||
7 | |||
8 | <div class="comment-actions"> | ||
9 | <div *ngIf="isUserLoggedIn()" (click)="onWantToReply()" class="comment-action-reply">Reply</div> | ||
10 | </div> | ||
11 | |||
12 | <my-video-comment-add | ||
13 | *ngIf="isUserLoggedIn() && inReplyToCommentId === comment.id" [video]="video" [parentComment]="comment" | ||
14 | (commentCreated)="onCommentReplyCreated($event)" | ||
15 | ></my-video-comment-add> | ||
16 | |||
17 | <div *ngIf="commentTree" class="children"> | ||
18 | <div *ngFor="let commentChild of commentTree.children"> | ||
19 | <my-video-comment | ||
20 | [comment]="commentChild.comment" | ||
21 | [video]="video" | ||
22 | [inReplyToCommentId]="inReplyToCommentId" | ||
23 | [commentTree]="commentChild" | ||
24 | (wantedToReply)="onWantedToReply($event)" | ||
25 | (resetReply)="onResetReply()" | ||
26 | ></my-video-comment> | ||
27 | </div> | ||
28 | </div> | ||
29 | </div> | ||
diff --git a/client/src/app/videos/+video-watch/comment/video-comment.component.scss b/client/src/app/videos/+video-watch/comment/video-comment.component.scss new file mode 100644 index 000000000..7e1a32f48 --- /dev/null +++ b/client/src/app/videos/+video-watch/comment/video-comment.component.scss | |||
@@ -0,0 +1,38 @@ | |||
1 | @import '_variables'; | ||
2 | @import '_mixins'; | ||
3 | |||
4 | .comment { | ||
5 | font-size: 15px; | ||
6 | margin-top: 30px; | ||
7 | |||
8 | .comment-account-date { | ||
9 | display: flex; | ||
10 | margin-bottom: 4px; | ||
11 | |||
12 | .comment-account { | ||
13 | font-weight: $font-bold; | ||
14 | } | ||
15 | |||
16 | .comment-date { | ||
17 | color: #585858; | ||
18 | margin-left: 10px; | ||
19 | } | ||
20 | } | ||
21 | |||
22 | .comment-actions { | ||
23 | margin: 10px 0; | ||
24 | |||
25 | .comment-action-reply { | ||
26 | color: #585858; | ||
27 | cursor: pointer; | ||
28 | } | ||
29 | } | ||
30 | } | ||
31 | |||
32 | .children { | ||
33 | margin-left: 20px; | ||
34 | |||
35 | .comment { | ||
36 | margin-top: 15px; | ||
37 | } | ||
38 | } | ||
diff --git a/client/src/app/videos/+video-watch/comment/video-comment.component.ts b/client/src/app/videos/+video-watch/comment/video-comment.component.ts new file mode 100644 index 000000000..b8e2acd52 --- /dev/null +++ b/client/src/app/videos/+video-watch/comment/video-comment.component.ts | |||
@@ -0,0 +1,67 @@ | |||
1 | import { Component, EventEmitter, Input, Output } from '@angular/core' | ||
2 | import { NotificationsService } from 'angular2-notifications' | ||
3 | import { VideoCommentThreadTree } from '../../../../../../shared/models/videos/video-comment.model' | ||
4 | import { AuthService } from '../../../core/auth' | ||
5 | import { User } from '../../../shared/users' | ||
6 | import { Video } from '../../../shared/video/video.model' | ||
7 | import { VideoComment } from './video-comment.model' | ||
8 | import { VideoCommentService } from './video-comment.service' | ||
9 | |||
10 | @Component({ | ||
11 | selector: 'my-video-comment', | ||
12 | templateUrl: './video-comment.component.html', | ||
13 | styleUrls: ['./video-comment.component.scss'] | ||
14 | }) | ||
15 | export class VideoCommentComponent { | ||
16 | @Input() video: Video | ||
17 | @Input() comment: VideoComment | ||
18 | @Input() commentTree: VideoCommentThreadTree | ||
19 | @Input() inReplyToCommentId: number | ||
20 | |||
21 | @Output() wantedToReply = new EventEmitter<VideoComment>() | ||
22 | @Output() resetReply = new EventEmitter() | ||
23 | |||
24 | constructor (private authService: AuthService, | ||
25 | private notificationsService: NotificationsService, | ||
26 | private videoCommentService: VideoCommentService) { | ||
27 | } | ||
28 | |||
29 | onCommentReplyCreated (comment: VideoComment) { | ||
30 | this.videoCommentService.addCommentReply(this.video.id, this.comment.id, comment) | ||
31 | .subscribe( | ||
32 | createdComment => { | ||
33 | if (!this.commentTree) { | ||
34 | this.commentTree = { | ||
35 | comment: this.comment, | ||
36 | children: [] | ||
37 | } | ||
38 | } | ||
39 | |||
40 | this.commentTree.children.push({ | ||
41 | comment: createdComment, | ||
42 | children: [] | ||
43 | }) | ||
44 | this.resetReply.emit() | ||
45 | }, | ||
46 | |||
47 | err => this.notificationsService.error('Error', err.message) | ||
48 | ) | ||
49 | } | ||
50 | |||
51 | onWantToReply () { | ||
52 | this.wantedToReply.emit(this.comment) | ||
53 | } | ||
54 | |||
55 | isUserLoggedIn () { | ||
56 | return this.authService.isLoggedIn() | ||
57 | } | ||
58 | |||
59 | // Event from child comment | ||
60 | onWantedToReply (comment: VideoComment) { | ||
61 | this.wantedToReply.emit(comment) | ||
62 | } | ||
63 | |||
64 | onResetReply () { | ||
65 | this.resetReply.emit() | ||
66 | } | ||
67 | } | ||
diff --git a/client/src/app/videos/+video-watch/comment/video-comment.model.ts b/client/src/app/videos/+video-watch/comment/video-comment.model.ts new file mode 100644 index 000000000..df7d5244c --- /dev/null +++ b/client/src/app/videos/+video-watch/comment/video-comment.model.ts | |||
@@ -0,0 +1,38 @@ | |||
1 | import { VideoComment as VideoCommentServerModel } from '../../../../../../shared/models/videos/video-comment.model' | ||
2 | |||
3 | export class VideoComment implements VideoCommentServerModel { | ||
4 | id: number | ||
5 | url: string | ||
6 | text: string | ||
7 | threadId: number | ||
8 | inReplyToCommentId: number | ||
9 | videoId: number | ||
10 | createdAt: Date | string | ||
11 | updatedAt: Date | string | ||
12 | account: { | ||
13 | name: string | ||
14 | host: string | ||
15 | } | ||
16 | totalReplies: number | ||
17 | |||
18 | by: string | ||
19 | |||
20 | private static createByString (account: string, serverHost: string) { | ||
21 | return account + '@' + serverHost | ||
22 | } | ||
23 | |||
24 | constructor (hash: VideoCommentServerModel) { | ||
25 | this.id = hash.id | ||
26 | this.url = hash.url | ||
27 | this.text = hash.text | ||
28 | this.threadId = hash.threadId | ||
29 | this.inReplyToCommentId = hash.inReplyToCommentId | ||
30 | this.videoId = hash.videoId | ||
31 | this.createdAt = new Date(hash.createdAt.toString()) | ||
32 | this.updatedAt = new Date(hash.updatedAt.toString()) | ||
33 | this.account = hash.account | ||
34 | this.totalReplies = hash.totalReplies | ||
35 | |||
36 | this.by = VideoComment.createByString(this.account.name, this.account.host) | ||
37 | } | ||
38 | } | ||
diff --git a/client/src/app/videos/+video-watch/comment/video-comment.service.ts b/client/src/app/videos/+video-watch/comment/video-comment.service.ts new file mode 100644 index 000000000..2fe6cc3e9 --- /dev/null +++ b/client/src/app/videos/+video-watch/comment/video-comment.service.ts | |||
@@ -0,0 +1,93 @@ | |||
1 | import { HttpClient, HttpParams } from '@angular/common/http' | ||
2 | import { Injectable } from '@angular/core' | ||
3 | import 'rxjs/add/operator/catch' | ||
4 | import 'rxjs/add/operator/map' | ||
5 | import { Observable } from 'rxjs/Observable' | ||
6 | import { ResultList } from '../../../../../../shared/models' | ||
7 | import { | ||
8 | VideoComment as VideoCommentServerModel, VideoCommentCreate, | ||
9 | VideoCommentThreadTree | ||
10 | } from '../../../../../../shared/models/videos/video-comment.model' | ||
11 | import { environment } from '../../../../environments/environment' | ||
12 | import { RestExtractor, RestService } from '../../../shared/rest' | ||
13 | import { ComponentPagination } from '../../../shared/rest/component-pagination.model' | ||
14 | import { SortField } from '../../../shared/video/sort-field.type' | ||
15 | import { VideoComment } from './video-comment.model' | ||
16 | |||
17 | @Injectable() | ||
18 | export class VideoCommentService { | ||
19 | private static BASE_VIDEO_URL = environment.apiUrl + '/api/v1/videos/' | ||
20 | |||
21 | constructor ( | ||
22 | private authHttp: HttpClient, | ||
23 | private restExtractor: RestExtractor, | ||
24 | private restService: RestService | ||
25 | ) {} | ||
26 | |||
27 | addCommentThread (videoId: number | string, comment: VideoCommentCreate) { | ||
28 | const url = VideoCommentService.BASE_VIDEO_URL + videoId + '/comment-threads' | ||
29 | |||
30 | return this.authHttp.post(url, comment) | ||
31 | .map(data => this.extractVideoComment(data['comment'])) | ||
32 | .catch(this.restExtractor.handleError) | ||
33 | } | ||
34 | |||
35 | addCommentReply (videoId: number | string, inReplyToCommentId: number, comment: VideoCommentCreate) { | ||
36 | const url = VideoCommentService.BASE_VIDEO_URL + videoId + '/comments/' + inReplyToCommentId | ||
37 | |||
38 | return this.authHttp.post(url, comment) | ||
39 | .map(data => this.extractVideoComment(data['comment'])) | ||
40 | .catch(this.restExtractor.handleError) | ||
41 | } | ||
42 | |||
43 | getVideoCommentThreads ( | ||
44 | videoId: number | string, | ||
45 | componentPagination: ComponentPagination, | ||
46 | sort: SortField | ||
47 | ): Observable<{ comments: VideoComment[], totalComments: number}> { | ||
48 | const pagination = this.restService.componentPaginationToRestPagination(componentPagination) | ||
49 | |||
50 | let params = new HttpParams() | ||
51 | params = this.restService.addRestGetParams(params, pagination, sort) | ||
52 | |||
53 | const url = VideoCommentService.BASE_VIDEO_URL + videoId + '/comment-threads' | ||
54 | return this.authHttp | ||
55 | .get(url, { params }) | ||
56 | .map(this.extractVideoComments) | ||
57 | .catch((res) => this.restExtractor.handleError(res)) | ||
58 | } | ||
59 | |||
60 | getVideoThreadComments (videoId: number | string, threadId: number): Observable<VideoCommentThreadTree> { | ||
61 | const url = `${VideoCommentService.BASE_VIDEO_URL + videoId}/comment-threads/${threadId}` | ||
62 | |||
63 | return this.authHttp | ||
64 | .get(url) | ||
65 | .map(tree => this.extractVideoCommentTree(tree as VideoCommentThreadTree)) | ||
66 | .catch((res) => this.restExtractor.handleError(res)) | ||
67 | } | ||
68 | |||
69 | private extractVideoComment (videoComment: VideoCommentServerModel) { | ||
70 | return new VideoComment(videoComment) | ||
71 | } | ||
72 | |||
73 | private extractVideoComments (result: ResultList<VideoCommentServerModel>) { | ||
74 | const videoCommentsJson = result.data | ||
75 | const totalComments = result.total | ||
76 | const comments = [] | ||
77 | |||
78 | for (const videoCommentJson of videoCommentsJson) { | ||
79 | comments.push(new VideoComment(videoCommentJson)) | ||
80 | } | ||
81 | |||
82 | return { comments, totalComments } | ||
83 | } | ||
84 | |||
85 | private extractVideoCommentTree (tree: VideoCommentThreadTree) { | ||
86 | if (!tree) return tree | ||
87 | |||
88 | tree.comment = new VideoComment(tree.comment) | ||
89 | tree.children.forEach(c => this.extractVideoCommentTree(c)) | ||
90 | |||
91 | return tree | ||
92 | } | ||
93 | } | ||
diff --git a/client/src/app/videos/+video-watch/comment/video-comments.component.html b/client/src/app/videos/+video-watch/comment/video-comments.component.html new file mode 100644 index 000000000..9d7581269 --- /dev/null +++ b/client/src/app/videos/+video-watch/comment/video-comments.component.html | |||
@@ -0,0 +1,31 @@ | |||
1 | <div> | ||
2 | <div class="title-page title-page-single"> | ||
3 | Comments | ||
4 | </div> | ||
5 | |||
6 | <my-video-comment-add | ||
7 | *ngIf="isUserLoggedIn()" | ||
8 | [video]="video" | ||
9 | (commentCreated)="onCommentThreadCreated($event)" | ||
10 | ></my-video-comment-add> | ||
11 | |||
12 | <div class="comment-threads"> | ||
13 | <div *ngFor="let comment of comments"> | ||
14 | <my-video-comment | ||
15 | [comment]="comment" | ||
16 | [video]="video" | ||
17 | [inReplyToCommentId]="inReplyToCommentId" | ||
18 | [commentTree]="threadComments[comment.id]" | ||
19 | (wantedToReply)="onWantedToReply($event)" | ||
20 | (resetReply)="onResetReply()" | ||
21 | ></my-video-comment> | ||
22 | |||
23 | <div *ngIf="comment.totalReplies !== 0 && !threadComments[comment.id]" (click)="viewReplies(comment)" class="view-replies"> | ||
24 | View all {{ comment.totalReplies }} replies | ||
25 | |||
26 | <span *ngIf="!threadLoading[comment.id]" class="glyphicon glyphicon-menu-down"></span> | ||
27 | <my-loader class="comment-thread-loading" [loading]="threadLoading[comment.id]"></my-loader> | ||
28 | </div> | ||
29 | </div> | ||
30 | </div> | ||
31 | </div> | ||
diff --git a/client/src/app/videos/+video-watch/comment/video-comments.component.scss b/client/src/app/videos/+video-watch/comment/video-comments.component.scss new file mode 100644 index 000000000..2f6e4663b --- /dev/null +++ b/client/src/app/videos/+video-watch/comment/video-comments.component.scss | |||
@@ -0,0 +1,14 @@ | |||
1 | @import '_variables'; | ||
2 | @import '_mixins'; | ||
3 | |||
4 | .view-replies { | ||
5 | font-weight: $font-semibold; | ||
6 | font-size: 15px; | ||
7 | cursor: pointer; | ||
8 | } | ||
9 | |||
10 | .glyphicon, .comment-thread-loading { | ||
11 | margin-left: 5px; | ||
12 | display: inline-block; | ||
13 | font-size: 13px; | ||
14 | } | ||
diff --git a/client/src/app/videos/+video-watch/comment/video-comments.component.ts b/client/src/app/videos/+video-watch/comment/video-comments.component.ts new file mode 100644 index 000000000..32e0f2fbd --- /dev/null +++ b/client/src/app/videos/+video-watch/comment/video-comments.component.ts | |||
@@ -0,0 +1,79 @@ | |||
1 | import { Component, Input, OnInit } from '@angular/core' | ||
2 | import { NotificationsService } from 'angular2-notifications' | ||
3 | import { VideoCommentThreadTree } from '../../../../../../shared/models/videos/video-comment.model' | ||
4 | import { AuthService } from '../../../core/auth' | ||
5 | import { ComponentPagination } from '../../../shared/rest/component-pagination.model' | ||
6 | import { User } from '../../../shared/users' | ||
7 | import { SortField } from '../../../shared/video/sort-field.type' | ||
8 | import { Video } from '../../../shared/video/video.model' | ||
9 | import { VideoComment } from './video-comment.model' | ||
10 | import { VideoCommentService } from './video-comment.service' | ||
11 | |||
12 | @Component({ | ||
13 | selector: 'my-video-comments', | ||
14 | templateUrl: './video-comments.component.html', | ||
15 | styleUrls: ['./video-comments.component.scss'] | ||
16 | }) | ||
17 | export class VideoCommentsComponent implements OnInit { | ||
18 | @Input() video: Video | ||
19 | @Input() user: User | ||
20 | |||
21 | comments: VideoComment[] = [] | ||
22 | sort: SortField = '-createdAt' | ||
23 | componentPagination: ComponentPagination = { | ||
24 | currentPage: 1, | ||
25 | itemsPerPage: 25, | ||
26 | totalItems: null | ||
27 | } | ||
28 | inReplyToCommentId: number | ||
29 | threadComments: { [ id: number ]: VideoCommentThreadTree } = {} | ||
30 | threadLoading: { [ id: number ]: boolean } = {} | ||
31 | |||
32 | constructor ( | ||
33 | private authService: AuthService, | ||
34 | private notificationsService: NotificationsService, | ||
35 | private videoCommentService: VideoCommentService | ||
36 | ) {} | ||
37 | |||
38 | ngOnInit () { | ||
39 | this.videoCommentService.getVideoCommentThreads(this.video.id, this.componentPagination, this.sort) | ||
40 | .subscribe( | ||
41 | res => { | ||
42 | this.comments = res.comments | ||
43 | this.componentPagination.totalItems = res.totalComments | ||
44 | }, | ||
45 | |||
46 | err => this.notificationsService.error('Error', err.message) | ||
47 | ) | ||
48 | } | ||
49 | |||
50 | viewReplies (comment: VideoComment) { | ||
51 | this.threadLoading[comment.id] = true | ||
52 | |||
53 | this.videoCommentService.getVideoThreadComments(this.video.id, comment.id) | ||
54 | .subscribe( | ||
55 | res => { | ||
56 | this.threadComments[comment.id] = res | ||
57 | this.threadLoading[comment.id] = false | ||
58 | }, | ||
59 | |||
60 | err => this.notificationsService.error('Error', err.message) | ||
61 | ) | ||
62 | } | ||
63 | |||
64 | onCommentThreadCreated (comment: VideoComment) { | ||
65 | this.comments.unshift(comment) | ||
66 | } | ||
67 | |||
68 | onWantedToReply (comment: VideoComment) { | ||
69 | this.inReplyToCommentId = comment.id | ||
70 | } | ||
71 | |||
72 | onResetReply () { | ||
73 | this.inReplyToCommentId = undefined | ||
74 | } | ||
75 | |||
76 | isUserLoggedIn () { | ||
77 | return this.authService.isLoggedIn() | ||
78 | } | ||
79 | } | ||
diff --git a/client/src/app/videos/+video-watch/video-download.component.html b/client/src/app/videos/+video-watch/modal/video-download.component.html index f8f17a471..f8f17a471 100644 --- a/client/src/app/videos/+video-watch/video-download.component.html +++ b/client/src/app/videos/+video-watch/modal/video-download.component.html | |||
diff --git a/client/src/app/videos/+video-watch/video-download.component.scss b/client/src/app/videos/+video-watch/modal/video-download.component.scss index 5fca82135..6325f67a3 100644 --- a/client/src/app/videos/+video-watch/video-download.component.scss +++ b/client/src/app/videos/+video-watch/modal/video-download.component.scss | |||
@@ -1,5 +1,5 @@ | |||
1 | @import '_variables'; | 1 | @import 'variables'; |
2 | @import '_mixins'; | 2 | @import 'mixins'; |
3 | 3 | ||
4 | .peertube-select-container { | 4 | .peertube-select-container { |
5 | @include peertube-select-container(130px); | 5 | @include peertube-select-container(130px); |
diff --git a/client/src/app/videos/+video-watch/video-download.component.ts b/client/src/app/videos/+video-watch/modal/video-download.component.ts index 44ece986c..1a73ea6df 100644 --- a/client/src/app/videos/+video-watch/video-download.component.ts +++ b/client/src/app/videos/+video-watch/modal/video-download.component.ts | |||
@@ -1,6 +1,6 @@ | |||
1 | import { Component, Input, OnInit, ViewChild } from '@angular/core' | 1 | import { Component, Input, OnInit, ViewChild } from '@angular/core' |
2 | import { ModalDirective } from 'ngx-bootstrap/modal' | 2 | import { ModalDirective } from 'ngx-bootstrap/modal' |
3 | import { VideoDetails } from '../../shared/video/video-details.model' | 3 | import { VideoDetails } from '../../../shared/video/video-details.model' |
4 | 4 | ||
5 | @Component({ | 5 | @Component({ |
6 | selector: 'my-video-download', | 6 | selector: 'my-video-download', |
diff --git a/client/src/app/videos/+video-watch/video-report.component.html b/client/src/app/videos/+video-watch/modal/video-report.component.html index a9a7beb48..a9a7beb48 100644 --- a/client/src/app/videos/+video-watch/video-report.component.html +++ b/client/src/app/videos/+video-watch/modal/video-report.component.html | |||
diff --git a/client/src/app/videos/+video-watch/video-report.component.scss b/client/src/app/videos/+video-watch/modal/video-report.component.scss index 09d273b35..84562f15c 100644 --- a/client/src/app/videos/+video-watch/video-report.component.scss +++ b/client/src/app/videos/+video-watch/modal/video-report.component.scss | |||
@@ -1,5 +1,5 @@ | |||
1 | @import '_variables'; | 1 | @import 'variables'; |
2 | @import '_mixins'; | 2 | @import 'mixins'; |
3 | 3 | ||
4 | textarea { | 4 | textarea { |
5 | @include peertube-textarea(100%, 60px); | 5 | @include peertube-textarea(100%, 60px); |
diff --git a/client/src/app/videos/+video-watch/video-report.component.ts b/client/src/app/videos/+video-watch/modal/video-report.component.ts index ece14754a..050e827e7 100644 --- a/client/src/app/videos/+video-watch/video-report.component.ts +++ b/client/src/app/videos/+video-watch/modal/video-report.component.ts | |||
@@ -2,8 +2,8 @@ import { Component, Input, OnInit, ViewChild } from '@angular/core' | |||
2 | import { FormBuilder, FormGroup } from '@angular/forms' | 2 | import { FormBuilder, FormGroup } from '@angular/forms' |
3 | import { NotificationsService } from 'angular2-notifications' | 3 | import { NotificationsService } from 'angular2-notifications' |
4 | import { ModalDirective } from 'ngx-bootstrap/modal' | 4 | import { ModalDirective } from 'ngx-bootstrap/modal' |
5 | import { FormReactive, VIDEO_ABUSE_REASON, VideoAbuseService } from '../../shared' | 5 | import { FormReactive, VIDEO_ABUSE_REASON, VideoAbuseService } from '../../../shared/index' |
6 | import { VideoDetails } from '../../shared/video/video-details.model' | 6 | import { VideoDetails } from '../../../shared/video/video-details.model' |
7 | 7 | ||
8 | @Component({ | 8 | @Component({ |
9 | selector: 'my-video-report', | 9 | selector: 'my-video-report', |
diff --git a/client/src/app/videos/+video-watch/video-share.component.html b/client/src/app/videos/+video-watch/modal/video-share.component.html index 85cf10a6c..85cf10a6c 100644 --- a/client/src/app/videos/+video-watch/video-share.component.html +++ b/client/src/app/videos/+video-watch/modal/video-share.component.html | |||
diff --git a/client/src/app/videos/+video-watch/video-share.component.scss b/client/src/app/videos/+video-watch/modal/video-share.component.scss index 184e09027..184e09027 100644 --- a/client/src/app/videos/+video-watch/video-share.component.scss +++ b/client/src/app/videos/+video-watch/modal/video-share.component.scss | |||
diff --git a/client/src/app/videos/+video-watch/video-share.component.ts b/client/src/app/videos/+video-watch/modal/video-share.component.ts index 0664c28be..678cccfb5 100644 --- a/client/src/app/videos/+video-watch/video-share.component.ts +++ b/client/src/app/videos/+video-watch/modal/video-share.component.ts | |||
@@ -3,7 +3,7 @@ import { Component, Input, ViewChild } from '@angular/core' | |||
3 | import { NotificationsService } from 'angular2-notifications' | 3 | import { NotificationsService } from 'angular2-notifications' |
4 | 4 | ||
5 | import { ModalDirective } from 'ngx-bootstrap/modal' | 5 | import { ModalDirective } from 'ngx-bootstrap/modal' |
6 | import { VideoDetails } from '../../shared/video/video-details.model' | 6 | import { VideoDetails } from '../../../shared/video/video-details.model' |
7 | 7 | ||
8 | @Component({ | 8 | @Component({ |
9 | selector: 'my-video-share', | 9 | selector: 'my-video-share', |
diff --git a/client/src/app/videos/+video-watch/video-watch.component.html b/client/src/app/videos/+video-watch/video-watch.component.html index 860edecd2..48d1bb474 100644 --- a/client/src/app/videos/+video-watch/video-watch.component.html +++ b/client/src/app/videos/+video-watch/video-watch.component.html | |||
@@ -54,6 +54,12 @@ | |||
54 | </a> | 54 | </a> |
55 | </li> | 55 | </li> |
56 | 56 | ||
57 | <li *ngIf="isVideoUpdatable()" role="menuitem"> | ||
58 | <a class="dropdown-item" title="Update this video" href="#" [routerLink]="[ '/videos/edit', video.uuid ]"> | ||
59 | <span class="icon icon-edit"></span> Update | ||
60 | </a> | ||
61 | </li> | ||
62 | |||
57 | <li *ngIf="isVideoRemovable()" role="menuitem"> | 63 | <li *ngIf="isVideoRemovable()" role="menuitem"> |
58 | <a class="dropdown-item" title="Delete this video" href="#" (click)="removeVideo($event)"> | 64 | <a class="dropdown-item" title="Delete this video" href="#" (click)="removeVideo($event)"> |
59 | <span class="icon icon-blacklist"></span> Delete | 65 | <span class="icon icon-blacklist"></span> Delete |
@@ -149,6 +155,7 @@ | |||
149 | </div> | 155 | </div> |
150 | </div> | 156 | </div> |
151 | 157 | ||
158 | <my-video-comments [video]="video" [user]="user"></my-video-comments> | ||
152 | </div> | 159 | </div> |
153 | 160 | ||
154 | <div class="other-videos"> | 161 | <div class="other-videos"> |
diff --git a/client/src/app/videos/+video-watch/video-watch.component.scss b/client/src/app/videos/+video-watch/video-watch.component.scss index b37fa3d61..c101aa04e 100644 --- a/client/src/app/videos/+video-watch/video-watch.component.scss +++ b/client/src/app/videos/+video-watch/video-watch.component.scss | |||
@@ -126,6 +126,10 @@ | |||
126 | background-image: url('../../../assets/images/video/download-black.svg'); | 126 | background-image: url('../../../assets/images/video/download-black.svg'); |
127 | } | 127 | } |
128 | 128 | ||
129 | &.icon-edit { | ||
130 | background-image: url('../../../assets/images/global/edit-black.svg'); | ||
131 | } | ||
132 | |||
129 | &.icon-alert { | 133 | &.icon-alert { |
130 | background-image: url('../../../assets/images/video/alert.svg'); | 134 | background-image: url('../../../assets/images/video/alert.svg'); |
131 | } | 135 | } |
diff --git a/client/src/app/videos/+video-watch/video-watch.component.ts b/client/src/app/videos/+video-watch/video-watch.component.ts index c388b138b..4afd6160c 100644 --- a/client/src/app/videos/+video-watch/video-watch.component.ts +++ b/client/src/app/videos/+video-watch/video-watch.component.ts | |||
@@ -14,9 +14,9 @@ import { VideoDetails } from '../../shared/video/video-details.model' | |||
14 | import { Video } from '../../shared/video/video.model' | 14 | import { Video } from '../../shared/video/video.model' |
15 | import { VideoService } from '../../shared/video/video.service' | 15 | import { VideoService } from '../../shared/video/video.service' |
16 | import { MarkdownService } from '../shared' | 16 | import { MarkdownService } from '../shared' |
17 | import { VideoDownloadComponent } from './video-download.component' | 17 | import { VideoDownloadComponent } from './modal/video-download.component' |
18 | import { VideoReportComponent } from './video-report.component' | 18 | import { VideoReportComponent } from './modal/video-report.component' |
19 | import { VideoShareComponent } from './video-share.component' | 19 | import { VideoShareComponent } from './modal/video-share.component' |
20 | 20 | ||
21 | @Component({ | 21 | @Component({ |
22 | selector: 'my-video-watch', | 22 | selector: 'my-video-watch', |
@@ -208,6 +208,10 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
208 | return this.authService.isLoggedIn() | 208 | return this.authService.isLoggedIn() |
209 | } | 209 | } |
210 | 210 | ||
211 | isVideoUpdatable () { | ||
212 | return this.video.isUpdatableBy(this.authService.getUser()) | ||
213 | } | ||
214 | |||
211 | isVideoBlacklistable () { | 215 | isVideoBlacklistable () { |
212 | return this.video.isBlackistableBy(this.user) | 216 | return this.video.isBlackistableBy(this.user) |
213 | } | 217 | } |
diff --git a/client/src/app/videos/+video-watch/video-watch.module.ts b/client/src/app/videos/+video-watch/video-watch.module.ts index e77883472..085a9ec5a 100644 --- a/client/src/app/videos/+video-watch/video-watch.module.ts +++ b/client/src/app/videos/+video-watch/video-watch.module.ts | |||
@@ -3,9 +3,13 @@ import { TooltipModule } from 'ngx-bootstrap/tooltip' | |||
3 | import { ClipboardModule } from 'ngx-clipboard' | 3 | import { ClipboardModule } from 'ngx-clipboard' |
4 | import { SharedModule } from '../../shared' | 4 | import { SharedModule } from '../../shared' |
5 | import { MarkdownService } from '../shared' | 5 | import { MarkdownService } from '../shared' |
6 | import { VideoDownloadComponent } from './video-download.component' | 6 | import { VideoCommentAddComponent } from './comment/video-comment-add.component' |
7 | import { VideoReportComponent } from './video-report.component' | 7 | import { VideoCommentComponent } from './comment/video-comment.component' |
8 | import { VideoShareComponent } from './video-share.component' | 8 | import { VideoCommentService } from './comment/video-comment.service' |
9 | import { VideoCommentsComponent } from './comment/video-comments.component' | ||
10 | import { VideoDownloadComponent } from './modal/video-download.component' | ||
11 | import { VideoReportComponent } from './modal/video-report.component' | ||
12 | import { VideoShareComponent } from './modal/video-share.component' | ||
9 | 13 | ||
10 | import { VideoWatchRoutingModule } from './video-watch-routing.module' | 14 | import { VideoWatchRoutingModule } from './video-watch-routing.module' |
11 | 15 | ||
@@ -24,7 +28,10 @@ import { VideoWatchComponent } from './video-watch.component' | |||
24 | 28 | ||
25 | VideoDownloadComponent, | 29 | VideoDownloadComponent, |
26 | VideoShareComponent, | 30 | VideoShareComponent, |
27 | VideoReportComponent | 31 | VideoReportComponent, |
32 | VideoCommentsComponent, | ||
33 | VideoCommentAddComponent, | ||
34 | VideoCommentComponent | ||
28 | ], | 35 | ], |
29 | 36 | ||
30 | exports: [ | 37 | exports: [ |
@@ -32,7 +39,8 @@ import { VideoWatchComponent } from './video-watch.component' | |||
32 | ], | 39 | ], |
33 | 40 | ||
34 | providers: [ | 41 | providers: [ |
35 | MarkdownService | 42 | MarkdownService, |
43 | VideoCommentService | ||
36 | ] | 44 | ] |
37 | }) | 45 | }) |
38 | export class VideoWatchModule { } | 46 | export class VideoWatchModule { } |
diff --git a/client/src/assets/images/global/edit-black.svg b/client/src/assets/images/global/edit-black.svg new file mode 100644 index 000000000..0176b0f37 --- /dev/null +++ b/client/src/assets/images/global/edit-black.svg | |||
@@ -0,0 +1,15 @@ | |||
1 | <?xml version="1.0" encoding="UTF-8"?> | ||
2 | <svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> | ||
3 | <!-- Generator: Sketch 43.2 (39069) - http://www.bohemiancoding.com/sketch --> | ||
4 | <title>edit</title> | ||
5 | <desc>Created with Sketch.</desc> | ||
6 | <defs></defs> | ||
7 | <g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd"> | ||
8 | <g id="Artboard-4" transform="translate(-48.000000, -203.000000)" stroke="#000000" stroke-width="2"> | ||
9 | <g id="41" transform="translate(48.000000, 203.000000)"> | ||
10 | <path d="M3,21.0000003 L3,17 L15.8898356,4.11016442 C17.0598483,2.9401517 18.9638992,2.94723715 20.1306896,4.11402752 L19.9181432,3.90148112 C21.0902894,5.07362738 21.0882407,6.97202708 19.9174652,8.1377941 L7,21.0000003 L3,21.0000003 Z" id="Path-74" stroke-linecap="round" stroke-linejoin="round"></path> | ||
11 | <path d="M14.5,5.5 L18.5,9.5" id="Path-75"></path> | ||
12 | </g> | ||
13 | </g> | ||
14 | </g> | ||
15 | </svg> | ||
diff --git a/client/src/assets/images/global/edit.svg b/client/src/assets/images/global/edit-grey.svg index 23ece68f1..23ece68f1 100644 --- a/client/src/assets/images/global/edit.svg +++ b/client/src/assets/images/global/edit-grey.svg | |||
diff --git a/client/src/sass/application.scss b/client/src/sass/application.scss index 0539ec486..253bb1b3c 100644 --- a/client/src/sass/application.scss +++ b/client/src/sass/application.scss | |||
@@ -118,10 +118,7 @@ label { | |||
118 | 118 | ||
119 | // Thanks https://gist.github.com/alexandrevicenzi/680147013e902a4eaa5d | 119 | // Thanks https://gist.github.com/alexandrevicenzi/680147013e902a4eaa5d |
120 | .glyphicon-refresh-animate { | 120 | .glyphicon-refresh-animate { |
121 | -animation: spin .7s infinite linear; | 121 | animation: spin .7s infinite linear; |
122 | -ms-animation: spin .7s infinite linear; | ||
123 | -webkit-animation: spinw .7s infinite linear; | ||
124 | -moz-animation: spinm .7s infinite linear; | ||
125 | } | 122 | } |
126 | 123 | ||
127 | @keyframes spin { | 124 | @keyframes spin { |
@@ -129,16 +126,6 @@ label { | |||
129 | to { transform: scale(1) rotate(360deg);} | 126 | to { transform: scale(1) rotate(360deg);} |
130 | } | 127 | } |
131 | 128 | ||
132 | @-webkit-keyframes spinw { | ||
133 | from { -webkit-transform: rotate(0deg);} | ||
134 | to { -webkit-transform: rotate(360deg);} | ||
135 | } | ||
136 | |||
137 | @-moz-keyframes spinm { | ||
138 | from { -moz-transform: rotate(0deg);} | ||
139 | to { -moz-transform: rotate(360deg);} | ||
140 | } | ||
141 | |||
142 | // ngprime data table customizations | 129 | // ngprime data table customizations |
143 | p-datatable { | 130 | p-datatable { |
144 | font-size: 15px !important; | 131 | font-size: 15px !important; |
@@ -151,7 +151,7 @@ app.use(function (req, res, next) { | |||
151 | }) | 151 | }) |
152 | 152 | ||
153 | app.use(function (err, req, res, next) { | 153 | app.use(function (err, req, res, next) { |
154 | logger.error(err) | 154 | logger.error(err, err) |
155 | res.sendStatus(err.status || 500) | 155 | res.sendStatus(err.status || 500) |
156 | }) | 156 | }) |
157 | 157 | ||
diff --git a/server/controllers/api/videos/comment.ts b/server/controllers/api/videos/comment.ts index e9dbb6d1b..276948098 100644 --- a/server/controllers/api/videos/comment.ts +++ b/server/controllers/api/videos/comment.ts | |||
@@ -66,9 +66,7 @@ async function addVideoCommentThreadRetryWrapper (req: express.Request, res: exp | |||
66 | const comment = await retryTransactionWrapper(addVideoCommentThread, options) | 66 | const comment = await retryTransactionWrapper(addVideoCommentThread, options) |
67 | 67 | ||
68 | res.json({ | 68 | res.json({ |
69 | comment: { | 69 | comment: comment.toFormattedJSON() |
70 | id: comment.id | ||
71 | } | ||
72 | }).end() | 70 | }).end() |
73 | } | 71 | } |
74 | 72 | ||
@@ -80,7 +78,7 @@ function addVideoCommentThread (req: express.Request, res: express.Response) { | |||
80 | text: videoCommentInfo.text, | 78 | text: videoCommentInfo.text, |
81 | inReplyToComment: null, | 79 | inReplyToComment: null, |
82 | video: res.locals.video, | 80 | video: res.locals.video, |
83 | accountId: res.locals.oauth.token.User.Account.id | 81 | account: res.locals.oauth.token.User.Account |
84 | }, t) | 82 | }, t) |
85 | }) | 83 | }) |
86 | } | 84 | } |
@@ -94,9 +92,7 @@ async function addVideoCommentReplyRetryWrapper (req: express.Request, res: expr | |||
94 | const comment = await retryTransactionWrapper(addVideoCommentReply, options) | 92 | const comment = await retryTransactionWrapper(addVideoCommentReply, options) |
95 | 93 | ||
96 | res.json({ | 94 | res.json({ |
97 | comment: { | 95 | comment: comment.toFormattedJSON() |
98 | id: comment.id | ||
99 | } | ||
100 | }).end() | 96 | }).end() |
101 | } | 97 | } |
102 | 98 | ||
@@ -108,7 +104,7 @@ function addVideoCommentReply (req: express.Request, res: express.Response, next | |||
108 | text: videoCommentInfo.text, | 104 | text: videoCommentInfo.text, |
109 | inReplyToComment: res.locals.videoComment, | 105 | inReplyToComment: res.locals.videoComment, |
110 | video: res.locals.video, | 106 | video: res.locals.video, |
111 | accountId: res.locals.oauth.token.User.Account.id | 107 | account: res.locals.oauth.token.User.Account |
112 | }, t) | 108 | }, t) |
113 | }) | 109 | }) |
114 | } | 110 | } |
diff --git a/server/lib/video-comment.ts b/server/lib/video-comment.ts index ef6a8f097..0d744c526 100644 --- a/server/lib/video-comment.ts +++ b/server/lib/video-comment.ts | |||
@@ -1,29 +1,32 @@ | |||
1 | import * as Sequelize from 'sequelize' | 1 | import * as Sequelize from 'sequelize' |
2 | import { ResultList } from '../../shared/models' | 2 | import { ResultList } from '../../shared/models' |
3 | import { VideoCommentThreadTree } from '../../shared/models/videos/video-comment.model' | 3 | import { VideoCommentThreadTree } from '../../shared/models/videos/video-comment.model' |
4 | import { AccountModel } from '../models/account/account' | ||
4 | import { VideoModel } from '../models/video/video' | 5 | import { VideoModel } from '../models/video/video' |
5 | import { VideoCommentModel } from '../models/video/video-comment' | 6 | import { VideoCommentModel } from '../models/video/video-comment' |
6 | import { getVideoCommentActivityPubUrl, sendVideoRateChangeToFollowers } from './activitypub' | 7 | import { getVideoCommentActivityPubUrl } from './activitypub' |
7 | import { sendCreateVideoCommentToOrigin, sendCreateVideoCommentToVideoFollowers } from './activitypub/send' | 8 | import { sendCreateVideoCommentToOrigin, sendCreateVideoCommentToVideoFollowers } from './activitypub/send' |
8 | 9 | ||
9 | async function createVideoComment (obj: { | 10 | async function createVideoComment (obj: { |
10 | text: string, | 11 | text: string, |
11 | inReplyToComment: VideoCommentModel, | 12 | inReplyToComment: VideoCommentModel, |
12 | video: VideoModel | 13 | video: VideoModel |
13 | accountId: number | 14 | account: AccountModel |
14 | }, t: Sequelize.Transaction) { | 15 | }, t: Sequelize.Transaction) { |
15 | let originCommentId: number = null | 16 | let originCommentId: number = null |
17 | let inReplyToCommentId: number = null | ||
16 | 18 | ||
17 | if (obj.inReplyToComment) { | 19 | if (obj.inReplyToComment) { |
18 | originCommentId = obj.inReplyToComment.originCommentId || obj.inReplyToComment.id | 20 | originCommentId = obj.inReplyToComment.originCommentId || obj.inReplyToComment.id |
21 | inReplyToCommentId = obj.inReplyToComment.id | ||
19 | } | 22 | } |
20 | 23 | ||
21 | const comment = await VideoCommentModel.create({ | 24 | const comment = await VideoCommentModel.create({ |
22 | text: obj.text, | 25 | text: obj.text, |
23 | originCommentId, | 26 | originCommentId, |
24 | inReplyToCommentId: obj.inReplyToComment.id, | 27 | inReplyToCommentId, |
25 | videoId: obj.video.id, | 28 | videoId: obj.video.id, |
26 | accountId: obj.accountId, | 29 | accountId: obj.account.id, |
27 | url: 'fake url' | 30 | url: 'fake url' |
28 | }, { transaction: t, validate: false }) | 31 | }, { transaction: t, validate: false }) |
29 | 32 | ||
@@ -32,6 +35,7 @@ async function createVideoComment (obj: { | |||
32 | const savedComment = await comment.save({ transaction: t }) | 35 | const savedComment = await comment.save({ transaction: t }) |
33 | savedComment.InReplyToVideoComment = obj.inReplyToComment | 36 | savedComment.InReplyToVideoComment = obj.inReplyToComment |
34 | savedComment.Video = obj.video | 37 | savedComment.Video = obj.video |
38 | savedComment.Account = obj.account | ||
35 | 39 | ||
36 | if (savedComment.Video.isOwned()) { | 40 | if (savedComment.Video.isOwned()) { |
37 | await sendCreateVideoCommentToVideoFollowers(savedComment, t) | 41 | await sendCreateVideoCommentToVideoFollowers(savedComment, t) |
diff --git a/server/middlewares/validators/pagination.ts b/server/middlewares/validators/pagination.ts index 0895b4eb8..25debfa6e 100644 --- a/server/middlewares/validators/pagination.ts +++ b/server/middlewares/validators/pagination.ts | |||
@@ -4,8 +4,8 @@ import { logger } from '../../helpers' | |||
4 | import { areValidationErrors } from './utils' | 4 | import { areValidationErrors } from './utils' |
5 | 5 | ||
6 | const paginationValidator = [ | 6 | const paginationValidator = [ |
7 | query('start').optional().isInt().withMessage('Should have a number start'), | 7 | query('start').optional().isInt({ min: 0 }).withMessage('Should have a number start'), |
8 | query('count').optional().isInt().withMessage('Should have a number count'), | 8 | query('count').optional().isInt({ min: 0 }).withMessage('Should have a number count'), |
9 | 9 | ||
10 | (req: express.Request, res: express.Response, next: express.NextFunction) => { | 10 | (req: express.Request, res: express.Response, next: express.NextFunction) => { |
11 | logger.debug('Checking pagination parameters', { parameters: req.query }) | 11 | logger.debug('Checking pagination parameters', { parameters: req.query }) |
diff --git a/server/models/video/video-comment.ts b/server/models/video/video-comment.ts index 25cd6d563..a3e8c48d4 100644 --- a/server/models/video/video-comment.ts +++ b/server/models/video/video-comment.ts | |||
@@ -8,18 +8,48 @@ import { VideoComment } from '../../../shared/models/videos/video-comment.model' | |||
8 | import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub' | 8 | import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub' |
9 | import { CONSTRAINTS_FIELDS } from '../../initializers' | 9 | import { CONSTRAINTS_FIELDS } from '../../initializers' |
10 | import { AccountModel } from '../account/account' | 10 | import { AccountModel } from '../account/account' |
11 | import { ActorModel } from '../activitypub/actor' | ||
12 | import { ServerModel } from '../server/server' | ||
11 | import { getSort, throwIfNotValid } from '../utils' | 13 | import { getSort, throwIfNotValid } from '../utils' |
12 | import { VideoModel } from './video' | 14 | import { VideoModel } from './video' |
13 | 15 | ||
14 | enum ScopeNames { | 16 | enum ScopeNames { |
15 | WITH_ACCOUNT = 'WITH_ACCOUNT', | 17 | WITH_ACCOUNT = 'WITH_ACCOUNT', |
16 | WITH_IN_REPLY_TO = 'WITH_IN_REPLY_TO' | 18 | WITH_IN_REPLY_TO = 'WITH_IN_REPLY_TO', |
19 | ATTRIBUTES_FOR_API = 'ATTRIBUTES_FOR_API' | ||
17 | } | 20 | } |
18 | 21 | ||
19 | @Scopes({ | 22 | @Scopes({ |
23 | [ScopeNames.ATTRIBUTES_FOR_API]: { | ||
24 | attributes: { | ||
25 | include: [ | ||
26 | [ | ||
27 | Sequelize.literal( | ||
28 | '(SELECT COUNT("replies"."id") ' + | ||
29 | 'FROM "videoComment" AS "replies" ' + | ||
30 | 'WHERE "replies"."originCommentId" = "VideoCommentModel"."id")' | ||
31 | ), | ||
32 | 'totalReplies' | ||
33 | ] | ||
34 | ] | ||
35 | } | ||
36 | }, | ||
20 | [ScopeNames.WITH_ACCOUNT]: { | 37 | [ScopeNames.WITH_ACCOUNT]: { |
21 | include: [ | 38 | include: [ |
22 | () => AccountModel | 39 | { |
40 | model: () => AccountModel, | ||
41 | include: [ | ||
42 | { | ||
43 | model: () => ActorModel, | ||
44 | include: [ | ||
45 | { | ||
46 | model: () => ServerModel, | ||
47 | required: false | ||
48 | } | ||
49 | ] | ||
50 | } | ||
51 | ] | ||
52 | } | ||
23 | ] | 53 | ] |
24 | }, | 54 | }, |
25 | [ScopeNames.WITH_IN_REPLY_TO]: { | 55 | [ScopeNames.WITH_IN_REPLY_TO]: { |
@@ -149,7 +179,7 @@ export class VideoCommentModel extends Model<VideoCommentModel> { | |||
149 | } | 179 | } |
150 | 180 | ||
151 | return VideoCommentModel | 181 | return VideoCommentModel |
152 | .scope([ ScopeNames.WITH_ACCOUNT ]) | 182 | .scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.ATTRIBUTES_FOR_API ]) |
153 | .findAndCountAll(query) | 183 | .findAndCountAll(query) |
154 | .then(({ rows, count }) => { | 184 | .then(({ rows, count }) => { |
155 | return { total: count, data: rows } | 185 | return { total: count, data: rows } |
@@ -169,7 +199,7 @@ export class VideoCommentModel extends Model<VideoCommentModel> { | |||
169 | } | 199 | } |
170 | 200 | ||
171 | return VideoCommentModel | 201 | return VideoCommentModel |
172 | .scope([ ScopeNames.WITH_ACCOUNT ]) | 202 | .scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.ATTRIBUTES_FOR_API ]) |
173 | .findAndCountAll(query) | 203 | .findAndCountAll(query) |
174 | .then(({ rows, count }) => { | 204 | .then(({ rows, count }) => { |
175 | return { total: count, data: rows } | 205 | return { total: count, data: rows } |
@@ -186,8 +216,10 @@ export class VideoCommentModel extends Model<VideoCommentModel> { | |||
186 | videoId: this.videoId, | 216 | videoId: this.videoId, |
187 | createdAt: this.createdAt, | 217 | createdAt: this.createdAt, |
188 | updatedAt: this.updatedAt, | 218 | updatedAt: this.updatedAt, |
219 | totalReplies: this.get('totalReplies') || 0, | ||
189 | account: { | 220 | account: { |
190 | name: this.Account.name | 221 | name: this.Account.name, |
222 | host: this.Account.Actor.getHost() | ||
191 | } | 223 | } |
192 | } as VideoComment | 224 | } as VideoComment |
193 | } | 225 | } |
diff --git a/server/tests/api/video-comments.ts b/server/tests/api/video-comments.ts index 2f1e6260a..2c7d1c6e2 100644 --- a/server/tests/api/video-comments.ts +++ b/server/tests/api/video-comments.ts | |||
@@ -39,7 +39,18 @@ describe('Test video comments', function () { | |||
39 | it('Should create a thread in this video', async function () { | 39 | it('Should create a thread in this video', async function () { |
40 | const text = 'my super first comment' | 40 | const text = 'my super first comment' |
41 | 41 | ||
42 | await addVideoCommentThread(server.url, server.accessToken, videoUUID, text) | 42 | const res = await addVideoCommentThread(server.url, server.accessToken, videoUUID, text) |
43 | const comment = res.body | ||
44 | |||
45 | expect(comment.inReplyToCommentId).to.be.null | ||
46 | expect(comment.text).equal('my super first comment') | ||
47 | expect(comment.videoId).to.equal(videoId) | ||
48 | expect(comment.id).to.equal(comment.threadId) | ||
49 | expect(comment.account.name).to.equal('root') | ||
50 | expect(comment.account.host).to.equal('localhost:9001') | ||
51 | expect(comment.totalReplies).to.equal(0) | ||
52 | expect(dateIsValid(comment.createdAt as string)).to.be.true | ||
53 | expect(dateIsValid(comment.updatedAt as string)).to.be.true | ||
43 | }) | 54 | }) |
44 | 55 | ||
45 | it('Should list threads of this video', async function () { | 56 | it('Should list threads of this video', async function () { |
@@ -55,6 +66,8 @@ describe('Test video comments', function () { | |||
55 | expect(comment.videoId).to.equal(videoId) | 66 | expect(comment.videoId).to.equal(videoId) |
56 | expect(comment.id).to.equal(comment.threadId) | 67 | expect(comment.id).to.equal(comment.threadId) |
57 | expect(comment.account.name).to.equal('root') | 68 | expect(comment.account.name).to.equal('root') |
69 | expect(comment.account.host).to.equal('localhost:9001') | ||
70 | expect(comment.totalReplies).to.equal(0) | ||
58 | expect(dateIsValid(comment.createdAt as string)).to.be.true | 71 | expect(dateIsValid(comment.createdAt as string)).to.be.true |
59 | expect(dateIsValid(comment.updatedAt as string)).to.be.true | 72 | expect(dateIsValid(comment.updatedAt as string)).to.be.true |
60 | 73 | ||
@@ -120,8 +133,11 @@ describe('Test video comments', function () { | |||
120 | expect(res.body.data).to.have.lengthOf(3) | 133 | expect(res.body.data).to.have.lengthOf(3) |
121 | 134 | ||
122 | expect(res.body.data[0].text).to.equal('my super first comment') | 135 | expect(res.body.data[0].text).to.equal('my super first comment') |
136 | expect(res.body.data[0].totalReplies).to.equal(2) | ||
123 | expect(res.body.data[1].text).to.equal('super thread 2') | 137 | expect(res.body.data[1].text).to.equal('super thread 2') |
138 | expect(res.body.data[1].totalReplies).to.equal(1) | ||
124 | expect(res.body.data[2].text).to.equal('super thread 3') | 139 | expect(res.body.data[2].text).to.equal('super thread 3') |
140 | expect(res.body.data[2].totalReplies).to.equal(0) | ||
125 | }) | 141 | }) |
126 | 142 | ||
127 | after(async function () { | 143 | after(async function () { |
diff --git a/shared/models/videos/video-comment.model.ts b/shared/models/videos/video-comment.model.ts index 69884782f..d572927c2 100644 --- a/shared/models/videos/video-comment.model.ts +++ b/shared/models/videos/video-comment.model.ts | |||
@@ -7,8 +7,10 @@ export interface VideoComment { | |||
7 | videoId: number | 7 | videoId: number |
8 | createdAt: Date | string | 8 | createdAt: Date | string |
9 | updatedAt: Date | string | 9 | updatedAt: Date | string |
10 | totalReplies: number | ||
10 | account: { | 11 | account: { |
11 | name: string | 12 | name: string |
13 | host: string | ||
12 | } | 14 | } |
13 | } | 15 | } |
14 | 16 | ||