aboutsummaryrefslogtreecommitdiffhomepage
path: root/client/src
diff options
context:
space:
mode:
Diffstat (limited to 'client/src')
-rw-r--r--client/src/app/+about/about-follows/about-follows.component.html22
-rw-r--r--client/src/app/+about/about-follows/about-follows.component.scss14
-rw-r--r--client/src/app/+about/about-follows/about-follows.component.ts103
-rw-r--r--client/src/app/+about/about-routing.module.ts10
-rw-r--r--client/src/app/+about/about.component.html4
-rw-r--r--client/src/app/+about/about.module.ts2
-rw-r--r--client/src/app/+accounts/account-video-channels/account-video-channels.component.html36
-rw-r--r--client/src/app/+accounts/account-video-channels/account-video-channels.component.scss36
-rw-r--r--client/src/app/+accounts/account-video-channels/account-video-channels.component.ts71
-rw-r--r--client/src/app/+accounts/account-videos/account-videos.component.ts4
-rw-r--r--client/src/app/+accounts/accounts-routing.module.ts6
-rw-r--r--client/src/app/+accounts/accounts.component.html4
-rw-r--r--client/src/app/+admin/admin.module.ts3
-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.ts36
-rw-r--r--client/src/app/+admin/follows/followers-list/followers-list.component.ts2
-rw-r--r--client/src/app/+admin/follows/following-add/following-add.component.ts2
-rw-r--r--client/src/app/+admin/follows/following-list/following-list.component.ts2
-rw-r--r--client/src/app/+admin/follows/index.ts1
-rw-r--r--client/src/app/+admin/follows/shared/index.ts1
-rw-r--r--client/src/app/+admin/users/user-edit/user-edit.ts5
-rw-r--r--client/src/app/+my-account/my-account-history/my-account-history.component.ts2
-rw-r--r--client/src/app/+my-account/my-account-settings/my-account-change-email/index.ts1
-rw-r--r--client/src/app/+my-account/my-account-settings/my-account-change-email/my-account-change-email.component.html36
-rw-r--r--client/src/app/+my-account/my-account-settings/my-account-change-email/my-account-change-email.component.scss24
-rw-r--r--client/src/app/+my-account/my-account-settings/my-account-change-email/my-account-change-email.component.ts73
-rw-r--r--client/src/app/+my-account/my-account-settings/my-account-change-password/my-account-change-password.component.html2
-rw-r--r--client/src/app/+my-account/my-account-settings/my-account-profile/my-account-profile.component.ts2
-rw-r--r--client/src/app/+my-account/my-account-settings/my-account-settings.component.html3
-rw-r--r--client/src/app/+my-account/my-account-video-channels/my-account-video-channel-edit.component.html7
-rw-r--r--client/src/app/+my-account/my-account-video-channels/my-account-video-channel-edit.ts5
-rw-r--r--client/src/app/+my-account/my-account-video-channels/my-account-video-channel-update.component.ts15
-rw-r--r--client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-create.component.ts3
-rw-r--r--client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.component.html10
-rw-r--r--client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.ts4
-rw-r--r--client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-update.component.ts47
-rw-r--r--client/src/app/+my-account/my-account-videos/my-account-videos.component.html2
-rw-r--r--client/src/app/+my-account/my-account.module.ts4
-rw-r--r--client/src/app/+signup/+register/custom-stepper.component.html25
-rw-r--r--client/src/app/+signup/+register/custom-stepper.component.scss66
-rw-r--r--client/src/app/+signup/+register/custom-stepper.component.ts19
-rw-r--r--client/src/app/+signup/+register/register-routing.module.ts (renamed from client/src/app/signup/signup-routing.module.ts)17
-rw-r--r--client/src/app/+signup/+register/register-step-channel.component.html54
-rw-r--r--client/src/app/+signup/+register/register-step-channel.component.ts56
-rw-r--r--client/src/app/+signup/+register/register-step-user.component.html73
-rw-r--r--client/src/app/+signup/+register/register-step-user.component.ts54
-rw-r--r--client/src/app/+signup/+register/register.component.html47
-rw-r--r--client/src/app/+signup/+register/register.component.scss81
-rw-r--r--client/src/app/+signup/+register/register.component.ts89
-rw-r--r--client/src/app/+signup/+register/register.module.ts33
-rw-r--r--client/src/app/+signup/+verify-account/verify-account-ask-send-email/verify-account-ask-send-email.component.html (renamed from client/src/app/+verify-account/verify-account-ask-send-email/verify-account-ask-send-email.component.html)0
-rw-r--r--client/src/app/+signup/+verify-account/verify-account-ask-send-email/verify-account-ask-send-email.component.scss (renamed from client/src/app/+verify-account/verify-account-ask-send-email/verify-account-ask-send-email.component.scss)0
-rw-r--r--client/src/app/+signup/+verify-account/verify-account-ask-send-email/verify-account-ask-send-email.component.ts (renamed from client/src/app/+verify-account/verify-account-ask-send-email/verify-account-ask-send-email.component.ts)0
-rw-r--r--client/src/app/+signup/+verify-account/verify-account-email/verify-account-email.component.html18
-rw-r--r--client/src/app/+signup/+verify-account/verify-account-email/verify-account-email.component.ts (renamed from client/src/app/+verify-account/verify-account-email/verify-account-email.component.ts)22
-rw-r--r--client/src/app/+signup/+verify-account/verify-account-routing.module.ts (renamed from client/src/app/+verify-account/verify-account-routing.module.ts)8
-rw-r--r--client/src/app/+signup/+verify-account/verify-account.module.ts25
-rw-r--r--client/src/app/+signup/shared/signup-shared.module.ts21
-rw-r--r--client/src/app/+signup/shared/signup-success.component.html16
-rw-r--r--client/src/app/+signup/shared/signup-success.component.scss76
-rw-r--r--client/src/app/+signup/shared/signup-success.component.ts10
-rw-r--r--client/src/app/+verify-account/index.ts2
-rw-r--r--client/src/app/+verify-account/verify-account-email/verify-account-email.component.html15
-rw-r--r--client/src/app/+verify-account/verify-account.module.ts27
-rw-r--r--client/src/app/+video-channels/video-channel-playlists/video-channel-playlists.component.ts5
-rw-r--r--client/src/app/+video-channels/video-channel-videos/video-channel-videos.component.ts2
-rw-r--r--client/src/app/app-routing.module.ts6
-rw-r--r--client/src/app/app.module.ts2
-rw-r--r--client/src/app/core/core.module.ts3
-rw-r--r--client/src/app/core/routing/redirect.service.ts9
-rw-r--r--client/src/app/core/routing/unlogged-guard.service.ts25
-rw-r--r--client/src/app/core/server/server.service.ts13
-rw-r--r--client/src/app/menu/menu.component.html2
-rw-r--r--client/src/app/search/search.component.html2
-rw-r--r--client/src/app/search/search.component.scss12
-rw-r--r--client/src/app/search/search.module.ts5
-rw-r--r--client/src/app/shared/actor/actor.model.ts2
-rw-r--r--client/src/app/shared/buttons/button.component.scss13
-rw-r--r--client/src/app/shared/buttons/button.component.ts2
-rw-r--r--client/src/app/shared/buttons/delete-button.component.html2
-rw-r--r--client/src/app/shared/buttons/edit-button.component.html2
-rw-r--r--client/src/app/shared/forms/form-validators/user-validators.service.ts33
-rw-r--r--client/src/app/shared/forms/peertube-checkbox.component.scss3
-rw-r--r--client/src/app/shared/forms/reactive-file.component.html7
-rw-r--r--client/src/app/shared/forms/reactive-file.component.scss10
-rw-r--r--client/src/app/shared/forms/reactive-file.component.ts2
-rw-r--r--client/src/app/shared/images/image-upload.component.html9
-rw-r--r--client/src/app/shared/images/image-upload.component.scss18
-rw-r--r--client/src/app/shared/images/preview-upload.component.html13
-rw-r--r--client/src/app/shared/images/preview-upload.component.scss27
-rw-r--r--client/src/app/shared/images/preview-upload.component.ts (renamed from client/src/app/shared/images/image-upload.component.ts)17
-rw-r--r--client/src/app/shared/instance/follow.service.ts (renamed from client/src/app/+admin/follows/shared/follow.service.ts)8
-rw-r--r--client/src/app/shared/misc/loader.component.html2
-rw-r--r--client/src/app/shared/misc/loader.component.scss14
-rw-r--r--client/src/app/shared/shared.module.ts20
-rw-r--r--client/src/app/shared/users/user-notifications.component.scss1
-rw-r--r--client/src/app/shared/users/user.model.ts1
-rw-r--r--client/src/app/shared/users/user.service.ts38
-rw-r--r--client/src/app/shared/video-channel/video-channel.service.ts17
-rw-r--r--client/src/app/shared/video/abstract-video-list.html20
-rw-r--r--client/src/app/shared/video/abstract-video-list.scss36
-rw-r--r--client/src/app/shared/video/abstract-video-list.ts80
-rw-r--r--client/src/app/shared/video/modals/video-download.component.html5
-rw-r--r--client/src/app/shared/video/modals/video-download.component.ts13
-rw-r--r--client/src/app/shared/video/video-details.model.ts3
-rw-r--r--client/src/app/shared/video/video-edit.model.ts5
-rw-r--r--client/src/app/shared/video/video.model.ts2
-rw-r--r--client/src/app/shared/video/videos-selection.component.ts2
-rw-r--r--client/src/app/signup/index.ts3
-rw-r--r--client/src/app/signup/signup.component.html72
-rw-r--r--client/src/app/signup/signup.component.scss39
-rw-r--r--client/src/app/signup/signup.component.ts78
-rw-r--r--client/src/app/signup/signup.module.ts24
-rw-r--r--client/src/app/videos/+video-edit/shared/video-edit.component.html14
-rw-r--r--client/src/app/videos/+video-edit/shared/video-edit.component.ts8
-rw-r--r--client/src/app/videos/+video-edit/video-add-components/video-import-torrent.component.html2
-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-import-url.component.html2
-rw-r--r--client/src/app/videos/+video-edit/video-add-components/video-import-url.component.ts2
-rw-r--r--client/src/app/videos/+video-edit/video-add-components/video-send.ts1
-rw-r--r--client/src/app/videos/+video-edit/video-add-components/video-upload.component.html23
-rw-r--r--client/src/app/videos/+video-edit/video-add-components/video-upload.component.scss17
-rw-r--r--client/src/app/videos/+video-edit/video-add-components/video-upload.component.ts108
-rw-r--r--client/src/app/videos/+video-edit/video-update.component.html2
-rw-r--r--client/src/app/videos/+video-edit/video-update.component.ts16
-rw-r--r--client/src/app/videos/+video-watch/modal/video-share.component.html192
-rw-r--r--client/src/app/videos/+video-watch/modal/video-share.component.scss68
-rw-r--r--client/src/app/videos/+video-watch/modal/video-share.component.ts91
-rw-r--r--client/src/app/videos/+video-watch/video-watch.component.html2
-rw-r--r--client/src/app/videos/+video-watch/video-watch.component.scss1
-rw-r--r--client/src/app/videos/+video-watch/video-watch.component.ts108
-rw-r--r--client/src/app/videos/video-list/video-local.component.ts2
-rw-r--r--client/src/app/videos/video-list/video-overview.component.html6
-rw-r--r--client/src/app/videos/video-list/video-overview.component.scss60
-rw-r--r--client/src/app/videos/video-list/video-recently-added.component.ts3
-rw-r--r--client/src/app/videos/video-list/video-trending.component.ts2
-rw-r--r--client/src/app/videos/video-list/video-user-subscriptions.component.ts3
-rw-r--r--client/src/assets/player/peertube-player-local-storage.ts2
-rw-r--r--client/src/assets/player/peertube-player-manager.ts36
-rw-r--r--client/src/assets/player/utils.ts51
-rw-r--r--client/src/assets/player/videojs-components/peertube-link-button.ts2
-rw-r--r--client/src/sass/application.scss118
-rw-r--r--client/src/sass/bootstrap.scss138
-rw-r--r--client/src/sass/include/_miniature.scss97
-rw-r--r--client/src/sass/include/_mixins.scss25
-rw-r--r--client/src/sass/include/_variables.scss4
-rw-r--r--client/src/sass/player/_player-variables.scss8
-rw-r--r--client/src/sass/player/context-menu.scss4
-rw-r--r--client/src/sass/player/peertube-skin.scss12
-rw-r--r--client/src/sass/player/settings-menu.scss2
-rw-r--r--client/src/standalone/videos/embed-api.ts130
-rw-r--r--client/src/standalone/videos/embed.html2
-rw-r--r--client/src/standalone/videos/embed.ts205
153 files changed, 2662 insertions, 1108 deletions
diff --git a/client/src/app/+about/about-follows/about-follows.component.html b/client/src/app/+about/about-follows/about-follows.component.html
new file mode 100644
index 000000000..18689bbf7
--- /dev/null
+++ b/client/src/app/+about/about-follows/about-follows.component.html
@@ -0,0 +1,22 @@
1<div class="row" myInfiniteScroller [autoInit]="true" (nearOfBottom)="onNearOfBottom()">
2 <div class="col-xl-6 col-md-12">
3 <div i18n class="subtitle">Followers</div>
4
5 <div i18n class="no-results" *ngIf="followersPagination.totalItems === 0">This instance does not have followers.</div>
6
7 <a *ngFor="let follower of followers" [href]="buildLink(follower)" target="_blank" rel="noopener noreferrer">
8 {{ follower }}
9 </a>
10 </div>
11
12 <div class="col-xl-6 col-md-12">
13 <div i18n class="subtitle">Followings</div>
14
15 <div i18n class="no-results" *ngIf="followingsPagination.totalItems === 0">This instance does not have followings.</div>
16
17 <a *ngFor="let following of followings" [href]="buildLink(following)" target="_blank" rel="noopener noreferrer">
18 {{ following }}
19 </a>
20 </div>
21
22</div>
diff --git a/client/src/app/+about/about-follows/about-follows.component.scss b/client/src/app/+about/about-follows/about-follows.component.scss
new file mode 100644
index 000000000..e0d597a96
--- /dev/null
+++ b/client/src/app/+about/about-follows/about-follows.component.scss
@@ -0,0 +1,14 @@
1@import '_variables';
2@import '_mixins';
3
4.subtitle {
5 font-size: 18px;
6 font-weight: $font-semibold;
7 margin-bottom: 20px;
8}
9
10a {
11 display: block;
12 width: fit-content;
13 margin-top: 3px;
14}
diff --git a/client/src/app/+about/about-follows/about-follows.component.ts b/client/src/app/+about/about-follows/about-follows.component.ts
new file mode 100644
index 000000000..f0e1375d6
--- /dev/null
+++ b/client/src/app/+about/about-follows/about-follows.component.ts
@@ -0,0 +1,103 @@
1import { Component, OnInit } from '@angular/core'
2import { FollowService } from '@app/shared/instance/follow.service'
3import { ComponentPagination, hasMoreItems } from '@app/shared/rest/component-pagination.model'
4import { Notifier } from '@app/core'
5import { RestService } from '@app/shared'
6import { SortMeta } from 'primeng/api'
7
8@Component({
9 selector: 'my-about-follows',
10 templateUrl: './about-follows.component.html',
11 styleUrls: [ './about-follows.component.scss' ]
12})
13
14export class AboutFollowsComponent implements OnInit {
15 followers: string[] = []
16 followings: string[] = []
17
18 followersPagination: ComponentPagination = {
19 currentPage: 1,
20 itemsPerPage: 40,
21 totalItems: null
22 }
23
24 followingsPagination: ComponentPagination = {
25 currentPage: 1,
26 itemsPerPage: 40,
27 totalItems: null
28 }
29
30 sort: SortMeta = {
31 field: 'createdAt',
32 order: -1
33 }
34
35 constructor (
36 private restService: RestService,
37 private notifier: Notifier,
38 private followService: FollowService
39 ) { }
40
41 ngOnInit () {
42 this.loadMoreFollowers()
43
44 this.loadMoreFollowings()
45 }
46
47 onNearOfBottom () {
48 this.onNearOfFollowersBottom()
49
50 this.onNearOfFollowingsBottom()
51 }
52
53 onNearOfFollowersBottom () {
54 if (!hasMoreItems(this.followersPagination)) return
55
56 this.followersPagination.currentPage += 1
57 this.loadMoreFollowers()
58 }
59
60 onNearOfFollowingsBottom () {
61 if (!hasMoreItems(this.followingsPagination)) return
62
63 this.followingsPagination.currentPage += 1
64 this.loadMoreFollowings()
65 }
66
67 buildLink (host: string) {
68 return window.location.protocol + '//' + host
69 }
70
71 private loadMoreFollowers () {
72 const pagination = this.restService.componentPaginationToRestPagination(this.followersPagination)
73
74 this.followService.getFollowers(pagination, this.sort)
75 .subscribe(
76 resultList => {
77 const newFollowers = resultList.data.map(r => r.follower.host)
78 this.followers = this.followers.concat(newFollowers)
79
80 this.followersPagination.totalItems = resultList.total
81 },
82
83 err => this.notifier.error(err.message)
84 )
85 }
86
87 private loadMoreFollowings () {
88 const pagination = this.restService.componentPaginationToRestPagination(this.followingsPagination)
89
90 this.followService.getFollowing(pagination, this.sort)
91 .subscribe(
92 resultList => {
93 const newFollowings = resultList.data.map(r => r.following.host)
94 this.followings = this.followings.concat(newFollowings)
95
96 this.followingsPagination.totalItems = resultList.total
97 },
98
99 err => this.notifier.error(err.message)
100 )
101 }
102
103}
diff --git a/client/src/app/+about/about-routing.module.ts b/client/src/app/+about/about-routing.module.ts
index c83c62c7f..33e5070cb 100644
--- a/client/src/app/+about/about-routing.module.ts
+++ b/client/src/app/+about/about-routing.module.ts
@@ -4,6 +4,7 @@ import { MetaGuard } from '@ngx-meta/core'
4import { AboutComponent } from './about.component' 4import { 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'
7 8
8const aboutRoutes: Routes = [ 9const aboutRoutes: Routes = [
9 { 10 {
@@ -33,6 +34,15 @@ const aboutRoutes: Routes = [
33 title: 'About PeerTube' 34 title: 'About PeerTube'
34 } 35 }
35 } 36 }
37 },
38 {
39 path: 'follows',
40 component: AboutFollowsComponent,
41 data: {
42 meta: {
43 title: 'About follows'
44 }
45 }
36 } 46 }
37 ] 47 ]
38 } 48 }
diff --git a/client/src/app/+about/about.component.html b/client/src/app/+about/about.component.html
index 8c50835c1..0c4a5156d 100644
--- a/client/src/app/+about/about.component.html
+++ b/client/src/app/+about/about.component.html
@@ -5,10 +5,12 @@
5 <a i18n routerLink="instance" routerLinkActive="active" class="title-page">Instance</a> 5 <a i18n routerLink="instance" routerLinkActive="active" class="title-page">Instance</a>
6 6
7 <a i18n routerLink="peertube" routerLinkActive="active" class="title-page">PeerTube</a> 7 <a i18n routerLink="peertube" routerLinkActive="active" class="title-page">PeerTube</a>
8
9 <a i18n routerLink="follows" routerLinkActive="active" class="title-page">Follows</a>
8 </div> 10 </div>
9 </div> 11 </div>
10 12
11 <div class="margin-content"> 13 <div class="margin-content">
12 <router-outlet></router-outlet> 14 <router-outlet></router-outlet>
13 </div> 15 </div>
14</div> \ No newline at end of file 16</div>
diff --git a/client/src/app/+about/about.module.ts b/client/src/app/+about/about.module.ts
index 9c6b29740..49a7a52f8 100644
--- a/client/src/app/+about/about.module.ts
+++ b/client/src/app/+about/about.module.ts
@@ -6,6 +6,7 @@ import { SharedModule } from '../shared'
6import { AboutInstanceComponent } from '@app/+about/about-instance/about-instance.component' 6import { AboutInstanceComponent } from '@app/+about/about-instance/about-instance.component'
7import { AboutPeertubeComponent } from '@app/+about/about-peertube/about-peertube.component' 7import { AboutPeertubeComponent } from '@app/+about/about-peertube/about-peertube.component'
8import { ContactAdminModalComponent } from '@app/+about/about-instance/contact-admin-modal.component' 8import { ContactAdminModalComponent } from '@app/+about/about-instance/contact-admin-modal.component'
9import { AboutFollowsComponent } from '@app/+about/about-follows/about-follows.component'
9 10
10@NgModule({ 11@NgModule({
11 imports: [ 12 imports: [
@@ -17,6 +18,7 @@ import { ContactAdminModalComponent } from '@app/+about/about-instance/contact-a
17 AboutComponent, 18 AboutComponent,
18 AboutInstanceComponent, 19 AboutInstanceComponent,
19 AboutPeertubeComponent, 20 AboutPeertubeComponent,
21 AboutFollowsComponent,
20 ContactAdminModalComponent 22 ContactAdminModalComponent
21 ], 23 ],
22 24
diff --git a/client/src/app/+accounts/account-video-channels/account-video-channels.component.html b/client/src/app/+accounts/account-video-channels/account-video-channels.component.html
index c3ef1d894..e9c8179b7 100644
--- a/client/src/app/+accounts/account-video-channels/account-video-channels.component.html
+++ b/client/src/app/+accounts/account-video-channels/account-video-channels.component.html
@@ -1,11 +1,25 @@
1<div *ngIf="account" class="row"> 1<div class="margin-content">
2 <a 2
3 *ngFor="let videoChannel of videoChannels" [routerLink]="[ '/video-channels', videoChannel.nameWithHost ]" 3 <div class="no-results" i18n *ngIf="channelPagination.totalItems === 0">This account does not have channels.</div>
4 class="video-channel" i18n-title title="See this video channel" 4
5 > 5 <div class="channels" myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [autoInit]="true">
6 <img [src]="videoChannel.avatarUrl" alt="Avatar" /> 6 <div class="section channel" *ngFor="let videoChannel of videoChannels">
7 7 <div class="section-title">
8 <div class="video-channel-display-name">{{ videoChannel.displayName }}</div> 8 <a [routerLink]="[ '/video-channels', videoChannel.nameWithHost ]" i18n-title title="See this video channel">
9 <div i18n class="video-channel-followers">{{ videoChannel.followersCount }} subscribers</div> 9 <img [src]="videoChannel.avatarUrl" alt="Avatar" />
10 </a> 10
11</div> \ No newline at end of file 11 <div>{{ videoChannel.displayName }}</div>
12 <div i18n class="followers">{{ videoChannel.followersCount }} subscribers</div>
13 </a>
14
15 <my-subscribe-button [videoChannel]="videoChannel"></my-subscribe-button>
16 </div>
17
18 <div *ngIf="getVideosOf(videoChannel)" class="videos">
19 <div class="no-results" i18n *ngIf="getVideosOf(videoChannel).length === 0">This channel does not have videos.</div>
20
21 <my-video-miniature *ngFor="let video of getVideosOf(videoChannel)" [video]="video" [user]="user" [displayVideoActions]="false"></my-video-miniature>
22 </div>
23 </div>
24 </div>
25</div>
diff --git a/client/src/app/+accounts/account-video-channels/account-video-channels.component.scss b/client/src/app/+accounts/account-video-channels/account-video-channels.component.scss
index 0c6de2efa..98931f0c2 100644
--- a/client/src/app/+accounts/account-video-channels/account-video-channels.component.scss
+++ b/client/src/app/+accounts/account-video-channels/account-video-channels.component.scss
@@ -1,30 +1,28 @@
1@import '_variables'; 1@import '_variables';
2@import '_mixins'; 2@import '_mixins';
3@import '_miniature';
3 4
4.row { 5.margin-content {
5 justify-content: center; 6 @include adapt-margin-content-width;
6} 7}
7 8
8a.video-channel { 9.section {
9 @include disable-default-a-behaviour; 10 @include miniature-rows;
10 11
11 display: inline-block; 12 overflow: visible; // For the subscribe dropdown
12 text-align: center; 13 padding-top: 0 !important;
13 color: var(--mainForegroundColor);
14 margin: 10px 30px;
15 14
16 img { 15 .section-title {
17 @include avatar(80px); 16 align-items: center;
18
19 margin-bottom: 10px;
20 } 17 }
21 18
22 .video-channel-display-name { 19 .videos {
23 font-size: 20px; 20 overflow: hidden;
24 font-weight: $font-bold;
25 }
26 21
27 .video-channel-followers { 22 .no-results {
28 font-size: 15px; 23 height: 50px;
24 }
29 } 25 }
30} \ No newline at end of file 26}
27
28
diff --git a/client/src/app/+accounts/account-video-channels/account-video-channels.component.ts b/client/src/app/+accounts/account-video-channels/account-video-channels.component.ts
index 44f5626bb..a8d4237e8 100644
--- a/client/src/app/+accounts/account-video-channels/account-video-channels.component.ts
+++ b/client/src/app/+accounts/account-video-channels/account-video-channels.component.ts
@@ -3,9 +3,14 @@ import { ActivatedRoute } from '@angular/router'
3import { Account } from '@app/shared/account/account.model' 3import { Account } from '@app/shared/account/account.model'
4import { AccountService } from '@app/shared/account/account.service' 4import { AccountService } from '@app/shared/account/account.service'
5import { VideoChannelService } from '@app/shared/video-channel/video-channel.service' 5import { VideoChannelService } from '@app/shared/video-channel/video-channel.service'
6import { flatMap, map, tap } from 'rxjs/operators' 6import { concatMap, map, switchMap, tap } from 'rxjs/operators'
7import { Subscription } from 'rxjs' 7import { from, Subscription } from 'rxjs'
8import { VideoChannel } from '@app/shared/video-channel/video-channel.model' 8import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
9import { Video } from '@app/shared/video/video.model'
10import { AuthService } from '@app/core'
11import { VideoService } from '@app/shared/video/video.service'
12import { VideoSortField } from '@app/shared/video/sort-field.type'
13import { ComponentPagination, hasMoreItems } from '@app/shared/rest/component-pagination.model'
9 14
10@Component({ 15@Component({
11 selector: 'my-account-video-channels', 16 selector: 'my-account-video-channels',
@@ -15,27 +20,73 @@ import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
15export class AccountVideoChannelsComponent implements OnInit, OnDestroy { 20export class AccountVideoChannelsComponent implements OnInit, OnDestroy {
16 account: Account 21 account: Account
17 videoChannels: VideoChannel[] = [] 22 videoChannels: VideoChannel[] = []
23 videos: { [id: number]: Video[] } = {}
24
25 channelPagination: ComponentPagination = {
26 currentPage: 1,
27 itemsPerPage: 2
28 }
29
30 videosPagination: ComponentPagination = {
31 currentPage: 1,
32 itemsPerPage: 12
33 }
34 videosSort: VideoSortField = '-publishedAt'
18 35
19 private accountSub: Subscription 36 private accountSub: Subscription
20 37
21 constructor ( 38 constructor (
22 protected route: ActivatedRoute, 39 private route: ActivatedRoute,
40 private authService: AuthService,
23 private accountService: AccountService, 41 private accountService: AccountService,
24 private videoChannelService: VideoChannelService 42 private videoChannelService: VideoChannelService,
43 private videoService: VideoService
25 ) { } 44 ) { }
26 45
46 get user () {
47 return this.authService.getUser()
48 }
49
27 ngOnInit () { 50 ngOnInit () {
28 // Parent get the account for us 51 // Parent get the account for us
29 this.accountSub = this.accountService.accountLoaded 52 this.accountSub = this.accountService.accountLoaded
30 .pipe( 53 .subscribe(account => {
31 tap(account => this.account = account), 54 this.account = account
32 flatMap(account => this.videoChannelService.listAccountVideoChannels(account)), 55
33 map(res => res.data) 56 this.loadMoreChannels()
34 ) 57 })
35 .subscribe(videoChannels => this.videoChannels = videoChannels)
36 } 58 }
37 59
38 ngOnDestroy () { 60 ngOnDestroy () {
39 if (this.accountSub) this.accountSub.unsubscribe() 61 if (this.accountSub) this.accountSub.unsubscribe()
40 } 62 }
63
64 loadMoreChannels () {
65 this.videoChannelService.listAccountVideoChannels(this.account, this.channelPagination)
66 .pipe(
67 tap(res => this.channelPagination.totalItems = res.total),
68 switchMap(res => from(res.data)),
69 concatMap(videoChannel => {
70 return this.videoService.getVideoChannelVideos(videoChannel, this.videosPagination, this.videosSort)
71 .pipe(map(data => ({ videoChannel, videos: data.videos })))
72 })
73 )
74 .subscribe(({ videoChannel, videos }) => {
75 this.videoChannels.push(videoChannel)
76
77 this.videos[videoChannel.id] = videos
78 })
79 }
80
81 getVideosOf (videoChannel: VideoChannel) {
82 return this.videos[ videoChannel.id ]
83 }
84
85 onNearOfBottom () {
86 if (!hasMoreItems(this.channelPagination)) return
87
88 this.channelPagination.currentPage += 1
89
90 this.loadMoreChannels()
91 }
41} 92}
diff --git a/client/src/app/+accounts/account-videos/account-videos.component.ts b/client/src/app/+accounts/account-videos/account-videos.component.ts
index 0d579fa0c..5a99aadce 100644
--- a/client/src/app/+accounts/account-videos/account-videos.component.ts
+++ b/client/src/app/+accounts/account-videos/account-videos.component.ts
@@ -29,6 +29,7 @@ export class AccountVideosComponent extends AbstractVideoList implements OnInit,
29 private accountSub: Subscription 29 private accountSub: Subscription
30 30
31 constructor ( 31 constructor (
32 protected i18n: I18n,
32 protected router: Router, 33 protected router: Router,
33 protected serverService: ServerService, 34 protected serverService: ServerService,
34 protected route: ActivatedRoute, 35 protected route: ActivatedRoute,
@@ -36,13 +37,10 @@ export class AccountVideosComponent extends AbstractVideoList implements OnInit,
36 protected notifier: Notifier, 37 protected notifier: Notifier,
37 protected confirmService: ConfirmService, 38 protected confirmService: ConfirmService,
38 protected screenService: ScreenService, 39 protected screenService: ScreenService,
39 private i18n: I18n,
40 private accountService: AccountService, 40 private accountService: AccountService,
41 private videoService: VideoService 41 private videoService: VideoService
42 ) { 42 ) {
43 super() 43 super()
44
45 this.titlePage = this.i18n('Published videos')
46 } 44 }
47 45
48 ngOnInit () { 46 ngOnInit () {
diff --git a/client/src/app/+accounts/accounts-routing.module.ts b/client/src/app/+accounts/accounts-routing.module.ts
index 531d763c4..45b24eb55 100644
--- a/client/src/app/+accounts/accounts-routing.module.ts
+++ b/client/src/app/+accounts/accounts-routing.module.ts
@@ -8,13 +8,17 @@ import { AccountVideoChannelsComponent } from './account-video-channels/account-
8 8
9const accountsRoutes: Routes = [ 9const accountsRoutes: Routes = [
10 { 10 {
11 path: 'peertube',
12 redirectTo: '/videos/local'
13 },
14 {
11 path: ':accountId', 15 path: ':accountId',
12 component: AccountsComponent, 16 component: AccountsComponent,
13 canActivateChild: [ MetaGuard ], 17 canActivateChild: [ MetaGuard ],
14 children: [ 18 children: [
15 { 19 {
16 path: '', 20 path: '',
17 redirectTo: 'videos', 21 redirectTo: 'video-channels',
18 pathMatch: 'full' 22 pathMatch: 'full'
19 }, 23 },
20 { 24 {
diff --git a/client/src/app/+accounts/accounts.component.html b/client/src/app/+accounts/accounts.component.html
index c1377c1ea..038e18c4b 100644
--- a/client/src/app/+accounts/accounts.component.html
+++ b/client/src/app/+accounts/accounts.component.html
@@ -26,10 +26,10 @@
26 </div> 26 </div>
27 27
28 <div class="links"> 28 <div class="links">
29 <a i18n routerLink="videos" routerLinkActive="active" class="title-page">Videos</a>
30
31 <a i18n routerLink="video-channels" routerLinkActive="active" class="title-page">Video channels</a> 29 <a i18n routerLink="video-channels" routerLinkActive="active" class="title-page">Video channels</a>
32 30
31 <a i18n routerLink="videos" routerLinkActive="active" class="title-page">Videos</a>
32
33 <a i18n routerLink="about" routerLinkActive="active" class="title-page">About</a> 33 <a i18n routerLink="about" routerLinkActive="active" class="title-page">About</a>
34 </div> 34 </div>
35 </div> 35 </div>
diff --git a/client/src/app/+admin/admin.module.ts b/client/src/app/+admin/admin.module.ts
index 71a4dfc4a..9ab883f60 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, FollowService } from './follows' 8import { FollowersListComponent, FollowingAddComponent, FollowsComponent } 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 {
@@ -66,7 +66,6 @@ import { DebugComponent, DebugService } from '@app/+admin/system/debug'
66 ], 66 ],
67 67
68 providers: [ 68 providers: [
69 FollowService,
70 RedundancyService, 69 RedundancyService,
71 JobService, 70 JobService,
72 LogsService, 71 LogsService,
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 637484622..d5b625d9c 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
@@ -287,6 +287,14 @@
287 </div> 287 </div>
288 288
289 <div class="form-group"> 289 <div class="form-group">
290 <my-peertube-checkbox
291 inputName="transcodingAllowAudioFiles" formControlName="allowAudioFiles"
292 i18n-labelText labelText="Allow audio files upload"
293 i18n-helpHtml helpHtml="Allow your users to upload audio files that will be merged with the preview file on upload"
294 ></my-peertube-checkbox>
295 </div>
296
297 <div class="form-group">
290 <label i18n for="transcodingThreads">Transcoding threads</label> 298 <label i18n for="transcodingThreads">Transcoding threads</label>
291 <div class="peertube-select-container"> 299 <div class="peertube-select-container">
292 <select id="transcodingThreads" formControlName="threads"> 300 <select id="transcodingThreads" formControlName="threads">
@@ -301,8 +309,8 @@
301 <ng-container formGroupName="resolutions"> 309 <ng-container formGroupName="resolutions">
302 <div class="form-group" *ngFor="let resolution of resolutions"> 310 <div class="form-group" *ngFor="let resolution of resolutions">
303 <my-peertube-checkbox 311 <my-peertube-checkbox
304 [inputName]="getResolutionKey(resolution)" [formControlName]="resolution" 312 [inputName]="getResolutionKey(resolution.id)" [formControlName]="resolution.id"
305 i18n-labelText labelText="Resolution {{resolution}} enabled" 313 i18n-labelText labelText="Resolution {{resolution.label}} enabled"
306 ></my-peertube-checkbox> 314 ></my-peertube-checkbox>
307 </div> 315 </div>
308 </ng-container> 316 </ng-container>
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts
index e64750713..055bae851 100644
--- a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts
+++ b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts
@@ -15,7 +15,7 @@ import { FormValidatorService } from '@app/shared/forms/form-validators/form-val
15export class EditCustomConfigComponent extends FormReactive implements OnInit { 15export class EditCustomConfigComponent extends FormReactive implements OnInit {
16 customConfig: CustomConfig 16 customConfig: CustomConfig
17 17
18 resolutions: string[] = [] 18 resolutions: { id: string, label: string }[] = []
19 transcodingThreadOptions: { label: string, value: number }[] = [] 19 transcodingThreadOptions: { label: string, value: number }[] = []
20 20
21 constructor ( 21 constructor (
@@ -30,11 +30,30 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
30 super() 30 super()
31 31
32 this.resolutions = [ 32 this.resolutions = [
33 this.i18n('240p'), 33 {
34 this.i18n('360p'), 34 id: '240p',
35 this.i18n('480p'), 35 label: this.i18n('240p')
36 this.i18n('720p'), 36 },
37 this.i18n('1080p') 37 {
38 id: '360p',
39 label: this.i18n('360p')
40 },
41 {
42 id: '480p',
43 label: this.i18n('480p')
44 },
45 {
46 id: '720p',
47 label: this.i18n('720p')
48 },
49 {
50 id: '1080p',
51 label: this.i18n('1080p')
52 },
53 {
54 id: '2160p',
55 label: this.i18n('2160p')
56 }
38 ] 57 ]
39 58
40 this.transcodingThreadOptions = [ 59 this.transcodingThreadOptions = [
@@ -116,6 +135,7 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
116 enabled: null, 135 enabled: null,
117 threads: this.customConfigValidatorsService.TRANSCODING_THREADS, 136 threads: this.customConfigValidatorsService.TRANSCODING_THREADS,
118 allowAdditionalExtensions: null, 137 allowAdditionalExtensions: null,
138 allowAudioFiles: null,
119 resolutions: {} 139 resolutions: {}
120 }, 140 },
121 autoBlacklist: { 141 autoBlacklist: {
@@ -139,8 +159,8 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
139 } 159 }
140 } 160 }
141 for (const resolution of this.resolutions) { 161 for (const resolution of this.resolutions) {
142 defaultValues.transcoding.resolutions[resolution] = 'false' 162 defaultValues.transcoding.resolutions[resolution.id] = 'false'
143 formGroupData.transcoding.resolutions[resolution] = null 163 formGroupData.transcoding.resolutions[resolution.id] = null
144 } 164 }
145 165
146 this.buildForm(formGroupData) 166 this.buildForm(formGroupData)
diff --git a/client/src/app/+admin/follows/followers-list/followers-list.component.ts b/client/src/app/+admin/follows/followers-list/followers-list.component.ts
index b78cdf656..e25d9ab66 100644
--- a/client/src/app/+admin/follows/followers-list/followers-list.component.ts
+++ b/client/src/app/+admin/follows/followers-list/followers-list.component.ts
@@ -3,7 +3,7 @@ import { ConfirmService, Notifier } from '@app/core'
3import { SortMeta } from 'primeng/primeng' 3import { SortMeta } from 'primeng/primeng'
4import { ActorFollow } from '../../../../../../shared/models/actors/follow.model' 4import { ActorFollow } from '../../../../../../shared/models/actors/follow.model'
5import { RestPagination, RestTable } from '../../../shared' 5import { RestPagination, RestTable } from '../../../shared'
6import { FollowService } from '../shared' 6import { FollowService } from '@app/shared/instance/follow.service'
7import { I18n } from '@ngx-translate/i18n-polyfill' 7import { I18n } from '@ngx-translate/i18n-polyfill'
8 8
9@Component({ 9@Component({
diff --git a/client/src/app/+admin/follows/following-add/following-add.component.ts b/client/src/app/+admin/follows/following-add/following-add.component.ts
index 2bb249746..308bbb0c5 100644
--- a/client/src/app/+admin/follows/following-add/following-add.component.ts
+++ b/client/src/app/+admin/follows/following-add/following-add.component.ts
@@ -3,7 +3,7 @@ import { Router } from '@angular/router'
3import { Notifier } from '@app/core' 3import { Notifier } from '@app/core'
4import { ConfirmService } from '../../../core' 4import { ConfirmService } from '../../../core'
5import { validateHost } from '../../../shared' 5import { validateHost } from '../../../shared'
6import { FollowService } from '../shared' 6import { FollowService } from '@app/shared/instance/follow.service'
7import { I18n } from '@ngx-translate/i18n-polyfill' 7import { I18n } from '@ngx-translate/i18n-polyfill'
8 8
9@Component({ 9@Component({
diff --git a/client/src/app/+admin/follows/following-list/following-list.component.ts b/client/src/app/+admin/follows/following-list/following-list.component.ts
index 4517a721e..ded616624 100644
--- a/client/src/app/+admin/follows/following-list/following-list.component.ts
+++ b/client/src/app/+admin/follows/following-list/following-list.component.ts
@@ -4,7 +4,7 @@ import { SortMeta } from 'primeng/primeng'
4import { ActorFollow } from '../../../../../../shared/models/actors/follow.model' 4import { ActorFollow } from '../../../../../../shared/models/actors/follow.model'
5import { ConfirmService } from '../../../core/confirm/confirm.service' 5import { ConfirmService } from '../../../core/confirm/confirm.service'
6import { RestPagination, RestTable } from '../../../shared' 6import { RestPagination, RestTable } from '../../../shared'
7import { FollowService } from '../shared' 7import { FollowService } from '@app/shared/instance/follow.service'
8import { I18n } from '@ngx-translate/i18n-polyfill' 8import { I18n } from '@ngx-translate/i18n-polyfill'
9 9
10@Component({ 10@Component({
diff --git a/client/src/app/+admin/follows/index.ts b/client/src/app/+admin/follows/index.ts
index 7849a06e7..e94f33710 100644
--- a/client/src/app/+admin/follows/index.ts
+++ b/client/src/app/+admin/follows/index.ts
@@ -1,6 +1,5 @@
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 './shared'
5export * from './follows.component' 4export * from './follows.component'
6export * from './follows.routes' 5export * from './follows.routes'
diff --git a/client/src/app/+admin/follows/shared/index.ts b/client/src/app/+admin/follows/shared/index.ts
deleted file mode 100644
index 78d456def..000000000
--- a/client/src/app/+admin/follows/shared/index.ts
+++ /dev/null
@@ -1 +0,0 @@
1export * from './follow.service'
diff --git a/client/src/app/+admin/users/user-edit/user-edit.ts b/client/src/app/+admin/users/user-edit/user-edit.ts
index adce1b2d4..ee6d2c489 100644
--- a/client/src/app/+admin/users/user-edit/user-edit.ts
+++ b/client/src/app/+admin/users/user-edit/user-edit.ts
@@ -7,7 +7,8 @@ import { UserAdminFlag } from '@shared/models/users/user-flag.model'
7export abstract class UserEdit extends FormReactive { 7export abstract class UserEdit extends FormReactive {
8 videoQuotaOptions: { value: string, label: string }[] = [] 8 videoQuotaOptions: { value: string, label: string }[] = []
9 videoQuotaDailyOptions: { value: string, label: string }[] = [] 9 videoQuotaDailyOptions: { value: string, label: string }[] = []
10 roles = Object.keys(USER_ROLE_LABELS).map(key => ({ value: key.toString(), label: USER_ROLE_LABELS[key] })) 10 roles = Object.keys(USER_ROLE_LABELS)
11 .map(key => ({ value: key.toString(), label: USER_ROLE_LABELS[key] }))
11 username: string 12 username: string
12 userId: number 13 userId: number
13 14
@@ -27,7 +28,7 @@ export abstract class UserEdit extends FormReactive {
27 const transcodingConfig = this.serverService.getConfig().transcoding 28 const transcodingConfig = this.serverService.getConfig().transcoding
28 29
29 const resolutions = transcodingConfig.enabledResolutions 30 const resolutions = transcodingConfig.enabledResolutions
30 const higherResolution = VideoResolution.H_1080P 31 const higherResolution = VideoResolution.H_4K
31 let multiplier = 0 32 let multiplier = 0
32 33
33 for (const resolution of resolutions) { 34 for (const resolution of resolutions) {
diff --git a/client/src/app/+my-account/my-account-history/my-account-history.component.ts b/client/src/app/+my-account/my-account-history/my-account-history.component.ts
index 73340d21a..13607119e 100644
--- a/client/src/app/+my-account/my-account-history/my-account-history.component.ts
+++ b/client/src/app/+my-account/my-account-history/my-account-history.component.ts
@@ -27,6 +27,7 @@ export class MyAccountHistoryComponent extends AbstractVideoList implements OnIn
27 videosHistoryEnabled: boolean 27 videosHistoryEnabled: boolean
28 28
29 constructor ( 29 constructor (
30 protected i18n: I18n,
30 protected router: Router, 31 protected router: Router,
31 protected serverService: ServerService, 32 protected serverService: ServerService,
32 protected route: ActivatedRoute, 33 protected route: ActivatedRoute,
@@ -34,7 +35,6 @@ export class MyAccountHistoryComponent extends AbstractVideoList implements OnIn
34 protected userService: UserService, 35 protected userService: UserService,
35 protected notifier: Notifier, 36 protected notifier: Notifier,
36 protected screenService: ScreenService, 37 protected screenService: ScreenService,
37 protected i18n: I18n,
38 private confirmService: ConfirmService, 38 private confirmService: ConfirmService,
39 private videoService: VideoService, 39 private videoService: VideoService,
40 private userHistoryService: UserHistoryService 40 private userHistoryService: UserHistoryService
diff --git a/client/src/app/+my-account/my-account-settings/my-account-change-email/index.ts b/client/src/app/+my-account/my-account-settings/my-account-change-email/index.ts
new file mode 100644
index 000000000..f42af361e
--- /dev/null
+++ b/client/src/app/+my-account/my-account-settings/my-account-change-email/index.ts
@@ -0,0 +1 @@
export * from './my-account-change-email.component'
diff --git a/client/src/app/+my-account/my-account-settings/my-account-change-email/my-account-change-email.component.html b/client/src/app/+my-account/my-account-settings/my-account-change-email/my-account-change-email.component.html
new file mode 100644
index 000000000..5492cdf22
--- /dev/null
+++ b/client/src/app/+my-account/my-account-settings/my-account-change-email/my-account-change-email.component.html
@@ -0,0 +1,36 @@
1<div *ngIf="error" class="alert alert-danger">{{ error }}</div>
2<div *ngIf="success" class="alert alert-success">{{ success }}</div>
3
4<div i18n class="current-email">
5 Your current email is <span class="email">{{ user.email }}</span>
6</div>
7
8<div i18n class="pending-email" *ngIf="user.pendingEmail">
9 <span class="email">{{ user.pendingEmail }}</span> is awaiting email verification
10</div>
11
12<form role="form" class="change-email" (ngSubmit)="changeEmail()" [formGroup]="form">
13
14 <div class="form-group">
15 <label i18n for="new-email">New email</label>
16 <input
17 type="email" id="new-email" i18n-placeholder placeholder="Your new email"
18 formControlName="new-email" [ngClass]="{ 'input-error': formErrors['new-email'] }"
19 >
20 <div *ngIf="formErrors['new-email']" class="form-error">
21 {{ formErrors['new-email'] }}
22 </div>
23 </div>
24
25 <div class="form-group">
26 <input
27 type="password" id="password" i18n-placeholder placeholder="Your password"
28 formControlName="password" [ngClass]="{ 'input-error': formErrors['password'] }"
29 >
30 <div *ngIf="formErrors['password']" class="form-error">
31 {{ formErrors['password'] }}
32 </div>
33 </div>
34
35 <input type="submit" i18n-value value="Change email" [disabled]="!form.valid">
36</form>
diff --git a/client/src/app/+my-account/my-account-settings/my-account-change-email/my-account-change-email.component.scss b/client/src/app/+my-account/my-account-settings/my-account-change-email/my-account-change-email.component.scss
new file mode 100644
index 000000000..81eba3ec9
--- /dev/null
+++ b/client/src/app/+my-account/my-account-settings/my-account-change-email/my-account-change-email.component.scss
@@ -0,0 +1,24 @@
1@import '_variables';
2@import '_mixins';
3
4input[type=password],
5input[type=email] {
6 @include peertube-input-text(340px);
7
8 display: block;
9}
10
11input[type=submit] {
12 @include peertube-button;
13 @include orange-button;
14}
15
16.current-email,
17.pending-email {
18 font-size: 16px;
19 margin: 15px 0;
20
21 .email {
22 font-weight: $font-semibold;
23 }
24}
diff --git a/client/src/app/+my-account/my-account-settings/my-account-change-email/my-account-change-email.component.ts b/client/src/app/+my-account/my-account-settings/my-account-change-email/my-account-change-email.component.ts
new file mode 100644
index 000000000..ec7cf935c
--- /dev/null
+++ b/client/src/app/+my-account/my-account-settings/my-account-change-email/my-account-change-email.component.ts
@@ -0,0 +1,73 @@
1import { Component, OnInit } from '@angular/core'
2import { AuthService, Notifier, ServerService } from '@app/core'
3import { FormReactive, UserService } from '../../../shared'
4import { I18n } from '@ngx-translate/i18n-polyfill'
5import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
6import { UserValidatorsService } from '@app/shared/forms/form-validators/user-validators.service'
7import { User } from '../../../../../../shared'
8import { tap } from 'rxjs/operators'
9
10@Component({
11 selector: 'my-account-change-email',
12 templateUrl: './my-account-change-email.component.html',
13 styleUrls: [ './my-account-change-email.component.scss' ]
14})
15export class MyAccountChangeEmailComponent extends FormReactive implements OnInit {
16 error: string = null
17 success: string = null
18 user: User = null
19
20 constructor (
21 protected formValidatorService: FormValidatorService,
22 private userValidatorsService: UserValidatorsService,
23 private notifier: Notifier,
24 private authService: AuthService,
25 private userService: UserService,
26 private serverService: ServerService,
27 private i18n: I18n
28 ) {
29 super()
30 }
31
32 ngOnInit () {
33 this.buildForm({
34 'new-email': this.userValidatorsService.USER_EMAIL,
35 'password': this.userValidatorsService.USER_PASSWORD
36 })
37
38 this.user = this.authService.getUser()
39 }
40
41 changeEmail () {
42 this.error = null
43 this.success = null
44
45 const password = this.form.value[ 'password' ]
46 const email = this.form.value[ 'new-email' ]
47
48 this.userService.changeEmail(password, email)
49 .pipe(
50 tap(() => this.authService.refreshUserInformation())
51 )
52 .subscribe(
53 () => {
54 this.form.reset()
55
56 if (this.serverService.getConfig().signup.requiresEmailVerification) {
57 this.success = this.i18n('Please check your emails to verify your new email.')
58 } else {
59 this.success = this.i18n('Email updated.')
60 }
61 },
62
63 err => {
64 if (err.status === 401) {
65 this.error = this.i18n('You current password is invalid.')
66 return
67 }
68
69 this.error = err.message
70 }
71 )
72 }
73}
diff --git a/client/src/app/+my-account/my-account-settings/my-account-change-password/my-account-change-password.component.html b/client/src/app/+my-account/my-account-settings/my-account-change-password/my-account-change-password.component.html
index ae797d1bc..a39061ee3 100644
--- a/client/src/app/+my-account/my-account-settings/my-account-change-password/my-account-change-password.component.html
+++ b/client/src/app/+my-account/my-account-settings/my-account-change-password/my-account-change-password.component.html
@@ -2,7 +2,7 @@
2 2
3<form role="form" (ngSubmit)="changePassword()" [formGroup]="form"> 3<form role="form" (ngSubmit)="changePassword()" [formGroup]="form">
4 4
5 <label i18n for="new-password">Change password</label> 5 <label i18n for="current-password">Change password</label>
6 <input 6 <input
7 type="password" id="current-password" i18n-placeholder placeholder="Current password" 7 type="password" id="current-password" i18n-placeholder placeholder="Current password"
8 formControlName="current-password" [ngClass]="{ 'input-error': formErrors['current-password'] }" 8 formControlName="current-password" [ngClass]="{ 'input-error': formErrors['current-password'] }"
diff --git a/client/src/app/+my-account/my-account-settings/my-account-profile/my-account-profile.component.ts b/client/src/app/+my-account/my-account-settings/my-account-profile/my-account-profile.component.ts
index a9503ed1b..fcad5a6c2 100644
--- a/client/src/app/+my-account/my-account-settings/my-account-profile/my-account-profile.component.ts
+++ b/client/src/app/+my-account/my-account-settings/my-account-profile/my-account-profile.component.ts
@@ -30,7 +30,7 @@ export class MyAccountProfileComponent extends FormReactive implements OnInit {
30 30
31 ngOnInit () { 31 ngOnInit () {
32 this.buildForm({ 32 this.buildForm({
33 'display-name': this.userValidatorsService.USER_DISPLAY_NAME, 33 'display-name': this.userValidatorsService.USER_DISPLAY_NAME_REQUIRED,
34 description: this.userValidatorsService.USER_DESCRIPTION 34 description: this.userValidatorsService.USER_DESCRIPTION
35 }) 35 })
36 36
diff --git a/client/src/app/+my-account/my-account-settings/my-account-settings.component.html b/client/src/app/+my-account/my-account-settings/my-account-settings.component.html
index ad64f28fe..f93d41110 100644
--- a/client/src/app/+my-account/my-account-settings/my-account-settings.component.html
+++ b/client/src/app/+my-account/my-account-settings/my-account-settings.component.html
@@ -13,6 +13,9 @@
13<div i18n class="account-title">Password</div> 13<div i18n class="account-title">Password</div>
14<my-account-change-password></my-account-change-password> 14<my-account-change-password></my-account-change-password>
15 15
16<div i18n class="account-title">Email</div>
17<my-account-change-email></my-account-change-email>
18
16<div i18n class="account-title">Video settings</div> 19<div i18n class="account-title">Video settings</div>
17<my-account-video-settings [user]="user" [userInformationLoaded]="userInformationLoaded"></my-account-video-settings> 20<my-account-video-settings [user]="user" [userInformationLoaded]="userInformationLoaded"></my-account-video-settings>
18 21
diff --git a/client/src/app/+my-account/my-account-video-channels/my-account-video-channel-edit.component.html b/client/src/app/+my-account/my-account-video-channels/my-account-video-channel-edit.component.html
index 81fb11f45..f87df87df 100644
--- a/client/src/app/+my-account/my-account-video-channels/my-account-video-channel-edit.component.html
+++ b/client/src/app/+my-account/my-account-video-channels/my-account-video-channel-edit.component.html
@@ -61,5 +61,12 @@ When you will upload a video in this channel, the video support field will be au
61 </div> 61 </div>
62 </div> 62 </div>
63 63
64 <div class="form-group" *ngIf="isBulkUpdateVideosDisplayed()">
65 <my-peertube-checkbox
66 inputName="bulkVideosSupportUpdate" formControlName="bulkVideosSupportUpdate"
67 i18n-labelText labelText="Overwrite support field of all videos of this channel"
68 ></my-peertube-checkbox>
69 </div>
70
64 <input type="submit" value="{{ getFormButtonTitle() }}" [disabled]="!form.valid"> 71 <input type="submit" value="{{ getFormButtonTitle() }}" [disabled]="!form.valid">
65</form> 72</form>
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 4dc65dd99..7479442d1 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
@@ -11,4 +11,9 @@ export abstract class MyAccountVideoChannelEdit extends FormReactive {
11 11
12 // FIXME: We need this method so angular does not complain in the child template 12 // FIXME: We need this method so angular does not complain in the child template
13 onAvatarChange (formData: FormData) { /* empty */ } 13 onAvatarChange (formData: FormData) { /* empty */ }
14
15 // Should be implemented by the child
16 isBulkUpdateVideosDisplayed () {
17 return false
18 }
14} 19}
diff --git a/client/src/app/+my-account/my-account-video-channels/my-account-video-channel-update.component.ts b/client/src/app/+my-account/my-account-video-channels/my-account-video-channel-update.component.ts
index da4fb645a..081e956d2 100644
--- a/client/src/app/+my-account/my-account-video-channels/my-account-video-channel-update.component.ts
+++ b/client/src/app/+my-account/my-account-video-channels/my-account-video-channel-update.component.ts
@@ -20,6 +20,7 @@ export class MyAccountVideoChannelUpdateComponent extends MyAccountVideoChannelE
20 videoChannelToUpdate: VideoChannel 20 videoChannelToUpdate: VideoChannel
21 21
22 private paramsSub: Subscription 22 private paramsSub: Subscription
23 private oldSupportField: string
23 24
24 constructor ( 25 constructor (
25 protected formValidatorService: FormValidatorService, 26 protected formValidatorService: FormValidatorService,
@@ -39,7 +40,8 @@ export class MyAccountVideoChannelUpdateComponent extends MyAccountVideoChannelE
39 this.buildForm({ 40 this.buildForm({
40 'display-name': this.videoChannelValidatorsService.VIDEO_CHANNEL_DISPLAY_NAME, 41 'display-name': this.videoChannelValidatorsService.VIDEO_CHANNEL_DISPLAY_NAME,
41 description: this.videoChannelValidatorsService.VIDEO_CHANNEL_DESCRIPTION, 42 description: this.videoChannelValidatorsService.VIDEO_CHANNEL_DESCRIPTION,
42 support: this.videoChannelValidatorsService.VIDEO_CHANNEL_SUPPORT 43 support: this.videoChannelValidatorsService.VIDEO_CHANNEL_SUPPORT,
44 bulkVideosSupportUpdate: null
43 }) 45 })
44 46
45 this.paramsSub = this.route.params.subscribe(routeParams => { 47 this.paramsSub = this.route.params.subscribe(routeParams => {
@@ -49,6 +51,8 @@ export class MyAccountVideoChannelUpdateComponent extends MyAccountVideoChannelE
49 videoChannelToUpdate => { 51 videoChannelToUpdate => {
50 this.videoChannelToUpdate = videoChannelToUpdate 52 this.videoChannelToUpdate = videoChannelToUpdate
51 53
54 this.oldSupportField = videoChannelToUpdate.support
55
52 this.form.patchValue({ 56 this.form.patchValue({
53 'display-name': videoChannelToUpdate.displayName, 57 'display-name': videoChannelToUpdate.displayName,
54 description: videoChannelToUpdate.description, 58 description: videoChannelToUpdate.description,
@@ -72,7 +76,8 @@ export class MyAccountVideoChannelUpdateComponent extends MyAccountVideoChannelE
72 const videoChannelUpdate: VideoChannelUpdate = { 76 const videoChannelUpdate: VideoChannelUpdate = {
73 displayName: body['display-name'], 77 displayName: body['display-name'],
74 description: body.description || null, 78 description: body.description || null,
75 support: body.support || null 79 support: body.support || null,
80 bulkVideosSupportUpdate: body.bulkVideosSupportUpdate || false
76 } 81 }
77 82
78 this.videoChannelService.updateVideoChannel(this.videoChannelToUpdate.name, videoChannelUpdate).subscribe( 83 this.videoChannelService.updateVideoChannel(this.videoChannelToUpdate.name, videoChannelUpdate).subscribe(
@@ -118,4 +123,10 @@ export class MyAccountVideoChannelUpdateComponent extends MyAccountVideoChannelE
118 getFormButtonTitle () { 123 getFormButtonTitle () {
119 return this.i18n('Update') 124 return this.i18n('Update')
120 } 125 }
126
127 isBulkUpdateVideosDisplayed () {
128 if (this.oldSupportField === undefined) return false
129
130 return this.oldSupportField !== this.form.value['support']
131 }
121} 132}
diff --git a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-create.component.ts b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-create.component.ts
index 87a10961f..8aed8b513 100644
--- a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-create.component.ts
+++ b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-create.component.ts
@@ -7,7 +7,6 @@ import { FormValidatorService } from '@app/shared/forms/form-validators/form-val
7import { VideoPlaylistValidatorsService } from '@app/shared' 7import { VideoPlaylistValidatorsService } from '@app/shared'
8import { VideoPlaylistCreate } from '@shared/models/videos/playlist/video-playlist-create.model' 8import { VideoPlaylistCreate } from '@shared/models/videos/playlist/video-playlist-create.model'
9import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service' 9import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service'
10import { VideoConstant } from '@shared/models'
11import { VideoPlaylistPrivacy } from '@shared/models/videos/playlist/video-playlist-privacy.model' 10import { VideoPlaylistPrivacy } from '@shared/models/videos/playlist/video-playlist-privacy.model'
12import { populateAsyncUserVideoChannels } from '@app/shared/misc/utils' 11import { populateAsyncUserVideoChannels } from '@app/shared/misc/utils'
13 12
@@ -18,7 +17,6 @@ import { populateAsyncUserVideoChannels } from '@app/shared/misc/utils'
18}) 17})
19export class MyAccountVideoPlaylistCreateComponent extends MyAccountVideoPlaylistEdit implements OnInit { 18export class MyAccountVideoPlaylistCreateComponent extends MyAccountVideoPlaylistEdit implements OnInit {
20 error: string 19 error: string
21 videoPlaylistPrivacies: VideoConstant<VideoPlaylistPrivacy>[] = []
22 20
23 constructor ( 21 constructor (
24 protected formValidatorService: FormValidatorService, 22 protected formValidatorService: FormValidatorService,
@@ -47,6 +45,7 @@ export class MyAccountVideoPlaylistCreateComponent extends MyAccountVideoPlaylis
47 }) 45 })
48 46
49 populateAsyncUserVideoChannels(this.authService, this.userVideoChannels) 47 populateAsyncUserVideoChannels(this.authService, this.userVideoChannels)
48 .catch(err => console.error('Cannot populate user video channels.', err))
50 49
51 this.serverService.videoPlaylistPrivaciesLoaded.subscribe( 50 this.serverService.videoPlaylistPrivaciesLoaded.subscribe(
52 () => { 51 () => {
diff --git a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.component.html b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.component.html
index 303fc46f7..82321459f 100644
--- a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.component.html
+++ b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.component.html
@@ -57,10 +57,12 @@
57 </div> 57 </div>
58 58
59 <div class="form-group"> 59 <div class="form-group">
60 <my-image-upload 60 <label i18n>Playlist thumbnail</label>
61 i18n-inputLabel inputLabel="Upload thumbnail" inputName="thumbnailfile" formControlName="thumbnailfile" 61
62 previewWidth="200px" previewHeight="110px" 62 <my-preview-upload
63 ></my-image-upload> 63 i18n-inputLabel inputLabel="Edit" inputName="thumbnailfile" formControlName="thumbnailfile"
64 previewWidth="223px" previewHeight="122px"
65 ></my-preview-upload>
64 </div> 66 </div>
65 </div> 67 </div>
66 </div> 68 </div>
diff --git a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.ts b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.ts
index fbfb4c8f7..e94188786 100644
--- a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.ts
+++ b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.ts
@@ -1,12 +1,12 @@
1import { FormReactive } from '@app/shared' 1import { FormReactive } from '@app/shared'
2import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
3import { ServerService } from '@app/core'
4import { VideoPlaylist } from '@shared/models/videos/playlist/video-playlist.model' 2import { VideoPlaylist } from '@shared/models/videos/playlist/video-playlist.model'
3import { VideoConstant, VideoPlaylistPrivacy } from '@shared/models'
5 4
6export abstract class MyAccountVideoPlaylistEdit extends FormReactive { 5export abstract class MyAccountVideoPlaylistEdit extends FormReactive {
7 // Declare it here to avoid errors in create template 6 // Declare it here to avoid errors in create template
8 videoPlaylistToUpdate: VideoPlaylist 7 videoPlaylistToUpdate: VideoPlaylist
9 userVideoChannels: { id: number, label: string }[] = [] 8 userVideoChannels: { id: number, label: string }[] = []
9 videoPlaylistPrivacies: VideoConstant<VideoPlaylistPrivacy>[] = []
10 10
11 abstract isCreation (): boolean 11 abstract isCreation (): boolean
12 abstract getFormButtonTitle (): string 12 abstract getFormButtonTitle (): string
diff --git a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-update.component.ts b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-update.component.ts
index 4887fdfb4..917ad7258 100644
--- a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-update.component.ts
+++ b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-update.component.ts
@@ -9,9 +9,8 @@ import { populateAsyncUserVideoChannels } from '@app/shared/misc/utils'
9import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service' 9import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service'
10import { VideoPlaylistValidatorsService } from '@app/shared' 10import { VideoPlaylistValidatorsService } from '@app/shared'
11import { VideoPlaylistUpdate } from '@shared/models/videos/playlist/video-playlist-update.model' 11import { VideoPlaylistUpdate } from '@shared/models/videos/playlist/video-playlist-update.model'
12import { VideoConstant } from '@shared/models'
13import { VideoPlaylistPrivacy } from '@shared/models/videos/playlist/video-playlist-privacy.model'
14import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model' 12import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model'
13import { delayWhen, map, switchMap } from 'rxjs/operators'
15 14
16@Component({ 15@Component({
17 selector: 'my-account-video-playlist-update', 16 selector: 'my-account-video-playlist-update',
@@ -21,7 +20,6 @@ import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model'
21export class MyAccountVideoPlaylistUpdateComponent extends MyAccountVideoPlaylistEdit implements OnInit, OnDestroy { 20export class MyAccountVideoPlaylistUpdateComponent extends MyAccountVideoPlaylistEdit implements OnInit, OnDestroy {
22 error: string 21 error: string
23 videoPlaylistToUpdate: VideoPlaylist 22 videoPlaylistToUpdate: VideoPlaylist
24 videoPlaylistPrivacies: VideoConstant<VideoPlaylistPrivacy>[] = []
25 23
26 private paramsSub: Subscription 24 private paramsSub: Subscription
27 25
@@ -53,31 +51,24 @@ export class MyAccountVideoPlaylistUpdateComponent extends MyAccountVideoPlaylis
53 }) 51 })
54 52
55 populateAsyncUserVideoChannels(this.authService, this.userVideoChannels) 53 populateAsyncUserVideoChannels(this.authService, this.userVideoChannels)
56 54 .catch(err => console.error('Cannot populate user video channels.', err))
57 this.paramsSub = this.route.params.subscribe(routeParams => { 55
58 const videoPlaylistId = routeParams['videoPlaylistId'] 56 this.paramsSub = this.route.params
59 57 .pipe(
60 this.videoPlaylistService.getVideoPlaylist(videoPlaylistId).subscribe( 58 map(routeParams => routeParams['videoPlaylistId']),
61 videoPlaylistToUpdate => { 59 switchMap(videoPlaylistId => this.videoPlaylistService.getVideoPlaylist(videoPlaylistId)),
62 this.videoPlaylistToUpdate = videoPlaylistToUpdate 60 delayWhen(() => this.serverService.videoPlaylistPrivaciesLoaded)
63 61 )
64 this.hydrateFormFromPlaylist() 62 .subscribe(
65 63 videoPlaylistToUpdate => {
66 this.serverService.videoPlaylistPrivaciesLoaded.subscribe( 64 this.videoPlaylistPrivacies = this.serverService.getVideoPlaylistPrivacies()
67 () => { 65 this.videoPlaylistToUpdate = videoPlaylistToUpdate
68 this.videoPlaylistPrivacies = this.serverService.getVideoPlaylistPrivacies() 66
69 .filter(p => { 67 this.hydrateFormFromPlaylist()
70 // If the playlist is not private, we cannot put it in private anymore 68 },
71 return this.videoPlaylistToUpdate.privacy.id === VideoPlaylistPrivacy.PRIVATE || 69
72 p.id !== VideoPlaylistPrivacy.PRIVATE 70 err => this.error = err.message
73 }) 71 )
74 }
75 )
76 },
77
78 err => this.error = err.message
79 )
80 })
81 } 72 }
82 73
83 ngOnDestroy () { 74 ngOnDestroy () {
diff --git a/client/src/app/+my-account/my-account-videos/my-account-videos.component.html b/client/src/app/+my-account/my-account-videos/my-account-videos.component.html
index 84d464800..2854093c4 100644
--- a/client/src/app/+my-account/my-account-videos/my-account-videos.component.html
+++ b/client/src/app/+my-account/my-account-videos/my-account-videos.component.html
@@ -20,7 +20,7 @@
20 <my-edit-button [routerLink]="[ '/videos', 'update', video.uuid ]"></my-edit-button> 20 <my-edit-button [routerLink]="[ '/videos', 'update', video.uuid ]"></my-edit-button>
21 21
22 <my-button i18n-label label="Change ownership" 22 <my-button i18n-label label="Change ownership"
23 className="action-button-change-ownership" 23 className="action-button-change-ownership grey-button"
24 icon="im-with-her" 24 icon="im-with-her"
25 (click)="changeOwnership($event, video)" 25 (click)="changeOwnership($event, video)"
26 ></my-button> 26 ></my-button>
diff --git a/client/src/app/+my-account/my-account.module.ts b/client/src/app/+my-account/my-account.module.ts
index 4a18a9968..ca5b1f7cb 100644
--- a/client/src/app/+my-account/my-account.module.ts
+++ b/client/src/app/+my-account/my-account.module.ts
@@ -36,6 +36,7 @@ import {
36 MyAccountVideoPlaylistElementsComponent 36 MyAccountVideoPlaylistElementsComponent
37} from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component' 37} from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component'
38import { DragDropModule } from '@angular/cdk/drag-drop' 38import { DragDropModule } from '@angular/cdk/drag-drop'
39import { MyAccountChangeEmailComponent } from '@app/+my-account/my-account-settings/my-account-change-email'
39 40
40@NgModule({ 41@NgModule({
41 imports: [ 42 imports: [
@@ -54,7 +55,10 @@ import { DragDropModule } from '@angular/cdk/drag-drop'
54 MyAccountChangePasswordComponent, 55 MyAccountChangePasswordComponent,
55 MyAccountVideoSettingsComponent, 56 MyAccountVideoSettingsComponent,
56 MyAccountProfileComponent, 57 MyAccountProfileComponent,
58 MyAccountChangeEmailComponent,
59
57 MyAccountVideosComponent, 60 MyAccountVideosComponent,
61
58 VideoChangeOwnershipComponent, 62 VideoChangeOwnershipComponent,
59 MyAccountOwnershipComponent, 63 MyAccountOwnershipComponent,
60 MyAccountAcceptOwnershipComponent, 64 MyAccountAcceptOwnershipComponent,
diff --git a/client/src/app/+signup/+register/custom-stepper.component.html b/client/src/app/+signup/+register/custom-stepper.component.html
new file mode 100644
index 000000000..bf507fc4f
--- /dev/null
+++ b/client/src/app/+signup/+register/custom-stepper.component.html
@@ -0,0 +1,25 @@
1<section class="container">
2 <header>
3 <ng-container *ngFor="let step of steps; let i = index; let isLast = last;">
4 <div
5 class="step-info" [ngClass]="{ active: selectedIndex === i, completed: isCompleted(step) }"
6 (click)="onClick(i)"
7 >
8 <div class="step-index">
9 <ng-container *ngIf="!isCompleted(step)">{{ i + 1 }}</ng-container>
10 <my-global-icon *ngIf="isCompleted(step)" iconName="tick"></my-global-icon>
11 </div>
12
13 <div class="step-label">{{ step.label }}</div>
14 </div>
15
16 <!-- Do no display if this is the last child -->
17 <div *ngIf="!isLast" class="connector"></div>
18 </ng-container>
19 </header>
20
21 <div [style.display]="selected ? 'block' : 'none'">
22 <ng-container [ngTemplateOutlet]="selected.content"></ng-container>
23 </div>
24
25</section>
diff --git a/client/src/app/+signup/+register/custom-stepper.component.scss b/client/src/app/+signup/+register/custom-stepper.component.scss
new file mode 100644
index 000000000..2371c8ae5
--- /dev/null
+++ b/client/src/app/+signup/+register/custom-stepper.component.scss
@@ -0,0 +1,66 @@
1@import '_variables';
2@import '_mixins';
3
4$grey-color: #9CA3AB;
5$index-block-height: 32px;
6
7header {
8 display: flex;
9 justify-content: space-between;
10 font-size: 15px;
11 margin-bottom: 30px;
12
13 .step-info {
14 color: $grey-color;
15 display: flex;
16 flex-direction: column;
17 align-items: center;
18 width: $index-block-height;
19
20 .step-index {
21 display: flex;
22 justify-content: center;
23 align-items: center;
24 width: $index-block-height;
25 height: $index-block-height;
26 border-radius: 100px;
27 border: 2px solid $grey-color;
28 margin-bottom: 10px;
29
30 my-global-icon {
31 @include apply-svg-color(var(--mainBackgroundColor));
32
33 width: 22px;
34 height: 22px;
35 }
36 }
37
38 .step-label {
39 width: max-content;
40 }
41
42 &.active,
43 &.completed {
44 .step-index {
45 border-color: var(--mainColor);
46 background-color: var(--mainColor);
47 color: var(--mainBackgroundColor);
48 }
49
50 .step-label {
51 color: var(--mainColor);
52 }
53 }
54
55 &.completed {
56 cursor: pointer;
57 }
58 }
59
60 .connector {
61 flex: auto;
62 margin: $index-block-height/2 10px 0 10px;
63 height: 2px;
64 background-color: $grey-color;
65 }
66}
diff --git a/client/src/app/+signup/+register/custom-stepper.component.ts b/client/src/app/+signup/+register/custom-stepper.component.ts
new file mode 100644
index 000000000..2ae40f3a9
--- /dev/null
+++ b/client/src/app/+signup/+register/custom-stepper.component.ts
@@ -0,0 +1,19 @@
1import { Component } from '@angular/core'
2import { CdkStep, CdkStepper } from '@angular/cdk/stepper'
3
4@Component({
5 selector: 'my-custom-stepper',
6 templateUrl: './custom-stepper.component.html',
7 styleUrls: [ './custom-stepper.component.scss' ],
8 providers: [ { provide: CdkStepper, useExisting: CustomStepperComponent } ]
9})
10export class CustomStepperComponent extends CdkStepper {
11
12 onClick (index: number): void {
13 this.selectedIndex = index
14 }
15
16 isCompleted (step: CdkStep) {
17 return step.stepControl && step.stepControl.dirty && step.stepControl.valid
18 }
19}
diff --git a/client/src/app/signup/signup-routing.module.ts b/client/src/app/+signup/+register/register-routing.module.ts
index 820d16d4d..e3a5001dc 100644
--- a/client/src/app/signup/signup-routing.module.ts
+++ b/client/src/app/+signup/+register/register-routing.module.ts
@@ -1,17 +1,18 @@
1import { NgModule } from '@angular/core' 1import { NgModule } from '@angular/core'
2import { RouterModule, Routes } from '@angular/router' 2import { RouterModule, Routes } from '@angular/router'
3import { MetaGuard } from '@ngx-meta/core' 3import { MetaGuard } from '@ngx-meta/core'
4import { SignupComponent } from './signup.component' 4import { RegisterComponent } from './register.component'
5import { ServerConfigResolver } from '@app/core/routing/server-config-resolver.service' 5import { ServerConfigResolver } from '@app/core/routing/server-config-resolver.service'
6import { UnloggedGuard } from '@app/core/routing/unlogged-guard.service'
6 7
7const signupRoutes: Routes = [ 8const registerRoutes: Routes = [
8 { 9 {
9 path: 'signup', 10 path: '',
10 component: SignupComponent, 11 component: RegisterComponent,
11 canActivate: [ MetaGuard ], 12 canActivate: [ MetaGuard, UnloggedGuard ],
12 data: { 13 data: {
13 meta: { 14 meta: {
14 title: 'Signup' 15 title: 'Register'
15 } 16 }
16 }, 17 },
17 resolve: { 18 resolve: {
@@ -21,7 +22,7 @@ const signupRoutes: Routes = [
21] 22]
22 23
23@NgModule({ 24@NgModule({
24 imports: [ RouterModule.forChild(signupRoutes) ], 25 imports: [ RouterModule.forChild(registerRoutes) ],
25 exports: [ RouterModule ] 26 exports: [ RouterModule ]
26}) 27})
27export class SignupRoutingModule {} 28export class RegisterRoutingModule {}
diff --git a/client/src/app/+signup/+register/register-step-channel.component.html b/client/src/app/+signup/+register/register-step-channel.component.html
new file mode 100644
index 000000000..253374f87
--- /dev/null
+++ b/client/src/app/+signup/+register/register-step-channel.component.html
@@ -0,0 +1,54 @@
1<form role="form" [formGroup]="form">
2
3 <div class="channel-explanations">
4 <p i18n>
5 A channel is an entity in which you upload your videos. Creating several of them helps you to organize and separate your content.<br />
6 For example, you could decide to have a channel to publish your piano concerts, and another channel in which you publish your videos talking about ecology.
7 </p>
8
9 <p>
10 Other users can decide to subscribe any channel they want, to be notified when you publish a new video.
11 </p>
12 </div>
13
14 <div class="form-group">
15 <label for="displayName" i18n>Channel display name</label>
16
17 <div class="input-group">
18 <input
19 type="text" id="displayName"
20 formControlName="displayName" [ngClass]="{ 'input-error': formErrors['displayName'] }"
21 >
22 </div>
23
24 <div *ngIf="formErrors.displayName" class="form-error">
25 {{ formErrors.displayName }}
26 </div>
27 </div>
28
29 <div class="form-group">
30 <label for="name" i18n>Channel name</label>
31
32 <div class="input-group">
33 <input
34 type="text" id="name" i18n-placeholder placeholder="Example: my_super_channel"
35 formControlName="name" [ngClass]="{ 'input-error': formErrors['name'] }"
36 >
37 <div class="input-group-append">
38 <span class="input-group-text">@{{ instanceHost }}</span>
39 </div>
40 </div>
41
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.
44 </div>
45
46 <div *ngIf="formErrors.name" class="form-error">
47 {{ formErrors.name }}
48 </div>
49
50 <div *ngIf="isSameThanUsername()" class="form-error" i18n>
51 Channel name cannot be the same than your account name. You can click on the first step to update your account name.
52 </div>
53 </div>
54</form>
diff --git a/client/src/app/+signup/+register/register-step-channel.component.ts b/client/src/app/+signup/+register/register-step-channel.component.ts
new file mode 100644
index 000000000..e434b91a7
--- /dev/null
+++ b/client/src/app/+signup/+register/register-step-channel.component.ts
@@ -0,0 +1,56 @@
1import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'
2import { AuthService } from '@app/core'
3import { FormReactive, UserService, VideoChannelValidatorsService } from '@app/shared'
4import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
5import { FormGroup } from '@angular/forms'
6import { pairwise } from 'rxjs/operators'
7import { concat, of } from 'rxjs'
8
9@Component({
10 selector: 'my-register-step-channel',
11 templateUrl: './register-step-channel.component.html',
12 styleUrls: [ './register.component.scss' ]
13})
14export class RegisterStepChannelComponent extends FormReactive implements OnInit {
15 @Input() username: string
16 @Output() formBuilt = new EventEmitter<FormGroup>()
17
18 constructor (
19 protected formValidatorService: FormValidatorService,
20 private authService: AuthService,
21 private userService: UserService,
22 private videoChannelValidatorsService: VideoChannelValidatorsService
23 ) {
24 super()
25 }
26
27 get instanceHost () {
28 return window.location.host
29 }
30
31 ngOnInit () {
32 this.buildForm({
33 displayName: this.videoChannelValidatorsService.VIDEO_CHANNEL_DISPLAY_NAME,
34 name: this.videoChannelValidatorsService.VIDEO_CHANNEL_NAME
35 })
36
37 setTimeout(() => this.formBuilt.emit(this.form))
38
39 concat(
40 of(''),
41 this.form.get('displayName').valueChanges
42 ).pipe(pairwise())
43 .subscribe(([ oldValue, newValue ]) => this.onDisplayNameChange(oldValue, newValue))
44 }
45
46 isSameThanUsername () {
47 return this.username && this.username === this.form.value['name']
48 }
49
50 private onDisplayNameChange (oldDisplayName: string, newDisplayName: string) {
51 const name = this.form.value['name'] || ''
52
53 const newName = this.userService.getNewUsername(oldDisplayName, newDisplayName, name)
54 this.form.patchValue({ name: newName })
55 }
56}
diff --git a/client/src/app/+signup/+register/register-step-user.component.html b/client/src/app/+signup/+register/register-step-user.component.html
new file mode 100644
index 000000000..47b3be8cc
--- /dev/null
+++ b/client/src/app/+signup/+register/register-step-user.component.html
@@ -0,0 +1,73 @@
1<form role="form" [formGroup]="form">
2
3 <div class="form-group">
4 <label for="displayName" i18n>Display name</label>
5
6 <div class="input-group">
7 <input
8 type="text" id="displayName" placeholder="John Doe"
9 formControlName="displayName" [ngClass]="{ 'input-error': formErrors['displayName'] }"
10 >
11 </div>
12
13 <div *ngIf="formErrors.displayName" class="form-error">
14 {{ formErrors.displayName }}
15 </div>
16 </div>
17
18 <div class="form-group">
19 <label for="username" i18n>Username</label>
20
21 <div class="input-group">
22 <input
23 type="text" id="username" i18n-placeholder placeholder="Example: jane_doe"
24 formControlName="username" [ngClass]="{ 'input-error': formErrors['username'] }"
25 >
26 <div class="input-group-append">
27 <span class="input-group-text">@{{ instanceHost }}</span>
28 </div>
29 </div>
30
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.
33 </div>
34
35 <div *ngIf="formErrors.username" class="form-error">
36 {{ formErrors.username }}
37 </div>
38 </div>
39
40 <div class="form-group">
41 <label for="email" i18n>Email</label>
42 <input
43 type="text" id="email" i18n-placeholder placeholder="Email"
44 formControlName="email" [ngClass]="{ 'input-error': formErrors['email'] }"
45 >
46 <div *ngIf="formErrors.email" class="form-error">
47 {{ formErrors.email }}
48 </div>
49 </div>
50
51 <div class="form-group">
52 <label for="password" i18n>Password</label>
53 <input
54 type="password" id="password" i18n-placeholder placeholder="Password"
55 formControlName="password" [ngClass]="{ 'input-error': formErrors['password'] }"
56 >
57 <div *ngIf="formErrors.password" class="form-error">
58 {{ formErrors.password }}
59 </div>
60 </div>
61
62 <div class="form-group form-group-terms">
63 <my-peertube-checkbox
64 inputName="terms" formControlName="terms"
65 i18n-labelHtml
66 labelHtml="I am at least 16 years old and agree to the <a href='/about/instance#terms-section' target='_blank'rel='noopener noreferrer'>Terms</a> of this instance"
67 ></my-peertube-checkbox>
68
69 <div *ngIf="formErrors.terms" class="form-error">
70 {{ formErrors.terms }}
71 </div>
72 </div>
73</form>
diff --git a/client/src/app/+signup/+register/register-step-user.component.ts b/client/src/app/+signup/+register/register-step-user.component.ts
new file mode 100644
index 000000000..3b71fd3c4
--- /dev/null
+++ b/client/src/app/+signup/+register/register-step-user.component.ts
@@ -0,0 +1,54 @@
1import { Component, EventEmitter, OnInit, Output } from '@angular/core'
2import { AuthService } from '@app/core'
3import { FormReactive, UserService, UserValidatorsService } from '@app/shared'
4import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
5import { FormGroup } from '@angular/forms'
6import { pairwise } from 'rxjs/operators'
7import { concat, of } from 'rxjs'
8
9@Component({
10 selector: 'my-register-step-user',
11 templateUrl: './register-step-user.component.html',
12 styleUrls: [ './register.component.scss' ]
13})
14export class RegisterStepUserComponent extends FormReactive implements OnInit {
15 @Output() formBuilt = new EventEmitter<FormGroup>()
16
17 constructor (
18 protected formValidatorService: FormValidatorService,
19 private authService: AuthService,
20 private userService: UserService,
21 private userValidatorsService: UserValidatorsService
22 ) {
23 super()
24 }
25
26 get instanceHost () {
27 return window.location.host
28 }
29
30 ngOnInit () {
31 this.buildForm({
32 displayName: this.userValidatorsService.USER_DISPLAY_NAME_REQUIRED,
33 username: this.userValidatorsService.USER_USERNAME,
34 password: this.userValidatorsService.USER_PASSWORD,
35 email: this.userValidatorsService.USER_EMAIL,
36 terms: this.userValidatorsService.USER_TERMS
37 })
38
39 setTimeout(() => this.formBuilt.emit(this.form))
40
41 concat(
42 of(''),
43 this.form.get('displayName').valueChanges
44 ).pipe(pairwise())
45 .subscribe(([ oldValue, newValue ]) => this.onDisplayNameChange(oldValue, newValue))
46 }
47
48 private onDisplayNameChange (oldDisplayName: string, newDisplayName: string) {
49 const username = this.form.value['username'] || ''
50
51 const newUsername = this.userService.getNewUsername(oldDisplayName, newDisplayName, username)
52 this.form.patchValue({ username: newUsername })
53 }
54}
diff --git a/client/src/app/+signup/+register/register.component.html b/client/src/app/+signup/+register/register.component.html
new file mode 100644
index 000000000..d7e47c1a8
--- /dev/null
+++ b/client/src/app/+signup/+register/register.component.html
@@ -0,0 +1,47 @@
1<div class="margin-content">
2
3 <div i18n class="title-page title-page-single">
4 Create an account
5 </div>
6
7 <my-signup-success *ngIf="signupDone" [message]="success"></my-signup-success>
8 <div *ngIf="info" class="alert alert-info">{{ info }}</div>
9
10 <div class="wrapper" *ngIf="!signupDone">
11 <div>
12 <my-custom-stepper linear *ngIf="!signupDone">
13 <cdk-step [stepControl]="formStepUser" i18n-label label="User information">
14 <my-register-step-user (formBuilt)="onUserFormBuilt($event)"></my-register-step-user>
15
16 <button i18n cdkStepperNext [disabled]="!formStepUser || !formStepUser.valid">Next</button>
17 </cdk-step>
18
19 <cdk-step [stepControl]="formStepChannel" i18n-label label="Channel information">
20 <my-register-step-channel (formBuilt)="onChannelFormBuilt($event)" [username]="getUsername()"></my-register-step-channel>
21
22 <button i18n cdkStepperNext (click)="signup()"
23 [disabled]="!formStepChannel || !formStepChannel.valid || hasSameChannelAndAccountNames()"
24 >
25 Create my account
26 </button>
27 </cdk-step>
28
29 <cdk-step i18n-label label="Done" editable="false">
30 <div *ngIf="!signupDone && !error" class="done-loader">
31 <my-loader [loading]="true"></my-loader>
32
33 <div i18n>PeerTube is creating your account...</div>
34 </div>
35
36 <div *ngIf="error" class="alert alert-danger">{{ error }}</div>
37 </cdk-step>
38 </my-custom-stepper>
39 </div>
40
41 <div>
42 <label i18n>Features found on this instance</label>
43 <my-instance-features-table></my-instance-features-table>
44 </div>
45 </div>
46
47</div>
diff --git a/client/src/app/+signup/+register/register.component.scss b/client/src/app/+signup/+register/register.component.scss
new file mode 100644
index 000000000..8d14992e7
--- /dev/null
+++ b/client/src/app/+signup/+register/register.component.scss
@@ -0,0 +1,81 @@
1@import '_variables';
2@import '_mixins';
3
4.alert {
5 font-size: 15px;
6 text-align: center;
7}
8
9.wrapper {
10 display: flex;
11 justify-content: space-between;
12 flex-wrap: wrap;
13
14 & > div {
15 margin-bottom: 40px;
16 width: 450px;
17
18 @media screen and (max-width: 500px) {
19 width: auto;
20 }
21 }
22}
23
24my-instance-features-table {
25 display: block;
26
27 margin-bottom: 40px;
28}
29
30.form-group-terms {
31 margin: 30px 0;
32}
33
34.input-group {
35 @include peertube-input-group(400px);
36}
37
38.input-group-append {
39 height: 30px;
40}
41
42input:not([type=submit]) {
43 @include peertube-input-text(400px);
44
45 display: block;
46
47 &#username,
48 &#name {
49 width: auto !important;
50 flex-grow: 1;
51 }
52}
53
54input[type=submit],
55button {
56 @include peertube-button;
57 @include orange-button;
58}
59
60.name-information {
61 margin-top: 10px;
62}
63
64.done-loader {
65 display: flex;
66 justify-content: center;
67 flex-direction: column;
68 align-items: center;
69
70 my-loader {
71 margin-bottom: 20px;
72
73 /deep/ .loader div {
74 border-color: var(--mainColor) transparent transparent transparent;
75 }
76
77 & + div {
78 font-size: 15px;
79 }
80 }
81}
diff --git a/client/src/app/+signup/+register/register.component.ts b/client/src/app/+signup/+register/register.component.ts
new file mode 100644
index 000000000..cd6059728
--- /dev/null
+++ b/client/src/app/+signup/+register/register.component.ts
@@ -0,0 +1,89 @@
1import { Component } from '@angular/core'
2import { AuthService, Notifier, RedirectService, ServerService } from '@app/core'
3import { UserService, UserValidatorsService } from '@app/shared'
4import { I18n } from '@ngx-translate/i18n-polyfill'
5import { UserRegister } from '@shared/models/users/user-register.model'
6import { FormGroup } from '@angular/forms'
7
8@Component({
9 selector: 'my-register',
10 templateUrl: './register.component.html',
11 styleUrls: [ './register.component.scss' ]
12})
13export class RegisterComponent {
14 info: string = null
15 error: string = null
16 success: string = null
17 signupDone = false
18
19 formStepUser: FormGroup
20 formStepChannel: FormGroup
21
22 constructor (
23 private authService: AuthService,
24 private userValidatorsService: UserValidatorsService,
25 private notifier: Notifier,
26 private userService: UserService,
27 private serverService: ServerService,
28 private redirectService: RedirectService,
29 private i18n: I18n
30 ) {
31 }
32
33 get requiresEmailVerification () {
34 return this.serverService.getConfig().signup.requiresEmailVerification
35 }
36
37 hasSameChannelAndAccountNames () {
38 return this.getUsername() === this.getChannelName()
39 }
40
41 getUsername () {
42 if (!this.formStepUser) return undefined
43
44 return this.formStepUser.value['username']
45 }
46
47 getChannelName () {
48 if (!this.formStepChannel) return undefined
49
50 return this.formStepChannel.value['name']
51 }
52
53 onUserFormBuilt (form: FormGroup) {
54 this.formStepUser = form
55 }
56
57 onChannelFormBuilt (form: FormGroup) {
58 this.formStepChannel = form
59 }
60
61 signup () {
62 this.error = null
63
64 const body: UserRegister = Object.assign(this.formStepUser.value, { channel: this.formStepChannel.value })
65
66 this.userService.signup(body).subscribe(
67 () => {
68 this.signupDone = true
69
70 if (this.requiresEmailVerification) {
71 this.info = this.i18n('Now please check your emails to verify your account and complete signup.')
72 return
73 }
74
75 // Auto login
76 this.authService.login(body.username, body.password)
77 .subscribe(
78 () => {
79 this.success = this.i18n('You are now logged in as {{username}}!', { username: body.username })
80 },
81
82 err => this.error = err.message
83 )
84 },
85
86 err => this.error = err.message
87 )
88 }
89}
diff --git a/client/src/app/+signup/+register/register.module.ts b/client/src/app/+signup/+register/register.module.ts
new file mode 100644
index 000000000..46336cbd0
--- /dev/null
+++ b/client/src/app/+signup/+register/register.module.ts
@@ -0,0 +1,33 @@
1import { NgModule } from '@angular/core'
2import { RegisterRoutingModule } from './register-routing.module'
3import { RegisterComponent } from './register.component'
4import { SharedModule } from '@app/shared'
5import { CdkStepperModule } from '@angular/cdk/stepper'
6import { RegisterStepChannelComponent } from './register-step-channel.component'
7import { RegisterStepUserComponent } from './register-step-user.component'
8import { CustomStepperComponent } from './custom-stepper.component'
9import { SignupSharedModule } from '@app/+signup/shared/signup-shared.module'
10
11@NgModule({
12 imports: [
13 RegisterRoutingModule,
14 SharedModule,
15 CdkStepperModule,
16 SignupSharedModule
17 ],
18
19 declarations: [
20 RegisterComponent,
21 CustomStepperComponent,
22 RegisterStepChannelComponent,
23 RegisterStepUserComponent
24 ],
25
26 exports: [
27 RegisterComponent
28 ],
29
30 providers: [
31 ]
32})
33export class RegisterModule { }
diff --git a/client/src/app/+verify-account/verify-account-ask-send-email/verify-account-ask-send-email.component.html b/client/src/app/+signup/+verify-account/verify-account-ask-send-email/verify-account-ask-send-email.component.html
index 2e4180632..2e4180632 100644
--- a/client/src/app/+verify-account/verify-account-ask-send-email/verify-account-ask-send-email.component.html
+++ b/client/src/app/+signup/+verify-account/verify-account-ask-send-email/verify-account-ask-send-email.component.html
diff --git a/client/src/app/+verify-account/verify-account-ask-send-email/verify-account-ask-send-email.component.scss b/client/src/app/+signup/+verify-account/verify-account-ask-send-email/verify-account-ask-send-email.component.scss
index efec6b706..efec6b706 100644
--- a/client/src/app/+verify-account/verify-account-ask-send-email/verify-account-ask-send-email.component.scss
+++ b/client/src/app/+signup/+verify-account/verify-account-ask-send-email/verify-account-ask-send-email.component.scss
diff --git a/client/src/app/+verify-account/verify-account-ask-send-email/verify-account-ask-send-email.component.ts b/client/src/app/+signup/+verify-account/verify-account-ask-send-email/verify-account-ask-send-email.component.ts
index cfd471fa4..cfd471fa4 100644
--- a/client/src/app/+verify-account/verify-account-ask-send-email/verify-account-ask-send-email.component.ts
+++ b/client/src/app/+signup/+verify-account/verify-account-ask-send-email/verify-account-ask-send-email.component.ts
diff --git a/client/src/app/+signup/+verify-account/verify-account-email/verify-account-email.component.html b/client/src/app/+signup/+verify-account/verify-account-email/verify-account-email.component.html
new file mode 100644
index 000000000..47519c943
--- /dev/null
+++ b/client/src/app/+signup/+verify-account/verify-account-email/verify-account-email.component.html
@@ -0,0 +1,18 @@
1<div class="margin-content">
2 <div i18n class="title-page title-page-single">
3 Verify account email confirmation
4 </div>
5
6 <my-signup-success i18n *ngIf="!isPendingEmail && success" message="Your email has been verified and you may now login.">
7 </my-signup-success>
8
9 <div i18n class="alert alert-success" *ngIf="isPendingEmail && success">
10 Email updated.
11 </div>
12
13 <div *ngIf="failed">
14 <span i18n>An error occurred.</span>
15
16 <a i18n routerLink="/verify-account/ask-send-email" [queryParams]="{ isPendingEmail: isPendingEmail }">Request new verification email.</a>
17 </div>
18</div>
diff --git a/client/src/app/+verify-account/verify-account-email/verify-account-email.component.ts b/client/src/app/+signup/+verify-account/verify-account-email/verify-account-email.component.ts
index f9ecf664b..054f04310 100644
--- a/client/src/app/+verify-account/verify-account-email/verify-account-email.component.ts
+++ b/client/src/app/+signup/+verify-account/verify-account-email/verify-account-email.component.ts
@@ -1,7 +1,7 @@
1import { Component, OnInit } from '@angular/core' 1import { Component, OnInit } from '@angular/core'
2import { ActivatedRoute, Router } from '@angular/router' 2import { ActivatedRoute, Router } from '@angular/router'
3import { I18n } from '@ngx-translate/i18n-polyfill' 3import { I18n } from '@ngx-translate/i18n-polyfill'
4import { Notifier } from '@app/core' 4import { AuthService, Notifier } from '@app/core'
5import { UserService } from '@app/shared' 5import { UserService } from '@app/shared'
6 6
7@Component({ 7@Component({
@@ -11,12 +11,15 @@ import { UserService } from '@app/shared'
11 11
12export class VerifyAccountEmailComponent implements OnInit { 12export class VerifyAccountEmailComponent implements OnInit {
13 success = false 13 success = false
14 failed = false
15 isPendingEmail = false
14 16
15 private userId: number 17 private userId: number
16 private verificationString: string 18 private verificationString: string
17 19
18 constructor ( 20 constructor (
19 private userService: UserService, 21 private userService: UserService,
22 private authService: AuthService,
20 private notifier: Notifier, 23 private notifier: Notifier,
21 private router: Router, 24 private router: Router,
22 private route: ActivatedRoute, 25 private route: ActivatedRoute,
@@ -25,8 +28,12 @@ export class VerifyAccountEmailComponent implements OnInit {
25 } 28 }
26 29
27 ngOnInit () { 30 ngOnInit () {
28 this.userId = this.route.snapshot.queryParams['userId'] 31 const queryParams = this.route.snapshot.queryParams
29 this.verificationString = this.route.snapshot.queryParams['verificationString'] 32 this.userId = queryParams['userId']
33 this.verificationString = queryParams['verificationString']
34 this.isPendingEmail = queryParams['isPendingEmail'] === 'true'
35
36 console.log(this.isPendingEmail)
30 37
31 if (!this.userId || !this.verificationString) { 38 if (!this.userId || !this.verificationString) {
32 this.notifier.error(this.i18n('Unable to find user id or verification string.')) 39 this.notifier.error(this.i18n('Unable to find user id or verification string.'))
@@ -36,16 +43,17 @@ export class VerifyAccountEmailComponent implements OnInit {
36 } 43 }
37 44
38 verifyEmail () { 45 verifyEmail () {
39 this.userService.verifyEmail(this.userId, this.verificationString) 46 this.userService.verifyEmail(this.userId, this.verificationString, this.isPendingEmail)
40 .subscribe( 47 .subscribe(
41 () => { 48 () => {
49 this.authService.refreshUserInformation()
50
42 this.success = true 51 this.success = true
43 setTimeout(() => {
44 this.router.navigate([ '/login' ])
45 }, 2000)
46 }, 52 },
47 53
48 err => { 54 err => {
55 this.failed = true
56
49 this.notifier.error(err.message) 57 this.notifier.error(err.message)
50 } 58 }
51 ) 59 )
diff --git a/client/src/app/+verify-account/verify-account-routing.module.ts b/client/src/app/+signup/+verify-account/verify-account-routing.module.ts
index a038f0336..16d5fe0d0 100644
--- a/client/src/app/+verify-account/verify-account-routing.module.ts
+++ b/client/src/app/+signup/+verify-account/verify-account-routing.module.ts
@@ -1,12 +1,8 @@
1import { NgModule } from '@angular/core' 1import { NgModule } from '@angular/core'
2import { RouterModule, Routes } from '@angular/router' 2import { RouterModule, Routes } from '@angular/router'
3
4import { MetaGuard } from '@ngx-meta/core' 3import { MetaGuard } from '@ngx-meta/core'
5 4import { VerifyAccountEmailComponent } from './verify-account-email/verify-account-email.component'
6import { VerifyAccountEmailComponent } from '@app/+verify-account/verify-account-email/verify-account-email.component' 5import { VerifyAccountAskSendEmailComponent } from './verify-account-ask-send-email/verify-account-ask-send-email.component'
7import {
8 VerifyAccountAskSendEmailComponent
9} from '@app/+verify-account/verify-account-ask-send-email/verify-account-ask-send-email.component'
10 6
11const verifyAccountRoutes: Routes = [ 7const verifyAccountRoutes: Routes = [
12 { 8 {
diff --git a/client/src/app/+signup/+verify-account/verify-account.module.ts b/client/src/app/+signup/+verify-account/verify-account.module.ts
new file mode 100644
index 000000000..9fe14e81e
--- /dev/null
+++ b/client/src/app/+signup/+verify-account/verify-account.module.ts
@@ -0,0 +1,25 @@
1import { NgModule } from '@angular/core'
2import { VerifyAccountRoutingModule } from './verify-account-routing.module'
3import { VerifyAccountEmailComponent } from './verify-account-email/verify-account-email.component'
4import { VerifyAccountAskSendEmailComponent } from './verify-account-ask-send-email/verify-account-ask-send-email.component'
5import { SharedModule } from '@app/shared'
6import { SignupSharedModule } from '@app/+signup/shared/signup-shared.module'
7
8@NgModule({
9 imports: [
10 VerifyAccountRoutingModule,
11 SharedModule,
12 SignupSharedModule
13 ],
14
15 declarations: [
16 VerifyAccountEmailComponent,
17 VerifyAccountAskSendEmailComponent
18 ],
19
20 exports: [],
21
22 providers: []
23})
24export class VerifyAccountModule {
25}
diff --git a/client/src/app/+signup/shared/signup-shared.module.ts b/client/src/app/+signup/shared/signup-shared.module.ts
new file mode 100644
index 000000000..cd21fdef3
--- /dev/null
+++ b/client/src/app/+signup/shared/signup-shared.module.ts
@@ -0,0 +1,21 @@
1import { NgModule } from '@angular/core'
2import { SignupSuccessComponent } from '../shared/signup-success.component'
3import { SharedModule } from '@app/shared'
4
5@NgModule({
6 imports: [
7 SharedModule
8 ],
9
10 declarations: [
11 SignupSuccessComponent
12 ],
13
14 exports: [
15 SignupSuccessComponent
16 ],
17
18 providers: [
19 ]
20})
21export class SignupSharedModule { }
diff --git a/client/src/app/+signup/shared/signup-success.component.html b/client/src/app/+signup/shared/signup-success.component.html
new file mode 100644
index 000000000..e35f858c6
--- /dev/null
+++ b/client/src/app/+signup/shared/signup-success.component.html
@@ -0,0 +1,16 @@
1<!-- Thanks: Amit Singh Sansoya from https://codepen.io/amit3200/pen/zWMJOO -->
2
3<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 130.2 130.2">
4 <circle class="path circle" fill="none" stroke="#73AF55" stroke-width="6" stroke-miterlimit="10" cx="65.1" cy="65.1" r="62.1"/>
5 <polyline class="path check" fill="none" stroke="#73AF55" stroke-width="6" stroke-linecap="round" stroke-miterlimit="10" points="100.2,40.2 51.5,88.8 29.8,67.5 "/>
6</svg>
7
8<p class="bottom-message">Welcome on PeerTube!</p>
9
10<div *ngIf="message" class="alert alert-success">
11 <p>{{ message }}</p>
12
13 <p i18n>
14 If you need help to use PeerTube, you can take a look to the <a href="https://docs.joinpeertube.org/#/use-setup-account" target="_blank" rel="noopener noreferrer">documentation</a>.
15 </p>
16</div>
diff --git a/client/src/app/+signup/shared/signup-success.component.scss b/client/src/app/+signup/shared/signup-success.component.scss
new file mode 100644
index 000000000..fbc27c8bc
--- /dev/null
+++ b/client/src/app/+signup/shared/signup-success.component.scss
@@ -0,0 +1,76 @@
1svg {
2 width: 100px;
3 display: block;
4 margin: 40px auto 0;
5}
6
7.path {
8 stroke-dasharray: 1000;
9 stroke-dashoffset: 0;
10
11 &.circle {
12 -webkit-animation: dash .9s ease-in-out;
13 animation: dash .9s ease-in-out;
14 }
15
16 &.line {
17 stroke-dashoffset: 1000;
18 -webkit-animation: dash .9s .35s ease-in-out forwards;
19 animation: dash .9s .35s ease-in-out forwards;
20 }
21
22 &.check {
23 stroke-dashoffset: -100;
24 -webkit-animation: dash-check .9s .35s ease-in-out forwards;
25 animation: dash-check .9s .35s ease-in-out forwards;
26 }
27}
28
29.bottom-message {
30 text-align: center;
31 margin: 20px 0 60px;
32 font-size: 1.25em;
33 color: #73AF55;
34}
35
36.alert {
37 font-size: 15px;
38 text-align: center;
39}
40
41
42@-webkit-keyframes dash {
43 0% {
44 stroke-dashoffset: 1000;
45 }
46 100% {
47 stroke-dashoffset: 0;
48 }
49}
50
51@keyframes dash {
52 0% {
53 stroke-dashoffset: 1000;
54 }
55 100% {
56 stroke-dashoffset: 0;
57 }
58}
59
60@-webkit-keyframes dash-check {
61 0% {
62 stroke-dashoffset: -100;
63 }
64 100% {
65 stroke-dashoffset: 900;
66 }
67}
68
69@keyframes dash-check {
70 0% {
71 stroke-dashoffset: -100;
72 }
73 100% {
74 stroke-dashoffset: 900;
75 }
76}
diff --git a/client/src/app/+signup/shared/signup-success.component.ts b/client/src/app/+signup/shared/signup-success.component.ts
new file mode 100644
index 000000000..19fb5922a
--- /dev/null
+++ b/client/src/app/+signup/shared/signup-success.component.ts
@@ -0,0 +1,10 @@
1import { Component, Input } from '@angular/core'
2
3@Component({
4 selector: 'my-signup-success',
5 templateUrl: './signup-success.component.html',
6 styleUrls: [ './signup-success.component.scss' ]
7})
8export class SignupSuccessComponent {
9 @Input() message: string
10}
diff --git a/client/src/app/+verify-account/index.ts b/client/src/app/+verify-account/index.ts
deleted file mode 100644
index 733f5ba77..000000000
--- a/client/src/app/+verify-account/index.ts
+++ /dev/null
@@ -1,2 +0,0 @@
1export * from '@app/+verify-account/verify-account-routing.module'
2export * from '@app/+verify-account/verify-account.module'
diff --git a/client/src/app/+verify-account/verify-account-email/verify-account-email.component.html b/client/src/app/+verify-account/verify-account-email/verify-account-email.component.html
deleted file mode 100644
index a83d4a3c2..000000000
--- a/client/src/app/+verify-account/verify-account-email/verify-account-email.component.html
+++ /dev/null
@@ -1,15 +0,0 @@
1<div class="margin-content">
2 <div i18n class="title-page title-page-single">
3 Verify account email confirmation
4 </div>
5
6 <div i18n *ngIf="success; else verificationError">
7 Your email has been verified and you may now login. Redirecting...
8 </div>
9 <ng-template #verificationError>
10 <div>
11 <span i18n>An error occurred. </span>
12 <a i18n routerLink="/verify-account/ask-send-email">Request new verification email.</a>
13 </div>
14 </ng-template>
15</div>
diff --git a/client/src/app/+verify-account/verify-account.module.ts b/client/src/app/+verify-account/verify-account.module.ts
deleted file mode 100644
index 9092c6b4f..000000000
--- a/client/src/app/+verify-account/verify-account.module.ts
+++ /dev/null
@@ -1,27 +0,0 @@
1import { NgModule } from '@angular/core'
2
3import { VerifyAccountRoutingModule } from '@app/+verify-account/verify-account-routing.module'
4import { VerifyAccountEmailComponent } from '@app/+verify-account/verify-account-email/verify-account-email.component'
5import {
6 VerifyAccountAskSendEmailComponent
7} from '@app/+verify-account/verify-account-ask-send-email/verify-account-ask-send-email.component'
8import { SharedModule } from '@app/shared'
9
10@NgModule({
11 imports: [
12 VerifyAccountRoutingModule,
13 SharedModule
14 ],
15
16 declarations: [
17 VerifyAccountEmailComponent,
18 VerifyAccountAskSendEmailComponent
19 ],
20
21 exports: [
22 ],
23
24 providers: [
25 ]
26})
27export class VerifyAccountModule { }
diff --git a/client/src/app/+video-channels/video-channel-playlists/video-channel-playlists.component.ts b/client/src/app/+video-channels/video-channel-playlists/video-channel-playlists.component.ts
index 907aefae1..7990044a2 100644
--- a/client/src/app/+video-channels/video-channel-playlists/video-channel-playlists.component.ts
+++ b/client/src/app/+video-channels/video-channel-playlists/video-channel-playlists.component.ts
@@ -5,7 +5,7 @@ import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
5import { Subscription } from 'rxjs' 5import { Subscription } from 'rxjs'
6import { Notifier } from '@app/core' 6import { Notifier } from '@app/core'
7import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model' 7import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model'
8import { ComponentPagination } from '@app/shared/rest/component-pagination.model' 8import { ComponentPagination, hasMoreItems } from '@app/shared/rest/component-pagination.model'
9import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service' 9import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service'
10 10
11@Component({ 11@Component({
@@ -46,8 +46,7 @@ export class VideoChannelPlaylistsComponent implements OnInit, OnDestroy {
46 } 46 }
47 47
48 onNearOfBottom () { 48 onNearOfBottom () {
49 // Last page 49 if (!hasMoreItems(this.pagination)) return
50 if (this.pagination.totalItems <= (this.pagination.currentPage * this.pagination.itemsPerPage)) return
51 50
52 this.pagination.currentPage += 1 51 this.pagination.currentPage += 1
53 this.loadVideoPlaylists() 52 this.loadVideoPlaylists()
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 5e60b34b4..629fd4450 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
@@ -29,6 +29,7 @@ export class VideoChannelVideosComponent extends AbstractVideoList implements On
29 private videoChannelSub: Subscription 29 private videoChannelSub: Subscription
30 30
31 constructor ( 31 constructor (
32 protected i18n: I18n,
32 protected router: Router, 33 protected router: Router,
33 protected serverService: ServerService, 34 protected serverService: ServerService,
34 protected route: ActivatedRoute, 35 protected route: ActivatedRoute,
@@ -36,7 +37,6 @@ export class VideoChannelVideosComponent extends AbstractVideoList implements On
36 protected notifier: Notifier, 37 protected notifier: Notifier,
37 protected confirmService: ConfirmService, 38 protected confirmService: ConfirmService,
38 protected screenService: ScreenService, 39 protected screenService: ScreenService,
39 private i18n: I18n,
40 private videoChannelService: VideoChannelService, 40 private videoChannelService: VideoChannelService,
41 private videoService: VideoService 41 private videoService: VideoService
42 ) { 42 ) {
diff --git a/client/src/app/app-routing.module.ts b/client/src/app/app-routing.module.ts
index db8888dba..7ca51f226 100644
--- a/client/src/app/app-routing.module.ts
+++ b/client/src/app/app-routing.module.ts
@@ -16,7 +16,7 @@ const routes: Routes = [
16 }, 16 },
17 { 17 {
18 path: 'verify-account', 18 path: 'verify-account',
19 loadChildren: './+verify-account/verify-account.module#VerifyAccountModule' 19 loadChildren: './+signup/+verify-account/verify-account.module#VerifyAccountModule'
20 }, 20 },
21 { 21 {
22 path: 'accounts', 22 path: 'accounts',
@@ -31,6 +31,10 @@ const routes: Routes = [
31 loadChildren: './+about/about.module#AboutModule' 31 loadChildren: './+about/about.module#AboutModule'
32 }, 32 },
33 { 33 {
34 path: 'signup',
35 loadChildren: './+signup/+register/register.module#RegisterModule'
36 },
37 {
34 path: '', 38 path: '',
35 component: AppComponent // Avoid 404, app component will redirect dynamically 39 component: AppComponent // Avoid 404, app component will redirect dynamically
36 }, 40 },
diff --git a/client/src/app/app.module.ts b/client/src/app/app.module.ts
index 0bbc2e08b..1e2936a37 100644
--- a/client/src/app/app.module.ts
+++ b/client/src/app/app.module.ts
@@ -14,7 +14,6 @@ import { HeaderComponent } from './header'
14import { LoginModule } from './login' 14import { LoginModule } from './login'
15import { AvatarNotificationComponent, LanguageChooserComponent, MenuComponent } from './menu' 15import { AvatarNotificationComponent, LanguageChooserComponent, MenuComponent } from './menu'
16import { SharedModule } from './shared' 16import { SharedModule } from './shared'
17import { SignupModule } from './signup'
18import { VideosModule } from './videos' 17import { VideosModule } from './videos'
19import { buildFileLocale, getCompleteLocale, isDefaultLocale } from '../../../shared/models/i18n' 18import { buildFileLocale, getCompleteLocale, isDefaultLocale } from '../../../shared/models/i18n'
20import { getDevLocale, isOnDevLocale } from '@app/shared/i18n/i18n-utils' 19import { getDevLocale, isOnDevLocale } from '@app/shared/i18n/i18n-utils'
@@ -53,7 +52,6 @@ export function metaFactory (serverService: ServerService): MetaLoader {
53 CoreModule, 52 CoreModule,
54 LoginModule, 53 LoginModule,
55 ResetPasswordModule, 54 ResetPasswordModule,
56 SignupModule,
57 SearchModule, 55 SearchModule,
58 SharedModule, 56 SharedModule,
59 VideosModule, 57 VideosModule,
diff --git a/client/src/app/core/core.module.ts b/client/src/app/core/core.module.ts
index d3e72afb4..06fa8fcf1 100644
--- a/client/src/app/core/core.module.ts
+++ b/client/src/app/core/core.module.ts
@@ -20,6 +20,7 @@ import { Notifier } from './notification'
20import { MessageService } from 'primeng/api' 20import { MessageService } from 'primeng/api'
21import { UserNotificationSocket } from '@app/core/notification/user-notification-socket.service' 21import { UserNotificationSocket } from '@app/core/notification/user-notification-socket.service'
22import { ServerConfigResolver } from './routing/server-config-resolver.service' 22import { ServerConfigResolver } from './routing/server-config-resolver.service'
23import { UnloggedGuard } from '@app/core/routing/unlogged-guard.service'
23 24
24@NgModule({ 25@NgModule({
25 imports: [ 26 imports: [
@@ -58,6 +59,8 @@ import { ServerConfigResolver } from './routing/server-config-resolver.service'
58 ThemeService, 59 ThemeService,
59 LoginGuard, 60 LoginGuard,
60 UserRightGuard, 61 UserRightGuard,
62 UnloggedGuard,
63
61 RedirectService, 64 RedirectService,
62 Notifier, 65 Notifier,
63 MessageService, 66 MessageService,
diff --git a/client/src/app/core/routing/redirect.service.ts b/client/src/app/core/routing/redirect.service.ts
index e1db4097b..571822b76 100644
--- a/client/src/app/core/routing/redirect.service.ts
+++ b/client/src/app/core/routing/redirect.service.ts
@@ -42,7 +42,14 @@ export class RedirectService {
42 } 42 }
43 43
44 redirectToPreviousRoute () { 44 redirectToPreviousRoute () {
45 if (this.previousUrl) return this.router.navigateByUrl(this.previousUrl) 45 const exceptions = [
46 '/verify-account'
47 ]
48
49 if (this.previousUrl) {
50 const isException = exceptions.find(e => this.previousUrl.startsWith(e))
51 if (!isException) return this.router.navigateByUrl(this.previousUrl)
52 }
46 53
47 return this.redirectToHomepage() 54 return this.redirectToHomepage()
48 } 55 }
diff --git a/client/src/app/core/routing/unlogged-guard.service.ts b/client/src/app/core/routing/unlogged-guard.service.ts
new file mode 100644
index 000000000..3132a1a77
--- /dev/null
+++ b/client/src/app/core/routing/unlogged-guard.service.ts
@@ -0,0 +1,25 @@
1import { Injectable } from '@angular/core'
2import { ActivatedRouteSnapshot, CanActivate, CanActivateChild, Router, RouterStateSnapshot } from '@angular/router'
3import { AuthService } from '../auth/auth.service'
4import { RedirectService } from './redirect.service'
5
6@Injectable()
7export class UnloggedGuard implements CanActivate, CanActivateChild {
8
9 constructor (
10 private router: Router,
11 private auth: AuthService,
12 private redirectService: RedirectService
13 ) {}
14
15 canActivate (route: ActivatedRouteSnapshot, state: RouterStateSnapshot) {
16 if (this.auth.isLoggedIn() === false) return true
17
18 this.redirectService.redirectToHomepage()
19 return false
20 }
21
22 canActivateChild (route: ActivatedRouteSnapshot, state: RouterStateSnapshot) {
23 return this.canActivate(route, state)
24 }
25}
diff --git a/client/src/app/core/server/server.service.ts b/client/src/app/core/server/server.service.ts
index 3a8a535fd..689f25a40 100644
--- a/client/src/app/core/server/server.service.ts
+++ b/client/src/app/core/server/server.service.ts
@@ -10,6 +10,7 @@ import { isDefaultLocale, peertubeTranslate } from '../../../../../shared/models
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 { VideoPlaylistPrivacy } from '@shared/models/videos/playlist/video-playlist-privacy.model' 12import { VideoPlaylistPrivacy } from '@shared/models/videos/playlist/video-playlist-privacy.model'
13import { cloneDeep } from 'lodash-es'
13 14
14@Injectable() 15@Injectable()
15export class ServerService { 16export class ServerService {
@@ -160,27 +161,27 @@ export class ServerService {
160 } 161 }
161 162
162 getConfig () { 163 getConfig () {
163 return this.config 164 return cloneDeep(this.config)
164 } 165 }
165 166
166 getVideoCategories () { 167 getVideoCategories () {
167 return this.videoCategories 168 return cloneDeep(this.videoCategories)
168 } 169 }
169 170
170 getVideoLicences () { 171 getVideoLicences () {
171 return this.videoLicences 172 return cloneDeep(this.videoLicences)
172 } 173 }
173 174
174 getVideoLanguages () { 175 getVideoLanguages () {
175 return this.videoLanguages 176 return cloneDeep(this.videoLanguages)
176 } 177 }
177 178
178 getVideoPrivacies () { 179 getVideoPrivacies () {
179 return this.videoPrivacies 180 return cloneDeep(this.videoPrivacies)
180 } 181 }
181 182
182 getVideoPlaylistPrivacies () { 183 getVideoPlaylistPrivacies () {
183 return this.videoPlaylistPrivacies 184 return cloneDeep(this.videoPlaylistPrivacies)
184 } 185 }
185 186
186 private loadAttributeEnum ( 187 private loadAttributeEnum (
diff --git a/client/src/app/menu/menu.component.html b/client/src/app/menu/menu.component.html
index e80e6b803..588cb8548 100644
--- a/client/src/app/menu/menu.component.html
+++ b/client/src/app/menu/menu.component.html
@@ -63,7 +63,7 @@
63 63
64 <a routerLink="/videos/overview" routerLinkActive="active"> 64 <a routerLink="/videos/overview" routerLinkActive="active">
65 <my-global-icon iconName="globe"></my-global-icon> 65 <my-global-icon iconName="globe"></my-global-icon>
66 <ng-container i18n>Overview</ng-container> 66 <ng-container i18n>Discover</ng-container>
67 </a> 67 </a>
68 68
69 <a routerLink="/videos/trending" routerLinkActive="active"> 69 <a routerLink="/videos/trending" routerLinkActive="active">
diff --git a/client/src/app/search/search.component.html b/client/src/app/search/search.component.html
index 0a9f78cb2..055f64cc8 100644
--- a/client/src/app/search/search.component.html
+++ b/client/src/app/search/search.component.html
@@ -20,7 +20,7 @@
20 </div> 20 </div>
21 </div> 21 </div>
22 22
23 <div class="results-filter" [ngbCollapse]="isSearchFilterCollapsed"> 23 <div class="results-filter collapse-transition" [ngbCollapse]="isSearchFilterCollapsed">
24 <my-search-filters [advancedSearch]="advancedSearch" (filtered)="onFiltered()"></my-search-filters> 24 <my-search-filters [advancedSearch]="advancedSearch" (filtered)="onFiltered()"></my-search-filters>
25 </div> 25 </div>
26 </div> 26 </div>
diff --git a/client/src/app/search/search.component.scss b/client/src/app/search/search.component.scss
index 4e3ce1c96..3343a276d 100644
--- a/client/src/app/search/search.component.scss
+++ b/client/src/app/search/search.component.scss
@@ -35,18 +35,6 @@
35 } 35 }
36 } 36 }
37 } 37 }
38
39 .results-filter {
40 // Animation when we show/hide the filters
41 transition: max-height 0.3s;
42 display: block !important;
43 overflow: hidden !important;
44 max-height: 0;
45
46 &.show {
47 max-height: 1500px;
48 }
49 }
50 } 38 }
51 39
52 .entry { 40 .entry {
diff --git a/client/src/app/search/search.module.ts b/client/src/app/search/search.module.ts
index 0411fbe24..8b791621e 100644
--- a/client/src/app/search/search.module.ts
+++ b/client/src/app/search/search.module.ts
@@ -4,14 +4,11 @@ import { SearchComponent } from '@app/search/search.component'
4import { SearchService } from '@app/search/search.service' 4import { SearchService } from '@app/search/search.service'
5import { SearchRoutingModule } from '@app/search/search-routing.module' 5import { SearchRoutingModule } from '@app/search/search-routing.module'
6import { SearchFiltersComponent } from '@app/search/search-filters.component' 6import { SearchFiltersComponent } from '@app/search/search-filters.component'
7import { NgbCollapseModule } from '@ng-bootstrap/ng-bootstrap'
8 7
9@NgModule({ 8@NgModule({
10 imports: [ 9 imports: [
11 SearchRoutingModule, 10 SearchRoutingModule,
12 SharedModule, 11 SharedModule
13
14 NgbCollapseModule
15 ], 12 ],
16 13
17 declarations: [ 14 declarations: [
diff --git a/client/src/app/shared/actor/actor.model.ts b/client/src/app/shared/actor/actor.model.ts
index adecec1fc..5a517c975 100644
--- a/client/src/app/shared/actor/actor.model.ts
+++ b/client/src/app/shared/actor/actor.model.ts
@@ -4,7 +4,6 @@ import { getAbsoluteAPIUrl } from '@app/shared/misc/utils'
4 4
5export abstract class Actor implements ActorServer { 5export abstract class Actor implements ActorServer {
6 id: number 6 id: number
7 uuid: string
8 url: string 7 url: string
9 name: string 8 name: string
10 host: string 9 host: string
@@ -35,7 +34,6 @@ export abstract class Actor implements ActorServer {
35 34
36 protected constructor (hash: ActorServer) { 35 protected constructor (hash: ActorServer) {
37 this.id = hash.id 36 this.id = hash.id
38 this.uuid = hash.uuid
39 this.url = hash.url 37 this.url = hash.url
40 this.name = hash.name 38 this.name = hash.name
41 this.host = hash.host 39 this.host = hash.host
diff --git a/client/src/app/shared/buttons/button.component.scss b/client/src/app/shared/buttons/button.component.scss
index 04199a2a9..99d7f51c1 100644
--- a/client/src/app/shared/buttons/button.component.scss
+++ b/client/src/app/shared/buttons/button.component.scss
@@ -5,16 +5,9 @@
5 @include peertube-button-link; 5 @include peertube-button-link;
6 @include button-with-icon(21px, 0, -2px); 6 @include button-with-icon(21px, 0, -2px);
7 7
8 font-weight: $font-semibold; 8 // FIXME: Firefox does not apply global .orange-button icon color
9 color: $grey-foreground-color; 9 &.orange-button {
10 background-color: $grey-background-color; 10 @include apply-svg-color(#fff)
11
12 &:hover {
13 background-color: $grey-background-hover-color;
14 }
15
16 my-global-icon {
17 @include apply-svg-color($grey-foreground-color);
18 } 11 }
19} 12}
20 13
diff --git a/client/src/app/shared/buttons/button.component.ts b/client/src/app/shared/buttons/button.component.ts
index c2b69d31a..cf334e8d5 100644
--- a/client/src/app/shared/buttons/button.component.ts
+++ b/client/src/app/shared/buttons/button.component.ts
@@ -9,7 +9,7 @@ import { GlobalIconName } from '@app/shared/images/global-icon.component'
9 9
10export class ButtonComponent { 10export class ButtonComponent {
11 @Input() label = '' 11 @Input() label = ''
12 @Input() className: string = undefined 12 @Input() className = 'grey-button'
13 @Input() icon: GlobalIconName = undefined 13 @Input() icon: GlobalIconName = undefined
14 @Input() title: string = undefined 14 @Input() title: string = undefined
15 15
diff --git a/client/src/app/shared/buttons/delete-button.component.html b/client/src/app/shared/buttons/delete-button.component.html
index b4acb9d32..25196fbd5 100644
--- a/client/src/app/shared/buttons/delete-button.component.html
+++ b/client/src/app/shared/buttons/delete-button.component.html
@@ -1,4 +1,4 @@
1<span class="action-button action-button-delete" [title]="title" role="button"> 1<span class="action-button action-button-delete grey-button" [title]="title" role="button">
2 <my-global-icon iconName="delete"></my-global-icon> 2 <my-global-icon iconName="delete"></my-global-icon>
3 3
4 <span class="button-label" *ngIf="label">{{ label }}</span> 4 <span class="button-label" *ngIf="label">{{ label }}</span>
diff --git a/client/src/app/shared/buttons/edit-button.component.html b/client/src/app/shared/buttons/edit-button.component.html
index da3addbae..3d7cd4780 100644
--- a/client/src/app/shared/buttons/edit-button.component.html
+++ b/client/src/app/shared/buttons/edit-button.component.html
@@ -1,4 +1,4 @@
1<a class="action-button action-button-edit" [routerLink]="routerLink" i18n-title title="Edit"> 1<a class="action-button action-button-edit grey-button" [routerLink]="routerLink" i18n-title title="Edit">
2 <my-global-icon iconName="edit"></my-global-icon> 2 <my-global-icon iconName="edit"></my-global-icon>
3 3
4 <span class="button-label" *ngIf="label">{{ label }}</span> 4 <span class="button-label" *ngIf="label">{{ label }}</span>
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 6589b2580..2dafb1816 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
@@ -12,7 +12,7 @@ export class UserValidatorsService {
12 readonly USER_VIDEO_QUOTA: BuildFormValidator 12 readonly USER_VIDEO_QUOTA: BuildFormValidator
13 readonly USER_VIDEO_QUOTA_DAILY: BuildFormValidator 13 readonly USER_VIDEO_QUOTA_DAILY: BuildFormValidator
14 readonly USER_ROLE: BuildFormValidator 14 readonly USER_ROLE: BuildFormValidator
15 readonly USER_DISPLAY_NAME: BuildFormValidator 15 readonly USER_DISPLAY_NAME_REQUIRED: BuildFormValidator
16 readonly USER_DESCRIPTION: BuildFormValidator 16 readonly USER_DESCRIPTION: BuildFormValidator
17 readonly USER_TERMS: BuildFormValidator 17 readonly USER_TERMS: BuildFormValidator
18 18
@@ -85,18 +85,7 @@ export class UserValidatorsService {
85 } 85 }
86 } 86 }
87 87
88 this.USER_DISPLAY_NAME = { 88 this.USER_DISPLAY_NAME_REQUIRED = this.getDisplayName(true)
89 VALIDATORS: [
90 Validators.required,
91 Validators.minLength(1),
92 Validators.maxLength(50)
93 ],
94 MESSAGES: {
95 'required': this.i18n('Display name is required.'),
96 'minlength': this.i18n('Display name must be at least 1 character long.'),
97 'maxlength': this.i18n('Display name cannot be more than 50 characters long.')
98 }
99 }
100 89
101 this.USER_DESCRIPTION = { 90 this.USER_DESCRIPTION = {
102 VALIDATORS: [ 91 VALIDATORS: [
@@ -129,4 +118,22 @@ export class UserValidatorsService {
129 } 118 }
130 } 119 }
131 } 120 }
121
122 private getDisplayName (required: boolean) {
123 const control = {
124 VALIDATORS: [
125 Validators.minLength(1),
126 Validators.maxLength(120)
127 ],
128 MESSAGES: {
129 'required': this.i18n('Display name is required.'),
130 'minlength': this.i18n('Display name must be at least 1 character long.'),
131 'maxlength': this.i18n('Display name cannot be more than 50 characters long.')
132 }
133 }
134
135 if (required) control.VALIDATORS.push(Validators.required)
136
137 return control
138 }
132} 139}
diff --git a/client/src/app/shared/forms/peertube-checkbox.component.scss b/client/src/app/shared/forms/peertube-checkbox.component.scss
index ea321ee65..84ea788af 100644
--- a/client/src/app/shared/forms/peertube-checkbox.component.scss
+++ b/client/src/app/shared/forms/peertube-checkbox.component.scss
@@ -14,9 +14,6 @@
14 14
15 input { 15 input {
16 @include peertube-checkbox(1px); 16 @include peertube-checkbox(1px);
17
18 width: 10px;
19 margin-right: 10px;
20 } 17 }
21 } 18 }
22 19
diff --git a/client/src/app/shared/forms/reactive-file.component.html b/client/src/app/shared/forms/reactive-file.component.html
index 7d691059d..f6bf5f9ae 100644
--- a/client/src/app/shared/forms/reactive-file.component.html
+++ b/client/src/app/shared/forms/reactive-file.component.html
@@ -1,6 +1,9 @@
1<div class="root"> 1<div class="root">
2 <div class="button-file"> 2 <div class="button-file" [ngClass]="{ 'with-icon': !!icon }">
3 <my-global-icon *ngIf="icon" [iconName]="icon"></my-global-icon>
4
3 <span>{{ inputLabel }}</span> 5 <span>{{ inputLabel }}</span>
6
4 <input 7 <input
5 type="file" 8 type="file"
6 [name]="inputName" [id]="inputName" [accept]="extensions" 9 [name]="inputName" [id]="inputName" [accept]="extensions"
@@ -8,7 +11,5 @@
8 /> 11 />
9 </div> 12 </div>
10 13
11 <div i18n class="file-constraints">(extensions: {{ allowedExtensionsMessage }}, max size: {{ maxFileSize | bytes }})</div>
12
13 <div class="filename" *ngIf="displayFilename === true && filename">{{ filename }}</div> 14 <div class="filename" *ngIf="displayFilename === true && filename">{{ filename }}</div>
14</div> 15</div>
diff --git a/client/src/app/shared/forms/reactive-file.component.scss b/client/src/app/shared/forms/reactive-file.component.scss
index d89844264..84c23c1d6 100644
--- a/client/src/app/shared/forms/reactive-file.component.scss
+++ b/client/src/app/shared/forms/reactive-file.component.scss
@@ -8,13 +8,11 @@
8 8
9 .button-file { 9 .button-file {
10 @include peertube-button-file(auto); 10 @include peertube-button-file(auto);
11 @include grey-button;
11 12
12 min-width: 190px; 13 &.with-icon {
13 } 14 @include button-with-icon;
14 15 }
15 .file-constraints {
16 margin-left: 5px;
17 font-size: 13px;
18 } 16 }
19 17
20 .filename { 18 .filename {
diff --git a/client/src/app/shared/forms/reactive-file.component.ts b/client/src/app/shared/forms/reactive-file.component.ts
index f60c38e8d..b7a821d4f 100644
--- a/client/src/app/shared/forms/reactive-file.component.ts
+++ b/client/src/app/shared/forms/reactive-file.component.ts
@@ -2,6 +2,7 @@ import { Component, EventEmitter, forwardRef, Input, OnInit, Output } from '@ang
2import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' 2import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
3import { Notifier } from '@app/core' 3import { Notifier } from '@app/core'
4import { I18n } from '@ngx-translate/i18n-polyfill' 4import { I18n } from '@ngx-translate/i18n-polyfill'
5import { GlobalIconName } from '@app/shared/images/global-icon.component'
5 6
6@Component({ 7@Component({
7 selector: 'my-reactive-file', 8 selector: 'my-reactive-file',
@@ -21,6 +22,7 @@ export class ReactiveFileComponent implements OnInit, ControlValueAccessor {
21 @Input() extensions: string[] = [] 22 @Input() extensions: string[] = []
22 @Input() maxFileSize: number 23 @Input() maxFileSize: number
23 @Input() displayFilename = false 24 @Input() displayFilename = false
25 @Input() icon: GlobalIconName
24 26
25 @Output() fileChanged = new EventEmitter<Blob>() 27 @Output() fileChanged = new EventEmitter<Blob>()
26 28
diff --git a/client/src/app/shared/images/image-upload.component.html b/client/src/app/shared/images/image-upload.component.html
deleted file mode 100644
index c09c862c4..000000000
--- a/client/src/app/shared/images/image-upload.component.html
+++ /dev/null
@@ -1,9 +0,0 @@
1<div class="root">
2 <my-reactive-file
3 [inputName]="inputName" [inputLabel]="inputLabel" [extensions]="videoImageExtensions" [maxFileSize]="maxVideoImageSize"
4 (fileChanged)="onFileChanged($event)"
5 ></my-reactive-file>
6
7 <img *ngIf="imageSrc" [ngStyle]="{ width: previewWidth, height: previewHeight }" [src]="imageSrc" class="preview" />
8 <div *ngIf="!imageSrc" [ngStyle]="{ width: previewWidth, height: previewHeight }" class="preview no-image"></div>
9</div>
diff --git a/client/src/app/shared/images/image-upload.component.scss b/client/src/app/shared/images/image-upload.component.scss
deleted file mode 100644
index b63963bca..000000000
--- a/client/src/app/shared/images/image-upload.component.scss
+++ /dev/null
@@ -1,18 +0,0 @@
1@import '_variables';
2@import '_mixins';
3
4.root {
5 height: auto;
6 display: flex;
7 align-items: center;
8
9 .preview {
10 border: 2px solid grey;
11 border-radius: 4px;
12 margin-left: 50px;
13
14 &.no-image {
15 background-color: #ececec;
16 }
17 }
18}
diff --git a/client/src/app/shared/images/preview-upload.component.html b/client/src/app/shared/images/preview-upload.component.html
new file mode 100644
index 000000000..5e1d5211b
--- /dev/null
+++ b/client/src/app/shared/images/preview-upload.component.html
@@ -0,0 +1,13 @@
1<div class="root">
2 <div class="preview-container">
3 <my-reactive-file
4 [inputName]="inputName" [inputLabel]="inputLabel" [extensions]="videoImageExtensions" [maxFileSize]="maxVideoImageSize"
5 icon="edit" (fileChanged)="onFileChanged($event)"
6 ></my-reactive-file>
7
8 <img *ngIf="imageSrc" [ngStyle]="{ width: previewWidth, height: previewHeight }" [src]="imageSrc" class="preview" />
9 <div *ngIf="!imageSrc" [ngStyle]="{ width: previewWidth, height: previewHeight }" class="preview no-image"></div>
10 </div>
11
12 <div i18n class="file-constraints">(extensions: {{ allowedExtensionsMessage }}, max size: {{ maxVideoImageSize | bytes }})</div>
13</div>
diff --git a/client/src/app/shared/images/preview-upload.component.scss b/client/src/app/shared/images/preview-upload.component.scss
new file mode 100644
index 000000000..257060239
--- /dev/null
+++ b/client/src/app/shared/images/preview-upload.component.scss
@@ -0,0 +1,27 @@
1@import '_variables';
2@import '_mixins';
3
4.root {
5 height: auto;
6 display: flex;
7 flex-direction: column;
8
9 .preview-container {
10 position: relative;
11
12 my-reactive-file {
13 position: absolute;
14 bottom: 10px;
15 left: 10px;
16 }
17
18 .preview {
19 border: 2px solid grey;
20 border-radius: 4px;
21
22 &.no-image {
23 background-color: #ececec;
24 }
25 }
26 }
27}
diff --git a/client/src/app/shared/images/image-upload.component.ts b/client/src/app/shared/images/preview-upload.component.ts
index 2da1592ff..44b78866e 100644
--- a/client/src/app/shared/images/image-upload.component.ts
+++ b/client/src/app/shared/images/preview-upload.component.ts
@@ -1,27 +1,28 @@
1import { Component, forwardRef, Input } from '@angular/core' 1import { Component, forwardRef, Input, OnInit } from '@angular/core'
2import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' 2import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
3import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser' 3import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser'
4import { ServerService } from '@app/core' 4import { ServerService } from '@app/core'
5 5
6@Component({ 6@Component({
7 selector: 'my-image-upload', 7 selector: 'my-preview-upload',
8 styleUrls: [ './image-upload.component.scss' ], 8 styleUrls: [ './preview-upload.component.scss' ],
9 templateUrl: './image-upload.component.html', 9 templateUrl: './preview-upload.component.html',
10 providers: [ 10 providers: [
11 { 11 {
12 provide: NG_VALUE_ACCESSOR, 12 provide: NG_VALUE_ACCESSOR,
13 useExisting: forwardRef(() => ImageUploadComponent), 13 useExisting: forwardRef(() => PreviewUploadComponent),
14 multi: true 14 multi: true
15 } 15 }
16 ] 16 ]
17}) 17})
18export class ImageUploadComponent implements ControlValueAccessor { 18export class PreviewUploadComponent implements OnInit, ControlValueAccessor {
19 @Input() inputLabel: string 19 @Input() inputLabel: string
20 @Input() inputName: string 20 @Input() inputName: string
21 @Input() previewWidth: string 21 @Input() previewWidth: string
22 @Input() previewHeight: string 22 @Input() previewHeight: string
23 23
24 imageSrc: SafeResourceUrl 24 imageSrc: SafeResourceUrl
25 allowedExtensionsMessage = ''
25 26
26 private file: File 27 private file: File
27 28
@@ -38,6 +39,10 @@ export class ImageUploadComponent implements ControlValueAccessor {
38 return this.serverService.getConfig().video.image.size.max 39 return this.serverService.getConfig().video.image.size.max
39 } 40 }
40 41
42 ngOnInit () {
43 this.allowedExtensionsMessage = this.videoImageExtensions.join(', ')
44 }
45
41 onFileChanged (file: File) { 46 onFileChanged (file: File) {
42 this.file = file 47 this.file = file
43 48
diff --git a/client/src/app/+admin/follows/shared/follow.service.ts b/client/src/app/shared/instance/follow.service.ts
index c2b8ef006..5a44c64f1 100644
--- a/client/src/app/+admin/follows/shared/follow.service.ts
+++ b/client/src/app/shared/instance/follow.service.ts
@@ -3,13 +3,13 @@ import { HttpClient, HttpParams } from '@angular/common/http'
3import { Injectable } from '@angular/core' 3import { Injectable } from '@angular/core'
4import { SortMeta } from 'primeng/primeng' 4import { SortMeta } from 'primeng/primeng'
5import { Observable } from 'rxjs' 5import { Observable } from 'rxjs'
6import { ActorFollow, ResultList } from '../../../../../../shared' 6import { ActorFollow, ResultList } from '@shared/index'
7import { environment } from '../../../../environments/environment' 7import { environment } from '../../../environments/environment'
8import { RestExtractor, RestPagination, RestService } from '../../../shared' 8import { RestExtractor, RestPagination, RestService } from '../rest'
9 9
10@Injectable() 10@Injectable()
11export class FollowService { 11export class FollowService {
12 private static BASE_APPLICATION_URL = environment.apiUrl + '/api/v1/server' 12 private static BASE_APPLICATION_URL = 'https://peertube2.cpy.re' + '/api/v1/server'
13 13
14 constructor ( 14 constructor (
15 private authHttp: HttpClient, 15 private authHttp: HttpClient,
diff --git a/client/src/app/shared/misc/loader.component.html b/client/src/app/shared/misc/loader.component.html
index b8b7ad343..ca8ed063e 100644
--- a/client/src/app/shared/misc/loader.component.html
+++ b/client/src/app/shared/misc/loader.component.html
@@ -1,5 +1,5 @@
1<div *ngIf="loading"> 1<div *ngIf="loading">
2 <div class="lds-ring"> 2 <div class="loader">
3 <div></div> 3 <div></div>
4 <div></div> 4 <div></div>
5 <div></div> 5 <div></div>
diff --git a/client/src/app/shared/misc/loader.component.scss b/client/src/app/shared/misc/loader.component.scss
index ddb64f07a..ffac9c707 100644
--- a/client/src/app/shared/misc/loader.component.scss
+++ b/client/src/app/shared/misc/loader.component.scss
@@ -3,14 +3,14 @@
3 3
4// Thanks to https://loading.io/css/ (CC0 License) 4// Thanks to https://loading.io/css/ (CC0 License)
5 5
6.lds-ring { 6.loader {
7 display: inline-block; 7 display: inline-block;
8 position: relative; 8 position: relative;
9 width: 50px; 9 width: 50px;
10 height: 50px; 10 height: 50px;
11} 11}
12 12
13.lds-ring div { 13.loader div {
14 box-sizing: border-box; 14 box-sizing: border-box;
15 display: block; 15 display: block;
16 position: absolute; 16 position: absolute;
@@ -19,23 +19,23 @@
19 margin: 6px; 19 margin: 6px;
20 border: 4px solid; 20 border: 4px solid;
21 border-radius: 50%; 21 border-radius: 50%;
22 animation: lds-ring 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite; 22 animation: loader 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;
23 border-color: #999999 transparent transparent transparent; 23 border-color: #999999 transparent transparent transparent;
24} 24}
25 25
26.lds-ring div:nth-child(1) { 26.loader div:nth-child(1) {
27 animation-delay: -0.45s; 27 animation-delay: -0.45s;
28} 28}
29 29
30.lds-ring div:nth-child(2) { 30.loader div:nth-child(2) {
31 animation-delay: -0.3s; 31 animation-delay: -0.3s;
32} 32}
33 33
34.lds-ring div:nth-child(3) { 34.loader div:nth-child(3) {
35 animation-delay: -0.15s; 35 animation-delay: -0.15s;
36} 36}
37 37
38@keyframes lds-ring { 38@keyframes loader {
39 0% { 39 0% {
40 transform: rotate(0deg); 40 transform: rotate(0deg);
41 } 41 }
diff --git a/client/src/app/shared/shared.module.ts b/client/src/app/shared/shared.module.ts
index ded65653f..eb57a2fff 100644
--- a/client/src/app/shared/shared.module.ts
+++ b/client/src/app/shared/shared.module.ts
@@ -53,7 +53,14 @@ import { VideoCaptionService } from '@app/shared/video-caption'
53import { PeertubeCheckboxComponent } from '@app/shared/forms/peertube-checkbox.component' 53import { PeertubeCheckboxComponent } from '@app/shared/forms/peertube-checkbox.component'
54import { VideoImportService } from '@app/shared/video-import/video-import.service' 54import { VideoImportService } from '@app/shared/video-import/video-import.service'
55import { ActionDropdownComponent } from '@app/shared/buttons/action-dropdown.component' 55import { ActionDropdownComponent } from '@app/shared/buttons/action-dropdown.component'
56import { NgbDropdownModule, NgbModalModule, NgbPopoverModule, NgbTabsetModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap' 56import {
57 NgbCollapseModule,
58 NgbDropdownModule,
59 NgbModalModule,
60 NgbPopoverModule,
61 NgbTabsetModule,
62 NgbTooltipModule
63} from '@ng-bootstrap/ng-bootstrap'
57import { RemoteSubscribeComponent, SubscribeButtonComponent, UserSubscriptionService } from '@app/shared/user-subscription' 64import { RemoteSubscribeComponent, SubscribeButtonComponent, UserSubscriptionService } from '@app/shared/user-subscription'
58import { InstanceFeaturesTableComponent } from '@app/shared/instance/instance-features-table.component' 65import { InstanceFeaturesTableComponent } from '@app/shared/instance/instance-features-table.component'
59import { OverviewService } from '@app/shared/overview' 66import { OverviewService } from '@app/shared/overview'
@@ -69,7 +76,7 @@ import { HtmlRendererService, LinkifierService, MarkdownService } from '@app/sha
69import { ConfirmComponent } from '@app/shared/confirm/confirm.component' 76import { ConfirmComponent } from '@app/shared/confirm/confirm.component'
70import { SmallLoaderComponent } from '@app/shared/misc/small-loader.component' 77import { SmallLoaderComponent } from '@app/shared/misc/small-loader.component'
71import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service' 78import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service'
72import { ImageUploadComponent } from '@app/shared/images/image-upload.component' 79import { PreviewUploadComponent } from '@app/shared/images/preview-upload.component'
73import { GlobalIconComponent } from '@app/shared/images/global-icon.component' 80import { GlobalIconComponent } from '@app/shared/images/global-icon.component'
74import { VideoPlaylistMiniatureComponent } from '@app/shared/video-playlist/video-playlist-miniature.component' 81import { VideoPlaylistMiniatureComponent } from '@app/shared/video-playlist/video-playlist-miniature.component'
75import { VideoAddToPlaylistComponent } from '@app/shared/video-playlist/video-add-to-playlist.component' 82import { VideoAddToPlaylistComponent } from '@app/shared/video-playlist/video-add-to-playlist.component'
@@ -85,6 +92,7 @@ import { VideoBlacklistComponent } from '@app/shared/video/modals/video-blacklis
85import { VideoDownloadComponent } from '@app/shared/video/modals/video-download.component' 92import { VideoDownloadComponent } from '@app/shared/video/modals/video-download.component'
86import { VideoReportComponent } from '@app/shared/video/modals/video-report.component' 93import { VideoReportComponent } from '@app/shared/video/modals/video-report.component'
87import { ClipboardModule } from 'ngx-clipboard' 94import { ClipboardModule } from 'ngx-clipboard'
95import { FollowService } from '@app/shared/instance/follow.service'
88 96
89@NgModule({ 97@NgModule({
90 imports: [ 98 imports: [
@@ -99,6 +107,7 @@ import { ClipboardModule } from 'ngx-clipboard'
99 NgbPopoverModule, 107 NgbPopoverModule,
100 NgbTabsetModule, 108 NgbTabsetModule,
101 NgbTooltipModule, 109 NgbTooltipModule,
110 NgbCollapseModule,
102 111
103 ClipboardModule, 112 ClipboardModule,
104 113
@@ -154,7 +163,7 @@ import { ClipboardModule } from 'ngx-clipboard'
154 ConfirmComponent, 163 ConfirmComponent,
155 164
156 GlobalIconComponent, 165 GlobalIconComponent,
157 ImageUploadComponent 166 PreviewUploadComponent
158 ], 167 ],
159 168
160 exports: [ 169 exports: [
@@ -169,6 +178,7 @@ import { ClipboardModule } from 'ngx-clipboard'
169 NgbPopoverModule, 178 NgbPopoverModule,
170 NgbTabsetModule, 179 NgbTabsetModule,
171 NgbTooltipModule, 180 NgbTooltipModule,
181 NgbCollapseModule,
172 182
173 ClipboardModule, 183 ClipboardModule,
174 184
@@ -218,7 +228,7 @@ import { ClipboardModule } from 'ngx-clipboard'
218 ConfirmComponent, 228 ConfirmComponent,
219 229
220 GlobalIconComponent, 230 GlobalIconComponent,
221 ImageUploadComponent, 231 PreviewUploadComponent,
222 232
223 NumberFormatterPipe, 233 NumberFormatterPipe,
224 ObjectLengthPipe, 234 ObjectLengthPipe,
@@ -271,6 +281,8 @@ import { ClipboardModule } from 'ngx-clipboard'
271 281
272 UserNotificationService, 282 UserNotificationService,
273 283
284 FollowService,
285
274 I18n 286 I18n
275 ] 287 ]
276}) 288})
diff --git a/client/src/app/shared/users/user-notifications.component.scss b/client/src/app/shared/users/user-notifications.component.scss
index 88f38d9cf..0c6c70d98 100644
--- a/client/src/app/shared/users/user-notifications.component.scss
+++ b/client/src/app/shared/users/user-notifications.component.scss
@@ -14,6 +14,7 @@
14 font-size: inherit; 14 font-size: inherit;
15 padding: 15px 5px 15px 10px; 15 padding: 15px 5px 15px 10px;
16 border-bottom: 1px solid $separator-border-color; 16 border-bottom: 1px solid $separator-border-color;
17 word-break: break-word;
17 18
18 &.unread { 19 &.unread {
19 background-color: rgba(0, 0, 0, 0.05); 20 background-color: rgba(0, 0, 0, 0.05);
diff --git a/client/src/app/shared/users/user.model.ts b/client/src/app/shared/users/user.model.ts
index e3ed2dfbd..14d13959a 100644
--- a/client/src/app/shared/users/user.model.ts
+++ b/client/src/app/shared/users/user.model.ts
@@ -8,6 +8,7 @@ export class User implements UserServerModel {
8 id: number 8 id: number
9 username: string 9 username: string
10 email: string 10 email: string
11 pendingEmail: string | null
11 emailVerified: boolean 12 emailVerified: boolean
12 nsfwPolicy: NSFWPolicyType 13 nsfwPolicy: NSFWPolicyType
13 14
diff --git a/client/src/app/shared/users/user.service.ts b/client/src/app/shared/users/user.service.ts
index cc5c051f1..41ee87197 100644
--- a/client/src/app/shared/users/user.service.ts
+++ b/client/src/app/shared/users/user.service.ts
@@ -9,6 +9,7 @@ import { Avatar } from '../../../../../shared/models/avatars/avatar.model'
9import { SortMeta } from 'primeng/api' 9import { SortMeta } from 'primeng/api'
10import { BytesPipe } from 'ngx-pipes' 10import { BytesPipe } from 'ngx-pipes'
11import { I18n } from '@ngx-translate/i18n-polyfill' 11import { I18n } from '@ngx-translate/i18n-polyfill'
12import { UserRegister } from '@shared/models/users/user-register.model'
12 13
13@Injectable() 14@Injectable()
14export class UserService { 15export class UserService {
@@ -37,6 +38,20 @@ export class UserService {
37 ) 38 )
38 } 39 }
39 40
41 changeEmail (password: string, newEmail: string) {
42 const url = UserService.BASE_USERS_URL + 'me'
43 const body: UserUpdateMe = {
44 currentPassword: password,
45 email: newEmail
46 }
47
48 return this.authHttp.put(url, body)
49 .pipe(
50 map(this.restExtractor.extractDataBool),
51 catchError(err => this.restExtractor.handleError(err))
52 )
53 }
54
40 updateMyProfile (profile: UserUpdateMe) { 55 updateMyProfile (profile: UserUpdateMe) {
41 const url = UserService.BASE_USERS_URL + 'me' 56 const url = UserService.BASE_USERS_URL + 'me'
42 57
@@ -64,7 +79,7 @@ export class UserService {
64 .pipe(catchError(err => this.restExtractor.handleError(err))) 79 .pipe(catchError(err => this.restExtractor.handleError(err)))
65 } 80 }
66 81
67 signup (userCreate: UserCreate) { 82 signup (userCreate: UserRegister) {
68 return this.authHttp.post(UserService.BASE_USERS_URL + 'register', userCreate) 83 return this.authHttp.post(UserService.BASE_USERS_URL + 'register', userCreate)
69 .pipe( 84 .pipe(
70 map(this.restExtractor.extractDataBool), 85 map(this.restExtractor.extractDataBool),
@@ -103,10 +118,11 @@ export class UserService {
103 ) 118 )
104 } 119 }
105 120
106 verifyEmail (userId: number, verificationString: string) { 121 verifyEmail (userId: number, verificationString: string, isPendingEmail: boolean) {
107 const url = `${UserService.BASE_USERS_URL}/${userId}/verify-email` 122 const url = `${UserService.BASE_USERS_URL}/${userId}/verify-email`
108 const body = { 123 const body = {
109 verificationString 124 verificationString,
125 isPendingEmail
110 } 126 }
111 127
112 return this.authHttp.post(url, body) 128 return this.authHttp.post(url, body)
@@ -135,6 +151,22 @@ export class UserService {
135 .pipe(catchError(res => this.restExtractor.handleError(res))) 151 .pipe(catchError(res => this.restExtractor.handleError(res)))
136 } 152 }
137 153
154 getNewUsername (oldDisplayName: string, newDisplayName: string, currentUsername: string) {
155 // Don't update display name, the user seems to have changed it
156 if (this.displayNameToUsername(oldDisplayName) !== currentUsername) return currentUsername
157
158 return this.displayNameToUsername(newDisplayName)
159 }
160
161 displayNameToUsername (displayName: string) {
162 if (!displayName) return ''
163
164 return displayName
165 .toLowerCase()
166 .replace(/\s/g, '_')
167 .replace(/[^a-z0-9_.]/g, '')
168 }
169
138 /* ###### Admin methods ###### */ 170 /* ###### Admin methods ###### */
139 171
140 addUser (userCreate: UserCreate) { 172 addUser (userCreate: UserCreate) {
diff --git a/client/src/app/shared/video-channel/video-channel.service.ts b/client/src/app/shared/video-channel/video-channel.service.ts
index d0bec649a..0168d37d9 100644
--- a/client/src/app/shared/video-channel/video-channel.service.ts
+++ b/client/src/app/shared/video-channel/video-channel.service.ts
@@ -2,7 +2,7 @@ import { catchError, map, tap } from 'rxjs/operators'
2import { Injectable } from '@angular/core' 2import { Injectable } from '@angular/core'
3import { Observable, ReplaySubject } from 'rxjs' 3import { Observable, ReplaySubject } from 'rxjs'
4import { RestExtractor } from '../rest/rest-extractor.service' 4import { RestExtractor } from '../rest/rest-extractor.service'
5import { HttpClient } from '@angular/common/http' 5import { HttpClient, HttpParams } from '@angular/common/http'
6import { VideoChannel as VideoChannelServer, VideoChannelCreate, VideoChannelUpdate } from '../../../../../shared/models/videos' 6import { VideoChannel as VideoChannelServer, VideoChannelCreate, VideoChannelUpdate } from '../../../../../shared/models/videos'
7import { AccountService } from '../account/account.service' 7import { AccountService } from '../account/account.service'
8import { ResultList } from '../../../../../shared' 8import { ResultList } from '../../../../../shared'
@@ -10,6 +10,8 @@ import { VideoChannel } from './video-channel.model'
10import { environment } from '../../../environments/environment' 10import { environment } from '../../../environments/environment'
11import { Account } from '@app/shared/account/account.model' 11import { Account } from '@app/shared/account/account.model'
12import { Avatar } from '../../../../../shared/models/avatars/avatar.model' 12import { Avatar } from '../../../../../shared/models/avatars/avatar.model'
13import { ComponentPagination } from '@app/shared/rest/component-pagination.model'
14import { RestService } from '@app/shared/rest'
13 15
14@Injectable() 16@Injectable()
15export class VideoChannelService { 17export class VideoChannelService {
@@ -29,6 +31,7 @@ export class VideoChannelService {
29 31
30 constructor ( 32 constructor (
31 private authHttp: HttpClient, 33 private authHttp: HttpClient,
34 private restService: RestService,
32 private restExtractor: RestExtractor 35 private restExtractor: RestExtractor
33 ) { } 36 ) { }
34 37
@@ -41,8 +44,16 @@ export class VideoChannelService {
41 ) 44 )
42 } 45 }
43 46
44 listAccountVideoChannels (account: Account): Observable<ResultList<VideoChannel>> { 47 listAccountVideoChannels (account: Account, componentPagination?: ComponentPagination): Observable<ResultList<VideoChannel>> {
45 return this.authHttp.get<ResultList<VideoChannelServer>>(AccountService.BASE_ACCOUNT_URL + account.nameWithHost + '/video-channels') 48 const pagination = componentPagination
49 ? this.restService.componentPaginationToRestPagination(componentPagination)
50 : { start: 0, count: 20 }
51
52 let params = new HttpParams()
53 params = this.restService.addRestGetParams(params, pagination)
54
55 const url = AccountService.BASE_ACCOUNT_URL + account.nameWithHost + '/video-channels'
56 return this.authHttp.get<ResultList<VideoChannelServer>>(url, { params })
46 .pipe( 57 .pipe(
47 map(res => VideoChannelService.extractVideoChannels(res)), 58 map(res => VideoChannelService.extractVideoChannels(res)),
48 catchError(err => this.restExtractor.handleError(err)) 59 catchError(err => this.restExtractor.handleError(err))
diff --git a/client/src/app/shared/video/abstract-video-list.html b/client/src/app/shared/video/abstract-video-list.html
index 268677977..efd369bca 100644
--- a/client/src/app/shared/video/abstract-video-list.html
+++ b/client/src/app/shared/video/abstract-video-list.html
@@ -6,7 +6,7 @@
6 </div> 6 </div>
7 </div> 7 </div>
8 8
9 <my-feed [syndicationItems]="syndicationItems"></my-feed> 9 <my-feed *ngIf="titlePage" [syndicationItems]="syndicationItems"></my-feed>
10 10
11 <div class="moderation-block" *ngIf="displayModerationBlock"> 11 <div class="moderation-block" *ngIf="displayModerationBlock">
12 <my-peertube-checkbox 12 <my-peertube-checkbox
@@ -22,11 +22,17 @@
22 myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [autoInit]="true" 22 myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [autoInit]="true"
23 class="videos" 23 class="videos"
24 > 24 >
25 <my-video-miniature 25 <ng-container *ngFor="let video of videos; trackBy: videoById;">
26 *ngFor="let video of videos; trackBy: videoById" [video]="video" [user]="user" [ownerDisplayType]="ownerDisplayType" 26 <div class="date-title" *ngIf="getCurrentGroupedDateLabel(video)">
27 [displayVideoActions]="displayVideoActions" [displayOptions]="displayOptions" 27 {{ getCurrentGroupedDateLabel(video) }}
28 (videoBlacklisted)="removeVideoFromArray(video)" (videoRemoved)="removeVideoFromArray(video)" 28 </div>
29 > 29
30 </my-video-miniature> 30 <my-video-miniature
31 [video]="video" [user]="user" [ownerDisplayType]="ownerDisplayType"
32 [displayVideoActions]="displayVideoActions" [displayOptions]="displayOptions"
33 (videoBlacklisted)="removeVideoFromArray(video)" (videoRemoved)="removeVideoFromArray(video)"
34 >
35 </my-video-miniature>
36 </ng-container>
31 </div> 37 </div>
32</div> 38</div>
diff --git a/client/src/app/shared/video/abstract-video-list.scss b/client/src/app/shared/video/abstract-video-list.scss
index 9d481d6e4..98b80fdfd 100644
--- a/client/src/app/shared/video/abstract-video-list.scss
+++ b/client/src/app/shared/video/abstract-video-list.scss
@@ -24,33 +24,19 @@
24 } 24 }
25} 25}
26 26
27.margin-content { 27.date-title {
28 width: $video-miniature-width * 6; 28 font-size: 16px;
29 margin: auto !important; 29 font-weight: $font-semibold;
30 30 margin-bottom: 20px;
31 @media screen and (max-width: 1800px) { 31 margin-top: -10px;
32 width: $video-miniature-width * 5; 32 padding-top: 20px;
33 }
34 33
35 @media screen and (max-width: 1800px - $video-miniature-width) { 34 &:not(:first-child) {
36 width: $video-miniature-width * 4; 35 border-top: 1px solid $separator-border-color;
37 } 36 }
37}
38 38
39 @media screen and (max-width: 1800px - (2* $video-miniature-width)) { 39.margin-content {
40 width: $video-miniature-width * 3; 40 @include adapt-margin-content-width;
41 }
42
43 @media screen and (max-width: 1800px - (3* $video-miniature-width)) {
44 width: $video-miniature-width * 2;
45 }
46
47 @media screen and (max-width: 500px) {
48 width: auto;
49 margin: 0 !important;
50
51 .videos {
52 @include video-miniature-small-screen;
53 }
54 }
55} 41}
56 42
diff --git a/client/src/app/shared/video/abstract-video-list.ts b/client/src/app/shared/video/abstract-video-list.ts
index fa9d38735..dc8f9cda9 100644
--- a/client/src/app/shared/video/abstract-video-list.ts
+++ b/client/src/app/shared/video/abstract-video-list.ts
@@ -11,6 +11,17 @@ import { MiniatureDisplayOptions, OwnerDisplayType } from '@app/shared/video/vid
11import { Syndication } from '@app/shared/video/syndication.model' 11import { Syndication } from '@app/shared/video/syndication.model'
12import { Notifier, ServerService } from '@app/core' 12import { Notifier, ServerService } from '@app/core'
13import { DisableForReuseHook } from '@app/core/routing/disable-for-reuse-hook' 13import { DisableForReuseHook } from '@app/core/routing/disable-for-reuse-hook'
14import { I18n } from '@ngx-translate/i18n-polyfill'
15import { isLastMonth, isLastWeek, isToday, isYesterday } from '@shared/core-utils/miscs/date'
16
17enum GroupDate {
18 UNKNOWN = 0,
19 TODAY = 1,
20 YESTERDAY = 2,
21 LAST_WEEK = 3,
22 LAST_MONTH = 4,
23 OLDER = 5
24}
14 25
15export abstract class AbstractVideoList implements OnInit, OnDestroy, DisableForReuseHook { 26export abstract class AbstractVideoList implements OnInit, OnDestroy, DisableForReuseHook {
16 pagination: ComponentPagination = { 27 pagination: ComponentPagination = {
@@ -31,6 +42,7 @@ export abstract class AbstractVideoList implements OnInit, OnDestroy, DisableFor
31 displayModerationBlock = false 42 displayModerationBlock = false
32 titleTooltip: string 43 titleTooltip: string
33 displayVideoActions = true 44 displayVideoActions = true
45 groupByDate = false
34 46
35 disabled = false 47 disabled = false
36 48
@@ -50,11 +62,15 @@ export abstract class AbstractVideoList implements OnInit, OnDestroy, DisableFor
50 protected abstract serverService: ServerService 62 protected abstract serverService: ServerService
51 protected abstract screenService: ScreenService 63 protected abstract screenService: ScreenService
52 protected abstract router: Router 64 protected abstract router: Router
65 protected abstract i18n: I18n
53 abstract titlePage: string 66 abstract titlePage: string
54 67
55 private resizeSubscription: Subscription 68 private resizeSubscription: Subscription
56 private angularState: number 69 private angularState: number
57 70
71 private groupedDateLabels: { [id in GroupDate]: string }
72 private groupedDates: { [id: number]: GroupDate } = {}
73
58 abstract getVideosObservable (page: number): Observable<{ videos: Video[], totalVideos: number }> 74 abstract getVideosObservable (page: number): Observable<{ videos: Video[], totalVideos: number }>
59 75
60 abstract generateSyndicationList (): void 76 abstract generateSyndicationList (): void
@@ -64,6 +80,15 @@ export abstract class AbstractVideoList implements OnInit, OnDestroy, DisableFor
64 } 80 }
65 81
66 ngOnInit () { 82 ngOnInit () {
83 this.groupedDateLabels = {
84 [GroupDate.UNKNOWN]: null,
85 [GroupDate.TODAY]: this.i18n('Today'),
86 [GroupDate.YESTERDAY]: this.i18n('Yesterday'),
87 [GroupDate.LAST_WEEK]: this.i18n('Last week'),
88 [GroupDate.LAST_MONTH]: this.i18n('Last month'),
89 [GroupDate.OLDER]: this.i18n('Older')
90 }
91
67 // Subscribe to route changes 92 // Subscribe to route changes
68 const routeParams = this.route.snapshot.queryParams 93 const routeParams = this.route.snapshot.queryParams
69 this.loadRouteParams(routeParams) 94 this.loadRouteParams(routeParams)
@@ -113,6 +138,8 @@ export abstract class AbstractVideoList implements OnInit, OnDestroy, DisableFor
113 this.pagination.totalItems = totalVideos 138 this.pagination.totalItems = totalVideos
114 this.videos = this.videos.concat(videos) 139 this.videos = this.videos.concat(videos)
115 140
141 if (this.groupByDate) this.buildGroupedDateLabels()
142
116 this.onMoreVideos() 143 this.onMoreVideos()
117 }, 144 },
118 145
@@ -134,6 +161,59 @@ export abstract class AbstractVideoList implements OnInit, OnDestroy, DisableFor
134 this.videos = this.videos.filter(v => v.id !== video.id) 161 this.videos = this.videos.filter(v => v.id !== video.id)
135 } 162 }
136 163
164 buildGroupedDateLabels () {
165 let currentGroupedDate: GroupDate = GroupDate.UNKNOWN
166
167 for (const video of this.videos) {
168 const publishedDate = video.publishedAt
169
170 if (currentGroupedDate <= GroupDate.TODAY && isToday(publishedDate)) {
171 if (currentGroupedDate === GroupDate.TODAY) continue
172
173 currentGroupedDate = GroupDate.TODAY
174 this.groupedDates[ video.id ] = currentGroupedDate
175 continue
176 }
177
178 if (currentGroupedDate <= GroupDate.YESTERDAY && isYesterday(publishedDate)) {
179 if (currentGroupedDate === GroupDate.YESTERDAY) continue
180
181 currentGroupedDate = GroupDate.YESTERDAY
182 this.groupedDates[ video.id ] = currentGroupedDate
183 continue
184 }
185
186 if (currentGroupedDate <= GroupDate.LAST_WEEK && isLastWeek(publishedDate)) {
187 if (currentGroupedDate === GroupDate.LAST_WEEK) continue
188
189 currentGroupedDate = GroupDate.LAST_WEEK
190 this.groupedDates[ video.id ] = currentGroupedDate
191 continue
192 }
193
194 if (currentGroupedDate <= GroupDate.LAST_MONTH && isLastMonth(publishedDate)) {
195 if (currentGroupedDate === GroupDate.LAST_MONTH) continue
196
197 currentGroupedDate = GroupDate.LAST_MONTH
198 this.groupedDates[ video.id ] = currentGroupedDate
199 continue
200 }
201
202 if (currentGroupedDate <= GroupDate.OLDER) {
203 if (currentGroupedDate === GroupDate.OLDER) continue
204
205 currentGroupedDate = GroupDate.OLDER
206 this.groupedDates[ video.id ] = currentGroupedDate
207 }
208 }
209 }
210
211 getCurrentGroupedDateLabel (video: Video) {
212 if (this.groupByDate === false) return undefined
213
214 return this.groupedDateLabels[this.groupedDates[video.id]]
215 }
216
137 // On videos hook for children that want to do something 217 // On videos hook for children that want to do something
138 protected onMoreVideos () { /* empty */ } 218 protected onMoreVideos () { /* empty */ }
139 219
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 dd01c1388..935d01330 100644
--- a/client/src/app/shared/video/modals/video-download.component.html
+++ b/client/src/app/shared/video/modals/video-download.component.html
@@ -31,11 +31,6 @@
31 <input type="radio" name="download" id="download-torrent" [(ngModel)]="downloadType" value="torrent"> 31 <input type="radio" name="download" id="download-torrent" [(ngModel)]="downloadType" value="torrent">
32 <label i18n for="download-torrent">Torrent (.torrent file)</label> 32 <label i18n for="download-torrent">Torrent (.torrent file)</label>
33 </div> 33 </div>
34
35 <div class="peertube-radio-container">
36 <input type="radio" name="download" id="download-magnet" [(ngModel)]="downloadType" value="magnet">
37 <label i18n for="download-magnet">Torrent (magnet link)</label>
38 </div>
39 </div> 34 </div>
40 </div> 35 </div>
41 36
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 d6d10d29e..16f3621b4 100644
--- a/client/src/app/shared/video/modals/video-download.component.ts
+++ b/client/src/app/shared/video/modals/video-download.component.ts
@@ -1,6 +1,6 @@
1import { Component, ElementRef, ViewChild } from '@angular/core' 1import { Component, ElementRef, ViewChild } from '@angular/core'
2import { VideoDetails } from '../../../shared/video/video-details.model' 2import { VideoDetails } from '../../../shared/video/video-details.model'
3import { NgbModal } from '@ng-bootstrap/ng-bootstrap' 3import { NgbActiveModal, NgbModal } from '@ng-bootstrap/ng-bootstrap'
4import { I18n } from '@ngx-translate/i18n-polyfill' 4import { I18n } from '@ngx-translate/i18n-polyfill'
5import { Notifier } from '@app/core' 5import { Notifier } from '@app/core'
6 6
@@ -12,10 +12,11 @@ import { Notifier } from '@app/core'
12export class VideoDownloadComponent { 12export class VideoDownloadComponent {
13 @ViewChild('modal') modal: ElementRef 13 @ViewChild('modal') modal: ElementRef
14 14
15 downloadType: 'direct' | 'torrent' | 'magnet' = 'torrent' 15 downloadType: 'direct' | 'torrent' = 'torrent'
16 resolutionId: number | string = -1 16 resolutionId: number | string = -1
17 17
18 video: VideoDetails 18 video: VideoDetails
19 activeModal: NgbActiveModal
19 20
20 constructor ( 21 constructor (
21 private notifier: Notifier, 22 private notifier: Notifier,
@@ -26,9 +27,7 @@ export class VideoDownloadComponent {
26 show (video: VideoDetails) { 27 show (video: VideoDetails) {
27 this.video = video 28 this.video = video
28 29
29 const m = this.modalService.open(this.modal) 30 this.activeModal = this.modalService.open(this.modal)
30 m.result.then(() => this.onClose())
31 .catch(() => this.onClose())
32 31
33 this.resolutionId = this.video.files[0].resolution.id 32 this.resolutionId = this.video.files[0].resolution.id
34 } 33 }
@@ -39,6 +38,7 @@ export class VideoDownloadComponent {
39 38
40 download () { 39 download () {
41 window.location.assign(this.getLink()) 40 window.location.assign(this.getLink())
41 this.activeModal.close()
42 } 42 }
43 43
44 getLink () { 44 getLink () {
@@ -57,9 +57,6 @@ export class VideoDownloadComponent {
57 57
58 case 'torrent': 58 case 'torrent':
59 return file.torrentDownloadUrl 59 return file.torrentDownloadUrl
60
61 case 'magnet':
62 return file.magnetUri
63 } 60 }
64 } 61 }
65 62
diff --git a/client/src/app/shared/video/video-details.model.ts b/client/src/app/shared/video/video-details.model.ts
index 22f024656..e4d443a06 100644
--- a/client/src/app/shared/video/video-details.model.ts
+++ b/client/src/app/shared/video/video-details.model.ts
@@ -1,5 +1,4 @@
1import { UserRight, VideoConstant, VideoDetails as VideoDetailsServerModel, VideoFile, VideoState } from '../../../../../shared' 1import { VideoConstant, VideoDetails as VideoDetailsServerModel, VideoFile, VideoState } from '../../../../../shared'
2import { AuthUser } from '../../core'
3import { Video } from '../../shared/video/video.model' 2import { Video } from '../../shared/video/video.model'
4import { Account } from '@app/shared/account/account.model' 3import { Account } from '@app/shared/account/account.model'
5import { VideoChannel } from '@app/shared/video-channel/video-channel.model' 4import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
diff --git a/client/src/app/shared/video/video-edit.model.ts b/client/src/app/shared/video/video-edit.model.ts
index 1f633d427..67d8e7711 100644
--- a/client/src/app/shared/video/video-edit.model.ts
+++ b/client/src/app/shared/video/video-edit.model.ts
@@ -85,6 +85,11 @@ export class VideoEdit implements VideoUpdate {
85 const originallyPublishedAt = new Date(values['originallyPublishedAt']) 85 const originallyPublishedAt = new Date(values['originallyPublishedAt'])
86 this.originallyPublishedAt = originallyPublishedAt.toISOString() 86 this.originallyPublishedAt = originallyPublishedAt.toISOString()
87 } 87 }
88
89 // Use the same file than the preview for the thumbnail
90 if (this.previewfile) {
91 this.thumbnailfile = this.previewfile
92 }
88 } 93 }
89 94
90 toFormPatch () { 95 toFormPatch () {
diff --git a/client/src/app/shared/video/video.model.ts b/client/src/app/shared/video/video.model.ts
index 0cef3eb8f..6f9de9241 100644
--- a/client/src/app/shared/video/video.model.ts
+++ b/client/src/app/shared/video/video.model.ts
@@ -52,7 +52,6 @@ export class Video implements VideoServerModel {
52 52
53 account: { 53 account: {
54 id: number 54 id: number
55 uuid: string
56 name: string 55 name: string
57 displayName: string 56 displayName: string
58 url: string 57 url: string
@@ -62,7 +61,6 @@ export class Video implements VideoServerModel {
62 61
63 channel: { 62 channel: {
64 id: number 63 id: number
65 uuid: string
66 name: string 64 name: string
67 displayName: string 65 displayName: string
68 url: string 66 url: string
diff --git a/client/src/app/shared/video/videos-selection.component.ts b/client/src/app/shared/video/videos-selection.component.ts
index 955ebca9f..d69f7b70e 100644
--- a/client/src/app/shared/video/videos-selection.component.ts
+++ b/client/src/app/shared/video/videos-selection.component.ts
@@ -20,6 +20,7 @@ import { Video } from '@app/shared/video/video.model'
20import { PeerTubeTemplateDirective } from '@app/shared/angular/peertube-template.directive' 20import { PeerTubeTemplateDirective } from '@app/shared/angular/peertube-template.directive'
21import { VideoSortField } from '@app/shared/video/sort-field.type' 21import { VideoSortField } from '@app/shared/video/sort-field.type'
22import { ComponentPagination } from '@app/shared/rest/component-pagination.model' 22import { ComponentPagination } from '@app/shared/rest/component-pagination.model'
23import { I18n } from '@ngx-translate/i18n-polyfill'
23 24
24export type SelectionType = { [ id: number ]: boolean } 25export type SelectionType = { [ id: number ]: boolean }
25 26
@@ -44,6 +45,7 @@ export class VideosSelectionComponent extends AbstractVideoList implements OnIni
44 globalButtonsTemplate: TemplateRef<any> 45 globalButtonsTemplate: TemplateRef<any>
45 46
46 constructor ( 47 constructor (
48 protected i18n: I18n,
47 protected router: Router, 49 protected router: Router,
48 protected route: ActivatedRoute, 50 protected route: ActivatedRoute,
49 protected notifier: Notifier, 51 protected notifier: Notifier,
diff --git a/client/src/app/signup/index.ts b/client/src/app/signup/index.ts
deleted file mode 100644
index b0aca9723..000000000
--- a/client/src/app/signup/index.ts
+++ /dev/null
@@ -1,3 +0,0 @@
1export * from './signup-routing.module'
2export * from './signup.component'
3export * from './signup.module'
diff --git a/client/src/app/signup/signup.component.html b/client/src/app/signup/signup.component.html
deleted file mode 100644
index 07d24b381..000000000
--- a/client/src/app/signup/signup.component.html
+++ /dev/null
@@ -1,72 +0,0 @@
1<div class="margin-content">
2
3 <div i18n class="title-page title-page-single">
4 Create an account
5 </div>
6
7 <div *ngIf="info" class="alert alert-info">{{ info }}</div>
8 <div *ngIf="error" class="alert alert-danger">{{ error }}</div>
9
10 <div class="d-flex justify-content-left flex-wrap">
11 <form role="form" (ngSubmit)="signup()" [formGroup]="form">
12 <div class="form-group">
13 <label for="username" i18n>Username</label>
14
15 <div class="input-group">
16 <input
17 type="text" id="username" i18n-placeholder placeholder="Example: jane_doe"
18 formControlName="username" [ngClass]="{ 'input-error': formErrors['username'] }"
19 >
20 <div class="input-group-append">
21 <span class="input-group-text">@{{ instanceHost }}</span>
22 </div>
23 </div>
24
25 <div *ngIf="formErrors.username" class="form-error">
26 {{ formErrors.username }}
27 </div>
28 </div>
29
30 <div class="form-group">
31 <label for="email" i18n>Email</label>
32 <input
33 type="text" id="email" i18n-placeholder placeholder="Email"
34 formControlName="email" [ngClass]="{ 'input-error': formErrors['email'] }"
35 >
36 <div *ngIf="formErrors.email" class="form-error">
37 {{ formErrors.email }}
38 </div>
39 </div>
40
41 <div class="form-group">
42 <label for="password" i18n>Password</label>
43 <input
44 type="password" id="password" i18n-placeholder placeholder="Password"
45 formControlName="password" [ngClass]="{ 'input-error': formErrors['password'] }"
46 >
47 <div *ngIf="formErrors.password" class="form-error">
48 {{ formErrors.password }}
49 </div>
50 </div>
51
52 <div class="form-group form-group-terms">
53 <my-peertube-checkbox
54 inputName="terms" formControlName="terms"
55 i18n-labelHtml labelHtml="I am at least 16 years old and agree to the <a href='/about/instance#terms-section' target='_blank'rel='noopener noreferrer'>Terms</a> of this instance"
56 ></my-peertube-checkbox>
57
58 <div *ngIf="formErrors.terms" class="form-error">
59 {{ formErrors.terms }}
60 </div>
61 </div>
62
63 <input type="submit" i18n-value value="Signup" [disabled]="!form.valid || signupDone">
64 </form>
65
66 <div>
67 <label i18n>Features found on this instance</label>
68 <my-instance-features-table></my-instance-features-table>
69 </div>
70 </div>
71
72</div>
diff --git a/client/src/app/signup/signup.component.scss b/client/src/app/signup/signup.component.scss
deleted file mode 100644
index 90e1e8e74..000000000
--- a/client/src/app/signup/signup.component.scss
+++ /dev/null
@@ -1,39 +0,0 @@
1@import '_variables';
2@import '_mixins';
3
4my-instance-features-table {
5 display: block;
6
7 margin-bottom: 40px;
8}
9
10form {
11 margin: 0 60px 40px 0;
12}
13
14.form-group-terms {
15 margin: 30px 0;
16}
17
18.input-group {
19 @include peertube-input-group(400px);
20}
21
22.input-group-append {
23 height: 30px;
24}
25
26input:not([type=submit]) {
27 @include peertube-input-text(400px);
28 display: block;
29
30 &#username {
31 width: auto;
32 flex-grow: 1;
33 }
34}
35
36input[type=submit] {
37 @include peertube-button;
38 @include orange-button;
39}
diff --git a/client/src/app/signup/signup.component.ts b/client/src/app/signup/signup.component.ts
deleted file mode 100644
index 13941ec79..000000000
--- a/client/src/app/signup/signup.component.ts
+++ /dev/null
@@ -1,78 +0,0 @@
1import { Component, OnInit } from '@angular/core'
2import { AuthService, Notifier, RedirectService, ServerService } from '@app/core'
3import { UserCreate } from '../../../../shared'
4import { FormReactive, UserService, UserValidatorsService } from '../shared'
5import { I18n } from '@ngx-translate/i18n-polyfill'
6import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
7
8@Component({
9 selector: 'my-signup',
10 templateUrl: './signup.component.html',
11 styleUrls: [ './signup.component.scss' ]
12})
13export class SignupComponent extends FormReactive implements OnInit {
14 info: string = null
15 error: string = null
16 signupDone = false
17
18 constructor (
19 protected formValidatorService: FormValidatorService,
20 private authService: AuthService,
21 private userValidatorsService: UserValidatorsService,
22 private notifier: Notifier,
23 private userService: UserService,
24 private serverService: ServerService,
25 private redirectService: RedirectService,
26 private i18n: I18n
27 ) {
28 super()
29 }
30
31 get instanceHost () {
32 return window.location.host
33 }
34
35 get requiresEmailVerification () {
36 return this.serverService.getConfig().signup.requiresEmailVerification
37 }
38
39 ngOnInit () {
40 this.buildForm({
41 username: this.userValidatorsService.USER_USERNAME,
42 password: this.userValidatorsService.USER_PASSWORD,
43 email: this.userValidatorsService.USER_EMAIL,
44 terms: this.userValidatorsService.USER_TERMS
45 })
46 }
47
48 signup () {
49 this.error = null
50
51 const userCreate: UserCreate = this.form.value
52
53 this.userService.signup(userCreate).subscribe(
54 () => {
55 this.signupDone = true
56
57 if (this.requiresEmailVerification) {
58 this.info = this.i18n('Welcome! Now please check your emails to verify your account and complete signup.')
59 return
60 }
61
62 // Auto login
63 this.authService.login(userCreate.username, userCreate.password)
64 .subscribe(
65 () => {
66 this.notifier.success(this.i18n('You are now logged in as {{username}}!', { username: userCreate.username }))
67
68 this.redirectService.redirectToHomepage()
69 },
70
71 err => this.error = err.message
72 )
73 },
74
75 err => this.error = err.message
76 )
77 }
78}
diff --git a/client/src/app/signup/signup.module.ts b/client/src/app/signup/signup.module.ts
deleted file mode 100644
index 61560ddcf..000000000
--- a/client/src/app/signup/signup.module.ts
+++ /dev/null
@@ -1,24 +0,0 @@
1import { NgModule } from '@angular/core'
2
3import { SignupRoutingModule } from './signup-routing.module'
4import { SignupComponent } from './signup.component'
5import { SharedModule } from '../shared'
6
7@NgModule({
8 imports: [
9 SignupRoutingModule,
10 SharedModule
11 ],
12
13 declarations: [
14 SignupComponent
15 ],
16
17 exports: [
18 SignupComponent
19 ],
20
21 providers: [
22 ]
23})
24export class SignupModule { }
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 99695204d..28572d611 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
@@ -187,18 +187,14 @@
187 <ng-template ngbTabContent> 187 <ng-template ngbTabContent>
188 <div class="row advanced-settings"> 188 <div class="row advanced-settings">
189 <div class="col-md-12 col-xl-8"> 189 <div class="col-md-12 col-xl-8">
190 <div class="form-group">
191 <my-image-upload
192 i18n-inputLabel inputLabel="Upload thumbnail" inputName="thumbnailfile" formControlName="thumbnailfile"
193 previewWidth="200px" previewHeight="110px"
194 ></my-image-upload>
195 </div>
196 190
197 <div class="form-group"> 191 <div class="form-group">
198 <my-image-upload 192 <label i18n for="previewfile">Video preview</label>
199 i18n-inputLabel inputLabel="Upload preview" inputName="previewfile" formControlName="previewfile" 193
194 <my-preview-upload
195 i18n-inputLabel inputLabel="Edit" inputName="previewfile" formControlName="previewfile"
200 previewWidth="360px" previewHeight="200px" 196 previewWidth="360px" previewHeight="200px"
201 ></my-image-upload> 197 ></my-preview-upload>
202 </div> 198 </div>
203 199
204 <div class="form-group"> 200 <div class="form-group">
diff --git a/client/src/app/videos/+video-edit/shared/video-edit.component.ts b/client/src/app/videos/+video-edit/shared/video-edit.component.ts
index 8345645f6..cea352bfb 100644
--- a/client/src/app/videos/+video-edit/shared/video-edit.component.ts
+++ b/client/src/app/videos/+video-edit/shared/video-edit.component.ts
@@ -13,6 +13,7 @@ import { VideoCaptionAddModalComponent } from '@app/videos/+video-edit/shared/vi
13import { VideoCaptionEdit } from '@app/shared/video-caption/video-caption-edit.model' 13import { VideoCaptionEdit } from '@app/shared/video-caption/video-caption-edit.model'
14import { removeElementFromArray } from '@app/shared/misc/utils' 14import { removeElementFromArray } from '@app/shared/misc/utils'
15import { VideoConstant, VideoPrivacy } from '../../../../../../shared' 15import { VideoConstant, VideoPrivacy } from '../../../../../../shared'
16import { VideoService } from '@app/shared/video/video.service'
16 17
17@Component({ 18@Component({
18 selector: 'my-video-edit', 19 selector: 'my-video-edit',
@@ -23,7 +24,6 @@ export class VideoEditComponent implements OnInit, OnDestroy {
23 @Input() form: FormGroup 24 @Input() form: FormGroup
24 @Input() formErrors: { [ id: string ]: string } = {} 25 @Input() formErrors: { [ id: string ]: string } = {}
25 @Input() validationMessages: FormReactiveValidationMessages = {} 26 @Input() validationMessages: FormReactiveValidationMessages = {}
26 @Input() videoPrivacies: VideoConstant<VideoPrivacy>[] = []
27 @Input() userVideoChannels: { id: number, label: string, support: string }[] = [] 27 @Input() userVideoChannels: { id: number, label: string, support: string }[] = []
28 @Input() schedulePublicationPossible = true 28 @Input() schedulePublicationPossible = true
29 @Input() videoCaptions: (VideoCaptionEdit & { captionPath?: string })[] = [] 29 @Input() videoCaptions: (VideoCaptionEdit & { captionPath?: string })[] = []
@@ -34,6 +34,7 @@ export class VideoEditComponent implements OnInit, OnDestroy {
34 // So that it can be accessed in the template 34 // So that it can be accessed in the template
35 readonly SPECIAL_SCHEDULED_PRIVACY = VideoEdit.SPECIAL_SCHEDULED_PRIVACY 35 readonly SPECIAL_SCHEDULED_PRIVACY = VideoEdit.SPECIAL_SCHEDULED_PRIVACY
36 36
37 videoPrivacies: VideoConstant<VideoPrivacy>[] = []
37 videoCategories: VideoConstant<number>[] = [] 38 videoCategories: VideoConstant<number>[] = []
38 videoLicences: VideoConstant<number>[] = [] 39 videoLicences: VideoConstant<number>[] = []
39 videoLanguages: VideoConstant<string>[] = [] 40 videoLanguages: VideoConstant<string>[] = []
@@ -58,6 +59,7 @@ export class VideoEditComponent implements OnInit, OnDestroy {
58 private formValidatorService: FormValidatorService, 59 private formValidatorService: FormValidatorService,
59 private videoValidatorsService: VideoValidatorsService, 60 private videoValidatorsService: VideoValidatorsService,
60 private videoCaptionService: VideoCaptionService, 61 private videoCaptionService: VideoCaptionService,
62 private videoService: VideoService,
61 private route: ActivatedRoute, 63 private route: ActivatedRoute,
62 private router: Router, 64 private router: Router,
63 private notifier: Notifier, 65 private notifier: Notifier,
@@ -100,7 +102,6 @@ export class VideoEditComponent implements OnInit, OnDestroy {
100 language: this.videoValidatorsService.VIDEO_LANGUAGE, 102 language: this.videoValidatorsService.VIDEO_LANGUAGE,
101 description: this.videoValidatorsService.VIDEO_DESCRIPTION, 103 description: this.videoValidatorsService.VIDEO_DESCRIPTION,
102 tags: null, 104 tags: null,
103 thumbnailfile: null,
104 previewfile: null, 105 previewfile: null,
105 support: this.videoValidatorsService.VIDEO_SUPPORT, 106 support: this.videoValidatorsService.VIDEO_SUPPORT,
106 schedulePublicationAt: this.videoValidatorsService.VIDEO_SCHEDULE_PUBLICATION_AT, 107 schedulePublicationAt: this.videoValidatorsService.VIDEO_SCHEDULE_PUBLICATION_AT,
@@ -133,6 +134,9 @@ export class VideoEditComponent implements OnInit, OnDestroy {
133 this.videoLicences = this.serverService.getVideoLicences() 134 this.videoLicences = this.serverService.getVideoLicences()
134 this.videoLanguages = this.serverService.getVideoLanguages() 135 this.videoLanguages = this.serverService.getVideoLanguages()
135 136
137 const privacies = this.serverService.getVideoPrivacies()
138 this.videoPrivacies = this.videoService.explainedPrivacyLabels(privacies)
139
136 this.initialVideoCaptions = this.videoCaptions.map(c => c.language.id) 140 this.initialVideoCaptions = this.videoCaptions.map(c => c.language.id)
137 141
138 this.ngZone.runOutsideAngular(() => { 142 this.ngZone.runOutsideAngular(() => {
diff --git a/client/src/app/videos/+video-edit/video-add-components/video-import-torrent.component.html b/client/src/app/videos/+video-edit/video-add-components/video-import-torrent.component.html
index 537d7ffa2..7a495fea5 100644
--- a/client/src/app/videos/+video-edit/video-add-components/video-import-torrent.component.html
+++ b/client/src/app/videos/+video-edit/video-add-components/video-import-torrent.component.html
@@ -58,7 +58,7 @@
58<form [hidden]="!hasImportedVideo" novalidate [formGroup]="form"> 58<form [hidden]="!hasImportedVideo" novalidate [formGroup]="form">
59 <my-video-edit 59 <my-video-edit
60 [form]="form" [formErrors]="formErrors" [videoCaptions]="videoCaptions" [schedulePublicationPossible]="false" 60 [form]="form" [formErrors]="formErrors" [videoCaptions]="videoCaptions" [schedulePublicationPossible]="false"
61 [validationMessages]="validationMessages" [videoPrivacies]="explainedVideoPrivacies" [userVideoChannels]="userVideoChannels" 61 [validationMessages]="validationMessages" [userVideoChannels]="userVideoChannels"
62 ></my-video-edit> 62 ></my-video-edit>
63 63
64 <div class="submit-container"> 64 <div class="submit-container">
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 d2e9f6cfe..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
@@ -100,8 +100,6 @@ export class VideoImportTorrentComponent extends VideoSend implements OnInit, Ca
100 previewUrl: null 100 previewUrl: null
101 })) 101 }))
102 102
103 this.explainedVideoPrivacies = this.videoService.explainedPrivacyLabels(this.videoPrivacies)
104
105 this.hydrateFormFromVideo() 103 this.hydrateFormFromVideo()
106 }, 104 },
107 105
diff --git a/client/src/app/videos/+video-edit/video-add-components/video-import-url.component.html b/client/src/app/videos/+video-edit/video-add-components/video-import-url.component.html
index 984b9d590..e4f19faa8 100644
--- a/client/src/app/videos/+video-edit/video-add-components/video-import-url.component.html
+++ b/client/src/app/videos/+video-edit/video-add-components/video-import-url.component.html
@@ -51,7 +51,7 @@
51<form [hidden]="!hasImportedVideo" novalidate [formGroup]="form"> 51<form [hidden]="!hasImportedVideo" novalidate [formGroup]="form">
52 <my-video-edit 52 <my-video-edit
53 [form]="form" [formErrors]="formErrors" [videoCaptions]="videoCaptions" [schedulePublicationPossible]="false" 53 [form]="form" [formErrors]="formErrors" [videoCaptions]="videoCaptions" [schedulePublicationPossible]="false"
54 [validationMessages]="validationMessages" [videoPrivacies]="explainedVideoPrivacies" [userVideoChannels]="userVideoChannels" 54 [validationMessages]="validationMessages" [userVideoChannels]="userVideoChannels"
55 ></my-video-edit> 55 ></my-video-edit>
56 56
57 <div class="submit-container"> 57 <div class="submit-container">
diff --git a/client/src/app/videos/+video-edit/video-add-components/video-import-url.component.ts b/client/src/app/videos/+video-edit/video-add-components/video-import-url.component.ts
index 2dffdbf0e..a5578bebd 100644
--- a/client/src/app/videos/+video-edit/video-add-components/video-import-url.component.ts
+++ b/client/src/app/videos/+video-edit/video-add-components/video-import-url.component.ts
@@ -91,8 +91,6 @@ export class VideoImportUrlComponent extends VideoSend implements OnInit, CanCom
91 previewUrl: null 91 previewUrl: null
92 })) 92 }))
93 93
94 this.explainedVideoPrivacies = this.videoService.explainedPrivacyLabels(this.videoPrivacies)
95
96 this.hydrateFormFromVideo() 94 this.hydrateFormFromVideo()
97 }, 95 },
98 96
diff --git a/client/src/app/videos/+video-edit/video-add-components/video-send.ts b/client/src/app/videos/+video-edit/video-add-components/video-send.ts
index 8401caeec..580c123a0 100644
--- a/client/src/app/videos/+video-edit/video-add-components/video-send.ts
+++ b/client/src/app/videos/+video-edit/video-add-components/video-send.ts
@@ -14,7 +14,6 @@ import { CanComponentDeactivateResult } from '@app/shared/guards/can-deactivate-
14export abstract class VideoSend extends FormReactive implements OnInit { 14export abstract class VideoSend extends FormReactive implements OnInit {
15 userVideoChannels: { id: number, label: string, support: string }[] = [] 15 userVideoChannels: { id: number, label: string, support: string }[] = []
16 videoPrivacies: VideoConstant<VideoPrivacy>[] = [] 16 videoPrivacies: VideoConstant<VideoPrivacy>[] = []
17 explainedVideoPrivacies: VideoConstant<VideoPrivacy>[] = []
18 videoCaptions: VideoCaptionEdit[] = [] 17 videoCaptions: VideoCaptionEdit[] = []
19 18
20 firstStepPrivacyId = 0 19 firstStepPrivacyId = 0
diff --git a/client/src/app/videos/+video-edit/video-add-components/video-upload.component.html b/client/src/app/videos/+video-edit/video-add-components/video-upload.component.html
index 536769d2f..0f904affb 100644
--- a/client/src/app/videos/+video-edit/video-add-components/video-upload.component.html
+++ b/client/src/app/videos/+video-edit/video-add-components/video-upload.component.html
@@ -26,6 +26,27 @@
26 </select> 26 </select>
27 </div> 27 </div>
28 </div> 28 </div>
29
30 <ng-container *ngIf="isUploadingAudioFile">
31 <div class="form-group audio-preview">
32 <label i18n for="previewfileUpload">Video background image</label>
33
34 <div i18n class="audio-image-info">
35 Image that will be merged with your audio file.
36 <br />
37 The chosen image will be definitive and cannot be modified.
38 </div>
39
40 <my-preview-upload
41 i18n-inputLabel inputLabel="Edit" inputName="previewfileUpload" [(ngModel)]="previewfileUpload"
42 previewWidth="360px" previewHeight="200px"
43 ></my-preview-upload>
44 </div>
45
46 <div class="form-group upload-audio-button">
47 <my-button className="orange-button" i18n-label [label]="getAudioUploadLabel()" icon="upload" (click)="uploadFirstStep(true)"></my-button>
48 </div>
49 </ng-container>
29 </div> 50 </div>
30</div> 51</div>
31 52
@@ -50,7 +71,7 @@
50<form [hidden]="!isUploadingVideo" novalidate [formGroup]="form"> 71<form [hidden]="!isUploadingVideo" novalidate [formGroup]="form">
51 <my-video-edit 72 <my-video-edit
52 [form]="form" [formErrors]="formErrors" [videoCaptions]="videoCaptions" 73 [form]="form" [formErrors]="formErrors" [videoCaptions]="videoCaptions"
53 [validationMessages]="validationMessages" [videoPrivacies]="explainedVideoPrivacies" [userVideoChannels]="userVideoChannels" 74 [validationMessages]="validationMessages" [userVideoChannels]="userVideoChannels"
54 [waitTranscodingEnabled]="waitTranscodingEnabled" 75 [waitTranscodingEnabled]="waitTranscodingEnabled"
55 ></my-video-edit> 76 ></my-video-edit>
56 77
diff --git a/client/src/app/videos/+video-edit/video-add-components/video-upload.component.scss b/client/src/app/videos/+video-edit/video-add-components/video-upload.component.scss
index 8adf8f169..684342f09 100644
--- a/client/src/app/videos/+video-edit/video-add-components/video-upload.component.scss
+++ b/client/src/app/videos/+video-edit/video-add-components/video-upload.component.scss
@@ -1,9 +1,20 @@
1@import 'variables'; 1@import 'variables';
2@import 'mixins'; 2@import 'mixins';
3 3
4.first-step-block .form-group-channel { 4.first-step-block {
5 margin-bottom: 20px; 5
6 margin-top: 35px; 6 .form-group-channel {
7 margin-bottom: 20px;
8 margin-top: 35px;
9 }
10
11 .audio-image-info {
12 margin-bottom: 10px;
13 }
14
15 .audio-preview {
16 margin: 30px 0;
17 }
7} 18}
8 19
9.upload-progress-cancel { 20.upload-progress-cancel {
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 d6d4bad21..69fa13a2f 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
@@ -35,8 +35,10 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy
35 userVideoQuotaUsed = 0 35 userVideoQuotaUsed = 0
36 userVideoQuotaUsedDaily = 0 36 userVideoQuotaUsedDaily = 0
37 37
38 isUploadingAudioFile = false
38 isUploadingVideo = false 39 isUploadingVideo = false
39 isUpdatingVideo = false 40 isUpdatingVideo = false
41
40 videoUploaded = false 42 videoUploaded = false
41 videoUploadObservable: Subscription = null 43 videoUploadObservable: Subscription = null
42 videoUploadPercents = 0 44 videoUploadPercents = 0
@@ -44,7 +46,9 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy
44 id: 0, 46 id: 0,
45 uuid: '' 47 uuid: ''
46 } 48 }
49
47 waitTranscodingEnabled = true 50 waitTranscodingEnabled = true
51 previewfileUpload: File
48 52
49 error: string 53 error: string
50 54
@@ -100,6 +104,17 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy
100 } 104 }
101 } 105 }
102 106
107 getVideoFile () {
108 return this.videofileInput.nativeElement.files[0]
109 }
110
111 getAudioUploadLabel () {
112 const videofile = this.getVideoFile()
113 if (!videofile) return this.i18n('Upload')
114
115 return this.i18n('Upload {{videofileName}}', { videofileName: videofile.name })
116 }
117
103 fileChange () { 118 fileChange () {
104 this.uploadFirstStep() 119 this.uploadFirstStep()
105 } 120 }
@@ -114,38 +129,15 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy
114 } 129 }
115 } 130 }
116 131
117 uploadFirstStep () { 132 uploadFirstStep (clickedOnButton = false) {
118 const videofile = this.videofileInput.nativeElement.files[0] 133 const videofile = this.getVideoFile()
119 if (!videofile) return 134 if (!videofile) return
120 135
121 // Check global user quota 136 if (!this.checkGlobalUserQuota(videofile)) return
122 const bytePipes = new BytesPipe() 137 if (!this.checkDailyUserQuota(videofile)) return
123 const videoQuota = this.authService.getUser().videoQuota
124 if (videoQuota !== -1 && (this.userVideoQuotaUsed + videofile.size) > videoQuota) {
125 const msg = this.i18n(
126 'Your video quota is exceeded with this video (video size: {{videoSize}}, used: {{videoQuotaUsed}}, quota: {{videoQuota}})',
127 {
128 videoSize: bytePipes.transform(videofile.size, 0),
129 videoQuotaUsed: bytePipes.transform(this.userVideoQuotaUsed, 0),
130 videoQuota: bytePipes.transform(videoQuota, 0)
131 }
132 )
133 this.notifier.error(msg)
134 return
135 }
136 138
137 // Check daily user quota 139 if (clickedOnButton === false && this.isAudioFile(videofile.name)) {
138 const videoQuotaDaily = this.authService.getUser().videoQuotaDaily 140 this.isUploadingAudioFile = true
139 if (videoQuotaDaily !== -1 && (this.userVideoQuotaUsedDaily + videofile.size) > videoQuotaDaily) {
140 const msg = this.i18n(
141 'Your daily video quota is exceeded with this video (video size: {{videoSize}}, used: {{quotaUsedDaily}}, quota: {{quotaDaily}})',
142 {
143 videoSize: bytePipes.transform(videofile.size, 0),
144 quotaUsedDaily: bytePipes.transform(this.userVideoQuotaUsedDaily, 0),
145 quotaDaily: bytePipes.transform(videoQuotaDaily, 0)
146 }
147 )
148 this.notifier.error(msg)
149 return 141 return
150 } 142 }
151 143
@@ -180,6 +172,11 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy
180 formData.append('channelId', '' + channelId) 172 formData.append('channelId', '' + channelId)
181 formData.append('videofile', videofile) 173 formData.append('videofile', videofile)
182 174
175 if (this.previewfileUpload) {
176 formData.append('previewfile', this.previewfileUpload)
177 formData.append('thumbnailfile', this.previewfileUpload)
178 }
179
183 this.isUploadingVideo = true 180 this.isUploadingVideo = true
184 this.firstStepDone.emit(name) 181 this.firstStepDone.emit(name)
185 182
@@ -187,11 +184,10 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy
187 name, 184 name,
188 privacy, 185 privacy,
189 nsfw, 186 nsfw,
190 channelId 187 channelId,
188 previewfile: this.previewfileUpload
191 }) 189 })
192 190
193 this.explainedVideoPrivacies = this.videoService.explainedPrivacyLabels(this.videoPrivacies)
194
195 this.videoUploadObservable = this.videoService.uploadVideo(formData).subscribe( 191 this.videoUploadObservable = this.videoService.uploadVideo(formData).subscribe(
196 event => { 192 event => {
197 if (event.type === HttpEventType.UploadProgress) { 193 if (event.type === HttpEventType.UploadProgress) {
@@ -251,4 +247,52 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy
251 } 247 }
252 ) 248 )
253 } 249 }
250
251 private checkGlobalUserQuota (videofile: File) {
252 const bytePipes = new BytesPipe()
253
254 // Check global user quota
255 const videoQuota = this.authService.getUser().videoQuota
256 if (videoQuota !== -1 && (this.userVideoQuotaUsed + videofile.size) > videoQuota) {
257 const msg = this.i18n(
258 'Your video quota is exceeded with this video (video size: {{videoSize}}, used: {{videoQuotaUsed}}, quota: {{videoQuota}})',
259 {
260 videoSize: bytePipes.transform(videofile.size, 0),
261 videoQuotaUsed: bytePipes.transform(this.userVideoQuotaUsed, 0),
262 videoQuota: bytePipes.transform(videoQuota, 0)
263 }
264 )
265 this.notifier.error(msg)
266
267 return false
268 }
269
270 return true
271 }
272
273 private checkDailyUserQuota (videofile: File) {
274 const bytePipes = new BytesPipe()
275
276 // Check daily user quota
277 const videoQuotaDaily = this.authService.getUser().videoQuotaDaily
278 if (videoQuotaDaily !== -1 && (this.userVideoQuotaUsedDaily + videofile.size) > videoQuotaDaily) {
279 const msg = this.i18n(
280 'Your daily video quota is exceeded with this video (video size: {{videoSize}}, used: {{quotaUsedDaily}}, quota: {{quotaDaily}})',
281 {
282 videoSize: bytePipes.transform(videofile.size, 0),
283 quotaUsedDaily: bytePipes.transform(this.userVideoQuotaUsedDaily, 0),
284 quotaDaily: bytePipes.transform(videoQuotaDaily, 0)
285 }
286 )
287 this.notifier.error(msg)
288
289 return false
290 }
291
292 return true
293 }
294
295 private isAudioFile (filename: string) {
296 return filename.endsWith('.mp3') || filename.endsWith('.flac') || filename.endsWith('.ogg')
297 }
254} 298}
diff --git a/client/src/app/videos/+video-edit/video-update.component.html b/client/src/app/videos/+video-edit/video-update.component.html
index b5cab7ed5..aa148311f 100644
--- a/client/src/app/videos/+video-edit/video-update.component.html
+++ b/client/src/app/videos/+video-edit/video-update.component.html
@@ -7,7 +7,7 @@
7 7
8 <my-video-edit 8 <my-video-edit
9 [form]="form" [formErrors]="formErrors" [schedulePublicationPossible]="schedulePublicationPossible" 9 [form]="form" [formErrors]="formErrors" [schedulePublicationPossible]="schedulePublicationPossible"
10 [validationMessages]="validationMessages" [videoPrivacies]="explainedVideoPrivacies" [userVideoChannels]="userVideoChannels" 10 [validationMessages]="validationMessages" [userVideoChannels]="userVideoChannels"
11 [videoCaptions]="videoCaptions" [waitTranscodingEnabled]="waitTranscodingEnabled" 11 [videoCaptions]="videoCaptions" [waitTranscodingEnabled]="waitTranscodingEnabled"
12 ></my-video-edit> 12 ></my-video-edit>
13 13
diff --git a/client/src/app/videos/+video-edit/video-update.component.ts b/client/src/app/videos/+video-edit/video-update.component.ts
index 10f797d02..81c66ff20 100644
--- a/client/src/app/videos/+video-edit/video-update.component.ts
+++ b/client/src/app/videos/+video-edit/video-update.component.ts
@@ -3,7 +3,6 @@ import { Component, HostListener, OnInit } from '@angular/core'
3import { ActivatedRoute, Router } from '@angular/router' 3import { ActivatedRoute, Router } from '@angular/router'
4import { LoadingBarService } from '@ngx-loading-bar/core' 4import { LoadingBarService } from '@ngx-loading-bar/core'
5import { Notifier } from '@app/core' 5import { Notifier } from '@app/core'
6import { VideoConstant, VideoPrivacy } from '../../../../../shared/models/videos'
7import { ServerService } from '../../core' 6import { ServerService } from '../../core'
8import { FormReactive } from '../../shared' 7import { FormReactive } from '../../shared'
9import { VideoEdit } from '../../shared/video/video-edit.model' 8import { VideoEdit } from '../../shared/video/video-edit.model'
@@ -13,6 +12,7 @@ import { FormValidatorService } from '@app/shared/forms/form-validators/form-val
13import { VideoCaptionService } from '@app/shared/video-caption' 12import { VideoCaptionService } from '@app/shared/video-caption'
14import { VideoCaptionEdit } from '@app/shared/video-caption/video-caption-edit.model' 13import { VideoCaptionEdit } from '@app/shared/video-caption/video-caption-edit.model'
15import { VideoDetails } from '@app/shared/video/video-details.model' 14import { VideoDetails } from '@app/shared/video/video-details.model'
15import { VideoPrivacy } from '@shared/models'
16 16
17@Component({ 17@Component({
18 selector: 'my-videos-update', 18 selector: 'my-videos-update',
@@ -23,8 +23,6 @@ export class VideoUpdateComponent extends FormReactive implements OnInit {
23 video: VideoEdit 23 video: VideoEdit
24 24
25 isUpdatingVideo = false 25 isUpdatingVideo = false
26 videoPrivacies: VideoConstant<VideoPrivacy>[] = []
27 explainedVideoPrivacies: VideoConstant<VideoPrivacy>[] = []
28 userVideoChannels: { id: number, label: string, support: string }[] = [] 26 userVideoChannels: { id: number, label: string, support: string }[] = []
29 schedulePublicationPossible = false 27 schedulePublicationPossible = false
30 videoCaptions: VideoCaptionEdit[] = [] 28 videoCaptions: VideoCaptionEdit[] = []
@@ -49,9 +47,6 @@ export class VideoUpdateComponent extends FormReactive implements OnInit {
49 ngOnInit () { 47 ngOnInit () {
50 this.buildForm({}) 48 this.buildForm({})
51 49
52 this.serverService.videoPrivaciesLoaded
53 .subscribe(() => this.videoPrivacies = this.serverService.getVideoPrivacies())
54
55 this.route.data 50 this.route.data
56 .pipe(map(data => data.videoData)) 51 .pipe(map(data => data.videoData))
57 .subscribe(({ video, videoChannels, videoCaptions }) => { 52 .subscribe(({ video, videoChannels, videoCaptions }) => {
@@ -59,14 +54,7 @@ export class VideoUpdateComponent extends FormReactive implements OnInit {
59 this.userVideoChannels = videoChannels 54 this.userVideoChannels = videoChannels
60 this.videoCaptions = videoCaptions 55 this.videoCaptions = videoCaptions
61 56
62 // We cannot set private a video that was not private 57 this.schedulePublicationPossible = this.video.privacy === VideoPrivacy.PRIVATE
63 if (this.video.privacy !== VideoPrivacy.PRIVATE) {
64 this.videoPrivacies = this.videoPrivacies.filter(p => p.id !== VideoPrivacy.PRIVATE)
65 } else { // We can schedule video publication only if it it is private
66 this.schedulePublicationPossible = this.video.privacy === VideoPrivacy.PRIVATE
67 }
68
69 this.explainedVideoPrivacies = this.videoService.explainedPrivacyLabels(this.videoPrivacies)
70 58
71 const videoFiles = (video as VideoDetails).files 59 const videoFiles = (video as VideoDetails).files
72 if (videoFiles.length > 1) { // Already transcoded 60 if (videoFiles.length > 1) { // Already transcoded
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 955b2b80c..82e59d04d 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
@@ -5,53 +5,167 @@
5 </div> 5 </div>
6 6
7 <div class="modal-body"> 7 <div class="modal-body">
8 <ngb-tabset class="root-tabset bootstrap" (tabChange)="onTabChange($event)">
8 9
9 <div class="start-at"> 10 <ngb-tab i18n-title title="URL" id="url">
10 <my-peertube-checkbox 11 <ng-template ngbTabContent>
11 inputName="startAt" [(ngModel)]="startAtCheckbox" 12
12 i18n-labelText labelText="Start at" 13 <div class="tab-content">
13 ></my-peertube-checkbox> 14 <div class="input-group">
14 15 <input #urlInput (click)="urlInput.select()" type="text" class="form-control readonly" readonly [value]="getVideoUrl()" />
15 <my-timestamp-input 16 <div class="input-group-append">
16 [timestamp]="currentVideoTimestamp" 17 <button [ngxClipboard]="urlInput" (click)="activateCopiedMessage()" type="button" class="btn btn-outline-secondary">
17 [maxTimestamp]="video.duration" 18 <span class="glyphicon glyphicon-copy"></span>
18 [disabled]="!startAtCheckbox" 19 </button>
19 [(ngModel)]="currentVideoTimestamp" 20 </div>
20 > 21 </div>
21 </my-timestamp-input> 22 </div>
22 </div> 23
24 </ng-template>
25 </ngb-tab>
26
27 <ngb-tab i18n-title title="QR-Code" id="qrcode">
28 <ng-template ngbTabContent>
29 <div class="tab-content">
30 <ngx-qrcode qrc-element-type="url" [qrc-value]="getVideoUrl()" qrc-errorCorrectionLevel="Q"></ngx-qrcode>
31 </div>
32 </ng-template>
33 </ngb-tab>
34
35 <ngb-tab i18n-title title="Embed" id="embed">
36 <ng-template ngbTabContent>
37 <div class="tab-content">
38 <div class="input-group">
39 <input #shareInput (click)="shareInput.select()" type="text" class="form-control readonly" readonly [value]="getVideoIframeCode()" />
40 <div class="input-group-append">
41 <button [ngxClipboard]="shareInput" (click)="activateCopiedMessage()" type="button" class="btn btn-outline-secondary">
42 <span class="glyphicon glyphicon-copy"></span>
43 </button>
44 </div>
45 </div>
46
47 <div i18n *ngIf="notSecure()" class="alert alert-warning">
48 The url is not secured (no HTTPS), so the embed video won't work on HTTPS websites (web browsers block non secured HTTP requests on HTTPS websites).
49 </div>
50 </div>
51 </ng-template>
52 </ngb-tab>
53
54 </ngb-tabset>
23 55
24 <div class="form-group"> 56 <div class="filters">
25 <label i18n>URL</label> 57 <div>
26 <div class="input-group input-group-sm"> 58 <div class="form-group start-at">
27 <input #urlInput (click)="urlInput.select()" type="text" class="form-control input-sm readonly" readonly [value]="getVideoUrl()" /> 59 <my-peertube-checkbox
28 <div class="input-group-append"> 60 inputName="startAt" [(ngModel)]="customizations.startAtCheckbox"
29 <button [ngxClipboard]="urlInput" (click)="activateCopiedMessage()" type="button" class="btn btn-outline-secondary"> 61 i18n-labelText labelText="Start at"
30 <span class="glyphicon glyphicon-copy"></span> 62 ></my-peertube-checkbox>
31 </button> 63
64 <my-timestamp-input
65 [timestamp]="customizations.startAt"
66 [maxTimestamp]="video.duration"
67 [disabled]="!customizations.startAtCheckbox"
68 [(ngModel)]="customizations.startAt"
69 >
70 </my-timestamp-input>
32 </div> 71 </div>
33 </div>
34 </div>
35 72
36 <div class="form-group qr-code-group"> 73 <div *ngIf="videoCaptions.length !== 0" class="form-group video-caption-block">
37 <label i18n>QR-Code</label> 74 <my-peertube-checkbox
38 <ngx-qrcode qrc-element-type="url" [qrc-value]="getVideoUrl()" qrc-errorCorrectionLevel="Q"></ngx-qrcode> 75 inputName="subtitleCheckbox" [(ngModel)]="customizations.subtitleCheckbox"
39 </div> 76 i18n-labelText labelText="Auto select subtitle"
77 ></my-peertube-checkbox>
40 78
41 <div class="form-group"> 79 <div class="peertube-select-container" [ngClass]="{ disabled: !customizations.subtitleCheckbox }">
42 <label i18n>Embed</label> 80 <select [(ngModel)]="customizations.subtitle" [disabled]="!customizations.subtitleCheckbox">
43 <div class="input-group input-group-sm"> 81 <option *ngFor="let caption of videoCaptions" [value]="caption.language.id">{{ caption.language.label }}</option>
44 <input #shareInput (click)="shareInput.select()" type="text" class="form-control input-sm readonly" readonly [value]="getVideoIframeCode()" /> 82 </select>
45 <div class="input-group-append"> 83 </div>
46 <button [ngxClipboard]="shareInput" (click)="activateCopiedMessage()" type="button" class="btn btn-outline-secondary">
47 <span class="glyphicon glyphicon-copy"></span>
48 </button>
49 </div> 84 </div>
50 </div> 85 </div>
51 </div>
52 86
53 <div i18n *ngIf="notSecure()" class="alert alert-warning"> 87 <div (click)="isAdvancedCustomizationCollapsed = !isAdvancedCustomizationCollapsed" role="button" class="advanced-filters-button"
54 The url is not secured (no HTTPS), so the embed video won't work on HTTPS websites (web browsers block non secured HTTP requests on HTTPS websites). 88 [attr.aria-expanded]="!isAdvancedCustomizationCollapsed" aria-controls="collapseBasic">
89
90 <ng-container *ngIf="isAdvancedCustomizationCollapsed">
91 <span class="glyphicon glyphicon-menu-down"></span>
92
93 <ng-container i18n>
94 More customization
95 </ng-container>
96 </ng-container>
97
98 <ng-container *ngIf="!isAdvancedCustomizationCollapsed">
99 <span class="glyphicon glyphicon-menu-up"></span>
100
101 <ng-container i18n>
102 Less customization
103 </ng-container>
104 </ng-container>
105 </div>
106
107 <div class="advanced-filters collapse-transition" [ngbCollapse]="isAdvancedCustomizationCollapsed">
108 <div>
109 <div class="form-group stop-at">
110 <my-peertube-checkbox
111 inputName="stopAt" [(ngModel)]="customizations.stopAtCheckbox"
112 i18n-labelText labelText="Stop at"
113 ></my-peertube-checkbox>
114
115 <my-timestamp-input
116 [timestamp]="customizations.stopAt"
117 [maxTimestamp]="video.duration"
118 [disabled]="!customizations.stopAtCheckbox"
119 [(ngModel)]="customizations.stopAt"
120 >
121 </my-timestamp-input>
122 </div>
123
124 <div class="form-group">
125 <my-peertube-checkbox
126 inputName="autoplay" [(ngModel)]="customizations.autoplay"
127 i18n-labelText labelText="Autoplay"
128 ></my-peertube-checkbox>
129 </div>
130
131 <div class="form-group">
132 <my-peertube-checkbox
133 inputName="muted" [(ngModel)]="customizations.muted"
134 i18n-labelText labelText="Muted"
135 ></my-peertube-checkbox>
136 </div>
137
138 <div class="form-group">
139 <my-peertube-checkbox
140 inputName="loop" [(ngModel)]="customizations.loop"
141 i18n-labelText labelText="Loop"
142 ></my-peertube-checkbox>
143 </div>
144 </div>
145
146 <ng-container *ngIf="isInEmbedTab()">
147 <div class="form-group">
148 <my-peertube-checkbox
149 inputName="title" [(ngModel)]="customizations.title"
150 i18n-labelText labelText="Display video title"
151 ></my-peertube-checkbox>
152 </div>
153
154 <div class="form-group">
155 <my-peertube-checkbox
156 inputName="warningTitle" [(ngModel)]="customizations.warningTitle"
157 i18n-labelText labelText="Display privacy warning"
158 ></my-peertube-checkbox>
159 </div>
160
161 <div class="form-group">
162 <my-peertube-checkbox
163 inputName="controls" [(ngModel)]="customizations.controls"
164 i18n-labelText labelText="Display player controls"
165 ></my-peertube-checkbox>
166 </div>
167 </ng-container>
168 </div>
55 </div> 169 </div>
56 </div> 170 </div>
57 171
diff --git a/client/src/app/videos/+video-watch/modal/video-share.component.scss b/client/src/app/videos/+video-watch/modal/video-share.component.scss
index 472a45920..c48abf9e0 100644
--- a/client/src/app/videos/+video-watch/modal/video-share.component.scss
+++ b/client/src/app/videos/+video-watch/modal/video-share.component.scss
@@ -1,5 +1,9 @@
1@import '~bootstrap/scss/functions'; 1@import '_mixins';
2@import '~bootstrap/scss/variables'; 2@import '_variables';
3
4.peertube-select-container {
5 @include peertube-select-container(200px);
6}
3 7
4.action-button-cancel { 8.action-button-cancel {
5 margin-right: 0 !important; 9 margin-right: 0 !important;
@@ -9,13 +13,65 @@
9 text-align: center; 13 text-align: center;
10} 14}
11 15
12.start-at { 16.tab-content {
17 margin-top: 30px;
13 display: flex; 18 display: flex;
14 justify-content: center; 19 justify-content: center;
15 margin-top: 10px;
16 align-items: center; 20 align-items: center;
21 flex-direction: column;
22}
23
24.alert {
25 margin-top: 20px;
26}
27
28input.readonly {
29 font-size: 15px;
30}
31
32.filters {
33 margin-top: 30px;
34 padding-top: 30px;
35 border-top: 1px solid $separator-border-color;
36
37 .advanced-filters-button {
38 display: flex;
39 justify-content: center;
40 align-items: center;
41 margin-top: 30px;
42 font-size: 16px;
43 font-weight: $font-semibold;
44 cursor: pointer;
45
46 .glyphicon {
47 margin-right: 5px;
48 }
49 }
50
51 .form-group {
52 margin-bottom: 0;
53 height: 34px;
54 display: flex;
55 align-items: center;
56 }
57
58 .video-caption-block {
59 display: flex;
60 align-items: center;
61
62 .peertube-select-container {
63 margin-left: 10px;
64 }
65 }
66
67 .start-at,
68 .stop-at {
69 width: 300px;
70 display: flex;
71 align-items: center;
17 72
18 my-timestamp-input { 73 my-timestamp-input {
19 margin-left: 10px; 74 margin-left: 10px;
75 }
20 } 76 }
21} 77}
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 6565d7f88..eaaf6b902 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
@@ -3,8 +3,26 @@ import { Notifier } from '@app/core'
3import { VideoDetails } from '../../../shared/video/video-details.model' 3import { VideoDetails } from '../../../shared/video/video-details.model'
4import { buildVideoEmbed, buildVideoLink } from '../../../../assets/player/utils' 4import { buildVideoEmbed, buildVideoLink } from '../../../../assets/player/utils'
5import { I18n } from '@ngx-translate/i18n-polyfill' 5import { I18n } from '@ngx-translate/i18n-polyfill'
6import { NgbModal } from '@ng-bootstrap/ng-bootstrap' 6import { NgbModal, NgbTabChangeEvent } from '@ng-bootstrap/ng-bootstrap'
7import { durationToString } from '@app/shared/misc/utils' 7import { VideoCaption } from '@shared/models'
8
9type Customizations = {
10 startAtCheckbox: boolean
11 startAt: number
12
13 stopAtCheckbox: boolean
14 stopAt: number
15
16 subtitleCheckbox: boolean
17 subtitle: string
18
19 loop: boolean
20 autoplay: boolean
21 muted: boolean
22 title: boolean
23 warningTitle: boolean
24 controls: boolean
25}
8 26
9@Component({ 27@Component({
10 selector: 'my-video-share', 28 selector: 'my-video-share',
@@ -15,9 +33,13 @@ export class VideoShareComponent {
15 @ViewChild('modal') modal: ElementRef 33 @ViewChild('modal') modal: ElementRef
16 34
17 @Input() video: VideoDetails = null 35 @Input() video: VideoDetails = null
36 @Input() videoCaptions: VideoCaption[] = []
18 37
19 currentVideoTimestamp: number 38 activeId: 'url' | 'qrcode' | 'embed'
20 startAtCheckbox = false 39 customizations: Customizations
40 isAdvancedCustomizationCollapsed = true
41
42 private currentVideoTimestamp: number
21 43
22 constructor ( 44 constructor (
23 private modalService: NgbModal, 45 private modalService: NgbModal,
@@ -26,19 +48,47 @@ export class VideoShareComponent {
26 ) { } 48 ) { }
27 49
28 show (currentVideoTimestamp?: number) { 50 show (currentVideoTimestamp?: number) {
29 this.currentVideoTimestamp = currentVideoTimestamp ? Math.floor(currentVideoTimestamp) : 0 51 this.currentVideoTimestamp = currentVideoTimestamp
52
53 let subtitle: string
54 if (this.videoCaptions.length !== 0) {
55 subtitle = this.videoCaptions[0].language.id
56 }
57
58 this.customizations = {
59 startAtCheckbox: false,
60 startAt: currentVideoTimestamp ? Math.floor(currentVideoTimestamp) : 0,
61
62 stopAtCheckbox: false,
63 stopAt: this.video.duration,
64
65 subtitleCheckbox: false,
66 subtitle,
67
68 loop: false,
69 autoplay: false,
70 muted: false,
71
72 // Embed options
73 title: true,
74 warningTitle: true,
75 controls: true
76 }
30 77
31 this.modalService.open(this.modal) 78 this.modalService.open(this.modal)
32 } 79 }
33 80
34 getVideoIframeCode () { 81 getVideoIframeCode () {
35 const embedUrl = buildVideoLink(this.getVideoTimestampIfEnabled(), this.video.embedUrl) 82 const options = this.getOptions(this.video.embedUrl)
36 83
84 const embedUrl = buildVideoLink(options)
37 return buildVideoEmbed(embedUrl) 85 return buildVideoEmbed(embedUrl)
38 } 86 }
39 87
40 getVideoUrl () { 88 getVideoUrl () {
41 return buildVideoLink(this.getVideoTimestampIfEnabled()) 89 const options = this.getOptions()
90
91 return buildVideoLink(options)
42 } 92 }
43 93
44 notSecure () { 94 notSecure () {
@@ -49,9 +99,30 @@ export class VideoShareComponent {
49 this.notifier.success(this.i18n('Copied')) 99 this.notifier.success(this.i18n('Copied'))
50 } 100 }
51 101
52 private getVideoTimestampIfEnabled () { 102 onTabChange (event: NgbTabChangeEvent) {
53 if (this.startAtCheckbox === true) return this.currentVideoTimestamp 103 this.activeId = event.nextId as any
104 }
105
106 isInEmbedTab () {
107 return this.activeId === 'embed'
108 }
109
110 private getOptions (baseUrl?: string) {
111 return {
112 baseUrl,
113
114 startTime: this.customizations.startAtCheckbox ? this.customizations.startAt : undefined,
115 stopTime: this.customizations.stopAtCheckbox ? this.customizations.stopAt : undefined,
116
117 subtitle: this.customizations.subtitleCheckbox ? this.customizations.subtitle : undefined,
118
119 loop: this.customizations.loop,
120 autoplay: this.customizations.autoplay,
121 muted: this.customizations.muted,
54 122
55 return undefined 123 title: this.customizations.title,
124 warningTitle: this.customizations.warningTitle,
125 controls: this.customizations.controls
126 }
56 } 127 }
57} 128}
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 2e39b9c6b..6a02f630a 100644
--- a/client/src/app/videos/+video-watch/video-watch.component.html
+++ b/client/src/app/videos/+video-watch/video-watch.component.html
@@ -219,5 +219,5 @@
219 219
220<ng-template [ngIf]="video !== null"> 220<ng-template [ngIf]="video !== null">
221 <my-video-support #videoSupportModal [video]="video"></my-video-support> 221 <my-video-support #videoSupportModal [video]="video"></my-video-support>
222 <my-video-share #videoShareModal [video]="video"></my-video-share> 222 <my-video-share #videoShareModal [video]="video" [videoCaptions]="videoCaptions"></my-video-share>
223</ng-template> 223</ng-template>
diff --git a/client/src/app/videos/+video-watch/video-watch.component.scss b/client/src/app/videos/+video-watch/video-watch.component.scss
index bada9bae8..35ea0fffd 100644
--- a/client/src/app/videos/+video-watch/video-watch.component.scss
+++ b/client/src/app/videos/+video-watch/video-watch.component.scss
@@ -347,6 +347,7 @@ $player-factor: 1.7; // 16/9
347 /deep/ .other-videos { 347 /deep/ .other-videos {
348 padding-left: 15px; 348 padding-left: 15px;
349 flex-basis: $other-videos-width; 349 flex-basis: $other-videos-width;
350 min-width: $other-videos-width;
350 351
351 .title-page { 352 .title-page {
352 margin-top: 0 !important; 353 margin-top: 0 !important;
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 631504eab..3f1a98f89 100644
--- a/client/src/app/videos/+video-watch/video-watch.component.ts
+++ b/client/src/app/videos/+video-watch/video-watch.component.ts
@@ -6,7 +6,7 @@ import { peertubeLocalStorage } from '@app/shared/misc/peertube-local-storage'
6import { VideoSupportComponent } from '@app/videos/+video-watch/modal/video-support.component' 6import { VideoSupportComponent } from '@app/videos/+video-watch/modal/video-support.component'
7import { MetaService } from '@ngx-meta/core' 7import { MetaService } from '@ngx-meta/core'
8import { Notifier, ServerService } from '@app/core' 8import { Notifier, ServerService } from '@app/core'
9import { forkJoin, Subscription } from 'rxjs' 9import { forkJoin, Observable, Subscription } from 'rxjs'
10import { Hotkey, HotkeysService } from 'angular2-hotkeys' 10import { Hotkey, HotkeysService } from 'angular2-hotkeys'
11import { UserVideoRateType, VideoCaption, VideoPrivacy, VideoState } from '../../../../../shared' 11import { UserVideoRateType, VideoCaption, VideoPrivacy, VideoState } from '../../../../../shared'
12import { AuthService, ConfirmService } from '../../core' 12import { AuthService, ConfirmService } from '../../core'
@@ -20,6 +20,7 @@ import { environment } from '../../../environments/environment'
20import { VideoCaptionService } from '@app/shared/video-caption' 20import { VideoCaptionService } from '@app/shared/video-caption'
21import { MarkdownService } from '@app/shared/renderer' 21import { MarkdownService } from '@app/shared/renderer'
22import { 22import {
23 CustomizationOptions,
23 P2PMediaLoaderOptions, 24 P2PMediaLoaderOptions,
24 PeertubePlayerManager, 25 PeertubePlayerManager,
25 PeertubePlayerManagerOptions, 26 PeertubePlayerManagerOptions,
@@ -28,8 +29,9 @@ import {
28import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model' 29import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model'
29import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service' 30import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service'
30import { Video } from '@app/shared/video/video.model' 31import { Video } from '@app/shared/video/video.model'
31import { isWebRTCDisabled } from '../../../assets/player/utils' 32import { isWebRTCDisabled, timeToInt } from '../../../assets/player/utils'
32import { VideoWatchPlaylistComponent } from '@app/videos/+video-watch/video-watch-playlist.component' 33import { VideoWatchPlaylistComponent } from '@app/videos/+video-watch/video-watch-playlist.component'
34import { getStoredTheater } from '../../../assets/player/peertube-player-local-storage'
33 35
34@Component({ 36@Component({
35 selector: 'my-video-watch', 37 selector: 'my-video-watch',
@@ -48,9 +50,11 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
48 playerElement: HTMLVideoElement 50 playerElement: HTMLVideoElement
49 theaterEnabled = false 51 theaterEnabled = false
50 userRating: UserVideoRateType = null 52 userRating: UserVideoRateType = null
51 video: VideoDetails = null
52 descriptionLoading = false 53 descriptionLoading = false
53 54
55 video: VideoDetails = null
56 videoCaptions: VideoCaption[] = []
57
54 playlist: VideoPlaylist = null 58 playlist: VideoPlaylist = null
55 59
56 completeDescriptionShown = false 60 completeDescriptionShown = false
@@ -120,6 +124,8 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
120 }) 124 })
121 125
122 this.initHotkeys() 126 this.initHotkeys()
127
128 this.theaterEnabled = getStoredTheater()
123 } 129 }
124 130
125 ngOnDestroy () { 131 ngOnDestroy () {
@@ -135,22 +141,18 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
135 141
136 setLike () { 142 setLike () {
137 if (this.isUserLoggedIn() === false) return 143 if (this.isUserLoggedIn() === false) return
138 if (this.userRating === 'like') { 144
139 // Already liked this video 145 // Already liked this video
140 this.setRating('none') 146 if (this.userRating === 'like') this.setRating('none')
141 } else { 147 else this.setRating('like')
142 this.setRating('like')
143 }
144 } 148 }
145 149
146 setDislike () { 150 setDislike () {
147 if (this.isUserLoggedIn() === false) return 151 if (this.isUserLoggedIn() === false) return
148 if (this.userRating === 'dislike') { 152
149 // Already disliked this video 153 // Already disliked this video
150 this.setRating('none') 154 if (this.userRating === 'dislike') this.setRating('none')
151 } else { 155 else this.setRating('dislike')
152 this.setRating('dislike')
153 }
154 } 156 }
155 157
156 showMoreDescription () { 158 showMoreDescription () {
@@ -249,12 +251,20 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
249 ) 251 )
250 .subscribe(([ video, captionsResult ]) => { 252 .subscribe(([ video, captionsResult ]) => {
251 const queryParams = this.route.snapshot.queryParams 253 const queryParams = this.route.snapshot.queryParams
252 const startTime = queryParams.start
253 const stopTime = queryParams.stop
254 const subtitle = queryParams.subtitle
255 const playerMode = queryParams.mode
256 254
257 this.onVideoFetched(video, captionsResult.data, { startTime, stopTime, subtitle, playerMode }) 255 const urlOptions = {
256 startTime: queryParams.start,
257 stopTime: queryParams.stop,
258
259 muted: queryParams.muted,
260 loop: queryParams.loop,
261 subtitle: queryParams.subtitle,
262
263 playerMode: queryParams.mode,
264 peertubeLink: false
265 }
266
267 this.onVideoFetched(video, captionsResult.data, urlOptions)
258 .catch(err => this.handleError(err)) 268 .catch(err => this.handleError(err))
259 }) 269 })
260 } 270 }
@@ -279,6 +289,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
279 private updateVideoDescription (description: string) { 289 private updateVideoDescription (description: string) {
280 this.video.description = description 290 this.video.description = description
281 this.setVideoDescriptionHTML() 291 this.setVideoDescriptionHTML()
292 .catch(err => console.error(err))
282 } 293 }
283 294
284 private async setVideoDescriptionHTML () { 295 private async setVideoDescriptionHTML () {
@@ -327,9 +338,10 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
327 private async onVideoFetched ( 338 private async onVideoFetched (
328 video: VideoDetails, 339 video: VideoDetails,
329 videoCaptions: VideoCaption[], 340 videoCaptions: VideoCaption[],
330 urlOptions: { startTime?: number, stopTime?: number, subtitle?: string, playerMode?: string } 341 urlOptions: CustomizationOptions & { playerMode: PlayerMode }
331 ) { 342 ) {
332 this.video = video 343 this.video = video
344 this.videoCaptions = videoCaptions
333 345
334 // Re init attributes 346 // Re init attributes
335 this.descriptionLoading = false 347 this.descriptionLoading = false
@@ -339,7 +351,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
339 351
340 this.videoWatchPlaylist.updatePlaylistIndex(video) 352 this.videoWatchPlaylist.updatePlaylistIndex(video)
341 353
342 let startTime = urlOptions.startTime || (this.video.userHistory ? this.video.userHistory.currentTime : 0) 354 let startTime = timeToInt(urlOptions.startTime) || (this.video.userHistory ? this.video.userHistory.currentTime : 0)
343 // If we are at the end of the video, reset the timer 355 // If we are at the end of the video, reset the timer
344 if (this.video.duration - startTime <= 1) startTime = 0 356 if (this.video.duration - startTime <= 1) startTime = 0
345 357
@@ -378,20 +390,26 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
378 enableHotkeys: true, 390 enableHotkeys: true,
379 inactivityTimeout: 2500, 391 inactivityTimeout: 2500,
380 poster: this.video.previewUrl, 392 poster: this.video.previewUrl,
393
381 startTime, 394 startTime,
382 stopTime: urlOptions.stopTime, 395 stopTime: urlOptions.stopTime,
396 controls: urlOptions.controls,
397 muted: urlOptions.muted,
398 loop: urlOptions.loop,
399 subtitle: urlOptions.subtitle,
400
401 peertubeLink: urlOptions.peertubeLink,
383 402
384 theaterMode: true, 403 theaterMode: true,
385 captions: videoCaptions.length !== 0, 404 captions: videoCaptions.length !== 0,
386 peertubeLink: false,
387 405
388 videoViewUrl: this.video.privacy.id !== VideoPrivacy.PRIVATE ? this.videoService.getVideoViewUrl(this.video.uuid) : null, 406 videoViewUrl: this.video.privacy.id !== VideoPrivacy.PRIVATE
407 ? this.videoService.getVideoViewUrl(this.video.uuid)
408 : null,
389 embedUrl: this.video.embedUrl, 409 embedUrl: this.video.embedUrl,
390 410
391 language: this.localeId, 411 language: this.localeId,
392 412
393 subtitle: urlOptions.subtitle,
394
395 userWatching: this.user && this.user.videosHistoryEnabled === true ? { 413 userWatching: this.user && this.user.videosHistoryEnabled === true ? {
396 url: this.videoService.getUserWatchingVideoUrl(this.video.uuid), 414 url: this.videoService.getUserWatchingVideoUrl(this.video.uuid),
397 authorizationHeader: this.authService.getRequestHeaderValue() 415 authorizationHeader: this.authService.getRequestHeaderValue()
@@ -433,7 +451,6 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
433 451
434 this.zone.runOutsideAngular(async () => { 452 this.zone.runOutsideAngular(async () => {
435 this.player = await PeertubePlayerManager.initialize(mode, options) 453 this.player = await PeertubePlayerManager.initialize(mode, options)
436 this.theaterEnabled = this.player.theaterEnabled
437 454
438 this.player.on('customError', ({ err }: { err: any }) => this.handleError(err)) 455 this.player.on('customError', ({ err }: { err: any }) => this.handleError(err))
439 456
@@ -466,20 +483,13 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
466 } 483 }
467 484
468 private setRating (nextRating: UserVideoRateType) { 485 private setRating (nextRating: UserVideoRateType) {
469 let method 486 const ratingMethods: { [id in UserVideoRateType]: (id: number) => Observable<any> } = {
470 switch (nextRating) { 487 like: this.videoService.setVideoLike,
471 case 'like': 488 dislike: this.videoService.setVideoDislike,
472 method = this.videoService.setVideoLike 489 none: this.videoService.unsetVideoLike
473 break
474 case 'dislike':
475 method = this.videoService.setVideoDislike
476 break
477 case 'none':
478 method = this.videoService.unsetVideoLike
479 break
480 } 490 }
481 491
482 method.call(this.videoService, this.video.id) 492 ratingMethods[nextRating].call(this.videoService, this.video.id)
483 .subscribe( 493 .subscribe(
484 () => { 494 () => {
485 // Update the video like attribute 495 // Update the video like attribute
@@ -545,25 +555,29 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
545 private flushPlayer () { 555 private flushPlayer () {
546 // Remove player if it exists 556 // Remove player if it exists
547 if (this.player) { 557 if (this.player) {
548 this.player.dispose() 558 try {
549 this.player = undefined 559 this.player.dispose()
560 this.player = undefined
561 } catch (err) {
562 console.error('Cannot dispose player.', err)
563 }
550 } 564 }
551 } 565 }
552 566
553 private initHotkeys () { 567 private initHotkeys () {
554 this.hotkeys = [ 568 this.hotkeys = [
555 new Hotkey('shift+l', (event: KeyboardEvent): boolean => { 569 new Hotkey('shift+l', () => {
556 this.setLike() 570 this.setLike()
557 return false 571 return false
558 }, undefined, this.i18n('Like the video')), 572 }, undefined, this.i18n('Like the video')),
559 new Hotkey('shift+d', (event: KeyboardEvent): boolean => { 573
574 new Hotkey('shift+d', () => {
560 this.setDislike() 575 this.setDislike()
561 return false 576 return false
562 }, undefined, this.i18n('Dislike the video')), 577 }, undefined, this.i18n('Dislike the video')),
563 new Hotkey('shift+s', (event: KeyboardEvent): boolean => { 578
564 this.subscribeButton.subscribed ? 579 new Hotkey('shift+s', () => {
565 this.subscribeButton.unsubscribe() : 580 this.subscribeButton.subscribed ? this.subscribeButton.unsubscribe() : this.subscribeButton.subscribe()
566 this.subscribeButton.subscribe()
567 return false 581 return false
568 }, undefined, this.i18n('Subscribe to the account')) 582 }, undefined, this.i18n('Subscribe to the account'))
569 ] 583 ]
diff --git a/client/src/app/videos/video-list/video-local.component.ts b/client/src/app/videos/video-list/video-local.component.ts
index 13d4023c2..65543343c 100644
--- a/client/src/app/videos/video-list/video-local.component.ts
+++ b/client/src/app/videos/video-list/video-local.component.ts
@@ -22,13 +22,13 @@ export class VideoLocalComponent extends AbstractVideoList implements OnInit, On
22 filter: VideoFilter = 'local' 22 filter: VideoFilter = 'local'
23 23
24 constructor ( 24 constructor (
25 protected i18n: I18n,
25 protected router: Router, 26 protected router: Router,
26 protected serverService: ServerService, 27 protected serverService: ServerService,
27 protected route: ActivatedRoute, 28 protected route: ActivatedRoute,
28 protected notifier: Notifier, 29 protected notifier: Notifier,
29 protected authService: AuthService, 30 protected authService: AuthService,
30 protected screenService: ScreenService, 31 protected screenService: ScreenService,
31 private i18n: I18n,
32 private videoService: VideoService 32 private videoService: VideoService
33 ) { 33 ) {
34 super() 34 super()
diff --git a/client/src/app/videos/video-list/video-overview.component.html b/client/src/app/videos/video-list/video-overview.component.html
index b644dd798..f59de584a 100644
--- a/client/src/app/videos/video-list/video-overview.component.html
+++ b/client/src/app/videos/video-list/video-overview.component.html
@@ -3,7 +3,7 @@
3 <div class="no-results" i18n *ngIf="notResults">No results.</div> 3 <div class="no-results" i18n *ngIf="notResults">No results.</div>
4 4
5 <div class="section" *ngFor="let object of overview.categories"> 5 <div class="section" *ngFor="let object of overview.categories">
6 <div class="section-title" i18n> 6 <div class="section-title">
7 <a routerLink="/search" [queryParams]="{ categoryOneOf: [ object.category.id ] }">{{ object.category.label }}</a> 7 <a routerLink="/search" [queryParams]="{ categoryOneOf: [ object.category.id ] }">{{ object.category.label }}</a>
8 </div> 8 </div>
9 9
@@ -11,7 +11,7 @@
11 </div> 11 </div>
12 12
13 <div class="section" *ngFor="let object of overview.tags"> 13 <div class="section" *ngFor="let object of overview.tags">
14 <div class="section-title" i18n> 14 <div class="section-title">
15 <a routerLink="/search" [queryParams]="{ tagsOneOf: [ object.tag ] }">#{{ object.tag }}</a> 15 <a routerLink="/search" [queryParams]="{ tagsOneOf: [ object.tag ] }">#{{ object.tag }}</a>
16 </div> 16 </div>
17 17
@@ -19,7 +19,7 @@
19 </div> 19 </div>
20 20
21 <div class="section channel" *ngFor="let object of overview.channels"> 21 <div class="section channel" *ngFor="let object of overview.channels">
22 <div class="section-title" i18n> 22 <div class="section-title">
23 <a [routerLink]="[ '/video-channels', buildVideoChannelBy(object) ]"> 23 <a [routerLink]="[ '/video-channels', buildVideoChannelBy(object) ]">
24 <img [src]="buildVideoChannelAvatarUrl(object)" alt="Avatar" /> 24 <img [src]="buildVideoChannelAvatarUrl(object)" alt="Avatar" />
25 25
diff --git a/client/src/app/videos/video-list/video-overview.component.scss b/client/src/app/videos/video-list/video-overview.component.scss
index a24766783..ade6f53b7 100644
--- a/client/src/app/videos/video-list/video-overview.component.scss
+++ b/client/src/app/videos/video-list/video-overview.component.scss
@@ -2,62 +2,10 @@
2@import '_mixins'; 2@import '_mixins';
3@import '_miniature'; 3@import '_miniature';
4 4
5.section { 5.margin-content {
6 max-height: 500px; // 2 rows max 6 @include adapt-margin-content-width;
7 overflow: hidden;
8 padding-top: 10px;
9
10 &:first-child {
11 padding-top: 30px;
12 }
13
14 my-video-miniature {
15 text-align: left;
16 }
17}
18
19.section-title {
20 font-size: 24px;
21 font-weight: $font-semibold;
22 margin-bottom: 10px;
23
24 a {
25 &:hover, &:focus:not(.focus-visible), &:active {
26 text-decoration: none;
27 outline: none;
28 }
29
30 color: var(--mainForegroundColor);
31 }
32} 7}
33 8
34.channel { 9.section {
35 .section-title a { 10 @include miniature-rows;
36 display: flex;
37 width: fit-content;
38 align-items: center;
39
40 img {
41 @include avatar(28px);
42
43 margin-right: 8px;
44 }
45 }
46}
47
48@media screen and (max-width: 500px) {
49 .margin-content {
50 margin: 0 !important;
51 }
52
53 .section-title {
54 font-size: 17px;
55 }
56
57 .section {
58 max-height: initial;
59 overflow: initial;
60
61 @include video-miniature-small-screen;
62 }
63} 11}
diff --git a/client/src/app/videos/video-list/video-recently-added.component.ts b/client/src/app/videos/video-list/video-recently-added.component.ts
index 80cef813e..f54bade98 100644
--- a/client/src/app/videos/video-list/video-recently-added.component.ts
+++ b/client/src/app/videos/video-list/video-recently-added.component.ts
@@ -17,15 +17,16 @@ import { Notifier, ServerService } from '@app/core'
17export class VideoRecentlyAddedComponent extends AbstractVideoList implements OnInit, OnDestroy { 17export class VideoRecentlyAddedComponent extends AbstractVideoList implements OnInit, OnDestroy {
18 titlePage: string 18 titlePage: string
19 sort: VideoSortField = '-publishedAt' 19 sort: VideoSortField = '-publishedAt'
20 groupByDate = true
20 21
21 constructor ( 22 constructor (
23 protected i18n: I18n,
22 protected route: ActivatedRoute, 24 protected route: ActivatedRoute,
23 protected serverService: ServerService, 25 protected serverService: ServerService,
24 protected router: Router, 26 protected router: Router,
25 protected notifier: Notifier, 27 protected notifier: Notifier,
26 protected authService: AuthService, 28 protected authService: AuthService,
27 protected screenService: ScreenService, 29 protected screenService: ScreenService,
28 private i18n: I18n,
29 private videoService: VideoService 30 private videoService: VideoService
30 ) { 31 ) {
31 super() 32 super()
diff --git a/client/src/app/videos/video-list/video-trending.component.ts b/client/src/app/videos/video-list/video-trending.component.ts
index e2ad95bc4..a2c819ebe 100644
--- a/client/src/app/videos/video-list/video-trending.component.ts
+++ b/client/src/app/videos/video-list/video-trending.component.ts
@@ -19,13 +19,13 @@ export class VideoTrendingComponent extends AbstractVideoList implements OnInit,
19 defaultSort: VideoSortField = '-trending' 19 defaultSort: VideoSortField = '-trending'
20 20
21 constructor ( 21 constructor (
22 protected i18n: I18n,
22 protected router: Router, 23 protected router: Router,
23 protected serverService: ServerService, 24 protected serverService: ServerService,
24 protected route: ActivatedRoute, 25 protected route: ActivatedRoute,
25 protected notifier: Notifier, 26 protected notifier: Notifier,
26 protected authService: AuthService, 27 protected authService: AuthService,
27 protected screenService: ScreenService, 28 protected screenService: ScreenService,
28 private i18n: I18n,
29 private videoService: VideoService 29 private videoService: VideoService
30 ) { 30 ) {
31 super() 31 super()
diff --git a/client/src/app/videos/video-list/video-user-subscriptions.component.ts b/client/src/app/videos/video-list/video-user-subscriptions.component.ts
index 2f0685ccc..3caa371d8 100644
--- a/client/src/app/videos/video-list/video-user-subscriptions.component.ts
+++ b/client/src/app/videos/video-list/video-user-subscriptions.component.ts
@@ -19,15 +19,16 @@ export class VideoUserSubscriptionsComponent extends AbstractVideoList implement
19 titlePage: string 19 titlePage: string
20 sort = '-publishedAt' as VideoSortField 20 sort = '-publishedAt' as VideoSortField
21 ownerDisplayType: OwnerDisplayType = 'auto' 21 ownerDisplayType: OwnerDisplayType = 'auto'
22 groupByDate = true
22 23
23 constructor ( 24 constructor (
25 protected i18n: I18n,
24 protected router: Router, 26 protected router: Router,
25 protected serverService: ServerService, 27 protected serverService: ServerService,
26 protected route: ActivatedRoute, 28 protected route: ActivatedRoute,
27 protected notifier: Notifier, 29 protected notifier: Notifier,
28 protected authService: AuthService, 30 protected authService: AuthService,
29 protected screenService: ScreenService, 31 protected screenService: ScreenService,
30 private i18n: I18n,
31 private videoService: VideoService 32 private videoService: VideoService
32 ) { 33 ) {
33 super() 34 super()
diff --git a/client/src/assets/player/peertube-player-local-storage.ts b/client/src/assets/player/peertube-player-local-storage.ts
index 059fca308..f6c5c5419 100644
--- a/client/src/assets/player/peertube-player-local-storage.ts
+++ b/client/src/assets/player/peertube-player-local-storage.ts
@@ -29,7 +29,7 @@ function getStoredTheater () {
29 const value = getLocalStorage('theater-enabled') 29 const value = getLocalStorage('theater-enabled')
30 if (value !== null && value !== undefined) return value === 'true' 30 if (value !== null && value !== undefined) return value === 'true'
31 31
32 return undefined 32 return false
33} 33}
34 34
35function saveVolumeInStore (value: number) { 35function saveVolumeInStore (value: number) {
diff --git a/client/src/assets/player/peertube-player-manager.ts b/client/src/assets/player/peertube-player-manager.ts
index 6cdd54372..083c621d2 100644
--- a/client/src/assets/player/peertube-player-manager.ts
+++ b/client/src/assets/player/peertube-player-manager.ts
@@ -39,7 +39,19 @@ export type P2PMediaLoaderOptions = {
39 videoFiles: VideoFile[] 39 videoFiles: VideoFile[]
40} 40}
41 41
42export type CommonOptions = { 42export interface CustomizationOptions {
43 startTime: number | string
44 stopTime: number | string
45
46 controls?: boolean
47 muted?: boolean
48 loop?: boolean
49 subtitle?: string
50
51 peertubeLink: boolean
52}
53
54export interface CommonOptions extends CustomizationOptions {
43 playerElement: HTMLVideoElement 55 playerElement: HTMLVideoElement
44 onPlayerElementChange: (element: HTMLVideoElement) => void 56 onPlayerElementChange: (element: HTMLVideoElement) => void
45 57
@@ -48,21 +60,14 @@ export type CommonOptions = {
48 enableHotkeys: boolean 60 enableHotkeys: boolean
49 inactivityTimeout: number 61 inactivityTimeout: number
50 poster: string 62 poster: string
51 startTime: number | string
52 stopTime: number | string
53 63
54 theaterMode: boolean 64 theaterMode: boolean
55 captions: boolean 65 captions: boolean
56 peertubeLink: boolean
57 66
58 videoViewUrl: string 67 videoViewUrl: string
59 embedUrl: string 68 embedUrl: string
60 69
61 language?: string 70 language?: string
62 controls?: boolean
63 muted?: boolean
64 loop?: boolean
65 subtitle?: string
66 71
67 videoCaptions: VideoJSCaption[] 72 videoCaptions: VideoJSCaption[]
68 73
@@ -117,8 +122,17 @@ export class PeertubePlayerManager {
117 videojs(options.common.playerElement, videojsOptions, function (this: any) { 122 videojs(options.common.playerElement, videojsOptions, function (this: any) {
118 const player = this 123 const player = this
119 124
120 player.tech_.one('error', () => self.maybeFallbackToWebTorrent(mode, player, options)) 125 let alreadyFallback = false
121 player.one('error', () => self.maybeFallbackToWebTorrent(mode, player, options)) 126
127 player.tech_.one('error', () => {
128 if (!alreadyFallback) self.maybeFallbackToWebTorrent(mode, player, options)
129 alreadyFallback = true
130 })
131
132 player.one('error', () => {
133 if (!alreadyFallback) self.maybeFallbackToWebTorrent(mode, player, options)
134 alreadyFallback = true
135 })
122 136
123 self.addContextMenu(mode, player, options.common.embedUrl) 137 self.addContextMenu(mode, player, options.common.embedUrl)
124 138
@@ -432,7 +446,7 @@ export class PeertubePlayerManager {
432 label: player.localize('Copy the video URL at the current time'), 446 label: player.localize('Copy the video URL at the current time'),
433 listener: function () { 447 listener: function () {
434 const player = this as videojs.Player 448 const player = this as videojs.Player
435 copyToClipboard(buildVideoLink(player.currentTime())) 449 copyToClipboard(buildVideoLink({ startTime: player.currentTime() }))
436 } 450 }
437 }, 451 },
438 { 452 {
diff --git a/client/src/assets/player/utils.ts b/client/src/assets/player/utils.ts
index 366689962..777abb568 100644
--- a/client/src/assets/player/utils.ts
+++ b/client/src/assets/player/utils.ts
@@ -27,18 +27,55 @@ function isMobile () {
27 return /iPhone|iPad|iPod|Android/i.test(navigator.userAgent) 27 return /iPhone|iPad|iPod|Android/i.test(navigator.userAgent)
28} 28}
29 29
30function buildVideoLink (time?: number, url?: string) { 30function buildVideoLink (options: {
31 if (!url) url = window.location.origin + window.location.pathname.replace('/embed/', '/watch/') 31 baseUrl?: string,
32 32
33 if (time) { 33 startTime?: number,
34 const timeInt = Math.floor(time) 34 stopTime?: number,
35 35
36 const params = new URLSearchParams(window.location.search) 36 subtitle?: string,
37 params.set('start', secondsToTime(timeInt))
38 37
39 return url + '?' + params.toString() 38 loop?: boolean,
39 autoplay?: boolean,
40 muted?: boolean,
41
42 // Embed options
43 title?: boolean,
44 warningTitle?: boolean,
45 controls?: boolean
46} = {}) {
47 const { baseUrl } = options
48
49 const url = baseUrl
50 ? baseUrl
51 : window.location.origin + window.location.pathname.replace('/embed/', '/watch/')
52
53 const params = new URLSearchParams(window.location.search)
54
55 if (options.startTime) {
56 const startTimeInt = Math.floor(options.startTime)
57 params.set('start', secondsToTime(startTimeInt))
58 }
59
60 if (options.stopTime) {
61 const stopTimeInt = Math.floor(options.stopTime)
62 params.set('stop', secondsToTime(stopTimeInt))
40 } 63 }
41 64
65 if (options.subtitle) params.set('subtitle', options.subtitle)
66
67 if (options.loop === true) params.set('loop', '1')
68 if (options.autoplay === true) params.set('autoplay', '1')
69 if (options.muted === true) params.set('muted', '1')
70 if (options.title === false) params.set('title', '0')
71 if (options.warningTitle === false) params.set('warningTitle', '0')
72 if (options.controls === false) params.set('controls', '0')
73
74 let hasParams = false
75 params.forEach(() => hasParams = true)
76
77 if (hasParams) return url + '?' + params.toString()
78
42 return url 79 return url
43} 80}
44 81
diff --git a/client/src/assets/player/videojs-components/peertube-link-button.ts b/client/src/assets/player/videojs-components/peertube-link-button.ts
index fed8ea33e..4d0ea37f5 100644
--- a/client/src/assets/player/videojs-components/peertube-link-button.ts
+++ b/client/src/assets/player/videojs-components/peertube-link-button.ts
@@ -16,7 +16,7 @@ class PeerTubeLinkButton extends Button {
16 } 16 }
17 17
18 updateHref () { 18 updateHref () {
19 this.el().setAttribute('href', buildVideoLink(this.player().currentTime())) 19 this.el().setAttribute('href', buildVideoLink({ startTime: this.player().currentTime() }))
20 } 20 }
21 21
22 handleClick () { 22 handleClick () {
diff --git a/client/src/sass/application.scss b/client/src/sass/application.scss
index d84766240..c64a8ebf8 100644
--- a/client/src/sass/application.scss
+++ b/client/src/sass/application.scss
@@ -12,6 +12,7 @@ $assets-path: '../assets/';
12@import './player/index'; 12@import './player/index';
13@import './loading-bar'; 13@import './loading-bar';
14 14
15@import './bootstrap';
15@import './primeng-custom'; 16@import './primeng-custom';
16 17
17[hidden] { 18[hidden] {
@@ -181,128 +182,11 @@ label {
181 font-weight: bold; 182 font-weight: bold;
182} 183}
183 184
184// Thanks https://gist.github.com/alexandrevicenzi/680147013e902a4eaa5d
185.glyphicon-refresh-animate {
186 animation: spin .7s infinite linear;
187}
188
189@keyframes spin { 185@keyframes spin {
190 from { transform: scale(1) rotate(0deg);} 186 from { transform: scale(1) rotate(0deg);}
191 to { transform: scale(1) rotate(360deg);} 187 to { transform: scale(1) rotate(360deg);}
192} 188}
193 189
194// Bootstrap customizations
195.dropdown-menu {
196 border-radius: 3px;
197 box-shadow: 0 3px 6px;
198 font-size: 15px;
199
200 .dropdown-item {
201 padding: 3px 15px;
202
203 &:active {
204 color: #000 !important;
205 }
206 }
207
208 button {
209 @include disable-default-a-behaviour;
210 }
211
212 a {
213 @include disable-default-a-behaviour;
214 color: #000 !important;
215 }
216}
217
218.modal {
219 .modal-content {
220 background-color: var(--mainBackgroundColor);
221 }
222
223 .modal-header {
224 border-bottom: none;
225 margin-bottom: 5px;
226
227 .modal-title {
228 font-size: 20px;
229 font-weight: $font-semibold;
230 }
231
232 my-global-icon {
233 @include icon(24px);
234
235 position: relative;
236 top: 3px;
237 float: right;
238
239 margin: 0;
240 padding: 0;
241 opacity: 1;
242 }
243 }
244
245 .inputs {
246 margin-bottom: 0;
247 text-align: right;
248
249 .action-button-cancel {
250 @include peertube-button;
251 @include grey-button;
252
253 display: inline-block;
254 margin-right: 10px;
255 }
256
257 .action-button-submit {
258 @include peertube-button;
259 @include orange-button;
260 }
261 }
262}
263
264// Nav customizations
265.nav .nav-link {
266 display: flex !important;
267 align-items: center;
268 height: 30px !important;
269 padding: 10px 15px !important;
270}
271
272.nav.nav-pills {
273 font-size: 16px !important;
274
275 .nav-link.active {
276 font-weight: $font-semibold !important;
277 }
278
279 a {
280 @include disable-default-a-behaviour;
281
282 color: var(--mainForegroundColor);
283 }
284}
285
286ngb-tabset.bootstrap {
287
288 .nav-link {
289 &, & a {
290 @include disable-default-a-behaviour;
291
292 color: var(--mainForegroundColor) !important;
293 }
294 }
295
296 .nav-pills .nav-link.active {
297 color: #000 !important;
298 }
299}
300
301.nav-tabs .nav-link.active {
302 background-color: var(--mainBackgroundColor) !important;
303 border-bottom: none;
304}
305
306.orange-button { 190.orange-button {
307 @include peertube-button; 191 @include peertube-button;
308 @include orange-button; 192 @include orange-button;
diff --git a/client/src/sass/bootstrap.scss b/client/src/sass/bootstrap.scss
new file mode 100644
index 000000000..12e73278a
--- /dev/null
+++ b/client/src/sass/bootstrap.scss
@@ -0,0 +1,138 @@
1$icon-font-path: '~@neos21/bootstrap3-glyphicons/assets/fonts/';
2@import '_bootstrap';
3
4@import '_variables';
5@import '_mixins';
6
7// Thanks https://gist.github.com/alexandrevicenzi/680147013e902a4eaa5d
8.glyphicon-refresh-animate {
9 animation: spin .7s infinite linear;
10}
11
12@keyframes spin {
13 from { transform: scale(1) rotate(0deg);}
14 to { transform: scale(1) rotate(360deg);}
15}
16
17.dropdown-menu {
18 border-radius: 3px;
19 box-shadow: 0 3px 6px;
20 font-size: 15px;
21
22 .dropdown-item {
23 padding: 3px 15px;
24
25 &:active {
26 color: #000 !important;
27 }
28 }
29
30 button {
31 @include disable-default-a-behaviour;
32 }
33
34 a {
35 @include disable-default-a-behaviour;
36 color: #000 !important;
37 }
38}
39
40.modal {
41 .modal-content {
42 background-color: var(--mainBackgroundColor);
43 }
44
45 .modal-header {
46 border-bottom: none;
47 margin-bottom: 5px;
48
49 .modal-title {
50 font-size: 20px;
51 font-weight: $font-semibold;
52 }
53
54 my-global-icon {
55 @include icon(24px);
56
57 position: relative;
58 top: 3px;
59 float: right;
60
61 margin: 0;
62 padding: 0;
63 opacity: 1;
64 }
65 }
66
67 .inputs {
68 margin-bottom: 0;
69 text-align: right;
70
71 .action-button-cancel {
72 @include peertube-button;
73 @include grey-button;
74
75 display: inline-block;
76 margin-right: 10px;
77 }
78
79 .action-button-submit {
80 @include peertube-button;
81 @include orange-button;
82 }
83 }
84}
85
86// Nav customizations
87.nav .nav-link {
88 display: flex !important;
89 align-items: center;
90 height: 30px !important;
91 padding: 10px 15px !important;
92}
93
94.nav.nav-pills {
95 font-size: 16px !important;
96
97 .nav-link.active {
98 font-weight: $font-semibold !important;
99 }
100
101 a {
102 @include disable-default-a-behaviour;
103
104 color: var(--mainForegroundColor);
105 }
106}
107
108ngb-tabset.bootstrap {
109
110 .nav-link {
111 &, & a {
112 @include disable-default-a-behaviour;
113
114 color: var(--mainForegroundColor) !important;
115 }
116 }
117
118 .nav-pills .nav-link.active {
119 color: #000 !important;
120 }
121}
122
123.nav-tabs .nav-link.active {
124 background-color: var(--mainBackgroundColor) !important;
125 border-bottom: none;
126}
127
128.collapse-transition {
129 // Animation when we show/hide the filters
130 transition: max-height 0.3s;
131 display: block !important;
132 overflow: hidden !important;
133 max-height: 0;
134
135 &.show {
136 max-height: 1500px;
137 }
138}
diff --git a/client/src/sass/include/_miniature.scss b/client/src/sass/include/_miniature.scss
index b62187fd2..0c2ee2d0d 100644
--- a/client/src/sass/include/_miniature.scss
+++ b/client/src/sass/include/_miniature.scss
@@ -138,3 +138,100 @@ $play-overlay-width: 18px;
138 } 138 }
139 } 139 }
140} 140}
141
142@mixin miniature-rows {
143 max-height: 540px; // 2 rows max
144 overflow: hidden;
145 padding-top: 10px;
146
147 &:first-child {
148 padding-top: 30px;
149 }
150
151 my-video-miniature {
152 text-align: left;
153 }
154
155 .section-title {
156 font-size: 24px;
157 font-weight: $font-semibold;
158 margin-bottom: 30px;
159 display: flex;
160 justify-content: space-between;
161
162 a {
163 &:hover, &:focus:not(.focus-visible), &:active {
164 text-decoration: none;
165 outline: none;
166 }
167
168 color: var(--mainForegroundColor);
169 }
170 }
171
172 &.channel {
173 .section-title {
174 a {
175 display: flex;
176 width: fit-content;
177 align-items: center;
178
179 img {
180 @include avatar(28px);
181
182 margin-right: 8px;
183 }
184 }
185
186 .followers {
187 color: $grey-foreground-color;
188 font-weight: normal;
189 font-size: 14px;
190 margin-left: 10px;
191 position: relative;
192 top: 2px;
193 }
194 }
195 }
196
197 @media screen and (max-width: $mobile-view) {
198 max-height: initial;
199 overflow: initial;
200
201 @include video-miniature-small-screen;
202
203 .section-title {
204 font-size: 17px;
205 }
206 }
207}
208
209@mixin adapt-margin-content-width {
210 width: $video-miniature-width * 6;
211 margin: auto !important;
212
213 @media screen and (max-width: 1800px) {
214 width: $video-miniature-width * 5;
215 }
216
217 @media screen and (max-width: 1800px - $video-miniature-width) {
218 width: $video-miniature-width * 4;
219 }
220
221 @media screen and (max-width: 1800px - (2* $video-miniature-width)) {
222 width: $video-miniature-width * 3;
223 }
224
225 @media screen and (max-width: 1800px - (3* $video-miniature-width)) {
226 width: $video-miniature-width * 2;
227 }
228
229 @media screen and (max-width: 500px) {
230 width: auto;
231 margin: 0 !important;
232
233 .videos {
234 @include video-miniature-small-screen;
235 }
236 }
237}
diff --git a/client/src/sass/include/_mixins.scss b/client/src/sass/include/_mixins.scss
index 262a8136f..f608e9299 100644
--- a/client/src/sass/include/_mixins.scss
+++ b/client/src/sass/include/_mixins.scss
@@ -235,6 +235,14 @@
235 position: relative; 235 position: relative;
236 font-size: 15px; 236 font-size: 15px;
237 237
238 &.disabled {
239 background-color: #E5E5E5;
240
241 select {
242 cursor: default;
243 }
244 }
245
238 @media screen and (max-width: $width) { 246 @media screen and (max-width: $width) {
239 width: 100%; 247 width: 100%;
240 } 248 }
@@ -282,16 +290,6 @@
282 } 290 }
283} 291}
284 292
285@mixin peertube-select-disabled-container ($width) {
286 @include peertube-select-container($width);
287
288 background-color: #E5E5E5;
289
290 select {
291 cursor: default;
292 }
293}
294
295// Thanks: https://codepen.io/triss90/pen/XNEdRe/ 293// Thanks: https://codepen.io/triss90/pen/XNEdRe/
296@mixin peertube-radio-container { 294@mixin peertube-radio-container {
297 input[type="radio"] { 295 input[type="radio"] {
@@ -331,7 +329,12 @@
331} 329}
332 330
333@mixin peertube-checkbox ($border-width) { 331@mixin peertube-checkbox ($border-width) {
334 display: none; 332 opacity: 0;
333 position: absolute;
334
335 &:focus + span {
336 outline: 1px solid #1e5180;
337 }
335 338
336 & + span { 339 & + span {
337 position: relative; 340 position: relative;
diff --git a/client/src/sass/include/_variables.scss b/client/src/sass/include/_variables.scss
index c7b205b11..aafeda257 100644
--- a/client/src/sass/include/_variables.scss
+++ b/client/src/sass/include/_variables.scss
@@ -71,7 +71,9 @@ $variables: (
71 --menuForegroundColor: var(--menuForegroundColor), 71 --menuForegroundColor: var(--menuForegroundColor),
72 --submenuColor: var(--submenuColor), 72 --submenuColor: var(--submenuColor),
73 --inputColor: var(--inputColor), 73 --inputColor: var(--inputColor),
74 --inputPlaceholderColor: var(--inputPlaceholderColor) 74 --inputPlaceholderColor: var(--inputPlaceholderColor),
75 --embedForegroundColor: var(--embedForegroundColor),
76 --embedBigPlayBackgroundColor: var(--embedBigPlayBackgroundColor)
75); 77);
76 78
77/*** theme helper ***/ 79/*** theme helper ***/
diff --git a/client/src/sass/player/_player-variables.scss b/client/src/sass/player/_player-variables.scss
index 110129790..4e9e8736c 100644
--- a/client/src/sass/player/_player-variables.scss
+++ b/client/src/sass/player/_player-variables.scss
@@ -10,4 +10,10 @@ $slider-bg-color: lighten($primary-background-color, 33%);
10 10
11$progress-margin: 10px; 11$progress-margin: 10px;
12 12
13$assets-path: '../../assets/' !default; \ No newline at end of file 13$assets-path: '../../assets/' !default;
14
15body {
16 --embedForegroundColor: #{$primary-foreground-color};
17
18 --embedBigPlayBackgroundColor: #{$primary-background-color};
19}
diff --git a/client/src/sass/player/context-menu.scss b/client/src/sass/player/context-menu.scss
index 71d6d1b1d..eeab0ccdf 100644
--- a/client/src/sass/player/context-menu.scss
+++ b/client/src/sass/player/context-menu.scss
@@ -14,7 +14,7 @@ $context-menu-width: 350px;
14 14
15 .vjs-menu-content { 15 .vjs-menu-content {
16 opacity: $primary-foreground-opacity; 16 opacity: $primary-foreground-opacity;
17 color: $primary-foreground-color; 17 color: var(--embedForegroundCsolor);
18 font-size: $font-size !important; 18 font-size: $font-size !important;
19 font-weight: $font-semibold; 19 font-weight: $font-semibold;
20 } 20 }
@@ -30,4 +30,4 @@ $context-menu-width: 350px;
30 background-color: rgba(255, 255, 255, 0.2); 30 background-color: rgba(255, 255, 255, 0.2);
31 } 31 }
32 } 32 }
33} \ No newline at end of file 33}
diff --git a/client/src/sass/player/peertube-skin.scss b/client/src/sass/player/peertube-skin.scss
index e63a2875c..996024ade 100644
--- a/client/src/sass/player/peertube-skin.scss
+++ b/client/src/sass/player/peertube-skin.scss
@@ -10,9 +10,8 @@
10} 10}
11 11
12.video-js.vjs-peertube-skin { 12.video-js.vjs-peertube-skin {
13
14 font-size: $font-size; 13 font-size: $font-size;
15 color: $primary-foreground-color; 14 color: var(--embedForegroundColor);
16 15
17 .vjs-dock-text { 16 .vjs-dock-text {
18 padding-right: 10px; 17 padding-right: 10px;
@@ -114,7 +113,7 @@
114 .vjs-control-bar, 113 .vjs-control-bar,
115 .vjs-big-play-button, 114 .vjs-big-play-button,
116 .vjs-settings-dialog { 115 .vjs-settings-dialog {
117 background-color: rgba($primary-background-color, 0.5); 116 background-color: var(--embedBigPlayBackgroundColor);
118 } 117 }
119 118
120 .vjs-poster { 119 .vjs-poster {
@@ -139,7 +138,8 @@
139 .vjs-theater-control, 138 .vjs-theater-control,
140 .vjs-settings 139 .vjs-settings
141 { 140 {
142 color: $primary-foreground-color !important; 141 color: var(--embedForegroundColor) !important;
142
143 opacity: $primary-foreground-opacity; 143 opacity: $primary-foreground-opacity;
144 transition: opacity .1s; 144 transition: opacity .1s;
145 145
@@ -151,7 +151,7 @@
151 .vjs-current-time, 151 .vjs-current-time,
152 .vjs-duration, 152 .vjs-duration,
153 .vjs-peertube { 153 .vjs-peertube {
154 color: $primary-foreground-color; 154 color: var(--embedForegroundColor);
155 opacity: $primary-foreground-opacity; 155 opacity: $primary-foreground-opacity;
156 } 156 }
157 157
@@ -171,7 +171,7 @@
171 transition: none; 171 transition: none;
172 172
173 .vjs-play-progress { 173 .vjs-play-progress {
174 background: $primary-foreground-color; 174 background: var(--embedForegroundColor);
175 175
176 // Not display the circle if the progress is not hovered 176 // Not display the circle if the progress is not hovered
177 &::before { 177 &::before {
diff --git a/client/src/sass/player/settings-menu.scss b/client/src/sass/player/settings-menu.scss
index 61965c85e..83407b445 100644
--- a/client/src/sass/player/settings-menu.scss
+++ b/client/src/sass/player/settings-menu.scss
@@ -38,7 +38,7 @@ $setting-transition-easing: ease-out;
38 position: absolute; 38 position: absolute;
39 right: .5em; 39 right: .5em;
40 bottom: 3.5em; 40 bottom: 3.5em;
41 color: $primary-foreground-color; 41 color: var(--embedForegroundColor);
42 opacity: $primary-foreground-opacity; 42 opacity: $primary-foreground-opacity;
43 margin: 0 auto; 43 margin: 0 auto;
44 font-size: $font-size !important; 44 font-size: $font-size !important;
diff --git a/client/src/standalone/videos/embed-api.ts b/client/src/standalone/videos/embed-api.ts
new file mode 100644
index 000000000..169e371da
--- /dev/null
+++ b/client/src/standalone/videos/embed-api.ts
@@ -0,0 +1,130 @@
1import './embed.scss'
2
3import * as Channel from 'jschannel'
4import { PeerTubeResolution } from '../player/definitions'
5import { PeerTubeEmbed } from './embed'
6
7/**
8 * Embed API exposes control of the embed player to the outside world via
9 * JSChannels and window.postMessage
10 */
11export class PeerTubeEmbedApi {
12 private channel: Channel.MessagingChannel
13 private isReady = false
14 private resolutions: PeerTubeResolution[] = null
15
16 constructor (private embed: PeerTubeEmbed) {
17 }
18
19 initialize () {
20 this.constructChannel()
21 this.setupStateTracking()
22
23 // We're ready!
24
25 this.notifyReady()
26 }
27
28 private get element () {
29 return this.embed.videoElement
30 }
31
32 private constructChannel () {
33 const channel = Channel.build({ window: window.parent, origin: '*', scope: this.embed.scope })
34
35 channel.bind('play', (txn, params) => this.embed.player.play())
36 channel.bind('pause', (txn, params) => this.embed.player.pause())
37 channel.bind('seek', (txn, time) => this.embed.player.currentTime(time))
38 channel.bind('setVolume', (txn, value) => this.embed.player.volume(value))
39 channel.bind('getVolume', (txn, value) => this.embed.player.volume())
40 channel.bind('isReady', (txn, params) => this.isReady)
41 channel.bind('setResolution', (txn, resolutionId) => this.setResolution(resolutionId))
42 channel.bind('getResolutions', (txn, params) => this.resolutions)
43 channel.bind('setPlaybackRate', (txn, playbackRate) => this.embed.player.playbackRate(playbackRate))
44 channel.bind('getPlaybackRate', (txn, params) => this.embed.player.playbackRate())
45 channel.bind('getPlaybackRates', (txn, params) => this.embed.playerOptions.playbackRates)
46
47 this.channel = channel
48 }
49
50 private setResolution (resolutionId: number) {
51 if (resolutionId === -1 && this.embed.player.webtorrent().isAutoResolutionForbidden()) return
52
53 // Auto resolution
54 if (resolutionId === -1) {
55 this.embed.player.webtorrent().enableAutoResolution()
56 return
57 }
58
59 this.embed.player.webtorrent().disableAutoResolution()
60 this.embed.player.webtorrent().updateResolution(resolutionId)
61 }
62
63 /**
64 * Let the host know that we're ready to go!
65 */
66 private notifyReady () {
67 this.isReady = true
68 this.channel.notify({ method: 'ready', params: true })
69 }
70
71 private setupStateTracking () {
72 let currentState: 'playing' | 'paused' | 'unstarted' = 'unstarted'
73
74 setInterval(() => {
75 const position = this.element.currentTime
76 const volume = this.element.volume
77
78 this.channel.notify({
79 method: 'playbackStatusUpdate',
80 params: {
81 position,
82 volume,
83 playbackState: currentState
84 }
85 })
86 }, 500)
87
88 this.element.addEventListener('play', ev => {
89 currentState = 'playing'
90 this.channel.notify({ method: 'playbackStatusChange', params: 'playing' })
91 })
92
93 this.element.addEventListener('pause', ev => {
94 currentState = 'paused'
95 this.channel.notify({ method: 'playbackStatusChange', params: 'paused' })
96 })
97
98 // PeerTube specific capabilities
99
100 if (this.embed.player.webtorrent) {
101 this.embed.player.webtorrent().on('autoResolutionUpdate', () => this.loadWebTorrentResolutions())
102 this.embed.player.webtorrent().on('videoFileUpdate', () => this.loadWebTorrentResolutions())
103 }
104 }
105
106 private loadWebTorrentResolutions () {
107 const resolutions = []
108 const currentResolutionId = this.embed.player.webtorrent().getCurrentResolutionId()
109
110 for (const videoFile of this.embed.player.webtorrent().videoFiles) {
111 let label = videoFile.resolution.label
112 if (videoFile.fps && videoFile.fps >= 50) {
113 label += videoFile.fps
114 }
115
116 resolutions.push({
117 id: videoFile.resolution.id,
118 label,
119 src: videoFile.magnetUri,
120 active: videoFile.resolution.id === currentResolutionId
121 })
122 }
123
124 this.resolutions = resolutions
125 this.channel.notify({
126 method: 'resolutionUpdate',
127 params: this.resolutions
128 })
129 }
130}
diff --git a/client/src/standalone/videos/embed.html b/client/src/standalone/videos/embed.html
index c3b6e08ca..5a15bf552 100644
--- a/client/src/standalone/videos/embed.html
+++ b/client/src/standalone/videos/embed.html
@@ -11,7 +11,7 @@
11 <link rel="icon" type="image/png" href="/client/assets/images/favicon.png" /> 11 <link rel="icon" type="image/png" href="/client/assets/images/favicon.png" />
12 </head> 12 </head>
13 13
14 <body> 14 <body id="custom-css">
15 15
16 <div id="error-block"> 16 <div id="error-block">
17 <h1 id="error-title"></h1> 17 <h1 id="error-title"></h1>
diff --git a/client/src/standalone/videos/embed.ts b/client/src/standalone/videos/embed.ts
index 707f04253..cfe8e94b1 100644
--- a/client/src/standalone/videos/embed.ts
+++ b/client/src/standalone/videos/embed.ts
@@ -1,9 +1,6 @@
1import './embed.scss' 1import './embed.scss'
2 2
3import * as Channel from 'jschannel'
4
5import { peertubeTranslate, ResultList, ServerConfig, VideoDetails } from '../../../../shared' 3import { peertubeTranslate, ResultList, ServerConfig, VideoDetails } from '../../../../shared'
6import { PeerTubeResolution } from '../player/definitions'
7import { VideoJSCaption } from '../../assets/player/peertube-videojs-typings' 4import { VideoJSCaption } from '../../assets/player/peertube-videojs-typings'
8import { VideoCaption } from '../../../../shared/models/videos/caption/video-caption.model' 5import { VideoCaption } from '../../../../shared/models/videos/caption/video-caption.model'
9import { 6import {
@@ -13,133 +10,9 @@ import {
13 PlayerMode 10 PlayerMode
14} from '../../assets/player/peertube-player-manager' 11} from '../../assets/player/peertube-player-manager'
15import { VideoStreamingPlaylistType } from '../../../../shared/models/videos/video-streaming-playlist.type' 12import { VideoStreamingPlaylistType } from '../../../../shared/models/videos/video-streaming-playlist.type'
13import { PeerTubeEmbedApi } from './embed-api'
16 14
17/** 15export class PeerTubeEmbed {
18 * Embed API exposes control of the embed player to the outside world via
19 * JSChannels and window.postMessage
20 */
21class PeerTubeEmbedApi {
22 private channel: Channel.MessagingChannel
23 private isReady = false
24 private resolutions: PeerTubeResolution[] = null
25
26 constructor (private embed: PeerTubeEmbed) {
27 }
28
29 initialize () {
30 this.constructChannel()
31 this.setupStateTracking()
32
33 // We're ready!
34
35 this.notifyReady()
36 }
37
38 private get element () {
39 return this.embed.videoElement
40 }
41
42 private constructChannel () {
43 const channel = Channel.build({ window: window.parent, origin: '*', scope: this.embed.scope })
44
45 channel.bind('play', (txn, params) => this.embed.player.play())
46 channel.bind('pause', (txn, params) => this.embed.player.pause())
47 channel.bind('seek', (txn, time) => this.embed.player.currentTime(time))
48 channel.bind('setVolume', (txn, value) => this.embed.player.volume(value))
49 channel.bind('getVolume', (txn, value) => this.embed.player.volume())
50 channel.bind('isReady', (txn, params) => this.isReady)
51 channel.bind('setResolution', (txn, resolutionId) => this.setResolution(resolutionId))
52 channel.bind('getResolutions', (txn, params) => this.resolutions)
53 channel.bind('setPlaybackRate', (txn, playbackRate) => this.embed.player.playbackRate(playbackRate))
54 channel.bind('getPlaybackRate', (txn, params) => this.embed.player.playbackRate())
55 channel.bind('getPlaybackRates', (txn, params) => this.embed.playerOptions.playbackRates)
56
57 this.channel = channel
58 }
59
60 private setResolution (resolutionId: number) {
61 if (resolutionId === -1 && this.embed.player.webtorrent().isAutoResolutionForbidden()) return
62
63 // Auto resolution
64 if (resolutionId === -1) {
65 this.embed.player.webtorrent().enableAutoResolution()
66 return
67 }
68
69 this.embed.player.webtorrent().disableAutoResolution()
70 this.embed.player.webtorrent().updateResolution(resolutionId)
71 }
72
73 /**
74 * Let the host know that we're ready to go!
75 */
76 private notifyReady () {
77 this.isReady = true
78 this.channel.notify({ method: 'ready', params: true })
79 }
80
81 private setupStateTracking () {
82 let currentState: 'playing' | 'paused' | 'unstarted' = 'unstarted'
83
84 setInterval(() => {
85 const position = this.element.currentTime
86 const volume = this.element.volume
87
88 this.channel.notify({
89 method: 'playbackStatusUpdate',
90 params: {
91 position,
92 volume,
93 playbackState: currentState
94 }
95 })
96 }, 500)
97
98 this.element.addEventListener('play', ev => {
99 currentState = 'playing'
100 this.channel.notify({ method: 'playbackStatusChange', params: 'playing' })
101 })
102
103 this.element.addEventListener('pause', ev => {
104 currentState = 'paused'
105 this.channel.notify({ method: 'playbackStatusChange', params: 'paused' })
106 })
107
108 // PeerTube specific capabilities
109
110 if (this.embed.player.webtorrent) {
111 this.embed.player.webtorrent().on('autoResolutionUpdate', () => this.loadWebTorrentResolutions())
112 this.embed.player.webtorrent().on('videoFileUpdate', () => this.loadWebTorrentResolutions())
113 }
114 }
115
116 private loadWebTorrentResolutions () {
117 const resolutions = []
118 const currentResolutionId = this.embed.player.webtorrent().getCurrentResolutionId()
119
120 for (const videoFile of this.embed.player.webtorrent().videoFiles) {
121 let label = videoFile.resolution.label
122 if (videoFile.fps && videoFile.fps >= 50) {
123 label += videoFile.fps
124 }
125
126 resolutions.push({
127 id: videoFile.resolution.id,
128 label,
129 src: videoFile.magnetUri,
130 active: videoFile.resolution.id === currentResolutionId
131 })
132 }
133
134 this.resolutions = resolutions
135 this.channel.notify({
136 method: 'resolutionUpdate',
137 params: this.resolutions
138 })
139 }
140}
141
142class PeerTubeEmbed {
143 videoElement: HTMLVideoElement 16 videoElement: HTMLVideoElement
144 player: any 17 player: any
145 playerOptions: any 18 playerOptions: any
@@ -152,6 +25,12 @@ class PeerTubeEmbed {
152 enableApi = false 25 enableApi = false
153 startTime: number | string = 0 26 startTime: number | string = 0
154 stopTime: number | string 27 stopTime: number | string
28
29 title: boolean
30 warningTitle: boolean
31 bigPlayBackgroundColor: string
32 foregroundColor: string
33
155 mode: PlayerMode 34 mode: PlayerMode
156 scope = 'peertube' 35 scope = 'peertube'
157 36
@@ -245,13 +124,18 @@ class PeerTubeEmbed {
245 this.controls = this.getParamToggle(params, 'controls', true) 124 this.controls = this.getParamToggle(params, 'controls', true)
246 this.muted = this.getParamToggle(params, 'muted', false) 125 this.muted = this.getParamToggle(params, 'muted', false)
247 this.loop = this.getParamToggle(params, 'loop', false) 126 this.loop = this.getParamToggle(params, 'loop', false)
127 this.title = this.getParamToggle(params, 'title', true)
248 this.enableApi = this.getParamToggle(params, 'api', this.enableApi) 128 this.enableApi = this.getParamToggle(params, 'api', this.enableApi)
129 this.warningTitle = this.getParamToggle(params, 'warningTitle', true)
249 130
250 this.scope = this.getParamString(params, 'scope', this.scope) 131 this.scope = this.getParamString(params, 'scope', this.scope)
251 this.subtitle = this.getParamString(params, 'subtitle') 132 this.subtitle = this.getParamString(params, 'subtitle')
252 this.startTime = this.getParamString(params, 'start') 133 this.startTime = this.getParamString(params, 'start')
253 this.stopTime = this.getParamString(params, 'stop') 134 this.stopTime = this.getParamString(params, 'stop')
254 135
136 this.bigPlayBackgroundColor = this.getParamString(params, 'bigPlayBackgroundColor')
137 this.foregroundColor = this.getParamString(params, 'foregroundColor')
138
255 this.mode = this.getParamString(params, 'mode') === 'p2p-media-loader' ? 'p2p-media-loader' : 'webtorrent' 139 this.mode = this.getParamString(params, 'mode') === 'p2p-media-loader' ? 'p2p-media-loader' : 'webtorrent'
256 } catch (err) { 140 } catch (err) {
257 console.error('Cannot get params from URL.', err) 141 console.error('Cannot get params from URL.', err)
@@ -276,15 +160,7 @@ class PeerTubeEmbed {
276 } 160 }
277 161
278 const videoInfo: VideoDetails = await videoResponse.json() 162 const videoInfo: VideoDetails = await videoResponse.json()
279 let videoCaptions: VideoJSCaption[] = [] 163 const videoCaptions = await this.buildCaptions(serverTranslations, captionsResponse)
280 if (captionsResponse.ok) {
281 const { data } = (await captionsResponse.json()) as ResultList<VideoCaption>
282 videoCaptions = data.map(c => ({
283 label: peertubeTranslate(c.language.label, serverTranslations),
284 language: c.language.id,
285 src: window.location.origin + c.captionPath
286 }))
287 }
288 164
289 this.loadParams() 165 this.loadParams()
290 166
@@ -337,33 +213,66 @@ class PeerTubeEmbed {
337 } 213 }
338 214
339 this.player = await PeertubePlayerManager.initialize(this.mode, options) 215 this.player = await PeertubePlayerManager.initialize(this.mode, options)
340
341 this.player.on('customError', (event: any, data: any) => this.handleError(data.err, serverTranslations)) 216 this.player.on('customError', (event: any, data: any) => this.handleError(data.err, serverTranslations))
342 217
343 window[ 'videojsPlayer' ] = this.player 218 window[ 'videojsPlayer' ] = this.player
344 219
220 this.buildCSS()
221
222 await this.buildDock(videoInfo, configResponse)
223
224 this.initializeApi()
225 }
226
227 private handleError (err: Error, translations?: { [ id: string ]: string }) {
228 if (err.message.indexOf('from xs param') !== -1) {
229 this.player.dispose()
230 this.videoElement = null
231 this.displayError('This video is not available because the remote instance is not responding.', translations)
232 return
233 }
234 }
235
236 private async buildDock (videoInfo: VideoDetails, configResponse: Response) {
345 if (this.controls) { 237 if (this.controls) {
238 const title = this.title ? videoInfo.name : undefined
239
346 const config: ServerConfig = await configResponse.json() 240 const config: ServerConfig = await configResponse.json()
347 const description = config.tracker.enabled 241 const description = config.tracker.enabled && this.warningTitle
348 ? '<span class="text">' + this.player.localize('Uses P2P, others may know your IP is downloading this video.') + '</span>' 242 ? '<span class="text">' + this.player.localize('Uses P2P, others may know your IP is downloading this video.') + '</span>'
349 : undefined 243 : undefined
350 244
351 this.player.dock({ 245 this.player.dock({
352 title: videoInfo.name, 246 title,
353 description 247 description
354 }) 248 })
355 } 249 }
250 }
356 251
357 this.initializeApi() 252 private buildCSS () {
253 const body = document.getElementById('custom-css')
254
255 if (this.bigPlayBackgroundColor) {
256 body.style.setProperty('--embedBigPlayBackgroundColor', this.bigPlayBackgroundColor)
257 }
258
259 if (this.foregroundColor) {
260 body.style.setProperty('--embedForegroundColor', this.foregroundColor)
261 }
358 } 262 }
359 263
360 private handleError (err: Error, translations?: { [ id: string ]: string }) { 264 private async buildCaptions (serverTranslations: any, captionsResponse: Response): Promise<VideoJSCaption[]> {
361 if (err.message.indexOf('from xs param') !== -1) { 265 if (captionsResponse.ok) {
362 this.player.dispose() 266 const { data } = (await captionsResponse.json()) as ResultList<VideoCaption>
363 this.videoElement = null 267
364 this.displayError('This video is not available because the remote instance is not responding.', translations) 268 return data.map(c => ({
365 return 269 label: peertubeTranslate(c.language.label, serverTranslations),
270 language: c.language.id,
271 src: window.location.origin + c.captionPath
272 }))
366 } 273 }
274
275 return []
367 } 276 }
368} 277}
369 278