aboutsummaryrefslogtreecommitdiffhomepage
path: root/client/src/app
diff options
context:
space:
mode:
Diffstat (limited to 'client/src/app')
-rw-r--r--client/src/app/+about/about-instance/about-instance.component.ts49
-rw-r--r--client/src/app/+about/about-instance/about-instance.resolver.ts27
-rw-r--r--client/src/app/+about/about-instance/contact-admin-modal.component.ts2
-rw-r--r--client/src/app/+about/about-routing.module.ts4
-rw-r--r--client/src/app/+about/about.module.ts2
-rw-r--r--client/src/app/+accounts/accounts.component.html25
-rw-r--r--client/src/app/+accounts/accounts.component.ts10
-rw-r--r--client/src/app/+admin/admin.component.html26
-rw-r--r--client/src/app/+admin/admin.component.ts22
-rw-r--r--client/src/app/+admin/admin.module.ts13
-rw-r--r--client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html12
-rw-r--r--client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.scss5
-rw-r--r--client/src/app/+admin/follows/followers-list/followers-list.component.html2
-rw-r--r--client/src/app/+admin/follows/following-add/following-add.component.scss2
-rw-r--r--client/src/app/+admin/follows/following-list/following-list.component.html2
-rw-r--r--client/src/app/+admin/follows/follows.component.html6
-rw-r--r--client/src/app/+admin/follows/follows.routes.ts5
-rw-r--r--client/src/app/+admin/follows/index.ts1
-rw-r--r--client/src/app/+admin/follows/shared/redundancy-checkbox.component.ts2
-rw-r--r--client/src/app/+admin/follows/shared/redundancy.service.ts28
-rw-r--r--client/src/app/+admin/follows/video-redundancies-list/index.ts1
-rw-r--r--client/src/app/+admin/follows/video-redundancies-list/video-redundancies-list.component.html82
-rw-r--r--client/src/app/+admin/follows/video-redundancies-list/video-redundancies-list.component.scss37
-rw-r--r--client/src/app/+admin/follows/video-redundancies-list/video-redundancies-list.component.ts178
-rw-r--r--client/src/app/+admin/follows/video-redundancies-list/video-redundancy-information.component.html24
-rw-r--r--client/src/app/+admin/follows/video-redundancies-list/video-redundancy-information.component.scss8
-rw-r--r--client/src/app/+admin/follows/video-redundancies-list/video-redundancy-information.component.ts11
-rw-r--r--client/src/app/+admin/moderation/instance-blocklist/instance-account-blocklist.component.ts2
-rw-r--r--client/src/app/+admin/moderation/instance-blocklist/instance-server-blocklist.component.ts2
-rw-r--r--client/src/app/+admin/moderation/video-abuse-list/moderation-comment-modal.component.ts2
-rw-r--r--client/src/app/+admin/moderation/video-abuse-list/video-abuse-list.component.ts2
-rw-r--r--client/src/app/+admin/moderation/video-blacklist-list/video-blacklist-list.component.ts2
-rw-r--r--client/src/app/+admin/plugins/plugin-search/plugin-search.component.html2
-rw-r--r--client/src/app/+admin/plugins/plugin-search/plugin-search.component.ts6
-rw-r--r--client/src/app/+admin/system/jobs/jobs.component.ts15
-rw-r--r--client/src/app/+admin/users/user-edit/user-create.component.ts10
-rw-r--r--client/src/app/+admin/users/user-edit/user-edit.component.html7
-rw-r--r--client/src/app/+admin/users/user-edit/user-update.component.ts4
-rw-r--r--client/src/app/+admin/users/user-list/user-list.component.html2
-rw-r--r--client/src/app/+admin/users/user-list/user-list.component.ts2
-rw-r--r--client/src/app/+admin/users/users.routes.ts4
-rw-r--r--client/src/app/+my-account/my-account-blocklist/my-account-blocklist.component.ts2
-rw-r--r--client/src/app/+my-account/my-account-blocklist/my-account-server-blocklist.component.ts2
-rw-r--r--client/src/app/+my-account/my-account-ownership/my-account-accept-ownership/my-account-accept-ownership.component.ts2
-rw-r--r--client/src/app/+my-account/my-account-ownership/my-account-ownership.component.ts2
-rw-r--r--client/src/app/+my-account/my-account-settings/my-account-video-settings/my-account-video-settings.component.html2
-rw-r--r--client/src/app/+my-account/my-account-video-channels/my-account-video-channel-edit.ts2
-rw-r--r--client/src/app/+my-account/my-account-video-channels/my-account-video-channels.component.scss2
-rw-r--r--client/src/app/+my-account/my-account-video-imports/my-account-video-imports.component.ts2
-rw-r--r--client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.scss2
-rw-r--r--client/src/app/+my-account/my-account-videos/video-change-ownership/video-change-ownership.component.ts2
-rw-r--r--client/src/app/+my-account/shared/actor-avatar-info.component.ts2
-rw-r--r--client/src/app/+signup/+register/register-step-channel.component.html2
-rw-r--r--client/src/app/+signup/+register/register-step-user.component.html2
-rw-r--r--client/src/app/+signup/+register/register.component.scss2
-rw-r--r--client/src/app/+video-channels/video-channel-videos/video-channel-videos.component.ts2
-rw-r--r--client/src/app/+video-channels/video-channels.component.html36
-rw-r--r--client/src/app/+video-channels/video-channels.component.scss20
-rw-r--r--client/src/app/+video-channels/video-channels.component.ts10
-rw-r--r--client/src/app/app.component.html2
-rw-r--r--client/src/app/app.component.scss6
-rw-r--r--client/src/app/app.component.ts4
-rw-r--r--client/src/app/app.module.ts26
-rw-r--r--client/src/app/core/hotkeys/hotkeys.component.scss7
-rw-r--r--client/src/app/core/routing/custom-reuse-strategy.ts4
-rw-r--r--client/src/app/core/routing/preload-selected-modules-list.ts4
-rw-r--r--client/src/app/core/server/server.service.ts13
-rw-r--r--client/src/app/header/header.component.html6
-rw-r--r--client/src/app/header/header.component.scss49
-rw-r--r--client/src/app/header/header.component.ts60
-rw-r--r--client/src/app/header/index.ts3
-rw-r--r--client/src/app/header/search-typeahead.component.html53
-rw-r--r--client/src/app/header/search-typeahead.component.scss145
-rw-r--r--client/src/app/header/search-typeahead.component.ts178
-rw-r--r--client/src/app/header/suggestion.component.html22
-rw-r--r--client/src/app/header/suggestion.component.scss32
-rw-r--r--client/src/app/header/suggestion.component.ts37
-rw-r--r--client/src/app/header/suggestions.component.html6
-rw-r--r--client/src/app/header/suggestions.component.ts24
-rw-r--r--client/src/app/menu/avatar-notification.component.html2
-rw-r--r--client/src/app/menu/avatar-notification.component.ts1
-rw-r--r--client/src/app/menu/language-chooser.component.ts2
-rw-r--r--client/src/app/menu/menu.component.html4
-rw-r--r--client/src/app/menu/menu.component.scss3
-rw-r--r--client/src/app/modal/instance-config-warning-modal.component.ts2
-rw-r--r--client/src/app/modal/welcome-modal.component.ts3
-rw-r--r--client/src/app/search/search-filters.component.html10
-rw-r--r--client/src/app/search/search.component.ts3
-rw-r--r--client/src/app/shared/angular/highlight.pipe.ts54
-rw-r--r--client/src/app/shared/buttons/action-dropdown.component.ts6
-rw-r--r--client/src/app/shared/buttons/button.component.scss23
-rw-r--r--client/src/app/shared/buttons/edit-button.component.ts2
-rw-r--r--client/src/app/shared/forms/form-validators/custom-config-validators.service.ts2
-rw-r--r--client/src/app/shared/forms/form-validators/user-validators.service.ts12
-rw-r--r--client/src/app/shared/forms/input-readonly-copy.component.html2
-rw-r--r--client/src/app/shared/forms/markdown-textarea.component.ts2
-rw-r--r--client/src/app/shared/images/global-icon.component.ts1
-rw-r--r--client/src/app/shared/images/preview-upload.component.ts4
-rw-r--r--client/src/app/shared/instance/instance-features-table.component.html11
-rw-r--r--client/src/app/shared/instance/instance-statistics.component.ts23
-rw-r--r--client/src/app/shared/menu/top-menu-dropdown.component.ts3
-rw-r--r--client/src/app/shared/misc/list-overflow.component.html35
-rw-r--r--client/src/app/shared/misc/list-overflow.component.scss61
-rw-r--r--client/src/app/shared/misc/list-overflow.component.ts119
-rw-r--r--client/src/app/shared/moderation/user-ban-modal.component.ts2
-rw-r--r--client/src/app/shared/moderation/user-moderation-dropdown.component.ts2
-rw-r--r--client/src/app/shared/renderer/markdown.service.ts4
-rw-r--r--client/src/app/shared/rest/rest-table.ts8
-rw-r--r--client/src/app/shared/rest/rest.service.ts2
-rw-r--r--client/src/app/shared/shared.module.ts12
-rw-r--r--client/src/app/shared/user-subscription/subscribe-button.component.html2
-rw-r--r--client/src/app/shared/video-abuse/video-abuse.service.ts2
-rw-r--r--client/src/app/shared/video-blacklist/video-blacklist.service.ts2
-rw-r--r--client/src/app/shared/video-import/video-import.service.ts2
-rw-r--r--client/src/app/shared/video-ownership/video-ownership.service.ts2
-rw-r--r--client/src/app/shared/video-playlist/video-playlist-element-miniature.component.ts2
-rw-r--r--client/src/app/shared/video/abstract-video-list.ts3
-rw-r--r--client/src/app/shared/video/infinite-scroller.directive.ts4
-rw-r--r--client/src/app/shared/video/modals/video-blacklist.component.ts6
-rw-r--r--client/src/app/shared/video/modals/video-download.component.html4
-rw-r--r--client/src/app/shared/video/modals/video-download.component.ts2
-rw-r--r--client/src/app/shared/video/modals/video-report.component.ts6
-rw-r--r--client/src/app/shared/video/redundancy.service.ts73
-rw-r--r--client/src/app/shared/video/video-actions-dropdown.component.ts38
-rw-r--r--client/src/app/shared/video/video-miniature.component.html2
-rw-r--r--client/src/app/shared/video/video-miniature.component.ts5
-rw-r--r--client/src/app/shared/video/video-thumbnail.component.ts2
-rw-r--r--client/src/app/shared/video/video.model.ts19
-rw-r--r--client/src/app/videos/+video-edit/shared/video-caption-add-modal.component.ts2
-rw-r--r--client/src/app/videos/+video-edit/shared/video-edit.component.html4
-rw-r--r--client/src/app/videos/+video-edit/shared/video-edit.module.ts2
-rw-r--r--client/src/app/videos/+video-edit/video-add-components/video-import-torrent.component.ts2
-rw-r--r--client/src/app/videos/+video-edit/video-add-components/video-upload.component.ts2
-rw-r--r--client/src/app/videos/+video-edit/video-add.component.ts6
-rw-r--r--client/src/app/videos/+video-watch/comment/video-comment-add.component.ts4
-rw-r--r--client/src/app/videos/+video-watch/comment/video-comment-thread-tree.model.ts7
-rw-r--r--client/src/app/videos/+video-watch/comment/video-comment.component.ts2
-rw-r--r--client/src/app/videos/+video-watch/comment/video-comment.model.ts2
-rw-r--r--client/src/app/videos/+video-watch/comment/video-comment.service.ts13
-rw-r--r--client/src/app/videos/+video-watch/comment/video-comments.component.ts6
-rw-r--r--client/src/app/videos/+video-watch/modal/video-share.component.html2
-rw-r--r--client/src/app/videos/+video-watch/modal/video-share.component.ts4
-rw-r--r--client/src/app/videos/+video-watch/modal/video-support.component.ts2
-rw-r--r--client/src/app/videos/+video-watch/video-watch.component.html5
-rw-r--r--client/src/app/videos/+video-watch/video-watch.component.ts6
145 files changed, 1676 insertions, 416 deletions
diff --git a/client/src/app/+about/about-instance/about-instance.component.ts b/client/src/app/+about/about-instance/about-instance.component.ts
index 87beb13da..c8c156105 100644
--- a/client/src/app/+about/about-instance/about-instance.component.ts
+++ b/client/src/app/+about/about-instance/about-instance.component.ts
@@ -1,12 +1,10 @@
1import { Component, OnInit, ViewChild } from '@angular/core' 1import { Component, OnInit, ViewChild } from '@angular/core'
2import { Notifier, ServerService } from '@app/core' 2import { Notifier, ServerService } from '@app/core'
3import { I18n } from '@ngx-translate/i18n-polyfill'
4import { ContactAdminModalComponent } from '@app/+about/about-instance/contact-admin-modal.component' 3import { ContactAdminModalComponent } from '@app/+about/about-instance/contact-admin-modal.component'
5import { InstanceService } from '@app/shared/instance/instance.service' 4import { InstanceService } from '@app/shared/instance/instance.service'
6import { MarkdownService } from '@app/shared/renderer'
7import { forkJoin } from 'rxjs'
8import { map, switchMap } from 'rxjs/operators'
9import { ServerConfig } from '@shared/models' 5import { ServerConfig } from '@shared/models'
6import { ActivatedRoute } from '@angular/router'
7import { ResolverData } from './about-instance.resolver'
10 8
11@Component({ 9@Component({
12 selector: 'my-about-instance', 10 selector: 'my-about-instance',
@@ -37,11 +35,10 @@ export class AboutInstanceComponent implements OnInit {
37 serverConfig: ServerConfig 35 serverConfig: ServerConfig
38 36
39 constructor ( 37 constructor (
38 private route: ActivatedRoute,
40 private notifier: Notifier, 39 private notifier: Notifier,
41 private serverService: ServerService, 40 private serverService: ServerService,
42 private instanceService: InstanceService, 41 private instanceService: InstanceService
43 private markdownService: MarkdownService,
44 private i18n: I18n
45 ) {} 42 ) {}
46 43
47 get instanceName () { 44 get instanceName () {
@@ -56,35 +53,23 @@ export class AboutInstanceComponent implements OnInit {
56 return this.serverConfig.instance.isNSFW 53 return this.serverConfig.instance.isNSFW
57 } 54 }
58 55
59 ngOnInit () { 56 async ngOnInit () {
60 this.serverConfig = this.serverService.getTmpConfig() 57 this.serverConfig = this.serverService.getTmpConfig()
61 this.serverService.getConfig() 58 this.serverService.getConfig()
62 .subscribe(config => this.serverConfig = config) 59 .subscribe(config => this.serverConfig = config)
63 60
64 this.instanceService.getAbout() 61 const { about, languages, categories }: ResolverData = this.route.snapshot.data.instanceData
65 .pipe( 62
66 switchMap(about => { 63 this.languages = languages
67 return forkJoin([ 64 this.categories = categories
68 this.instanceService.buildTranslatedLanguages(about), 65
69 this.instanceService.buildTranslatedCategories(about) 66 this.shortDescription = about.instance.shortDescription
70 ]).pipe(map(([ languages, categories ]) => ({ about, languages, categories }))) 67
71 }) 68 this.creationReason = about.instance.creationReason
72 ).subscribe( 69 this.maintenanceLifetime = about.instance.maintenanceLifetime
73 async ({ about, languages, categories }) => { 70 this.businessModel = about.instance.businessModel
74 this.languages = languages 71
75 this.categories = categories 72 this.html = await this.instanceService.buildHtml(about)
76
77 this.shortDescription = about.instance.shortDescription
78
79 this.creationReason = about.instance.creationReason
80 this.maintenanceLifetime = about.instance.maintenanceLifetime
81 this.businessModel = about.instance.businessModel
82
83 this.html = await this.instanceService.buildHtml(about)
84 },
85
86 () => this.notifier.error(this.i18n('Cannot get about information from server'))
87 )
88 } 73 }
89 74
90 openContactModal () { 75 openContactModal () {
diff --git a/client/src/app/+about/about-instance/about-instance.resolver.ts b/client/src/app/+about/about-instance/about-instance.resolver.ts
new file mode 100644
index 000000000..94c6abe5a
--- /dev/null
+++ b/client/src/app/+about/about-instance/about-instance.resolver.ts
@@ -0,0 +1,27 @@
1import { Injectable } from '@angular/core'
2import { ActivatedRouteSnapshot, Resolve } from '@angular/router'
3import { map, switchMap } from 'rxjs/operators'
4import { forkJoin } from 'rxjs'
5import { InstanceService } from '@app/shared/instance/instance.service'
6import { About } from '@shared/models/server'
7
8export type ResolverData = { about: About, languages: string[], categories: string[] }
9
10@Injectable()
11export class AboutInstanceResolver implements Resolve<any> {
12 constructor (
13 private instanceService: InstanceService
14 ) {}
15
16 resolve (route: ActivatedRouteSnapshot) {
17 return this.instanceService.getAbout()
18 .pipe(
19 switchMap(about => {
20 return forkJoin([
21 this.instanceService.buildTranslatedLanguages(about),
22 this.instanceService.buildTranslatedCategories(about)
23 ]).pipe(map(([ languages, categories ]) => ({ about, languages, categories })))
24 })
25 )
26 }
27}
diff --git a/client/src/app/+about/about-instance/contact-admin-modal.component.ts b/client/src/app/+about/about-instance/contact-admin-modal.component.ts
index 2ed41e741..d5e146b82 100644
--- a/client/src/app/+about/about-instance/contact-admin-modal.component.ts
+++ b/client/src/app/+about/about-instance/contact-admin-modal.component.ts
@@ -51,7 +51,7 @@ export class ContactAdminModalComponent extends FormReactive implements OnInit {
51 } 51 }
52 52
53 show () { 53 show () {
54 this.openedModal = this.modalService.open(this.modal, { keyboard: false }) 54 this.openedModal = this.modalService.open(this.modal, { centered: true, keyboard: false })
55 } 55 }
56 56
57 hide () { 57 hide () {
diff --git a/client/src/app/+about/about-routing.module.ts b/client/src/app/+about/about-routing.module.ts
index 33e5070cb..91ccb846f 100644
--- a/client/src/app/+about/about-routing.module.ts
+++ b/client/src/app/+about/about-routing.module.ts
@@ -5,6 +5,7 @@ import { AboutComponent } from './about.component'
5import { AboutInstanceComponent } from '@app/+about/about-instance/about-instance.component' 5import { AboutInstanceComponent } from '@app/+about/about-instance/about-instance.component'
6import { AboutPeertubeComponent } from '@app/+about/about-peertube/about-peertube.component' 6import { AboutPeertubeComponent } from '@app/+about/about-peertube/about-peertube.component'
7import { AboutFollowsComponent } from '@app/+about/about-follows/about-follows.component' 7import { AboutFollowsComponent } from '@app/+about/about-follows/about-follows.component'
8import { AboutInstanceResolver } from '@app/+about/about-instance/about-instance.resolver'
8 9
9const aboutRoutes: Routes = [ 10const aboutRoutes: Routes = [
10 { 11 {
@@ -24,6 +25,9 @@ const aboutRoutes: Routes = [
24 meta: { 25 meta: {
25 title: 'About this instance' 26 title: 'About this instance'
26 } 27 }
28 },
29 resolve: {
30 instanceData: AboutInstanceResolver
27 } 31 }
28 }, 32 },
29 { 33 {
diff --git a/client/src/app/+about/about.module.ts b/client/src/app/+about/about.module.ts
index 14bf76e55..84d697540 100644
--- a/client/src/app/+about/about.module.ts
+++ b/client/src/app/+about/about.module.ts
@@ -7,6 +7,7 @@ import { AboutPeertubeComponent } from '@app/+about/about-peertube/about-peertub
7import { ContactAdminModalComponent } from '@app/+about/about-instance/contact-admin-modal.component' 7import { ContactAdminModalComponent } from '@app/+about/about-instance/contact-admin-modal.component'
8import { AboutFollowsComponent } from '@app/+about/about-follows/about-follows.component' 8import { AboutFollowsComponent } from '@app/+about/about-follows/about-follows.component'
9import { AboutPeertubeContributorsComponent } from '@app/+about/about-peertube/about-peertube-contributors.component' 9import { AboutPeertubeContributorsComponent } from '@app/+about/about-peertube/about-peertube-contributors.component'
10import { AboutInstanceResolver } from '@app/+about/about-instance/about-instance.resolver'
10 11
11@NgModule({ 12@NgModule({
12 imports: [ 13 imports: [
@@ -28,6 +29,7 @@ import { AboutPeertubeContributorsComponent } from '@app/+about/about-peertube/a
28 ], 29 ],
29 30
30 providers: [ 31 providers: [
32 AboutInstanceResolver
31 ] 33 ]
32}) 34})
33export class AboutModule { } 35export class AboutModule { }
diff --git a/client/src/app/+accounts/accounts.component.html b/client/src/app/+accounts/accounts.component.html
index 85f7dd30c..6a76393b9 100644
--- a/client/src/app/+accounts/accounts.component.html
+++ b/client/src/app/+accounts/accounts.component.html
@@ -7,14 +7,13 @@
7 <div class="actor-info"> 7 <div class="actor-info">
8 <div class="actor-names"> 8 <div class="actor-names">
9 <div class="actor-display-name">{{ account.displayName }}</div> 9 <div class="actor-display-name">{{ account.displayName }}</div>
10 <div class="actor-name">{{ account.nameWithHost }} 10 <div class="actor-name">
11 11 <span>{{ account.nameWithHost }}</span>
12 <button ngxClipboard [cbContent]="account.nameWithHostForced" (click)="activateCopiedMessage()" 12 <button [cdkCopyToClipboard]="account.nameWithHostForced" (click)="activateCopiedMessage()"
13 class="btn btn-outline-secondary btn-sm copy-button" 13 class="btn btn-outline-secondary btn-sm copy-button"
14 > 14 >
15 <span class="glyphicon glyphicon-copy"></span> 15 <span class="glyphicon glyphicon-copy"></span>
16 </button> 16 </button>
17
18 </div> 17 </div>
19 <span *ngIf="accountUser?.blocked" [ngbTooltip]="accountUser.blockedReason" class="badge badge-danger" i18n>Banned</span> 18 <span *ngIf="accountUser?.blocked" [ngbTooltip]="accountUser.blockedReason" class="badge badge-danger" i18n>Banned</span>
20 <span *ngIf="account.mutedByUser" class="badge badge-danger" i18n>Muted</span> 19 <span *ngIf="account.mutedByUser" class="badge badge-danger" i18n>Muted</span>
@@ -38,12 +37,12 @@
38 </div> 37 </div>
39 </div> 38 </div>
40 39
41 <div class="links"> 40 <div class="links w-100">
42 <a i18n routerLink="video-channels" routerLinkActive="active" class="title-page">Video channels</a> 41 <ng-template #linkTemplate let-item="item">
43 42 <a [routerLink]="item.routerLink" routerLinkActive="active" class="title-page">{{ item.label }}</a>
44 <a i18n routerLink="videos" routerLinkActive="active" class="title-page">Videos</a> 43 </ng-template>
45 44
46 <a i18n routerLink="about" routerLinkActive="active" class="title-page">About</a> 45 <list-overflow [items]="links" [itemTemplate]="linkTemplate"></list-overflow>
47 </div> 46 </div>
48 </div> 47 </div>
49 48
diff --git a/client/src/app/+accounts/accounts.component.ts b/client/src/app/+accounts/accounts.component.ts
index ad611f221..4fea0e4ed 100644
--- a/client/src/app/+accounts/accounts.component.ts
+++ b/client/src/app/+accounts/accounts.component.ts
@@ -10,6 +10,7 @@ import { User, UserRight } from '../../../../shared'
10import { I18n } from '@ngx-translate/i18n-polyfill' 10import { I18n } from '@ngx-translate/i18n-polyfill'
11import { VideoChannelService } from '@app/shared/video-channel/video-channel.service' 11import { VideoChannelService } from '@app/shared/video-channel/video-channel.service'
12import { VideoChannel } from '@app/shared/video-channel/video-channel.model' 12import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
13import { ListOverflowItem } from '@app/shared/misc/list-overflow.component'
13 14
14@Component({ 15@Component({
15 templateUrl: './accounts.component.html', 16 templateUrl: './accounts.component.html',
@@ -19,6 +20,7 @@ export class AccountsComponent implements OnInit, OnDestroy {
19 account: Account 20 account: Account
20 accountUser: User 21 accountUser: User
21 videoChannels: VideoChannel[] = [] 22 videoChannels: VideoChannel[] = []
23 links: ListOverflowItem[] = []
22 24
23 isAccountManageable = false 25 isAccountManageable = false
24 accountFollowerTitle = '' 26 accountFollowerTitle = ''
@@ -70,6 +72,12 @@ export class AccountsComponent implements OnInit, OnDestroy {
70 72
71 err => this.notifier.error(err.message) 73 err => this.notifier.error(err.message)
72 ) 74 )
75
76 this.links = [
77 { label: this.i18n('Video channels'), routerLink: 'video-channels' },
78 { label: this.i18n('Videos'), routerLink: 'videos' },
79 { label: this.i18n('About'), routerLink: 'about' }
80 ]
73 } 81 }
74 82
75 ngOnDestroy () { 83 ngOnDestroy () {
@@ -96,7 +104,7 @@ export class AccountsComponent implements OnInit, OnDestroy {
96 } 104 }
97 105
98 subscribersDisplayFor (count: number) { 106 subscribersDisplayFor (count: number) {
99 return this.i18n(`{count, plural, =1 {1 subscriber} other {${count} subscribers}}`, { count }) 107 return this.i18n('{count, plural, =1 {1 subscriber} other {{{count}} subscribers}}', { count })
100 } 108 }
101 109
102 private getUserIfNeeded (account: Account) { 110 private getUserIfNeeded (account: Account) {
diff --git a/client/src/app/+admin/admin.component.html b/client/src/app/+admin/admin.component.html
index 9a3d90c18..6c98fe453 100644
--- a/client/src/app/+admin/admin.component.html
+++ b/client/src/app/+admin/admin.component.html
@@ -1,28 +1,10 @@
1<div class="row"> 1<div class="row">
2 <div class="sub-menu"> 2 <div class="sub-menu">
3 <a i18n *ngIf="hasUsersRight()" routerLink="/admin/users" routerLinkActive="active" class="title-page"> 3 <ng-template #linkTemplate let-item="item">
4 Users 4 <a [routerLink]="item.routerLink" routerLinkActive="active" class="title-page">{{ item.label }}</a>
5 </a> 5 </ng-template>
6 6
7 <a i18n *ngIf="hasServerFollowRight()" routerLink="/admin/follows" routerLinkActive="active" class="title-page"> 7 <list-overflow [items]="items" [itemTemplate]="linkTemplate"></list-overflow>
8 Manage follows
9 </a>
10
11 <a i18n *ngIf="hasVideoAbusesRight() || hasVideoBlacklistRight()" routerLink="/admin/moderation" routerLinkActive="active" class="title-page">
12 Moderation
13 </a>
14
15 <a i18n *ngIf="hasConfigRight()" routerLink="/admin/config" routerLinkActive="active" class="title-page">
16 Configuration
17 </a>
18
19 <a i18n *ngIf="hasPluginsRight()" routerLink="/admin/plugins" routerLinkActive="active" class="title-page">
20 Plugins/Themes
21 </a>
22
23 <a i18n *ngIf="hasJobsRight() || hasLogsRight() || hasDebugRight()" routerLink="/admin/system" routerLinkActive="active" class="title-page">
24 System
25 </a>
26 </div> 8 </div>
27 9
28 <div class="margin-content"> 10 <div class="margin-content">
diff --git a/client/src/app/+admin/admin.component.ts b/client/src/app/+admin/admin.component.ts
index b23999d40..9662522dc 100644
--- a/client/src/app/+admin/admin.component.ts
+++ b/client/src/app/+admin/admin.component.ts
@@ -1,12 +1,28 @@
1import { Component } from '@angular/core' 1import { Component, OnInit } from '@angular/core'
2import { UserRight } from '../../../../shared' 2import { UserRight } from '../../../../shared'
3import { AuthService } from '../core/auth/auth.service' 3import { AuthService } from '../core/auth/auth.service'
4import { ListOverflowItem } from '@app/shared/misc/list-overflow.component'
5import { I18n } from '@ngx-translate/i18n-polyfill'
4 6
5@Component({ 7@Component({
6 templateUrl: './admin.component.html' 8 templateUrl: './admin.component.html'
7}) 9})
8export class AdminComponent { 10export class AdminComponent implements OnInit {
9 constructor (private auth: AuthService) {} 11 items: ListOverflowItem[] = []
12
13 constructor (
14 private auth: AuthService,
15 private i18n: I18n
16 ) {}
17
18 ngOnInit () {
19 if (this.hasUsersRight()) this.items.push({ label: this.i18n('Users'), routerLink: '/admin/users' })
20 if (this.hasServerFollowRight()) this.items.push({ label: this.i18n('Follows & redundancies'), routerLink: '/admin/follows' })
21 if (this.hasVideoAbusesRight() || this.hasVideoBlacklistRight()) this.items.push({ label: this.i18n('Moderation'), routerLink: '/admin/moderation' })
22 if (this.hasConfigRight()) this.items.push({ label: this.i18n('Configuration'), routerLink: '/admin/config' })
23 if (this.hasPluginsRight()) this.items.push({ label: this.i18n('Plugins/Themes'), routerLink: '/admin/plugins' })
24 if (this.hasJobsRight() || this.hasLogsRight() || this.hasDebugRight()) this.items.push({ label: this.i18n('System'), routerLink: '/admin/system' })
25 }
10 26
11 hasUsersRight () { 27 hasUsersRight () {
12 return this.auth.getUser().hasRight(UserRight.MANAGE_USERS) 28 return this.auth.getUser().hasRight(UserRight.MANAGE_USERS)
diff --git a/client/src/app/+admin/admin.module.ts b/client/src/app/+admin/admin.module.ts
index 9c56b5750..fdbe70314 100644
--- a/client/src/app/+admin/admin.module.ts
+++ b/client/src/app/+admin/admin.module.ts
@@ -5,7 +5,7 @@ import { TableModule } from 'primeng/table'
5import { SharedModule } from '../shared' 5import { SharedModule } from '../shared'
6import { AdminRoutingModule } from './admin-routing.module' 6import { AdminRoutingModule } from './admin-routing.module'
7import { AdminComponent } from './admin.component' 7import { AdminComponent } from './admin.component'
8import { FollowersListComponent, FollowingAddComponent, FollowsComponent } from './follows' 8import { FollowersListComponent, FollowingAddComponent, FollowsComponent, VideoRedundanciesListComponent } from './follows'
9import { FollowingListComponent } from './follows/following-list/following-list.component' 9import { FollowingListComponent } from './follows/following-list/following-list.component'
10import { UserCreateComponent, UserListComponent, UserPasswordComponent, UsersComponent, UserUpdateComponent } from './users' 10import { UserCreateComponent, UserListComponent, UserPasswordComponent, UsersComponent, UserUpdateComponent } from './users'
11import { 11import {
@@ -16,7 +16,6 @@ import {
16} from './moderation' 16} from './moderation'
17import { ModerationComponent } from '@app/+admin/moderation/moderation.component' 17import { ModerationComponent } from '@app/+admin/moderation/moderation.component'
18import { RedundancyCheckboxComponent } from '@app/+admin/follows/shared/redundancy-checkbox.component' 18import { RedundancyCheckboxComponent } from '@app/+admin/follows/shared/redundancy-checkbox.component'
19import { RedundancyService } from '@app/+admin/follows/shared/redundancy.service'
20import { InstanceAccountBlocklistComponent, InstanceServerBlocklistComponent } from '@app/+admin/moderation/instance-blocklist' 19import { InstanceAccountBlocklistComponent, InstanceServerBlocklistComponent } from '@app/+admin/moderation/instance-blocklist'
21import { JobsComponent } from '@app/+admin/system/jobs/jobs.component' 20import { JobsComponent } from '@app/+admin/system/jobs/jobs.component'
22import { JobService, LogsComponent, LogsService, SystemComponent } from '@app/+admin/system' 21import { JobService, LogsComponent, LogsService, SystemComponent } from '@app/+admin/system'
@@ -27,13 +26,18 @@ import { PluginSearchComponent } from '@app/+admin/plugins/plugin-search/plugin-
27import { PluginShowInstalledComponent } from '@app/+admin/plugins/plugin-show-installed/plugin-show-installed.component' 26import { PluginShowInstalledComponent } from '@app/+admin/plugins/plugin-show-installed/plugin-show-installed.component'
28import { SelectButtonModule } from 'primeng/selectbutton' 27import { SelectButtonModule } from 'primeng/selectbutton'
29import { PluginApiService } from '@app/+admin/plugins/shared/plugin-api.service' 28import { PluginApiService } from '@app/+admin/plugins/shared/plugin-api.service'
29import { VideoRedundancyInformationComponent } from '@app/+admin/follows/video-redundancies-list/video-redundancy-information.component'
30import { ChartModule } from 'primeng/chart'
30 31
31@NgModule({ 32@NgModule({
32 imports: [ 33 imports: [
33 AdminRoutingModule, 34 AdminRoutingModule,
35
36 SharedModule,
37
34 TableModule, 38 TableModule,
35 SelectButtonModule, 39 SelectButtonModule,
36 SharedModule 40 ChartModule
37 ], 41 ],
38 42
39 declarations: [ 43 declarations: [
@@ -44,6 +48,8 @@ import { PluginApiService } from '@app/+admin/plugins/shared/plugin-api.service'
44 FollowersListComponent, 48 FollowersListComponent,
45 FollowingListComponent, 49 FollowingListComponent,
46 RedundancyCheckboxComponent, 50 RedundancyCheckboxComponent,
51 VideoRedundanciesListComponent,
52 VideoRedundancyInformationComponent,
47 53
48 UsersComponent, 54 UsersComponent,
49 UserCreateComponent, 55 UserCreateComponent,
@@ -78,7 +84,6 @@ import { PluginApiService } from '@app/+admin/plugins/shared/plugin-api.service'
78 ], 84 ],
79 85
80 providers: [ 86 providers: [
81 RedundancyService,
82 JobService, 87 JobService,
83 LogsService, 88 LogsService,
84 DebugService, 89 DebugService,
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html
index 915d60090..b09614061 100644
--- a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html
+++ b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html
@@ -41,7 +41,7 @@
41 41
42 <div> 42 <div>
43 <p-multiSelect 43 <p-multiSelect
44 inputId="instanceCategories" [options]="categoryItems" formControlName="categories" showToggleAll="false" 44 inputId="instanceCategories" [options]="categoryItems" formControlName="categories" [showToggleAll]="false"
45 [defaultLabel]="getDefaultCategoryLabel()" [selectedItemsLabel]="getSelectedCategoryLabel()" 45 [defaultLabel]="getDefaultCategoryLabel()" [selectedItemsLabel]="getSelectedCategoryLabel()"
46 emptyFilterMessage="No results found" i18n-emptyFilterMessage 46 emptyFilterMessage="No results found" i18n-emptyFilterMessage
47 ></p-multiSelect> 47 ></p-multiSelect>
@@ -53,7 +53,7 @@
53 53
54 <div> 54 <div>
55 <p-multiSelect 55 <p-multiSelect
56 inputId="instanceLanguages" [options]="languageItems" formControlName="languages" showToggleAll="false" 56 inputId="instanceLanguages" [options]="languageItems" formControlName="languages" [showToggleAll]="false"
57 [defaultLabel]="getDefaultLanguageLabel()" [selectedItemsLabel]="getSelectedLanguageLabel()" 57 [defaultLabel]="getDefaultLanguageLabel()" [selectedItemsLabel]="getSelectedLanguageLabel()"
58 emptyFilterMessage="No results found" i18n-emptyFilterMessage 58 emptyFilterMessage="No results found" i18n-emptyFilterMessage
59 ></p-multiSelect> 59 ></p-multiSelect>
@@ -234,6 +234,9 @@
234 inputName="signupEnabled" formControlName="enabled" 234 inputName="signupEnabled" formControlName="enabled"
235 i18n-labelText labelText="Signup enabled" 235 i18n-labelText labelText="Signup enabled"
236 > 236 >
237 <ng-container ngProjectAs="description">
238 <span i18n>⚠️ This functionality requires a lot of attention and extra moderation.</span>
239 </ng-container>
237 <ng-container ngProjectAs="extra"> 240 <ng-container ngProjectAs="extra">
238 <my-peertube-checkbox [ngClass]="{ 'disabled-checkbox-extra': !isSignupEnabled() }" 241 <my-peertube-checkbox [ngClass]="{ 'disabled-checkbox-extra': !isSignupEnabled() }"
239 inputName="signupRequiresEmailVerification" formControlName="requiresEmailVerification" 242 inputName="signupRequiresEmailVerification" formControlName="requiresEmailVerification"
@@ -243,10 +246,11 @@
243 <div [ngClass]="{ 'disabled-checkbox-extra': !isSignupEnabled() }" class="mt-3"> 246 <div [ngClass]="{ 'disabled-checkbox-extra': !isSignupEnabled() }" class="mt-3">
244 <label i18n for="signupLimit">Signup limit</label> 247 <label i18n for="signupLimit">Signup limit</label>
245 <input 248 <input
246 type="text" id="signupLimit" 249 type="number" min="-1" id="signupLimit"
247 formControlName="limit" [ngClass]="{ 'input-error': formErrors['signup.limit'] }" 250 formControlName="limit" [ngClass]="{ 'input-error': formErrors['signup.limit'] }"
248 > 251 >
249 <div *ngIf="formErrors.signup.limit" class="form-error">{{ formErrors.signup.limit }}</div> 252 <div *ngIf="formErrors.signup.limit" class="form-error">{{ formErrors.signup.limit }}</div>
253 <small *ngIf="form.value['signup']['limit'] === -1" class="text-muted">Signup won't be limited to a fixed number of users.</small>
250 </div> 254 </div>
251 </ng-container> 255 </ng-container>
252 </my-peertube-checkbox> 256 </my-peertube-checkbox>
@@ -318,7 +322,7 @@
318 i18n-labelText labelText="Blacklist new videos automatically" 322 i18n-labelText labelText="Blacklist new videos automatically"
319 > 323 >
320 <ng-container ngProjectAs="description"> 324 <ng-container ngProjectAs="description">
321 <span i18n>Videos of regular users will stay private until a moderator reviews them. Can be overriden per user.</span> 325 <span i18n>Unless a user is marked as trusted, their videos will stay private until a moderator reviews them.</span>
322 </ng-container> 326 </ng-container>
323 </my-peertube-checkbox> 327 </my-peertube-checkbox>
324 </div> 328 </div>
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.scss b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.scss
index 60d608028..dd70f1c06 100644
--- a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.scss
+++ b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.scss
@@ -10,6 +10,11 @@ input[type=text] {
10 display: block; 10 display: block;
11} 11}
12 12
13input[type=number] {
14 @include peertube-input-text(315px);
15 display: block;
16}
17
13input[type=checkbox] { 18input[type=checkbox] {
14 @include peertube-checkbox(1px); 19 @include peertube-checkbox(1px);
15} 20}
diff --git a/client/src/app/+admin/follows/followers-list/followers-list.component.html b/client/src/app/+admin/follows/followers-list/followers-list.component.html
index 6b5f3b450..c532b5f32 100644
--- a/client/src/app/+admin/follows/followers-list/followers-list.component.html
+++ b/client/src/app/+admin/follows/followers-list/followers-list.component.html
@@ -6,7 +6,7 @@
6 <div class="caption"> 6 <div class="caption">
7 <input 7 <input
8 type="text" name="table-filter" id="table-filter" i18n-placeholder placeholder="Filter..." 8 type="text" name="table-filter" id="table-filter" i18n-placeholder placeholder="Filter..."
9 (keyup)="onSearch($event.target.value)" 9 (keyup)="onSearch($event)"
10 > 10 >
11 </div> 11 </div>
12 </ng-template> 12 </ng-template>
diff --git a/client/src/app/+admin/follows/following-add/following-add.component.scss b/client/src/app/+admin/follows/following-add/following-add.component.scss
index 1baddc95f..df104c14e 100644
--- a/client/src/app/+admin/follows/following-add/following-add.component.scss
+++ b/client/src/app/+admin/follows/following-add/following-add.component.scss
@@ -7,7 +7,7 @@ textarea {
7 7
8.form-control { 8.form-control {
9 &, &:focus { 9 &, &:focus {
10 background-color: var(--inputColor); 10 background-color: var(--inputBackgroundColor);
11 color: var(--mainForegroundColor); 11 color: var(--mainForegroundColor);
12 } 12 }
13} 13}
diff --git a/client/src/app/+admin/follows/following-list/following-list.component.html b/client/src/app/+admin/follows/following-list/following-list.component.html
index 5a252eda9..01aba0c11 100644
--- a/client/src/app/+admin/follows/following-list/following-list.component.html
+++ b/client/src/app/+admin/follows/following-list/following-list.component.html
@@ -7,7 +7,7 @@
7 <div> 7 <div>
8 <input 8 <input
9 type="text" name="table-filter" id="table-filter" i18n-placeholder placeholder="Filter..." 9 type="text" name="table-filter" id="table-filter" i18n-placeholder placeholder="Filter..."
10 (keyup)="onSearch($event.target.value)" 10 (keyup)="onSearch($event)"
11 > 11 >
12 </div> 12 </div>
13 </div> 13 </div>
diff --git a/client/src/app/+admin/follows/follows.component.html b/client/src/app/+admin/follows/follows.component.html
index 21d477132..46581daf9 100644
--- a/client/src/app/+admin/follows/follows.component.html
+++ b/client/src/app/+admin/follows/follows.component.html
@@ -1,5 +1,5 @@
1<div class="admin-sub-header"> 1<div class="admin-sub-header">
2 <div i18n class="form-sub-title">Manage follows</div> 2 <div i18n class="form-sub-title">Follows & redundancies</div>
3 3
4 <div class="admin-sub-nav"> 4 <div class="admin-sub-nav">
5 <a i18n routerLink="following-list" routerLinkActive="active">Following</a> 5 <a i18n routerLink="following-list" routerLinkActive="active">Following</a>
@@ -7,7 +7,9 @@
7 <a i18n routerLink="following-add" routerLinkActive="active">Follow</a> 7 <a i18n routerLink="following-add" routerLinkActive="active">Follow</a>
8 8
9 <a i18n routerLink="followers-list" routerLinkActive="active">Followers</a> 9 <a i18n routerLink="followers-list" routerLinkActive="active">Followers</a>
10
11 <a i18n routerLink="video-redundancies-list" routerLinkActive="active">Video redundancies</a>
10 </div> 12 </div>
11</div> 13</div>
12 14
13<router-outlet></router-outlet> \ No newline at end of file 15<router-outlet></router-outlet>
diff --git a/client/src/app/+admin/follows/follows.routes.ts b/client/src/app/+admin/follows/follows.routes.ts
index e84c79e82..298733eb0 100644
--- a/client/src/app/+admin/follows/follows.routes.ts
+++ b/client/src/app/+admin/follows/follows.routes.ts
@@ -6,6 +6,7 @@ import { FollowingAddComponent } from './following-add'
6import { FollowersListComponent } from './followers-list' 6import { FollowersListComponent } from './followers-list'
7import { UserRight } from '../../../../../shared' 7import { UserRight } from '../../../../../shared'
8import { FollowingListComponent } from './following-list/following-list.component' 8import { FollowingListComponent } from './following-list/following-list.component'
9import { VideoRedundanciesListComponent } from '@app/+admin/follows/video-redundancies-list'
9 10
10export const FollowsRoutes: Routes = [ 11export const FollowsRoutes: Routes = [
11 { 12 {
@@ -47,6 +48,10 @@ export const FollowsRoutes: Routes = [
47 title: 'Add follow' 48 title: 'Add follow'
48 } 49 }
49 } 50 }
51 },
52 {
53 path: 'video-redundancies-list',
54 component: VideoRedundanciesListComponent
50 } 55 }
51 ] 56 ]
52 } 57 }
diff --git a/client/src/app/+admin/follows/index.ts b/client/src/app/+admin/follows/index.ts
index e94f33710..4fcb35cb1 100644
--- a/client/src/app/+admin/follows/index.ts
+++ b/client/src/app/+admin/follows/index.ts
@@ -1,5 +1,6 @@
1export * from './following-add' 1export * from './following-add'
2export * from './followers-list' 2export * from './followers-list'
3export * from './following-list' 3export * from './following-list'
4export * from './video-redundancies-list'
4export * from './follows.component' 5export * from './follows.component'
5export * from './follows.routes' 6export * from './follows.routes'
diff --git a/client/src/app/+admin/follows/shared/redundancy-checkbox.component.ts b/client/src/app/+admin/follows/shared/redundancy-checkbox.component.ts
index fa1da26bf..9d7883d97 100644
--- a/client/src/app/+admin/follows/shared/redundancy-checkbox.component.ts
+++ b/client/src/app/+admin/follows/shared/redundancy-checkbox.component.ts
@@ -1,7 +1,7 @@
1import { Component, Input } from '@angular/core' 1import { Component, Input } from '@angular/core'
2import { Notifier } from '@app/core' 2import { Notifier } from '@app/core'
3import { I18n } from '@ngx-translate/i18n-polyfill' 3import { I18n } from '@ngx-translate/i18n-polyfill'
4import { RedundancyService } from '@app/+admin/follows/shared/redundancy.service' 4import { RedundancyService } from '@app/shared/video/redundancy.service'
5 5
6@Component({ 6@Component({
7 selector: 'my-redundancy-checkbox', 7 selector: 'my-redundancy-checkbox',
diff --git a/client/src/app/+admin/follows/shared/redundancy.service.ts b/client/src/app/+admin/follows/shared/redundancy.service.ts
deleted file mode 100644
index 87ae01c04..000000000
--- a/client/src/app/+admin/follows/shared/redundancy.service.ts
+++ /dev/null
@@ -1,28 +0,0 @@
1import { catchError, map } from 'rxjs/operators'
2import { HttpClient } from '@angular/common/http'
3import { Injectable } from '@angular/core'
4import { RestExtractor } from '@app/shared'
5import { environment } from '../../../../environments/environment'
6
7@Injectable()
8export class RedundancyService {
9 static BASE_USER_SUBSCRIPTIONS_URL = environment.apiUrl + '/api/v1/server/redundancy'
10
11 constructor (
12 private authHttp: HttpClient,
13 private restExtractor: RestExtractor
14 ) { }
15
16 updateRedundancy (host: string, redundancyAllowed: boolean) {
17 const url = RedundancyService.BASE_USER_SUBSCRIPTIONS_URL + '/' + host
18
19 const body = { redundancyAllowed }
20
21 return this.authHttp.put(url, body)
22 .pipe(
23 map(this.restExtractor.extractDataBool),
24 catchError(err => this.restExtractor.handleError(err))
25 )
26 }
27
28}
diff --git a/client/src/app/+admin/follows/video-redundancies-list/index.ts b/client/src/app/+admin/follows/video-redundancies-list/index.ts
new file mode 100644
index 000000000..6a7c7f483
--- /dev/null
+++ b/client/src/app/+admin/follows/video-redundancies-list/index.ts
@@ -0,0 +1 @@
export * from './video-redundancies-list.component'
diff --git a/client/src/app/+admin/follows/video-redundancies-list/video-redundancies-list.component.html b/client/src/app/+admin/follows/video-redundancies-list/video-redundancies-list.component.html
new file mode 100644
index 000000000..80c66ec60
--- /dev/null
+++ b/client/src/app/+admin/follows/video-redundancies-list/video-redundancies-list.component.html
@@ -0,0 +1,82 @@
1<div class="admin-sub-header">
2 <div i18n class="form-sub-title">Video redundancies list</div>
3
4 <div class="select-filter-block">
5 <label for="displayType" i18n>Display</label>
6
7 <div class="peertube-select-container">
8 <select id="displayType" name="displayType" [(ngModel)]="displayType" (ngModelChange)="onDisplayTypeChanged()">
9 <option value="my-videos">My videos duplicated by remote instances</option>
10 <option value="remote-videos">Remote videos duplicated by my instance</option>
11 </select>
12 </div>
13 </div>
14</div>
15
16<p-table
17 [value]="videoRedundancies" [lazy]="true" [paginator]="true" [totalRecords]="totalRecords" [rows]="rowsPerPage"
18 [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" dataKey="id"
19>
20 <ng-template pTemplate="header">
21 <tr>
22 <th i18n *ngIf="isDisplayingRemoteVideos()">Strategy</th>
23 <th i18n pSortableColumn="name">Video name <p-sortIcon field="name"></p-sortIcon></th>
24 <th i18n>Video URL</th>
25 <th i18n *ngIf="isDisplayingRemoteVideos()">Total size</th>
26 <th></th>
27 </tr>
28 </ng-template>
29
30 <ng-template pTemplate="body" let-redundancy>
31 <tr class="expander" [pRowToggler]="redundancy">
32 <td *ngIf="isDisplayingRemoteVideos()">{{ getRedundancyStrategy(redundancy) }}</td>
33
34 <td>{{ redundancy.name }}</td>
35
36 <td>
37 <a target="_blank" rel="noopener noreferrer" [href]="redundancy.url">{{ redundancy.url }}</a>
38 </td>
39
40 <td *ngIf="isDisplayingRemoteVideos()">{{ getTotalSize(redundancy) | bytes: 1 }}</td>
41
42 <td class="action-cell">
43 <my-delete-button (click)="removeRedundancy(redundancy)"></my-delete-button>
44 </td>
45 </tr>
46 </ng-template>
47
48 <ng-template pTemplate="rowexpansion" let-redundancy>
49 <tr>
50 <td colspan="2">
51 <div *ngFor="let file of redundancy.redundancies.files" class="expansion-block">
52 <my-video-redundancy-information [redundancyElement]="file"></my-video-redundancy-information>
53 </div>
54 </td>
55 </tr>
56
57 <tr>
58 <td colspan="2">
59 <div *ngFor="let playlist of redundancy.redundancies.streamingPlaylists">
60 <my-video-redundancy-information [redundancyElement]="playlist"></my-video-redundancy-information>
61 </div>
62 </td>
63 </tr>
64 </ng-template>
65</p-table>
66
67
68<div class="redundancies-charts" *ngIf="isDisplayingRemoteVideos()">
69 <div class="form-sub-title" i18n>Enabled strategies stats</div>
70
71 <div class="chart-blocks">
72
73 <div *ngIf="noRedundancies" i18n class="no-results">
74 No redundancy strategy is enabled on your instance.
75 </div>
76
77 <div class="chart-block" *ngFor="let r of redundanciesGraphsData">
78 <p-chart type="pie" [data]="r.graphData" [options]="r.options" width="300px" height="300px"></p-chart>
79 </div>
80
81 </div>
82</div>
diff --git a/client/src/app/+admin/follows/video-redundancies-list/video-redundancies-list.component.scss b/client/src/app/+admin/follows/video-redundancies-list/video-redundancies-list.component.scss
new file mode 100644
index 000000000..05018c281
--- /dev/null
+++ b/client/src/app/+admin/follows/video-redundancies-list/video-redundancies-list.component.scss
@@ -0,0 +1,37 @@
1@import '_variables';
2@import '_mixins';
3
4.expansion-block {
5 margin-bottom: 20px;
6}
7
8.admin-sub-header {
9 align-items: flex-end;
10
11 .select-filter-block {
12 &:not(:last-child) {
13 margin-right: 10px;
14 }
15
16 label {
17 margin-bottom: 2px;
18 }
19
20 .peertube-select-container {
21 @include peertube-select-container(auto);
22 }
23 }
24}
25
26.redundancies-charts {
27 margin-top: 50px;
28
29 .chart-blocks {
30 display: flex;
31 justify-content: center;
32
33 .chart-block {
34 margin: 0 20px;
35 }
36 }
37}
diff --git a/client/src/app/+admin/follows/video-redundancies-list/video-redundancies-list.component.ts b/client/src/app/+admin/follows/video-redundancies-list/video-redundancies-list.component.ts
new file mode 100644
index 000000000..4b41d1d86
--- /dev/null
+++ b/client/src/app/+admin/follows/video-redundancies-list/video-redundancies-list.component.ts
@@ -0,0 +1,178 @@
1import { Component, OnInit } from '@angular/core'
2import { Notifier, ServerService } from '@app/core'
3import { SortMeta } from 'primeng/api'
4import { ConfirmService } from '../../../core/confirm/confirm.service'
5import { RestPagination, RestTable } from '../../../shared'
6import { I18n } from '@ngx-translate/i18n-polyfill'
7import { VideoRedundanciesTarget, VideoRedundancy } from '@shared/models'
8import { peertubeLocalStorage } from '@app/shared/misc/peertube-web-storage'
9import { VideosRedundancyStats } from '@shared/models/server'
10import { BytesPipe } from 'ngx-pipes'
11import { RedundancyService } from '@app/shared/video/redundancy.service'
12
13@Component({
14 selector: 'my-video-redundancies-list',
15 templateUrl: './video-redundancies-list.component.html',
16 styleUrls: [ './video-redundancies-list.component.scss' ]
17})
18export class VideoRedundanciesListComponent extends RestTable implements OnInit {
19 private static LOCAL_STORAGE_DISPLAY_TYPE = 'video-redundancies-list-display-type'
20
21 videoRedundancies: VideoRedundancy[] = []
22 totalRecords = 0
23 rowsPerPage = 10
24
25 sort: SortMeta = { field: 'name', order: 1 }
26 pagination: RestPagination = { count: this.rowsPerPage, start: 0 }
27 displayType: VideoRedundanciesTarget = 'my-videos'
28
29 redundanciesGraphsData: { stats: VideosRedundancyStats, graphData: object, options: object }[] = []
30
31 noRedundancies = false
32
33 private bytesPipe: BytesPipe
34
35 constructor (
36 private notifier: Notifier,
37 private confirmService: ConfirmService,
38 private redundancyService: RedundancyService,
39 private serverService: ServerService,
40 private i18n: I18n
41 ) {
42 super()
43
44 this.bytesPipe = new BytesPipe()
45 }
46
47 ngOnInit () {
48 this.loadSelectLocalStorage()
49
50 this.initialize()
51
52 this.serverService.getServerStats()
53 .subscribe(res => {
54 const redundancies = res.videosRedundancy
55
56 if (redundancies.length === 0) this.noRedundancies = true
57
58 for (const r of redundancies) {
59 this.buildPieData(r)
60 }
61 })
62 }
63
64 isDisplayingRemoteVideos () {
65 return this.displayType === 'remote-videos'
66 }
67
68 getTotalSize (redundancy: VideoRedundancy) {
69 return redundancy.redundancies.files.reduce((a, b) => a + b.size, 0) +
70 redundancy.redundancies.streamingPlaylists.reduce((a, b) => a + b.size, 0)
71 }
72
73 onDisplayTypeChanged () {
74 this.pagination.start = 0
75 this.saveSelectLocalStorage()
76
77 this.loadData()
78 }
79
80 getRedundancyStrategy (redundancy: VideoRedundancy) {
81 if (redundancy.redundancies.files.length !== 0) return redundancy.redundancies.files[0].strategy
82 if (redundancy.redundancies.streamingPlaylists.length !== 0) return redundancy.redundancies.streamingPlaylists[0].strategy
83
84 return ''
85 }
86
87 buildPieData (stats: VideosRedundancyStats) {
88 const totalSize = stats.totalSize
89 ? stats.totalSize - stats.totalUsed
90 : stats.totalUsed
91
92 if (totalSize === 0) return
93
94 this.redundanciesGraphsData.push({
95 stats,
96 graphData: {
97 labels: [ this.i18n('Used'), this.i18n('Available') ],
98 datasets: [
99 {
100 data: [ stats.totalUsed, totalSize ],
101 backgroundColor: [
102 '#FF6384',
103 '#36A2EB'
104 ],
105 hoverBackgroundColor: [
106 '#FF6384',
107 '#36A2EB'
108 ]
109 }
110 ]
111 },
112 options: {
113 title: {
114 display: true,
115 text: stats.strategy
116 },
117
118 tooltips: {
119 callbacks: {
120 label: (tooltipItem: any, data: any) => {
121 const dataset = data.datasets[tooltipItem.datasetIndex]
122 let label = data.labels[tooltipItem.index]
123 if (label) label += ': '
124 else label = ''
125
126 label += this.bytesPipe.transform(dataset.data[tooltipItem.index], 1)
127 return label
128 }
129 }
130 }
131 }
132 })
133 }
134
135 async removeRedundancy (redundancy: VideoRedundancy) {
136 const message = this.i18n('Do you really want to remove this video redundancy?')
137 const res = await this.confirmService.confirm(message, this.i18n('Remove redundancy'))
138 if (res === false) return
139
140 this.redundancyService.removeVideoRedundancies(redundancy)
141 .subscribe(
142 () => {
143 this.notifier.success(this.i18n('Video redundancies removed!'))
144 this.loadData()
145 },
146
147 err => this.notifier.error(err.message)
148 )
149
150 }
151
152 protected loadData () {
153 const options = {
154 pagination: this.pagination,
155 sort: this.sort,
156 target: this.displayType
157 }
158
159 this.redundancyService.listVideoRedundancies(options)
160 .subscribe(
161 resultList => {
162 this.videoRedundancies = resultList.data
163 this.totalRecords = resultList.total
164 },
165
166 err => this.notifier.error(err.message)
167 )
168 }
169
170 private loadSelectLocalStorage () {
171 const displayType = peertubeLocalStorage.getItem(VideoRedundanciesListComponent.LOCAL_STORAGE_DISPLAY_TYPE)
172 if (displayType) this.displayType = displayType as VideoRedundanciesTarget
173 }
174
175 private saveSelectLocalStorage () {
176 peertubeLocalStorage.setItem(VideoRedundanciesListComponent.LOCAL_STORAGE_DISPLAY_TYPE, this.displayType)
177 }
178}
diff --git a/client/src/app/+admin/follows/video-redundancies-list/video-redundancy-information.component.html b/client/src/app/+admin/follows/video-redundancies-list/video-redundancy-information.component.html
new file mode 100644
index 000000000..a379520e3
--- /dev/null
+++ b/client/src/app/+admin/follows/video-redundancies-list/video-redundancy-information.component.html
@@ -0,0 +1,24 @@
1<div>
2 <span class="label">Url</span>
3 <a target="_blank" rel="noopener noreferrer" [href]="redundancyElement.fileUrl">{{ redundancyElement.fileUrl }}</a>
4</div>
5
6<div>
7 <span class="label">Created on</span>
8 <span>{{ redundancyElement.createdAt | date: 'medium' }}</span>
9</div>
10
11<div>
12 <span class="label">Expires on</span>
13 <span>{{ redundancyElement.expiresOn | date: 'medium' }}</span>
14</div>
15
16<div>
17 <span class="label">Size</span>
18 <span>{{ redundancyElement.size | bytes: 1 }}</span>
19</div>
20
21<div *ngIf="redundancyElement.strategy">
22 <span class="label">Strategy</span>
23 <span>{{ redundancyElement.strategy }}</span>
24</div>
diff --git a/client/src/app/+admin/follows/video-redundancies-list/video-redundancy-information.component.scss b/client/src/app/+admin/follows/video-redundancies-list/video-redundancy-information.component.scss
new file mode 100644
index 000000000..6b09fbb01
--- /dev/null
+++ b/client/src/app/+admin/follows/video-redundancies-list/video-redundancy-information.component.scss
@@ -0,0 +1,8 @@
1@import '_variables';
2@import '_mixins';
3
4.label {
5 display: inline-block;
6 min-width: 100px;
7 font-weight: $font-semibold;
8}
diff --git a/client/src/app/+admin/follows/video-redundancies-list/video-redundancy-information.component.ts b/client/src/app/+admin/follows/video-redundancies-list/video-redundancy-information.component.ts
new file mode 100644
index 000000000..6f3090c08
--- /dev/null
+++ b/client/src/app/+admin/follows/video-redundancies-list/video-redundancy-information.component.ts
@@ -0,0 +1,11 @@
1import { Component, Input } from '@angular/core'
2import { FileRedundancyInformation, StreamingPlaylistRedundancyInformation } from '@shared/models'
3
4@Component({
5 selector: 'my-video-redundancy-information',
6 templateUrl: './video-redundancy-information.component.html',
7 styleUrls: [ './video-redundancy-information.component.scss' ]
8})
9export class VideoRedundancyInformationComponent {
10 @Input() redundancyElement: FileRedundancyInformation | StreamingPlaylistRedundancyInformation
11}
diff --git a/client/src/app/+admin/moderation/instance-blocklist/instance-account-blocklist.component.ts b/client/src/app/+admin/moderation/instance-blocklist/instance-account-blocklist.component.ts
index 032bf745a..03e3379e6 100644
--- a/client/src/app/+admin/moderation/instance-blocklist/instance-account-blocklist.component.ts
+++ b/client/src/app/+admin/moderation/instance-blocklist/instance-account-blocklist.component.ts
@@ -2,7 +2,7 @@ import { Component, OnInit } from '@angular/core'
2import { Notifier } from '@app/core' 2import { Notifier } from '@app/core'
3import { I18n } from '@ngx-translate/i18n-polyfill' 3import { I18n } from '@ngx-translate/i18n-polyfill'
4import { RestPagination, RestTable } from '@app/shared' 4import { RestPagination, RestTable } from '@app/shared'
5import { SortMeta } from 'primeng/components/common/sortmeta' 5import { SortMeta } from 'primeng/api'
6import { AccountBlock, BlocklistService } from '@app/shared/blocklist' 6import { AccountBlock, BlocklistService } from '@app/shared/blocklist'
7 7
8@Component({ 8@Component({
diff --git a/client/src/app/+admin/moderation/instance-blocklist/instance-server-blocklist.component.ts b/client/src/app/+admin/moderation/instance-blocklist/instance-server-blocklist.component.ts
index db3dfcd1c..d92a4f195 100644
--- a/client/src/app/+admin/moderation/instance-blocklist/instance-server-blocklist.component.ts
+++ b/client/src/app/+admin/moderation/instance-blocklist/instance-server-blocklist.component.ts
@@ -2,7 +2,7 @@ import { Component, OnInit } from '@angular/core'
2import { Notifier } from '@app/core' 2import { Notifier } from '@app/core'
3import { I18n } from '@ngx-translate/i18n-polyfill' 3import { I18n } from '@ngx-translate/i18n-polyfill'
4import { RestPagination, RestTable } from '@app/shared' 4import { RestPagination, RestTable } from '@app/shared'
5import { SortMeta } from 'primeng/components/common/sortmeta' 5import { SortMeta } from 'primeng/api'
6import { BlocklistService } from '@app/shared/blocklist' 6import { BlocklistService } from '@app/shared/blocklist'
7import { ServerBlock } from '../../../../../../shared' 7import { ServerBlock } from '../../../../../../shared'
8 8
diff --git a/client/src/app/+admin/moderation/video-abuse-list/moderation-comment-modal.component.ts b/client/src/app/+admin/moderation/video-abuse-list/moderation-comment-modal.component.ts
index f8a5ef8cb..29f90194b 100644
--- a/client/src/app/+admin/moderation/video-abuse-list/moderation-comment-modal.component.ts
+++ b/client/src/app/+admin/moderation/video-abuse-list/moderation-comment-modal.component.ts
@@ -38,7 +38,7 @@ export class ModerationCommentModalComponent extends FormReactive implements OnI
38 38
39 openModal (abuseToComment: VideoAbuse) { 39 openModal (abuseToComment: VideoAbuse) {
40 this.abuseToComment = abuseToComment 40 this.abuseToComment = abuseToComment
41 this.openedModal = this.modalService.open(this.modal) 41 this.openedModal = this.modalService.open(this.modal, { centered: true })
42 42
43 this.form.patchValue({ 43 this.form.patchValue({
44 moderationComment: this.abuseToComment.moderationComment 44 moderationComment: this.abuseToComment.moderationComment
diff --git a/client/src/app/+admin/moderation/video-abuse-list/video-abuse-list.component.ts b/client/src/app/+admin/moderation/video-abuse-list/video-abuse-list.component.ts
index 778f18d3d..51114e087 100644
--- a/client/src/app/+admin/moderation/video-abuse-list/video-abuse-list.component.ts
+++ b/client/src/app/+admin/moderation/video-abuse-list/video-abuse-list.component.ts
@@ -1,7 +1,7 @@
1import { Component, OnInit, ViewChild } from '@angular/core' 1import { Component, OnInit, ViewChild } from '@angular/core'
2import { Account } from '../../../shared/account/account.model' 2import { Account } from '../../../shared/account/account.model'
3import { Notifier } from '@app/core' 3import { Notifier } from '@app/core'
4import { SortMeta } from 'primeng/components/common/sortmeta' 4import { SortMeta } from 'primeng/api'
5import { VideoAbuse, VideoAbuseState } from '../../../../../../shared' 5import { VideoAbuse, VideoAbuseState } from '../../../../../../shared'
6import { RestPagination, RestTable, VideoAbuseService } from '../../../shared' 6import { RestPagination, RestTable, VideoAbuseService } from '../../../shared'
7import { I18n } from '@ngx-translate/i18n-polyfill' 7import { I18n } from '@ngx-translate/i18n-polyfill'
diff --git a/client/src/app/+admin/moderation/video-blacklist-list/video-blacklist-list.component.ts b/client/src/app/+admin/moderation/video-blacklist-list/video-blacklist-list.component.ts
index 5876f658b..a215714b8 100644
--- a/client/src/app/+admin/moderation/video-blacklist-list/video-blacklist-list.component.ts
+++ b/client/src/app/+admin/moderation/video-blacklist-list/video-blacklist-list.component.ts
@@ -1,5 +1,5 @@
1import { Component, OnInit } from '@angular/core' 1import { Component, OnInit } from '@angular/core'
2import { SortMeta } from 'primeng/components/common/sortmeta' 2import { SortMeta } from 'primeng/api'
3import { Notifier, ServerService } from '@app/core' 3import { Notifier, ServerService } from '@app/core'
4import { ConfirmService } from '../../../core' 4import { ConfirmService } from '../../../core'
5import { RestPagination, RestTable, VideoBlacklistService } from '../../../shared' 5import { RestPagination, RestTable, VideoBlacklistService } from '../../../shared'
diff --git a/client/src/app/+admin/plugins/plugin-search/plugin-search.component.html b/client/src/app/+admin/plugins/plugin-search/plugin-search.component.html
index 6ec6301b1..5f18028c9 100644
--- a/client/src/app/+admin/plugins/plugin-search/plugin-search.component.html
+++ b/client/src/app/+admin/plugins/plugin-search/plugin-search.component.html
@@ -3,7 +3,7 @@
3</div> 3</div>
4 4
5<div class="search-bar"> 5<div class="search-bar">
6 <input type="text" (input)="onSearchChange($event.target.value)" i18n-placeholder placeholder="Search..."/> 6 <input type="text" (input)="onSearchChange($event)" i18n-placeholder placeholder="Search..."/>
7</div> 7</div>
8 8
9<div class="alert alert-info" i18n *ngIf="pluginInstalled"> 9<div class="alert alert-info" i18n *ngIf="pluginInstalled">
diff --git a/client/src/app/+admin/plugins/plugin-search/plugin-search.component.ts b/client/src/app/+admin/plugins/plugin-search/plugin-search.component.ts
index e08ded3f1..dc59e759b 100644
--- a/client/src/app/+admin/plugins/plugin-search/plugin-search.component.ts
+++ b/client/src/app/+admin/plugins/plugin-search/plugin-search.component.ts
@@ -70,8 +70,10 @@ export class PluginSearchComponent implements OnInit {
70 this.reloadPlugins() 70 this.reloadPlugins()
71 } 71 }
72 72
73 onSearchChange (search: string) { 73 onSearchChange (event: Event) {
74 this.searchSubject.next(search) 74 const target = event.target as HTMLInputElement
75
76 this.searchSubject.next(target.value)
75 } 77 }
76 78
77 reloadPlugins () { 79 reloadPlugins () {
diff --git a/client/src/app/+admin/system/jobs/jobs.component.ts b/client/src/app/+admin/system/jobs/jobs.component.ts
index 20c8ea71a..bc40452cf 100644
--- a/client/src/app/+admin/system/jobs/jobs.component.ts
+++ b/client/src/app/+admin/system/jobs/jobs.component.ts
@@ -16,8 +16,8 @@ import { JobTypeClient } from '../../../../types/job-type-client.type'
16 styleUrls: [ './jobs.component.scss' ] 16 styleUrls: [ './jobs.component.scss' ]
17}) 17})
18export class JobsComponent extends RestTable implements OnInit { 18export class JobsComponent extends RestTable implements OnInit {
19 private static JOB_STATE_LOCAL_STORAGE_STATE = 'jobs-list-state' 19 private static LOCAL_STORAGE_STATE = 'jobs-list-state'
20 private static JOB_STATE_LOCAL_STORAGE_TYPE = 'jobs-list-type' 20 private static LOCAL_STORAGE_TYPE = 'jobs-list-type'
21 21
22 jobState: JobStateClient = 'waiting' 22 jobState: JobStateClient = 'waiting'
23 jobStates: JobStateClient[] = [ 'active', 'completed', 'failed', 'waiting', 'delayed' ] 23 jobStates: JobStateClient[] = [ 'active', 'completed', 'failed', 'waiting', 'delayed' ]
@@ -34,7 +34,8 @@ export class JobsComponent extends RestTable implements OnInit {
34 'video-file-import', 34 'video-file-import',
35 'video-import', 35 'video-import',
36 'videos-views', 36 'videos-views',
37 'activitypub-refresher' 37 'activitypub-refresher',
38 'video-redundancy'
38 ] 39 ]
39 40
40 jobs: Job[] = [] 41 jobs: Job[] = []
@@ -77,15 +78,15 @@ export class JobsComponent extends RestTable implements OnInit {
77 } 78 }
78 79
79 private loadJobStateAndType () { 80 private loadJobStateAndType () {
80 const state = peertubeLocalStorage.getItem(JobsComponent.JOB_STATE_LOCAL_STORAGE_STATE) 81 const state = peertubeLocalStorage.getItem(JobsComponent.LOCAL_STORAGE_STATE)
81 if (state) this.jobState = state as JobState 82 if (state) this.jobState = state as JobState
82 83
83 const type = peertubeLocalStorage.getItem(JobsComponent.JOB_STATE_LOCAL_STORAGE_TYPE) 84 const type = peertubeLocalStorage.getItem(JobsComponent.LOCAL_STORAGE_TYPE)
84 if (type) this.jobType = type as JobType 85 if (type) this.jobType = type as JobType
85 } 86 }
86 87
87 private saveJobStateAndType () { 88 private saveJobStateAndType () {
88 peertubeLocalStorage.setItem(JobsComponent.JOB_STATE_LOCAL_STORAGE_STATE, this.jobState) 89 peertubeLocalStorage.setItem(JobsComponent.LOCAL_STORAGE_STATE, this.jobState)
89 peertubeLocalStorage.setItem(JobsComponent.JOB_STATE_LOCAL_STORAGE_TYPE, this.jobType) 90 peertubeLocalStorage.setItem(JobsComponent.LOCAL_STORAGE_TYPE, this.jobType)
90 } 91 }
91} 92}
diff --git a/client/src/app/+admin/users/user-edit/user-create.component.ts b/client/src/app/+admin/users/user-edit/user-create.component.ts
index e726ec4d7..1769c0de0 100644
--- a/client/src/app/+admin/users/user-edit/user-create.component.ts
+++ b/client/src/app/+admin/users/user-edit/user-create.component.ts
@@ -1,5 +1,5 @@
1import { Component, OnInit } from '@angular/core' 1import { Component, OnInit } from '@angular/core'
2import { Router } from '@angular/router' 2import { Router, ActivatedRoute } from '@angular/router'
3import { AuthService, Notifier, ServerService } from '@app/core' 3import { AuthService, Notifier, ServerService } from '@app/core'
4import { UserCreate, UserRole } from '../../../../../../shared' 4import { UserCreate, UserRole } from '../../../../../../shared'
5import { UserEdit } from './user-edit' 5import { UserEdit } from './user-edit'
@@ -23,6 +23,7 @@ export class UserCreateComponent extends UserEdit implements OnInit {
23 protected configService: ConfigService, 23 protected configService: ConfigService,
24 protected auth: AuthService, 24 protected auth: AuthService,
25 private userValidatorsService: UserValidatorsService, 25 private userValidatorsService: UserValidatorsService,
26 private route: ActivatedRoute,
26 private router: Router, 27 private router: Router,
27 private notifier: Notifier, 28 private notifier: Notifier,
28 private userService: UserService, 29 private userService: UserService,
@@ -45,7 +46,7 @@ export class UserCreateComponent extends UserEdit implements OnInit {
45 this.buildForm({ 46 this.buildForm({
46 username: this.userValidatorsService.USER_USERNAME, 47 username: this.userValidatorsService.USER_USERNAME,
47 email: this.userValidatorsService.USER_EMAIL, 48 email: this.userValidatorsService.USER_EMAIL,
48 password: this.userValidatorsService.USER_PASSWORD, 49 password: this.isPasswordOptional() ? this.userValidatorsService.USER_PASSWORD_OPTIONAL : this.userValidatorsService.USER_PASSWORD,
49 role: this.userValidatorsService.USER_ROLE, 50 role: this.userValidatorsService.USER_ROLE,
50 videoQuota: this.userValidatorsService.USER_VIDEO_QUOTA, 51 videoQuota: this.userValidatorsService.USER_VIDEO_QUOTA,
51 videoQuotaDaily: this.userValidatorsService.USER_VIDEO_QUOTA_DAILY, 52 videoQuotaDaily: this.userValidatorsService.USER_VIDEO_QUOTA_DAILY,
@@ -78,6 +79,11 @@ export class UserCreateComponent extends UserEdit implements OnInit {
78 return true 79 return true
79 } 80 }
80 81
82 isPasswordOptional () {
83 const serverConfig = this.route.snapshot.data.serverConfig
84 return serverConfig.email.enabled
85 }
86
81 getFormButtonTitle () { 87 getFormButtonTitle () {
82 return this.i18n('Create user') 88 return this.i18n('Create user')
83 } 89 }
diff --git a/client/src/app/+admin/users/user-edit/user-edit.component.html b/client/src/app/+admin/users/user-edit/user-edit.component.html
index 4ff4d0d12..2aca5ddca 100644
--- a/client/src/app/+admin/users/user-edit/user-edit.component.html
+++ b/client/src/app/+admin/users/user-edit/user-edit.component.html
@@ -29,6 +29,13 @@
29 29
30 <div class="form-group" *ngIf="isCreation()"> 30 <div class="form-group" *ngIf="isCreation()">
31 <label i18n for="password">Password</label> 31 <label i18n for="password">Password</label>
32 <my-help *ngIf="isPasswordOptional()">
33 <ng-template ptTemplate="customHtml">
34 <ng-container i18n>
35 If you leave the password empty, an email will be sent to the user.
36 </ng-container>
37 </ng-template>
38 </my-help>
32 <input 39 <input
33 type="password" id="password" autocomplete="new-password" 40 type="password" id="password" autocomplete="new-password"
34 formControlName="password" [ngClass]="{ 'input-error': formErrors['password'] }" 41 formControlName="password" [ngClass]="{ 'input-error': formErrors['password'] }"
diff --git a/client/src/app/+admin/users/user-edit/user-update.component.ts b/client/src/app/+admin/users/user-edit/user-update.component.ts
index d1682a99d..1ab2e9dbf 100644
--- a/client/src/app/+admin/users/user-edit/user-update.component.ts
+++ b/client/src/app/+admin/users/user-edit/user-update.component.ts
@@ -92,6 +92,10 @@ export class UserUpdateComponent extends UserEdit implements OnInit, OnDestroy {
92 return false 92 return false
93 } 93 }
94 94
95 isPasswordOptional () {
96 return false
97 }
98
95 getFormButtonTitle () { 99 getFormButtonTitle () {
96 return this.i18n('Update user') 100 return this.i18n('Update user')
97 } 101 }
diff --git a/client/src/app/+admin/users/user-list/user-list.component.html b/client/src/app/+admin/users/user-list/user-list.component.html
index ca05bac19..249883efc 100644
--- a/client/src/app/+admin/users/user-list/user-list.component.html
+++ b/client/src/app/+admin/users/user-list/user-list.component.html
@@ -25,7 +25,7 @@
25 <div> 25 <div>
26 <input 26 <input
27 type="text" name="table-filter" id="table-filter" i18n-placeholder placeholder="Filter..." 27 type="text" name="table-filter" id="table-filter" i18n-placeholder placeholder="Filter..."
28 (keyup)="onSearch($event.target.value)" 28 (keyup)="onSearch($event)"
29 > 29 >
30 </div> 30 </div>
31 </div> 31 </div>
diff --git a/client/src/app/+admin/users/user-list/user-list.component.ts b/client/src/app/+admin/users/user-list/user-list.component.ts
index 7c5e8eaa4..6e9a1feda 100644
--- a/client/src/app/+admin/users/user-list/user-list.component.ts
+++ b/client/src/app/+admin/users/user-list/user-list.component.ts
@@ -1,6 +1,6 @@
1import { Component, OnInit, ViewChild } from '@angular/core' 1import { Component, OnInit, ViewChild } from '@angular/core'
2import { AuthService, Notifier } from '@app/core' 2import { AuthService, Notifier } from '@app/core'
3import { SortMeta } from 'primeng/components/common/sortmeta' 3import { SortMeta } from 'primeng/api'
4import { ConfirmService, ServerService } from '../../../core' 4import { ConfirmService, ServerService } from '../../../core'
5import { RestPagination, RestTable, UserService } from '../../../shared' 5import { RestPagination, RestTable, UserService } from '../../../shared'
6import { I18n } from '@ngx-translate/i18n-polyfill' 6import { I18n } from '@ngx-translate/i18n-polyfill'
diff --git a/client/src/app/+admin/users/users.routes.ts b/client/src/app/+admin/users/users.routes.ts
index 8b3791bd3..2d4f9305e 100644
--- a/client/src/app/+admin/users/users.routes.ts
+++ b/client/src/app/+admin/users/users.routes.ts
@@ -5,6 +5,7 @@ import { UserRight } from '../../../../../shared'
5import { UsersComponent } from './users.component' 5import { UsersComponent } from './users.component'
6import { UserCreateComponent, UserUpdateComponent } from './user-edit' 6import { UserCreateComponent, UserUpdateComponent } from './user-edit'
7import { UserListComponent } from './user-list' 7import { UserListComponent } from './user-list'
8import { ServerConfigResolver } from '@app/core/routing/server-config-resolver.service'
8 9
9export const UsersRoutes: Routes = [ 10export const UsersRoutes: Routes = [
10 { 11 {
@@ -36,6 +37,9 @@ export const UsersRoutes: Routes = [
36 meta: { 37 meta: {
37 title: 'Create a user' 38 title: 'Create a user'
38 } 39 }
40 },
41 resolve: {
42 serverConfig: ServerConfigResolver
39 } 43 }
40 }, 44 },
41 { 45 {
diff --git a/client/src/app/+my-account/my-account-blocklist/my-account-blocklist.component.ts b/client/src/app/+my-account/my-account-blocklist/my-account-blocklist.component.ts
index e3025dec4..15b58e45c 100644
--- a/client/src/app/+my-account/my-account-blocklist/my-account-blocklist.component.ts
+++ b/client/src/app/+my-account/my-account-blocklist/my-account-blocklist.component.ts
@@ -2,7 +2,7 @@ import { Component, OnInit } from '@angular/core'
2import { Notifier } from '@app/core' 2import { Notifier } from '@app/core'
3import { I18n } from '@ngx-translate/i18n-polyfill' 3import { I18n } from '@ngx-translate/i18n-polyfill'
4import { RestPagination, RestTable } from '@app/shared' 4import { RestPagination, RestTable } from '@app/shared'
5import { SortMeta } from 'primeng/components/common/sortmeta' 5import { SortMeta } from 'primeng/api'
6import { AccountBlock, BlocklistService } from '@app/shared/blocklist' 6import { AccountBlock, BlocklistService } from '@app/shared/blocklist'
7 7
8@Component({ 8@Component({
diff --git a/client/src/app/+my-account/my-account-blocklist/my-account-server-blocklist.component.ts b/client/src/app/+my-account/my-account-blocklist/my-account-server-blocklist.component.ts
index 4c5cc28b8..e735d4ab7 100644
--- a/client/src/app/+my-account/my-account-blocklist/my-account-server-blocklist.component.ts
+++ b/client/src/app/+my-account/my-account-blocklist/my-account-server-blocklist.component.ts
@@ -2,7 +2,7 @@ import { Component, OnInit } from '@angular/core'
2import { Notifier } from '@app/core' 2import { Notifier } from '@app/core'
3import { I18n } from '@ngx-translate/i18n-polyfill' 3import { I18n } from '@ngx-translate/i18n-polyfill'
4import { RestPagination, RestTable } from '@app/shared' 4import { RestPagination, RestTable } from '@app/shared'
5import { SortMeta } from 'primeng/components/common/sortmeta' 5import { SortMeta } from 'primeng/api'
6import { ServerBlock } from '../../../../../shared' 6import { ServerBlock } from '../../../../../shared'
7import { BlocklistService } from '@app/shared/blocklist' 7import { BlocklistService } from '@app/shared/blocklist'
8 8
diff --git a/client/src/app/+my-account/my-account-ownership/my-account-accept-ownership/my-account-accept-ownership.component.ts b/client/src/app/+my-account/my-account-ownership/my-account-accept-ownership/my-account-accept-ownership.component.ts
index 6df929ec9..d5682914e 100644
--- a/client/src/app/+my-account/my-account-ownership/my-account-accept-ownership/my-account-accept-ownership.component.ts
+++ b/client/src/app/+my-account/my-account-ownership/my-account-accept-ownership/my-account-accept-ownership.component.ts
@@ -53,7 +53,7 @@ export class MyAccountAcceptOwnershipComponent extends FormReactive implements O
53 show (videoChangeOwnership: VideoChangeOwnership) { 53 show (videoChangeOwnership: VideoChangeOwnership) {
54 this.videoChangeOwnership = videoChangeOwnership 54 this.videoChangeOwnership = videoChangeOwnership
55 this.modalService 55 this.modalService
56 .open(this.modal) 56 .open(this.modal, { centered: true })
57 .result 57 .result
58 .then(() => this.acceptOwnership()) 58 .then(() => this.acceptOwnership())
59 .catch(() => this.videoChangeOwnership = undefined) 59 .catch(() => this.videoChangeOwnership = undefined)
diff --git a/client/src/app/+my-account/my-account-ownership/my-account-ownership.component.ts b/client/src/app/+my-account/my-account-ownership/my-account-ownership.component.ts
index aeeb0e5a7..cd8065800 100644
--- a/client/src/app/+my-account/my-account-ownership/my-account-ownership.component.ts
+++ b/client/src/app/+my-account/my-account-ownership/my-account-ownership.component.ts
@@ -1,7 +1,7 @@
1import { Component, OnInit, ViewChild } from '@angular/core' 1import { Component, OnInit, ViewChild } from '@angular/core'
2import { Notifier } from '@app/core' 2import { Notifier } from '@app/core'
3import { RestPagination, RestTable } from '@app/shared' 3import { RestPagination, RestTable } from '@app/shared'
4import { SortMeta } from 'primeng/components/common/sortmeta' 4import { SortMeta } from 'primeng/api'
5import { VideoChangeOwnership } from '../../../../../shared' 5import { VideoChangeOwnership } from '../../../../../shared'
6import { VideoOwnershipService } from '@app/shared/video-ownership' 6import { VideoOwnershipService } from '@app/shared/video-ownership'
7import { Account } from '@app/shared/account/account.model' 7import { Account } from '@app/shared/account/account.model'
diff --git a/client/src/app/+my-account/my-account-settings/my-account-video-settings/my-account-video-settings.component.html b/client/src/app/+my-account/my-account-settings/my-account-video-settings/my-account-video-settings.component.html
index 17d8cde06..51a672734 100644
--- a/client/src/app/+my-account/my-account-settings/my-account-video-settings/my-account-video-settings.component.html
+++ b/client/src/app/+my-account/my-account-settings/my-account-video-settings/my-account-video-settings.component.html
@@ -28,7 +28,7 @@
28 28
29 <div> 29 <div>
30 <p-multiSelect 30 <p-multiSelect
31 inputId="videoLanguages" [options]="languageItems" formControlName="videoLanguages" showToggleAll="true" 31 inputId="videoLanguages" [options]="languageItems" formControlName="videoLanguages" [showToggleAll]="true"
32 [defaultLabel]="getDefaultVideoLanguageLabel()" [selectedItemsLabel]="getSelectedVideoLanguageLabel()" 32 [defaultLabel]="getDefaultVideoLanguageLabel()" [selectedItemsLabel]="getSelectedVideoLanguageLabel()"
33 emptyFilterMessage="No results found" i18n-emptyFilterMessage 33 emptyFilterMessage="No results found" i18n-emptyFilterMessage
34 ></p-multiSelect> 34 ></p-multiSelect>
diff --git a/client/src/app/+my-account/my-account-video-channels/my-account-video-channel-edit.ts b/client/src/app/+my-account/my-account-video-channels/my-account-video-channel-edit.ts
index 7479442d1..355cb4f55 100644
--- a/client/src/app/+my-account/my-account-video-channels/my-account-video-channel-edit.ts
+++ b/client/src/app/+my-account/my-account-video-channels/my-account-video-channel-edit.ts
@@ -9,7 +9,7 @@ export abstract class MyAccountVideoChannelEdit extends FormReactive {
9 abstract isCreation (): boolean 9 abstract isCreation (): boolean
10 abstract getFormButtonTitle (): string 10 abstract getFormButtonTitle (): string
11 11
12 // FIXME: We need this method so angular does not complain in the child template 12 // We need this method so angular does not complain in child template that doesn't need this
13 onAvatarChange (formData: FormData) { /* empty */ } 13 onAvatarChange (formData: FormData) { /* empty */ }
14 14
15 // Should be implemented by the child 15 // Should be implemented by the child
diff --git a/client/src/app/+my-account/my-account-video-channels/my-account-video-channels.component.scss b/client/src/app/+my-account/my-account-video-channels/my-account-video-channels.component.scss
index 20582e478..db0c7f94f 100644
--- a/client/src/app/+my-account/my-account-video-channels/my-account-video-channels.component.scss
+++ b/client/src/app/+my-account/my-account-video-channels/my-account-video-channels.component.scss
@@ -58,7 +58,7 @@
58 margin: 20px 0 50px; 58 margin: 20px 0 50px;
59} 59}
60 60
61@media screen and (max-width: 800px) { 61@media screen and (max-width: $small-view) {
62 .video-channels-header { 62 .video-channels-header {
63 text-align: center; 63 text-align: center;
64 } 64 }
diff --git a/client/src/app/+my-account/my-account-video-imports/my-account-video-imports.component.ts b/client/src/app/+my-account/my-account-video-imports/my-account-video-imports.component.ts
index 21a10c8ff..74c42bb4f 100644
--- a/client/src/app/+my-account/my-account-video-imports/my-account-video-imports.component.ts
+++ b/client/src/app/+my-account/my-account-video-imports/my-account-video-imports.component.ts
@@ -1,6 +1,6 @@
1import { Component, OnInit } from '@angular/core' 1import { Component, OnInit } from '@angular/core'
2import { RestPagination, RestTable } from '@app/shared' 2import { RestPagination, RestTable } from '@app/shared'
3import { SortMeta } from 'primeng/components/common/sortmeta' 3import { SortMeta } from 'primeng/api'
4import { Notifier } from '@app/core' 4import { Notifier } from '@app/core'
5import { I18n } from '@ngx-translate/i18n-polyfill' 5import { I18n } from '@ngx-translate/i18n-polyfill'
6import { VideoImport, VideoImportState } from '../../../../../shared/models/videos' 6import { VideoImport, VideoImportState } from '../../../../../shared/models/videos'
diff --git a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.scss b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.scss
index 4e4156b22..aed3302ba 100644
--- a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.scss
+++ b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.scss
@@ -43,7 +43,7 @@
43 } 43 }
44} 44}
45 45
46@media screen and (max-width: 800px) { 46@media screen and (max-width: $small-view) {
47 .video-playlists-header { 47 .video-playlists-header {
48 text-align: center; 48 text-align: center;
49 } 49 }
diff --git a/client/src/app/+my-account/my-account-videos/video-change-ownership/video-change-ownership.component.ts b/client/src/app/+my-account/my-account-videos/video-change-ownership/video-change-ownership.component.ts
index 36d1ea091..f4e2b5955 100644
--- a/client/src/app/+my-account/my-account-videos/video-change-ownership/video-change-ownership.component.ts
+++ b/client/src/app/+my-account/my-account-videos/video-change-ownership/video-change-ownership.component.ts
@@ -43,7 +43,7 @@ export class VideoChangeOwnershipComponent extends FormReactive implements OnIni
43 show (video: Video) { 43 show (video: Video) {
44 this.video = video 44 this.video = video
45 this.modalService 45 this.modalService
46 .open(this.modal) 46 .open(this.modal, { centered: true })
47 .result 47 .result
48 .then(() => this.changeOwnership()) 48 .then(() => this.changeOwnership())
49 .catch((_) => _) // Called when closing (cancel) the modal without validating, do nothing 49 .catch((_) => _) // Called when closing (cancel) the modal without validating, do nothing
diff --git a/client/src/app/+my-account/shared/actor-avatar-info.component.ts b/client/src/app/+my-account/shared/actor-avatar-info.component.ts
index 101dfa556..8e034bb82 100644
--- a/client/src/app/+my-account/shared/actor-avatar-info.component.ts
+++ b/client/src/app/+my-account/shared/actor-avatar-info.component.ts
@@ -11,7 +11,7 @@ import { ServerConfig } from '@shared/models'
11 styleUrls: [ './actor-avatar-info.component.scss' ] 11 styleUrls: [ './actor-avatar-info.component.scss' ]
12}) 12})
13export class ActorAvatarInfoComponent implements OnInit { 13export class ActorAvatarInfoComponent implements OnInit {
14 @ViewChild('avatarfileInput', { static: false }) avatarfileInput: ElementRef<HTMLInputElement> 14 @ViewChild('avatarfileInput') avatarfileInput: ElementRef<HTMLInputElement>
15 15
16 @Input() actor: VideoChannel | Account 16 @Input() actor: VideoChannel | Account
17 17
diff --git a/client/src/app/+signup/+register/register-step-channel.component.html b/client/src/app/+signup/+register/register-step-channel.component.html
index 88ff6e3ff..170c2964e 100644
--- a/client/src/app/+signup/+register/register-step-channel.component.html
+++ b/client/src/app/+signup/+register/register-step-channel.component.html
@@ -40,7 +40,7 @@
40 </div> 40 </div>
41 41
42 <div class="name-information" i18n> 42 <div class="name-information" i18n>
43 The channel name is a unique identifier of your channel on this instance. It's like an address mail, so other people can find your channel. 43 The channel name is a unique identifier of your channel on this and all the other instances. It's as unique as an email address, which makes it easy for other people to interact with it.
44 </div> 44 </div>
45 45
46 <div *ngIf="formErrors.name" class="form-error"> 46 <div *ngIf="formErrors.name" class="form-error">
diff --git a/client/src/app/+signup/+register/register-step-user.component.html b/client/src/app/+signup/+register/register-step-user.component.html
index a2a657660..6bac4e4a4 100644
--- a/client/src/app/+signup/+register/register-step-user.component.html
+++ b/client/src/app/+signup/+register/register-step-user.component.html
@@ -29,7 +29,7 @@
29 </div> 29 </div>
30 30
31 <div class="name-information" i18n> 31 <div class="name-information" i18n>
32 The username is a unique identifier of your account on this instance. It's like an address mail, so other people can find you. 32 The username is a unique identifier of your account on this and all the other instances. It's as unique as an email address, which makes it easy for other people to interact with it.
33 </div> 33 </div>
34 34
35 <div *ngIf="formErrors.username" class="form-error"> 35 <div *ngIf="formErrors.username" class="form-error">
diff --git a/client/src/app/+signup/+register/register.component.scss b/client/src/app/+signup/+register/register.component.scss
index 2f62dd59d..e135b5cb4 100644
--- a/client/src/app/+signup/+register/register.component.scss
+++ b/client/src/app/+signup/+register/register.component.scss
@@ -44,7 +44,7 @@
44 } 44 }
45 } 45 }
46 46
47 @media screen and (max-width: 500px) { 47 @media screen and (max-width: $mobile-view) {
48 width: auto; 48 width: auto;
49 } 49 }
50 } 50 }
diff --git a/client/src/app/+video-channels/video-channel-videos/video-channel-videos.component.ts b/client/src/app/+video-channels/video-channel-videos/video-channel-videos.component.ts
index f4fe14662..f32a892a4 100644
--- a/client/src/app/+video-channels/video-channel-videos/video-channel-videos.component.ts
+++ b/client/src/app/+video-channels/video-channel-videos/video-channel-videos.component.ts
@@ -72,7 +72,7 @@ export class VideoChannelVideosComponent extends AbstractVideoList implements On
72 .getVideoChannelVideos(this.videoChannel, newPagination, this.sort) 72 .getVideoChannelVideos(this.videoChannel, newPagination, this.sort)
73 .pipe( 73 .pipe(
74 tap(({ total }) => { 74 tap(({ total }) => {
75 this.titlePage = this.i18n(`{total, plural, =1 {Published 1 video} other {Published ${total} videos}}`, { total }) 75 this.titlePage = this.i18n(`{total, plural, =1 {Published 1 video} other {Published {{total}} videos}}`, { total })
76 }) 76 })
77 ) 77 )
78 } 78 }
diff --git a/client/src/app/+video-channels/video-channels.component.html b/client/src/app/+video-channels/video-channels.component.html
index f0bb083ca..1087de113 100644
--- a/client/src/app/+video-channels/video-channels.component.html
+++ b/client/src/app/+video-channels/video-channels.component.html
@@ -7,32 +7,38 @@
7 <div class="actor-info"> 7 <div class="actor-info">
8 <div class="actor-names"> 8 <div class="actor-names">
9 <div class="actor-display-name">{{ videoChannel.displayName }}</div> 9 <div class="actor-display-name">{{ videoChannel.displayName }}</div>
10 <div class="actor-name">{{ videoChannel.nameWithHost }} 10 <div class="actor-name">
11 <button ngxClipboard [cbContent]="videoChannel.nameWithHost" (click)="activateCopiedMessage()" 11 <span>{{ videoChannel.nameWithHost }}</span>
12 <button [cdkCopyToClipboard]="videoChannel.nameWithHost" (click)="activateCopiedMessage()"
12 class="btn btn-outline-secondary btn-sm copy-button" 13 class="btn btn-outline-secondary btn-sm copy-button"
13 > 14 >
14 <span class="glyphicon glyphicon-copy"></span> 15 <span class="glyphicon glyphicon-copy"></span>
15 </button> 16 </button>
16 </div> 17 </div>
18 </div>
17 19
18 <div class="right-buttons"> 20 <div class="right-buttons">
19 <a *ngIf="isChannelManageable" [routerLink]="[ '/my-account/video-channels/update', videoChannel.nameWithHost ]" class="btn btn-outline-tertiary mr-2" i18n>Manage</a> 21 <a *ngIf="isChannelManageable" [routerLink]="[ '/my-account/video-channels/update', videoChannel.nameWithHost ]" class="btn btn-outline-tertiary mr-2" i18n>Manage</a>
20 <my-subscribe-button #subscribeButton [videoChannels]="[videoChannel]"></my-subscribe-button> 22 <my-subscribe-button #subscribeButton [videoChannels]="[videoChannel]"></my-subscribe-button>
21 </div>
22 </div> 23 </div>
23 <div class="actor-followers" i18n>{videoChannel.followersCount, plural, =1 {1 subscriber} other {{{ videoChannel.followersCount }} subscribers}}</div>
24 24
25 <a [routerLink]="[ '/accounts', videoChannel.ownerBy ]" i18n-title title="Go the owner account page" class="actor-owner"> 25 <div class="actor-lower">
26 <span i18n>Created by {{ videoChannel.ownerBy }}</span> 26 <div class="actor-followers" i18n>{videoChannel.followersCount, plural, =1 {1 subscriber} other {{{ videoChannel.followersCount }} subscribers}}</div>
27 <img [src]="videoChannel.ownerAvatarUrl" alt="Owner account avatar" /> 27
28 </a> 28 <a [routerLink]="[ '/accounts', videoChannel.ownerBy ]" i18n-title title="Go the owner account page" class="actor-owner">
29 <span i18n>Created by {{ videoChannel.ownerBy }}</span>
30 <img [src]="videoChannel.ownerAvatarUrl" alt="Owner account avatar" />
31 </a>
32 </div>
29 </div> 33 </div>
30 </div> 34 </div>
31 35
32 <div class="links"> 36 <div class="links w-100">
33 <a i18n routerLink="videos" routerLinkActive="active" class="title-page">Videos</a> 37 <ng-template #linkTemplate let-item="item">
34 <a i18n routerLink="video-playlists" routerLinkActive="active" class="title-page">Video playlists</a> 38 <a [routerLink]="item.routerLink" routerLinkActive="active" class="title-page">{{ item.label }}</a>
35 <a i18n routerLink="about" routerLinkActive="active" class="title-page">About</a> 39 </ng-template>
40
41 <list-overflow [items]="links" [itemTemplate]="linkTemplate"></list-overflow>
36 </div> 42 </div>
37 </div> 43 </div>
38 44
diff --git a/client/src/app/+video-channels/video-channels.component.scss b/client/src/app/+video-channels/video-channels.component.scss
index 50b69e7ac..6470629f8 100644
--- a/client/src/app/+video-channels/video-channels.component.scss
+++ b/client/src/app/+video-channels/video-channels.component.scss
@@ -8,6 +8,21 @@
8 width: 100%; 8 width: 100%;
9 } 9 }
10 10
11 .actor-info {
12 display: grid !important;
13 grid-template-columns: 1fr auto;
14 grid-template-rows: 1fr auto / 1fr auto;
15 grid-template-areas: "name buttons" "lower buttons";
16
17 @media screen and (max-width: #{map-get($grid-breakpoints, lg)}) {
18 grid-template-areas: "name name" "lower buttons";
19 }
20 }
21
22 .actor-names {
23 grid-area: name;
24 }
25
11 .actor-name { 26 .actor-name {
12 flex-grow: 1; 27 flex-grow: 1;
13 28
@@ -25,6 +40,9 @@
25 margin-left: auto; 40 margin-left: auto;
26 margin-top: 20px; 41 margin-top: 20px;
27 42
43 grid-row: buttons-start / span buttons-end;
44 grid-column: buttons-start;
45
28 a { 46 a {
29 @include peertube-button-outline; 47 @include peertube-button-outline;
30 line-height: 1.8; 48 line-height: 1.8;
@@ -33,4 +51,4 @@
33 my-subscribe-button { 51 my-subscribe-button {
34 height: min-content; 52 height: min-content;
35 } 53 }
36} \ No newline at end of file 54}
diff --git a/client/src/app/+video-channels/video-channels.component.ts b/client/src/app/+video-channels/video-channels.component.ts
index 7b335b13f..0889ca854 100644
--- a/client/src/app/+video-channels/video-channels.component.ts
+++ b/client/src/app/+video-channels/video-channels.component.ts
@@ -9,16 +9,18 @@ import { AuthService, Notifier } from '@app/core'
9import { Hotkey, HotkeysService } from 'angular2-hotkeys' 9import { Hotkey, HotkeysService } from 'angular2-hotkeys'
10import { SubscribeButtonComponent } from '@app/shared/user-subscription/subscribe-button.component' 10import { SubscribeButtonComponent } from '@app/shared/user-subscription/subscribe-button.component'
11import { I18n } from '@ngx-translate/i18n-polyfill' 11import { I18n } from '@ngx-translate/i18n-polyfill'
12import { ListOverflowItem } from '@app/shared/misc/list-overflow.component'
12 13
13@Component({ 14@Component({
14 templateUrl: './video-channels.component.html', 15 templateUrl: './video-channels.component.html',
15 styleUrls: [ './video-channels.component.scss' ] 16 styleUrls: [ './video-channels.component.scss' ]
16}) 17})
17export class VideoChannelsComponent implements OnInit, OnDestroy { 18export class VideoChannelsComponent implements OnInit, OnDestroy {
18 @ViewChild('subscribeButton', { static: false }) subscribeButton: SubscribeButtonComponent 19 @ViewChild('subscribeButton') subscribeButton: SubscribeButtonComponent
19 20
20 videoChannel: VideoChannel 21 videoChannel: VideoChannel
21 hotkeys: Hotkey[] 22 hotkeys: Hotkey[]
23 links: ListOverflowItem[] = []
22 isChannelManageable = false 24 isChannelManageable = false
23 25
24 private routeSub: Subscription 26 private routeSub: Subscription
@@ -62,6 +64,12 @@ export class VideoChannelsComponent implements OnInit, OnDestroy {
62 }, undefined, this.i18n('Subscribe to the account')) 64 }, undefined, this.i18n('Subscribe to the account'))
63 ] 65 ]
64 if (this.isUserLoggedIn()) this.hotkeysService.add(this.hotkeys) 66 if (this.isUserLoggedIn()) this.hotkeysService.add(this.hotkeys)
67
68 this.links = [
69 { label: this.i18n('Videos'), routerLink: 'videos' },
70 { label: this.i18n('Video playlists'), routerLink: 'video-playlists' },
71 { label: this.i18n('About'), routerLink: 'about' }
72 ]
65 } 73 }
66 74
67 ngOnDestroy () { 75 ngOnDestroy () {
diff --git a/client/src/app/app.component.html b/client/src/app/app.component.html
index 2660c5377..f5a8dbd34 100644
--- a/client/src/app/app.component.html
+++ b/client/src/app/app.component.html
@@ -15,7 +15,7 @@
15 </div> 15 </div>
16 16
17 <div class="header-right" [ngClass]="{ 'border-bottom': isMenuDisplayed === false }"> 17 <div class="header-right" [ngClass]="{ 'border-bottom': isMenuDisplayed === false }">
18 <my-header></my-header> 18 <my-header class="w-100 d-flex justify-content-end"></my-header>
19 </div> 19 </div>
20 </div> 20 </div>
21 21
diff --git a/client/src/app/app.component.scss b/client/src/app/app.component.scss
index 51a7a3dd1..a7be8e1af 100644
--- a/client/src/app/app.component.scss
+++ b/client/src/app/app.component.scss
@@ -16,12 +16,12 @@
16 top: 0; 16 top: 0;
17 width: 100%; 17 width: 100%;
18 background-color: var(--mainBackgroundColor); 18 background-color: var(--mainBackgroundColor);
19 z-index: 1000; 19 z-index: z(header);
20 box-shadow: 0 1px 3px rgba(0, 0, 0, 0.16); 20 box-shadow: 0 1px 3px rgba(0, 0, 0, 0.16);
21 display: flex; 21 display: flex;
22 22
23 .top-left-block { 23 .top-left-block {
24 z-index: 1001; 24 z-index: z(headerLeft);
25 height: $header-height; 25 height: $header-height;
26 display: flex; 26 display: flex;
27 align-items: center; 27 align-items: center;
@@ -61,7 +61,7 @@
61 } 61 }
62 } 62 }
63 63
64 @media screen and (max-width: 500px) { 64 @media screen and (max-width: $mobile-view) {
65 width: 70px; 65 width: 70px;
66 66
67 .peertube-title { 67 .peertube-title {
diff --git a/client/src/app/app.component.ts b/client/src/app/app.component.ts
index 03eb83cb8..59966243b 100644
--- a/client/src/app/app.component.ts
+++ b/client/src/app/app.component.ts
@@ -25,8 +25,8 @@ import { InstanceService } from '@app/shared/instance/instance.service'
25 styleUrls: [ './app.component.scss' ] 25 styleUrls: [ './app.component.scss' ]
26}) 26})
27export class AppComponent implements OnInit { 27export class AppComponent implements OnInit {
28 @ViewChild('welcomeModal', { static: false }) welcomeModal: WelcomeModalComponent 28 @ViewChild('welcomeModal') welcomeModal: WelcomeModalComponent
29 @ViewChild('instanceConfigWarningModal', { static: false }) instanceConfigWarningModal: InstanceConfigWarningModalComponent 29 @ViewChild('instanceConfigWarningModal') instanceConfigWarningModal: InstanceConfigWarningModalComponent
30 30
31 isMenuDisplayed = true 31 isMenuDisplayed = true
32 isMenuChangedByUser = false 32 isMenuChangedByUser = false
diff --git a/client/src/app/app.module.ts b/client/src/app/app.module.ts
index dda705811..9e220a383 100644
--- a/client/src/app/app.module.ts
+++ b/client/src/app/app.module.ts
@@ -4,22 +4,21 @@ import { ServerService } from '@app/core'
4import { ResetPasswordModule } from '@app/reset-password' 4import { ResetPasswordModule } from '@app/reset-password'
5 5
6import { MetaLoader, MetaModule, MetaStaticLoader, PageTitlePositioning } from '@ngx-meta/core' 6import { MetaLoader, MetaModule, MetaStaticLoader, PageTitlePositioning } from '@ngx-meta/core'
7import { ClipboardModule } from 'ngx-clipboard'
8import 'focus-visible' 7import 'focus-visible'
9 8
10import { AppRoutingModule } from './app-routing.module' 9import { AppRoutingModule } from './app-routing.module'
11import { AppComponent } from './app.component' 10import { AppComponent } from './app.component'
12import { CoreModule } from './core' 11import { CoreModule } from './core'
13import { HeaderComponent } from './header' 12import { HeaderComponent, SearchTypeaheadComponent, SuggestionsComponent, SuggestionComponent } from './header'
14import { LoginModule } from './login' 13import { LoginModule } from './login'
15import { AvatarNotificationComponent, LanguageChooserComponent, MenuComponent } from './menu' 14import { AvatarNotificationComponent, LanguageChooserComponent, MenuComponent } from './menu'
16import { SharedModule } from './shared' 15import { SharedModule } from './shared'
17import { VideosModule } from './videos' 16import { VideosModule } from './videos'
18import { buildFileLocale, getCompleteLocale, isDefaultLocale } from '../../../shared/models/i18n'
19import { getDevLocale, isOnDevLocale } from '@app/shared/i18n/i18n-utils'
20import { SearchModule } from '@app/search' 17import { SearchModule } from '@app/search'
21import { WelcomeModalComponent } from '@app/modal/welcome-modal.component' 18import { WelcomeModalComponent } from '@app/modal/welcome-modal.component'
22import { InstanceConfigWarningModalComponent } from '@app/modal/instance-config-warning-modal.component' 19import { InstanceConfigWarningModalComponent } from '@app/modal/instance-config-warning-modal.component'
20import { buildFileLocale, getCompleteLocale, isDefaultLocale } from '@shared/models'
21import { APP_BASE_HREF } from '@angular/common'
23 22
24export function metaFactory (serverService: ServerService): MetaLoader { 23export function metaFactory (serverService: ServerService): MetaLoader {
25 return new MetaStaticLoader({ 24 return new MetaStaticLoader({
@@ -42,14 +41,15 @@ export function metaFactory (serverService: ServerService): MetaLoader {
42 LanguageChooserComponent, 41 LanguageChooserComponent,
43 AvatarNotificationComponent, 42 AvatarNotificationComponent,
44 HeaderComponent, 43 HeaderComponent,
44 SearchTypeaheadComponent,
45 SuggestionsComponent,
46 SuggestionComponent,
45 47
46 WelcomeModalComponent, 48 WelcomeModalComponent,
47 InstanceConfigWarningModalComponent 49 InstanceConfigWarningModalComponent
48 ], 50 ],
49 imports: [ 51 imports: [
50 BrowserModule, 52 BrowserModule,
51 // FIXME: https://github.com/maxisam/ngx-clipboard/issues/133
52 ClipboardModule,
53 53
54 CoreModule, 54 CoreModule,
55 SharedModule, 55 SharedModule,
@@ -69,17 +69,17 @@ export function metaFactory (serverService: ServerService): MetaLoader {
69 69
70 AppRoutingModule // Put it after all the module because it has the 404 route 70 AppRoutingModule // Put it after all the module because it has the 404 route
71 ], 71 ],
72
72 providers: [ 73 providers: [
73 { 74 {
75 provide: APP_BASE_HREF,
76 useValue: '/'
77 },
78
79 {
74 provide: TRANSLATIONS, 80 provide: TRANSLATIONS,
75 useFactory: (locale: string) => { 81 useFactory: (locale: string) => {
76 // On dev mode, test localization 82 // Default locale, nothing to translate
77 if (isOnDevLocale()) {
78 locale = buildFileLocale(getDevLocale())
79 return require(`raw-loader!../locale/angular.${locale}.xlf`)
80 }
81
82 // Default locale, nothing to translate
83 const completeLocale = getCompleteLocale(locale) 83 const completeLocale = getCompleteLocale(locale)
84 if (isDefaultLocale(completeLocale)) return '' 84 if (isDefaultLocale(completeLocale)) return ''
85 85
diff --git a/client/src/app/core/hotkeys/hotkeys.component.scss b/client/src/app/core/hotkeys/hotkeys.component.scss
index 3aa0b6252..a970260c9 100644
--- a/client/src/app/core/hotkeys/hotkeys.component.scss
+++ b/client/src/app/core/hotkeys/hotkeys.component.scss
@@ -1,3 +1,6 @@
1@import '_variables';
2@import '_mixins';
3
1.cfp-hotkeys-container { 4.cfp-hotkeys-container {
2 display: flex !important; 5 display: flex !important;
3 align-items: center; 6 align-items: center;
@@ -23,7 +26,7 @@
23} 26}
24 27
25.cfp-hotkeys-container.fade.in { 28.cfp-hotkeys-container.fade.in {
26 z-index: 10002; 29 z-index: z(hotkeys);
27 visibility: visible; 30 visibility: visible;
28 opacity: 1; 31 opacity: 1;
29} 32}
@@ -91,7 +94,7 @@
91 cursor: pointer; 94 cursor: pointer;
92} 95}
93 96
94@media all and (max-width: 500px) { 97@media all and (max-width: $mobile-view) {
95 .cfp-hotkeys { 98 .cfp-hotkeys {
96 font-size: 0.8em; 99 font-size: 0.8em;
97 } 100 }
diff --git a/client/src/app/core/routing/custom-reuse-strategy.ts b/client/src/app/core/routing/custom-reuse-strategy.ts
index a9f61acec..c0f9f04e0 100644
--- a/client/src/app/core/routing/custom-reuse-strategy.ts
+++ b/client/src/app/core/routing/custom-reuse-strategy.ts
@@ -1,5 +1,7 @@
1import { ActivatedRouteSnapshot, DetachedRouteHandle, RouteReuseStrategy } from '@angular/router' 1import { ActivatedRouteSnapshot, DetachedRouteHandle, RouteReuseStrategy } from '@angular/router'
2import { Injectable } from '@angular/core'
2 3
4@Injectable()
3export class CustomReuseStrategy implements RouteReuseStrategy { 5export class CustomReuseStrategy implements RouteReuseStrategy {
4 storedRouteHandles = new Map<string, DetachedRouteHandle>() 6 storedRouteHandles = new Map<string, DetachedRouteHandle>()
5 recentlyUsed: string 7 recentlyUsed: string
@@ -76,6 +78,6 @@ export class CustomReuseStrategy implements RouteReuseStrategy {
76 } 78 }
77 79
78 private isReuseEnabled (route: ActivatedRouteSnapshot) { 80 private isReuseEnabled (route: ActivatedRouteSnapshot) {
79 return route.data.reuse && route.data.reuse.enabled && route.queryParams['a-state'] 81 return route.data.reuse && route.data.reuse.enabled && route.queryParams[ 'a-state' ]
80 } 82 }
81} 83}
diff --git a/client/src/app/core/routing/preload-selected-modules-list.ts b/client/src/app/core/routing/preload-selected-modules-list.ts
index 3bca60317..64af68225 100644
--- a/client/src/app/core/routing/preload-selected-modules-list.ts
+++ b/client/src/app/core/routing/preload-selected-modules-list.ts
@@ -1,7 +1,9 @@
1import { Observable, timer as observableTimer, of as ofObservable } from 'rxjs' 1import { Observable, of as ofObservable, timer as observableTimer } from 'rxjs'
2import { switchMap } from 'rxjs/operators' 2import { switchMap } from 'rxjs/operators'
3import { PreloadingStrategy, Route } from '@angular/router' 3import { PreloadingStrategy, Route } from '@angular/router'
4import { Injectable } from '@angular/core'
4 5
6@Injectable()
5export class PreloadSelectedModulesList implements PreloadingStrategy { 7export class PreloadSelectedModulesList implements PreloadingStrategy {
6 preload (route: Route, load: Function): Observable<any> { 8 preload (route: Route, load: Function): Observable<any> {
7 if (!route.data || !route.data.preload) return ofObservable(null) 9 if (!route.data || !route.data.preload) return ofObservable(null)
diff --git a/client/src/app/core/server/server.service.ts b/client/src/app/core/server/server.service.ts
index 3997ce6db..e015d0e14 100644
--- a/client/src/app/core/server/server.service.ts
+++ b/client/src/app/core/server/server.service.ts
@@ -9,6 +9,7 @@ import { VideoConstant } from '../../../../../shared/models/videos'
9import { isDefaultLocale, peertubeTranslate } from '../../../../../shared/models/i18n' 9import { isDefaultLocale, peertubeTranslate } from '../../../../../shared/models/i18n'
10import { getDevLocale, isOnDevLocale } from '@app/shared/i18n/i18n-utils' 10import { getDevLocale, isOnDevLocale } from '@app/shared/i18n/i18n-utils'
11import { sortBy } from '@app/shared/misc/utils' 11import { sortBy } from '@app/shared/misc/utils'
12import { ServerStats } from '@shared/models/server'
12 13
13@Injectable() 14@Injectable()
14export class ServerService { 15export class ServerService {
@@ -16,6 +17,8 @@ export class ServerService {
16 private static BASE_VIDEO_URL = environment.apiUrl + '/api/v1/videos/' 17 private static BASE_VIDEO_URL = environment.apiUrl + '/api/v1/videos/'
17 private static BASE_VIDEO_PLAYLIST_URL = environment.apiUrl + '/api/v1/video-playlists/' 18 private static BASE_VIDEO_PLAYLIST_URL = environment.apiUrl + '/api/v1/video-playlists/'
18 private static BASE_LOCALE_URL = environment.apiUrl + '/client/locales/' 19 private static BASE_LOCALE_URL = environment.apiUrl + '/client/locales/'
20 private static BASE_STATS_URL = environment.apiUrl + '/api/v1/server/stats'
21
19 private static CONFIG_LOCAL_STORAGE_KEY = 'server-config' 22 private static CONFIG_LOCAL_STORAGE_KEY = 'server-config'
20 23
21 configReloaded = new Subject<void>() 24 configReloaded = new Subject<void>()
@@ -44,6 +47,12 @@ export class ServerService {
44 css: '' 47 css: ''
45 } 48 }
46 }, 49 },
50 search: {
51 remoteUri: {
52 users: true,
53 anonymous: false
54 }
55 },
47 plugin: { 56 plugin: {
48 registered: [] 57 registered: []
49 }, 58 },
@@ -238,6 +247,10 @@ export class ServerService {
238 return this.localeObservable.pipe(first()) 247 return this.localeObservable.pipe(first())
239 } 248 }
240 249
250 getServerStats () {
251 return this.http.get<ServerStats>(ServerService.BASE_STATS_URL)
252 }
253
241 private loadAttributeEnum <T extends string | number> ( 254 private loadAttributeEnum <T extends string | number> (
242 baseUrl: string, 255 baseUrl: string,
243 attributeName: 'categories' | 'licences' | 'languages' | 'privacies', 256 attributeName: 'categories' | 'licences' | 'languages' | 'privacies',
diff --git a/client/src/app/header/header.component.html b/client/src/app/header/header.component.html
index 4fd18f9bd..49e219187 100644
--- a/client/src/app/header/header.component.html
+++ b/client/src/app/header/header.component.html
@@ -1,8 +1,4 @@
1<input 1<my-search-typeahead class="w-100 d-flex justify-content-end"></my-search-typeahead>
2 type="text" id="search-video" name="search-video" [attr.aria-label]="ariaLabelTextForSearch" i18n-placeholder placeholder="Search videos, channels…"
3 [(ngModel)]="searchValue" (keyup.enter)="doSearch()"
4>
5<span (click)="doSearch()" class="icon icon-search"></span>
6 2
7<a class="upload-button" routerLink="/videos/upload"> 3<a class="upload-button" routerLink="/videos/upload">
8 <my-global-icon iconName="upload"></my-global-icon> 4 <my-global-icon iconName="upload"></my-global-icon>
diff --git a/client/src/app/header/header.component.scss b/client/src/app/header/header.component.scss
index 2bbde74bc..91b390773 100644
--- a/client/src/app/header/header.component.scss
+++ b/client/src/app/header/header.component.scss
@@ -1,51 +1,8 @@
1@import '_variables'; 1@import '_variables';
2@import '_mixins'; 2@import '_mixins';
3 3
4#search-video { 4my-search-typeahead {
5 @include peertube-input-text($search-input-width);
6 padding-left: 10px;
7 margin-right: 15px; 5 margin-right: 15px;
8 padding-right: 40px; // For the search icon
9 font-size: 14px;
10
11 transition: box-shadow .3s ease;
12
13 /* light border style */
14 border: 1px solid var(--mainBackgroundColor);
15 box-shadow: rgba(0, 0, 0, 0.1) 0px 1px 20px 0px;
16
17 &:focus {
18 box-shadow: rgba(0, 0, 0, 0.2) 0px 1px 20px 0px;
19 }
20
21 &::placeholder {
22 color: var(--inputPlaceholderColor);
23 }
24
25 &:focus::placeholder {
26 opacity: 0 !important;
27 }
28
29 @media screen and (max-width: 800px) {
30 width: calc(100% - 150px);
31 }
32
33 @media screen and (max-width: 600px) {
34 width: calc(100% - 70px);
35 }
36}
37
38.icon.icon-search {
39 @include icon(25px);
40 height: 21px;
41
42 background-color: var(--mainForegroundColor);
43 mask: url('../../assets/images/header/search.svg') no-repeat 50% 50%;
44
45 // yolo
46 position: absolute;
47 margin-left: -50px;
48 margin-top: 5px;
49} 6}
50 7
51.upload-button { 8.upload-button {
@@ -56,10 +13,6 @@
56 color: var(--mainBackgroundColor) !important; 13 color: var(--mainBackgroundColor) !important;
57 margin-right: 25px; 14 margin-right: 25px;
58 15
59 @media screen and (max-width: 800px) {
60 margin-right: 0;
61 }
62
63 @media screen and (max-width: 600px) { 16 @media screen and (max-width: 600px) {
64 margin-right: 10px; 17 margin-right: 10px;
65 padding: 0 10px; 18 padding: 0 10px;
diff --git a/client/src/app/header/header.component.ts b/client/src/app/header/header.component.ts
index 92a7eded6..cce76b0d1 100644
--- a/client/src/app/header/header.component.ts
+++ b/client/src/app/header/header.component.ts
@@ -1,10 +1,4 @@
1import { filter, first, map, tap } from 'rxjs/operators' 1import { Component } from '@angular/core'
2import { Component, OnInit } from '@angular/core'
3import { ActivatedRoute, NavigationEnd, Params, Router } from '@angular/router'
4import { getParameterByName } from '../shared/misc/utils'
5import { AuthService, Notifier, ServerService } from '@app/core'
6import { of } from 'rxjs'
7import { I18n } from '@ngx-translate/i18n-polyfill'
8 2
9@Component({ 3@Component({
10 selector: 'my-header', 4 selector: 'my-header',
@@ -12,54 +6,4 @@ import { I18n } from '@ngx-translate/i18n-polyfill'
12 styleUrls: [ './header.component.scss' ] 6 styleUrls: [ './header.component.scss' ]
13}) 7})
14 8
15export class HeaderComponent implements OnInit { 9export class HeaderComponent {}
16 searchValue = ''
17 ariaLabelTextForSearch = ''
18
19 constructor (
20 private router: Router,
21 private route: ActivatedRoute,
22 private auth: AuthService,
23 private serverService: ServerService,
24 private authService: AuthService,
25 private notifier: Notifier,
26 private i18n: I18n
27 ) {}
28
29 ngOnInit () {
30 this.ariaLabelTextForSearch = this.i18n('Search videos, channels')
31
32 this.router.events
33 .pipe(
34 filter(e => e instanceof NavigationEnd),
35 map(() => getParameterByName('search', window.location.href))
36 )
37 .subscribe(searchQuery => this.searchValue = searchQuery || '')
38 }
39
40 doSearch () {
41 const queryParams: Params = {}
42
43 if (window.location.pathname === '/search' && this.route.snapshot.queryParams) {
44 Object.assign(queryParams, this.route.snapshot.queryParams)
45 }
46
47 Object.assign(queryParams, { search: this.searchValue })
48
49 const o = this.auth.isLoggedIn()
50 ? this.loadUserLanguagesIfNeeded(queryParams)
51 : of(true)
52
53 o.subscribe(() => this.router.navigate([ '/search' ], { queryParams }))
54 }
55
56 private loadUserLanguagesIfNeeded (queryParams: any) {
57 if (queryParams && queryParams.languageOneOf) return of(queryParams)
58
59 return this.auth.userInformationLoaded
60 .pipe(
61 first(),
62 tap(() => Object.assign(queryParams, { languageOneOf: this.auth.getUser().videoLanguages }))
63 )
64 }
65}
diff --git a/client/src/app/header/index.ts b/client/src/app/header/index.ts
index d98d2d00a..a882d4d1f 100644
--- a/client/src/app/header/index.ts
+++ b/client/src/app/header/index.ts
@@ -1 +1,4 @@
1export * from './header.component' 1export * from './header.component'
2export * from './search-typeahead.component'
3export * from './suggestions.component'
4export * from './suggestion.component'
diff --git a/client/src/app/header/search-typeahead.component.html b/client/src/app/header/search-typeahead.component.html
new file mode 100644
index 000000000..e36809060
--- /dev/null
+++ b/client/src/app/header/search-typeahead.component.html
@@ -0,0 +1,53 @@
1<div class="d-inline-flex position-relative" id="typeahead-container">
2 <input
3 type="text" id="search-video" name="search-video" #searchVideo i18n-placeholder placeholder="Search videos, channels…"
4 [(ngModel)]="search" (ngModelChange)="onSearchChange()" (keyup)="handleKeyUp($event)"
5 >
6 <span class="icon icon-search" (click)="doSearch()"></span>
7
8 <div class="position-absolute jump-to-suggestions">
9 <!-- suggestions -->
10 <my-suggestions *ngIf="search && newSearch" [results]="results" [highlight]="search" (init)="initKeyboardEventsManager($event)"></my-suggestions>
11
12 <!-- suggestion help, not shown until one of the suggestions is selected and specific to that suggestion -->
13 <div *ngIf="showHelp" id="typeahead-help" class="overflow-hidden">
14 <ng-container *ngIf="activeResult.type === 'search-global'">
15 <div class="d-flex justify-content-between">
16 <label class="small-title" i18n>Global search</label>
17 <div class="advanced-search-status text-muted">
18 <span *ngIf="serverConfig" class="mr-1" i18n>using {{ serverConfig.followings.instance.autoFollowIndex.indexUrl }}</span>
19 <i class="glyphicon glyphicon-globe"></i>
20 </div>
21 </div>
22 <div class="text-muted" i18n>Results will be augmented with those of a third-party index. Only data necessary to make the query will be sent.</div>
23 </ng-container>
24 </div>
25
26 <!-- search instructions, when search input is empty -->
27 <div *ngIf="areInstructionsDisplayed" id="typeahead-instructions" class="overflow-hidden">
28 <div class="d-flex justify-content-between">
29 <label class="small-title" i18n>Advanced search</label>
30 <div class="advanced-search-status c-help">
31 <span [ngClass]="canSearchAnyURI ? 'text-success' : 'text-muted'" i18n-title title="Determines whether you can resolve any distant content, or if this instance only allows doing so for instances it follows.">
32 <span *ngIf="canSearchAnyURI" class="mr-1" i18n>any instance</span>
33 <span *ngIf="!canSearchAnyURI" class="mr-1" i18n>only followed instances</span>
34 <i [ngClass]="canSearchAnyURI ? 'glyphicon glyphicon-ok-sign' : 'glyphicon glyphicon-exclamation-sign'"></i>
35 </span>
36 </div>
37 </div>
38 <ul>
39 <li>
40 <em>@username@domain</em> <span class="flex-auto text-muted" i18n>account or channel</span>
41 </li>
42 <li>
43 <em>URL</em> <span class="text-muted" i18n>account or channel</span>
44 </li>
45 <li>
46 <em>UUID</em> <span class="text-muted" i18n>video</span>
47 </li>
48 </ul>
49 <span class="text-muted" i18n>Any other text will return matching video, channel or account names.</span>
50 </div>
51 </div>
52
53</div>
diff --git a/client/src/app/header/search-typeahead.component.scss b/client/src/app/header/search-typeahead.component.scss
new file mode 100644
index 000000000..a55e78326
--- /dev/null
+++ b/client/src/app/header/search-typeahead.component.scss
@@ -0,0 +1,145 @@
1@import '_mixins';
2@import '_variables';
3@import '_bootstrap-variables';
4@import '~bootstrap/scss/mixins/_breakpoints';
5
6#search-video {
7 @include peertube-input-text($search-input-width);
8 padding-left: 10px;
9 padding-right: 40px; // For the search icon
10 font-size: 14px;
11
12 &::placeholder {
13 color: var(--inputPlaceholderColor);
14 }
15}
16
17.icon.icon-search {
18 @include icon(25px);
19 height: 21px;
20
21 background-color: var(--mainForegroundColor);
22 mask: url('../../assets/images/header/search.svg') no-repeat 50% 50%;
23
24 // yolo
25 position: absolute;
26 margin-left: -35px;
27 margin-top: 5px;
28}
29
30.jump-to-suggestions {
31 top: 100%;
32 left: 0;
33 z-index: 35;
34 width: 100%;
35}
36
37#typeahead-help,
38#typeahead-instructions,
39my-suggestions ::ng-deep ul {
40 border: 1px solid var(--mainBackgroundColor);
41 border-bottom-right-radius: 3px;
42 border-bottom-left-radius: 3px;
43 background: var(--mainBackgroundColor);
44 transition: .3s ease;
45 transition-property: box-shadow;
46}
47
48#typeahead-help,
49#typeahead-instructions {
50 margin-top: 10px;
51 width: 100%;
52 padding: .5rem 1rem;
53 white-space: normal;
54
55 ul {
56 list-style: none;
57 padding: 0;
58 margin-bottom: .5rem;
59
60 em {
61 font-weight: 600;
62 margin-right: 0.2rem;
63 font-style: normal;
64 }
65 }
66}
67
68#typeahead-container {
69 input {
70 border: 1px solid var(--mainBackgroundColor) !important;
71 box-shadow: rgba(0, 0, 0, 0.1) 0px 1px 20px 0px;
72 flex-grow: 1;
73 transition: box-shadow .3s ease, width .2s ease;
74 }
75
76 @media screen and (min-width: $mobile-view) {
77 margin-left: 10px;
78 }
79
80 @media screen and (max-width: $small-view) {
81 flex: 1;
82
83 input {
84 width: unset;
85 }
86 }
87
88 span {
89 right: 10px;
90 }
91
92 & > div:last-child {
93 // we have to switch the display and not the opacity,
94 // to avoid clashing with the rest of the interface.
95 display: none;
96 }
97
98 &:focus,
99 ::ng-deep &:focus-within {
100 & > div:last-child {
101 @media screen and (min-width: $mobile-view) {
102 display: initial !important;
103 }
104
105 #typeahead-help,
106 #typeahead-instructions,
107 my-suggestions ::ng-deep ul {
108 box-shadow: rgba(0, 0, 0, 0.2) 0px 10px 20px -5px;
109 }
110 }
111
112 ::ng-deep input {
113 box-shadow: rgba(0, 0, 0, 0.2) 0px 1px 20px 0px;
114 border-end-start-radius: 0;
115 border-end-end-radius: 0;
116
117 @include media-breakpoint-up(lg) {
118 width: 500px;
119 }
120 }
121 }
122}
123
124.glyphicon {
125 top: 3px;
126}
127
128.advanced-search-status {
129 height: max-content;
130 cursor: default;
131
132 &.c-help {
133 cursor: help;
134 }
135}
136
137.small-title {
138 @include in-content-small-title;
139
140 margin-bottom: .5rem;
141}
142
143::ng-deep my-suggestion {
144 width: 100%;
145}
diff --git a/client/src/app/header/search-typeahead.component.ts b/client/src/app/header/search-typeahead.component.ts
new file mode 100644
index 000000000..396a875c8
--- /dev/null
+++ b/client/src/app/header/search-typeahead.component.ts
@@ -0,0 +1,178 @@
1import { Component, ElementRef, OnDestroy, OnInit, QueryList, ViewChild } from '@angular/core'
2import { ActivatedRoute, Params, Router } from '@angular/router'
3import { AuthService, ServerService } from '@app/core'
4import { first, tap } from 'rxjs/operators'
5import { ListKeyManager } from '@angular/cdk/a11y'
6import { Result, SuggestionComponent } from './suggestion.component'
7import { of } from 'rxjs'
8import { ServerConfig } from '@shared/models'
9
10@Component({
11 selector: 'my-search-typeahead',
12 templateUrl: './search-typeahead.component.html',
13 styleUrls: [ './search-typeahead.component.scss' ]
14})
15export class SearchTypeaheadComponent implements OnInit, OnDestroy {
16 @ViewChild('searchVideo', { static: true }) searchInput: ElementRef<HTMLInputElement>
17
18 hasChannel = false
19 inChannel = false
20 newSearch = true
21
22 search = ''
23 serverConfig: ServerConfig
24
25 inThisChannelText: string
26
27 keyboardEventsManager: ListKeyManager<SuggestionComponent>
28 results: Result[] = []
29
30 constructor (
31 private authService: AuthService,
32 private router: Router,
33 private route: ActivatedRoute,
34 private serverService: ServerService
35 ) {}
36
37 ngOnInit () {
38 this.route.queryParams
39 .pipe(first(params => params.search !== undefined && params.search !== null))
40 .subscribe(params => this.search = params.search)
41 this.serverService.getConfig()
42 .subscribe(config => this.serverConfig = config)
43 }
44
45 ngOnDestroy () {
46 if (this.keyboardEventsManager) this.keyboardEventsManager.change.unsubscribe()
47 }
48
49 get activeResult () {
50 return this.keyboardEventsManager?.activeItem?.result
51 }
52
53 get areInstructionsDisplayed () {
54 return !this.search
55 }
56
57 get showHelp () {
58 return this.search && this.newSearch && this.activeResult?.type === 'search-global'
59 }
60
61 get canSearchAnyURI () {
62 if (!this.serverConfig) return false
63 return this.authService.isLoggedIn()
64 ? this.serverConfig.search.remoteUri.users
65 : this.serverConfig.search.remoteUri.anonymous
66 }
67
68 onSearchChange () {
69 this.computeResults()
70 }
71
72 computeResults () {
73 this.newSearch = true
74 let results: Result[] = []
75
76 if (this.search) {
77 results = [
78 /* Channel search is still unimplemented. Uncomment when it is.
79 {
80 text: this.search,
81 type: 'search-channel'
82 },
83 */
84 {
85 text: this.search,
86 type: 'search-instance',
87 default: true
88 },
89 /* Global search is still unimplemented. Uncomment when it is.
90 {
91 text: this.search,
92 type: 'search-global'
93 },
94 */
95 ...results
96 ]
97 }
98
99 this.results = results.filter(
100 (result: Result) => {
101 // if we're not in a channel or one of its videos/playlits, show all channel-related results
102 if (!(this.hasChannel || this.inChannel)) return !result.type.includes('channel')
103 // if we're in a channel, show all channel-related results except for the channel redirection itself
104 if (this.inChannel) return result.type !== 'channel'
105 // all other result types are kept
106 return true
107 }
108 )
109 }
110
111 setEventItems (event: { items: QueryList<SuggestionComponent>, index?: number }) {
112 event.items.forEach(e => {
113 if (this.keyboardEventsManager.activeItem && this.keyboardEventsManager.activeItem === e) {
114 this.keyboardEventsManager.activeItem.active = true
115 } else {
116 e.active = false
117 }
118 })
119 }
120
121 initKeyboardEventsManager (event: { items: QueryList<SuggestionComponent>, index?: number }) {
122 if (this.keyboardEventsManager) this.keyboardEventsManager.change.unsubscribe()
123
124 this.keyboardEventsManager = new ListKeyManager(event.items)
125
126 if (event.index !== undefined) {
127 this.keyboardEventsManager.setActiveItem(event.index)
128 } else {
129 this.keyboardEventsManager.setFirstItemActive()
130 }
131
132 this.keyboardEventsManager.change.subscribe(
133 _ => this.setEventItems(event)
134 )
135 }
136
137 handleKeyUp (event: KeyboardEvent) {
138 event.stopImmediatePropagation()
139 if (!this.keyboardEventsManager) return
140
141 switch (event.key) {
142 case 'ArrowDown':
143 case 'ArrowUp':
144 this.keyboardEventsManager.onKeydown(event)
145 break
146 case 'Enter':
147 this.newSearch = false
148 this.doSearch()
149 break
150 }
151 }
152
153 doSearch () {
154 const queryParams: Params = {}
155
156 if (window.location.pathname === '/search' && this.route.snapshot.queryParams) {
157 Object.assign(queryParams, this.route.snapshot.queryParams)
158 }
159
160 Object.assign(queryParams, { search: this.search })
161
162 const o = this.authService.isLoggedIn()
163 ? this.loadUserLanguagesIfNeeded(queryParams)
164 : of(true)
165
166 o.subscribe(() => this.router.navigate([ '/search' ], { queryParams }))
167 }
168
169 private loadUserLanguagesIfNeeded (queryParams: any) {
170 if (queryParams && queryParams.languageOneOf) return of(queryParams)
171
172 return this.authService.userInformationLoaded
173 .pipe(
174 first(),
175 tap(() => Object.assign(queryParams, { languageOneOf: this.authService.getUser().videoLanguages }))
176 )
177 }
178}
diff --git a/client/src/app/header/suggestion.component.html b/client/src/app/header/suggestion.component.html
new file mode 100644
index 000000000..d7ae3450a
--- /dev/null
+++ b/client/src/app/header/suggestion.component.html
@@ -0,0 +1,22 @@
1<a tabindex="-1" class="d-flex flex-auto flex-items-center p-2" [class.focus-visible]="active">
2 <div class="flex-shrink-0 mr-2 text-center">
3 <my-global-icon *ngIf="result.type !== 'channel'" iconName="search"></my-global-icon>
4 <my-global-icon *ngIf="result.type === 'channel'" iconName="folder"></my-global-icon>
5 </div>
6
7 <img class="avatar mr-2 flex-shrink-0 d-none" alt="" aria-label="Team" src="" width="28" height="28">
8
9 <div class="flex-auto overflow-hidden text-left no-wrap css-truncate css-truncate-target" [attr.aria-label]="result.text" [innerHTML]="result.text | highlight : highlight"></div>
10
11 <div *ngIf="result.type !== 'channel' && result.type !== 'suggestion'" class="border rounded flex-shrink-0 px-1 bg-gray text-gray-light ml-1 f6">
12 <span *ngIf="result.type === 'search-channel'" i18n>In this channel</span>
13 <span *ngIf="result.type === 'search-instance'" i18n>In this instance</span>
14 <span *ngIf="result.type === 'search-global'" i18n>In the vidiverse</span>
15 <span *ngIf="result.type === 'search-any'" aria-hidden="true" class="d-inline-block ml-1 v-align-middle">↵</span>
16 </div>
17
18 <div *ngIf="result.type === 'channel'" aria-hidden="true" class="border rounded flex-shrink-0 px-1 bg-gray text-gray-light ml-1 f6 d-on-nav-focus" i18n>
19 Jump to channel
20 <span class="d-inline-block ml-1 v-align-middle">↵</span>
21 </div>
22</a> \ No newline at end of file
diff --git a/client/src/app/header/suggestion.component.scss b/client/src/app/header/suggestion.component.scss
new file mode 100644
index 000000000..1de2f43bd
--- /dev/null
+++ b/client/src/app/header/suggestion.component.scss
@@ -0,0 +1,32 @@
1@import '_mixins';
2
3a {
4 @include disable-default-a-behaviour;
5 width: 100%;
6
7 &, &:hover {
8 color: var(--mainForegroundColor);
9
10 &.focus-visible {
11 background-color: var(--mainHoverColor);
12 color: var(--mainBackgroundColor);
13 }
14 }
15}
16
17.bg-gray {
18 background-color: var(--mainBackgroundColor);
19}
20
21.text-gray-light {
22 color: var(--mainForegroundColor);
23}
24
25my-global-icon {
26 width: 17px;
27 position: relative;
28 top: -2px;
29 margin: 5px;
30
31 @include apply-svg-color(var(--mainForegroundColor));
32}
diff --git a/client/src/app/header/suggestion.component.ts b/client/src/app/header/suggestion.component.ts
new file mode 100644
index 000000000..69641b511
--- /dev/null
+++ b/client/src/app/header/suggestion.component.ts
@@ -0,0 +1,37 @@
1import { Input, Component, Output, EventEmitter, OnInit, ChangeDetectionStrategy } from '@angular/core'
2import { RouterLink } from '@angular/router'
3import { ListKeyManagerOption } from '@angular/cdk/a11y'
4
5export type Result = {
6 text: string
7 type: 'channel' | 'suggestion' | 'search-channel' | 'search-instance' | 'search-global' | 'search-any'
8 routerLink?: RouterLink,
9 default?: boolean
10}
11
12@Component({
13 selector: 'my-suggestion',
14 templateUrl: './suggestion.component.html',
15 styleUrls: [ './suggestion.component.scss' ],
16 changeDetection: ChangeDetectionStrategy.OnPush
17})
18export class SuggestionComponent implements OnInit, ListKeyManagerOption {
19 @Input() result: Result
20 @Input() highlight: string
21 @Output() selected = new EventEmitter()
22
23 disabled = false
24 active = false
25
26 getLabel () {
27 return this.result.text
28 }
29
30 ngOnInit () {
31 if (this.result.default) this.active = true
32 }
33
34 selectItem () {
35 this.selected.emit(this.result)
36 }
37}
diff --git a/client/src/app/header/suggestions.component.html b/client/src/app/header/suggestions.component.html
new file mode 100644
index 000000000..8d017d78d
--- /dev/null
+++ b/client/src/app/header/suggestions.component.html
@@ -0,0 +1,6 @@
1<ul role="listbox" class="p-0 m-0">
2 <li *ngFor="let result of results; let i = index" class="d-flex flex-justify-start flex-items-center p-0 f5"
3 role="option" aria-selected="true" (mouseenter)="hoverItem(i)">
4 <my-suggestion [result]="result" [highlight]="highlight"></my-suggestion>
5 </li>
6</ul> \ No newline at end of file
diff --git a/client/src/app/header/suggestions.component.ts b/client/src/app/header/suggestions.component.ts
new file mode 100644
index 000000000..ee3ef73c2
--- /dev/null
+++ b/client/src/app/header/suggestions.component.ts
@@ -0,0 +1,24 @@
1import { Input, QueryList, Component, Output, AfterViewInit, EventEmitter, ViewChildren, ChangeDetectionStrategy } from '@angular/core'
2import { SuggestionComponent } from './suggestion.component'
3
4@Component({
5 selector: 'my-suggestions',
6 templateUrl: './suggestions.component.html',
7 changeDetection: ChangeDetectionStrategy.OnPush
8})
9export class SuggestionsComponent implements AfterViewInit {
10 @Input() results: any[]
11 @Input() highlight: string
12 @ViewChildren(SuggestionComponent) listItems: QueryList<SuggestionComponent>
13 @Output() init = new EventEmitter()
14
15 ngAfterViewInit () {
16 this.listItems.changes.subscribe(
17 _ => this.init.emit({ items: this.listItems })
18 )
19 }
20
21 hoverItem (index: number) {
22 this.init.emit({ items: this.listItems, index: index })
23 }
24}
diff --git a/client/src/app/menu/avatar-notification.component.html b/client/src/app/menu/avatar-notification.component.html
index 7975afba5..df2a102a3 100644
--- a/client/src/app/menu/avatar-notification.component.html
+++ b/client/src/app/menu/avatar-notification.component.html
@@ -30,7 +30,7 @@
30 </div> 30 </div>
31 31
32 <my-user-notifications 32 <my-user-notifications
33 [ignoreLoadingBar]="true" [infiniteScroll]="false" itemsPerPage="10" 33 [ignoreLoadingBar]="true" [infiniteScroll]="false" [itemsPerPage]="10"
34 [markAllAsReadSubject]="markAllAsReadSubject" (notificationsLoaded)="onNotificationLoaded()" 34 [markAllAsReadSubject]="markAllAsReadSubject" (notificationsLoaded)="onNotificationLoaded()"
35 ></my-user-notifications> 35 ></my-user-notifications>
36 36
diff --git a/client/src/app/menu/avatar-notification.component.ts b/client/src/app/menu/avatar-notification.component.ts
index 989a11849..c447f031c 100644
--- a/client/src/app/menu/avatar-notification.component.ts
+++ b/client/src/app/menu/avatar-notification.component.ts
@@ -6,7 +6,6 @@ import { Notifier, UserNotificationSocket } from '@app/core'
6import { NgbPopover } from '@ng-bootstrap/ng-bootstrap' 6import { NgbPopover } from '@ng-bootstrap/ng-bootstrap'
7import { NavigationEnd, Router } from '@angular/router' 7import { NavigationEnd, Router } from '@angular/router'
8import { filter } from 'rxjs/operators' 8import { filter } from 'rxjs/operators'
9import { UserNotificationsComponent } from '@app/shared'
10 9
11@Component({ 10@Component({
12 selector: 'my-avatar-notification', 11 selector: 'my-avatar-notification',
diff --git a/client/src/app/menu/language-chooser.component.ts b/client/src/app/menu/language-chooser.component.ts
index 4a6e4c75a..43f622dfb 100644
--- a/client/src/app/menu/language-chooser.component.ts
+++ b/client/src/app/menu/language-chooser.component.ts
@@ -21,7 +21,7 @@ export class LanguageChooserComponent {
21 } 21 }
22 22
23 show () { 23 show () {
24 this.modalService.open(this.modal) 24 this.modalService.open(this.modal, { centered: true })
25 } 25 }
26 26
27 buildLanguageLink (lang: { id: string }) { 27 buildLanguageLink (lang: { id: string }) {
diff --git a/client/src/app/menu/menu.component.html b/client/src/app/menu/menu.component.html
index 675fb597d..91422df77 100644
--- a/client/src/app/menu/menu.component.html
+++ b/client/src/app/menu/menu.component.html
@@ -8,10 +8,10 @@
8 <a *ngIf="user.account" [routerLink]="[ '/accounts', user.account.nameWithHost ]" class="logged-in-display-name">{{ user.account?.displayName }}</a> 8 <a *ngIf="user.account" [routerLink]="[ '/accounts', user.account.nameWithHost ]" class="logged-in-display-name">{{ user.account?.displayName }}</a>
9 <a *ngIf="!user.account" routerLink="/my-account/settings" class="logged-in-display-name">{{ user.account?.displayName }}</a> 9 <a *ngIf="!user.account" routerLink="/my-account/settings" class="logged-in-display-name">{{ user.account?.displayName }}</a>
10 10
11 <div ngxClipboard [cbContent]="user.account?.nameWithHost" class="logged-in-username">{{ user.username }}</div> 11 <div class="logged-in-username">{{ user.username }}</div>
12 </div> 12 </div>
13 13
14 <div class="logged-in-more" ngbDropdown placement="bottom-right auto"> 14 <div class="logged-in-more" ngbDropdown placement="bottom-right auto" container="body">
15 <my-global-icon iconName="more-vertical" ngbDropdownToggle role="button"></my-global-icon> 15 <my-global-icon iconName="more-vertical" ngbDropdownToggle role="button"></my-global-icon>
16 16
17 <div ngbDropdownMenu> 17 <div ngbDropdownMenu>
diff --git a/client/src/app/menu/menu.component.scss b/client/src/app/menu/menu.component.scss
index 112fdc1ce..dec045928 100644
--- a/client/src/app/menu/menu.component.scss
+++ b/client/src/app/menu/menu.component.scss
@@ -6,7 +6,8 @@
6 height: calc(100vh - #{$header-height}); 6 height: calc(100vh - #{$header-height});
7 padding: 0; 7 padding: 0;
8 width: $menu-width; 8 width: $menu-width;
9 z-index: 10000; 9 z-index: z(menu);
10 scrollbar-color: var(--actionButtonColor) var(--menuBackgroundColor);
10} 11}
11 12
12menu { 13menu {
diff --git a/client/src/app/modal/instance-config-warning-modal.component.ts b/client/src/app/modal/instance-config-warning-modal.component.ts
index 742a7dd41..5e1433548 100644
--- a/client/src/app/modal/instance-config-warning-modal.component.ts
+++ b/client/src/app/modal/instance-config-warning-modal.component.ts
@@ -24,7 +24,7 @@ export class InstanceConfigWarningModalComponent {
24 show (about: About) { 24 show (about: About) {
25 this.about = about 25 this.about = about
26 26
27 const ref = this.modalService.open(this.modal) 27 const ref = this.modalService.open(this.modal, { centered: true })
28 28
29 ref.result.finally(() => { 29 ref.result.finally(() => {
30 if (this.stopDisplayModal === true) this.doNotOpenAgain() 30 if (this.stopDisplayModal === true) this.doNotOpenAgain()
diff --git a/client/src/app/modal/welcome-modal.component.ts b/client/src/app/modal/welcome-modal.component.ts
index 19a147b85..e022776e3 100644
--- a/client/src/app/modal/welcome-modal.component.ts
+++ b/client/src/app/modal/welcome-modal.component.ts
@@ -18,7 +18,8 @@ export class WelcomeModalComponent {
18 ) { } 18 ) { }
19 19
20 show () { 20 show () {
21 this.modalService.open(this.modal,{ 21 this.modalService.open(this.modal, {
22 centered: true,
22 backdrop: 'static', 23 backdrop: 'static',
23 keyboard: false, 24 keyboard: false,
24 size: 'lg' 25 size: 'lg'
diff --git a/client/src/app/search/search-filters.component.html b/client/src/app/search/search-filters.component.html
index 07fb2c048..60680c7bd 100644
--- a/client/src/app/search/search-filters.component.html
+++ b/client/src/app/search/search-filters.component.html
@@ -103,7 +103,7 @@
103 </button> 103 </button>
104 <div class="peertube-select-container"> 104 <div class="peertube-select-container">
105 <select id="category" name="category" [(ngModel)]="advancedSearch.categoryOneOf"> 105 <select id="category" name="category" [(ngModel)]="advancedSearch.categoryOneOf">
106 <option [value]="undefined" i18n>Any or no category set</option> 106 <option [value]="undefined" i18n>Display all categories</option>
107 <option *ngFor="let category of videoCategories" [value]="category.id">{{ category.label }}</option> 107 <option *ngFor="let category of videoCategories" [value]="category.id">{{ category.label }}</option>
108 </select> 108 </select>
109 </div> 109 </div>
@@ -116,7 +116,7 @@
116 </button> 116 </button>
117 <div class="peertube-select-container"> 117 <div class="peertube-select-container">
118 <select id="licence" name="licence" [(ngModel)]="advancedSearch.licenceOneOf"> 118 <select id="licence" name="licence" [(ngModel)]="advancedSearch.licenceOneOf">
119 <option [value]="undefined" i18n>Any or no license set</option> 119 <option [value]="undefined" i18n>Display all licenses</option>
120 <option *ngFor="let licence of videoLicences" [value]="licence.id">{{ licence.label }}</option> 120 <option *ngFor="let licence of videoLicences" [value]="licence.id">{{ licence.label }}</option>
121 </select> 121 </select>
122 </div> 122 </div>
@@ -129,7 +129,7 @@
129 </button> 129 </button>
130 <div class="peertube-select-container"> 130 <div class="peertube-select-container">
131 <select id="language" name="language" [(ngModel)]="advancedSearch.languageOneOf"> 131 <select id="language" name="language" [(ngModel)]="advancedSearch.languageOneOf">
132 <option [value]="undefined" i18n>Any or no language set</option> 132 <option [value]="undefined" i18n>Display all languages</option>
133 <option *ngFor="let language of videoLanguages" [value]="language.id">{{ language.label }}</option> 133 <option *ngFor="let language of videoLanguages" [value]="language.id">{{ language.label }}</option>
134 </select> 134 </select>
135 </div> 135 </div>
@@ -146,7 +146,7 @@
146 [(ngModel)]="advancedSearch.tagsAllOf" name="tagsAllOf" id="tagsAllOf" 146 [(ngModel)]="advancedSearch.tagsAllOf" name="tagsAllOf" id="tagsAllOf"
147 [validators]="tagValidators" [errorMessages]="tagValidatorsMessages" 147 [validators]="tagValidators" [errorMessages]="tagValidatorsMessages"
148 i18n-placeholder placeholder="+ Tag" i18n-secondaryPlaceholder secondaryPlaceholder="Enter a tag" 148 i18n-placeholder placeholder="+ Tag" i18n-secondaryPlaceholder secondaryPlaceholder="Enter a tag"
149 maxItems="5" modelAsStrings="true" 149 [maxItems]="5" [modelAsStrings]="true"
150 ></tag-input> 150 ></tag-input>
151 </div> 151 </div>
152 152
@@ -159,7 +159,7 @@
159 [(ngModel)]="advancedSearch.tagsOneOf" name="tagsOneOf" id="tagsOneOf" 159 [(ngModel)]="advancedSearch.tagsOneOf" name="tagsOneOf" id="tagsOneOf"
160 [validators]="tagValidators" [errorMessages]="tagValidatorsMessages" 160 [validators]="tagValidators" [errorMessages]="tagValidatorsMessages"
161 i18n-placeholder placeholder="+ Tag" i18n-secondaryPlaceholder secondaryPlaceholder="Enter a tag" 161 i18n-placeholder placeholder="+ Tag" i18n-secondaryPlaceholder secondaryPlaceholder="Enter a tag"
162 maxItems="5" modelAsStrings="true" 162 [maxItems]="5" [modelAsStrings]="true"
163 ></tag-input> 163 ></tag-input>
164 </div> 164 </div>
165 </div> 165 </div>
diff --git a/client/src/app/search/search.component.ts b/client/src/app/search/search.component.ts
index dfd8d8823..075994dd3 100644
--- a/client/src/app/search/search.component.ts
+++ b/client/src/app/search/search.component.ts
@@ -141,7 +141,8 @@ export class SearchComponent implements OnInit, OnDestroy {
141 return this.advancedSearch.size() 141 return this.advancedSearch.size()
142 } 142 }
143 143
144 removeVideoFromArray (video: Video) { 144 // Add VideoChannel for typings, but the template already checks "video" argument is a video
145 removeVideoFromArray (video: Video | VideoChannel) {
145 this.results = this.results.filter(r => !this.isVideo(r) || r.id !== video.id) 146 this.results = this.results.filter(r => !this.isVideo(r) || r.id !== video.id)
146 } 147 }
147 148
diff --git a/client/src/app/shared/angular/highlight.pipe.ts b/client/src/app/shared/angular/highlight.pipe.ts
new file mode 100644
index 000000000..fb6042280
--- /dev/null
+++ b/client/src/app/shared/angular/highlight.pipe.ts
@@ -0,0 +1,54 @@
1import { PipeTransform, Pipe } from '@angular/core'
2import { SafeHtml } from '@angular/platform-browser'
3
4// Thanks https://gist.github.com/adamrecsko/0f28f474eca63e0279455476cc11eca7#gistcomment-2917369
5@Pipe({ name: 'highlight' })
6export class HighlightPipe implements PipeTransform {
7 /* use this for single match search */
8 static SINGLE_MATCH = 'Single-Match'
9 /* use this for single match search with a restriction that target should start with search string */
10 static SINGLE_AND_STARTS_WITH_MATCH = 'Single-And-StartsWith-Match'
11 /* use this for global search */
12 static MULTI_MATCH = 'Multi-Match'
13
14 // tslint:disable-next-line:no-empty
15 constructor () {}
16
17 transform (
18 contentString: string = null,
19 stringToHighlight: string = null,
20 option = 'Single-And-StartsWith-Match',
21 caseSensitive = false,
22 highlightStyleName = 'search-highlight'
23 ): SafeHtml {
24 if (stringToHighlight && contentString && option) {
25 let regex: any = ''
26 const caseFlag: string = !caseSensitive ? 'i' : ''
27 switch (option) {
28 case 'Single-Match': {
29 regex = new RegExp(stringToHighlight, caseFlag)
30 break
31 }
32 case 'Single-And-StartsWith-Match': {
33 regex = new RegExp('^' + stringToHighlight, caseFlag)
34 break
35 }
36 case 'Multi-Match': {
37 regex = new RegExp(stringToHighlight, 'g' + caseFlag)
38 break
39 }
40 default: {
41 // default will be a global case-insensitive match
42 regex = new RegExp(stringToHighlight, 'gi')
43 }
44 }
45 const replaced = contentString.replace(
46 regex,
47 (match) => `<span class="${highlightStyleName}">${match}</span>`
48 )
49 return replaced
50 } else {
51 return contentString
52 }
53 }
54}
diff --git a/client/src/app/shared/buttons/action-dropdown.component.ts b/client/src/app/shared/buttons/action-dropdown.component.ts
index a8b3ab16c..6649b092a 100644
--- a/client/src/app/shared/buttons/action-dropdown.component.ts
+++ b/client/src/app/shared/buttons/action-dropdown.component.ts
@@ -34,10 +34,10 @@ export class ActionDropdownComponent<T> {
34 @Input() label: string 34 @Input() label: string
35 @Input() theme: DropdownTheme = 'grey' 35 @Input() theme: DropdownTheme = 'grey'
36 36
37 getActions () { 37 getActions (): DropdownAction<T>[][] {
38 if (this.actions.length !== 0 && Array.isArray(this.actions[0])) return this.actions 38 if (this.actions.length !== 0 && Array.isArray(this.actions[0])) return this.actions as DropdownAction<T>[][]
39 39
40 return [ this.actions ] 40 return [ this.actions as DropdownAction<T>[] ]
41 } 41 }
42 42
43 areActionsDisplayed (actions: Array<DropdownAction<T> | DropdownAction<T>[]>, entry: T): boolean { 43 areActionsDisplayed (actions: Array<DropdownAction<T> | DropdownAction<T>[]>, entry: T): boolean {
diff --git a/client/src/app/shared/buttons/button.component.scss b/client/src/app/shared/buttons/button.component.scss
index 2a8cfc748..3ccfefd7e 100644
--- a/client/src/app/shared/buttons/button.component.scss
+++ b/client/src/app/shared/buttons/button.component.scss
@@ -10,11 +10,26 @@ my-small-loader ::ng-deep .root {
10.action-button { 10.action-button {
11 @include peertube-button-link; 11 @include peertube-button-link;
12 @include button-with-icon(21px, 0, -2px); 12 @include button-with-icon(21px, 0, -2px);
13}
13 14
14 // FIXME: Firefox does not apply global .orange-button icon color 15.orange-button {
15 &.orange-button { 16 @include peertube-button;
16 @include apply-svg-color(#fff) 17 @include orange-button;
17 } 18}
19
20.orange-button-link {
21 @include peertube-button-link;
22 @include orange-button;
23}
24
25.grey-button {
26 @include peertube-button;
27 @include grey-button;
28}
29
30.grey-button-link {
31 @include peertube-button-link;
32 @include grey-button;
18} 33}
19 34
20// In a table, try to minimize the space taken by this button 35// In a table, try to minimize the space taken by this button
diff --git a/client/src/app/shared/buttons/edit-button.component.ts b/client/src/app/shared/buttons/edit-button.component.ts
index 1fe4f7b30..9cfe1a3bb 100644
--- a/client/src/app/shared/buttons/edit-button.component.ts
+++ b/client/src/app/shared/buttons/edit-button.component.ts
@@ -8,5 +8,5 @@ import { Component, Input } from '@angular/core'
8 8
9export class EditButtonComponent { 9export class EditButtonComponent {
10 @Input() label: string 10 @Input() label: string
11 @Input() routerLink: string[] = [] 11 @Input() routerLink: string[] | string = []
12} 12}
diff --git a/client/src/app/shared/forms/form-validators/custom-config-validators.service.ts b/client/src/app/shared/forms/form-validators/custom-config-validators.service.ts
index 767e3f026..d20754d11 100644
--- a/client/src/app/shared/forms/form-validators/custom-config-validators.service.ts
+++ b/client/src/app/shared/forms/form-validators/custom-config-validators.service.ts
@@ -56,7 +56,7 @@ export class CustomConfigValidatorsService {
56 } 56 }
57 57
58 this.SIGNUP_LIMIT = { 58 this.SIGNUP_LIMIT = {
59 VALIDATORS: [ Validators.required, Validators.min(1), Validators.pattern('[0-9]+') ], 59 VALIDATORS: [ Validators.required, Validators.min(-1), Validators.pattern('-?[0-9]+') ],
60 MESSAGES: { 60 MESSAGES: {
61 'required': this.i18n('Signup limit is required.'), 61 'required': this.i18n('Signup limit is required.'),
62 'min': this.i18n('Signup limit must be greater than 1.'), 62 'min': this.i18n('Signup limit must be greater than 1.'),
diff --git a/client/src/app/shared/forms/form-validators/user-validators.service.ts b/client/src/app/shared/forms/form-validators/user-validators.service.ts
index 4dff3e422..13b9228d4 100644
--- a/client/src/app/shared/forms/form-validators/user-validators.service.ts
+++ b/client/src/app/shared/forms/form-validators/user-validators.service.ts
@@ -8,6 +8,7 @@ export class UserValidatorsService {
8 readonly USER_USERNAME: BuildFormValidator 8 readonly USER_USERNAME: BuildFormValidator
9 readonly USER_EMAIL: BuildFormValidator 9 readonly USER_EMAIL: BuildFormValidator
10 readonly USER_PASSWORD: BuildFormValidator 10 readonly USER_PASSWORD: BuildFormValidator
11 readonly USER_PASSWORD_OPTIONAL: BuildFormValidator
11 readonly USER_CONFIRM_PASSWORD: BuildFormValidator 12 readonly USER_CONFIRM_PASSWORD: BuildFormValidator
12 readonly USER_VIDEO_QUOTA: BuildFormValidator 13 readonly USER_VIDEO_QUOTA: BuildFormValidator
13 readonly USER_VIDEO_QUOTA_DAILY: BuildFormValidator 14 readonly USER_VIDEO_QUOTA_DAILY: BuildFormValidator
@@ -56,6 +57,17 @@ export class UserValidatorsService {
56 } 57 }
57 } 58 }
58 59
60 this.USER_PASSWORD_OPTIONAL = {
61 VALIDATORS: [
62 Validators.minLength(6),
63 Validators.maxLength(255)
64 ],
65 MESSAGES: {
66 'minlength': this.i18n('Password must be at least 6 characters long.'),
67 'maxlength': this.i18n('Password cannot be more than 255 characters long.')
68 }
69 }
70
59 this.USER_CONFIRM_PASSWORD = { 71 this.USER_CONFIRM_PASSWORD = {
60 VALIDATORS: [], 72 VALIDATORS: [],
61 MESSAGES: { 73 MESSAGES: {
diff --git a/client/src/app/shared/forms/input-readonly-copy.component.html b/client/src/app/shared/forms/input-readonly-copy.component.html
index 27571b63f..b6a56ec44 100644
--- a/client/src/app/shared/forms/input-readonly-copy.component.html
+++ b/client/src/app/shared/forms/input-readonly-copy.component.html
@@ -2,7 +2,7 @@
2 <input #urlInput (click)="urlInput.select()" type="text" class="form-control readonly" readonly [value]="value" /> 2 <input #urlInput (click)="urlInput.select()" type="text" class="form-control readonly" readonly [value]="value" />
3 3
4 <div class="input-group-append"> 4 <div class="input-group-append">
5 <button [ngxClipboard]="urlInput" (click)="activateCopiedMessage()" type="button" class="btn btn-outline-secondary"> 5 <button [cdkCopyToClipboard]="urlInput.value" (click)="activateCopiedMessage()" type="button" class="btn btn-outline-secondary">
6 <span class="glyphicon glyphicon-copy"></span> 6 <span class="glyphicon glyphicon-copy"></span>
7 </button> 7 </button>
8 </div> 8 </div>
diff --git a/client/src/app/shared/forms/markdown-textarea.component.ts b/client/src/app/shared/forms/markdown-textarea.component.ts
index 19cd37573..cbcfdfe78 100644
--- a/client/src/app/shared/forms/markdown-textarea.component.ts
+++ b/client/src/app/shared/forms/markdown-textarea.component.ts
@@ -21,7 +21,7 @@ import { MarkdownService } from '@app/shared/renderer'
21 21
22export class MarkdownTextareaComponent implements ControlValueAccessor, OnInit { 22export class MarkdownTextareaComponent implements ControlValueAccessor, OnInit {
23 @Input() content = '' 23 @Input() content = ''
24 @Input() classes: string[] = [] 24 @Input() classes: string[] | { [klass: string]: any[] | any } = []
25 @Input() textareaWidth = '100%' 25 @Input() textareaWidth = '100%'
26 @Input() textareaHeight = '150px' 26 @Input() textareaHeight = '150px'
27 @Input() previewColumn = false 27 @Input() previewColumn = false
diff --git a/client/src/app/shared/images/global-icon.component.ts b/client/src/app/shared/images/global-icon.component.ts
index 806aca347..b6e641228 100644
--- a/client/src/app/shared/images/global-icon.component.ts
+++ b/client/src/app/shared/images/global-icon.component.ts
@@ -1,6 +1,5 @@
1import { ChangeDetectionStrategy, Component, ElementRef, Input, OnInit } from '@angular/core' 1import { ChangeDetectionStrategy, Component, ElementRef, Input, OnInit } from '@angular/core'
2import { HooksService } from '@app/core/plugins/hooks.service' 2import { HooksService } from '@app/core/plugins/hooks.service'
3import { I18n } from '@ngx-translate/i18n-polyfill'
4 3
5const icons = { 4const icons = {
6 'add': require('!!raw-loader?!../../../assets/images/global/add.svg'), 5 'add': require('!!raw-loader?!../../../assets/images/global/add.svg'),
diff --git a/client/src/app/shared/images/preview-upload.component.ts b/client/src/app/shared/images/preview-upload.component.ts
index f56f5b1f8..85a2173e9 100644
--- a/client/src/app/shared/images/preview-upload.component.ts
+++ b/client/src/app/shared/images/preview-upload.component.ts
@@ -26,7 +26,7 @@ export class PreviewUploadComponent implements OnInit, ControlValueAccessor {
26 allowedExtensionsMessage = '' 26 allowedExtensionsMessage = ''
27 27
28 private serverConfig: ServerConfig 28 private serverConfig: ServerConfig
29 private file: File 29 private file: Blob
30 30
31 constructor ( 31 constructor (
32 private sanitizer: DomSanitizer, 32 private sanitizer: DomSanitizer,
@@ -49,7 +49,7 @@ export class PreviewUploadComponent implements OnInit, ControlValueAccessor {
49 this.allowedExtensionsMessage = this.videoImageExtensions.join(', ') 49 this.allowedExtensionsMessage = this.videoImageExtensions.join(', ')
50 } 50 }
51 51
52 onFileChanged (file: File) { 52 onFileChanged (file: Blob) {
53 this.file = file 53 this.file = file
54 54
55 this.propagateChange(this.file) 55 this.propagateChange(this.file)
diff --git a/client/src/app/shared/instance/instance-features-table.component.html b/client/src/app/shared/instance/instance-features-table.component.html
index fd8b3354f..51a56d414 100644
--- a/client/src/app/shared/instance/instance-features-table.component.html
+++ b/client/src/app/shared/instance/instance-features-table.component.html
@@ -91,5 +91,16 @@
91 <my-feature-boolean [value]="serverConfig.tracker.enabled"></my-feature-boolean> 91 <my-feature-boolean [value]="serverConfig.tracker.enabled"></my-feature-boolean>
92 </td> 92 </td>
93 </tr> 93 </tr>
94
95 <tr>
96 <td i18n class="label" colspan="2">Search</td>
97 </tr>
98
99 <tr>
100 <td i18n class="sub-label">Users can resolve distant content</td>
101 <td>
102 <my-feature-boolean [value]="serverConfig.search.remoteUri.users"></my-feature-boolean>
103 </td>
104 </tr>
94 </table> 105 </table>
95</div> 106</div>
diff --git a/client/src/app/shared/instance/instance-statistics.component.ts b/client/src/app/shared/instance/instance-statistics.component.ts
index 8ec728f05..40aa8a4c0 100644
--- a/client/src/app/shared/instance/instance-statistics.component.ts
+++ b/client/src/app/shared/instance/instance-statistics.component.ts
@@ -1,9 +1,6 @@
1import { map } from 'rxjs/operators'
2import { HttpClient } from '@angular/common/http'
3import { Component, OnInit } from '@angular/core' 1import { Component, OnInit } from '@angular/core'
4import { I18n } from '@ngx-translate/i18n-polyfill'
5import { ServerStats } from '@shared/models/server' 2import { ServerStats } from '@shared/models/server'
6import { environment } from '../../../environments/environment' 3import { ServerService } from '@app/core'
7 4
8@Component({ 5@Component({
9 selector: 'my-instance-statistics', 6 selector: 'my-instance-statistics',
@@ -11,27 +8,15 @@ import { environment } from '../../../environments/environment'
11 styleUrls: [ './instance-statistics.component.scss' ] 8 styleUrls: [ './instance-statistics.component.scss' ]
12}) 9})
13export class InstanceStatisticsComponent implements OnInit { 10export class InstanceStatisticsComponent implements OnInit {
14 private static BASE_STATS_URL = environment.apiUrl + '/api/v1/server/stats'
15
16 serverStats: ServerStats = null 11 serverStats: ServerStats = null
17 12
18 constructor ( 13 constructor (
19 private http: HttpClient, 14 private serverService: ServerService
20 private i18n: I18n
21 ) { 15 ) {
22 } 16 }
23 17
24 ngOnInit () { 18 ngOnInit () {
25 this.getStats() 19 this.serverService.getServerStats()
26 .subscribe( 20 .subscribe(res => this.serverStats = res)
27 res => {
28 this.serverStats = res
29 }
30 )
31 }
32
33 getStats () {
34 return this.http
35 .get<ServerStats>(InstanceStatisticsComponent.BASE_STATS_URL)
36 } 21 }
37} 22}
diff --git a/client/src/app/shared/menu/top-menu-dropdown.component.ts b/client/src/app/shared/menu/top-menu-dropdown.component.ts
index 5ccdafb54..24a083654 100644
--- a/client/src/app/shared/menu/top-menu-dropdown.component.ts
+++ b/client/src/app/shared/menu/top-menu-dropdown.component.ts
@@ -49,8 +49,7 @@ export class TopMenuDropdownComponent implements OnInit, OnDestroy {
49 e => e.children && e.children.some(c => !!c.iconName) 49 e => e.children && e.children.some(c => !!c.iconName)
50 ) 50 )
51 51
52 // FIXME: We have to set body for the container to avoid because of scroll overflow on mobile view 52 // We have to set body for the container to avoid scroll overflow on mobile view
53 // But this break our hovering system
54 if (this.screen.isInMobileView()) { 53 if (this.screen.isInMobileView()) {
55 this.container = 'body' 54 this.container = 'body'
56 } 55 }
diff --git a/client/src/app/shared/misc/list-overflow.component.html b/client/src/app/shared/misc/list-overflow.component.html
new file mode 100644
index 000000000..986572801
--- /dev/null
+++ b/client/src/app/shared/misc/list-overflow.component.html
@@ -0,0 +1,35 @@
1<div #itemsParent class="d-flex align-items-center text-nowrap w-100 list-overflow-parent">
2 <span [id]="getId(id)" #itemsRendered *ngFor="let item of items; index as id">
3 <ng-container *ngTemplateOutlet="itemTemplate; context: {item: item}"></ng-container>
4 </span>
5
6 <ng-container *ngIf="isMenuDisplayed()">
7 <button *ngIf="isInMobileView" class="btn btn-outline-secondary btn-sm list-overflow-menu" (click)="toggleModal()">
8 <span class="glyphicon glyphicon-chevron-down"></span>
9 </button>
10
11 <div *ngIf="!isInMobileView" class="list-overflow-menu" ngbDropdown container="body" #dropdown="ngbDropdown" (mouseleave)="closeDropdownIfHovered(dropdown)" (mouseenter)="openDropdownOnHover(dropdown)">
12 <button class="btn btn-outline-secondary btn-sm" [ngClass]="{ routeActive: active }"
13 ngbDropdownAnchor (click)="dropdownAnchorClicked(dropdown)" role="button"
14 >
15 <span class="glyphicon glyphicon-chevron-down"></span>
16 </button>
17
18 <div ngbDropdownMenu>
19 <a *ngFor="let item of items | slice:showItemsUntilIndexExcluded:items.length"
20 [routerLink]="item.routerLink" routerLinkActive="active" class="dropdown-item">
21 {{ item.label }}
22 </a>
23 </div>
24 </div>
25 </ng-container>
26</div >
27
28<ng-template #modal let-close="close" let-dismiss="dismiss">
29 <div class="modal-body">
30 <a *ngFor="let item of items | slice:showItemsUntilIndexExcluded:items.length"
31 [routerLink]="item.routerLink" routerLinkActive="active" (click)="dismissOtherModals()">
32 {{ item.label }}
33 </a>
34 </div>
35</ng-template>
diff --git a/client/src/app/shared/misc/list-overflow.component.scss b/client/src/app/shared/misc/list-overflow.component.scss
new file mode 100644
index 000000000..1e5fe4c10
--- /dev/null
+++ b/client/src/app/shared/misc/list-overflow.component.scss
@@ -0,0 +1,61 @@
1@import '_mixins';
2
3:host {
4 width: 100%;
5}
6
7.list-overflow-parent {
8 overflow: hidden;
9}
10
11.list-overflow-menu {
12 position: absolute;
13 right: 25px;
14}
15
16button {
17 width: 30px;
18 border: none;
19
20 &::after {
21 display: none;
22 }
23
24 &.routeActive {
25 &::after {
26 display: inherit;
27 border: 2px solid var(--mainColor);
28 position: relative;
29 right: 95%;
30 top: 50%;
31 }
32 }
33}
34
35::ng-deep .dropdown-menu {
36 margin-top: 0 !important;
37 position: static;
38 right: auto;
39 bottom: auto
40}
41
42.modal-body {
43 a {
44 @include disable-default-a-behaviour;
45
46 color: currentColor;
47 box-sizing: border-box;
48 display: block;
49 font-size: 1.2rem;
50 padding: 9px 12px;
51 text-align: initial;
52 text-transform: unset;
53 width: 100%;
54
55 &.active {
56 color: var(--mainBackgroundColor) !important;
57 background-color: var(--mainHoverColor);
58 opacity: .9;
59 }
60 }
61}
diff --git a/client/src/app/shared/misc/list-overflow.component.ts b/client/src/app/shared/misc/list-overflow.component.ts
new file mode 100644
index 000000000..c493ab795
--- /dev/null
+++ b/client/src/app/shared/misc/list-overflow.component.ts
@@ -0,0 +1,119 @@
1import {
2 AfterViewInit,
3 ChangeDetectionStrategy,
4 ChangeDetectorRef,
5 Component,
6 ElementRef,
7 HostListener,
8 Input,
9 QueryList,
10 TemplateRef,
11 ViewChild,
12 ViewChildren
13} from '@angular/core'
14import { NgbDropdown, NgbModal } from '@ng-bootstrap/ng-bootstrap'
15import { lowerFirst, uniqueId } from 'lodash-es'
16import { ScreenService } from './screen.service'
17import { take } from 'rxjs/operators'
18
19export interface ListOverflowItem {
20 label: string
21 routerLink: string | any[]
22}
23
24@Component({
25 selector: 'list-overflow',
26 templateUrl: './list-overflow.component.html',
27 styleUrls: [ './list-overflow.component.scss' ],
28 changeDetection: ChangeDetectionStrategy.OnPush
29})
30export class ListOverflowComponent<T extends ListOverflowItem> implements AfterViewInit {
31 @ViewChild('modal', { static: true }) modal: ElementRef
32 @ViewChild('itemsParent', { static: true }) parent: ElementRef<HTMLDivElement>
33 @ViewChildren('itemsRendered') itemsRendered: QueryList<ElementRef>
34 @Input() items: T[]
35 @Input() itemTemplate: TemplateRef<{item: T}>
36
37 showItemsUntilIndexExcluded: number
38 active = false
39 isInTouchScreen = false
40 isInMobileView = false
41
42 private openedOnHover = false
43
44 constructor (
45 private cdr: ChangeDetectorRef,
46 private modalService: NgbModal,
47 private screenService: ScreenService
48 ) {}
49
50 ngAfterViewInit () {
51 setTimeout(() => this.onWindowResize(), 0)
52 }
53
54 isMenuDisplayed () {
55 return !!this.showItemsUntilIndexExcluded
56 }
57
58 @HostListener('window:resize', ['$event'])
59 onWindowResize () {
60 this.isInTouchScreen = !!this.screenService.isInTouchScreen()
61 this.isInMobileView = !!this.screenService.isInMobileView()
62
63 const parentWidth = this.parent.nativeElement.getBoundingClientRect().width
64 let showItemsUntilIndexExcluded: number
65 let accWidth = 0
66
67 for (const [index, el] of this.itemsRendered.toArray().entries()) {
68 accWidth += el.nativeElement.getBoundingClientRect().width
69 if (showItemsUntilIndexExcluded === undefined) {
70 showItemsUntilIndexExcluded = (parentWidth < accWidth) ? index : undefined
71 }
72
73 const e = document.getElementById(this.getId(index))
74 const shouldBeVisible = showItemsUntilIndexExcluded ? index < showItemsUntilIndexExcluded : true
75 e.style.visibility = shouldBeVisible ? 'inherit' : 'hidden'
76 }
77
78 this.showItemsUntilIndexExcluded = showItemsUntilIndexExcluded
79 this.cdr.markForCheck()
80 }
81
82 openDropdownOnHover (dropdown: NgbDropdown) {
83 this.openedOnHover = true
84 dropdown.open()
85
86 // Menu was closed
87 dropdown.openChange
88 .pipe(take(1))
89 .subscribe(() => this.openedOnHover = false)
90 }
91
92 dropdownAnchorClicked (dropdown: NgbDropdown) {
93 if (this.openedOnHover) {
94 this.openedOnHover = false
95 return
96 }
97
98 return dropdown.toggle()
99 }
100
101 closeDropdownIfHovered (dropdown: NgbDropdown) {
102 if (this.openedOnHover === false) return
103
104 dropdown.close()
105 this.openedOnHover = false
106 }
107
108 toggleModal () {
109 this.modalService.open(this.modal, { centered: true })
110 }
111
112 dismissOtherModals () {
113 this.modalService.dismissAll()
114 }
115
116 getId (id: number | string = uniqueId()): string {
117 return lowerFirst(this.constructor.name) + '_' + id
118 }
119}
diff --git a/client/src/app/shared/moderation/user-ban-modal.component.ts b/client/src/app/shared/moderation/user-ban-modal.component.ts
index cf0e1577a..1647e3691 100644
--- a/client/src/app/shared/moderation/user-ban-modal.component.ts
+++ b/client/src/app/shared/moderation/user-ban-modal.component.ts
@@ -39,7 +39,7 @@ export class UserBanModalComponent extends FormReactive implements OnInit {
39 39
40 openModal (user: User | User[]) { 40 openModal (user: User | User[]) {
41 this.usersToBan = user 41 this.usersToBan = user
42 this.openedModal = this.modalService.open(this.modal) 42 this.openedModal = this.modalService.open(this.modal, { centered: true })
43 } 43 }
44 44
45 hide () { 45 hide () {
diff --git a/client/src/app/shared/moderation/user-moderation-dropdown.component.ts b/client/src/app/shared/moderation/user-moderation-dropdown.component.ts
index 11d8588f4..9197556b0 100644
--- a/client/src/app/shared/moderation/user-moderation-dropdown.component.ts
+++ b/client/src/app/shared/moderation/user-moderation-dropdown.component.ts
@@ -14,7 +14,7 @@ import { ServerConfig } from '@shared/models'
14 templateUrl: './user-moderation-dropdown.component.html' 14 templateUrl: './user-moderation-dropdown.component.html'
15}) 15})
16export class UserModerationDropdownComponent implements OnInit, OnChanges { 16export class UserModerationDropdownComponent implements OnInit, OnChanges {
17 @ViewChild('userBanModal', { static: false }) userBanModal: UserBanModalComponent 17 @ViewChild('userBanModal') userBanModal: UserBanModalComponent
18 18
19 @Input() user: User 19 @Input() user: User
20 @Input() account: Account 20 @Input() account: Account
diff --git a/client/src/app/shared/renderer/markdown.service.ts b/client/src/app/shared/renderer/markdown.service.ts
index 0d3fde537..f0c87326f 100644
--- a/client/src/app/shared/renderer/markdown.service.ts
+++ b/client/src/app/shared/renderer/markdown.service.ts
@@ -1,7 +1,7 @@
1import { Injectable } from '@angular/core' 1import { Injectable } from '@angular/core'
2import { MarkdownIt } from 'markdown-it'
3import { buildVideoLink } from '../../../assets/player/utils' 2import { buildVideoLink } from '../../../assets/player/utils'
4import { HtmlRendererService } from '@app/shared/renderer/html-renderer.service' 3import { HtmlRendererService } from '@app/shared/renderer/html-renderer.service'
4import * as MarkdownIt from 'markdown-it'
5 5
6type MarkdownParsers = { 6type MarkdownParsers = {
7 textMarkdownIt: MarkdownIt 7 textMarkdownIt: MarkdownIt
@@ -100,7 +100,7 @@ export class MarkdownService {
100 } 100 }
101 101
102 private async createMarkdownIt (config: MarkdownConfig) { 102 private async createMarkdownIt (config: MarkdownConfig) {
103 // FIXME: import('...') returns a struct module, containing a "default" field corresponding to our sanitizeHtml function 103 // FIXME: import('...') returns a struct module, containing a "default" field
104 const MarkdownItClass: typeof import ('markdown-it') = (await import('markdown-it') as any).default 104 const MarkdownItClass: typeof import ('markdown-it') = (await import('markdown-it') as any).default
105 105
106 const markdownIt = new MarkdownItClass('zero', { linkify: true, breaks: true, html: config.html }) 106 const markdownIt = new MarkdownItClass('zero', { linkify: true, breaks: true, html: config.html })
diff --git a/client/src/app/shared/rest/rest-table.ts b/client/src/app/shared/rest/rest-table.ts
index c180346af..a33e99e25 100644
--- a/client/src/app/shared/rest/rest-table.ts
+++ b/client/src/app/shared/rest/rest-table.ts
@@ -1,6 +1,5 @@
1import { peertubeLocalStorage } from '@app/shared/misc/peertube-web-storage' 1import { peertubeLocalStorage } from '@app/shared/misc/peertube-web-storage'
2import { LazyLoadEvent } from 'primeng/components/common/lazyloadevent' 2import { LazyLoadEvent, SortMeta } from 'primeng/api'
3import { SortMeta } from 'primeng/components/common/sortmeta'
4import { RestPagination } from './rest-pagination' 3import { RestPagination } from './rest-pagination'
5import { Subject } from 'rxjs' 4import { Subject } from 'rxjs'
6import { debounceTime, distinctUntilChanged } from 'rxjs/operators' 5import { debounceTime, distinctUntilChanged } from 'rxjs/operators'
@@ -66,8 +65,9 @@ export abstract class RestTable {
66 }) 65 })
67 } 66 }
68 67
69 onSearch (search: string) { 68 onSearch (event: Event) {
70 this.searchStream.next(search) 69 const target = event.target as HTMLInputElement
70 this.searchStream.next(target.value)
71 } 71 }
72 72
73 protected abstract loadData (): void 73 protected abstract loadData (): void
diff --git a/client/src/app/shared/rest/rest.service.ts b/client/src/app/shared/rest/rest.service.ts
index 16bb6d82c..5bd2b5e43 100644
--- a/client/src/app/shared/rest/rest.service.ts
+++ b/client/src/app/shared/rest/rest.service.ts
@@ -1,6 +1,6 @@
1import { Injectable } from '@angular/core' 1import { Injectable } from '@angular/core'
2import { HttpParams } from '@angular/common/http' 2import { HttpParams } from '@angular/common/http'
3import { SortMeta } from 'primeng/components/common/sortmeta' 3import { SortMeta } from 'primeng/api'
4import { ComponentPagination, ComponentPaginationLight } from './component-pagination.model' 4import { ComponentPagination, ComponentPaginationLight } from './component-pagination.model'
5 5
6import { RestPagination } from './rest-pagination' 6import { RestPagination } from './rest-pagination'
diff --git a/client/src/app/shared/shared.module.ts b/client/src/app/shared/shared.module.ts
index b2eb13f73..30b3ba0c1 100644
--- a/client/src/app/shared/shared.module.ts
+++ b/client/src/app/shared/shared.module.ts
@@ -5,9 +5,10 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms'
5import { RouterModule } from '@angular/router' 5import { RouterModule } from '@angular/router'
6import { MarkdownTextareaComponent } from '@app/shared/forms/markdown-textarea.component' 6import { MarkdownTextareaComponent } from '@app/shared/forms/markdown-textarea.component'
7import { HelpComponent } from '@app/shared/misc/help.component' 7import { HelpComponent } from '@app/shared/misc/help.component'
8import { ListOverflowComponent } from '@app/shared/misc/list-overflow.component'
8import { InfiniteScrollerDirective } from '@app/shared/video/infinite-scroller.directive' 9import { InfiniteScrollerDirective } from '@app/shared/video/infinite-scroller.directive'
9import { BytesPipe, KeysPipe, NgPipesModule } from 'ngx-pipes' 10import { BytesPipe, KeysPipe, NgPipesModule } from 'ngx-pipes'
10import { SharedModule as PrimeSharedModule } from 'primeng/components/common/shared' 11import { SharedModule as PrimeSharedModule } from 'primeng/api'
11import { AUTH_INTERCEPTOR_PROVIDER } from './auth' 12import { AUTH_INTERCEPTOR_PROVIDER } from './auth'
12import { ButtonComponent } from './buttons/button.component' 13import { ButtonComponent } from './buttons/button.component'
13import { DeleteButtonComponent } from './buttons/delete-button.component' 14import { DeleteButtonComponent } from './buttons/delete-button.component'
@@ -88,16 +89,18 @@ import { NumberFormatterPipe } from '@app/shared/angular/number-formatter.pipe'
88import { VideoDurationPipe } from '@app/shared/angular/video-duration-formatter.pipe' 89import { VideoDurationPipe } from '@app/shared/angular/video-duration-formatter.pipe'
89import { ObjectLengthPipe } from '@app/shared/angular/object-length.pipe' 90import { ObjectLengthPipe } from '@app/shared/angular/object-length.pipe'
90import { FromNowPipe } from '@app/shared/angular/from-now.pipe' 91import { FromNowPipe } from '@app/shared/angular/from-now.pipe'
92import { HighlightPipe } from '@app/shared/angular/highlight.pipe'
91import { PeerTubeTemplateDirective } from '@app/shared/angular/peertube-template.directive' 93import { PeerTubeTemplateDirective } from '@app/shared/angular/peertube-template.directive'
92import { VideoActionsDropdownComponent } from '@app/shared/video/video-actions-dropdown.component' 94import { VideoActionsDropdownComponent } from '@app/shared/video/video-actions-dropdown.component'
93import { VideoBlacklistComponent } from '@app/shared/video/modals/video-blacklist.component' 95import { VideoBlacklistComponent } from '@app/shared/video/modals/video-blacklist.component'
94import { VideoDownloadComponent } from '@app/shared/video/modals/video-download.component' 96import { VideoDownloadComponent } from '@app/shared/video/modals/video-download.component'
95import { VideoReportComponent } from '@app/shared/video/modals/video-report.component' 97import { VideoReportComponent } from '@app/shared/video/modals/video-report.component'
96import { ClipboardModule } from 'ngx-clipboard'
97import { FollowService } from '@app/shared/instance/follow.service' 98import { FollowService } from '@app/shared/instance/follow.service'
98import { MultiSelectModule } from 'primeng/multiselect' 99import { MultiSelectModule } from 'primeng/multiselect'
99import { FeatureBooleanComponent } from '@app/shared/instance/feature-boolean.component' 100import { FeatureBooleanComponent } from '@app/shared/instance/feature-boolean.component'
100import { InputReadonlyCopyComponent } from '@app/shared/forms/input-readonly-copy.component' 101import { InputReadonlyCopyComponent } from '@app/shared/forms/input-readonly-copy.component'
102import { RedundancyService } from '@app/shared/video/redundancy.service'
103import { ClipboardModule } from '@angular/cdk/clipboard'
101 104
102@NgModule({ 105@NgModule({
103 imports: [ 106 imports: [
@@ -147,6 +150,7 @@ import { InputReadonlyCopyComponent } from '@app/shared/forms/input-readonly-cop
147 NumberFormatterPipe, 150 NumberFormatterPipe,
148 ObjectLengthPipe, 151 ObjectLengthPipe,
149 FromNowPipe, 152 FromNowPipe,
153 HighlightPipe,
150 PeerTubeTemplateDirective, 154 PeerTubeTemplateDirective,
151 VideoDurationPipe, 155 VideoDurationPipe,
152 156
@@ -155,6 +159,7 @@ import { InputReadonlyCopyComponent } from '@app/shared/forms/input-readonly-cop
155 InfiniteScrollerDirective, 159 InfiniteScrollerDirective,
156 TextareaAutoResizeDirective, 160 TextareaAutoResizeDirective,
157 HelpComponent, 161 HelpComponent,
162 ListOverflowComponent,
158 163
159 ReactiveFileComponent, 164 ReactiveFileComponent,
160 PeertubeCheckboxComponent, 165 PeertubeCheckboxComponent,
@@ -226,6 +231,7 @@ import { InputReadonlyCopyComponent } from '@app/shared/forms/input-readonly-cop
226 InfiniteScrollerDirective, 231 InfiniteScrollerDirective,
227 TextareaAutoResizeDirective, 232 TextareaAutoResizeDirective,
228 HelpComponent, 233 HelpComponent,
234 ListOverflowComponent,
229 InputReadonlyCopyComponent, 235 InputReadonlyCopyComponent,
230 236
231 ReactiveFileComponent, 237 ReactiveFileComponent,
@@ -250,6 +256,7 @@ import { InputReadonlyCopyComponent } from '@app/shared/forms/input-readonly-cop
250 NumberFormatterPipe, 256 NumberFormatterPipe,
251 ObjectLengthPipe, 257 ObjectLengthPipe,
252 FromNowPipe, 258 FromNowPipe,
259 HighlightPipe,
253 PeerTubeTemplateDirective, 260 PeerTubeTemplateDirective,
254 VideoDurationPipe 261 VideoDurationPipe
255 ], 262 ],
@@ -300,6 +307,7 @@ import { InputReadonlyCopyComponent } from '@app/shared/forms/input-readonly-cop
300 UserNotificationService, 307 UserNotificationService,
301 308
302 FollowService, 309 FollowService,
310 RedundancyService,
303 311
304 I18n 312 I18n
305 ] 313 ]
diff --git a/client/src/app/shared/user-subscription/subscribe-button.component.html b/client/src/app/shared/user-subscription/subscribe-button.component.html
index f08c88f3c..85b3d1fdb 100644
--- a/client/src/app/shared/user-subscription/subscribe-button.component.html
+++ b/client/src/app/shared/user-subscription/subscribe-button.component.html
@@ -55,7 +55,7 @@
55 </button> 55 </button>
56 56
57 <button class="dropdown-item dropdown-item-neutral" i18n>Subscribe with a Mastodon account:</button> 57 <button class="dropdown-item dropdown-item-neutral" i18n>Subscribe with a Mastodon account:</button>
58 <my-remote-subscribe showHelp="true" [uri]="uri"></my-remote-subscribe> 58 <my-remote-subscribe [showHelp]="true" [uri]="uri"></my-remote-subscribe>
59 59
60 <div class="dropdown-divider"></div> 60 <div class="dropdown-divider"></div>
61 61
diff --git a/client/src/app/shared/video-abuse/video-abuse.service.ts b/client/src/app/shared/video-abuse/video-abuse.service.ts
index b0b59ea0c..61a328575 100644
--- a/client/src/app/shared/video-abuse/video-abuse.service.ts
+++ b/client/src/app/shared/video-abuse/video-abuse.service.ts
@@ -1,7 +1,7 @@
1import { catchError, map } from 'rxjs/operators' 1import { catchError, map } from 'rxjs/operators'
2import { HttpClient, HttpParams } from '@angular/common/http' 2import { HttpClient, HttpParams } from '@angular/common/http'
3import { Injectable } from '@angular/core' 3import { Injectable } from '@angular/core'
4import { SortMeta } from 'primeng/components/common/sortmeta' 4import { SortMeta } from 'primeng/api'
5import { Observable } from 'rxjs' 5import { Observable } from 'rxjs'
6import { ResultList, VideoAbuse, VideoAbuseUpdate } from '../../../../../shared' 6import { ResultList, VideoAbuse, VideoAbuseUpdate } from '../../../../../shared'
7import { environment } from '../../../environments/environment' 7import { environment } from '../../../environments/environment'
diff --git a/client/src/app/shared/video-blacklist/video-blacklist.service.ts b/client/src/app/shared/video-blacklist/video-blacklist.service.ts
index 491fa698b..116177c4a 100644
--- a/client/src/app/shared/video-blacklist/video-blacklist.service.ts
+++ b/client/src/app/shared/video-blacklist/video-blacklist.service.ts
@@ -1,7 +1,7 @@
1import { catchError, map, concatMap, toArray } from 'rxjs/operators' 1import { catchError, map, concatMap, toArray } from 'rxjs/operators'
2import { HttpClient, HttpParams } from '@angular/common/http' 2import { HttpClient, HttpParams } from '@angular/common/http'
3import { Injectable } from '@angular/core' 3import { Injectable } from '@angular/core'
4import { SortMeta } from 'primeng/components/common/sortmeta' 4import { SortMeta } from 'primeng/api'
5import { from as observableFrom, Observable } from 'rxjs' 5import { from as observableFrom, Observable } from 'rxjs'
6import { VideoBlacklist, VideoBlacklistType, ResultList } from '../../../../../shared' 6import { VideoBlacklist, VideoBlacklistType, ResultList } from '../../../../../shared'
7import { Video } from '../video/video.model' 7import { Video } from '../video/video.model'
diff --git a/client/src/app/shared/video-import/video-import.service.ts b/client/src/app/shared/video-import/video-import.service.ts
index 3e3fb7dfb..afd9e3fb5 100644
--- a/client/src/app/shared/video-import/video-import.service.ts
+++ b/client/src/app/shared/video-import/video-import.service.ts
@@ -9,7 +9,7 @@ import { VideoImportCreate, VideoUpdate } from '../../../../../shared/models/vid
9import { objectToFormData } from '@app/shared/misc/utils' 9import { objectToFormData } from '@app/shared/misc/utils'
10import { ResultList } from '../../../../../shared/models/result-list.model' 10import { ResultList } from '../../../../../shared/models/result-list.model'
11import { UserService } from '@app/shared/users/user.service' 11import { UserService } from '@app/shared/users/user.service'
12import { SortMeta } from 'primeng/components/common/sortmeta' 12import { SortMeta } from 'primeng/api'
13import { RestPagination } from '@app/shared/rest' 13import { RestPagination } from '@app/shared/rest'
14import { ServerService } from '@app/core' 14import { ServerService } from '@app/core'
15 15
diff --git a/client/src/app/shared/video-ownership/video-ownership.service.ts b/client/src/app/shared/video-ownership/video-ownership.service.ts
index aa9e4839a..b95d5b792 100644
--- a/client/src/app/shared/video-ownership/video-ownership.service.ts
+++ b/client/src/app/shared/video-ownership/video-ownership.service.ts
@@ -5,7 +5,7 @@ import { environment } from '../../../environments/environment'
5import { RestExtractor, RestService } from '../rest' 5import { RestExtractor, RestService } from '../rest'
6import { VideoChangeOwnershipCreate } from '../../../../../shared/models/videos' 6import { VideoChangeOwnershipCreate } from '../../../../../shared/models/videos'
7import { Observable } from 'rxjs/index' 7import { Observable } from 'rxjs/index'
8import { SortMeta } from 'primeng/components/common/sortmeta' 8import { SortMeta } from 'primeng/api'
9import { ResultList, VideoChangeOwnership } from '../../../../../shared' 9import { ResultList, VideoChangeOwnership } from '../../../../../shared'
10import { RestPagination } from '@app/shared/rest' 10import { RestPagination } from '@app/shared/rest'
11import { VideoChangeOwnershipAccept } from '../../../../../shared/models/videos/video-change-ownership-accept.model' 11import { VideoChangeOwnershipAccept } from '../../../../../shared/models/videos/video-change-ownership-accept.model'
diff --git a/client/src/app/shared/video-playlist/video-playlist-element-miniature.component.ts b/client/src/app/shared/video-playlist/video-playlist-element-miniature.component.ts
index 4864581b5..a2c0724cd 100644
--- a/client/src/app/shared/video-playlist/video-playlist-element-miniature.component.ts
+++ b/client/src/app/shared/video-playlist/video-playlist-element-miniature.component.ts
@@ -18,7 +18,7 @@ import { VideoPlaylistElement } from '@app/shared/video-playlist/video-playlist-
18 changeDetection: ChangeDetectionStrategy.OnPush 18 changeDetection: ChangeDetectionStrategy.OnPush
19}) 19})
20export class VideoPlaylistElementMiniatureComponent implements OnInit { 20export class VideoPlaylistElementMiniatureComponent implements OnInit {
21 @ViewChild('moreDropdown', { static: false }) moreDropdown: NgbDropdown 21 @ViewChild('moreDropdown') moreDropdown: NgbDropdown
22 22
23 @Input() playlist: VideoPlaylist 23 @Input() playlist: VideoPlaylist
24 @Input() playlistElement: VideoPlaylistElement 24 @Input() playlistElement: VideoPlaylistElement
diff --git a/client/src/app/shared/video/abstract-video-list.ts b/client/src/app/shared/video/abstract-video-list.ts
index c2fe6f754..2f5f82aa3 100644
--- a/client/src/app/shared/video/abstract-video-list.ts
+++ b/client/src/app/shared/video/abstract-video-list.ts
@@ -14,6 +14,7 @@ import { DisableForReuseHook } from '@app/core/routing/disable-for-reuse-hook'
14import { I18n } from '@ngx-translate/i18n-polyfill' 14import { I18n } from '@ngx-translate/i18n-polyfill'
15import { isLastMonth, isLastWeek, isToday, isYesterday } from '@shared/core-utils/miscs/date' 15import { isLastMonth, isLastWeek, isToday, isYesterday } from '@shared/core-utils/miscs/date'
16import { ServerConfig } from '@shared/models' 16import { ServerConfig } from '@shared/models'
17import { GlobalIconName } from '@app/shared/images/global-icon.component'
17 18
18enum GroupDate { 19enum GroupDate {
19 UNKNOWN = 0, 20 UNKNOWN = 0,
@@ -61,7 +62,7 @@ export abstract class AbstractVideoList implements OnInit, OnDestroy, DisableFor
61 62
62 actions: { 63 actions: {
63 routerLink: string 64 routerLink: string
64 iconName: string 65 iconName: GlobalIconName
65 label: string 66 label: string
66 }[] = [] 67 }[] = []
67 68
diff --git a/client/src/app/shared/video/infinite-scroller.directive.ts b/client/src/app/shared/video/infinite-scroller.directive.ts
index 9f613c5fa..f09c3d1fc 100644
--- a/client/src/app/shared/video/infinite-scroller.directive.ts
+++ b/client/src/app/shared/video/infinite-scroller.directive.ts
@@ -1,4 +1,4 @@
1import { distinctUntilChanged, filter, map, share, startWith, tap, throttleTime } from 'rxjs/operators' 1import { distinctUntilChanged, filter, map, share, startWith, throttleTime } from 'rxjs/operators'
2import { AfterContentChecked, Directive, ElementRef, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core' 2import { AfterContentChecked, Directive, ElementRef, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core'
3import { fromEvent, Observable, Subscription } from 'rxjs' 3import { fromEvent, Observable, Subscription } from 'rxjs'
4 4
@@ -53,7 +53,7 @@ export class InfiniteScrollerDirective implements OnInit, OnDestroy, AfterConten
53 const scrollableElement = this.onItself ? this.container : window 53 const scrollableElement = this.onItself ? this.container : window
54 const scrollObservable = fromEvent(scrollableElement, 'scroll') 54 const scrollObservable = fromEvent(scrollableElement, 'scroll')
55 .pipe( 55 .pipe(
56 startWith(null as string), // FIXME: typings 56 startWith(true),
57 throttleTime(200, undefined, throttleOptions), 57 throttleTime(200, undefined, throttleOptions),
58 map(() => this.getScrollInfo()), 58 map(() => this.getScrollInfo()),
59 distinctUntilChanged((o1, o2) => o1.current === o2.current), 59 distinctUntilChanged((o1, o2) => o1.current === o2.current),
diff --git a/client/src/app/shared/video/modals/video-blacklist.component.ts b/client/src/app/shared/video/modals/video-blacklist.component.ts
index f0c70a365..6ef9c250b 100644
--- a/client/src/app/shared/video/modals/video-blacklist.component.ts
+++ b/client/src/app/shared/video/modals/video-blacklist.component.ts
@@ -1,12 +1,12 @@
1import { Component, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core' 1import { Component, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'
2import { Notifier, RedirectService } from '@app/core' 2import { Notifier, RedirectService } from '@app/core'
3import { VideoBlacklistService } from '../../../shared/video-blacklist' 3import { VideoBlacklistService } from '../../../shared/video-blacklist'
4import { VideoDetails } from '../../../shared/video/video-details.model'
5import { I18n } from '@ngx-translate/i18n-polyfill' 4import { I18n } from '@ngx-translate/i18n-polyfill'
6import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' 5import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
7import { NgbModal } from '@ng-bootstrap/ng-bootstrap' 6import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
8import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref' 7import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
9import { FormReactive, VideoBlacklistValidatorsService } from '@app/shared/forms' 8import { FormReactive, VideoBlacklistValidatorsService } from '@app/shared/forms'
9import { Video } from '@app/shared/video/video.model'
10 10
11@Component({ 11@Component({
12 selector: 'my-video-blacklist', 12 selector: 'my-video-blacklist',
@@ -14,7 +14,7 @@ import { FormReactive, VideoBlacklistValidatorsService } from '@app/shared/forms
14 styleUrls: [ './video-blacklist.component.scss' ] 14 styleUrls: [ './video-blacklist.component.scss' ]
15}) 15})
16export class VideoBlacklistComponent extends FormReactive implements OnInit { 16export class VideoBlacklistComponent extends FormReactive implements OnInit {
17 @Input() video: VideoDetails = null 17 @Input() video: Video = null
18 18
19 @ViewChild('modal', { static: true }) modal: NgbModal 19 @ViewChild('modal', { static: true }) modal: NgbModal
20 20
@@ -46,7 +46,7 @@ export class VideoBlacklistComponent extends FormReactive implements OnInit {
46 } 46 }
47 47
48 show () { 48 show () {
49 this.openedModal = this.modalService.open(this.modal, { keyboard: false }) 49 this.openedModal = this.modalService.open(this.modal, { centered: true, keyboard: false })
50 } 50 }
51 51
52 hide () { 52 hide () {
diff --git a/client/src/app/shared/video/modals/video-download.component.html b/client/src/app/shared/video/modals/video-download.component.html
index 8cca985b1..976da03f3 100644
--- a/client/src/app/shared/video/modals/video-download.component.html
+++ b/client/src/app/shared/video/modals/video-download.component.html
@@ -23,13 +23,15 @@
23 <select *ngIf="type === 'video'" [(ngModel)]="resolutionId"> 23 <select *ngIf="type === 'video'" [(ngModel)]="resolutionId">
24 <option *ngFor="let file of getVideoFiles()" [value]="file.resolution.id">{{ file.resolution.label }}</option> 24 <option *ngFor="let file of getVideoFiles()" [value]="file.resolution.id">{{ file.resolution.label }}</option>
25 </select> 25 </select>
26
26 <select *ngIf="type === 'subtitles'" [(ngModel)]="subtitleLanguageId"> 27 <select *ngIf="type === 'subtitles'" [(ngModel)]="subtitleLanguageId">
27 <option *ngFor="let caption of videoCaptions" [value]="caption.language.id">{{ caption.language.label }}</option> 28 <option *ngFor="let caption of videoCaptions" [value]="caption.language.id">{{ caption.language.label }}</option>
28 </select> 29 </select>
29 </div> 30 </div>
31
30 <input #urlInput (click)="urlInput.select()" type="text" class="form-control input-sm readonly" readonly [value]="getLink()" /> 32 <input #urlInput (click)="urlInput.select()" type="text" class="form-control input-sm readonly" readonly [value]="getLink()" />
31 <div class="input-group-append"> 33 <div class="input-group-append">
32 <button [ngxClipboard]="urlInput" (click)="activateCopiedMessage()" type="button" class="btn btn-outline-secondary"> 34 <button [cdkCopyToClipboard]="urlInput.value" (click)="activateCopiedMessage()" type="button" class="btn btn-outline-secondary">
33 <span class="glyphicon glyphicon-copy"></span> 35 <span class="glyphicon glyphicon-copy"></span>
34 </button> 36 </button>
35 </div> 37 </div>
diff --git a/client/src/app/shared/video/modals/video-download.component.ts b/client/src/app/shared/video/modals/video-download.component.ts
index c1ceca263..6909c4279 100644
--- a/client/src/app/shared/video/modals/video-download.component.ts
+++ b/client/src/app/shared/video/modals/video-download.component.ts
@@ -48,7 +48,7 @@ export class VideoDownloadComponent {
48 this.video = video 48 this.video = video
49 this.videoCaptions = videoCaptions && videoCaptions.length ? videoCaptions : undefined 49 this.videoCaptions = videoCaptions && videoCaptions.length ? videoCaptions : undefined
50 50
51 this.activeModal = this.modalService.open(this.modal) 51 this.activeModal = this.modalService.open(this.modal, { centered: true })
52 52
53 this.resolutionId = this.getVideoFiles()[0].resolution.id 53 this.resolutionId = this.getVideoFiles()[0].resolution.id
54 if (this.videoCaptions) this.subtitleLanguageId = this.videoCaptions[0].language.id 54 if (this.videoCaptions) this.subtitleLanguageId = this.videoCaptions[0].language.id
diff --git a/client/src/app/shared/video/modals/video-report.component.ts b/client/src/app/shared/video/modals/video-report.component.ts
index 1d368ff17..988fa03d4 100644
--- a/client/src/app/shared/video/modals/video-report.component.ts
+++ b/client/src/app/shared/video/modals/video-report.component.ts
@@ -1,13 +1,13 @@
1import { Component, Input, OnInit, ViewChild } from '@angular/core' 1import { Component, Input, OnInit, ViewChild } from '@angular/core'
2import { Notifier } from '@app/core' 2import { Notifier } from '@app/core'
3import { FormReactive } from '../../../shared/forms' 3import { FormReactive } from '../../../shared/forms'
4import { VideoDetails } from '../../../shared/video/video-details.model'
5import { I18n } from '@ngx-translate/i18n-polyfill' 4import { I18n } from '@ngx-translate/i18n-polyfill'
6import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' 5import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
7import { VideoAbuseValidatorsService } from '@app/shared/forms/form-validators/video-abuse-validators.service' 6import { VideoAbuseValidatorsService } from '@app/shared/forms/form-validators/video-abuse-validators.service'
8import { NgbModal } from '@ng-bootstrap/ng-bootstrap' 7import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
9import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref' 8import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
10import { VideoAbuseService } from '@app/shared/video-abuse' 9import { VideoAbuseService } from '@app/shared/video-abuse'
10import { Video } from '@app/shared/video/video.model'
11 11
12@Component({ 12@Component({
13 selector: 'my-video-report', 13 selector: 'my-video-report',
@@ -15,7 +15,7 @@ import { VideoAbuseService } from '@app/shared/video-abuse'
15 styleUrls: [ './video-report.component.scss' ] 15 styleUrls: [ './video-report.component.scss' ]
16}) 16})
17export class VideoReportComponent extends FormReactive implements OnInit { 17export class VideoReportComponent extends FormReactive implements OnInit {
18 @Input() video: VideoDetails = null 18 @Input() video: Video = null
19 19
20 @ViewChild('modal', { static: true }) modal: NgbModal 20 @ViewChild('modal', { static: true }) modal: NgbModal
21 21
@@ -53,7 +53,7 @@ export class VideoReportComponent extends FormReactive implements OnInit {
53 } 53 }
54 54
55 show () { 55 show () {
56 this.openedModal = this.modalService.open(this.modal, { keyboard: false }) 56 this.openedModal = this.modalService.open(this.modal, { centered: true, keyboard: false })
57 } 57 }
58 58
59 hide () { 59 hide () {
diff --git a/client/src/app/shared/video/redundancy.service.ts b/client/src/app/shared/video/redundancy.service.ts
new file mode 100644
index 000000000..fb918d73b
--- /dev/null
+++ b/client/src/app/shared/video/redundancy.service.ts
@@ -0,0 +1,73 @@
1import { catchError, map, toArray } from 'rxjs/operators'
2import { HttpClient, HttpParams } from '@angular/common/http'
3import { Injectable } from '@angular/core'
4import { RestExtractor, RestPagination, RestService } from '@app/shared/rest'
5import { SortMeta } from 'primeng/api'
6import { ResultList, Video, VideoRedundanciesTarget, VideoRedundancy } from '@shared/models'
7import { concat, Observable } from 'rxjs'
8import { environment } from '../../../environments/environment'
9
10@Injectable()
11export class RedundancyService {
12 static BASE_REDUNDANCY_URL = environment.apiUrl + '/api/v1/server/redundancy'
13
14 constructor (
15 private authHttp: HttpClient,
16 private restService: RestService,
17 private restExtractor: RestExtractor
18 ) { }
19
20 updateRedundancy (host: string, redundancyAllowed: boolean) {
21 const url = RedundancyService.BASE_REDUNDANCY_URL + '/' + host
22
23 const body = { redundancyAllowed }
24
25 return this.authHttp.put(url, body)
26 .pipe(
27 map(this.restExtractor.extractDataBool),
28 catchError(err => this.restExtractor.handleError(err))
29 )
30 }
31
32 listVideoRedundancies (options: {
33 pagination: RestPagination,
34 sort: SortMeta,
35 target?: VideoRedundanciesTarget
36 }): Observable<ResultList<VideoRedundancy>> {
37 const { pagination, sort, target } = options
38
39 let params = new HttpParams()
40 params = this.restService.addRestGetParams(params, pagination, sort)
41
42 if (target) params = params.append('target', target)
43
44 return this.authHttp.get<ResultList<VideoRedundancy>>(RedundancyService.BASE_REDUNDANCY_URL + '/videos', { params })
45 .pipe(
46 catchError(res => this.restExtractor.handleError(res))
47 )
48 }
49
50 addVideoRedundancy (video: Video) {
51 return this.authHttp.post(RedundancyService.BASE_REDUNDANCY_URL + '/videos', { videoId: video.id })
52 .pipe(
53 catchError(res => this.restExtractor.handleError(res))
54 )
55 }
56
57 removeVideoRedundancies (redundancy: VideoRedundancy) {
58 const observables = redundancy.redundancies.streamingPlaylists.map(r => r.id)
59 .concat(redundancy.redundancies.files.map(r => r.id))
60 .map(id => this.removeRedundancy(id))
61
62 return concat(...observables)
63 .pipe(toArray())
64 }
65
66 private removeRedundancy (redundancyId: number) {
67 return this.authHttp.delete(RedundancyService.BASE_REDUNDANCY_URL + '/videos/' + redundancyId)
68 .pipe(
69 map(this.restExtractor.extractDataBool),
70 catchError(res => this.restExtractor.handleError(res))
71 )
72 }
73}
diff --git a/client/src/app/shared/video/video-actions-dropdown.component.ts b/client/src/app/shared/video/video-actions-dropdown.component.ts
index afdeab18d..69f45346e 100644
--- a/client/src/app/shared/video/video-actions-dropdown.component.ts
+++ b/client/src/app/shared/video/video-actions-dropdown.component.ts
@@ -14,6 +14,7 @@ import { VideoBlacklistComponent } from '@app/shared/video/modals/video-blacklis
14import { VideoBlacklistService } from '@app/shared/video-blacklist' 14import { VideoBlacklistService } from '@app/shared/video-blacklist'
15import { ScreenService } from '@app/shared/misc/screen.service' 15import { ScreenService } from '@app/shared/misc/screen.service'
16import { VideoCaption } from '@shared/models' 16import { VideoCaption } from '@shared/models'
17import { RedundancyService } from '@app/shared/video/redundancy.service'
17 18
18export type VideoActionsDisplayType = { 19export type VideoActionsDisplayType = {
19 playlist?: boolean 20 playlist?: boolean
@@ -22,6 +23,7 @@ export type VideoActionsDisplayType = {
22 blacklist?: boolean 23 blacklist?: boolean
23 delete?: boolean 24 delete?: boolean
24 report?: boolean 25 report?: boolean
26 duplicate?: boolean
25} 27}
26 28
27@Component({ 29@Component({
@@ -30,12 +32,12 @@ export type VideoActionsDisplayType = {
30 styleUrls: [ './video-actions-dropdown.component.scss' ] 32 styleUrls: [ './video-actions-dropdown.component.scss' ]
31}) 33})
32export class VideoActionsDropdownComponent implements OnChanges { 34export class VideoActionsDropdownComponent implements OnChanges {
33 @ViewChild('playlistDropdown', { static: false }) playlistDropdown: NgbDropdown 35 @ViewChild('playlistDropdown') playlistDropdown: NgbDropdown
34 @ViewChild('playlistAdd', { static: false }) playlistAdd: VideoAddToPlaylistComponent 36 @ViewChild('playlistAdd') playlistAdd: VideoAddToPlaylistComponent
35 37
36 @ViewChild('videoDownloadModal', { static: false }) videoDownloadModal: VideoDownloadComponent 38 @ViewChild('videoDownloadModal') videoDownloadModal: VideoDownloadComponent
37 @ViewChild('videoReportModal', { static: false }) videoReportModal: VideoReportComponent 39 @ViewChild('videoReportModal') videoReportModal: VideoReportComponent
38 @ViewChild('videoBlacklistModal', { static: false }) videoBlacklistModal: VideoBlacklistComponent 40 @ViewChild('videoBlacklistModal') videoBlacklistModal: VideoBlacklistComponent
39 41
40 @Input() video: Video | VideoDetails 42 @Input() video: Video | VideoDetails
41 @Input() videoCaptions: VideoCaption[] = [] 43 @Input() videoCaptions: VideoCaption[] = []
@@ -46,7 +48,8 @@ export class VideoActionsDropdownComponent implements OnChanges {
46 update: true, 48 update: true,
47 blacklist: true, 49 blacklist: true,
48 delete: true, 50 delete: true,
49 report: true 51 report: true,
52 duplicate: true
50 } 53 }
51 @Input() placement = 'left' 54 @Input() placement = 'left'
52 55
@@ -74,6 +77,7 @@ export class VideoActionsDropdownComponent implements OnChanges {
74 private screenService: ScreenService, 77 private screenService: ScreenService,
75 private videoService: VideoService, 78 private videoService: VideoService,
76 private blocklistService: BlocklistService, 79 private blocklistService: BlocklistService,
80 private redundancyService: RedundancyService,
77 private i18n: I18n 81 private i18n: I18n
78 ) { } 82 ) { }
79 83
@@ -144,6 +148,10 @@ export class VideoActionsDropdownComponent implements OnChanges {
144 return this.video && this.video instanceof VideoDetails && this.video.downloadEnabled 148 return this.video && this.video instanceof VideoDetails && this.video.downloadEnabled
145 } 149 }
146 150
151 canVideoBeDuplicated () {
152 return this.video.canBeDuplicatedBy(this.user)
153 }
154
147 /* Action handlers */ 155 /* Action handlers */
148 156
149 async unblacklistVideo () { 157 async unblacklistVideo () {
@@ -186,6 +194,18 @@ export class VideoActionsDropdownComponent implements OnChanges {
186 ) 194 )
187 } 195 }
188 196
197 duplicateVideo () {
198 this.redundancyService.addVideoRedundancy(this.video)
199 .subscribe(
200 () => {
201 const message = this.i18n('This video will be duplicated by your instance.')
202 this.notifier.success(message)
203 },
204
205 err => this.notifier.error(err.message)
206 )
207 }
208
189 onVideoBlacklisted () { 209 onVideoBlacklisted () {
190 this.videoBlacklisted.emit() 210 this.videoBlacklisted.emit()
191 } 211 }
@@ -234,6 +254,12 @@ export class VideoActionsDropdownComponent implements OnChanges {
234 isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.blacklist && this.isVideoUnblacklistable() 254 isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.blacklist && this.isVideoUnblacklistable()
235 }, 255 },
236 { 256 {
257 label: this.i18n('Duplicate (redundancy)'),
258 handler: () => this.duplicateVideo(),
259 isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.duplicate && this.canVideoBeDuplicated(),
260 iconName: 'cloud-download'
261 },
262 {
237 label: this.i18n('Delete'), 263 label: this.i18n('Delete'),
238 handler: () => this.removeVideo(), 264 handler: () => this.removeVideo(),
239 isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.delete && this.isVideoRemovable(), 265 isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.delete && this.isVideoRemovable(),
diff --git a/client/src/app/shared/video/video-miniature.component.html b/client/src/app/shared/video/video-miniature.component.html
index 46c49c15b..819be6d48 100644
--- a/client/src/app/shared/video/video-miniature.component.html
+++ b/client/src/app/shared/video/video-miniature.component.html
@@ -50,7 +50,7 @@
50 </div> 50 </div>
51 51
52 <div class="video-actions"> 52 <div class="video-actions">
53 <!-- FIXME: remove bottom placement when overflow is fixed in bootstrap dropdown --> 53 <!-- FIXME: remove bottom placement when overflow is fixed in bootstrap dropdown: https://github.com/ng-bootstrap/ng-bootstrap/issues/3495 -->
54 <my-video-actions-dropdown 54 <my-video-actions-dropdown
55 *ngIf="showActions" [video]="video" [displayOptions]="videoActionsDisplayOptions" placement="bottom-left bottom-right left" 55 *ngIf="showActions" [video]="video" [displayOptions]="videoActionsDisplayOptions" placement="bottom-left bottom-right left"
56 (videoRemoved)="onVideoRemoved()" (videoBlacklisted)="onVideoBlacklisted()" (videoUnblacklisted)="onVideoUnblacklisted()" 56 (videoRemoved)="onVideoRemoved()" (videoBlacklisted)="onVideoBlacklisted()" (videoUnblacklisted)="onVideoUnblacklisted()"
diff --git a/client/src/app/shared/video/video-miniature.component.ts b/client/src/app/shared/video/video-miniature.component.ts
index 598a7a983..9d22e13fd 100644
--- a/client/src/app/shared/video/video-miniature.component.ts
+++ b/client/src/app/shared/video/video-miniature.component.ts
@@ -64,7 +64,8 @@ export class VideoMiniatureComponent implements OnInit {
64 update: true, 64 update: true,
65 blacklist: true, 65 blacklist: true,
66 delete: true, 66 delete: true,
67 report: true 67 report: true,
68 duplicate: false
68 } 69 }
69 showActions = false 70 showActions = false
70 serverConfig: ServerConfig 71 serverConfig: ServerConfig
@@ -199,7 +200,7 @@ export class VideoMiniatureComponent implements OnInit {
199 } 200 }
200 201
201 isWatchLaterPlaylistDisplayed () { 202 isWatchLaterPlaylistDisplayed () {
202 return this.inWatchLaterPlaylist !== undefined 203 return this.isUserLoggedIn() && this.inWatchLaterPlaylist !== undefined
203 } 204 }
204 205
205 private setUpBy () { 206 private setUpBy () {
diff --git a/client/src/app/shared/video/video-thumbnail.component.ts b/client/src/app/shared/video/video-thumbnail.component.ts
index 2420ec715..111b4c8bb 100644
--- a/client/src/app/shared/video/video-thumbnail.component.ts
+++ b/client/src/app/shared/video/video-thumbnail.component.ts
@@ -12,7 +12,7 @@ export class VideoThumbnailComponent {
12 @Input() video: Video 12 @Input() video: Video
13 @Input() nsfw = false 13 @Input() nsfw = false
14 @Input() routerLink: any[] 14 @Input() routerLink: any[]
15 @Input() queryParams: any[] 15 @Input() queryParams: { [ p: string ]: any }
16 16
17 @Input() displayWatchLaterPlaylist: boolean 17 @Input() displayWatchLaterPlaylist: boolean
18 @Input() inWatchLaterPlaylist: boolean 18 @Input() inWatchLaterPlaylist: boolean
diff --git a/client/src/app/shared/video/video.model.ts b/client/src/app/shared/video/video.model.ts
index fb98d5382..546518cca 100644
--- a/client/src/app/shared/video/video.model.ts
+++ b/client/src/app/shared/video/video.model.ts
@@ -42,6 +42,9 @@ export class Video implements VideoServerModel {
42 dislikes: number 42 dislikes: number
43 nsfw: boolean 43 nsfw: boolean
44 44
45 originInstanceUrl: string
46 originInstanceHost: string
47
45 waitTranscoding?: boolean 48 waitTranscoding?: boolean
46 state?: VideoConstant<VideoState> 49 state?: VideoConstant<VideoState>
47 scheduledUpdate?: VideoScheduleUpdate 50 scheduledUpdate?: VideoScheduleUpdate
@@ -86,22 +89,31 @@ export class Video implements VideoServerModel {
86 this.waitTranscoding = hash.waitTranscoding 89 this.waitTranscoding = hash.waitTranscoding
87 this.state = hash.state 90 this.state = hash.state
88 this.description = hash.description 91 this.description = hash.description
92
89 this.duration = hash.duration 93 this.duration = hash.duration
90 this.durationLabel = durationToString(hash.duration) 94 this.durationLabel = durationToString(hash.duration)
95
91 this.id = hash.id 96 this.id = hash.id
92 this.uuid = hash.uuid 97 this.uuid = hash.uuid
98
93 this.isLocal = hash.isLocal 99 this.isLocal = hash.isLocal
94 this.name = hash.name 100 this.name = hash.name
101
95 this.thumbnailPath = hash.thumbnailPath 102 this.thumbnailPath = hash.thumbnailPath
96 this.thumbnailUrl = absoluteAPIUrl + hash.thumbnailPath 103 this.thumbnailUrl = absoluteAPIUrl + hash.thumbnailPath
104
97 this.previewPath = hash.previewPath 105 this.previewPath = hash.previewPath
98 this.previewUrl = absoluteAPIUrl + hash.previewPath 106 this.previewUrl = absoluteAPIUrl + hash.previewPath
107
99 this.embedPath = hash.embedPath 108 this.embedPath = hash.embedPath
100 this.embedUrl = absoluteAPIUrl + hash.embedPath 109 this.embedUrl = absoluteAPIUrl + hash.embedPath
110
101 this.views = hash.views 111 this.views = hash.views
102 this.likes = hash.likes 112 this.likes = hash.likes
103 this.dislikes = hash.dislikes 113 this.dislikes = hash.dislikes
114
104 this.nsfw = hash.nsfw 115 this.nsfw = hash.nsfw
116
105 this.account = hash.account 117 this.account = hash.account
106 this.channel = hash.channel 118 this.channel = hash.channel
107 119
@@ -124,6 +136,9 @@ export class Video implements VideoServerModel {
124 this.blacklistedReason = hash.blacklistedReason 136 this.blacklistedReason = hash.blacklistedReason
125 137
126 this.userHistory = hash.userHistory 138 this.userHistory = hash.userHistory
139
140 this.originInstanceHost = this.account.host
141 this.originInstanceUrl = 'https://' + this.originInstanceHost
127 } 142 }
128 143
129 isVideoNSFWForUser (user: User, serverConfig: ServerConfig) { 144 isVideoNSFWForUser (user: User, serverConfig: ServerConfig) {
@@ -152,4 +167,8 @@ export class Video implements VideoServerModel {
152 isUpdatableBy (user: AuthUser) { 167 isUpdatableBy (user: AuthUser) {
153 return user && this.isLocal === true && (this.account.name === user.username || user.hasRight(UserRight.UPDATE_ANY_VIDEO)) 168 return user && this.isLocal === true && (this.account.name === user.username || user.hasRight(UserRight.UPDATE_ANY_VIDEO))
154 } 169 }
170
171 canBeDuplicatedBy (user: AuthUser) {
172 return user && this.isLocal === false && user.hasRight(UserRight.MANAGE_VIDEOS_REDUNDANCIES)
173 }
155} 174}
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 1a9bf5171..9856aac9e 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
@@ -56,7 +56,7 @@ export class VideoCaptionAddModalComponent extends FormReactive implements OnIni
56 show () { 56 show () {
57 this.closingModal = false 57 this.closingModal = false
58 58
59 this.openedModal = this.modalService.open(this.modal, { keyboard: false }) 59 this.openedModal = this.modalService.open(this.modal, { centered: true, keyboard: false })
60 } 60 }
61 61
62 hide () { 62 hide () {
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 e40649d95..6d72e5765 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
@@ -29,7 +29,7 @@
29 <tag-input 29 <tag-input
30 [validators]="tagValidators" [errorMessages]="tagValidatorsMessages" 30 [validators]="tagValidators" [errorMessages]="tagValidatorsMessages"
31 i18n-placeholder placeholder="+ Tag" i18n-secondaryPlaceholder secondaryPlaceholder="Enter a new tag" 31 i18n-placeholder placeholder="+ Tag" i18n-secondaryPlaceholder secondaryPlaceholder="Enter a new tag"
32 formControlName="tags" maxItems="5" modelAsStrings="true" 32 formControlName="tags" [maxItems]="5" [modelAsStrings]="true"
33 ></tag-input> 33 ></tag-input>
34 </div> 34 </div>
35 35
@@ -44,7 +44,7 @@
44 </ng-template> 44 </ng-template>
45 </my-help> 45 </my-help>
46 46
47 <my-markdown-textarea truncate="250" formControlName="description" markdownVideo="true"></my-markdown-textarea> 47 <my-markdown-textarea [truncate]="250" formControlName="description" [markdownVideo]="true"></my-markdown-textarea>
48 48
49 <div *ngIf="formErrors.description" class="form-error"> 49 <div *ngIf="formErrors.description" class="form-error">
50 {{ formErrors.description }} 50 {{ formErrors.description }}
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 39b6daa93..1357d607c 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
@@ -2,7 +2,7 @@ import { NgModule } from '@angular/core'
2import { TagInputModule } from 'ngx-chips' 2import { TagInputModule } from 'ngx-chips'
3import { SharedModule } from '../../../shared/' 3import { SharedModule } from '../../../shared/'
4import { VideoEditComponent } from './video-edit.component' 4import { VideoEditComponent } from './video-edit.component'
5import { CalendarModule } from 'primeng/components/calendar/calendar' 5import { CalendarModule } from 'primeng/calendar'
6import { VideoCaptionAddModalComponent } from './video-caption-add-modal.component' 6import { VideoCaptionAddModalComponent } from './video-caption-add-modal.component'
7 7
8@NgModule({ 8@NgModule({
diff --git a/client/src/app/videos/+video-edit/video-add-components/video-import-torrent.component.ts b/client/src/app/videos/+video-edit/video-add-components/video-import-torrent.component.ts
index 74e1e755b..e47624dd6 100644
--- a/client/src/app/videos/+video-edit/video-add-components/video-import-torrent.component.ts
+++ b/client/src/app/videos/+video-edit/video-add-components/video-import-torrent.component.ts
@@ -25,7 +25,7 @@ import { scrollToTop } from '@app/shared/misc/utils'
25export class VideoImportTorrentComponent extends VideoSend implements OnInit, CanComponentDeactivate { 25export class VideoImportTorrentComponent extends VideoSend implements OnInit, CanComponentDeactivate {
26 @Output() firstStepDone = new EventEmitter<string>() 26 @Output() firstStepDone = new EventEmitter<string>()
27 @Output() firstStepError = new EventEmitter<void>() 27 @Output() firstStepError = new EventEmitter<void>()
28 @ViewChild('torrentfileInput', { static: false }) torrentfileInput: ElementRef<HTMLInputElement> 28 @ViewChild('torrentfileInput') torrentfileInput: ElementRef<HTMLInputElement>
29 29
30 magnetUri = '' 30 magnetUri = ''
31 31
diff --git a/client/src/app/videos/+video-edit/video-add-components/video-upload.component.ts b/client/src/app/videos/+video-edit/video-add-components/video-upload.component.ts
index aa87f9581..efdd284e8 100644
--- a/client/src/app/videos/+video-edit/video-add-components/video-upload.component.ts
+++ b/client/src/app/videos/+video-edit/video-add-components/video-upload.component.ts
@@ -27,7 +27,7 @@ import { scrollToTop } from '@app/shared/misc/utils'
27export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy, CanComponentDeactivate { 27export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy, CanComponentDeactivate {
28 @Output() firstStepDone = new EventEmitter<string>() 28 @Output() firstStepDone = new EventEmitter<string>()
29 @Output() firstStepError = new EventEmitter<void>() 29 @Output() firstStepError = new EventEmitter<void>()
30 @ViewChild('videofileInput', { static: false }) videofileInput: ElementRef<HTMLInputElement> 30 @ViewChild('videofileInput') videofileInput: ElementRef<HTMLInputElement>
31 31
32 // So that it can be accessed in the template 32 // So that it can be accessed in the template
33 readonly SPECIAL_SCHEDULED_PRIVACY = VideoEdit.SPECIAL_SCHEDULED_PRIVACY 33 readonly SPECIAL_SCHEDULED_PRIVACY = VideoEdit.SPECIAL_SCHEDULED_PRIVACY
diff --git a/client/src/app/videos/+video-edit/video-add.component.ts b/client/src/app/videos/+video-edit/video-add.component.ts
index 401d8a08f..30ab08ea0 100644
--- a/client/src/app/videos/+video-edit/video-add.component.ts
+++ b/client/src/app/videos/+video-edit/video-add.component.ts
@@ -12,9 +12,9 @@ import { ServerConfig } from '@shared/models'
12 styleUrls: [ './video-add.component.scss' ] 12 styleUrls: [ './video-add.component.scss' ]
13}) 13})
14export class VideoAddComponent implements OnInit, CanComponentDeactivate { 14export class VideoAddComponent implements OnInit, CanComponentDeactivate {
15 @ViewChild('videoUpload', { static: false }) videoUpload: VideoUploadComponent 15 @ViewChild('videoUpload') videoUpload: VideoUploadComponent
16 @ViewChild('videoImportUrl', { static: false }) videoImportUrl: VideoImportUrlComponent 16 @ViewChild('videoImportUrl') videoImportUrl: VideoImportUrlComponent
17 @ViewChild('videoImportTorrent', { static: false }) videoImportTorrent: VideoImportTorrentComponent 17 @ViewChild('videoImportTorrent') videoImportTorrent: VideoImportTorrentComponent
18 18
19 secondStepType: 'upload' | 'import-url' | 'import-torrent' 19 secondStepType: 'upload' | 'import-url' | 'import-torrent'
20 videoName: string 20 videoName: string
diff --git a/client/src/app/videos/+video-watch/comment/video-comment-add.component.ts b/client/src/app/videos/+video-watch/comment/video-comment-add.component.ts
index 1be96ad9e..a8c432653 100644
--- a/client/src/app/videos/+video-watch/comment/video-comment-add.component.ts
+++ b/client/src/app/videos/+video-watch/comment/video-comment-add.component.ts
@@ -25,7 +25,7 @@ export class VideoCommentAddComponent extends FormReactive implements OnInit {
25 @Input() parentComments: VideoComment[] 25 @Input() parentComments: VideoComment[]
26 @Input() focusOnInit = false 26 @Input() focusOnInit = false
27 27
28 @Output() commentCreated = new EventEmitter<VideoCommentCreate>() 28 @Output() commentCreated = new EventEmitter<VideoComment>()
29 @Output() cancel = new EventEmitter() 29 @Output() cancel = new EventEmitter()
30 30
31 @ViewChild('visitorModal', { static: true }) visitorModal: NgbModal 31 @ViewChild('visitorModal', { static: true }) visitorModal: NgbModal
@@ -96,7 +96,7 @@ export class VideoCommentAddComponent extends FormReactive implements OnInit {
96 this.addingComment = true 96 this.addingComment = true
97 97
98 const commentCreate: VideoCommentCreate = this.form.value 98 const commentCreate: VideoCommentCreate = this.form.value
99 let obs: Observable<any> 99 let obs: Observable<VideoComment>
100 100
101 if (this.parentComment) { 101 if (this.parentComment) {
102 obs = this.addCommentReply(commentCreate) 102 obs = this.addCommentReply(commentCreate)
diff --git a/client/src/app/videos/+video-watch/comment/video-comment-thread-tree.model.ts b/client/src/app/videos/+video-watch/comment/video-comment-thread-tree.model.ts
new file mode 100644
index 000000000..1566d7369
--- /dev/null
+++ b/client/src/app/videos/+video-watch/comment/video-comment-thread-tree.model.ts
@@ -0,0 +1,7 @@
1import { VideoCommentThreadTree as VideoCommentThreadTreeServerModel } from '../../../../../../shared/models/videos/video-comment.model'
2import { VideoComment } from '@app/videos/+video-watch/comment/video-comment.model'
3
4export class VideoCommentThreadTree implements VideoCommentThreadTreeServerModel {
5 comment: VideoComment
6 children: VideoCommentThreadTree[]
7}
diff --git a/client/src/app/videos/+video-watch/comment/video-comment.component.ts b/client/src/app/videos/+video-watch/comment/video-comment.component.ts
index 61f9335d1..f7eca45fd 100644
--- a/client/src/app/videos/+video-watch/comment/video-comment.component.ts
+++ b/client/src/app/videos/+video-watch/comment/video-comment.component.ts
@@ -1,6 +1,5 @@
1import { Component, EventEmitter, Input, OnChanges, OnInit, Output } from '@angular/core' 1import { Component, EventEmitter, Input, OnChanges, OnInit, Output } from '@angular/core'
2import { User, UserRight } from '../../../../../../shared/models/users' 2import { User, UserRight } from '../../../../../../shared/models/users'
3import { VideoCommentThreadTree } from '../../../../../../shared/models/videos/video-comment.model'
4import { AuthService } from '@app/core/auth' 3import { AuthService } from '@app/core/auth'
5import { AccountService } from '@app/shared/account/account.service' 4import { AccountService } from '@app/shared/account/account.service'
6import { Video } from '@app/shared/video/video.model' 5import { Video } from '@app/shared/video/video.model'
@@ -10,6 +9,7 @@ import { Account } from '@app/shared/account/account.model'
10import { Notifier } from '@app/core' 9import { Notifier } from '@app/core'
11import { UserService } from '@app/shared' 10import { UserService } from '@app/shared'
12import { Actor } from '@app/shared/actor/actor.model' 11import { Actor } from '@app/shared/actor/actor.model'
12import { VideoCommentThreadTree } from '@app/videos/+video-watch/comment/video-comment-thread-tree.model'
13 13
14@Component({ 14@Component({
15 selector: 'my-video-comment', 15 selector: 'my-video-comment',
diff --git a/client/src/app/videos/+video-watch/comment/video-comment.model.ts b/client/src/app/videos/+video-watch/comment/video-comment.model.ts
index aaeb0ea9c..171fc4acc 100644
--- a/client/src/app/videos/+video-watch/comment/video-comment.model.ts
+++ b/client/src/app/videos/+video-watch/comment/video-comment.model.ts
@@ -1,5 +1,5 @@
1import { Account as AccountInterface } from '../../../../../../shared/models/actors' 1import { Account as AccountInterface } from '../../../../../../shared/models/actors'
2import { VideoComment as VideoCommentServerModel } from '../../../../../../shared/models/videos/video-comment.model' 2import { VideoComment as VideoCommentServerModel, VideoCommentCreate } from '../../../../../../shared/models/videos/video-comment.model'
3import { Actor } from '@app/shared/actor/actor.model' 3import { Actor } from '@app/shared/actor/actor.model'
4import { getAbsoluteAPIUrl } from '@app/shared/misc/utils' 4import { getAbsoluteAPIUrl } from '@app/shared/misc/utils'
5 5
diff --git a/client/src/app/videos/+video-watch/comment/video-comment.service.ts b/client/src/app/videos/+video-watch/comment/video-comment.service.ts
index a81e5236a..0b0715390 100644
--- a/client/src/app/videos/+video-watch/comment/video-comment.service.ts
+++ b/client/src/app/videos/+video-watch/comment/video-comment.service.ts
@@ -7,13 +7,14 @@ import { FeedFormat, ResultList } from '../../../../../../shared/models'
7import { 7import {
8 VideoComment as VideoCommentServerModel, 8 VideoComment as VideoCommentServerModel,
9 VideoCommentCreate, 9 VideoCommentCreate,
10 VideoCommentThreadTree 10 VideoCommentThreadTree as VideoCommentThreadTreeServerModel
11} from '../../../../../../shared/models/videos/video-comment.model' 11} from '../../../../../../shared/models/videos/video-comment.model'
12import { environment } from '../../../../environments/environment' 12import { environment } from '../../../../environments/environment'
13import { RestExtractor, RestService } from '../../../shared/rest' 13import { RestExtractor, RestService } from '../../../shared/rest'
14import { ComponentPaginationLight } from '../../../shared/rest/component-pagination.model' 14import { ComponentPaginationLight } from '../../../shared/rest/component-pagination.model'
15import { CommentSortField } from '../../../shared/video/sort-field.type' 15import { CommentSortField } from '../../../shared/video/sort-field.type'
16import { VideoComment } from './video-comment.model' 16import { VideoComment } from './video-comment.model'
17import { VideoCommentThreadTree } from '@app/videos/+video-watch/comment/video-comment-thread-tree.model'
17 18
18@Injectable() 19@Injectable()
19export class VideoCommentService { 20export class VideoCommentService {
@@ -76,9 +77,9 @@ export class VideoCommentService {
76 const url = `${VideoCommentService.BASE_VIDEO_URL + videoId}/comment-threads/${threadId}` 77 const url = `${VideoCommentService.BASE_VIDEO_URL + videoId}/comment-threads/${threadId}`
77 78
78 return this.authHttp 79 return this.authHttp
79 .get(url) 80 .get<VideoCommentThreadTreeServerModel>(url)
80 .pipe( 81 .pipe(
81 map(tree => this.extractVideoCommentTree(tree as VideoCommentThreadTree)), 82 map(tree => this.extractVideoCommentTree(tree)),
82 catchError(err => this.restExtractor.handleError(err)) 83 catchError(err => this.restExtractor.handleError(err))
83 ) 84 )
84 } 85 }
@@ -138,12 +139,12 @@ export class VideoCommentService {
138 return { data: comments, total: totalComments } 139 return { data: comments, total: totalComments }
139 } 140 }
140 141
141 private extractVideoCommentTree (tree: VideoCommentThreadTree) { 142 private extractVideoCommentTree (tree: VideoCommentThreadTreeServerModel) {
142 if (!tree) return tree 143 if (!tree) return tree as VideoCommentThreadTree
143 144
144 tree.comment = new VideoComment(tree.comment) 145 tree.comment = new VideoComment(tree.comment)
145 tree.children.forEach(c => this.extractVideoCommentTree(c)) 146 tree.children.forEach(c => this.extractVideoCommentTree(c))
146 147
147 return tree 148 return tree as VideoCommentThreadTree
148 } 149 }
149} 150}
diff --git a/client/src/app/videos/+video-watch/comment/video-comments.component.ts b/client/src/app/videos/+video-watch/comment/video-comments.component.ts
index 974c61d6c..750c09c47 100644
--- a/client/src/app/videos/+video-watch/comment/video-comments.component.ts
+++ b/client/src/app/videos/+video-watch/comment/video-comments.component.ts
@@ -1,8 +1,7 @@
1import { Component, ElementRef, Input, OnChanges, OnDestroy, OnInit, SimpleChanges, ViewChild, Output, EventEmitter } from '@angular/core' 1import { Component, ElementRef, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output, SimpleChanges, ViewChild } from '@angular/core'
2import { ActivatedRoute } from '@angular/router' 2import { ActivatedRoute } from '@angular/router'
3import { ConfirmService, Notifier } from '@app/core' 3import { ConfirmService, Notifier } from '@app/core'
4import { Subject, Subscription } from 'rxjs' 4import { Subject, Subscription } from 'rxjs'
5import { VideoCommentThreadTree } from '../../../../../../shared/models/videos/video-comment.model'
6import { AuthService } from '../../../core/auth' 5import { AuthService } from '../../../core/auth'
7import { ComponentPagination, hasMoreItems } from '../../../shared/rest/component-pagination.model' 6import { ComponentPagination, hasMoreItems } from '../../../shared/rest/component-pagination.model'
8import { User } from '../../../shared/users' 7import { User } from '../../../shared/users'
@@ -13,6 +12,7 @@ import { VideoCommentService } from './video-comment.service'
13import { I18n } from '@ngx-translate/i18n-polyfill' 12import { I18n } from '@ngx-translate/i18n-polyfill'
14import { Syndication } from '@app/shared/video/syndication.model' 13import { Syndication } from '@app/shared/video/syndication.model'
15import { HooksService } from '@app/core/plugins/hooks.service' 14import { HooksService } from '@app/core/plugins/hooks.service'
15import { VideoCommentThreadTree } from '@app/videos/+video-watch/comment/video-comment-thread-tree.model'
16 16
17@Component({ 17@Component({
18 selector: 'my-video-comments', 18 selector: 'my-video-comments',
@@ -20,7 +20,7 @@ import { HooksService } from '@app/core/plugins/hooks.service'
20 styleUrls: ['./video-comments.component.scss'] 20 styleUrls: ['./video-comments.component.scss']
21}) 21})
22export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy { 22export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy {
23 @ViewChild('commentHighlightBlock', { static: false }) commentHighlightBlock: ElementRef 23 @ViewChild('commentHighlightBlock') commentHighlightBlock: ElementRef
24 @Input() video: VideoDetails 24 @Input() video: VideoDetails
25 @Input() user: User 25 @Input() user: User
26 26
diff --git a/client/src/app/videos/+video-watch/modal/video-share.component.html b/client/src/app/videos/+video-watch/modal/video-share.component.html
index 549a9f30e..593dd8529 100644
--- a/client/src/app/videos/+video-watch/modal/video-share.component.html
+++ b/client/src/app/videos/+video-watch/modal/video-share.component.html
@@ -42,7 +42,7 @@
42 <ngb-tab i18n-title title="QR-Code" id="qrcode"> 42 <ngb-tab i18n-title title="QR-Code" id="qrcode">
43 <ng-template ngbTabContent> 43 <ng-template ngbTabContent>
44 <div class="tab-content"> 44 <div class="tab-content">
45 <qrcode [qrdata]="getVideoUrl()" size="256" level="Q"></qrcode> 45 <qrcode [qrdata]="getVideoUrl()" [size]="256" level="Q"></qrcode>
46 </div> 46 </div>
47 </ng-template> 47 </ng-template>
48 </ngb-tab> 48 </ngb-tab>
diff --git a/client/src/app/videos/+video-watch/modal/video-share.component.ts b/client/src/app/videos/+video-watch/modal/video-share.component.ts
index a2b38b3a0..5109bcd11 100644
--- a/client/src/app/videos/+video-watch/modal/video-share.component.ts
+++ b/client/src/app/videos/+video-watch/modal/video-share.component.ts
@@ -1,8 +1,6 @@
1import { Component, ElementRef, Input, ViewChild } from '@angular/core' 1import { Component, ElementRef, Input, ViewChild } from '@angular/core'
2import { Notifier } from '@app/core'
3import { VideoDetails } from '../../../shared/video/video-details.model' 2import { VideoDetails } from '../../../shared/video/video-details.model'
4import { buildVideoEmbed, buildVideoLink } from '../../../../assets/player/utils' 3import { buildVideoEmbed, buildVideoLink } from '../../../../assets/player/utils'
5import { I18n } from '@ngx-translate/i18n-polyfill'
6import { NgbModal, NgbTabChangeEvent } from '@ng-bootstrap/ng-bootstrap' 4import { NgbModal, NgbTabChangeEvent } from '@ng-bootstrap/ng-bootstrap'
7import { VideoCaption } from '@shared/models' 5import { VideoCaption } from '@shared/models'
8import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model' 6import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model'
@@ -74,7 +72,7 @@ export class VideoShareComponent {
74 controls: true 72 controls: true
75 } 73 }
76 74
77 this.modalService.open(this.modal) 75 this.modalService.open(this.modal, { centered: true })
78 } 76 }
79 77
80 getVideoIframeCode () { 78 getVideoIframeCode () {
diff --git a/client/src/app/videos/+video-watch/modal/video-support.component.ts b/client/src/app/videos/+video-watch/modal/video-support.component.ts
index b56a51fbf..0058172f2 100644
--- a/client/src/app/videos/+video-watch/modal/video-support.component.ts
+++ b/client/src/app/videos/+video-watch/modal/video-support.component.ts
@@ -21,7 +21,7 @@ export class VideoSupportComponent {
21 ) { } 21 ) { }
22 22
23 show () { 23 show () {
24 this.modalService.open(this.modal) 24 this.modalService.open(this.modal, { centered: true })
25 25
26 this.markdownService.enhancedMarkdownToHTML(this.video.support) 26 this.markdownService.enhancedMarkdownToHTML(this.video.support)
27 .then(r => this.videoHTMLSupport = r) 27 .then(r => this.videoHTMLSupport = r)
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 bc3a3ffdd..a382777f5 100644
--- a/client/src/app/videos/+video-watch/video-watch.component.html
+++ b/client/src/app/videos/+video-watch/video-watch.component.html
@@ -188,6 +188,11 @@
188 <span class="video-attribute-value">{{ video.privacy.label }}</span> 188 <span class="video-attribute-value">{{ video.privacy.label }}</span>
189 </div> 189 </div>
190 190
191 <div *ngIf="video.isLocal === false" class="video-attribute">
192 <span i18n class="video-attribute-label">Origin instance</span>
193 <a class="video-attribute-value" target="_blank" rel="noopener noreferrer" [href]="video.originInstanceUrl">{{ video.originInstanceHost }}</a>
194 </div>
195
191 <div *ngIf="!!video.originallyPublishedAt" class="video-attribute"> 196 <div *ngIf="!!video.originallyPublishedAt" class="video-attribute">
192 <span i18n class="video-attribute-label">Originally published</span> 197 <span i18n class="video-attribute-label">Originally published</span>
193 <span class="video-attribute-value">{{ video.originallyPublishedAt | date: 'dd MMMM yyyy' }}</span> 198 <span class="video-attribute-value">{{ video.originallyPublishedAt | date: 'dd MMMM yyyy' }}</span>
diff --git a/client/src/app/videos/+video-watch/video-watch.component.ts b/client/src/app/videos/+video-watch/video-watch.component.ts
index e09e44809..ee3deb5e9 100644
--- a/client/src/app/videos/+video-watch/video-watch.component.ts
+++ b/client/src/app/videos/+video-watch/video-watch.component.ts
@@ -47,9 +47,9 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
47 private static LOCAL_STORAGE_PRIVACY_CONCERN_KEY = 'video-watch-privacy-concern' 47 private static LOCAL_STORAGE_PRIVACY_CONCERN_KEY = 'video-watch-privacy-concern'
48 48
49 @ViewChild('videoWatchPlaylist', { static: true }) videoWatchPlaylist: VideoWatchPlaylistComponent 49 @ViewChild('videoWatchPlaylist', { static: true }) videoWatchPlaylist: VideoWatchPlaylistComponent
50 @ViewChild('videoShareModal', { static: false }) videoShareModal: VideoShareComponent 50 @ViewChild('videoShareModal') videoShareModal: VideoShareComponent
51 @ViewChild('videoSupportModal', { static: false }) videoSupportModal: VideoSupportComponent 51 @ViewChild('videoSupportModal') videoSupportModal: VideoSupportComponent
52 @ViewChild('subscribeButton', { static: false }) subscribeButton: SubscribeButtonComponent 52 @ViewChild('subscribeButton') subscribeButton: SubscribeButtonComponent
53 53
54 player: any 54 player: any
55 playerElement: HTMLVideoElement 55 playerElement: HTMLVideoElement