aboutsummaryrefslogtreecommitdiffhomepage
path: root/client
diff options
context:
space:
mode:
Diffstat (limited to 'client')
-rw-r--r--client/src/app/+accounts/accounts.component.html5
-rw-r--r--client/src/app/+accounts/accounts.component.scss12
-rw-r--r--client/src/app/+accounts/accounts.component.ts48
-rw-r--r--client/src/app/+admin/admin.module.ts5
-rw-r--r--client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts24
-rw-r--r--client/src/app/+admin/follows/followers-list/followers-list.component.html9
-rw-r--r--client/src/app/+admin/follows/followers-list/followers-list.component.scss10
-rw-r--r--client/src/app/+admin/follows/followers-list/followers-list.component.ts2
-rw-r--r--client/src/app/+admin/follows/following-list/following-list.component.html11
-rw-r--r--client/src/app/+admin/follows/following-list/following-list.component.scss11
-rw-r--r--client/src/app/+admin/follows/following-list/following-list.component.ts4
-rw-r--r--client/src/app/+admin/follows/shared/follow.service.ts8
-rw-r--r--client/src/app/+admin/jobs/jobs-list/jobs-list.component.ts2
-rw-r--r--client/src/app/+admin/moderation/video-abuse-list/video-abuse-list.component.ts2
-rw-r--r--client/src/app/+admin/moderation/video-blacklist-list/video-blacklist-list.component.ts2
-rw-r--r--client/src/app/+admin/users/index.ts1
-rw-r--r--client/src/app/+admin/users/shared/index.ts1
-rw-r--r--client/src/app/+admin/users/shared/user.service.ts96
-rw-r--r--client/src/app/+admin/users/user-edit/user-create.component.ts2
-rw-r--r--client/src/app/+admin/users/user-edit/user-update.component.ts2
-rw-r--r--client/src/app/+admin/users/user-list/user-list.component.html35
-rw-r--r--client/src/app/+admin/users/user-list/user-list.component.scss8
-rw-r--r--client/src/app/+admin/users/user-list/user-list.component.ts122
-rw-r--r--client/src/app/+my-account/my-account-ownership/my-account-ownership.component.ts2
-rw-r--r--client/src/app/+my-account/my-account-video-imports/my-account-video-imports.component.ts2
-rw-r--r--client/src/app/+my-account/my-account-videos/video-change-ownership/video-change-ownership.component.html6
-rw-r--r--client/src/app/app.component.ts13
-rw-r--r--client/src/app/header/header.component.html4
-rw-r--r--client/src/app/shared/account/account.model.ts3
-rw-r--r--client/src/app/shared/buttons/action-dropdown.component.html8
-rw-r--r--client/src/app/shared/buttons/action-dropdown.component.scss20
-rw-r--r--client/src/app/shared/buttons/action-dropdown.component.ts5
-rw-r--r--client/src/app/shared/forms/form-validators/video-change-ownership-validators.service.ts15
-rw-r--r--client/src/app/shared/forms/peertube-checkbox.component.html2
-rw-r--r--client/src/app/shared/forms/peertube-checkbox.component.scss6
-rw-r--r--client/src/app/shared/moderation/index.ts2
-rw-r--r--client/src/app/shared/moderation/user-ban-modal.component.html (renamed from client/src/app/+admin/users/user-list/user-ban-modal.component.html)2
-rw-r--r--client/src/app/shared/moderation/user-ban-modal.component.scss (renamed from client/src/app/+admin/users/user-list/user-ban-modal.component.scss)0
-rw-r--r--client/src/app/shared/moderation/user-ban-modal.component.ts (renamed from client/src/app/+admin/users/user-list/user-ban-modal.component.ts)29
-rw-r--r--client/src/app/shared/moderation/user-moderation-dropdown.component.html5
-rw-r--r--client/src/app/shared/moderation/user-moderation-dropdown.component.scss0
-rw-r--r--client/src/app/shared/moderation/user-moderation-dropdown.component.ts129
-rw-r--r--client/src/app/shared/rest/rest-table.ts27
-rw-r--r--client/src/app/shared/shared.module.ts8
-rw-r--r--client/src/app/shared/users/user.service.ts114
-rw-r--r--client/src/app/shared/video/abstract-video-list.html16
-rw-r--r--client/src/app/shared/video/abstract-video-list.scss25
-rw-r--r--client/src/app/shared/video/abstract-video-list.ts7
-rw-r--r--client/src/app/shared/video/video-miniature.component.html3
-rw-r--r--client/src/app/shared/video/video-miniature.component.ts9
-rw-r--r--client/src/app/shared/video/video-thumbnail.component.html10
-rw-r--r--client/src/app/shared/video/video-thumbnail.component.scss13
-rw-r--r--client/src/app/shared/video/video-thumbnail.component.ts8
-rw-r--r--client/src/app/shared/video/video.model.ts6
-rw-r--r--client/src/app/shared/video/video.service.ts4
-rw-r--r--client/src/app/videos/+video-edit/shared/video-edit.component.scss5
-rw-r--r--client/src/app/videos/+video-watch/video-watch.component.ts13
-rw-r--r--client/src/app/videos/video-list/video-local.component.ts12
-rw-r--r--client/src/assets/player/peertube-player.ts8
-rw-r--r--client/src/assets/player/peertube-videojs-plugin.ts34
-rw-r--r--client/src/assets/player/peertube-videojs-typings.ts10
-rw-r--r--client/src/sass/include/_mixins.scss2
-rw-r--r--client/src/sass/primeng-custom.scss44
63 files changed, 776 insertions, 277 deletions
diff --git a/client/src/app/+accounts/accounts.component.html b/client/src/app/+accounts/accounts.component.html
index 69f648269..036e794d2 100644
--- a/client/src/app/+accounts/accounts.component.html
+++ b/client/src/app/+accounts/accounts.component.html
@@ -8,6 +8,11 @@
8 <div class="actor-names"> 8 <div class="actor-names">
9 <div class="actor-display-name">{{ account.displayName }}</div> 9 <div class="actor-display-name">{{ account.displayName }}</div>
10 <div class="actor-name">{{ account.nameWithHost }}</div> 10 <div class="actor-name">{{ account.nameWithHost }}</div>
11
12 <span *ngIf="user?.blocked" [ngbTooltip]="user.blockedReason" class="badge badge-danger" i18n>Banned</span>
13
14 <my-user-moderation-dropdown buttonSize="small" [user]="user" (userChanged)="onUserChanged()" (userDeleted)="onUserDeleted()">
15 </my-user-moderation-dropdown>
11 </div> 16 </div>
12 <div i18n class="actor-followers">{{ account.followersCount }} subscribers</div> 17 <div i18n class="actor-followers">{{ account.followersCount }} subscribers</div>
13 </div> 18 </div>
diff --git a/client/src/app/+accounts/accounts.component.scss b/client/src/app/+accounts/accounts.component.scss
index 909b65bc7..3cedda889 100644
--- a/client/src/app/+accounts/accounts.component.scss
+++ b/client/src/app/+accounts/accounts.component.scss
@@ -3,4 +3,16 @@
3 3
4.sub-menu { 4.sub-menu {
5 @include sub-menu-with-actor; 5 @include sub-menu-with-actor;
6}
7
8my-user-moderation-dropdown,
9.badge {
10 margin-left: 10px;
11
12 position: relative;
13 top: 3px;
14}
15
16.badge {
17 font-size: 13px;
6} \ No newline at end of file 18} \ No newline at end of file
diff --git a/client/src/app/+accounts/accounts.component.ts b/client/src/app/+accounts/accounts.component.ts
index af0451e91..e19927d6b 100644
--- a/client/src/app/+accounts/accounts.component.ts
+++ b/client/src/app/+accounts/accounts.component.ts
@@ -1,10 +1,14 @@
1import { Component, OnInit, OnDestroy } from '@angular/core' 1import { Component, OnDestroy, OnInit } from '@angular/core'
2import { ActivatedRoute } from '@angular/router' 2import { ActivatedRoute } from '@angular/router'
3import { AccountService } from '@app/shared/account/account.service' 3import { AccountService } from '@app/shared/account/account.service'
4import { Account } from '@app/shared/account/account.model' 4import { Account } from '@app/shared/account/account.model'
5import { RestExtractor } from '@app/shared' 5import { RestExtractor, UserService } from '@app/shared'
6import { catchError, switchMap, distinctUntilChanged, map } from 'rxjs/operators' 6import { catchError, distinctUntilChanged, map, switchMap, tap } from 'rxjs/operators'
7import { Subscription } from 'rxjs' 7import { Subscription } from 'rxjs'
8import { NotificationsService } from 'angular2-notifications'
9import { User, UserRight } from '../../../../shared'
10import { I18n } from '@ngx-translate/i18n-polyfill'
11import { AuthService, RedirectService } from '@app/core'
8 12
9@Component({ 13@Component({
10 templateUrl: './accounts.component.html', 14 templateUrl: './accounts.component.html',
@@ -12,13 +16,19 @@ import { Subscription } from 'rxjs'
12}) 16})
13export class AccountsComponent implements OnInit, OnDestroy { 17export class AccountsComponent implements OnInit, OnDestroy {
14 account: Account 18 account: Account
19 user: User
15 20
16 private routeSub: Subscription 21 private routeSub: Subscription
17 22
18 constructor ( 23 constructor (
19 private route: ActivatedRoute, 24 private route: ActivatedRoute,
25 private userService: UserService,
20 private accountService: AccountService, 26 private accountService: AccountService,
21 private restExtractor: RestExtractor 27 private notificationsService: NotificationsService,
28 private restExtractor: RestExtractor,
29 private redirectService: RedirectService,
30 private authService: AuthService,
31 private i18n: I18n
22 ) {} 32 ) {}
23 33
24 ngOnInit () { 34 ngOnInit () {
@@ -27,12 +37,40 @@ export class AccountsComponent implements OnInit, OnDestroy {
27 map(params => params[ 'accountId' ]), 37 map(params => params[ 'accountId' ]),
28 distinctUntilChanged(), 38 distinctUntilChanged(),
29 switchMap(accountId => this.accountService.getAccount(accountId)), 39 switchMap(accountId => this.accountService.getAccount(accountId)),
40 tap(account => this.getUserIfNeeded(account)),
30 catchError(err => this.restExtractor.redirectTo404IfNotFound(err, [ 400, 404 ])) 41 catchError(err => this.restExtractor.redirectTo404IfNotFound(err, [ 400, 404 ]))
31 ) 42 )
32 .subscribe(account => this.account = account) 43 .subscribe(
44 account => this.account = account,
45
46 err => this.notificationsService.error(this.i18n('Error'), err.message)
47 )
33 } 48 }
34 49
35 ngOnDestroy () { 50 ngOnDestroy () {
36 if (this.routeSub) this.routeSub.unsubscribe() 51 if (this.routeSub) this.routeSub.unsubscribe()
37 } 52 }
53
54 onUserChanged () {
55 this.getUserIfNeeded(this.account)
56 }
57
58 onUserDeleted () {
59 this.redirectService.redirectToHomepage()
60 }
61
62 private getUserIfNeeded (account: Account) {
63 if (!account.userId) return
64 if (!this.authService.isLoggedIn()) return
65
66 const user = this.authService.getUser()
67 if (user.hasRight(UserRight.MANAGE_USERS)) {
68 this.userService.getUser(account.userId)
69 .subscribe(
70 user => this.user = user,
71
72 err => this.notificationsService.error(this.i18n('Error'), err.message)
73 )
74 }
75 }
38} 76}
diff --git a/client/src/app/+admin/admin.module.ts b/client/src/app/+admin/admin.module.ts
index 5784609ef..8c6db98d9 100644
--- a/client/src/app/+admin/admin.module.ts
+++ b/client/src/app/+admin/admin.module.ts
@@ -10,9 +10,8 @@ import { FollowingListComponent } from './follows/following-list/following-list.
10import { JobsComponent } from './jobs/job.component' 10import { JobsComponent } from './jobs/job.component'
11import { JobsListComponent } from './jobs/jobs-list/jobs-list.component' 11import { JobsListComponent } from './jobs/jobs-list/jobs-list.component'
12import { JobService } from './jobs/shared/job.service' 12import { JobService } from './jobs/shared/job.service'
13import { UserCreateComponent, UserListComponent, UsersComponent, UserService, UserUpdateComponent } from './users' 13import { UserCreateComponent, UserListComponent, UsersComponent, UserUpdateComponent } from './users'
14import { ModerationCommentModalComponent, VideoAbuseListComponent, VideoBlacklistListComponent } from './moderation' 14import { ModerationCommentModalComponent, VideoAbuseListComponent, VideoBlacklistListComponent } from './moderation'
15import { UserBanModalComponent } from '@app/+admin/users/user-list/user-ban-modal.component'
16import { ModerationComponent } from '@app/+admin/moderation/moderation.component' 15import { ModerationComponent } from '@app/+admin/moderation/moderation.component'
17import { RedundancyCheckboxComponent } from '@app/+admin/follows/shared/redundancy-checkbox.component' 16import { RedundancyCheckboxComponent } from '@app/+admin/follows/shared/redundancy-checkbox.component'
18import { RedundancyService } from '@app/+admin/follows/shared/redundancy.service' 17import { RedundancyService } from '@app/+admin/follows/shared/redundancy.service'
@@ -37,7 +36,6 @@ import { RedundancyService } from '@app/+admin/follows/shared/redundancy.service
37 UserCreateComponent, 36 UserCreateComponent,
38 UserUpdateComponent, 37 UserUpdateComponent,
39 UserListComponent, 38 UserListComponent,
40 UserBanModalComponent,
41 39
42 ModerationComponent, 40 ModerationComponent,
43 VideoBlacklistListComponent, 41 VideoBlacklistListComponent,
@@ -58,7 +56,6 @@ import { RedundancyService } from '@app/+admin/follows/shared/redundancy.service
58 providers: [ 56 providers: [
59 FollowService, 57 FollowService,
60 RedundancyService, 58 RedundancyService,
61 UserService,
62 JobService, 59 JobService,
63 ConfigService 60 ConfigService
64 ] 61 ]
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts
index 4983b0425..25b303f44 100644
--- a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts
+++ b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts
@@ -1,6 +1,5 @@
1import { Component, OnInit } from '@angular/core' 1import { Component, OnInit } from '@angular/core'
2import { ConfigService } from '@app/+admin/config/shared/config.service' 2import { ConfigService } from '@app/+admin/config/shared/config.service'
3import { ConfirmService } from '@app/core'
4import { ServerService } from '@app/core/server/server.service' 3import { ServerService } from '@app/core/server/server.service'
5import { CustomConfigValidatorsService, FormReactive, UserValidatorsService } from '@app/shared' 4import { CustomConfigValidatorsService, FormReactive, UserValidatorsService } from '@app/shared'
6import { NotificationsService } from 'angular2-notifications' 5import { NotificationsService } from 'angular2-notifications'
@@ -29,7 +28,6 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
29 private notificationsService: NotificationsService, 28 private notificationsService: NotificationsService,
30 private configService: ConfigService, 29 private configService: ConfigService,
31 private serverService: ServerService, 30 private serverService: ServerService,
32 private confirmService: ConfirmService,
33 private i18n: I18n 31 private i18n: I18n
34 ) { 32 ) {
35 super() 33 super()
@@ -124,28 +122,6 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
124 } 122 }
125 123
126 async formValidated () { 124 async formValidated () {
127 const newCustomizationJavascript = this.form.value['customizationJavascript']
128 const newCustomizationCSS = this.form.value['customizationCSS']
129
130 const customizations = []
131 if (newCustomizationJavascript && newCustomizationJavascript !== this.oldCustomJavascript) customizations.push('JavaScript')
132 if (newCustomizationCSS && newCustomizationCSS !== this.oldCustomCSS) customizations.push('CSS')
133
134 if (customizations.length !== 0) {
135 const customizationsText = customizations.join('/')
136
137 // FIXME: i18n service does not support string concatenation
138 const message = this.i18n('You set custom {{customizationsText}}. ', { customizationsText }) +
139 this.i18n('This could lead to security issues or bugs if you do not understand it. ') +
140 this.i18n('Are you sure you want to update the configuration?')
141
142 const label = this.i18n('Please type') + ` "I understand the ${customizationsText} I set" ` + this.i18n('to confirm.')
143 const expectedInputValue = `I understand the ${customizationsText} I set`
144
145 const confirmRes = await this.confirmService.confirmWithInput(message, label, expectedInputValue)
146 if (confirmRes === false) return
147 }
148
149 const data: CustomConfig = { 125 const data: CustomConfig = {
150 instance: { 126 instance: {
151 name: this.form.value['instanceName'], 127 name: this.form.value['instanceName'],
diff --git a/client/src/app/+admin/follows/followers-list/followers-list.component.html b/client/src/app/+admin/follows/followers-list/followers-list.component.html
index 5645a60cc..fc022bdb4 100644
--- a/client/src/app/+admin/follows/followers-list/followers-list.component.html
+++ b/client/src/app/+admin/follows/followers-list/followers-list.component.html
@@ -2,6 +2,15 @@
2 [value]="followers" [lazy]="true" [paginator]="true" [totalRecords]="totalRecords" [rows]="rowsPerPage" 2 [value]="followers" [lazy]="true" [paginator]="true" [totalRecords]="totalRecords" [rows]="rowsPerPage"
3 [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" 3 [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)"
4> 4>
5 <ng-template pTemplate="caption">
6 <div class="caption">
7 <input
8 type="text" name="table-filter" id="table-filter" i18n-placeholder placeholder="Filter..."
9 (keyup)="onSearch($event.target.value)"
10 >
11 </div>
12 </ng-template>
13
5 <ng-template pTemplate="header"> 14 <ng-template pTemplate="header">
6 <tr> 15 <tr>
7 <th i18n style="width: 60px">ID</th> 16 <th i18n style="width: 60px">ID</th>
diff --git a/client/src/app/+admin/follows/followers-list/followers-list.component.scss b/client/src/app/+admin/follows/followers-list/followers-list.component.scss
index e69de29bb..a6f0656b8 100644
--- a/client/src/app/+admin/follows/followers-list/followers-list.component.scss
+++ b/client/src/app/+admin/follows/followers-list/followers-list.component.scss
@@ -0,0 +1,10 @@
1@import '_variables';
2@import '_mixins';
3
4.caption {
5 justify-content: flex-end;
6
7 input {
8 @include peertube-input-text(250px);
9 }
10} \ No newline at end of file
diff --git a/client/src/app/+admin/follows/followers-list/followers-list.component.ts b/client/src/app/+admin/follows/followers-list/followers-list.component.ts
index ca993dcd3..4a25b7ff3 100644
--- a/client/src/app/+admin/follows/followers-list/followers-list.component.ts
+++ b/client/src/app/+admin/follows/followers-list/followers-list.component.ts
@@ -28,7 +28,7 @@ export class FollowersListComponent extends RestTable implements OnInit {
28 } 28 }
29 29
30 ngOnInit () { 30 ngOnInit () {
31 this.loadSort() 31 this.initialize()
32 } 32 }
33 33
34 protected loadData () { 34 protected loadData () {
diff --git a/client/src/app/+admin/follows/following-list/following-list.component.html b/client/src/app/+admin/follows/following-list/following-list.component.html
index 8af624ac5..5bc8fbc2d 100644
--- a/client/src/app/+admin/follows/following-list/following-list.component.html
+++ b/client/src/app/+admin/follows/following-list/following-list.component.html
@@ -2,6 +2,17 @@
2 [value]="following" [lazy]="true" [paginator]="true" [totalRecords]="totalRecords" [rows]="rowsPerPage" 2 [value]="following" [lazy]="true" [paginator]="true" [totalRecords]="totalRecords" [rows]="rowsPerPage"
3 [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" 3 [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)"
4> 4>
5 <ng-template pTemplate="caption">
6 <div class="caption">
7 <div>
8 <input
9 type="text" name="table-filter" id="table-filter" i18n-placeholder placeholder="Filter..."
10 (keyup)="onSearch($event.target.value)"
11 >
12 </div>
13 </div>
14 </ng-template>
15
5 <ng-template pTemplate="header"> 16 <ng-template pTemplate="header">
6 <tr> 17 <tr>
7 <th i18n style="width: 60px">ID</th> 18 <th i18n style="width: 60px">ID</th>
diff --git a/client/src/app/+admin/follows/following-list/following-list.component.scss b/client/src/app/+admin/follows/following-list/following-list.component.scss
index bfcdcaa49..a6f0656b8 100644
--- a/client/src/app/+admin/follows/following-list/following-list.component.scss
+++ b/client/src/app/+admin/follows/following-list/following-list.component.scss
@@ -1,13 +1,10 @@
1@import '_variables'; 1@import '_variables';
2@import '_mixins'; 2@import '_mixins';
3 3
4my-redundancy-checkbox /deep/ my-peertube-checkbox { 4.caption {
5 .form-group { 5 justify-content: flex-end;
6 margin-bottom: 0;
7 align-items: center;
8 }
9 6
10 label { 7 input {
11 margin: 0; 8 @include peertube-input-text(250px);
12 } 9 }
13} \ No newline at end of file 10} \ No newline at end of file
diff --git a/client/src/app/+admin/follows/following-list/following-list.component.ts b/client/src/app/+admin/follows/following-list/following-list.component.ts
index dd57884c6..9b7029f75 100644
--- a/client/src/app/+admin/follows/following-list/following-list.component.ts
+++ b/client/src/app/+admin/follows/following-list/following-list.component.ts
@@ -29,7 +29,7 @@ export class FollowingListComponent extends RestTable implements OnInit {
29 } 29 }
30 30
31 ngOnInit () { 31 ngOnInit () {
32 this.loadSort() 32 this.initialize()
33 } 33 }
34 34
35 async removeFollowing (follow: ActorFollow) { 35 async removeFollowing (follow: ActorFollow) {
@@ -53,7 +53,7 @@ export class FollowingListComponent extends RestTable implements OnInit {
53 } 53 }
54 54
55 protected loadData () { 55 protected loadData () {
56 this.followService.getFollowing(this.pagination, this.sort) 56 this.followService.getFollowing(this.pagination, this.sort, this.search)
57 .subscribe( 57 .subscribe(
58 resultList => { 58 resultList => {
59 this.following = resultList.data 59 this.following = resultList.data
diff --git a/client/src/app/+admin/follows/shared/follow.service.ts b/client/src/app/+admin/follows/shared/follow.service.ts
index 27169a9cd..a2904179e 100644
--- a/client/src/app/+admin/follows/shared/follow.service.ts
+++ b/client/src/app/+admin/follows/shared/follow.service.ts
@@ -18,10 +18,12 @@ export class FollowService {
18 ) { 18 ) {
19 } 19 }
20 20
21 getFollowing (pagination: RestPagination, sort: SortMeta): Observable<ResultList<ActorFollow>> { 21 getFollowing (pagination: RestPagination, sort: SortMeta, search?: string): Observable<ResultList<ActorFollow>> {
22 let params = new HttpParams() 22 let params = new HttpParams()
23 params = this.restService.addRestGetParams(params, pagination, sort) 23 params = this.restService.addRestGetParams(params, pagination, sort)
24 24
25 if (search) params = params.append('search', search)
26
25 return this.authHttp.get<ResultList<ActorFollow>>(FollowService.BASE_APPLICATION_URL + '/following', { params }) 27 return this.authHttp.get<ResultList<ActorFollow>>(FollowService.BASE_APPLICATION_URL + '/following', { params })
26 .pipe( 28 .pipe(
27 map(res => this.restExtractor.convertResultListDateToHuman(res)), 29 map(res => this.restExtractor.convertResultListDateToHuman(res)),
@@ -29,10 +31,12 @@ export class FollowService {
29 ) 31 )
30 } 32 }
31 33
32 getFollowers (pagination: RestPagination, sort: SortMeta): Observable<ResultList<ActorFollow>> { 34 getFollowers (pagination: RestPagination, sort: SortMeta, search?: string): Observable<ResultList<ActorFollow>> {
33 let params = new HttpParams() 35 let params = new HttpParams()
34 params = this.restService.addRestGetParams(params, pagination, sort) 36 params = this.restService.addRestGetParams(params, pagination, sort)
35 37
38 if (search) params = params.append('search', search)
39
36 return this.authHttp.get<ResultList<ActorFollow>>(FollowService.BASE_APPLICATION_URL + '/followers', { params }) 40 return this.authHttp.get<ResultList<ActorFollow>>(FollowService.BASE_APPLICATION_URL + '/followers', { params })
37 .pipe( 41 .pipe(
38 map(res => this.restExtractor.convertResultListDateToHuman(res)), 42 map(res => this.restExtractor.convertResultListDateToHuman(res)),
diff --git a/client/src/app/+admin/jobs/jobs-list/jobs-list.component.ts b/client/src/app/+admin/jobs/jobs-list/jobs-list.component.ts
index 866ba1b23..44778ab56 100644
--- a/client/src/app/+admin/jobs/jobs-list/jobs-list.component.ts
+++ b/client/src/app/+admin/jobs/jobs-list/jobs-list.component.ts
@@ -34,7 +34,7 @@ export class JobsListComponent extends RestTable implements OnInit {
34 34
35 ngOnInit () { 35 ngOnInit () {
36 this.loadJobState() 36 this.loadJobState()
37 this.loadSort() 37 this.initialize()
38 } 38 }
39 39
40 onJobStateChanged () { 40 onJobStateChanged () {
diff --git a/client/src/app/+admin/moderation/video-abuse-list/video-abuse-list.component.ts b/client/src/app/+admin/moderation/video-abuse-list/video-abuse-list.component.ts
index 681db7434..9837af586 100644
--- a/client/src/app/+admin/moderation/video-abuse-list/video-abuse-list.component.ts
+++ b/client/src/app/+admin/moderation/video-abuse-list/video-abuse-list.component.ts
@@ -57,7 +57,7 @@ export class VideoAbuseListComponent extends RestTable implements OnInit {
57 } 57 }
58 58
59 ngOnInit () { 59 ngOnInit () {
60 this.loadSort() 60 this.initialize()
61 } 61 }
62 62
63 openModerationCommentModal (videoAbuse: VideoAbuse) { 63 openModerationCommentModal (videoAbuse: VideoAbuse) {
diff --git a/client/src/app/+admin/moderation/video-blacklist-list/video-blacklist-list.component.ts b/client/src/app/+admin/moderation/video-blacklist-list/video-blacklist-list.component.ts
index bb051d00f..e491edaca 100644
--- a/client/src/app/+admin/moderation/video-blacklist-list/video-blacklist-list.component.ts
+++ b/client/src/app/+admin/moderation/video-blacklist-list/video-blacklist-list.component.ts
@@ -39,7 +39,7 @@ export class VideoBlacklistListComponent extends RestTable implements OnInit {
39 } 39 }
40 40
41 ngOnInit () { 41 ngOnInit () {
42 this.loadSort() 42 this.initialize()
43 } 43 }
44 44
45 getVideoUrl (videoBlacklist: VideoBlacklist) { 45 getVideoUrl (videoBlacklist: VideoBlacklist) {
diff --git a/client/src/app/+admin/users/index.ts b/client/src/app/+admin/users/index.ts
index efcd0d9cb..156e54d89 100644
--- a/client/src/app/+admin/users/index.ts
+++ b/client/src/app/+admin/users/index.ts
@@ -1,4 +1,3 @@
1export * from './shared'
2export * from './user-edit' 1export * from './user-edit'
3export * from './user-list' 2export * from './user-list'
4export * from './users.component' 3export * from './users.component'
diff --git a/client/src/app/+admin/users/shared/index.ts b/client/src/app/+admin/users/shared/index.ts
deleted file mode 100644
index 1f1302dc5..000000000
--- a/client/src/app/+admin/users/shared/index.ts
+++ /dev/null
@@ -1 +0,0 @@
1export * from './user.service'
diff --git a/client/src/app/+admin/users/shared/user.service.ts b/client/src/app/+admin/users/shared/user.service.ts
deleted file mode 100644
index 470beef08..000000000
--- a/client/src/app/+admin/users/shared/user.service.ts
+++ /dev/null
@@ -1,96 +0,0 @@
1import { catchError, map } from 'rxjs/operators'
2import { HttpClient, HttpParams } from '@angular/common/http'
3import { Injectable } from '@angular/core'
4import { BytesPipe } from 'ngx-pipes'
5import { SortMeta } from 'primeng/components/common/sortmeta'
6import { Observable } from 'rxjs'
7import { ResultList, UserCreate, UserUpdate, User, UserRole } from '../../../../../../shared'
8import { environment } from '../../../../environments/environment'
9import { RestExtractor, RestPagination, RestService } from '../../../shared'
10import { I18n } from '@ngx-translate/i18n-polyfill'
11
12@Injectable()
13export class UserService {
14 private static BASE_USERS_URL = environment.apiUrl + '/api/v1/users/'
15 private bytesPipe = new BytesPipe()
16
17 constructor (
18 private authHttp: HttpClient,
19 private restService: RestService,
20 private restExtractor: RestExtractor,
21 private i18n: I18n
22 ) { }
23
24 addUser (userCreate: UserCreate) {
25 return this.authHttp.post(UserService.BASE_USERS_URL, userCreate)
26 .pipe(
27 map(this.restExtractor.extractDataBool),
28 catchError(err => this.restExtractor.handleError(err))
29 )
30 }
31
32 updateUser (userId: number, userUpdate: UserUpdate) {
33 return this.authHttp.put(UserService.BASE_USERS_URL + userId, userUpdate)
34 .pipe(
35 map(this.restExtractor.extractDataBool),
36 catchError(err => this.restExtractor.handleError(err))
37 )
38 }
39
40 getUser (userId: number) {
41 return this.authHttp.get<User>(UserService.BASE_USERS_URL + userId)
42 .pipe(catchError(err => this.restExtractor.handleError(err)))
43 }
44
45 getUsers (pagination: RestPagination, sort: SortMeta): Observable<ResultList<User>> {
46 let params = new HttpParams()
47 params = this.restService.addRestGetParams(params, pagination, sort)
48
49 return this.authHttp.get<ResultList<User>>(UserService.BASE_USERS_URL, { params })
50 .pipe(
51 map(res => this.restExtractor.convertResultListDateToHuman(res)),
52 map(res => this.restExtractor.applyToResultListData(res, this.formatUser.bind(this))),
53 catchError(err => this.restExtractor.handleError(err))
54 )
55 }
56
57 removeUser (user: User) {
58 return this.authHttp.delete(UserService.BASE_USERS_URL + user.id)
59 .pipe(catchError(err => this.restExtractor.handleError(err)))
60 }
61
62 banUser (user: User, reason?: string) {
63 const body = reason ? { reason } : {}
64
65 return this.authHttp.post(UserService.BASE_USERS_URL + user.id + '/block', body)
66 .pipe(catchError(err => this.restExtractor.handleError(err)))
67 }
68
69 unbanUser (user: User) {
70 return this.authHttp.post(UserService.BASE_USERS_URL + user.id + '/unblock', {})
71 .pipe(catchError(err => this.restExtractor.handleError(err)))
72 }
73
74 private formatUser (user: User) {
75 let videoQuota
76 if (user.videoQuota === -1) {
77 videoQuota = this.i18n('Unlimited')
78 } else {
79 videoQuota = this.bytesPipe.transform(user.videoQuota, 0)
80 }
81
82 const videoQuotaUsed = this.bytesPipe.transform(user.videoQuotaUsed, 0)
83
84 const roleLabels: { [ id in UserRole ]: string } = {
85 [UserRole.USER]: this.i18n('User'),
86 [UserRole.ADMINISTRATOR]: this.i18n('Administrator'),
87 [UserRole.MODERATOR]: this.i18n('Moderator')
88 }
89
90 return Object.assign(user, {
91 roleLabel: roleLabels[user.role],
92 videoQuota,
93 videoQuotaUsed
94 })
95 }
96}
diff --git a/client/src/app/+admin/users/user-edit/user-create.component.ts b/client/src/app/+admin/users/user-edit/user-create.component.ts
index 132e280b9..dd8e4efd5 100644
--- a/client/src/app/+admin/users/user-edit/user-create.component.ts
+++ b/client/src/app/+admin/users/user-edit/user-create.component.ts
@@ -1,7 +1,6 @@
1import { Component, OnInit } from '@angular/core' 1import { Component, OnInit } from '@angular/core'
2import { Router } from '@angular/router' 2import { Router } from '@angular/router'
3import { NotificationsService } from 'angular2-notifications' 3import { NotificationsService } from 'angular2-notifications'
4import { UserService } from '../shared'
5import { ServerService } from '../../../core' 4import { ServerService } from '../../../core'
6import { UserCreate, UserRole } from '../../../../../../shared' 5import { UserCreate, UserRole } from '../../../../../../shared'
7import { UserEdit } from './user-edit' 6import { UserEdit } from './user-edit'
@@ -9,6 +8,7 @@ import { I18n } from '@ngx-translate/i18n-polyfill'
9import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' 8import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
10import { UserValidatorsService } from '@app/shared/forms/form-validators/user-validators.service' 9import { UserValidatorsService } from '@app/shared/forms/form-validators/user-validators.service'
11import { ConfigService } from '@app/+admin/config/shared/config.service' 10import { ConfigService } from '@app/+admin/config/shared/config.service'
11import { UserService } from '@app/shared'
12 12
13@Component({ 13@Component({
14 selector: 'my-user-create', 14 selector: 'my-user-create',
diff --git a/client/src/app/+admin/users/user-edit/user-update.component.ts b/client/src/app/+admin/users/user-edit/user-update.component.ts
index 9eb91ac95..cd3885a99 100644
--- a/client/src/app/+admin/users/user-edit/user-update.component.ts
+++ b/client/src/app/+admin/users/user-edit/user-update.component.ts
@@ -2,7 +2,6 @@ import { Component, OnDestroy, OnInit } from '@angular/core'
2import { ActivatedRoute, Router } from '@angular/router' 2import { ActivatedRoute, Router } from '@angular/router'
3import { Subscription } from 'rxjs' 3import { Subscription } from 'rxjs'
4import { NotificationsService } from 'angular2-notifications' 4import { NotificationsService } from 'angular2-notifications'
5import { UserService } from '../shared'
6import { ServerService } from '../../../core' 5import { ServerService } from '../../../core'
7import { UserEdit } from './user-edit' 6import { UserEdit } from './user-edit'
8import { User, UserUpdate } from '../../../../../../shared' 7import { User, UserUpdate } from '../../../../../../shared'
@@ -10,6 +9,7 @@ import { I18n } from '@ngx-translate/i18n-polyfill'
10import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' 9import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
11import { UserValidatorsService } from '@app/shared/forms/form-validators/user-validators.service' 10import { UserValidatorsService } from '@app/shared/forms/form-validators/user-validators.service'
12import { ConfigService } from '@app/+admin/config/shared/config.service' 11import { ConfigService } from '@app/+admin/config/shared/config.service'
12import { UserService } from '@app/shared'
13 13
14@Component({ 14@Component({
15 selector: 'my-user-update', 15 selector: 'my-user-update',
diff --git a/client/src/app/+admin/users/user-list/user-list.component.html b/client/src/app/+admin/users/user-list/user-list.component.html
index bb1b26442..afa9ccfe4 100644
--- a/client/src/app/+admin/users/user-list/user-list.component.html
+++ b/client/src/app/+admin/users/user-list/user-list.component.html
@@ -10,9 +10,32 @@
10<p-table 10<p-table
11 [value]="users" [lazy]="true" [paginator]="true" [totalRecords]="totalRecords" [rows]="rowsPerPage" 11 [value]="users" [lazy]="true" [paginator]="true" [totalRecords]="totalRecords" [rows]="rowsPerPage"
12 [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" dataKey="id" 12 [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" dataKey="id"
13 [(selection)]="selectedUsers"
13> 14>
15 <ng-template pTemplate="caption">
16 <div class="caption">
17 <div>
18 <my-action-dropdown
19 *ngIf="isInSelectionMode()" i18n-label label="Batch actions" theme="orange"
20 [actions]="bulkUserActions" [entry]="selectedUsers"
21 >
22 </my-action-dropdown>
23 </div>
24
25 <div>
26 <input
27 type="text" name="table-filter" id="table-filter" i18n-placeholder placeholder="Filter..."
28 (keyup)="onSearch($event.target.value)"
29 >
30 </div>
31 </div>
32 </ng-template>
33
14 <ng-template pTemplate="header"> 34 <ng-template pTemplate="header">
15 <tr> 35 <tr>
36 <th style="width: 40px">
37 <p-tableHeaderCheckbox></p-tableHeaderCheckbox>
38 </th>
16 <th style="width: 40px"></th> 39 <th style="width: 40px"></th>
17 <th i18n pSortableColumn="username">Username <p-sortIcon field="username"></p-sortIcon></th> 40 <th i18n pSortableColumn="username">Username <p-sortIcon field="username"></p-sortIcon></th>
18 <th i18n>Email</th> 41 <th i18n>Email</th>
@@ -25,12 +48,17 @@
25 48
26 <ng-template pTemplate="body" let-expanded="expanded" let-user> 49 <ng-template pTemplate="body" let-expanded="expanded" let-user>
27 50
28 <tr [ngClass]="{ banned: user.blocked }"> 51 <tr [pSelectableRow]="user" [ngClass]="{ banned: user.blocked }">
52 <td>
53 <p-tableCheckbox [value]="user"></p-tableCheckbox>
54 </td>
55
29 <td> 56 <td>
30 <span *ngIf="user.blockedReason" class="expander" [pRowToggler]="user"> 57 <span *ngIf="user.blockedReason" class="expander" [pRowToggler]="user">
31 <i [ngClass]="expanded ? 'glyphicon glyphicon-menu-down' : 'glyphicon glyphicon-menu-right'"></i> 58 <i [ngClass]="expanded ? 'glyphicon glyphicon-menu-down' : 'glyphicon glyphicon-menu-right'"></i>
32 </span> 59 </span>
33 </td> 60 </td>
61
34 <td> 62 <td>
35 {{ user.username }} 63 {{ user.username }}
36 <span *ngIf="user.blocked" class="banned-info">(banned)</span> 64 <span *ngIf="user.blocked" class="banned-info">(banned)</span>
@@ -40,7 +68,8 @@
40 <td>{{ user.roleLabel }}</td> 68 <td>{{ user.roleLabel }}</td>
41 <td>{{ user.createdAt }}</td> 69 <td>{{ user.createdAt }}</td>
42 <td class="action-cell"> 70 <td class="action-cell">
43 <my-action-dropdown i18n-label label="Actions" [actions]="userActions" [entry]="user"></my-action-dropdown> 71 <my-user-moderation-dropdown *ngIf="!isInSelectionMode()" [user]="user" (userChanged)="onUserChanged()" (userDeleted)="onUserChanged()">
72 </my-user-moderation-dropdown>
44 </td> 73 </td>
45 </tr> 74 </tr>
46 </ng-template> 75 </ng-template>
@@ -55,4 +84,4 @@
55 </ng-template> 84 </ng-template>
56</p-table> 85</p-table>
57 86
58<my-user-ban-modal #userBanModal (userBanned)="onUserBanned()"></my-user-ban-modal> \ No newline at end of file 87<my-user-ban-modal #userBanModal (userBanned)="onUsersBanned()"></my-user-ban-modal>
diff --git a/client/src/app/+admin/users/user-list/user-list.component.scss b/client/src/app/+admin/users/user-list/user-list.component.scss
index 47291918d..f235769f0 100644
--- a/client/src/app/+admin/users/user-list/user-list.component.scss
+++ b/client/src/app/+admin/users/user-list/user-list.component.scss
@@ -15,4 +15,12 @@ tr.banned {
15 15
16.ban-reason-label { 16.ban-reason-label {
17 font-weight: $font-semibold; 17 font-weight: $font-semibold;
18}
19
20.caption {
21 justify-content: space-between;
22
23 input {
24 @include peertube-input-text(250px);
25 }
18} \ No newline at end of file 26} \ No newline at end of file
diff --git a/client/src/app/+admin/users/user-list/user-list.component.ts b/client/src/app/+admin/users/user-list/user-list.component.ts
index 100ffc00e..33384dc35 100644
--- a/client/src/app/+admin/users/user-list/user-list.component.ts
+++ b/client/src/app/+admin/users/user-list/user-list.component.ts
@@ -2,13 +2,11 @@ import { Component, OnInit, ViewChild } from '@angular/core'
2import { NotificationsService } from 'angular2-notifications' 2import { NotificationsService } from 'angular2-notifications'
3import { SortMeta } from 'primeng/components/common/sortmeta' 3import { SortMeta } from 'primeng/components/common/sortmeta'
4import { ConfirmService } from '../../../core' 4import { ConfirmService } from '../../../core'
5import { RestPagination, RestTable } from '../../../shared' 5import { RestPagination, RestTable, UserService } from '../../../shared'
6import { UserService } from '../shared'
7import { I18n } from '@ngx-translate/i18n-polyfill' 6import { I18n } from '@ngx-translate/i18n-polyfill'
8import { DropdownAction } from '@app/shared/buttons/action-dropdown.component'
9import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
10import { UserBanModalComponent } from '@app/+admin/users/user-list/user-ban-modal.component'
11import { User } from '../../../../../../shared' 7import { User } from '../../../../../../shared'
8import { UserBanModalComponent } from '@app/shared/moderation'
9import { DropdownAction } from '@app/shared/buttons/action-dropdown.component'
12 10
13@Component({ 11@Component({
14 selector: 'my-user-list', 12 selector: 'my-user-list',
@@ -23,9 +21,9 @@ export class UserListComponent extends RestTable implements OnInit {
23 rowsPerPage = 10 21 rowsPerPage = 10
24 sort: SortMeta = { field: 'createdAt', order: 1 } 22 sort: SortMeta = { field: 'createdAt', order: 1 }
25 pagination: RestPagination = { count: this.rowsPerPage, start: 0 } 23 pagination: RestPagination = { count: this.rowsPerPage, start: 0 }
26 userActions: DropdownAction<User>[] = []
27 24
28 private openedModal: NgbModalRef 25 selectedUsers: User[] = []
26 bulkUserActions: DropdownAction<User>[] = []
29 27
30 constructor ( 28 constructor (
31 private notificationsService: NotificationsService, 29 private notificationsService: NotificationsService,
@@ -34,84 +32,94 @@ export class UserListComponent extends RestTable implements OnInit {
34 private i18n: I18n 32 private i18n: I18n
35 ) { 33 ) {
36 super() 34 super()
35 }
37 36
38 this.userActions = [ 37 ngOnInit () {
39 { 38 this.initialize()
40 label: this.i18n('Edit'), 39
41 linkBuilder: this.getRouterUserEditLink 40 this.bulkUserActions = [
42 },
43 { 41 {
44 label: this.i18n('Delete'), 42 label: this.i18n('Delete'),
45 handler: user => this.removeUser(user) 43 handler: users => this.removeUsers(users)
46 }, 44 },
47 { 45 {
48 label: this.i18n('Ban'), 46 label: this.i18n('Ban'),
49 handler: user => this.openBanUserModal(user), 47 handler: users => this.openBanUserModal(users),
50 isDisplayed: user => !user.blocked 48 isDisplayed: users => users.every(u => u.blocked === false)
51 }, 49 },
52 { 50 {
53 label: this.i18n('Unban'), 51 label: this.i18n('Unban'),
54 handler: user => this.unbanUser(user), 52 handler: users => this.unbanUsers(users),
55 isDisplayed: user => user.blocked 53 isDisplayed: users => users.every(u => u.blocked === true)
56 } 54 }
57 ] 55 ]
58 } 56 }
59 57
60 ngOnInit () { 58 protected loadData () {
61 this.loadSort() 59 this.selectedUsers = []
62 }
63 60
64 hideBanUserModal () { 61 this.userService.getUsers(this.pagination, this.sort, this.search)
65 this.openedModal.close() 62 .subscribe(
63 resultList => {
64 this.users = resultList.data
65 this.totalRecords = resultList.total
66 },
67
68 err => this.notificationsService.error(this.i18n('Error'), err.message)
69 )
66 } 70 }
67 71
68 openBanUserModal (user: User) { 72 openBanUserModal (users: User[]) {
69 if (user.username === 'root') { 73 for (const user of users) {
70 this.notificationsService.error(this.i18n('Error'), this.i18n('You cannot ban root.')) 74 if (user.username === 'root') {
71 return 75 this.notificationsService.error(this.i18n('Error'), this.i18n('You cannot ban root.'))
76 return
77 }
72 } 78 }
73 79
74 this.userBanModal.openModal(user) 80 this.userBanModal.openModal(users)
75 } 81 }
76 82
77 onUserBanned () { 83 onUsersBanned () {
78 this.loadData() 84 this.loadData()
79 } 85 }
80 86
81 async unbanUser (user: User) { 87 async unbanUsers (users: User[]) {
82 const message = this.i18n('Do you really want to unban {{username}}?', { username: user.username }) 88 const message = this.i18n('Do you really want to unban {{num}} users?', { num: users.length })
89
83 const res = await this.confirmService.confirm(message, this.i18n('Unban')) 90 const res = await this.confirmService.confirm(message, this.i18n('Unban'))
84 if (res === false) return 91 if (res === false) return
85 92
86 this.userService.unbanUser(user) 93 this.userService.unbanUsers(users)
87 .subscribe( 94 .subscribe(
88 () => { 95 () => {
89 this.notificationsService.success( 96 const message = this.i18n('{{num}} users unbanned.', { num: users.length })
90 this.i18n('Success'), 97
91 this.i18n('User {{username}} unbanned.', { username: user.username }) 98 this.notificationsService.success(this.i18n('Success'), message)
92 ) 99 this.loadData()
93 this.loadData() 100 },
94 }, 101
95 102 err => this.notificationsService.error(this.i18n('Error'), err.message)
96 err => this.notificationsService.error(this.i18n('Error'), err.message) 103 )
97 )
98 } 104 }
99 105
100 async removeUser (user: User) { 106 async removeUsers (users: User[]) {
101 if (user.username === 'root') { 107 for (const user of users) {
102 this.notificationsService.error(this.i18n('Error'), this.i18n('You cannot delete root.')) 108 if (user.username === 'root') {
103 return 109 this.notificationsService.error(this.i18n('Error'), this.i18n('You cannot delete root.'))
110 return
111 }
104 } 112 }
105 113
106 const message = this.i18n('If you remove this user, you will not be able to create another with the same username!') 114 const message = this.i18n('If you remove these users, you will not be able to create others with the same username!')
107 const res = await this.confirmService.confirm(message, this.i18n('Delete')) 115 const res = await this.confirmService.confirm(message, this.i18n('Delete'))
108 if (res === false) return 116 if (res === false) return
109 117
110 this.userService.removeUser(user).subscribe( 118 this.userService.removeUser(users).subscribe(
111 () => { 119 () => {
112 this.notificationsService.success( 120 this.notificationsService.success(
113 this.i18n('Success'), 121 this.i18n('Success'),
114 this.i18n('User {{username}} deleted.', { username: user.username }) 122 this.i18n('{{num}} users deleted.', { num: users.length })
115 ) 123 )
116 this.loadData() 124 this.loadData()
117 }, 125 },
@@ -120,19 +128,7 @@ export class UserListComponent extends RestTable implements OnInit {
120 ) 128 )
121 } 129 }
122 130
123 getRouterUserEditLink (user: User) { 131 isInSelectionMode () {
124 return [ '/admin', 'users', 'update', user.id ] 132 return this.selectedUsers.length !== 0
125 }
126
127 protected loadData () {
128 this.userService.getUsers(this.pagination, this.sort)
129 .subscribe(
130 resultList => {
131 this.users = resultList.data
132 this.totalRecords = resultList.total
133 },
134
135 err => this.notificationsService.error(this.i18n('Error'), err.message)
136 )
137 } 133 }
138} 134}
diff --git a/client/src/app/+my-account/my-account-ownership/my-account-ownership.component.ts b/client/src/app/+my-account/my-account-ownership/my-account-ownership.component.ts
index 13517b9f4..520278671 100644
--- a/client/src/app/+my-account/my-account-ownership/my-account-ownership.component.ts
+++ b/client/src/app/+my-account/my-account-ownership/my-account-ownership.component.ts
@@ -31,7 +31,7 @@ export class MyAccountOwnershipComponent extends RestTable implements OnInit {
31 } 31 }
32 32
33 ngOnInit () { 33 ngOnInit () {
34 this.loadSort() 34 this.initialize()
35 } 35 }
36 36
37 protected loadData () { 37 protected loadData () {
diff --git a/client/src/app/+my-account/my-account-video-imports/my-account-video-imports.component.ts b/client/src/app/+my-account/my-account-video-imports/my-account-video-imports.component.ts
index d9fb20446..5b920c98d 100644
--- a/client/src/app/+my-account/my-account-video-imports/my-account-video-imports.component.ts
+++ b/client/src/app/+my-account/my-account-video-imports/my-account-video-imports.component.ts
@@ -27,7 +27,7 @@ export class MyAccountVideoImportsComponent extends RestTable implements OnInit
27 } 27 }
28 28
29 ngOnInit () { 29 ngOnInit () {
30 this.loadSort() 30 this.initialize()
31 } 31 }
32 32
33 isVideoImportSuccess (videoImport: VideoImport) { 33 isVideoImportSuccess (videoImport: VideoImport) {
diff --git a/client/src/app/+my-account/my-account-videos/video-change-ownership/video-change-ownership.component.html b/client/src/app/+my-account/my-account-videos/video-change-ownership/video-change-ownership.component.html
index 69b198faa..7c0df850d 100644
--- a/client/src/app/+my-account/my-account-videos/video-change-ownership/video-change-ownership.component.html
+++ b/client/src/app/+my-account/my-account-videos/video-change-ownership/video-change-ownership.component.html
@@ -22,9 +22,9 @@
22 </span> 22 </span>
23 23
24 <input 24 <input
25 type="submit" i18n-value value="Submit" class="action-button-submit" 25 type="submit" i18n-value value="Submit" class="action-button-submit"
26 [disabled]="!form.valid" 26 [disabled]="!form.valid"
27 (click)="close()" 27 (click)="close()"
28 /> 28 />
29 </div> 29 </div>
30 </div> 30 </div>
diff --git a/client/src/app/app.component.ts b/client/src/app/app.component.ts
index 7cd0fff1b..dc4d0bf6a 100644
--- a/client/src/app/app.component.ts
+++ b/client/src/app/app.component.ts
@@ -4,9 +4,10 @@ import { GuardsCheckStart, NavigationEnd, Router } from '@angular/router'
4import { AuthService, RedirectService, ServerService, ThemeService } from '@app/core' 4import { AuthService, RedirectService, ServerService, ThemeService } from '@app/core'
5import { is18nPath } from '../../../shared/models/i18n' 5import { is18nPath } from '../../../shared/models/i18n'
6import { ScreenService } from '@app/shared/misc/screen.service' 6import { ScreenService } from '@app/shared/misc/screen.service'
7import { skip } from 'rxjs/operators' 7import { skip, debounceTime } from 'rxjs/operators'
8import { HotkeysService, Hotkey } from 'angular2-hotkeys' 8import { HotkeysService, Hotkey } from 'angular2-hotkeys'
9import { I18n } from '@ngx-translate/i18n-polyfill' 9import { I18n } from '@ngx-translate/i18n-polyfill'
10import { fromEvent } from 'rxjs'
10 11
11@Component({ 12@Component({
12 selector: 'my-app', 13 selector: 'my-app',
@@ -28,6 +29,7 @@ export class AppComponent implements OnInit {
28 } 29 }
29 30
30 isMenuDisplayed = true 31 isMenuDisplayed = true
32 isMenuChangedByUser = false
31 33
32 customCSS: SafeHtml 34 customCSS: SafeHtml
33 35
@@ -165,6 +167,10 @@ export class AppComponent implements OnInit {
165 return false 167 return false
166 }, undefined, this.i18n('Toggle Dark theme')) 168 }, undefined, this.i18n('Toggle Dark theme'))
167 ]) 169 ])
170
171 fromEvent(window, 'resize')
172 .pipe(debounceTime(200))
173 .subscribe(() => this.onResize())
168 } 174 }
169 175
170 isUserLoggedIn () { 176 isUserLoggedIn () {
@@ -173,5 +179,10 @@ export class AppComponent implements OnInit {
173 179
174 toggleMenu () { 180 toggleMenu () {
175 this.isMenuDisplayed = !this.isMenuDisplayed 181 this.isMenuDisplayed = !this.isMenuDisplayed
182 this.isMenuChangedByUser = true
183 }
184
185 onResize () {
186 this.isMenuDisplayed = window.innerWidth >= 800 && !this.isMenuChangedByUser
176 } 187 }
177} 188}
diff --git a/client/src/app/header/header.component.html b/client/src/app/header/header.component.html
index a04354db5..c23e0c55d 100644
--- a/client/src/app/header/header.component.html
+++ b/client/src/app/header/header.component.html
@@ -1,6 +1,6 @@
1<input 1<input
2 type="text" id="search-video" name="search-video" i18n-placeholder placeholder="Search..." 2 type="text" id="search-video" name="search-video" i18n-placeholder placeholder="Search..."
3 [(ngModel)]="searchValue" (keyup.enter)="doSearch()" 3 [(ngModel)]="searchValue" (keyup.enter)="doSearch()"
4> 4>
5<span (click)="doSearch()" class="icon icon-search"></span> 5<span (click)="doSearch()" class="icon icon-search"></span>
6 6
diff --git a/client/src/app/shared/account/account.model.ts b/client/src/app/shared/account/account.model.ts
index 5058e372f..42f2cfeaf 100644
--- a/client/src/app/shared/account/account.model.ts
+++ b/client/src/app/shared/account/account.model.ts
@@ -6,11 +6,14 @@ export class Account extends Actor implements ServerAccount {
6 description: string 6 description: string
7 nameWithHost: string 7 nameWithHost: string
8 8
9 userId?: number
10
9 constructor (hash: ServerAccount) { 11 constructor (hash: ServerAccount) {
10 super(hash) 12 super(hash)
11 13
12 this.displayName = hash.displayName 14 this.displayName = hash.displayName
13 this.description = hash.description 15 this.description = hash.description
16 this.userId = hash.userId
14 this.nameWithHost = Actor.CREATE_BY_STRING(this.name, this.host) 17 this.nameWithHost = Actor.CREATE_BY_STRING(this.name, this.host)
15 } 18 }
16} 19}
diff --git a/client/src/app/shared/buttons/action-dropdown.component.html b/client/src/app/shared/buttons/action-dropdown.component.html
index 8b7241379..111627424 100644
--- a/client/src/app/shared/buttons/action-dropdown.component.html
+++ b/client/src/app/shared/buttons/action-dropdown.component.html
@@ -1,6 +1,10 @@
1<div class="dropdown-root" ngbDropdown [placement]="placement"> 1<div class="dropdown-root" ngbDropdown [placement]="placement">
2 <div class="action-button" ngbDropdownToggle role="button"> 2 <div
3 <span class="icon icon-action"></span> 3 class="action-button" [ngClass]="{ small: buttonSize === 'small', grey: theme === 'grey', orange: theme === 'orange' }"
4 ngbDropdownToggle role="button"
5 >
6 <span *ngIf="!label" class="icon icon-action"></span>
7 <span *ngIf="label" class="dropdown-toggle">{{ label }}</span>
4 </div> 8 </div>
5 9
6 <div ngbDropdownMenu class="dropdown-menu"> 10 <div ngbDropdownMenu class="dropdown-menu">
diff --git a/client/src/app/shared/buttons/action-dropdown.component.scss b/client/src/app/shared/buttons/action-dropdown.component.scss
index 615511093..0a9aa7b04 100644
--- a/client/src/app/shared/buttons/action-dropdown.component.scss
+++ b/client/src/app/shared/buttons/action-dropdown.component.scss
@@ -3,7 +3,14 @@
3 3
4.action-button { 4.action-button {
5 @include peertube-button; 5 @include peertube-button;
6 @include grey-button; 6
7 &.grey {
8 @include grey-button;
9 }
10
11 &.orange {
12 @include orange-button;
13 }
7 14
8 display: inline-block; 15 display: inline-block;
9 padding: 0 10px; 16 padding: 0 10px;
@@ -22,6 +29,17 @@
22 background-image: url('../../../assets/images/video/more.svg'); 29 background-image: url('../../../assets/images/video/more.svg');
23 top: -1px; 30 top: -1px;
24 } 31 }
32
33 &.small {
34 font-size: 14px;
35 height: 20px;
36 line-height: 20px;
37 }
38}
39
40.dropdown-toggle::after {
41 position: relative;
42 top: 1px;
25} 43}
26 44
27.dropdown-menu { 45.dropdown-menu {
diff --git a/client/src/app/shared/buttons/action-dropdown.component.ts b/client/src/app/shared/buttons/action-dropdown.component.ts
index 17f9cc618..022ab5ee8 100644
--- a/client/src/app/shared/buttons/action-dropdown.component.ts
+++ b/client/src/app/shared/buttons/action-dropdown.component.ts
@@ -16,5 +16,8 @@ export type DropdownAction<T> = {
16export class ActionDropdownComponent<T> { 16export class ActionDropdownComponent<T> {
17 @Input() actions: DropdownAction<T>[] = [] 17 @Input() actions: DropdownAction<T>[] = []
18 @Input() entry: T 18 @Input() entry: T
19 @Input() placement = 'left' 19 @Input() placement = 'bottom-left'
20 @Input() buttonSize: 'normal' | 'small' = 'normal'
21 @Input() label: string
22 @Input() theme: 'orange' | 'grey' = 'grey'
20} 23}
diff --git a/client/src/app/shared/forms/form-validators/video-change-ownership-validators.service.ts b/client/src/app/shared/forms/form-validators/video-change-ownership-validators.service.ts
index 087b80b44..c6fbb7538 100644
--- a/client/src/app/shared/forms/form-validators/video-change-ownership-validators.service.ts
+++ b/client/src/app/shared/forms/form-validators/video-change-ownership-validators.service.ts
@@ -1,5 +1,5 @@
1import { I18n } from '@ngx-translate/i18n-polyfill' 1import { I18n } from '@ngx-translate/i18n-polyfill'
2import { Validators } from '@angular/forms' 2import { AbstractControl, ValidationErrors, Validators } from '@angular/forms'
3import { Injectable } from '@angular/core' 3import { Injectable } from '@angular/core'
4import { BuildFormValidator } from '@app/shared' 4import { BuildFormValidator } from '@app/shared'
5 5
@@ -9,10 +9,19 @@ export class VideoChangeOwnershipValidatorsService {
9 9
10 constructor (private i18n: I18n) { 10 constructor (private i18n: I18n) {
11 this.USERNAME = { 11 this.USERNAME = {
12 VALIDATORS: [ Validators.required ], 12 VALIDATORS: [ Validators.required, this.localAccountValidator ],
13 MESSAGES: { 13 MESSAGES: {
14 'required': this.i18n('The username is required.') 14 'required': this.i18n('The username is required.'),
15 'localAccountOnly': this.i18n('You can only transfer ownership to a local account')
15 } 16 }
16 } 17 }
17 } 18 }
19
20 localAccountValidator (control: AbstractControl): ValidationErrors {
21 if (control.value.includes('@')) {
22 return { 'localAccountOnly': true }
23 }
24
25 return null
26 }
18} 27}
diff --git a/client/src/app/shared/forms/peertube-checkbox.component.html b/client/src/app/shared/forms/peertube-checkbox.component.html
index 38691f050..fb3006b53 100644
--- a/client/src/app/shared/forms/peertube-checkbox.component.html
+++ b/client/src/app/shared/forms/peertube-checkbox.component.html
@@ -1,4 +1,4 @@
1<div class="form-group"> 1<div class="root">
2 <label class="form-group-checkbox"> 2 <label class="form-group-checkbox">
3 <input type="checkbox" [(ngModel)]="checked" (ngModelChange)="onModelChange()" [id]="inputName" [disabled]="isDisabled" /> 3 <input type="checkbox" [(ngModel)]="checked" (ngModelChange)="onModelChange()" [id]="inputName" [disabled]="isDisabled" />
4 <span role="checkbox" [attr.aria-checked]="checked"></span> 4 <span role="checkbox" [attr.aria-checked]="checked"></span>
diff --git a/client/src/app/shared/forms/peertube-checkbox.component.scss b/client/src/app/shared/forms/peertube-checkbox.component.scss
index ee133f190..6e4e20775 100644
--- a/client/src/app/shared/forms/peertube-checkbox.component.scss
+++ b/client/src/app/shared/forms/peertube-checkbox.component.scss
@@ -1,7 +1,7 @@
1@import '_variables'; 1@import '_variables';
2@import '_mixins'; 2@import '_mixins';
3 3
4.form-group { 4.root {
5 display: flex; 5 display: flex;
6 6
7 .form-group-checkbox { 7 .form-group-checkbox {
@@ -20,6 +20,10 @@
20 } 20 }
21 } 21 }
22 22
23 label {
24 margin-bottom: 0;
25 }
26
23 my-help { 27 my-help {
24 position: relative; 28 position: relative;
25 top: -2px; 29 top: -2px;
diff --git a/client/src/app/shared/moderation/index.ts b/client/src/app/shared/moderation/index.ts
new file mode 100644
index 000000000..9a77c64c0
--- /dev/null
+++ b/client/src/app/shared/moderation/index.ts
@@ -0,0 +1,2 @@
1export * from './user-ban-modal.component'
2export * from './user-moderation-dropdown.component'
diff --git a/client/src/app/+admin/users/user-list/user-ban-modal.component.html b/client/src/app/shared/moderation/user-ban-modal.component.html
index b2958caa4..fa5cb7404 100644
--- a/client/src/app/+admin/users/user-list/user-ban-modal.component.html
+++ b/client/src/app/shared/moderation/user-ban-modal.component.html
@@ -1,6 +1,6 @@
1<ng-template #modal> 1<ng-template #modal>
2 <div class="modal-header"> 2 <div class="modal-header">
3 <h4 i18n class="modal-title">Ban {{ userToBan.username }}</h4> 3 <h4 i18n class="modal-title">Ban</h4>
4 <span class="close" aria-hidden="true" (click)="hideBanUserModal()"></span> 4 <span class="close" aria-hidden="true" (click)="hideBanUserModal()"></span>
5 </div> 5 </div>
6 6
diff --git a/client/src/app/+admin/users/user-list/user-ban-modal.component.scss b/client/src/app/shared/moderation/user-ban-modal.component.scss
index 84562f15c..84562f15c 100644
--- a/client/src/app/+admin/users/user-list/user-ban-modal.component.scss
+++ b/client/src/app/shared/moderation/user-ban-modal.component.scss
diff --git a/client/src/app/+admin/users/user-list/user-ban-modal.component.ts b/client/src/app/shared/moderation/user-ban-modal.component.ts
index 4fd4d561c..60bd442dd 100644
--- a/client/src/app/+admin/users/user-list/user-ban-modal.component.ts
+++ b/client/src/app/shared/moderation/user-ban-modal.component.ts
@@ -1,12 +1,12 @@
1import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core' 1import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core'
2import { NotificationsService } from 'angular2-notifications' 2import { NotificationsService } from 'angular2-notifications'
3import { FormReactive, UserValidatorsService } from '../../../shared'
4import { UserService } from '../shared'
5import { I18n } from '@ngx-translate/i18n-polyfill' 3import { I18n } from '@ngx-translate/i18n-polyfill'
6import { NgbModal } from '@ng-bootstrap/ng-bootstrap' 4import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
7import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref' 5import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
8import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' 6import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
9import { User } from '../../../../../../shared' 7import { FormReactive, UserValidatorsService } from '@app/shared/forms'
8import { UserService } from '@app/shared/users'
9import { User } from '../../../../../shared'
10 10
11@Component({ 11@Component({
12 selector: 'my-user-ban-modal', 12 selector: 'my-user-ban-modal',
@@ -15,9 +15,9 @@ import { User } from '../../../../../../shared'
15}) 15})
16export class UserBanModalComponent extends FormReactive implements OnInit { 16export class UserBanModalComponent extends FormReactive implements OnInit {
17 @ViewChild('modal') modal: NgbModal 17 @ViewChild('modal') modal: NgbModal
18 @Output() userBanned = new EventEmitter<User>() 18 @Output() userBanned = new EventEmitter<User | User[]>()
19 19
20 private userToBan: User 20 private usersToBan: User | User[]
21 private openedModal: NgbModalRef 21 private openedModal: NgbModalRef
22 22
23 constructor ( 23 constructor (
@@ -37,28 +37,29 @@ export class UserBanModalComponent extends FormReactive implements OnInit {
37 }) 37 })
38 } 38 }
39 39
40 openModal (user: User) { 40 openModal (user: User | User[]) {
41 this.userToBan = user 41 this.usersToBan = user
42 this.openedModal = this.modalService.open(this.modal) 42 this.openedModal = this.modalService.open(this.modal)
43 } 43 }
44 44
45 hideBanUserModal () { 45 hideBanUserModal () {
46 this.userToBan = undefined 46 this.usersToBan = undefined
47 this.openedModal.close() 47 this.openedModal.close()
48 } 48 }
49 49
50 async banUser () { 50 async banUser () {
51 const reason = this.form.value['reason'] || undefined 51 const reason = this.form.value['reason'] || undefined
52 52
53 this.userService.banUser(this.userToBan, reason) 53 this.userService.banUsers(this.usersToBan, reason)
54 .subscribe( 54 .subscribe(
55 () => { 55 () => {
56 this.notificationsService.success( 56 const message = Array.isArray(this.usersToBan)
57 this.i18n('Success'), 57 ? this.i18n('{{num}} users banned.', { num: this.usersToBan.length })
58 this.i18n('User {{username}} banned.', { username: this.userToBan.username }) 58 : this.i18n('User {{username}} banned.', { username: this.usersToBan.username })
59 )
60 59
61 this.userBanned.emit(this.userToBan) 60 this.notificationsService.success(this.i18n('Success'), message)
61
62 this.userBanned.emit(this.usersToBan)
62 this.hideBanUserModal() 63 this.hideBanUserModal()
63 }, 64 },
64 65
diff --git a/client/src/app/shared/moderation/user-moderation-dropdown.component.html b/client/src/app/shared/moderation/user-moderation-dropdown.component.html
new file mode 100644
index 000000000..01db7cd4a
--- /dev/null
+++ b/client/src/app/shared/moderation/user-moderation-dropdown.component.html
@@ -0,0 +1,5 @@
1<ng-container *ngIf="user && userActions.length !== 0">
2 <my-user-ban-modal #userBanModal (userBanned)="onUserBanned()"></my-user-ban-modal>
3
4 <my-action-dropdown [actions]="userActions" [entry]="user" [buttonSize]="buttonSize" [placement]="placement"></my-action-dropdown>
5</ng-container> \ No newline at end of file
diff --git a/client/src/app/shared/moderation/user-moderation-dropdown.component.scss b/client/src/app/shared/moderation/user-moderation-dropdown.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/client/src/app/shared/moderation/user-moderation-dropdown.component.scss
diff --git a/client/src/app/shared/moderation/user-moderation-dropdown.component.ts b/client/src/app/shared/moderation/user-moderation-dropdown.component.ts
new file mode 100644
index 000000000..105c99d8b
--- /dev/null
+++ b/client/src/app/shared/moderation/user-moderation-dropdown.component.ts
@@ -0,0 +1,129 @@
1import { Component, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'
2import { NotificationsService } from 'angular2-notifications'
3import { I18n } from '@ngx-translate/i18n-polyfill'
4import { DropdownAction } from '@app/shared/buttons/action-dropdown.component'
5import { UserBanModalComponent } from '@app/shared/moderation/user-ban-modal.component'
6import { UserService } from '@app/shared/users'
7import { AuthService, ConfirmService } from '@app/core'
8import { User, UserRight } from '../../../../../shared/models/users'
9
10@Component({
11 selector: 'my-user-moderation-dropdown',
12 templateUrl: './user-moderation-dropdown.component.html',
13 styleUrls: [ './user-moderation-dropdown.component.scss' ]
14})
15export class UserModerationDropdownComponent implements OnInit {
16 @ViewChild('userBanModal') userBanModal: UserBanModalComponent
17
18 @Input() user: User
19 @Input() buttonSize: 'normal' | 'small' = 'normal'
20 @Input() placement = 'left'
21
22 @Output() userChanged = new EventEmitter()
23 @Output() userDeleted = new EventEmitter()
24
25 userActions: DropdownAction<User>[] = []
26
27 constructor (
28 private authService: AuthService,
29 private notificationsService: NotificationsService,
30 private confirmService: ConfirmService,
31 private userService: UserService,
32 private i18n: I18n
33 ) { }
34
35 ngOnInit () {
36 this.buildActions()
37 }
38
39 openBanUserModal (user: User) {
40 if (user.username === 'root') {
41 this.notificationsService.error(this.i18n('Error'), this.i18n('You cannot ban root.'))
42 return
43 }
44
45 this.userBanModal.openModal(user)
46 }
47
48 onUserBanned () {
49 this.userChanged.emit()
50 }
51
52 async unbanUser (user: User) {
53 const message = this.i18n('Do you really want to unban {{username}}?', { username: user.username })
54 const res = await this.confirmService.confirm(message, this.i18n('Unban'))
55 if (res === false) return
56
57 this.userService.unbanUsers(user)
58 .subscribe(
59 () => {
60 this.notificationsService.success(
61 this.i18n('Success'),
62 this.i18n('User {{username}} unbanned.', { username: user.username })
63 )
64
65 this.userChanged.emit()
66 },
67
68 err => this.notificationsService.error(this.i18n('Error'), err.message)
69 )
70 }
71
72 async removeUser (user: User) {
73 if (user.username === 'root') {
74 this.notificationsService.error(this.i18n('Error'), this.i18n('You cannot delete root.'))
75 return
76 }
77
78 const message = this.i18n('If you remove this user, you will not be able to create another with the same username!')
79 const res = await this.confirmService.confirm(message, this.i18n('Delete'))
80 if (res === false) return
81
82 this.userService.removeUser(user).subscribe(
83 () => {
84 this.notificationsService.success(
85 this.i18n('Success'),
86 this.i18n('User {{username}} deleted.', { username: user.username })
87 )
88 this.userDeleted.emit()
89 },
90
91 err => this.notificationsService.error(this.i18n('Error'), err.message)
92 )
93 }
94
95 getRouterUserEditLink (user: User) {
96 return [ '/admin', 'users', 'update', user.id ]
97 }
98
99 private buildActions () {
100 this.userActions = []
101
102 if (this.authService.isLoggedIn()) {
103 const authUser = this.authService.getUser()
104
105 if (authUser.hasRight(UserRight.MANAGE_USERS)) {
106 this.userActions = this.userActions.concat([
107 {
108 label: this.i18n('Edit'),
109 linkBuilder: this.getRouterUserEditLink
110 },
111 {
112 label: this.i18n('Delete'),
113 handler: user => this.removeUser(user)
114 },
115 {
116 label: this.i18n('Ban'),
117 handler: user => this.openBanUserModal(user),
118 isDisplayed: user => !user.blocked
119 },
120 {
121 label: this.i18n('Unban'),
122 handler: user => this.unbanUser(user),
123 isDisplayed: user => user.blocked
124 }
125 ])
126 }
127 }
128 }
129}
diff --git a/client/src/app/shared/rest/rest-table.ts b/client/src/app/shared/rest/rest-table.ts
index fe1a91d2d..26748f245 100644
--- a/client/src/app/shared/rest/rest-table.ts
+++ b/client/src/app/shared/rest/rest-table.ts
@@ -1,8 +1,9 @@
1import { peertubeLocalStorage } from '@app/shared/misc/peertube-local-storage' 1import { peertubeLocalStorage } from '@app/shared/misc/peertube-local-storage'
2import { LazyLoadEvent } from 'primeng/components/common/lazyloadevent' 2import { LazyLoadEvent } from 'primeng/components/common/lazyloadevent'
3import { SortMeta } from 'primeng/components/common/sortmeta' 3import { SortMeta } from 'primeng/components/common/sortmeta'
4
5import { RestPagination } from './rest-pagination' 4import { RestPagination } from './rest-pagination'
5import { Subject } from 'rxjs'
6import { debounceTime, distinctUntilChanged } from 'rxjs/operators'
6 7
7export abstract class RestTable { 8export abstract class RestTable {
8 9
@@ -11,10 +12,17 @@ export abstract class RestTable {
11 abstract sort: SortMeta 12 abstract sort: SortMeta
12 abstract pagination: RestPagination 13 abstract pagination: RestPagination
13 14
15 protected search: string
16 private searchStream: Subject<string>
14 private sortLocalStorageKey = 'rest-table-sort-' + this.constructor.name 17 private sortLocalStorageKey = 'rest-table-sort-' + this.constructor.name
15 18
16 protected abstract loadData (): void 19 protected abstract loadData (): void
17 20
21 initialize () {
22 this.loadSort()
23 this.initSearch()
24 }
25
18 loadSort () { 26 loadSort () {
19 const result = peertubeLocalStorage.getItem(this.sortLocalStorageKey) 27 const result = peertubeLocalStorage.getItem(this.sortLocalStorageKey)
20 28
@@ -46,4 +54,21 @@ export abstract class RestTable {
46 peertubeLocalStorage.setItem(this.sortLocalStorageKey, JSON.stringify(this.sort)) 54 peertubeLocalStorage.setItem(this.sortLocalStorageKey, JSON.stringify(this.sort))
47 } 55 }
48 56
57 initSearch () {
58 this.searchStream = new Subject()
59
60 this.searchStream
61 .pipe(
62 debounceTime(400),
63 distinctUntilChanged()
64 )
65 .subscribe(search => {
66 this.search = search
67 this.loadData()
68 })
69 }
70
71 onSearch (search: string) {
72 this.searchStream.next(search)
73 }
49} 74}
diff --git a/client/src/app/shared/shared.module.ts b/client/src/app/shared/shared.module.ts
index 076f1d275..9647a7966 100644
--- a/client/src/app/shared/shared.module.ts
+++ b/client/src/app/shared/shared.module.ts
@@ -56,6 +56,8 @@ import { NgbDropdownModule, NgbModalModule, NgbPopoverModule, NgbTabsetModule, N
56import { SubscribeButtonComponent, RemoteSubscribeComponent, UserSubscriptionService } from '@app/shared/user-subscription' 56import { SubscribeButtonComponent, RemoteSubscribeComponent, UserSubscriptionService } from '@app/shared/user-subscription'
57import { InstanceFeaturesTableComponent } from '@app/shared/instance/instance-features-table.component' 57import { InstanceFeaturesTableComponent } from '@app/shared/instance/instance-features-table.component'
58import { OverviewService } from '@app/shared/overview' 58import { OverviewService } from '@app/shared/overview'
59import { UserBanModalComponent } from '@app/shared/moderation'
60import { UserModerationDropdownComponent } from '@app/shared/moderation/user-moderation-dropdown.component'
59 61
60@NgModule({ 62@NgModule({
61 imports: [ 63 imports: [
@@ -94,7 +96,9 @@ import { OverviewService } from '@app/shared/overview'
94 PeertubeCheckboxComponent, 96 PeertubeCheckboxComponent,
95 SubscribeButtonComponent, 97 SubscribeButtonComponent,
96 RemoteSubscribeComponent, 98 RemoteSubscribeComponent,
97 InstanceFeaturesTableComponent 99 InstanceFeaturesTableComponent,
100 UserBanModalComponent,
101 UserModerationDropdownComponent
98 ], 102 ],
99 103
100 exports: [ 104 exports: [
@@ -130,6 +134,8 @@ import { OverviewService } from '@app/shared/overview'
130 SubscribeButtonComponent, 134 SubscribeButtonComponent,
131 RemoteSubscribeComponent, 135 RemoteSubscribeComponent,
132 InstanceFeaturesTableComponent, 136 InstanceFeaturesTableComponent,
137 UserBanModalComponent,
138 UserModerationDropdownComponent,
133 139
134 NumberFormatterPipe, 140 NumberFormatterPipe,
135 ObjectLengthPipe, 141 ObjectLengthPipe,
diff --git a/client/src/app/shared/users/user.service.ts b/client/src/app/shared/users/user.service.ts
index bd5cd45d4..27a81f0a2 100644
--- a/client/src/app/shared/users/user.service.ts
+++ b/client/src/app/shared/users/user.service.ts
@@ -1,21 +1,27 @@
1import { Observable } from 'rxjs' 1import { from, Observable } from 'rxjs'
2import { catchError, map } from 'rxjs/operators' 2import { catchError, concatMap, map, toArray } from 'rxjs/operators'
3import { HttpClient, HttpParams } from '@angular/common/http' 3import { HttpClient, HttpParams } from '@angular/common/http'
4import { Injectable } from '@angular/core' 4import { Injectable } from '@angular/core'
5import { UserCreate, UserUpdateMe, UserVideoQuota } from '../../../../../shared' 5import { ResultList, User, UserCreate, UserRole, UserUpdate, UserUpdateMe, UserVideoQuota } from '../../../../../shared'
6import { environment } from '../../../environments/environment' 6import { environment } from '../../../environments/environment'
7import { RestExtractor } from '../rest' 7import { RestExtractor, RestPagination, RestService } from '../rest'
8import { Avatar } from '../../../../../shared/models/avatars/avatar.model' 8import { Avatar } from '../../../../../shared/models/avatars/avatar.model'
9import { SortMeta } from 'primeng/api'
10import { BytesPipe } from 'ngx-pipes'
11import { I18n } from '@ngx-translate/i18n-polyfill'
9 12
10@Injectable() 13@Injectable()
11export class UserService { 14export class UserService {
12 static BASE_USERS_URL = environment.apiUrl + '/api/v1/users/' 15 static BASE_USERS_URL = environment.apiUrl + '/api/v1/users/'
13 16
17 private bytesPipe = new BytesPipe()
18
14 constructor ( 19 constructor (
15 private authHttp: HttpClient, 20 private authHttp: HttpClient,
16 private restExtractor: RestExtractor 21 private restExtractor: RestExtractor,
17 ) { 22 private restService: RestService,
18 } 23 private i18n: I18n
24 ) { }
19 25
20 changePassword (currentPassword: string, newPassword: string) { 26 changePassword (currentPassword: string, newPassword: string) {
21 const url = UserService.BASE_USERS_URL + 'me' 27 const url = UserService.BASE_USERS_URL + 'me'
@@ -128,4 +134,98 @@ export class UserService {
128 .get<string[]>(url, { params }) 134 .get<string[]>(url, { params })
129 .pipe(catchError(res => this.restExtractor.handleError(res))) 135 .pipe(catchError(res => this.restExtractor.handleError(res)))
130 } 136 }
137
138 /* ###### Admin methods ###### */
139
140 addUser (userCreate: UserCreate) {
141 return this.authHttp.post(UserService.BASE_USERS_URL, userCreate)
142 .pipe(
143 map(this.restExtractor.extractDataBool),
144 catchError(err => this.restExtractor.handleError(err))
145 )
146 }
147
148 updateUser (userId: number, userUpdate: UserUpdate) {
149 return this.authHttp.put(UserService.BASE_USERS_URL + userId, userUpdate)
150 .pipe(
151 map(this.restExtractor.extractDataBool),
152 catchError(err => this.restExtractor.handleError(err))
153 )
154 }
155
156 getUser (userId: number) {
157 return this.authHttp.get<User>(UserService.BASE_USERS_URL + userId)
158 .pipe(catchError(err => this.restExtractor.handleError(err)))
159 }
160
161 getUsers (pagination: RestPagination, sort: SortMeta, search?: string): Observable<ResultList<User>> {
162 let params = new HttpParams()
163 params = this.restService.addRestGetParams(params, pagination, sort)
164
165 if (search) params = params.append('search', search)
166
167 return this.authHttp.get<ResultList<User>>(UserService.BASE_USERS_URL, { params })
168 .pipe(
169 map(res => this.restExtractor.convertResultListDateToHuman(res)),
170 map(res => this.restExtractor.applyToResultListData(res, this.formatUser.bind(this))),
171 catchError(err => this.restExtractor.handleError(err))
172 )
173 }
174
175 removeUser (usersArg: User | User[]) {
176 const users = Array.isArray(usersArg) ? usersArg : [ usersArg ]
177
178 return from(users)
179 .pipe(
180 concatMap(u => this.authHttp.delete(UserService.BASE_USERS_URL + u.id)),
181 toArray(),
182 catchError(err => this.restExtractor.handleError(err))
183 )
184 }
185
186 banUsers (usersArg: User | User[], reason?: string) {
187 const body = reason ? { reason } : {}
188 const users = Array.isArray(usersArg) ? usersArg : [ usersArg ]
189
190 return from(users)
191 .pipe(
192 concatMap(u => this.authHttp.post(UserService.BASE_USERS_URL + u.id + '/block', body)),
193 toArray(),
194 catchError(err => this.restExtractor.handleError(err))
195 )
196 }
197
198 unbanUsers (usersArg: User | User[]) {
199 const users = Array.isArray(usersArg) ? usersArg : [ usersArg ]
200
201 return from(users)
202 .pipe(
203 concatMap(u => this.authHttp.post(UserService.BASE_USERS_URL + u.id + '/unblock', {})),
204 toArray(),
205 catchError(err => this.restExtractor.handleError(err))
206 )
207 }
208
209 private formatUser (user: User) {
210 let videoQuota
211 if (user.videoQuota === -1) {
212 videoQuota = this.i18n('Unlimited')
213 } else {
214 videoQuota = this.bytesPipe.transform(user.videoQuota, 0)
215 }
216
217 const videoQuotaUsed = this.bytesPipe.transform(user.videoQuotaUsed, 0)
218
219 const roleLabels: { [ id in UserRole ]: string } = {
220 [UserRole.USER]: this.i18n('User'),
221 [UserRole.ADMINISTRATOR]: this.i18n('Administrator'),
222 [UserRole.MODERATOR]: this.i18n('Moderator')
223 }
224
225 return Object.assign(user, {
226 roleLabel: roleLabels[user.role],
227 videoQuota,
228 videoQuotaUsed
229 })
230 }
131} 231}
diff --git a/client/src/app/shared/video/abstract-video-list.html b/client/src/app/shared/video/abstract-video-list.html
index d543ab7c1..69a619b76 100644
--- a/client/src/app/shared/video/abstract-video-list.html
+++ b/client/src/app/shared/video/abstract-video-list.html
@@ -1,8 +1,18 @@
1<div [ngClass]="{ 'margin-content': marginContent }"> 1<div [ngClass]="{ 'margin-content': marginContent }">
2 <div *ngIf="titlePage" class="title-page title-page-single"> 2 <div class="videos-header">
3 {{ titlePage }} 3 <div *ngIf="titlePage" class="title-page title-page-single">
4 {{ titlePage }}
5 </div>
6 <my-video-feed [syndicationItems]="syndicationItems"></my-video-feed>
7
8 <div class="moderation-block" *ngIf="displayModerationBlock">
9 <my-peertube-checkbox
10 (change)="toggleModerationDisplay()"
11 inputName="display-unlisted-private" i18n-labelText labelText="Display unlisted and private videos"
12 >
13 </my-peertube-checkbox>
14 </div>
4 </div> 15 </div>
5 <my-video-feed [syndicationItems]="syndicationItems"></my-video-feed>
6 16
7 <div class="no-results" i18n *ngIf="pagination.totalItems === 0">No results.</div> 17 <div class="no-results" i18n *ngIf="pagination.totalItems === 0">No results.</div>
8 <div 18 <div
diff --git a/client/src/app/shared/video/abstract-video-list.scss b/client/src/app/shared/video/abstract-video-list.scss
index 3f9c73a29..92998cb44 100644
--- a/client/src/app/shared/video/abstract-video-list.scss
+++ b/client/src/app/shared/video/abstract-video-list.scss
@@ -8,12 +8,27 @@
8 } 8 }
9} 9}
10 10
11.title-page.title-page-single { 11.videos-header {
12 margin-right: 5px; 12 display: flex;
13} 13 height: 80px;
14 align-items: center;
15
16 .title-page.title-page-single {
17 margin: 0 5px 0 0;
18 }
14 19
15my-video-feed { 20 my-video-feed {
16 display: inline-block; 21 display: inline-block;
22 position: relative;
23 top: 1px;
24 }
25
26 .moderation-block {
27 display: flex;
28 flex-grow: 1;
29 justify-content: flex-end;
30 align-items: center;
31 }
17} 32}
18 33
19@media screen and (max-width: 500px) { 34@media screen and (max-width: 500px) {
diff --git a/client/src/app/shared/video/abstract-video-list.ts b/client/src/app/shared/video/abstract-video-list.ts
index 6a758ebe0..1f43f974c 100644
--- a/client/src/app/shared/video/abstract-video-list.ts
+++ b/client/src/app/shared/video/abstract-video-list.ts
@@ -37,6 +37,7 @@ export abstract class AbstractVideoList implements OnInit, OnDestroy {
37 videoPages: Video[][] = [] 37 videoPages: Video[][] = []
38 ownerDisplayType: OwnerDisplayType = 'account' 38 ownerDisplayType: OwnerDisplayType = 'account'
39 firstLoadedPage: number 39 firstLoadedPage: number
40 displayModerationBlock = false
40 41
41 protected baseVideoWidth = 215 42 protected baseVideoWidth = 215
42 protected baseVideoHeight = 205 43 protected baseVideoHeight = 205
@@ -83,7 +84,7 @@ export abstract class AbstractVideoList implements OnInit, OnDestroy {
83 84
84 pageByVideoId (index: number, page: Video[]) { 85 pageByVideoId (index: number, page: Video[]) {
85 // Video are unique in all pages 86 // Video are unique in all pages
86 return page[0].id 87 return page.length !== 0 ? page[0].id : 0
87 } 88 }
88 89
89 videoById (index: number, video: Video) { 90 videoById (index: number, video: Video) {
@@ -160,6 +161,10 @@ export abstract class AbstractVideoList implements OnInit, OnDestroy {
160 ) 161 )
161 } 162 }
162 163
164 toggleModerationDisplay () {
165 throw new Error('toggleModerationDisplay is not implemented')
166 }
167
163 protected hasMoreVideos () { 168 protected hasMoreVideos () {
164 // No results 169 // No results
165 if (this.pagination.totalItems === 0) return false 170 if (this.pagination.totalItems === 0) return false
diff --git a/client/src/app/shared/video/video-miniature.component.html b/client/src/app/shared/video/video-miniature.component.html
index cfc483018..277a0cf35 100644
--- a/client/src/app/shared/video/video-miniature.component.html
+++ b/client/src/app/shared/video/video-miniature.component.html
@@ -8,6 +8,9 @@
8 [routerLink]="[ '/videos/watch', video.uuid ]" [attr.title]="video.name" [ngClass]="{ 'blur-filter': isVideoBlur }" 8 [routerLink]="[ '/videos/watch', video.uuid ]" [attr.title]="video.name" [ngClass]="{ 'blur-filter': isVideoBlur }"
9 > 9 >
10 {{ video.name }} 10 {{ video.name }}
11
12 <span *ngIf="isUnlistedVideo()" class="badge badge-warning" i18n>Unlisted</span>
13 <span *ngIf="isPrivateVideo()" class="badge badge-danger" i18n>Private</span>
11 </a> 14 </a>
12 15
13 <span i18n class="video-miniature-created-at-views">{{ video.publishedAt | myFromNow }} - {{ video.views | myNumberFormatter }} views</span> 16 <span i18n class="video-miniature-created-at-views">{{ video.publishedAt | myFromNow }} - {{ video.views | myNumberFormatter }} views</span>
diff --git a/client/src/app/shared/video/video-miniature.component.ts b/client/src/app/shared/video/video-miniature.component.ts
index 7e8692b0b..2f951a1f1 100644
--- a/client/src/app/shared/video/video-miniature.component.ts
+++ b/client/src/app/shared/video/video-miniature.component.ts
@@ -2,6 +2,7 @@ import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core
2import { User } from '../users' 2import { User } from '../users'
3import { Video } from './video.model' 3import { Video } from './video.model'
4import { ServerService } from '@app/core' 4import { ServerService } from '@app/core'
5import { VideoPrivacy } from '../../../../../shared'
5 6
6export type OwnerDisplayType = 'account' | 'videoChannel' | 'auto' 7export type OwnerDisplayType = 'account' | 'videoChannel' | 'auto'
7 8
@@ -49,4 +50,12 @@ export class VideoMiniatureComponent implements OnInit {
49 displayOwnerVideoChannel () { 50 displayOwnerVideoChannel () {
50 return this.ownerDisplayTypeChosen === 'videoChannel' 51 return this.ownerDisplayTypeChosen === 'videoChannel'
51 } 52 }
53
54 isUnlistedVideo () {
55 return this.video.privacy.id === VideoPrivacy.UNLISTED
56 }
57
58 isPrivateVideo () {
59 return this.video.privacy.id === VideoPrivacy.PRIVATE
60 }
52} 61}
diff --git a/client/src/app/shared/video/video-thumbnail.component.html b/client/src/app/shared/video/video-thumbnail.component.html
index c1d45ea18..d25666916 100644
--- a/client/src/app/shared/video/video-thumbnail.component.html
+++ b/client/src/app/shared/video/video-thumbnail.component.html
@@ -2,9 +2,11 @@
2 [routerLink]="['/videos/watch', video.uuid]" [attr.title]="video.name" 2 [routerLink]="['/videos/watch', video.uuid]" [attr.title]="video.name"
3 class="video-thumbnail" 3 class="video-thumbnail"
4> 4>
5<img alt="" [attr.aria-labelledby]="video.name" [attr.src]="getImageUrl()" [ngClass]="{ 'blur-filter': nsfw }" /> 5 <img alt="" [attr.aria-labelledby]="video.name" [attr.src]="getImageUrl()" [ngClass]="{ 'blur-filter': nsfw }" />
6 6
7<div class="video-thumbnail-overlay"> 7 <div class="video-thumbnail-overlay">{{ video.durationLabel }}</div>
8 {{ video.durationLabel }} 8
9</div> 9 <div class="progress-bar" *ngIf="video.userHistory?.currentTime">
10 <div [ngStyle]="{ 'width.%': getProgressPercent() }"></div>
11 </div>
10</a> 12</a>
diff --git a/client/src/app/shared/video/video-thumbnail.component.scss b/client/src/app/shared/video/video-thumbnail.component.scss
index 1dd8e5338..4772edaf0 100644
--- a/client/src/app/shared/video/video-thumbnail.component.scss
+++ b/client/src/app/shared/video/video-thumbnail.component.scss
@@ -29,6 +29,19 @@
29 } 29 }
30 } 30 }
31 31
32 .progress-bar {
33 height: 3px;
34 width: 100%;
35 position: relative;
36 top: -3px;
37 background-color: rgba(0, 0, 0, 0.20);
38
39 div {
40 height: 100%;
41 background-color: var(--mainColor);
42 }
43 }
44
32 .video-thumbnail-overlay { 45 .video-thumbnail-overlay {
33 position: absolute; 46 position: absolute;
34 right: 5px; 47 right: 5px;
diff --git a/client/src/app/shared/video/video-thumbnail.component.ts b/client/src/app/shared/video/video-thumbnail.component.ts
index 86d8f6f74..ca43700c7 100644
--- a/client/src/app/shared/video/video-thumbnail.component.ts
+++ b/client/src/app/shared/video/video-thumbnail.component.ts
@@ -22,4 +22,12 @@ export class VideoThumbnailComponent {
22 22
23 return this.video.thumbnailUrl 23 return this.video.thumbnailUrl
24 } 24 }
25
26 getProgressPercent () {
27 if (!this.video.userHistory) return 0
28
29 const currentTime = this.video.userHistory.currentTime
30
31 return (currentTime / this.video.duration) * 100
32 }
25} 33}
diff --git a/client/src/app/shared/video/video.model.ts b/client/src/app/shared/video/video.model.ts
index 80794faa6..b92c96450 100644
--- a/client/src/app/shared/video/video.model.ts
+++ b/client/src/app/shared/video/video.model.ts
@@ -66,6 +66,10 @@ export class Video implements VideoServerModel {
66 avatar: Avatar 66 avatar: Avatar
67 } 67 }
68 68
69 userHistory?: {
70 currentTime: number
71 }
72
69 static buildClientUrl (videoUUID: string) { 73 static buildClientUrl (videoUUID: string) {
70 return '/videos/watch/' + videoUUID 74 return '/videos/watch/' + videoUUID
71 } 75 }
@@ -116,6 +120,8 @@ export class Video implements VideoServerModel {
116 120
117 this.blacklisted = hash.blacklisted 121 this.blacklisted = hash.blacklisted
118 this.blacklistedReason = hash.blacklistedReason 122 this.blacklistedReason = hash.blacklistedReason
123
124 this.userHistory = hash.userHistory
119 } 125 }
120 126
121 isVideoNSFWForUser (user: User, serverConfig: ServerConfig) { 127 isVideoNSFWForUser (user: User, serverConfig: ServerConfig) {
diff --git a/client/src/app/shared/video/video.service.ts b/client/src/app/shared/video/video.service.ts
index 2255a18a2..724a0bde9 100644
--- a/client/src/app/shared/video/video.service.ts
+++ b/client/src/app/shared/video/video.service.ts
@@ -58,6 +58,10 @@ export class VideoService implements VideosProvider {
58 return VideoService.BASE_VIDEO_URL + uuid + '/views' 58 return VideoService.BASE_VIDEO_URL + uuid + '/views'
59 } 59 }
60 60
61 getUserWatchingVideoUrl (uuid: string) {
62 return VideoService.BASE_VIDEO_URL + uuid + '/watching'
63 }
64
61 getVideo (uuid: string): Observable<VideoDetails> { 65 getVideo (uuid: string): Observable<VideoDetails> {
62 return this.serverService.localeObservable 66 return this.serverService.localeObservable
63 .pipe( 67 .pipe(
diff --git a/client/src/app/videos/+video-edit/shared/video-edit.component.scss b/client/src/app/videos/+video-edit/shared/video-edit.component.scss
index b039d7ad4..25db8e8ed 100644
--- a/client/src/app/videos/+video-edit/shared/video-edit.component.scss
+++ b/client/src/app/videos/+video-edit/shared/video-edit.component.scss
@@ -5,6 +5,11 @@
5 @include peertube-select-container(auto); 5 @include peertube-select-container(auto);
6} 6}
7 7
8my-peertube-checkbox {
9 display: block;
10 margin-bottom: 1rem;
11}
12
8.video-edit { 13.video-edit {
9 height: 100%; 14 height: 100%;
10 min-height: 300px; 15 min-height: 300px;
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 ea10b22ad..c5deddf05 100644
--- a/client/src/app/videos/+video-watch/video-watch.component.ts
+++ b/client/src/app/videos/+video-watch/video-watch.component.ts
@@ -369,7 +369,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
369 ) 369 )
370 } 370 }
371 371
372 private async onVideoFetched (video: VideoDetails, videoCaptions: VideoCaption[], startTime = 0) { 372 private async onVideoFetched (video: VideoDetails, videoCaptions: VideoCaption[], startTimeFromUrl: number) {
373 this.video = video 373 this.video = video
374 374
375 // Re init attributes 375 // Re init attributes
@@ -377,6 +377,10 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
377 this.completeDescriptionShown = false 377 this.completeDescriptionShown = false
378 this.remoteServerDown = false 378 this.remoteServerDown = false
379 379
380 let startTime = startTimeFromUrl || (this.video.userHistory ? this.video.userHistory.currentTime : 0)
381 // Don't start the video if we are at the end
382 if (this.video.duration - startTime <= 1) startTime = 0
383
380 if (this.video.isVideoNSFWForUser(this.user, this.serverService.getConfig())) { 384 if (this.video.isVideoNSFWForUser(this.user, this.serverService.getConfig())) {
381 const res = await this.confirmService.confirm( 385 const res = await this.confirmService.confirm(
382 this.i18n('This video contains mature or explicit content. Are you sure you want to watch it?'), 386 this.i18n('This video contains mature or explicit content. Are you sure you want to watch it?'),
@@ -414,7 +418,12 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
414 poster: this.video.previewUrl, 418 poster: this.video.previewUrl,
415 startTime, 419 startTime,
416 theaterMode: true, 420 theaterMode: true,
417 language: this.localeId 421 language: this.localeId,
422
423 userWatching: this.user ? {
424 url: this.videoService.getUserWatchingVideoUrl(this.video.uuid),
425 authorizationHeader: this.authService.getRequestHeaderValue()
426 } : undefined
418 }) 427 })
419 428
420 if (this.videojsLocaleLoaded === false) { 429 if (this.videojsLocaleLoaded === false) {
diff --git a/client/src/app/videos/video-list/video-local.component.ts b/client/src/app/videos/video-list/video-local.component.ts
index c91c639ca..9d000cf2e 100644
--- a/client/src/app/videos/video-list/video-local.component.ts
+++ b/client/src/app/videos/video-list/video-local.component.ts
@@ -10,6 +10,7 @@ import { VideoService } from '../../shared/video/video.service'
10import { VideoFilter } from '../../../../../shared/models/videos/video-query.type' 10import { VideoFilter } from '../../../../../shared/models/videos/video-query.type'
11import { I18n } from '@ngx-translate/i18n-polyfill' 11import { I18n } from '@ngx-translate/i18n-polyfill'
12import { ScreenService } from '@app/shared/misc/screen.service' 12import { ScreenService } from '@app/shared/misc/screen.service'
13import { UserRight } from '../../../../../shared/models/users'
13 14
14@Component({ 15@Component({
15 selector: 'my-videos-local', 16 selector: 'my-videos-local',
@@ -40,6 +41,11 @@ export class VideoLocalComponent extends AbstractVideoList implements OnInit, On
40 ngOnInit () { 41 ngOnInit () {
41 super.ngOnInit() 42 super.ngOnInit()
42 43
44 if (this.authService.isLoggedIn()) {
45 const user = this.authService.getUser()
46 this.displayModerationBlock = user.hasRight(UserRight.SEE_ALL_VIDEOS)
47 }
48
43 this.generateSyndicationList() 49 this.generateSyndicationList()
44 } 50 }
45 51
@@ -56,4 +62,10 @@ export class VideoLocalComponent extends AbstractVideoList implements OnInit, On
56 generateSyndicationList () { 62 generateSyndicationList () {
57 this.syndicationItems = this.videoService.getVideoFeedUrls(this.sort, this.filter, this.categoryOneOf) 63 this.syndicationItems = this.videoService.getVideoFeedUrls(this.sort, this.filter, this.categoryOneOf)
58 } 64 }
65
66 toggleModerationDisplay () {
67 this.filter = this.filter === 'local' ? 'all-local' as 'all-local' : 'local' as 'local'
68
69 this.reloadVideos()
70 }
59} 71}
diff --git a/client/src/assets/player/peertube-player.ts b/client/src/assets/player/peertube-player.ts
index 1bf6c9267..792662b6c 100644
--- a/client/src/assets/player/peertube-player.ts
+++ b/client/src/assets/player/peertube-player.ts
@@ -10,7 +10,7 @@ import './webtorrent-info-button'
10import './peertube-videojs-plugin' 10import './peertube-videojs-plugin'
11import './peertube-load-progress-bar' 11import './peertube-load-progress-bar'
12import './theater-button' 12import './theater-button'
13import { VideoJSCaption, videojsUntyped } from './peertube-videojs-typings' 13import { UserWatching, VideoJSCaption, videojsUntyped } from './peertube-videojs-typings'
14import { buildVideoEmbed, buildVideoLink, copyToClipboard } from './utils' 14import { buildVideoEmbed, buildVideoLink, copyToClipboard } from './utils'
15import { getCompleteLocale, getShortLocale, is18nLocale, isDefaultLocale } from '../../../../shared/models/i18n/i18n' 15import { getCompleteLocale, getShortLocale, is18nLocale, isDefaultLocale } from '../../../../shared/models/i18n/i18n'
16 16
@@ -34,10 +34,13 @@ function getVideojsOptions (options: {
34 startTime: number | string 34 startTime: number | string
35 theaterMode: boolean, 35 theaterMode: boolean,
36 videoCaptions: VideoJSCaption[], 36 videoCaptions: VideoJSCaption[],
37
37 language?: string, 38 language?: string,
38 controls?: boolean, 39 controls?: boolean,
39 muted?: boolean, 40 muted?: boolean,
40 loop?: boolean 41 loop?: boolean
42
43 userWatching?: UserWatching
41}) { 44}) {
42 const videojsOptions = { 45 const videojsOptions = {
43 // We don't use text track settings for now 46 // We don't use text track settings for now
@@ -57,7 +60,8 @@ function getVideojsOptions (options: {
57 playerElement: options.playerElement, 60 playerElement: options.playerElement,
58 videoViewUrl: options.videoViewUrl, 61 videoViewUrl: options.videoViewUrl,
59 videoDuration: options.videoDuration, 62 videoDuration: options.videoDuration,
60 startTime: options.startTime 63 startTime: options.startTime,
64 userWatching: options.userWatching
61 } 65 }
62 }, 66 },
63 controlBar: { 67 controlBar: {
diff --git a/client/src/assets/player/peertube-videojs-plugin.ts b/client/src/assets/player/peertube-videojs-plugin.ts
index adc376e94..2330f476f 100644
--- a/client/src/assets/player/peertube-videojs-plugin.ts
+++ b/client/src/assets/player/peertube-videojs-plugin.ts
@@ -3,7 +3,7 @@ import * as WebTorrent from 'webtorrent'
3import { VideoFile } from '../../../../shared/models/videos/video.model' 3import { VideoFile } from '../../../../shared/models/videos/video.model'
4import { renderVideo } from './video-renderer' 4import { renderVideo } from './video-renderer'
5import './settings-menu-button' 5import './settings-menu-button'
6import { PeertubePluginOptions, VideoJSCaption, VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings' 6import { PeertubePluginOptions, UserWatching, VideoJSCaption, VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings'
7import { isMobile, timeToInt, videoFileMaxByResolution, videoFileMinByResolution } from './utils' 7import { isMobile, timeToInt, videoFileMaxByResolution, videoFileMinByResolution } from './utils'
8import * as CacheChunkStore from 'cache-chunk-store' 8import * as CacheChunkStore from 'cache-chunk-store'
9import { PeertubeChunkStore } from './peertube-chunk-store' 9import { PeertubeChunkStore } from './peertube-chunk-store'
@@ -32,7 +32,8 @@ class PeerTubePlugin extends Plugin {
32 AUTO_QUALITY_THRESHOLD_PERCENT: 30, // Bandwidth should be 30% more important than a resolution bitrate to change to it 32 AUTO_QUALITY_THRESHOLD_PERCENT: 30, // Bandwidth should be 30% more important than a resolution bitrate to change to it
33 AUTO_QUALITY_OBSERVATION_TIME: 10000, // Wait 10 seconds after having change the resolution before another check 33 AUTO_QUALITY_OBSERVATION_TIME: 10000, // Wait 10 seconds after having change the resolution before another check
34 AUTO_QUALITY_HIGHER_RESOLUTION_DELAY: 5000, // Buffering higher resolution during 5 seconds 34 AUTO_QUALITY_HIGHER_RESOLUTION_DELAY: 5000, // Buffering higher resolution during 5 seconds
35 BANDWIDTH_AVERAGE_NUMBER_OF_VALUES: 5 // Last 5 seconds to build average bandwidth 35 BANDWIDTH_AVERAGE_NUMBER_OF_VALUES: 5, // Last 5 seconds to build average bandwidth
36 USER_WATCHING_VIDEO_INTERVAL: 5000 // Every 5 seconds, notify the user is watching the video
36 } 37 }
37 38
38 private readonly webtorrent = new WebTorrent({ 39 private readonly webtorrent = new WebTorrent({
@@ -67,6 +68,7 @@ class PeerTubePlugin extends Plugin {
67 private videoViewInterval 68 private videoViewInterval
68 private torrentInfoInterval 69 private torrentInfoInterval
69 private autoQualityInterval 70 private autoQualityInterval
71 private userWatchingVideoInterval
70 private addTorrentDelay 72 private addTorrentDelay
71 private qualityObservationTimer 73 private qualityObservationTimer
72 private runAutoQualitySchedulerTimer 74 private runAutoQualitySchedulerTimer
@@ -100,6 +102,8 @@ class PeerTubePlugin extends Plugin {
100 this.runTorrentInfoScheduler() 102 this.runTorrentInfoScheduler()
101 this.runViewAdd() 103 this.runViewAdd()
102 104
105 if (options.userWatching) this.runUserWatchVideo(options.userWatching)
106
103 this.player.one('play', () => { 107 this.player.one('play', () => {
104 // Don't run immediately scheduler, wait some seconds the TCP connections are made 108 // Don't run immediately scheduler, wait some seconds the TCP connections are made
105 this.runAutoQualitySchedulerTimer = setTimeout(() => this.runAutoQualityScheduler(), this.CONSTANTS.AUTO_QUALITY_SCHEDULER) 109 this.runAutoQualitySchedulerTimer = setTimeout(() => this.runAutoQualityScheduler(), this.CONSTANTS.AUTO_QUALITY_SCHEDULER)
@@ -121,6 +125,8 @@ class PeerTubePlugin extends Plugin {
121 clearInterval(this.torrentInfoInterval) 125 clearInterval(this.torrentInfoInterval)
122 clearInterval(this.autoQualityInterval) 126 clearInterval(this.autoQualityInterval)
123 127
128 if (this.userWatchingVideoInterval) clearInterval(this.userWatchingVideoInterval)
129
124 // Don't need to destroy renderer, video player will be destroyed 130 // Don't need to destroy renderer, video player will be destroyed
125 this.flushVideoFile(this.currentVideoFile, false) 131 this.flushVideoFile(this.currentVideoFile, false)
126 132
@@ -524,6 +530,21 @@ class PeerTubePlugin extends Plugin {
524 }, 1000) 530 }, 1000)
525 } 531 }
526 532
533 private runUserWatchVideo (options: UserWatching) {
534 let lastCurrentTime = 0
535
536 this.userWatchingVideoInterval = setInterval(() => {
537 const currentTime = Math.floor(this.player.currentTime())
538
539 if (currentTime - lastCurrentTime >= 1) {
540 lastCurrentTime = currentTime
541
542 this.notifyUserIsWatching(currentTime, options.url, options.authorizationHeader)
543 .catch(err => console.error('Cannot notify user is watching.', err))
544 }
545 }, this.CONSTANTS.USER_WATCHING_VIDEO_INTERVAL)
546 }
547
527 private clearVideoViewInterval () { 548 private clearVideoViewInterval () {
528 if (this.videoViewInterval !== undefined) { 549 if (this.videoViewInterval !== undefined) {
529 clearInterval(this.videoViewInterval) 550 clearInterval(this.videoViewInterval)
@@ -537,6 +558,15 @@ class PeerTubePlugin extends Plugin {
537 return fetch(this.videoViewUrl, { method: 'POST' }) 558 return fetch(this.videoViewUrl, { method: 'POST' })
538 } 559 }
539 560
561 private notifyUserIsWatching (currentTime: number, url: string, authorizationHeader: string) {
562 const body = new URLSearchParams()
563 body.append('currentTime', currentTime.toString())
564
565 const headers = new Headers({ 'Authorization': authorizationHeader })
566
567 return fetch(url, { method: 'PUT', body, headers })
568 }
569
540 private fallbackToHttp (done?: Function, play = true) { 570 private fallbackToHttp (done?: Function, play = true) {
541 this.disableAutoResolution(true) 571 this.disableAutoResolution(true)
542 572
diff --git a/client/src/assets/player/peertube-videojs-typings.ts b/client/src/assets/player/peertube-videojs-typings.ts
index 993d5ee6b..b117007af 100644
--- a/client/src/assets/player/peertube-videojs-typings.ts
+++ b/client/src/assets/player/peertube-videojs-typings.ts
@@ -22,6 +22,11 @@ type VideoJSCaption = {
22 src: string 22 src: string
23} 23}
24 24
25type UserWatching = {
26 url: string,
27 authorizationHeader: string
28}
29
25type PeertubePluginOptions = { 30type PeertubePluginOptions = {
26 videoFiles: VideoFile[] 31 videoFiles: VideoFile[]
27 playerElement: HTMLVideoElement 32 playerElement: HTMLVideoElement
@@ -30,6 +35,8 @@ type PeertubePluginOptions = {
30 startTime: number | string 35 startTime: number | string
31 autoplay: boolean, 36 autoplay: boolean,
32 videoCaptions: VideoJSCaption[] 37 videoCaptions: VideoJSCaption[]
38
39 userWatching?: UserWatching
33} 40}
34 41
35// videojs typings don't have some method we need 42// videojs typings don't have some method we need
@@ -39,5 +46,6 @@ export {
39 VideoJSComponentInterface, 46 VideoJSComponentInterface,
40 PeertubePluginOptions, 47 PeertubePluginOptions,
41 videojsUntyped, 48 videojsUntyped,
42 VideoJSCaption 49 VideoJSCaption,
50 UserWatching
43} 51}
diff --git a/client/src/sass/include/_mixins.scss b/client/src/sass/include/_mixins.scss
index 2efd6a1d3..b25d7ae0f 100644
--- a/client/src/sass/include/_mixins.scss
+++ b/client/src/sass/include/_mixins.scss
@@ -29,7 +29,7 @@
29 display: block; 29 display: block;
30 /* Fallback for non-webkit */ 30 /* Fallback for non-webkit */
31 display: -webkit-box; 31 display: -webkit-box;
32 max-height: $font-size*$line-height*$lines-to-show; 32 max-height: $font-size*$line-height*$lines-to-show + 0.2;
33 /* Fallback for non-webkit */ 33 /* Fallback for non-webkit */
34 font-size: $font-size; 34 font-size: $font-size;
35 line-height: $line-height; 35 line-height: $line-height;
diff --git a/client/src/sass/primeng-custom.scss b/client/src/sass/primeng-custom.scss
index 5a03ac9c5..0568de4e2 100644
--- a/client/src/sass/primeng-custom.scss
+++ b/client/src/sass/primeng-custom.scss
@@ -14,8 +14,17 @@
14p-table { 14p-table {
15 font-size: 15px !important; 15 font-size: 15px !important;
16 16
17 .ui-table-caption {
18 border: none;
19
20 .caption {
21 height: 40px;
22 display: flex;
23 align-items: center;
24 }
25 }
26
17 td { 27 td {
18 // border: 1px solid #E5E5E5 !important;
19 padding-left: 15px !important; 28 padding-left: 15px !important;
20 29
21 &:not(.action-cell) { 30 &:not(.action-cell) {
@@ -28,6 +37,11 @@ p-table {
28 tr { 37 tr {
29 background-color: var(--mainBackgroundColor) !important; 38 background-color: var(--mainBackgroundColor) !important;
30 height: 46px; 39 height: 46px;
40
41 &.ui-state-highlight {
42 background-color:var(--submenuColor) !important;
43 color:var(--mainForegroundColor) !important;
44 }
31 } 45 }
32 46
33 .ui-table-tbody { 47 .ui-table-tbody {
@@ -216,4 +230,32 @@ p-calendar .ui-datepicker {
216 @include glyphicon-light; 230 @include glyphicon-light;
217 } 231 }
218 } 232 }
233}
234
235.ui-chkbox-box {
236 &.ui-state-active {
237 border-color: var(--mainColor) !important;
238 background-color: var(--mainColor) !important;
239 }
240
241 .ui-chkbox-icon {
242 position: relative;
243
244 &:after {
245 content: '';
246 position: absolute;
247 left: 5px;
248 width: 5px;
249 height: 12px;
250 opacity: 0;
251 transform: rotate(45deg) scale(0);
252 border-right: 2px solid var(--mainBackgroundColor);
253 border-bottom: 2px solid var(--mainBackgroundColor);
254 }
255
256 &.pi-check:after {
257 opacity: 1;
258 transform: rotate(45deg) scale(1);
259 }
260 }
219} \ No newline at end of file 261} \ No newline at end of file