aboutsummaryrefslogtreecommitdiffhomepage
path: root/client/src/app
diff options
context:
space:
mode:
Diffstat (limited to 'client/src/app')
-rw-r--r--client/src/app/+accounts/account-video-channels/account-video-channels.component.html7
-rw-r--r--client/src/app/+accounts/account-video-channels/account-video-channels.component.scss1
-rw-r--r--client/src/app/+accounts/accounts.component.html2
-rw-r--r--client/src/app/+admin/admin.component.ts16
-rw-r--r--client/src/app/+admin/admin.module.ts2
-rw-r--r--client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts3
-rw-r--r--client/src/app/+admin/config/edit-custom-config/edit-vod-transcoding.component.html25
-rw-r--r--client/src/app/+admin/config/edit-custom-config/edit-vod-transcoding.component.ts9
-rw-r--r--client/src/app/+admin/moderation/instance-blocklist/instance-account-blocklist.component.html56
-rw-r--r--client/src/app/+admin/moderation/moderation.routes.ts12
-rw-r--r--client/src/app/+admin/overview/comments/index.ts (renamed from client/src/app/+admin/moderation/video-comment-list/index.ts)1
-rw-r--r--client/src/app/+admin/overview/comments/video-comment-list.component.html (renamed from client/src/app/+admin/moderation/video-comment-list/video-comment-list.component.html)6
-rw-r--r--client/src/app/+admin/overview/comments/video-comment-list.component.scss (renamed from client/src/app/+admin/moderation/video-comment-list/video-comment-list.component.scss)8
-rw-r--r--client/src/app/+admin/overview/comments/video-comment-list.component.ts (renamed from client/src/app/+admin/moderation/video-comment-list/video-comment-list.component.ts)2
-rw-r--r--client/src/app/+admin/overview/comments/video-comment.routes.ts30
-rw-r--r--client/src/app/+admin/overview/index.ts1
-rw-r--r--client/src/app/+admin/overview/overview.routes.ts10
-rw-r--r--client/src/app/+admin/overview/users/user-list/user-list.component.html20
-rw-r--r--client/src/app/+admin/overview/users/user-list/user-list.component.scss4
-rw-r--r--client/src/app/+admin/overview/users/user-list/user-list.component.ts42
-rw-r--r--client/src/app/+admin/overview/users/users.routes.ts2
-rw-r--r--client/src/app/+admin/overview/videos/video-list.component.html4
-rw-r--r--client/src/app/+admin/overview/videos/video-list.component.scss2
-rw-r--r--client/src/app/+admin/overview/videos/video.routes.ts2
-rw-r--r--client/src/app/+manage/video-channel-edit/video-channel-update.component.ts4
-rw-r--r--client/src/app/+my-account/my-account-settings/my-account-settings.component.ts2
-rw-r--r--client/src/app/+my-library/+my-video-channels/my-video-channels.component.html2
-rw-r--r--client/src/app/+my-library/+my-video-channels/my-video-channels.component.scss1
-rw-r--r--client/src/app/+my-library/my-follows/my-followers.component.html2
-rw-r--r--client/src/app/+my-library/my-follows/my-followers.component.scss2
-rw-r--r--client/src/app/+my-library/my-follows/my-subscriptions.component.html2
-rw-r--r--client/src/app/+my-library/my-ownership/my-accept-ownership/my-accept-ownership.component.ts4
-rw-r--r--client/src/app/+my-library/my-ownership/my-ownership.component.html2
-rw-r--r--client/src/app/+my-library/my-video-playlists/my-video-playlist-create.component.ts4
-rw-r--r--client/src/app/+my-library/my-video-playlists/my-video-playlist-update.component.ts4
-rw-r--r--client/src/app/+my-library/my-videos/my-videos.component.ts8
-rw-r--r--client/src/app/+search/search.component.html2
-rw-r--r--client/src/app/+search/search.component.scss4
-rw-r--r--client/src/app/+video-channels/video-channels.component.html4
-rw-r--r--client/src/app/+video-channels/video-channels.component.scss4
-rw-r--r--client/src/app/+video-editor/edit/index.ts2
-rw-r--r--client/src/app/+video-editor/edit/video-editor-edit.component.html88
-rw-r--r--client/src/app/+video-editor/edit/video-editor-edit.component.scss76
-rw-r--r--client/src/app/+video-editor/edit/video-editor-edit.component.ts202
-rw-r--r--client/src/app/+video-editor/edit/video-editor-edit.resolver.ts18
-rw-r--r--client/src/app/+video-editor/index.ts1
-rw-r--r--client/src/app/+video-editor/shared/index.ts1
-rw-r--r--client/src/app/+video-editor/shared/video-editor.service.ts28
-rw-r--r--client/src/app/+video-editor/video-editor-routing.module.ts30
-rw-r--r--client/src/app/+video-editor/video-editor.module.ts27
-rw-r--r--client/src/app/+videos/+video-edit/shared/video-caption-add-modal.component.ts3
-rw-r--r--client/src/app/+videos/+video-edit/shared/video-caption-edit-modal/video-caption-edit-modal.component.html36
-rw-r--r--client/src/app/+videos/+video-edit/shared/video-caption-edit-modal/video-caption-edit-modal.component.scss4
-rw-r--r--client/src/app/+videos/+video-edit/shared/video-caption-edit-modal/video-caption-edit-modal.component.ts83
-rw-r--r--client/src/app/+videos/+video-edit/shared/video-edit.component.html18
-rw-r--r--client/src/app/+videos/+video-edit/shared/video-edit.component.scss4
-rw-r--r--client/src/app/+videos/+video-edit/shared/video-edit.component.ts12
-rw-r--r--client/src/app/+videos/+video-edit/shared/video-edit.module.ts4
-rw-r--r--client/src/app/+videos/+video-edit/video-add-components/video-send.ts4
-rw-r--r--client/src/app/+videos/+video-edit/video-update.resolver.ts4
-rw-r--r--client/src/app/+videos/+video-watch/shared/action-buttons/action-buttons.component.ts1
-rw-r--r--client/src/app/+videos/+video-watch/shared/comment/video-comment.component.html2
-rw-r--r--client/src/app/+videos/+video-watch/shared/comment/video-comment.component.scss9
-rw-r--r--client/src/app/+videos/+video-watch/shared/information/video-alert.component.html4
-rw-r--r--client/src/app/+videos/+video-watch/shared/information/video-alert.component.ts4
-rw-r--r--client/src/app/+videos/+video-watch/shared/metadata/video-avatar-channel.component.html6
-rw-r--r--client/src/app/+videos/+video-watch/shared/metadata/video-avatar-channel.component.scss9
-rw-r--r--client/src/app/+videos/+video-watch/shared/metadata/video-avatar-channel.component.ts4
-rw-r--r--client/src/app/+videos/+video-watch/shared/playlist/video-watch-playlist.component.scss1
-rw-r--r--client/src/app/+videos/+video-watch/shared/playlist/video-watch-playlist.component.ts10
-rw-r--r--client/src/app/+videos/+video-watch/video-watch.component.html6
-rw-r--r--client/src/app/+videos/video-list/overview/video-overview.component.html2
-rw-r--r--client/src/app/+videos/video-list/overview/video-overview.component.scss1
-rw-r--r--client/src/app/app-routing.module.ts6
-rw-r--r--client/src/app/core/users/user.model.ts4
-rw-r--r--client/src/app/core/users/user.service.ts2
-rw-r--r--client/src/app/helpers/utils/channel.ts16
-rw-r--r--client/src/app/helpers/utils/dom.ts (renamed from client/src/app/helpers/utils/ui.ts)26
-rw-r--r--client/src/app/helpers/utils/index.ts2
-rw-r--r--client/src/app/helpers/utils/url.ts5
-rw-r--r--client/src/app/modal/account-setup-warning-modal.component.ts2
-rw-r--r--client/src/app/shared/form-validators/video-captions-validators.ts7
-rw-r--r--client/src/app/shared/shared-abuse-list/abuse-list-table.component.html2
-rw-r--r--client/src/app/shared/shared-actor-image-edit/actor-avatar-edit.component.ts2
-rw-r--r--client/src/app/shared/shared-actor-image/actor-avatar.component.html2
-rw-r--r--client/src/app/shared/shared-actor-image/actor-avatar.component.scss29
-rw-r--r--client/src/app/shared/shared-actor-image/actor-avatar.component.ts21
-rw-r--r--client/src/app/shared/shared-custom-markup/peertube-custom-tags/channel-miniature-markup.component.html2
-rw-r--r--client/src/app/shared/shared-custom-markup/peertube-custom-tags/channel-miniature-markup.component.scss2
-rw-r--r--client/src/app/shared/shared-forms/form-reactive.ts2
-rw-r--r--client/src/app/shared/shared-forms/form-validator.service.ts2
-rw-r--r--client/src/app/shared/shared-forms/select/select-channel.component.ts2
-rw-r--r--client/src/app/shared/shared-forms/timestamp-input.component.html3
-rw-r--r--client/src/app/shared/shared-forms/timestamp-input.component.scss14
-rw-r--r--client/src/app/shared/shared-forms/timestamp-input.component.ts2
-rw-r--r--client/src/app/shared/shared-instance/instance-statistics.component.html28
-rw-r--r--client/src/app/shared/shared-main/account/account.model.ts16
-rw-r--r--client/src/app/shared/shared-main/account/actor.model.ts28
-rw-r--r--client/src/app/shared/shared-main/misc/channels-setup-message.component.ts2
-rw-r--r--client/src/app/shared/shared-main/users/user-notification.model.ts8
-rw-r--r--client/src/app/shared/shared-main/video-caption/video-caption-edit.model.ts4
-rw-r--r--client/src/app/shared/shared-main/video-caption/video-caption.service.ts7
-rw-r--r--client/src/app/shared/shared-main/video-channel/video-channel.model.ts42
-rw-r--r--client/src/app/shared/shared-main/video-channel/video-channel.service.ts2
-rw-r--r--client/src/app/shared/shared-main/video/video.model.ts12
-rw-r--r--client/src/app/shared/shared-moderation/account-blocklist.component.html2
-rw-r--r--client/src/app/shared/shared-moderation/blocklist.service.ts15
-rw-r--r--client/src/app/shared/shared-moderation/user-ban-modal.component.html16
-rw-r--r--client/src/app/shared/shared-moderation/user-ban-modal.component.scss5
-rw-r--r--client/src/app/shared/shared-moderation/user-ban-modal.component.ts35
-rw-r--r--client/src/app/shared/shared-moderation/user-moderation-dropdown.component.ts46
-rw-r--r--client/src/app/shared/shared-user-settings/user-video-settings.component.html2
-rw-r--r--client/src/app/shared/shared-video-miniature/video-actions-dropdown.component.ts23
-rw-r--r--client/src/app/shared/shared-video-miniature/video-miniature.component.html2
-rw-r--r--client/src/app/shared/shared-video-miniature/video-miniature.component.ts4
115 files changed, 1141 insertions, 335 deletions
diff --git a/client/src/app/+accounts/account-video-channels/account-video-channels.component.html b/client/src/app/+accounts/account-video-channels/account-video-channels.component.html
index 105bc12c3..379c0443e 100644
--- a/client/src/app/+accounts/account-video-channels/account-video-channels.component.html
+++ b/client/src/app/+accounts/account-video-channels/account-video-channels.component.html
@@ -9,8 +9,11 @@
9 9
10 <div class="channel-avatar-row"> 10 <div class="channel-avatar-row">
11 <my-actor-avatar 11 <my-actor-avatar
12 [channel]="videoChannel" [internalHref]="getVideoChannelLink(videoChannel)" 12 [channel]="videoChannel"
13 i18n-title title="See this video channel" 13 [internalHref]="getVideoChannelLink(videoChannel)"
14 i18n-title
15 title="See this video channel"
16 size="75"
14 ></my-actor-avatar> 17 ></my-actor-avatar>
15 18
16 <h2> 19 <h2>
diff --git a/client/src/app/+accounts/account-video-channels/account-video-channels.component.scss b/client/src/app/+accounts/account-video-channels/account-video-channels.component.scss
index be9e94f69..30b8098be 100644
--- a/client/src/app/+accounts/account-video-channels/account-video-channels.component.scss
+++ b/client/src/app/+accounts/account-video-channels/account-video-channels.component.scss
@@ -29,7 +29,6 @@
29 grid-template-rows: auto 1fr; 29 grid-template-rows: auto 1fr;
30 30
31 my-actor-avatar { 31 my-actor-avatar {
32 @include actor-avatar-size(75px);
33 @include margin-right(15px); 32 @include margin-right(15px);
34 33
35 grid-column: 1; 34 grid-column: 1;
diff --git a/client/src/app/+accounts/accounts.component.html b/client/src/app/+accounts/accounts.component.html
index 8362e6b7e..1544ad034 100644
--- a/client/src/app/+accounts/accounts.component.html
+++ b/client/src/app/+accounts/accounts.component.html
@@ -2,7 +2,7 @@
2 <div class="account-info"> 2 <div class="account-info">
3 3
4 <div class="account-avatar-row"> 4 <div class="account-avatar-row">
5 <my-actor-avatar class="main-avatar" [account]="account"></my-actor-avatar> 5 <my-actor-avatar class="main-avatar" [account]="account" size="120"></my-actor-avatar>
6 6
7 <div> 7 <div>
8 <div class="section-label" i18n>ACCOUNT</div> 8 <div class="section-label" i18n>ACCOUNT</div>
diff --git a/client/src/app/+admin/admin.component.ts b/client/src/app/+admin/admin.component.ts
index b8a957d1c..746549555 100644
--- a/client/src/app/+admin/admin.component.ts
+++ b/client/src/app/+admin/admin.component.ts
@@ -52,6 +52,14 @@ export class AdminComponent implements OnInit {
52 }) 52 })
53 } 53 }
54 54
55 if (this.hasVideoCommentsRight()) {
56 overviewItems.children.push({
57 label: $localize`Comments`,
58 routerLink: '/admin/comments',
59 iconName: 'message-circle'
60 })
61 }
62
55 if (overviewItems.children.length !== 0) { 63 if (overviewItems.children.length !== 0) {
56 this.menuEntries.push(overviewItems) 64 this.menuEntries.push(overviewItems)
57 } 65 }
@@ -104,14 +112,6 @@ export class AdminComponent implements OnInit {
104 }) 112 })
105 } 113 }
106 114
107 if (this.hasVideoCommentsRight()) {
108 moderationItems.children.push({
109 label: $localize`Video comments`,
110 routerLink: '/admin/moderation/video-comments/list',
111 iconName: 'message-circle'
112 })
113 }
114
115 if (this.hasAccountsBlocklistRight()) { 115 if (this.hasAccountsBlocklistRight()) {
116 moderationItems.children.push({ 116 moderationItems.children.push({
117 label: $localize`Muted accounts`, 117 label: $localize`Muted accounts`,
diff --git a/client/src/app/+admin/admin.module.ts b/client/src/app/+admin/admin.module.ts
index c672fa280..366e29883 100644
--- a/client/src/app/+admin/admin.module.ts
+++ b/client/src/app/+admin/admin.module.ts
@@ -32,13 +32,13 @@ import { RedundancyCheckboxComponent } from './follows/shared/redundancy-checkbo
32import { VideoRedundancyInformationComponent } from './follows/video-redundancies-list/video-redundancy-information.component' 32import { VideoRedundancyInformationComponent } from './follows/video-redundancies-list/video-redundancy-information.component'
33import { AbuseListComponent, VideoBlockListComponent } from './moderation' 33import { AbuseListComponent, VideoBlockListComponent } from './moderation'
34import { InstanceAccountBlocklistComponent, InstanceServerBlocklistComponent } from './moderation/instance-blocklist' 34import { InstanceAccountBlocklistComponent, InstanceServerBlocklistComponent } from './moderation/instance-blocklist'
35import { VideoCommentListComponent } from './moderation/video-comment-list'
36import { 35import {
37 UserCreateComponent, 36 UserCreateComponent,
38 UserListComponent, 37 UserListComponent,
39 UserPasswordComponent, 38 UserPasswordComponent,
40 UserUpdateComponent, 39 UserUpdateComponent,
41 VideoAdminService, 40 VideoAdminService,
41 VideoCommentListComponent,
42 VideoListComponent 42 VideoListComponent
43} from './overview' 43} from './overview'
44import { 44import {
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 f2eaa3033..e3b6f8305 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
@@ -197,6 +197,9 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
197 resolutions: {} 197 resolutions: {}
198 } 198 }
199 }, 199 },
200 videoEditor: {
201 enabled: null
202 },
200 autoBlacklist: { 203 autoBlacklist: {
201 videos: { 204 videos: {
202 ofUsers: { 205 ofUsers: {
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-vod-transcoding.component.html b/client/src/app/+admin/config/edit-custom-config/edit-vod-transcoding.component.html
index 1158f027b..2be855756 100644
--- a/client/src/app/+admin/config/edit-custom-config/edit-vod-transcoding.component.html
+++ b/client/src/app/+admin/config/edit-custom-config/edit-vod-transcoding.component.html
@@ -192,4 +192,29 @@
192 192
193 </div> 193 </div>
194 </div> 194 </div>
195
196 <div class="form-row mt-2"> <!-- video editor grid -->
197 <div class="form-group col-12 col-lg-4 col-xl-3">
198 <div i18n class="inner-form-title">VIDEO EDITOR</div>
199 <div i18n class="inner-form-description">
200 Allows your users to edit their video (cut, add intro/outro, add a watermark etc)
201 </div>
202 </div>
203
204 <div class="form-group form-group-right col-12 col-lg-8 col-xl-9">
205
206 <ng-container formGroupName="videoEditor">
207 <div class="form-group" [ngClass]="getTranscodingDisabledClass()">
208 <my-peertube-checkbox
209 inputName="videoEditorEnabled" formControlName="enabled"
210 i18n-labelText labelText="Enable video editor"
211 >
212 <ng-container ngProjectAs="description" *ngIf="!isTranscodingEnabled()">
213 <span i18n>⚠️ You need to enable transcoding first to enable video editor</span>
214 </ng-container>
215 </my-peertube-checkbox>
216 </div>
217 </ng-container>
218 </div>
219 </div>
195</ng-container> 220</ng-container>
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-vod-transcoding.component.ts b/client/src/app/+admin/config/edit-custom-config/edit-vod-transcoding.component.ts
index 3397c3dbd..948c10b69 100644
--- a/client/src/app/+admin/config/edit-custom-config/edit-vod-transcoding.component.ts
+++ b/client/src/app/+admin/config/edit-custom-config/edit-vod-transcoding.component.ts
@@ -71,6 +71,8 @@ export class EditVODTranscodingComponent implements OnInit, OnChanges {
71 } 71 }
72 72
73 private checkTranscodingFields () { 73 private checkTranscodingFields () {
74 const transcodingControl = this.form.get('transcoding.enabled')
75 const videoEditorControl = this.form.get('videoEditor.enabled')
74 const hlsControl = this.form.get('transcoding.hls.enabled') 76 const hlsControl = this.form.get('transcoding.hls.enabled')
75 const webtorrentControl = this.form.get('transcoding.webtorrent.enabled') 77 const webtorrentControl = this.form.get('transcoding.webtorrent.enabled')
76 78
@@ -95,5 +97,12 @@ export class EditVODTranscodingComponent implements OnInit, OnChanges {
95 webtorrentControl.enable() 97 webtorrentControl.enable()
96 } 98 }
97 }) 99 })
100
101 transcodingControl.valueChanges
102 .subscribe(newValue => {
103 if (newValue === false) {
104 videoEditorControl.setValue(false)
105 }
106 })
98 } 107 }
99} 108}
diff --git a/client/src/app/+admin/moderation/instance-blocklist/instance-account-blocklist.component.html b/client/src/app/+admin/moderation/instance-blocklist/instance-account-blocklist.component.html
deleted file mode 100644
index feade0c26..000000000
--- a/client/src/app/+admin/moderation/instance-blocklist/instance-account-blocklist.component.html
+++ /dev/null
@@ -1,56 +0,0 @@
1<p-table
2 [value]="blockedAccounts" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" [rowsPerPageOptions]="rowsPerPageOptions"
3 [sortField]="sort.field" [sortOrder]="sort.order"
4 [lazy]="true" (onLazyLoad)="loadLazy($event)" [lazyLoadOnInit]="false"
5 [showCurrentPageReport]="true" i18n-currentPageReportTemplate
6 currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} muted accounts"
7>
8 <ng-template pTemplate="caption">
9 <div class="caption">
10 <div class="ml-auto">
11 <my-advanced-input-filter (search)="onSearch($event)"></my-advanced-input-filter>
12 </div>
13 </div>
14 </ng-template>
15
16 <ng-template pTemplate="header">
17 <tr>
18 <th style="width: 150px;">Action</th> <!-- column for action buttons -->
19 <th style="width: calc(100% - 300px);" i18n>Account</th>
20 <th style="width: 150px;" i18n pSortableColumn="createdAt">Muted at <p-sortIcon field="createdAt"></p-sortIcon></th>
21 </tr>
22 </ng-template>
23
24 <ng-template pTemplate="body" let-accountBlock>
25 <tr>
26 <td class="action-cell">
27 <button class="unblock-button" (click)="unblockAccount(accountBlock)" i18n>Unmute</button>
28 </td>
29
30 <td>
31 <a [href]="accountBlock.blockedAccount.url" i18n-title title="Open account in a new tab" target="_blank" rel="noopener noreferrer">
32 <div class="chip two-lines">
33 <my-actor-avatar [account]="accountBlock.blockedAccount"></my-actor-avatar>
34 <div>
35 {{ accountBlock.blockedAccount.displayName }}
36 <span class="text-muted">{{ accountBlock.blockedAccount.nameWithHost }}</span>
37 </div>
38 </div>
39 </a>
40 </td>
41
42 <td>{{ accountBlock.createdAt | date: 'short' }}</td>
43 </tr>
44 </ng-template>
45
46 <ng-template pTemplate="emptymessage">
47 <tr>
48 <td colspan="6">
49 <div class="no-results">
50 <ng-container *ngIf="search" i18n>No account found matching current filters.</ng-container>
51 <ng-container *ngIf="!search" i18n>No account found.</ng-container>
52 </div>
53 </td>
54 </tr>
55 </ng-template>
56</p-table>
diff --git a/client/src/app/+admin/moderation/moderation.routes.ts b/client/src/app/+admin/moderation/moderation.routes.ts
index 5c39ff366..1ad301039 100644
--- a/client/src/app/+admin/moderation/moderation.routes.ts
+++ b/client/src/app/+admin/moderation/moderation.routes.ts
@@ -2,7 +2,6 @@ import { Routes } from '@angular/router'
2import { AbuseListComponent } from '@app/+admin/moderation/abuse-list' 2import { AbuseListComponent } from '@app/+admin/moderation/abuse-list'
3import { InstanceAccountBlocklistComponent, InstanceServerBlocklistComponent } from '@app/+admin/moderation/instance-blocklist' 3import { InstanceAccountBlocklistComponent, InstanceServerBlocklistComponent } from '@app/+admin/moderation/instance-blocklist'
4import { VideoBlockListComponent } from '@app/+admin/moderation/video-block-list' 4import { VideoBlockListComponent } from '@app/+admin/moderation/video-block-list'
5import { VideoCommentListComponent } from './video-comment-list'
6import { UserRightGuard } from '@app/core' 5import { UserRightGuard } from '@app/core'
7import { UserRight } from '@shared/models' 6import { UserRight } from '@shared/models'
8 7
@@ -69,6 +68,7 @@ export const ModerationRoutes: Routes = [
69 } 68 }
70 }, 69 },
71 70
71 // We move this component in admin overview pages
72 { 72 {
73 path: 'video-comments', 73 path: 'video-comments',
74 redirectTo: 'video-comments/list', 74 redirectTo: 'video-comments/list',
@@ -76,14 +76,8 @@ export const ModerationRoutes: Routes = [
76 }, 76 },
77 { 77 {
78 path: 'video-comments/list', 78 path: 'video-comments/list',
79 component: VideoCommentListComponent, 79 redirectTo: '/admin/comments/list',
80 canActivate: [ UserRightGuard ], 80 pathMatch: 'full'
81 data: {
82 userRight: UserRight.SEE_ALL_COMMENTS,
83 meta: {
84 title: $localize`Video comments`
85 }
86 }
87 }, 81 },
88 82
89 { 83 {
diff --git a/client/src/app/+admin/moderation/video-comment-list/index.ts b/client/src/app/+admin/overview/comments/index.ts
index eb08b4177..c487f7a81 100644
--- a/client/src/app/+admin/moderation/video-comment-list/index.ts
+++ b/client/src/app/+admin/overview/comments/index.ts
@@ -1 +1,2 @@
1export * from './video-comment-list.component' 1export * from './video-comment-list.component'
2export * from './video-comment.routes'
diff --git a/client/src/app/+admin/moderation/video-comment-list/video-comment-list.component.html b/client/src/app/+admin/overview/comments/video-comment-list.component.html
index 9bf23c21a..27a5d82ff 100644
--- a/client/src/app/+admin/moderation/video-comment-list/video-comment-list.component.html
+++ b/client/src/app/+admin/overview/comments/video-comment-list.component.html
@@ -25,8 +25,10 @@
25 </my-action-dropdown> 25 </my-action-dropdown>
26 </div> 26 </div>
27 27
28 <div class="ml-auto"> 28 <div class="ml-auto right-form">
29 <my-advanced-input-filter [filters]="inputFilters" (search)="onSearch($event)"></my-advanced-input-filter> 29 <my-advanced-input-filter [filters]="inputFilters" (search)="onSearch($event)"></my-advanced-input-filter>
30
31 <my-button i18n-label label="Refresh" icon="refresh" (click)="reloadData()"></my-button>
30 </div> 32 </div>
31 </div> 33 </div>
32 </ng-template> 34 </ng-template>
@@ -66,7 +68,7 @@
66 <td> 68 <td>
67 <a [href]="videoComment.account.localUrl" i18n-title title="Open account in a new tab" target="_blank" rel="noopener noreferrer"> 69 <a [href]="videoComment.account.localUrl" i18n-title title="Open account in a new tab" target="_blank" rel="noopener noreferrer">
68 <div class="chip two-lines"> 70 <div class="chip two-lines">
69 <my-actor-avatar [account]="videoComment.account"></my-actor-avatar> 71 <my-actor-avatar [account]="videoComment.account" size="32"></my-actor-avatar>
70 <div> 72 <div>
71 {{ videoComment.account.displayName }} 73 {{ videoComment.account.displayName }}
72 <span>{{ videoComment.by }}</span> 74 <span>{{ videoComment.by }}</span>
diff --git a/client/src/app/+admin/moderation/video-comment-list/video-comment-list.component.scss b/client/src/app/+admin/overview/comments/video-comment-list.component.scss
index 3cf7b8db6..d7eb02142 100644
--- a/client/src/app/+admin/moderation/video-comment-list/video-comment-list.component.scss
+++ b/client/src/app/+admin/overview/comments/video-comment-list.component.scss
@@ -45,6 +45,14 @@ my-global-icon {
45 } 45 }
46} 46}
47 47
48.right-form {
49 display: flex;
50
51 > *:not(:last-child) {
52 @include margin-right(10px);
53 }
54}
55
48@media screen and (max-width: $primeng-breakpoint) { 56@media screen and (max-width: $primeng-breakpoint) {
49 .video { 57 .video {
50 align-items: flex-start !important; 58 align-items: flex-start !important;
diff --git a/client/src/app/+admin/moderation/video-comment-list/video-comment-list.component.ts b/client/src/app/+admin/overview/comments/video-comment-list.component.ts
index 25fe65133..f3f43a900 100644
--- a/client/src/app/+admin/moderation/video-comment-list/video-comment-list.component.ts
+++ b/client/src/app/+admin/overview/comments/video-comment-list.component.ts
@@ -117,7 +117,7 @@ export class VideoCommentListComponent extends RestTable implements OnInit {
117 return this.selectedComments.length !== 0 117 return this.selectedComments.length !== 0
118 } 118 }
119 119
120 protected reloadData () { 120 reloadData () {
121 this.videoCommentService.getAdminVideoComments({ 121 this.videoCommentService.getAdminVideoComments({
122 pagination: this.pagination, 122 pagination: this.pagination,
123 sort: this.sort, 123 sort: this.sort,
diff --git a/client/src/app/+admin/overview/comments/video-comment.routes.ts b/client/src/app/+admin/overview/comments/video-comment.routes.ts
new file mode 100644
index 000000000..f0bd440ad
--- /dev/null
+++ b/client/src/app/+admin/overview/comments/video-comment.routes.ts
@@ -0,0 +1,30 @@
1import { Routes } from '@angular/router'
2import { UserRightGuard } from '@app/core'
3import { UserRight } from '@shared/models'
4import { VideoCommentListComponent } from './video-comment-list.component'
5
6export const commentRoutes: Routes = [
7 {
8 path: 'comments',
9 canActivate: [ UserRightGuard ],
10 data: {
11 userRight: UserRight.SEE_ALL_COMMENTS
12 },
13 children: [
14 {
15 path: '',
16 redirectTo: 'list',
17 pathMatch: 'full'
18 },
19 {
20 path: 'list',
21 component: VideoCommentListComponent,
22 data: {
23 meta: {
24 title: $localize`Comments list`
25 }
26 }
27 }
28 ]
29 }
30]
diff --git a/client/src/app/+admin/overview/index.ts b/client/src/app/+admin/overview/index.ts
index a9c46893f..111360734 100644
--- a/client/src/app/+admin/overview/index.ts
+++ b/client/src/app/+admin/overview/index.ts
@@ -1,3 +1,4 @@
1export * from './comments'
1export * from './users' 2export * from './users'
2export * from './videos' 3export * from './videos'
3export * from './overview.routes' 4export * from './overview.routes'
diff --git a/client/src/app/+admin/overview/overview.routes.ts b/client/src/app/+admin/overview/overview.routes.ts
index 1e6686d16..72d6835d7 100644
--- a/client/src/app/+admin/overview/overview.routes.ts
+++ b/client/src/app/+admin/overview/overview.routes.ts
@@ -1,8 +1,10 @@
1import { Routes } from '@angular/router' 1import { Routes } from '@angular/router'
2import { UsersRoutes } from './users' 2import { commentRoutes } from './comments'
3import { VideosRoutes } from './videos' 3import { usersRoutes } from './users'
4import { videosRoutes } from './videos'
4 5
5export const OverviewRoutes: Routes = [ 6export const OverviewRoutes: Routes = [
6 ...UsersRoutes, 7 ...commentRoutes,
7 ...VideosRoutes 8 ...usersRoutes,
9 ...videosRoutes
8] 10]
diff --git a/client/src/app/+admin/overview/users/user-list/user-list.component.html b/client/src/app/+admin/overview/users/user-list/user-list.component.html
index 7eb89fea1..0662e3ac3 100644
--- a/client/src/app/+admin/overview/users/user-list/user-list.component.html
+++ b/client/src/app/+admin/overview/users/user-list/user-list.component.html
@@ -62,10 +62,10 @@
62 </div> 62 </div>
63 </th> 63 </th>
64 <th *ngIf="isSelected('username')" pResizableColumn pSortableColumn="username">{{ getColumn('username').label }} <p-sortIcon field="username"></p-sortIcon></th> 64 <th *ngIf="isSelected('username')" pResizableColumn pSortableColumn="username">{{ getColumn('username').label }} <p-sortIcon field="username"></p-sortIcon></th>
65 <th *ngIf="isSelected('role')" style="width: 120px;" pSortableColumn="role">{{ getColumn('role').label }} <p-sortIcon field="role"></p-sortIcon></th>
65 <th *ngIf="isSelected('email')">{{ getColumn('email').label }}</th> 66 <th *ngIf="isSelected('email')">{{ getColumn('email').label }}</th>
66 <th *ngIf="isSelected('quota')" style="width: 160px;" pSortableColumn="videoQuotaUsed">{{ getColumn('quota').label }} <p-sortIcon field="videoQuotaUsed"></p-sortIcon></th> 67 <th *ngIf="isSelected('quota')" style="width: 160px;" pSortableColumn="videoQuotaUsed">{{ getColumn('quota').label }} <p-sortIcon field="videoQuotaUsed"></p-sortIcon></th>
67 <th *ngIf="isSelected('quotaDaily')" style="width: 160px;">{{ getColumn('quotaDaily').label }}</th> 68 <th *ngIf="isSelected('quotaDaily')" style="width: 160px;">{{ getColumn('quotaDaily').label }}</th>
68 <th *ngIf="isSelected('role')" style="width: 120px;" pSortableColumn="role">{{ getColumn('role').label }} <p-sortIcon field="role"></p-sortIcon></th>
69 <th *ngIf="isSelected('pluginAuth')" style="width: 140px;" pResizableColumn >{{ getColumn('pluginAuth').label }}</th> 69 <th *ngIf="isSelected('pluginAuth')" style="width: 140px;" pResizableColumn >{{ getColumn('pluginAuth').label }}</th>
70 <th *ngIf="isSelected('createdAt')" style="width: 150px;" pSortableColumn="createdAt">{{ getColumn('createdAt').label }} <p-sortIcon field="createdAt"></p-sortIcon></th> 70 <th *ngIf="isSelected('createdAt')" style="width: 150px;" pSortableColumn="createdAt">{{ getColumn('createdAt').label }} <p-sortIcon field="createdAt"></p-sortIcon></th>
71 <th *ngIf="isSelected('lastLoginDate')" style="width: 150px;" pSortableColumn="lastLoginDate">{{ getColumn('lastLoginDate').label }} <p-sortIcon field="lastLoginDate"></p-sortIcon></th> 71 <th *ngIf="isSelected('lastLoginDate')" style="width: 150px;" pSortableColumn="lastLoginDate">{{ getColumn('lastLoginDate').label }} <p-sortIcon field="lastLoginDate"></p-sortIcon></th>
@@ -84,8 +84,9 @@
84 </td> 84 </td>
85 85
86 <td class="action-cell"> 86 <td class="action-cell">
87 <my-user-moderation-dropdown *ngIf="!isInSelectionMode()" [user]="user" container="body" 87 <my-user-moderation-dropdown
88 (userChanged)="onUserChanged()" (userDeleted)="onUserChanged()"> 88 *ngIf="!isInSelectionMode()" [user]="user" [account]="user.accountMutedStatus" [displayOptions]="userModerationDisplayOptions"
89 container="body" (userChanged)="onUserChanged()" (userDeleted)="onUserChanged()">
89 </my-user-moderation-dropdown> 90 </my-user-moderation-dropdown>
90 </td> 91 </td>
91 92
@@ -99,6 +100,14 @@
99 </div> 100 </div>
100 </div> 101 </div>
101 </a> 102 </a>
103
104 <div *ngIf="user.accountMutedStatus.mutedByInstance" class="badges-username badge badge-red" i18n>Muted</div>
105 <div *ngIf="user.blocked" class="badges-username badge badge-red" i18n>Banned</div>
106 </td>
107
108 <td *ngIf="isSelected('role')">
109 <span *ngIf="user.blocked" class="badge badge-banned" i18n-title title="The user was banned">{{ user.roleLabel }}</span>
110 <span *ngIf="!user.blocked" class="badge" [ngClass]="getRoleClass(user.role)">{{ user.roleLabel }}</span>
102 </td> 111 </td>
103 112
104 <td *ngIf="isSelected('email')" [title]="user.email"> 113 <td *ngIf="isSelected('email')" [title]="user.email">
@@ -138,11 +147,6 @@
138 </div> 147 </div>
139 </td> 148 </td>
140 149
141 <td *ngIf="isSelected('role')">
142 <span *ngIf="user.blocked" class="badge badge-banned" i18n-title title="The user was banned">{{ user.roleLabel }}</span>
143 <span *ngIf="!user.blocked" class="badge" [ngClass]="getRoleClass(user.role)">{{ user.roleLabel }}</span>
144 </td>
145
146 <td *ngIf="isSelected('pluginAuth')"> 150 <td *ngIf="isSelected('pluginAuth')">
147 <ng-container *ngIf="user.pluginAuth">{{ user.pluginAuth }}</ng-container> 151 <ng-container *ngIf="user.pluginAuth">{{ user.pluginAuth }}</ng-container>
148 </td> 152 </td>
diff --git a/client/src/app/+admin/overview/users/user-list/user-list.component.scss b/client/src/app/+admin/overview/users/user-list/user-list.component.scss
index 335bd2995..8160703f0 100644
--- a/client/src/app/+admin/overview/users/user-list/user-list.component.scss
+++ b/client/src/app/+admin/overview/users/user-list/user-list.component.scss
@@ -23,6 +23,10 @@ tr.banned > td {
23 font-weight: $font-semibold; 23 font-weight: $font-semibold;
24} 24}
25 25
26.badges-username {
27 margin-left: 15px;
28}
29
26.user-table-primary-text .glyphicon { 30.user-table-primary-text .glyphicon {
27 @include margin-left(0.1rem); 31 @include margin-left(0.1rem);
28 32
diff --git a/client/src/app/+admin/overview/users/user-list/user-list.component.ts b/client/src/app/+admin/overview/users/user-list/user-list.component.ts
index 9a9d0f5c6..d22e1355e 100644
--- a/client/src/app/+admin/overview/users/user-list/user-list.component.ts
+++ b/client/src/app/+admin/overview/users/user-list/user-list.component.ts
@@ -2,9 +2,10 @@ import { SortMeta } from 'primeng/api'
2import { Component, OnInit, ViewChild } from '@angular/core' 2import { Component, OnInit, ViewChild } from '@angular/core'
3import { ActivatedRoute, Router } from '@angular/router' 3import { ActivatedRoute, Router } from '@angular/router'
4import { AuthService, ConfirmService, Notifier, RestPagination, RestTable, ServerService } from '@app/core' 4import { AuthService, ConfirmService, Notifier, RestPagination, RestTable, ServerService } from '@app/core'
5import { getAPIHost } from '@app/helpers'
5import { AdvancedInputFilter } from '@app/shared/shared-forms' 6import { AdvancedInputFilter } from '@app/shared/shared-forms'
6import { DropdownAction } from '@app/shared/shared-main' 7import { Actor, DropdownAction } from '@app/shared/shared-main'
7import { UserBanModalComponent } from '@app/shared/shared-moderation' 8import { AccountMutedStatus, BlocklistService, UserBanModalComponent, UserModerationDisplayType } from '@app/shared/shared-moderation'
8import { UserAdminService } from '@app/shared/shared-users' 9import { UserAdminService } from '@app/shared/shared-users'
9import { User, UserRole } from '@shared/models' 10import { User, UserRole } from '@shared/models'
10 11
@@ -23,7 +24,7 @@ type UserForList = User & {
23export class UserListComponent extends RestTable implements OnInit { 24export class UserListComponent extends RestTable implements OnInit {
24 @ViewChild('userBanModal', { static: true }) userBanModal: UserBanModalComponent 25 @ViewChild('userBanModal', { static: true }) userBanModal: UserBanModalComponent
25 26
26 users: User[] = [] 27 users: (User & { accountMutedStatus: AccountMutedStatus })[] = []
27 28
28 totalRecords = 0 29 totalRecords = 0
29 sort: SortMeta = { field: 'createdAt', order: 1 } 30 sort: SortMeta = { field: 'createdAt', order: 1 }
@@ -47,6 +48,12 @@ export class UserListComponent extends RestTable implements OnInit {
47 } 48 }
48 ] 49 ]
49 50
51 userModerationDisplayOptions: UserModerationDisplayType = {
52 instanceAccount: true,
53 instanceUser: true,
54 myAccount: false
55 }
56
50 requiresEmailVerification = false 57 requiresEmailVerification = false
51 58
52 private _selectedColumns: string[] 59 private _selectedColumns: string[]
@@ -58,6 +65,7 @@ export class UserListComponent extends RestTable implements OnInit {
58 private confirmService: ConfirmService, 65 private confirmService: ConfirmService,
59 private serverService: ServerService, 66 private serverService: ServerService,
60 private auth: AuthService, 67 private auth: AuthService,
68 private blocklist: BlocklistService,
61 private userAdminService: UserAdminService 69 private userAdminService: UserAdminService
62 ) { 70 ) {
63 super() 71 super()
@@ -115,9 +123,9 @@ export class UserListComponent extends RestTable implements OnInit {
115 123
116 this.columns = [ 124 this.columns = [
117 { id: 'username', label: $localize`Username` }, 125 { id: 'username', label: $localize`Username` },
126 { id: 'role', label: $localize`Role` },
118 { id: 'email', label: $localize`Email` }, 127 { id: 'email', label: $localize`Email` },
119 { id: 'quota', label: $localize`Video quota` }, 128 { id: 'quota', label: $localize`Video quota` },
120 { id: 'role', label: $localize`Role` },
121 { id: 'createdAt', label: $localize`Created` } 129 { id: 'createdAt', label: $localize`Created` }
122 ] 130 ]
123 131
@@ -237,11 +245,35 @@ export class UserListComponent extends RestTable implements OnInit {
237 search: this.search 245 search: this.search
238 }).subscribe({ 246 }).subscribe({
239 next: resultList => { 247 next: resultList => {
240 this.users = resultList.data 248 this.users = resultList.data.map(u => ({
249 ...u,
250
251 accountMutedStatus: {
252 ...u.account,
253
254 nameWithHost: Actor.CREATE_BY_STRING(u.account.name, u.account.host),
255
256 mutedByInstance: false,
257 mutedByUser: false,
258 mutedServerByInstance: false,
259 mutedServerByUser: false
260 }
261 }))
241 this.totalRecords = resultList.total 262 this.totalRecords = resultList.total
263
264 this.loadMutedStatus()
242 }, 265 },
243 266
244 error: err => this.notifier.error(err.message) 267 error: err => this.notifier.error(err.message)
245 }) 268 })
246 } 269 }
270
271 private loadMutedStatus () {
272 this.blocklist.getStatus({ accounts: this.users.map(u => u.username + '@' + getAPIHost()) })
273 .subscribe(blockStatus => {
274 for (const user of this.users) {
275 user.accountMutedStatus.mutedByInstance = blockStatus.accounts[user.username + '@' + getAPIHost()].blockedByServer
276 }
277 })
278 }
247} 279}
diff --git a/client/src/app/+admin/overview/users/users.routes.ts b/client/src/app/+admin/overview/users/users.routes.ts
index 8b63f5bc7..c9724e5fb 100644
--- a/client/src/app/+admin/overview/users/users.routes.ts
+++ b/client/src/app/+admin/overview/users/users.routes.ts
@@ -4,7 +4,7 @@ import { UserRight } from '@shared/models'
4import { UserCreateComponent, UserUpdateComponent } from './user-edit' 4import { UserCreateComponent, UserUpdateComponent } from './user-edit'
5import { UserListComponent } from './user-list' 5import { UserListComponent } from './user-list'
6 6
7export const UsersRoutes: Routes = [ 7export const usersRoutes: Routes = [
8 { 8 {
9 path: 'users', 9 path: 'users',
10 canActivate: [ UserRightGuard ], 10 canActivate: [ UserRightGuard ],
diff --git a/client/src/app/+admin/overview/videos/video-list.component.html b/client/src/app/+admin/overview/videos/video-list.component.html
index 974912b32..75d9be5f1 100644
--- a/client/src/app/+admin/overview/videos/video-list.component.html
+++ b/client/src/app/+admin/overview/videos/video-list.component.html
@@ -24,9 +24,7 @@
24 <div class="ml-auto right-form"> 24 <div class="ml-auto right-form">
25 <my-advanced-input-filter [filters]="inputFilters" (search)="onSearch($event)"></my-advanced-input-filter> 25 <my-advanced-input-filter [filters]="inputFilters" (search)="onSearch($event)"></my-advanced-input-filter>
26 26
27 <div class="button-filter-block"> 27 <my-button i18n-label label="Refresh" icon="refresh" (click)="reloadData()"></my-button>
28 <my-button i18n-label label="Refresh" icon="refresh" (click)="reloadData()"></my-button>
29 </div>
30 </div> 28 </div>
31 29
32 </div> 30 </div>
diff --git a/client/src/app/+admin/overview/videos/video-list.component.scss b/client/src/app/+admin/overview/videos/video-list.component.scss
index 543cb433c..cb47b6548 100644
--- a/client/src/app/+admin/overview/videos/video-list.component.scss
+++ b/client/src/app/+admin/overview/videos/video-list.component.scss
@@ -1,5 +1,6 @@
1@use '_variables' as *; 1@use '_variables' as *;
2@use '_mixins' as *; 2@use '_mixins' as *;
3
3my-embed { 4my-embed {
4 display: block; 5 display: block;
5 max-width: 500px; 6 max-width: 500px;
@@ -27,4 +28,3 @@ my-embed {
27 @include margin-right(10px); 28 @include margin-right(10px);
28 } 29 }
29} 30}
30
diff --git a/client/src/app/+admin/overview/videos/video.routes.ts b/client/src/app/+admin/overview/videos/video.routes.ts
index 984df7b82..01cb5b497 100644
--- a/client/src/app/+admin/overview/videos/video.routes.ts
+++ b/client/src/app/+admin/overview/videos/video.routes.ts
@@ -3,7 +3,7 @@ import { UserRightGuard } from '@app/core'
3import { UserRight } from '@shared/models' 3import { UserRight } from '@shared/models'
4import { VideoListComponent } from './video-list.component' 4import { VideoListComponent } from './video-list.component'
5 5
6export const VideosRoutes: Routes = [ 6export const videosRoutes: Routes = [
7 { 7 {
8 path: 'videos', 8 path: 'videos',
9 canActivate: [ UserRightGuard ], 9 canActivate: [ UserRightGuard ],
diff --git a/client/src/app/+manage/video-channel-edit/video-channel-update.component.ts b/client/src/app/+manage/video-channel-edit/video-channel-update.component.ts
index 21b6167b2..51cd45605 100644
--- a/client/src/app/+manage/video-channel-edit/video-channel-update.component.ts
+++ b/client/src/app/+manage/video-channel-edit/video-channel-update.component.ts
@@ -111,7 +111,7 @@ export class VideoChannelUpdateComponent extends VideoChannelEdit implements OnI
111 next: data => { 111 next: data => {
112 this.notifier.success($localize`Avatar changed.`) 112 this.notifier.success($localize`Avatar changed.`)
113 113
114 this.videoChannel.updateAvatar(data.avatar) 114 this.videoChannel.updateAvatar(data.avatars)
115 }, 115 },
116 116
117 error: (err: HttpErrorResponse) => genericUploadErrorHandler({ 117 error: (err: HttpErrorResponse) => genericUploadErrorHandler({
@@ -141,7 +141,7 @@ export class VideoChannelUpdateComponent extends VideoChannelEdit implements OnI
141 next: data => { 141 next: data => {
142 this.notifier.success($localize`Banner changed.`) 142 this.notifier.success($localize`Banner changed.`)
143 143
144 this.videoChannel.updateBanner(data.banner) 144 this.videoChannel.updateBanner(data.banners)
145 }, 145 },
146 146
147 error: (err: HttpErrorResponse) => genericUploadErrorHandler({ 147 error: (err: HttpErrorResponse) => genericUploadErrorHandler({
diff --git a/client/src/app/+my-account/my-account-settings/my-account-settings.component.ts b/client/src/app/+my-account/my-account-settings/my-account-settings.component.ts
index a5bcb6496..577f4a252 100644
--- a/client/src/app/+my-account/my-account-settings/my-account-settings.component.ts
+++ b/client/src/app/+my-account/my-account-settings/my-account-settings.component.ts
@@ -43,7 +43,7 @@ export class MyAccountSettingsComponent implements OnInit, AfterViewChecked {
43 next: data => { 43 next: data => {
44 this.notifier.success($localize`Avatar changed.`) 44 this.notifier.success($localize`Avatar changed.`)
45 45
46 this.user.updateAccountAvatar(data.avatar) 46 this.user.updateAccountAvatar(data.avatars)
47 }, 47 },
48 48
49 error: (err: HttpErrorResponse) => genericUploadErrorHandler({ 49 error: (err: HttpErrorResponse) => genericUploadErrorHandler({
diff --git a/client/src/app/+my-library/+my-video-channels/my-video-channels.component.html b/client/src/app/+my-library/+my-video-channels/my-video-channels.component.html
index 77947315b..c1ded0f6d 100644
--- a/client/src/app/+my-library/+my-video-channels/my-video-channels.component.html
+++ b/client/src/app/+my-library/+my-video-channels/my-video-channels.component.html
@@ -19,7 +19,7 @@
19 19
20<div class="video-channels"> 20<div class="video-channels">
21 <div *ngFor="let videoChannel of videoChannels; let i = index" class="video-channel"> 21 <div *ngFor="let videoChannel of videoChannels; let i = index" class="video-channel">
22 <my-actor-avatar [channel]="videoChannel" [internalHref]="[ '/c', videoChannel.nameWithHost ]"></my-actor-avatar> 22 <my-actor-avatar [channel]="videoChannel" [internalHref]="[ '/c', videoChannel.nameWithHost ]" size="80"></my-actor-avatar>
23 23
24 <div class="video-channel-info"> 24 <div class="video-channel-info">
25 <a [routerLink]="[ '/c', videoChannel.nameWithHost ]" class="video-channel-names" i18n-title title="Channel page"> 25 <a [routerLink]="[ '/c', videoChannel.nameWithHost ]" class="video-channel-names" i18n-title title="Channel page">
diff --git a/client/src/app/+my-library/+my-video-channels/my-video-channels.component.scss b/client/src/app/+my-library/+my-video-channels/my-video-channels.component.scss
index 998e46cb2..484355967 100644
--- a/client/src/app/+my-library/+my-video-channels/my-video-channels.component.scss
+++ b/client/src/app/+my-library/+my-video-channels/my-video-channels.component.scss
@@ -24,7 +24,6 @@ my-edit-button {
24 padding-bottom: 0; 24 padding-bottom: 0;
25 25
26 my-actor-avatar { 26 my-actor-avatar {
27 @include actor-avatar-size(80px);
28 @include margin-right(10px); 27 @include margin-right(10px);
29 } 28 }
30} 29}
diff --git a/client/src/app/+my-library/my-follows/my-followers.component.html b/client/src/app/+my-library/my-follows/my-followers.component.html
index eac750c86..4303695a3 100644
--- a/client/src/app/+my-library/my-follows/my-followers.component.html
+++ b/client/src/app/+my-library/my-follows/my-followers.component.html
@@ -14,7 +14,7 @@
14 14
15<div class="actors" myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()"> 15<div class="actors" myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()">
16 <div *ngFor="let follow of follows" class="actor"> 16 <div *ngFor="let follow of follows" class="actor">
17 <my-actor-avatar [account]="follow.follower" [href]="follow.follower.url"></my-actor-avatar> 17 <my-actor-avatar [account]="follow.follower" [href]="follow.follower.url" size="40"></my-actor-avatar>
18 18
19 <div class="actor-info"> 19 <div class="actor-info">
20 <a [href]="follow.follower.url" class="actor-names" rel="noopener noreferrer" target="_blank" i18n-title title="Follower page"> 20 <a [href]="follow.follower.url" class="actor-names" rel="noopener noreferrer" target="_blank" i18n-title title="Follower page">
diff --git a/client/src/app/+my-library/my-follows/my-followers.component.scss b/client/src/app/+my-library/my-follows/my-followers.component.scss
index 15b51c419..fae4cd972 100644
--- a/client/src/app/+my-library/my-follows/my-followers.component.scss
+++ b/client/src/app/+my-library/my-follows/my-followers.component.scss
@@ -12,7 +12,7 @@ input[type=text] {
12} 12}
13 13
14.actor { 14.actor {
15 @include actor-row($avatar-size: 40px, $min-height: auto, $separator: true); 15 @include actor-row($min-height: auto, $separator: true);
16 16
17 .actor-display-name { 17 .actor-display-name {
18 font-size: 16px; 18 font-size: 16px;
diff --git a/client/src/app/+my-library/my-follows/my-subscriptions.component.html b/client/src/app/+my-library/my-follows/my-subscriptions.component.html
index 775f0e783..391c4d3be 100644
--- a/client/src/app/+my-library/my-follows/my-subscriptions.component.html
+++ b/client/src/app/+my-library/my-follows/my-subscriptions.component.html
@@ -14,7 +14,7 @@
14 14
15<div class="actors" myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()"> 15<div class="actors" myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()">
16 <div *ngFor="let videoChannel of videoChannels" class="actor"> 16 <div *ngFor="let videoChannel of videoChannels" class="actor">
17 <my-actor-avatar [channel]="videoChannel" [internalHref]="[ '/c', videoChannel.nameWithHost ]"></my-actor-avatar> 17 <my-actor-avatar [channel]="videoChannel" [internalHref]="[ '/c', videoChannel.nameWithHost ]" size="80"></my-actor-avatar>
18 18
19 <div class="actor-info"> 19 <div class="actor-info">
20 <a [routerLink]="[ '/c', videoChannel.nameWithHost ]" class="actor-names" i18n-title title="Channel page"> 20 <a [routerLink]="[ '/c', videoChannel.nameWithHost ]" class="actor-names" i18n-title title="Channel page">
diff --git a/client/src/app/+my-library/my-ownership/my-accept-ownership/my-accept-ownership.component.ts b/client/src/app/+my-library/my-ownership/my-accept-ownership/my-accept-ownership.component.ts
index 764da2369..8ead237c7 100644
--- a/client/src/app/+my-library/my-ownership/my-accept-ownership/my-accept-ownership.component.ts
+++ b/client/src/app/+my-library/my-ownership/my-accept-ownership/my-accept-ownership.component.ts
@@ -1,7 +1,7 @@
1import { SelectChannelItem } from 'src/types/select-options-item.model' 1import { SelectChannelItem } from 'src/types/select-options-item.model'
2import { Component, ElementRef, EventEmitter, OnInit, Output, ViewChild } from '@angular/core' 2import { Component, ElementRef, EventEmitter, OnInit, Output, ViewChild } from '@angular/core'
3import { AuthService, Notifier } from '@app/core' 3import { AuthService, Notifier } from '@app/core'
4import { listUserChannels } from '@app/helpers' 4import { listUserChannelsForSelect } from '@app/helpers'
5import { OWNERSHIP_CHANGE_CHANNEL_VALIDATOR } from '@app/shared/form-validators/video-ownership-change-validators' 5import { OWNERSHIP_CHANGE_CHANNEL_VALIDATOR } from '@app/shared/form-validators/video-ownership-change-validators'
6import { FormReactive, FormValidatorService } from '@app/shared/shared-forms' 6import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
7import { VideoOwnershipService } from '@app/shared/shared-main' 7import { VideoOwnershipService } from '@app/shared/shared-main'
@@ -36,7 +36,7 @@ export class MyAcceptOwnershipComponent extends FormReactive implements OnInit {
36 ngOnInit () { 36 ngOnInit () {
37 this.videoChannels = [] 37 this.videoChannels = []
38 38
39 listUserChannels(this.authService) 39 listUserChannelsForSelect(this.authService)
40 .subscribe(channels => this.videoChannels = channels) 40 .subscribe(channels => this.videoChannels = channels)
41 41
42 this.buildForm({ 42 this.buildForm({
diff --git a/client/src/app/+my-library/my-ownership/my-ownership.component.html b/client/src/app/+my-library/my-ownership/my-ownership.component.html
index 4c02c78fc..cb032505e 100644
--- a/client/src/app/+my-library/my-ownership/my-ownership.component.html
+++ b/client/src/app/+my-library/my-ownership/my-ownership.component.html
@@ -37,7 +37,7 @@
37 <td> 37 <td>
38 <a [href]="videoChangeOwnership.initiatorAccount.url" i18n-title title="Open account in a new tab" target="_blank" rel="noopener noreferrer"> 38 <a [href]="videoChangeOwnership.initiatorAccount.url" i18n-title title="Open account in a new tab" target="_blank" rel="noopener noreferrer">
39 <div class="chip two-lines"> 39 <div class="chip two-lines">
40 <my-actor-avatar [account]="videoChangeOwnership.initiatorAccount"></my-actor-avatar> 40 <my-actor-avatar [account]="videoChangeOwnership.initiatorAccount" size="32"></my-actor-avatar>
41 <div> 41 <div>
42 {{ videoChangeOwnership.initiatorAccount.displayName }} 42 {{ videoChangeOwnership.initiatorAccount.displayName }}
43 <span class="text-muted">{{ videoChangeOwnership.initiatorAccount.nameWithHost }}</span> 43 <span class="text-muted">{{ videoChangeOwnership.initiatorAccount.nameWithHost }}</span>
diff --git a/client/src/app/+my-library/my-video-playlists/my-video-playlist-create.component.ts b/client/src/app/+my-library/my-video-playlists/my-video-playlist-create.component.ts
index 8bc78b2db..9eb3e9888 100644
--- a/client/src/app/+my-library/my-video-playlists/my-video-playlist-create.component.ts
+++ b/client/src/app/+my-library/my-video-playlists/my-video-playlist-create.component.ts
@@ -1,7 +1,7 @@
1import { Component, OnInit } from '@angular/core' 1import { Component, OnInit } from '@angular/core'
2import { Router } from '@angular/router' 2import { Router } from '@angular/router'
3import { AuthService, Notifier, ServerService } from '@app/core' 3import { AuthService, Notifier, ServerService } from '@app/core'
4import { listUserChannels } from '@app/helpers' 4import { listUserChannelsForSelect } from '@app/helpers'
5import { 5import {
6 setPlaylistChannelValidator, 6 setPlaylistChannelValidator,
7 VIDEO_PLAYLIST_CHANNEL_ID_VALIDATOR, 7 VIDEO_PLAYLIST_CHANNEL_ID_VALIDATOR,
@@ -46,7 +46,7 @@ export class MyVideoPlaylistCreateComponent extends MyVideoPlaylistEdit implemen
46 setPlaylistChannelValidator(this.form.get('videoChannelId'), privacy) 46 setPlaylistChannelValidator(this.form.get('videoChannelId'), privacy)
47 }) 47 })
48 48
49 listUserChannels(this.authService) 49 listUserChannelsForSelect(this.authService)
50 .subscribe(channels => this.userVideoChannels = channels) 50 .subscribe(channels => this.userVideoChannels = channels)
51 51
52 this.serverService.getVideoPlaylistPrivacies() 52 this.serverService.getVideoPlaylistPrivacies()
diff --git a/client/src/app/+my-library/my-video-playlists/my-video-playlist-update.component.ts b/client/src/app/+my-library/my-video-playlists/my-video-playlist-update.component.ts
index 06ac3ad50..ef7ba0018 100644
--- a/client/src/app/+my-library/my-video-playlists/my-video-playlist-update.component.ts
+++ b/client/src/app/+my-library/my-video-playlists/my-video-playlist-update.component.ts
@@ -3,7 +3,7 @@ import { map, switchMap } from 'rxjs/operators'
3import { Component, OnDestroy, OnInit } from '@angular/core' 3import { Component, OnDestroy, OnInit } from '@angular/core'
4import { ActivatedRoute, Router } from '@angular/router' 4import { ActivatedRoute, Router } from '@angular/router'
5import { AuthService, Notifier, ServerService } from '@app/core' 5import { AuthService, Notifier, ServerService } from '@app/core'
6import { listUserChannels } from '@app/helpers' 6import { listUserChannelsForSelect } from '@app/helpers'
7import { 7import {
8 setPlaylistChannelValidator, 8 setPlaylistChannelValidator,
9 VIDEO_PLAYLIST_CHANNEL_ID_VALIDATOR, 9 VIDEO_PLAYLIST_CHANNEL_ID_VALIDATOR,
@@ -51,7 +51,7 @@ export class MyVideoPlaylistUpdateComponent extends MyVideoPlaylistEdit implemen
51 setPlaylistChannelValidator(this.form.get('videoChannelId'), privacy) 51 setPlaylistChannelValidator(this.form.get('videoChannelId'), privacy)
52 }) 52 })
53 53
54 listUserChannels(this.authService) 54 listUserChannelsForSelect(this.authService)
55 .subscribe(channels => this.userVideoChannels = channels) 55 .subscribe(channels => this.userVideoChannels = channels)
56 56
57 this.paramsSub = this.route.params 57 this.paramsSub = this.route.params
diff --git a/client/src/app/+my-library/my-videos/my-videos.component.ts b/client/src/app/+my-library/my-videos/my-videos.component.ts
index 261e87f99..c998b7c49 100644
--- a/client/src/app/+my-library/my-videos/my-videos.component.ts
+++ b/client/src/app/+my-library/my-videos/my-videos.component.ts
@@ -9,7 +9,7 @@ import { AdvancedInputFilter } from '@app/shared/shared-forms'
9import { DropdownAction, Video, VideoService } from '@app/shared/shared-main' 9import { DropdownAction, Video, VideoService } from '@app/shared/shared-main'
10import { LiveStreamInformationComponent } from '@app/shared/shared-video-live' 10import { LiveStreamInformationComponent } from '@app/shared/shared-video-live'
11import { MiniatureDisplayOptions, SelectionType, VideosSelectionComponent } from '@app/shared/shared-video-miniature' 11import { MiniatureDisplayOptions, SelectionType, VideosSelectionComponent } from '@app/shared/shared-video-miniature'
12import { VideoChannel, VideoSortField } from '@shared/models' 12import { VideoChannel, VideoSortField, VideoState } from '@shared/models'
13import { VideoChangeOwnershipComponent } from './modals/video-change-ownership.component' 13import { VideoChangeOwnershipComponent } from './modals/video-change-ownership.component'
14 14
15@Component({ 15@Component({
@@ -205,6 +205,12 @@ export class MyVideosComponent implements OnInit, DisableForReuseHook {
205 private buildActions () { 205 private buildActions () {
206 this.videoActions = [ 206 this.videoActions = [
207 { 207 {
208 label: $localize`Editor`,
209 linkBuilder: ({ video }) => [ '/video-editor/edit', video.uuid ],
210 isDisplayed: ({ video }) => video.state.id === VideoState.PUBLISHED,
211 iconName: 'film'
212 },
213 {
208 label: $localize`Display live information`, 214 label: $localize`Display live information`,
209 handler: ({ video }) => this.displayLiveInformation(video), 215 handler: ({ video }) => this.displayLiveInformation(video),
210 isDisplayed: ({ video }) => video.isLive, 216 isDisplayed: ({ video }) => video.isLive,
diff --git a/client/src/app/+search/search.component.html b/client/src/app/+search/search.component.html
index 412b962d1..2c84dd930 100644
--- a/client/src/app/+search/search.component.html
+++ b/client/src/app/+search/search.component.html
@@ -36,7 +36,7 @@
36 <ng-container *ngFor="let result of results"> 36 <ng-container *ngFor="let result of results">
37 <div *ngIf="isVideoChannel(result)" class="entry video-channel"> 37 <div *ngIf="isVideoChannel(result)" class="entry video-channel">
38 38
39 <my-actor-avatar [channel]="result" [internalHref]="getInternalChannelUrl(result)" [href]="getExternalChannelUrl(result)"></my-actor-avatar> 39 <my-actor-avatar [channel]="result" [internalHref]="getInternalChannelUrl(result)" [href]="getExternalChannelUrl(result)" size="120"></my-actor-avatar>
40 40
41 <div class="video-channel-info"> 41 <div class="video-channel-info">
42 <a *ngIf="!isExternalChannelUrl()" [routerLink]="getInternalChannelUrl(result)" class="video-channel-names"> 42 <a *ngIf="!isExternalChannelUrl()" [routerLink]="getInternalChannelUrl(result)" class="video-channel-names">
diff --git a/client/src/app/+search/search.component.scss b/client/src/app/+search/search.component.scss
index b521825e5..cab1d0e88 100644
--- a/client/src/app/+search/search.component.scss
+++ b/client/src/app/+search/search.component.scss
@@ -58,10 +58,6 @@
58 max-width: 800px; 58 max-width: 800px;
59} 59}
60 60
61.video-channel my-actor-avatar {
62 @include build-channel-img-size($video-thumbnail-width);
63}
64
65.video-channel-info { 61.video-channel-info {
66 flex-grow: 1; 62 flex-grow: 1;
67 margin: 0 10px; 63 margin: 0 10px;
diff --git a/client/src/app/+video-channels/video-channels.component.html b/client/src/app/+video-channels/video-channels.component.html
index 212e2f867..09f81d2ce 100644
--- a/client/src/app/+video-channels/video-channels.component.html
+++ b/client/src/app/+video-channels/video-channels.component.html
@@ -23,7 +23,7 @@
23 <div class="section-label" i18n>OWNER ACCOUNT</div> 23 <div class="section-label" i18n>OWNER ACCOUNT</div>
24 24
25 <div class="avatar-row"> 25 <div class="avatar-row">
26 <my-actor-avatar class="account-avatar" [account]="ownerAccount" [internalHref]="getAccountUrl()"></my-actor-avatar> 26 <my-actor-avatar class="account-avatar" [account]="ownerAccount" [internalHref]="getAccountUrl()" size="48"></my-actor-avatar>
27 27
28 <div class="actor-info"> 28 <div class="actor-info">
29 <h4> 29 <h4>
@@ -51,7 +51,7 @@
51 </ng-template> 51 </ng-template>
52 52
53 <div class="channel-avatar-row"> 53 <div class="channel-avatar-row">
54 <my-actor-avatar class="main-avatar" [channel]="videoChannel"></my-actor-avatar> 54 <my-actor-avatar class="main-avatar" [channel]="videoChannel" size="120"></my-actor-avatar>
55 55
56 <div> 56 <div>
57 <div class="section-label" i18n>VIDEO CHANNEL</div> 57 <div class="section-label" i18n>VIDEO CHANNEL</div>
diff --git a/client/src/app/+video-channels/video-channels.component.scss b/client/src/app/+video-channels/video-channels.component.scss
index 72ee2d7bb..004ad7998 100644
--- a/client/src/app/+video-channels/video-channels.component.scss
+++ b/client/src/app/+video-channels/video-channels.component.scss
@@ -107,10 +107,6 @@
107 display: flex; 107 display: flex;
108 margin-bottom: 15px; 108 margin-bottom: 15px;
109 109
110 .account-avatar {
111 @include actor-avatar-size(48px);
112 }
113
114 .actor-info { 110 .actor-info {
115 @include margin-left(15px); 111 @include margin-left(15px);
116 } 112 }
diff --git a/client/src/app/+video-editor/edit/index.ts b/client/src/app/+video-editor/edit/index.ts
new file mode 100644
index 000000000..390ca80fc
--- /dev/null
+++ b/client/src/app/+video-editor/edit/index.ts
@@ -0,0 +1,2 @@
1export * from './video-editor-edit.component'
2export * from './video-editor-edit.resolver'
diff --git a/client/src/app/+video-editor/edit/video-editor-edit.component.html b/client/src/app/+video-editor/edit/video-editor-edit.component.html
new file mode 100644
index 000000000..d33dfaf18
--- /dev/null
+++ b/client/src/app/+video-editor/edit/video-editor-edit.component.html
@@ -0,0 +1,88 @@
1<div class="margin-content">
2 <h1 class="title-page title-page-single" i18n>Edit {{ video.name }}</h1>
3
4 <div class="columns">
5 <form role="form" [formGroup]="form">
6
7 <div class="section cut" formGroupName="cut">
8 <h2 i18n>CUT VIDEO</h2>
9
10 <div i18n class="description">Set a new start/end.</div>
11
12 <div class="form-group">
13 <label i18n for="cutStart">New start</label>
14 <my-timestamp-input inputName="cutStart" [disableBorder]="false" [maxTimestamp]="video.duration" formControlName="start"></my-timestamp-input>
15 </div>
16
17 <div class="form-group">
18 <label i18n for="cutEnd">New end</label>
19 <my-timestamp-input inputName="cutEnd" [disableBorder]="false" [maxTimestamp]="video.duration" formControlName="end"></my-timestamp-input>
20 </div>
21 </div>
22
23 <div class="section" formGroupName="add-intro">
24 <h2 i18n>ADD INTRO</h2>
25
26 <div i18n class="description">Concatenate a file at the beginning of the video.</div>
27
28 <div class="form-group">
29 <my-reactive-file
30 formControlName="file" inputName="addIntroFile" i18n-inputLabel inputLabel="Select the intro video file"
31 [extensions]="videoExtensions" [displayFilename]="true"
32 [ngbTooltip]="getIntroOutroTooltip()"
33 ></my-reactive-file>
34 </div>
35 </div>
36
37 <div class="section" formGroupName="add-outro">
38 <h2 i18n>ADD OUTRO</h2>
39
40 <div i18n class="description">Concatenate a file at the end of the video.</div>
41
42 <div class="form-group">
43 <my-reactive-file
44 formControlName="file" inputName="addOutroFile" i18n-inputLabel inputLabel="Select the outro video file"
45 [extensions]="videoExtensions" [displayFilename]="true"
46 [ngbTooltip]="getIntroOutroTooltip()"
47 ></my-reactive-file>
48 </div>
49 </div>
50
51 <div class="section" formGroupName="add-watermark">
52 <h2 i18n>ADD WATERMARK</h2>
53
54 <div i18n class="description">Add a watermark image to the video.</div>
55
56 <div class="form-group">
57 <my-reactive-file
58 formControlName="file" inputName="addWatermarkFile" i18n-inputLabel inputLabel="Select watermark image file"
59 [extensions]="imageExtensions" [displayFilename]="true"
60 [ngbTooltip]="getWatermarkTooltip()"
61 ></my-reactive-file>
62 </div>
63 </div>
64
65 <my-button
66 className="orange-button" i18n-label label="Run video edition" icon="circle-tick"
67 (click)="runEdition()" (keydown.enter)="runEdition()"
68 [disabled]="!form.valid || isRunningEdition || noEdition()"
69 ></my-button>
70 </form>
71
72
73 <div class="information">
74 <div>
75 <label i18n>Video before edition</label>
76 <my-embed [video]="video"></my-embed>
77 </div>
78
79 <div *ngIf="!noEdition()">
80 <label i18n>Edition tasks:</label>
81
82 <ol>
83 <li *ngFor="let task of getTasksSummary()">{{ task }}</li>
84 </ol>
85 </div>
86 </div>
87 </div>
88</div>
diff --git a/client/src/app/+video-editor/edit/video-editor-edit.component.scss b/client/src/app/+video-editor/edit/video-editor-edit.component.scss
new file mode 100644
index 000000000..43f336f59
--- /dev/null
+++ b/client/src/app/+video-editor/edit/video-editor-edit.component.scss
@@ -0,0 +1,76 @@
1@use '_variables' as *;
2@use '_mixins' as *;
3
4.columns {
5 display: flex;
6
7 .information {
8 width: 100%;
9 margin-left: 50px;
10
11 > div {
12 margin-bottom: 30px;
13 }
14
15 @media screen and (max-width: $small-view) {
16 display: none;
17 }
18 }
19}
20
21h1 {
22 font-size: 20px;
23}
24
25h2 {
26 font-weight: $font-bold;
27 font-size: 16px;
28 color: pvar(--mainColor);
29 background-color: pvar(--mainBackgroundColor);
30 padding: 0 5px;
31 width: fit-content;
32 margin: -8px 0 0;
33}
34
35.section {
36 $min-width: 600px;
37
38 @include padding-left(10px);
39
40 min-width: $min-width;
41
42 margin-bottom: 50px;
43 border: 1px solid $separator-border-color;
44 border-radius: 5px;
45 width: fit-content;
46
47 .form-group,
48 .description {
49 @include margin-left(5px);
50 }
51
52 .description {
53 color: pvar(--greyForegroundColor);
54 margin-top: 5px;
55 margin-bottom: 15px;
56 }
57
58 @media screen and (max-width: $min-width) {
59 min-width: none;
60 }
61}
62
63my-timestamp-input {
64 display: block;
65}
66
67my-embed {
68 display: block;
69 max-width: 500px;
70 width: 100%;
71}
72
73my-reactive-file {
74 display: block;
75 width: fit-content;
76}
diff --git a/client/src/app/+video-editor/edit/video-editor-edit.component.ts b/client/src/app/+video-editor/edit/video-editor-edit.component.ts
new file mode 100644
index 000000000..93d7ffcec
--- /dev/null
+++ b/client/src/app/+video-editor/edit/video-editor-edit.component.ts
@@ -0,0 +1,202 @@
1import { Component, OnInit } from '@angular/core'
2import { ActivatedRoute, Router } from '@angular/router'
3import { ConfirmService, Notifier, ServerService } from '@app/core'
4import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
5import { Video, VideoDetails } from '@app/shared/shared-main'
6import { LoadingBarService } from '@ngx-loading-bar/core'
7import { secondsToTime } from '@shared/core-utils'
8import { VideoEditorTask, VideoEditorTaskCut } from '@shared/models'
9import { VideoEditorService } from '../shared'
10
11@Component({
12 selector: 'my-video-editor-edit',
13 templateUrl: './video-editor-edit.component.html',
14 styleUrls: [ './video-editor-edit.component.scss' ]
15})
16export class VideoEditorEditComponent extends FormReactive implements OnInit {
17 isRunningEdition = false
18
19 video: VideoDetails
20
21 constructor (
22 protected formValidatorService: FormValidatorService,
23 private serverService: ServerService,
24 private notifier: Notifier,
25 private router: Router,
26 private route: ActivatedRoute,
27 private videoEditorService: VideoEditorService,
28 private loadingBar: LoadingBarService,
29 private confirmService: ConfirmService
30 ) {
31 super()
32 }
33
34 ngOnInit () {
35 this.video = this.route.snapshot.data.video
36
37 const defaultValues = {
38 cut: {
39 start: 0,
40 end: this.video.duration
41 }
42 }
43
44 this.buildForm({
45 cut: {
46 start: null,
47 end: null
48 },
49 'add-intro': {
50 file: null
51 },
52 'add-outro': {
53 file: null
54 },
55 'add-watermark': {
56 file: null
57 }
58 }, defaultValues)
59 }
60
61 get videoExtensions () {
62 return this.serverService.getHTMLConfig().video.file.extensions
63 }
64
65 get imageExtensions () {
66 return this.serverService.getHTMLConfig().video.image.extensions
67 }
68
69 async runEdition () {
70 if (this.isRunningEdition) return
71
72 const title = $localize`Are you sure you want to edit "${this.video.name}"?`
73 const listHTML = this.getTasksSummary().map(t => `<li>${t}</li>`).join('')
74
75 // eslint-disable-next-line max-len
76 const confirmHTML = $localize`The current video will be overwritten by this edited video and <strong>you won't be able to recover it</strong>.<br /><br />` +
77 $localize`As a reminder, the following tasks will be executed: <ol>${listHTML}</ol>`
78
79 if (await this.confirmService.confirm(confirmHTML, title) !== true) return
80
81 this.isRunningEdition = true
82
83 const tasks = this.buildTasks()
84
85 this.loadingBar.useRef().start()
86
87 return this.videoEditorService.editVideo(this.video.uuid, tasks)
88 .subscribe({
89 next: () => {
90 this.notifier.success($localize`Video updated.`)
91 this.router.navigateByUrl(Video.buildWatchUrl(this.video))
92 },
93
94 error: err => {
95 this.loadingBar.useRef().complete()
96 this.isRunningEdition = false
97 this.notifier.error(err.message)
98 console.error(err)
99 }
100 })
101 }
102
103 getIntroOutroTooltip () {
104 return $localize`(extensions: ${this.videoExtensions.join(', ')})`
105 }
106
107 getWatermarkTooltip () {
108 return $localize`(extensions: ${this.imageExtensions.join(', ')})`
109 }
110
111 noEdition () {
112 return this.buildTasks().length === 0
113 }
114
115 getTasksSummary () {
116 const tasks = this.buildTasks()
117
118 return tasks.map(t => {
119 if (t.name === 'add-intro') {
120 return $localize`"${this.getFilename(t.options.file)}" will be added at the beggining of the video`
121 }
122
123 if (t.name === 'add-outro') {
124 return $localize`"${this.getFilename(t.options.file)}" will be added at the end of the video`
125 }
126
127 if (t.name === 'add-watermark') {
128 return $localize`"${this.getFilename(t.options.file)}" image watermark will be added to the video`
129 }
130
131 if (t.name === 'cut') {
132 const { start, end } = t.options
133
134 if (start !== undefined && end !== undefined) {
135 return $localize`Video will begin at ${secondsToTime(start)} and stop at ${secondsToTime(end)}`
136 }
137
138 if (start !== undefined) {
139 return $localize`Video will begin at ${secondsToTime(start)}`
140 }
141
142 if (end !== undefined) {
143 return $localize`Video will stop at ${secondsToTime(end)}`
144 }
145 }
146
147 return ''
148 })
149 }
150
151 private getFilename (obj: any) {
152 return obj.name
153 }
154
155 private buildTasks () {
156 const tasks: VideoEditorTask[] = []
157 const value = this.form.value
158
159 const cut = value['cut']
160 if (cut['start'] !== 0 || cut['end'] !== this.video.duration) {
161
162 const options: VideoEditorTaskCut['options'] = {}
163 if (cut['start'] !== 0) options.start = cut['start']
164 if (cut['end'] !== this.video.duration) options.end = cut['end']
165
166 tasks.push({
167 name: 'cut',
168 options
169 })
170 }
171
172 if (value['add-intro']?.['file']) {
173 tasks.push({
174 name: 'add-intro',
175 options: {
176 file: value['add-intro']['file']
177 }
178 })
179 }
180
181 if (value['add-outro']?.['file']) {
182 tasks.push({
183 name: 'add-outro',
184 options: {
185 file: value['add-outro']['file']
186 }
187 })
188 }
189
190 if (value['add-watermark']?.['file']) {
191 tasks.push({
192 name: 'add-watermark',
193 options: {
194 file: value['add-watermark']['file']
195 }
196 })
197 }
198
199 return tasks
200 }
201
202}
diff --git a/client/src/app/+video-editor/edit/video-editor-edit.resolver.ts b/client/src/app/+video-editor/edit/video-editor-edit.resolver.ts
new file mode 100644
index 000000000..7b95ae834
--- /dev/null
+++ b/client/src/app/+video-editor/edit/video-editor-edit.resolver.ts
@@ -0,0 +1,18 @@
1
2import { Injectable } from '@angular/core'
3import { ActivatedRouteSnapshot, Resolve } from '@angular/router'
4import { VideoService } from '@app/shared/shared-main'
5
6@Injectable()
7export class VideoEditorEditResolver implements Resolve<any> {
8 constructor (
9 private videoService: VideoService
10 ) {
11 }
12
13 resolve (route: ActivatedRouteSnapshot) {
14 const videoId: string = route.params['videoId']
15
16 return this.videoService.getVideo({ videoId })
17 }
18}
diff --git a/client/src/app/+video-editor/index.ts b/client/src/app/+video-editor/index.ts
new file mode 100644
index 000000000..5a9e9fdd0
--- /dev/null
+++ b/client/src/app/+video-editor/index.ts
@@ -0,0 +1 @@
export * from './video-editor.module'
diff --git a/client/src/app/+video-editor/shared/index.ts b/client/src/app/+video-editor/shared/index.ts
new file mode 100644
index 000000000..eaf88b6f4
--- /dev/null
+++ b/client/src/app/+video-editor/shared/index.ts
@@ -0,0 +1 @@
export * from './video-editor.service'
diff --git a/client/src/app/+video-editor/shared/video-editor.service.ts b/client/src/app/+video-editor/shared/video-editor.service.ts
new file mode 100644
index 000000000..5b7053039
--- /dev/null
+++ b/client/src/app/+video-editor/shared/video-editor.service.ts
@@ -0,0 +1,28 @@
1import { catchError } from 'rxjs'
2import { HttpClient } from '@angular/common/http'
3import { Injectable } from '@angular/core'
4import { RestExtractor } from '@app/core'
5import { objectToFormData } from '@app/helpers'
6import { VideoService } from '@app/shared/shared-main'
7import { VideoEditorCreateEdition, VideoEditorTask } from '@shared/models'
8
9@Injectable()
10export class VideoEditorService {
11
12 constructor (
13 private authHttp: HttpClient,
14 private restExtractor: RestExtractor
15 ) {}
16
17 editVideo (videoId: number | string, tasks: VideoEditorTask[]) {
18 const url = VideoService.BASE_VIDEO_URL + '/' + videoId + '/editor/edit'
19 const body: VideoEditorCreateEdition = {
20 tasks
21 }
22
23 const data = objectToFormData(body)
24
25 return this.authHttp.post(url, data)
26 .pipe(catchError(err => this.restExtractor.handleError(err)))
27 }
28}
diff --git a/client/src/app/+video-editor/video-editor-routing.module.ts b/client/src/app/+video-editor/video-editor-routing.module.ts
new file mode 100644
index 000000000..9f37a0dae
--- /dev/null
+++ b/client/src/app/+video-editor/video-editor-routing.module.ts
@@ -0,0 +1,30 @@
1import { NgModule } from '@angular/core'
2import { RouterModule, Routes } from '@angular/router'
3import { VideoEditorEditResolver } from './edit'
4import { VideoEditorEditComponent } from './edit/video-editor-edit.component'
5
6const videoEditorRoutes: Routes = [
7 {
8 path: '',
9 children: [
10 {
11 path: 'edit/:videoId',
12 component: VideoEditorEditComponent,
13 data: {
14 meta: {
15 title: $localize`Edit video`
16 }
17 },
18 resolve: {
19 video: VideoEditorEditResolver
20 }
21 }
22 ]
23 }
24]
25
26@NgModule({
27 imports: [ RouterModule.forChild(videoEditorRoutes) ],
28 exports: [ RouterModule ]
29})
30export class VideoEditorRoutingModule {}
diff --git a/client/src/app/+video-editor/video-editor.module.ts b/client/src/app/+video-editor/video-editor.module.ts
new file mode 100644
index 000000000..7bbebc17b
--- /dev/null
+++ b/client/src/app/+video-editor/video-editor.module.ts
@@ -0,0 +1,27 @@
1import { NgModule } from '@angular/core'
2import { SharedFormModule } from '@app/shared/shared-forms'
3import { SharedMainModule } from '@app/shared/shared-main'
4import { VideoEditorEditComponent, VideoEditorEditResolver } from './edit'
5import { VideoEditorService } from './shared'
6import { VideoEditorRoutingModule } from './video-editor-routing.module'
7
8@NgModule({
9 imports: [
10 VideoEditorRoutingModule,
11
12 SharedMainModule,
13 SharedFormModule
14 ],
15
16 declarations: [
17 VideoEditorEditComponent
18 ],
19
20 exports: [],
21
22 providers: [
23 VideoEditorService,
24 VideoEditorEditResolver
25 ]
26})
27export class VideoEditorModule { }
diff --git a/client/src/app/+videos/+video-edit/shared/video-caption-add-modal.component.ts b/client/src/app/+videos/+video-edit/shared/video-caption-add-modal.component.ts
index 5c4152884..6deadfcbe 100644
--- a/client/src/app/+videos/+video-edit/shared/video-caption-add-modal.component.ts
+++ b/client/src/app/+videos/+video-edit/shared/video-caption-add-modal.component.ts
@@ -77,7 +77,8 @@ export class VideoCaptionAddModalComponent extends FormReactive implements OnIni
77 77
78 this.captionAdded.emit({ 78 this.captionAdded.emit({
79 language: languageObject, 79 language: languageObject,
80 captionfile: this.form.value['captionfile'] 80 captionfile: this.form.value['captionfile'],
81 action: 'CREATE'
81 }) 82 })
82 83
83 this.hide() 84 this.hide()
diff --git a/client/src/app/+videos/+video-edit/shared/video-caption-edit-modal/video-caption-edit-modal.component.html b/client/src/app/+videos/+video-edit/shared/video-caption-edit-modal/video-caption-edit-modal.component.html
new file mode 100644
index 000000000..be6f676c2
--- /dev/null
+++ b/client/src/app/+videos/+video-edit/shared/video-caption-edit-modal/video-caption-edit-modal.component.html
@@ -0,0 +1,36 @@
1<ng-template #modal>
2 <ng-container [formGroup]="form">
3
4 <div class="modal-header">
5 <h4 i18n class="modal-title">Edit caption</h4>
6 <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon>
7 </div>
8
9 <div class="modal-body">
10 <label i18n for="captionFileContent">Caption</label>
11 <textarea
12 id="captionFileContent"
13 formControlName="captionFileContent"
14 class="form-control caption-textarea"
15 [ngClass]="{ 'input-error': formErrors['captionFileContent'] }"
16 >
17 </textarea>
18
19 <div *ngIf="formErrors.captionFileContent" class="form-error">
20 {{ formErrors.captionFileContent }}
21 </div>
22 </div>
23
24 <div class="modal-footer inputs">
25 <input
26 type="button" role="button" i18n-value value="Cancel" class="peertube-button grey-button"
27 (click)="cancel()" (key.enter)="cancel()"
28 >
29
30 <input
31 type="submit" i18n-value value="Edit this caption" class="peertube-button orange-button"
32 [disabled]="!form.valid" (click)="updateCaption()"
33 >
34 </div>
35 </ng-container>
36</ng-template>
diff --git a/client/src/app/+videos/+video-edit/shared/video-caption-edit-modal/video-caption-edit-modal.component.scss b/client/src/app/+videos/+video-edit/shared/video-caption-edit-modal/video-caption-edit-modal.component.scss
new file mode 100644
index 000000000..bd96f2b7a
--- /dev/null
+++ b/client/src/app/+videos/+video-edit/shared/video-caption-edit-modal/video-caption-edit-modal.component.scss
@@ -0,0 +1,4 @@
1.caption-textarea {
2 min-height: 600px;
3}
4
diff --git a/client/src/app/+videos/+video-edit/shared/video-caption-edit-modal/video-caption-edit-modal.component.ts b/client/src/app/+videos/+video-edit/shared/video-caption-edit-modal/video-caption-edit-modal.component.ts
new file mode 100644
index 000000000..f74f3c5ea
--- /dev/null
+++ b/client/src/app/+videos/+video-edit/shared/video-caption-edit-modal/video-caption-edit-modal.component.ts
@@ -0,0 +1,83 @@
1import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'
2import { VIDEO_CAPTION_FILE_CONTENT_VALIDATOR } from '@app/shared/form-validators/video-captions-validators'
3import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
4import { VideoCaptionEdit, VideoCaptionService, VideoCaptionWithPathEdit } from '@app/shared/shared-main'
5import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
6import { HTMLServerConfig, VideoConstant } from '@shared/models'
7import { ServerService } from '../../../../core'
8
9@Component({
10 selector: 'my-video-caption-edit-modal',
11 styleUrls: [ './video-caption-edit-modal.component.scss' ],
12 templateUrl: './video-caption-edit-modal.component.html'
13})
14
15export class VideoCaptionEditModalComponent extends FormReactive implements OnInit {
16 @Input() videoCaption: VideoCaptionWithPathEdit
17 @Input() serverConfig: HTMLServerConfig
18
19 @Output() captionEdited = new EventEmitter<VideoCaptionEdit>()
20
21 @ViewChild('modal', { static: true }) modal: ElementRef
22
23 videoCaptionLanguages: VideoConstant<string>[] = []
24 private openedModal: NgbModalRef
25
26 constructor (
27 protected formValidatorService: FormValidatorService,
28 private modalService: NgbModal,
29 private videoCaptionService: VideoCaptionService,
30 private serverService: ServerService
31 ) {
32 super()
33 }
34
35 ngOnInit () {
36 this.serverService.getVideoLanguages().subscribe(languages => this.videoCaptionLanguages = languages)
37
38 this.buildForm({ captionFileContent: VIDEO_CAPTION_FILE_CONTENT_VALIDATOR })
39
40 this.loadCaptionContent()
41 }
42
43 loadCaptionContent () {
44 const { captionPath } = this.videoCaption
45 if (!captionPath) return
46
47 this.videoCaptionService.getCaptionContent({ captionPath })
48 .subscribe(res => {
49 this.form.patchValue({
50 captionFileContent: res
51 })
52 })
53 }
54
55 show () {
56 this.openedModal = this.modalService.open(this.modal, { centered: true, keyboard: false })
57 }
58
59 hide () {
60 this.openedModal.close()
61 }
62
63 cancel () {
64 this.hide()
65 }
66
67 updateCaption () {
68 const format = 'vtt'
69 const languageId = this.videoCaption.language.id
70 const languageObject = this.videoCaptionLanguages.find(l => l.id === languageId)
71
72 this.captionEdited.emit({
73 language: languageObject,
74 captionfile: new File([ this.form.value['captionFileContent'] ], `${languageId}.${format}`, {
75 type: 'text/vtt',
76 lastModified: Date.now()
77 }),
78 action: 'UPDATE'
79 })
80
81 this.hide()
82 }
83}
diff --git a/client/src/app/+videos/+video-edit/shared/video-edit.component.html b/client/src/app/+videos/+video-edit/shared/video-edit.component.html
index 9bb13ba88..2281f8631 100644
--- a/client/src/app/+videos/+video-edit/shared/video-edit.component.html
+++ b/client/src/app/+videos/+video-edit/shared/video-edit.component.html
@@ -186,6 +186,7 @@
186 186
187 <div i18n class="caption-entry-state">Already uploaded &#10004;</div> 187 <div i18n class="caption-entry-state">Already uploaded &#10004;</div>
188 188
189 <span i18n class="caption-entry-edit" (click)="videoCaptionEditModal.show()">Edit</span>
189 <span i18n class="caption-entry-delete" (click)="deleteCaption(videoCaption)">Delete</span> 190 <span i18n class="caption-entry-delete" (click)="deleteCaption(videoCaption)">Delete</span>
190 </ng-container> 191 </ng-container>
191 192
@@ -197,6 +198,14 @@
197 <span i18n class="caption-entry-delete" (click)="deleteCaption(videoCaption)">Cancel create</span> 198 <span i18n class="caption-entry-delete" (click)="deleteCaption(videoCaption)">Cancel create</span>
198 </ng-container> 199 </ng-container>
199 200
201 <ng-container *ngIf="videoCaption.action === 'UPDATE'">
202 <span class="caption-entry-label">{{ videoCaption.language.label }}</span>
203
204 <div i18n class="caption-entry-state caption-entry-state-create">Will be edited on update</div>
205
206 <span i18n class="caption-entry-delete" (click)="deleteCaption(videoCaption)">Cancel edition</span>
207 </ng-container>
208
200 <ng-container *ngIf="videoCaption.action === 'REMOVE'"> 209 <ng-container *ngIf="videoCaption.action === 'REMOVE'">
201 <span class="caption-entry-label">{{ videoCaption.language.label }}</span> 210 <span class="caption-entry-label">{{ videoCaption.language.label }}</span>
202 211
@@ -204,6 +213,13 @@
204 213
205 <span i18n class="caption-entry-delete" (click)="deleteCaption(videoCaption)">Cancel deletion</span> 214 <span i18n class="caption-entry-delete" (click)="deleteCaption(videoCaption)">Cancel deletion</span>
206 </ng-container> 215 </ng-container>
216
217 <my-video-caption-edit-modal
218 #videoCaptionEditModal
219 [videoCaption]="videoCaption"
220 [serverConfig]="serverConfig"
221 (captionEdited)="onCaptionEdited($event)"
222 ></my-video-caption-edit-modal>
207 </div> 223 </div>
208 </div> 224 </div>
209 225
@@ -373,5 +389,5 @@
373</div> 389</div>
374 390
375<my-video-caption-add-modal 391<my-video-caption-add-modal
376 #videoCaptionAddModal [existingCaptions]="getExistingCaptions()" [serverConfig]="serverConfig" (captionAdded)="onCaptionAdded($event)" 392 #videoCaptionAddModal [existingCaptions]="getExistingCaptions()" [serverConfig]="serverConfig" (captionAdded)="onCaptionEdited($event)"
377></my-video-caption-add-modal> 393></my-video-caption-add-modal>
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 4b1dec89a..5344e5431 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
@@ -96,6 +96,10 @@ my-peertube-checkbox {
96 } 96 }
97 } 97 }
98 98
99 .caption-entry-edit {
100 @include peertube-button;
101 }
102
99 .caption-entry-delete { 103 .caption-entry-delete {
100 @include peertube-button; 104 @include peertube-button;
101 @include grey-button; 105 @include grey-button;
diff --git a/client/src/app/+videos/+video-edit/shared/video-edit.component.ts b/client/src/app/+videos/+video-edit/shared/video-edit.component.ts
index 31dbe43e6..2801fc519 100644
--- a/client/src/app/+videos/+video-edit/shared/video-edit.component.ts
+++ b/client/src/app/+videos/+video-edit/shared/video-edit.component.ts
@@ -21,7 +21,7 @@ import {
21} from '@app/shared/form-validators/video-validators' 21} from '@app/shared/form-validators/video-validators'
22import { FormReactiveValidationMessages, FormValidatorService } from '@app/shared/shared-forms' 22import { FormReactiveValidationMessages, FormValidatorService } from '@app/shared/shared-forms'
23import { InstanceService } from '@app/shared/shared-instance' 23import { InstanceService } from '@app/shared/shared-instance'
24import { VideoCaptionEdit, VideoEdit, VideoService } from '@app/shared/shared-main' 24import { VideoCaptionEdit, VideoCaptionWithPathEdit, VideoEdit, VideoService } from '@app/shared/shared-main'
25import { PluginInfo } from '@root-helpers/plugins-manager' 25import { PluginInfo } from '@root-helpers/plugins-manager'
26import { 26import {
27 HTMLServerConfig, 27 HTMLServerConfig,
@@ -34,6 +34,7 @@ import {
34} from '@shared/models' 34} from '@shared/models'
35import { I18nPrimengCalendarService } from './i18n-primeng-calendar.service' 35import { I18nPrimengCalendarService } from './i18n-primeng-calendar.service'
36import { VideoCaptionAddModalComponent } from './video-caption-add-modal.component' 36import { VideoCaptionAddModalComponent } from './video-caption-add-modal.component'
37import { VideoCaptionEditModalComponent } from './video-caption-edit-modal/video-caption-edit-modal.component'
37import { VideoEditType } from './video-edit.type' 38import { VideoEditType } from './video-edit.type'
38 39
39type VideoLanguages = VideoConstant<string> & { group?: string } 40type VideoLanguages = VideoConstant<string> & { group?: string }
@@ -58,13 +59,14 @@ export class VideoEditComponent implements OnInit, OnDestroy {
58 @Input() userVideoChannels: SelectChannelItem[] = [] 59 @Input() userVideoChannels: SelectChannelItem[] = []
59 @Input() forbidScheduledPublication = true 60 @Input() forbidScheduledPublication = true
60 61
61 @Input() videoCaptions: (VideoCaptionEdit & { captionPath?: string })[] = [] 62 @Input() videoCaptions: VideoCaptionWithPathEdit[] = []
62 63
63 @Input() waitTranscodingEnabled = true 64 @Input() waitTranscodingEnabled = true
64 @Input() type: VideoEditType 65 @Input() type: VideoEditType
65 @Input() liveVideo: LiveVideo 66 @Input() liveVideo: LiveVideo
66 67
67 @ViewChild('videoCaptionAddModal', { static: true }) videoCaptionAddModal: VideoCaptionAddModalComponent 68 @ViewChild('videoCaptionAddModal', { static: true }) videoCaptionAddModal: VideoCaptionAddModalComponent
69 @ViewChild('videoCaptionEditModal', { static: true }) editCaptionModal: VideoCaptionEditModalComponent
68 70
69 @Output() formBuilt = new EventEmitter<void>() 71 @Output() formBuilt = new EventEmitter<void>()
70 @Output() pluginFieldsAdded = new EventEmitter<void>() 72 @Output() pluginFieldsAdded = new EventEmitter<void>()
@@ -228,12 +230,12 @@ export class VideoEditComponent implements OnInit, OnDestroy {
228 .map(c => c.language.id) 230 .map(c => c.language.id)
229 } 231 }
230 232
231 onCaptionAdded (caption: VideoCaptionEdit) { 233 onCaptionEdited (caption: VideoCaptionEdit) {
232 const existingCaption = this.videoCaptions.find(c => c.language.id === caption.language.id) 234 const existingCaption = this.videoCaptions.find(c => c.language.id === caption.language.id)
233 235
234 // Replace existing caption? 236 // Replace existing caption?
235 if (existingCaption) { 237 if (existingCaption) {
236 Object.assign(existingCaption, caption, { action: 'CREATE' as 'CREATE' }) 238 Object.assign(existingCaption, caption)
237 } else { 239 } else {
238 this.videoCaptions.push( 240 this.videoCaptions.push(
239 Object.assign(caption, { action: 'CREATE' as 'CREATE' }) 241 Object.assign(caption, { action: 'CREATE' as 'CREATE' })
@@ -251,7 +253,7 @@ export class VideoEditComponent implements OnInit, OnDestroy {
251 } 253 }
252 254
253 // This caption is not on the server, just remove it from our array 255 // This caption is not on the server, just remove it from our array
254 if (caption.action === 'CREATE') { 256 if (caption.action === 'CREATE' || caption.action === 'UPDATE') {
255 removeElementFromArray(this.videoCaptions, caption) 257 removeElementFromArray(this.videoCaptions, caption)
256 return 258 return
257 } 259 }
diff --git a/client/src/app/+videos/+video-edit/shared/video-edit.module.ts b/client/src/app/+videos/+video-edit/shared/video-edit.module.ts
index 7a3854065..4e8767364 100644
--- a/client/src/app/+videos/+video-edit/shared/video-edit.module.ts
+++ b/client/src/app/+videos/+video-edit/shared/video-edit.module.ts
@@ -6,6 +6,7 @@ import { SharedMainModule } from '@app/shared/shared-main'
6import { SharedVideoLiveModule } from '@app/shared/shared-video-live' 6import { SharedVideoLiveModule } from '@app/shared/shared-video-live'
7import { I18nPrimengCalendarService } from './i18n-primeng-calendar.service' 7import { I18nPrimengCalendarService } from './i18n-primeng-calendar.service'
8import { VideoCaptionAddModalComponent } from './video-caption-add-modal.component' 8import { VideoCaptionAddModalComponent } from './video-caption-add-modal.component'
9import { VideoCaptionEditModalComponent } from './video-caption-edit-modal/video-caption-edit-modal.component'
9import { VideoEditComponent } from './video-edit.component' 10import { VideoEditComponent } from './video-edit.component'
10 11
11@NgModule({ 12@NgModule({
@@ -20,7 +21,8 @@ import { VideoEditComponent } from './video-edit.component'
20 21
21 declarations: [ 22 declarations: [
22 VideoEditComponent, 23 VideoEditComponent,
23 VideoCaptionAddModalComponent 24 VideoCaptionAddModalComponent,
25 VideoCaptionEditModalComponent
24 ], 26 ],
25 27
26 exports: [ 28 exports: [
diff --git a/client/src/app/+videos/+video-edit/video-add-components/video-send.ts b/client/src/app/+videos/+video-edit/video-add-components/video-send.ts
index 3d0e1bf2a..9de373cd3 100644
--- a/client/src/app/+videos/+video-edit/video-add-components/video-send.ts
+++ b/client/src/app/+videos/+video-edit/video-add-components/video-send.ts
@@ -2,7 +2,7 @@ import { catchError, switchMap, tap } from 'rxjs/operators'
2import { SelectChannelItem } from 'src/types/select-options-item.model' 2import { SelectChannelItem } from 'src/types/select-options-item.model'
3import { Directive, EventEmitter, OnInit } from '@angular/core' 3import { Directive, EventEmitter, OnInit } from '@angular/core'
4import { AuthService, CanComponentDeactivateResult, Notifier, ServerService } from '@app/core' 4import { AuthService, CanComponentDeactivateResult, Notifier, ServerService } from '@app/core'
5import { listUserChannels } from '@app/helpers' 5import { listUserChannelsForSelect } from '@app/helpers'
6import { FormReactive } from '@app/shared/shared-forms' 6import { FormReactive } from '@app/shared/shared-forms'
7import { VideoCaptionEdit, VideoCaptionService, VideoEdit, VideoService } from '@app/shared/shared-main' 7import { VideoCaptionEdit, VideoCaptionService, VideoEdit, VideoService } from '@app/shared/shared-main'
8import { LoadingBarService } from '@ngx-loading-bar/core' 8import { LoadingBarService } from '@ngx-loading-bar/core'
@@ -38,7 +38,7 @@ export abstract class VideoSend extends FormReactive implements OnInit {
38 ngOnInit () { 38 ngOnInit () {
39 this.buildForm({}) 39 this.buildForm({})
40 40
41 listUserChannels(this.authService) 41 listUserChannelsForSelect(this.authService)
42 .subscribe(channels => { 42 .subscribe(channels => {
43 this.userVideoChannels = channels 43 this.userVideoChannels = channels
44 this.firstStepChannelId = this.userVideoChannels[0].id 44 this.firstStepChannelId = this.userVideoChannels[0].id
diff --git a/client/src/app/+videos/+video-edit/video-update.resolver.ts b/client/src/app/+videos/+video-edit/video-update.resolver.ts
index 91e76b7fe..82dae5c1c 100644
--- a/client/src/app/+videos/+video-edit/video-update.resolver.ts
+++ b/client/src/app/+videos/+video-edit/video-update.resolver.ts
@@ -3,7 +3,7 @@ import { map, switchMap } from 'rxjs/operators'
3import { Injectable } from '@angular/core' 3import { Injectable } from '@angular/core'
4import { ActivatedRouteSnapshot, Resolve } from '@angular/router' 4import { ActivatedRouteSnapshot, Resolve } from '@angular/router'
5import { AuthService } from '@app/core' 5import { AuthService } from '@app/core'
6import { listUserChannels } from '@app/helpers' 6import { listUserChannelsForSelect } from '@app/helpers'
7import { VideoCaptionService, VideoDetails, VideoService } from '@app/shared/shared-main' 7import { VideoCaptionService, VideoDetails, VideoService } from '@app/shared/shared-main'
8import { LiveVideoService } from '@app/shared/shared-video-live' 8import { LiveVideoService } from '@app/shared/shared-video-live'
9 9
@@ -33,7 +33,7 @@ export class VideoUpdateResolver implements Resolve<any> {
33 .loadCompleteDescription(video.descriptionPath) 33 .loadCompleteDescription(video.descriptionPath)
34 .pipe(map(description => Object.assign(video, { description }))), 34 .pipe(map(description => Object.assign(video, { description }))),
35 35
36 listUserChannels(this.authService), 36 listUserChannelsForSelect(this.authService),
37 37
38 this.videoCaptionService 38 this.videoCaptionService
39 .listCaptions(video.id) 39 .listCaptions(video.id)
diff --git a/client/src/app/+videos/+video-watch/shared/action-buttons/action-buttons.component.ts b/client/src/app/+videos/+video-watch/shared/action-buttons/action-buttons.component.ts
index e59238ffe..6e8a64f46 100644
--- a/client/src/app/+videos/+video-watch/shared/action-buttons/action-buttons.component.ts
+++ b/client/src/app/+videos/+video-watch/shared/action-buttons/action-buttons.component.ts
@@ -35,6 +35,7 @@ export class ActionButtonsComponent implements OnInit, OnChanges {
35 playlist: false, 35 playlist: false,
36 download: true, 36 download: true,
37 update: true, 37 update: true,
38 editor: true,
38 blacklist: true, 39 blacklist: true,
39 delete: true, 40 delete: true,
40 report: true, 41 report: true,
diff --git a/client/src/app/+videos/+video-watch/shared/comment/video-comment.component.html b/client/src/app/+videos/+video-watch/shared/comment/video-comment.component.html
index d0e9bcd29..5014b9692 100644
--- a/client/src/app/+videos/+video-watch/shared/comment/video-comment.component.html
+++ b/client/src/app/+videos/+video-watch/shared/comment/video-comment.component.html
@@ -1,6 +1,6 @@
1<div *ngIf="isCommentDisplayed()" class="root-comment" [ngClass]="{ 'is-child': isChild() }"> 1<div *ngIf="isCommentDisplayed()" class="root-comment" [ngClass]="{ 'is-child': isChild() }">
2 <div class="left"> 2 <div class="left">
3 <my-actor-avatar *ngIf="!comment.isDeleted" [href]="comment.account.url" [account]="comment.account"></my-actor-avatar> 3 <my-actor-avatar *ngIf="!comment.isDeleted" [href]="comment.account.url" [account]="comment.account" [size]="isChild() ? '25' : '36'"></my-actor-avatar>
4 <div class="vertical-border"></div> 4 <div class="vertical-border"></div>
5 </div> 5 </div>
6 6
diff --git a/client/src/app/+videos/+video-watch/shared/comment/video-comment.component.scss b/client/src/app/+videos/+video-watch/shared/comment/video-comment.component.scss
index 87e313d41..54f828014 100644
--- a/client/src/app/+videos/+video-watch/shared/comment/video-comment.component.scss
+++ b/client/src/app/+videos/+video-watch/shared/comment/video-comment.component.scss
@@ -25,10 +25,6 @@
25 } 25 }
26} 26}
27 27
28my-actor-avatar {
29 @include actor-avatar-size(36px);
30}
31
32.comment { 28.comment {
33 flex-grow: 1; 29 flex-grow: 1;
34 // Fix word-wrap with flex 30 // Fix word-wrap with flex
@@ -160,11 +156,6 @@ my-video-comment-add {
160} 156}
161 157
162.is-child { 158.is-child {
163 // Reduce avatars size for replies
164 my-actor-avatar {
165 @include actor-avatar-size(25px);
166 }
167
168 .left { 159 .left {
169 @include margin-right(6px); 160 @include margin-right(6px);
170 } 161 }
diff --git a/client/src/app/+videos/+video-watch/shared/information/video-alert.component.html b/client/src/app/+videos/+video-watch/shared/information/video-alert.component.html
index 0c4d46714..c6ffb1abd 100644
--- a/client/src/app/+videos/+video-watch/shared/information/video-alert.component.html
+++ b/client/src/app/+videos/+video-watch/shared/information/video-alert.component.html
@@ -14,6 +14,10 @@
14 The video is being transcoded, it may not work properly. 14 The video is being transcoded, it may not work properly.
15</div> 15</div>
16 16
17<div i18n class="alert alert-warning" *ngIf="isVideoToEdit()">
18 The video is being edited, it may not work properly.
19</div>
20
17<div i18n class="alert alert-warning" *ngIf="isVideoToMoveToExternalStorage()"> 21<div i18n class="alert alert-warning" *ngIf="isVideoToMoveToExternalStorage()">
18 The video is being moved to an external server, it may not work properly. 22 The video is being moved to an external server, it may not work properly.
19</div> 23</div>
diff --git a/client/src/app/+videos/+video-watch/shared/information/video-alert.component.ts b/client/src/app/+videos/+video-watch/shared/information/video-alert.component.ts
index a3d3fa6fb..79b56705f 100644
--- a/client/src/app/+videos/+video-watch/shared/information/video-alert.component.ts
+++ b/client/src/app/+videos/+video-watch/shared/information/video-alert.component.ts
@@ -14,6 +14,10 @@ export class VideoAlertComponent {
14 return this.video && this.video.state.id === VideoState.TO_TRANSCODE 14 return this.video && this.video.state.id === VideoState.TO_TRANSCODE
15 } 15 }
16 16
17 isVideoToEdit () {
18 return this.video && this.video.state.id === VideoState.TO_EDIT
19 }
20
17 isVideoTranscodingFailed () { 21 isVideoTranscodingFailed () {
18 return this.video && this.video.state.id === VideoState.TRANSCODING_FAILED 22 return this.video && this.video.state.id === VideoState.TRANSCODING_FAILED
19 } 23 }
diff --git a/client/src/app/+videos/+video-watch/shared/metadata/video-avatar-channel.component.html b/client/src/app/+videos/+video-watch/shared/metadata/video-avatar-channel.component.html
index d433c7aba..a23152b67 100644
--- a/client/src/app/+videos/+video-watch/shared/metadata/video-avatar-channel.component.html
+++ b/client/src/app/+videos/+video-watch/shared/metadata/video-avatar-channel.component.html
@@ -2,19 +2,19 @@
2 <my-actor-avatar 2 <my-actor-avatar
3 *ngIf="showChannel" 3 *ngIf="showChannel"
4 class="channel" 4 class="channel"
5 [class.main-avatar]="showChannel"
6 [channel]="video.channel" 5 [channel]="video.channel"
7 [internalHref]="[ '/c', video.byVideoChannel ]" 6 [internalHref]="[ '/c', video.byVideoChannel ]"
8 [title]="channelLinkTitle" 7 [title]="channelLinkTitle"
8 size="35"
9 ></my-actor-avatar> 9 ></my-actor-avatar>
10 10
11 <my-actor-avatar 11 <my-actor-avatar
12 *ngIf="showAccount" 12 *ngIf="showAccount"
13 class="account" 13 class="account"
14 [class.main-avatar]="!showChannel"
15 [class.second-avatar]="showChannel" 14 [class.second-avatar]="showChannel"
16 [account]="video.account" 15 [account]="video.account"
17 [internalHref]="[ '/a', video.byAccount ]" 16 [internalHref]="[ '/a', video.byAccount ]"
18 [title]="accountLinkTitle"> 17 [title]="accountLinkTitle"
18 size="35">
19 </my-actor-avatar> 19 </my-actor-avatar>
20</div> 20</div>
diff --git a/client/src/app/+videos/+video-watch/shared/metadata/video-avatar-channel.component.scss b/client/src/app/+videos/+video-watch/shared/metadata/video-avatar-channel.component.scss
index 71c5e4b5a..80711ff32 100644
--- a/client/src/app/+videos/+video-watch/shared/metadata/video-avatar-channel.component.scss
+++ b/client/src/app/+videos/+video-watch/shared/metadata/video-avatar-channel.component.scss
@@ -1,9 +1,5 @@
1@use '_mixins' as *; 1@use '_mixins' as *;
2 2
3@mixin main {
4 @include actor-avatar-size(35px);
5}
6
7@mixin secondary { 3@mixin secondary {
8 height: 60%; 4 height: 60%;
9 width: 60%; 5 width: 60%;
@@ -14,16 +10,11 @@
14} 10}
15 11
16.wrapper { 12.wrapper {
17 @include actor-avatar-size(35px);
18 @include margin-right(5px); 13 @include margin-right(5px);
19 14
20 position: relative; 15 position: relative;
21 margin-bottom: 5px; 16 margin-bottom: 5px;
22 17
23 .main-avatar {
24 @include main();
25 }
26
27 .second-avatar { 18 .second-avatar {
28 @include secondary(); 19 @include secondary();
29 } 20 }
diff --git a/client/src/app/+videos/+video-watch/shared/metadata/video-avatar-channel.component.ts b/client/src/app/+videos/+video-watch/shared/metadata/video-avatar-channel.component.ts
index 146c440b3..4bfc7bd9d 100644
--- a/client/src/app/+videos/+video-watch/shared/metadata/video-avatar-channel.component.ts
+++ b/client/src/app/+videos/+video-watch/shared/metadata/video-avatar-channel.component.ts
@@ -20,8 +20,4 @@ export class VideoAvatarChannelComponent implements OnInit {
20 this.channelLinkTitle = $localize`${this.video.account.name} (channel page)` 20 this.channelLinkTitle = $localize`${this.video.account.name} (channel page)`
21 this.accountLinkTitle = $localize`${this.video.byAccount} (account page)` 21 this.accountLinkTitle = $localize`${this.video.byAccount} (account page)`
22 } 22 }
23
24 isChannelAvatarNull () {
25 return this.video.channel.avatar === null
26 }
27} 23}
diff --git a/client/src/app/+videos/+video-watch/shared/playlist/video-watch-playlist.component.scss b/client/src/app/+videos/+video-watch/shared/playlist/video-watch-playlist.component.scss
index 75ed9d901..fc67ac65a 100644
--- a/client/src/app/+videos/+video-watch/shared/playlist/video-watch-playlist.component.scss
+++ b/client/src/app/+videos/+video-watch/shared/playlist/video-watch-playlist.component.scss
@@ -4,6 +4,7 @@
4@use '_miniature' as *; 4@use '_miniature' as *;
5 5
6.playlist { 6.playlist {
7 position: relative;
7 min-width: 200px; 8 min-width: 200px;
8 max-width: 470px; 9 max-width: 470px;
9 height: 66vh; 10 height: 66vh;
diff --git a/client/src/app/+videos/+video-watch/shared/playlist/video-watch-playlist.component.ts b/client/src/app/+videos/+video-watch/shared/playlist/video-watch-playlist.component.ts
index b44238310..879d296a7 100644
--- a/client/src/app/+videos/+video-watch/shared/playlist/video-watch-playlist.component.ts
+++ b/client/src/app/+videos/+video-watch/shared/playlist/video-watch-playlist.component.ts
@@ -1,9 +1,10 @@
1import { Component, EventEmitter, Input, Output } from '@angular/core' 1import { Component, EventEmitter, Input, Output } from '@angular/core'
2import { Router } from '@angular/router' 2import { Router } from '@angular/router'
3import { AuthService, ComponentPagination, HooksService, Notifier, SessionStorageService, UserService } from '@app/core' 3import { AuthService, ComponentPagination, HooksService, Notifier, SessionStorageService, UserService } from '@app/core'
4import { isInViewport } from '@app/helpers'
4import { VideoPlaylist, VideoPlaylistElement, VideoPlaylistService } from '@app/shared/shared-video-playlist' 5import { VideoPlaylist, VideoPlaylistElement, VideoPlaylistService } from '@app/shared/shared-video-playlist'
5import { peertubeSessionStorage } from '@root-helpers/peertube-web-storage'
6import { getBoolOrDefault } from '@root-helpers/local-storage-utils' 6import { getBoolOrDefault } from '@root-helpers/local-storage-utils'
7import { peertubeSessionStorage } from '@root-helpers/peertube-web-storage'
7import { VideoPlaylistPrivacy } from '@shared/models' 8import { VideoPlaylistPrivacy } from '@shared/models'
8 9
9@Component({ 10@Component({
@@ -132,7 +133,12 @@ export class VideoWatchPlaylistComponent {
132 this.videoFound.emit(playlistElement.video.uuid) 133 this.videoFound.emit(playlistElement.video.uuid)
133 134
134 setTimeout(() => { 135 setTimeout(() => {
135 document.querySelector('.element-' + this.currentPlaylistPosition).scrollIntoView(false) 136 const element = document.querySelector<HTMLElement>('.element-' + this.currentPlaylistPosition)
137 const container = document.querySelector<HTMLElement>('.playlist')
138
139 if (isInViewport(element, container)) return
140
141 container.scrollTop = element.offsetTop
136 }) 142 })
137 143
138 return 144 return
diff --git a/client/src/app/+videos/+video-watch/video-watch.component.html b/client/src/app/+videos/+video-watch/video-watch.component.html
index 830215225..4c15ae3d7 100644
--- a/client/src/app/+videos/+video-watch/video-watch.component.html
+++ b/client/src/app/+videos/+video-watch/video-watch.component.html
@@ -11,11 +11,7 @@
11 <img class="placeholder-image" *ngIf="playerPlaceholderImgSrc" [src]="playerPlaceholderImgSrc" alt="Placeholder image" i18n-alt> 11 <img class="placeholder-image" *ngIf="playerPlaceholderImgSrc" [src]="playerPlaceholderImgSrc" alt="Placeholder image" i18n-alt>
12 </div> 12 </div>
13 13
14 <my-video-watch-playlist 14 <my-video-watch-playlist #videoWatchPlaylist [playlist]="playlist" (videoFound)="onPlaylistVideoFound($event)"></my-video-watch-playlist>
15 #videoWatchPlaylist
16 [playlist]="playlist" class="playlist"
17 (videoFound)="onPlaylistVideoFound($event)"
18 ></my-video-watch-playlist>
19 15
20 <my-plugin-placeholder pluginId="player-next"></my-plugin-placeholder> 16 <my-plugin-placeholder pluginId="player-next"></my-plugin-placeholder>
21 </div> 17 </div>
diff --git a/client/src/app/+videos/video-list/overview/video-overview.component.html b/client/src/app/+videos/video-list/overview/video-overview.component.html
index 1a715560c..f250c2407 100644
--- a/client/src/app/+videos/video-list/overview/video-overview.component.html
+++ b/client/src/app/+videos/video-list/overview/video-overview.component.html
@@ -33,7 +33,7 @@
33 <div class="section channel videos" *ngFor="let object of overview.channels"> 33 <div class="section channel videos" *ngFor="let object of overview.channels">
34 <div class="section-title"> 34 <div class="section-title">
35 <a [routerLink]="[ '/c', buildVideoChannelBy(object) ]"> 35 <a [routerLink]="[ '/c', buildVideoChannelBy(object) ]">
36 <my-actor-avatar [channel]="buildVideoChannel(object)"></my-actor-avatar> 36 <my-actor-avatar [channel]="buildVideoChannel(object)" size="28"></my-actor-avatar>
37 37
38 <h2 class="section-title">{{ object.channel.displayName }}</h2> 38 <h2 class="section-title">{{ object.channel.displayName }}</h2>
39 </a> 39 </a>
diff --git a/client/src/app/+videos/video-list/overview/video-overview.component.scss b/client/src/app/+videos/video-list/overview/video-overview.component.scss
index 2239d1913..8b2aa88f2 100644
--- a/client/src/app/+videos/video-list/overview/video-overview.component.scss
+++ b/client/src/app/+videos/video-list/overview/video-overview.component.scss
@@ -52,7 +52,6 @@
52 align-items: center; 52 align-items: center;
53 53
54 my-actor-avatar { 54 my-actor-avatar {
55 @include actor-avatar-size(28px);
56 @include margin-right(8px); 55 @include margin-right(8px);
57 56
58 font-size: initial; 57 font-size: initial;
diff --git a/client/src/app/app-routing.module.ts b/client/src/app/app-routing.module.ts
index b5afc9c92..cd499845b 100644
--- a/client/src/app/app-routing.module.ts
+++ b/client/src/app/app-routing.module.ts
@@ -143,6 +143,12 @@ const routes: Routes = [
143 canActivateChild: [ MetaGuard ] 143 canActivateChild: [ MetaGuard ]
144 }, 144 },
145 145
146 {
147 path: 'video-editor',
148 loadChildren: () => import('./+video-editor/video-editor.module').then(m => m.VideoEditorModule),
149 canActivateChild: [ MetaGuard ]
150 },
151
146 // Matches /@:actorName 152 // Matches /@:actorName
147 { 153 {
148 matcher: (url): UrlMatchResult => { 154 matcher: (url): UrlMatchResult => {
diff --git a/client/src/app/core/users/user.model.ts b/client/src/app/core/users/user.model.ts
index f211051ce..6ba30e4b8 100644
--- a/client/src/app/core/users/user.model.ts
+++ b/client/src/app/core/users/user.model.ts
@@ -132,8 +132,8 @@ export class User implements UserServerModel {
132 } 132 }
133 } 133 }
134 134
135 updateAccountAvatar (newAccountAvatar?: ActorImage) { 135 updateAccountAvatar (newAccountAvatars?: ActorImage[]) {
136 if (newAccountAvatar) this.account.updateAvatar(newAccountAvatar) 136 if (newAccountAvatars) this.account.updateAvatar(newAccountAvatars)
137 else this.account.resetAvatar() 137 else this.account.resetAvatar()
138 } 138 }
139 139
diff --git a/client/src/app/core/users/user.service.ts b/client/src/app/core/users/user.service.ts
index b14bff193..b4024c02d 100644
--- a/client/src/app/core/users/user.service.ts
+++ b/client/src/app/core/users/user.service.ts
@@ -118,7 +118,7 @@ export class UserService {
118 changeAvatar (avatarForm: FormData) { 118 changeAvatar (avatarForm: FormData) {
119 const url = UserService.BASE_USERS_URL + 'me/avatar/pick' 119 const url = UserService.BASE_USERS_URL + 'me/avatar/pick'
120 120
121 return this.authHttp.post<{ avatar: ActorImage }>(url, avatarForm) 121 return this.authHttp.post<{ avatars: ActorImage[] }>(url, avatarForm)
122 .pipe(catchError(err => this.restExtractor.handleError(err))) 122 .pipe(catchError(err => this.restExtractor.handleError(err)))
123 } 123 }
124 124
diff --git a/client/src/app/helpers/utils/channel.ts b/client/src/app/helpers/utils/channel.ts
index 93863a8af..83f36b70f 100644
--- a/client/src/app/helpers/utils/channel.ts
+++ b/client/src/app/helpers/utils/channel.ts
@@ -1,8 +1,10 @@
1import { minBy } from 'lodash-es'
1import { first, map } from 'rxjs/operators' 2import { first, map } from 'rxjs/operators'
2import { SelectChannelItem } from 'src/types/select-options-item.model' 3import { SelectChannelItem } from 'src/types/select-options-item.model'
4import { VideoChannel } from '@shared/models'
3import { AuthService } from '../../core/auth' 5import { AuthService } from '../../core/auth'
4 6
5function listUserChannels (authService: AuthService) { 7function listUserChannelsForSelect (authService: AuthService) {
6 return authService.userInformationLoaded 8 return authService.userInformationLoaded
7 .pipe( 9 .pipe(
8 first(), 10 first(),
@@ -23,12 +25,20 @@ function listUserChannels (authService: AuthService) {
23 id: c.id, 25 id: c.id,
24 label: c.displayName, 26 label: c.displayName,
25 support: c.support, 27 support: c.support,
26 avatarPath: c.avatar?.path 28 avatarPath: getAvatarPath(c)
27 }) as SelectChannelItem) 29 }) as SelectChannelItem)
28 }) 30 })
29 ) 31 )
30} 32}
31 33
32export { 34export {
33 listUserChannels 35 listUserChannelsForSelect
36}
37
38// ---------------------------------------------------------------------------
39
40function getAvatarPath (c: VideoChannel) {
41 if (!c.avatars || c.avatars.length === 0) return undefined
42
43 return minBy(c.avatars, 'width').path
34} 44}
diff --git a/client/src/app/helpers/utils/ui.ts b/client/src/app/helpers/utils/dom.ts
index ac8298926..f65e4d726 100644
--- a/client/src/app/helpers/utils/ui.ts
+++ b/client/src/app/helpers/utils/dom.ts
@@ -6,14 +6,24 @@ function scrollToTop (behavior: 'auto' | 'smooth' = 'auto') {
6 }) 6 })
7} 7}
8 8
9function isInViewport (el: HTMLElement) { 9function isInViewport (el: HTMLElement, container: HTMLElement = document.documentElement) {
10 const bounding = el.getBoundingClientRect() 10 const boundingEl = el.getBoundingClientRect()
11 return ( 11 const boundingContainer = container.getBoundingClientRect()
12 bounding.top >= 0 && 12
13 bounding.left >= 0 && 13 const relativePos = {
14 bounding.bottom <= (window.innerHeight || document.documentElement.clientHeight) && 14 top: 0,
15 bounding.right <= (window.innerWidth || document.documentElement.clientWidth) 15 left: 0,
16 ) 16 bottom: 0,
17 right: 0
18 }
19
20 relativePos.top = boundingEl.top - boundingContainer.top
21 relativePos.left = boundingEl.left - boundingContainer.left
22
23 return relativePos.top >= 0 &&
24 relativePos.left >= 0 &&
25 boundingEl.bottom <= boundingContainer.bottom &&
26 boundingEl.right <= boundingContainer.right
17} 27}
18 28
19function isXPercentInViewport (el: HTMLElement, percentVisible: number) { 29function isXPercentInViewport (el: HTMLElement, percentVisible: number) {
diff --git a/client/src/app/helpers/utils/index.ts b/client/src/app/helpers/utils/index.ts
index dc09c92ab..f821985c9 100644
--- a/client/src/app/helpers/utils/index.ts
+++ b/client/src/app/helpers/utils/index.ts
@@ -2,6 +2,6 @@ export * from './channel'
2export * from './date' 2export * from './date'
3export * from './html' 3export * from './html'
4export * from './object' 4export * from './object'
5export * from './ui' 5export * from './dom'
6export * from './upload' 6export * from './upload'
7export * from './url' 7export * from './url'
diff --git a/client/src/app/helpers/utils/url.ts b/client/src/app/helpers/utils/url.ts
index b3cded8f4..08c27e3c1 100644
--- a/client/src/app/helpers/utils/url.ts
+++ b/client/src/app/helpers/utils/url.ts
@@ -13,6 +13,10 @@ function getAbsoluteAPIUrl () {
13 return absoluteAPIUrl 13 return absoluteAPIUrl
14} 14}
15 15
16function getAPIHost () {
17 return new URL(getAbsoluteAPIUrl()).host
18}
19
16function getAbsoluteEmbedUrl () { 20function getAbsoluteEmbedUrl () {
17 let absoluteEmbedUrl = environment.originServerUrl 21 let absoluteEmbedUrl = environment.originServerUrl
18 if (!absoluteEmbedUrl) { 22 if (!absoluteEmbedUrl) {
@@ -52,5 +56,6 @@ function objectToFormData (obj: any, form?: FormData, namespace?: string) {
52export { 56export {
53 objectToFormData, 57 objectToFormData,
54 getAbsoluteAPIUrl, 58 getAbsoluteAPIUrl,
59 getAPIHost,
55 getAbsoluteEmbedUrl 60 getAbsoluteEmbedUrl
56} 61}
diff --git a/client/src/app/modal/account-setup-warning-modal.component.ts b/client/src/app/modal/account-setup-warning-modal.component.ts
index c78de447d..c4cbf92b6 100644
--- a/client/src/app/modal/account-setup-warning-modal.component.ts
+++ b/client/src/app/modal/account-setup-warning-modal.component.ts
@@ -32,7 +32,7 @@ export class AccountSetupWarningModalComponent {
32 } 32 }
33 33
34 hasAccountAvatar (user: User) { 34 hasAccountAvatar (user: User) {
35 return !!user.account.avatar 35 return user.account.avatars.length !== 0
36 } 36 }
37 37
38 hasAccountDescription (user: User) { 38 hasAccountDescription (user: User) {
diff --git a/client/src/app/shared/form-validators/video-captions-validators.ts b/client/src/app/shared/form-validators/video-captions-validators.ts
index a16216422..e589fe934 100644
--- a/client/src/app/shared/form-validators/video-captions-validators.ts
+++ b/client/src/app/shared/form-validators/video-captions-validators.ts
@@ -14,3 +14,10 @@ export const VIDEO_CAPTION_FILE_VALIDATOR: BuildFormValidator = {
14 required: $localize`Video caption file is required.` 14 required: $localize`Video caption file is required.`
15 } 15 }
16} 16}
17
18export const VIDEO_CAPTION_FILE_CONTENT_VALIDATOR: BuildFormValidator = {
19 VALIDATORS: [ Validators.required ],
20 MESSAGES: {
21 required: $localize`Caption content is required.`
22 }
23}
diff --git a/client/src/app/shared/shared-abuse-list/abuse-list-table.component.html b/client/src/app/shared/shared-abuse-list/abuse-list-table.component.html
index 07cc73461..f0a27c6e2 100644
--- a/client/src/app/shared/shared-abuse-list/abuse-list-table.component.html
+++ b/client/src/app/shared/shared-abuse-list/abuse-list-table.component.html
@@ -43,7 +43,7 @@
43 <td *ngIf="isAdminView()"> 43 <td *ngIf="isAdminView()">
44 <a *ngIf="abuse.reporterAccount" [href]="abuse.reporterAccount.url" i18n-title title="Open account in a new tab" target="_blank" rel="noopener noreferrer"> 44 <a *ngIf="abuse.reporterAccount" [href]="abuse.reporterAccount.url" i18n-title title="Open account in a new tab" target="_blank" rel="noopener noreferrer">
45 <div class="chip two-lines"> 45 <div class="chip two-lines">
46 <my-actor-avatar [account]="abuse.reporterAccount"></my-actor-avatar> 46 <my-actor-avatar [account]="abuse.reporterAccount" size="32"></my-actor-avatar>
47 <div> 47 <div>
48 {{ abuse.reporterAccount.displayName }} 48 {{ abuse.reporterAccount.displayName }}
49 <span>{{ abuse.reporterAccount.nameWithHost }}</span> 49 <span>{{ abuse.reporterAccount.nameWithHost }}</span>
diff --git a/client/src/app/shared/shared-actor-image-edit/actor-avatar-edit.component.ts b/client/src/app/shared/shared-actor-image-edit/actor-avatar-edit.component.ts
index 8b7d64ed3..01bb401fb 100644
--- a/client/src/app/shared/shared-actor-image-edit/actor-avatar-edit.component.ts
+++ b/client/src/app/shared/shared-actor-image-edit/actor-avatar-edit.component.ts
@@ -72,7 +72,7 @@ export class ActorAvatarEditComponent implements OnInit {
72 } 72 }
73 73
74 hasAvatar () { 74 hasAvatar () {
75 return !!this.preview || !!this.actor.avatar 75 return !!this.preview || this.actor.avatars.length !== 0
76 } 76 }
77 77
78 isChannel () { 78 isChannel () {
diff --git a/client/src/app/shared/shared-actor-image/actor-avatar.component.html b/client/src/app/shared/shared-actor-image/actor-avatar.component.html
index 13a5385a8..c285b6cc3 100644
--- a/client/src/app/shared/shared-actor-image/actor-avatar.component.html
+++ b/client/src/app/shared/shared-actor-image/actor-avatar.component.html
@@ -1,7 +1,7 @@
1<ng-template #img> 1<ng-template #img>
2 <img *ngIf="previewImage || avatarUrl || !initial" [class]="getClass('avatar')" [src]="previewImage || avatarUrl || defaultAvatarUrl" [alt]="alt" /> 2 <img *ngIf="previewImage || avatarUrl || !initial" [class]="getClass('avatar')" [src]="previewImage || avatarUrl || defaultAvatarUrl" [alt]="alt" />
3 3
4 <div *ngIf="!avatarUrl && initial" [class]="getClass('initial')"> 4 <div *ngIf="!avatarUrl && initial" [ngClass]="getClass('initial')">
5 <span>{{ initial }}</span> 5 <span>{{ initial }}</span>
6 </div> 6 </div>
7</ng-template> 7</ng-template>
diff --git a/client/src/app/shared/shared-actor-image/actor-avatar.component.scss b/client/src/app/shared/shared-actor-image/actor-avatar.component.scss
index a2424b593..68bf74553 100644
--- a/client/src/app/shared/shared-actor-image/actor-avatar.component.scss
+++ b/client/src/app/shared/shared-actor-image/actor-avatar.component.scss
@@ -20,38 +20,23 @@
20 } 20 }
21} 21}
22 22
23.avatar-18 { 23$sizes: '18', '25', '28', '32', '34', '35', '36', '40', '48', '75', '80', '100', '120';
24 --avatarSize: 18px;
25 --initialFontSize: 13px;
26}
27 24
28.avatar-25 { 25@each $size in $sizes {
29 --avatarSize: 25px; 26 .avatar-#{$size} {
27 --avatarSize: #{$size}px;
28 }
30} 29}
31 30
32.avatar-32 { 31.avatar-18 {
33 --avatarSize: 32px; 32 --initialFontSize: 13px;
34}
35
36.avatar-34 {
37 --avatarSize: 34px;
38}
39
40.avatar-36 {
41 --avatarSize: 36px;
42}
43
44.avatar-40 {
45 --avatarSize: 40px;
46} 33}
47 34
48.avatar-100 { 35.avatar-100 {
49 --avatarSize: 100px;
50 --initialFontSize: 40px; 36 --initialFontSize: 40px;
51} 37}
52 38
53.avatar-120 { 39.avatar-120 {
54 --avatarSize: 120px;
55 --initialFontSize: 46px; 40 --initialFontSize: 46px;
56} 41}
57 42
diff --git a/client/src/app/shared/shared-actor-image/actor-avatar.component.ts b/client/src/app/shared/shared-actor-image/actor-avatar.component.ts
index c323dc724..b2e1ef46e 100644
--- a/client/src/app/shared/shared-actor-image/actor-avatar.component.ts
+++ b/client/src/app/shared/shared-actor-image/actor-avatar.component.ts
@@ -4,11 +4,11 @@ import { Account } from '../shared-main/account/account.model'
4 4
5type ActorInput = { 5type ActorInput = {
6 name: string 6 name: string
7 avatar?: { url?: string, path: string } 7 avatars: { width: number, url?: string, path: string }[]
8 url: string 8 url: string
9} 9}
10 10
11export type ActorAvatarSize = '18' | '25' | '32' | '34' | '36' | '40' | '100' | '120' 11export type ActorAvatarSize = '18' | '25' | '28' | '32' | '34' | '35' | '36' | '40' | '48' | '75' | '80' | '100' | '120'
12 12
13@Component({ 13@Component({
14 selector: 'my-actor-avatar', 14 selector: 'my-actor-avatar',
@@ -23,7 +23,7 @@ export class ActorAvatarComponent {
23 23
24 @Input() previewImage: string 24 @Input() previewImage: string
25 25
26 @Input() size: ActorAvatarSize 26 @Input() size: ActorAvatarSize = '32'
27 27
28 // Use an external link 28 // Use an external link
29 @Input() href: string 29 @Input() href: string
@@ -50,14 +50,13 @@ export class ActorAvatarComponent {
50 } 50 }
51 51
52 get defaultAvatarUrl () { 52 get defaultAvatarUrl () {
53 if (this.channel) return VideoChannel.GET_DEFAULT_AVATAR_URL() 53 if (this.account) return Account.GET_DEFAULT_AVATAR_URL(+this.size)
54 54 if (this.channel) return VideoChannel.GET_DEFAULT_AVATAR_URL(+this.size)
55 return Account.GET_DEFAULT_AVATAR_URL()
56 } 55 }
57 56
58 get avatarUrl () { 57 get avatarUrl () {
59 if (this.account) return Account.GET_ACTOR_AVATAR_URL(this.account) 58 if (this.account) return Account.GET_ACTOR_AVATAR_URL(this.account, +this.size)
60 if (this.channel) return VideoChannel.GET_ACTOR_AVATAR_URL(this.channel) 59 if (this.channel) return VideoChannel.GET_ACTOR_AVATAR_URL(this.channel, +this.size)
61 60
62 return '' 61 return ''
63 } 62 }
@@ -90,9 +89,11 @@ export class ActorAvatarComponent {
90 } 89 }
91 90
92 private getColorTheme () { 91 private getColorTheme () {
92 const initialLowercase = this.initial.toLowerCase()
93
93 // Keep consistency with CSS 94 // Keep consistency with CSS
94 const themes = { 95 const themes = {
95 abc: 'blue', 96 '0123456789abc': 'blue',
96 def: 'green', 97 def: 'green',
97 ghi: 'purple', 98 ghi: 'purple',
98 jkl: 'gray', 99 jkl: 'gray',
@@ -103,7 +104,7 @@ export class ActorAvatarComponent {
103 } 104 }
104 105
105 const theme = Object.keys(themes) 106 const theme = Object.keys(themes)
106 .find(chars => chars.includes(this.initial)) 107 .find(chars => chars.includes(initialLowercase))
107 108
108 return themes[theme] 109 return themes[theme]
109 } 110 }
diff --git a/client/src/app/shared/shared-custom-markup/peertube-custom-tags/channel-miniature-markup.component.html b/client/src/app/shared/shared-custom-markup/peertube-custom-tags/channel-miniature-markup.component.html
index de150aac9..52a402329 100644
--- a/client/src/app/shared/shared-custom-markup/peertube-custom-tags/channel-miniature-markup.component.html
+++ b/client/src/app/shared/shared-custom-markup/peertube-custom-tags/channel-miniature-markup.component.html
@@ -1,7 +1,7 @@
1<div *ngIf="channel" class="channel"> 1<div *ngIf="channel" class="channel">
2 2
3 <div class="channel-avatar-row"> 3 <div class="channel-avatar-row">
4 <my-actor-avatar [channel]="channel" [internalHref]="getVideoChannelLink()" i18n-title title="See this video channel"></my-actor-avatar> 4 <my-actor-avatar [channel]="channel" [internalHref]="getVideoChannelLink()" i18n-title title="See this video channel" size="75"></my-actor-avatar>
5 5
6 <h6> 6 <h6>
7 <a [routerLink]="getVideoChannelLink()" i18n-title title="See this video channel"> 7 <a [routerLink]="getVideoChannelLink()" i18n-title title="See this video channel">
diff --git a/client/src/app/shared/shared-custom-markup/peertube-custom-tags/channel-miniature-markup.component.scss b/client/src/app/shared/shared-custom-markup/peertube-custom-tags/channel-miniature-markup.component.scss
index 39e8d2091..e8ef478d9 100644
--- a/client/src/app/shared/shared-custom-markup/peertube-custom-tags/channel-miniature-markup.component.scss
+++ b/client/src/app/shared/shared-custom-markup/peertube-custom-tags/channel-miniature-markup.component.scss
@@ -26,8 +26,6 @@
26 } 26 }
27 27
28 my-actor-avatar { 28 my-actor-avatar {
29 @include actor-avatar-size(75px);
30
31 grid-column: 1; 29 grid-column: 1;
32 grid-row: 1 / 4; 30 grid-row: 1 / 4;
33 } 31 }
diff --git a/client/src/app/shared/shared-forms/form-reactive.ts b/client/src/app/shared/shared-forms/form-reactive.ts
index 07a12c6f6..6b3a6c773 100644
--- a/client/src/app/shared/shared-forms/form-reactive.ts
+++ b/client/src/app/shared/shared-forms/form-reactive.ts
@@ -24,7 +24,7 @@ export abstract class FormReactive {
24 this.formErrors = formErrors 24 this.formErrors = formErrors
25 this.validationMessages = validationMessages 25 this.validationMessages = validationMessages
26 26
27 this.form.statusChanges.subscribe(async status => { 27 this.form.statusChanges.subscribe(async () => {
28 // FIXME: remove when https://github.com/angular/angular/issues/41519 is fixed 28 // FIXME: remove when https://github.com/angular/angular/issues/41519 is fixed
29 await this.waitPendingCheck() 29 await this.waitPendingCheck()
30 30
diff --git a/client/src/app/shared/shared-forms/form-validator.service.ts b/client/src/app/shared/shared-forms/form-validator.service.ts
index 0fe50ac9b..f67d5bb33 100644
--- a/client/src/app/shared/shared-forms/form-validator.service.ts
+++ b/client/src/app/shared/shared-forms/form-validator.service.ts
@@ -30,7 +30,7 @@ export class FormValidatorService {
30 30
31 if (field?.MESSAGES) validationMessages[name] = field.MESSAGES as { [ name: string ]: string } 31 if (field?.MESSAGES) validationMessages[name] = field.MESSAGES as { [ name: string ]: string }
32 32
33 const defaultValue = defaultValues[name] || '' 33 const defaultValue = defaultValues[name] ?? ''
34 34
35 if (field?.VALIDATORS) group[name] = [ defaultValue, field.VALIDATORS ] 35 if (field?.VALIDATORS) group[name] = [ defaultValue, field.VALIDATORS ]
36 else group[name] = [ defaultValue ] 36 else group[name] = [ defaultValue ]
diff --git a/client/src/app/shared/shared-forms/select/select-channel.component.ts b/client/src/app/shared/shared-forms/select/select-channel.component.ts
index 40a7c53bb..5fcae0050 100644
--- a/client/src/app/shared/shared-forms/select/select-channel.component.ts
+++ b/client/src/app/shared/shared-forms/select/select-channel.component.ts
@@ -31,7 +31,7 @@ export class SelectChannelComponent implements ControlValueAccessor, OnChanges {
31 this.channels = this.items.map(c => { 31 this.channels = this.items.map(c => {
32 const avatarPath = c.avatarPath 32 const avatarPath = c.avatarPath
33 ? c.avatarPath 33 ? c.avatarPath
34 : VideoChannel.GET_DEFAULT_AVATAR_URL() 34 : VideoChannel.GET_DEFAULT_AVATAR_URL(20)
35 35
36 return Object.assign({}, c, { avatarPath }) 36 return Object.assign({}, c, { avatarPath })
37 }) 37 })
diff --git a/client/src/app/shared/shared-forms/timestamp-input.component.html b/client/src/app/shared/shared-forms/timestamp-input.component.html
index c57a4b32c..c89a7b019 100644
--- a/client/src/app/shared/shared-forms/timestamp-input.component.html
+++ b/client/src/app/shared/shared-forms/timestamp-input.component.html
@@ -1,4 +1,5 @@
1<p-inputMask 1<p-inputMask
2 [disabled]="disabled" [(ngModel)]="timestampString" (onBlur)="onBlur()" 2 [disabled]="disabled" [(ngModel)]="timestampString" (onBlur)="onBlur()"
3 mask="9:99:99" slotChar="0" (ngModelChange)="onModelChange()" 3 [ngClass]="{ 'border-disabled': disableBorder }"
4 mask="9:99:99" slotChar="0" (ngModelChange)="onModelChange()" [inputId]="inputName"
4></p-inputMask> 5></p-inputMask>
diff --git a/client/src/app/shared/shared-forms/timestamp-input.component.scss b/client/src/app/shared/shared-forms/timestamp-input.component.scss
index d2358c027..27d6fa173 100644
--- a/client/src/app/shared/shared-forms/timestamp-input.component.scss
+++ b/client/src/app/shared/shared-forms/timestamp-input.component.scss
@@ -1,10 +1,10 @@
1@use '_variables' as *; 1@use '_variables' as *;
2@use '_mixins' as *;
2 3
3p-inputmask { 4p-inputmask {
4 ::ng-deep input { 5 ::ng-deep input {
5 width: 80px; 6 width: 80px;
6 font-size: 15px; 7 font-size: 15px;
7 border: 0;
8 8
9 &:focus-within, 9 &:focus-within,
10 &:focus { 10 &:focus {
@@ -16,4 +16,16 @@ p-inputmask {
16 opacity: 0.5; 16 opacity: 0.5;
17 } 17 }
18 } 18 }
19
20 &.border-disabled {
21 ::ng-deep input {
22 border: 0;
23 }
24 }
25
26 &:not(.border-disabled) {
27 ::ng-deep input {
28 @include peertube-input-text(80px);
29 }
30 }
19} 31}
diff --git a/client/src/app/shared/shared-forms/timestamp-input.component.ts b/client/src/app/shared/shared-forms/timestamp-input.component.ts
index 3fc705905..79ca63673 100644
--- a/client/src/app/shared/shared-forms/timestamp-input.component.ts
+++ b/client/src/app/shared/shared-forms/timestamp-input.component.ts
@@ -18,6 +18,8 @@ export class TimestampInputComponent implements ControlValueAccessor, OnInit {
18 @Input() maxTimestamp: number 18 @Input() maxTimestamp: number
19 @Input() timestamp: number 19 @Input() timestamp: number
20 @Input() disabled = false 20 @Input() disabled = false
21 @Input() inputName: string
22 @Input() disableBorder = true
21 23
22 @Output() inputBlur = new EventEmitter() 24 @Output() inputBlur = new EventEmitter()
23 25
diff --git a/client/src/app/shared/shared-instance/instance-statistics.component.html b/client/src/app/shared/shared-instance/instance-statistics.component.html
index 399cf10fe..2ca61fd94 100644
--- a/client/src/app/shared/shared-instance/instance-statistics.component.html
+++ b/client/src/app/shared/shared-instance/instance-statistics.component.html
@@ -1,13 +1,13 @@
1<p i18n *ngIf="null === serverStats">Loading instance statistics...</p> 1<p i18n *ngIf="null === serverStats">Loading instance statistics...</p>
2 2
3<section *ngIf="null !== serverStats"> 3<section *ngIf="null !== serverStats">
4 <h3 i18n>Local</h3> 4 <h3 i18n>By users on this instance</h3>
5 5
6 <div class="row"> 6 <div class="row">
7 <div class="col-6 col-lg-4 col-xl-3"> 7 <div class="col-6 col-lg-4 col-xl-3">
8 <div class="card stat"> 8 <div class="card stat">
9 <div class="card-body"> 9 <div class="card-body">
10 <p class="stat-value">{{ serverStats.totalUsers }}</p> 10 <p class="stat-value">{{ serverStats.totalUsers | number }}</p>
11 <p class="stat-label" i18n>users</p> 11 <p class="stat-label" i18n>users</p>
12 </div> 12 </div>
13 <i class="glyphicon glyphicon-user icon-bottom"></i> 13 <i class="glyphicon glyphicon-user icon-bottom"></i>
@@ -17,7 +17,7 @@
17 <div class="col-6 col-lg-4 col-xl-3"> 17 <div class="col-6 col-lg-4 col-xl-3">
18 <div class="card stat"> 18 <div class="card stat">
19 <div class="card-body"> 19 <div class="card-body">
20 <p class="stat-value">{{ serverStats.totalLocalVideos }}</p> 20 <p class="stat-value">{{ serverStats.totalLocalVideos | number }}</p>
21 <p class="stat-label" i18n>videos</p> 21 <p class="stat-label" i18n>videos</p>
22 </div> 22 </div>
23 <i class="glyphicon glyphicon-facetime-video"></i> 23 <i class="glyphicon glyphicon-facetime-video"></i>
@@ -27,8 +27,8 @@
27 <div class="col-6 col-lg-4 col-xl-3"> 27 <div class="col-6 col-lg-4 col-xl-3">
28 <div class="card stat"> 28 <div class="card stat">
29 <div class="card-body"> 29 <div class="card-body">
30 <p class="stat-value">{{ serverStats.totalLocalVideoViews }}</p> 30 <p class="stat-value">{{ serverStats.totalLocalVideoViews | number }}</p>
31 <p class="stat-label" i18n>video views</p> 31 <p class="stat-label" i18n>views</p>
32 </div> 32 </div>
33 <i class="glyphicon glyphicon-eye-open"></i> 33 <i class="glyphicon glyphicon-eye-open"></i>
34 </div> 34 </div>
@@ -37,8 +37,8 @@
37 <div class="col-6 col-lg-4 col-xl-3"> 37 <div class="col-6 col-lg-4 col-xl-3">
38 <div class="card stat"> 38 <div class="card stat">
39 <div class="card-body"> 39 <div class="card-body">
40 <p class="stat-value">{{ serverStats.totalLocalVideoComments }}</p> 40 <p class="stat-value">{{ serverStats.totalLocalVideoComments | number }}</p>
41 <p class="stat-label" i18n>video comments</p> 41 <p class="stat-label" i18n>comments</p>
42 </div> 42 </div>
43 <i class="glyphicon glyphicon-comment"></i> 43 <i class="glyphicon glyphicon-comment"></i>
44 </div> 44 </div>
@@ -48,20 +48,20 @@
48 <div class="card stat"> 48 <div class="card stat">
49 <div class="card-body"> 49 <div class="card-body">
50 <p class="stat-value">{{ serverStats.totalLocalVideoFilesSize | bytes:1 }}</p> 50 <p class="stat-value">{{ serverStats.totalLocalVideoFilesSize | bytes:1 }}</p>
51 <p class="stat-label" i18n>of hosted video</p> 51 <p class="stat-label" i18n>hosted video</p>
52 </div> 52 </div>
53 <i class="glyphicon glyphicon-hdd"></i> 53 <i class="glyphicon glyphicon-hdd"></i>
54 </div> 54 </div>
55 </div> 55 </div>
56 </div> 56 </div>
57 57
58 <h3 i18n>Federation</h3> 58 <h3 i18n>In this instance federation</h3>
59 59
60 <div class="row"> 60 <div class="row">
61 <div class="col-6 col-lg-4 col-xl-3"> 61 <div class="col-6 col-lg-4 col-xl-3">
62 <div class="card stat"> 62 <div class="card stat">
63 <div class="card-body"> 63 <div class="card-body">
64 <p class="stat-value">{{ serverStats.totalVideos }}</p> 64 <p class="stat-value">{{ serverStats.totalVideos | number }}</p>
65 <p class="stat-label" i18n>videos</p> 65 <p class="stat-label" i18n>videos</p>
66 </div> 66 </div>
67 <i class="glyphicon glyphicon-facetime-video"></i> 67 <i class="glyphicon glyphicon-facetime-video"></i>
@@ -71,8 +71,8 @@
71 <div class="col-6 col-lg-4 col-xl-3"> 71 <div class="col-6 col-lg-4 col-xl-3">
72 <div class="card stat"> 72 <div class="card stat">
73 <div class="card-body"> 73 <div class="card-body">
74 <p class="stat-value">{{ serverStats.totalVideoComments }}</p> 74 <p class="stat-value">{{ serverStats.totalVideoComments | number }}</p>
75 <p class="stat-label" i18n>video comments</p> 75 <p class="stat-label" i18n>comments</p>
76 </div> 76 </div>
77 <i class="glyphicon glyphicon-comment"></i> 77 <i class="glyphicon glyphicon-comment"></i>
78 </div> 78 </div>
@@ -81,7 +81,7 @@
81 <div class="col-6 col-lg-4 col-xl-3"> 81 <div class="col-6 col-lg-4 col-xl-3">
82 <div class="card stat"> 82 <div class="card stat">
83 <div class="card-body"> 83 <div class="card-body">
84 <p class="stat-value">{{ serverStats.totalInstanceFollowers }}</p> 84 <p class="stat-value">{{ serverStats.totalInstanceFollowers | number }}</p>
85 <p class="stat-label" i18n>followers</p> 85 <p class="stat-label" i18n>followers</p>
86 </div> 86 </div>
87 <i class="glyphicon glyphicon-retweet"></i> 87 <i class="glyphicon glyphicon-retweet"></i>
@@ -91,7 +91,7 @@
91 <div class="col-6 col-lg-4 col-xl-3"> 91 <div class="col-6 col-lg-4 col-xl-3">
92 <div class="card stat"> 92 <div class="card stat">
93 <div class="card-body"> 93 <div class="card-body">
94 <p class="stat-value">{{ serverStats.totalInstanceFollowing }}</p> 94 <p class="stat-value">{{ serverStats.totalInstanceFollowing | number }}</p>
95 <p class="stat-label" i18n>following</p> 95 <p class="stat-label" i18n>following</p>
96 </div> 96 </div>
97 <i class="glyphicon glyphicon-retweet"></i> 97 <i class="glyphicon glyphicon-retweet"></i>
diff --git a/client/src/app/shared/shared-main/account/account.model.ts b/client/src/app/shared/shared-main/account/account.model.ts
index 8b78d01a6..a26a9c11c 100644
--- a/client/src/app/shared/shared-main/account/account.model.ts
+++ b/client/src/app/shared/shared-main/account/account.model.ts
@@ -17,11 +17,15 @@ export class Account extends Actor implements ServerAccount {
17 17
18 userId?: number 18 userId?: number
19 19
20 static GET_ACTOR_AVATAR_URL (actor: { avatar?: { url?: string, path: string } }) { 20 static GET_ACTOR_AVATAR_URL (actor: { avatars: { width: number, url?: string, path: string }[] }, size: number) {
21 return Actor.GET_ACTOR_AVATAR_URL(actor) 21 return Actor.GET_ACTOR_AVATAR_URL(actor, size)
22 } 22 }
23 23
24 static GET_DEFAULT_AVATAR_URL () { 24 static GET_DEFAULT_AVATAR_URL (size: number) {
25 if (size <= 48) {
26 return `${window.location.origin}/client/assets/images/default-avatar-account-48x48.png`
27 }
28
25 return `${window.location.origin}/client/assets/images/default-avatar-account.png` 29 return `${window.location.origin}/client/assets/images/default-avatar-account.png`
26 } 30 }
27 31
@@ -42,12 +46,12 @@ export class Account extends Actor implements ServerAccount {
42 this.mutedServerByInstance = false 46 this.mutedServerByInstance = false
43 } 47 }
44 48
45 updateAvatar (newAvatar: ActorImage) { 49 updateAvatar (newAvatars: ActorImage[]) {
46 this.avatar = newAvatar 50 this.avatars = newAvatars
47 } 51 }
48 52
49 resetAvatar () { 53 resetAvatar () {
50 this.avatar = null 54 this.avatars = []
51 } 55 }
52 56
53 updateBlockStatus (blockStatus: BlockStatus) { 57 updateBlockStatus (blockStatus: BlockStatus) {
diff --git a/client/src/app/shared/shared-main/account/actor.model.ts b/client/src/app/shared/shared-main/account/actor.model.ts
index 082f44fb9..bd693860d 100644
--- a/client/src/app/shared/shared-main/account/actor.model.ts
+++ b/client/src/app/shared/shared-main/account/actor.model.ts
@@ -1,4 +1,4 @@
1import { getAbsoluteAPIUrl } from '@app/helpers' 1import { getAbsoluteAPIUrl, getAPIHost } from '@app/helpers'
2import { Actor as ServerActor, ActorImage } from '@shared/models' 2import { Actor as ServerActor, ActorImage } from '@shared/models'
3 3
4export abstract class Actor implements ServerActor { 4export abstract class Actor implements ServerActor {
@@ -13,25 +13,26 @@ export abstract class Actor implements ServerActor {
13 13
14 createdAt: Date | string 14 createdAt: Date | string
15 15
16 avatar: ActorImage 16 // TODO: remove, deprecated in 4.2
17 avatar: never
18
19 avatars: ActorImage[]
17 20
18 isLocal: boolean 21 isLocal: boolean
19 22
20 static GET_ACTOR_AVATAR_URL (actor: { avatar?: { url?: string, path: string } }) { 23 static GET_ACTOR_AVATAR_URL (actor: { avatars: { width: number, url?: string, path: string }[] }, size: number) {
21 if (actor?.avatar?.url) return actor.avatar.url 24 const avatar = actor.avatars.sort((a, b) => a.width - b.width).find(a => a.width >= size)
22 25
23 if (actor?.avatar) { 26 if (!avatar) return ''
24 const absoluteAPIUrl = getAbsoluteAPIUrl() 27 if (avatar.url) return avatar.url
25 28
26 return absoluteAPIUrl + actor.avatar.path 29 const absoluteAPIUrl = getAbsoluteAPIUrl()
27 }
28 30
29 return '' 31 return absoluteAPIUrl + avatar.path
30 } 32 }
31 33
32 static CREATE_BY_STRING (accountName: string, host: string, forceHostname = false) { 34 static CREATE_BY_STRING (accountName: string, host: string, forceHostname = false) {
33 const absoluteAPIUrl = getAbsoluteAPIUrl() 35 const thisHost = getAPIHost()
34 const thisHost = new URL(absoluteAPIUrl).host
35 36
36 if (host.trim() === thisHost && !forceHostname) return accountName 37 if (host.trim() === thisHost && !forceHostname) return accountName
37 38
@@ -39,8 +40,7 @@ export abstract class Actor implements ServerActor {
39 } 40 }
40 41
41 static IS_LOCAL (host: string) { 42 static IS_LOCAL (host: string) {
42 const absoluteAPIUrl = getAbsoluteAPIUrl() 43 const thisHost = getAPIHost()
43 const thisHost = new URL(absoluteAPIUrl).host
44 44
45 return host.trim() === thisHost 45 return host.trim() === thisHost
46 } 46 }
@@ -55,7 +55,7 @@ export abstract class Actor implements ServerActor {
55 55
56 if (hash.createdAt) this.createdAt = new Date(hash.createdAt.toString()) 56 if (hash.createdAt) this.createdAt = new Date(hash.createdAt.toString())
57 57
58 this.avatar = hash.avatar 58 this.avatars = hash.avatars
59 this.isLocal = Actor.IS_LOCAL(this.host) 59 this.isLocal = Actor.IS_LOCAL(this.host)
60 } 60 }
61} 61}
diff --git a/client/src/app/shared/shared-main/misc/channels-setup-message.component.ts b/client/src/app/shared/shared-main/misc/channels-setup-message.component.ts
index 702475029..4f9cbc525 100644
--- a/client/src/app/shared/shared-main/misc/channels-setup-message.component.ts
+++ b/client/src/app/shared/shared-main/misc/channels-setup-message.component.ts
@@ -19,7 +19,7 @@ export class ChannelsSetupMessageComponent implements OnInit {
19 hasChannelNotConfigured () { 19 hasChannelNotConfigured () {
20 if (!this.user.videoChannels) return false 20 if (!this.user.videoChannels) return false
21 21
22 return this.user.videoChannels.filter((channel: VideoChannel) => (!channel.avatar || !channel.description)).length > 0 22 return this.user.videoChannels.filter((channel: VideoChannel) => (channel.avatars.length === 0 || !channel.description)).length > 0
23 } 23 }
24 24
25 ngOnInit () { 25 ngOnInit () {
diff --git a/client/src/app/shared/shared-main/users/user-notification.model.ts b/client/src/app/shared/shared-main/users/user-notification.model.ts
index 439547102..1eb69d5a2 100644
--- a/client/src/app/shared/shared-main/users/user-notification.model.ts
+++ b/client/src/app/shared/shared-main/users/user-notification.model.ts
@@ -254,11 +254,11 @@ export class UserNotification implements UserNotificationServer {
254 return [ this.buildVideoUrl(comment.video), { threadId: comment.threadId } ] 254 return [ this.buildVideoUrl(comment.video), { threadId: comment.threadId } ]
255 } 255 }
256 256
257 private setAccountAvatarUrl (actor: { avatarUrl?: string, avatar?: { url?: string, path: string } }) { 257 private setAccountAvatarUrl (actor: { avatarUrl?: string, avatars: { width: number, url?: string, path: string }[] }) {
258 actor.avatarUrl = Account.GET_ACTOR_AVATAR_URL(actor) || Account.GET_DEFAULT_AVATAR_URL() 258 actor.avatarUrl = VideoChannel.GET_ACTOR_AVATAR_URL(actor, 48) || Account.GET_DEFAULT_AVATAR_URL(48)
259 } 259 }
260 260
261 private setVideoChannelAvatarUrl (actor: { avatarUrl?: string, avatar?: { url?: string, path: string } }) { 261 private setVideoChannelAvatarUrl (actor: { avatarUrl?: string, avatars: { width: number, url?: string, path: string }[] }) {
262 actor.avatarUrl = VideoChannel.GET_ACTOR_AVATAR_URL(actor) || VideoChannel.GET_DEFAULT_AVATAR_URL() 262 actor.avatarUrl = VideoChannel.GET_ACTOR_AVATAR_URL(actor, 48) || VideoChannel.GET_DEFAULT_AVATAR_URL(48)
263 } 263 }
264} 264}
diff --git a/client/src/app/shared/shared-main/video-caption/video-caption-edit.model.ts b/client/src/app/shared/shared-main/video-caption/video-caption-edit.model.ts
index 732f20158..129e80bc0 100644
--- a/client/src/app/shared/shared-main/video-caption/video-caption-edit.model.ts
+++ b/client/src/app/shared/shared-main/video-caption/video-caption-edit.model.ts
@@ -4,6 +4,8 @@ export interface VideoCaptionEdit {
4 label?: string 4 label?: string
5 } 5 }
6 6
7 action?: 'CREATE' | 'REMOVE' 7 action?: 'CREATE' | 'REMOVE' | 'UPDATE'
8 captionfile?: any 8 captionfile?: any
9} 9}
10
11export type VideoCaptionWithPathEdit = VideoCaptionEdit & { captionPath?: string }
diff --git a/client/src/app/shared/shared-main/video-caption/video-caption.service.ts b/client/src/app/shared/shared-main/video-caption/video-caption.service.ts
index 97b79d842..00ebe5bc6 100644
--- a/client/src/app/shared/shared-main/video-caption/video-caption.service.ts
+++ b/client/src/app/shared/shared-main/video-caption/video-caption.service.ts
@@ -7,6 +7,7 @@ import { objectToFormData, sortBy } from '@app/helpers'
7import { VideoService } from '@app/shared/shared-main/video' 7import { VideoService } from '@app/shared/shared-main/video'
8import { peertubeTranslate } from '@shared/core-utils/i18n' 8import { peertubeTranslate } from '@shared/core-utils/i18n'
9import { ResultList, VideoCaption } from '@shared/models' 9import { ResultList, VideoCaption } from '@shared/models'
10import { environment } from '../../../../environments/environment'
10import { VideoCaptionEdit } from './video-caption-edit.model' 11import { VideoCaptionEdit } from './video-caption-edit.model'
11 12
12@Injectable() 13@Injectable()
@@ -57,7 +58,7 @@ export class VideoCaptionService {
57 let obs: Observable<any> = of(undefined) 58 let obs: Observable<any> = of(undefined)
58 59
59 for (const videoCaption of videoCaptions) { 60 for (const videoCaption of videoCaptions) {
60 if (videoCaption.action === 'CREATE') { 61 if (videoCaption.action === 'CREATE' || videoCaption.action === 'UPDATE') {
61 obs = obs.pipe(switchMap(() => this.addCaption(videoId, videoCaption.language.id, videoCaption.captionfile))) 62 obs = obs.pipe(switchMap(() => this.addCaption(videoId, videoCaption.language.id, videoCaption.captionfile)))
62 } else if (videoCaption.action === 'REMOVE') { 63 } else if (videoCaption.action === 'REMOVE') {
63 obs = obs.pipe(switchMap(() => this.removeCaption(videoId, videoCaption.language.id))) 64 obs = obs.pipe(switchMap(() => this.removeCaption(videoId, videoCaption.language.id)))
@@ -66,4 +67,8 @@ export class VideoCaptionService {
66 67
67 return obs 68 return obs
68 } 69 }
70
71 getCaptionContent ({ captionPath }: Pick<VideoCaption, 'captionPath'>) {
72 return this.authHttp.get(environment.originServerUrl + captionPath, { responseType: 'text' })
73 }
69} 74}
diff --git a/client/src/app/shared/shared-main/video-channel/video-channel.model.ts b/client/src/app/shared/shared-main/video-channel/video-channel.model.ts
index ac2679b42..e22b0cfd0 100644
--- a/client/src/app/shared/shared-main/video-channel/video-channel.model.ts
+++ b/client/src/app/shared/shared-main/video-channel/video-channel.model.ts
@@ -12,7 +12,11 @@ export class VideoChannel extends Actor implements ServerVideoChannel {
12 nameWithHost: string 12 nameWithHost: string
13 nameWithHostForced: string 13 nameWithHostForced: string
14 14
15 banner: ActorImage 15 // TODO: remove, deprecated in 4.2
16 banner: never
17
18 banners: ActorImage[]
19
16 bannerUrl: string 20 bannerUrl: string
17 21
18 updatedAt: Date | string 22 updatedAt: Date | string
@@ -24,23 +28,25 @@ export class VideoChannel extends Actor implements ServerVideoChannel {
24 28
25 viewsPerDay?: ViewsPerDate[] 29 viewsPerDay?: ViewsPerDate[]
26 30
27 static GET_ACTOR_AVATAR_URL (actor: { avatar?: { url?: string, path: string } }) { 31 static GET_ACTOR_AVATAR_URL (actor: { avatars: { width: number, url?: string, path: string }[] }, size: number) {
28 return Actor.GET_ACTOR_AVATAR_URL(actor) 32 return Actor.GET_ACTOR_AVATAR_URL(actor, size)
29 } 33 }
30 34
31 static GET_ACTOR_BANNER_URL (channel: ServerVideoChannel) { 35 static GET_ACTOR_BANNER_URL (channel: ServerVideoChannel) {
32 if (channel?.banner?.url) return channel.banner.url 36 if (!channel) return ''
33
34 if (channel?.banner) {
35 const absoluteAPIUrl = getAbsoluteAPIUrl()
36 37
37 return absoluteAPIUrl + channel.banner.path 38 const banner = channel.banners[0]
38 } 39 if (!banner) return ''
39 40
40 return '' 41 if (banner.url) return banner.url
42 return getAbsoluteAPIUrl() + banner.path
41 } 43 }
42 44
43 static GET_DEFAULT_AVATAR_URL () { 45 static GET_DEFAULT_AVATAR_URL (size: number) {
46 if (size <= 48) {
47 return `${window.location.origin}/client/assets/images/default-avatar-video-channel-48x48.png`
48 }
49
44 return `${window.location.origin}/client/assets/images/default-avatar-video-channel.png` 50 return `${window.location.origin}/client/assets/images/default-avatar-video-channel.png`
45 } 51 }
46 52
@@ -51,7 +57,7 @@ export class VideoChannel extends Actor implements ServerVideoChannel {
51 this.description = hash.description 57 this.description = hash.description
52 this.support = hash.support 58 this.support = hash.support
53 59
54 this.banner = hash.banner 60 this.banners = hash.banners
55 61
56 this.isLocal = hash.isLocal 62 this.isLocal = hash.isLocal
57 63
@@ -74,24 +80,24 @@ export class VideoChannel extends Actor implements ServerVideoChannel {
74 this.updateComputedAttributes() 80 this.updateComputedAttributes()
75 } 81 }
76 82
77 updateAvatar (newAvatar: ActorImage) { 83 updateAvatar (newAvatars: ActorImage[]) {
78 this.avatar = newAvatar 84 this.avatars = newAvatars
79 85
80 this.updateComputedAttributes() 86 this.updateComputedAttributes()
81 } 87 }
82 88
83 resetAvatar () { 89 resetAvatar () {
84 this.updateAvatar(null) 90 this.updateAvatar([])
85 } 91 }
86 92
87 updateBanner (newBanner: ActorImage) { 93 updateBanner (newBanners: ActorImage[]) {
88 this.banner = newBanner 94 this.banners = newBanners
89 95
90 this.updateComputedAttributes() 96 this.updateComputedAttributes()
91 } 97 }
92 98
93 resetBanner () { 99 resetBanner () {
94 this.updateBanner(null) 100 this.updateBanner([])
95 } 101 }
96 102
97 updateComputedAttributes () { 103 updateComputedAttributes () {
diff --git a/client/src/app/shared/shared-main/video-channel/video-channel.service.ts b/client/src/app/shared/shared-main/video-channel/video-channel.service.ts
index f37f13c51..480d250fb 100644
--- a/client/src/app/shared/shared-main/video-channel/video-channel.service.ts
+++ b/client/src/app/shared/shared-main/video-channel/video-channel.service.ts
@@ -80,7 +80,7 @@ export class VideoChannelService {
80 changeVideoChannelImage (videoChannelName: string, avatarForm: FormData, type: 'avatar' | 'banner') { 80 changeVideoChannelImage (videoChannelName: string, avatarForm: FormData, type: 'avatar' | 'banner') {
81 const url = VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannelName + '/' + type + '/pick' 81 const url = VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannelName + '/' + type + '/pick'
82 82
83 return this.authHttp.post<{ avatar?: ActorImage, banner?: ActorImage }>(url, avatarForm) 83 return this.authHttp.post<{ avatars?: ActorImage[], banners?: ActorImage[] }>(url, avatarForm)
84 .pipe(catchError(err => this.restExtractor.handleError(err))) 84 .pipe(catchError(err => this.restExtractor.handleError(err)))
85 } 85 }
86 86
diff --git a/client/src/app/shared/shared-main/video/video.model.ts b/client/src/app/shared/shared-main/video/video.model.ts
index fe5643688..8e275181c 100644
--- a/client/src/app/shared/shared-main/video/video.model.ts
+++ b/client/src/app/shared/shared-main/video/video.model.ts
@@ -84,7 +84,11 @@ export class Video implements VideoServerModel {
84 displayName: string 84 displayName: string
85 url: string 85 url: string
86 host: string 86 host: string
87 avatar?: ActorImage 87
88 // TODO: remove, deprecated in 4.2
89 avatar: ActorImage
90
91 avatars: ActorImage[]
88 } 92 }
89 93
90 channel: { 94 channel: {
@@ -93,7 +97,11 @@ export class Video implements VideoServerModel {
93 displayName: string 97 displayName: string
94 url: string 98 url: string
95 host: string 99 host: string
96 avatar?: ActorImage 100
101 // TODO: remove, deprecated in 4.2
102 avatar: ActorImage
103
104 avatars: ActorImage[]
97 } 105 }
98 106
99 userHistory?: { 107 userHistory?: {
diff --git a/client/src/app/shared/shared-moderation/account-blocklist.component.html b/client/src/app/shared/shared-moderation/account-blocklist.component.html
index 637abcb51..0143194e9 100644
--- a/client/src/app/shared/shared-moderation/account-blocklist.component.html
+++ b/client/src/app/shared/shared-moderation/account-blocklist.component.html
@@ -33,7 +33,7 @@
33 <td> 33 <td>
34 <a [href]="accountBlock.blockedAccount.url" i18n-title title="Open account in a new tab" target="_blank" rel="noopener noreferrer"> 34 <a [href]="accountBlock.blockedAccount.url" i18n-title title="Open account in a new tab" target="_blank" rel="noopener noreferrer">
35 <div class="chip two-lines"> 35 <div class="chip two-lines">
36 <my-actor-avatar [account]="accountBlock.blockedAccount"></my-actor-avatar> 36 <my-actor-avatar [account]="accountBlock.blockedAccount" size="32"></my-actor-avatar>
37 <div> 37 <div>
38 {{ accountBlock.blockedAccount.displayName }} 38 {{ accountBlock.blockedAccount.displayName }}
39 <span class="text-muted">{{ accountBlock.blockedAccount.nameWithHost }}</span> 39 <span class="text-muted">{{ accountBlock.blockedAccount.nameWithHost }}</span>
diff --git a/client/src/app/shared/shared-moderation/blocklist.service.ts b/client/src/app/shared/shared-moderation/blocklist.service.ts
index f4836c6c4..3e92c2831 100644
--- a/client/src/app/shared/shared-moderation/blocklist.service.ts
+++ b/client/src/app/shared/shared-moderation/blocklist.service.ts
@@ -1,5 +1,6 @@
1import { SortMeta } from 'primeng/api' 1import { SortMeta } from 'primeng/api'
2import { catchError, map } from 'rxjs/operators' 2import { from } from 'rxjs'
3import { catchError, concatMap, map, toArray } from 'rxjs/operators'
3import { HttpClient, HttpParams } from '@angular/common/http' 4import { HttpClient, HttpParams } from '@angular/common/http'
4import { Injectable } from '@angular/core' 5import { Injectable } from '@angular/core'
5import { RestExtractor, RestPagination, RestService } from '@app/core' 6import { RestExtractor, RestPagination, RestService } from '@app/core'
@@ -120,11 +121,15 @@ export class BlocklistService {
120 ) 121 )
121 } 122 }
122 123
123 blockAccountByInstance (account: Pick<Account, 'nameWithHost'>) { 124 blockAccountByInstance (accountsArg: Pick<Account, 'nameWithHost'> | Pick<Account, 'nameWithHost'>[]) {
124 const body = { accountName: account.nameWithHost } 125 const accounts = Array.isArray(accountsArg) ? accountsArg : [ accountsArg ]
125 126
126 return this.authHttp.post(BlocklistService.BASE_SERVER_BLOCKLIST_URL + '/accounts', body) 127 return from(accounts)
127 .pipe(catchError(err => this.restExtractor.handleError(err))) 128 .pipe(
129 concatMap(a => this.authHttp.post(BlocklistService.BASE_SERVER_BLOCKLIST_URL + '/accounts', { accountName: a.nameWithHost })),
130 toArray(),
131 catchError(err => this.restExtractor.handleError(err))
132 )
128 } 133 }
129 134
130 unblockAccountByInstance (account: Pick<Account, 'nameWithHost'>) { 135 unblockAccountByInstance (account: Pick<Account, 'nameWithHost'>) {
diff --git a/client/src/app/shared/shared-moderation/user-ban-modal.component.html b/client/src/app/shared/shared-moderation/user-ban-modal.component.html
index b41ae230d..2b6726bdc 100644
--- a/client/src/app/shared/shared-moderation/user-ban-modal.component.html
+++ b/client/src/app/shared/shared-moderation/user-ban-modal.component.html
@@ -1,11 +1,15 @@
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</h4> 3 <h4 i18n class="modal-title">{{ getModalTitle() }}</h4>
4 4
5 <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon> 5 <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon>
6 </div> 6 </div>
7 7
8 <div class="modal-body"> 8 <div class="modal-body">
9 <div class="description" i18n>
10 A banned user will no longer be able to login.
11 </div>
12
9 <form novalidate [formGroup]="form" (ngSubmit)="banUser()"> 13 <form novalidate [formGroup]="form" (ngSubmit)="banUser()">
10 <div class="form-group"> 14 <div class="form-group">
11 <textarea 15 <textarea
@@ -17,8 +21,12 @@
17 </div> 21 </div>
18 </div> 22 </div>
19 23
20 <div i18n> 24 <div class="form-group">
21 A banned user will no longer be able to login. 25 <my-peertube-checkbox
26 inputName="banMute" formControlName="mute"
27 i18n-labelText labelText="Mute to also hide videos/comments"
28 >
29 </my-peertube-checkbox>
22 </div> 30 </div>
23 31
24 <div class="form-group inputs"> 32 <div class="form-group inputs">
@@ -27,7 +35,7 @@
27 (click)="hide()" (key.enter)="hide()" 35 (click)="hide()" (key.enter)="hide()"
28 > 36 >
29 37
30 <input type="submit" i18n-value [value]="modalMessage" class="peertube-button orange-button" [disabled]="!form.valid" /> 38 <input type="submit" i18n-value [value]="getModalTitle()" class="peertube-button orange-button" [disabled]="!form.valid" />
31 </div> 39 </div>
32 </form> 40 </form>
33 </div> 41 </div>
diff --git a/client/src/app/shared/shared-moderation/user-ban-modal.component.scss b/client/src/app/shared/shared-moderation/user-ban-modal.component.scss
index 08e072d8f..2c46c3d03 100644
--- a/client/src/app/shared/shared-moderation/user-ban-modal.component.scss
+++ b/client/src/app/shared/shared-moderation/user-ban-modal.component.scss
@@ -1,6 +1,11 @@
1@use '_variables' as *; 1@use '_variables' as *;
2@use '_mixins' as *; 2@use '_mixins' as *;
3 3
4.description {
5 font-size: 15px;
6 margin-bottom: 15px;
7}
8
4textarea { 9textarea {
5 @include peertube-textarea(100%, 60px); 10 @include peertube-textarea(100%, 60px);
6} 11}
diff --git a/client/src/app/shared/shared-moderation/user-ban-modal.component.ts b/client/src/app/shared/shared-moderation/user-ban-modal.component.ts
index 17cad18ec..9edfac388 100644
--- a/client/src/app/shared/shared-moderation/user-ban-modal.component.ts
+++ b/client/src/app/shared/shared-moderation/user-ban-modal.component.ts
@@ -1,3 +1,4 @@
1import { forkJoin } from 'rxjs'
1import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core' 2import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core'
2import { Notifier } from '@app/core' 3import { Notifier } from '@app/core'
3import { FormReactive, FormValidatorService } from '@app/shared/shared-forms' 4import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
@@ -5,7 +6,9 @@ import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
5import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref' 6import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
6import { User } from '@shared/models' 7import { User } from '@shared/models'
7import { USER_BAN_REASON_VALIDATOR } from '../form-validators/user-validators' 8import { USER_BAN_REASON_VALIDATOR } from '../form-validators/user-validators'
9import { Account } from '../shared-main'
8import { UserAdminService } from '../shared-users' 10import { UserAdminService } from '../shared-users'
11import { BlocklistService } from './blocklist.service'
9 12
10@Component({ 13@Component({
11 selector: 'my-user-ban-modal', 14 selector: 'my-user-ban-modal',
@@ -24,23 +27,22 @@ export class UserBanModalComponent extends FormReactive implements OnInit {
24 protected formValidatorService: FormValidatorService, 27 protected formValidatorService: FormValidatorService,
25 private modalService: NgbModal, 28 private modalService: NgbModal,
26 private notifier: Notifier, 29 private notifier: Notifier,
27 private userAdminService: UserAdminService 30 private userAdminService: UserAdminService,
31 private blocklistService: BlocklistService
28 ) { 32 ) {
29 super() 33 super()
30 } 34 }
31 35
32 ngOnInit () { 36 ngOnInit () {
33 this.buildForm({ 37 this.buildForm({
34 reason: USER_BAN_REASON_VALIDATOR 38 reason: USER_BAN_REASON_VALIDATOR,
39 mute: null
35 }) 40 })
36 } 41 }
37 42
38 openModal (user: User | User[]) { 43 openModal (user: User | User[]) {
39 this.usersToBan = user 44 this.usersToBan = user
40 this.openedModal = this.modalService.open(this.modal, { centered: true }) 45 this.openedModal = this.modalService.open(this.modal, { centered: true })
41
42 const isSingleUser = !(Array.isArray(this.usersToBan) && this.usersToBan.length > 1)
43 this.modalMessage = isSingleUser ? $localize`Ban this user` : $localize`Ban these users`
44 } 46 }
45 47
46 hide () { 48 hide () {
@@ -50,8 +52,15 @@ export class UserBanModalComponent extends FormReactive implements OnInit {
50 52
51 banUser () { 53 banUser () {
52 const reason = this.form.value['reason'] || undefined 54 const reason = this.form.value['reason'] || undefined
55 const mute = this.form.value['mute']
56
57 const observables = [
58 this.userAdminService.banUsers(this.usersToBan, reason)
59 ]
60
61 if (mute) observables.push(this.muteAccounts())
53 62
54 this.userAdminService.banUsers(this.usersToBan, reason) 63 forkJoin(observables)
55 .subscribe({ 64 .subscribe({
56 next: () => { 65 next: () => {
57 const message = Array.isArray(this.usersToBan) 66 const message = Array.isArray(this.usersToBan)
@@ -61,6 +70,7 @@ export class UserBanModalComponent extends FormReactive implements OnInit {
61 this.notifier.success(message) 70 this.notifier.success(message)
62 71
63 this.userBanned.emit(this.usersToBan) 72 this.userBanned.emit(this.usersToBan)
73
64 this.hide() 74 this.hide()
65 }, 75 },
66 76
@@ -68,4 +78,17 @@ export class UserBanModalComponent extends FormReactive implements OnInit {
68 }) 78 })
69 } 79 }
70 80
81 getModalTitle () {
82 if (Array.isArray(this.usersToBan)) return $localize`Ban ${this.usersToBan.length} users`
83
84 return $localize`Ban "${this.usersToBan.username}"`
85 }
86
87 private muteAccounts () {
88 const accounts = Array.isArray(this.usersToBan)
89 ? this.usersToBan.map(u => new Account(u.account))
90 : new Account(this.usersToBan.account)
91
92 return this.blocklistService.blockAccountByInstance(accounts)
93 }
71} 94}
diff --git a/client/src/app/shared/shared-moderation/user-moderation-dropdown.component.ts b/client/src/app/shared/shared-moderation/user-moderation-dropdown.component.ts
index 0d19565ef..787318c2c 100644
--- a/client/src/app/shared/shared-moderation/user-moderation-dropdown.component.ts
+++ b/client/src/app/shared/shared-moderation/user-moderation-dropdown.component.ts
@@ -7,6 +7,16 @@ import { BlocklistService } from './blocklist.service'
7import { BulkService } from './bulk.service' 7import { BulkService } from './bulk.service'
8import { UserBanModalComponent } from './user-ban-modal.component' 8import { UserBanModalComponent } from './user-ban-modal.component'
9 9
10export type AccountMutedStatus =
11 Pick<Account, 'id' | 'nameWithHost' | 'host' | 'userId' |
12 'mutedByInstance' | 'mutedByUser' | 'mutedServerByInstance' | 'mutedServerByUser'>
13
14export type UserModerationDisplayType = {
15 myAccount?: boolean
16 instanceAccount?: boolean
17 instanceUser?: boolean
18}
19
10@Component({ 20@Component({
11 selector: 'my-user-moderation-dropdown', 21 selector: 'my-user-moderation-dropdown',
12 templateUrl: './user-moderation-dropdown.component.html' 22 templateUrl: './user-moderation-dropdown.component.html'
@@ -15,8 +25,8 @@ export class UserModerationDropdownComponent implements OnInit, OnChanges {
15 @ViewChild('userBanModal') userBanModal: UserBanModalComponent 25 @ViewChild('userBanModal') userBanModal: UserBanModalComponent
16 26
17 @Input() user: User 27 @Input() user: User
18 @Input() account: Account 28 @Input() account: AccountMutedStatus
19 @Input() prependActions: DropdownAction<{ user: User, account: Account }>[] 29 @Input() prependActions: DropdownAction<{ user: User, account: AccountMutedStatus }>[]
20 30
21 @Input() buttonSize: 'normal' | 'small' = 'normal' 31 @Input() buttonSize: 'normal' | 'small' = 'normal'
22 @Input() buttonStyled = true 32 @Input() buttonStyled = true
@@ -24,10 +34,16 @@ export class UserModerationDropdownComponent implements OnInit, OnChanges {
24 @Input() label: string 34 @Input() label: string
25 @Input() container: 'body' | undefined = undefined 35 @Input() container: 'body' | undefined = undefined
26 36
37 @Input() displayOptions: UserModerationDisplayType = {
38 myAccount: true,
39 instanceAccount: true,
40 instanceUser: true
41 }
42
27 @Output() userChanged = new EventEmitter() 43 @Output() userChanged = new EventEmitter()
28 @Output() userDeleted = new EventEmitter() 44 @Output() userDeleted = new EventEmitter()
29 45
30 userActions: DropdownAction<{ user: User, account: Account }>[][] = [] 46 userActions: DropdownAction<{ user: User, account: AccountMutedStatus }>[][] = []
31 47
32 requiresEmailVerification = false 48 requiresEmailVerification = false
33 49
@@ -111,7 +127,7 @@ export class UserModerationDropdownComponent implements OnInit, OnChanges {
111 }) 127 })
112 } 128 }
113 129
114 blockAccountByUser (account: Account) { 130 blockAccountByUser (account: AccountMutedStatus) {
115 this.blocklistService.blockAccountByUser(account) 131 this.blocklistService.blockAccountByUser(account)
116 .subscribe({ 132 .subscribe({
117 next: () => { 133 next: () => {
@@ -125,7 +141,7 @@ export class UserModerationDropdownComponent implements OnInit, OnChanges {
125 }) 141 })
126 } 142 }
127 143
128 unblockAccountByUser (account: Account) { 144 unblockAccountByUser (account: AccountMutedStatus) {
129 this.blocklistService.unblockAccountByUser(account) 145 this.blocklistService.unblockAccountByUser(account)
130 .subscribe({ 146 .subscribe({
131 next: () => { 147 next: () => {
@@ -167,7 +183,7 @@ export class UserModerationDropdownComponent implements OnInit, OnChanges {
167 }) 183 })
168 } 184 }
169 185
170 blockAccountByInstance (account: Account) { 186 blockAccountByInstance (account: AccountMutedStatus) {
171 this.blocklistService.blockAccountByInstance(account) 187 this.blocklistService.blockAccountByInstance(account)
172 .subscribe({ 188 .subscribe({
173 next: () => { 189 next: () => {
@@ -181,7 +197,7 @@ export class UserModerationDropdownComponent implements OnInit, OnChanges {
181 }) 197 })
182 } 198 }
183 199
184 unblockAccountByInstance (account: Account) { 200 unblockAccountByInstance (account: AccountMutedStatus) {
185 this.blocklistService.unblockAccountByInstance(account) 201 this.blocklistService.unblockAccountByInstance(account)
186 .subscribe({ 202 .subscribe({
187 next: () => { 203 next: () => {
@@ -246,7 +262,7 @@ export class UserModerationDropdownComponent implements OnInit, OnChanges {
246 return user && this.authService.getUser().id === user.id 262 return user && this.authService.getUser().id === user.id
247 } 263 }
248 264
249 private isMyAccount (account: Account) { 265 private isMyAccount (account: AccountMutedStatus) {
250 return account && this.authService.getUser().account.id === account.id 266 return account && this.authService.getUser().account.id === account.id
251 } 267 }
252 268
@@ -267,9 +283,9 @@ export class UserModerationDropdownComponent implements OnInit, OnChanges {
267 } 283 }
268 284
269 private buildMyAccountModerationActions () { 285 private buildMyAccountModerationActions () {
270 if (!this.account || !this.authService.isLoggedIn()) return [] 286 if (!this.account || !this.displayOptions.myAccount || !this.authService.isLoggedIn()) return []
271 287
272 const myAccountActions: DropdownAction<{ user: User, account: Account }>[] = [ 288 const myAccountActions: DropdownAction<{ user: User, account: AccountMutedStatus }>[] = [
273 { 289 {
274 label: $localize`My account moderation`, 290 label: $localize`My account moderation`,
275 class: [ 'red' ], 291 class: [ 'red' ],
@@ -315,9 +331,9 @@ export class UserModerationDropdownComponent implements OnInit, OnChanges {
315 331
316 const authUser = this.authService.getUser() 332 const authUser = this.authService.getUser()
317 333
318 let instanceActions: DropdownAction<{ user: User, account: Account }>[] = [] 334 let instanceActions: DropdownAction<{ user: User, account: AccountMutedStatus }>[] = []
319 335
320 if (this.user && authUser.hasRight(UserRight.MANAGE_USERS) && authUser.canManage(this.user)) { 336 if (this.user && this.displayOptions.instanceUser && authUser.hasRight(UserRight.MANAGE_USERS) && authUser.canManage(this.user)) {
321 instanceActions = instanceActions.concat([ 337 instanceActions = instanceActions.concat([
322 { 338 {
323 label: $localize`Edit user`, 339 label: $localize`Edit user`,
@@ -351,7 +367,7 @@ export class UserModerationDropdownComponent implements OnInit, OnChanges {
351 } 367 }
352 368
353 // Instance actions on account blocklists 369 // Instance actions on account blocklists
354 if (this.account && authUser.hasRight(UserRight.MANAGE_ACCOUNTS_BLOCKLIST)) { 370 if (this.account && this.displayOptions.instanceAccount && authUser.hasRight(UserRight.MANAGE_ACCOUNTS_BLOCKLIST)) {
355 instanceActions = instanceActions.concat([ 371 instanceActions = instanceActions.concat([
356 { 372 {
357 label: $localize`Mute this account`, 373 label: $localize`Mute this account`,
@@ -369,7 +385,7 @@ export class UserModerationDropdownComponent implements OnInit, OnChanges {
369 } 385 }
370 386
371 // Instance actions on server blocklists 387 // Instance actions on server blocklists
372 if (this.account && authUser.hasRight(UserRight.MANAGE_SERVERS_BLOCKLIST)) { 388 if (this.account && this.displayOptions.instanceAccount && authUser.hasRight(UserRight.MANAGE_SERVERS_BLOCKLIST)) {
373 instanceActions = instanceActions.concat([ 389 instanceActions = instanceActions.concat([
374 { 390 {
375 label: $localize`Mute the instance`, 391 label: $localize`Mute the instance`,
@@ -386,7 +402,7 @@ export class UserModerationDropdownComponent implements OnInit, OnChanges {
386 ]) 402 ])
387 } 403 }
388 404
389 if (this.account && authUser.hasRight(UserRight.REMOVE_ANY_VIDEO_COMMENT)) { 405 if (this.account && this.displayOptions.instanceAccount && authUser.hasRight(UserRight.REMOVE_ANY_VIDEO_COMMENT)) {
390 instanceActions = instanceActions.concat([ 406 instanceActions = instanceActions.concat([
391 { 407 {
392 label: $localize`Remove comments from your instance`, 408 label: $localize`Remove comments from your instance`,
diff --git a/client/src/app/shared/shared-user-settings/user-video-settings.component.html b/client/src/app/shared/shared-user-settings/user-video-settings.component.html
index 4843f65b9..446ade445 100644
--- a/client/src/app/shared/shared-user-settings/user-video-settings.component.html
+++ b/client/src/app/shared/shared-user-settings/user-video-settings.component.html
@@ -42,7 +42,7 @@
42 i18n-labelText labelText="Help share videos being played" 42 i18n-labelText labelText="Help share videos being played"
43 > 43 >
44 <ng-container ngProjectAs="description"> 44 <ng-container ngProjectAs="description">
45 <span i18n>The <a routerLink="/about/peertube" fragment="privacy">sharing system</a> implies that some technical information about your system (such as a public IP address) can be sent to other peers, but greatly helps to reduce server load.</span> 45 <span i18n>The <a routerLink="/about/peertube" fragment="privacy" target="_blank">sharing system</a> implies that some technical information about your system (such as a public IP address) can be sent to other peers, but greatly helps to reduce server load.</span>
46 </ng-container> 46 </ng-container>
47 </my-peertube-checkbox> 47 </my-peertube-checkbox>
48 </div> 48 </div>
diff --git a/client/src/app/shared/shared-video-miniature/video-actions-dropdown.component.ts b/client/src/app/shared/shared-video-miniature/video-actions-dropdown.component.ts
index c2a318285..abbfc63f8 100644
--- a/client/src/app/shared/shared-video-miniature/video-actions-dropdown.component.ts
+++ b/client/src/app/shared/shared-video-miniature/video-actions-dropdown.component.ts
@@ -1,8 +1,8 @@
1import { Component, EventEmitter, Input, OnChanges, Output, ViewChild } from '@angular/core' 1import { Component, EventEmitter, Input, OnChanges, Output, ViewChild } from '@angular/core'
2import { AuthService, ConfirmService, Notifier, ScreenService } from '@app/core' 2import { AuthService, ConfirmService, Notifier, ScreenService, ServerService } from '@app/core'
3import { BlocklistService, VideoBlockComponent, VideoBlockService, VideoReportComponent } from '@app/shared/shared-moderation' 3import { BlocklistService, VideoBlockComponent, VideoBlockService, VideoReportComponent } from '@app/shared/shared-moderation'
4import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap' 4import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap'
5import { VideoCaption } from '@shared/models' 5import { VideoCaption, VideoState } from '@shared/models'
6import { 6import {
7 Actor, 7 Actor,
8 DropdownAction, 8 DropdownAction,
@@ -29,6 +29,7 @@ export type VideoActionsDisplayType = {
29 liveInfo?: boolean 29 liveInfo?: boolean
30 removeFiles?: boolean 30 removeFiles?: boolean
31 transcoding?: boolean 31 transcoding?: boolean
32 editor?: boolean
32} 33}
33 34
34@Component({ 35@Component({
@@ -59,7 +60,8 @@ export class VideoActionsDropdownComponent implements OnChanges {
59 mute: true, 60 mute: true,
60 liveInfo: false, 61 liveInfo: false,
61 removeFiles: false, 62 removeFiles: false,
62 transcoding: false 63 transcoding: false,
64 editor: true
63 } 65 }
64 @Input() placement = 'left' 66 @Input() placement = 'left'
65 67
@@ -89,7 +91,8 @@ export class VideoActionsDropdownComponent implements OnChanges {
89 private videoBlocklistService: VideoBlockService, 91 private videoBlocklistService: VideoBlockService,
90 private screenService: ScreenService, 92 private screenService: ScreenService,
91 private videoService: VideoService, 93 private videoService: VideoService,
92 private redundancyService: RedundancyService 94 private redundancyService: RedundancyService,
95 private serverService: ServerService
93 ) { } 96 ) { }
94 97
95 get user () { 98 get user () {
@@ -149,6 +152,12 @@ export class VideoActionsDropdownComponent implements OnChanges {
149 return this.video.isUpdatableBy(this.user) 152 return this.video.isUpdatableBy(this.user)
150 } 153 }
151 154
155 isVideoEditable () {
156 return this.serverService.getHTMLConfig().videoEditor.enabled &&
157 this.video.state?.id === VideoState.PUBLISHED &&
158 this.video.isUpdatableBy(this.user)
159 }
160
152 isVideoRemovable () { 161 isVideoRemovable () {
153 return this.video.isRemovableBy(this.user) 162 return this.video.isRemovableBy(this.user)
154 } 163 }
@@ -330,6 +339,12 @@ export class VideoActionsDropdownComponent implements OnChanges {
330 isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.update && this.isVideoUpdatable() 339 isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.update && this.isVideoUpdatable()
331 }, 340 },
332 { 341 {
342 label: $localize`Editor`,
343 linkBuilder: ({ video }) => [ '/video-editor/edit', video.uuid ],
344 iconName: 'film',
345 isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.editor && this.isVideoEditable()
346 },
347 {
333 label: $localize`Block`, 348 label: $localize`Block`,
334 handler: () => this.showBlockModal(), 349 handler: () => this.showBlockModal(),
335 iconName: 'no', 350 iconName: 'no',
diff --git a/client/src/app/shared/shared-video-miniature/video-miniature.component.html b/client/src/app/shared/shared-video-miniature/video-miniature.component.html
index 30483831a..3cf128de0 100644
--- a/client/src/app/shared/shared-video-miniature/video-miniature.component.html
+++ b/client/src/app/shared/shared-video-miniature/video-miniature.component.html
@@ -13,11 +13,13 @@
13 <my-actor-avatar 13 <my-actor-avatar
14 *ngIf="displayOptions.avatar && displayOwnerVideoChannel() && !displayAsRow" [title]="channelLinkTitle" 14 *ngIf="displayOptions.avatar && displayOwnerVideoChannel() && !displayAsRow" [title]="channelLinkTitle"
15 [channel]="video.channel" [size]="actorImageSize" [internalHref]="[ '/c', video.byVideoChannel ]" 15 [channel]="video.channel" [size]="actorImageSize" [internalHref]="[ '/c', video.byVideoChannel ]"
16 size="32"
16 ></my-actor-avatar> 17 ></my-actor-avatar>
17 18
18 <my-actor-avatar 19 <my-actor-avatar
19 *ngIf="displayOptions.avatar && displayOwnerAccount() && !displayAsRow" [title]="channelLinkTitle" 20 *ngIf="displayOptions.avatar && displayOwnerAccount() && !displayAsRow" [title]="channelLinkTitle"
20 [account]="video.account" [size]="actorImageSize" [internalHref]="[ '/c', video.byVideoChannel ]" 21 [account]="video.account" [size]="actorImageSize" [internalHref]="[ '/c', video.byVideoChannel ]"
22 size="32"
21 ></my-actor-avatar> 23 ></my-actor-avatar>
22 24
23 <div class="w-100 d-flex flex-column"> 25 <div class="w-100 d-flex flex-column">
diff --git a/client/src/app/shared/shared-video-miniature/video-miniature.component.ts b/client/src/app/shared/shared-video-miniature/video-miniature.component.ts
index 847e401ed..7de9fc8e2 100644
--- a/client/src/app/shared/shared-video-miniature/video-miniature.component.ts
+++ b/client/src/app/shared/shared-video-miniature/video-miniature.component.ts
@@ -195,6 +195,10 @@ export class VideoMiniatureComponent implements OnInit {
195 return $localize`To import` 195 return $localize`To import`
196 } 196 }
197 197
198 if (video.state.id === VideoState.TO_EDIT) {
199 return $localize`To edit`
200 }
201
198 return '' 202 return ''
199 } 203 }
200 204